diff --git a/.agents/skills/effort-graph/setup.md b/.agents/skills/effort-graph/setup.md
index 71a13793..d961a875 100644
--- a/.agents/skills/effort-graph/setup.md
+++ b/.agents/skills/effort-graph/setup.md
@@ -84,3 +84,22 @@ On resume, begin with `flatbread effort list --status active`, then use bounded
effort-scoped reads. Capture mutation `generation` tokens and use
`--strict-min-generation` for immediate read-after-write checks; never implement
client polling loops. Semantic changes go through `flatbread effort write`.
+
+## 4. Open the explorer (optional)
+
+With a complete `effortGraphContent()` preset in config, Flatbread serves the
+content-relation explorer automatically (`@flatbread/explorer` ships with
+`flatbread`):
+
+```bash
+npx flatbread start --watch --open
+```
+
+Flatbread checks for the prebuilt single-page app (SPA) assets under
+`dist/static/`. When those assets are missing, Flatbread does not serve the
+explorer and `npx flatbread start --open` opens `/graphql` instead.
+
+- Explorer UI (when served): `http://localhost:5057/`
+- Apollo GraphQL sandbox: `http://localhost:5057/graphql`
+
+No separate app install is required.
diff --git a/AGENTS.md b/AGENTS.md
index 38faa0fa..cdbbc9ff 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -65,7 +65,7 @@ The repo uses Mergify stacks for PR management. The `mergify-cli` is installed v
- **Vitest packages run in watch mode by default.** Always use `vitest run` (not bare `vitest`) to get a single run and exit.
- **`flatbread` CLI is not on PATH globally.** From `examples/nextjs`, prefer `pnpm exec flatbread …` (local binary), or `npx flatbread` from a shell. The `pnpm play` script from the root handles this automatically.
- **Build before test.** All packages must be built (`pnpm build`) before running tests or starting dev servers. `pnpm test` handles this automatically.
-- **The Next.js example `dev` script uses `--https`.** This requires an SSL certificate. In headless/CI environments, run without `--https`: `pnpm exec flatbread start -- next dev --turbopack`.
+- **`-H, --https` does not make Flatbread serve HTTPS.** The server listens over plain HTTP whatever you pass. From `examples/nextjs`, run `pnpm exec flatbread start -- next dev --turbopack`.
- **Full local CI parity check:** `pnpm verify` runs lint, typecheck, build, and all tests.
### Weave merge driver
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9c7e08b5..ca2c83f9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -24,8 +24,7 @@ Use this path first. The Next.js app reads shared content from
2. `cd examples/nextjs`
3. One-shot codegen: `pnpm exec flatbread codegen --verbose` (output: `generated/graphql.ts`; globs and dirs come from `flatbread.config.js`).
4. Run the app **and** Flatbread together with **`flatbread start`** (there is **no** `flatbread dev` subcommand):
- - **`pnpm dev`** — starts Next with local HTTPS and watches Flatbread content, config, and GraphQL documents. GraphQL runs on **5057** and Next on **3000**.
- - Headless / no HTTPS: `pnpm exec flatbread start --watch -- next dev --turbopack`.
+ - **`pnpm dev`** — starts Next and watches Flatbread content, config, and GraphQL documents (`pnpm exec flatbread start --watch -- next dev --turbopack`). GraphQL runs on **5057** and Next on **3000**.
Optional **`pnpm play`** from the repo root is a shortcut for **`cd examples/nextjs && pnpm dev`** — same as step 4 above, not a separate product command.
@@ -35,7 +34,10 @@ 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.
+- **Effort Graph explorer:**
+ 1. Run `pnpm play:efforts` (builds `@flatbread/explorer` via `preplay:efforts`, then `flatbread start --watch --open`).
+ 2. When `flatbread.config.js` uses `effortGraphContent()`, Flatbread serves `@flatbread/explorer` at `http://localhost:5057/`. The Apollo sandbox is at `/graphql`.
+ 3. For hot module replacement (HMR) on the single-page app (SPA) shell, run `pnpm exec flatbread start --watch` and `pnpm --filter @flatbread/explorer dev` in parallel. Vite on **5173** proxies API routes to **5057**.
- Check local CI parity before opening a PR: `pnpm verify`
## Working on a package
diff --git a/ava.config.js b/ava.config.js
index cb9c7c85..531f2ef9 100644
--- a/ava.config.js
+++ b/ava.config.js
@@ -13,6 +13,8 @@ export default {
// parser/runtime guardrails.
'!packages/codegen/src/__tests__/**',
'!packages/utils/src/__tests__/**',
+ // Explorer SPA uses Node's built-in test runner (see package scripts).
+ '!packages/explorer/**',
],
extensions: {
js: true,
diff --git a/docs/local-dev-loop.md b/docs/local-dev-loop.md
index 214aaba0..71934d34 100644
--- a/docs/local-dev-loop.md
+++ b/docs/local-dev-loop.md
@@ -26,12 +26,11 @@ cd examples/nextjs
pnpm exec flatbread codegen --verbose
```
-For development, use the unified watcher. This path avoids the example
-package's HTTPS convenience script and keeps the Flatbread GraphQL endpoint on
-plain HTTP port `5057`.
+For development, use the unified watcher. It serves GraphQL on port `5057`,
+refreshes generated artifacts, and runs Next.js. The example package's
+`pnpm dev` script runs the same command.
```bash
-# serve GraphQL, refresh generated artifacts, and run Next.js without HTTPS
pnpm exec flatbread start --watch -- next dev --turbopack
```
@@ -104,10 +103,8 @@ Watch mode does the following:
or config change. If a change is invalid, it keeps the previous schema.
- Watch mode is a long-running process; do not use it in CI or one-shot
scripts.
-- The Next.js example `pnpm dev` includes `--https` for local convenience, but
- the Flatbread GraphQL endpoint remains documented as HTTP on `5057`. In
- headless environments prefer
- `pnpm exec flatbread start --watch -- next dev --turbopack`.
+- Flatbread serves plain HTTP. The `-H, --https` flag does not change how it
+ listens, so the GraphQL endpoint is always HTTP on `5057`.
- Codegen failures are logged and do not undo a committed schema generation.
- Watch mode requires a source plugin with `fetchPaths`; sources without it fail
fast at startup.
diff --git a/examples/effort-viz/.gitignore b/examples/effort-viz/.gitignore
deleted file mode 100644
index 20fec1a7..00000000
--- a/examples/effort-viz/.gitignore
+++ /dev/null
@@ -1,42 +0,0 @@
-# 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
deleted file mode 100644
index d13feb10..00000000
--- a/examples/effort-viz/README.md
+++ /dev/null
@@ -1,155 +0,0 @@
-# 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/layout.tsx b/examples/effort-viz/app/layout.tsx
deleted file mode 100644
index db47db10..00000000
--- a/examples/effort-viz/app/layout.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-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 (
-
-
-
-
-
- {children}
-
-
- );
-}
diff --git a/examples/effort-viz/app/page.tsx b/examples/effort-viz/app/page.tsx
deleted file mode 100644
index bdd34f65..00000000
--- a/examples/effort-viz/app/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { EffortGraphApp } from './components/EffortGraphApp';
-
-export default function Home() {
- return ;
-}
diff --git a/examples/effort-viz/flatbread.config.js b/examples/effort-viz/flatbread.config.js
deleted file mode 100644
index 7267c7e1..00000000
--- a/examples/effort-viz/flatbread.config.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import {
- defineConfig,
- effortGraphContent,
- sourceFilesystem,
- transformerMarkdown,
-} from 'flatbread';
-
-export default defineConfig({
- source: sourceFilesystem(),
- transformer: transformerMarkdown(),
- content: effortGraphContent('../../.flatbread-efforts'),
-});
diff --git a/examples/effort-viz/next.config.ts b/examples/effort-viz/next.config.ts
deleted file mode 100644
index 5e891cf0..00000000
--- a/examples/effort-viz/next.config.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { NextConfig } from 'next';
-
-const nextConfig: NextConfig = {
- /* config options here */
-};
-
-export default nextConfig;
diff --git a/examples/effort-viz/package.json b/examples/effort-viz/package.json
deleted file mode 100644
index 783e5f37..00000000
--- a/examples/effort-viz/package.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "name": "effort-viz",
- "version": "0.1.0",
- "private": true,
- "scripts": {
- "dev": "flatbread start --watch -- next dev --turbopack",
- "build": "flatbread start -- next build",
- "start": "next start",
- "typecheck": "tsc --noEmit",
- "test": "node --import tsx --test lib/physics/*.test.ts lib/*.test.ts"
- },
- "dependencies": {
- "@react-three/drei": "^10.0.6",
- "@react-three/fiber": "^9.1.2",
- "flatbread": "workspace:*",
- "next": "15.4.4",
- "react": "19.1.0",
- "react-dom": "19.1.0",
- "react-markdown": "10.1.0",
- "rehype-sanitize": "^6.0.0",
- "remark-gfm": "^4.0.1",
- "three": "^0.175.0"
- },
- "devDependencies": {
- "@tailwindcss/postcss": "^4.1.11",
- "@types/node": "^20.19.10",
- "@types/react": "^19.1.9",
- "@types/react-dom": "^19.1.7",
- "@types/three": "^0.175.0",
- "tailwindcss": "^4.1.11",
- "tsx": "^4.21.0",
- "typescript": "^5.9.2"
- }
-}
diff --git a/examples/effort-viz/postcss.config.mjs b/examples/effort-viz/postcss.config.mjs
deleted file mode 100644
index ba720fe5..00000000
--- a/examples/effort-viz/postcss.config.mjs
+++ /dev/null
@@ -1,5 +0,0 @@
-const config = {
- plugins: ['@tailwindcss/postcss'],
-};
-
-export default config;
diff --git a/examples/effort-viz/tsconfig.json b/examples/effort-viz/tsconfig.json
deleted file mode 100644
index d8b93235..00000000
--- a/examples/effort-viz/tsconfig.json
+++ /dev/null
@@ -1,27 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2017",
- "lib": ["dom", "dom.iterable", "esnext"],
- "allowJs": true,
- "skipLibCheck": true,
- "strict": true,
- "noEmit": true,
- "esModuleInterop": true,
- "module": "esnext",
- "moduleResolution": "bundler",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "jsx": "preserve",
- "incremental": true,
- "plugins": [
- {
- "name": "next"
- }
- ],
- "paths": {
- "@/*": ["./*"]
- }
- },
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules"]
-}
diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md
index a9b80494..eb570260 100644
--- a/examples/nextjs/README.md
+++ b/examples/nextjs/README.md
@@ -35,8 +35,9 @@ tests. For this guide, focus on posts, authors, and tags.
4. **Start Flatbread and Next** (**there is no `flatbread dev`** — use
**`flatbread start`**):
- - **With local HTTPS:** `pnpm dev`. This runs watch mode and starts Next.
- - **Headless or no HTTPS:** `pnpm exec flatbread start --watch -- next dev --turbopack`.
+ - `pnpm dev` — runs watch mode and starts Next
+ (`pnpm exec flatbread start --watch -- next dev --turbopack`).
+ GraphQL on **5057**, Next on **3000**.
With `--watch`, Flatbread reloads valid content and config changes and
refreshes generated types. You do not need a second codegen watcher.
@@ -47,7 +48,7 @@ tests. For this guide, focus on posts, authors, and tags.
| Script | Purpose |
| ----------------------------------------- | --------------------------------------------------------------------------------------------------- |
-| `pnpm dev` | **`flatbread start --watch`** + Next dev (HTTPS). GraphQL on **5057**, Next on **3000**. |
+| `pnpm dev` | **`flatbread start --watch`** + Next dev. GraphQL on **5057**, Next on **3000**. |
| `pnpm build` | **`flatbread start`** wrapping **`next build`** so schema/codegen paths resolve during build. |
| `pnpm start` | **`next start` only** — production Next; does **not** run Flatbread unless you arrange it. |
| `pnpm run codegen` | Optional separate type watcher. Use it only when `flatbread start --watch` is not running. |
diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json
index a88a368a..dc9b1692 100644
--- a/examples/nextjs/package.json
+++ b/examples/nextjs/package.json
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "dev": "flatbread start --watch --https -- next dev --turbopack",
+ "dev": "flatbread start --watch -- next dev --turbopack",
"codegen": "flatbread codegen --watch",
"demo:edit": "node scripts/demo-edit.mjs",
"demo:restore": "node scripts/demo-restore.mjs",
diff --git a/flatbread-flow-agentic-workflows.md b/flatbread-flow-agentic-workflows.md
index 64e7e18e..43df26fc 100644
--- a/flatbread-flow-agentic-workflows.md
+++ b/flatbread-flow-agentic-workflows.md
@@ -92,7 +92,7 @@ When running this workflow under the `proof` skill (the `@flatbread/proof` packa
- **`readonly: true` and `tools:` frontmatter are advisory in DAG mode.** A subagent acting as `flatbread-architecture-planner` can still write files because the runner does not pass agent frontmatter to `Agent.create`. `Do not edit files.` is purely a prompt-level instruction; reinforce it in the prompt body for read-only tasks.
- **Project skills do auto-attach.** `proof` and `flatbread-major-migration` are visible to subagents via description, so subtask prompts can reference them by name without re-explaining their contents.
- **Same-rank file-write safety for Flatbread.** The coupled chain `packages/core` → `packages/codegen` → `examples/nextjs` is the most common contention. Treat the safe parallel cuts list below as the source of truth.
-- **Same-rank port-5057 safety.** `flatbread start` always binds port `5057` (and `5058` for HTTPS, per `packages/flatbread/src/cli/index.ts:128-135`). Never put two tasks that invoke `flatbread start`, `pnpm dev`, `pnpm build`, or `agent-browser` against `examples/nextjs` in the same rank.
+- **Same-rank port-5057 safety.** `flatbread start` binds port `5057` over plain HTTP. Never put two tasks that invoke `flatbread start`, `pnpm dev`, `pnpm build`, or `agent-browser` against `examples/nextjs` in the same rank.
- **2000-char upstream stitch cap on a 4000-char `STREAM_CAP`.** Each child sees at most 2000 chars of each parent's `resultText`, and the parent's output is itself capped at 4000 chars. Subagent output must lead with the structured headings the downstream task needs and group related entries (e.g. `packages/core/src/{generators,resolvers,types}.ts`) to fit in the window.
### Default DAG shape per phase
diff --git a/package.json b/package.json
index 71ad7769..54fb5e3a 100644
--- a/package.json
+++ b/package.json
@@ -27,17 +27,18 @@
"lint:fix": "pnpm lint:fix:prettier",
"lint:fix:fast": "prettier --write --plugin-search-dir=. .",
"lint:fix:prettier": "pretty-quick --staged",
- "typecheck": "pnpm --filter @flatbread/proof --filter effort-viz typecheck",
+ "typecheck": "pnpm --filter @flatbread/proof --filter @flatbread/explorer typecheck",
"play": "cd examples/nextjs && pnpm dev",
- "play:efforts": "cd examples/effort-viz && pnpm dev",
+ "preplay:efforts": "pnpm --filter @flatbread/explorer build",
+ "play:efforts": "pnpm exec flatbread start --watch --open",
"play:build": "pnpm build && cd examples/nextjs && pnpm build",
"prepublish:ci": "pnpm install --frozen-lockfile && pnpm build:types",
"publish:ci": "esno scripts/publish.ts",
"bump": "esno scripts/bumpVersions.ts",
"test:ava": "ava",
"test:vitest": "pnpm --filter @flatbread/codegen --filter @flatbread/utils test",
- "test:examples": "pnpm --filter effort-viz test",
- "test": "pnpm build && pnpm test:ava && pnpm test:vitest && pnpm test:examples",
+ "test:explorer": "pnpm --filter @flatbread/explorer test",
+ "test": "pnpm build && pnpm test:ava && pnpm test:vitest && pnpm test:explorer",
"verify": "pnpm skills:check && pnpm skills:pack-check && pnpm lint && pnpm typecheck && pnpm build && pnpm test",
"cursor:fetch-cloud-agent": "pnpm --filter @flatbread/proof exec node scripts/fetch-cloud-agent-conversation.mjs",
"dev:test": "ava --watch --verbose",
diff --git a/packages/effort-graph/skills/effort-graph/setup.md b/packages/effort-graph/skills/effort-graph/setup.md
index 71a13793..d961a875 100644
--- a/packages/effort-graph/skills/effort-graph/setup.md
+++ b/packages/effort-graph/skills/effort-graph/setup.md
@@ -84,3 +84,22 @@ On resume, begin with `flatbread effort list --status active`, then use bounded
effort-scoped reads. Capture mutation `generation` tokens and use
`--strict-min-generation` for immediate read-after-write checks; never implement
client polling loops. Semantic changes go through `flatbread effort write`.
+
+## 4. Open the explorer (optional)
+
+With a complete `effortGraphContent()` preset in config, Flatbread serves the
+content-relation explorer automatically (`@flatbread/explorer` ships with
+`flatbread`):
+
+```bash
+npx flatbread start --watch --open
+```
+
+Flatbread checks for the prebuilt single-page app (SPA) assets under
+`dist/static/`. When those assets are missing, Flatbread does not serve the
+explorer and `npx flatbread start --open` opens `/graphql` instead.
+
+- Explorer UI (when served): `http://localhost:5057/`
+- Apollo GraphQL sandbox: `http://localhost:5057/graphql`
+
+No separate app install is required.
diff --git a/packages/explorer/README.md b/packages/explorer/README.md
new file mode 100644
index 00000000..0227709e
--- /dev/null
+++ b/packages/explorer/README.md
@@ -0,0 +1,58 @@
+# `@flatbread/explorer`
+
+Content-relation explorer for Flatbread. v1 ships a generic single-page app
+(SPA) shell plus an **Effort Graph** preset. When your config uses
+`effortGraphContent()`, `flatbread start` serves this UI at `/`.
+
+## Try it locally
+
+```bash
+# flatbread.config.js includes effortGraphContent()
+npx flatbread start --watch --open
+# → http://localhost:5057/ explorer
+# → http://localhost:5057/graphql Apollo sandbox
+```
+
+No separate Next.js app is required. Flatbread checks for the prebuilt assets
+under `dist/static/` (see [Develop in the monorepo](#develop-in-the-monorepo)).
+When those assets are missing, Flatbread does not serve the explorer and
+`npx flatbread start --open` opens `/graphql` instead. `pnpm play:efforts` runs
+the explorer build automatically.
+
+## Static deploy
+
+This package publishes prebuilt assets under `dist/static/`. Drop them on any
+static host and point at a reachable Flatbread GraphQL endpoint:
+
+```
+https://your-host.example/?endpoint=https://api.example.com/graphql
+```
+
+Same-origin deploys (assets served by Flatbread) need no query param.
+
+## What the package exports
+
+| Export | Role |
+| ------------------------------ | ------------------------------------------ |
+| `getExplorerStaticDir()` | Absolute path to `dist/static` for Express |
+| `explorerAssetsPresent()` | Whether prebuilt `index.html` exists |
+| `matchExplorerPreset(content)` | Detect Effort Graph (and later presets) |
+| `EXPLORER_BOOTSTRAP_PATH` | Bootstrap JSON path Flatbread injects |
+
+There is no public React component export in v1.
+
+## Develop in the monorepo
+
+```bash
+pnpm --filter @flatbread/explorer test
+pnpm play:efforts # builds explorer, then flatbread start --watch --open
+```
+
+For UI-only iteration with hot module replacement (HMR), run Flatbread and Vite
+in separate terminals (Vite proxies `/graphql` and `/events` to Flatbread on
+port **5057**, or `FLATBREAD_PORT` when set):
+
+```bash
+pnpm exec flatbread start --watch # terminal 1 — GraphQL on :5057
+pnpm --filter @flatbread/explorer dev # terminal 2 — SPA on :5173
+```
diff --git a/packages/explorer/index.html b/packages/explorer/index.html
new file mode 100644
index 00000000..50c8a3bb
--- /dev/null
+++ b/packages/explorer/index.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+ Flatbread Explorer
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/explorer/package.json b/packages/explorer/package.json
new file mode 100644
index 00000000..30aed741
--- /dev/null
+++ b/packages/explorer/package.json
@@ -0,0 +1,59 @@
+{
+ "name": "@flatbread/explorer",
+ "version": "0.1.0-alpha.0",
+ "description": "Content-relation explorer SPA for Flatbread — generic graph shell with Effort Graph as the first preset.",
+ "type": "module",
+ "scripts": {
+ "build": "pnpm build:node && pnpm build:web",
+ "build:node": "tsup",
+ "build:web": "vite build",
+ "dev": "vite",
+ "typecheck": "tsc -p tsconfig.json --noEmit",
+ "test": "node --import tsx --test src/node/matchPreset.test.ts src/node/staticDir.test.ts src/web/core/endpoints.test.ts src/web/core/physics/forces.test.ts src/web/core/physics/simulation.test.ts src/web/presets/effort-graph/glyphs.test.ts src/web/presets/effort-graph/lifecycle.test.ts src/web/presets/effort-graph/normalize.test.ts src/web/presets/effort-graph/query.test.ts src/web/presets/effort-graph/useEffortGraphLive.test.ts"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/FlatbreadLabs/flatbread.git",
+ "directory": "packages/explorer"
+ },
+ "homepage": "https://github.com/FlatbreadLabs/flatbread#readme",
+ "author": "Tony Ketcham ",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/FlatbreadLabs/flatbread/issues"
+ },
+ "exports": {
+ ".": "./dist/node/index.js"
+ },
+ "main": "dist/node/index.js",
+ "module": "dist/node/index.js",
+ "types": "dist/node/index.d.ts",
+ "files": [
+ "dist",
+ "*.d.ts"
+ ],
+ "dependencies": {
+ "@flatbread/effort-graph": "workspace:*"
+ },
+ "devDependencies": {
+ "@react-three/drei": "^10.0.6",
+ "@react-three/fiber": "^9.1.2",
+ "@tailwindcss/postcss": "^4.1.11",
+ "@types/node": "^20.19.10",
+ "@types/react": "^19.1.9",
+ "@types/react-dom": "^19.1.7",
+ "@types/three": "^0.175.0",
+ "@vitejs/plugin-react": "^4.7.0",
+ "react": "19.1.0",
+ "react-dom": "19.1.0",
+ "react-markdown": "10.1.0",
+ "rehype-sanitize": "^6.0.0",
+ "remark-gfm": "^4.0.1",
+ "tailwindcss": "^4.1.11",
+ "three": "^0.175.0",
+ "tsup": "8.5.1",
+ "tsx": "^4.21.0",
+ "typescript": "6.0.3",
+ "vite": "^6.3.5"
+ }
+}
diff --git a/packages/explorer/postcss.config.mjs b/packages/explorer/postcss.config.mjs
new file mode 100644
index 00000000..a34a3d56
--- /dev/null
+++ b/packages/explorer/postcss.config.mjs
@@ -0,0 +1,5 @@
+export default {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+};
diff --git a/packages/explorer/src/node/index.ts b/packages/explorer/src/node/index.ts
new file mode 100644
index 00000000..97c492e8
--- /dev/null
+++ b/packages/explorer/src/node/index.ts
@@ -0,0 +1,11 @@
+export {
+ explorerAssetsPresent,
+ getExplorerStaticDir,
+ setExplorerStaticDirOverride,
+ EXPLORER_BOOTSTRAP_PATH,
+} from './staticDir.js';
+export {
+ matchExplorerPreset,
+ type ExplorerPresetId,
+ type ExplorerPresetMatch,
+} from './matchPreset.js';
diff --git a/packages/explorer/src/node/matchPreset.test.ts b/packages/explorer/src/node/matchPreset.test.ts
new file mode 100644
index 00000000..86c6a3d5
--- /dev/null
+++ b/packages/explorer/src/node/matchPreset.test.ts
@@ -0,0 +1,28 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { effortGraphContent } from '@flatbread/effort-graph';
+import { matchExplorerPreset } from './matchPreset.js';
+
+describe('matchExplorerPreset', () => {
+ it('matches a full effortGraphContent() preset', () => {
+ const match = matchExplorerPreset(effortGraphContent('.flatbread-efforts'));
+ assert.deepEqual(match, {
+ preset: 'effort-graph',
+ root: '.flatbread-efforts',
+ });
+ });
+
+ it('returns null for unrelated content', () => {
+ assert.equal(
+ matchExplorerPreset([{ collection: 'Post', path: 'posts' }]),
+ null
+ );
+ });
+
+ it('returns null when the Effort Graph preset is incomplete', () => {
+ const partial = effortGraphContent('.flatbread-efforts').filter(
+ (entry) => entry.collection !== 'Blob'
+ );
+ assert.equal(matchExplorerPreset(partial), null);
+ });
+});
diff --git a/packages/explorer/src/node/matchPreset.ts b/packages/explorer/src/node/matchPreset.ts
new file mode 100644
index 00000000..95a0f94a
--- /dev/null
+++ b/packages/explorer/src/node/matchPreset.ts
@@ -0,0 +1,29 @@
+import { findEffortGraphContentRoot } from '@flatbread/effort-graph';
+
+export type ExplorerPresetId = 'effort-graph';
+
+export interface ExplorerPresetMatch {
+ preset: ExplorerPresetId;
+ /** Content root for the matched preset (Effort Graph markdown tree). */
+ root: string;
+}
+
+type ContentLike = readonly {
+ collection: string;
+ path?: string;
+ refs?: Record;
+}[];
+
+/**
+ * Return the explorer preset that should mount for this Flatbread content
+ * config, or `null` when no registered preset matches.
+ */
+export function matchExplorerPreset(
+ content: ContentLike
+): ExplorerPresetMatch | null {
+ const root = findEffortGraphContentRoot(
+ content as Parameters[0]
+ );
+ if (!root) return null;
+ return { preset: 'effort-graph', root };
+}
diff --git a/packages/explorer/src/node/staticDir.test.ts b/packages/explorer/src/node/staticDir.test.ts
new file mode 100644
index 00000000..aa04340d
--- /dev/null
+++ b/packages/explorer/src/node/staticDir.test.ts
@@ -0,0 +1,119 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import { mkdtemp, rm } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { afterEach, describe, it } from 'node:test';
+import {
+ explorerAssetsPresent,
+ getExplorerStaticDir,
+ setExplorerStaticDirOverride,
+} from './staticDir.js';
+
+afterEach(() => {
+ setExplorerStaticDirOverride(undefined);
+});
+
+describe('getExplorerStaticDir', () => {
+ it('resolves to a path ending in dist/static', () => {
+ const dir = getExplorerStaticDir();
+ assert.ok(dir.endsWith(`${path.sep}static`) || dir.endsWith('/static'));
+ assert.ok(
+ dir.includes(`${path.sep}dist${path.sep}`) || dir.includes('/dist/')
+ );
+ });
+
+ it('points at a directory that exists after build (or is creatable)', () => {
+ const dir = getExplorerStaticDir();
+ // During unit tests before build, the folder may not exist yet — only check shape.
+ assert.equal(path.basename(dir), 'static');
+ assert.equal(path.basename(path.dirname(dir)), 'dist');
+ // If built, index.html should be present.
+ const index = path.join(dir, 'index.html');
+ if (fs.existsSync(dir)) {
+ assert.ok(fs.existsSync(index), `expected ${index} after vite build`);
+ }
+ });
+
+ it('honors setExplorerStaticDirOverride when set', async () => {
+ const emptyDir = await mkdtemp(
+ path.join(os.tmpdir(), 'flatbread-explorer-static-')
+ );
+ try {
+ setExplorerStaticDirOverride(emptyDir);
+ assert.equal(getExplorerStaticDir(), emptyDir);
+ assert.equal(explorerAssetsPresent(), false);
+ } finally {
+ setExplorerStaticDirOverride(undefined);
+ await rm(emptyDir, { recursive: true, force: true });
+ }
+ });
+});
+
+describe('explorerAssetsPresent', () => {
+ it('returns true when index.html is a regular file', async () => {
+ const staticDir = await mkdtemp(
+ path.join(os.tmpdir(), 'flatbread-explorer-present-')
+ );
+ try {
+ await fs.promises.writeFile(
+ path.join(staticDir, 'index.html'),
+ '\n',
+ 'utf8'
+ );
+ setExplorerStaticDirOverride(staticDir);
+ assert.equal(explorerAssetsPresent(), true);
+ } finally {
+ setExplorerStaticDirOverride(undefined);
+ await rm(staticDir, { recursive: true, force: true });
+ }
+ });
+
+ it('returns false when index.html is missing', async () => {
+ const emptyDir = await mkdtemp(
+ path.join(os.tmpdir(), 'flatbread-explorer-absent-')
+ );
+ try {
+ setExplorerStaticDirOverride(emptyDir);
+ assert.equal(explorerAssetsPresent(), false);
+ } finally {
+ setExplorerStaticDirOverride(undefined);
+ await rm(emptyDir, { recursive: true, force: true });
+ }
+ });
+
+ it('returns false when index.html is a directory', async () => {
+ const staticDir = await mkdtemp(
+ path.join(os.tmpdir(), 'flatbread-explorer-eisdir-')
+ );
+ try {
+ await fs.promises.mkdir(path.join(staticDir, 'index.html'));
+ setExplorerStaticDirOverride(staticDir);
+ assert.equal(explorerAssetsPresent(), false);
+ } finally {
+ setExplorerStaticDirOverride(undefined);
+ await rm(staticDir, { recursive: true, force: true });
+ }
+ });
+
+ it('reflects whether index.html exists under the static dir', async () => {
+ const indexPath = path.join(getExplorerStaticDir(), 'index.html');
+ if (!fs.existsSync(indexPath)) {
+ assert.equal(explorerAssetsPresent(), false);
+ return;
+ }
+
+ assert.equal(explorerAssetsPresent(), true);
+
+ const emptyDir = await mkdtemp(
+ path.join(os.tmpdir(), 'flatbread-explorer-absent-')
+ );
+ try {
+ setExplorerStaticDirOverride(emptyDir);
+ assert.equal(explorerAssetsPresent(), false);
+ } finally {
+ setExplorerStaticDirOverride(undefined);
+ await rm(emptyDir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/packages/explorer/src/node/staticDir.ts b/packages/explorer/src/node/staticDir.ts
new file mode 100644
index 00000000..b7cc907f
--- /dev/null
+++ b/packages/explorer/src/node/staticDir.ts
@@ -0,0 +1,46 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+/** HTTP path Flatbread serves for explorer bootstrap JSON. */
+export const EXPLORER_BOOTSTRAP_PATH = '/__flatbread/explorer.json';
+
+let staticDirOverride: string | undefined;
+
+/**
+ * Test-only: force `getExplorerStaticDir()` to `dir`.
+ * Pass `undefined` to clear. Not for production callers.
+ */
+export function setExplorerStaticDirOverride(dir: string | undefined): void {
+ staticDirOverride = dir;
+}
+
+/**
+ * Absolute path to the prebuilt SPA assets shipped in this package.
+ * Flatbread mounts these with `express.static` when a preset matches.
+ * Honors `setExplorerStaticDirOverride` when set (tests only).
+ */
+export function getExplorerStaticDir(): string {
+ if (staticDirOverride !== undefined) {
+ return staticDirOverride;
+ }
+ const here = path.dirname(fileURLToPath(import.meta.url));
+ // Works from both `src/node` (tests) and `dist/node` (published).
+ const packageRoot = path.resolve(here, '../..');
+ return path.join(packageRoot, 'dist', 'static');
+}
+
+/**
+ * True when prebuilt SPA `index.html` is a regular file under
+ * `getExplorerStaticDir()`. A directory with that name does not count.
+ * Flatbread uses this with `matchExplorerPreset` before mounting or advertising
+ * explorer.
+ */
+export function explorerAssetsPresent(): boolean {
+ const indexPath = path.join(getExplorerStaticDir(), 'index.html');
+ try {
+ return fs.statSync(indexPath).isFile();
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/explorer/src/vite-env.d.ts b/packages/explorer/src/vite-env.d.ts
new file mode 100644
index 00000000..11f02fe2
--- /dev/null
+++ b/packages/explorer/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/effort-viz/app/components/DetailDrawer.tsx b/packages/explorer/src/web/app/components/DetailDrawer.tsx
similarity index 93%
rename from examples/effort-viz/app/components/DetailDrawer.tsx
rename to packages/explorer/src/web/app/components/DetailDrawer.tsx
index 0d9a963d..31f2e801 100644
--- a/examples/effort-viz/app/components/DetailDrawer.tsx
+++ b/packages/explorer/src/web/app/components/DetailDrawer.tsx
@@ -1,10 +1,11 @@
-'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 { oklchCss, effortColor, retiredOklch } from '@/core/oklch';
+import { PRIMITIVES, primitiveOklch } from '@/presets/effort-graph/primitives';
+import {
+ effectiveLifecycle,
+ type LifecycleIndex,
+} from '@/presets/effort-graph/lifecycle';
+import type { GraphEdge, GraphNode } from '@/presets/effort-graph/types';
import { useTheme } from '../hooks/useTheme';
import { MarkdownSurface } from './MarkdownSurface';
import {
@@ -70,7 +71,9 @@ export function DetailDrawer({
if (!nodeId) return;
headingRef.current?.focus();
return () => {
- const canvas = document.querySelector('[role="application"]');
+ 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();
@@ -168,7 +171,10 @@ export function DetailDrawer({
>
{node.title}
-
+
{node.id}
@@ -202,8 +208,8 @@ export function DetailDrawer({
{life.state === 'invalidated'
? 'Marked wrong by a later Finding. Its own frontmatter still records the state it was in when written.'
: life.state === 'rejected'
- ? 'Rejected by a later Decision. Its own frontmatter still records the state it was in when written.'
- : 'Replaced by a later record. Its own frontmatter still records the state it was in when written.'}{' '}
+ ? 'Rejected by a later Decision. Its own frontmatter still records the state it was in when written.'
+ : 'Replaced by a later record. Its own frontmatter still records the state it was in when written.'}{' '}
Read from the graph edges, which are authoritative.
)}
@@ -353,7 +359,9 @@ function RelationRow({
{peer?.title ?? peerId}
diff --git a/examples/effort-viz/app/components/EffortGraphApp.tsx b/packages/explorer/src/web/app/components/EffortGraphApp.tsx
similarity index 70%
rename from examples/effort-viz/app/components/EffortGraphApp.tsx
rename to packages/explorer/src/web/app/components/EffortGraphApp.tsx
index 4854b7bd..6a11d464 100644
--- a/examples/effort-viz/app/components/EffortGraphApp.tsx
+++ b/packages/explorer/src/web/app/components/EffortGraphApp.tsx
@@ -1,40 +1,25 @@
-'use client';
+import { lazy, Suspense, useEffect, useMemo } from 'react';
-import dynamic from 'next/dynamic';
-import { useEffect, useMemo } from 'react';
-
-import { useEffortGraphLive } from '@/lib/useEffortGraphLive';
+import { resolveGraphqlEndpoint } from '@/core/endpoints';
+import { useEffortGraphLive } from '@/presets/effort-graph/useEffortGraphLive';
import {
buildAlivenessMap,
buildLifecycleIndex,
summarizeGraph,
-} from '@/lib/lifecycle';
-import type { GraphNode } from '@/lib/types';
+} from '@/presets/effort-graph/lifecycle';
+import type { GraphNode } from '@/presets/effort-graph/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…
-
- ),
-});
+const GraphCanvas = lazy(() => import('./GraphCanvas'));
export function EffortGraphApp() {
- const {
- nodes,
- edges,
- status,
- generation,
- error,
- selectedId,
- setSelectedId,
- } = useEffortGraphLive();
+ const endpoint = resolveGraphqlEndpoint();
+ const { nodes, edges, status, generation, error, selectedId, setSelectedId } =
+ useEffortGraphLive({ endpoint });
const nodesById = useMemo(() => {
const map = new Map();
@@ -63,7 +48,7 @@ export function EffortGraphApp() {
return groups;
}, [edges]);
- const selectedNode = selectedId ? (nodesById.get(selectedId) ?? null) : null;
+ 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.
@@ -82,12 +67,20 @@ export function EffortGraphApp() {
) : (
-
+
+ Booting canvas…
+
+ }
+ >
+
+
)}
@@ -127,20 +120,22 @@ function EmptyState({
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.',
- ];
+ ? [
+ "Can't reach Flatbread",
+ error?.message ??
+ 'Run `flatbread start --watch --open` so GraphQL is served (default port 5057).',
+ ]
+ : [
+ 'No Effort Graph records yet',
+ 'Nothing found in the Effort Graph content root. Journal a record and it will grow in here.',
+ ];
return (
{heading}
-
{message}
+
+ {message}
+
);
}
diff --git a/examples/effort-viz/app/components/GraphCanvas.tsx b/packages/explorer/src/web/app/components/GraphCanvas.tsx
similarity index 94%
rename from examples/effort-viz/app/components/GraphCanvas.tsx
rename to packages/explorer/src/web/app/components/GraphCanvas.tsx
index 171fcfcc..09837e45 100644
--- a/examples/effort-viz/app/components/GraphCanvas.tsx
+++ b/packages/explorer/src/web/app/components/GraphCanvas.tsx
@@ -1,5 +1,3 @@
-'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';
@@ -23,31 +21,39 @@ import {
type SimEdge,
type SimNode,
type VeinPoint,
-} from '@/lib/physics';
+} from '@/core/physics';
import {
effortOklch,
oklchToThreeColor,
retiredOklch,
structuralOklch,
type Oklch,
-} from '@/lib/oklch';
-import { PRIMITIVES, primitiveOklch } from '@/lib/primitives';
+} from '@/core/oklch';
+import { PRIMITIVES, primitiveOklch } from '@/presets/effort-graph/primitives';
import {
CIRCLE_SEGMENTS,
GLYPH_OUTLINES,
RING_INNER_RATIO,
glyphExtent,
type GlyphId,
-} from '@/lib/glyphs';
+} from '@/presets/effort-graph/glyphs';
import {
buildAlivenessMap,
isOpenBlocker,
type Aliveness,
type EffectiveLifecycle,
-} from '@/lib/lifecycle';
-import type { GraphEdge, GraphEdgeKind, GraphNode } from '@/lib/types';
+} from '@/presets/effort-graph/lifecycle';
+import type {
+ GraphEdge,
+ GraphEdgeKind,
+ GraphNode,
+} from '@/presets/effort-graph/types';
import { useTheme, type ColorMode } from '../hooks/useTheme';
-import { RELATION_META, relationStrokeOklch, type RelationMeta } from './RelationLegend';
+import {
+ RELATION_META,
+ relationStrokeOklch,
+ type RelationMeta,
+} from './RelationLegend';
export interface GraphCanvasProps {
nodes: GraphNode[];
@@ -79,7 +85,10 @@ function toSimInputs(
return { simNodes, simEdges };
}
-function idsChanged(current: Array<{ id: string }>, previous: string[]): boolean {
+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;
@@ -106,7 +115,8 @@ function glyphGeometry(glyph: GlyphId): THREE.BufferGeometry {
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);
+ for (let i = 1; i < outline.length; i++)
+ shape.lineTo(outline[i].x, outline[i].y);
shape.closePath();
geometry = new THREE.ShapeGeometry(shape);
}
@@ -126,7 +136,8 @@ export default function GraphCanvas(props: GraphCanvasProps) {
const [focusedId, setFocusedId] = useState(null);
const wrapperRef = useRef(null);
const takeoverRef = useRef(null);
- if (takeoverRef.current === null) takeoverRef.current = createCameraTakeover();
+ if (takeoverRef.current === null)
+ takeoverRef.current = createCameraTakeover();
const takeover = takeoverRef.current;
/**
@@ -167,7 +178,8 @@ export default function GraphCanvas(props: GraphCanvasProps) {
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];
+ const next =
+ walkOrder[(index + delta + walkOrder.length) % walkOrder.length];
setFocusedId(next.id);
},
[walkOrder, focusedId, selectedId]
@@ -192,7 +204,8 @@ export default function GraphCanvas(props: GraphCanvasProps) {
break;
case 'End':
event.preventDefault();
- if (walkOrder.length > 0) setFocusedId(walkOrder[walkOrder.length - 1].id);
+ if (walkOrder.length > 0)
+ setFocusedId(walkOrder[walkOrder.length - 1].id);
break;
case 'Enter':
case ' ':
@@ -215,7 +228,9 @@ export default function GraphCanvas(props: GraphCanvasProps) {
[step, walkOrder, focusedId, selectedId, onSelect]
);
- const focusedNode = focusedId ? nodes.find((n) => n.id === focusedId) : undefined;
+ const focusedNode = focusedId
+ ? nodes.find((n) => n.id === focusedId)
+ : undefined;
const focusedLife = focusedId ? lifecycles.get(focusedId) : undefined;
return (
@@ -236,8 +251,9 @@ export default function GraphCanvas(props: GraphCanvasProps) {
className="relative h-full w-full outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent"
>
- 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.
+ 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.