diff --git a/.gitignore b/.gitignore index 1f2d5756..ec928b8e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ yarn-error.log .pnpm-debug.log **/.flatbread-efforts/.journal/ -**/.flatbread/effort-graph/read-cache/ \ No newline at end of file +**/.flatbread/effort-graph/read-cache/ +# proof / local DAG artifacts +.flatbread/artifacts/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 956ddee6..9c7e08b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,7 @@ Optional **`pnpm play`** from the repo root is a shortcut for **`cd examples/nex - Build all packages: `pnpm build` - **Workspace libraries (watch-only):** `pnpm dev` — runs package `dev` scripts (e.g. `tsup --watch`) for `packages/*`; it does **not** start the Next.js example. - **Next.js example:** prefer the flow under [Recommended onboarding](#recommended-onboarding-try-flatbread-in-the-nextjs-example); or `pnpm play` as a convenience alias. +- **Effort Graph viz (`examples/effort-viz`):** after `pnpm build`, run `pnpm play:efforts` (or `pnpm --filter effort-viz dev`) to dogfood `.flatbread-efforts` with live SSE updates — see that example's README. - Check local CI parity before opening a PR: `pnpm verify` ## Working on a package diff --git a/examples/effort-viz/.gitignore b/examples/effort-viz/.gitignore new file mode 100644 index 00000000..20fec1a7 --- /dev/null +++ b/examples/effort-viz/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem +.flatbread-codegen-cache.json + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/examples/effort-viz/README.md b/examples/effort-viz/README.md new file mode 100644 index 00000000..8d9d39b3 --- /dev/null +++ b/examples/effort-viz/README.md @@ -0,0 +1,99 @@ +# 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 get + +- **Live graph** — `useEffortGraphLive` opens an `EventSource` on `/events`. + On `ready` and each `generation` event it refetches the Effort Graph query and + updates the canvas. The status pill shows connecting / live / disconnected and + the current generation. +- **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, node labels, edge + “veins”, spawn/retract physics, and a detail drawer on node click. +- **Theme** — sun/moon toggle in the top bar; preference persists in + `localStorage` (`effort-viz-theme`) with a boot script to avoid FOUC. + +## 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` | Physics/simulation unit tests under `lib/physics/`. | +| `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` +- `lib/physics/` — force simulation, growth, and layout helpers +- `lib/query.ts` — Effort Graph GraphQL query +- `flatbread.config.js` — Effort Graph content preset + +## Troubleshooting + +### Empty graph or “Connecting” forever + +Ensure Flatbread is running on port **5057**. Use `pnpm --filter effort-viz dev` +(or `pnpm play:efforts`), not `next dev` alone. + +### Typecheck / build + +```bash +pnpm build +pnpm --filter effort-viz exec tsc --noEmit +pnpm --filter effort-viz build +``` + +Production build starts Flatbread briefly so Next can typecheck; you may see a +non-fatal ESLint config warning from the root toolchain — the build still +completes. diff --git a/examples/effort-viz/app/components/DetailDrawer.tsx b/examples/effort-viz/app/components/DetailDrawer.tsx new file mode 100644 index 00000000..258c3296 --- /dev/null +++ b/examples/effort-viz/app/components/DetailDrawer.tsx @@ -0,0 +1,203 @@ +'use client'; + +import { useMemo } from 'react'; +import { nodeColor } from '@/lib/oklch'; +import type { GraphEdge, GraphNode } from '@/lib/types'; +import { useTheme } from '../hooks/useTheme'; + +interface DetailDrawerProps { + node: GraphNode | null; + edges: GraphEdge[]; + nodesById: Map; + onClose: () => void; + onSelect: (id: string | null) => void; +} + +const KIND_LABEL: Record = { + effort: 'Effort', + issue: 'Issue', + finding: 'Finding', + decision: 'Decision', + constraint: 'Constraint', + risk: 'Risk', +}; + +export function DetailDrawer({ + node, + edges, + nodesById, + onClose, + onSelect, +}: DetailDrawerProps) { + const { mode } = useTheme(); + + const relations = useMemo(() => { + if (!node) return { outgoing: [], incoming: [] }; + const outgoing: GraphEdge[] = []; + const incoming: GraphEdge[] = []; + for (const e of edges) { + if (e.source === node.id) outgoing.push(e); + if (e.target === node.id) incoming.push(e); + } + return { outgoing, incoming }; + }, [node, edges]); + + if (!node) return null; + + const effortId = node.effortId ?? node.id; + const swatch = nodeColor(effortId, node.kind, mode).css; + const lifecycle = node.lifecycle ?? node.status ?? node.state; + + return ( + + ); +} + +function MetaRow({ + label, + value, +}: { + label: string; + value: React.ReactNode; +}) { + return ( +
+ + {label} + + + {value} + +
+ ); +} + +function RelationList({ + title, + edges, + peerFor, + nodesById, + onSelect, +}: { + title: string; + edges: GraphEdge[]; + peerFor: (edge: GraphEdge) => string; + nodesById: Map; + onSelect: (id: string | null) => void; +}) { + if (edges.length === 0) return null; + return ( +
+

+ {title} · {edges.length} +

+
    + {edges.map((edge) => { + const peerId = peerFor(edge); + const peer = nodesById.get(peerId); + return ( +
  • + +
  • + ); + })} +
+
+ ); +} diff --git a/examples/effort-viz/app/components/EffortGraphApp.tsx b/examples/effort-viz/app/components/EffortGraphApp.tsx new file mode 100644 index 00000000..0adb5345 --- /dev/null +++ b/examples/effort-viz/app/components/EffortGraphApp.tsx @@ -0,0 +1,108 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { useMemo } from 'react'; + +import { useEffortGraphLive } from '@/lib/useEffortGraphLive'; +import type { GraphNode } from '@/lib/types'; + +import { TopBar } from './TopBar'; +import { Legend } from './Legend'; +import { DetailDrawer } from './DetailDrawer'; + +const GraphCanvas = dynamic(() => import('./GraphCanvas'), { + ssr: false, + loading: () => ( +
+ Booting canvas… +
+ ), +}); + +export function EffortGraphApp() { + const { + nodes, + edges, + status, + generation, + error, + selectedId, + setSelectedId, + } = useEffortGraphLive(); + + const nodesById = useMemo(() => { + const map = new Map(); + for (const n of nodes) map.set(n.id, n); + return map; + }, [nodes]); + + const selectedNode = selectedId ? (nodesById.get(selectedId) ?? null) : null; + + return ( +
+ + +
+ {nodes.length === 0 ? ( + + ) : ( + + )} + +
+
+ +
+
+ + {selectedNode && ( +
+ setSelectedId(null)} + onSelect={setSelectedId} + /> +
+ )} +
+
+ ); +} + +function EmptyState({ + status, + error, +}: { + status: ReturnType['status']; + error: Error | null; +}) { + const message = + status === 'connecting' + ? 'Connecting to Flatbread…' + : status === 'error' + ? (error?.message ?? 'Unable to reach the Flatbread live server.') + : 'No effort graph data yet.'; + + return ( +
+

+ Effort graph is quiet +

+

+ {message} +

+
+ ); +} diff --git a/examples/effort-viz/app/components/GraphCanvas.tsx b/examples/effort-viz/app/components/GraphCanvas.tsx new file mode 100644 index 00000000..4f866126 --- /dev/null +++ b/examples/effort-viz/app/components/GraphCanvas.tsx @@ -0,0 +1,523 @@ +'use client'; + +import { Canvas, useFrame, useThree } from '@react-three/fiber'; +import { Html, OrbitControls } from '@react-three/drei'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import * as THREE from 'three'; +import type { Group, Mesh, OrthographicCamera } from 'three'; + +/** Minimal controls surface used by FitCamera (drei OrbitControls instance). */ +interface PanZoomControlsHandle { + target: THREE.Vector3; + update: () => void; +} + +import { + createGraphSimulation, + effectiveRadius, + isEdgeGone, + veinTipPolyline, + type GraphInputEdge, + type GraphInputNode, + type GraphSimulation, + type SimEdge, + type SimNode, + type VeinPoint, +} from '@/lib/physics'; +import { nodeColor, oklchToThreeColor } from '@/lib/oklch'; +import type { GraphEdge, GraphNode } from '@/lib/types'; +import { useTheme, type ColorMode } from '../hooks/useTheme'; + +export interface GraphCanvasProps { + nodes: GraphNode[]; + edges: GraphEdge[]; + selectedId: string | null; + onSelect: (id: string | null) => void; +} + +/** + * Convert normalized graph nodes/edges → physics inputs. + * Efforts have effortId=null in the normalizer; the sim needs a stable + * cluster id, so we fall back to the node's own id for effort hubs. + */ +function toSimInputs( + nodes: GraphNode[], + edges: GraphEdge[] +): { simNodes: GraphInputNode[]; simEdges: GraphInputEdge[] } { + const simNodes = nodes.map((n) => ({ + id: n.id, + effortId: n.effortId ?? n.id, + kind: n.kind, + parentId: n.effortId ?? undefined, + })); + const simEdges = edges.map((e) => ({ + id: e.id, + from: e.source, + to: e.target, + })); + return { simNodes, simEdges }; +} + +function idsChanged(current: Array<{ id: string }>, previous: string[]): boolean { + if (current.length !== previous.length) return true; + for (let i = 0; i < current.length; i++) { + if (current[i].id !== previous[i]) return true; + } + return false; +} + +export default function GraphCanvas(props: GraphCanvasProps) { + const { mode } = useTheme(); + return ( + props.onSelect(null)} + > + + + + ); +} + +interface GraphSceneProps extends GraphCanvasProps { + mode: ColorMode; +} + +function GraphScene({ nodes, edges, selectedId, onSelect, mode }: GraphSceneProps) { + const simRef = useRef(null); + if (simRef.current === null) { + simRef.current = createGraphSimulation(); + } + const sim = simRef.current; + + const nodeIdsRef = useRef([]); + const edgeIdsRef = useRef([]); + const [, setRenderTick] = useState(0); + + useEffect(() => { + const { simNodes, simEdges } = toSimInputs(nodes, edges); + sim.sync(simNodes, simEdges); + const state = sim.getState(); + nodeIdsRef.current = state.nodes.map((n) => n.id); + edgeIdsRef.current = state.edges.map((e) => e.id); + setRenderTick((t) => t + 1); + }, [nodes, edges, sim]); + + useFrame((_, delta) => { + sim.step(delta); + const state = sim.getState(); + if ( + idsChanged(state.nodes, nodeIdsRef.current) || + idsChanged(state.edges, edgeIdsRef.current) + ) { + nodeIdsRef.current = state.nodes.map((n) => n.id); + edgeIdsRef.current = state.edges.map((e) => e.id); + setRenderTick((t) => t + 1); + } + }); + + const state = sim.getState(); + + const nodeMetaById = useMemo(() => { + const map = new Map(); + for (const n of nodes) map.set(n.id, n); + return map; + }, [nodes]); + + const [hoveredId, setHoveredId] = useState(null); + + return ( + <> + + + + {state.edges.map((edge) => ( + + ))} + + + {state.nodes.map((node) => { + const meta = nodeMetaById.get(node.id); + if (!meta) return null; + return ( + + ); + })} + + + {state.nodes.map((node) => { + const meta = nodeMetaById.get(node.id); + if (!meta) return null; + const isSelected = selectedId === node.id; + const isHovered = hoveredId === node.id; + const shouldLabel = + meta.kind === 'effort' || isSelected || isHovered; + if (!shouldLabel) return null; + return ( + + ); + })} + + + + ); +} + +/** + * Frame the orthographic camera on the live simulation bounds once the + * layout has mostly settled (or after a short timeout). Re-fits when the + * node count changes substantially (live add/remove bursts). + */ +function FitCamera({ + sim, + nodeCount, +}: { + sim: GraphSimulation; + nodeCount: number; +}) { + const camera = useThree((s) => s.camera) as OrthographicCamera; + const controls = useThree((s) => s.controls) as PanZoomControlsHandle | null; + const size = useThree((s) => s.size); + const fittedForCount = useRef(null); + const elapsedRef = useRef(0); + + useEffect(() => { + fittedForCount.current = null; + elapsedRef.current = 0; + }, [nodeCount]); + + useFrame((_, delta) => { + if (fittedForCount.current === nodeCount) return; + elapsedRef.current += delta; + + const { nodes } = sim.getState(); + const alive = nodes.filter( + (n) => n.growth > 0.35 && n.state !== 'retracting' + ); + if (alive.length === 0) return; + + let kinetic = 0; + for (const n of alive) kinetic += n.vx * n.vx + n.vy * n.vy; + const settled = kinetic < 2.5 || elapsedRef.current > 2.4; + if (!settled) return; + + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const n of alive) { + const r = Math.max(effectiveRadius(n), 1); + if (n.x - r < minX) minX = n.x - r; + if (n.x + r > maxX) maxX = n.x + r; + if (n.y - r < minY) minY = n.y - r; + if (n.y + r > maxY) maxY = n.y + r; + } + + const width = Math.max(maxX - minX, 20); + const height = Math.max(maxY - minY, 20); + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + const pad = 1.35; + const zoom = Math.min( + size.width / (width * pad), + size.height / (height * pad), + 12 + ); + + camera.position.set(cx, cy, 100); + camera.zoom = Math.max(zoom, 0.75); + camera.updateProjectionMatrix(); + if (controls) { + controls.target.set(cx, cy, 0); + controls.update(); + } + fittedForCount.current = nodeCount; + }); + + return null; +} + +/** + * Orthographic pan + zoom controls tuned for 2D: + * - no orbit tumble + * - screen-space pan + * - mouse: LMB pan / wheel zoom + * - touch: one-finger pan / two-finger dolly + pan + */ +function PanZoomControls() { + return ( + + ); +} + +interface NodeMeshProps { + node: SimNode; + meta: GraphNode; + mode: ColorMode; + selected: boolean; + onSelect: (id: string | null) => void; + onHover: (id: string | null) => void; +} + +const CIRCLE_SEGMENTS = 40; + +function NodeMesh({ + node, + meta, + mode, + selected, + onSelect, + onHover, +}: NodeMeshProps) { + const groupRef = useRef(null); + const bodyRef = useRef(null); + const ringRef = useRef(null); + + const color = useMemo(() => { + const effortId = meta.effortId ?? meta.id; + return oklchToThreeColor(nodeColor(effortId, meta.kind, mode).oklch); + }, [meta.effortId, meta.id, meta.kind, mode]); + + useFrame(() => { + const g = groupRef.current; + if (!g) return; + g.position.x = node.x; + g.position.y = node.y; + const r = effectiveRadius(node); + const s = Math.max(r, 0.001); + if (bodyRef.current) { + bodyRef.current.scale.set(s, s, 1); + } + if (ringRef.current) { + const ringScale = s + Math.max(1.4, s * 0.18); + ringRef.current.scale.set(ringScale, ringScale, 1); + ringRef.current.visible = selected; + } + }); + + const handlePointerDown = useCallback( + (e: { stopPropagation: () => void }) => { + e.stopPropagation(); + onSelect(node.id); + }, + [node.id, onSelect] + ); + + return ( + + + + + + { + e.stopPropagation(); + document.body.style.cursor = 'pointer'; + onHover(node.id); + }} + onPointerOut={() => { + document.body.style.cursor = ''; + onHover(null); + }} + > + + + + + ); +} + +interface EdgeLineProps { + edge: SimEdge; + mode: ColorMode; + nodesById: Map; + selectedId: string | null; +} + +/** + * Renders a single vein/edge as a `` primitive. Buffer positions are + * refreshed in `useFrame` from `veinTipPolyline`, so the tip grows in and + * retracts out with `edge.growth`. + */ +function EdgeLine({ edge, mode, nodesById, selectedId }: EdgeLineProps) { + const positionsRef = useRef(new Float32Array(16 * 3)); + const scratchRef = useRef([]); + + const color = useMemo(() => { + const sourceMeta = nodesById.get(edge.from); + const effortId = + sourceMeta?.effortId ?? sourceMeta?.id ?? edge.from ?? 'unknown'; + const kind = sourceMeta?.kind ?? 'effort'; + return oklchToThreeColor(nodeColor(effortId, kind, mode).oklch); + }, [edge.from, nodesById, mode]); + + // Build the line object once. Using `` sidesteps the JSX conflict + // between three's `Line` and SVG's `line` element. + const line = useMemo(() => { + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute( + 'position', + new THREE.BufferAttribute(positionsRef.current, 3) + ); + geometry.setDrawRange(0, 0); + const material = new THREE.LineBasicMaterial({ + transparent: true, + opacity: 0, + depthWrite: false, + }); + const obj = new THREE.Line(geometry, material); + obj.renderOrder = -1; + obj.frustumCulled = false; + return obj; + }, []); + + useEffect(() => { + (line.material as THREE.LineBasicMaterial).color.setHex(color); + }, [line, color]); + + useEffect(() => { + return () => { + line.geometry.dispose(); + (line.material as THREE.LineBasicMaterial).dispose(); + }; + }, [line]); + + useFrame(() => { + const geom = line.geometry; + const material = line.material as THREE.LineBasicMaterial; + if (isEdgeGone(edge)) { + geom.setDrawRange(0, 0); + return; + } + const path = edge.path; + if (!path || path.length < 2) { + geom.setDrawRange(0, 0); + return; + } + const visible = veinTipPolyline(path, edge.growth, scratchRef.current); + if (visible.length < 2) { + geom.setDrawRange(0, 0); + return; + } + const needed = visible.length * 3; + let positions = positionsRef.current; + if (positions.length < needed) { + positions = new Float32Array(Math.max(needed, positions.length * 2, 16)); + positionsRef.current = positions; + geom.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + } + for (let i = 0; i < visible.length; i++) { + positions[i * 3] = visible[i].x; + positions[i * 3 + 1] = visible[i].y; + positions[i * 3 + 2] = 0; + } + const attr = geom.getAttribute('position') as + | THREE.BufferAttribute + | undefined; + if (attr) attr.needsUpdate = true; + geom.setDrawRange(0, visible.length); + + const isTouched = + selectedId !== null && + (selectedId === edge.from || selectedId === edge.to); + const base = mode === 'dark' ? 0.42 : 0.36; + const target = isTouched ? Math.min(base + 0.4, 0.95) : base; + material.opacity = target * Math.max(0.001, edge.growth); + }); + + return ; +} + +interface NodeLabelProps { + node: SimNode; + meta: GraphNode; + selected: boolean; +} + +function NodeLabel({ node, meta, selected }: NodeLabelProps) { + const groupRef = useRef(null); + + useFrame(() => { + const g = groupRef.current; + if (!g) return; + const r = effectiveRadius(node); + g.position.x = node.x; + g.position.y = node.y + r + 1.5; + g.visible = r > 0.5; + }); + + return ( + + +
+ {meta.title} +
+ +
+ ); +} + +function CursorReset() { + useEffect(() => { + return () => { + document.body.style.cursor = ''; + }; + }, []); + return null; +} + +export { toSimInputs }; diff --git a/examples/effort-viz/app/components/Legend.tsx b/examples/effort-viz/app/components/Legend.tsx new file mode 100644 index 00000000..a372c898 --- /dev/null +++ b/examples/effort-viz/app/components/Legend.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { nodeColor } from '@/lib/oklch'; +import type { GraphNodeKind } from '@/lib/types'; +import { useTheme } from '../hooks/useTheme'; + +const KINDS: Array<{ kind: GraphNodeKind; label: string }> = [ + { kind: 'effort', label: 'Effort' }, + { kind: 'issue', label: 'Issue' }, + { kind: 'finding', label: 'Finding' }, + { kind: 'decision', label: 'Decision' }, + { kind: 'constraint', label: 'Constraint' }, + { kind: 'risk', label: 'Risk' }, +]; + +/** Neutral sample effort id so legend swatches show kind lightness offsets. */ +const LEGEND_SAMPLE_EFFORT = 'eff-legend-sample'; + +const SIZE_CLASS: Record = { + effort: 'size-2.5', + issue: 'size-1.5', + finding: 'size-1.5', + decision: 'size-1.5', + constraint: 'size-1.5', + risk: 'size-1.5', +}; + +export function Legend() { + const { mode } = useTheme(); + + return ( +
+ {KINDS.map(({ kind, label }) => { + const swatch = nodeColor(LEGEND_SAMPLE_EFFORT, kind, mode).css; + return ( +
+ + {label} +
+ ); + })} +
+ ); +} diff --git a/examples/effort-viz/app/components/TopBar.tsx b/examples/effort-viz/app/components/TopBar.tsx new file mode 100644 index 00000000..5ae76515 --- /dev/null +++ b/examples/effort-viz/app/components/TopBar.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { useTheme } from '../hooks/useTheme'; +import type { LiveStatus } from '@/lib/useEffortGraphLive'; + +interface TopBarProps { + status: LiveStatus; + generation: number | null; + nodeCount: number; + edgeCount: number; +} + +const STATUS_LABEL: Record = { + connecting: 'Connecting', + live: 'Live', + error: 'Disconnected', +}; + +export function TopBar({ status, generation, nodeCount, edgeCount }: TopBarProps) { + return ( +
+
+ + Effort Graph + + + {nodeCount} nodes · {edgeCount} edges + +
+
+ + +
+
+ ); +} + +function StatusPill({ + status, + generation, +}: { + status: LiveStatus; + generation: number | null; +}) { + const dotClass = + status === 'live' + ? 'bg-emerald-500' + : status === 'connecting' + ? 'bg-amber-500 animate-pulse' + : 'bg-red-500'; + + return ( +
+ + + {STATUS_LABEL[status]} + + {generation !== null && ( + · gen {generation} + )} +
+ ); +} + +function ThemeToggle() { + const { mode, toggle } = useTheme(); + const label = mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'; + return ( + + ); +} + +function SunIcon() { + return ( + + + + + + + + + + + + ); +} + +function MoonIcon() { + return ( + + + + ); +} diff --git a/examples/effort-viz/app/globals.css b/examples/effort-viz/app/globals.css new file mode 100644 index 00000000..344373f1 --- /dev/null +++ b/examples/effort-viz/app/globals.css @@ -0,0 +1,139 @@ +@import 'tailwindcss'; + +:root { + color-scheme: light dark; + --background: #ffffff; + --foreground: #171717; + --muted: #737373; + --muted-foreground: #737373; + --border: #eaeaea; + --accent: #0070f3; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + --background: #0a0a0a; + --foreground: #ededed; + --muted: #888888; + --muted-foreground: #888888; + --border: #262626; + --accent: #3291ff; + } +} + +[data-theme='light'] { + color-scheme: light; + --background: #ffffff; + --foreground: #171717; + --muted: #737373; + --muted-foreground: #737373; + --border: #eaeaea; + --accent: #0070f3; +} + +[data-theme='dark'] { + color-scheme: dark; + --background: #0a0a0a; + --foreground: #ededed; + --muted: #888888; + --muted-foreground: #888888; + --border: #262626; + --accent: #3291ff; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-border: var(--border); + --color-accent: var(--accent); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + background: var(--background); + color: var(--foreground); + font-family: var(--font-geist-sans), system-ui, -apple-system, sans-serif; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow: hidden; + overscroll-behavior: none; +} + +/* Subtle atmospheric field behind the graph — not a flat wash. */ +body::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + background: + radial-gradient( + 120% 80% at 12% -10%, + color-mix(in srgb, var(--accent) 10%, transparent), + transparent 55% + ), + radial-gradient( + 90% 70% at 88% 110%, + color-mix(in srgb, var(--foreground) 4%, transparent), + transparent 50% + ); +} + +body > * { + position: relative; + z-index: 1; +} + +::selection { + background: color-mix(in srgb, var(--accent) 24%, transparent); + color: var(--foreground); +} + +/* R3F canvas: fill available space, transparent to CSS bg. */ +canvas { + outline: none; + touch-action: none; +} + +/* Floating DOM labels anchored to graph nodes. Kept crisp + understated. */ +.effort-label { + font-family: var(--font-geist-sans), system-ui, sans-serif; + font-size: 10.5px; + line-height: 1.15; + letter-spacing: -0.005em; + padding: 3px 7px; + border-radius: 999px; + color: var(--foreground); + background: color-mix(in srgb, var(--background) 82%, transparent); + border: 1px solid var(--border); + white-space: nowrap; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + transform-origin: center bottom; +} + +.effort-label-primary { + font-weight: 600; +} + +.effort-label-selected { + font-weight: 600; + border-color: color-mix(in srgb, var(--foreground) 40%, transparent); + box-shadow: 0 1px 0 rgb(0 0 0 / 4%); +} diff --git a/examples/effort-viz/app/hooks/useTheme.tsx b/examples/effort-viz/app/hooks/useTheme.tsx new file mode 100644 index 00000000..07654ad0 --- /dev/null +++ b/examples/effort-viz/app/hooks/useTheme.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; + +export type ColorMode = 'light' | 'dark'; + +const STORAGE_KEY = 'effort-viz-theme'; + +interface ThemeContextValue { + mode: ColorMode; + toggle: () => void; + setMode: (mode: ColorMode) => void; +} + +const ThemeContext = createContext(null); + +function resolveInitialMode(): ColorMode { + if (typeof document === 'undefined') return 'light'; + const attr = document.documentElement.dataset.theme; + if (attr === 'light' || attr === 'dark') return attr; + if (typeof window !== 'undefined' && window.matchMedia) { + return window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; + } + return 'light'; +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [mode, setModeState] = useState('light'); + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + setModeState(resolveInitialMode()); + setHydrated(true); + }, []); + + useEffect(() => { + if (!hydrated) return; + document.documentElement.dataset.theme = mode; + try { + window.localStorage.setItem(STORAGE_KEY, mode); + } catch { + // storage may be unavailable (e.g. private mode) — non-fatal + } + }, [mode, hydrated]); + + const setMode = useCallback((next: ColorMode) => { + setModeState(next); + }, []); + + const toggle = useCallback(() => { + setModeState((prev) => (prev === 'dark' ? 'light' : 'dark')); + }, []); + + const value = useMemo( + () => ({ mode, setMode, toggle }), + [mode, setMode, toggle] + ); + + return {children}; +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return ctx; +} + +export const THEME_BOOT_SCRIPT = `(function(){try{var k='${STORAGE_KEY}';var s=localStorage.getItem(k);var m=(s==='light'||s==='dark')?s:(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.dataset.theme=m;}catch(e){}})();`; diff --git a/examples/effort-viz/app/layout.tsx b/examples/effort-viz/app/layout.tsx new file mode 100644 index 00000000..81a53dee --- /dev/null +++ b/examples/effort-viz/app/layout.tsx @@ -0,0 +1,40 @@ +import type { Metadata } 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: 'Interactive visualization of Flatbread effort graph data', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + +