DESIGN.md · diff

git:20260813.fa58e4a to git:20260816.b9ceace

243 added, 140 removed. Audit A to A.

# MeMesh Design System
The dashboard's visual language, written down.
- `CLAUDE.md` has instructed every contributor to "read DESIGN.md before making
- any visual or UI decision" and to "flag any code that doesn't match DESIGN.md
- in QA mode" — while no such file existed. So the rule could not be followed and
- could not be checked, and the drift it was meant to prevent happened anyway:
- `--font-mono` was used in five places and is not a token (the palette defines
- `--mono`), so those elements silently fell back to the browser's default
- monospace or to the body sans-serif. Palette values were hardcoded where a
- token existed.
-
Everything below is derived from `dashboard/src/styles/global.css`, which
remains the implementation. When they disagree, the CSS is what ships — fix
whichever is wrong, do not leave them apart.
---
- ## Direction: Precision Engineer
+ ## Direction: VIVARIUM
- Chosen deliberately over two alternatives (a warm serif "Neural Organic"
- direction and a green-phosphor "Retro Terminal" one). The product stores things
- people cannot afford to lose, so the interface trades personality for the
- appearance of reliability.
+ A nocturnal glass vivarium. You are not operating an instrument panel; you
+ are looking in on a living second brain that grows while you are away. What
+ glows is alive. What is sealed in amber was preserved by a human hand. Total
+ stillness is the alarm.
- Practically that means: no decoration that does not carry information, no
- gradients as ornament, no rounded-friendly shapes, no illustration. Dense,
- quiet, and legible.
+ Chosen 2026-08-16, superseding "Precision Engineer" (2026-04). The
+ discipline survives the direction change: decoration that carries no
+ information does not appear. What changed is what counts as information —
+ this product's memory is grown by machines while the human is away, so
+ recency, capture activity and liveness are first-class data, and the
+ interface renders them as light and motion. Three laws:
- **Gradients carry information or they do not appear.** A gradient is allowed
- only when the gradient *is* the data: the Graph drift legend (stale red → fresh
- green is the scale it explains) and the nav-overflow fade (the fade *is* the
- "more, scroll right" affordance). A gradient used as a card background is
- ornament — flatten it to `--accent-soft`. Three banners shipped decorative
- `linear-gradient` fills (`MemoryLoopCard`, `InsightsBanner`, `OnboardingBanner`)
- and were flattened.
+ **Gradients carry information or they do not appear.** (Unchanged.) A
+ gradient is allowed only when the gradient *is* the data: the Graph drift
+ legend (stale red → fresh green is the scale it explains) and the
+ nav-overflow fade (the fade *is* the "more, scroll right" affordance).
- **Canvas cannot read a token.** `ctx.fillStyle = 'var(--accent)'` is invalid and
- silently draws black. The two `<canvas>` renderers resolve the tokens they need
- from the live stylesheet via `getComputedStyle` and draw with the resolved
- values — `GraphTab` once at mount, `MemoryTimeline` per draw (its
- `ResizeObserver` redraws on resize and tab-reveal) — so a palette change still
- reaches the canvas. Never hardcode a palette hex into a canvas draw call; if
- `getComputedStyle` returns empty (no stylesheet, e.g. a test), that is a visible
- signal, not a value to paper over with a literal fallback.
+ **Luminance carries information or it does not appear.** Brightness and
+ glow encode vitality — recency and liveness. Nothing else may glow. This is
+ also why the night is neutral: if the ground, text and borders were all
+ green-tinted, green would stop carrying signal (the first draft of this
+ direction failed exactly that way — "too green").
+ **Every moving pixel tells a truth.** No idle or random animation. Every
+ animation is driven by a real event or real state, and the same data always
+ replays the same way. The single sanctioned idle animation is the heartbeat
+ (below), and it is honest: it runs only while the dashboard is actually
+ connected.
+
+ **Canvas cannot read a token.** `ctx.fillStyle = 'var(--life)'` is invalid
+ and silently draws black. The two `<canvas>` renderers resolve the tokens
+ they need from the live stylesheet via `getComputedStyle` and draw with the
+ resolved values — `GraphTab` once at mount, `MemoryTimeline` per draw — so
+ a palette change still reaches the canvas. Never hardcode a palette hex
+ into a canvas draw call; if `getComputedStyle` returns empty (no
+ stylesheet, e.g. a test), that is a visible signal, not a value to paper
+ over with a literal fallback.
+
---
## Tokens
**Never write a literal where a token exists.** A literal is invisible to a
- palette change and cannot be checked. If a value is missing from this list, add
- a token rather than a literal.
+ palette change and cannot be checked. If a value is missing from this list,
+ add a token rather than a literal.
- ### Surfaces
+ ### Surfaces — the neutral night
| Token | Value | Use |
|---|---|---|
- | `--bg-0` | `#080A0C` | Page background |
- | `--bg-1` | `#0D1014` | Panels |
- | `--bg-2` | `#14181D` | Raised surfaces |
- | `--bg-card` | `rgba(20, 24, 29, 0.9)` | Cards over the page |
- | `--bg-hover` | `#1A1F26` | Hover state |
- | `--bg-input` | `#0D1014` | Form fields |
+ | `--bg-0` | `#09090A` | Page / substrate |
+ | `--bg-1` | `#0F0F11` | Panels |
+ | `--bg-2` | `#151518` | Raised surfaces |
+ | `--bg-card` | `rgba(15, 15, 17, 0.9)` | Cards over the page |
+ | `--bg-hover` | `#1B1B1F` | Hover state |
+ | `--bg-input` | `#0F0F11` | Form fields |
- ### Text
+ ### Text — neutral, slightly cool
| Token | Value | Use |
|---|---|---|
- | `--text-0` | `#F0F2F4` | Primary |
- | `--text-1` | `#B8BEC6` | Secondary |
- | `--text-2` | `#7A828E` | Muted / labels |
- | `--text-3` | `#4A5260` | Disabled / hints |
+ | `--text-0` | `#EDEDEE` | Primary |
+ | `--text-1` | `#B4B5B7` | Secondary |
+ | `--text-2` | `#7E8083` | Muted / labels |
+ | `--text-3` | `#4B4D51` | Disabled / hints |
- ### Accent and status
+ ### Life, amber, and status
- | Token | Value | Use |
+ | Token | Value | Meaning |
|---|---|---|
- | `--accent` | `#00D6B4` | The single accent. Actions, focus, active state |
- | `--accent-hover` | `#00F0CA` | Accent hover |
- | `--accent-soft` | 8% accent | Accent fills |
- | `--success` | `#00D6B4` | Same as accent by design — success is the normal state |
- | `--danger` | `#FF6B6B` | Errors, destructive actions |
- | `--warning` | `#FFB84D` | Needs attention, not broken |
- | `--info` | `#60A5FA` | Neutral information |
- | `--neutral-soft` | 10% grey | "No signal" / unknown status fill |
+ | `--life` | `#8FF25C` | Bioluminescent green. Alive, machine-grown, recently touched. Actions, focus, active state |
+ | `--life-hover` | `#A9FF75` | Life hover |
+ | `--life-soft` | 8% life | Life fills (pair rule below) |
+ | `--amber` | `#E4C590` | The human seal: pinned, human-judged, human-preserved. Rings and small labels ONLY — never large fills, never body text |
+ | `--amber-soft` | 8% amber | The one amber fill: seal-label backgrounds |
+ | `--success` | = `--life` | Success is the normal state, by design |
+ | `--danger` | `#FF7A6B` | Errors, destructive actions |
+ | `--warning` | `#FFAB40` | Needs attention, not broken. Separated from amber by BOTH chroma and channel: warning is high-chroma and appears as fills/badges; amber is low-chroma and appears as rings/labels. The two never share a channel |
+ | `--info` | `#6FB7D9` | Neutral information |
+ | `--neutral-soft` | 10% grey | "No signal" / unknown status fill — deliberately grey; "unknown" must not read as alive |
- Each status colour has a `-soft` variant at 8% for backgrounds. Use the pair
- for **fills**, never a hand-rolled `rgba()` of the same hue.
+ Each status colour has a `-soft` variant at 8% for backgrounds. Use the
+ pair for **fills**, never a hand-rolled `rgba()` of the same hue.
- `--neutral-soft` is deliberately grey rather than accent-tinted: a "no signal"
- or "unknown" status must not read as the accent.
+ **Hover glows and elevation shadows** are the one place a hand-rolled life
+ `rgba()` is allowed — `box-shadow`/`border-color` at 15–40% alpha, where no
+ 8% `-soft` token fits the intent. The pair rule governs solid fills; a glow
+ is not a fill.
- **Hover glows and elevation shadows** are the one place a hand-rolled accent
- `rgba()` is allowed — `box-shadow`/`border-color` at 15–40% alpha, where no 8%
- `-soft` token fits the intent. The pair rule governs solid fills; a glow is not
- a fill. These already ship in `global.css` (`.card:hover`, `.stat:hover`,
- `.dot-ok`, `mark`) and stay.
+ ### Borders — neutral, so green can mean something
- **The graph earns visibility; it does not distribute it.** Uniform brightness
- carries no information — a graph where every edge is drawn at the same alpha
- is a hairball, and a graph where no node is named until hover cannot be read.
- The renderer therefore ranks: a small backbone of the highest-traffic edges
- (≤128, ≤5 per node) draws readable while the rest recede (and are
- deterministically sampled on dense graphs) — re-picked per view, so a
- filtered or ego neighbourhood keeps a bright skeleton instead of falling
- entirely to the faint layer; node labels follow a zoom-tiered budget
- (3/12/28) allocated by traffic-then-recency over the nodes actually in view,
- at constant screen size regardless of zoom; node radii stay inside a tight
- 3.5–9px band so hubs read as bigger without dominating (ranking uses the raw
- recall counts, never the clamped radius); each connected node gets a rim in
- its own hue stepped darker (category restated at the boundary — not
- decoration; orphans keep their dashed boundary); and label text is stroked
- in `--bg-0` — the canvas's own background — before filling so it stays
- legible over nodes (legibility is information, not a glow). Initial
- positions are seeded per type on a golden-angle spiral with name-hash
- jitter, slotted by name order rather than response order — the same data
- draws the same shape on every visit, and the simulation relaxes instead of
- untangling. What was deliberately NOT adopted from graph tools that look
- good (vignettes, ambient glows, background grids): ornament that carries no
- information stays out, per the direction above.
+ `--border` (`rgba(255,255,255,.08)`), `--border-subtle`
+ (`rgba(255,255,255,.04)`), `--border-focus` (solid `--life`). This reverses
+ the previous system's accent-tinted borders on purpose: when every hairline
+ is green, a green glow is noise; when the night is neutral, a green edge is
+ a statement. The focus ring is that statement.
- **Entity-type colours are a separate categorical palette.** The graph and its
- legend colour nodes by type across ~14 types; a single accent cannot encode a
- category. Those hues live in `dashboard/src/lib/type-palette.ts` as literals on
- purpose — they map to no token, so a token could not express them. The five
- that *do* coincide with a token (`decision`/`concept` = accent, `pattern` =
- info, `lesson_learned` = warning, `session-insight` = text-2) are resolved from
- the tokens at runtime, not written as literals, so a palette change reaches
- them.
+ ### Species palette — one formula, not fourteen magic numbers
- ### Borders
+ Entity-type colours are a separate categorical palette: the graph and its
+ legend colour nodes by type, and a single accent cannot encode a category.
+ They live in `dashboard/src/lib/type-palette.ts` and every value is the
+ output of ONE formula — `oklch(0.78 0.12 H) → hex` — with the hue recorded
+ beside each entry. Equal lightness and chroma mean no species shouts over
+ another. Anchors: `lesson_learned` 85 (earthy — amber differs by chroma),
+ `concept` 200, `pattern`/`technical_pattern` 230; the rest are spaced ≥25°
+ apart with the life band (~135°) left free. The values are written down
+ (rather than computed at runtime) because `tests/dashboard-i18n.test.ts`
+ parses that block as the type vocabulary.
- `--border` (8% accent), `--border-subtle` (4% accent), `--border-focus`
- (solid accent). Borders are tinted with the accent rather than grey — it is
- what makes the surface separation read as deliberate rather than as a default.
+ The two types that coincide with a token are NOT in the palette file:
+ `decision` = `--life` (decisions are this brain's main produce) and
+ `session-insight` = `--text-2` (weak signal stays grey). GraphTab resolves
+ those from the tokens at runtime, so a palette change reaches them.
- ### Type
+ **Hue encodes species; luminance encodes vitality. The two channels never
+ compete.** **Amber is a state, not a species**: a pinned node keeps its
+ species fill and gains an amber ring/seal. Who grew it (species) and who
+ preserved it (human) are two questions answered on two channels.
- | Token | Value |
- |---|---|
- | `--font` | `'Satoshi', -apple-system, system-ui, sans-serif` |
- | `--mono` | `'Geist Mono', 'JetBrains Mono', ui-monospace, monospace` |
+ ### Type — three voices
- There is **no `--font-mono`**. Referencing one silently produces the fallback
- in `var(--font-mono, monospace)`, or nothing at all in `var(--font-mono)` —
- which is how four stat numbers rendered in Satoshi while every other stat in
- the app used Geist Mono.
+ | Token | Stack | Role |
+ |---|---|---|
+ | `--font-ui` | `'Bricolage Grotesque', -apple-system, system-ui, sans-serif` | UI chrome: labels, nav, buttons, table heads. Variable font — its optical-size axis keeps 11px labels sturdy while headings grow character |
+ | `--font-memory` | `'Newsreader', 'Songti TC', Georgia, serif` | Memory content itself (`.mem-preview` and successors): observations, titles. Memories are read, not scanned. 15px/1.6 — a serif needs the extra pixel to hold the same optical size. CJK falls back to the system serif |
+ | `--mono` | `'Geist Mono', 'JetBrains Mono', ui-monospace, monospace` | Anything the user might compare digit by digit: counts, scores, IDs, timestamps, tokens, file paths |
- Use `--mono` for anything the user might compare digit by digit: counts,
- scores, IDs, timestamps, tokens, file paths.
+ **Italic is epistemology.** In the memory voice, roman = captured verbatim;
+ italic = machine-inferred or condensed (dreamer output, auto-summaries).
+ The slant marks "testimony vs. conjecture" in the letterform itself. Never
+ use italic decoratively in memory content. (The italic wiring arrives with
+ the provenance field; the rule is constitutional now so no component ships
+ a decorative italic in the meantime.)
+ Fonts load via Google Fonts `<link>` in `dashboard/index.html`; offline,
+ the fallback stacks carry the page. There is no `--font` and no
+ `--font-mono` — referencing either silently produces a fallback font.
Base size is 14px on `html`.
### Radius
- `--radius` 8px, `--radius-sm` 6px, `--radius-xs` 4px, `--radius-hairline` 2px
- for data-viz bars, tracks and 1–3px decorations (they collapse to 2px — a 1px
- difference is invisible at that scale, and one token beats three magic numbers).
+ `--radius` 8px, `--radius-sm` 6px, `--radius-xs` 4px, `--radius-hairline`
+ 2px for data-viz bars, tracks and 1–3px decorations.
- Write the token, never the number: an inline `borderRadius: 6` is invisible to a
- scale change exactly as a colour literal is.
+ Write the token, never the number: an inline `borderRadius: 6` is invisible
+ to a scale change exactly as a colour literal is.
**Two shapes are rounder, on purpose, and are sanctioned:**
- **Pills** (`border-radius: 9999px`) for status badges (`.badge`) and the
- feedback FAB (`.fb-btn`). The pill shape signals "tag / floating action"; it
- is established in the shipped UI, and the CSS is the implementation. This
- overrides "nothing rounder" for these two shapes only — everywhere else uses
- the scale.
- - **Circles** (`border-radius: 50%`) for genuinely round elements: the status
- `.dot` and the `.loading` spinner.
+ feedback FAB (`.fb-btn`). This overrides "nothing rounder" for these two
+ shapes only — everywhere else uses the scale.
+ - **Circles** (`border-radius: 50%`) for genuinely round elements: the
+ status `.dot` and the `.loading` spinner.
+ ### Motion tokens
+
+ | Token | Value | Use |
+ |---|---|---|
+ | `--t-heartbeat` | `3200ms` | Header liveness beat |
+ | `--t-germinate` | `450ms` | Single new-memory reveal |
+ | `--t-replay-max` | `2500ms` | Full germination replay budget |
+ | `--t-pulse` | `600ms` | One recall travelling one graph edge |
+
---
+ ## The living mechanics
+
+ Each mechanic encodes real state; none is ornament. **Shipped now:** the
+ heartbeat, the serif memory voice, the species palette, the neutral night.
+ **Arrives with the UX arc** (each in its own PR, in this order): vitality
+ fade, bud marks, germination replay, mycelium pulse.
+
+ 1. **Heartbeat** *(shipped)* — the header status dot breathes
+ (`--t-heartbeat`, ease-in-out glow) only while `/v1/health` answers;
+ disconnected renders a perfectly still error dot. Stillness IS the
+ alarm; no red banner needs to fire first. Mapping the breath rate to
+ capture-event frequency is the intended end state; the first
+ implementation breathes at a fixed rate.
+ 2. **Vitality fade** — every memory row and graph node will carry
+ `--vitality: 0–1`, computed from the SAME freshness curve the recall
+ engine uses for scoring, so the light in the UI and the score in the
+ ranking are one number, not two truths. Scale: <1h full species colour +
+ glow → <1d faint glow → <1w chroma −30% → <30d chroma −60% → >90d grey
+ litter (faded, never hidden). Glow must never push text below WCAG AA
+ ("green never on green": body text stays on the neutral text layers).
+ 3. **Bud marks** — a 2px `--life` dot on rows/nodes created since the
+ user's last visit (`last_seen` watermark), cleared on first sight.
+ 4. **Germination replay** — on open, memories captured since the last
+ visit stagger in (`--t-germinate` each, ≤`--t-replay-max` total) in true
+ capture order, once, deterministically. Same data, same replay.
+ 5. **Mycelium pulse** — when a real recall event fires, one light dot
+ travels the graph edge (`--t-pulse`). An idle graph is still.
+
+ `prefers-reduced-motion`: every mechanic degrades to a static mark (glow,
+ dot, weight) — information kept, motion gone.
+
+ ---
+
+ ## Component patterns
+
+ Normative for the tab-consolidation arc; existing components migrate as
+ they are touched.
+
+ - **Chip** (shared): token-only styling. Inactive: `--border` border,
+ `--text-2` text, transparent fill. Active: `--life` border, `--life-soft`
+ fill, `--life` text. Radius `--radius`, 11px `--font-ui`, count in
+ `--mono`. `aria-pressed` always.
+ - **Composition bar**: cluster segments coloured by the species formula,
+ height 6px, `--radius-hairline`, on a `--bg-0` track. The bar itself is
+ `aria-hidden`; its legend chips are the interactive, keyboard-reachable
+ targets.
+ - **Expander row**: a chevron button with `aria-expanded` +
+ `aria-controls`; the body renders lazily on first expand.
+
+ ## The graph earns visibility; it does not distribute it.
+
+ Uniform brightness carries no information — a graph where every edge is
+ drawn at the same alpha is a hairball, and a graph where no node is named
+ until hover cannot be read. The renderer therefore ranks: a small backbone
+ of the highest-traffic edges (≤128, ≤5 per node) draws readable while the
+ rest recede (and are deterministically sampled on dense graphs) — re-picked
+ per view, so a filtered or ego neighbourhood keeps a bright skeleton
+ instead of falling entirely to the faint layer; node labels follow a
+ zoom-tiered budget (3/12/28) allocated by traffic-then-recency over the
+ nodes actually in view, at constant screen size regardless of zoom; node
+ radii stay inside a tight 3.5–9px band so hubs read as bigger without
+ dominating (ranking uses the raw recall counts, never the clamped radius);
+ each connected node gets a rim in its own hue stepped darker (category
+ restated at the boundary — not decoration; orphans keep their dashed
+ boundary); and label text is stroked in `--bg-0` — the canvas's own
+ background — before filling so it stays legible over nodes (legibility is
+ information, not a glow). Initial positions are seeded per type on a
+ golden-angle spiral with name-hash jitter, slotted by name order rather
+ than response order — the same data draws the same shape on every visit,
+ and the simulation relaxes instead of untangling. What was deliberately NOT
+ adopted from graph tools that look good (vignettes, ambient glows,
+ background grids): ornament that carries no information stays out, per the
+ direction above.
+
+ ---
+
## Accessibility
These are requirements, not preferences.
- **Every error message needs a live region.** `role="alert"` or
- `aria-live="assertive"`. Text inserted into the DOM without one is announced
- to nobody — the auth screen told a screen-reader user nothing at all when
- their token was empty or wrong.
+ `aria-live="assertive"`. Text inserted into the DOM without one is
+ announced to nobody.
- **Associate errors with their field**: `aria-describedby` pointing at the
message, plus `aria-invalid` on the input.
- **Focus the obvious field on mount** when a screen has exactly one, and
especially when the user did not choose to be there (the auth screen is
reached by an involuntary 401).
- - **Every state must be reachable and visible by keyboard.** Focus rings use
- `--border-focus`; do not remove them.
- - **Do not rely on `required` for validation you also implement.** The browser
- blocks submission before the handler runs, which makes the handler's own
- message unreachable — a dead branch that reads as a safety net.
+ - **Every state must be reachable and visible by keyboard.** Focus rings
+ use `--border-focus`; do not remove them.
+ - **Do not rely on `required` for validation you also implement.** The
+ browser blocks submission before the handler runs, which makes the
+ handler's own message unreachable.
+ - **Motion degrades.** Everything under "The living mechanics" has a
+ `prefers-reduced-motion` static form.
---
+ ## Anti-goals (constitutional)
+
+ - **No leaves.** No plant illustration, particles, mascots. The metaphor
+ lives in light, type and behaviour; draw a leaf and it becomes a toy.
+ - **No lying liveness.** No random idle animation, ever. "Looking busy" is
+ the one sin an honest-memory product cannot afford.
+ - **No gamification.** No streaks, badges-as-achievements, scores for the
+ human. The brain is not a pet game.
+ - **Single dark theme.** The vivarium is nocturnal. The token sheet stays
+ complete (capability), the commitment is one look.
+
+ ---
+
## Copy
Follows the same rule as error messages elsewhere in the project: say what
- happened and what the reader can do. "That token was not accepted. Check it and
- try again." — not "Authentication error".
+ happened and what the reader can do. "That token was not accepted. Check it
+ and try again." — not "Authentication error".
- All user-facing strings go through `t()` in `dashboard/src/lib/i18n.ts`, in all
- 11 locales. **Never write `t('x') || 'English fallback'`**: `t()` returns the
- key string on a miss, which is truthy, so the right-hand branch is unreachable.
- It reads as a safety net and is not one. The real fallback chain is inside
- `t()`: locale → English → key.
+ All user-facing strings go through `t()` in `dashboard/src/lib/i18n.ts`, in
+ all 11 locales. **Never write `t('x') || 'English fallback'`**: `t()`
+ returns the key string on a miss, which is truthy, so the right-hand branch
+ is unreachable. The real fallback chain is inside `t()`: locale → English →
+ key.
- `tests/dashboard-i18n.test.ts` fails the build when a statically-written key is
- missing from the English catalogue, and when a locale drifts from English. It
- cannot see template-literal keys (`` t(`radar.axis.${axis}`) ``) — those need a
- fixed-key-set assertion if you add more.
+ `tests/dashboard-i18n.test.ts` fails the build when a statically-written
+ key is missing from the English catalogue, and when a locale drifts from
+ English. It cannot see template-literal keys (`` t(`radar.axis.${axis}`) ``)
+ — those need a fixed-key-set assertion if you add more.
+
+ ## Decisions log
+
+ | Date | Decision | Rationale |
+ |---|---|---|
+ | 2026-04-18 | Precision Engineer direction (Satoshi, cyan `#00D6B4`, zero decoration) | The product stores things people cannot afford to lose; reliability over personality |
+ | 2026-08-16 | VIVARIUM supersedes it | North star "a second brain, alive": memory here is machine-grown while the human is away, so liveness is data — rendered as light and motion. The no-uninformative-decoration discipline is retained and extended to luminance |
+ | 2026-08-16 | Neutral night ground | The first VIVARIUM draft tinted ground/text/borders green and the accent stopped carrying signal ("too green") |
+ | 2026-08-16 | Life accent `#8FF25C` | Chosen on a side-by-side preview over deep-sea teal, jelly pink and ice blue |
+ | 2026-08-16 | Species palette by formula | `oklch(0.78 0.12 H)` replaces nine hand-picked hexes; hue=species, luminance=vitality |