Skip to content

Architecture

Intro named three guardrails. This section shows how they look in code. The FSD six-layer dependency direction, the single message-type file, and the backend adapter. In all three, the build or the type system blocks the AI from cutting across boundaries on a whim.

Dependencies flow top to bottom. Importing against an arrow breaks the build.

graph TB
app[app — routing, provider, manifest glue]
pages[pages — popup, options, sidePanel]
widgets[widgets — UI blocks]
features[features — single user action]
entities[entities — domain model]
shared[shared — utils, message types, adapter interface]
app --> pages
pages --> widgets
widgets --> features
features --> entities
entities --> shared

Each layer has a clear seat.

  • shared: domain-agnostic utils, UI kit, message types, adapter interfaces. Imported anywhere; depends only on itself.
  • entities: domain models like “captured page” or “backend config”. This is where discriminated unions sit.
  • features: one user action per slice. “Analyze the current page”, “send result to backend”, “switch backend mode”.
  • widgets: blocks that compose several features. The popup header, the sidePanel body.
  • pages: entry-point screens — popup, options, sidePanel.
  • app: entry-point glue. Routing, providers, the seam between manifest and the React tree.

eslint-plugin-boundaries enforces those arrows. Reverse imports are blocked, and so is reaching across slices within the same layer. features/analyze-page cannot import features/send-to-backend directly. The only way into a slice from the outside is through its index.ts.

When you let the AI pick which layer code goes into, it will blur features and widgets together more often than not. Naming the slice for the model is safer. Something like “add the options form component to features/configure-backend.”

The reasoning behind this direction lives in docs/adr/0001-fsd-import-direction.md. Code carries the what; the ADR carries the why.

Every message between content scripts, the background worker, and popup passes through the discriminated union in shared/lib/messaging/messages.ts.

export type Message =
| { type: 'analyze-page'; tabId: number }
| { type: 'send-result'; payload: PageResult }
| { type: 'config-changed'; mode: BackendMode }

On the sending side, chrome.runtime.sendMessage(msg) and chrome.tabs.sendMessage(tabId, msg) narrow against the same union. On the receiving side, chrome.runtime.onMessage.addListener switches on msg.type.

Adding a new message is one line in messages.ts. Both sides break compilation at once and have to update together. The bug where the same payload shape ends up redefined in two places gets caught here.

sequenceDiagram
participant CS as content_script
participant BG as background (service worker)
participant POP as popup
participant ADP as backend adapter
participant BE as backend (console / supabase / server)
CS->>BG: chrome.runtime.sendMessage (analyze-page)
BG->>ADP: backend.send(payload)
ADP->>BE: mode-specific branch
BE-->>ADP: ok / error
ADP-->>BG: Result<T>
BG-->>CS: response
POP->>BG: user-triggered action
BG-->>POP: state update

Popup talks only to the background worker. A direct path between content script and popup is intentionally absent. Routing through background keeps the service worker alive when needed and concentrates permission checks and adapter calls in one place.

shared/api/backend/ holds one interface and three implementations.

shared/api/backend/
├── index.ts public entry, factory included
├── types.ts Backend interface, BackendMode union
├── console-log.ts prints payload to console
├── supabase-direct.ts ships straight to Supabase
└── server-relay.ts routes through your server

The calling site, a feature or an entity, only knows about backend.send(payload). The mode is chosen on the options page and stored in chrome.storage.sync. A factory reads that value and picks the implementation.

The adapter exists so UI work can start before the backend is decided. Day one, the console-log mode is enough to validate payload shape. Once a Supabase table is in place, switch to supabase-direct. The calling code is not touched.

To add an adapter, append a branch to the BackendMode union in types.ts and drop another file under shared/api/backend/ that implements the same interface. The factory’s switch makes any missing case a compile error.

Three Claude Code hooks ship in .claude/hooks/ and .claude/settings.json wires them up. They cover the spots where context tends to fall apart even when neither the user nor the AI is paying attention to a command.

ScriptEventResponsibility
session-start.shSessionStartPrepends active.md, the last 30 lines of progress.md, feature_list.json, the first 50 lines of extension-spec.md, and MEMORY.md to stdout. Exits silently on the very first session after a fresh clone
stop-reminder.shStopPrints a one-line nudge to the user when active.md holds fewer than 20 non-whitespace characters
pre-commit-check.shPreToolUse (Bash matcher)When tool_input.command matches git commit, runs pnpm run typecheck && pnpm run lint. On failure exits 2 and pipes the last 30 lines of the failing log to stdout, so the AI sees the block reason

The Bash matcher in PreToolUse extracts tool_input.command from the JSON on stdin via jq (or sed as fallback) and only routes a git commit match into the typecheck gate. Other Bash commands pass through with exit 0 so the rest of the workflow does not pick up extra friction.

The package manager is decided by lockfile: pnpm-lock.yaml, then yarn.lock, then package-lock.json. If package.json is absent or has no typecheck script, the hook treats the project as pre-bootstrap and lets the commit through.

To add a new hook, write .claude/hooks/<name>.sh (start with #!/usr/bin/env bash, mark it executable), register it under the right event in settings.json, and add a row to .claude/hooks/README.md. Each hook is independent, so removing one does not affect the other two.

For the same system written at a slower, non-developer pace, see chapter 07 of the beginner guide.

Where the hooks act in the background, two slash commands cover the spots a user has to enter on purpose. Both are wired by skills under .claude/skills/.

CommandWhen it fitsCore behavior
/recover-from-blockedRight after review-extension writes blocked because two attempts in a row failedPulls the dimensions scoring below 3 from the latest .claude/state/review-*.md, compares the last two iterations of the relevant builder-*.md to flag same-mistake repeats, then prints a plain-language readout and offers three options (rollback / retry-with-help / manual)
/resumeAfter /clear, or coming back after a few daysSynthesizes a stage from eight state files plus recent git log (empty → paced → analyzed → designed → customized → implemented → built → packaged, with side branches). Where session-start.sh prepends raw context, this command hands a one-screen synthesized summary back to the user

Option A on /recover-from-blocked invokes git reset --hard, so two blocks sit in the way on purpose: an AskUserQuestion step plus a literal yes typed in afterward. If the working tree is dirty, an extra line warns that those changes will go too.

/resume halts synthesis whenever active.md and the detected stage disagree, asking the user which one is correct. Work happens on multiple machines, or active.md does not get updated after finishing — an explicit prompt costs less than a wrong synthesized answer.

For the same two commands written at the slower beginner pace, see chapter 08 of the beginner guide.

Sub-phase 5-b.0 of the 5-customize-extension skill adds an extra fork when Figma MCP is registered. The job that usually means hand-copying design tokens from Figma to CSS gets cut down to one URL.

The fork only lights up when .claude/state/mcp-tools.json has figma (or claude_ai_Figma) in its selected array. Without it, the flow drops directly into the main 5-b step and falls back to a one-word color_tone palette, which is the existing behavior.

flowchart TB
start[/5-customize-extension] --> mcp{figma key in mcp-tools.json}
mcp -- "yes" --> pick{Where do tokens come from?}
mcp -- "no" --> tone[color_tone — one-word palette]
pick -- "Figma" --> url[paste Figma URL]
pick -- "tone word" --> tone
url --> parse[parse fileKey + nodeId]
parse --> vars[get_variable_defs]
vars --> map[map text/bg/primary/secondary/border]
map --> tokens[write tokens.css CSS variables]
tokens --> brand[brand.json: theme_source figma + synced_at]
vars -- "missing variables" --> ctx[get_design_context fallback]
ctx --> map

URL parsing follows the standard figma.com/design/:fileKey/...?node-id=:nodeId rule, converting - to : inside the nodeId — the same convention documented in the Figma MCP guide. The call to mcp__claude_ai_Figma__get_variable_defs(fileKey, nodeId) returns the design variables as JSON; only the color variables get pulled out and mapped to the --color-* CSS variables in src/shared/ui/tokens.css. When some variables are missing (a frame has no secondary defined, for instance), get_design_context is called as a fallback, returning context plus a screenshot, and the AI fills the gap with a complementary color in the same tone.

brand.json ends up holding:

{
"theme_source": "figma",
"figma_file_key": "<fileKey>",
"figma_node_id": "<nodeId>",
"synced_at": "<ISO>",
"manual_overrides": ["secondary"]
}

The operationally important key here is manual_overrides. On a second call (same figma_file_key), the sync only updates the changed variables and leaves anything the user hand-edited in tokens.css alone. Manual overrides are detected on re-entry by diffing the current variable values against the values recorded at the last sync — not by file mtime or git blame.

Font variables only come along when theme_depth is colors+fonts or full theme; in colors-only mode they are ignored. When fonts do come in, they are wired up by adding the appropriate import line to src/shared/ui/font.ts.

For the same flow written at the slower beginner pace, see chapter 05 of the beginner guide.

Verification comes next. How /test-extension turns the scenarios in extension-spec.md §2 into Vitest test code, why the report is rewritten in scenario language instead of raw test output, and how it sits alongside the static six-dimension review by review-extension.