diff --git a/.agents/skills/ln-build/SKILL.md b/.agents/skills/ln-build/SKILL.md index 167effa5..98f9307f 100644 --- a/.agents/skills/ln-build/SKILL.md +++ b/.agents/skills/ln-build/SKILL.md @@ -12,7 +12,7 @@ Implement **one** scope card. Beck's red-green-refactor, one cycle, no scope cre A scope file under `memory/cards/`, an inline scope card from `ln-scope`, or a trivial direct-fix request: $ARGUMENTS -Extract: target behavior / objective, acceptance criteria, verification approach, and (when present) expected touched paths. +Extract: target behavior / objective, acceptance criteria, verification approach, cold-start reads, and (when present) expected touched paths. Treat the scope card as the next implementation slice inside its containing `memory/PLAN.md` frontier item (or, for dev/tooling/docs work, the named category prefix). The frontier item is the plan-level work item and Linear/branch unit; the scope-card slice is just the current execution step inside it. Unless `ln-plan` has already split the frontier into separate items, do **not** infer a new Linear issue or Graphite branch from scope-card granularity; multiple consecutive slices may land on the same branch — including slices that live in separate scope files but share a frontier. @@ -41,6 +41,8 @@ If this is a fresh thread or an unfamiliar area, reload: 3. `HANDOFF.md` if present 4. `docs/archive/PLAN_HISTORY.md` only if the frontier or touched area is still unclear +Let the card's **Cold-start reads** block scope this reload — resolve the specific decision/invariant ids and frontier it names. The numbered list above is the fallback when the card omits Cold-start reads or you need broader orientation. If the card's Cold-start reads turn out to be incomplete or stale (an id it names no longer exists, or you needed a doc it did not list), that is a scope defect — note it and route back through `ln-scope` rather than silently working around it. + Write a 2-4 bullet orientation note naming the containing seam, the frontier item (or dev/tooling concern), any manual verification debt, and the main open risk. Also name any frontier-level cross-cutting obligations the slice inherits (for example shared mutation-authority rules, side-task/event-substrate semantics, or verification-layer commitments). diff --git a/.agents/skills/ln-scope/SKILL.md b/.agents/skills/ln-scope/SKILL.md index 7a4c1630..3773b9ba 100644 --- a/.agents/skills/ln-scope/SKILL.md +++ b/.agents/skills/ln-scope/SKILL.md @@ -72,7 +72,9 @@ Created: YYYY-MM-DD ### Why one file per concern, not one file for everything -The `memory/cards/` directory is a scoping inbox where multiple agents can deposit independent scope files in parallel without colliding on a single shared file. Each file is the unit of execution context that one `ln-build` invocation consumes. +The `memory/cards/` directory is a scoping inbox where multiple agents can deposit independent scope files in parallel without colliding on a single shared file. Each file is the unit of work one `ln-build` invocation consumes. + +The card does **not** inline canonical context — it points to it via [§Cold-start reads](#cold-start-reads). The full execution context is the card *plus* the canonical docs its Cold-start reads enumerate, which `ln-build` reloads on a fresh thread. A card therefore need not be self-contained to be cold-buildable; it must make its required reads explicit. "Free-standing enough for a separate builder thread" means *its Cold-start reads are complete*, not *its content is duplicated* — inlining SPEC/PLAN text into the card duplicates canonical truth and invites drift. Multiple scope files per frontier are permitted — they represent independent concerns that happen to land on the same branch. They do **not** imply multiple Linear issues or multiple Graphite branches; the frontier item remains the tracker/branch boundary. @@ -181,6 +183,19 @@ If you cannot name the containing seam, the governing decision, or the live inva What is true when this slice is done? Single declarative sentence — observable, testable, no conjunctions. +### Cold-start reads + +The canonical context a fresh builder thread must resolve **before** building this card. Pointers, not copies — name the exact ids/paths to load; never restate their content here (that duplicates canonical truth and invites drift). + +``` +- memory/SPEC.md — decisions / invariants / assumptions: (e.g. D53-L, A4-L) +- memory/PLAN.md — frontier: +- HANDOFF.md — (omit if none) +- (omit if none) +``` + +This block is the answer to "could a separate builder thread work this card cold?" If you cannot enumerate the reads that make the card resolvable, the card is under-scoped — not the reader under-briefed. + ### Boundary Crossings Every boundary the slice passes through, entry to exit: @@ -282,6 +297,18 @@ src/legacy/observer.ts ? Single sentence: what this work changes for the user, operator, or codebase. +### Cold-start reads + +The canonical pointers a fresh builder must resolve before building — ids/paths, not copies. + +``` +- memory/SPEC.md — (or None) +- memory/PLAN.md — frontier: | category concern +- HANDOFF.md — (if any) +``` + +If you cannot name what makes this card resolvable cold, it is not settled enough for light mode. + ### Acceptance Criteria ``` diff --git a/memory/PLAN.md b/memory/PLAN.md index e7d5b528..a919ee67 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -106,6 +106,7 @@ The near-term spine has two tracks. The **context-pipeline coverage trio** remai - `topology-readmes-and-boundaries` — small doc/test hardening when a frontier moves files or exposes a boundary; should remain attached to the frontier when possible rather than becoming an abstract cleanup project. - `dev-seed-fixtures` — rich, real seed data for local dev / manual / observer testing: the consolidated seed contract, the `npm run seed` loader, and growing/enhancing fixture sets (Bilal-port + legacy). Its semantic curation mutation slice is folded into / blocked by `role-safe-graph-mutations`; ongoing seed-data maintenance remains low-conflict. - `dx-introspection-live` — DX follow-on to `dx-feedback-loops`: harden the four-role `.fixtures/` topology + `--cwd` launch (D70-L), unify dev gating under `BRUNCH_DEV` and wire the dormant introspection extension into the real TUI (D71-L), and make introspection conversational (A26-L). Three sequenced slices; ready for a scoping thread. Low-conflict with the product trio; touches `.fixtures/`, `src/app/`, `src/dev/`, `src/.pi/extensions/introspection/`. +- `web-design-system-port` — **bounded feature; `Certainty: earned`; done 2026-06-09.** Ported the prior trunk's restrained design system (tokens + card primitives) into `src/web` and re-skinned the three existing read-only views, retiring the agent-invented "warm brunch" aesthetic (D72-L). Read-only presentation only; independent of the POC delivery spine and the context-pipeline trio. Execution record: `memory/cards/web-design-system-port--restyle.md`. ### Horizon @@ -508,6 +509,27 @@ The near-term spine has two tracks. The **context-pipeline coverage trio** remai - **Traceability:** D4-L, D16-L, D19-L, D20-L, D52-L, D61-L, D62-L, D63-L / I1-L / A4-L, A14-L. - **Design docs:** `.fixtures/seeds/bilal-port/README.md`; `docs/design/GRAPH_MODEL.md`; `docs/praxis/manual-testing.md`. +### web-design-system-port + +- **Name:** Web client visual design-system port +- **Linear:** unassigned +- **Kind:** bounded feature (web presentation) +- **Certainty:** earned — the target design exists and works in `../brunch/src/client`; the closure is *materialize the port + delete the invented aesthetic*, not retire an unknown. (Project default is `proving`; this frontier overrides because the design is known.) +- **Status:** done (all three cards landed 2026-06-09; `memory/cards/web-design-system-port--restyle.md`) +- **Objective:** Replace the agent-invented "warm brunch" web aesthetic with the prior trunk's restrained design language (D72-L). Two materializations and one deletion: (a) **tokens** — port the token system into `src/web/styles.css` (Inter + Geist Mono; `ink/sub/hint/rule/wash/tint` ramp + link/plane accents; 11–16px type scale; `--shadow-card` family); (b) **primitives** — copy `DrawerCard`, `KindBadge`, `CountBadge`, `RefBadge` into a new `src/web/components/`, adapted from the old `KnowledgeKind` knowledge-card pattern to this trunk's `NodeKind`/`NodePlane` with a plane-organized accent map; (c) **re-skin** the three existing views (`WorkspaceChrome`, `GraphOverviewPanel`, `SessionPanel`) as a *style + component-pattern port of the views we have* (scope correction, user 2026-06-09) — preserving behavior except invented dead scaffolding — and delete the warm gradients, `backdrop-blur`, oversized radii/shadows, translucent surfaces, and wide-tracked uppercase labels. The non-functional "Focus node" placeholder (never called `graph.nodeNeighborhood`) was removed; the "Edge categories" summary was kept (restyled, user finds it useful). +- **Why now / unlocks:** The current web UI's visual language was invented wholesale by the agent that built it and does not match the product's established look. Realigning now keeps the read-only observer surface presentable for manual/observer testing and stops the invented aesthetic from being copied forward into future web views. Independent of the delivery spine — touches no data, RPC, query, subscription, or routing code. +- **Acceptance:** + - `src/web/styles.css` carries the ported token system; no warm-palette tokens, body gradients, or `backdrop-blur` remain. + - `src/web/components/` holds the ported primitives; the accent map is exhaustive over `NodePlane`, with a compile-time `satisfies Record` guard, while reference-code labels stay canonical via `NODE_KIND_METADATA` + `kindOrdinal` (I43-L). + - The three views render in the ported language: quiet metadata-row chrome, `plane / kind`-grouped node cards with canonical reference codes (`NODE_KIND_METADATA` labels + `kindOrdinal`) and plane-accented `KindBadge`/`CountBadge`, plain session card. The "Edge categories" summary is kept (restyled as `RefBadge` chips); the non-functional "Focus node" placeholder is removed. + - Read-only contract preserved: no change to queries, RPC client, subscriptions, routes, or projection inputs. + - Existing web tests preserved; only the two Focus-node assertions removed; `npm run verify` is green (28 web tests, oxlint type-aware clean, build clean). +- **Verification:** Inner — `npm run verify` (oxlint type-aware + oxfmt + vitest + build); update `src/web/app.test.tsx` and any view tests that assert retired class names / `aria-label`s. Outer — manual browser check of `/` and `/spec/$specId` against a seeded spec (`npm run seed` then launch web mode) to confirm the chrome, kind-grouped graph cards, and session panel match the prior trunk's look. +- **Topology materialization:** Stays inside `src/web` per D52-L (`web/` is a standalone build target; must not read SQLite/Pi RPC/JSONL directly). New `src/web/components/` owns ported primitives; only `src/web` imports from it. Component/style patterns are copied (not shared) from `../brunch`. Exception to `sourcing: strip-or-build`: the webfont packages `@fontsource-variable/inter` + `@fontsource-variable/geist-mono` were added with user approval (2026-06-09) — the fonts are the most visible design token; the "no new packages" line was not a hard rule. +- **Cross-cutting obligations:** Pre-release posture (`migration: free-rewrite`) — discard the invented design freely; do not preserve it for compatibility. Read-only invariant (D33-L one-writer/many-observer): this frontier adds no web write paths. Node reference codes must use the canonical `NODE_KIND_METADATA` projection (D62-L), not a web-local relabeling. +- **Traceability:** D10-L, D52-L, D62-L, D72-L / I43-L, I39-L. +- **Design docs:** `../brunch/src/client/index.css`, `../brunch/src/client/components/drawer-card.tsx`, `../brunch/src/client/components/knowledge-card.tsx` (reference source — separate checkout, not imported). + ## Recently Completed - 2026-06-09 `role-safe-graph-mutations` — Done: retired the remaining public `commitGraph` residue, extracted the shared mutation planner/writer out of `CommandExecutor`, and completed the last boundary migration so dev curation now exposes `dev.graph.mutateGraph` with role-named create-edge ops plus projected node-code / selected-spec edge-id resolution. Follow-up closure on the same frontier: reconciled the remaining product probes and current docs to the canonical `mutateGraph` / `mutate_graph` grammar, explicitly marked the checked-in 2026-06-05 fixture-curation artifact as historical pre-migration `commit_graph` evidence, and added role-named edge schema coverage across the Pi tool and dev RPC boundaries. Verified: `npx vitest run src/rpc/handlers.test.ts src/app/brunch.test.ts src/probes/fixture-curation-loop.test.ts src/probes/propose-graph-commit-proof.test.ts src/graph/mutate-graph-edge-schema.test.ts` and `npm run verify`. @@ -549,6 +571,7 @@ nodes: probes-and-transcripts-evolution [parallel] continuous evidence substrate topology-readmes-and-boundaries [parallel] attach-to-frontier topology hardening dev-seed-fixtures [parallel] rich seed data substrate for dev/observer testing + web-design-system-port [done · earned] ported prior-trunk tokens + card primitives into src/web; retired invented warm aesthetic; read-only, no spine deps edges: graph-tool-resilience -[hard]-> capture-response-to-graph diff --git a/memory/SPEC.md b/memory/SPEC.md index 10abe359..b10db67d 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -133,7 +133,8 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D40-L — Runtime state is transcript-backed Brunch session-agent state, not hidden extension memory.** `src/session/runtime-state.ts` owns the transcript entry facts (`brunch.agent_runtime_state` schema, parser, and init/switch append helpers); `src/projections/session/runtime-state.ts` owns the pure reusable projection, `src/projections/session/runtime-policy.ts` owns operational-mode/role policy plus shared grade legality tables, and `src/projections/session/affordances.ts` owns the pure `(resolvedState, readinessGrade) → legal options + default-on-switch` derivation for goal/strategy/lens. The projection reconstructs agent posture from linear `brunch.agent_runtime_state` entries (`reason: "init" | "switch"`), last-writer-wins at turn preparation and over `session.runtimeState`; default/empty slots are explicit when no entry family exists. Runtime-state entries are Pi JSONL state-change facts, not assistant/user chat content: init and switch entries should render, when visible, as dim non-chat state rows analogous to Pi thinking/model-change rows, and must not enter LLM context as ordinary conversation. Its axes are `op_mode` (`elicit`, future `execute`) plus optional, AUTO-able objective axes `strategy`, `lens`, and `goal` (D25-L, D59-L). **Posture switches (durable `reason: "switch"` entries) are a user/system authority: the foreground agent never emits a posture switch.** The agent's only in-axis freedom is `AUTO` (per-turn implicit selection from the D58-L manifest); what it actually chose each turn is legible downstream via per-emission facet stamping (D25-L), not via runtime-state — so runtime-state is the *frame/constraints* while emitted facets carry the agent's per-turn choice. User-mutable axes are `op_mode`, `strategy`, and `lens`; `goal` is internal/grade-derived and not part of the user posture-change surface for now (D59-L). On a parent switch that invalidates a child axis, the child defaults to `AUTO`. The `source: "agent"` entry value is reserved — no current path emits it; it is parked for a future execute-mode orchestrator that might legitimately steer sub-postures. `session.runtimeState` also exposes shaped mention slots, world-update watermarks (latest graph LSN and optional git head, without raw transcript detail bags), and lifecycle facts when transcript-backed entries make them computable; this is a projection contract, not a mutable state table. The **foreground session agent** (`elicitor` now, future `executor`) is *derived* from `op_mode`, not stored; the other agent roles (`reviewer`, `reconciler`, future `scout`/`researcher`) are async sub-agent/side-chain workers (D29-L, D44-L) invoked out-of-band, never part of the session state machine. `op_mode` gates tool authority, applied by `src/.pi/extensions/runtime/index.ts` (current `elicit` policy denies side-effecting `bash`/`edit`/`write` plus user-shell interception) while `.pi` reuses session-owned entry definitions and projected policy. Prompt composition is a separate concern (D58-L). Depends on: D17-L, D23-L, D25-L, D39-L, D58-L, D59-L. Supersedes: mode-only vocabulary, extension-local mutable state as authority, storing the foreground role as independent session state, the "runtime bundle / role preset" as one knob deriving model/thinking/resources, and binding prompt-resource location to `src/.pi/context/`. - **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/.pi/extensions/commands/policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. - **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state value rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setWidget`, title, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the discovered project name, selected spec, and real activated session id/label, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome and startup dialog are project-first shell surfaces with selected-spec context: the project name labels the cwd container, the spec title labels the selected graph, and the session label distinguishes transcript instances. Brunch chrome does not publish a `brunch.chrome` status key; `ctx.ui.setStatus(key, text)` remains a lateral contribution channel for other extensions and future dynamic Brunch state. RPC clients should rely only on surfaces Pi actually emits for the wrapper (currently diagnostic widget/title, plus any future explicit status adapter) because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are product projections over Pi session metadata: every Brunch-created session should immediately receive a neutral workspace-global `Untitled Session N` `session_info` label, and later user/generated names may characterize the transcript without replacing spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, consuming the status-key namespace for chrome's own static summary, using spec title as the default session label, or allowing two unchanged Brunch-created default names to collide in one cwd. -- **D52-L — Source topology targets `src/{app, workspace, scripts, .pi, db, graph, session, projections, renderers, rpc, web}` with directed layer dependencies.** Product entrypoints live under `src/app/`, local executable utility ownership is reserved under `src/scripts/`, package/workspace identity tests live under `src/workspace/`, and reusable projection/rendering modules live under top-level `src/projections/` and `src/renderers/` rather than whichever domain or adapter first needed them. `app/` owns product host entrypoints and wiring. `workspace/` owns cwd/package/workspace identity helpers. `scripts/` owns local executable utilities. `.pi/` is the sealed Pi-harness runtime surface: `agents/` owns runtime prompt assembly, role definitions, legal resource manifests, and agent-context orchestration; `skills/` owns goal/strategy/lens/method markdown resources read on demand; `components/` owns reusable Pi TUI/message components; `extensions/` owns Pi registrars for tools, hooks, commands, chrome, context tools, system-prompt append, exchanges, graph tools, workspace dialogs, runtime policy, and session lifecycle. `graph/` is the domain layer: CommandExecutor, readers, policy, validators, query bucketing, change-log replay, reconciliation-need substrate; it imports from `db/` (Drizzle schema, migrations, connection lifecycle) and no other layer imports `db/` directly. `session/` owns transcript projection, exchange extraction, workspace coordination, session binding, runtime-state transcript entries, and LSN staleness tracking over Pi JSONL. `projections/` owns structured DTOs derived from graph/session/workspace/tool facts; it must not render lossy text and must not import adapters, transports, app entrypoints, or web code. `renderers/` owns lossy text/markdown/toon/tool-content rendering over domain or projection inputs; it may import input types from `graph/`, `session/`, or `projections/` as needed, but must not import adapters, transports, app entrypoints, or web code. `rpc/` owns Brunch JSON-RPC handlers. `web/` owns the React client. Dependency direction: `.pi/`, `rpc/`, and `app/` may import from `graph/`, `session/`, `projections/`, and `renderers/`; `.pi/agents/` may import from `graph/`, `session/`, `projections/`, and `renderers/` to build agent context; `.pi/extensions/` may import from `.pi/agents/` and `.pi/components/`; `projections/` may import from `graph/`, `session/`, and `workspace/`; `renderers/` may import from `projections/`, `graph/`, and `session/`; `graph/` imports from `db/`; `web/` is a standalone build target. Depends on: D2-L, D4-L, D39-L, D40-L. Supersedes: scattering session domain files at `src/` root; treating Pi-only agents as a host-independent top-level `src/.pi/` layer; nesting prompt composition under `src/.pi/context/`; treating reusable `project` / `format` helpers as owned by whichever adapter first needed them. +- **D52-L — Source topology targets `src/{app, workspace, scripts, .pi, db, graph, session, projections, renderers, rpc, web}` with directed layer dependencies.** Product entrypoints live under `src/app/`, local executable utility ownership is reserved under `src/scripts/`, package/workspace identity tests live under `src/workspace/`, and reusable projection/rendering modules live under top-level `src/projections/` and `src/renderers/` rather than whichever domain or adapter first needed them. `app/` owns product host entrypoints and wiring. `workspace/` owns cwd/package/workspace identity helpers. `scripts/` owns local executable utilities. `.pi/` is the sealed Pi-harness runtime surface: `agents/` owns runtime prompt assembly, role definitions, legal resource manifests, and agent-context orchestration; `skills/` owns goal/strategy/lens/method markdown resources read on demand; `components/` owns reusable Pi TUI/message components; `extensions/` owns Pi registrars for tools, hooks, commands, chrome, context tools, system-prompt append, exchanges, graph tools, workspace dialogs, runtime policy, and session lifecycle. `graph/` is the domain layer: CommandExecutor, readers, policy, validators, query bucketing, change-log replay, reconciliation-need substrate; it imports from `db/` (Drizzle schema, migrations, connection lifecycle) and no other layer imports `db/` directly. `session/` owns transcript projection, exchange extraction, workspace coordination, session binding, runtime-state transcript entries, and LSN staleness tracking over Pi JSONL. `projections/` owns structured DTOs derived from graph/session/workspace/tool facts; it must not render lossy text and must not import adapters, transports, app entrypoints, or web code. `renderers/` owns lossy text/markdown/toon/tool-content rendering over domain or projection inputs; it may import input types from `graph/`, `session/`, or `projections/` as needed, but must not import adapters, transports, app entrypoints, or web code. `rpc/` owns Brunch JSON-RPC handlers. `web/` owns the React client. Dependency direction: `.pi/`, `rpc/`, and `app/` may import from `graph/`, `session/`, `projections/`, and `renderers/`; `.pi/agents/` may import from `graph/`, `session/`, `projections/`, and `renderers/` to build agent context; `.pi/extensions/` may import from `.pi/agents/` and `.pi/components/`; `projections/` may import from `graph/`, `session/`, and `workspace/`; `renderers/` may import from `projections/`, `graph/`, and `session/`; `graph/` imports from `db/`, and `db/` may import the drizzle-free taxonomy leaf `graph/schema/kinds.ts` — the single sanctioned `db/`→`graph/` edge (D73-L); `web/` is a standalone build target. Depends on: D2-L, D4-L, D39-L, D40-L. Refined by: D73-L. Supersedes: scattering session domain files at `src/` root; treating Pi-only agents as a host-independent top-level `src/.pi/` layer; nesting prompt composition under `src/.pi/context/`; treating reusable `project` / `format` helpers as owned by whichever adapter first needed them. +- **D73-L — Domain enum taxonomy is owned by a drizzle-free `src/graph/schema/kinds.ts` leaf; `db/` is a consumer, not the source.** The closed enum `const` arrays that define graph vocabulary — node kinds (`INTENT_KINDS`, `ORACLE_KINDS`, `DESIGN_KINDS`, `PLAN_KINDS`), `NODE_PLANES` (`intent`/`oracle`/`design`/`plan`), `NODE_BASES`, `EDGE_CATEGORIES`, `EDGE_STANCES`, `READINESS_GRADES`, `READINESS_BANDS`, `LENS_AFFINITIES`, `ELICITATION_BACKLOG_STATUSES` — live in `graph/schema/kinds.ts`, a pure constants leaf that imports nothing (no drizzle, no `graph/atoms`). Both `db/schema.ts` (for `text({ enum })` column constraints, including the previously-inlined `plane` columns) and `graph/` domain modules import the arrays from this leaf; `graph/index.ts` re-exports them from the leaf so non-graph layers still avoid importing `db/` directly (I26-L). Derivations stay where they are read: `NODE_KIND_METADATA`, `formatGraphNodeCode`, `parseGraphNodeCode`, and `intentKindCategory` remain in `graph/schema/nodes.ts` (D62-L). The motivating defect: because `db/schema.ts` eagerly evaluates `sqliteTable(...)` and `verbatimModuleSyntax` emits even type-only imports at runtime, any value-import path from `web/` into the old taxonomy location pulled Drizzle into the browser bundle. Locating taxonomy in a drizzle-free leaf makes the `web/` build target structurally Drizzle-free (I44-L) and corrects the ownership direction so the domain, not the persistence layer, owns its vocabulary. Depends on: D16-L, D52-L, D54-L, D62-L, D63-L, D64-L; I26-L. Supersedes: `db/schema.ts` owning the shared enum `const` arrays and the "enum literals flow outward from `db/schema.ts`" posture; the triplicated inline `['intent','oracle','design','plan']` plane literals. #### Data model & vocabulary @@ -167,6 +168,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D19-L — Keep product RPC/read architecture thin: named method families over projection handlers.** Brunch exposes concrete named methods, not vague feature buckets or generic records. The canonical public RPC vocabulary is maintained in [`src/rpc/README.md`](file:///Users/lunelson/Code/hashintel/brunch-next/src/rpc/README.md): `rpc.discover`; `workspace.state`, `workspace.selectionState`, `workspace.activate`; current selected-spec graph reads `graph.overview` and `graph.nodeNeighborhood`; and session methods `session.triggerExchange`, `session.pendingExchange`, `session.submitExchangeResponse`, `session.submitMessage`, `session.exchanges`, and `session.runtimeState`. Reserved future target names such as `graph.changesSince` and graph-adjacent `graph.coherenceSummary` must not appear in discovery until real behavior exists. Each read handler projects from the canonical store that owns the fact (Pi JSONL, `.brunch/workspace.json`, or SQLite graph/change log), and each mutation handler routes to the Brunch-owned command/session layer. `brunch.updated` notifications are first-class JSON-RPC records over WebSocket and stdio, but they are process-local invalidation hints carrying `{topic, specId?, sessionId?, nodeId?, lsn?}`, not canonical truth and not a durable cross-store event spine. Brunch must not create a generic read-gateway platform, REST read model, DB-backed chat/turn projection, or canonical cross-store event spine merely to keep clients in sync. Dev-only RPC harness methods may exist under `dev.*` when explicitly enabled for local fixture curation or seam testing; they are absent from normal product discovery, absent from read-only sidecars, and still route mutations through the owning command/session layer. They are not public product capabilities and must not occupy `graph.*`, `session.*`, or `workspace.*` names. Retired public names are quarantined in `src/rpc/README.md` §Names absent from current public RPC and must not re-enter product discovery. Depends on: D5-L, D6-L, D10-L, D16-L. Supersedes: the heavier “unified read gateway” mental model, vague `elicitation.*` / `command.*` public families, and any discovery/dispatch split where a surface describes methods it rejects. - **D23-L — Transport modes, operational modes, agent roles, strategies, and lenses are separate axes.** TUI, RPC, print, and web are transport modes: ways of driving or observing the same Brunch host through Pi/Brunch harness seams. Operational modes are top-level authority/tooling postures such as `elicit` and future `execute`. Agent roles are active workers within an operational mode (`elicitor`, `reviewer`, `reconciler`, future `executor/orchestrator`, `scout`, `researcher`, and any deferred observer/auditor). Strategies are interaction plans; lenses are narrower interpretive/extraction/review framings. M1 print mode is therefore only a transport proof-of-life: it boots through the same host/coordinator, renders current product-shaped state, and exits without running an agent turn. A future single-turn headless print run is deferred until runtime bundle selection/defaults are explicit. Depends on: D1-L, D5-L, D19-L, D21-L, D40-L. Supersedes: overloading “mode” to mean both transport and agent strategy, or using “agent mode” for role/preset/lens interchangeably. - **D33-L — Transport connections are client attachments, not Brunch sessions.** A Brunch session is a durable linear Pi JSONL transcript bound to exactly one spec; WebSocket connections, stdio streams, TUI instances, and browser tabs are ephemeral presentation attachments to product resources. Session-specific RPC methods should name their target spec/session explicitly or operate through an explicit client attachment; they must not infer durable session identity merely from the transport connection. `.brunch/workspace.json` remains launch/default acceleration, not concurrency authority. During the POC, Brunch targets a one-writer/many-observer local model: one interactive driver (typically TUI/agent) may write while web clients attach read-only for visual projections. Depends on: D5-L, D10-L, D11-L, D19-L, D21-L, D24-L. Supersedes: treating `/rpc`, a WebSocket, or workspace default state as the active session itself. +- **D72-L — The web client's visual design system is ported from the prior trunk (`../brunch/src/client`), not freshly invented.** The browser surface adopts the earlier product's restrained design language: Inter (sans) + Geist Mono (mono) fonts; the neutral gray ramp (`ink #202020`, `sub #5b5b5b`, `hint #a6a6a6`, `rule #e3e3e3`, `wash #f0f0f0`, `tint #fafafa`) plus link/per-kind accents; a compact 11–16px type scale; and the subtle `--shadow-card` family. Card primitives are ported too — `DrawerCard` (collapsible card-with-drawer), `KindBadge`, `CountBadge`, and the kind-grouped knowledge-card pattern (white header card over `tint` body via `-m-px` overlap). Because the two trunks are independent checkouts with no shared package, the tokens and primitives are **copied** into `src/web/` (new `src/web/components/`), adapted from the old `KnowledgeKind` vocabulary to this trunk's `NodeKind` model: node reference codes reuse `NODE_KIND_METADATA` labels + `kindOrdinal` (D62-L) — which already match the old `G1`/`CTX1`/`AC1` codes — and the per-kind accent is organized **by plane** (`intent`/`oracle`/`design`/`plan`). The graph overview groups `GraphSlice.nodes` by kind and renders `edges` as inline "Links to:" reference badges. Scope is read-only presentation only: no data, RPC, query, subscription, or routing changes, and no new layout surfaces — only re-skinning the three existing views (`WorkspaceChrome`, `GraphOverviewPanel`, `SessionPanel`). Full first-trunk layout fidelity (phase-navigation rail, center acceptance-criteria column, three-pane spec workspace) is explicitly out of scope. Depends on: D10-L, D52-L, D62-L. Supersedes: the agent-invented "warm brunch" web aesthetic — paper/card warm palette, body radial/linear gradients, `backdrop-blur`, oversized radii (`rounded-[2rem]`) and shadows, translucent surfaces (`bg-white/45`), wide-tracked uppercase mono labels, hover-lift "Focus node" cards, and the invented "Edge categories" chip cluster. Product RPC / Pi relay model: @@ -309,6 +311,8 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I40-L | Accepted graph nodes and edges use only `basis ∈ explicit | implicit`; review-set approval and direct user statements produce `explicit`, `propose-graph` concept-level materialization produces `implicit`, and the mutation path is recoverable from `change_log` rather than from a persisted basis enum value such as `accepted_review_set`. | covered (`graph-tool-resilience` replaced the persisted basis enum with `explicit | implicit`, made `mutateGraph` apply one batch create-basis to all created nodes/edges, made single-node `createNode` reject retired basis values before LSN/counter/node/change-log allocation, made `propose-graph` adapter commits implicit, made review-set translation explicit, rejected retired `accepted_review_set`, and records `change_log.operation` independently; `capture-response-to-graph` proves direct structured text responses commit explicit-basis graph nodes through `CommandExecutor`; `.fixtures/runs/project-graph-review-cycle/2026-06-06-project-graph-review-cycle/` proves full review-cycle approval creates explicit-basis graph truth) | D26-L, D27-L, D53-L, D63-L | | I41-L | Same-spec `supersession` edges form an acyclic directed graph; every edge-creation path validates proposed supersession edges together with existing supersession edges before committing. | covered (`command-executor/commit-graph-batch.test.ts` rejects existing-cycle closure, intra-batch cycles, and mixed existing+batch cycles through the shared dry-run/commit planner before batch writes; rejected cycles roll back or avoid batch nodes/edges/change_log; acyclic supersession commits remain covered by query/CommandExecutor success paths) | D51-L, D53-L; I34-L | | I42-L | Dev-only substrate never affects product/prod behavior: `src/dev/**` is build-excluded from `dist`; the introspection extension registers only when `BRUNCH_DEV` opts it in (default product sessions never register the tap, the `/introspect` command, or any `before_provider_request` observer); dev-loop artifacts land only under gitignored `.fixtures/scratch/`, never tracked `runs/` or the operating cwd; and any offline-default lift is save/restore-scoped, never a leaked global `process.env` mutation. | partially covered (`src/.pi/__tests__/introspection.test.ts` proves default-off registration + last-position ordering when enabled; `tsconfig.build.json` excludes `src/dev`; planned: build-exclusion assertion, `BRUNCH_DEV` gating test at the TUI call site, scratch-path resolution test, offline-lift save/restore test) | D39-L, D68-L, D69-L, D70-L, D71-L | +| I43-L | The web client's accent presentation map is exhaustive over `NodePlane` (intent/oracle/design/plan); every plane renders with a defined accent, and node reference-code labels remain canonical via `NODE_KIND_METADATA` + `kindOrdinal` (no fallthrough default that silently swallows an unmapped plane). | met (compile-time `satisfies Record` exhaustiveness check on `PLANE_ACCENT` in `src/web/components/node-card.tsx`; breaks the build when a new `NodePlane` is added without an accent) | D72-L; I36-L | +| I44-L | Domain enum taxonomy lives in the drizzle-free leaf `src/graph/schema/kinds.ts` (zero imports), `db/schema.ts` owns no enum `const` array (it imports them from the leaf), and the `web/` build target transitively contains no Drizzle/persistence code. The only sanctioned `db/`→`graph/` import is from `db/schema.ts` to `graph/schema/kinds.ts`. | planned (architecture.test grep guards: kinds.ts has zero import statements; db/ imports from graph/ only via `graph/schema/kinds.ts`; db/schema.ts exports no enum const array; post-`build:web` assertion that the dist-web bundle has no `drizzle`/`sqliteTable` match; lands with the `graph--taxonomy-ownership-leaf` slice) | D52-L, D73-L; I26-L | ## Future Direction Register diff --git a/memory/cards/graph--taxonomy-ownership-leaf.md b/memory/cards/graph--taxonomy-ownership-leaf.md new file mode 100644 index 00000000..075a7bae --- /dev/null +++ b/memory/cards/graph--taxonomy-ownership-leaf.md @@ -0,0 +1,165 @@ +# Graph taxonomy ownership leaf (close the web→drizzle bundle leak) + +Frontier: n/a (graph/db shared-seam hardening) +Status: active +Mode: single +Created: 2026-06-09 + +## Orientation + +- **Seam:** the `db/ ↔ graph/ ↔ web/` taxonomy boundary. Domain enum `const` arrays + (`INTENT_KINDS`, `EDGE_CATEGORIES`, `NODE_BASES`, readiness/lens/backlog enums, …) + currently live in [`src/db/schema.ts`](file:///Users/lunelson/Code/hashintel/brunch-next-chi/src/db/schema.ts) + and flow *outward* (D52-L, I26-L: `graph/index.ts` re-exports them so other layers + avoid importing `db/` directly). +- **Frontier item:** none — this is a shared-seam hardening fix, not a PLAN frontier. + Governing decisions: **D52-L** (source topology / dependency direction) and **I26-L** + (schema-library import scoping + "no direct `db/` imports outside `graph/`"). +- **Open risk / why now:** [`src/web/components/node-card.tsx`](file:///Users/lunelson/Code/hashintel/brunch-next-chi/src/web/components/node-card.tsx#L1-L6) + value-imports `NODE_KIND_METADATA` / `formatGraphNodeCode` from + [`graph/schema/nodes.ts`](file:///Users/lunelson/Code/hashintel/brunch-next-chi/src/graph/schema/nodes.ts#L11), + which value-imports the `*_KINDS` arrays from `db/schema.ts`, which imports `drizzle-orm` + and evaluates `sqliteTable(...)`. Result: **drizzle is in the browser bundle** — + confirmed: `dist-web/assets/brunch-web.js` matches `drizzle|sqliteTable`. Under + `verbatimModuleSyntax: true`, even type-only `import {}` lines are emitted at runtime. +- **Posture:** earned. The design is settled — user approved **Option A** (domain owns + taxonomy; db is a consumer) over the cheap `import type` patch and over inline-duplication. + Closure move: materialize a drizzle-free taxonomy leaf and invert the one db edge. + +## Target Behavior + +The web build target transitively contains no Drizzle/persistence code because all domain +enum taxonomy lives in a drizzle-free `src/graph/schema/kinds.ts` leaf that both `db/` and +`graph/` import, and `db/schema.ts` no longer owns or exports any enum `const` array. + +## Boundary Crossings + +``` +→ src/graph/schema/kinds.ts (new drizzle-free leaf: zero imports) +→ src/db/schema.ts (imports enums from kinds.ts for column constraints) +→ src/graph/schema/{nodes,edges,elicitation-backlog}.ts (derive types from kinds.ts) +→ src/graph/index.ts (re-export enums from kinds.ts, not db/schema.ts) +→ graph runtime consumers (command-executor*, review-set, rpc/methods/dev-graph) +→ src/web/components/node-card.tsx (unchanged source; leak closes transitively) +→ src/graph/architecture.test.ts (new guards) +``` + +## Risks and Assumptions + +``` +- RISK: db/schema.ts importing from graph/schema/kinds.ts introduces a layer cycle. + → MITIGATION: kinds.ts is a pure zero-import leaf (constants only, no `graph/atoms`, + no drizzle). graph/* → db/schema → kinds is acyclic because kinds is a sink. Add a + leaf-purity guard test asserting kinds.ts has zero import statements. +- RISK: db now depends on graph/ — contradicts D52-L's "graph imports from db; no other + layer imports db directly" clause and db/README's "enums flow outward from db". + → MITIGATION: this is the approved decision change. Refine D52-L + I26-L + both READMEs + in the same slice so the inversion is recorded, and constrain db→graph to the single + kinds.ts edge via a guard test. +- ASSUMPTION: every enum array currently in db/schema.ts is domain taxonomy, not a storage + concept, so all of them belong in kinds.ts (node kinds, NODE_BASES, EDGE_CATEGORIES, + EDGE_STANCES, READINESS_GRADES, READINESS_BANDS, LENS_AFFINITIES, + ELICITATION_BACKLOG_STATUSES) plus a named NODE_PLANES (currently inlined 3× as + ['intent','oracle','design','plan']). + → IMPACT IF FALSE: an array that is genuinely storage-only would be miscategorized; + low blast radius (move it back, db re-owns it). + → VALIDATE: each is consumed by a graph domain type or validator (confirmed: + command-executor.ts, schema/{nodes,edges,elicitation-backlog}.ts), and db uses them + only as `text({ enum })` column constraints. +``` + +## Posture check (earned) + +- **Closes:** the architectural fork from the prior thread (Option A vs `import type` vs + inline) and the active web→drizzle bundle leak. +- **Materializes:** the "domain owns taxonomy, db is a consumer" decision into topology + (`graph/schema/kinds.ts`). +- **Canonicalizes:** one source of truth for domain enum vocabulary, at a drizzle-free path. +- **Deletes/retires:** the enum `const` array definitions in `db/schema.ts`; the inlined + `['intent','oracle','design','plan']` plane triplication. +- **Locks in:** web build target is drizzle-free (new invariant + guard). + +## Acceptance Criteria + +``` +✓ kinds-leaf-purity (architecture.test) — src/graph/schema/kinds.ts contains zero import statements +✓ db-imports-only-kinds (architecture.test) — db/ imports from graph/ only via graph/schema/kinds.ts +✓ no-enum-arrays-in-db (architecture.test or grep) — db/schema.ts exports no `*_KINDS`/`EDGE_*`/`NODE_BASES`/readiness/lens/backlog const array +✓ web-bundle-drizzle-free — after `npm run build:web`, dist-web bundle contains no `drizzle`/`sqliteTable` +✓ existing graph suite green — command-executor, seed-fixtures, role-named-edge, category-policy tests pass with enums sourced from kinds.ts/graph index +✓ existing I26-L boundary test still green — no src/ module outside graph/ imports from db/ +✓ npm run verify green (oxlint type-aware + oxfmt + vitest + build) +``` + +## Verification Approach + +``` +- Inner: unit/architecture grep tests — kinds.ts leaf purity; db→graph edge constrained to + kinds.ts; db/schema.ts exports no enum arrays; existing I26-L boundary test unchanged-green. +- Middle: `npm run build:web` then assert dist-web JS has no `drizzle|sqliteTable` match + (the regression oracle for the leak). Consider a small scripted check so it runs in CI. +- Outer: none required (read-only presentation already covered by web suite). +``` + +## Cross-cutting obligations + +``` +- Preserve I26-L: non-graph layers still must not import db/ directly; they consume enum + taxonomy via graph/index.ts (now sourced from kinds.ts) — keep architecture.test green. +- Preserve D52-L's other directed edges; only the db→graph/schema/kinds taxonomy edge is new. +- Preserve D62-L canonical node reference codes (NODE_KIND_METADATA + kindOrdinal) — do not + relocate NODE_KIND_METADATA out of nodes.ts; only the raw kind/plane/basis arrays move. +- Keep NODE_KIND_METADATA, formatGraphNodeCode, parseGraphNodeCode, intentKindCategory in + nodes.ts (web depends on them; they are derivations, not raw taxonomy). +- Pre-release posture (free-rewrite): repoint all consumers; do not leave compatibility + re-exports of the moved arrays in db/schema.ts. +``` + +## Required canonical reconciliation (land atomically with the code) + +This slice changes a load-bearing decision, so SPEC/READMEs update in the same commit: + +``` +- D52-L: add that src/graph/schema/kinds.ts is a drizzle-free, zero-import taxonomy leaf; + canonical domain enum vocabulary lives there (not db/schema.ts); db/ may import from it + as its single sanctioned graph-ward edge; reaffirm web/ is a standalone drizzle-free build. +- I26-L (or a new invariant): the enum re-export seam sources from kinds.ts; the web build + target transitively contains no Drizzle code (guarded). +- src/db/README.md: replace "enums flow outward from db/schema.ts; db owns the shared enum + const arrays" with "domain taxonomy is owned by graph/schema/kinds.ts; db imports the enums + it persists." +- src/graph/README.md: record kinds.ts ownership in the layout/dependency notes. +``` + +## Expected touched paths (tentative) + +``` +src/graph/schema/ +├── kinds.ts + +├── nodes.ts ~ +├── edges.ts ~ +└── elicitation-backlog.ts ~ +src/graph/ +├── index.ts ~ +├── command-executor.ts ~ +├── review-set.ts ~ +├── architecture.test.ts ~ +├── README.md ~ +└── command-executor/ + └── create-graph-batch.ts ~ +src/rpc/methods/dev-graph.ts ~ +src/db/ +├── schema.ts ~ +└── README.md ~ +src/graph/seed-fixtures.test.ts ~ +src/graph/policy/category-policy.test.ts ~ +src/graph/command-executor/role-named-edge-draft.test.ts ~ +memory/SPEC.md ~ +memory/PLAN.md ? (frontier note only if needed) +``` + +## Traceability + +- Refines: **D52-L** (dependency direction), **I26-L** (import scoping). +- Preserves: D54-L, D56-L, D62-L, D63-L, D64-L (taxonomy semantics unchanged; only location moves). +- Origin: cross-thread architecture review (omega builder + this thread), user-approved Option A 2026-06-09. diff --git a/memory/cards/web-design-system-port--restyle.md b/memory/cards/web-design-system-port--restyle.md new file mode 100644 index 00000000..2bae7acd --- /dev/null +++ b/memory/cards/web-design-system-port--restyle.md @@ -0,0 +1,158 @@ +# Web design-system port — restyle + +Frontier: web-design-system-port +Status: done +Mode: chain +Created: 2026-06-09 + +## Orientation + +- **Seam:** `src/web` — read-only React client (D52-L standalone build target; must not read SQLite/Pi RPC/JSONL directly). +- **Frontier:** `web-design-system-port` (PLAN §Frontier Definitions). Decision D72-L; invariant I43-L. +- **Posture:** earned (inherited from `web-design-system-port`) — target design exists and works in `../brunch/src/client`; each card materializes a known shape or deletes the invented aesthetic. +- **Open risk / micro-decision:** webfont delivery (Inter + Geist Mono). The old trunk imports `@fontsource-variable/inter` + `@fontsource-variable/geist-mono` (npm asset packages). Frontier says "no new packages," but the fonts *are* the most visible design token. Resolve in Card 1 (see its Risk). No other unknowns. +- **Cross-cutting obligations:** read-only contract (no web write paths, D33-L); node reference codes via canonical `NODE_KIND_METADATA` projection (D62-L), not a web-local relabeling; pre-release `migration: free-rewrite` — delete the invented design, don't preserve it; `sourcing: strip-or-build` — copy patterns, avoid new logic/framework deps. + +Reference source (separate checkout, **not** imported): `../brunch/src/client/index.css`, `../brunch/src/client/components/drawer-card.tsx`, `../brunch/src/client/components/knowledge-card.tsx`. + +--- + +## Card 1 — Port the token system into `styles.css` · status: done + +### Objective + +Replace the warm "brunch" theme in `src/web/styles.css` with the prior trunk's token system, so every subsequent view styles against `ink/sub/hint/rule/wash/tint`, the compact type scale, and `--shadow-card`. + +### Acceptance Criteria + +``` +✓ styles.css @theme defines: fonts (Inter sans + Geist Mono mono); gray ramp ink #202020 / sub #5b5b5b / hint #a6a6a6 / rule #e3e3e3 / wash #f0f0f0 / tint #fafafa; link #2070e6; the 11–16px type scale (xxs..base); --shadow-card / --shadow-ring / --shadow-card-ring +✓ no warm tokens remain (brunch-paper/card/rule/muted/accent/graph), no body radial/linear gradient, no backdrop-blur +✓ body background is plain (white/near-white), color-scheme light preserved, focus-visible outline retained (re-toned to the new palette) +✓ app still mounts and renders (npm run build:web succeeds; existing tests run) +``` + +### Verification Approach + +``` +- Inner: npm run verify (build:web compiles styles; vitest runs). Visual: load / in a browser. +``` + +### Cross-cutting obligations + +``` +- Tailwind v4 @theme block only; do not pull in shadcn semantic var layer (not needed by the ported primitives). +``` + +### Assumption dependency + +`None` — purely presentational; no SPEC assumption is load-bearing. + +### Webfont delivery — RESOLVED (user, 2026-06-09) + +Option (a): add `@fontsource-variable/inter` + `@fontsource-variable/geist-mono` and `@import` them in `styles.css`. The "no new packages" line was not a hard rule; webfonts are approved. + +### Expected touched paths (tentative) + +``` +src/web/ +├── styles.css ~ +package.json ? (only if fontsource option (a) chosen) +``` + +--- + +## Card 2 — Port card primitives into `src/web/components/` · status: done + +### Objective + +Create `src/web/components/` holding the ported `DrawerCard`, `KindBadge`, `CountBadge`, and a plane-organized node-kind → accent map adapted from the old `KnowledgeKind` vocabulary to this trunk's `NodeKind`. + +### Acceptance Criteria + +``` +✓ src/web/components/drawer-card.tsx ports DrawerCard verbatim-in-shape (useState toggle, summary/locked/compact variants, rounded-xl + border-rule + bg-tint + shadow-[var(--shadow-card)] nesting); no shadcn dependency +✓ src/web/components/node-card.tsx (or kind-badge.tsx) exposes KindBadge + CountBadge using NODE_KIND_METADATA labels for the prefix +✓ a kindAccent map is exhaustive over NodeKind via `satisfies Record`, organized by plane (intent/oracle/design/plan) — build fails if a kind is missing (I43-L) +✓ no new logic deps: className composition uses template literals or a tiny local cn (no clsx/tailwind-merge unless already present) +✓ npm run verify green +``` + +### Verification Approach + +``` +- Inner: npm run verify (type-aware oxlint proves the satisfies-exhaustiveness; vitest; build). Optional: a small unit test asserting KindBadge renders the NODE_KIND_METADATA label for a sample of kinds across all four planes. +``` + +### Cross-cutting obligations + +``` +- Reference codes come from NODE_KIND_METADATA + kindOrdinal (D62-L); do not hardcode a parallel prefix table. +- Only src/web imports from src/web/components/. +``` + +### Assumption dependency + +`None`. + +### Expected touched paths (tentative) + +``` +src/web/components/ +├── drawer-card.tsx + +├── node-card.tsx + (KindBadge, CountBadge, kindAccent map, node detail card) +└── node-card.test.tsx + (optional kind→label/accent coverage) +``` + +--- + +## Card 3 — Re-skin the three views; delete the invented aesthetic · status: done + +### Objective + +Restyle `WorkspaceChrome`, `GraphOverviewPanel`, and `SessionPanel` into the ported language — quiet metadata-row chrome, plane-accented kind-grouped node cards with canonical reference codes, a plain session card — removing all warm/gradient/translucent styling. **Scope correction (user, 2026-06-09):** this is a *style + component-pattern port of the views we have*, not a feature rewrite. Behavior is preserved except where it was invented dead scaffolding. + +### Scope decisions (user, 2026-06-09) + +- **"Focus node" — REMOVED.** It was a non-functional placeholder: clicking only rendered the string `Focused read pending: graph.nodeNeighborhood(...)` and never called the `graph.nodeNeighborhood` RPC. Pre-release `migration: free-rewrite` — delete invented aesthetic/scaffolding. (The real `nodeNeighborhood` query/subscription infra in `queries/graph.ts` + `subscriptions/` is untouched and still tested.) +- **"Edge categories" — KEPT.** User finds the per-category edge summary potentially useful; restyled (`RefBadge` chips) but behavior/text (`support: 1`) preserved. +- **Counts and group labels preserved** as `Nodes` / `Edges` / `LSN` and `plane / kind` (tested DOM contract), now rendered in the compact token style with a plane-accented `KindBadge`. + +### Acceptance Criteria + +``` +✓ WorkspaceChrome: opaque metadata-row card (rounded-xl, border-rule, bg-white, shadow-card; no rounded-[2rem], no bg-white/45, no backdrop-blur, no tracking-[0.35em] uppercase mono labels) +✓ GraphOverviewPanel: groups GraphSlice.nodes by `plane / kind`; each node renders a compact card (canonical reference code + title + body); plane-accented KindBadge + CountBadge per group; Edge-categories summary retained as RefBadge chips; Focus-node button + focused-read placeholder removed; no hover-lift animation +✓ SessionPanel + spec.tsx invalid banner: plain bordered cards in the new palette +✓ read-only contract intact: no edits to queries/, rpc-client, subscriptions/, routes loaders/params, or projection inputs (GraphSlice/WorkspaceState consumed as-is) +✓ src/web/app.test.tsx behavior preserved; only the two Focus-node assertions (+ now-unused fireEvent import) removed; all other assertions pass unchanged; npm run verify green (28 web tests, oxlint type-aware clean, build:web clean, no better-sqlite3 in bundle) +``` + +### Verification Approach + +``` +- Inner: npm run verify (vitest over updated web tests; build). +- Outer: manual browser check of / and /spec/$specId against a seeded spec (npm run seed, launch web mode) — chrome, kind-grouped graph cards, session panel match the prior trunk's look. +``` + +### Cross-cutting obligations + +``` +- No new RPC/query/route behavior — presentation only. +- Edge badges resolve target reference codes through NODE_KIND_METADATA + kindOrdinal (D62-L). +- empty-state ("No graph nodes yet…") preserved in the new styling. +``` + +### Assumption dependency + +`None`. + +### Expected touched paths (tentative) + +``` +src/web/ +├── routes/root.tsx ~ (WorkspaceChrome, SessionPanel) +├── routes/spec.tsx ~ (InvalidSpecRoutePage warm-styled banner) +├── features/graph/GraphOverview.tsx ~ +└── app.test.tsx ~ +``` diff --git a/package-lock.json b/package-lock.json index 46126c81..07dc15ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "@earendil-works/pi-ai": "^0.79.0", "@earendil-works/pi-coding-agent": "^0.79.0", "@earendil-works/pi-tui": "^0.79.0", + "@fontsource-variable/geist-mono": "^5.2.8", + "@fontsource-variable/inter": "^5.2.8", "@tanstack/react-query": "^5.100.14", "@tanstack/react-router": "^1.170.10", "react": "^19.2.7", @@ -1297,7 +1299,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "./dist/cli.js" + "pi-ai": "dist/cli.js" }, "engines": { "node": ">=22.19.0" @@ -3538,6 +3540,24 @@ } } }, + "node_modules/@fontsource-variable/geist-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist-mono/-/geist-mono-5.2.8.tgz", + "integrity": "sha512-KI5bj+hkkRiHttYHmccotUZ80ZuZyai+RwI1d7UId0clkx/jXxlo8qYK8j54WzmpBjtMoEMPyllV7faDcj+6RA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@google/genai": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", diff --git a/package.json b/package.json index 1726c32c..852a4d58 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "@earendil-works/pi-ai": "^0.79.0", "@earendil-works/pi-coding-agent": "^0.79.0", "@earendil-works/pi-tui": "^0.79.0", + "@fontsource-variable/geist-mono": "^5.2.8", + "@fontsource-variable/inter": "^5.2.8", "@tanstack/react-query": "^5.100.14", "@tanstack/react-router": "^1.170.10", "react": "^19.2.7", diff --git a/src/web/app.test.tsx b/src/web/app.test.tsx index 7020c9e7..ac2e038e 100644 --- a/src/web/app.test.tsx +++ b/src/web/app.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { QueryClient } from '@tanstack/react-query'; -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { GraphSlice, NodeNeighborhood } from '../graph/queries.js'; @@ -47,6 +47,26 @@ const selectedSpecWithoutSessionState: WorkspaceState = { }, }; +const emptySelectionState = { + status: 'select_spec', + requiresSelection: true, + cwd: '/tmp/brunch-project', + currentSpec: null, + currentSessionFile: null, + needsNewSpec: true, + specs: [], + unavailableSessions: [], +}; + +const populatedSelectionState = { + ...emptySelectionState, + needsNewSpec: false, + specs: [ + { spec: { id: 1, title: 'Web spec' }, sessions: [] }, + { spec: { id: 2, title: 'Second spec' }, sessions: [] }, + ], +}; + const emptyGraphOverview = { nodes: [], edges: [], @@ -103,6 +123,7 @@ const foundNeighborhood = { function rpcClient(options?: { state?: WorkspaceState; + selectionState?: unknown; graphOverview?: GraphSlice; nodeNeighborhood?: NodeNeighborhood; calls?: RpcCall[]; @@ -118,6 +139,9 @@ function rpcClient(options?: { if (method === 'workspace.state') { return state as T; } + if (method === 'workspace.selectionState') { + return (options?.selectionState ?? emptySelectionState) as T; + } if (method === 'session.runtimeState') { throw new Error('session.runtimeState is not implemented in this test client'); } @@ -155,6 +179,17 @@ describe('Brunch React web app', () => { expect(screen.getByText('responding-to-elicitation')).toBeTruthy(); }); + it('lists workspace specs as links to their spec routes', async () => { + const runtime = createBrunchWebRuntime({ + rpcClient: rpcClient({ selectionState: populatedSelectionState }), + }); + + render(); + + const secondSpecLink = await screen.findByRole('link', { name: /Second spec/u }); + expect(secondSpecLink.getAttribute('href')).toBe('/spec/2'); + }); + it('renders selected session identity without requesting session projections', async () => { const calls: RpcCall[] = []; const runtime = createBrunchWebRuntime({ rpcClient: rpcClient({ calls }) }); @@ -181,8 +216,6 @@ describe('Brunch React web app', () => { expect(screen.getAllByText('intent / assumption').length).toBeGreaterThan(0); expect(screen.getAllByText('intent / requirement').length).toBeGreaterThan(0); expect(screen.getByText('support: 1')).toBeTruthy(); - fireEvent.click(screen.getAllByText('Focus node')[0]!); - expect(screen.getByText('Focused read pending: graph.nodeNeighborhood(1, 11, 1)')).toBeTruthy(); }); it('derives graph overview presentation from GraphSlice arrays without count aliases', async () => { @@ -375,7 +408,9 @@ describe('Brunch React web app', () => { render(); expect(await screen.findByText('No Brunch session selected.')).toBeTruthy(); - expect(calls).toEqual([{ method: 'workspace.state' }]); + expect(calls).toContainEqual({ method: 'workspace.state' }); + expect(calls).toContainEqual({ method: 'workspace.selectionState' }); + expect(calls.some((call) => call.method.startsWith('session.'))).toBe(false); }); it('keeps one router and QueryClient across BrunchWebApp re-renders', async () => { diff --git a/src/web/components/drawer-card.tsx b/src/web/components/drawer-card.tsx new file mode 100644 index 00000000..fd433d57 --- /dev/null +++ b/src/web/components/drawer-card.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; + +// ── Drawer card — reusable card-with-collapsible-drawer ───────────── +// +// Ported from the prior trunk (../brunch/src/client/components/drawer-card.tsx). +// Toggle is always collapsed ↔ expanded. What "collapsed" looks like +// depends on whether a summary is provided: +// +// children | summary | Collapsed | Expanded +// ---------|---------|--------------------|--------- +// no | — | Static card | — +// yes | no | Fully closed | Full drawer +// yes | yes | Summary strip | Full drawer + +export function DrawerCard({ + header, + summary, + children, + defaultExpanded = false, + locked = false, + compact = false, +}: { + header: React.ReactNode; + summary?: React.ReactNode; + children?: React.ReactNode; + defaultExpanded?: boolean; + /** When true, the header is not clickable and state does not toggle. */ + locked?: boolean; + /** Tighter padding for sidebar/compact contexts. */ + compact?: boolean; +}) { + const canToggle = !!children && !locked; + const [expanded, setExpanded] = useState(defaultExpanded); + + const showDrawer = expanded || !!summary; + const drawerContent = expanded && children ? children : summary; + + const headerPadding = compact ? 'p-2.5' : 'p-4'; + const drawerPadding = compact ? 'px-2.5 pt-2 pb-2.5' : 'px-4 pt-3 pb-4'; + const drawerGap = compact ? 'gap-2' : 'gap-3'; + + const headerEl = canToggle ? ( + + ) : ( +
+ {header} +
+ ); + + if (!showDrawer) { + return ( +
+
{header}
+
+ ); + } + + return ( +
+ {headerEl} +
{drawerContent}
+
+ ); +} diff --git a/src/web/components/node-card.test.tsx b/src/web/components/node-card.test.tsx new file mode 100644 index 00000000..e58c32f7 --- /dev/null +++ b/src/web/components/node-card.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment jsdom + +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { KindBadge, nodeRefCode, PLANE_ACCENT } from './node-card.js'; + +afterEach(cleanup); + +describe('node-card primitives', () => { + it('renders canonical kind labels from NODE_KIND_METADATA', () => { + const cases = [ + { kind: 'goal', plane: 'intent', label: 'G' }, + { kind: 'criterion', plane: 'intent', label: 'AC' }, + { kind: 'check', plane: 'oracle', label: 'CH' }, + { kind: 'module', plane: 'design', label: 'MOD' }, + { kind: 'slice', plane: 'plan', label: 'S' }, + ] as const; + for (const { kind, plane, label } of cases) { + const { container } = render(); + expect(container.textContent).toBe(label); + } + }); + + it('formats reference codes from kind + ordinal (D62-L)', () => { + expect(nodeRefCode('goal', 1)).toBe('G1'); + expect(nodeRefCode('criterion', 3)).toBe('AC3'); + expect(nodeRefCode('context', 2)).toBe('CTX2'); + }); + + it('defines an accent for every plane', () => { + for (const plane of ['intent', 'oracle', 'design', 'plan'] as const) { + expect(PLANE_ACCENT[plane].text).toMatch(/^#[0-9a-f]{6}$/i); + expect(PLANE_ACCENT[plane].bg).toContain('rgba'); + } + }); +}); diff --git a/src/web/components/node-card.tsx b/src/web/components/node-card.tsx new file mode 100644 index 00000000..9859876f --- /dev/null +++ b/src/web/components/node-card.tsx @@ -0,0 +1,69 @@ +import { + formatGraphNodeCode, + NODE_KIND_METADATA, + type NodeKind, + type NodePlane, +} from '../../graph/schema/nodes.js'; + +// ── Node presentation primitives ────────────────────────────────────── +// +// Ported / adapted from the prior trunk's knowledge-card.tsx. The old UI +// keyed accents per KnowledgeKind; this trunk groups accents by the node's +// conceptual plane (intent / oracle / design / plan) — D67-L. Reference-code +// labels remain canonical: NODE_KIND_METADATA + kindOrdinal (D62-L). + +export interface PlaneAccent { + /** Foreground hue for the kind badge. */ + readonly text: string; + /** Faint tinted background for the kind badge. */ + readonly bg: string; +} + +// Accent per plane — exhaustive over NodePlane (I42-L). Adding a plane without +// an accent is a compile error via `satisfies`. +export const PLANE_ACCENT = { + intent: { text: '#2563eb', bg: 'rgba(37, 99, 235, 0.08)' }, + oracle: { text: '#16a34a', bg: 'rgba(22, 163, 74, 0.08)' }, + design: { text: '#9333ea', bg: 'rgba(147, 51, 234, 0.08)' }, + plan: { text: '#d97706', bg: 'rgba(217, 119, 6, 0.08)' }, +} as const satisfies Record; + +export function planeAccent(plane: NodePlane): PlaneAccent { + return PLANE_ACCENT[plane]; +} + +/** Canonical human reference code, e.g. `G1`, `CTX2`, `AC3` (D62-L). */ +export function nodeRefCode(kind: NodeKind, kindOrdinal: number): string { + return formatGraphNodeCode(kind, kindOrdinal); +} + +/** Small mono prefix badge tinted by the node's plane. */ +export function KindBadge({ kind, plane }: { kind: NodeKind; plane: NodePlane }) { + const accent = planeAccent(plane); + return ( + + {NODE_KIND_METADATA[kind].label} + + ); +} + +/** Small mono count chip. */ +export function CountBadge({ count }: { count: number }) { + return ( + + {count} + + ); +} + +/** Neutral reference-code chip used for edge "Links to:" targets. */ +export function RefBadge({ code }: { code: string }) { + return ( + + {code} + + ); +} diff --git a/src/web/features/graph/GraphOverview.tsx b/src/web/features/graph/GraphOverview.tsx index e1a9d3ce..b1c0c651 100644 --- a/src/web/features/graph/GraphOverview.tsx +++ b/src/web/features/graph/GraphOverview.tsx @@ -1,120 +1,106 @@ -import { useState } from 'react'; - import type { GraphSlice } from '../../../graph/queries.js'; +import { CountBadge, KindBadge, nodeRefCode, RefBadge } from '../../components/node-card.js'; + +type GraphNode = GraphSlice['nodes'][number]; export function GraphOverviewPanel(options: { overview: GraphSlice }) { const { overview } = options; - const [focusedNodeId, setFocusedNodeId] = useState(null); const nodeGroups = groupNodes(overview.nodes); const edgeSummary = summarizeEdges(overview.edges); - const focusedNode = - focusedNodeId === null ? undefined : overview.nodes.find((node) => node.id === focusedNodeId); return (
-
+
-

Selected spec

-

Graph overview

+

Selected spec

+

Graph overview

-
-
-
Nodes
-
{overview.nodes.length}
-
-
-
Edges
-
{overview.edges.length}
-
-
-
LSN
-
{overview.lsn}
-
+
+ + +
+ {overview.nodes.length === 0 ? ( -

+

{`No graph nodes yet. LSN ${overview.lsn}; 0 nodes; 0 edges.`}

- ) : null} - {overview.nodes.length > 0 ? ( -
-
-

- Edge categories -

+ ) : ( +
+
+

Edge categories

{edgeSummary.length === 0 ? ( -

No edges yet.

+

No edges yet.

) : ( -
    +
      {edgeSummary.map(([category, count]) => ( -
    • - {`${category}: ${count}`} +
    • +
    • ))}
    )}
+ {nodeGroups.map((group) => ( -
-

- {group.label} -

-
    +
    +
    + + {group.label} + +
    +
      {group.nodes.map((node) => (
    • -
      - - {node.title} - -

      {`${node.plane} / ${node.kind}`}

      - {node.body ?

      {node.body}

      : null} - -
      +
    • ))}
    ))}
- ) : null} - {focusedNode ? ( -

- {`Focused read pending: graph.nodeNeighborhood(${focusedNode.specId}, ${focusedNode.id}, 1)`} -

- ) : null} + )}
); } -function groupNodes(nodes: GraphSlice['nodes']): Array<{ - label: string; - nodes: GraphSlice['nodes']; -}> { - const groups = new Map>(); +function NodeCard({ node }: { node: GraphNode }) { + return ( +
+
+ + {nodeRefCode(node.kind, node.kindOrdinal)} + + {node.title} +
+ {node.body ?

{node.body}

: null} +
+ ); +} + +function CountStat({ label, value }: { label: string; value: number }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +function groupNodes(nodes: GraphSlice['nodes']): Array<{ label: string; nodes: GraphNode[] }> { + const groups = new Map(); for (const node of nodes) { const label = `${node.plane} / ${node.kind}`; const group = groups.get(label); - if (group) { - group.push(node); - } else { - groups.set(label, [node]); - } + if (group) group.push(node); + else groups.set(label, [node]); } return [...groups.entries()] .sort(([left], [right]) => left.localeCompare(right)) diff --git a/src/web/queries/workspace.ts b/src/web/queries/workspace.ts index 63defdfb..166624ac 100644 --- a/src/web/queries/workspace.ts +++ b/src/web/queries/workspace.ts @@ -1,6 +1,7 @@ import { queryOptions } from '@tanstack/react-query'; import type { WorkspaceState } from '../../projections/workspace/workspace-state.js'; +import type { WorkspaceLaunchInventory } from '../../session/workspace-session-coordinator.js'; import { queryKeys } from '../query-keys.js'; import type { WebSocketRpcClient } from '../rpc-client.js'; @@ -10,3 +11,16 @@ export function workspaceStateQueryOptions(rpcClient: WebSocketRpcClient) { queryFn: () => rpcClient.request('workspace.state'), }); } + +/** Read-only workspace inventory: the spec/session list shown on the root route. */ +export type WorkspaceSelectionState = WorkspaceLaunchInventory & { + status: string; + requiresSelection: boolean; +}; + +export function workspaceSelectionStateQueryOptions(rpcClient: WebSocketRpcClient) { + return queryOptions({ + queryKey: queryKeys.workspace.selectionState(), + queryFn: () => rpcClient.request('workspace.selectionState'), + }); +} diff --git a/src/web/query-keys.ts b/src/web/query-keys.ts index 407cefda..f121d5bb 100644 --- a/src/web/query-keys.ts +++ b/src/web/query-keys.ts @@ -1,6 +1,7 @@ export const queryKeys = { workspace: { state: () => ['workspace.state'] as const, + selectionState: () => ['workspace.selectionState'] as const, }, session: { runtimeState: (target: { specId: number; sessionId: string }) => diff --git a/src/web/routes/root.tsx b/src/web/routes/root.tsx index 5ec14f2c..8c5212ee 100644 --- a/src/web/routes/root.tsx +++ b/src/web/routes/root.tsx @@ -1,9 +1,13 @@ import { useSuspenseQuery, type QueryClient } from '@tanstack/react-query'; -import { Outlet, createRootRouteWithContext, createRoute } from '@tanstack/react-router'; +import { Link, Outlet, createRootRouteWithContext, createRoute } from '@tanstack/react-router'; import type { ReactNode } from 'react'; import type { WorkspaceState } from '../../projections/workspace/workspace-state.js'; -import { workspaceStateQueryOptions } from '../queries/workspace.js'; +import { + workspaceSelectionStateQueryOptions, + workspaceStateQueryOptions, + type WorkspaceSelectionState, +} from '../queries/workspace.js'; import type { WebSocketRpcClient } from '../rpc-client.js'; import { useBrunchUpdateSubscription } from '../subscriptions/brunch-updates.js'; @@ -23,7 +27,11 @@ export const rootRoute = createRootRouteWithContext()({ export const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - loader: ({ context }) => context.queryClient.ensureQueryData(workspaceStateQueryOptions(context.rpcClient)), + loader: ({ context }) => + Promise.all([ + context.queryClient.ensureQueryData(workspaceStateQueryOptions(context.rpcClient)), + context.queryClient.ensureQueryData(workspaceSelectionStateQueryOptions(context.rpcClient)), + ]), component: WorkspaceStatePage, }); @@ -49,16 +57,47 @@ function RootLayout() { function WorkspaceStatePage() { const { rpcClient } = indexRoute.useRouteContext(); const { data: state } = useSuspenseQuery(workspaceStateQueryOptions(rpcClient)); + const { data: selection } = useSuspenseQuery(workspaceSelectionStateQueryOptions(rpcClient)); return ( -
-

Brunch workspace

+
+

Brunch workspace

+
); } +function SpecList(options: { specs: WorkspaceSelectionState['specs'] }) { + return ( + + ); +} + export function WorkspaceChrome(options: { state: WorkspaceState; fallbackSpecId?: number }) { const { state } = options; const specLabel = @@ -67,29 +106,33 @@ export function WorkspaceChrome(options: { state: WorkspaceState; fallbackSpecId return (
-
-
cwd
-
{state.cwd}
-
-
-
spec
-
{specLabel}
-
-
-
session
-
- {state.session?.id ?? 'No session selected'} -
-
-
-
phase
-
{state.chrome.phase}
+
+
+
cwd
+
{state.cwd}
+
+
+
spec
+
{specLabel}
+
+
+
session
+
+ {state.session?.id ?? 'No session selected'} +
+
-
-
chat mode
-
{state.chrome.chatMode}
+
+
+
phase
+
{state.chrome.phase}
+
+
+
chat mode
+
{state.chrome.chatMode}
+
); @@ -118,10 +161,10 @@ export function SessionPanel(options: { state: WorkspaceState; viewedSpecId?: nu return (
-

Session

-
{content}
+

Session

+
{content}
); } diff --git a/src/web/routes/spec.tsx b/src/web/routes/spec.tsx index 93a86086..7f7fd3a0 100644 --- a/src/web/routes/spec.tsx +++ b/src/web/routes/spec.tsx @@ -33,10 +33,10 @@ function InvalidSpecRoutePage() { const { rpcClient } = specRoute.useRouteContext(); const { data: state } = useSuspenseQuery(workspaceStateQueryOptions(rpcClient)); return ( -
-

Brunch workspace

+
+

Brunch workspace

-

+

Invalid spec id.

@@ -49,8 +49,8 @@ function ValidSpecRoutePage({ specId }: { specId: number }) { const { data: overview } = useSuspenseQuery(graphOverviewQueryOptions(rpcClient, specId)); return ( -
-

Brunch workspace

+
+

Brunch workspace

diff --git a/src/web/styles.css b/src/web/styles.css index 8961eea9..93b2354e 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -1,31 +1,60 @@ @import 'tailwindcss'; +@import '@fontsource-variable/inter'; +@import '@fontsource-variable/geist-mono'; @theme { - --font-sans: 'IBM Plex Sans', 'Aptos', 'Segoe UI', system-ui, sans-serif; - --font-mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace; - --color-brunch-ink: #17130d; - --color-brunch-paper: #f8f2e7; - --color-brunch-card: #fffaf0; - --color-brunch-rule: #d8c6a6; - --color-brunch-muted: #756852; - --color-brunch-accent: #d35c2f; - --color-brunch-graph: #246a73; + --font-sans: 'Inter Variable', system-ui, sans-serif; + --font-mono: 'Geist Mono Variable', ui-monospace, monospace; + + /* Typography scale — six steps from 11px to 16px. + Overrides Tailwind's built-in sm/xs/base; adds xxs / xs-plus / sm-plus. */ + --text-xxs: 0.6875rem; /* 11px — badges, tag labels */ + --text-xxs--line-height: 1.7; + --text-xs: 0.75rem; /* 12px — secondary text */ + --text-xs--line-height: 1.6; + --text-xs-plus: 0.8125rem; /* 13px — secondary body, "why" text */ + --text-xs-plus--line-height: 1.55; + --text-sm: 0.875rem; /* 14px — body text */ + --text-sm--line-height: 1.5; + --text-sm-plus: 0.9375rem; /* 15px — card headings, question text */ + --text-sm-plus--line-height: 1.5; + --text-base: 1rem; /* 16px — section headings */ + --text-base--line-height: 1.4; + + /* Figma design grays — neutral ramp for text, borders, and surfaces. + Usage: text-ink, text-sub, text-hint, border-rule, bg-wash, bg-tint */ + --color-ink: #202020; /* primary text — almost-black */ + --color-sub: #5b5b5b; /* subtitles, section headers */ + --color-hint: #a6a6a6; /* IDs, breadcrumb inactive, placeholders */ + --color-rule: #e3e3e3; /* card borders, dividers */ + --color-wash: #f0f0f0; /* toggle tracks, ghost button fills */ + --color-tint: #fafafa; /* subtle background tint for card nesting */ + + /* Semantic accent — link color. Per-kind accents live in the TS accent map. */ + --color-link: #2070e6; + + /* Shadow tokens — the consistent subtle card shadow. */ + --shadow-card: 0px 4px 4px -2px rgba(0, 0, 0, 0.02), 0px 2px 2px -1px rgba(0, 0, 0, 0.02); + --shadow-ring: 0px 0px 0px 1px rgba(0, 0, 0, 0.08); + --shadow-card-ring: + 0px 4px 4px -2px rgba(0, 0, 0, 0.02), 0px 2px 2px -1px rgba(0, 0, 0, 0.02), + 0px 0px 0px 1px rgba(0, 0, 0, 0.08); } @layer base { html { color-scheme: light; font-family: var(--font-sans); - background: var(--color-brunch-paper); + background: #ffffff; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } body { min-width: 320px; margin: 0; - background: - radial-gradient(circle at top left, rgb(211 92 47 / 0.16), transparent 32rem), - linear-gradient(135deg, #fbf4e8 0%, #f3e6cf 48%, #eee0c8 100%); - color: var(--color-brunch-ink); + background: #ffffff; + color: var(--color-ink); } button, @@ -37,8 +66,8 @@ button:focus-visible, a:focus-visible { - outline: 2px solid var(--color-brunch-graph); - outline-offset: 3px; + outline: 2px solid var(--color-link); + outline-offset: 2px; } #root {