Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@
yarn-error.log
.pnpm-debug.log
**/.flatbread-efforts/.journal/
**/.flatbread/effort-graph/read-cache/
**/.flatbread/effort-graph/read-cache/
# proof / local DAG artifacts
.flatbread/artifacts/
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions examples/effort-viz/.gitignore
Original file line number Diff line number Diff line change
@@ -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
99 changes: 99 additions & 0 deletions examples/effort-viz/README.md
Original file line number Diff line number Diff line change
@@ -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.
203 changes: 203 additions & 0 deletions examples/effort-viz/app/components/DetailDrawer.tsx
Original file line number Diff line number Diff line change
@@ -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<string, GraphNode>;
onClose: () => void;
onSelect: (id: string | null) => void;
}

const KIND_LABEL: Record<GraphNode['kind'], string> = {
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 (
<aside className="pointer-events-auto flex w-full flex-col overflow-hidden rounded-xl border border-border bg-background/85 shadow-[0_1px_0_rgba(0,0,0,0.02),0_8px_24px_-8px_rgba(0,0,0,0.15)] backdrop-blur-md sm:w-[320px]">
<header className="flex items-start justify-between gap-3 border-b border-border px-4 py-3">
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex items-center gap-2">
<span
aria-hidden
className="size-2.5 shrink-0 rounded-full"
style={{ background: swatch }}
/>
<span className="text-[10px] font-medium uppercase tracking-[0.08em] text-muted">
{KIND_LABEL[node.kind]}
</span>
</div>
<h2
className="truncate text-sm font-semibold tracking-tight text-foreground"
title={node.title}
>
{node.title}
</h2>
<span className="truncate font-mono text-[10px] text-muted">
{node.id}
</span>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close detail panel"
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted transition-colors hover:bg-muted/15 hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 6 6 18" />
<path d="M6 6l12 12" />
</svg>
</button>
</header>

<div className="flex flex-col gap-3 px-4 py-3">
<MetaRow label="Kind" value={KIND_LABEL[node.kind]} />
{lifecycle && <MetaRow label="State" value={lifecycle} />}
{node.kindLabel && node.kind !== 'effort' && (
<MetaRow label="Subkind" value={node.kindLabel} />
)}
{node.effortId && (
<MetaRow
label="Effort"
value={
<button
type="button"
onClick={() => onSelect(node.effortId)}
className="truncate text-left font-mono text-[11px] text-foreground underline-offset-2 hover:underline"
>
{nodesById.get(node.effortId)?.title ?? node.effortId}
</button>
}
/>
)}
{node.likelihood && (
<MetaRow label="Likelihood" value={node.likelihood} />
)}
{node.severity && <MetaRow label="Severity" value={node.severity} />}
</div>

<RelationList
title="Outgoing"
edges={relations.outgoing}
peerFor={(edge) => edge.target}
nodesById={nodesById}
onSelect={onSelect}
/>
<RelationList
title="Incoming"
edges={relations.incoming}
peerFor={(edge) => edge.source}
nodesById={nodesById}
onSelect={onSelect}
/>
</aside>
);
}

function MetaRow({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
return (
<div className="flex items-baseline justify-between gap-3">
<span className="text-[10px] uppercase tracking-[0.08em] text-muted">
{label}
</span>
<span className="min-w-0 truncate text-right text-[12px] text-foreground">
{value}
</span>
</div>
);
}

function RelationList({
title,
edges,
peerFor,
nodesById,
onSelect,
}: {
title: string;
edges: GraphEdge[];
peerFor: (edge: GraphEdge) => string;
nodesById: Map<string, GraphNode>;
onSelect: (id: string | null) => void;
}) {
if (edges.length === 0) return null;
return (
<section className="border-t border-border px-4 py-3">
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-[0.08em] text-muted">
{title} · {edges.length}
</h3>
<ul className="flex flex-col gap-1">
{edges.map((edge) => {
const peerId = peerFor(edge);
const peer = nodesById.get(peerId);
return (
<li key={edge.id}>
<button
type="button"
onClick={() => onSelect(peerId)}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1 text-left text-[11px] transition-colors hover:bg-muted/15 focus:outline-none focus-visible:bg-muted/15"
>
<span className="min-w-0 truncate text-foreground">
{peer?.title ?? peerId}
</span>
<span className="shrink-0 font-mono text-[9px] uppercase tracking-[0.06em] text-muted">
{edge.kind.replace(/_/g, ' ')}
</span>
</button>
</li>
);
})}
</ul>
</section>
);
}
Loading
Loading