docs(design): handoff package for implementing the design

Jason accepted the draft 1 direction on 2026-10-10; the refined Relay
mark stays as a placeholder. Adds IMPLEMENTING.md (gap map against
row 40 and the bus/CLI modules, strings, boundaries, acceptance,
proposed rows for Sage), tokens.json and generated tokens.css using the
webui's existing names, icons.svg, and tools/build-tokens.mjs and
tools/check-design.mjs. Coverage statuses corrected against the code
(digest implemented; mosaic stop is row 41; priority and agent state
not in the bus reads). No packages/ change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-10 11:22:06 -05:00
co-authored by Claude Opus 5.5
parent d40a35393b
commit 811e7ba562
10 changed files with 2323 additions and 30 deletions
+39
View File
@@ -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.
+1
View File
@@ -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
+324
View File
@@ -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="<id>"` picks any other palette. The webui already sets
both attributes on `<html>`.
- **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 `<svg><use href="/icons.svg#i-inbox"/></svg>`. 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[/<id>]`, `#/tasks[/<ref>]`, `#/agents`,
`#/trail/task/<ref>`, `#/trail/decision/<id>` (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: <key> (<text>); this authorizes the action`, or
`…; this declines the action`;
- `resolve <id8> with <key>? [y/N] `;
- `resolved <full id>: <key>`;
- `outcome unknown: the broker may have recorded it. Check mosaic inbox or mosaic trail <id> before trying again`.
- **Refusal:** `refused: this runs only from a human shell, outside any agent run (found <VARS> in the environment)`.
- **Web copy:**
- `Copied: <command>`;
- `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 `<id8> <action> (<class>[, blocking]) raised <at> by <role>`;
- tasks `<ref> <state> <title>`;
- 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.
+39 -20
View File
@@ -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.
+23
View File
@@ -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>

After

Width:  |  Height:  |  Size: 4.8 KiB

+12 -10
View File
@@ -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"],
+813
View File
@@ -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;
}
+814
View File
@@ -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"
}
}
+72
View File
@@ -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)`);
}
+186
View File
@@ -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);