File Organization
app/ # Next.js App Router pages, layouts, and route handlers (app/api/)
components/
branding/ # Brand assets and logo components
background/ # Ambient canvases (the ASCII terrain on the landing page)
graph/ # React Flow canvas, custom nodes, custom edges
maps/ # Map surfaces: JourneyMap, SystemMap, cards, editor dialog
overview/ delivery/ # The dashboard cards, and the delivery board
acceptances/ decisions/ pyramid/ values/ # The acceptance + decision + value surfaces
library/ journal/ # Node browser; one event's human sentence
projects/ # The project-list surfaces (create, import, seed, restore)
query/ # QueryProvider: hands the one QueryClient to React, wires the local bus + focus listener
generate/ # Prompt builder form/output components for /generate
publik/ sync/ auth/ # Share, Synk backup, sign-in surfaces
settings/ # Product manager, repo links, token manager
layout/ # Shell UI: project sidebar, switcher, command palette, minimap, badges
panels/ # The push-panel stack, its panel content, and forms
PanelStack.tsx # Content-agnostic column stack: keyboard, breadcrumb, visibility
ProjectPanels.tsx # Binds the stack to panel kind: node detail, or the raw bundle
docs/ # The /docs space's shell: article frame, markdown renderer, ⌘K search, space switcher, mobile FAB
ui/ # shadcn/ui primitives (do not edit directly — use CLI)
wobble/ # The hand-drawn icon effect (docs/icon-wobble.md)
lib/
config/ # Labels + display order for the ids in @arkaik/schema (§ Config / Taxonomies)
data/ # DataProvider interface + local, remote, seed and routing implementations
project-queries.ts # The query cache's keys, entry shapes, selectors, write-back reducers and seams — its only writer
query-client.ts # The one QueryClient per browser (fresh per call on the server)
hooks/ # React hooks for state management
useProjectPanels.tsx # Panel-stack provider + the `?node=` contract
services/ # Server-only: hosted graph store, publik, synk, auth, GitHub App
sync/ # Client sync engine (Synk SyncManager)
prompts/ # Prompt assembly blocks/types for the AI prompt builder
utils/ # Helpers: layout, export, projections, cn()
packages/ # The MIT toolchain, an npm workspace
schema/ # @arkaik/schema — canonical zod model, ids, validation, projections
cli/ # arkaik — the CLI
mcp/ # arkaik-mcp — the stdio MCP server
plugin/ # Claude Code plugin: the agent skill + generated assets
db/ # Postgres migrations + the migrate runner
public/
schema/ # Public JSON schema + example bundle for import contract
llms.txt # Concise LLM manifest
robots.txt # Crawl directives + sitemap pointer
seed/ # Example project JSON: pebbles.json, arkaik-self-map.json
tests/ # The test:* suites CI runs
docs/ # This documentation
State Management
- No global store for domain data. No Zustand, Redux, or Context-based state for nodes, edges, projects, or the journal — those flow through hooks and props.
- A query cache is not a store. The hooks read through a TanStack Query cache (
lib/data/project-queries.ts, data-layer.md § "The query cache") that only deduplicates and remembers reads; domain data still flows through the same hooks and props.lib/data/project-queries.tsis the cache's only writer: components never calluseQueryClient()— a write that bypasses the hooks calls one of its invalidation seams instead. - Route-shell UI state may use a scoped provider, mounted in the project layout alongside
SidebarProvider. The panel stack (ProjectPanelsProvider) is the one that exists; the bar for adding another is that a page segment cannot own the state, because it remounts when its dynamic params change. - Reusable state logic lives in hooks:
useNodes,useEdges,useProject,useProjects,useJournal. - Hook intent:
useNodesanduseEdgeshandle project graph CRUD.useProjecthandles project-level metadata (includingroot_node_idand card preferences).useProjectspowers project lists/switching in route shell UI.useJournalexposes the read-only event log for timelines and the changelog, projected to the event types the caller renders (useJournal(id, { types })).
- The Journey map (
components/maps/JourneyMap.tsx) usesuseNodesanduseEdgesfor data, and manages flow expansion as localuseState(expandedFlows); graph construction is the purebuildJourneyGraph(lib/utils/journey-graph.ts). - Data flows via props from the project page down to canvas components.
- Route-shell concerns such as the project switcher and persistent sidebar should stay in the project layout and use route state plus lightweight hooks instead of introducing shared global state.
Keyboard Shortcuts
- Journey-map shortcuts are wired in
components/maps/JourneyMap.tsxusinglib/hooks/useKeyboardShortcuts.ts. - The ⌘K command palette is wired in
app/project/[id]/layout.tsx(isCommandPaletteShortcut), so it answers from every project page. The docs space wires the same shortcut incomponents/docs/DocsSearch.tsx— one overlay, one ranker, a catalogue each (buildProjectCommands/buildDocsCommands). - Shortcut key checks and focus guards live in
lib/utils/keyboard.ts. - Keep shortcut handlers thin: they should call existing page handlers (
handleDeleteNodeRequest,handleExport) instead of duplicating business logic. - Delete shortcuts must not directly mutate storage. Always route through the existing confirmation dialog flow.
- Ignore destructive shortcuts when focus is in editable controls (
input,textarea,contenteditable, or combobox/textbox roles). Modifier chords such as ⌘K are the exception — they cannot collide with typing, and stay live inside inputs. - Escape belongs to the panel stack while it is non-empty, and
PanelStackowns it: it pops one panel, wherever the pointer is. Surfaces must not also bind Escape to a close — with the stack empty, Escape falls through to whatever the page wants it for. An open Radix layer (dialog, popover, select, menu) still wins over the stack.
Panel Stack
Reading a node must not cost you the graph. Node details are inline grid columns, not an overlay — nothing floats over anything. Opening a panel narrows the surface beside it; the surface only leaves once the trail is deep enough to need the room.
The surface — canvas, board, list — is a cell in that grid, at index 0. It is pushed out of the window by the same rule that hides deep panels, which is what makes the whole thing read as one strip of columns rather than a page with things stuck to its edge.
main
└─ wrapper
├─ header the surface's own toolbar, full width
└─ panel grid breadcrumb row, then the columns
Two columns, one below 768px, newest always on the right. With no panels the
surface has the grid to itself; one panel gives surface | panel; two retires
the surface and shows A | B; deeper always shows the last two. Cells outside
that window stay mounted, so unwinding brings them back untouched.
One rule generates the traversal: a click in panel i owns everything above
i. Depth 0 is the surface, so a click on it always leaves exactly one panel
open (a swap); a click inside a panel pushes a new one.
| Action | Before | After | URL |
|---|---|---|---|
| Click a node on the surface | canvas | canvas · A | → ?node=A |
| Click another node on the surface | canvas · A | canvas · B | → ?node=B |
| Click a reference inside panel A | canvas · A | canvas · A · B | → ?node=B |
| Esc, or close the top panel | canvas · A · B | canvas · A | → ?node=A |
| Close a panel that isn't the top | canvas · A · B | canvas · B | unchanged |
Breadcrumb jump to panel i | canvas · A · B | canvas · A | → ?node=A |
| Browser Back | canvas · A · B | canvas · A | → ?node=A |
Three pieces, deliberately separable — only the third knows what a node is:
lib/utils/panel-stack.ts— pure transitions, no React and no DOM. Covered bytests/app/panel-stack.test.js(npm run test:panel-stack).components/panels/PanelStack.tsx— renders the columns; owns the keyboard, focus, breadcrumb, visibility rule and animation. Content-agnostic.lib/hooks/useProjectPanels.tsx+components/panels/ProjectPanels.tsx— the binding: what a panel can be (a node, or the raw bundle), node id ⇄ descriptor ⇄?node=, withNodeDetailPanelas a node panel's content.
The URL contract: ?node= addresses the top node panel only, on whatever
route you are on, composing with the filters already there
(?species=view&node=…). It scans past a panel that is not a node — the raw
bundle is a tool rather than a location, so opening it over a node panel leaves
that node's address standing.
The stack below the top is client state by design — it is exploration history,
not an address. User actions publish the new top themselves; reconcileArrival
handles the arrivals nobody published (cold load, Back, Forward), inferring
intent from where the id already sits in the stack.
Four things to keep in mind when touching it:
openNodemust stay identity-stable. It is a dependency of the graph builders, so an identity that changed with the address would re-run the ELK layout on every open.- Panels resolve their node by id, against the surface's own
allNodes. An edit reaches every panel showing that node, and a node deleted under the stack takes its panels with it — noselectedNodecopy to keep in step. - Panels leave the window with
hidden; the surface does not. A React Flow canvas measures its container, anddisplay:nonegives it zero size and NaN geometry. The surface goesinvisible absoluteinstead, keeping a real box — which also keeps its ELK layout and viewport intact. - A surface that measures itself needs
onLayoutChange. The columns resize as panels come and go; the maps use it to re-frame rather than show a clipped corner.
Styling
- Tailwind CSS for all styling — no CSS modules, no styled-components.
- shadcn/ui for UI primitives (
components/ui/). Generated via CLI — don't edit these files by hand. - Sidebar primitives are also generated via shadcn CLI. Compose with them in
components/layout/rather than forking the generated files. - class-variance-authority (CVA) for component variants.
cn()helper (lib/utils.ts) for merging Tailwind classes:cn("base-class", conditional && "active-class").tailwind-mergeresolves conflicting Tailwind classes automatically viacn().
Config / Taxonomies
A taxonomy lives in two files, and the split is the point: an id is part of the portable format, a label is not.
Ids belong to the schema package. packages/schema/src/ids.ts holds plain as const arrays — SPECIES_IDS, STATUS_IDS, PLATFORM_IDS, EDGE_TYPE_IDS, VALUE_IDS — with each union type derived from its array, and deliberately no zod dependency so the standalone validator can read them without pulling the runtime in. Everything that must agree about the vocabulary reads from there: the validator, the CLI, the MCP server, the generated JSON Schema.
Labels and display order belong to lib/config/, and each array is checked against the ids rather than redefining them:
// lib/config/species.ts
import type { SpeciesId } from "@arkaik/schema";
export const SPECIES = [
{ id: "flow", level: 1, label: "Flow", description: "an ordered sequence of views and sub-flows" },
// ...
] as const satisfies readonly { id: SpeciesId; level: number | null; label: string; description: string }[];
export type { SpeciesId };
as const satisfies is what makes that safe. as const keeps the literal tuple, so iteration and narrowing still work; satisfies makes the compiler reject any id that is not a real SpeciesId — without widening the array's type the way an annotation would. There is still exactly one source of truth for the vocabulary; it just moved down a layer.
To add a taxonomy value:
- Add the id to the array in
packages/schema/src/ids.ts— plus its admissible source/target pairs inVALID_EDGE_SEMANTICSif it is an edge type. - Add the matching entry (label, order, icon, whatever the app renders) to the
lib/config/array. Doing this first will not compile — that failingsatisfiescheck is the guard working, not a problem to route around. - Run
npm run generate. The JSON Schema, the standalone validator, the skill reference and the prompt fragments are all derived, and CI fails the PR on any drift. - Update graph-model.md, the documented source of truth for the taxonomy.
lib/config/stages.ts is the one exception, and legitimately so: metadata.stage is a free string in the format, so STAGES is a plain app-side as const with no schema id to satisfy.
Components
- Node components receive React Flow
NodePropswith adataobject containinglabel,status,platforms,expanded,onToggle. - Edge components receive React Flow
EdgePropsand render SVG paths. - All node components are in
components/graph/nodes/and must be registered in thenodeTypesmap inCanvas.tsx. - Species affordances in panel and library cards should use a compact icon trigger with a hover card explaining the species from
lib/config/species.tsdescriptions.
Data Mutations
All writes go through the DataProvider interface:
Component → Hook (useNodes.addNode) → getProvider().createNode(node) → the routed backend
Never write to localStorage, IndexedDB, or /api/graph directly, and never import localProvider at a call site. getProvider() is the seam — it is what lets the same component work against a local, hosted, or seed project without knowing which it has (data-layer.md § Providers).
Routing UI
- Shared project navigation belongs in
app/project/[id]/layout.tsx, not inside individual route pages. - Route-aware active states should derive from
usePathname()anduseSearchParams(). - When a UI control represents a shareable filter, keep it URL-driven. The library
speciesfilter is the current example. - Cross-project navigation should preserve the current in-project destination when it can be mapped safely.
Documentation Frontmatter
Every file /docs serves may open with a YAML frontmatter block. All of it is
optional — a doc with no frontmatter takes its title from its first # Heading
and sorts by that title.
| Key | Effect |
|---|---|
title | The page title, shown in the handwritten face above the prose. Defaults to the first # Heading. |
navTitle | A shorter label for the sidebar and the ⌘K palette. Defaults to title. |
order | Sort weight within its section, ascending. Defaults to 0, then ties break alphabetically. |
icon | The sidebar glyph, by name from lib/config/docs-icons.ts. Defaults to the generic file mark. |
hidden | Withholds the page from the index entirely. |
icon names come from an allowlist, not from lucide-react directly: a name
nobody registered falls back to the file mark rather than reaching an arbitrary
export, and the sidebar's bundle carries only the glyphs docs actually use. Add
a name to DOC_ICONS to use a new one — tests/app/docs-icons.test.js fails on
an icon: that names nothing.
The page title is rendered as chrome, above the body, so MarkdownContent never
sees the document's opening # Heading (see components/docs/DocsArticle.tsx).
Keep writing it: it is what the title is derived from, and what the file looks
like on GitHub.
Cursor Semantics
Graph interactive elements must use the correct Tailwind cursor class. Do not leave clickable elements without an explicit cursor — React Flow's canvas can suppress browser defaults.
| Action | Cursor class | Example |
|---|---|---|
| Show hover card | cursor-help | species badge (EntityBadges) |
| Insert node | cursor-copy | insert button on compose edge |
| Unfold flow | cursor-zoom-in | collapsed FlowNode |
| Collapse flow | cursor-zoom-out | expanded FlowNode |
| Open panel | cursor-pointer | info button on any node |
| Show popover | cursor-context-menu | API/platform buttons on ViewNode |
Non-interactive but focusable elements (e.g. branch nodes, static cards) use cursor-default.
Naming
- Files: kebab-case for config and utils (
edge-types.ts), PascalCase for components (FlowNode.tsx)- Current graph node components are
FlowNode.tsx,ViewNode.tsxandSystemLayerNode.tsx(components/graph/nodes/)
- Current graph node components are
- Types: PascalCase (
SpeciesId,ProjectBundle) - Config arrays: UPPER_SNAKE_CASE (
SPECIES,STATUSES,EDGE_TYPES) - Hooks: camelCase with
useprefix (useNodes,useJournal)
Shipping larger work — parts, tasks, and stacked PRs
Anything bigger than a single focused change is planned as parts, each part
broken into tasks. One part is one branch is one PR, and the branches are
chained into a GitHub Stack (gh stack, see the gh-stack skill) so each
PR's diff shows only its own layer. The extension is a one-time install:
gh extension install github/gh-stack.
- A part is a reviewable unit, not a milestone. It should stand on its own: a reviewer who reads only that PR should be able to say yes or no to it. If a part can't be described without referring forward to the next one, the split is in the wrong place.
- Order parts by dependency, never by convenience. Shared types and domain models go lowest, then persistence, then the API, then the UI that consumes it. If code in one layer needs code from another, the dependency belongs in the same part or a lower one.
- Separate a move from a rewrite. When work both relocates existing code and changes it, make the relocation its own part. A pure-move diff is read in seconds; the same change tangled with a redesign hides the redesign.
- Fix a lower layer in the layer that owns it. Discovering mid-stack that a
lower part needs a change means navigating down (
gh stack down), committing there, and runninggh stack rebase --upstack— not patching around it at the top. Otherwise the fix lands in the wrong PR. - Each part is independently verifiable. It must pass the lint and typecheck on its own, without the parts above it. A part that only compiles once a later part lands is not a part.
- One stack tells one story. Unrelated work — a different feature, a drive-by fix — starts its own stack rather than riding along.