This document records all significant technology and architecture decisions made for the project, including the reasoning and alternatives considered.
- Guiding Principle: Pure JS/TS Over Native
- Package Manager: pnpm
- HTTP Server: Hono
- Frontend Framework: React + Vite (not Next.js)
- Frontend Ecosystem: TanStack (Router, Query, Table, Form)
- Desktop Runtime: Tauri v2
- Styling: Tailwind CSS
- Validation: Zod
- Database: node-sqlite3-wasm
- Testing: Vitest
- Conditional Classes: clsx
- UI Components: shadcn/ui
- Progress Streaming: SSE
- Developer Debug Panel
- Breadcrumb Navigation
- Home Page Split Layout
- Two-Level DAG Pipeline (Stage / Step Model)
- Per-Book Step Run Queue
- Unified Stage Sidebar (Single Expandable Rail)
- Stage Color System — Single Source of Truth
- Context-Aware Top Bar Button
- Unified Stage/Step Status via useBookRun
- Visual Refinement + File-Based Debug Screenshots
Status: Decided Date: 2026-02-09
Always prefer pure JavaScript/TypeScript or WASM-compiled libraries over native C/C++ bindings.
ADT Studio is a desktop app that needs to run on Windows, macOS, and Linux. Native Node.js bindings (packages using node-gyp, prebuild, or N-API with compiled C/C++) create significant problems:
- Cross-platform build failures: Native modules must be compiled per-platform and per-Node-version.
node-gyprequires Python, C++ compilers, and platform-specific toolchains — a constant source of CI/CD failures. - Desktop packaging: Whether using Tauri or Electron, native bindings add complexity to the build pipeline and increase binary size.
- Contributor friction: New developers must install platform-specific build tools before they can
pnpm install. - WASM has matured: WebAssembly alternatives for performance-critical libraries (SQLite, image processing) now match or exceed the DX of native bindings, with zero compilation step.
| Approach | Verdict |
|---|---|
| Allow native bindings case-by-case | Rejected — creates inconsistency, each native dep is a ticking time bomb for build issues |
| Pure JS only (no WASM) | Too restrictive — some functionality (SQLite, PDF parsing) genuinely benefits from compiled code |
| Pure JS/TS + WASM | Chosen — best of both worlds: performance via WASM, portability via JS |
Status: Decided Date: 2026-02-09
Use pnpm as the package manager for the monorepo.
- Disk efficiency: Content-addressable storage with symlinks means packages are stored once globally, not duplicated across projects.
- Strict by default: Prevents phantom dependencies — if a package isn't in your
package.json, you can't import it. This catches real bugs. - First-class workspaces:
pnpm --filter,workspace:*protocol, topology-aware task execution. - Speed: Faster installs than npm/yarn in monorepo scenarios due to hardlinks and parallel operations.
| Tool | Why Not |
|---|---|
| npm | No content-addressable store, slower installs, weaker workspace support |
| yarn (classic) | Hoisting issues, less strict dependency resolution |
| yarn (berry/PnP) | Plug'n'Play is invasive, breaks many packages, steep learning curve |
Status: Decided Date: 2026-02-09
Use Hono as the HTTP server framework for the API.
- Tiny footprint (~14KB): Critical for desktop sidecar — we want the API process to be lightweight.
- TypeScript-first: Typed routes, typed middleware, typed context — no
@types/expressneeded. - Faster benchmarks: Outperforms Express on request throughput.
- Edge-portable: Runs on Node.js, Deno, Bun, Cloudflare Workers — future-proof if we ever move the API.
- Built-in middleware: CORS, body parsing, error handling, auth helpers without extra packages.
| Framework | Why Not |
|---|---|
| Express | Large, slow, requires many middleware packages, weak TypeScript support |
| Fastify | Good performance but larger footprint, plugin system adds complexity we don't need |
| tRPC | Couples frontend and backend too tightly, we want a clean HTTP API boundary |
Status: Decided Date: 2026-02-09
Use React with Vite as a pure SPA, not Next.js.
ADT Studio is a desktop-first application embedded in a desktop webview (Tauri or Electron). This changes the entire calculus:
- No server needed in the frontend: Next.js brings SSR, server components, API routes, and a Node.js server — none of which apply. The desktop shell serves static HTML/JS/CSS, not from a Next.js server.
- SPA is the right model: The app runs locally, talks to a local Hono API via HTTP. There's no SEO, no initial page load from a remote server, no need for streaming SSR.
- Vite is faster for development: Near-instant HMR via native ES modules vs. Next.js's webpack/turbopack compilation.
- Simpler build output: Vite produces a static
dist/folder. Next.js produces a.next/directory that expects a Node.js runtime to serve it. - Smaller bundle: No Next.js runtime, no React Server Components hydration, no router overhead.
- Monorepo simplicity: Vite's config is minimal. Next.js in a monorepo requires careful configuration of
transpilePackages,serverComponentsExternalPackages, and module resolution.
| Next.js Feature | ADT Studio Need | Verdict |
|---|---|---|
| SSR/SSG | Not needed (desktop app, no SEO) | Unnecessary complexity |
| Server Components | No server in frontend | Not applicable |
| API Routes | We have Hono for this | Duplicate |
| File-based routing | We use TanStack Router (type-safe) | Prefer explicit routes |
| Image optimization | Local app, not web-served | Not needed |
| Middleware | Hono handles this server-side | Not applicable |
| Framework | Why Not |
|---|---|
| Next.js | Overkill — SSR/SSG/server components not needed, adds complexity to desktop embedding |
| Create React App | Deprecated, slow, ejection required for customization |
| Remix | Server-focused like Next.js, same issues for a desktop SPA |
| SvelteKit | Would require the team to learn Svelte, React ecosystem is more mature |
Status: Decided Date: 2026-02-09
Adopt the full TanStack ecosystem: Router, Query, Table, and Form. Not individual pieces from different library authors.
One team, one philosophy, designed to work together.
The core problem with mixing libraries (React Router + SWR + react-hook-form + react-table) is integration friction. Each library has its own:
- State model and lifecycle
- TypeScript approach (some type-safe, some not)
- Error handling pattern
- Caching strategy
TanStack eliminates this by providing a unified ecosystem where type safety flows from Router → Query → Table → Form.
- Type-safe routes: Route params, search params, and loaders are all typed at compile time. A typo in a route path or param name is a TS error, not a runtime 404.
- Search params as state: URL search params are treated as first-class typed state — like
useStatebut synced to the URL. Perfect for filters, pagination, sorting. - Integrated data loading: Route loaders integrate with TanStack Query, so data prefetching happens at the routing level.
- React Router v7 caveat: React Router v7 added type safety, but only in "framework mode" (Remix-style). In SPA mode (which we need for desktop embedding), you don't get the enhanced features. TanStack Router is type-safe in all modes.
- Caching and deduplication: Multiple components requesting the same data get a single network call.
- Stale-while-revalidate: Show cached data immediately, refresh in background.
- Automatic refetching:
refetchInterval,refetchOnWindowFocus,refetchOnReconnect. - Mutations with optimistic updates: Built-in rollback on failure.
- Loading/error states: Consistent pattern across the entire app.
- Replaces: Manual
useEffect+useState+setIntervalpolling pattern.
- Headless: Provides sorting, filtering, pagination, grouping, virtualization logic — we provide our own Tailwind-styled UI.
- Type-safe columns: Column definitions are typed against the data shape.
- No lock-in: Since it's headless, we can change the UI without touching the logic.
- Zod integration: Native adapter for Zod schemas — same schemas we use everywhere.
- Type-safe fields: Field names and values are typed from the form schema.
- Framework-agnostic: Same mental model as the rest of TanStack.
| Library | Why Not |
|---|---|
| React Router | Type safety only in framework mode; we need SPA mode for desktop embedding |
| SWR | Less features than TanStack Query, no mutation support built-in |
| react-hook-form | Good library but separate ecosystem, TanStack Form integrates better with our Zod-first approach |
| AG Grid / DataGrid | Heavy, opinionated UI — we want headless + Tailwind |
| Redux Toolkit Query | Brings in Redux, which we explicitly avoid |
Status: Decided Date: 2026-02-09
Use Tauri v2 with a sidecar architecture — the API server is compiled into a standalone Node.js binary and bundled inside the Tauri app.
| Aspect | Details |
|---|---|
| Bundle size | ~10x smaller (uses system webview) |
| Memory | Lower footprint, no bundled Chromium |
| Backend | Rust — performance for file I/O, process management |
| Security | Allowlist-based permissions |
| Trade-off | Requires Rust toolchain, webview inconsistencies across platforms |
| Aspect | Details |
|---|---|
| Bundle size | Larger (~150MB, bundles Chromium) |
| Memory | Higher (separate Chromium process) |
| Backend | Node.js — same language as the rest of the stack |
| Security | Full Chromium sandboxing, well-understood model |
| Trade-off | Heavier, but consistent rendering across platforms, mature ecosystem |
The apps/desktop/ wrapper is designed to be runtime-agnostic. The Studio SPA communicates with the API over HTTP regardless of which desktop shell is used. This means the choice can be deferred without blocking other work.
Status: Decided Date: 2026-02-09
Use Tailwind CSS utility classes exclusively for styling.
- No CSS files to manage: Styles live in JSX, no context switching.
- Consistent design system: Built-in spacing, color, and typography scales.
- Purged output: Only used classes end up in the bundle.
- No runtime: Unlike styled-components or Emotion, Tailwind has zero JS runtime cost.
- clsx for conditionals: Clean, readable conditional class composition.
| Approach | Why Not |
|---|---|
| CSS Modules | Requires separate .module.css files, context switching, no design system |
| styled-components | JS runtime overhead, breaks with SSR (not our concern but still), verbose |
| Emotion | Same issues as styled-components |
| vanilla-extract | Type-safe but requires build step config, extra complexity |
Status: Decided Date: 2026-02-09
Use Zod for all schema validation and type inference.
- Single source of truth: Define a Zod schema once, infer the TypeScript type with
z.infer<>. No duplicate interface + schema definitions. - Runtime + compile-time: Validates at runtime (API boundaries) and provides compile-time types.
.safeParse(): Non-throwing validation perfect for API error handling.- Composable: Schema composition, transforms, refinements, defaults.
- TanStack Form integration: Native Zod adapter for form validation.
| Library | Why Not |
|---|---|
| io-ts | Functional programming style (fp-ts dependency), steeper learning curve, worse DX |
| Yup | Weaker TypeScript inference, larger bundle, less performant |
| Joi | No TypeScript inference at all, designed for Node.js server-side only |
| ArkType | Promising but less mature ecosystem, fewer integrations |
Status: Decided Date: 2026-02-09
Use node-sqlite3-wasm (pure WASM SQLite) instead of better-sqlite3 (native C binding).
This directly follows from Decision 001: Pure JS/TS Over Native.
- No native compilation:
better-sqlite3requiresnode-gypand a C++ compiler.node-sqlite3-wasmis a WASM binary that works everywhere Node.js runs. - Cross-platform by default: No platform-specific prebuilds, no desktop runtime rebuild steps.
- File system persistence: Unlike
sql.js(in-memory only without manual serialization) or@sqlite.org/sqlite-wasm(browser-only),node-sqlite3-wasmhas a VFS that maps SQLite file operations to Node.js'sfsAPI. - Based on SQLite 3.51.1: Current SQLite version, actively maintained.
- Synchronous API: Same synchronous query pattern as
better-sqlite3.
- Manual cleanup required:
node-sqlite3-wasmis not fully garbage-collected. You must calldb.close()when done, otherwise you risk memory leaks. This is manageable with proper lifecycle management. - Slightly slower than native: WASM has ~10-20% overhead vs native C for CPU-bound operations. For our use case (book metadata, entity versioning), this is negligible.
| Library | Why Not |
|---|---|
| better-sqlite3 | Native C++ binding — violates pure JS/TS principle, causes cross-platform build issues |
| sql.js | Compiles SQLite to WASM but only supports in-memory databases. Persistence requires manual import/export of the entire DB as a binary blob — not suitable for file-based book storage |
| @sqlite.org/sqlite-wasm | Official SQLite WASM build but browser-only. Node.js support limited to in-memory databases without persistence |
| Drizzle ORM | ORM abstraction layer — adds complexity, we want direct SQL control. Could layer on top later if needed |
| Prisma | Heavy, requires code generation step, runs a Rust query engine binary (another native dep) |
| Flat JSON files | Sufficient for simple cases but entity versioning with queries (get latest version, filter by type) benefits from SQL |
Status: Decided Date: 2026-02-09
Use Vitest as the test runner.
- Native ESM: Works with ES modules out of the box. Jest requires
--experimental-vm-modulesor transform configuration. - Vite pipeline: Reuses Vite's transform pipeline — tests run the same code as development.
- Faster: Worker-thread-based execution, instant re-runs in watch mode.
- Jest-compatible API:
describe,it,expect,vi.mock— minimal migration effort. - Built-in coverage: Via c8/v8, no separate package needed.
| Runner | Why Not |
|---|---|
| Jest | ESM support still experimental, slower, requires babel/ts-jest transforms |
| Mocha + Chai | More configuration needed, no built-in mocking, dated DX |
| Node.js test runner | Too minimal, no watch mode, limited ecosystem |
Status: Decided Date: 2026-02-09
Use clsx for conditional CSS class composition.
- ~228 bytes: Significantly smaller than classnames.
- Same API: Drop-in replacement, identical function signature.
- Better tree-shaking: ES module by default.
- Perfect for Tailwind: Conditional utility class patterns like
clsx("p-4", isActive && "bg-blue-100").
| Library | Why Not |
|---|---|
| classnames | Larger bundle (~600 bytes), CommonJS default, same API but bigger |
| Template literals | Messy for multiple conditions, no falsy value handling |
| tailwind-merge | Overkill unless we have conflicting utility classes (we shouldn't with proper component structure) |
Status: Decided Date: 2026-02-10
Use shadcn/ui as the component library for the React frontend (Default style, Neutral base, UNICEF blue accent).
- Not a dependency: shadcn/ui is a copy-paste system. Components are copied into
src/components/ui/, not installed as a package. We own the code. - Tailwind-native: Built entirely on Tailwind CSS. No conflicting styling systems.
- Radix primitives: Uses Radix UI headless primitives for accessibility (keyboard nav, ARIA, focus management).
- Consistent design: Pre-built Button, Dialog, Select, Input, Table, Form, Card etc. prevents subtly-different components for similar needs.
- Customizable: CSS variables for theming via Tailwind v4's
@themedirective.
| Option | Why Not |
|---|---|
| Raw Tailwind only | Slower to build, more code to maintain, higher risk of inconsistent components |
| MUI / Ant Design | Heavy, opinionated, JS runtime for styling, conflicts with Tailwind approach |
| Headless UI | Fewer components, would need to build more from scratch |
| Radix directly | shadcn is already built on Radix — gives us the styled layer for free |
Status: Decided Date: 2026-02-10
Use Server-Sent Events (SSE) for streaming real-time pipeline progress from API to frontend. The SSE connection is always-on — it opens when the book layout mounts and stays open until unmount. SSE events patch the TanStack Query cache directly via setQueryData, eliminating the need for a separate local state machine.
- One-way stream: Pipeline progress is server→client only. SSE is purpose-built for this.
- Native browser support:
EventSourceAPI with automatic reconnection — no toggle or manual reconnect logic needed. - Hono built-in:
streamSSE()helper, zero extra dependencies. - Matches pipeline design: Pipeline already emits
ProgressEvent(discriminated union). We serialize and send. - Always-on eliminates race conditions: No SSE enable/disable toggle means no missed events during connection setup. On
open(including reconnection), a fullstep-statusrefetch runs to catch any events missed during the gap. - Cache-patching avoids dual-source bugs: SSE events update the same TanStack Query cache that the
step-statusendpoint populates. Components read from one source viauseBookRun().
| Option | Why Not |
|---|---|
| Polling | Higher latency, unnecessary requests, less responsive UI |
| WebSocket | Full duplex overkill for one-way progress, more complex server setup |
| SSE with local state machine | Dual-source (SSE state + query cache) required reconciliation everywhere, caused bugs |
Status: Decided Date: 2026-02-10
Add a browser-DevTools-style bottom drawer panel (toggled via Cmd+Shift+D) on book workflow routes for inspecting LLM logs, pipeline stats, active config, and entity version history. Also provide a dedicated popout route (/books/$label/debug) for detached debugging in a separate window.
- Iteration speed: Pipeline development requires seeing exactly what's happening — prompts sent, cache hits, token usage, validation failures. This data exists in
llm_logbut was only accessible via raw SQL. - Zero external tools: No need to open a separate DB viewer or log aggregator. Everything is in the browser, colocated with the pipeline output.
- Live + historical: SSE
llm-logevents stream during a run (summary only, no full prompts in SSE). Full prompt/response detail fetched on demand from REST endpoints. - Docked + detached workflows: The bottom drawer supports in-context debugging, while
/books/$label/debugsupports dedicated multi-monitor or side-by-side debugging during long runs. - Cost awareness: Token counts and estimated cost displayed per step, helping catch expensive prompts early.
- Config visibility: Merged config (global + book override) displayed in a structured read-only view, clarifying which settings are active.
- Minimal footprint: 4 REST endpoints, 1 SSE event type, and lightweight React route/components. No new dependencies.
| Option | Why Not |
|---|---|
| External log viewer | Requires switching context, additional setup, not integrated with pipeline UI |
| Browser console logging | Unstructured, lost on page refresh, no filtering/aggregation |
| Dedicated debug-only app/shell | More routing and layout complexity than needed; a single popout route is sufficient |
Status: Decided Date: 2026-02-10
Remove the global header bar and use per-page breadcrumb navigation instead.
- Vertical space: A persistent header with "ADT Studio" title consumed 36-56px on every page. On a desktop app where every pixel matters for pipeline output and storyboards, this is wasted space.
- Context-aware: Breadcrumbs (e.g.,
ADT Studio / Book Title / Storyboard) provide navigation context that a static header doesn't — the user always knows where they are in the hierarchy. - Consistency: Every page follows the same pattern: breadcrumb trail at the top, then page content. The home page uses its title as the breadcrumb root.
| Option | Why Not |
|---|---|
| Slim header (36px) | Still wastes vertical space on every page for a static label |
| Floating action button | Inconsistent with the rest of the navigation model |
| Sidebar nav | Wastes horizontal space, overkill for a 4-level hierarchy |
Status: Decided Date: 2026-02-10
Home page uses a 30/70 vertical split: workflow guide on the left, books list on the right.
- No dead space: With few books, a full-width grid leaves a massive void. The workflow column fills vertical space naturally with the 5-step timeline.
- Onboarding built-in: New users see how the tool works without navigating to a separate help page.
- Book list as vertical cards: Full-width horizontal cards in a single column show all metadata (title, label, authors, publisher, language, page count) with always-visible edit/delete actions — more information density than small grid cards.
| Option | Why Not |
|---|---|
| Full-width book grid | Massive empty space with 1-2 books, workflow hidden at bottom |
| Centered content | Looks unfinished on wide screens, wastes horizontal space |
| Workflow as horizontal strip | Compact but takes away from the books section, hard to show 5 steps with descriptions |
Status: Decided Date: 2026-02-19
Organize the pipeline as a two-level DAG with a single source of truth in packages/types/src/pipeline.ts. The two levels are:
- Stages — High-level groupings visible in the UI (Extract, Storyboard, Quizzes, Captions, Glossary, Translate, Speech, Package). Stages have inter-stage dependencies forming a DAG.
- Steps — Atomic processing operations within a stage (e.g.,
image-filtering,page-sectioning,tts). Steps have intra-stage dependencies and can run in parallel when their dependencies are met.
Previously, the pipeline topology was duplicated in multiple places:
- A hardcoded
STEP_ORDERarray in the API step runner - A separate
UI_STEP_ORDERwith step-to-UI mappings in the frontend hooks - Hardcoded sub-step lists in every view component
- A flat step list in the CLI runner
Each location had its own notion of ordering, grouping, and dependencies. Adding or reordering a step required changes across 5+ files, and inconsistencies crept in.
// packages/types/src/pipeline.ts
export const PIPELINE: StageDef[] = [
{
name: "extract",
label: "Extract",
dependsOn: [],
steps: [
{ name: "extract", label: "PDF Extraction" },
{ name: "metadata", label: "Metadata", dependsOn: ["extract"] },
{ name: "image-filtering", label: "Image Filtering", dependsOn: ["extract"] },
// ... more steps with intra-stage dependencies
],
},
{
name: "storyboard",
label: "Storyboard",
dependsOn: ["extract"], // inter-stage dependency
steps: [
{ name: "page-sectioning", label: "Page Sectioning" },
{ name: "web-rendering", label: "Web Rendering", dependsOn: ["page-sectioning"] },
],
},
// ... more stages
]Derived lookups (STAGE_ORDER, STEP_TO_STAGE, STAGE_BY_NAME, ALL_STEP_NAMES) are computed once from PIPELINE and exported alongside it.
| Consumer | What it derives |
|---|---|
CLI (packages/pipeline/src/cli.ts) |
Progress bars grouped by stage, step labels |
DAG runner (packages/pipeline/src/pipeline-dag.ts) |
Execution graph, parallelism |
API step runner (apps/api/src/services/step-runner.ts) |
Stage ordering, step groupings |
API routes (apps/api/src/routes/steps.ts) |
Topology for range-based execution |
UI sidebar (StepSidebar.tsx) |
Stage list and navigation |
UI run cards (StageRunCard.tsx) |
Sub-step list per stage |
UI step indicator (StepIndicator.tsx) |
Step ordering and labels |
UI hooks (step-mapping.ts, step-run-range.ts) |
Stage ordering, step-to-stage mapping |
| Approach | Why Not |
|---|---|
| Flat step list with hardcoded groupings | No DAG, no parallelism within groups, grouping logic duplicated everywhere |
| Config file (JSON/YAML) | Loses TypeScript type safety, requires runtime parsing |
| Database-driven pipeline | Over-engineered for a fixed pipeline, adds latency and complexity |
| Separate definitions per consumer | What we had before — led to duplication and drift |
Status: Decided Date: 2026-02-20
When a user starts a stage run while another is already running for the same book, queue it and execute sequentially rather than rejecting with HTTP 409. The frontend serializes API calls through a promise chain to guarantee ordering matches click order.
Previously, starting a stage run while another was active returned HTTP 409 ("step run already in progress"). The individual stage views only checked if their own stage was running (not any stage), so the Run button appeared enabled. Clicking it called startRun() which wiped global progress state, then the API call failed — leaving the UI corrupted with no progress indicators.
The natural user intent is "run Extract, then run Storyboard" — this should just work without requiring them to wait for each stage to finish before clicking the next.
Each book has a BookRunState = { active: StepRunJob | null, queue: QueuedStepRun[] }. When a run is requested:
- No active job: Start immediately, return
{ status: "started" } - Active job exists: Push to queue, return
{ status: "queued" }
An executeJob() → drainQueue() cycle ensures the next queued job starts automatically when the current one completes (success or failure). Stages are independent — a failure in one doesn't block the next.
Each job carries a beforeRun callback that clears downstream pipeline data. This runs when the job starts executing, not when enqueued. This is critical — if a user queues Storyboard while Extract is running, we must not clear storyboard data until Extract finishes and Storyboard actually starts. The callback is idempotent (guarded by a ran flag) to handle any edge cases.
The SSE stream (GET /steps/status) stays open across queue transitions. A new queue-next event type tells the frontend when a queued run starts executing. The stream only closes when the active job finishes AND the queue is empty.
A runChainRef (ref to a Promise<void>) serializes API calls. Each queueRun() call chains onto the previous promise, ensuring HTTP POSTs arrive at the server in the exact order the user clicked — even if they click rapidly. Without this, concurrent fetch() calls could arrive out of order due to network timing.
All run handlers call queueRun(options) from useBookRun(). The function does an optimistic cache update (mark stage "queued", clear downstream steps via getStageClearOrder), then chains the API call. This is provided through BookRunContext, eliminating duplication and ensuring consistent behavior.
queueRun invalidates step-status after the API call returns (backend has already cleared downstream data). SSE events trigger targeted invalidation on step-complete (per-stage data queries) and full refetch on complete / queue-next.
| File | Role |
|---|---|
apps/api/src/services/stage-service.ts |
Backend queue logic, BookRunState, drainQueue(), getStageStates() |
apps/api/src/routes/stages.ts |
Route changes, beforeRun closure, SSE stream, step-status endpoint |
apps/studio/src/hooks/use-book-run.ts |
Unified hook: TanStack Query + SSE cache-patching + queueRun + promise chain |
apps/studio/src/routes/books.$label.tsx |
BookRunProvider wrapping book layout |
apps/studio/src/components/pipeline/stages/* |
All handlers use queueRun from useBookRun() |
| Approach | Why Not |
|---|---|
| Fix the 409 guard (disable Run button globally) | Works but poor UX — users must wait for each stage before clicking the next |
| Client-side queue only | Race conditions between tabs/reconnects, server doesn't know about ordering |
| WebSocket for bidirectional control | Overkill — SSE already handles server→client; we only need client→server ordering |
| External job queue (Bull, BullMQ) | Violates "minimize dependencies" principle, in-memory queue is sufficient for single-user desktop app |
Status: Decided Date: 2026-02-20
The stage sidebar uses a single component with one shared DOM structure for both collapsed (icon-only) and expanded (labels visible) states. The rail overlays the pages panel on hover via CSS group-hover and overflow-hidden, with no JavaScript-driven show/hide logic for labels.
The sidebar originally had two completely separate code paths: an icon rail for when the pages panel was open, and a full labeled list for when it was closed. These diverged visually and structurally, leading to inconsistencies (different widths, different rounding, different hover states).
- Single DOM structure: One
stageItemsarray rendered in all modes. No conditional component swap. - CSS-driven expansion: The inner panel uses
w-12 group-hover/rail:w-[220px]withoverflow-hidden. Labels are always in the DOM — they're clipped by width, not toggled viadisplay. This prevents flash-before-collapse when using transition delays. - Transition delay:
delay-150on collapse,group-hover/rail:delay-100on expand. Prevents twitchy hover behavior. railCollapsed: The rail collapses only wheneffectivePagesOpen && !isSettings. When settings are open, the rail expands to show settings sub-tabs.- Fixed export button: The export button sits outside the expanding overlay (below the
flex-1rail area) so it doesn't resize during hover transitions.
| Approach | Why Not |
|---|---|
| Two separate components | Led to visual drift, double maintenance |
JavaScript hover state with useState |
Adds re-renders, harder to coordinate with CSS transitions |
display: none / inline toggling for labels |
Flashes on hover exit before the width transition starts — overflow-hidden clipping is smoother |
Status: Decided Date: 2026-02-20
All stage colors are defined once in apps/studio/src/components/pipeline/stage-config.ts. Each stage has:
color— Tailwind bg class (e.g.bg-blue-600), used for backgrounds, icon fills, card headers, step headershex— Same color as a hex string (e.g.#2563eb), used for SVG strokes (progress ring) and inline styles (top-bar button)borderDark— Border variant (e.g.border-blue-600), used for card outlinestextColor,bgLight,borderColor— lighter variants for secondary uses
No other file should define color hex values or Tailwind color mappings for stages. Consumers look up from STAGES via STAGES.find(s => s.slug === slug) ?? STAGES[0], eliminating hardcoded fallback colors.
Stage colors were previously scattered across multiple files: a COLOR_MAP in StepProgressRing.tsx, a STAGE_HEX map in books.$label.tsx, a HOVER_BG_BY_COLOR map in StageRunCard.tsx, and the stage config itself. Changing a color shade required updating 4+ files. After unification, changing the shade from 500→700→600 required editing only stage-config.ts (plus the HOVER_BG_BY_COLOR in StageRunCard which maps bg-* to hover:bg-* for Tailwind JIT).
Tailwind's JIT compiler scans source files for complete class name strings. Dynamic class generation like `bg-${color}-600` or `hover:${bgClass}` will not be detected. All Tailwind classes must appear as complete literal strings in source code.
For hover variants that depend on the stage color, use either:
- A static lookup map (e.g.
HOVER_BG_BY_COLORmapping"bg-blue-600"→"hover:bg-blue-600") - CSS custom properties with arbitrary value syntax:
style={{ '--stage-clr': hex }}+className="text-[var(--stage-clr)] hover:bg-[var(--stage-clr)]"
Status: Decided Date: 2026-02-20
The top-right button in the sidebar header changes based on the active stage:
| Stage | Button | Action |
|---|---|---|
| Book | Settings gear | Opens API key settings dialog |
| Preview | Rotate/refresh icon | Dispatches adt:repackage event |
| All others | Settings gear | Navigates to that stage's settings page |
The button is rendered as a round circle filled with the stage's hex color (via inline backgroundColor style), with a white icon. This provides a visual hint of which stage is active even in the header.
Previously, each stage row in the sidebar had its own settings gear or refresh button inline. This cluttered the stage list, especially when the rail was collapsed. Moving the action to the fixed header position keeps it always accessible and consistent.
Status: Decided Date: 2026-02-20
Replace the dual-source stage/step status system (local React state from SSE + server TanStack Query cache) with a single unified useBookRun() hook that treats the TanStack Query cache as the sole source of truth, patched live by SSE events.
Previously, stage/step status came from two independent sources:
- Local React state (
use-stage-run.ts) — SSE progress events populatedsteps,subSteps,targetStepsmaps. Lost on page refresh. - Server query cache (
step-statusvia TanStack Query) — DBstep_completionstable. Survived refresh but lagged behind SSE.
Every consumer had to consult both, with guards like stageIsActive, completedNodes fallback, manual setQueryData calls, and sseEnabled toggling with reconnection logic. Stage views each had 5-10 lines of boilerplate combining these sources. Bugs arose when the two sources disagreed — e.g., step statuses not clearing on rerun, spinners not stopping when DB said "done" but SSE said "running".
-
Backend
step-statusendpoint (GET /books/:label/step-status) mergesStageService.getStageStates()(in-memory run state: running/queued/error) with DBstep_completions(done) into a single response with per-stage and per-step states. -
Always-on SSE — one connection per book, open on mount, close on unmount. No toggle, no reconnect logic.
EventSourcehandles reconnection natively. Onopen, a full refetch catches any missed events. -
SSE patches query cache —
step-start,step-complete,step-erroretc. directly callsetQueryDataon thestep-statusquery. Sub-step progress (page X/Y) is stored in auseRef<Map>with a tick counter for reactivity. -
Single hook API —
useBookRun()providesstageState(),stepState(),stepProgress(),queueRun(),error,isRunning. Stage views go from ~7 lines of status boilerplate to ~2.
| File | Role |
|---|---|
apps/api/src/services/stage-service.ts |
getStageStates() — merges in-memory run state |
apps/api/src/routes/stages.ts |
Unified step-status endpoint, always-on SSE |
apps/studio/src/hooks/use-book-run.ts |
TanStack Query + SSE cache-patching + queueRun |
apps/studio/src/routes/books.$label.tsx |
BookRunProvider wrapping book layout |
| Approach | Why Not |
|---|---|
| Keep dual-source, fix reconciliation bugs | Fundamental complexity — every new consumer must correctly merge two sources |
| Move all state to SSE (no query cache) | Loses state on page refresh, can't preload status before SSE connects |
| Polling instead of SSE | Higher latency, unnecessary requests — SSE is already working well |
Status: Decided
Date: 2026-03-09
Add an optional screenshot-driven visual refinement loop to storyboard rendering and AI HTML edits, and store debug screenshots as files (books/{label}/.debug-images/{hash}.png) rather than DB blobs.
HTML generated from page-sectioning often passes structural validation while still failing visually (overlap, unreadable text, bad responsive behavior). A second-pass visual check was needed that compares rendered HTML screenshots against the source page image.
The first implementation stored screenshots in a SQLite debug_images table. That increased DB size/churn and added unnecessary schema complexity for debug-only binary assets.
- Quality: Multi-iteration visual review catches layout issues structural checks miss.
- Transparency: Screenshot hashes in LLM logs can be resolved directly via the debug image endpoint.
- Simplicity: Flat files are sufficient for debug screenshots and align with the project principle "flat files > database when sufficient."
- Operational safety: Storyboard reruns clear debug screenshots at run start, avoiding unbounded growth.
- Visual refinement is configured per render strategy under
render_strategies.*.config.visual_refinement. - The visual-review loop was extracted to a shared pipeline utility (
runVisualReviewLoop) used by both storyboard rendering and AI edit flows. - Debug screenshot storage moved from DB table to filesystem:
- write:
Storage.putDebugImage(hash, data)creates.debug-images/{hash}.png - clear:
Storage.clearDebugImages()removes files in.debug-images/ - resolve:
/api/books/:label/debug/llm-image/:hashchecks.debug-imagesfirst.
- write:
- Legacy
debug_imagesschema artifacts are dropped on DB open (DROP TABLE IF EXISTS debug_images).
| Approach | Why Not |
|---|---|
| Structural validation only | Misses visual regressions and responsive layout problems |
| Store screenshots in SQLite BLOBs | Adds DB bloat/churn for debug binaries, harder cleanup |
| External object store for screenshots | Unnecessary infrastructure for local/self-hosted workflows |
| # | Decision | Chosen | Over |
|---|---|---|---|
| 001 | Binding strategy | Pure JS/TS + WASM | Native C/C++ bindings |
| 002 | Package manager | pnpm | npm, yarn |
| 003 | HTTP server | Hono | Express, Fastify |
| 004 | Frontend build | React + Vite (SPA) | Next.js, CRA |
| 005 | Frontend ecosystem | TanStack (Router, Query, Table, Form) | React Router, SWR, react-hook-form |
| 006 | Desktop runtime | Tauri v2 (sidecar) | Electron |
| 007 | Styling | Tailwind CSS | CSS Modules, styled-components |
| 008 | Validation | Zod | io-ts, Yup, Joi |
| 009 | Database | node-sqlite3-wasm | better-sqlite3, sql.js |
| 010 | Testing | Vitest | Jest |
| 011 | Class utility | clsx | classnames |
| 012 | UI components | shadcn/ui | MUI, Headless UI, raw Tailwind |
| 013 | Progress streaming | Always-on SSE with cache-patching | Polling, WebSocket, SSE with local state machine |
| 014 | Debug panel | Bottom drawer + REST + SSE | External viewer, console logs |
| 015 | Navigation | Breadcrumb per page | Global header, sidebar |
| 016 | Home layout | 30/70 split (guide + books) | Full-width grid, centered content |
| 017 | Pipeline model | Two-level DAG (Stage / Step) | Flat step list, config file, per-consumer definitions |
| 018 | Concurrent stage runs | Per-book queue with promise chain serialization | 409 rejection, client-only queue, external job queue |
| 019 | Stage sidebar | Single expandable rail with CSS hover | Two separate components, JS-driven hover |
| 020 | Stage colors | Single source in stage-config.ts | Scattered hex/class maps across files |
| 021 | Top bar button | Context-aware per stage | Per-stage inline buttons in sidebar |
| 022 | Stage/step status | Unified useBookRun() with SSE cache-patching |
Dual-source (local SSE state + query cache) |
| 023 | Visual QA + debug screenshots | Screenshot-based refinement + file-backed debug images | Structural-only validation, DB BLOB storage |