diff --git a/.flatbread-efforts/decisions/dec-brand-the-agent-memory-surface-as-crumb-trail--tngncepdbwjkh9jc.md b/.flatbread-efforts/decisions/dec-brand-the-agent-memory-surface-as-crumb-trail--tngncepdbwjkh9jc.md index 551729c5..217c5e94 100644 --- a/.flatbread-efforts/decisions/dec-brand-the-agent-memory-surface-as-crumb-trail--tngncepdbwjkh9jc.md +++ b/.flatbread-efforts/decisions/dec-brand-the-agent-memory-surface-as-crumb-trail--tngncepdbwjkh9jc.md @@ -9,6 +9,8 @@ derives_from: - fnd-workshop-shortlist-favors-crumb-graph-over-yeast--tv3ydt3wfb3n4y0g supersedes: - dec-brand-the-agent-memory-surface-as-crumb-graph--fvskcvagx3a7sybe +superseded_by: + - dec-keep-effort-graph-as-the-product-name--r5wr2vdjwjs9bs13 --- Supersedes Crumb Graph as the product name. Same system and deferred implementation; product brand is now Crumb Trail, with Crumb Graph retained as the datamodel explainer. diff --git a/.flatbread-efforts/decisions/dec-keep-effort-graph-as-the-product-name--r5wr2vdjwjs9bs13.md b/.flatbread-efforts/decisions/dec-keep-effort-graph-as-the-product-name--r5wr2vdjwjs9bs13.md new file mode 100644 index 00000000..7f0e8542 --- /dev/null +++ b/.flatbread-efforts/decisions/dec-keep-effort-graph-as-the-product-name--r5wr2vdjwjs9bs13.md @@ -0,0 +1,43 @@ +--- +id: dec-keep-effort-graph-as-the-product-name--r5wr2vdjwjs9bs13 +effort: eff-flatbread-product-branding--zt7b35sa05kyvhdz +title: Keep Effort Graph as the product name +state: accepted +created_at: '2026-07-26T03:27:20.788Z' +derives_from: + - fnd-crumb-rebrand-refuted-in-review-effort-graph-is--n8kb269z7vz8jhyk +supersedes: + - dec-brand-the-agent-memory-surface-as-crumb-trail--tngncepdbwjkh9jc +--- + +Supersedes Crumb Trail (and, transitively, Crumb Graph). The rebrand line is refuted: **Effort Graph** is the product name for Flatbread's longform agent memory, and crumb naming is dropped entirely rather than retained as a datamodel explainer. + +## Context + +Two prior Decisions moved the brand from Effort Graph to Crumb Graph and then to Crumb Trail, each time deferring the package, CLI, path, and skill renames. Review of the accumulated naming stack found the crumb metaphor works against the product: crumbs connote leftovers in a system whose pitch is durable, trustworthy reasoning, and _crumb trail_ is already taken by breadcrumb navigation in UI vocabulary. Meanwhile every identifier a user touches — `@flatbread/effort-graph`, `flatbread effort`, `.flatbread-efforts/`, the `effort-graph` skill — still said Effort. + +## Decision + +Adopt **Effort Graph** as the product name, not merely an allowed technical descriptor. Retire **Crumb Graph** and **Crumb Trail**; neither is a product name, a datamodel explainer, nor a documented alias. + +Describe the datamodel in plain terms — Effort-scoped relational records with typed edges and bounded digests — instead of coining a second branded layer for it. + +No rename work is required or deferred: code, packages, CLI, paths, and skills already match the product name, and that alignment is now the point rather than a coincidence. + +## Alternatives considered + +- **Keep Crumb Trail as the brand and finally execute the renames.** Rejected: it pays a full breaking-rename cost to buy a metaphor that review judged actively misleading. +- **Keep Crumb Trail for marketing, Effort Graph in code.** Rejected: this is the status quo the prior Decisions created, and it is the failure mode — a permanent gap between what the docs call the product and what every command and import path calls it. +- **Keep Crumb Graph as a datamodel explainer only.** Rejected: a second branded vocabulary for the same graph adds a translation step without adding precision over saying Effort-scoped records and edges. +- **Pick a third unleavened, Proof-peer name.** Rejected for now: naming churn has already cost two Decisions and two stale Issues, and no candidate beats the name that the API already teaches. + +## Consequences + +- Docs, skills copy, and product messaging use Effort Graph with no crumb aliases; existing crumb references are wording bugs to fix, not variants to preserve. +- The rename Issues under this Effort close as wontfix — the work is cancelled, not postponed. +- Brand and implementation vocabulary are unified, so future naming pressure has to argue for renaming real identifiers rather than only marketing copy. +- Flatbread gives up a bread-metaphor peer to Proof for this surface. That is accepted: primitive fidelity beats metaphor symmetry here. + +## Reversal criteria + +Revisit only if Effort Graph measurably fails external comprehension — for example if users read Effort as project-management effort tracking or story points rather than a thread of work — and a candidate name wins while committing to rename the packages, CLI, and paths in the same Decision rather than deferring them again. diff --git a/.flatbread-efforts/findings/fnd-crumb-rebrand-refuted-in-review-effort-graph-is--n8kb269z7vz8jhyk.md b/.flatbread-efforts/findings/fnd-crumb-rebrand-refuted-in-review-effort-graph-is--n8kb269z7vz8jhyk.md new file mode 100644 index 00000000..85293c71 --- /dev/null +++ b/.flatbread-efforts/findings/fnd-crumb-rebrand-refuted-in-review-effort-graph-is--n8kb269z7vz8jhyk.md @@ -0,0 +1,30 @@ +--- +id: fnd-crumb-rebrand-refuted-in-review-effort-graph-is--n8kb269z7vz8jhyk +effort: eff-flatbread-product-branding--zt7b35sa05kyvhdz +title: Crumb rebrand refuted in review; Effort Graph is the retained name +kind: dead-end +created_at: '2026-07-26T03:26:52.792Z' +derives_from: + - fnd-postable-brand-names-favor-trail-over-graph--zzpgaw0kvqtaqmxt + - fnd-workshop-shortlist-favors-crumb-graph-over-yeast--tv3ydt3wfb3n4y0g +--- + +## What happened + +The Crumb Graph → Crumb Trail branding line was grilled in review and refuted. The owner call is to keep **Effort Graph** as the product name for Flatbread's longform agent memory, not as a fallback technical alias. + +## Why the rebrand did not hold + +- **Crumb reads as leftovers, not memory.** In a product about durable reasoning, the metaphor undercuts the value claim: crumbs are what falls off the loaf, not the record you are meant to trust. +- **Crumb collides with breadcrumbs.** In UI vocabulary a crumb trail is a navigation path widget. Users landing on the docs pattern-match to breadcrumb navigation before they reach agent memory. +- **Effort Graph already names the load-bearing primitive.** Every record belongs to exactly one Effort. A brand that hides the Effort primitive forces a second vocabulary on top of the one the API, CLI, and skills already teach. +- **The rebrand's own reversal criteria fired.** Both prior Decisions listed external-comprehension failure and crumb ≈ breadcrumbs/scraps confusion as revisit triggers. Review found exactly those failures, so the Decisions closed themselves out on their stated terms. +- **Deferring implementation hid the cost.** Both Decisions deferred the package/CLI/path/skill renames, which kept the accepted brand permanently out of sync with every identifier a user actually touches. + +## Standing evidence + +The two workshop Findings remain accurate as records of what those exercises produced — the shortlist did favor crumb over yeast metaphors, and trail did test as more postable than graph. Neither is invalidated. What is refuted is treating brandability as decisive over primitive fidelity and collision risk. + +## Consequence + +The rename work tracked by both implementation Issues is a dead end and should close as wontfix rather than stay open as latent debt. diff --git a/.flatbread-efforts/findings/fnd-supersession-transitions-decision-state-on-the-r--2m807tcfjz2jz9gt.md b/.flatbread-efforts/findings/fnd-supersession-transitions-decision-state-on-the-r--2m807tcfjz2jz9gt.md new file mode 100644 index 00000000..cafad550 --- /dev/null +++ b/.flatbread-efforts/findings/fnd-supersession-transitions-decision-state-on-the-r--2m807tcfjz2jz9gt.md @@ -0,0 +1,33 @@ +--- +id: fnd-supersession-transitions-decision-state-on-the-r--2m807tcfjz2jz9gt +effort: eff-effort-graph-memory-and-agent-wedge--szeqvmgqjqnhd002 +title: >- + Supersession transitions Decision state on the retro-link path but not on + create +kind: survey +created_at: '2026-07-26T05:06:57.371Z' +--- + +## Observation + +`packages/effort-graph/src/planner.ts` has two code paths that create a `supersedes` edge, and they disagree about whether the superseded Decision changes state. + +- **Retro-link (`Supersede` mutation), planner.ts ~195-215.** When the target is a Decision it composes `supersedeDecisionLifecycle(snapshot, b.id).nextFrontmatter`, which sets `state: 'superseded'`, then appends the `superseded_by` back-pointer. +- **Inline on create (`WriteDecision` with `supersedes: [...]`), planner.ts ~127-160.** The generic forward-edge loop only appends the `superseded_by` reverse projection. It never consults `supersedeDecisionLifecycle` and never touches `state`. + +## Evidence + +Two records in this repo's own graph show the create-path outcome — both carry `superseded_by` while still reporting `state: accepted`: + +- `dec-brand-the-agent-memory-surface-as-crumb-graph--fvskcvagx3a7sybe` +- `dec-brand-the-agent-memory-surface-as-crumb-trail--tngncepdbwjkh9jc` + +`DecisionFrontmatterSchema` in `schemas.ts` already admits `'superseded'`, and `decision-lifecycle.ts` exists to produce it, so this is an inconsistency between the two paths rather than a deliberate modelling choice. + +The blind spot is a test gap: the planner suite covers the create path with `supersedes` only for Findings, which have no state field, so the Decision case is unexercised. + +## Why it matters beyond cosmetics + +- `planner.ts` gates `MitigateRisk` on `state !== 'accepted'`, so a superseded Decision can currently mitigate a Risk. +- `flatbread effort records --state accepted` returns retired Decisions. +- Consumers reading `state` directly label retired reasoning as committed. `examples/effort-viz` now carries a dedicated module (`lib/lifecycle.ts`) that derives effective lifecycle from edges specifically to work around this. diff --git a/.flatbread-efforts/issues/iss-implement-crumb-graph-rename-across-packages-cli--dp6jvt2kafab7m4t.md b/.flatbread-efforts/issues/iss-implement-crumb-graph-rename-across-packages-cli--dp6jvt2kafab7m4t.md index 520ea1b1..9e236c50 100644 --- a/.flatbread-efforts/issues/iss-implement-crumb-graph-rename-across-packages-cli--dp6jvt2kafab7m4t.md +++ b/.flatbread-efforts/issues/iss-implement-crumb-graph-rename-across-packages-cli--dp6jvt2kafab7m4t.md @@ -3,12 +3,14 @@ id: iss-implement-crumb-graph-rename-across-packages-cli--dp6jvt2kafab7m4t effort: eff-flatbread-product-branding--zt7b35sa05kyvhdz title: 'Implement Crumb Graph rename across packages, CLI, paths, and skills' kind: gap -status: open +status: wontfix created_at: '2026-07-19T03:45:50.712Z' derives_from: - dec-brand-the-agent-memory-surface-as-crumb-graph--fvskcvagx3a7sybe superseded_by: - iss-implement-crumb-trail-rename-across-packages-cli--316rdzpt80sdxkw1 +resolved_by: + - dec-keep-effort-graph-as-the-product-name--r5wr2vdjwjs9bs13 --- Branding Decision accepts Crumb Graph as the product name with Effort Graph as descriptor. Implementation is pending and out of scope for the branding commit. @@ -16,3 +18,5 @@ Branding Decision accepts Crumb Graph as the product name with Effort Graph as d Likely touchpoints (non-exhaustive): `@flatbread/effort-graph`, `effortGraphContent`, `flatbread effort` CLI, `.flatbread-efforts/`, `.flatbread/effort-graph/`, agent skill name/paths, docs/README copy, error codes (`EFFORT_GRAPH_*`), and dogfood references. Do not start the mechanical rename until an implementation plan chooses which identifiers move vs stay for compatibility. + +Resolution: cancelled as `wontfix`; product name is Effort Graph; resolved by `dec-keep-effort-graph-as-the-product-name--r5wr2vdjwjs9bs13`; do not reopen rename work. diff --git a/.flatbread-efforts/issues/iss-implement-crumb-trail-rename-across-packages-cli--316rdzpt80sdxkw1.md b/.flatbread-efforts/issues/iss-implement-crumb-trail-rename-across-packages-cli--316rdzpt80sdxkw1.md index c3179aaf..3de9e4e5 100644 --- a/.flatbread-efforts/issues/iss-implement-crumb-trail-rename-across-packages-cli--316rdzpt80sdxkw1.md +++ b/.flatbread-efforts/issues/iss-implement-crumb-trail-rename-across-packages-cli--316rdzpt80sdxkw1.md @@ -3,12 +3,14 @@ id: iss-implement-crumb-trail-rename-across-packages-cli--316rdzpt80sdxkw1 effort: eff-flatbread-product-branding--zt7b35sa05kyvhdz title: 'Implement Crumb Trail rename across packages, CLI, paths, and skills' kind: gap -status: open +status: wontfix created_at: '2026-07-19T03:54:27.962Z' derives_from: - dec-brand-the-agent-memory-surface-as-crumb-trail--tngncepdbwjkh9jc supersedes: - iss-implement-crumb-graph-rename-across-packages-cli--dp6jvt2kafab7m4t +resolved_by: + - dec-keep-effort-graph-as-the-product-name--r5wr2vdjwjs9bs13 --- Supersedes the Crumb Graph rename Issue. Branding now targets Crumb Trail as the product name (Crumb Graph = datamodel explainer; Effort Graph = technical descriptor). @@ -16,3 +18,5 @@ Supersedes the Crumb Graph rename Issue. Branding now targets Crumb Trail as the Implementation remains pending. Likely touchpoints (non-exhaustive): `@flatbread/effort-graph`, `effortGraphContent`, `flatbread effort` CLI, `.flatbread-efforts/`, `.flatbread/effort-graph/`, agent skill name/paths, docs/README copy, error codes (`EFFORT_GRAPH_*`), and dogfood references. Do not start the mechanical rename until an implementation plan chooses which identifiers move vs stay for compatibility, and how Crumb Trail / Crumb Graph / Effort Graph map onto public vs internal names. + +Resolution: cancelled as `wontfix`; product name is Effort Graph; resolved by `dec-keep-effort-graph-as-the-product-name--r5wr2vdjwjs9bs13`; do not reopen rename work. diff --git a/.flatbread-efforts/issues/iss-writedecision-with-supersedes-leaves-the-superse--by624gyf21ex42sv.md b/.flatbread-efforts/issues/iss-writedecision-with-supersedes-leaves-the-superse--by624gyf21ex42sv.md new file mode 100644 index 00000000..9ba4d4a9 --- /dev/null +++ b/.flatbread-efforts/issues/iss-writedecision-with-supersedes-leaves-the-superse--by624gyf21ex42sv.md @@ -0,0 +1,26 @@ +--- +id: iss-writedecision-with-supersedes-leaves-the-superse--by624gyf21ex42sv +effort: eff-effort-graph-memory-and-agent-wedge--szeqvmgqjqnhd002 +title: WriteDecision with supersedes leaves the superseded Decision in state accepted +kind: defect +status: open +created_at: '2026-07-26T05:07:15.240Z' +derives_from: + - fnd-supersession-transitions-decision-state-on-the-r--2m807tcfjz2jz9gt +--- + +## Problem + +Creating a Decision with an inline `supersedes` edge appends the `superseded_by` reverse projection to the target but leaves its `state` untouched, so a replaced Decision keeps reporting `state: accepted`. The `Supersede` retro-link mutation on the same target does set `state: superseded`. Same semantic act, two different outcomes depending on which mutation got there first. + +## Fix sketch + +In the create-path forward-edge loop in `packages/effort-graph/src/planner.ts`, when `edge === 'supersedes'` and the target is a Decision, compose `supersedeDecisionLifecycle(snapshot, target.id).nextFrontmatter` before appending the back-pointer — the same composition the `Supersede` branch already performs. Add a planner test mirroring the existing retro-link supersession test but driven through `WriteDecision`, since the current create-path test only covers Findings and so cannot catch this. + +## Repair + +Two records in this repo already carry the bad shape (`dec-brand-the-agent-memory-surface-as-crumb-graph--fvskcvagx3a7sybe`, `dec-brand-the-agent-memory-surface-as-crumb-trail--tngncepdbwjkh9jc`). Repair them through the reindexer rather than by hand-editing frontmatter. + +## Scope note + +Consumers should keep deriving supersession from edges regardless of the fix: forward edges are the authoritative representation, and edge-derived state also covers legacy and hand-edited records. `examples/effort-viz/lib/lifecycle.ts` does this and should not be reverted once the writer is corrected. diff --git a/examples/effort-viz/README.md b/examples/effort-viz/README.md index 8d9d39b3..d13feb10 100644 --- a/examples/effort-viz/README.md +++ b/examples/effort-viz/README.md @@ -36,19 +36,69 @@ Flatbread serves GraphQL at **`http://localhost:5057/graphql`**. The app subscribes to **`http://localhost:5057/events`** (SSE) for schema generation updates. -## What you get +## What you can read off it + +Within a few seconds of opening the page you should be able to answer: + +- **What kinds of records are here?** Every primitive has its own hue *and* its + own silhouette — Issues are amber diamonds, Findings blue circles, Decisions + violet squares, Constraints green/teal slabs (flat bars), Risks red triangles, + and each Effort is a ring whose core carries that cluster's tint. +- **What is still live, and what got overturned?** Rejected, superseded, + invalidated, won't-fix, deprecated, and abandoned records fade to a + desaturated ghost with a struck-through label. Supersession is derived from + the graph's edges rather than from frontmatter, because forward edges are the + authoritative representation and `state` can lag behind them — a Decision + replaced through an inline `supersedes` still records `state: accepted`, so + reading the field alone would label retired reasoning as committed. +- **What is blocking?** Open Issues with `kind: blocker` wear an amber warning + outline. +- **How much work is tracked?** The header counts primitives and lifecycle + (`5 Efforts · 4 open Issues · 3 proposed Decisions`) rather than nodes and + edges — roughly half the "edges" are synthesised membership spokes, so a raw + edge count flatters the graph without informing anyone. + +## Encoding notes + +Hue belongs to the **primitive**, not the Effort. Effort membership is already +carried by three other channels — the force layout pulls same-Effort records to +a shared centroid, each cluster has a large labelled hub, and membership spokes +take the cluster's tint — so spending the strongest nominal channel on it left +record kind with nothing. Shape repeats hue as a colour-vision backstop, since +amber/red and blue/violet partially merge under deuteranopia. Silhouettes are +area-normalized (`lib/glyphs.ts`) so a triangle and a square read at the same +visual weight; otherwise size would imply an importance ranking nobody +intended. + +The legend derives its swatches from the same outlines and palette the canvas +builds geometry from (`lib/glyphs.ts`, `lib/primitives.ts`), so it cannot drift +from the render, and it only lists the relations the current generation actually +contains. + +## Other features - **Live graph** — `useEffortGraphLive` opens an `EventSource` on `/events`. On `ready` and each `generation` event it refetches the Effort Graph query and - updates the canvas. The status pill shows connecting / live / disconnected and - the current generation. + updates the canvas. The status pill shows connecting / live / partial / + disconnected / error and the current generation. **Partial** means records + loaded but relationship fields could not be confirmed yet — retirement links + may be missing until the next successful schema probe. - **Watch mode** — `flatbread start --watch` reloads content and config changes - under `.flatbread-efforts`. Edit an effort, issue, or finding file and the + under `.flatbread-efforts`. Edit an Effort, Issue, or Finding file and the graph animates in/out without restarting Next. -- **R3F canvas** — orthographic 2D scene with pan/zoom, node labels, edge - “veins”, spawn/retract physics, and a detail drawer on node click. -- **Theme** — sun/moon toggle in the top bar; preference persists in - `localStorage` (`effort-viz-theme`) with a boot script to avoid FOUC. +- **R3F canvas** — orthographic 2D scene with pan/zoom, cluster labels, edge + “veins”, spawn/retract physics, and a detail drawer on record click. +- **Keyboard** — Tab to the canvas, then arrow keys to walk records in a stable + Effort-then-primitive order, Enter to open the drawer, Escape to close. The + camera follows focus and each move is announced to screen readers. The canvas + itself is still a WebGL surface, so this is a focus proxy rather than a full + DOM mirror of the graph. +- **Reduced motion** — `prefers-reduced-motion` settles the layout and finishes + every growth animation before the first paint, and the camera snaps instead + of easing. +- **Theme** — sun/moon toggle in the top bar. The app follows + `prefers-color-scheme` until you pick a mode, after which the choice persists + in `localStorage` (`effort-viz-theme`) with a boot script to avoid FOUC. ## Scripts @@ -58,7 +108,7 @@ updates. | `pnpm play:efforts` | Same as `dev`, from the monorepo root. | | `pnpm --filter effort-viz build` | `flatbread start` wrapping `next build` (Flatbread must be up during the build). | | `pnpm --filter effort-viz start` | Production Next only (`next start`); run Flatbread separately if needed. | -| `pnpm --filter effort-viz test` | Physics/simulation unit tests under `lib/physics/`. | +| `pnpm --filter effort-viz test` | Unit tests: physics/simulation, normalizer, lifecycle derivation, glyph invariants. | | `pnpm --filter effort-viz exec tsc --noEmit` | Typecheck without running dev servers. | ## Configuration @@ -74,7 +124,13 @@ updates. - `app/` — layout, theme tokens, R3F canvas and UI chrome - `app/hooks/useTheme.tsx` — light/dark context + FOUC boot script - `app/components/` — `EffortGraphApp`, `GraphCanvas`, `TopBar`, `Legend`, - `DetailDrawer` + `DetailDrawer`, `RelationLegend` (shared relation + badge metadata) +- `lib/primitives.ts` — per-primitive label, hue, and glyph: the encoding's + single source of truth +- `lib/glyphs.ts` — area-normalized glyph outlines shared by the canvas and the + legend +- `lib/lifecycle.ts` — effective lifecycle derived from edges, plus the header + summary - `lib/physics/` — force simulation, growth, and layout helpers - `lib/query.ts` — Effort Graph GraphQL query - `flatbread.config.js` — Effort Graph content preset diff --git a/examples/effort-viz/app/components/DetailDrawer.tsx b/examples/effort-viz/app/components/DetailDrawer.tsx index de54246f..0d9a963d 100644 --- a/examples/effort-viz/app/components/DetailDrawer.tsx +++ b/examples/effort-viz/app/components/DetailDrawer.tsx @@ -1,16 +1,20 @@ 'use client'; -import { useMemo } from 'react'; -import { nodeColor } from '@/lib/oklch'; +import { useEffect, useMemo, useRef } from 'react'; +import { oklchCss, effortColor, retiredOklch } from '@/lib/oklch'; +import { PRIMITIVES, primitiveOklch } from '@/lib/primitives'; +import { effectiveLifecycle, type LifecycleIndex } from '@/lib/lifecycle'; import type { GraphEdge, GraphNode } from '@/lib/types'; import { useTheme } from '../hooks/useTheme'; import { MarkdownSurface } from './MarkdownSurface'; import { + PrimitiveGlyph, RELATION_GROUP_LABEL, RELATION_GROUP_ORDER, RELATION_META, RelationLineSample, lifecycleBadge, + relationStrokeOklch, type RelationGroupId, } from './RelationLegend'; @@ -18,19 +22,11 @@ interface DetailDrawerProps { node: GraphNode | null; edges: GraphEdge[]; nodesById: Map; + lifecycleIndex: LifecycleIndex; onClose: () => void; onSelect: (id: string | null) => void; } -const KIND_LABEL: Record = { - effort: 'Effort', - issue: 'Issue', - finding: 'Finding', - decision: 'Decision', - constraint: 'Constraint', - risk: 'Risk', -}; - type DirectedEdge = GraphEdge & { direction: 'outgoing' | 'incoming' }; interface GroupedRelations { @@ -42,10 +38,45 @@ export function DetailDrawer({ node, edges, nodesById, + lifecycleIndex, onClose, onSelect, }: DetailDrawerProps) { const { mode } = useTheme(); + const headingRef = useRef(null); + const nodeId = node?.id ?? null; + + // Escape closes from anywhere, including while the graph canvas has focus. + useEffect(() => { + if (!nodeId) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [nodeId, onClose]); + + /* + * Move focus to the heading whenever a different record is shown, and hand it + * back to the graph on close. + * + * Without this, opening the panel from the keyboard leaves focus on the canvas + * and a screen reader is told nothing: the body and relations — the entire + * reason to open a record — stay unreachable. Following a relation also + * unmounts the button that was focused, which would otherwise drop focus to + * the document body. + */ + useEffect(() => { + if (!nodeId) return; + headingRef.current?.focus(); + return () => { + const canvas = document.querySelector('[role="application"]'); + // Only reclaim focus if it is still inside the panel being torn down. + if (document.activeElement?.closest('aside[aria-labelledby]')) { + canvas?.focus(); + } + }; + }, [nodeId]); const groupedRelations = useMemo((): GroupedRelations[] => { if (!node) return []; @@ -76,74 +107,87 @@ export function DetailDrawer({ }); }, [node, edges]); - const navigateTarget = useMemo(() => { + const resolveRecord = useMemo(() => { const bySlug = new Map(); - const byId = new Map(); for (const n of nodesById.values()) { - byId.set(n.id, n.id); if (n.slug) bySlug.set(n.slug, n.id); } - return (target: string) => { - const id = byId.get(target) ?? bySlug.get(target); - if (id) onSelect(id); - }; - }, [nodesById, onSelect]); + return (target: string): string | null => + (nodesById.has(target) ? target : bySlug.get(target)) ?? null; + }, [nodesById]); if (!node) return null; - const lifecycle = node.lifecycle ?? node.status ?? node.state; - const badge = lifecycleBadge(lifecycle); + const primitive = PRIMITIVES[node.kind]; + const life = effectiveLifecycle(node, lifecycleIndex); + const badge = lifecycleBadge(node.kind, life.state); + const retired = life.aliveness === 'retired'; const hasRelations = groupedRelations.length > 0; const hasBody = Boolean(node.body?.trim()); - const effortId = node.effortId ?? node.id; - const swatch = nodeColor(effortId, node.kind, mode).css; + + const tint = + node.kind === 'effort' + ? effortColor(node.id, mode).css + : oklchCss( + retired + ? retiredOklch(primitiveOklch(node.kind, mode), mode) + : primitiveOklch(node.kind, mode) + ); return ( -