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 ( - - - + + +
+ + + 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.

{ const { simNodes, simEdges } = toSimInputs(nodes, edges); - const settledBefore = sim.getState().nodes.some((n) => n.state === 'settled'); + const settledBefore = sim + .getState() + .nodes.some((n) => n.state === 'settled'); sim.sync(simNodes, simEdges); /* * Reduced motion: settle the layout and finish every growth animation @@ -633,7 +651,10 @@ function FitCamera({ * 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 }; + const state = { + moved: false, + downAt: null as { x: number; y: number } | null, + }; return { get: () => state.moved, onWheel: () => { @@ -645,7 +666,9 @@ function createCameraTakeover() { 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) { + if ( + Math.hypot(event.clientX - down.x, event.clientY - down.y) > TAP_SLOP_PX + ) { state.moved = true; } }, @@ -810,7 +833,9 @@ function NodeMesh({ const haloColor = useMemo( () => oklchToThreeColor( - mode === 'light' ? { l: 0.2, c: 0.01, h: 260 } : { l: 0.96, c: 0.01, h: 260 } + mode === 'light' + ? { l: 0.2, c: 0.01, h: 260 } + : { l: 0.96, c: 0.01, h: 260 } ), [mode] ); @@ -853,9 +878,12 @@ function NodeMesh({ } }); - const handlePointerDown = useCallback((event: { clientX: number; clientY: number }) => { - pointerStart.current = { x: event.clientX, y: event.clientY }; - }, []); + 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. @@ -863,11 +891,18 @@ function NodeMesh({ * every time a pan happened to start over a record. */ const handlePointerUp = useCallback( - (event: { clientX: number; clientY: number; stopPropagation: () => void }) => { + (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); + const travelled = Math.hypot( + event.clientX - start.x, + event.clientY - start.y + ); if (travelled > TAP_SLOP_PX) return; event.stopPropagation(); onSelect(node.id); @@ -878,7 +913,12 @@ function NodeMesh({ return ( - + {blocker && !retired && } - + {/* A hub's core: fills the ring's hole so membership spokes converging on @@ -1129,7 +1173,9 @@ function EdgeLine({ positions[i * 3 + 1] = visible[i].y; positions[i * 3 + 2] = 0; } - const attr = geom.getAttribute('position') as THREE.BufferAttribute | undefined; + 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 @@ -1143,7 +1189,8 @@ function EdgeLine({ const highlighted = (activeId !== null && (activeId === edge.from || activeId === edge.to)) || - (hoveredId !== null && (hoveredId === edge.from || hoveredId === edge.to)); + (hoveredId !== null && + (hoveredId === edge.from || hoveredId === edge.to)); const baseOpacity = relationEmphasisOpacity(meta.emphasis); const modeScale = mode === 'dark' ? 1.05 : 0.95; @@ -1255,7 +1302,10 @@ function ClusterLabel({ return ( - + ); } diff --git a/examples/effort-viz/app/components/Legend.tsx b/packages/explorer/src/web/app/components/Legend.tsx similarity index 88% rename from examples/effort-viz/app/components/Legend.tsx rename to packages/explorer/src/web/app/components/Legend.tsx index c143fa8f..a6575d1a 100644 --- a/examples/effort-viz/app/components/Legend.tsx +++ b/packages/explorer/src/web/app/components/Legend.tsx @@ -1,5 +1,3 @@ -'use client'; - import { useId, useState } from 'react'; import { @@ -8,9 +6,13 @@ import { retiredOklch, structuralOklch, type ColorMode, -} from '@/lib/oklch'; -import { PRIMITIVES, PRIMITIVE_ORDER, primitiveOklch } from '@/lib/primitives'; -import type { GraphNode } from '@/lib/types'; +} from '@/core/oklch'; +import { + PRIMITIVES, + PRIMITIVE_ORDER, + primitiveOklch, +} from '@/presets/effort-graph/primitives'; +import type { GraphNode } from '@/presets/effort-graph/types'; import { useTheme } from '../hooks/useTheme'; import { PrimitiveGlyph, @@ -64,7 +66,11 @@ export function Legend({ efforts, presentGroups }: LegendProps) { }`} aria-hidden > - +
@@ -97,7 +103,9 @@ export function Legend({ efforts, presentGroups }: LegendProps) { size={13} core={ kind === 'effort' - ? oklchCss(structuralOklch(mode === 'light' ? 'dark' : 'light')) + ? oklchCss( + structuralOklch(mode === 'light' ? 'dark' : 'light') + ) : undefined } /> @@ -130,9 +138,13 @@ export function Legend({ efforts, presentGroups }: LegendProps) { mode={mode} size={13} retired - tint={oklchCss(retiredOklch(primitiveOklch('decision', mode), mode))} + tint={oklchCss( + retiredOklch(primitiveOklch('decision', mode), mode) + )} /> - Retired + + Retired +

Rejected, superseded, invalidated, or won't fix. @@ -140,7 +152,9 @@ export function Legend({ efforts, presentGroups }: LegendProps) {

Blocker - open, gating work + + open, gating work +
@@ -193,7 +207,10 @@ export function Legend({ efforts, presentGroups }: LegendProps) { className="size-2.5 shrink-0 rounded-full" style={{ background: effortColor(effort.id, mode).css }} /> - + {effort.title} @@ -224,7 +241,13 @@ function SectionHeading({ children }: { children: React.ReactNode }) { */ function BlockerSample({ mode }: { mode: ColorMode }) { return ( - + (
  • {children}
  • @@ -101,7 +102,10 @@ export function MarkdownSurface({ a: ({ href, children }) => { const target = href ?? ''; const recordId = - target && !target.startsWith('#') && !hasScheme(target) && resolveRecord + target && + !target.startsWith('#') && + !hasScheme(target) && + resolveRecord ? resolveRecord(normalizeNavigateTarget(target)) : null; @@ -127,7 +131,9 @@ export function MarkdownSurface({ : {})} > {children} - {external && (opens in a new tab)} + {external && ( + (opens in a new tab) + )} ); }, @@ -198,7 +204,9 @@ export function MarkdownSurface({ return ; }, strong: ({ children }) => ( - {children} + + {children} + ), em: ({ children }) => ( {children} diff --git a/examples/effort-viz/app/components/RelationLegend.tsx b/packages/explorer/src/web/app/components/RelationLegend.tsx similarity index 82% rename from examples/effort-viz/app/components/RelationLegend.tsx rename to packages/explorer/src/web/app/components/RelationLegend.tsx index 82680a8f..8f2b8191 100644 --- a/examples/effort-viz/app/components/RelationLegend.tsx +++ b/packages/explorer/src/web/app/components/RelationLegend.tsx @@ -1,10 +1,14 @@ -'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'; +import type { + GraphEdgeKind, + GraphNodeKind, +} from '@/presets/effort-graph/types'; +import type { ColorMode } from '@/core/oklch'; +import { oklchCss, structuralOklch } from '@/core/oklch'; +import { PRIMITIVES, primitiveOklch } from '@/presets/effort-graph/primitives'; +import { + RING_INNER_RATIO, + glyphSvgPoints, +} from '@/presets/effort-graph/glyphs'; export type RelationGroupId = | 'lineage' @@ -41,7 +45,8 @@ export const RELATION_META: Record = { derives_from: { kind: 'derives_from', label: 'Derives from', - description: 'Causal upstream evidence or context this record was built on.', + description: + 'Causal upstream evidence or context this record was built on.', group: 'lineage', directionHint: { outgoing: 'Builds on', incoming: 'Informs' }, dash: 'solid', @@ -76,7 +81,10 @@ export const RELATION_META: Record = { label: 'Invalidates', description: 'Says an earlier record was wrong — not merely replaced.', group: 'invalidation', - directionHint: { outgoing: 'Shows this was wrong', incoming: 'Shown wrong by' }, + directionHint: { + outgoing: 'Shows this was wrong', + incoming: 'Shown wrong by', + }, dash: 'dashed', weight: 'medium', emphasis: 'strong', @@ -197,9 +205,13 @@ export function relationStrokeOklch( 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.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 }; + return mode === 'light' + ? { l: 0.42, c: 0.015, h: 260 } + : { l: 0.76, c: 0.015, h: 260 }; } /** @@ -225,12 +237,14 @@ const LIFECYCLE_BADGE: Record = { }, 'effort:abandoned': { label: 'Abandoned', - className: 'border-border bg-muted/12 text-muted line-through decoration-muted/60', + 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', + className: + 'border-amber-500/40 bg-amber-500/12 text-amber-800 dark:text-amber-200', }, 'issue:resolved': { label: 'Resolved', @@ -243,12 +257,14 @@ const LIFECYCLE_BADGE: Record = { }, 'issue:wontfix': { label: "Won't fix", - className: 'border-border bg-muted/12 text-muted line-through decoration-muted/60', + 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', + className: + 'border-amber-500/40 bg-amber-500/12 text-amber-800 dark:text-amber-200', }, 'decision:accepted': { label: 'Accepted', @@ -257,20 +273,24 @@ const LIFECYCLE_BADGE: Record = { }, 'decision:rejected': { label: 'Rejected', - className: 'border-rose-500/40 bg-rose-500/12 text-rose-800 dark:text-rose-200', + 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', + 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', + 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', + className: + 'border-amber-500/40 bg-amber-500/12 text-amber-800 dark:text-amber-200', }, 'risk:mitigated': { label: 'Mitigated', @@ -279,25 +299,30 @@ const LIFECYCLE_BADGE: Record = { }, 'risk:realized': { label: 'Realized', - className: 'border-rose-500/40 bg-rose-500/12 text-rose-800 dark:text-rose-200', + 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', + 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', - }, -}; +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, @@ -327,7 +352,8 @@ function strokeDash( ): 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)}`; + if (dash === 'dashed') + return `${(5 * scale).toFixed(1)} ${(3 * scale).toFixed(1)}`; return `${(1.6 * scale).toFixed(1)} ${(2.6 * scale).toFixed(1)}`; } @@ -375,7 +401,11 @@ export function RelationLineSample({ opacity={opacity} /> {meta.arrow && ( - + )} ); @@ -428,7 +458,12 @@ export function PrimitiveGlyph({ strokeWidth={half * (1 - RING_INNER_RATIO)} /> {core && ( - + )} ) : points ? ( diff --git a/examples/effort-viz/app/components/TopBar.tsx b/packages/explorer/src/web/app/components/TopBar.tsx similarity index 86% rename from examples/effort-viz/app/components/TopBar.tsx rename to packages/explorer/src/web/app/components/TopBar.tsx index f0654644..c64a1395 100644 --- a/examples/effort-viz/app/components/TopBar.tsx +++ b/packages/explorer/src/web/app/components/TopBar.tsx @@ -1,11 +1,9 @@ -'use client'; - import { useTheme } from '../hooks/useTheme'; -import type { GraphSummary } from '@/lib/lifecycle'; +import type { GraphSummary } from '@/presets/effort-graph/lifecycle'; import { liveStatusLabel, type LiveStatus, -} from '@/lib/useEffortGraphLive'; +} from '@/presets/effort-graph/useEffortGraphLive'; interface TopBarProps { status: LiveStatus; @@ -42,7 +40,11 @@ export function TopBar({ status, generation, summary }: TopBarProps) { <> {' · '} - + )} @@ -67,7 +69,7 @@ function Count({ }) { return ( <> - {value} {value === 1 ? label : (plural ?? `${label}s`)} + {value} {value === 1 ? label : plural ?? `${label}s`} ); } @@ -83,10 +85,10 @@ function StatusPill({ status === 'live' ? 'bg-emerald-500' : status === 'connecting' - ? 'bg-amber-500 motion-safe:animate-pulse' - : status === 'partial' - ? 'bg-amber-500' - : 'bg-red-500'; + ? 'bg-amber-500 motion-safe:animate-pulse' + : status === 'partial' + ? 'bg-amber-500' + : 'bg-red-500'; return (
    - + {liveStatusLabel(status)} @@ -112,7 +117,8 @@ function StatusPill({ function ThemeToggle() { const { mode, toggle } = useTheme(); - const label = mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'; + const label = + mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'; return (