Skip to content

See++ v2 - #93

Merged
knazir merged 93 commits into
masterfrom
v2
Apr 26, 2026
Merged

See++ v2#93
knazir merged 93 commits into
masterfrom
v2

Conversation

@knazir

@knazir knazir commented Apr 25, 2026

Copy link
Copy Markdown
Owner

See++ v2

Replaces the 2018 frontend with a ground-up rewrite. New look, more features. The legacy app stays live at old.seepluspl.us for 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

  • Full visual overhaul. Reworked typography, colors, spacing, and layout across the whole UI, not just the visualization. Every pane has been redesigned from scratch.
  • Workspaces. Save your code, load it back later, and share with the Share button or a /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.
  • Examples panel. ⌘/Ctrl+K opens a list of curated example programs (linked lists, BSTs, recursion, and so on). Click one to load it in the editor.
  • Color theme. Light or dark, follows system preference by default.
  • Cleaner visualization. Stack and heap layouts are now more detailed. Hovering a pointer highlights its target and the arrow between them, and cards animate smoothly when you step through the trace.
  • Keyboard navigation. ⌘/Ctrl+↵ to run, arrow keys to step through the trace, and clicking a line number jumps the trace to the next time that line runs.
  • Admin panel at /admin for managing feature flags (if deployed).
  • Cutover banner at the top of the page with a link to the old site and the issue tracker. Click the ✕ to dismiss.

Tech

Frontend stack is Vite + React 18 + Tailwind v4 + Zustand, with CodeMirror 6 for the editor.

  • Heap layout uses ELK's layered algorithm and routes heap-to-heap edges as part of the layout pass. Stack-to-heap edges go through a separate geometry pass in viz/routeEdges.ts since ELK only sees the heap subgraph.
  • OAuth sits behind a small provider abstraction (Google plus a dev-only provider triple-gated on env, Github coming in the near future). Sessions live in Postgres via connect-pg-simple, and the session id rotates on login (regenerate-then-save) to block fixation.
  • Workspaces persist in Postgres with nanoid slugs. Ownership is tied to user id, and anonymous workspaces are immutable once written.
  • Feature flags are a Postgres table plus an admin panel and a useFlag() hook. Flags auto-create on first read.
  • Trace contract is owned by backend/src/parse_vg_trace.ts and revalidated at the I/O boundary by a Zod schema in frontend/src/trace/schema.ts. Any drift between the two surfaces at parse time rather than deep in the render tree.
  • Hardening. /api/run is 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.
  • Tests run in Vitest for both halves: 130 frontend, 6 backend, all green. Backend npm test runs in CI now (it was previously the exit 1 placeholder from npm init).
  • Repo Plumbing. New .github/workflows/ci.yml runs lint + typecheck + test + build for both halves on every PR, and a CODEOWNERS file auto-requests review.
  • Deploy. Copilot manifests alias seepluspl.us to the new frontend and old.seepluspl.us to the legacy one. The frontend image takes GA_MEASUREMENT_ID as a build arg; nginx serves the SPA with a strict CSP plus standard security headers (X-Frame-Options, Referrer-Policy, HSTS).
  • Analytics are GA4 with five custom events (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.ts had a frame_id / frameId typo that silently disabled pass 5 ("skip extraneous step after a return"). The comparison was undefined === undefined === true on every record, which weakened the optimization to "delete the next step whenever line and funcName match." Added a discriminating regression test.
  • The OAuth callback called req.session.save() under a comment claiming it regenerated the session id. save() doesn't rotate the id; only regenerate() does, so the callback now uses regenerate-then-save.
  • AdminPage treats any 401/403/404 from an admin endpoint as the same neutral empty state shown to signed-out viewers, so a forged me.isAdmin = true in 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.md files interspersed throughout the repo with context for agents.

knazir added 30 commits October 30, 2025 01:22
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
knazir added 22 commits April 25, 2026 11:37
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread backend/src/flags/index.ts Outdated
Comment on lines +30 to +35
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(),

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Suggested change
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),

Copilot uses AI. Check for mistakes.
Comment on lines +15 to +22
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]);

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread frontend/package.json
Comment on lines +23 to +27
"codemirror": "^6.0.2",
"elkjs": "^0.11.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zod": "^4.3.6",

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
// frontend/src/flags/names.ts.
export const FLAGS = {
/** Gates the in-product tutor panel + breadcrumb + topbar button. */
TUTOR_PANEL: "tutor-panel",

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
TUTOR_PANEL: "tutor-panel",
TUTOR_PANEL: "tutor-panel",
BANNER_V2_CUTOVER: "banner-v2-cutover",

Copilot uses AI. Check for mistakes.
Comment thread backend/schema.sql
Comment on lines +5 to +20
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,

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
knazir added 2 commits April 26, 2026 01:15
- 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)
@knazir
knazir merged commit c5e9925 into master Apr 26, 2026
2 checks passed
@knazir
knazir deleted the v2 branch April 26, 2026 01:05
knazir added a commit that referenced this pull request Apr 26, 2026
- 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants