diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 34366fc8..539dd7af 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -3993,3 +3993,42 @@ The manifest checked 3/3 in the canonical tree. The candidate landed as f7c2d5cf Correction to my first landing commit message: it named a07b3784 as the gate base. The worktree was created at 03:50:07 local, before a07b3784 (03:50:10) and after 560373c3 (03:50:00), so the base was 560373c3. I amended the unpushed commit before pushing; a07b3784 adds only Filbert's SESSIONS line. The recall check's content stayed out of scope. Dewey's two follow-ups (the name check reads the newest run record, and "no agent identity on headless run" passes when `system-prompt.md` is absent) and Darkwing's note 2 (the guards ask `docker info`, but the runs use `docker compose run`) go to #1541. + +### 2026-10-10 — Claude Code (direct session for Jason), design handoff package + +Before: draft 1 (1e11100b) had a preview, a spec and the mark, but nothing an implementing agent could take values from. Jason accepted the direction on 2026-10-10 and kept the refined Relay as a placeholder mark ("Use the design's logo for now and we will revise"). + +After: `docs/design/` adds: +- `IMPLEMENTING.md`: the brief, with a gap map per view against what row 40 shipped and the bus, CLI and business modules expose, plus the strings to keep, boundaries, acceptance and five proposed rows for Sage; +- `tokens.json`, and `tokens.css` generated by `tools/build-tokens.mjs`. It uses the webui's existing names (`data-mode`, `--onAction`, `--font`, `--r-lg`, `--pad`, `--gap`), so it can replace the inline `setProperty` theming without renaming any `var()` use; +- `icons.svg`, with the mark and 17 icons; +- `tools/check-design.mjs`, the browser checks. + +Nothing under `packages/` changed. No queue row was moved. + +Evidence: +- `tokens.json` colours equal `packages/webui/src/public/brand.js` `tokens()` for 10 palettes × 3 modes × 14 keys: 0 mismatches; +- `build-tokens.mjs --check`: current; +- `check-design.mjs` on the preview passes all 4 checks: + - no script errors across 4 tabs, 13 views × 5 states and 13 terminal screens; + - no sideways scroll at 400 px; + - terminal screens at exactly 120 columns (80 for compact); + - preview colours equal `tokens.json` for 10 palettes × 4 modes; +- each check failed on a planted defect: a thrown error in one view and state, a 121-column frame, a 900 px element, and an altered `--action`. + +My first two plants did nothing. A duplicate object key overrode the thrown error, and `frame()` re-wraps long lines. I re-planted both. + +Coverage corrections, checked against the code. The table now lists 41 features: 20 implemented, 3 in progress, 1 planned, 17 proposed. +- The 08:00 digest is implemented (`packages/cli/src/notifier.mjs`), not planned. +- `mosaic stop` is row 41 work. +- Task priority and requirement, and agent state per role, are not in the bus reads, so they are now separate proposed rows. + +Correction within this session: I first marked the trail filter chips as not shipped, from a grep cut short by `head`. Row 40 did ship them (`bus.js:313`), so they stay implemented. + +Open for Jason: +- the default palette and mode; +- the final mark; +- no-argument `mosaic` opening the full-screen app; +- decision Seen from the web, which would give the browser a human-authority write. + +The private artifact was republished as version 2. No push. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index a1f0ebd5..1f07ad76 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -592,3 +592,4 @@ are never rewritten or removed; corrections are new entries. 2026-10-10T08:49:19Z | darkwing | row 48 (#1534) round 1 review | approve, #1534 comment 27099, queue rev 320 (85fac901), packet 1355592f; candidate 285a4ec1 on 5e87c41f, gate green (queue 148/0, all test-*.sh 0 failed, test-task 26/0 no-Docker with the recall-pair skip, test-release 14/0 with Docker); fake docker info with daemon unreachable: recall pair fails both, 67/31 rc 1; stderr delayed 0.5 s: exit wait 6/2, close wait 8/0, candidate 10 runs 8/0; no real-model run; notes 1-3 non-blocking; no push 2026-10-10T08:50:10Z | filbert | row 48 (#1534) round 1 review | approve, #1534 comment 27100 (read back, matches), queue rev 321 (560373c3); candidate 285a4ec1 on 5e87c41f, gate green (queue 148/0, all test-*.sh 0 failed, test-task 26/0 no-Docker with the recall-pair skip; with-Docker 98/0 and fail-mutant 96/2 taken from Dewey's outputs); mutants: exit+late-stderr 6/2, close+late 8/0, guard invert/none 26/2, skip-as-fail 26/1, skip-as-pass 27/0 survives (N1); notes N1-N3 non-blocking; no push 2026-10-10T08:56:08Z | sage | row 48 (#1534) landing | done; gate green on 560373c3 plus candidate 285a4ec1 (queue 148/0, conversation 182/0, webui 22/0, control-board 124/0, all test-*.sh 0 failed; test-task 98/0 with Docker, 26/0 without with the recall-pair skip); code f7c2d5cf verify-commit 3/3 (message amended before push: gate base 560373c3, not a07b3784); BUILD-LOG 99d9cff9; rev 322; follow-up #1541 (recall record, vacuous identity check, docker info vs compose); #1534 closed +2026-10-10T16:21:55Z | Claude Code (direct session for Jason) | Mosaic Stack design handoff package | done; docs/design/ IMPLEMENTING.md (gap map, strings, boundaries, acceptance, 5 proposed rows for Sage), tokens.json/tokens.css (webui names, 0 colour mismatches with brand.js), icons.svg, tools/build-tokens.mjs and tools/check-design.mjs (4/4 pass, each shown to fail on a planted defect); coverage corrected to 41 features; mark stays a placeholder per Jason; no packages/ change, no queue move, no push diff --git a/docs/design/IMPLEMENTING.md b/docs/design/IMPLEMENTING.md new file mode 100644 index 00000000..b4a3f8bd --- /dev/null +++ b/docs/design/IMPLEMENTING.md @@ -0,0 +1,324 @@ +# Implementing the Mosaic Stack design + +This brief is for the agents who build the design. Read it with +`README.md` (the spec) and the preview `mosaic-stack-design.html`. + +## Status + +- **2026-10-10:** Jason accepted the direction of draft 1. The mark + stays the refined Relay as a placeholder until he revises it. Do not + spend work on logo variants. +- **Provisional defaults**, carried in the files and reversible: + - Harbor palette, following the system light or dark setting; + - the web shows decisions and copies `mosaic decide`; it never + resolves them (REQ-DEC-3); + - `mosaic` with no arguments opens the full-screen app. Today it + exits 4 (usage). +- **Not authorized by this brief:** queue rows, dispatch, push, or + any change to a security boundary. Sage owns the queue. Each + implementation row needs its own brief and review. + +## Sources of truth + +When two sources disagree, the higher one wins: + +1. The PRD (`docs/prd/mosaic-stack.md`) and the repository invariants + (`AGENTS.md`). +2. Shipped behaviour and strings in `packages/`. The design quotes + them, and the code is the reference. +3. This brief. +4. `tokens.json`, and the generated `tokens.css`. +5. The preview. It uses sample data and its own internal names. Copy + layout and states from it, not data, ids or code. + +## Files + +| File | Use | +|---|---| +| `tokens.json` | Every design value: 10 palettes × light, dim and dark (14 colours each), type scale, radii, spacing, layout widths, terminal colours, status vocabulary | +| `tokens.css` | Generated from `tokens.json`. Do not edit by hand | +| `icons.svg` | Sprite: `mark` (placeholder) and 17 line icons `i-board` … `i-flag` | +| `brand/relay-refined.svg` | The placeholder mark as a standalone file | +| `tools/build-tokens.mjs` | Regenerates `tokens.css`; `--check` exits 1 when it is stale | +| `tools/check-design.mjs` | Browser checks for the preview (see Acceptance) | + +## Tokens + +- **Names match the shipped console.** `tokens.css` uses the property + names `packages/webui` already uses: `--canvas` … `--danger`, + `--onAction` (camelCase, as `brand.js` sets it), `--font`, `--mono`, + `--r`, `--r-lg`, `--pad`, `--gap`. It adds two names: + - `--r-table` (8 px); + - `--strong` (a `color-mix` of `--text`). +- **Selectors.** No attribute means Harbor, light or dark from + `prefers-color-scheme`. `data-mode="light|dim|dark"` sets the mode. + `data-palette=""` picks any other palette. The webui already sets + both attributes on ``. +- **Values are the shipped values.** For all 10 × 3 × 14 colours, + `tokens.json` equals what `packages/webui/src/public/brand.js` + `tokens()` computes. Checked 2026-10-10: 0 mismatches. +- **Adopting it in the webui:** + - serve `tokens.css` (add it to the `files` map in + `packages/webui/src/serve.mjs`); + - stop the inline `setProperty` loop in `app.js` `theme()`, which + would override the stylesheet; + - keep setting `data-palette` and `data-mode`; + - add a System choice that removes `data-mode`; + - keep the localStorage key `mosaic-console-appearance`. +- **Changes the design makes to existing values:** + - `--r` goes from 8 to 6 px; + - `--r-lg` goes from 14 to 10 px; + - `--gap` stays 12 px and `--pad` stays 16 px; + - `--shadow` stays owned by `app.css`. Use it only for overlays: the + inspector over the table, the command palette and the toast. +- **Fonts.** The console's CSP is `font-src 'self'`, so every font is + self-hosted. Manrope 400 to 700 is already served. Add JetBrains Mono + 400, 500 and 700. Manrope 800 is optional: titles may use 700. +- **Type scale:** + - page title 28/800; + - section 20/800; + - question 16/700; + - text 14/400 at 1.5; + - table cells 13; + - labels 11/700 in capitals with 0.06em tracking; + - mono 12.5. + + Use `font-variant-numeric: tabular-nums` in columns. +- **Regenerate** after editing `tokens.json`: + `node docs/design/tools/build-tokens.mjs`. Copy the result to the + webui; the design directory stays the source. + +## Icons and the mark + +- **Format.** 24-unit grid, `stroke="currentColor"`, width 1.75, round + caps and joins, no fills. The webui inlines icons through + `Brand.icon(id)` with the same stroke rules. Replace those paths with + the sprite's paths so there is one icon set, or serve `icons.svg` and + use ``. CSP + `default-src 'self'` allows a same-origin sprite. +- **Labels.** An icon never carries state alone. Every icon in + navigation sits next to its text label. +- **The mark.** The `mark` symbol uses `fill="currentColor"` and + `viewBox="0 0 16 17"`. Draw it at 16, 24 or 32 px. It is a + placeholder: keep it in one component so a later swap touches one + file. + +## Components + +Every component needs the listed states, keyboard behaviour and +focus ring (`--focus`, 2 px, offset 2 px). + +| Component | States and rules | +|---|---| +| Command bar | Business name, freshness line ("Vikunja last read 14:21:07 (12 s ago)"), Ctrl+K button. Warning colour plus words when stale | +| Section list | Icon + label + count; `aria-current="page"` on the active view. Below 760 px it becomes a horizontal strip that scrolls inside itself | +| Dense table | 13 px cells, padding 7/10, header labels in caps. Row hover, selected row, ↑/↓ moves, Enter opens the inspector. Wide tables scroll inside their own container | +| Inspector | Right drawer, 300 to 370 px. Over the table below 1100 px; a full-screen sheet below 760 px. Esc closes and returns focus to the row | +| Status mark | Glyph + word, never colour alone: ● working, ◐ waiting, ▲ error, ○ offline, – idle, ? unknown | +| Class chip | routine, within-role, cross-role, gated; BLOCKING is a separate chip | +| Copy command | Mono command + Copy button. Uses the shipped strings (below). Never runs anything | +| Toast | One line, polite live region, auto-dismiss; errors stay until dismissed | +| Command palette | Ctrl+K opens; type to filter views, decisions and tasks; Enter goes; Esc closes and restores focus | +| View states | Five per view: normal; loading (skeleton rows, `aria-busy`); empty (says what would appear and where it comes from); error (banner, last good data stays on screen with its read time); refusal (fail closed: the refusal code in mono and the file or rule that is missing) | +| Not found | Names the bad route, links to the Board, mentions Ctrl+K | + +## Views and the gap map + +Existing routes in `packages/webui` are hash routes: `#/` (the board), +`#/inbox[/]`, `#/tasks[/]`, `#/agents`, +`#/trail/task/`, `#/trail/decision/` (with `?kind=`). New +views follow the same form: `#/talk`, `#/queue`, `#/runs`, +`#/ledger`, `#/business`, `#/library`, `#/settings`, `#/setup`. + +Kinds of work: +- **restyle:** built; apply tokens, layout and states; +- **extend:** built; add design pieces whose data the current read + already returns; +- **new view:** the data exists in a module; add a read-only endpoint + to the webui server and a view; +- **backend:** the data is not produced or exposed yet; +- **blocked:** waits on another row or on Jason. + +| View | Data today | Kind | Notes | +|---|---|---|---| +| Board `#/` | `GET /api/board`, `POST /api/seen` (control board) | restyle | Seat table with Input needed and Seen/Unsee already works | +| Inbox `#/inbox` | `GET /api/bus/inbox`: open decisions routed to `human` | restyle | Detail, authorization sentence, copy commands and history already work | +| Inbox tabs: Routed to roles, Recent | none: `inbox` returns open decisions for the caller's role only | backend | Needs a reader op for decisions routed to other roles and for closed ones | +| Decision Seen | broker `decision.seen` needs a human session (`broker.mjs:614-633`) | blocked | The web holds a reader session. Writing Seen from the browser gives it human authority, a security-boundary change for Jason. The web may show existing `seen` events (bus.js already reads them). The TUI may write Seen once a CLI verb exists | +| Tasks `#/tasks` | `GET /api/bus/tasks`: title, bucket, done, markers, freshness | restyle | | +| Task priority and requirement columns | not in `task_current.fields`; requirement is in the `task.created` event body | backend | | +| Field writers in the task inspector | prose in `docs/plans/2026-10-04_slice-1.md:62-72` | backend | Encode the table as data first | +| Agents `#/agents` | `GET /api/bus/agents`: latest claim per role | restyle | | +| Agent state and Input needed per role | the control board has state per seat; the bus has none per role | backend | Needs a seat-to-role mapping, or state on the bus | +| Launch budget (Opus n of 4, Sonnet n of 4) | ceiling only (`vocabulary.mjs:85-86`); `session.launched` has no model family | blocked | Row 41 (S6) | +| Stop launches (`mosaic stop`) | broker `launch.revoke` / `launch.restore`, no CLI verb | blocked | Row 41 (S6). Web shows the command only | +| Credentials (service, account, role, expiry) | `Credentials.status()` in `packages/bus/src/credentials.mjs:118`; not on the broker | backend | Metadata only. Never a value, token or path | +| Trail `#/trail/...` | `GET /api/bus/trail?subject=`; filter chips shipped (`bus.js:313`) | restyle | | +| Talk `#/talk` | the conversation History view exists; the live PM session is row 41 | blocked | Row 41 (S6). Reuse the controller/observer rules of `packages/conversation` | +| Queue `#/queue` | `packages/queue/src/store.mjs` `list`/`show`; no endpoint | new view | Read-only. Rows move only through `queue move` | +| Ledger `#/ledger` | `packages/ledger` `summarize`, `queueChecks` | new view | Read-only | +| Business `#/business` | `packages/business` `loadBusiness`, `loadRole`, `resolveInstance`, `vocabulary.mjs` | new view | Read-only. A missing or invalid file shows the refusal state. Credential refs as metadata only | +| Runs and releases `#/runs` | readers live inside `scripts/mosaic-task.mjs` and `scripts/release.sh`, not exported | backend | Extract a read-only module first | +| Library `#/library` | no meta-harness in committed code | blocked | Row 41 (S6) creates `packages/harness` | +| Settings `#/settings` | appearance exists in the webui header; notifier config `readNotifyConfig` | extend | Appearance, notifications (binding names only), about (loopback, release). Read-only apart from appearance | +| Set up `#/setup` | validation lives in `scripts/mosaic-config.mjs` (a script) | backend | First-run checklist; the installer is out of v1 scope | +| Command palette, five states, not found | client-side | extend | | + +The Board is the console home. The control board's own page on port +7331 stays until the console's Board covers it. + +## The full-screen terminal app + +- **Where.** `packages/cli`. Node built-ins only; the CLI has no + dependencies and must keep none. +- **Talk.** Embed the conversation `Terminal` class + (`packages/conversation/src/terminal.mjs:54`, + `new Terminal({client, write, rows, onQuit})`). Its `main()` owns the + process, so do not call it. The app owns the screen and draws the + conversation inside the Talk pane. +- **Size.** Read `process.stdout.columns` and `rows`; redraw on + `resize`. Designed at 120×36; every screen must fit 80×24. Below + 100 columns: + - the nav shows keys only; + - Inbox drops RAISED BY and REACHES YOU; + - detail moves below the list. +- **Colour.** + - Use the terminal's own 16 colours (`tokens.json` `terminal.ansi`), + never 256-colour or RGB. + - Honour `NO_COLOR` (any non-empty value): no colour codes; reverse + video stays for the selected row, and every state still reads + from its word and mark. + - Not a TTY: refuse the full-screen app and point at the verbs. +- **Refusal.** In an agent run, exit 3 with the transport's message + (`packages/cli/src/transport.mjs:30`). That is the same check the + verbs use; do not write a second one. +- **Deciding.** In Inbox, a digit picks an option. Then print the + CLI's lines and resolve only on `y`. Reuse the `decide` code path + rather than a copy of it. "Outcome unknown" offers no retry key. +- **Keys:** + - `c` talk, `i` inbox, `t` tasks, `a` agents, `r` trail; + - ↑↓ or j/k move, Enter opens, `v` opens the trail of the selection; + - `:` command line, `?` help, `q` quit; + - Tab moves between the nav and the composer; + - the composer keeps the conversation keys. +- **No-argument behaviour** is a CLI change from exit 4 to opening the + app. It is provisional; land it behind its own review. + +## Strings to keep exactly + +These are shipped. Reuse them; do not reword. + +- **Decide:** + - `your choice: (); this authorizes the action`, or + `…; this declines the action`; + - `resolve with ? [y/N] `; + - `resolved : `; + - `outcome unknown: the broker may have recorded it. Check mosaic inbox or mosaic trail before trying again`. +- **Refusal:** `refused: this runs only from a human shell, outside any agent run (found in the environment)`. +- **Web copy:** + - `Copied: `; + - `The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.`; + - `Decisions are answered in a terminal with mosaic decide; Console only shows them.` +- **CLI list formats** (`packages/cli/src/format.mjs`): + - inbox ` ([, blocking]) raised by `; + - tasks ` `; + - agents `<role> <holder_run> <harness|-> since <at>`; + - trail `<at> <table>#<seq> <summary>`. +- **Trail sentences, empty states and not-found lines** are in + `packages/webui/src/public/bus.js` (for example + `No task <ref> in this business.`). The "feat" labels there mark + the gaps this design fills: poller status, bucket names, role + instances and launch limits. +- **Bus wording stays in mono:** event kinds (`task.changed.external`), + refusal codes (`decision-closed`, `launch-revoked`), task refs and + ids. + +## Boundaries + +- **No Resolve button on the web.** Copy the `mosaic decide` command + only (REQ-DEC-3). +- **Seen is not resolution.** Board Seen/Unsee exists. Decision Seen + from the web is blocked (see the gap map). +- **Read-only web**, apart from Board Seen/Unsee, the board's + existing reply path, and appearance settings stored in the browser. + Queue rows move only through `queue move`; the business file is + written by the human only. +- **Secrets.** Show service, account, role and expiry. Never show + values, tokens or file paths. Never log them. +- **Server.** + - Loopback only, no sign-in; + - keep the existing CSP, Host and Origin checks; + - new endpoints are `GET` only and return JSON; + - no new dependencies in `packages/webui` or `packages/cli`. + +## Acceptance + +For every implementation row: +- the package's own suite passes: `node --test tests/` in + `packages/webui` or `packages/cli`, plus the repository suites the row + touches; +- `node docs/design/tools/build-tokens.mjs --check` passes when tokens + changed; +- every changed view shows its five states. Seeded tests cover the + empty, error-with-stale-data and refusal states; +- browser tests (`packages/webui/tests/browser.mjs` style): + - no script errors; + - no sideways page scroll at 400 px; + - keyboard path: ↑/↓, Enter, Esc returning focus to the row; + - focus is visible; +- terminal app tests: + - every screen fits 120 columns and 80×24; + - `NO_COLOR` output has no escape codes; + - an agent-run environment exits 3; +- strings in "Strings to keep exactly" are unchanged (a grep test is + enough). + +`node docs/design/tools/check-design.mjs` checks the preview itself: +- no script errors across 4 tabs, 13 views × 5 states and 13 terminal + screens; +- no sideways scroll at 400 px; +- terminal widths; +- palette colours against `tokens.json`. + +It needs a local Chromium (`$CHROMIUM` or `/usr/bin/chromium`). Run it +after editing the preview. + +## Proposed rows (for Sage) + +Draft order. These go after rows 41 and 42. Sage decides whether to +queue them, and in what order. + +1. **Console tokens and shell restyle** (frontend; Dewey's area). + - Scope: + - adopt `tokens.css`; + - command bar, section list, dense table, inspector, toast, + command palette, not found; + - five states on Board, Inbox, Tasks, Agents and Trail. + - No new data. +2. **Read-only views with existing data** (frontend plus webui + server). Queue, Ledger, Business, Settings: `GET` endpoints over the + existing modules. +3. **Full-screen `mosaic` app** (CLI): Inbox, Tasks, Agents, Trail, + Help, decide, NO_COLOR and 80×24. Talk joins after row 41. + - Needs Jason's confirmation of no-argument `mosaic`. +4. **Backend reads for the design** (bus): + - decisions routed to roles and recent decisions; + - task priority and requirement; + - agent state per role; + - credential status on the broker; + - the field-writer table as data; + - a runs and release reader module. + + Each is a separate small row. +5. **After row 41:** Talk view, launch budget, `mosaic stop` in both + surfaces, Library. + +## Open decisions for Jason + +1. Default palette and mode (provisional: Harbor, following the system + setting). +2. The final mark (placeholder: refined Relay). +3. No-argument `mosaic` opens the full-screen app (provisional). +4. Decision Seen from the web. It needs a human-authority write path + from the browser. The recommendation is to keep it in the terminal + for v1. diff --git a/docs/design/README.md b/docs/design/README.md index 82b0ea56..4b735639 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -1,7 +1,10 @@ # Mosaic Stack design, draft 1 -- Status: draft 1, 2026-10-09. A design for review, not built software. - Nothing under `packages/` changed for it. +- Status: draft 1, 2026-10-09. A design, not built software. Nothing + under `packages/` changed for it. +- 2026-10-10: Jason accepted the direction. The mark stays the refined + Relay as a placeholder until he revises it. The defaults below are + provisional. Implementers start from `IMPLEMENTING.md`. - Asked for by Jason: branding, WebUI page designs, a TUI design, and every feature included. - Preview: `mosaic-stack-design.html` in this directory. It is one @@ -162,37 +165,53 @@ below). ## Coverage -The Coverage tab lists 39 features. Each row gives the requirement, the +The Coverage tab lists 41 features. Each row gives the requirement, the web screen, the TUI screen and a status. The status describes the code today, not these screens: -- **implemented**: exists in the code now (18 features); +- **implemented**: exists in the code now (20 features); - **in progress**: has an open queue row (3); -- **planned**: in the PRD or a brief, with no code yet (3); -- **proposed**: new in this design (15). +- **planned**: in the PRD or a brief, with no code yet (1); +- **proposed**: new in this design (17). -- **In progress.** Talk to the stack-owned PM session (row 41), the - launch budget (row 41), and the trail filter chips (row 40). -- **Planned.** The 08:00 digest, `mosaic stop`, and the business file - view. +- **In progress.** All row 41 (S6): talk to the stack-owned PM + session, the launch budget, and `mosaic stop`. +- **Planned.** The business file view. - **Proposed** (the larger items). The Inbox tabs, Seen for decisions, - the field-writer table on tasks, credential expiry, the command - palette, and the business, library, queue, runs and ledger views. - Also first-run setup and the five-state treatment on every view. + task priority and requirement columns, agent state per role, the + field-writer table on tasks, credential expiry, the command palette, + and the business, library, queue, runs and ledger views. Also + first-run setup and the five-state treatment on every view. + +Corrections on 2026-10-10, checked against the code: +- the 08:00 digest is implemented (`packages/cli/src/notifier.mjs`); +- `mosaic stop` is row 41 work; +- task priority and agent state are not in the bus reads, so they + moved out of the implemented rows. + +`IMPLEMENTING.md` has the gap map per view. ## Open decisions for Jason -1. **Default palette and mode.** Proposed: Harbor, following the system - setting, with Dim available. -2. **Final mark.** Proposed: refined Relay. The original stays usable - from 48 px. +1. **Default palette and mode.** Provisional: Harbor, following the + system setting, with Dim available. +2. **Final mark.** The refined Relay is the placeholder (Jason, + 2026-10-10: "Use the design's logo for now and we will revise."). 3. **Resolving from the web.** The PRD rules it out (REQ-DEC-3). - Proposed: keep the Copy command through v1. -4. **One `mosaic` app.** Proposed: `mosaic` with no arguments opens the - full-screen app, and the verbs remain for scripts. + Provisional: keep the Copy command through v1. +4. **One `mosaic` app.** Provisional: `mosaic` with no arguments opens + the full-screen app, and the verbs remain for scripts. +5. **Decision Seen from the web.** It would give the browser a + human-authority write. Recommended: keep it in the terminal for + v1. ## Files +- `IMPLEMENTING.md`: the brief for implementing agents. - `mosaic-stack-design.html`: the preview page. Its source is built from a scratch file. Edit the HTML directly for draft 2. +- `tokens.json`, `tokens.css`: design values. `tokens.css` is + generated by `tools/build-tokens.mjs`. +- `icons.svg`: the icon sprite, plus the placeholder mark. - `brand/relay-refined.svg`, `brand/relay-original.svg`: the mark in Harbor action blue. +- `tools/check-design.mjs`: browser checks for the preview. diff --git a/docs/design/icons.svg b/docs/design/icons.svg new file mode 100644 index 00000000..bda6fd69 --- /dev/null +++ b/docs/design/icons.svg @@ -0,0 +1,23 @@ +<svg xmlns="http://www.w3.org/2000/svg"> + <!-- Mosaic Stack icons, draft 1. 24-unit grid, 1.75 stroke, round caps and joins, no fill. + Use: <svg class="i" aria-hidden="true"><use href="icons.svg#i-inbox"/></svg> with color set by CSS. + "mark" is the placeholder Relay mark (fill, not stroke). --> + <symbol id="mark" viewBox="0 0 16 17" fill="currentColor"><path fill-rule="evenodd" d="M1 7 5 3 9 3 12 6 9 9 6 6 4 8 4 9 7 12 5 14 1 10ZM15 10 11 14 7 14 4 11 7 8 10 11 12 9 12 8 9 5 11 3 15 7Z"/></symbol> + <symbol id="i-board" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="13.5" width="7" height="7" rx="1.5"/></symbol> + <symbol id="i-inbox" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 13.5 6 5.5h12l2.5 8v5a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/><path d="M3.5 13.5h5l1 2.5h5l1-2.5h5"/></symbol> + <symbol id="i-talk" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 5h15a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H10l-4.5 3.5V16h-1a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z"/><path d="M8 10.5h8"/></symbol> + <symbol id="i-tasks" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M10 6.5h10M10 12h10M10 17.5h10"/><path d="m3.5 6.5 1.5 1.5 2.5-3"/><path d="m3.5 12 1.5 1.5 2.5-3"/><path d="M4 17.5h3"/></symbol> + <symbol id="i-agents" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8.5" r="3"/><path d="M3.5 19.5c.6-3.2 2.8-5 5.5-5s4.9 1.8 5.5 5"/><circle cx="17" cy="9.5" r="2.4"/><path d="M15.8 14.6c2.2.2 3.9 1.8 4.7 4.9"/></symbol> + <symbol id="i-trail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="5.5" r="2"/><circle cx="18" cy="18.5" r="2"/><path d="M8 5.5h7.5a3.25 3.25 0 0 1 0 6.5h-7a3.25 3.25 0 0 0 0 6.5H16"/></symbol> + <symbol id="i-queue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6.5h16M4 12h16M4 17.5h9"/><path d="m16.5 15.5 2 2 2-2"/></symbol> + <symbol id="i-runs" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5 20 8v8l-8 4.5L4 16V8z"/><path d="m4 8 8 4.5L20 8M12 12.5v8"/></symbol> + <symbol id="i-ledger" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 20.5V10M10 20.5V4M15 20.5v-8M20 20.5v-5"/></symbol> + <symbol id="i-business" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 20.5V8.5l6-4v16M10.5 20.5h9v-9l-9-3"/><path d="M3 20.5h18M14 13.5h2M14 16.5h2M6.5 10.5h2M6.5 13.5h2"/></symbol> + <symbol id="i-library" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 4.5h4v15h-4zM10 4.5h4v15h-4z"/><path d="m15.5 5.3 3.4-.9 3.1 14.3-3.4.9z"/></symbol> + <symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2"/><circle cx="9" cy="17" r="2"/></symbol> + <symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="6"/><path d="m20 20-4.5-4.5"/></symbol> + <symbol id="i-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="8.5" y="8.5" width="11" height="11" rx="1.5"/><path d="M15.5 8.5v-3a1 1 0 0 0-1-1h-9a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h3"/></symbol> + <symbol id="i-close" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M18 6 6 18"/></symbol> + <symbol id="i-term" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="m7.5 9.5 3 2.5-3 2.5M12.5 15h4"/></symbol> + <symbol id="i-flag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5.5 20.5v-16M5.5 4.5h12l-2.5 4 2.5 4h-12"/></symbol> +</svg> diff --git a/docs/design/mosaic-stack-design.html b/docs/design/mosaic-stack-design.html index 4208b31c..b37cf1d8 100644 --- a/docs/design/mosaic-stack-design.html +++ b/docs/design/mosaic-stack-design.html @@ -847,7 +847,7 @@ agents(){ return vh("Agents","Role instances, who holds each one, and the launch budget.", `<span class="st implemented">implemented</span>`) + stateBanner() + `<div class="cards"><div class="card"><span class="label">Opus sessions</span><div class="big tnum">2 <span class="note">of 4</span></div><div class="meter" aria-hidden="true"><i class="on"></i><i class="on"></i><i></i><i></i></div></div> <div class="card"><span class="label">Sonnet sessions</span><div class="big tnum">1 <span class="note">of 4</span></div><div class="meter" aria-hidden="true"><i class="on"></i><i></i><i></i><i></i></div></div> - <div class="card"><span class="label">Launching</span><div class="big" style="color:var(--success)">allowed</div><div class="note">Stop all launches with <code>mosaic stop</code> <span class="st planned">planned</span></div></div></div> + <div class="card"><span class="label">Launching</span><div class="big" style="color:var(--success)">allowed</div><div class="note">Stop all launches with <code>mosaic stop</code> <span class="st progress">in progress</span></div></div></div> <div class="tbl"><table><thead><tr><th>Role</th><th>Holder</th><th>Harness</th><th>State</th><th>Attention</th><th>Held for</th><th>Run</th></tr></thead><tbody>${body(rows,7,"<b>No role is claimed</b>The PM launches role sessions within the business file's limits.")}</tbody></table></div> <div class="section"><h3>Role credentials</h3><p class="note" style="margin-bottom:8px">Metadata only. Values and file paths are never shown; the broker alone reads them.</p> <div class="tbl"><table><thead><tr><th>Service</th><th>Account</th><th>Role</th><th>Status</th></tr></thead><tbody>${body(creds.map(([s,a,r,x,c])=>`<tr><td>${s}</td><td class="mono">${a}</td><td>${r}</td><td>${c?`<span class="chip ${c==="bad"?"gated":"warn"}">${x}</span>`:x}</td></tr>`).join(""),4,"<b>No credentials declared</b>")}</tbody></table></div></div>`; @@ -907,7 +907,7 @@ settings(){ <div class="seg" role="group" aria-label="Mode">${["system","light","dim","dark"].map(m=>`<button data-setmode="${m}" aria-pressed="${theme.mode===m}">${m[0].toUpperCase()+m.slice(1)}</button>`).join("")}</div></div> <div class="panel stack"><h3 class="h2" style="font-size:16px">Notifications</h3> <dl class="kv"><dt>Blocking gated</dt><dd>CLI inbox and a Discord DM, right away</dd><dt>Everything else</dt><dd>One digest at 08:00 America/Chicago</dd><dt>Discord</dt><dd>connector #1509 · DM to you only</dd></dl> - <p class="note">Set in the business variables. <span class="st implemented">implemented</span> DM · <span class="st planned">planned</span> digest</p> + <p class="note">Set in the business variables. <span class="st implemented">implemented</span> DM and digest</p> <h3 class="h2" style="font-size:16px;margin-top:8px">About</h3> <dl class="kv"><dt>Release</dt><dd class="mono">0.0.12</dd><dt>WebUI</dt><dd class="mono">127.0.0.1:7330 · loopback only</dd><dt>Control board</dt><dd class="mono">127.0.0.1:7331</dd><dt>Sign-in</dt><dd>None in v1; localhost only, no SSO</dd><dt>System config</dt><dd class="mono">~/.config/mosaic-dev/config.json</dd></dl></div></div>`; }, @@ -1271,7 +1271,7 @@ agents(){ " vikunja bot-mosaic-stack-sync sync, read-only {w|expires 2026-10-21 · 12 days}", " vikunja bot-mosaic-stack-coder coder {d|not configured}", "", - " {m|Stop every launch with} {b|mosaic stop} {m|(planned). Restoring launches is logged as launch.restored.}" + " {m|Stop every launch with} {b|mosaic stop} {m|(row 41). Restoring launches is logged as launch.restored.}" ]; return frame("Agents", body, [" {m|3 roles held · 1 free · 0 revoked}", " {m|> (Tab to type to the PM)}", HINT_NAV]); }, @@ -1408,19 +1408,21 @@ const COV = [ ["Outcome unknown, no retry","bus","toast + trail","outcome-unknown box","implemented",""], ["Refused inside an agent run (exit 3)","REQ-DEC-3","n/a","shell","implemented",""], ["Blocking decisions: Discord DM","REQ-DEC-4, #1509","Settings › Notifications","Inbox footer","implemented",""], - ["Daily digest 08:00 Central","REQ-DEC-4","Board card, Settings","Inbox footer","planned",""], - ["Mark decision seen","bus decision ops","Inspector button","s in Inbox","proposed","Seen is not resolution"], - ["Tasks list, buckets, priority, requirement","REQ-TASK-1","Tasks","Tasks (t)","implemented",""], + ["Daily digest 08:00 Central","REQ-DEC-4","Board card, Settings","Inbox footer","implemented","packages/cli notifier; runs while the bus host runs"], + ["Mark decision seen","bus decision ops","Inspector button","s in Inbox","proposed","Broker op decision.seen exists (human session); no CLI or web caller. Seen is not resolution"], + ["Tasks list and buckets","REQ-TASK-1","Tasks","Tasks (t)","implemented",""], + ["Task priority and requirement columns","REQ-TASK-1","Tasks","Tasks (t)","proposed","Not in the bus tasks read; requirement is in the task.created event"], ["Task markers (changed, not created, conflict, missing)","REQ-TASK-1, -2","Tasks + inspector","Tasks","implemented",""], ["Freshness: Vikunja last read","REQ-TASK-2","command bar, Tasks","Tasks header","implemented",""], ["Field writers per task","REQ-TASK-1","Task inspector","Tasks detail","proposed","From the slice 1 field table"], - ["Agents: role holders and state","REQ-ROLE-2, REQ-CLI-1","Agents","Agents (a)","implemented",""], + ["Agents: role holders","REQ-ROLE-2, REQ-CLI-1","Agents","Agents (a)","implemented",""], + ["Agent state and Input needed per role","REQ-ROLE-2","Agents","Agents (a)","proposed","The control board has state per seat; the bus agents read has none"], ["Launch budget: Opus n of 4, Sonnet n of 4","REQ-LAUNCH-1","Agents, Board","Agents","progress","row 41"], - ["Revoke launches in one word","REQ-LAUNCH-1","Agents card","mosaic stop","planned",""], + ["Revoke launches in one word","REQ-LAUNCH-1","Agents card","mosaic stop","progress","row 41; broker launch.revoke exists, no CLI verb yet"], ["Revoke a stuck holder (gated)","REQ-ROLE-2","Role inspector → Inbox","Agents → Inbox","implemented","role.revoke is always gated"], - ["Credential metadata and expiry","REQ-CRED-1","Agents, Settings","Agents","proposed","Names and dates only"], + ["Credential metadata and expiry","REQ-CRED-1","Agents, Settings","Agents","proposed","Names and dates only; Credentials.status() exists but the broker does not expose it"], ["Trail of a task or decision","REQ-EVT-1, REQ-CLI-1","Trail","Trail (r)","implemented",""], - ["Trail filter chips","slice 1 S5","Trail","Trail","progress","row 40"], + ["Trail filter chips","slice 1 S5","Trail","Trail","implemented","Row 40: All, Requests, Decisions, Launches, Task changes, Review"], ["Control board: seat states, Input needed, Seen/Unsee","control-board","Board (home)","—","implemented","Port 7331 today; joins the console"], ["Command palette Ctrl+K","D-002","everywhere",": command line","proposed",""], ["Business file: roles, arbiters, limits","REQ-ROLE-3, -4","Business","—","planned","Read-only view"], diff --git a/docs/design/tokens.css b/docs/design/tokens.css new file mode 100644 index 00000000..bd895c63 --- /dev/null +++ b/docs/design/tokens.css @@ -0,0 +1,813 @@ +/* Generated from tokens.json by tools/build-tokens.mjs. Do not edit by hand. */ + +/* Fonts: Manrope and JetBrains Mono. Self-host the woff2 files: the console's CSP blocks external hosts. packages/webui already serves Manrope 400-700; weight 800 and JetBrains Mono 400/500/700 still need adding (or drop 800 and use 700). The design preview loads both from Google Fonts only because it is a standalone page. */ + +:root { + --font: "Manrope", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + --r: 6px; + --r-lg: 10px; + --r-table: 8px; + --pad: 16px; + --gap: 12px; +} + +/* Harbor: the default palette */ + +:root { + --canvas: #f3f5f7; + --surface: #ffffff; + --raised: #e6eaef; + --line: #ced5df; + --border: #636e7e; + --text: #434d5b; + --muted: #5e6978; + --action: #2266bf; + --onAction: #ffffff; + --accent: #8d5e20; + --focus: #2266bf; + --success: #287658; + --warning: #8f5c19; + --danger: #ba2e26; + --strong: color-mix(in srgb, var(--text) 72%, #000); + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-mode="light"]):not([data-mode="dim"]) { + --canvas: #12161c; + --surface: #1c232b; + --raised: #28323e; + --line: #384556; + --border: #a4acb7; + --text: #b9c1cb; + --muted: #a4acb7; + --action: #74a6e7; + --onAction: #10141a; + --accent: #e1b47a; + --focus: #74a6e7; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e37d78; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; + } +} + +:root[data-mode="dark"] { + --canvas: #12161c; + --surface: #1c232b; + --raised: #28323e; + --line: #384556; + --border: #a4acb7; + --text: #b9c1cb; + --muted: #a4acb7; + --action: #74a6e7; + --onAction: #10141a; + --accent: #e1b47a; + --focus: #74a6e7; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e37d78; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} + +:root[data-mode="dim"] { + --canvas: #242d38; + --surface: #2e3947; + --raised: #384556; + --line: #47576c; + --border: #a4acb7; + --text: #d6dbe1; + --muted: #a9b1bc; + --action: #8ab4ea; + --onAction: #10141a; + --accent: #e1b47a; + --focus: #8ab4ea; + --success: #85d5b7; + --warning: #e7b574; + --danger: #ea9d99; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} + +/* Carmine */ + +:root[data-palette="carmine"] { + --canvas: #f7f3f3; + --surface: #ffffff; + --raised: #efe7e8; + --line: #deced1; + --border: #7e6367; + --text: #5e4549; + --muted: #7b6065; + --action: #b92740; + --onAction: #ffffff; + --accent: #2e706c; + --focus: #b92740; + --success: #287658; + --warning: #8f5c19; + --danger: #ba2e26; + --strong: color-mix(in srgb, var(--text) 72%, #000); + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root[data-palette="carmine"]:not([data-mode="light"]):not([data-mode="dim"]) { + --canvas: #1b1214; + --surface: #2b1d1f; + --raised: #3d292c; + --line: #55393e; + --border: #b7a4a7; + --text: #c9b6b9; + --muted: #b7a4a7; + --action: #e2788a; + --onAction: #10141a; + --accent: #8bd0cb; + --focus: #e2788a; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e37d78; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; + } +} + +:root[data-palette="carmine"][data-mode="dark"] { + --canvas: #1b1214; + --surface: #2b1d1f; + --raised: #3d292c; + --line: #55393e; + --border: #b7a4a7; + --text: #c9b6b9; + --muted: #b7a4a7; + --action: #e2788a; + --onAction: #10141a; + --accent: #8bd0cb; + --focus: #e2788a; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e37d78; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} + +:root[data-palette="carmine"][data-mode="dim"] { + --canvas: #372528; + --surface: #462f33; + --raised: #55393e; + --line: #6b484e; + --border: #b7a4a7; + --text: #ded3d5; + --muted: #bca9ac; + --action: #e996a4; + --onAction: #10141a; + --accent: #8bd0cb; + --focus: #e996a4; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e99995; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} + +/* Atlantic */ + +:root[data-palette="atlantic"] { + --canvas: #f3f6f7; + --surface: #ffffff; + --raised: #e6ecef; + --line: #cdd9df; + --border: #63757e; + --text: #3f4e55; + --muted: #5c6d75; + --action: #19719a; + --onAction: #ffffff; + --accent: #257467; + --focus: #19719a; + --success: #287658; + --warning: #935f1a; + --danger: #ba2e26; + --strong: color-mix(in srgb, var(--text) 72%, #000); + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root[data-palette="atlantic"]:not([data-mode="light"]):not([data-mode="dim"]) { + --canvas: #12191c; + --surface: #1c272b; + --raised: #28373e; + --line: #384d57; + --border: #a4b1b7; + --text: #bcc8cd; + --muted: #a4b1b7; + --action: #73c3e8; + --onAction: #10141a; + --accent: #83d8ca; + --focus: #73c3e8; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e4817c; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; + } +} + +:root[data-palette="atlantic"][data-mode="dark"] { + --canvas: #12191c; + --surface: #1c272b; + --raised: #28373e; + --line: #384d57; + --border: #a4b1b7; + --text: #bcc8cd; + --muted: #a4b1b7; + --action: #73c3e8; + --onAction: #10141a; + --accent: #83d8ca; + --focus: #73c3e8; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e4817c; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} + +:root[data-palette="atlantic"][data-mode="dim"] { + --canvas: #243238; + --surface: #2e3f47; + --raised: #384d57; + --line: #46606d; + --border: #a4b1b7; + --text: #e2e7e9; + --muted: #afbbc0; + --action: #73c3e8; + --onAction: #10141a; + --accent: #83d8ca; + --focus: #73c3e8; + --success: #85d5b7; + --warning: #e7b574; + --danger: #edaaa6; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} + +/* Terracotta */ + +:root[data-palette="terracotta"] { + --canvas: #f7f4f3; + --surface: #ffffff; + --raised: #eee9e7; + --line: #ded3cf; + --border: #7e6b63; + --text: #584841; + --muted: #78665e; + --action: #a74f2a; + --onAction: #ffffff; + --accent: #356f88; + --focus: #a74f2a; + --success: #287658; + --warning: #8f5c19; + --danger: #ba2e26; + --strong: color-mix(in srgb, var(--text) 72%, #000); + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root[data-palette="terracotta"]:not([data-mode="light"]):not([data-mode="dim"]) { + --canvas: #1b1513; + --surface: #2a211d; + --raised: #3c2f2a; + --line: #54423b; + --border: #b7a9a4; + --text: #cbbeb9; + --muted: #b7a9a4; + --action: #de9a7c; + --onAction: #10141a; + --accent: #89bcd1; + --focus: #de9a7c; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e37d78; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; + } +} + +:root[data-palette="terracotta"][data-mode="dark"] { + --canvas: #1b1513; + --surface: #2a211d; + --raised: #3c2f2a; + --line: #54423b; + --border: #b7a9a4; + --text: #cbbeb9; + --muted: #b7a9a4; + --action: #de9a7c; + --onAction: #10141a; + --accent: #89bcd1; + --focus: #de9a7c; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e37d78; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} + +:root[data-palette="terracotta"][data-mode="dim"] { + --canvas: #362b26; + --surface: #453630; + --raised: #54423b; + --line: #695349; + --border: #b7a9a4; + --text: #e5dfdc; + --muted: #beb2ac; + --action: #e2a68d; + --onAction: #10141a; + --accent: #89bcd1; + --focus: #e2a68d; + --success: #85d5b7; + --warning: #e7b574; + --danger: #eba29e; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} + +/* Aubergine */ + +:root[data-palette="aubergine"] { + --canvas: #f5f4f6; + --surface: #ffffff; + --raised: #ece8ed; + --line: #d9d2db; + --border: #78637e; + --text: #59455e; + --muted: #75607b; + --action: #88469b; + --onAction: #ffffff; + --accent: #77672c; + --focus: #88469b; + --success: #287658; + --warning: #8f5c19; + --danger: #ba2e26; + --strong: color-mix(in srgb, var(--text) 72%, #000); + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root[data-palette="aubergine"]:not([data-mode="light"]):not([data-mode="dim"]) { + --canvas: #18141a; + --surface: #262028; + --raised: #362d39; + --line: #4c3f50; + --border: #b3a4b7; + --text: #c7b9cb; + --muted: #b3a4b7; + --action: #bf8ecc; + --onAction: #10141a; + --accent: #d3c388; + --focus: #bf8ecc; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e37d78; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; + } +} + +:root[data-palette="aubergine"][data-mode="dark"] { + --canvas: #18141a; + --surface: #262028; + --raised: #362d39; + --line: #4c3f50; + --border: #b3a4b7; + --text: #c7b9cb; + --muted: #b3a4b7; + --action: #bf8ecc; + --onAction: #10141a; + --accent: #d3c388; + --focus: #bf8ecc; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e37d78; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} + +:root[data-palette="aubergine"][data-mode="dim"] { + --canvas: #312933; + --surface: #3e3441; + --raised: #4c3f50; + --line: #5f4f63; + --border: #b3a4b7; + --text: #e1d9e3; + --muted: #baacbe; + --action: #cba4d6; + --onAction: #10141a; + --accent: #d3c388; + --focus: #cba4d6; + --success: #85d5b7; + --warning: #e7b574; + --danger: #ea9d99; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} + +/* Mineral */ + +:root[data-palette="mineral"] { + --canvas: #f3f6f6; + --surface: #ffffff; + --raised: #e8eded; + --line: #d0dcda; + --border: #637e7a; + --text: #3d524f; + --muted: #586f6c; + --action: #2a7469; + --onAction: #ffffff; + --accent: #9f414f; + --focus: #2a7469; + --success: #287658; + --warning: #935f1a; + --danger: #ba2e26; + --strong: color-mix(in srgb, var(--text) 72%, #000); + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root[data-palette="mineral"]:not([data-mode="light"]):not([data-mode="dim"]) { + --canvas: #141a19; + --surface: #1f2927; + --raised: #2c3a38; + --line: #3d514e; + --border: #a4b7b4; + --text: #b9cbc8; + --muted: #a4b7b4; + --action: #87d4c8; + --onAction: #10141a; + --accent: #d18f99; + --focus: #87d4c8; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e58580; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; + } +} + +:root[data-palette="mineral"][data-mode="dark"] { + --canvas: #141a19; + --surface: #1f2927; + --raised: #2c3a38; + --line: #3d514e; + --border: #a4b7b4; + --text: #b9cbc8; + --muted: #a4b7b4; + --action: #87d4c8; + --onAction: #10141a; + --accent: #d18f99; + --focus: #87d4c8; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e58580; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} + +:root[data-palette="mineral"][data-mode="dim"] { + --canvas: #273432; + --surface: #324340; + --raised: #3d514e; + --line: #4d6662; + --border: #a4b7b4; + --text: #e5ebea; + --muted: #b2c2c0; + --action: #87d4c8; + --onAction: #10141a; + --accent: #e0b3ba; + --focus: #87d4c8; + --success: #85d5b7; + --warning: #e7b574; + --danger: #eeaeaa; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} + +/* Cobalt */ + +:root[data-palette="cobalt"] { + --canvas: #f3f3f7; + --surface: #ffffff; + --raised: #e6e8ef; + --line: #cdd1df; + --border: #63687e; + --text: #454a5e; + --muted: #60657b; + --action: #1c3bc4; + --onAction: #ffffff; + --accent: #79671b; + --focus: #1c3bc4; + --success: #277255; + --warning: #8f5c19; + --danger: #ba2e26; + --strong: color-mix(in srgb, var(--text) 72%, #000); + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root[data-palette="cobalt"]:not([data-mode="light"]):not([data-mode="dim"]) { + --canvas: #12141c; + --surface: #1c1f2c; + --raised: #282c3e; + --line: #383d57; + --border: #a4a7b7; + --text: #b6b9c9; + --muted: #a4a7b7; + --action: #798eec; + --onAction: #10141a; + --accent: #e1ce7a; + --focus: #798eec; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e37d78; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; + } +} + +:root[data-palette="cobalt"][data-mode="dark"] { + --canvas: #12141c; + --surface: #1c1f2c; + --raised: #282c3e; + --line: #383d57; + --border: #a4a7b7; + --text: #b6b9c9; + --muted: #a4a7b7; + --action: #798eec; + --onAction: #10141a; + --accent: #e1ce7a; + --focus: #798eec; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e37d78; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} + +:root[data-palette="cobalt"][data-mode="dim"] { + --canvas: #242838; + --surface: #2e3248; + --raised: #383d57; + --line: #464d6d; + --border: #a4a7b7; + --text: #d0d2dc; + --muted: #a6aab9; + --action: #94a5f0; + --onAction: #10141a; + --accent: #e1ce7a; + --focus: #94a5f0; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e89591; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} + +/* Rosewood */ + +:root[data-palette="rosewood"] { + --canvas: #f6f3f5; + --surface: #ffffff; + --raised: #ede8eb; + --line: #dcd0d6; + --border: #7e6371; + --text: #5e4552; + --muted: #7b606e; + --action: #a53b72; + --onAction: #ffffff; + --accent: #416c95; + --focus: #a53b72; + --success: #287658; + --warning: #8f5c19; + --danger: #ba2e26; + --strong: color-mix(in srgb, var(--text) 72%, #000); + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root[data-palette="rosewood"]:not([data-mode="light"]):not([data-mode="dim"]) { + --canvas: #1a1417; + --surface: #291f24; + --raised: #3a2c33; + --line: #513d48; + --border: #b7a4ae; + --text: #cbb9c2; + --muted: #b7a4ae; + --action: #d487af; + --onAction: #10141a; + --accent: #8eaecd; + --focus: #d487af; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e37d78; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; + } +} + +:root[data-palette="rosewood"][data-mode="dark"] { + --canvas: #1a1417; + --surface: #291f24; + --raised: #3a2c33; + --line: #513d48; + --border: #b7a4ae; + --text: #cbb9c2; + --muted: #b7a4ae; + --action: #d487af; + --onAction: #10141a; + --accent: #8eaecd; + --focus: #d487af; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e37d78; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} + +:root[data-palette="rosewood"][data-mode="dim"] { + --canvas: #34272e; + --surface: #43323b; + --raised: #513d48; + --line: #664d5a; + --border: #b7a4ae; + --text: #e1d6db; + --muted: #beacb5; + --action: #dc9ebe; + --onAction: #10141a; + --accent: #95b3d0; + --focus: #dc9ebe; + --success: #85d5b7; + --warning: #e7b574; + --danger: #ea9d99; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} + +/* Graphite */ + +:root[data-palette="graphite"] { + --canvas: #f5f5f5; + --surface: #ffffff; + --raised: #eaeaeb; + --line: #d5d6d7; + --border: #636e7e; + --text: #434d5b; + --muted: #5e6978; + --action: #616975; + --onAction: #ffffff; + --accent: #5e6978; + --focus: #616975; + --success: #287658; + --warning: #8f5c19; + --danger: #ba2e26; + --strong: color-mix(in srgb, var(--text) 72%, #000); + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root[data-palette="graphite"]:not([data-mode="light"]):not([data-mode="dim"]) { + --canvas: #161718; + --surface: #232425; + --raised: #323334; + --line: #454749; + --border: #a4abb7; + --text: #bcc3cd; + --muted: #a4abb7; + --action: #a6acb5; + --onAction: #10141a; + --accent: #a4abb7; + --focus: #a6acb5; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e37d78; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; + } +} + +:root[data-palette="graphite"][data-mode="dark"] { + --canvas: #161718; + --surface: #232425; + --raised: #323334; + --line: #454749; + --border: #a4abb7; + --text: #bcc3cd; + --muted: #a4abb7; + --action: #a6acb5; + --onAction: #10141a; + --accent: #a4abb7; + --focus: #a6acb5; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e37d78; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} + +:root[data-palette="graphite"][data-mode="dim"] { + --canvas: #2d2e2f; + --surface: #393a3c; + --raised: #454749; + --line: #57595c; + --border: #a4abb7; + --text: #dfe2e7; + --muted: #afb6c0; + --action: #b1b6be; + --onAction: #10141a; + --accent: #afb6c0; + --focus: #b1b6be; + --success: #85d5b7; + --warning: #e7b574; + --danger: #eba29e; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} + +/* Grove */ + +:root[data-palette="grove"] { + --canvas: #f4f6f5; + --surface: #ffffff; + --raised: #e8edea; + --line: #d2dbd6; + --border: #637e6e; + --text: #3d5246; + --muted: #586f62; + --action: #377752; + --onAction: #ffffff; + --accent: #9c444a; + --focus: #377752; + --success: #287658; + --warning: #935f1a; + --danger: #ba2e26; + --strong: color-mix(in srgb, var(--text) 72%, #000); + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root[data-palette="grove"]:not([data-mode="light"]):not([data-mode="dim"]) { + --canvas: #141917; + --surface: #202823; + --raised: #2d3932; + --line: #3f4f46; + --border: #a4b7ac; + --text: #b9cbc1; + --muted: #a4b7ac; + --action: #8fcca9; + --onAction: #10141a; + --accent: #cd8e92; + --focus: #8fcca9; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e58580; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; + } +} + +:root[data-palette="grove"][data-mode="dark"] { + --canvas: #141917; + --surface: #202823; + --raised: #2d3932; + --line: #3f4f46; + --border: #a4b7ac; + --text: #b9cbc1; + --muted: #a4b7ac; + --action: #8fcca9; + --onAction: #10141a; + --accent: #cd8e92; + --focus: #8fcca9; + --success: #85d5b7; + --warning: #e7b574; + --danger: #e58580; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} + +:root[data-palette="grove"][data-mode="dim"] { + --canvas: #29332d; + --surface: #34413a; + --raised: #3f4f46; + --line: #4f6358; + --border: #a4b7ac; + --text: #e2e9e5; + --muted: #afc0b6; + --action: #8fcca9; + --onAction: #10141a; + --accent: #ddb1b4; + --focus: #8fcca9; + --success: #85d5b7; + --warning: #e7b574; + --danger: #edaaa6; + --strong: color-mix(in srgb, var(--text) 70%, #fff); + color-scheme: dark; +} diff --git a/docs/design/tokens.json b/docs/design/tokens.json new file mode 100644 index 00000000..a48c7e34 --- /dev/null +++ b/docs/design/tokens.json @@ -0,0 +1,814 @@ +{ + "$comment": "Mosaic Stack design tokens, draft 1. Source of truth for web and terminal colour, type and shape. Palette values equal agents/dewey/work/wui/assets/palettes.json. Regenerate tokens.css with: node docs/design/tools/build-tokens.mjs", + "version": "draft-1", + "default": { + "palette": "harbor", + "mode": "system", + "$comment": "Provisional (Jason 2026-10-10 accepted the direction; the default is still open)." + }, + "modes": [ + "light", + "dim", + "dark" + ], + "modeRules": { + "system": "light unless prefers-color-scheme is dark, then dark", + "dim": "only by explicit choice" + }, + "color": { + "semantic": { + "canvas": "page background", + "surface": "panels, tables, inspector", + "raised": "selected nav, hover, chips", + "line": "hairlines and table rules", + "border": "control borders that must meet 3:1", + "text": "body text", + "strong": "derived: text mixed 72% with black (light) or 70% with white (dim, dark); headings and emphasis", + "muted": "secondary text, timestamps", + "action": "links, primary buttons, selection", + "onAction": "text on action fills", + "accent": "the PM, decisions, the second voice", + "focus": "focus rings", + "success": "working, pass, fresh", + "warning": "waiting, stale, expiring, outcome unknown", + "danger": "blocking, refused, error, conflict" + } + }, + "palettes": [ + { + "id": "harbor", + "name": "Harbor", + "theory": "Complementary", + "note": "Clear blue with a restrained copper counterpoint. The warm accent balances cool navigation and gives selected details a second voice.", + "modes": { + "light": { + "canvas": "#f3f5f7", + "surface": "#ffffff", + "raised": "#e6eaef", + "line": "#ced5df", + "border": "#636e7e", + "text": "#434d5b", + "muted": "#5e6978", + "action": "#2266bf", + "onAction": "#ffffff", + "accent": "#8d5e20", + "focus": "#2266bf", + "success": "#287658", + "warning": "#8f5c19", + "danger": "#ba2e26" + }, + "dim": { + "canvas": "#242d38", + "surface": "#2e3947", + "raised": "#384556", + "line": "#47576c", + "border": "#a4acb7", + "text": "#d6dbe1", + "muted": "#a9b1bc", + "action": "#8ab4ea", + "onAction": "#10141a", + "accent": "#e1b47a", + "focus": "#8ab4ea", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#ea9d99" + }, + "dark": { + "canvas": "#12161c", + "surface": "#1c232b", + "raised": "#28323e", + "line": "#384556", + "border": "#a4acb7", + "text": "#b9c1cb", + "muted": "#a4acb7", + "action": "#74a6e7", + "onAction": "#10141a", + "accent": "#e1b47a", + "focus": "#74a6e7", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#e37d78" + } + } + }, + { + "id": "carmine", + "name": "Carmine", + "theory": "Near-complementary", + "note": "A red-led identity with a cool turquoise counterpoint. Rose-tinted neutrals keep the red from covering every surface.", + "modes": { + "light": { + "canvas": "#f7f3f3", + "surface": "#ffffff", + "raised": "#efe7e8", + "line": "#deced1", + "border": "#7e6367", + "text": "#5e4549", + "muted": "#7b6065", + "action": "#b92740", + "onAction": "#ffffff", + "accent": "#2e706c", + "focus": "#b92740", + "success": "#287658", + "warning": "#8f5c19", + "danger": "#ba2e26" + }, + "dim": { + "canvas": "#372528", + "surface": "#462f33", + "raised": "#55393e", + "line": "#6b484e", + "border": "#b7a4a7", + "text": "#ded3d5", + "muted": "#bca9ac", + "action": "#e996a4", + "onAction": "#10141a", + "accent": "#8bd0cb", + "focus": "#e996a4", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#e99995" + }, + "dark": { + "canvas": "#1b1214", + "surface": "#2b1d1f", + "raised": "#3d292c", + "line": "#55393e", + "border": "#b7a4a7", + "text": "#c9b6b9", + "muted": "#b7a4a7", + "action": "#e2788a", + "onAction": "#10141a", + "accent": "#8bd0cb", + "focus": "#e2788a", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#e37d78" + } + } + }, + { + "id": "atlantic", + "name": "Atlantic", + "theory": "Analogous", + "note": "Ocean blue and teal sit close on the hue wheel. Their shared cool bias makes a quieter combination than an opposing accent.", + "modes": { + "light": { + "canvas": "#f3f6f7", + "surface": "#ffffff", + "raised": "#e6ecef", + "line": "#cdd9df", + "border": "#63757e", + "text": "#3f4e55", + "muted": "#5c6d75", + "action": "#19719a", + "onAction": "#ffffff", + "accent": "#257467", + "focus": "#19719a", + "success": "#287658", + "warning": "#935f1a", + "danger": "#ba2e26" + }, + "dim": { + "canvas": "#243238", + "surface": "#2e3f47", + "raised": "#384d57", + "line": "#46606d", + "border": "#a4b1b7", + "text": "#e2e7e9", + "muted": "#afbbc0", + "action": "#73c3e8", + "onAction": "#10141a", + "accent": "#83d8ca", + "focus": "#73c3e8", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#edaaa6" + }, + "dark": { + "canvas": "#12191c", + "surface": "#1c272b", + "raised": "#28373e", + "line": "#384d57", + "border": "#a4b1b7", + "text": "#bcc8cd", + "muted": "#a4b1b7", + "action": "#73c3e8", + "onAction": "#10141a", + "accent": "#83d8ca", + "focus": "#73c3e8", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#e4817c" + } + } + }, + { + "id": "terracotta", + "name": "Terracotta", + "theory": "Complementary", + "note": "Fired-clay orange meets a desaturated steel blue. Warm neutral surfaces connect the palette without needing a beige wash everywhere.", + "modes": { + "light": { + "canvas": "#f7f4f3", + "surface": "#ffffff", + "raised": "#eee9e7", + "line": "#ded3cf", + "border": "#7e6b63", + "text": "#584841", + "muted": "#78665e", + "action": "#a74f2a", + "onAction": "#ffffff", + "accent": "#356f88", + "focus": "#a74f2a", + "success": "#287658", + "warning": "#8f5c19", + "danger": "#ba2e26" + }, + "dim": { + "canvas": "#362b26", + "surface": "#453630", + "raised": "#54423b", + "line": "#695349", + "border": "#b7a9a4", + "text": "#e5dfdc", + "muted": "#beb2ac", + "action": "#e2a68d", + "onAction": "#10141a", + "accent": "#89bcd1", + "focus": "#e2a68d", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#eba29e" + }, + "dark": { + "canvas": "#1b1513", + "surface": "#2a211d", + "raised": "#3c2f2a", + "line": "#54423b", + "border": "#b7a9a4", + "text": "#cbbeb9", + "muted": "#b7a9a4", + "action": "#de9a7c", + "onAction": "#10141a", + "accent": "#89bcd1", + "focus": "#de9a7c", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#e37d78" + } + } + }, + { + "id": "aubergine", + "name": "Aubergine", + "theory": "Triadic pair", + "note": "Muted plum and ochre sit 120 degrees apart on the hue wheel. The third triadic hue, teal, is left out so the interface has two brand accents rather than three.", + "modes": { + "light": { + "canvas": "#f5f4f6", + "surface": "#ffffff", + "raised": "#ece8ed", + "line": "#d9d2db", + "border": "#78637e", + "text": "#59455e", + "muted": "#75607b", + "action": "#88469b", + "onAction": "#ffffff", + "accent": "#77672c", + "focus": "#88469b", + "success": "#287658", + "warning": "#8f5c19", + "danger": "#ba2e26" + }, + "dim": { + "canvas": "#312933", + "surface": "#3e3441", + "raised": "#4c3f50", + "line": "#5f4f63", + "border": "#b3a4b7", + "text": "#e1d9e3", + "muted": "#baacbe", + "action": "#cba4d6", + "onAction": "#10141a", + "accent": "#d3c388", + "focus": "#cba4d6", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#ea9d99" + }, + "dark": { + "canvas": "#18141a", + "surface": "#262028", + "raised": "#362d39", + "line": "#4c3f50", + "border": "#b3a4b7", + "text": "#c7b9cb", + "muted": "#b3a4b7", + "action": "#bf8ecc", + "onAction": "#10141a", + "accent": "#d3c388", + "focus": "#bf8ecc", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#e37d78" + } + } + }, + { + "id": "mineral", + "name": "Mineral", + "theory": "Complementary", + "note": "Deep teal and dusty rose oppose each other while keeping saturation restrained. Blue-green tinted layers carry the identity in dark mode.", + "modes": { + "light": { + "canvas": "#f3f6f6", + "surface": "#ffffff", + "raised": "#e8eded", + "line": "#d0dcda", + "border": "#637e7a", + "text": "#3d524f", + "muted": "#586f6c", + "action": "#2a7469", + "onAction": "#ffffff", + "accent": "#9f414f", + "focus": "#2a7469", + "success": "#287658", + "warning": "#935f1a", + "danger": "#ba2e26" + }, + "dim": { + "canvas": "#273432", + "surface": "#324340", + "raised": "#3d514e", + "line": "#4d6662", + "border": "#a4b7b4", + "text": "#e5ebea", + "muted": "#b2c2c0", + "action": "#87d4c8", + "onAction": "#10141a", + "accent": "#e0b3ba", + "focus": "#87d4c8", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#eeaeaa" + }, + "dark": { + "canvas": "#141a19", + "surface": "#1f2927", + "raised": "#2c3a38", + "line": "#3d514e", + "border": "#a4b7b4", + "text": "#b9cbc8", + "muted": "#a4b7b4", + "action": "#87d4c8", + "onAction": "#10141a", + "accent": "#d18f99", + "focus": "#87d4c8", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#e58580" + } + } + }, + { + "id": "cobalt", + "name": "Cobalt", + "theory": "Complementary", + "note": "A stronger royal blue meets a small amber accent. The high hue separation is controlled by using amber only in secondary details.", + "modes": { + "light": { + "canvas": "#f3f3f7", + "surface": "#ffffff", + "raised": "#e6e8ef", + "line": "#cdd1df", + "border": "#63687e", + "text": "#454a5e", + "muted": "#60657b", + "action": "#1c3bc4", + "onAction": "#ffffff", + "accent": "#79671b", + "focus": "#1c3bc4", + "success": "#277255", + "warning": "#8f5c19", + "danger": "#ba2e26" + }, + "dim": { + "canvas": "#242838", + "surface": "#2e3248", + "raised": "#383d57", + "line": "#464d6d", + "border": "#a4a7b7", + "text": "#d0d2dc", + "muted": "#a6aab9", + "action": "#94a5f0", + "onAction": "#10141a", + "accent": "#e1ce7a", + "focus": "#94a5f0", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#e89591" + }, + "dark": { + "canvas": "#12141c", + "surface": "#1c1f2c", + "raised": "#282c3e", + "line": "#383d57", + "border": "#a4a7b7", + "text": "#b6b9c9", + "muted": "#a4a7b7", + "action": "#798eec", + "onAction": "#10141a", + "accent": "#e1ce7a", + "focus": "#798eec", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#e37d78" + } + } + }, + { + "id": "rosewood", + "name": "Rosewood", + "theory": "Triadic pair", + "note": "Dusty pink and steel blue occupy two points of a triadic relationship. The third, yellow-green, is omitted to avoid an overly colorful interface.", + "modes": { + "light": { + "canvas": "#f6f3f5", + "surface": "#ffffff", + "raised": "#ede8eb", + "line": "#dcd0d6", + "border": "#7e6371", + "text": "#5e4552", + "muted": "#7b606e", + "action": "#a53b72", + "onAction": "#ffffff", + "accent": "#416c95", + "focus": "#a53b72", + "success": "#287658", + "warning": "#8f5c19", + "danger": "#ba2e26" + }, + "dim": { + "canvas": "#34272e", + "surface": "#43323b", + "raised": "#513d48", + "line": "#664d5a", + "border": "#b7a4ae", + "text": "#e1d6db", + "muted": "#beacb5", + "action": "#dc9ebe", + "onAction": "#10141a", + "accent": "#95b3d0", + "focus": "#dc9ebe", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#ea9d99" + }, + "dark": { + "canvas": "#1a1417", + "surface": "#291f24", + "raised": "#3a2c33", + "line": "#513d48", + "border": "#b7a4ae", + "text": "#cbb9c2", + "muted": "#b7a4ae", + "action": "#d487af", + "onAction": "#10141a", + "accent": "#8eaecd", + "focus": "#d487af", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#e37d78" + } + } + }, + { + "id": "graphite", + "name": "Graphite", + "theory": "Monochromatic", + "note": "One blue-gray hue uses value and saturation changes instead of a second brand hue. Semantic status colors remain independent.", + "modes": { + "light": { + "canvas": "#f5f5f5", + "surface": "#ffffff", + "raised": "#eaeaeb", + "line": "#d5d6d7", + "border": "#636e7e", + "text": "#434d5b", + "muted": "#5e6978", + "action": "#616975", + "onAction": "#ffffff", + "accent": "#5e6978", + "focus": "#616975", + "success": "#287658", + "warning": "#8f5c19", + "danger": "#ba2e26" + }, + "dim": { + "canvas": "#2d2e2f", + "surface": "#393a3c", + "raised": "#454749", + "line": "#57595c", + "border": "#a4abb7", + "text": "#dfe2e7", + "muted": "#afb6c0", + "action": "#b1b6be", + "onAction": "#10141a", + "accent": "#afb6c0", + "focus": "#b1b6be", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#eba29e" + }, + "dark": { + "canvas": "#161718", + "surface": "#232425", + "raised": "#323334", + "line": "#454749", + "border": "#a4abb7", + "text": "#bcc3cd", + "muted": "#a4abb7", + "action": "#a6acb5", + "onAction": "#10141a", + "accent": "#a4abb7", + "focus": "#a6acb5", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#e37d78" + } + } + }, + { + "id": "grove", + "name": "Grove", + "theory": "Split-complementary pair", + "note": "Muted forest green meets dusty rose, 30 degrees to one side of its magenta complement. The second split accent is omitted. Low-saturation surfaces keep green from dominating.", + "modes": { + "light": { + "canvas": "#f4f6f5", + "surface": "#ffffff", + "raised": "#e8edea", + "line": "#d2dbd6", + "border": "#637e6e", + "text": "#3d5246", + "muted": "#586f62", + "action": "#377752", + "onAction": "#ffffff", + "accent": "#9c444a", + "focus": "#377752", + "success": "#287658", + "warning": "#935f1a", + "danger": "#ba2e26" + }, + "dim": { + "canvas": "#29332d", + "surface": "#34413a", + "raised": "#3f4f46", + "line": "#4f6358", + "border": "#a4b7ac", + "text": "#e2e9e5", + "muted": "#afc0b6", + "action": "#8fcca9", + "onAction": "#10141a", + "accent": "#ddb1b4", + "focus": "#8fcca9", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#edaaa6" + }, + "dark": { + "canvas": "#141917", + "surface": "#202823", + "raised": "#2d3932", + "line": "#3f4f46", + "border": "#a4b7ac", + "text": "#b9cbc1", + "muted": "#a4b7ac", + "action": "#8fcca9", + "onAction": "#10141a", + "accent": "#cd8e92", + "focus": "#8fcca9", + "success": "#85d5b7", + "warning": "#e7b574", + "danger": "#e58580" + } + } + } + ], + "font": { + "sans": { + "family": "Manrope", + "stack": "\"Manrope\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif", + "weights": [ + 400, + 500, + 600, + 700, + 800 + ] + }, + "mono": { + "family": "JetBrains Mono", + "stack": "\"JetBrains Mono\", ui-monospace, \"SFMono-Regular\", Menlo, Consolas, monospace", + "weights": [ + 400, + 500, + 700 + ], + "use": "ids, refs, actions, refusal codes, commands: anything a person might type or paste" + }, + "source": "Self-host the woff2 files: the console's CSP blocks external hosts. packages/webui already serves Manrope 400-700; weight 800 and JetBrains Mono 400/500/700 still need adding (or drop 800 and use 700). The design preview loads both from Google Fonts only because it is a standalone page." + }, + "type": { + "page": { + "size": 28, + "weight": 800 + }, + "section": { + "size": 20, + "weight": 800 + }, + "question": { + "size": 16, + "weight": 700 + }, + "body": { + "size": 14, + "weight": 400, + "lineHeight": 1.5 + }, + "table": { + "size": 13, + "weight": 400, + "cellPadding": [ + 7, + 10 + ] + }, + "label": { + "size": 11, + "weight": 700, + "case": "upper", + "tracking": "0.06em" + }, + "mono": { + "size": 12.5, + "weight": 400 + }, + "numerals": "tabular-nums in any column of digits" + }, + "shape": { + "radius": { + "control": 6, + "panel": 10, + "pill": 999, + "kbd": 4, + "table": 8 + }, + "stroke": { + "hairline": 1 + }, + "focusRing": { + "width": 2, + "offset": 2 + } + }, + "space": { + "gutter": 16, + "scale": [ + 4, + 6, + 8, + 10, + 12, + 14, + 16, + 20 + ], + "$comment": "Values the preview uses; gap 8 is the common sibling gap.", + "gap": 12 + }, + "layout": { + "console": { + "sideList": 220, + "inspector": { + "min": 300, + "max": 370 + }, + "breakpoints": { + "inspectorOverlay": 1100, + "singleColumn": 760 + } + }, + "maxWidth": 1320 + }, + "icon": { + "grid": 24, + "stroke": 1.75, + "linecap": "round", + "linejoin": "round", + "fill": "none", + "sprite": "docs/design/icons.svg" + }, + "terminal": { + "size": { + "design": [ + 120, + 36 + ], + "minimum": [ + 80, + 24 + ], + "keysOnlyNavBelow": 100 + }, + "ansi": [ + { + "role": "action", + "ansi": "blue (4)", + "use": "action, selection, links, your messages" + }, + { + "role": "accent", + "ansi": "yellow (3)", + "use": "the PM, decisions" + }, + { + "role": "success", + "ansi": "green (2)", + "use": "working, pass, fresh" + }, + { + "role": "warning", + "ansi": "bright yellow (11)", + "use": "waiting, stale, expiring, outcome unknown" + }, + { + "role": "danger", + "ansi": "red (1)", + "use": "blocking, refused, error, conflict" + }, + { + "role": "muted", + "ansi": "bright black (8)", + "use": "timestamps, hints, rules" + }, + { + "role": "selection", + "ansi": "reverse video", + "use": "selected row" + } + ], + "noColor": "NO_COLOR set: no SGR colour; every state still reads from its word and mark; selection keeps reverse video" + }, + "status": { + "agent": [ + [ + "working", + "●" + ], + [ + "waiting", + "◐" + ], + [ + "error", + "▲" + ], + [ + "offline", + "○" + ], + [ + "idle", + "–" + ], + [ + "unknown", + "?" + ] + ], + "decision": [ + "routine", + "within-role", + "cross-role", + "gated", + "BLOCKING" + ], + "taskMarker": [ + "changed in Vikunja", + "not created by the stack", + "conflict", + "missing: moved", + "not-found", + "no-access" + ] + }, + "mark": { + "status": "placeholder (Jason 2026-10-10: use it for now, revise later)", + "file": "docs/design/brand/relay-refined.svg", + "viewBox": "0 0 16 17", + "fill": "action" + } +} diff --git a/docs/design/tools/build-tokens.mjs b/docs/design/tools/build-tokens.mjs new file mode 100644 index 00000000..f2cc62ab --- /dev/null +++ b/docs/design/tools/build-tokens.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +// Generates docs/design/tokens.css from docs/design/tokens.json. +// Usage: node docs/design/tools/build-tokens.mjs [--check] +// --check exits 1 when tokens.css is out of date instead of writing it. +// +// Selector contract for consumers: +// no attributes default palette, light or dark from prefers-color-scheme +// data-mode="light|dim|dark" explicit mode (packages/webui already sets data-mode) +// data-palette="<id>" any non-default palette +// Property names match packages/webui/src/public/shared/app.css and brand.js +// (--onAction stays camelCase there), so this file can replace the inline +// setProperty theming without renaming any var() use. +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const src = join(here, '..', 'tokens.json'); +const out = join(here, '..', 'tokens.css'); +const t = JSON.parse(readFileSync(src, 'utf8')); + +const strong = (mode) => mode === 'light' + ? 'color-mix(in srgb, var(--text) 72%, #000)' + : 'color-mix(in srgb, var(--text) 70%, #fff)'; +const colors = (modes, mode) => Object.entries(modes[mode]).map(([k, v]) => ` --${k}: ${v};`) + .concat(` --strong: ${strong(mode)};`, ` color-scheme: ${mode === 'light' ? 'light' : 'dark'};`).join('\n'); +const block = (sel, body) => `${sel} {\n${body}\n}`; +const media = (sel, body) => `@media (prefers-color-scheme: dark) {\n ${sel} {\n${body.replace(/^/gm, ' ')}\n }\n}`; + +const def = t.palettes.find((p) => p.id === t.default.palette); +if (!def) { console.error(`default palette ${t.default.palette} is not in tokens.json`); process.exit(2); } + +const shared = [ + ` --font: ${t.font.sans.stack};`, + ` --mono: ${t.font.mono.stack};`, + ` --r: ${t.shape.radius.control}px;`, + ` --r-lg: ${t.shape.radius.panel}px;`, + ` --r-table: ${t.shape.radius.table}px;`, + ` --pad: ${t.space.gutter}px;`, + ` --gap: ${t.space.gap}px;`, +].join('\n'); + +const parts = [ + `/* Generated from tokens.json by tools/build-tokens.mjs. Do not edit by hand. */`, + `/* Fonts: ${t.font.sans.family} and ${t.font.mono.family}. ${t.font.source} */`, + block(':root', shared), + `/* ${def.name}: the default palette */`, + block(':root', colors(def.modes, 'light')), + media(':root:not([data-mode="light"]):not([data-mode="dim"])', colors(def.modes, 'dark')), + block(':root[data-mode="dark"]', colors(def.modes, 'dark')), + block(':root[data-mode="dim"]', colors(def.modes, 'dim')), +]; +for (const p of t.palettes) { + if (p.id === def.id) continue; + const s = `:root[data-palette="${p.id}"]`; + parts.push(`/* ${p.name} */`, + block(s, colors(p.modes, 'light')), + media(`${s}:not([data-mode="light"]):not([data-mode="dim"])`, colors(p.modes, 'dark')), + block(`${s}[data-mode="dark"]`, colors(p.modes, 'dark')), + block(`${s}[data-mode="dim"]`, colors(p.modes, 'dim'))); +} +const css = parts.join('\n\n') + '\n'; + +if (process.argv.includes('--check')) { + let cur = ''; + try { cur = readFileSync(out, 'utf8'); } catch {} + if (cur !== css) { console.error('tokens.css is out of date; run node docs/design/tools/build-tokens.mjs'); process.exit(1); } + console.log('tokens.css is current'); +} else { + writeFileSync(out, css); + console.log(`wrote ${out} (${t.palettes.length} palettes x ${t.modes.length} modes)`); +} diff --git a/docs/design/tools/check-design.mjs b/docs/design/tools/check-design.mjs new file mode 100644 index 00000000..7d46d3b9 --- /dev/null +++ b/docs/design/tools/check-design.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +// Browser checks for the design preview (docs/design/mosaic-stack-design.html). +// Usage: node docs/design/tools/check-design.mjs [page.html] +// Needs a local Chromium: $CHROMIUM, else /usr/bin/chromium. No dependencies. +// Exit 0 when every check passes, 1 when any fails, 2 when the browser cannot start. +// +// Checks: +// 1. no script errors while visiting every tab, console view, preview state, +// inspector, terminal screen, palette and mode; +// 2. no sideways page scroll at 400 px on any tab or console view; +// 3. terminal screens are exactly 120 columns (80 for the compact screen) and +// no taller than 36 rows (24 for compact); shell screens stay within 120; +// 4. the preview's palette colours equal tokens.json for every palette and mode. +// The CDP client follows packages/webui/tests/browser.mjs. +import { spawn } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const page = resolve(process.argv[2] || join(here, '..', 'mosaic-stack-design.html')); +const tokens = JSON.parse(readFileSync(join(here, '..', 'tokens.json'), 'utf8')); + +async function browser() { + const profile = await mkdtemp(join(tmpdir(), 'mosaic-design-check-')); + const child = spawn(process.env.CHROMIUM || '/usr/bin/chromium', [ + '--headless', '--no-first-run', '--no-default-browser-check', + '--disable-dev-shm-usage', '--remote-debugging-pipe', `--user-data-dir=${profile}`, + ], { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] }); + const started = new Promise((ok, fail) => { child.once('error', fail); child.once('spawn', ok); }); + await started; + let id = 0, buffer = ''; + const pending = new Map(), errors = []; + child.stdio[4].on('data', (chunk) => { + buffer += chunk.toString(); + let end; + while ((end = buffer.indexOf('\0')) !== -1) { + const m = JSON.parse(buffer.slice(0, end)); + buffer = buffer.slice(end + 1); + if (m.method === 'Runtime.exceptionThrown') errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text); + if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') errors.push(m.params.args.map((a) => a.value ?? a.description).join(' ')); + if (pending.has(m.id)) { + const { ok, fail, timer } = pending.get(m.id); + clearTimeout(timer); + pending.delete(m.id); + m.error ? fail(new Error(JSON.stringify(m.error))) : ok(m.result); + } + } + }); + const send = (method, params = {}, sessionId) => new Promise((ok, fail) => { + const key = ++id; + const timer = setTimeout(() => { pending.delete(key); fail(new Error(`CDP timeout: ${method}`)); }, 25000); + pending.set(key, { ok, fail, timer }); + child.stdio[3].write(JSON.stringify({ id: key, method, params, ...(sessionId ? { sessionId } : {}) }) + '\0'); + }); + const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); + const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); + const call = (method, params) => send(method, params, sessionId); + await call('Page.enable'); + await call('Runtime.enable'); + const evaluate = async (expression) => { + const r = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); + if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ': ' + r.result.description); + return r.result.value; + }; + return { + errors, call, evaluate, + viewport: (width, height = 900) => call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }), + scheme: (value) => call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value }] }), + async navigate(url) { + const r = await call('Page.navigate', { url }); + if (r.errorText) throw new Error(r.errorText); + for (let n = 0; n < 50 && !(await evaluate('document.readyState === "complete"')); n++) await new Promise((ok) => setTimeout(ok, 100)); + }, + async close() { + await send('Browser.close').catch(() => {}); + if (child.exitCode === null) await new Promise((ok) => child.once('exit', ok)); + await rm(profile, { recursive: true, force: true }); + }, + }; +} + +const results = []; +const check = (name, failures) => { + results.push({ name, failures }); + console.log(`${failures.length ? 'FAIL' : 'pass'} ${name}`); + for (const f of failures.slice(0, 12)) console.log(` ${f}`); + if (failures.length > 12) console.log(` ... ${failures.length - 12} more`); +}; + +let b; +try { b = await browser(); } catch (err) { + console.error(`cannot start Chromium (${process.env.CHROMIUM || '/usr/bin/chromium'}): ${err.message}`); + process.exit(2); +} +try { + await b.viewport(1280); + await b.scheme('light'); + await b.navigate(pathToFileURL(page).href); + await b.evaluate('localStorage.clear(), true').catch(() => {}); + + const tabs = await b.evaluate('[...document.querySelectorAll("[id^=tab-]")].map(e => e.id.slice(4))'); + const views = await b.evaluate('(document.querySelector("#tab-console").click(), [...document.querySelectorAll("#side [data-go]")].map(e => e.dataset.go))'); + const states = await b.evaluate('[...document.querySelectorAll("#stateSel option")].map(o => o.value)'); + const screens = await b.evaluate('(document.querySelector("#tab-terminal").click(), [...document.querySelectorAll("#screenList [data-scr]")].map(e => e.dataset.scr))'); + const nav = (v) => `document.querySelector('#side [data-go="${v}"]').click()`; + const setState = (s) => `(()=>{const e=document.querySelector("#stateSel");e.value=${JSON.stringify(s)};e.dispatchEvent(new Event("change",{bubbles:true}))})()`; + + // 1. Script errors across every view, state, inspector and screen. + const before = b.errors.length; + for (const t of tabs) await b.evaluate(`document.querySelector("#tab-${t}").click(), true`); + await b.evaluate('document.querySelector("#tab-console").click(), true'); + for (const s of states) { + await b.evaluate(`${setState(s)}, true`); + for (const v of views) { + await b.evaluate(`${nav(v)}, true`); + await b.evaluate('(()=>{const r=document.querySelector("#view tbody tr");if(r)r.click();document.dispatchEvent(new KeyboardEvent("keydown",{key:"Escape",bubbles:true}))})(), true'); + } + } + await b.evaluate(`${setState('normal')}, true`); + await b.evaluate('document.querySelector("#tab-terminal").click(), true'); + for (const s of screens) await b.evaluate(`document.querySelector('#screenList [data-scr="${s}"]').click(), true`); + check(`no script errors (${tabs.length} tabs, ${views.length} views x ${states.length} states, ${screens.length} terminal screens)`, b.errors.slice(before)); + + // 3. Terminal widths and heights. + const termFails = []; + for (const s of screens) { + const lines = await b.evaluate(`(document.querySelector('#screenList [data-scr="${s}"]').click(), document.querySelector("#term").innerText.split("\\n"))`); + const shell = await b.evaluate('document.querySelector("#termTitle").textContent.endsWith("shell")'); + const compact = await b.evaluate('document.querySelector("#termTitle").textContent.endsWith("80×24")'); + const [w, h] = compact ? [80, 24] : [120, 36]; + lines.forEach((l, i) => { + const n = [...l].length; + if (shell ? n > w : n !== w) termFails.push(`${s} line ${i + 1}: ${n} columns, expected ${shell ? 'at most ' : ''}${w}`); + }); + if (!shell && lines.length > h) termFails.push(`${s}: ${lines.length} rows, expected at most ${h}`); + } + check(`terminal screens fit 120x36 and 80x24 (${screens.length} screens)`, termFails); + + // 2. No sideways scroll at 400 px. + await b.viewport(400, 860); + const wide = []; + const overflow = async (label) => { + const r = await b.evaluate('({sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth})'); + if (r.sw > r.cw) wide.push(`${label}: page is ${r.sw}px wide in a ${r.cw}px viewport`); + }; + for (const t of tabs) { await b.evaluate(`document.querySelector("#tab-${t}").click(), true`); await overflow(`tab ${t}`); } + await b.evaluate('document.querySelector("#tab-console").click(), true'); + for (const v of views) { + await b.evaluate(`${nav(v)}, true`); + await overflow(`view ${v}`); + await b.evaluate('(()=>{const r=document.querySelector("#view tbody tr");if(r)r.click()})(), true'); + await overflow(`view ${v} with inspector`); + await b.evaluate('document.dispatchEvent(new KeyboardEvent("keydown",{key:"Escape",bubbles:true})), true'); + } + check(`no sideways scroll at 400 px (${tabs.length} tabs, ${views.length} views)`, wide); + await b.viewport(1280); + + // 4. Palettes and modes match tokens.json. + const colourFails = []; + const before4 = b.errors.length; + const modes = await b.evaluate('[...document.querySelectorAll("#modeSeg button")].map(e => e.dataset.mode)'); + for (const p of tokens.palettes) { + await b.evaluate(`(()=>{const e=document.querySelector("#palSel");e.value=${JSON.stringify(p.id)};e.dispatchEvent(new Event("change",{bubbles:true}))})(), true`); + for (const m of modes) { + for (const scheme of m === 'system' ? ['light', 'dark'] : ['light']) { + await b.scheme(scheme); + await b.evaluate(`document.querySelector('#modeSeg [data-mode="${m}"]').click(), true`); + const want = p.modes[m === 'system' ? scheme : m]; + const got = await b.evaluate('(()=>{const s=getComputedStyle(document.documentElement);return {canvas:s.getPropertyValue("--canvas").trim(),action:s.getPropertyValue("--action").trim(),text:s.getPropertyValue("--text").trim(),danger:s.getPropertyValue("--danger").trim()}})()'); + for (const k of Object.keys(got)) { + if (got[k].toLowerCase() !== String(want[k]).toLowerCase()) colourFails.push(`${p.id} ${m}${m === 'system' ? `/${scheme}` : ''} --${k}: page ${got[k]}, tokens.json ${want[k]}`); + } + } + } + } + await b.scheme('light'); + check(`palette colours match tokens.json (${tokens.palettes.length} palettes x ${modes.length} modes)`, colourFails.concat(b.errors.slice(before4))); +} finally { + await b.close(); +} +const failed = results.filter((r) => r.failures.length).length; +console.log(failed ? `${failed} of ${results.length} checks failed` : `all ${results.length} checks passed`); +process.exit(failed ? 1 : 0);