@siax/platform (0.4.0)
Installation
@siax:registry=https://git.cloud.siax.io/api/packages/sax3l/npm/npm install @siax/platform@0.4.0"@siax/platform": "0.4.0"About this package
@siax/platform
The SIAX console/portal kit: the shared customer shell and UI contract that every SIAX console and the portal is built on, plus the canonical identity/scope/session surfaces, status and risk registries, four-eyes approval and dependency-free chart primitives.
Built on @siax/ui primitives. Every component is keyboard-operable,
screen-reader labelled, non-colour-dependent for state, and safe to
server-render.
pnpm --filter @siax/platform test # 128 behaviour + a11y tests
pnpm --filter @siax/platform typecheck
pnpm --filter @siax/platform lint
The customer shell contract (HSP-020)
Five rules. They exist because each one is a real failure this kit replaces, and they are enforced by tests rather than by review.
1. Browser state is partitioned per customer
Every console keeps something in the browser: a remembered filter, the last opened run id, a query cache, an open subscription. None of it is a security boundary — the owning service authorizes every call against CL0UD policy. But a UI that keeps tenant A's remembered ids or live subscription alive after the operator switches to tenant B shows tenant A's data under tenant B's name. That is a confidentiality incident in the only place the customer looks, and no amount of server-side authorization prevents it.
Three lines of defence, in order:
| # | Mechanism | Guarantee |
|---|---|---|
| 1 | Partition | Key is siax:t:<tenantId>:<namespace>:<key> — a read under B cannot reach a value written under A, even if nothing is purged |
| 2 | Purge | On switch, every siax:t:<previousTenantId>: key is removed |
| 3 | Teardown | On switch, every registered subscription disposer runs — in a layout effect, before the new tenant's shell paints |
import { TenantScopeProvider, useTenantScopedState, useTenantSubscription } from '@siax/platform';
<TenantScopeProvider tenantId={session.tenantId} tenantName={session.tenantName}>
<App />
</TenantScopeProvider>;
// Partitioned by construction. Tenant B starts from the default; no migration,
// no inheritance.
const [filter, setFilter] = useTenantScopedState('view', 'filter', 'all');
// Disposed on unmount AND on tenant switch.
useTenantSubscription((tenantId) => {
const source = new EventSource(`/api/runs?tenant=${tenantId}`);
return () => source.close();
});
The one deliberate exception. Colour theme, interface locale and interface
density are properties of the human, not of the customer. Re-asking an
operator for their language every time they change customer is hostile, and
none of the three carries tenant data — they are closed enums, never free text
and never an id. They live under siax:u:pref:, which purgeTenant does not
touch, and writeUserPreference refuses any value outside the caller's
declared allowlist so that prefix cannot quietly become a second,
unpartitioned store. Everything else is tenant-scoped; there is no third
option.
purgeStaleOnMount (default on) additionally sweeps every other tenant
at boot, so a browser last used for three customers is not still carrying two
of them. The trade-off is that returning to a customer within one session
starts from defaults — the privacy-safe default, and what the portal should
run. A console that legitimately wants per-tenant view memory within a session
turns it off; the outgoing tenant is purged on every switch either way, and
that part is not configurable.
2. UiCapabilityManifest is data, and only data
A declarative document describing which human-facing capabilities a domain
offers and in what support state. Schema:
./schemas/ui-capability-manifest.schema.json
($id https://design.siax.io/schemas/ui-capability-manifest/v1.json).
- No executable code. It arrives over the wire as JSON; nothing in it is callable.
- No authorization authority. The role hint is named
advisoryRoles, notrequiredRoles, precisely so no consumer can mistake it for a second auth layer. It may de-emphasise a control for progressive disclosure. The server still authorizes every call, and a 403 rendersForbiddenViewregardless of what the manifest says. - Fail-closed. An unrecognised schema id, an absent capability, or no manifest at all resolves to a non-supported state. There is no code path in which absence renders the live control.
support |
Meaning |
|---|---|
supported |
Implemented and exercised — the only state that renders the control |
unsupported |
Deliberately not offered here (a product decision, with a reason) |
not_run |
Implemented, never verified in this environment — no result claimed |
planned |
Declared, not built |
retired |
Withdrawn |
| (absent) | Resolves to unknown — nothing is claimed, and this is not an error |
Every non-supported state requires a supportReason: an unexplained
dead end is not an honest surface, and the validator rejects one.
const { manifest } = validateUiCapabilityManifest(await res.json());
<CapabilityGate manifest={manifest} capabilityId="h0st.vm.restart" denied={status === 403}>
<RestartButton />
</CapabilityGate>;
The validator is hand-rolled rather than backed by a JSON Schema runtime —
this package ships to every console and the portal, and a schema engine in
that blast radius to check a ten-field document is a dependency no consumer
asked for. ui-capability-manifest.test.ts reads the published schema and
asserts the enums, required fields and fail-closed rules match, so drift fails
a test instead of silently accepting a bad document.
3. States are distinguished, not collapsed
"No rows" because the query returned nothing, because the source was
unreachable, and because the capability has never been run here are three
different facts. A customer shown the same grey box for all three has been
misinformed. OperationStateView gives each its own ARIA role, its own
data-siax-state, and its own required evidence:
| State | Role | Carries |
|---|---|---|
empty |
status |
— |
not_run |
status |
reason; "no result is claimed" in words |
stale |
status |
source time, so "old" is checkable |
partial |
status |
every failed source by name, never "some sources failed" |
unsupported / planned / retired / unknown |
note |
declared reason |
error |
alert |
verbatim failure reason |
Only error is an alert. A screen-reader user must not be interrupted
assertively because a list is empty, and a genuine failure must not be demoted
to a polite one. Every non-error state also says in text that it is a state
and not a failure, so the distinction never depends on colour.
4. Consequential actions state their consequences
DestructiveConfirmDialog adds three things to a generic confirm, each of
which stops a real accident: the risk level, the named blast radius, and —
the one that matters most in a multi-tenant console — which customer the
action lands on, read from the active tenant scope rather than from whatever
the caller remembered to pass. Deleting the right thing in the wrong tenant is
the failure a confirm dialog exists to prevent.
All of it sits inside the accessible description, so a screen-reader user
hears what will happen, to what, for which customer, before focus reaches the
confirm button — not an aria-label that says "Confirm".
AsyncOperationPanel is the long-running counterpart, with three honesty
rules: never invent a percentage (no reported progress renders an
indeterminate bar and says so), NOT_RUN is not 0%, and partial failure is
not success.
5. Reuse before addition
| Need | Use | Not |
|---|---|---|
| Tenant dropdown | @siax/ui TenantSwitcher (via TenantSwitchControl) |
a second dropdown |
| Generic type-to-confirm | @siax/ui ConfirmDialog |
— |
| Table-row confirm | ConfirmActionButton (inline arm→commit) |
a modal per row |
| Consequential action | DestructiveConfirmDialog |
a bare window.confirm |
| Status → tone | STATUS_REGISTRY |
a per-console STATUS_STYLES map |
| Expert view | the locked compact density |
a fourth density level |
"Expert view" is the operator-facing name for the densest level of DES1GN's
locked density contract (comfortable / default / compact, enforced by
scripts/check-architecture-lock.mjs). Inventing a parallel expert level
would either break that gate or create a second vocabulary for the same idea.
Applied as data-siax-density on <html>, so a console styles off one
attribute selector instead of threading a prop through every component.
Accessibility and keyboard
- The skip link is the first focusable element in the document, and
<main>carriestabIndex={-1}so it can actually receive focus — a bare#idjump moves scroll but not focus in several browsers, which leaves the next Tab back at the top of the navigation. - One
<nav>element in both layouts. Belowmdit collapses behind anaria-expanded/aria-controlsdisclosure and ishidden(the attribute, so it leaves the tab order rather than staying focusable off-screen). A separate "mobile nav" would drift from the desktop one. SHELL_KEYBOARD_CONTRACTis exported as data so a console can render its own help sheet from the same source the shell implements.
Storybook. Components in this package deliberately ship no .stories.tsx
files yet. packages/ui/.storybook/main.ts does not glob packages/platform,
and the Lost Pixel gate fails closed on any captured story without a committed
baseline PNG — so adding stories requires a baseline regeneration run on the
runner fleet ([regen-baseline]), which pushes to main and cannot be done
from a PR branch. The a11y contract is covered here instead by behaviour tests
that drive the components with @testing-library/user-event and assert on the
accessibility tree (accessible name, accessible description, roles, focus
location) rather than on attribute presence.
Dependencies
Dependencies
| ID | Version |
|---|---|
| @siax/tokens | 0.1.2 |
Development Dependencies
| ID | Version |
|---|---|
| @eslint/js | ^10.0.1 |
| @siax/config | 0.2.1 |
| @siax/ui | 0.7.0 |
| @testing-library/dom | ^10.4.1 |
| @testing-library/jest-dom | ^6.6.3 |
| @testing-library/react | ^16.3.2 |
| @testing-library/user-event | ^14.6.3 |
| @types/react | ^19.2.0 |
| @types/react-dom | ^19.2.0 |
| @vitejs/plugin-react | ^4.7.0 |
| axe-core | ^4.13.0 |
| eslint | ^10.0.0 |
| eslint-plugin-react-hooks | ^7.1.0 |
| globals | ^17.0.0 |
| jsdom | ^30.0.1 |
| react | ^19.2.0 |
| react-dom | ^19.2.0 |
| typescript | ^5.7.2 |
| typescript-eslint | ^8.66.0 |
| vitest | ^3.0.0 |
Peer Dependencies
| ID | Version |
|---|---|
| @siax/ui | 0.7.0 |
| react | ^19.2.0 |
| react-dom | ^19.2.0 |