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/.gitignore b/.gitignore index 1f2d5756..ec928b8e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ yarn-error.log .pnpm-debug.log **/.flatbread-efforts/.journal/ -**/.flatbread/effort-graph/read-cache/ \ No newline at end of file +**/.flatbread/effort-graph/read-cache/ +# proof / local DAG artifacts +.flatbread/artifacts/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 956ddee6..9c7e08b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,7 @@ Optional **`pnpm play`** from the repo root is a shortcut for **`cd examples/nex - Build all packages: `pnpm build` - **Workspace libraries (watch-only):** `pnpm dev` — runs package `dev` scripts (e.g. `tsup --watch`) for `packages/*`; it does **not** start the Next.js example. - **Next.js example:** prefer the flow under [Recommended onboarding](#recommended-onboarding-try-flatbread-in-the-nextjs-example); or `pnpm play` as a convenience alias. +- **Effort Graph viz (`examples/effort-viz`):** after `pnpm build`, run `pnpm play:efforts` (or `pnpm --filter effort-viz dev`) to dogfood `.flatbread-efforts` with live SSE updates — see that example's README. - Check local CI parity before opening a PR: `pnpm verify` ## Working on a package diff --git a/examples/effort-viz/.gitignore b/examples/effort-viz/.gitignore new file mode 100644 index 00000000..20fec1a7 --- /dev/null +++ b/examples/effort-viz/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem +.flatbread-codegen-cache.json + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/examples/effort-viz/README.md b/examples/effort-viz/README.md new file mode 100644 index 00000000..d13feb10 --- /dev/null +++ b/examples/effort-viz/README.md @@ -0,0 +1,155 @@ +# Effort Graph Visualization (Next.js + R3F) + +Next.js example that dogfoods the monorepo's Effort Graph content at +`.flatbread-efforts`. It renders an interactive 2D force-directed graph with +`@react-three/fiber`, subscribes to Flatbread live schema generations over SSE, +and ships a Vercel-like light/dark UI shell. + +## Prerequisites + +From the **monorepo root**: + +```bash +pnpm install +pnpm build +``` + +Build workspace packages (especially `flatbread`) before starting the example. +The dev script wraps `flatbread start --watch`, which needs compiled package +output. + +## Quick start + +```bash +pnpm --filter effort-viz dev +``` + +From the repo root you can also use the convenience alias: + +```bash +pnpm play:efforts +``` + +Then open **[http://localhost:3000](http://localhost:3000)**. + +Flatbread serves GraphQL at **`http://localhost:5057/graphql`**. The app +subscribes to **`http://localhost:5057/events`** (SSE) for schema generation +updates. + +## 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 / 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 + graph animates in/out without restarting Next. +- **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 + +| Script | Purpose | +| --- | --- | +| `pnpm --filter effort-viz dev` | `flatbread start --watch` + Next dev (Turbopack). GraphQL on **5057**, Next on **3000**. | +| `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` | Unit tests: physics/simulation, normalizer, lifecycle derivation, glyph invariants. | +| `pnpm --filter effort-viz exec tsc --noEmit` | Typecheck without running dev servers. | + +## Configuration + +- `flatbread.config.js` — loads effort graph collections from + `../../.flatbread-efforts` via `effortGraphContent()`. +- `lib/graphql.ts` — `graphqlFetch` helper (default endpoint + `http://localhost:5057/graphql`). +- `lib/useEffortGraphLive.ts` — SSE subscription + GraphQL refetch loop. + +## Project structure + +- `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`, `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 + +## Troubleshooting + +### Empty graph or “Connecting” forever + +Ensure Flatbread is running on port **5057**. Use `pnpm --filter effort-viz dev` +(or `pnpm play:efforts`), not `next dev` alone. + +### Typecheck / build + +```bash +pnpm build +pnpm --filter effort-viz exec tsc --noEmit +pnpm --filter effort-viz build +``` + +Production build starts Flatbread briefly so Next can typecheck; you may see a +non-fatal ESLint config warning from the root toolchain — the build still +completes. diff --git a/examples/effort-viz/app/components/DetailDrawer.tsx b/examples/effort-viz/app/components/DetailDrawer.tsx new file mode 100644 index 00000000..0d9a963d --- /dev/null +++ b/examples/effort-viz/app/components/DetailDrawer.tsx @@ -0,0 +1,369 @@ +'use client'; + +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'; + +interface DetailDrawerProps { + node: GraphNode | null; + edges: GraphEdge[]; + nodesById: Map; + lifecycleIndex: LifecycleIndex; + onClose: () => void; + onSelect: (id: string | null) => void; +} + +type DirectedEdge = GraphEdge & { direction: 'outgoing' | 'incoming' }; + +interface GroupedRelations { + group: RelationGroupId; + edges: DirectedEdge[]; +} + +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 []; + + const buckets = new Map(); + for (const edge of edges) { + let direction: DirectedEdge['direction'] | null = null; + if (edge.source === node.id) direction = 'outgoing'; + else if (edge.target === node.id) direction = 'incoming'; + if (!direction) continue; + + const group = RELATION_META[edge.kind].group; + const list = buckets.get(group) ?? []; + list.push({ ...edge, direction }); + buckets.set(group, list); + } + + return RELATION_GROUP_ORDER.flatMap((group) => { + const groupEdges = buckets.get(group); + if (!groupEdges || groupEdges.length === 0) return []; + groupEdges.sort((a, b) => { + if (a.direction !== b.direction) { + return a.direction === 'outgoing' ? -1 : 1; + } + return a.kind.localeCompare(b.kind); + }); + return [{ group, edges: groupEdges }]; + }); + }, [node, edges]); + + const resolveRecord = useMemo(() => { + const bySlug = new Map(); + for (const n of nodesById.values()) { + if (n.slug) bySlug.set(n.slug, n.id); + } + return (target: string): string | null => + (nodesById.has(target) ? target : bySlug.get(target)) ?? null; + }, [nodesById]); + + if (!node) return null; + + 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 tint = + node.kind === 'effort' + ? effortColor(node.id, mode).css + : oklchCss( + retired + ? retiredOklch(primitiveOklch(node.kind, mode), mode) + : primitiveOklch(node.kind, mode) + ); + + return ( + + ); +} + +/** Frontmatter values are lowercase tokens; display them as prose. */ +function sentenceCase(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function formatDate(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +function MetaRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ + {label} + + + {value} + +
+ ); +} + +function RelationRow({ + edge, + nodesById, + lifecycleIndex, + mode, + onSelect, +}: { + edge: DirectedEdge; + nodesById: Map; + lifecycleIndex: LifecycleIndex; + mode: ReturnType['mode']; + onSelect: (id: string | null) => void; +}) { + const meta = RELATION_META[edge.kind]; + const peerId = edge.direction === 'outgoing' ? edge.target : edge.source; + const peer = nodesById.get(peerId); + const hint = meta.directionHint[edge.direction]; + const peerLife = peer ? effectiveLifecycle(peer, lifecycleIndex) : null; + const peerRetired = peerLife?.aliveness === 'retired'; + const stroke = + meta.group === 'membership' + ? undefined + : oklchCss(relationStrokeOklch(meta.group, mode)); + + return ( +
  • + +
  • + ); +} diff --git a/examples/effort-viz/app/components/EffortGraphApp.tsx b/examples/effort-viz/app/components/EffortGraphApp.tsx new file mode 100644 index 00000000..4854b7bd --- /dev/null +++ b/examples/effort-viz/app/components/EffortGraphApp.tsx @@ -0,0 +1,146 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { useEffect, useMemo } from 'react'; + +import { useEffortGraphLive } from '@/lib/useEffortGraphLive'; +import { + buildAlivenessMap, + buildLifecycleIndex, + summarizeGraph, +} from '@/lib/lifecycle'; +import type { GraphNode } from '@/lib/types'; + +import { TopBar } from './TopBar'; +import { Legend } from './Legend'; +import { DetailDrawer } from './DetailDrawer'; +import { RELATION_META, type RelationGroupId } from './RelationLegend'; + +const GraphCanvas = dynamic(() => import('./GraphCanvas'), { + ssr: false, + loading: () => ( +
    + Booting canvas… +
    + ), +}); + +export function EffortGraphApp() { + const { + nodes, + edges, + status, + generation, + error, + selectedId, + setSelectedId, + } = useEffortGraphLive(); + + const nodesById = useMemo(() => { + const map = new Map(); + for (const n of nodes) map.set(n.id, n); + return map; + }, [nodes]); + + const lifecycleIndex = useMemo(() => buildLifecycleIndex(edges), [edges]); + const summary = useMemo( + () => summarizeGraph(nodes, buildAlivenessMap(nodes, edges)), + [nodes, edges] + ); + + const efforts = useMemo( + () => + nodes + .filter((n) => n.kind === 'effort') + .sort((a, b) => a.title.localeCompare(b.title)), + [nodes] + ); + + /** Only key the relations the current generation actually contains. */ + const presentGroups = useMemo(() => { + const groups = new Set(); + for (const edge of edges) groups.add(RELATION_META[edge.kind].group); + return groups; + }, [edges]); + + const selectedNode = selectedId ? (nodesById.get(selectedId) ?? null) : null; + + // A selected record can vanish on a live update; don't keep a dangling id + // that would silently reopen the drawer if the same id returns. + useEffect(() => { + if (selectedId && !nodesById.has(selectedId)) setSelectedId(null); + }, [selectedId, nodesById, setSelectedId]); + + return ( +
    +
    + +
    + +
    + {nodes.length === 0 ? ( + + ) : ( +
    + +
    + )} + +
    +
    + {nodes.length > 0 && ( + + )} +
    +
    + + {selectedNode && ( +
    + setSelectedId(null)} + onSelect={setSelectedId} + /> +
    + )} +
    +
    + ); +} + +function EmptyState({ + status, + error, +}: { + status: ReturnType['status']; + error: Error | null; +}) { + const [heading, message] = + status === 'connecting' + ? ['Connecting to Flatbread', 'Waiting for the live schema on port 5057.'] + : status === 'error' || status === 'disconnected' + ? [ + "Can't reach Flatbread", + error?.message ?? + 'Start the dev server with `pnpm play:efforts` so GraphQL is served on port 5057.', + ] + : [ + 'No Effort Graph records yet', + 'Nothing found in .flatbread-efforts. Journal a record and it will grow in here.', + ]; + + return ( +
    +

    {heading}

    +

    {message}

    +
    + ); +} diff --git a/examples/effort-viz/app/components/GraphCanvas.tsx b/examples/effort-viz/app/components/GraphCanvas.tsx new file mode 100644 index 00000000..171fcfcc --- /dev/null +++ b/examples/effort-viz/app/components/GraphCanvas.tsx @@ -0,0 +1,1295 @@ +'use client'; + +import { Canvas, useFrame, useThree } from '@react-three/fiber'; +import { Html, OrbitControls } from '@react-three/drei'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import * as THREE from 'three'; +import type { Group, Mesh, OrthographicCamera } from 'three'; + +/** Minimal controls surface used by the camera helpers (drei OrbitControls). */ +interface PanZoomControlsHandle { + target: THREE.Vector3; + update: () => void; +} + +import { + createGraphSimulation, + effectiveRadius, + isEdgeGone, + veinTipPolyline, + type GraphInputEdge, + type GraphInputNode, + type GraphSimulation, + type SimEdge, + type SimNode, + type VeinPoint, +} from '@/lib/physics'; +import { + effortOklch, + oklchToThreeColor, + retiredOklch, + structuralOklch, + type Oklch, +} from '@/lib/oklch'; +import { PRIMITIVES, primitiveOklch } from '@/lib/primitives'; +import { + CIRCLE_SEGMENTS, + GLYPH_OUTLINES, + RING_INNER_RATIO, + glyphExtent, + type GlyphId, +} from '@/lib/glyphs'; +import { + buildAlivenessMap, + isOpenBlocker, + type Aliveness, + type EffectiveLifecycle, +} from '@/lib/lifecycle'; +import type { GraphEdge, GraphEdgeKind, GraphNode } from '@/lib/types'; +import { useTheme, type ColorMode } from '../hooks/useTheme'; +import { RELATION_META, relationStrokeOklch, type RelationMeta } from './RelationLegend'; + +export interface GraphCanvasProps { + nodes: GraphNode[]; + edges: GraphEdge[]; + selectedId: string | null; + onSelect: (id: string | null) => void; +} + +/** + * Convert normalized graph nodes/edges → physics inputs. + * Efforts have effortId=null in the normalizer; the sim needs a stable + * cluster id, so we fall back to the node's own id for effort hubs. + */ +function toSimInputs( + nodes: GraphNode[], + edges: GraphEdge[] +): { simNodes: GraphInputNode[]; simEdges: GraphInputEdge[] } { + const simNodes = nodes.map((n) => ({ + id: n.id, + effortId: n.effortId ?? n.id, + kind: n.kind, + parentId: n.effortId ?? undefined, + })); + const simEdges = edges.map((e) => ({ + id: e.id, + from: e.source, + to: e.target, + })); + return { simNodes, simEdges }; +} + +function idsChanged(current: Array<{ id: string }>, previous: string[]): boolean { + if (current.length !== previous.length) return true; + for (let i = 0; i < current.length; i++) { + if (current[i].id !== previous[i]) return true; + } + return false; +} + +/** + * Cached glyph geometry, built once per kind. Node meshes scale a unit glyph + * rather than rebuilding geometry, so adding a record costs one mesh. + */ +const GLYPH_GEOMETRY = new Map(); + +function glyphGeometry(glyph: GlyphId): THREE.BufferGeometry { + const cached = GLYPH_GEOMETRY.get(glyph); + if (cached) return cached; + + let geometry: THREE.BufferGeometry; + if (glyph === 'circle') { + geometry = new THREE.CircleGeometry(1, CIRCLE_SEGMENTS); + } else if (glyph === 'ring') { + geometry = new THREE.RingGeometry(RING_INNER_RATIO, 1, CIRCLE_SEGMENTS); + } else { + const shape = new THREE.Shape(); + const outline = GLYPH_OUTLINES[glyph]; + shape.moveTo(outline[0].x, outline[0].y); + for (let i = 1; i < outline.length; i++) shape.lineTo(outline[i].x, outline[i].y); + shape.closePath(); + geometry = new THREE.ShapeGeometry(shape); + } + GLYPH_GEOMETRY.set(glyph, geometry); + return geometry; +} + +/** Pointer travel (CSS px) beyond which a gesture is a pan, not a tap. */ +const TAP_SLOP_PX = 8; + +/** Minimum on-screen hit diameter, in CSS px, regardless of zoom. */ +const MIN_HIT_DIAMETER_PX = 44; + +export default function GraphCanvas(props: GraphCanvasProps) { + const { mode } = useTheme(); + const { nodes, selectedId, onSelect } = props; + const [focusedId, setFocusedId] = useState(null); + const wrapperRef = useRef(null); + const takeoverRef = useRef(null); + if (takeoverRef.current === null) takeoverRef.current = createCameraTakeover(); + const takeover = takeoverRef.current; + + /** + * Stable keyboard traversal order: Effort hub, then its records grouped by + * primitive. Deliberately independent of simulation array order, which + * churns as nodes spawn and retract. + */ + const walkOrder = useMemo(() => { + const kindRank: Record = { + effort: 0, + issue: 1, + finding: 2, + decision: 3, + constraint: 4, + risk: 5, + }; + return [...nodes].sort((a, b) => { + const ea = a.effortId ?? a.id; + const eb = b.effortId ?? b.id; + if (ea !== eb) return ea.localeCompare(eb); + if (a.kind !== b.kind) return kindRank[a.kind] - kindRank[b.kind]; + return a.title.localeCompare(b.title); + }); + }, [nodes]); + + const lifecycles = useMemo( + () => buildAlivenessMap(props.nodes, props.edges), + [props.nodes, props.edges] + ); + + // Drop focus when the focused record leaves the graph on a live update. + useEffect(() => { + if (focusedId && !nodes.some((n) => n.id === focusedId)) setFocusedId(null); + }, [nodes, focusedId]); + + const step = useCallback( + (delta: number) => { + if (walkOrder.length === 0) return; + const current = focusedId ?? selectedId; + const index = current ? walkOrder.findIndex((n) => n.id === current) : -1; + const next = walkOrder[(index + delta + walkOrder.length) % walkOrder.length]; + setFocusedId(next.id); + }, + [walkOrder, focusedId, selectedId] + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + switch (event.key) { + case 'ArrowRight': + case 'ArrowDown': + event.preventDefault(); + step(1); + break; + case 'ArrowLeft': + case 'ArrowUp': + event.preventDefault(); + step(-1); + break; + case 'Home': + event.preventDefault(); + if (walkOrder.length > 0) setFocusedId(walkOrder[0].id); + break; + case 'End': + event.preventDefault(); + if (walkOrder.length > 0) setFocusedId(walkOrder[walkOrder.length - 1].id); + break; + case 'Enter': + case ' ': + if (focusedId) { + event.preventDefault(); + onSelect(focusedId); + } + break; + case 'Escape': + if (selectedId || focusedId) { + event.preventDefault(); + onSelect(null); + setFocusedId(null); + } + break; + default: + break; + } + }, + [step, walkOrder, focusedId, selectedId, onSelect] + ); + + const focusedNode = focusedId ? nodes.find((n) => n.id === focusedId) : undefined; + const focusedLife = focusedId ? lifecycles.get(focusedId) : undefined; + + return ( +
    +

    + Use the arrow keys to move between records, Enter to open a record's + details, and Escape to close. Drag to pan and scroll to zoom. +

    + { + props.onSelect(null); + setFocusedId(null); + }} + > + + +
    + {focusedNode + ? `${PRIMITIVES[focusedNode.kind].label}: ${focusedNode.title}${ + focusedLife?.state ? `, ${focusedLife.state}` : '' + }` + : ''} +
    +
    + ); +} + +interface GraphSceneProps extends GraphCanvasProps { + mode: ColorMode; + focusedId: string | null; + onFocus: (id: string | null) => void; + lifecycles: Map; + drawerOpen: boolean; + takeover: CameraTakeover; +} + +function GraphScene({ + nodes, + edges, + selectedId, + onSelect, + mode, + focusedId, + onFocus, + lifecycles, + drawerOpen, + takeover, +}: GraphSceneProps) { + const simRef = useRef(null); + if (simRef.current === null) { + simRef.current = createGraphSimulation(); + } + const sim = simRef.current; + + const nodeIdsRef = useRef([]); + const edgeIdsRef = useRef([]); + const [, setRenderTick] = useState(0); + const reduceMotion = usePrefersReducedMotion(); + + useEffect(() => { + const { simNodes, simEdges } = toSimInputs(nodes, edges); + const settledBefore = sim.getState().nodes.some((n) => n.state === 'settled'); + sim.sync(simNodes, simEdges); + /* + * Reduced motion: settle the layout and finish every growth animation + * before the next paint, so the graph appears in place instead of crawling + * outward from the origin. + * + * Only on the first sync. Repulsion is O(n²) and this runs synchronously, + * so re-settling on every live generation would freeze the tab for seconds + * on a large graph — a worse experience than the animation it replaces. + * Later syncs need far fewer steps because they start from a settled + * layout and only have to grow the new records in. + */ + const warmupSteps = reduceMotion ? (settledBefore ? 60 : 240) : 0; + for (let i = 0; i < warmupSteps; i++) sim.step(1 / 60); + const state = sim.getState(); + nodeIdsRef.current = state.nodes.map((n) => n.id); + edgeIdsRef.current = state.edges.map((e) => e.id); + setRenderTick((t) => t + 1); + }, [nodes, edges, sim, reduceMotion]); + + useFrame((_, delta) => { + sim.step(delta); + const state = sim.getState(); + if ( + idsChanged(state.nodes, nodeIdsRef.current) || + idsChanged(state.edges, edgeIdsRef.current) + ) { + nodeIdsRef.current = state.nodes.map((n) => n.id); + edgeIdsRef.current = state.edges.map((e) => e.id); + setRenderTick((t) => t + 1); + } + }); + + const state = sim.getState(); + + /** + * Metadata for every node we have *ever* seen, not just the current query + * result. A removed record is gone from `nodes` the instant it is deleted, + * but the simulation keeps it alive while it retracts — without this cache + * the retract animation has no colour or kind to render and the node pops + * out of existence while its edges withdraw gracefully. + */ + const metaCacheRef = useRef(new Map()); + const nodeMetaById = useMemo(() => { + const cache = metaCacheRef.current; + for (const n of nodes) cache.set(n.id, n); + return cache; + }, [nodes]); + + useEffect(() => { + const cache = metaCacheRef.current; + const live = new Set(state.nodes.map((n) => n.id)); + for (const id of cache.keys()) { + if (!live.has(id)) cache.delete(id); + } + }, [state.nodes, nodes]); + + const edgesById = useMemo(() => { + const map = new Map(); + for (const e of edges) map.set(e.id, e); + return map; + }, [edges]); + + const [hoveredId, setHoveredId] = useState(null); + + // A removed node never fires pointerout, so clear hover when it disappears. + useEffect(() => { + if (hoveredId && !nodes.some((n) => n.id === hoveredId)) setHoveredId(null); + }, [nodes, hoveredId]); + + const activeId = focusedId ?? selectedId; + + return ( + <> + + + + + {state.edges.map((edge) => { + const graphEdge = edgesById.get(edge.id); + const edgeKind = graphEdge?.kind ?? edgeKindFromId(edge.id); + if (!edgeKind) return null; + return ( + + ); + })} + + + {state.nodes.map((node) => { + const meta = nodeMetaById.get(node.id); + if (!meta) return null; + return ( + + ); + })} + + + {state.nodes.map((node) => { + const meta = nodeMetaById.get(node.id); + if (!meta) return null; + const isActive = activeId === node.id; + const isHovered = hoveredId === node.id; + if (meta.kind === 'effort') { + return ( + + ); + } + if (!isActive && !isHovered) return null; + return ( + + ); + })} + + + + ); +} + +/** Live `prefers-reduced-motion`, so a mid-session change takes effect. */ +function usePrefersReducedMotion(): boolean { + const [reduce, setReduce] = useState(false); + useEffect(() => { + const query = window.matchMedia('(prefers-reduced-motion: reduce)'); + setReduce(query.matches); + const onChange = (event: MediaQueryListEvent) => setReduce(event.matches); + query.addEventListener('change', onChange); + return () => query.removeEventListener('change', onChange); + }, []); + return reduce; +} + +/** + * Screen-space padding so fitted nodes stay clear of floating chrome. Only the + * legend is reserved, because it is always present; the detail drawer is + * transient, and permanently reserving its ~356px would surrender a quarter of + * the canvas to a panel that is usually closed. + */ +function fitChromeInsets(viewportWidth: number): { + top: number; + right: number; + bottom: number; + left: number; +} { + const isWide = viewportWidth >= 640; + return { + top: 24, + bottom: isWide ? 40 : 116, + left: isWide ? 284 : 16, + right: isWide ? 40 : 16, + }; +} + +/** + * Frame the orthographic camera on the live simulation bounds once the + * layout has mostly settled (or after a short timeout), then ease into place + * rather than snapping — a hard cut after a two-second delay reads as a bug. + * Re-fits when the node count changes or the canvas viewport resizes. + */ +function FitCamera({ + sim, + nodeCount, + reduceMotion, + takeover, +}: { + sim: GraphSimulation; + nodeCount: number; + reduceMotion: boolean; + takeover: CameraTakeover; +}) { + const camera = useThree((s) => s.camera) as OrthographicCamera; + const controls = useThree((s) => s.controls) as PanZoomControlsHandle | null; + const size = useThree((s) => s.size); + const fitted = useRef(false); + const elapsedRef = useRef(0); + const targetRef = useRef<{ x: number; y: number; zoom: number } | null>(null); + + const sizeKey = `${size.width}x${size.height}`; + + useEffect(() => { + fitted.current = false; + elapsedRef.current = 0; + }, [nodeCount, sizeKey]); + + useFrame((_, delta) => { + // Reader pan/zoom wins immediately, including mid-ease: drop any pending + // fit target and stop writing the camera for the rest of the session. + if (takeover.get()) { + targetRef.current = null; + return; + } + + // Ease toward a pending fit target — exponential approach, which is an + // ease-out. A hard cut after the settle delay reads as a glitch. + const target = targetRef.current; + if (target) { + const t = reduceMotion ? 1 : 1 - Math.exp(-delta * 9); + camera.position.x += (target.x - camera.position.x) * t; + camera.position.y += (target.y - camera.position.y) * t; + camera.zoom += (target.zoom - camera.zoom) * t; + camera.updateProjectionMatrix(); + if (controls) { + controls.target.set(camera.position.x, camera.position.y, 0); + controls.update(); + } + const close = + Math.abs(target.x - camera.position.x) < 0.05 && + Math.abs(target.y - camera.position.y) < 0.05 && + Math.abs(target.zoom - camera.zoom) < 0.005; + if (close) targetRef.current = null; + return; + } + + elapsedRef.current += delta; + + const { nodes } = sim.getState(); + let alive = 0; + let kinetic = 0; + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const n of nodes) { + if (n.growth <= 0.35 || n.state === 'retracting') continue; + alive += 1; + kinetic += n.vx * n.vx + n.vy * n.vy; + const r = Math.max(effectiveRadius(n), 1); + if (n.x - r < minX) minX = n.x - r; + if (n.x + r > maxX) maxX = n.x + r; + if (n.y - r < minY) minY = n.y - r; + if (n.y + r > maxY) maxY = n.y + r; + } + if (alive === 0) return; + + const settled = reduceMotion || kinetic < 2.5 || elapsedRef.current > 2.4; + if (!settled) return; + + const width = Math.max(maxX - minX, 20); + const height = Math.max(maxY - minY, 20); + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + const pad = 1.12; + + const insets = fitChromeInsets(size.width); + const availW = Math.max(size.width - insets.left - insets.right, 64); + const availH = Math.max(size.height - insets.top - insets.bottom, 64); + const zoom = Math.min(availW / (width * pad), availH / (height * pad), 12); + const clampedZoom = Math.max(zoom, 0.75); + + const nextX = cx - (insets.left - insets.right) / 2 / clampedZoom; + const nextY = cy - (insets.bottom - insets.top) / 2 / clampedZoom; + + /* + * Keep re-fitting while the layout is still spreading. Cluster separation + * pushes the graph outward for a while after the first fit, so a one-shot + * fit leaves records stranded off-screen. + */ + if (fitted.current) { + const drifted = + Math.abs(nextX - camera.position.x) > 4 / clampedZoom || + Math.abs(nextY - camera.position.y) > 4 / clampedZoom || + Math.abs(clampedZoom - camera.zoom) / camera.zoom > 0.04; + if (!drifted) return; + } + + targetRef.current = { x: nextX, y: nextY, zoom: clampedZoom }; + fitted.current = true; + }); + + return null; +} + +/** + * Tracks whether the reader has taken the camera over, from raw DOM input on + * the canvas wrapper. + * + * Deliberately not derived from OrbitControls events: `start` fires on + * pointer-down before anything moves, so a tap to select a record would count + * as a pan, and `change` also fires from our own easing's `controls.update()`. + * A wheel gesture or a drag past the tap threshold is unambiguous. + */ +function createCameraTakeover() { + const state = { moved: false, downAt: null as { x: number; y: number } | null }; + return { + get: () => state.moved, + onWheel: () => { + state.moved = true; + }, + onPointerDown: (event: React.PointerEvent) => { + state.downAt = { x: event.clientX, y: event.clientY }; + }, + onPointerMove: (event: React.PointerEvent) => { + const down = state.downAt; + if (!down) return; + if (Math.hypot(event.clientX - down.x, event.clientY - down.y) > TAP_SLOP_PX) { + state.moved = true; + } + }, + onPointerUp: () => { + state.downAt = null; + }, + }; +} + +type CameraTakeover = ReturnType; + +/** + * Keep the keyboard-focused record on screen. Without this, arrowing through + * the graph silently moves focus to nodes outside the viewport. + */ +function PanToFocus({ + sim, + focusedId, + reduceMotion, + drawerOpen, +}: { + sim: GraphSimulation; + focusedId: string | null; + reduceMotion: boolean; + drawerOpen: boolean; +}) { + const camera = useThree((s) => s.camera) as OrthographicCamera; + const controls = useThree((s) => s.controls) as PanZoomControlsHandle | null; + const size = useThree((s) => s.size); + const pendingRef = useRef(null); + + useEffect(() => { + pendingRef.current = focusedId; + }, [focusedId]); + + useFrame((_, delta) => { + const id = pendingRef.current; + if (!id) return; + const node = sim.getState().nodes.find((n) => n.id === id); + if (!node) { + pendingRef.current = null; + return; + } + + /* + * The drawer covers the right edge, so aim left of centre while it is + * open. Otherwise pressing Enter can park the record you just selected + * underneath the panel describing it. + */ + const isWide = size.width >= 640; + const shiftPx = drawerOpen && isWide ? 200 : 0; + const halfW = size.width / 2 / camera.zoom; + const halfH = size.height / 2 / camera.zoom; + const dx = node.x + shiftPx / camera.zoom - camera.position.x; + const dy = node.y - camera.position.y; + if (Math.abs(dx) < halfW * 0.5 && Math.abs(dy) < halfH * 0.6) { + pendingRef.current = null; + return; + } + + const t = reduceMotion ? 1 : 1 - Math.exp(-delta * 8); + camera.position.x += dx * t; + camera.position.y += dy * t; + camera.updateProjectionMatrix(); + if (controls) { + controls.target.set(camera.position.x, camera.position.y, 0); + controls.update(); + } + if (Math.abs(dx) < 0.4 && Math.abs(dy) < 0.4) pendingRef.current = null; + }); + + return null; +} + +/** + * Orthographic pan + zoom controls tuned for 2D: + * - no orbit tumble + * - screen-space pan + * - mouse: LMB pan / wheel zoom + * - touch: one-finger pan / two-finger dolly + pan + */ +function PanZoomControls() { + return ( + + ); +} + +interface NodeMeshProps { + node: SimNode; + meta: GraphNode; + mode: ColorMode; + aliveness: Aliveness; + blocker: boolean; + selected: boolean; + focused: boolean; + reduceMotion: boolean; + onSelect: (id: string | null) => void; + onFocus: (id: string | null) => void; + onHover: (id: string | null) => void; +} + +/** + * Base colour for a node. Records take their primitive's hue; Effort hubs take + * a neutral structural tone, because a hub is scaffolding rather than another + * coloured record. Cluster identity rides on the hub's tinted core instead. + */ +function nodeBaseOklch(meta: GraphNode, mode: ColorMode): Oklch { + if (meta.kind === 'effort') return structuralOklch(mode); + return primitiveOklch(meta.kind, mode); +} + +function NodeMesh({ + node, + meta, + mode, + aliveness, + blocker, + selected, + focused, + reduceMotion, + onSelect, + onFocus, + onHover, +}: NodeMeshProps) { + const groupRef = useRef(null); + const bodyRef = useRef(null); + const coreRef = useRef(null); + const haloRef = useRef(null); + const hitRef = useRef(null); + const camera = useThree((s) => s.camera) as OrthographicCamera; + const pointerStart = useRef<{ x: number; y: number } | null>(null); + + const glyph = PRIMITIVES[meta.kind].glyph; + const geometry = useMemo(() => glyphGeometry(glyph), [glyph]); + const extent = useMemo(() => glyphExtent(glyph), [glyph]); + const isHub = meta.kind === 'effort'; + + const retired = aliveness === 'retired'; + const color = useMemo(() => { + const base = nodeBaseOklch(meta, mode); + return oklchToThreeColor(retired ? retiredOklch(base, mode) : base); + }, [meta, mode, retired]); + + /** Cluster tint, shown only in a hub's core so it matches the legend chip. */ + const coreColor = useMemo( + () => (isHub ? oklchToThreeColor(effortOklch(meta.id, mode)) : 0), + [isHub, meta.id, mode] + ); + + const haloColor = useMemo( + () => + oklchToThreeColor( + mode === 'light' ? { l: 0.2, c: 0.01, h: 260 } : { l: 0.96, c: 0.01, h: 260 } + ), + [mode] + ); + + useFrame(() => { + const g = groupRef.current; + if (!g) return; + g.position.x = node.x; + g.position.y = node.y; + const r = Math.max(effectiveRadius(node), 0.001); + bodyRef.current?.scale.set(r, r, 1); + coreRef.current?.scale.set(r, r, 1); + + const halo = haloRef.current; + if (halo) { + const show = selected || focused; + halo.visible = show; + if (show) { + // Pad outward in world units so the halo clears pointy silhouettes + // at every zoom level instead of hugging a circumscribed circle. + halo.scale.setScalar(r + Math.max(1.6, r * 0.22) / extent); + } + } + + const hit = hitRef.current; + if (hit) { + /* + * Guarantee a 44 CSS px pointer diameter at every zoom. R3F sizes an + * orthographic frustum to the canvas in pixels, so one world unit is + * exactly `camera.zoom` CSS px — world radius is therefore + * `(MIN_HIT_DIAMETER_PX / 2) / zoom`. Do not reach for `viewport.factor` + * here: R3F hard-codes it to 1 for orthographic cameras, which would + * pin the hit radius to a constant 22 world units and make targets + * grow as you zoom in. Do not world-cap the padding either: at + * minZoom (~0.5) that floor needs ~44 world units of radius, and any + * smaller cap shrinks the on-screen target below 44 CSS px. + */ + const minWorld = MIN_HIT_DIAMETER_PX / 2 / Math.max(camera.zoom, 0.0001); + hit.scale.setScalar(Math.max(r * extent, minWorld)); + } + }); + + const handlePointerDown = useCallback((event: { clientX: number; clientY: number }) => { + pointerStart.current = { x: event.clientX, y: event.clientY }; + }, []); + + /** + * Commit selection on pointer-up, and only when the pointer barely moved. + * Left-drag pans the camera, so selecting on pointer-down opened the drawer + * every time a pan happened to start over a record. + */ + const handlePointerUp = useCallback( + (event: { clientX: number; clientY: number; stopPropagation: () => void }) => { + const start = pointerStart.current; + pointerStart.current = null; + if (!start) return; + const travelled = Math.hypot(event.clientX - start.x, event.clientY - start.y); + if (travelled > TAP_SLOP_PX) return; + event.stopPropagation(); + onSelect(node.id); + onFocus(node.id); + }, + [node.id, onSelect, onFocus] + ); + + return ( + + + + + {blocker && !retired && } + + + + {/* + A hub's core: fills the ring's hole so membership spokes converging on + the centre don't show through as clutter, and carries the cluster tint + that the Efforts legend keys against. + */} + {isHub && ( + + + + + )} + {/* Invisible, still raycast: an oversized circular pointer target. */} + { + if (e.pointerType === 'touch') return; + e.stopPropagation(); + document.body.style.cursor = 'pointer'; + onHover(node.id); + }} + onPointerOut={(e) => { + if (e.pointerType === 'touch') return; + document.body.style.cursor = ''; + onHover(null); + }} + > + + + + + ); +} + +/** + * Warning outline marking an open blocker Issue — the record gating an Effort. + * + * The slow rotation is the only motion left once the layout settles, so it is + * gated on `prefers-reduced-motion`: otherwise the canvas would never come to + * rest for a reader who asked for exactly that. + */ +function BlockerRing({ animate }: { animate: boolean }) { + const ref = useRef(null); + useFrame(({ clock }) => { + if (!ref.current) return; + ref.current.rotation.z = animate ? clock.elapsedTime * 0.25 : 0; + }); + return ( + + + + + ); +} + +interface EdgeLineProps { + edge: SimEdge; + edgeKind: GraphEdgeKind; + mode: ColorMode; + nodesById: Map; + lifecycles: Map; + activeId: string | null; + hoveredId: string | null; +} + +/** Recover kind from `${source}:${kind}:${target}` ids when the map misses. */ +function edgeKindFromId(id: string): GraphEdgeKind | null { + const parts = id.split(':'); + if (parts.length < 3) return null; + const kind = parts[1]; + return kind in RELATION_META ? (kind as GraphEdgeKind) : null; +} + +function relationEmphasisOpacity(emphasis: RelationMeta['emphasis']): number { + if (emphasis === 'subtle') return 0.4; + if (emphasis === 'medium') return 0.62; + if (emphasis === 'strong') return 0.88; + return 0.72; +} + +function dashWorldUnits( + dash: RelationMeta['dash'], + weight: RelationMeta['weight'] +): { dashSize: number; gapSize: number } | null { + if (dash === 'solid') return null; + const scale = weight === 'medium' ? 1.15 : weight === 'bold' ? 1.3 : 1; + if (dash === 'dashed') { + return { dashSize: 1.0 * scale, gapSize: 0.65 * scale }; + } + return { dashSize: 0.3 * scale, gapSize: 0.5 * scale }; +} + +/** + * Membership spokes take their Effort's tint — this is the channel that keeps + * cluster identity legible now that primitives own hue. Every other group + * reads from the shared relation palette so the legend matches exactly. + */ +function edgeStrokeOklch( + kind: GraphEdgeKind, + sourceMeta: GraphNode | undefined, + mode: ColorMode +): Oklch { + const meta = RELATION_META[kind]; + if (meta.group === 'membership') { + const effortId = sourceMeta?.effortId ?? sourceMeta?.id ?? 'unknown'; + return effortOklch(effortId, mode); + } + return relationStrokeOklch(meta.group, mode); +} + +/** + * Renders a single vein/edge as a styled line (+ optional arrowhead). Buffer + * positions are refreshed in `useFrame` from `veinTipPolyline`, so the tip + * grows in and retracts out with `edge.growth`. Style matches the legend. + */ +function EdgeLine({ + edge, + edgeKind, + mode, + nodesById, + lifecycles, + activeId, + hoveredId, +}: EdgeLineProps) { + const meta = RELATION_META[edgeKind]; + const positionsRef = useRef(new Float32Array(16 * 3)); + const scratchRef = useRef([]); + const distanceCountRef = useRef(-1); + + const color = useMemo(() => { + const sourceMeta = nodesById.get(edge.from); + return oklchToThreeColor(edgeStrokeOklch(edgeKind, sourceMeta, mode)); + }, [edge.from, edgeKind, nodesById, mode]); + + /** Retired endpoints fade their relations too, so dead branches recede. */ + const retiredEndpoint = useMemo( + () => + lifecycles.get(edge.from)?.aliveness === 'retired' || + lifecycles.get(edge.to)?.aliveness === 'retired', + [lifecycles, edge.from, edge.to] + ); + + const { line, arrow } = useMemo(() => { + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute( + 'position', + new THREE.BufferAttribute(positionsRef.current, 3) + ); + geometry.setDrawRange(0, 0); + + const pattern = dashWorldUnits(meta.dash, meta.weight); + const material = + pattern === null + ? new THREE.LineBasicMaterial({ + transparent: true, + opacity: 0, + depthWrite: false, + }) + : new THREE.LineDashedMaterial({ + transparent: true, + opacity: 0, + depthWrite: false, + dashSize: pattern.dashSize, + gapSize: pattern.gapSize, + }); + + const lineObj = new THREE.Line(geometry, material); + lineObj.renderOrder = -1; + lineObj.frustumCulled = false; + + const arrowShape = new THREE.Shape(); + arrowShape.moveTo(0, 0); + arrowShape.lineTo(-0.85, 0.38); + arrowShape.lineTo(-0.85, -0.38); + arrowShape.closePath(); + const arrowGeom = new THREE.ShapeGeometry(arrowShape); + const arrowMat = new THREE.MeshBasicMaterial({ + transparent: true, + opacity: 0, + depthWrite: false, + }); + const arrowMesh = new THREE.Mesh(arrowGeom, arrowMat); + arrowMesh.visible = false; + arrowMesh.renderOrder = 0; + arrowMesh.frustumCulled = false; + + return { line: lineObj, arrow: arrowMesh }; + }, [meta.dash, meta.weight]); + + useEffect(() => { + (line.material as THREE.LineBasicMaterial).color.setHex(color); + (arrow.material as THREE.MeshBasicMaterial).color.setHex(color); + }, [line, arrow, color]); + + useEffect(() => { + return () => { + line.geometry.dispose(); + (line.material as THREE.Material).dispose(); + arrow.geometry.dispose(); + (arrow.material as THREE.Material).dispose(); + }; + }, [line, arrow]); + + useFrame(() => { + const geom = line.geometry; + const material = line.material as THREE.LineBasicMaterial; + const arrowMaterial = arrow.material as THREE.MeshBasicMaterial; + + if (isEdgeGone(edge)) { + geom.setDrawRange(0, 0); + arrow.visible = false; + return; + } + const path = edge.path; + if (!path || path.length < 2) { + geom.setDrawRange(0, 0); + arrow.visible = false; + return; + } + const visible = veinTipPolyline(path, edge.growth, scratchRef.current); + if (visible.length < 2) { + geom.setDrawRange(0, 0); + arrow.visible = false; + return; + } + const needed = visible.length * 3; + let positions = positionsRef.current; + if (positions.length < needed) { + positions = new Float32Array(Math.max(needed, positions.length * 2, 16)); + positionsRef.current = positions; + geom.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + distanceCountRef.current = -1; + } + for (let i = 0; i < visible.length; i++) { + positions[i * 3] = visible[i].x; + positions[i * 3 + 1] = visible[i].y; + positions[i * 3 + 2] = 0; + } + const attr = geom.getAttribute('position') as THREE.BufferAttribute | undefined; + if (attr) attr.needsUpdate = true; + geom.setDrawRange(0, visible.length); + // `computeLineDistances` allocates a fresh attribute on every call, so + // only recompute when the vertex count actually changes. Dash phase drift + // as endpoints move is imperceptible; a per-frame allocation for every + // dashed edge is not. + if (meta.dash !== 'solid' && distanceCountRef.current !== visible.length) { + line.computeLineDistances(); + distanceCountRef.current = visible.length; + } + + const highlighted = + (activeId !== null && (activeId === edge.from || activeId === edge.to)) || + (hoveredId !== null && (hoveredId === edge.from || hoveredId === edge.to)); + + const baseOpacity = relationEmphasisOpacity(meta.emphasis); + const modeScale = mode === 'dark' ? 1.05 : 0.95; + const retiredScale = retiredEndpoint && !highlighted ? 0.5 : 1; + const target = highlighted + ? Math.min(baseOpacity * modeScale + 0.35, 0.98) + : baseOpacity * modeScale * retiredScale; + const growthOpacity = target * Math.max(0.001, edge.growth); + material.opacity = growthOpacity; + arrowMaterial.opacity = growthOpacity; + + if (meta.arrow && visible.length >= 2) { + const tip = visible[visible.length - 1]; + const prev = visible[visible.length - 2]; + const angle = Math.atan2(tip.y - prev.y, tip.x - prev.x); + const scale = highlighted ? 1.2 : 1; + arrow.position.set(tip.x, tip.y, 0.02); + arrow.rotation.z = angle; + arrow.scale.set(scale, scale, 1); + arrow.visible = true; + } else { + arrow.visible = false; + } + }); + + return ( + + + {meta.arrow ? : null} + + ); +} + +interface NodeLabelProps { + node: SimNode; + meta: GraphNode; + active: boolean; + retired: boolean; +} + +function NodeLabel({ node, meta, active, retired }: NodeLabelProps) { + const groupRef = useRef(null); + + useFrame(() => { + const g = groupRef.current; + if (!g) return; + const r = effectiveRadius(node); + g.position.x = node.x; + g.position.y = node.y + r + 1.5; + g.visible = r > 0.5; + }); + + return ( + + + + ); +} + +/** + * Effort title, anchored above the whole cluster rather than above the hub. + * + * The hub sits at its cluster's centroid, so a label placed on it lands in the + * middle of the records it is naming. Riding the cluster's top edge instead + * turns it into a heading for the group and clears the records entirely. + */ +function ClusterLabel({ + sim, + node, + meta, + active, + retired, +}: { + sim: GraphSimulation; + node: SimNode; + meta: GraphNode; + active: boolean; + retired: boolean; +}) { + const groupRef = useRef(null); + + useFrame(() => { + const g = groupRef.current; + if (!g) return; + const r = effectiveRadius(node); + if (r <= 0.5) { + g.visible = false; + return; + } + g.visible = true; + + let top = node.y + r; + let sumX = 0; + let members = 0; + for (const other of sim.getState().nodes) { + if (other.effortId !== node.effortId) continue; + const otherR = effectiveRadius(other); + if (otherR <= 0.1) continue; + if (other.y + otherR > top) top = other.y + otherR; + sumX += other.x; + members += 1; + } + g.position.x = members > 0 ? sumX / members : node.x; + g.position.y = top + 2.5; + }); + + return ( + + + + ); +} + +function labelClasses(hub: boolean, active: boolean, retired: boolean): string { + const classes = ['effort-label']; + if (hub) classes.push('effort-label-hub'); + if (active) classes.push('effort-label-active'); + if (retired) classes.push('effort-label-retired'); + return classes.join(' '); +} + +function LabelSurface({ text, classes }: { text: string; classes: string }) { + return ( + +
    + {text} +
    + + ); +} + +function CursorReset() { + useEffect(() => { + return () => { + document.body.style.cursor = ''; + }; + }, []); + return null; +} + +export { toSimInputs }; diff --git a/examples/effort-viz/app/components/Legend.tsx b/examples/effort-viz/app/components/Legend.tsx new file mode 100644 index 00000000..c143fa8f --- /dev/null +++ b/examples/effort-viz/app/components/Legend.tsx @@ -0,0 +1,241 @@ +'use client'; + +import { useId, useState } from 'react'; + +import { + effortColor, + oklchCss, + retiredOklch, + structuralOklch, + type ColorMode, +} from '@/lib/oklch'; +import { PRIMITIVES, PRIMITIVE_ORDER, primitiveOklch } from '@/lib/primitives'; +import type { GraphNode } from '@/lib/types'; +import { useTheme } from '../hooks/useTheme'; +import { + PrimitiveGlyph, + RELATION_GROUP_LABEL, + RELATION_GROUP_SAMPLE, + RELATION_META, + RelationLineSample, + relationStrokeOklch, + type RelationGroupId, +} from './RelationLegend'; + +interface LegendProps { + /** Effort hubs currently in the graph, so the cluster key reflects reality. */ + efforts: GraphNode[]; + /** Relation groups present in this generation — the key never over-promises. */ + presentGroups: Set; +} + +export function Legend({ efforts, presentGroups }: LegendProps) { + const { mode } = useTheme(); + const [collapsed, setCollapsed] = useState(false); + const panelId = useId(); + + const relationRows = ( + Object.keys(RELATION_GROUP_LABEL) as RelationGroupId[] + ).filter((group) => presentGroups.has(group)); + + return ( +
    + + + {/* + Scrolls internally rather than growing past the viewport: the key is + pinned to the bottom-left, so an over-tall panel silently loses its + last sections off the bottom edge. + */} +
    +
    + Primitives + {/* + The column gap must clearly exceed the glyph-to-label gap, or the + eye groups a glyph with the label to its left and reads the whole + key off by one. + */} +
      + {PRIMITIVE_ORDER.map((kind) => ( +
    • + {/* Neutral core here — a hue would imply Efforts have one. */} + + + {PRIMITIVES[kind].label} + +
    • + ))} +
    +

    + Shape and colour both mark the primitive. +

    +
    + +
    + Lifecycle + {/* + Shown as a before/after on one primitive so the row reads as a + comparison. Two separate rows made the Decision square itself look + like the thing being defined. + */} +
    + + Live + + → + + + Retired +
    +

    + Rejected, superseded, invalidated, or won't fix. +

    +
    + + Blocker + open, gating work +
    +
    + + {relationRows.length > 0 && ( +
    + Relations + {/* + Names only. The definitions live in the drawer, where each + relation is shown against the actual record it connects — a + legend that carries them too doubles its own height and pushes + the Efforts key off the bottom of the viewport. + */} +
      + {relationRows.map((group) => { + const meta = RELATION_META[RELATION_GROUP_SAMPLE[group]]; + const stroke = + group === 'membership' + ? undefined + : oklchCss(relationStrokeOklch(group, mode)); + return ( +
    • + + + {RELATION_GROUP_LABEL[group]} + +
    • + ); + })} +
    +
    + )} + + {efforts.length > 0 && ( +
    + Efforts + {/* + Plain tint chips rather than miniature hubs: at legend scale a + hub's core is under 3px across, which is not enough pixels to + tell five tints apart. + */} +
      + {efforts.map((effort) => ( +
    • + + + {effort.title} + +
    • + ))} +
    +

    + Each tint fills its Effort hub's core. +

    +
    + )} +
    +
    + ); +} + +function SectionHeading({ children }: { children: React.ReactNode }) { + return ( +

    + {children} +

    + ); +} + +/** + * Mirrors the canvas blocker treatment: a triangular warning outline around an + * Issue. The inner shape is the Issue diamond, not another triangle — a blocker + * is always an Issue, and showing a triangle inside would read as a Risk. + */ +function BlockerSample({ mode }: { mode: ColorMode }) { + return ( + + + + + ); +} diff --git a/examples/effort-viz/app/components/MarkdownSurface.tsx b/examples/effort-viz/app/components/MarkdownSurface.tsx new file mode 100644 index 00000000..a662be33 --- /dev/null +++ b/examples/effort-viz/app/components/MarkdownSurface.tsx @@ -0,0 +1,212 @@ +'use client'; + +import type { ComponentPropsWithoutRef } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import rehypeSanitize from 'rehype-sanitize'; + +export interface MarkdownSurfaceProps { + /** Raw markdown source (from `_content.raw`). */ + value: string; + /** Reserved for a future editing surface; ignored while readonly. */ + editable?: boolean; + onChange?: (next: string) => void; + /** + * Resolve a link target to a record id, or null when it points somewhere + * else. Links only become in-graph navigation when this resolves — an + * unresolvable target has to stay a real anchor rather than degrade into a + * button that silently does nothing. + */ + resolveRecord?: (target: string) => string | null; + onNavigate?: (id: string) => void; + className?: string; +} + +/** True for anything with its own URL scheme: http, mailto, tel, and friends. */ +function hasScheme(href: string): boolean { + return /^[a-z][a-z0-9+.-]*:/i.test(href); +} + +function normalizeNavigateTarget(href: string): string { + const withoutHash = href.split('#')[0] ?? href; + const segment = withoutHash.replace(/^\//, '').split('/').pop() ?? withoutHash; + return segment.replace(/\.md$/i, ''); +} + +const LINK_CLASS = + 'text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded-sm'; + +export function MarkdownSurface({ + value, + resolveRecord, + onNavigate, + className = '', +}: MarkdownSurfaceProps) { + return ( +
    + ( +

    + {children} +

    + ), + h2: ({ children }) => ( +

    + {children} +

    + ), + h3: ({ children }) => ( +

    + {children} +

    + ), + h4: ({ children }) => ( +

    + {children} +

    + ), + p: ({ children }) => ( +

    + {children} +

    + ), + ul: ({ children }) => ( +
      + {children} +
    + ), + ol: ({ children }) => ( +
      + {children} +
    + ), + li: ({ children, className: liClassName }) => ( +
  • + {children} +
  • + ), + blockquote: ({ children }) => ( +
    + {children} +
    + ), + hr: () =>
    , + a: ({ href, children }) => { + const target = href ?? ''; + const recordId = + target && !target.startsWith('#') && !hasScheme(target) && resolveRecord + ? resolveRecord(normalizeNavigateTarget(target)) + : null; + + if (recordId && onNavigate) { + return ( + + ); + } + + const external = hasScheme(target) && !target.startsWith('mailto:'); + return ( + + {children} + {external && (opens in a new tab)} + + ); + }, + code: ({ className: codeClassName, children, ...props }) => { + const isBlock = Boolean(codeClassName); + if (isBlock) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); + }, + pre: ({ children }) => ( +
    +              {children}
    +            
    + ), + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + thead: ({ children }) => ( + + {children} + + ), + th: ({ children }) => ( + {children} + ), + td: ({ children }) => ( + + {children} + + ), + input: ({ + type, + checked, + disabled, + ...props + }: ComponentPropsWithoutRef<'input'>) => { + if (type === 'checkbox') { + return ( + + ); + } + return ; + }, + strong: ({ children }) => ( + {children} + ), + em: ({ children }) => ( + {children} + ), + }} + > + {value} +
    +
    + ); +} diff --git a/examples/effort-viz/app/components/RelationLegend.tsx b/examples/effort-viz/app/components/RelationLegend.tsx new file mode 100644 index 00000000..82680a8f --- /dev/null +++ b/examples/effort-viz/app/components/RelationLegend.tsx @@ -0,0 +1,441 @@ +'use client'; + +import type { GraphEdgeKind, GraphNodeKind } from '@/lib/types'; +import type { ColorMode } from '@/lib/oklch'; +import { oklchCss, structuralOklch } from '@/lib/oklch'; +import { PRIMITIVES, primitiveOklch } from '@/lib/primitives'; +import { RING_INNER_RATIO, glyphSvgPoints } from '@/lib/glyphs'; + +export type RelationGroupId = + | 'lineage' + | 'supersession' + | 'invalidation' + | 'resolution' + | 'mitigation' + | 'rejection' + | 'evidence' + | 'membership'; + +export interface RelationMeta { + kind: GraphEdgeKind; + label: string; + description: string; + group: RelationGroupId; + /** Rendered in the drawer so direction reads as a sentence, not a bare arrow. */ + directionHint: { outgoing: string; incoming: string }; + dash: 'solid' | 'dashed' | 'dotted'; + weight: 'thin' | 'medium' | 'bold'; + emphasis: 'subtle' | 'base' | 'medium' | 'strong'; + arrow: boolean; +} + +/** + * Relation styling. Edges stay largely achromatic on purpose: hue is spent on + * primitives now, and layering seven more edge hues on top would put a dozen + * competing colours on one canvas. Texture (dash, weight) separates the + * groups instead. Membership takes its Effort's tint; resolution and + * mitigation share one accent; everything else — including invalidation — + * stays on a neutral stroke. + */ +export const RELATION_META: Record = { + derives_from: { + kind: 'derives_from', + label: 'Derives from', + description: 'Causal upstream evidence or context this record was built on.', + group: 'lineage', + directionHint: { outgoing: 'Builds on', incoming: 'Informs' }, + dash: 'solid', + weight: 'thin', + emphasis: 'base', + arrow: true, + }, + supersedes: { + kind: 'supersedes', + label: 'Supersedes', + description: 'Replaces an earlier record of the same primitive.', + group: 'supersession', + directionHint: { outgoing: 'Replaces', incoming: 'Replaced by' }, + dash: 'solid', + weight: 'medium', + emphasis: 'strong', + arrow: true, + }, + superseded_by: { + kind: 'superseded_by', + label: 'Superseded by', + description: 'Replaced by a later record; this one is no longer current.', + group: 'supersession', + directionHint: { outgoing: 'Replaced by', incoming: 'Replaces' }, + dash: 'solid', + weight: 'medium', + emphasis: 'strong', + arrow: true, + }, + invalidates: { + kind: 'invalidates', + label: 'Invalidates', + description: 'Says an earlier record was wrong — not merely replaced.', + group: 'invalidation', + directionHint: { outgoing: 'Shows this was wrong', incoming: 'Shown wrong by' }, + dash: 'dashed', + weight: 'medium', + emphasis: 'strong', + arrow: true, + }, + resolved_by: { + kind: 'resolved_by', + label: 'Resolved by', + description: 'Closes an Issue through a Decision or Finding.', + group: 'resolution', + directionHint: { outgoing: 'Resolved by', incoming: 'Resolves' }, + dash: 'solid', + weight: 'medium', + emphasis: 'strong', + arrow: true, + }, + mitigated_by: { + kind: 'mitigated_by', + label: 'Mitigated by', + description: 'Reduces a Risk through an accepted Decision.', + group: 'mitigation', + directionHint: { outgoing: 'Mitigated by', incoming: 'Mitigates' }, + dash: 'solid', + weight: 'medium', + emphasis: 'medium', + arrow: true, + }, + rejected_by: { + kind: 'rejected_by', + label: 'Rejected by', + description: 'Closed when a sibling Decision in this Effort was accepted.', + group: 'rejection', + directionHint: { outgoing: 'Rejected by', incoming: 'Rejects' }, + dash: 'dotted', + weight: 'medium', + emphasis: 'medium', + arrow: true, + }, + evidence: { + kind: 'evidence', + label: 'Evidence', + description: 'A Finding cited as support for this record.', + group: 'evidence', + // Edge direction is citing record → Finding (normalize source=record, target=finding). + directionHint: { outgoing: 'Supported by', incoming: 'Supports' }, + dash: 'dotted', + weight: 'thin', + emphasis: 'subtle', + arrow: true, + }, + membership: { + kind: 'membership', + label: 'Belongs to Effort', + description: 'Every record belongs to exactly one Effort.', + group: 'membership', + directionHint: { outgoing: 'Contains', incoming: 'Belongs to' }, + dash: 'solid', + weight: 'thin', + emphasis: 'subtle', + arrow: false, + }, +}; + +export const RELATION_GROUP_ORDER: RelationGroupId[] = [ + 'lineage', + 'supersession', + 'invalidation', + 'resolution', + 'mitigation', + 'rejection', + 'evidence', + 'membership', +]; + +/** Group headings use the edge names the CLI and frontmatter use. */ +export const RELATION_GROUP_LABEL: Record = { + lineage: 'Derives from', + supersession: 'Supersedes', + invalidation: 'Invalidates', + resolution: 'Resolved by', + mitigation: 'Mitigated by', + rejection: 'Rejected by', + evidence: 'Evidence', + membership: 'Belongs to Effort', +}; + +/** One representative kind per group, for legend rows. */ +export const RELATION_GROUP_SAMPLE: Record = { + lineage: 'derives_from', + supersession: 'supersedes', + invalidation: 'invalidates', + resolution: 'resolved_by', + mitigation: 'mitigated_by', + rejection: 'rejected_by', + evidence: 'evidence', + membership: 'membership', +}; + +/** + * Stroke colour for a relation group. + * + * Primitives own the hue budget, so edges get one accent between them and + * otherwise stay neutral. Resolution takes it because "this Issue is closed" is + * the one relation whose meaning is not already visible on its endpoints. The + * accent sits at h196, in the gap the primitive hues leave between Constraint + * (152) and Finding (250). + * + * Invalidation deliberately has no hue: every candidate warning colour collides + * with the Risk red, and the claim is already carried more precisely by ghosting + * the invalidated record itself rather than tinting the line pointing at it. + * + * Membership resolves per Effort at the call site, so the legend passes no + * colour and renders a neutral sample rather than picking an arbitrary + * cluster's tint. + */ +export function relationStrokeOklch( + group: RelationGroupId, + mode: ColorMode +): { l: number; c: number; h: number } { + if (group === 'resolution' || group === 'mitigation') { + return mode === 'light' ? { l: 0.52, c: 0.12, h: 196 } : { l: 0.72, c: 0.1, h: 196 }; + } + return mode === 'light' ? { l: 0.42, c: 0.015, h: 260 } : { l: 0.76, c: 0.015, h: 260 }; +} + +/** + * Lifecycle badge styling, keyed by `primitive:state` so the same word can + * mean different things per primitive. A Decision's `accepted` is a + * commitment; a Risk's `accepted` is "we chose to live with this hazard" — + * they must not share the same reassuring green. + */ +const LIFECYCLE_BADGE: Record = { + // Effort + 'effort:active': { + label: 'Active', + className: 'border-sky-500/40 bg-sky-500/12 text-sky-800 dark:text-sky-200', + }, + 'effort:paused': { + label: 'Paused', + className: 'border-border bg-muted/12 text-foreground/75', + }, + 'effort:completed': { + label: 'Completed', + className: + 'border-emerald-500/40 bg-emerald-500/12 text-emerald-800 dark:text-emerald-200', + }, + 'effort:abandoned': { + label: 'Abandoned', + className: 'border-border bg-muted/12 text-muted line-through decoration-muted/60', + }, + // Issue + 'issue:open': { + label: 'Open', + className: 'border-amber-500/40 bg-amber-500/12 text-amber-800 dark:text-amber-200', + }, + 'issue:resolved': { + label: 'Resolved', + className: + 'border-emerald-500/40 bg-emerald-500/12 text-emerald-800 dark:text-emerald-200', + }, + 'issue:deferred': { + label: 'Deferred', + className: 'border-border bg-muted/12 text-foreground/75', + }, + 'issue:wontfix': { + label: "Won't fix", + className: 'border-border bg-muted/12 text-muted line-through decoration-muted/60', + }, + // Decision + 'decision:proposed': { + label: 'Proposed', + className: 'border-amber-500/40 bg-amber-500/12 text-amber-800 dark:text-amber-200', + }, + 'decision:accepted': { + label: 'Accepted', + className: + 'border-emerald-500/40 bg-emerald-500/12 text-emerald-800 dark:text-emerald-200', + }, + 'decision:rejected': { + label: 'Rejected', + className: 'border-rose-500/40 bg-rose-500/12 text-rose-800 dark:text-rose-200', + }, + 'decision:superseded': { + label: 'Superseded', + className: 'border-border bg-muted/12 text-muted line-through decoration-muted/60', + }, + 'decision:deprecated': { + label: 'Deprecated', + className: 'border-border bg-muted/12 text-muted line-through decoration-muted/60', + }, + // Risk + 'risk:open': { + label: 'Open', + className: 'border-amber-500/40 bg-amber-500/12 text-amber-800 dark:text-amber-200', + }, + 'risk:mitigated': { + label: 'Mitigated', + className: + 'border-emerald-500/40 bg-emerald-500/12 text-emerald-800 dark:text-emerald-200', + }, + 'risk:realized': { + label: 'Realized', + className: 'border-rose-500/40 bg-rose-500/12 text-rose-800 dark:text-rose-200', + }, + 'risk:accepted': { + label: 'Accepted risk', + className: 'border-amber-500/40 bg-amber-500/12 text-amber-800 dark:text-amber-200', + }, +}; + +/** Applies to any primitive — these come from edges, not frontmatter. */ +const EDGE_DERIVED_BADGE: Record = { + superseded: { + label: 'Superseded', + className: 'border-border bg-muted/12 text-muted line-through decoration-muted/60', + }, + invalidated: { + label: 'Invalidated', + className: 'border-rose-500/40 bg-rose-500/12 text-rose-800 dark:text-rose-200', + }, +}; + +export function lifecycleBadge( + kind: GraphNodeKind, + state: string | undefined +): { label: string; className: string } | null { + if (!state) return null; + const key = state.toLowerCase(); + return ( + LIFECYCLE_BADGE[`${kind}:${key}`] ?? + EDGE_DERIVED_BADGE[key] ?? { + label: state, + className: 'border-border bg-muted/12 text-foreground/75', + } + ); +} + +/** + * Dash pattern, scaled by `weight` exactly as `dashWorldUnits` scales it on the + * canvas. `weight` deliberately does *not* drive stroke width here: three.js + * `LineBasicMaterial` ignores `linewidth` on every major platform, so a legend + * drawing 1.25/2/2.5px strokes would advertise a distinction the canvas cannot + * render. Dash length and opacity are what actually vary. + */ +function strokeDash( + dash: RelationMeta['dash'], + weight: RelationMeta['weight'] +): string | undefined { + if (dash === 'solid') return undefined; + const scale = weight === 'medium' ? 1.15 : weight === 'bold' ? 1.3 : 1; + if (dash === 'dashed') return `${(5 * scale).toFixed(1)} ${(3 * scale).toFixed(1)}`; + return `${(1.6 * scale).toFixed(1)} ${(2.6 * scale).toFixed(1)}`; +} + +const LEGEND_STROKE_WIDTH = 1.6; + +function lineOpacity(emphasis: RelationMeta['emphasis']): number { + if (emphasis === 'subtle') return 0.5; + if (emphasis === 'medium') return 0.7; + if (emphasis === 'strong') return 0.92; + return 0.78; +} + +/** + * Legend sample for one relation. `color` must be the same value the canvas + * strokes with, otherwise the legend teaches an encoding that doesn't exist. + */ +export function RelationLineSample({ + meta, + color, + className = '', +}: { + meta: RelationMeta; + color?: string; + className?: string; +}) { + const opacity = lineOpacity(meta.emphasis); + const dash = strokeDash(meta.dash, meta.weight); + + return ( + + + {meta.arrow && ( + + )} + + ); +} + +/** + * The legend's primitive marker. Draws the *same* outline the canvas builds + * its `ShapeGeometry` from, in the same hue, so the key cannot drift from the + * render. + */ +export function PrimitiveGlyph({ + kind, + mode, + size = 14, + retired = false, + tint, + core, +}: { + kind: GraphNodeKind; + mode: ColorMode; + size?: number; + retired?: boolean; + /** Override the fill, e.g. to show a record in its retired tone. */ + tint?: string; + /** Cluster tint for the centre of an Effort hub. */ + core?: string; +}) { + const { glyph } = PRIMITIVES[kind]; + const fill = tint ?? oklchCss(primitiveOklch(kind, mode)); + const points = glyphSvgPoints(glyph, size); + const half = size / 2; + + return ( + + {glyph === 'ring' ? ( + <> + + {core && ( + + )} + + ) : points ? ( + + ) : ( + + )} + + ); +} diff --git a/examples/effort-viz/app/components/TopBar.tsx b/examples/effort-viz/app/components/TopBar.tsx new file mode 100644 index 00000000..f0654644 --- /dev/null +++ b/examples/effort-viz/app/components/TopBar.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { useTheme } from '../hooks/useTheme'; +import type { GraphSummary } from '@/lib/lifecycle'; +import { + liveStatusLabel, + type LiveStatus, +} from '@/lib/useEffortGraphLive'; + +interface TopBarProps { + status: LiveStatus; + generation: number | null; + summary: GraphSummary; +} + +export function TopBar({ status, generation, summary }: TopBarProps) { + return ( +
    +
    +

    + Effort Graph +

    + {/* + Counts are phrased in primitives, not nodes and edges: roughly half of + the "edges" are synthesised membership spokes, and "173 nodes" tells a + reader a dot appeared where "2 proposed Decisions" tells them someone + still owes a call. + */} +

    + + {' · '} + + {' · '} + + {summary.liveRisks > 0 && ( + <> + {' · '} + + + )} + {summary.retired > 0 && ( + <> + {' · '} + + + + + )} +

    +
    +
    + + +
    +
    + ); +} + +function Count({ + value, + label, + plural, +}: { + value: number; + label: string; + plural?: string; +}) { + return ( + <> + {value} {value === 1 ? label : (plural ?? `${label}s`)} + + ); +} + +function StatusPill({ + status, + generation, +}: { + status: LiveStatus; + generation: number | null; +}) { + const dotClass = + status === 'live' + ? 'bg-emerald-500' + : status === 'connecting' + ? 'bg-amber-500 motion-safe:animate-pulse' + : status === 'partial' + ? 'bg-amber-500' + : 'bg-red-500'; + + return ( +
    + + + {liveStatusLabel(status)} + + + {generation !== null ? `· gen ${generation}` : ''} + +
    + ); +} + +function ThemeToggle() { + const { mode, toggle } = useTheme(); + const label = mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'; + return ( + + ); +} + +function SunIcon() { + return ( + + + + + + + + + + + + ); +} + +function MoonIcon() { + return ( + + + + ); +} diff --git a/examples/effort-viz/app/globals.css b/examples/effort-viz/app/globals.css new file mode 100644 index 00000000..de67133c --- /dev/null +++ b/examples/effort-viz/app/globals.css @@ -0,0 +1,168 @@ +@import 'tailwindcss'; + +:root { + color-scheme: light dark; + --background: #ffffff; + --foreground: #171717; + --muted: #737373; + --muted-foreground: #737373; + --border: #eaeaea; + --accent: #0070f3; + --safe-area-top: env(safe-area-inset-top, 0px); + --safe-area-right: env(safe-area-inset-right, 0px); + --safe-area-bottom: env(safe-area-inset-bottom, 0px); + --safe-area-left: env(safe-area-inset-left, 0px); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + --background: #0a0a0a; + --foreground: #ededed; + --muted: #888888; + --muted-foreground: #888888; + --border: #262626; + --accent: #3291ff; + } +} + +[data-theme='light'] { + color-scheme: light; + --background: #ffffff; + --foreground: #171717; + --muted: #737373; + --muted-foreground: #737373; + --border: #eaeaea; + --accent: #0070f3; +} + +[data-theme='dark'] { + color-scheme: dark; + --background: #0a0a0a; + --foreground: #ededed; + --muted: #888888; + --muted-foreground: #888888; + --border: #262626; + --accent: #3291ff; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-border: var(--border); + --color-accent: var(--accent); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +* { + box-sizing: border-box; +} + +html { + height: 100%; + min-height: 100dvh; +} + +body { + min-height: 100dvh; + height: 100%; + background: var(--background); + color: var(--foreground); + font-family: var(--font-geist-sans), system-ui, -apple-system, sans-serif; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow: hidden; + overscroll-behavior: none; +} + +.safe-area-top { + padding-top: var(--safe-area-top); +} + +.safe-area-x { + padding-left: var(--safe-area-left); + padding-right: var(--safe-area-right); +} + +/* + * Subtle atmospheric field behind the graph — not a flat wash. This lives on + * the graph region rather than `body`, because the app root paints an opaque + * background that would occlude anything behind it. + */ +.atmosphere::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + z-index: 0; + background: + radial-gradient( + 110% 75% at 10% -12%, + color-mix(in srgb, var(--accent) 4%, transparent), + transparent 52% + ), + radial-gradient( + 85% 65% at 92% 112%, + color-mix(in srgb, var(--foreground) 3%, transparent), + transparent 48% + ); +} + +::selection { + background: color-mix(in srgb, var(--accent) 24%, transparent); + color: var(--foreground); +} + +/* R3F canvas: fill available space, transparent to CSS bg. */ +canvas { + outline: none; + touch-action: none; +} + +/* + * Floating DOM labels anchored to graph nodes. + * + * Weight is constant at every state: these are repositioned every frame, and a + * weight change on hover or selection reflows the text, shifting the label out + * from under the pointer. State reads through border and background instead. + * No backdrop blur either — one blurred DOM node per label over a canvas that + * repaints every frame is the most expensive way to buy legibility. + */ +.effort-label { + font-family: var(--font-geist-sans), system-ui, sans-serif; + font-size: 11.5px; + font-weight: 500; + line-height: 1.2; + letter-spacing: -0.005em; + padding: 3px 8px; + border-radius: 999px; + color: var(--foreground); + background: color-mix(in srgb, var(--background) 94%, transparent); + border: 1px solid var(--border); + white-space: nowrap; + max-width: min(216px, 42vw); + overflow: hidden; + text-overflow: ellipsis; + transform-origin: center bottom; +} + +/* Effort hubs anchor their cluster, so their labels carry more weight. */ +.effort-label-hub { + font-size: 12px; + color: var(--foreground); + border-color: color-mix(in srgb, var(--foreground) 18%, transparent); +} + +.effort-label-active { + border-color: color-mix(in srgb, var(--foreground) 45%, transparent); + box-shadow: 0 1px 2px rgb(0 0 0 / 8%); +} + +.effort-label-retired { + color: var(--muted); + text-decoration: line-through; + text-decoration-color: color-mix(in srgb, var(--muted) 60%, transparent); +} diff --git a/examples/effort-viz/app/hooks/useTheme.tsx b/examples/effort-viz/app/hooks/useTheme.tsx new file mode 100644 index 00000000..6a332eb2 --- /dev/null +++ b/examples/effort-viz/app/hooks/useTheme.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; + +export type ColorMode = 'light' | 'dark'; + +const STORAGE_KEY = 'effort-viz-theme'; + +interface ThemeContextValue { + mode: ColorMode; + toggle: () => void; + setMode: (mode: ColorMode) => void; +} + +const ThemeContext = createContext(null); + +function resolveInitialMode(): ColorMode { + if (typeof document === 'undefined') return 'light'; + const attr = document.documentElement.dataset.theme; + if (attr === 'light' || attr === 'dark') return attr; + if (typeof window !== 'undefined' && window.matchMedia) { + return window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; + } + return 'light'; +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [mode, setModeState] = useState('light'); + const [hydrated, setHydrated] = useState(false); + /** + * Whether the user has actually chosen a mode. Without this the first mount + * persists the resolved system preference, silently converting "follow the + * system" into a permanent explicit choice. + */ + const [explicit, setExplicit] = useState(false); + + useEffect(() => { + let stored: string | null = null; + try { + stored = window.localStorage.getItem(STORAGE_KEY); + } catch { + // storage may be unavailable (e.g. private mode) — non-fatal + } + setExplicit(stored === 'light' || stored === 'dark'); + setModeState(resolveInitialMode()); + setHydrated(true); + }, []); + + // Follow the system while the user has not chosen for themselves. + useEffect(() => { + if (explicit || typeof window === 'undefined' || !window.matchMedia) return; + const query = window.matchMedia('(prefers-color-scheme: dark)'); + const onChange = (event: MediaQueryListEvent) => { + setModeState(event.matches ? 'dark' : 'light'); + }; + query.addEventListener('change', onChange); + return () => query.removeEventListener('change', onChange); + }, [explicit]); + + useEffect(() => { + if (!hydrated) return; + document.documentElement.dataset.theme = mode; + if (!explicit) return; + try { + window.localStorage.setItem(STORAGE_KEY, mode); + } catch { + // storage may be unavailable (e.g. private mode) — non-fatal + } + }, [mode, hydrated, explicit]); + + const setMode = useCallback((next: ColorMode) => { + setExplicit(true); + setModeState(next); + }, []); + + const toggle = useCallback(() => { + setExplicit(true); + setModeState((prev) => (prev === 'dark' ? 'light' : 'dark')); + }, []); + + const value = useMemo( + () => ({ mode, setMode, toggle }), + [mode, setMode, toggle] + ); + + return {children}; +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return ctx; +} + +export const THEME_BOOT_SCRIPT = `(function(){try{var k='${STORAGE_KEY}';var s=localStorage.getItem(k);var m=(s==='light'||s==='dark')?s:(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.dataset.theme=m;}catch(e){}})();`; diff --git a/examples/effort-viz/app/layout.tsx b/examples/effort-viz/app/layout.tsx new file mode 100644 index 00000000..db47db10 --- /dev/null +++ b/examples/effort-viz/app/layout.tsx @@ -0,0 +1,47 @@ +import type { Metadata, Viewport } from 'next'; +import { Geist, Geist_Mono } from 'next/font/google'; +import './globals.css'; +import { ThemeProvider, THEME_BOOT_SCRIPT } from './hooks/useTheme'; + +const geistSans = Geist({ + variable: '--font-geist-sans', + subsets: ['latin'], +}); + +const geistMono = Geist_Mono({ + variable: '--font-geist-mono', + subsets: ['latin'], +}); + +export const metadata: Metadata = { + title: 'Effort Graph', + description: + "Live view of Flatbread's Effort Graph — Efforts, Issues, Findings, Decisions, Constraints, and Risks with typed relations.", +}; + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + viewportFit: 'cover', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + +