Conversation
Worktree + ports:
- scripts/worktree-ports.sh hashes a slug into deterministic
BACKEND_PORT / FRONTEND_PORT / DB_PORT / DB_NAME in .env.local.
- docker-compose.yml parameterizes project + container names by
WORKTREE_SLUG and adds Postgres alongside backend + code-runner.
- localdev.sh wraps 'docker compose' to auto-load .env.local.
- Two worktrees up at once never collide.
Docs and agent conventions:
- .agents/ with README, worktree-setup, subagent-patterns,
pr-ready-checklist, session-log/.
- CLAUDE.md hierarchy: root + backend/ + code-runner/. Each reflects
the trace architecture (frontend owns the consumer schema, backend
owns parse_vg_trace.ts, goldens are the contract test).
- docs/v2/plan.md stays as the phase roadmap with a pinned banner
pointing at the ADR.
- docs/v2/adr/0001-no-monorepo.md captures the implementation
assumptions overturned during P0.
CI:
- Minimal GitHub Actions workflow: backend install + tsc build on
PR + push. Grows to include integration tests at P2 and a frontend
job at P3.
Housekeeping:
- Deleted the unfinished frontend/ directory wholesale; it's rebuilt
from scratch at P3.
- .gitignore narrowed so .claude/agents/ (when we reintroduce scoped
personas) and shared CLAUDE.md files are trackable; settings.local.json
and credentials stay ignored.
What was attempted and reverted during P0 (kept off this branch's log):
pnpm workspaces + Turborepo + packages/* with five shared packages;
starter Claude Code hooks; eight speculative agent personas; -v2
filename suffixes. All rolled back in favor of per-directory tooling
and frontend-owned schema. See ADR-0001 for the why.
The mock at tmp/design-spec/project/src/viz.jsx already implements FLIP (via Web Animations API, 520ms cubic-bezier), a working singly-linked-list recognition heuristic, and a scrubbar wired to the same animation primitive. The three spikes were manufactured risk — the 'will this work?' questions already have 'yes' answers on disk. Residual characterization concerns (50+ node FLIP behavior, scrub latency at high step counts) fold into P4 as inline measurement, not a separate phase. Plan banner updated to point at both ADRs. Phasing is now P0 -> P2 -> ... -> P9.
Delete docs/v2/plan.md and docs/v2/p0-kickoff.md — the phased-plan
framing was written for a greenfield project. We are not greenfield:
the backend is a clean 2025 Node/Express TS rewrite and tmp/design-spec/
is a working React prototype. Continuing to execute against a 9-phase
plan with week estimates, risk registers, and an agent-orchestration
catalog produced obvious waste (all of P0's monorepo work was ripped
out once exposed to reality).
Add docs/v2/README.md: current state, v1 goals, lift-the-keepers
frontend strategy, flat ordered backlog of 20 small tickets. No weekly
estimates. No phases. No speculative infrastructure.
Add docs/v2/adr/0003-scope-cut.md documenting:
- ~25 items killed outright (OpenAPI gen, Storybook, nuqs early,
traceVersion negotiation, property tests, S3 trace cache, Sentry,
Redis, tutor evals, scheduled agents, headless CI agents, agent-
workflow metrics, etc.)
- 2 items scoped down (recognition shapes; OAuth providers)
- Lift-the-keepers frontend strategy: Vite + TS + Tailwind + Zustand,
fresh scaffold; lift FLIP + recognition + styles from the mock.
Also updates root CLAUDE.md and .agents/README.md to point at
docs/v2/README.md instead of the deleted plan.
First item on the v2 backlog (docs/v2/README.md #1). Hand-rolled rather than using create-vite so there's no template boilerplate to delete. Stack: - Vite 6 (+ @vitejs/plugin-react) - React 18.3 + TypeScript 5.7 (strict, noUncheckedIndexedAccess) - Tailwind v4 via @tailwindcss/vite (no tailwind.config.js needed; @import 'tailwindcss' in index.css) - Zustand 5 — minimal store stub in src/store/; slices grow with backlog items #6+ - ESLint 9 flat config with react-hooks + react-refresh plugins - Prettier 3 - Vitest 3 + @testing-library/react (vitest 2 conflicts with vite 6 on bundled-vite types) Scripts: dev / build / preview / typecheck / test / test:watch / lint / format / format:check. Test harness sanity check only (src/App.test.tsx) — renders App and asserts the title. Not a component spec; there's nothing meaningful to test in an empty scaffold. Real component tests land with the components themselves. CI workflow gets a frontend job mirroring the backend one (lint + typecheck + test + build on PR + push). Local verification: npm run {lint, typecheck, test, build} all green; dev server boots on :4000 and serves index.html with the expected root div and main.tsx entry.
Ticket #2 on docs/v2/README.md. Ports the 'dark (canonical)' token set from tmp/design-spec/project/src/ styles.css into a Tailwind v4 @theme block. Tokens auto-become utilities (bg-bg-0, text-ink-0, border-line, duration-med, ease-out-soft, etc.). Explicitly NOT ported: - Light theme. v1 ships dark-only. Add when needed. - Accent variant classes (body.accent-indigo/green/gold). Were tweaks- panel affordances; not a v1 feature. - Component-specific CSS from the mock (app chrome, panel styles, edge paths). Each lands with its owning component in later tickets. Fonts self-hosted via @fontsource-variable packages (one file per font covers the full weight range). No Google Fonts CDN. App.tsx updated to use the new tokens (bg-bg-0 / text-ink-0 / text-ink-2 / font-mono) so the theme is actually exercised. Subtitle added as a minimal second element. Verified: typecheck / lint / test / build all green; dev server serves the CSS with tokens present; bundle is 144KB JS + 11KB CSS + font woff2 files subset by script.
Backlog #3 + #4 + #5 landed as one substantial change (docs/v2/README.md). Components (src/components/): - TopBar: brand + Run button wired to store.run(). Disabled while running; label flips to 'Running…'. - EditorPane: @uiw/react-codemirror + @codemirror/lang-cpp. Custom theme sourced from --color-* / --font-mono CSS vars so it tracks the design tokens. ⌘↵ / Ctrl+↵ runs from anywhere in the editor. - VizPane: debug placeholder — JSON.stringify(trace). Real viz components (StackFrames, HeapGraph) land in later tickets. - ConsolePane: stdout / stderr / error text from the trace response (ink, warn, err color tokens respectively). State (src/store/index.ts): - Single Zustand store. Workspace (code, setCode) + execution (running, trace, error, run). Nested slices would be ceremony at this size; split if it doubles. - DEFAULT_PROGRAM: the mock's linked-list push_front + reverse. - run() is reentrancy-safe: a second call while one is in flight is a no-op. API (src/api/client.ts): - Thin fetch wrapper around POST /api/run. Accepts an optional fetchFn so tests can inject a mock without touching globals. - RunError captures status + body for actionable error display. Vite dev proxy: /api -> http://localhost:${BACKEND_PORT:-3000} so the same-origin fetch works without CORS in development. Tests (non-redundant by design): - store/store.test.ts: 4 unit tests covering initial state, setCode, run() success path (asserts request body + returned trace), run() error capture (status 500 + message), and run() reentrancy guard. Uses a typed fetch mock — no global stubbing. - App.test.tsx (replaces the old harness-sanity test): renders the real composed App and drives a Run click end-to-end with fetch globally stubbed. Asserts the four layout regions are present and that the returned JSON + stdout render in the right panes. Branch coverage lives in store.test.ts; this test only verifies DOM wiring. index.html: <link rel=icon href='data:,'/> to kill the favicon 404 that's been cluttering the console since day one. Verified: - npm run typecheck / lint / test / build — all green (7 tests pass). - Bundle: 672 KB JS (218 KB gzipped). Larger because of CodeMirror; code-splitting is a later polish. - Playwright MCP: navigated :4000, DOM snapshot shows all four regions with the right data-testids and the default program rendered with C++ syntax highlighting. 0 console errors.
The previous commit accidentally shipped 6 .playwright-mcp/* logs +
yaml snapshots and 2 *-verify.png screenshots that were produced during
UI verification. They're session ephemera, not source.
Adds .playwright-mcp/ and *-verify.{png,jpg,jpeg} to .gitignore and
removes the committed files.
Captures the CodeMirror 6 vs Monaco decision with:
- Why CM6 now: ~5x smaller bundle, CSS-variable theming works
directly against our design tokens, no language-service features
are load-bearing for a visualizer.
- Why revisit at multi-file (backlog #17+): Monaco's native model
system + shared undo scopes + cross-file find earn their bundle
cost once there's more than one file.
- Swap cost estimate: ~one afternoon, fully reversible. Only
EditorPane + its immediate deps touch; store / api / layout /
tokens all stay.
Also adds a short comment in EditorPane.tsx pointing at the ADR so the
decision is discoverable from the code, not only from docs/.
Backlog #6 on docs/v2/README.md. Builds on top of the shell from #3+#4+#5. Trace schema (frontend/src/trace/): - schema.ts: Zod mirror of backend's ProgramTrace (from parse_vg_trace.ts). Per-frame fields typed; passthrough on unknown fields for forward-compat. Encoded-value guards (isCData / isCStruct / isCArray) + displayEncoded() render scalars, pointers, nullptr, structs, and arrays readably. - fixtures.ts: TINY_TRACE reused by store + App + component tests so the shape is defined once. Store (src/store/index.ts): - trace is now ProgramTrace (validated), not unknown. - New stepIndex + stepForward / stepBackward / stepTo. Actions clamp to [0, steps.length - 1] and no-op before a trace is loaded. - run() now safeParse()s the backend response — shape drift surfaces as a readable 'Backend returned an unexpected trace shape: …' error on the store, not a crash in render. - useCurrentStep() selector exported for components that just need trace[stepIndex]. Components: - StackFrames.tsx: reversed stack (top-of-stack first). Active frame (isHighlighted) is expanded with accent-bordered accent-soft fill and its locals; inactive frames condense to func + line. - VizPane.tsx: step counter + prev/next buttons in the header, disabled at bounds. StackFrames fills the body. Raw-JSON debug view preserved as a collapsed <details> so it's still there when I need it but out of the way for real use. - ConsolePane.tsx: stdout sourced from trace[stepIndex].stdout (backend emits cumulative per-step stdout, so no manual concat). Per-step exceptionMsg surfaces in warn color; store-level error remains in err. Tests (non-redundant by design): - trace/schema.test.ts (6 tests): schema accepts fixture, rejects missing trace array and missing funcName, preserves unknown fields via passthrough; type guards narrow correctly; displayEncoded renders scalars / pointers / nullptr / structs / arrays. - store/store.test.ts (8 tests): split into workspace+execution and step navigation describes. Covers setCode, run() success (request body + stored trace), shape drift, HTTP error, reentrancy, step clamping in both directions, stepTo out-of-bounds, step no-op before trace. - components/StackFrames.test.tsx (3 tests): top-of-stack ordering, active-vs-condensed behavior including only-active-frame-shows-locals, empty-state copy. - App.test.tsx updated: Run → StackFrames render → step-forward advances. Installed zod (was missing; without it the schema types degraded to any and silently lost safety everywhere downstream). Verified: typecheck / lint / test (20 passing) / build all green. Playwright MCP walkthrough with a two-step stubbed trace — confirmed active-frame accent styling, locals rendering, step-counter bounds, and cumulative stdout behavior.
…ap pointers Backlog #7 on docs/v2/README.md. The visualizer now actually visualizes — a linked list renders as two Node cards connected by a real arrow, with a nullptr tail chip on the last node. Components: - HeapNode.tsx: renders one heap block (C_ARRAY wrapper). Unwraps the common solo-C_STRUCT case to a typed card (struct name + addr header, one row per field). Non-pointer fields use displayEncoded; pointer fields render inline and carry data-ptr-target=<addr> so the edge layer can anchor an arrow. Nullptr pointer fields render as a slashed chip (data-ptr-target='null') — no edge. - HeapGraph.tsx: iterates step.heap entries, one HeapNode per addr. Empty-state copy when there's no heap yet. Naive single-column layout; dagre-style layering is later polish. - EdgeLayer.tsx: absolute SVG overlay that queries the viz body for every [data-ptr-target] -> [data-heap-addr] pair and draws a curved bezier with an accent-colored arrow marker. useLayoutEffect + ResizeObserver + MutationObserver keep edges correctly positioned on step change, container resize, and DOM shape changes. Wiring: - VizPane body is now a relatively-positioned container holding a stack | heap split plus the EdgeLayer overlay. Raw-JSON debug view stays at the bottom in a collapsed <details>. - StackFrames LocalRow tags pointer locals with data-ptr-target (plus the same slashed nullptr chip as heap fields). Stack-to-heap edges work off the same attribute convention as heap-to-heap. Fixtures & tests: - LL_TRACE: main with list→0xaaa and two chained Node heap blocks. - HeapGraph.test.tsx (5 tests): node count + data-heap-addr values, pointer-target attribute presence and value, nullptr-chip rendering, struct-field inline rendering, empty-state copy. - test-setup.ts: no-op ResizeObserver shim for jsdom (EdgeLayer depends on it; jsdom doesn't ship one). Verified: typecheck / lint / test (25 passing) / build all green. Playwright MCP walkthrough with LL_TRACE stubbed: stack 'list' renders an accent arrow to the 0xaaa card; 0xaaa.next draws a second arrow to 0xbbb; 0xbbb.next shows the slashed nullptr chip with no edge. The visualizer visually renders a linked list.
Before: the editor host wrapped CodeMirror in an overflow-auto div and
relied on CM6's default intrinsic sizing, so on tall viewports the
gutter + background stopped at the last line and the pane below
revealed the layout bg. Looked cut off.
Theme now forces the editor to fill:
.cm-editor { height: 100% }
.cm-scroller { min-height: 100%; overflow: auto } // owns its own scroll
.cm-content { min-height: 100%; bg-0 } // empty area = editor bg
.cm-gutters { min-height: 100% } // line-number column extends
Removed overflow-auto from the wrapper — CM6 handles overflow internally.
Added explicit style={{ height: '100%' }} on <CodeMirror> for the same
reason.
Verified in Playwright MCP at 1600x1400; gutter + bg now reach the
bottom of the pane. 25/25 tests still green.
Three responsiveness problems at narrow viewports: 1. Editor content beyond the viewport wasn't scrollable — CodeMirror scroller had min-height: 100% which let content push it taller than the parent, so overflow happened at the layout level (invisible, clipped) rather than inside the scroller. Fix: .cm-scroller gets explicit height: 100% + overflow: auto. Content still has min-height: 100% so the editor surface fills when the program is short. 2. The 50/50 editor | viz split was unusable at narrow widths. Fix: at viewports < lg (< 1024px) the split stacks vertically — editor on top, viz under it. Border flips from right to bottom at that breakpoint. At lg+ the horizontal split returns. 3. Console pane fixed at 144px (h-36) ate ~22% of a 650px-tall window. Fix: h-24 (96px) by default, h-32 (128px) at lg+. Added shrink-0 so it doesn't fight the flex-1 main body during narrow-width reshuffles. Verified via Playwright MCP at 900x650 (stacked, all panes visible, no cutoff) and 1400x900 (side-by-side, full program visible). 25/25 tests still green.
Previous pattern only caught *-verify.png, so an ad-hoc screenshot named *-fix.png slipped into the repo. Scoped the exclusion to the root only (leading slash) so component-test snapshots under frontend/tests/ or similar subdirs are still trackable if we add them later.
Backlog #8 on docs/v2/README.md. Ported from tmp/design-spec/project/ src/viz.jsx:186-222 (520ms cubic-bezier, 360ms enter). The numbers are product choices from Claude Design's mock; don't tune without review. New module src/anim/flip.ts: - captureRects(map) / computeDelta(prev, curr) — pure layout helpers. - playFlip(prevRects, currentEls): Web Animations API inverse-to-zero translate for every persisting key whose rect moved. Skips zero deltas and keys not in the prev map (those are new — enter handles). - playEnter(el): opacity+slide-up for a newly-mounted node. - FLIP_DURATION/FLIP_EASING/ENTER_DURATION exported as constants so EdgeLayer can match the window. HeapGraph orchestration: - Ref maps for addr → HTMLElement and addr → previous DOMRect (plus a prev-addrs Set for new-vs-persisting classification). - useLayoutEffect on stepIndex+step: playFlip on persisting nodes, playEnter on new nodes, then recapture rects for the next transition. - HeapNode accepts an optional registerEl callback ref. EdgeLayer kept in sync: - After every stepIndex/trace change, a rAF loop re-measures edges for FLIP_DURATION + buffer (60ms). getBoundingClientRect reflects the animating transform, so arrows follow the nodes frame-by-frame instead of snapping to final positions while nodes are mid-slide. - ResizeObserver + MutationObserver scheduling preserved (coalesced via rAF); now wires cleanly into the cleanup via a single 'following' flag + rafRef. Test setup: - anim/flip.test.ts: 6 tests pinning the public surface — captureRects keying, computeDelta sign, playFlip invokes element.animate with the right keyframes + opts, zero-delta skip, new-key skip, playEnter keyframes. - test-setup.ts: Element.animate stub for jsdom (returns a minimal Animation-like). Lets component tests render HeapGraph without blowing up. Verified: typecheck / lint / test (31 passing) / build all green. Playwright MCP walkthrough with a 3-step trace (grow + swap order): step 1 single node; step 2 new 0xbbb enters + gets chained; step 3 topology reversed (0xbbb renders above 0xaaa, stack points at 0xbbb). 0 console errors across all three.
Backlog #9 (plus the exec-bar half of #11). Moves step controls out of the viz header into a dedicated full-width bar at the bottom of the app (below the console), matching the mock's layout. Bar always renders; inert without a trace so the app doesn't reflow after Run. Controls: - Restart / Step-back / Play-Pause / Step-forward icon buttons. - Range input scrubbar (spp-scrub CSS shim for theme-matching thumb + track — Tailwind can't reach ::-webkit-slider-thumb). - NN / NN step counter. - LINE NN indicator sourced from the current step. - Play auto-advances every 700ms (mock's value); auto-stops at last step; clicking play when already at end rewinds to 0 first so space twice in a row always plays. Global keyboard (src/hooks/useGlobalShortcuts.ts): - Space toggles play. - Arrow-left / right step. - ⌘↵ / Ctrl↵ runs. - Silently skips while focus is inside an INPUT/TEXTAREA/SELECT, any contenteditable, or a descendant of .cm-editor — so arrow/space keys type normally in the editor. Store additions: playing / setPlaying / togglePlay. setPlaying rewinds to 0 when called with true at end-of-trace. run() resets playing. Tests (non-redundant): - ExecutionBar.test.tsx: disabled-before-trace, scrub-via-range, play-interval (vi.useFakeTimers to step through 700ms + verify auto-stop), play-from-end rewinds, line indicator from current step. - useGlobalShortcuts.test.ts: arrow navigation, space toggles, ignores-while-in-input, ignores-while-in-.cm-editor. - App.test.tsx updated to assert exec-bar in the layout and assert the exec-counter text (01/02 → 02/02). 40/40 tests pass. Playwright verified: ⏮ ◀ ▶ ▶ · TIMELINE · slider · 01/03 · LINE 11 layout renders; step forward advances to 02/03 LINE 12 with FLIP on the heap nodes.
Backlog #10. Ported from tmp/design-spec/project/src/viz.jsx:230-280 and adapted to our typed ExecutionPoint. src/viz/recognize.ts — pure function step → { kind: 'LL', chain } | null. Criteria (all must hold for a positive result): - heap has >= 2 blocks - some stack-local pointer targets a heap addr (has a root) - from that root, a chain with exactly ONE outgoing pointer field per node walks to nullptr covering every heap addr exactly once - DLL / tree / cycle all fail these criteria → null (v1.5 scope per ADR-0003 which relaxed the precision bar so null is safe). Cycle-safe (visited Set). Store: recognitionOn + toggleRecognition. VizPane: toggle button in the header. Disabled (with tooltip) when recognize() returns null at the current step. Active state uses the data-[active] CSS attribute pattern so styling is declarative. HeapGraph: when a shape is recognized, reorders entries along the detected chain and switches layout from flex-col to flex-row flex-wrap. Uses the same addr-keyed DOM elements, so FLIP smoothly interpolates the transition when the user toggles. HeapGraph still renders the same HeapNode cards; EdgeLayer still draws arrows (edge routing is column- optimal so horizontal chains look a little loopy — visual polish for later if it matters). Tests (8 in recognize.test.ts): - LL happy paths: 2-node and 3-node chains. - Rejects: empty heap, single heap node, no stack root, DLL (two outgoing pointer fields), cycle, partial-coverage (dangling node). 48/48 tests green. Playwright verified: 3-node list renders vertically by default; toggle flips to horizontal chain with head → tail order, arrows still drawn.
…ting Backlog #13, with user's explicit design correction: the mock's dimming overlay was too intrusive — a user who's debugging wants to keep seeing the trace while they edit. So we go subtle, not blocking. Derivation: store tracks lastRunCode (set on successful run); stale = trace !== null && code !== lastRunCode. Exposed via useIsStale() hook. Three subtle cues, in descending loudness: 1. Topbar pill next to the Run button: 'edited · ⌘↵ to re-run', warn color, tiny dot, tooltip explains. Primary cue. 2. Editor tab dot: small warn-colored dot next to 'main.cpp' when the editor is ahead of the last run. 3. Viz body opacity dims to 0.7 with a duration-fast transition. Still fully interactive — user can step, scrub, toggle recognized, read values, etc. No overlay, no pointer-events: none. All three clear the moment a successful run resets lastRunCode to match the current code. Tests: new store/stale.test.ts (4 cases) — false before run, false when code matches, true after edit, resets after re-run. Covers the derivation at the store boundary; subtle CSS effects are visual and covered via Playwright walkthroughs. 52/52 tests green.
Backlog #11 + #12. Store additions: - consoleOpen + toggleConsole. - modal: 'examples' | 'sign-in' | null + openModal + closeModal. Components: - Modal.tsx: overlay + centered card, click-outside + Esc close, backdrop blur. Shared primitive for both modals. - ExamplesModal.tsx: 3 canned programs (Hello world, linked-list build+reverse, recursive factorial). Clicking one loads its code into the editor and closes. - SignInModal.tsx: 'Continue with Google' button, disabled and labeled SOON. Explicit note that the real wire happens at backlog #16. No backend call, no state leakage. - TutorBreadcrumb.tsx: slim strip above the console, only renders once a trace exists and there's no error. Placeholder copy points at v1.5. - ConsolePane.tsx: header is now a button that toggles open/closed. Collapsed = 32px (header only); open = existing 96/128px. Chevron indicator, error/output suffix in the header for at-a-glance status. TopBar: new Examples + Sign in buttons left of the stale pill / Run button. Keyboard: ⌘K opens Examples from anywhere (even inside the editor — that's the point), via useGlobalShortcuts. Esc closes the active modal (Modal primitive handles it). Tests: - Modal.test.tsx (3): renders title+children, Esc closes, click- outside closes but click-on-card doesn't. - ExamplesModal.test.tsx (2): examples render; picking one sets code + clears modal. 57/57 tests green. Playwright verified Examples + Sign-in modals render with backdrop blur, focused card, proper content.
Backlog #14. Side-by-side against tmp/design-spec/project/screenshots/ 01-v2.png; focused tweaks, not a redesign. Topbar: - 'See++' → lowercase 'see++' + accent square dot. Matches the mock's brand lockup. Viz header: - 'viz' → 'execution visualization' with live STACK N · HEAP M counts sourced from the current step. - Single toggle button → dual-pill 'raw | recognized' group where both states are visible and the active one is highlighted. More discoverable than a button whose label toggles. Stack frames: - Locals now show a small type indicator line below the value (e.g. 'int' under a scalar). Pointer locals keep the → addr chip. Console: - Header now carries the mock's 'stdout · stderr · build' channel labels on the right so the console reads as a dev-tool surface even when collapsed. Tutor breadcrumb: - Prefixed with a 'step N' pill for context. Accent dot instead of a muted ink-3 dot — more visible, matches the mock's affordance. 57/57 tests green; Playwright verified the final layout matches the mock's 01-v2.png closely for the shared panes (topbar branding, viz header counts + toggle group, stack locals with type, heap cards + edges, tutor breadcrumb, console channels, exec bar).
Ran a real gap analysis against tmp/design-spec/project/screenshots/01-v2.png
and styles.css and fixed the drift across every major region. This is the
equivalent of running through the mock component-by-component.
Topbar (TopBar.tsx):
- 44px height, section-separated layout matching the mock.
- New Brand component: SVG 4-square brand mark + 'see++ v2' with accent '++'.
- Center section: Examples (⌘K) / Save / Share, ink-3 leading glyphs.
- Right primary: Tutor (⌘J) in accent — disabled w/ v1.5 title.
- Theme toggle icon stub + Account icon (opens signin modal).
- Status pill: dot color by state (idle / running / traced / stale / error),
with live 'N steps · N kb heap' suffix when a trace is loaded.
- Run chip redesigned: inline lightning glyph, shadow elevation, ⌘↵ keycap
inside the button (matches mock's run-chip .k styling).
Editor (EditorPane.tsx):
- Proper tab bar above the editor: main.cpp tab with chevron, close X,
accent underline on active.
- Syntax theme ported from mock's .tok-* palette via HighlightStyle +
syntaxHighlighting: kw purple, type gold, fn cyan, string green,
number gold, comment italic ink-3, preproc brown.
- Active line now uses accent-soft bg with accent-line top/bottom borders
(mock's .ln-wrap.active::before treatment).
Viz (VizPane.tsx):
- Explicit 240px | 1fr grid for STACK | HEAP columns with independent
scroll.
- Sticky section labels with count badges ('STACK 2', 'HEAP 3') matching
mock's .section-label + .count styling.
- Header right: 'N on heap' live summary alongside the raw/recognized
dual toggle.
StackFrames.tsx:
- Frame cards with chevron affordance; click to expand/collapse; active
frame auto-expanded + shadow-[inset_2px] accent strip.
- Function signature parsed: fn name + (arg1, arg2) with args in ink-2.
- Line indicator + pin affordance (toggleable, keeps frame expanded
across steps).
- Local rows: name:type pair on left, accent-bordered pill value on
right; nullptr gets the mock's diagonal strike-through treatment.
HeapNode.tsx:
- Struct heap block renders with accent type label + addr in header
matching .heap-node-head.
- Field rows with type annotation; pointer values in accent pill;
nullptr with diagonal strike — same treatment as stack locals.
ExecutionBar.tsx:
- Native <input type=range> replaced with a custom scrubbar div: 6px
track with accent fill + per-step ticks (major every 5), rectangular
draggable thumb with vertical guide line.
- Two button groups: primary (restart/back/play/forward) and step
in/out (disabled w/ 'arrives with schema' tooltip — v1 doesn't have
depth metadata yet).
- Step counter restyled: big 16px accent-colored current / small muted
total, separator.
- LINE indicator in a bordered pill matching .xb-counter .line-indicator.
- Custom scrubbar is pointerdown/pointermove/pointerup draggable;
keyboard accessible via arrow-left/right when focused.
TutorBreadcrumb.tsx:
- Event-aware contextual copy (enter / call / return / exception /
instruction-limit-reached / generic step).
- Explain (accent-soft + sparkle) and Ask (bordered) buttons — UI-only
for v1 per backlog; dismissible via X.
ExamplesModal.tsx:
- 6 canned examples in a 2-column grid (LL reverse, recursive fact,
hello world, pointer swap, BST insert, dynamic array).
- Per-card: category tag (uppercase ink-3), title, description,
difficulty dots (accent-filled vs line-muted), step-count estimate.
- 'Featured' state when the currently-loaded code matches the example.
- Modal size grown to max-w-3xl to fit the grid properly.
SignInModal.tsx:
- Context-aware title/copy driven by new signInReason state ('save' vs
'share' vs 'generic'): 'Save this workspace' / 'Create a shareable
link' / default.
- Accent-circle icon at top matching the reason.
- GitHub + Google OAuth buttons with brand dots, 'SOON' suffix.
- Email/password inputs (UI-only).
- Primary Sign-in button + 'Continue without signing in' skip.
- Footer note points at backlog #16 for the real wire.
Modal.tsx:
- size prop: 'md' (sign-in) vs 'lg' (examples grid).
Tests updated for the new surfaces:
- StackFrames frame names now include the (args) signature — test
updated to assert ['foo(x)', 'main(arg)'] and only-active-expanded.
- ExamplesModal ids now match the mock's 6-example naming.
- ExecutionBar's native range is gone; test the custom scrubbar via
keyboard + aria attributes instead.
57/57 tests green. Playwright MCP verified against the mock screenshot:
topbar lockup matches, viz columns + counts match, frame cards match,
heap cards match, exec bar custom scrubbar + button groups match, tutor
breadcrumb matches, console + stdout/stderr/build labels match.
Architecture: - Dark remains the canonical @theme token set; light is a same-named-var override under html[data-theme='light']. Every color utility resolves through var(), so switching themes is one DOM-attribute update with zero rebuilds and zero theme-aware component logic. - CodeMirror syntax palette (.tok-* from the mock) now lives as design tokens too (--color-syntax-kw / -type / -fn / -str / -num / -preproc), so the editor re-themes in lockstep with the rest of the UI. - Accent-contrast ink / shadow values moved from hardcoded hex into --color-accent-ink / --color-accent-ink-soft / --color-accent-shadow so light/dark each supply their own. Theme module (src/theme/): - theme.ts: pure, no-React preference resolution — readStoredPreference / resolvePreference / applyTheme / persistPreference / nextPreference. Used by BOTH the inline FOUC shim and the React-side hook. Safe shims for missing localStorage / matchMedia. - useTheme(): mounted once in App; applies the resolved theme on pref change and (for 'system') subscribes to matchMedia so OS-level theme flips apply live with no reload. Legacy Safari addListener path included. FOUC prevention (index.html): - Synchronous inline script runs before React mounts: reads localStorage['spp.theme'], resolves 'system' via matchMedia, sets html[data-theme] BEFORE first paint. No flash on reload, no JS-free fallback flash either. - <meta name='color-scheme' content='light dark'> so browser chrome (native form controls, scrollbars, autofill) tints correctly. - body gets color-scheme: dark by default + 'light' override under [data-theme='light']. Store (src/store/index.ts): - themePreference / setThemePreference. setThemePreference persists + applies + updates state in one call, so the store is the source of truth and the DOM never drifts. Topbar toggle: - Previously disabled stub; now a cycling button (system ↔ dark ↔ light). Icon reflects CURRENT state (☾/☀/◐); title shows both current and next states. Tests (src/theme/theme.test.ts): - 13 unit tests covering stored read, system resolution via injected MediaQueryList, dark/light passthrough, apply to DOM, persist write, missing-storage fallback, QuotaExceeded swallow, cycle order, MQ constant. 70/70 tests green. Playwright MCP verified: page loads honoring OS system preference on first hit, topbar cycle flips dark and light correctly (data-theme attribute round-trips + background / ink / accent all swap), syntax highlights re-theme without re-rendering the editor.
The <CodeMirror theme='dark'> prop from @uiw/react-codemirror was applying CM's built-in dark theme ON TOP of our sppTheme. That injected a hardcoded dark gutter / scroller background that shadowed our var()-driven overrides, so the gutter stayed dark even when html[data-theme='light'] flipped the rest of the UI. Dropping the prop. sppTheme already reads everything from --color-* vars, so the editor re-themes with the rest of the app. Verified via Playwright MCP computed-style reads on .cm-gutters: dark → bg rgb(13,15,18) ink rgb(85,86,79) border rgb(42,47,56) light → bg rgb(246,244,239) ink rgb(155,154,146) border rgb(210,206,193) …all match the --color-bg-0 / --color-ink-3 / --color-line-soft tokens in each respective theme. Screenshot confirms the full editor surface (gutter, content, active-line highlight, syntax colors) now flips together on theme toggle.
./localdev.sh up now brings up the entire stack — postgres, backend,
code-runner image, AND frontend — in one command, on the same compose
network, with the frontend proxying /api to the backend over service-name
DNS.
frontend/Dockerfile.dev:
- node:22-alpine, npm ci from the committed lock, CMD runs 'vite
--host' so the dev server binds 0.0.0.0 and the host port-forward
works.
frontend/.dockerignore: keeps node_modules / dist / .turbo out of the
build context so host-macOS compiled deps don't poison the image.
vite.config.ts:
- server.host: true for container port-forward.
- server.proxy.target reads VITE_BACKEND_URL (compose sets it to
http://backend:3000); falls through to localhost:${BACKEND_PORT} on
host-side npm run dev so both modes work from the same config.
- server.watch.usePolling gated on CHOKIDAR_USEPOLLING env, set true
in compose because docker-on-macOS bind mounts don't propagate
inotify events reliably.
docker-compose.yml:
- frontend service uncommented and wired.
- Named volume spp_frontend_node_modules preserves the container's
linux/alpine-compiled node_modules against the ./frontend:/app bind
mount that would otherwise shadow it with the host's (empty or
macOS-compiled) directory.
- depends_on: backend so the proxy has a target at boot.
localdev.sh:
- Pre-existing bug: 'compose_args[@]: unbound variable' when the array
was empty and set -u was on. Fixed with the
${arr[@]+"${arr[@]}"} safe-empty-expansion idiom.
Verified end-to-end via Playwright MCP against the real compose stack
(no stubs, no mocks):
- ./localdev.sh up --build → all three services report healthy on
3000 / 4000 / 5432.
- Navigate http://localhost:4000 → page loads, zero console errors.
- Direct fetch('/api/run', {code: 'int main(){return 0;}'}) via the
frontend's origin → HTTP 200, JSON body '{"code":"…","trace":[]}'
round-trips successfully through the Vite proxy.
- Click Run on the default linked-list program → real HTTP request
arrives at the backend (docker compose logs show the records), the
backend parses, returns a typed trace, the frontend Zod-validates
and stores it without errors.
Pre-existing upstream bug observed during the test (not in scope for
this commit): SPP-Valgrind is emitting kind:1 records with empty stack
arrays ({func_name:'main',line:2,IP:...,stack:[]}), so
parse_vg_trace.ts's 'skip before main' pass filters them all out and
the trace lands empty. The integration pipeline itself is correct — the
backend reports what the tool gave it and the frontend reflects that
honestly. Fixing SPP-Valgrind is a separate investigation.
Two fixes the user called out:
1. frontend-legacy was missing from docker-compose.yml. The P0
docker-compose.v2.yml → docker-compose.yml rename carried forward
only backend / postgres / code-runner-local-build / frontend, leaving
the 2018 reference frontend unreachable from ./localdev.sh up.
Restored exactly as it was in the pre-v2 compose: builds from
frontend-legacy/Dockerfile.dev with SPP_API_URL=http://localhost:3000/api,
volume mount of ./frontend-legacy, port 8000:8000 hard-coded (legacy
is reference material, not worktree-isolated on purpose).
2. Compose was generating 'spp-main' as the project name and
'spp-main-backend' etc. as container names even on the main clone,
because of the main default-substitution idiom.
Switched to } — the hyphen + slug
suffix is opt-in per worktree, so the main clone gets the original
clean names (spp, spp-backend, spp-frontend, spp-frontend-legacy,
spp-postgres) and worktrees still get their own namespace
(spp-<slug>, spp-backend-<slug>, …).
Verified via 'docker compose config':
main clone → name: spp, container_name: spp-backend
WORKTREE_SLUG=spike-flip → name: spp-spike-flip, container_name:
spp-backend-spike-flip
localdev.sh exports WORKTREE_SLUG with an empty default so compose's
'variable not set' warnings don't spam on the main clone.
End-to-end verified: ./localdev.sh up brings all four services up with
the correct names — spp-backend on :3000, spp-frontend on :4000,
spp-frontend-legacy on :8000, spp-postgres on :5432 — all responding.
Root cause: SPP-Valgrind's stack walker doesn't initialize on entry to
main() unless a libstdc++ call runs first. Programs without any stdlib
use (e.g. raw 'struct Node { … }; int main() { Node* n = new Node{…}; }')
cause every trace record to come back with stack:[], and the backend's
'skip everything before main' pass filters every such record out. The
result was the new frontend getting 0-step traces for its own default
program while the legacy UI's default (which opens with
'cout << "This is a linked list."') was working fine on the same
backend + code-runner.
Walked through the repro matrix via Playwright-driven fetches at the
real stack:
my default (no include, no cout) → 0 steps
+ #include <iostream> → 0 steps
+ using namespace std; + cout in main → 6–25 steps
multi-func + include + cout BEFORE call → populated stacks
multi-func + include + cout AFTER call → 0 steps
So the priming call must precede any user function call in main.
Fixes:
- DEFAULT_PROGRAM: add #include <iostream>, using namespace std,
'cout << "linked list demo" << endl;' as the first statement of
main. Comment points at backend/CLAUDE.md.
- ExamplesModal.tsx: every example (hello, ll-reverse, fact-rec,
ptr-swap, bst-insert, arr-dyn) now includes the preamble + cout
as first line of main. Module-level comment + backend/CLAUDE.md
pointer.
- backend/CLAUDE.md: new 'Known quirk — SPP-Valgrind stack walker
priming' section documents the issue, the required example-program
shape, and the two real upstream fix options (synthetic-frame
fallback in parse_vg_trace.ts, or patching SPP-Valgrind to emit
stack records from main's first instruction). Option 1 is the
smaller lift and lives in this package — deferred but noted.
- 'Don't' list gets a bullet so future contributors don't ship a
traceable example without priming.
Verified end-to-end via Playwright MCP against ./localdev.sh up:
- Load http://localhost:4000 → status pill IDLE.
- Click Run → status flips RUNNING → TRACED · 54 steps · 484 b heap.
- Viz populates: main() frame with list: pointer, console shows
'linked list demo', tutor breadcrumb 'Entering main() — line 28'.
- Step forward 25 times → step 26/54 inside push_front(Node*, int)
at line 13; heap now has 2 Node blocks with real pointer edges
drawn between them; 'N on heap' counter updates; debug · raw
trace <details> reveals the live ExecutionPoint JSON from the
backend. Zero Zod validation errors.
Integration parity with the legacy frontend is now achieved.
Four user-facing asks, one branch:
1. Heap exit animation (anim/flip.ts + HeapGraph.tsx)
- New playExit: faster than enter (280ms vs 360ms), distinct curve
(shrink + drift UP signals 'released' vs enter's drift-from-below).
- HeapGraph now defers React unmount for EXIT_DURATION when a heap
addr leaves: stashes the block in a local exiting map so the DOM
element stays mounted long enough for element.animate to run, then
drops on a timer. Re-allocs at the same address during the exit
window correctly cancel the pending exit.
- FLIP still runs on persisting + exiting nodes so their positions
interpolate instead of snapping when layout around them shifts.
2. Trace-line highlight (src/editor/traceLine.ts + EditorPane)
- New StateField + StateEffect carry step.line into the CM state.
Decoration.line with class cm-trace-line paints the line that the
active trace step is executing. Null / out-of-bounds → no-op.
- EditorPane dispatches setTraceLine.of(step?.line ?? null) whenever
useCurrentStep() changes.
3. Dual highlight (sppTheme + cm-trace-line styles)
- Cursor's active-line subdued from accent-soft to bg-1 — it's still
visible (so the user can tell where their edit cursor is while
scrubbing a stale trace) but doesn't compete with the trace line.
- Trace line uses accent-soft background + inset 3px accent
left-bar. When cursor + trace land on the same row the trace wins
visually (rendered later in the cascade).
4. Gutter-click jump (src/editor/gutterJump.ts + store)
- Custom lineNumbers extension with domEventHandlers.click wiring
into store.jumpToNextOccurrence(n), which scans forward from the
current stepIndex and wraps around once. Multiple clicks on the
same line walk through every occurrence.
- basicSetup.lineNumbers=false so our gutter is the only one.
Bonus cleanup:
- StackFrames: inner pin <button> was nested inside an outer <button>
expand control — invalid HTML that React flagged. Outer became
role='button' div with keyboard activation so the pin can be a
legitimate child.
Tests (73 → 76 passing):
- anim/flip.test.ts: playExit keyframes + duration < enter + fill:
forwards.
- store/store.test.ts: jumpToNextOccurrence walks forward, wraps,
no-ops on missing line, no-ops before a trace is loaded.
End-to-end verified against ./localdev.sh up:
- Run the default program → 54-step real trace.
- Trace-line highlight updates as stepIndex advances (screenshot at
step 16 shows line 12 = push_front's closing brace highlighted).
- Gutter click on line 10 from step 0 → step 6/54 line 10; click
again → step 14/54 line 10 (walks forward through occurrences).
- Zero console errors after the HTML-nesting fix.
…primed The walker only starts emitting frames once libstdc++ runs, so early records inside main() arrive with stack:[] and get dropped by the "skip everything before main" pass. Fill in a single synthetic frame so those records survive. Examples no longer need a priming cout call.
…-in/out - Replace '<UNINITIALIZED>' literal with '?' in displayEncoded - Restore legacy favicon at /favicon.ico - New jump-to-end transport button - Wire step-into/step-out to stack-depth deltas in the trace - Drop the priming-cout comment in DEFAULT_PROGRAM now that backend handles it
Bundles all the must-fix backend hardening from the v2 review: - auth.ts: regenerate session id BEFORE assigning userId on login (req.session.save alone does NOT rotate the id; only regenerate does — what blocks classic session fixation). Adds a regression test. - session.ts: explicit secure=true in production. secure: "auto" is fragile against any deploy that mishandles X-Forwarded-Proto. - index.ts: add helmet for default security headers; cap JSON body at 128KB; rate-limit POST /api/run to 10/min/IP — the one endpoint that drives Lambda/Docker spend was the only POST without a limiter. - index.ts: tighten dev-mode CORS — reflect only localhost/127.0.0.1 origins instead of any origin, since credentials:true was already on. - providers/dev.ts: belt-and-braces — DevAuthProvider throws if NODE_ENV !== "development" even if registered. Defense against a future code path that imports it without the registration gate. - db.ts: apply schema.sql inside BEGIN/COMMIT so a partial failure doesn't leave the DB half-migrated.
- store/run(): when the user typed during an in-flight run, store the
trace but null out lastRunCode so useIsStale fires unambiguously
true. Previously the trace landed with lastRunCode = sentCode, which
was correct for "trace is for sentCode" but caused a corner case if
the user happened to type back to a coincidentally-matching prior
value. Adds two regression tests.
- trace/schema: clamp the LINE_PREPEND_OFFSET shift at 0 via a
shiftLine() helper. Compile-failure payloads carry line: 0 (when
lineNum is unknown) and were producing line: -1 — hidden today by
the build-failure hoist in run(), but a landmine for any consumer
that reads trace[0].line before that hoist. Test covers both
build-failure and ordinary shifts.
- store/WriteIntent: drop the unreachable 'share' variant. Share goes
through createAndOpenShareModal() directly (no name prompt), so the
variant had no creator and the matching else-if in
completePendingWrite was dead code. Comment notes why.
- api/client: extract ensureOkWorkspace / ensureOkAdmin to replace
eight near-identical "if (!res.ok) { body = await res.text…; throw
new …Error(…) }" blocks. Rule of three met twice over.
- editor/gutterJump: drop the explicit MouseEvent annotation on
mousedown — CM6 types the event as Event, and the override was a
pre-existing typecheck failure. preventDefault() works on Event.
EdgeLayer: - Narrow MutationObserver to attributeFilter [style, data-heap-addr, data-ptr-target, data-stack-addr, data-stack-contains, data-stack-expanded]. Without it, every hover toggle of data-highlighted/data-orphan fired schedule() → computeEdges → setEdges → re-render → write data-highlighted on a path → re-fire the observer. Coalesced by rAF but still wasteful on bigger heaps. - Compute edgePath once per edge instead of twice (hit region + visible path each rebuilt the string). - Replace per-pointer N×3 querySelector resolveTarget with a single buildTargetMap walk: heap blocks > stack locals > frames containing the address. Drops cssEscape since map keys handle the escaping. HeapGraph: - Reset prevRectsRef / prevAddrsRef when trace identity changes. Valgrind reuses allocator addresses across runs; without the reset, the first render of a new trace played a FLIP "move" from stale prior-run rects keyed by colliding addresses. HeapViewport already resets pan on trace change — this is the missing analog for FLIP.
Bundles all the must-fix UI items from the v2 review (C1, C2, plus the modal a11y polish items). AdminPage 403 handling (C1): - api/client: AdminApiError now carries status, thrown by ensureOkAdmin. - AdminPage: classify 401/403/404 from any admin endpoint as "server said no" and fall through to the unified empty state — same UI as a signed-out viewer. A forged me.isAdmin in DevTools loses to the first fetch. Other errors get a sanitized "Could not load flags" message instead of leaking the raw server body. Adds 3 regression tests. writeStatus split (C2): - store: dismissShareModal is a separate action that clears shareUrl and the modal slot only. dismissWriteFeedback no longer touches the share modal. Walk-through that bit before: closing the share modal yanked an in-flight save toast. - ShareLinkModal: wired to dismissShareModal. Modal a11y pass: - createPortal to document.body so the modal escapes any transformed/ overflow-hidden ancestor. - aria-labelledby points at the visible h2 instead of a redundant aria-label that duplicated its text. - Modal stack registry: only the top-of-stack modal handles Esc, so stacking two modals doesn't close both at once. - useAnyModalOpen exposes the registry to other code. - useGlobalShortcuts: no-ops while any modal is open, so Space/Arrow keys in a confirm dialog activate buttons / move focus instead of scrubbing the timeline, and Cmd+K can't stack a second Examples modal. EditorPane runRef removal: - Drop the runRef indirection. Zustand actions are stable references, so reading the latest run from the store at dispatch time is just as correct and one fewer effect. SettingsMenu radio semantics: - Drop role="menu" + role="menuitemradio" — we don't implement the arrow-key menu navigation those imply. The popover is really a dialog containing three radiogroups, so set role="dialog" on the outer and role="radio" on the buttons.
nginx.conf: add CSP (locked to 'self' for script + connect, since the SPA is same-origin and there are no third-party fonts/analytics yet), X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy strict-origin-when-cross-origin, Permissions-Policy zeroing camera/mic/geolocation, and HSTS for the ALB-terminated path. docs/README: the quickstart pointed at port 8000 (the legacy frontend) and described the new frontend as "in development". v2 is the default — correct the project structure block, the tech-stack table (CodeMirror 6, not Monaco), and the localhost port (4000). Add cross-links to the new oauth-setup and feature-flags runbooks.
Without this, hitting Cmd+Enter inside the editor leaves CodeMirror focused, so the immediate ArrowRight that follows is swallowed by the editor (useGlobalShortcuts.shouldIgnore skips anything inside .cm-editor). Costs a click outside the editor to break out. After: as soon as `running` flips true, blow focus off CodeMirror so the next ArrowRight engages the global step shortcut. Matches the muscle-memory of "Run, then arrow through the trace."
The "pick the nearer side" anchor heuristic broke down for heap-to-heap arrows. A heap pointer chip's right edge ≈ the enclosing card's right (clean exit), but its left edge sits well INSIDE the card. So a leftward exit drew the arrow over the source card's own value/next rows. Fix: anchor the source on chip.right and the target on target.left for all pointer kinds, matching what stack-to-heap already does. The bezier control points (c1 to the right of source, c2 to the left of target) keep the curve readable when the target sits below-left of the source — the arrow sweeps out and around rather than through the source card body. Verified live with the linked-list demo at the mid-reverse step.
Auto-request review from @knazir on every PR.
Previous rule "source = chip.right, target = target.left" worked for linked-list-style structures where targets always sit to the right of their sources, but forced every BST/tree edge to cross the canvas to reach the left side of its target. New rule keeps the source anchor (chip.right is required so the arrow visibly originates at the address value's end) but picks the target side that's closer to the source's exit point. The bezier control point on the target end flips with the side: c2x = target.x - dx when entering from the left, target.x + dx when entering from the right. That keeps the curve smooth in both flow directions instead of overshooting when sweeping back leftward into a target on the near side. Verified live with the BST example at step 99 (5 heap nodes) — tree edges now fan cleanly to children without crossing the canvas.
Pulls all edge-routing logic out of EdgeLayer.computeEdges into a pure
module so it can be reasoned about and tested independently. Replaces
the rigid "always exit chip.right, always enter target.left" rule with
geometry-driven decisions across four integrated passes.
New: viz/routeEdges.ts (pure, 8 unit tests)
Pass 1 — anchor side selection (A): for each edge, pick which side of
the source CHIP and the target it uses. Heap-card chips use card-to-
target geometry so the arrow exits the card on the side facing the
target instead of crossing the card body. Stack chips fall back to
chip-to-target direction. The chip stays the visible source — only
which of its two horizontal edges the arrow leaves from changes.
Pass 2 — per-(target, target-side) port distribution (B + C): when N
arrows arrive at the same target on the same side, spread them along
the target side's vertical extent so they don't pile up at a single
point. Sorted by source y to keep within-cluster crossings minimal.
This subsumes "edge bundling between card pairs" — arrows that share
endpoints get distinct anchor points instead of stacking.
Pass 3 — per-(sourceCard, source-side) ordering (B + C): documented
no-op today since each chip lives on its own row, but the integration
point exists for future card layouts that put multiple chips per row.
Pass 4 — layout-time port hints (D): when a layoutCenters Map is
passed, side selection prefers layout-time card centers over live
DOM rects. Keeps side decisions stable across FLIP animations — the
rendered coordinates interpolate, but the routing decision doesn't
flip mid-animation when a rect happens to cross a threshold.
Integration:
- layoutHeap now also returns `centers` (per-card x,y from dagre)
alongside the existing top-left positions.
- viz/layoutHintsContext.tsx — small ref-backed context (same pattern
as the existing hoverContext) so HeapGraph can publish layout
centers and EdgeLayer can read them at compute time without forcing
a re-render on every layout pass.
- VizPane wraps HeapGraph + EdgeLayer in LayoutHintsProvider.
- HeapGraph publishes layout.centers after each layoutHeap() call.
- EdgeLayer is now a thin DOM-measurement + render shell over
routeEdges. It collects EdgeSamples from the DOM, calls the pure
module, then handles clipping + container-relative coords and
bezier control-point selection from sourceSide/targetSide.
Verified live with:
- Binary search tree (5 nodes, mid-insert): children fan from the
parent's nearest side, no card sweeps, no octopus.
- Reverse-linked list (3 nodes, mid-reverse): heap-to-heap arrows
arc cleanly, stack→heap arrows enter from the appropriate side.
If this routing turns out wrong on some other data structure, the
whole change is one commit to revert.
Layered (1) + (2) into the routing module added in the previous commit. Both keep the chip as the visible source of every arrow — only target side selection and side-pair scoring change. (1) Vertical routing — Side is now 4-way (left/right/top/bottom). The target side is picked from the dominant axis of the (target → source) vector, so a heap pointer whose target sits below at near-same x enters through target.top instead of forcing target.left/right and sweeping horizontally across the canvas. Bezier control points handle the four sides naturally: top entries pull c2 above the target, bottom entries pull below. Pass 2 port distribution distributes top/bottom entries along the target's HORIZONTAL extent (and left/right entries along the vertical extent, as before). (2) Obstacle-aware fallback — every (sourceSide × targetSide) candidate is scored against the layout's other card rectangles via Liang–Barsky segment-rect intersection. Score is `crosses × 100 + naturalDistance`, where naturalDistance is the count of sides that don't match the direction-derived "natural" choice. Crosses dominate; absent obstacles the natural pair always wins. With obstacles, the lowest-cost non-crossing alternative wins. Length is deliberately NOT in the score: bezier curves don't follow straight lines, and natural-side preference already encodes "go toward the target." Layout-stable direction calculation now applies to BOTH source and target side selection: stableSrc and stableTgt come from layoutCenters when available, falling back to DOM card centers, then to chip center for stack chips. Earlier version only stabilised the source side, which let the target side flip mid-FLIP if the target rect interpolated past the threshold. EdgeLayer: - Collects all heap cards and stack frames (via [data-heap-addr] and [data-stack-frame-id]) as the obstacle list and passes them to routeEdges. Per-edge filtering of the source's own card and target's own rect is done inside the routing module via id-match. Tests: - Adds 5 new cases: source-above-target picks target.top; source-below picks target.bottom; horizontal-dominant geometry doesn't over-trigger top/bottom; obstacle-aware alternative selection picks chip.left when an obstacle straddles the natural chip.right path; routing does not treat the source's own card or target's card as obstacles. - Distribution test extended for top/bottom entries (horizontal spread). Verified live with the linked-list 2-node mid-push state (the user's complaint screenshot): heap-to-heap arrow now exits chip.right and enters target.top with a clean vertical bezier — no sweep.
Builds a layout-engine abstraction with two implementations behind it
and ships ELK as a second engine controlled by a feature flag. The flag
isn't a kill switch — it's a forcing function for the seam between
"layout strategy" and the rest of the visualisation. Both engines ship;
toggling the flag swaps them at runtime.
Architecture:
viz/layout/types.ts Shared types: LayoutInput, LayoutResult,
LayoutEngine, RoutedLayoutEdge. The contract
every engine implements. Async by interface
so ELK's worker-backed execution and dagre's
sync result both fit.
viz/layout/dagre.ts The legacy implementation, lifted out of
layoutHeap.ts intact. Wrapped as Promise-
returning to satisfy the interface.
viz/layout/elk.ts ELK-backed engine using the `layered`
algorithm + POLYLINE edge routing. Cycles
are handled by ELK's GREEDY cycle-breaker.
ELK additionally fills in `edges` with
routed polylines per source→target pair.
viz/layout/index.ts getLayoutEngine(name) factory. Falls back
to dagre on unknown names so a typo'd flag
value can't crash the visualisation.
viz/layoutHeap.ts Public API delegating to the selected
engine. New `engine` option on the existing
layoutHeap() call.
Integration:
- HeapGraph reads useFlag(LAYOUT_ENGINE_ELK) and passes the engine
name to layoutHeap. The effect is async (cancel-on-unmount via a
`cancelled` flag); FLIP captures `from` rects BEFORE awaiting
layout so cards animate from their previous positions even though
new positions land asynchronously.
- LayoutHints context now carries `centers`, `edges`, and
`worldOrigin`. Centers are translated to CLIENT coords by
HeapGraph before publishing so they align with the chip's
DOM-rect-based stableSrc; edge polylines stay in world coords and
EdgeLayer applies the offset itself.
- EdgeLayer prefers ELK's polylines when present and the user
setting is "curved" (the polyline IS the engine's smooth answer).
For "straight" / "orthogonal", the user's setting wins and the
geometry path renders. The geometry curved-bezier and orthogonal
paths are 4-way targetSide-aware: top/bottom entries pull control
points / corners vertically so the curve / right-angles approach
cleanly from above/below instead of grazing horizontally.
- 2-point ELK polylines (no engine bend) fall through to the
geometry curved-bezier so simple parent→child edges render as
actual curves rather than straight segments. 3+ point polylines
get a Catmull-Rom-to-cubic-Bezier smoothing through the engine's
waypoints.
- Stale polylines after step changes are dropped per-edge via an
endpoint sanity check (last point lands inside the target's
current rect, with 8px tolerance). Avoids the curved↔straight
flicker a wholesale hint-clear would cause.
- Stack chips (no source card) are pinned to chip.right as the
source side regardless of obstacle scoring — the address value
sits in the rightmost column of every stack-frame row, and
leaving from chip.left to double back through the canvas looks
far worse than the cross it would avoid. Heap chips keep
adaptive selection.
Flag:
frontend/src/flags/names.ts adds LAYOUT_ENGINE_ELK = 'layout-engine-elk'.
Default off. Backend mirror unchanged — flag is purely client-side.
Tests:
layoutHeap.test.ts is now parametrised over both engines via
describe.each(['dagre', 'elk']) so neither engine can drift from
the shared invariants. Adds an ELK-only test asserting routed edges
are present, and a stack-chip pinning regression test in
routeEdges.test.ts.
Verified live across:
- Linked-list mid-reverse (3 nodes vertically stacked): heap-to-
heap arrows curve into target.top with vertical approach;
stack arrows enter heap nodes from the closest side.
- BST mid-insert (5 nodes): tree shape laid out top-down, no
cross-canvas spaghetti, port distribution at shared targets.
- Setting toggles: curved → engine-smoothed polylines + 2-point
bezier fallback, straight → straight lines, orthogonal →
right-angle bends with vertical-aware corners.
Sweep across the files touched since the previous code review:
- Remove comments that narrate past bug fixes or reference specific
conversation feedback ("we deliberately do NOT clear hints because
doing so caused a flicker", "Without this guard, two stacked modals
both call onClose...", etc.). The reasons end up in commit messages
and tests; the code itself shouldn't carry incident write-ups.
- Compress (A)/(B)/(C)/(1)/(2) lettered taxonomies that read like
replies to my own iterative work into one short paragraph stating
what the module does.
- Trim docstrings that re-explain what well-named identifiers already
say. Keep comments that document non-obvious WHY: hidden constraints,
external-system quirks, load-bearing invariants, coordinate-system
notes that load-bear elsewhere.
- Test-side: replace "Regression test for bug X" preambles with
one-liners stating the invariant under test. Discriminating-case
tests keep the contrast they document.
Net: 531 deletions / 225 insertions across 31 files. No behaviour
change. 130 frontend + 6 backend tests still pass; typecheck clean.
Commit to ELK as the only layout engine. Removes dagre, the LayoutEngine interface, the layout/ subdirectory, the LAYOUT_ENGINE_ELK flag, and the @dagrejs/dagre dependency. ELK code folds back into viz/layoutHeap.ts as a single async function; the seam was useful as a forcing function during the swap, but a single-implementation interface earns no rent. If a future structure-aware layout shows up (mrtree for trees, force for dense graphs, etc.), the right move will be to feed ELK the appropriate algorithm option, not to bring back a multi-engine factory. 124 frontend tests pass (was 130 — lost 6 from the parametrised dagre cases that no longer exist); typecheck clean; visually identical against the BST mid-insert state.
Matches the legacy frontend setup. The gtag bootstrap script is injected into index.html at build time only when VITE_GA_MEASUREMENT_ID is set — dev and unset builds ship no analytics code at all. CSP allows googletagmanager.com (loader) and *.google-analytics.com (beacons), with unsafe-inline on script-src for the inline gtag bootstrap (the cost of doing GA without nonces). Dockerfile.prod takes GA_MEASUREMENT_ID as a build arg the same way legacy does; copilot/frontend/manifest.yml passes the same property ID for both test and prod environments. Measurement IDs are public, not secret, so committing the value is fine. Also: drop the stale "Monaco" reference in the frontend manifest comment — we use CodeMirror.
Single-line soft-accent banner above the topbar pointing to the legacy URL and the issue tracker. Two layers of off-switch: - BANNER_V2_CUTOVER feature flag (server-side; admin can flip globally without a deploy). - localStorage dismissal (per-browser; user clicks the X). Default flag value is true so the banner shows out of the box at cutover; flip to false in the admin panel when ready to retire. Later the component file + the FLAGS entry can be deleted entirely and the orphan flag row pruned from the DB. Cursor stays default-arrow over the links to match the projectwide no-pointer-on-links convention.
Adds a thin gtag wrapper at frontend/src/analytics.ts and instruments five call sites: - run_clicked — fires at the top of run() before the request. - run_succeeded — duration_ms, step_count, truncated. - run_failed — coarse `reason` bucket: shape_drift, compile_error, rate_limit, payload_too_large, server_error, client_error, network_error, unknown. No user content surfaces. - workspace_shared — slug + reused flag (true when the existing workspace was reopened rather than re-POSTed). - example_loaded — example_id only. track() is no-op when window.gtag is missing, so dev / unset builds have no observable behaviour. Verified live with a stubbed window.gtag: example_loaded, run_clicked, and run_succeeded captured with the expected payloads.
CI: - actions/checkout@v4 -> @v5, actions/setup-node@v4 -> @v5. Both v4 releases ship Node 20 internals which GitHub deprecates June 2026. Frontend (silences four react-refresh/only-export-components warnings): - Modal.tsx kept as a component-only file. The stack registry plus useAnyModalOpen hook moved to components/modalStack.ts. Modal uses isTopOfStack() instead of reaching into the array directly. - viz/hoverContext.tsx renamed to .ts (context + useHook only). Provider extracted to viz/HoverProvider.tsx. - viz/layoutHintsContext.tsx renamed to .ts (context + hooks + types). Provider extracted to viz/LayoutHintsProvider.tsx. Drive-by: drop a stale eslint-disable in MyWorkspaces.tsx that the rule no longer flags. 130 frontend + 6 backend tests still green; lint now clean (was 4 warnings + 1 stale-disable).
Matches the existing per-directory pattern (backend/, code-runner/). Navigational, not exhaustive.
There was a problem hiding this comment.
Pull request overview
Replaces the legacy frontend with a new v2 SPA (Vite/React/Tailwind/Zustand) plus supporting backend infrastructure for OAuth, sessions, workspaces, feature flags, and hardened deployment/CI plumbing.
Changes:
- Rebuild frontend stack and UI primitives (viz layout/FLIP, modals, theming, keyboard shortcuts) with Vitest-based tests.
- Add backend Postgres integration (schema-at-boot), OAuth/session plumbing, feature flags (public + admin), and CI workflow updates.
- Update local/dev and deploy plumbing (docker compose, nginx, Copilot manifests, docs/runbooks).
Reviewed changes
Copilot reviewed 148 out of 161 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| localdev.sh | Wraps docker compose with .env loading |
| docker-compose.yml | Adds Postgres + new frontend dev wiring |
| .env.example | Expanded local dev configuration template |
| .github/workflows/ci.yml | CI for backend + frontend |
| .github/CODEOWNERS | Default repo codeowner |
| .gitignore | Broader ignores; keeps shared CLAUDE.md |
| README.md | Local dev port + licensing text update |
| CLAUDE.md | Repo-wide agent/developer context |
| docs/README.md | Docs index updates for v2 |
| docs/oauth-setup.md | Google OAuth setup runbook |
| docs/feature-flags.md | Feature flag system documentation |
| docs/img/demos/linked_list_example.png | Docs asset (unchanged binary) |
| frontend/package.json | Vite/TS/Vitest deps + scripts |
| frontend/vite.config.ts | Dev server + proxy + GA injection plugin |
| frontend/vitest.config.ts | Vitest config (jsdom + setup) |
| frontend/tsconfig.json | Project references root TS config |
| frontend/tsconfig.app.json | App TS config (strict) |
| frontend/tsconfig.node.json | Node/tooling TS config |
| frontend/eslint.config.js | ESLint v9 flat config |
| frontend/index.html | Vite entry HTML + theme prepaint |
| frontend/nginx.conf | Prod nginx SPA + security headers |
| frontend/Dockerfile.dev | Vite dev container |
| frontend/Dockerfile.prod | Vite build → nginx runtime |
| frontend/.gitignore | Frontend ignores (dist/coverage) |
| frontend/.dockerignore | Docker ignore for frontend |
| frontend/.prettierrc.json | Prettier configuration |
| frontend/.prettierignore | Prettier ignore list |
| frontend/CLAUDE.md | Frontend agent/developer notes |
| frontend/public/index.html | Removes CRA public index |
| frontend/public/manifest.json | Removes CRA manifest |
| frontend/public/robots.txt | Removes CRA robots.txt |
| frontend/public/logo192.png | CRA asset (binary) removed/changed |
| frontend/public/logo512.png | CRA asset (binary) removed/changed |
| frontend/src/main.tsx | New React entrypoint |
| frontend/src/index.tsx | Removes CRA entrypoint |
| frontend/src/react-app-env.d.ts | Removes CRA typing ref |
| frontend/src/reportWebVitals.ts | Removes CRA web-vitals |
| frontend/src/analytics.ts | GA tracking wrapper |
| frontend/src/test-setup.ts | jsdom shims (ResizeObserver/animate/etc.) |
| frontend/src/platform/kbd.ts | Platform-aware shortcut labels |
| frontend/src/hooks/useMediaQuery.ts | Media query hook |
| frontend/src/hooks/useGlobalShortcuts.ts | Global keyboard shortcuts |
| frontend/src/hooks/useGlobalShortcuts.test.ts | Shortcut behavior tests |
| frontend/src/theme/theme.ts | Theme preference + persistence utilities |
| frontend/src/theme/theme.test.ts | Theme utility tests |
| frontend/src/theme/useTheme.ts | React hook syncing theme to DOM |
| frontend/src/trace/schema.ts | Zod validation + encoded value helpers |
| frontend/src/trace/fixtures.ts | Trace fixtures for tests |
| frontend/src/flags/names.ts | Frontend canonical flag names |
| frontend/src/store/stale.test.ts | Store “stale trace” tests |
| frontend/src/anim/flip.ts | FLIP animation helpers |
| frontend/src/anim/flip.test.ts | FLIP unit tests |
| frontend/src/editor/traceLine.ts | CodeMirror “current trace line” extension |
| frontend/src/editor/gutterJump.ts | CodeMirror gutter click-to-jump |
| frontend/src/viz/reachability.ts | Heap reachability/orphan detection |
| frontend/src/viz/reachability.test.ts | Reachability tests |
| frontend/src/viz/layoutHeap.ts | ELK heap layout + routed edges |
| frontend/src/viz/layoutHeap.test.ts | LayoutHeap tests |
| frontend/src/viz/layoutHintsContext.ts | Layout hints ref context |
| frontend/src/viz/LayoutHintsProvider.tsx | Provider for layout hints |
| frontend/src/viz/hoverContext.ts | Hover state context |
| frontend/src/viz/HoverProvider.tsx | Provider for hover state |
| frontend/src/components/modalStack.ts | Global modal stack manager |
| frontend/src/components/Modal.tsx | Portal modal w/ focus trap + Esc |
| frontend/src/components/Modal.test.tsx | Modal interaction tests |
| frontend/src/components/Splitter.tsx | Reusable draggable separator |
| frontend/src/components/HeapViewport.tsx | Pannable heap viewport wrapper |
| frontend/src/components/HeapViewport.test.tsx | HeapViewport behavior tests |
| frontend/src/components/HeapGraph.tsx | Heap layout + FLIP orchestration |
| frontend/src/components/HeapGraph.test.tsx | HeapGraph render tests |
| frontend/src/components/VizPane.test.tsx | Viz pane empty/banner/header tests |
| frontend/src/components/StackFrames.test.tsx | Stack frame rendering tests |
| frontend/src/components/ExecutionBar.test.tsx | Execution bar behavior tests |
| frontend/src/components/ExamplesModal.test.tsx | Examples modal tests |
| frontend/src/components/AdminPage.test.tsx | Admin page permission/error tests |
| frontend/src/components/App.test.tsx | App integration test (fetch-stubbed) |
| frontend/src/components/CutoverBanner.tsx | Cutover banner with dismissal |
| frontend/src/components/CutoverBanner.test.tsx | Cutover banner tests |
| frontend/src/components/ConsolePane.tsx | Console output pane |
| frontend/src/components/TutorBreadcrumb.tsx | Tutor breadcrumb placeholder UI |
| frontend/src/components/SignInModal.tsx | Provider-driven sign-in modal |
| frontend/src/components/ShareLinkModal.tsx | Share link modal + clipboard copy |
| frontend/src/components/SaveFeedbackToast.tsx | Save outcome toast UI |
| frontend/src/components/NamePromptModal.tsx | Optional name prompt for writes |
| frontend/src/components/Visualization.tsx | Removes legacy placeholder component |
| frontend/src/components/Visualization.css | Removes legacy placeholder styles |
| frontend/src/components/Output.tsx | Removes legacy placeholder component |
| frontend/src/components/Output.css | Removes legacy placeholder styles |
| frontend/src/components/Header.tsx | Removes legacy header component |
| frontend/src/components/Header.css | Removes legacy header styles |
| frontend/src/components/Editor.tsx | Removes Monaco-based editor |
| frontend/src/components/Editor.css | Removes legacy editor styles |
| frontend/src/components/App.css | Removes legacy app styles |
| frontend/src/assets/support.png | Asset (binary) |
| frontend/src/assets/logo.png | Asset (binary) |
| frontend/src/assets/github.png | Asset (binary) |
| frontend/src/assets/cpp.png | Asset (binary) |
| frontend-legacy/public/index.html | Adds GA snippet to legacy HTML |
| frontend-legacy/Dockerfile.prod | Adds GA build arg/env |
| frontend-legacy/CLAUDE.md | Legacy frontend context notes |
| copilot/frontend/manifest.yml | Frontend aliases + GA build arg |
| copilot/frontend-legacy/manifest.yml | Legacy alias + GA build arg |
| copilot/backend/manifest.yml | OAuth vars/secrets + CORS regex docs |
| copilot/backend/addons/workspaces-db.yml | RDS addon for workspaces DB |
| code-runner/CLAUDE.md | Code-runner context notes |
| backend/package.json | Adds vitest + auth/db deps |
| backend/tsconfig.json | Includes src; excludes tests/dist |
| backend/vitest.config.ts | Backend vitest config |
| backend/Dockerfile.prod | Copies schema.sql into runtime image |
| backend/schema.sql | Boot-applied DB schema (workspaces/users/sessions/flags) |
| backend/CLAUDE.md | Backend context notes |
| backend/src/index.ts | Helmet, rate limit, DB init, routers |
| backend/src/db.ts | Postgres pool + boot-time schema apply |
| backend/src/valgrind_utils.ts | GCC error payload now includes buildOutput |
| backend/src/runners/local.ts | Swallow compile-fail container exit |
| backend/src/parse_vg_trace.ts | ProgramTrace buildOutput + bugfixes |
| backend/src/parse_vg_trace.test.ts | Regression tests for trace finalize pass |
| backend/src/routes/flags.ts | Public /api/flags endpoint |
| backend/src/routes/admin.ts | Admin flag CRUD endpoints |
| backend/src/routes/auth.test.ts | Session fixation regression tests |
| backend/src/flags/index.ts | Flag cache + auto-create + CRUD |
| backend/src/flags/names.ts | Backend canonical flag names |
| backend/src/auth/session.ts | Postgres-backed sessions |
| backend/src/auth/userRepo.ts | User/identity upsert repo |
| backend/src/auth/providers/provider.ts | Provider interface/types |
| backend/src/auth/providers/index.ts | Provider registry + dev gating |
| backend/src/auth/providers/google.ts | Google OAuth provider impl |
| backend/src/auth/providers/dev.ts | Dev-only auth provider |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function mapRow(row: Record<string, unknown>): FlagRow { | ||
| return { | ||
| name: row.name as string, | ||
| enabled: Boolean(row.enabled), | ||
| description: (row.description as string | null) ?? null, | ||
| updatedAt: (row.updated_at as Date).toISOString(), |
There was a problem hiding this comment.
mapRow() assumes row.updated_at is a Date and calls .toISOString(), but node-postgres typically returns TIMESTAMPTZ columns as strings unless a custom type parser is installed. This can throw at runtime and break flag loading / admin flag CRUD; please coerce via new Date(...) (or install a type parser) before calling .toISOString().
| function mapRow(row: Record<string, unknown>): FlagRow { | |
| return { | |
| name: row.name as string, | |
| enabled: Boolean(row.enabled), | |
| description: (row.description as string | null) ?? null, | |
| updatedAt: (row.updated_at as Date).toISOString(), | |
| function toIsoString(value: unknown): string { | |
| if (value instanceof Date) { | |
| return value.toISOString(); | |
| } | |
| return new Date(String(value)).toISOString(); | |
| } | |
| function mapRow(row: Record<string, unknown>): FlagRow { | |
| return { | |
| name: row.name as string, | |
| enabled: Boolean(row.enabled), | |
| description: (row.description as string | null) ?? null, | |
| updatedAt: toIsoString(row.updated_at), |
| useEffect(() => { | ||
| if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; | ||
| const mql = window.matchMedia(query); | ||
| const onChange = () => setMatches(mql.matches); | ||
| onChange(); | ||
| mql.addEventListener('change', onChange); | ||
| return () => mql.removeEventListener('change', onChange); | ||
| }, [query]); |
There was a problem hiding this comment.
useMediaQuery uses mql.addEventListener('change', ...) unconditionally. Older Safari versions only support mql.addListener/removeListener, so this hook can throw or silently fail there. Please feature-detect and fall back to addListener/removeListener (similar to useTheme).
| "codemirror": "^6.0.2", | ||
| "elkjs": "^0.11.1", | ||
| "react": "^18.3.1", | ||
| "react-dom": "^18.3.1", | ||
| "zod": "^4.3.6", |
There was a problem hiding this comment.
PR description (and various docs in this PR) state the frontend is on React 19, but frontend/package.json pins react/react-dom to ^18.3.1 (and @types/react to 18.x). Either bump the dependencies to React 19 to match the stated stack, or update the docs/PR description to reflect React 18 so consumers know what they're building against.
| // frontend/src/flags/names.ts. | ||
| export const FLAGS = { | ||
| /** Gates the in-product tutor panel + breadcrumb + topbar button. */ | ||
| TUTOR_PANEL: "tutor-panel", |
There was a problem hiding this comment.
frontend/src/flags/names.ts defines BANNER_V2_CUTOVER and explicitly says to keep values in sync with backend/src/flags/names.ts, but the backend FLAGS constant currently only includes TUTOR_PANEL. If the intent is a mirrored canonical list, add BANNER_V2_CUTOVER to the backend FLAGS as well (even if backend code doesn’t read it yet) or adjust the “keep in sync” guidance to avoid drift.
| TUTOR_PANEL: "tutor-panel", | |
| TUTOR_PANEL: "tutor-panel", | |
| BANNER_V2_CUTOVER: "banner-v2-cutover", |
| CREATE TABLE IF NOT EXISTS workspaces ( | ||
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | ||
| slug TEXT NOT NULL UNIQUE, | ||
| code TEXT NOT NULL, | ||
| created_at TIMESTAMPTZ NOT NULL DEFAULT now(), | ||
| updated_at TIMESTAMPTZ NOT NULL DEFAULT now() | ||
| ); | ||
|
|
||
| CREATE INDEX IF NOT EXISTS workspaces_created_at_idx ON workspaces (created_at DESC); | ||
|
|
||
| -- Users: identity-provider-agnostic record. Email/displayName/avatar are | ||
| -- the last values seen from any provider; overwritten on subsequent logins. | ||
| CREATE TABLE IF NOT EXISTS users ( | ||
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | ||
| email TEXT NOT NULL, | ||
| display_name TEXT, |
There was a problem hiding this comment.
schema.sql uses gen_random_uuid() as the DEFAULT for workspaces.id and users.id, but the function is provided by the pgcrypto extension and will error on a fresh Postgres unless CREATE EXTENSION pgcrypto has been run. Consider adding CREATE EXTENSION IF NOT EXISTS pgcrypto; at the top of this schema (or switching to a UUID generator that you explicitly enable).
- backend/flags: handle TIMESTAMPTZ as string OR Date in mapRow - schema.sql: add CREATE EXTENSION IF NOT EXISTS pgcrypto for older PG - useMediaQuery: Safari < 14 addListener fallback (mirrors useTheme) - flags names: clarify mirror policy (banner is client-only by design) - docs: correct React 18 references (project is on 18.3.1, not 19)
- backend/flags: handle TIMESTAMPTZ as string OR Date in mapRow - schema.sql: add CREATE EXTENSION IF NOT EXISTS pgcrypto for older PG - useMediaQuery: Safari < 14 addListener fallback (mirrors useTheme) - flags names: clarify mirror policy (banner is client-only by design) - docs: correct React 18 references (project is on 18.3.1, not 19)
See++ v2
Replaces the 2018 frontend with a ground-up rewrite. New look, more features. The legacy app stays live at
old.seepluspl.usfor the cutover window, and the new UI has a banner pointing at it.Why
After the recent backend rewrite to be more secure and move off of the legacy WSGI-based system, the frontend is now the weakest link in terms of hard-to-update legacy code. There are a few new features planned for See++ that are made much easier by updating the tech stack, which is what this is.
What's new
/w/<slug>link. Anonymous saves work, and signing in with Google ties a workspace to your account so it shows up under My Workspaces. Auth also works locally for testing./adminfor managing feature flags (if deployed).Tech
Frontend stack is Vite + React 18 + Tailwind v4 + Zustand, with CodeMirror 6 for the editor.
layeredalgorithm and routes heap-to-heap edges as part of the layout pass. Stack-to-heap edges go through a separate geometry pass inviz/routeEdges.tssince ELK only sees the heap subgraph.connect-pg-simple, and the session id rotates on login (regenerate-then-save) to block fixation.useFlag()hook. Flags auto-create on first read.backend/src/parse_vg_trace.tsand revalidated at the I/O boundary by a Zod schema infrontend/src/trace/schema.ts. Any drift between the two surfaces at parse time rather than deep in the render tree./api/runis rate-limited at 10/min/IP, helmet sets default security headers, the CSP is locked to first-party plus the GA hosts, the schema is applied transactionally at boot, and JSON bodies are capped at 128KB.npm testruns in CI now (it was previously theexit 1placeholder fromnpm init)..github/workflows/ci.ymlruns lint + typecheck + test + build for both halves on every PR, and aCODEOWNERSfile auto-requests review.seepluspl.usto the new frontend andold.seepluspl.usto the legacy one. The frontend image takesGA_MEASUREMENT_IDas a build arg; nginx serves the SPA with a strict CSP plus standard security headers (X-Frame-Options, Referrer-Policy, HSTS).run_clicked,run_succeeded,run_failed,workspace_shared,example_loaded). The wrapper no-ops when the build var is unset.Notable fixes along the way
parse_vg_trace.tshad aframe_id/frameIdtypo that silently disabled pass 5 ("skip extraneous step after a return"). The comparison wasundefined === undefined === trueon every record, which weakened the optimization to "delete the next step whenever line and funcName match." Added a discriminating regression test.req.session.save()under a comment claiming it regenerated the session id.save()doesn't rotate the id; onlyregenerate()does, so the callback now uses regenerate-then-save.me.isAdmin = truein DevTools loses to the first server response.Coding Agents
I've been using See++ to experiment with different agentic coding tools. For this larger, v2 rewrite, I've found a great deal of success using Claude Code. You'll find
CLAUDE.mdfiles interspersed throughout the repo with context for agents.