From 3f0478fbb7d9f8350d2b316b4fc7dcd9c0d1a5af Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 21:20:03 +0200 Subject: [PATCH 01/32] refactor(ui): the console is split into data, model, state, design-system and screen layers --- .dependency-cruiser-known-violations.json | 6 +- .dependency-cruiser.mjs | 38 + src/lint-rules.test.ts | 67 + ui/AGENTS.md | 82 ++ ui/CLAUDE.md | 1 + ui/src/app.tsx | 32 - ui/src/app/app.tsx | 36 + ui/src/app/routes.test.ts | 28 + ui/src/app/routes.ts | 33 + ui/src/app/shell.tsx | 81 ++ ui/src/{ => data}/api.test.ts | 8 +- ui/src/{ => data}/api.ts | 30 +- ui/src/{views => data}/event-feed.ts | 43 +- ui/src/{ => data}/live/browser.ts | 2 - ui/src/data/live/clock.ts | 7 + ui/src/{ => data}/live/connection.test.ts | 27 - ui/src/{ => data}/live/connection.ts | 108 +- ui/src/{ => data}/live/sse.test.ts | 5 - ui/src/{ => data}/live/sse.ts | 26 +- ui/src/{ => data}/live/test-support.ts | 26 +- ui/src/data/preferences.test.ts | 41 + ui/src/data/preferences.ts | 32 + ui/src/{ => data}/session.test.ts | 2 - ui/src/{ => data}/session.ts | 15 +- ui/src/{ => data}/sign-in-check.test.ts | 3 - ui/src/{ => data}/sign-in-check.ts | 15 +- ui/src/{screen => data}/stream.test.ts | 0 ui/src/{screen => data}/stream.ts | 0 ui/src/ds/button.css | 70 + ui/src/ds/button.tsx | 38 + ui/src/ds/chart.css | 108 ++ ui/src/{charts.test.tsx => ds/chart.test.tsx} | 2 +- ui/src/{charts.tsx => ds/chart.tsx} | 26 +- ui/src/ds/choice.css | 101 ++ ui/src/ds/choice.tsx | 81 ++ ui/src/ds/facts.css | 41 + ui/src/ds/facts.tsx | 24 + ui/src/ds/foundation.css | 263 ++++ ui/src/ds/icon.tsx | 34 + ui/src/ds/index.ts | 27 + ui/src/{router.test.ts => ds/link.test.ts} | 3 +- ui/src/ds/link.tsx | 45 + ui/src/ds/loaded.tsx | 35 + ui/src/ds/master-detail.css | 231 +++ ui/src/ds/master-detail.tsx | 137 ++ ui/src/ds/meter.css | 58 + ui/src/ds/meter.tsx | 25 + ui/src/ds/pager.css | 50 + ui/src/ds/pager.tsx | 60 + ui/src/ds/panel.css | 98 ++ ui/src/ds/panel.tsx | 69 + ui/src/ds/sidebar.css | 333 +++++ ui/src/ds/sidebar.tsx | 128 ++ ui/src/ds/slots.css | 90 ++ ui/src/ds/slots.tsx | 69 + ui/src/ds/stat.css | 62 + ui/src/ds/stat.tsx | 54 + ui/src/ds/status.css | 35 + ui/src/ds/status.tsx | 23 + ui/src/ds/table.css | 183 +++ ui/src/{ => ds}/table.tsx | 46 +- ui/src/layout.tsx | 101 -- ui/src/live/connection-banner.tsx | 39 - ui/src/live/route-state.tsx | 44 - ui/src/live/time.ts | 46 - ui/src/main.tsx | 11 +- ui/src/model/activity.test.ts | 61 + ui/src/model/activity.ts | 47 + .../attention-model.ts => model/attention.ts} | 26 +- .../events-model.ts => model/events.ts} | 69 +- ui/src/model/fleet.test.ts | 112 ++ ui/src/model/fleet.ts | 104 ++ .../history.test.ts} | 22 +- .../history-model.ts => model/history.ts} | 32 +- .../leases-model.ts => model/leases.ts} | 52 +- ui/src/{ => model}/load-thresholds.test.ts | 0 ui/src/{ => model}/load-thresholds.ts | 2 +- .../load-model.test.ts => model/load.test.ts} | 2 +- ui/src/{views/load-model.ts => model/load.ts} | 2 +- ui/src/model/overview.test.ts | 150 ++ ui/src/model/overview.ts | 211 +++ ui/src/{ => model}/paging.test.ts | 2 - ui/src/{ => model}/paging.ts | 35 +- ui/src/model/paths.test.ts | 22 + ui/src/model/paths.ts | 18 + ui/src/model/stat.ts | 5 + ui/src/model/time.ts | 25 + ui/src/{status.tsx => model/tones.ts} | 16 +- .../waiting-model.ts => model/waiting.ts} | 31 +- .../workers.test.ts} | 2 +- .../workers-model.ts => model/workers.ts} | 42 +- ui/src/pager.tsx | 125 -- ui/src/router.tsx | 72 - ui/src/screen/viewer.test.tsx | 96 -- ui/src/screen/viewer.tsx | 265 ---- ui/src/screens/attention/index.tsx | 9 + ui/src/screens/events/index.tsx | 9 + ui/src/screens/leases/index.tsx | 9 + ui/src/screens/not-found.tsx | 12 + ui/src/screens/overview/index.tsx | 195 +++ ui/src/screens/overview/overview.css | 142 ++ ui/src/screens/sign-in.css | 48 + ui/src/screens/sign-in.tsx | 58 + ui/src/screens/waiting/index.tsx | 9 + ui/src/screens/workers/index.tsx | 9 + ui/src/shell.test.tsx | 71 - ui/src/shell.tsx | 95 -- ui/src/sign-in.tsx | 86 -- ui/src/state/events.ts | 19 + .../{live/live-context.tsx => state/live.tsx} | 28 +- ui/src/state/overview.ts | 37 + ui/src/state/paging.ts | 45 + ui/src/state/resource.ts | 46 + ui/src/state/router.ts | 41 + .../services.tsx} | 20 +- ui/src/state/shell.ts | 43 + ui/src/state/sidebar.ts | 27 + ui/src/state/sign-in.ts | 23 + ui/src/styles.css | 1289 ----------------- ui/src/views/attention.test.tsx | 221 --- ui/src/views/attention.tsx | 146 -- ui/src/views/events.test.tsx | 690 --------- ui/src/views/events.tsx | 282 ---- ui/src/views/index.test.ts | 23 - ui/src/views/index.tsx | 36 - ui/src/views/leases.test.tsx | 284 ---- ui/src/views/leases.tsx | 360 ----- ui/src/views/not-found.tsx | 18 - ui/src/views/stats.test.tsx | 186 --- ui/src/views/waiting.test.tsx | 145 -- ui/src/views/waiting.tsx | 113 -- ui/src/views/worker-detail.test.tsx | 691 --------- ui/src/views/worker-detail.tsx | 444 ------ ui/src/views/worker-facts.tsx | 128 -- ui/src/views/workers.test.tsx | 330 ----- ui/src/views/workers.tsx | 183 --- 136 files changed, 4716 insertions(+), 7266 deletions(-) create mode 100644 ui/AGENTS.md create mode 100644 ui/CLAUDE.md delete mode 100644 ui/src/app.tsx create mode 100644 ui/src/app/app.tsx create mode 100644 ui/src/app/routes.test.ts create mode 100644 ui/src/app/routes.ts create mode 100644 ui/src/app/shell.tsx rename ui/src/{ => data}/api.test.ts (94%) rename ui/src/{ => data}/api.ts (71%) rename ui/src/{views => data}/event-feed.ts (60%) rename ui/src/{ => data}/live/browser.ts (78%) create mode 100644 ui/src/data/live/clock.ts rename ui/src/{ => data}/live/connection.test.ts (90%) rename ui/src/{ => data}/live/connection.ts (67%) rename ui/src/{ => data}/live/sse.test.ts (82%) rename ui/src/{ => data}/live/sse.ts (63%) rename ui/src/{ => data}/live/test-support.ts (75%) create mode 100644 ui/src/data/preferences.test.ts create mode 100644 ui/src/data/preferences.ts rename ui/src/{ => data}/session.test.ts (92%) rename ui/src/{ => data}/session.ts (64%) rename ui/src/{ => data}/sign-in-check.test.ts (95%) rename ui/src/{ => data}/sign-in-check.ts (71%) rename ui/src/{screen => data}/stream.test.ts (100%) rename ui/src/{screen => data}/stream.ts (100%) create mode 100644 ui/src/ds/button.css create mode 100644 ui/src/ds/button.tsx create mode 100644 ui/src/ds/chart.css rename ui/src/{charts.test.tsx => ds/chart.test.tsx} (95%) rename ui/src/{charts.tsx => ds/chart.tsx} (75%) create mode 100644 ui/src/ds/choice.css create mode 100644 ui/src/ds/choice.tsx create mode 100644 ui/src/ds/facts.css create mode 100644 ui/src/ds/facts.tsx create mode 100644 ui/src/ds/foundation.css create mode 100644 ui/src/ds/icon.tsx create mode 100644 ui/src/ds/index.ts rename ui/src/{router.test.ts => ds/link.test.ts} (80%) create mode 100644 ui/src/ds/link.tsx create mode 100644 ui/src/ds/loaded.tsx create mode 100644 ui/src/ds/master-detail.css create mode 100644 ui/src/ds/master-detail.tsx create mode 100644 ui/src/ds/meter.css create mode 100644 ui/src/ds/meter.tsx create mode 100644 ui/src/ds/pager.css create mode 100644 ui/src/ds/pager.tsx create mode 100644 ui/src/ds/panel.css create mode 100644 ui/src/ds/panel.tsx create mode 100644 ui/src/ds/sidebar.css create mode 100644 ui/src/ds/sidebar.tsx create mode 100644 ui/src/ds/slots.css create mode 100644 ui/src/ds/slots.tsx create mode 100644 ui/src/ds/stat.css create mode 100644 ui/src/ds/stat.tsx create mode 100644 ui/src/ds/status.css create mode 100644 ui/src/ds/status.tsx create mode 100644 ui/src/ds/table.css rename ui/src/{ => ds}/table.tsx (53%) delete mode 100644 ui/src/layout.tsx delete mode 100644 ui/src/live/connection-banner.tsx delete mode 100644 ui/src/live/route-state.tsx delete mode 100644 ui/src/live/time.ts create mode 100644 ui/src/model/activity.test.ts create mode 100644 ui/src/model/activity.ts rename ui/src/{views/attention-model.ts => model/attention.ts} (76%) rename ui/src/{views/events-model.ts => model/events.ts} (58%) create mode 100644 ui/src/model/fleet.test.ts create mode 100644 ui/src/model/fleet.ts rename ui/src/{views/history-model.test.ts => model/history.test.ts} (77%) rename ui/src/{views/history-model.ts => model/history.ts} (58%) rename ui/src/{views/leases-model.ts => model/leases.ts} (60%) rename ui/src/{ => model}/load-thresholds.test.ts (100%) rename ui/src/{ => model}/load-thresholds.ts (95%) rename ui/src/{views/load-model.test.ts => model/load.test.ts} (99%) rename ui/src/{views/load-model.ts => model/load.ts} (98%) create mode 100644 ui/src/model/overview.test.ts create mode 100644 ui/src/model/overview.ts rename ui/src/{ => model}/paging.test.ts (94%) rename ui/src/{ => model}/paging.ts (54%) create mode 100644 ui/src/model/paths.test.ts create mode 100644 ui/src/model/paths.ts create mode 100644 ui/src/model/stat.ts create mode 100644 ui/src/model/time.ts rename ui/src/{status.tsx => model/tones.ts} (74%) rename ui/src/{views/waiting-model.ts => model/waiting.ts} (58%) rename ui/src/{views/workers-model.test.ts => model/workers.test.ts} (94%) rename ui/src/{views/workers-model.ts => model/workers.ts} (64%) delete mode 100644 ui/src/pager.tsx delete mode 100644 ui/src/router.tsx delete mode 100644 ui/src/screen/viewer.test.tsx delete mode 100644 ui/src/screen/viewer.tsx create mode 100644 ui/src/screens/attention/index.tsx create mode 100644 ui/src/screens/events/index.tsx create mode 100644 ui/src/screens/leases/index.tsx create mode 100644 ui/src/screens/not-found.tsx create mode 100644 ui/src/screens/overview/index.tsx create mode 100644 ui/src/screens/overview/overview.css create mode 100644 ui/src/screens/sign-in.css create mode 100644 ui/src/screens/sign-in.tsx create mode 100644 ui/src/screens/waiting/index.tsx create mode 100644 ui/src/screens/workers/index.tsx delete mode 100644 ui/src/shell.test.tsx delete mode 100644 ui/src/shell.tsx delete mode 100644 ui/src/sign-in.tsx create mode 100644 ui/src/state/events.ts rename ui/src/{live/live-context.tsx => state/live.tsx} (72%) create mode 100644 ui/src/state/overview.ts create mode 100644 ui/src/state/paging.ts create mode 100644 ui/src/state/resource.ts create mode 100644 ui/src/state/router.ts rename ui/src/{console-context.tsx => state/services.tsx} (57%) create mode 100644 ui/src/state/shell.ts create mode 100644 ui/src/state/sidebar.ts create mode 100644 ui/src/state/sign-in.ts delete mode 100644 ui/src/styles.css delete mode 100644 ui/src/views/attention.test.tsx delete mode 100644 ui/src/views/attention.tsx delete mode 100644 ui/src/views/events.test.tsx delete mode 100644 ui/src/views/events.tsx delete mode 100644 ui/src/views/index.test.ts delete mode 100644 ui/src/views/index.tsx delete mode 100644 ui/src/views/leases.test.tsx delete mode 100644 ui/src/views/leases.tsx delete mode 100644 ui/src/views/not-found.tsx delete mode 100644 ui/src/views/stats.test.tsx delete mode 100644 ui/src/views/waiting.test.tsx delete mode 100644 ui/src/views/waiting.tsx delete mode 100644 ui/src/views/worker-detail.test.tsx delete mode 100644 ui/src/views/worker-detail.tsx delete mode 100644 ui/src/views/worker-facts.tsx delete mode 100644 ui/src/views/workers.test.tsx delete mode 100644 ui/src/views/workers.tsx diff --git a/.dependency-cruiser-known-violations.json b/.dependency-cruiser-known-violations.json index 57b5e0acb..6f0eea046 100644 --- a/.dependency-cruiser-known-violations.json +++ b/.dependency-cruiser-known-violations.json @@ -232,7 +232,7 @@ }, { "type": "dependency", - "from": "ui/src/views/leases-model.ts", + "from": "ui/src/model/leases.ts", "to": "src/contract/schemas.ts", "unresolvedTo": "../../../src/contract/schemas", "dependencyTypes": ["local", "type-only", "import"], @@ -243,7 +243,7 @@ }, { "type": "dependency", - "from": "ui/src/views/waiting-model.ts", + "from": "ui/src/model/waiting.ts", "to": "src/contract/schemas.ts", "unresolvedTo": "../../../src/contract/schemas", "dependencyTypes": ["local", "type-only", "import"], @@ -254,7 +254,7 @@ }, { "type": "dependency", - "from": "ui/src/views/workers-model.ts", + "from": "ui/src/model/workers.ts", "to": "src/contract/schemas.ts", "unresolvedTo": "../../../src/contract/schemas", "dependencyTypes": ["local", "type-only", "import"], diff --git a/.dependency-cruiser.mjs b/.dependency-cruiser.mjs index 3b4f611a6..06d5a8a1b 100644 --- a/.dependency-cruiser.mjs +++ b/.dependency-cruiser.mjs @@ -14,6 +14,8 @@ function findComponents(dir) { const escape = (path) => path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const testFile = "(\\.test\\.tsx?|/test-[^/]+\\.tsx?)$"; const linkTestFile = "(\\.test\\.tsx?|/test-[^/]+\\.tsx?|/testing\\.ts)$"; +// why: the console layers below the screens are plain TypeScript, so a test drives them without a browser. +const uiLibrary = "(^|node_modules/)(react|react-dom|recharts|@tanstack/[^/]+)(/|$)"; const fsOrProcess = "^(node:)?(fs|fs/promises|child_process)$"; // why: e2e imports the compiled package from dist/, which mirrors src/, so each component's rule covers its compiled copy too. @@ -195,6 +197,42 @@ export default { "^src/(core|leasing|drivers|gateway|contract)/", { path: "^src/(modules|module-registry)/" }, ), + direction( + "ui-ds-stands-alone", + "the console's design system imports nothing else of the console", + "^ui/src/ds/", + { path: "^ui/src/(app|screens|state|data|model)/" }, + ), + direction( + "ui-model-is-pure", + "the console's business rules import no other layer and no UI library", + "^ui/src/model/", + { path: `^ui/src/(app|screens|state|data|ds)/|${uiLibrary}` }, + ), + direction( + "ui-data-is-below-the-ui", + "the console's network and storage layer imports no state, screen, design system or UI library", + "^ui/src/data/", + { path: `^ui/src/(app|screens|state|ds)/|${uiLibrary}` }, + ), + direction( + "ui-state-feeds-screens", + "the console's state layer imports no screen, and from the design system only the types it fills", + "^ui/src/state/", + { path: "^ui/src/(app|screens)/" }, + ), + direction( + "ui-state-ds-types-only", + "the console's state layer imports no screen, and from the design system only the types it fills", + "^ui/src/state/", + { path: "^ui/src/ds/", dependencyTypesNot: ["type-only"] }, + ), + direction( + "ui-screens-present-only", + "a console screen reaches data only through the state layer: no network, storage or business rule of its own", + "^ui/src/screens/", + { path: "^ui/src/(app|data|model)/" }, + ), direction( "contract-imports-nothing", "the contract imports nothing outside src/contract/", diff --git a/src/lint-rules.test.ts b/src/lint-rules.test.ts index 9808b6ba2..86693cc6f 100644 --- a/src/lint-rules.test.ts +++ b/src/lint-rules.test.ts @@ -652,6 +652,73 @@ describe("the cycle check between components", () => { }); }); +describe("the console's layers", () => { + function layerViolations(world: Readonly>): string[] { + const dir = join(scratch, `ui-layers-${counter++}`); + for (const [path, contents] of Object.entries(world)) { + mkdirSync(dirname(join(dir, path)), { recursive: true }); + writeFileSync(join(dir, path), contents); + } + copyFileSync(join(repoRoot, ".dependency-cruiser.mjs"), join(dir, ".dependency-cruiser.mjs")); + copyFileSync(join(repoRoot, "tsconfig.json"), join(dir, "tsconfig.json")); + return cruise(dir, ["ui"]) + .filter((v) => v.rule.name.startsWith("ui-")) + .map((v) => `${v.rule.name} ${v.from} -> ${v.to}`) + .sort(); + } + + const layers = { + // why: the config looks for components under src/ when it loads, so the fixture needs one. + "src/x.ts": "export {};\n", + "ui/src/app/app.ts": + 'import "../screens/s.js";\nimport "../state/s.js";\nimport "../data/d.js";\nexport {};\n', + "ui/src/data/d.ts": 'import "../model/m.js";\nexport const d = 1;\n', + "ui/src/ds/b.ts": "export type Props = number;\nexport const b = 1;\n", + "ui/src/model/m.ts": "export const m = 1;\n", + "ui/src/screens/s.ts": 'import "../ds/b.js";\nimport "../state/s.js";\nexport {};\n', + "ui/src/state/s.ts": + 'import type { Props } from "../ds/b.js";\nimport "../data/d.js";\nimport "../model/m.js";\nexport type P = Props;\n', + }; + + it("passes each layer importing only the layers below it, and the state layer taking design-system types", () => { + expect(layerViolations(layers)).toEqual([]); + }); + + it("fails every import that crosses a layer the wrong way", () => { + const found = layerViolations({ + ...layers, + "ui/src/data/d.ts": 'import "../state/s.js";\nimport "react";\nexport const d = 1;\n', + "ui/src/ds/b.ts": + 'import "../model/m.js";\nexport type Props = number;\nexport const b = 1;\n', + "ui/src/model/m.ts": 'import "../data/d.js";\nimport "recharts";\nexport const m = 1;\n', + "ui/src/screens/s.ts": 'import "../model/m.js";\nimport "../data/d.js";\nexport {};\n', + "ui/src/state/s.ts": + 'import { b } from "../ds/b.js";\nimport "../screens/s.js";\nexport const s = b;\n', + }); + + expect(found).toEqual([ + "ui-data-is-below-the-ui ui/src/data/d.ts -> react", + "ui-data-is-below-the-ui ui/src/data/d.ts -> ui/src/state/s.ts", + "ui-ds-stands-alone ui/src/ds/b.ts -> ui/src/model/m.ts", + "ui-model-is-pure ui/src/model/m.ts -> recharts", + "ui-model-is-pure ui/src/model/m.ts -> ui/src/data/d.ts", + "ui-screens-present-only ui/src/screens/s.ts -> ui/src/data/d.ts", + "ui-screens-present-only ui/src/screens/s.ts -> ui/src/model/m.ts", + "ui-state-ds-types-only ui/src/state/s.ts -> ui/src/ds/b.ts", + "ui-state-feeds-screens ui/src/state/s.ts -> ui/src/screens/s.ts", + ]); + }); + + it("lets a test reach across layers, as a test drives its subject through what it needs", () => { + expect( + layerViolations({ + ...layers, + "ui/src/model/m.test.ts": 'import "../data/d.js";\nexport {};\n', + }), + ).toEqual([]); + }); +}); + describe("the repo's known violations", () => { const read = (file: string): Violation[] => JSON.parse(readFileSync(join(repoRoot, file), "utf8")) as Violation[]; diff --git a/ui/AGENTS.md b/ui/AGENTS.md new file mode 100644 index 000000000..e56f28568 --- /dev/null +++ b/ui/AGENTS.md @@ -0,0 +1,82 @@ +# Web console + +Does: the operator's web console — a React page the daemon serves from `dist/ui`. +Entry: `src/main.tsx` → `App` (`src/app/app.tsx`). +Uses: the daemon's `/v1` HTTP API and event stream; contract types from `src/contract/` (type-only). +Surface: [CONSOLE.md](../docs/CONSOLE.md). Look and voice: [DESIGN.md](../docs/internal/DESIGN.md). + +## Layers + +Each layer imports only the layers below it. `.dependency-cruiser.mjs` enforces it (rules +`ui-*`); `src/lint-rules.test.ts` proves each rule fires. + +| Layer | Holds | May import | +| -------------- | ------------------------------------------------------------------------- | --------------------------- | +| `src/app/` | `App`, the `Shell`, the route table (`routes.ts`) | every layer | +| `src/screens/` | one folder per page: presentation only | `ds`, `state` | +| `src/state/` | React hooks that read `data`, apply `model`, return view models | `data`, `model`, `ds` types | +| `src/ds/` | the design system: tokens, components, their CSS | nothing of the console | +| `src/data/` | the API client, session, preferences, live connection, SSE, screen stream | `model` | +| `src/model/` | business rules: pure functions over the daemon's data | nothing; contract types | + +- A screen never fetches, never reads a route, never decides what a state means. It gets a view + model from a `state/` hook (`useOverview`) and draws it with `ds/` components. +- `model/` has no React and no network, so every rule is a plain unit test. +- `ds/` knows nothing of Simlock's data: props are plain values (`tone`, `percent`, `to`). + `Link` navigates through `NavigationProvider`, which `App` feeds with `state/router.ts`. + +## How a screen is built + +1. Model: a pure builder in `src/model/.ts` turns route data into the page's view model + (`overviewOf` in `overview.ts`). Shared rules live once: `workerHealth`, `workerSlots` + (`fleet.ts`), `eventSummary`, `eventTone` (`activity.ts`), `attentionTone` + (`attention.ts`), the word → tone tables (`tones.ts`), page paths (`paths.ts`). +2. State: a hook in `src/state/.ts` reads routes with `useLiveResource`, turns each into a + `LoadView` (`resource.ts`: `loadView`, `mapView`, `bothViews`), and maps it through the + builder. It re-exports the view-model types the screen needs. +3. Screen: `src/screens//index.tsx` exports `Screen`, wraps content in `Loaded`, and + composes `ds` components. Its CSS sits beside it. +4. Route: an entry in `src/app/routes.ts` gives it a path, a label and an icon. + +## Pages + +| Path | Screen | Layout | +| ------------------- | --------- | --------------------------------------------------------------------- | +| `/` | Overview | stat tiles, lease chart, capacity, workers table, attention, activity | +| `/workers[/]` | Workers | list + inspector (`MasterDetail`) | +| `/leases[/]` | Leases | list + inspector; the inspector holds the live screen | +| `/waiting[/]` | Waiting | list + inspector | +| `/attention[/]` | Attention | list + inspector | +| `/events[/]` | Events | list + inspector with the payload | + +On a list page the URL names the chosen item; with none, the first item shows on a wide screen +and the list alone on a phone. Lists page in the URL (`state/paging.ts`, `model/paging.ts`). + +## Design system + +`src/ds/index.ts` exports every component. Tokens and base elements: `foundation.css`. + +- Frame: `AppFrame`, `Sidebar` (collapsible; width kept per browser by `data/preferences.ts`), + `Page`, `MasterDetail`, `ListPane`, `List`, `ListRow`, `Inspector`. +- Content: `Panel`, `Card`, `Row`, `Grid`, `StatRow`, `StatTile`, `Facts`, `DataTable`, `Pager`, + `MinuteChart`, `MinuteTable`, `Empty`, `Id`, `Loaded`. +- Signals: `Status` (square and word), `StatusDot` (square alone, the word as its name), + `Meter`, `Slots`, `ShareBar`, `SlotLegend`. +- Controls: `Button`, `ButtonLink`, `Link`, `ChoiceChips` (a radio group), `Tabs`, `Icon`. + +## Rules + +- Colour is never the only signal: a `StatusDot` and a `Slots` group carry their words as + accessible names; a `Meter` and a `ShareBar` only shade a figure printed beside them. +- Orange is brand and interaction only: never a state. +- Nothing sets a `