SIAX Technology (sax3l)

@siax/platform (0.4.0)

Published 2026-09-18 06:12:35 +00:00 by siax-bot

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, not requiredRoles, 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 renders ForbiddenView regardless 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> carries tabIndex={-1} so it can actually receive focus — a bare #id jump 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. Below md it collapses behind an aria-expanded / aria-controls disclosure and is hidden (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_CONTRACT is 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
Details
npm
2026-09-18 06:12:35 +00:00
1
UNLICENSED
latest
57 KiB
Assets (1)
Versions (5) View all
0.4.0 2026-09-18
0.3.1 2026-09-17
0.3.0 2026-09-17
0.2.1 2026-09-17
0.2.0 2026-09-16