Uh oh!
There was an error while loading. Please reload this page.
feat(landing): port Sapling Landing v5 - #524
Conversation
Ports the design-token layer from `Sapling Landing v4.dc.html` into a .landing-v4 scope so it can be built alongside the existing .landing-page surface rather than replacing it in place. - 10 palette values + the 4 mastery-tier colours, as literal source values. These are deliberately NOT aliased onto the app's brand tokens: #0C5638 / #0E9E5A are their own scale, distinct from --brand-forest (#1B6C42) and --brand-forest-bright (#2D8F5C). - All 35 keyframes verbatim, including the six s1* intro-lockup frames whose per-keyframe animation-timing-function declarations are the effect — collapsing them onto one shared curve destroys the leaf weight. - The three responsive rules and the scrollbar treatment. Fonts bind to the existing next/font variables; DM Sans, Spectral, Playfair Display and JetBrains Mono are exactly the four families the source loads, so no extra webfont request is added. prefers-reduced-motion is not repeated — the existing global block is already a superset of the source's rule. Assets recovered from the design project: kofi-symbol.png (321x258) plus the two journal photos. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The single hard-coded course graph the whole landing page is built on. This is not decorative filler — Act I, explore mode and the gallery all render this same data, so it is ported node for node: 7 units, 42 concepts, every authored 3D position, mastery score and blurb. - course.ts XTIER / XTIER_LABEL / XTIER_ORDER, COURSE, nodeUses() - content.ts SCRAMBLE, LAB_KIND, GAL, FAQS, POST_META, POSTS - color.ts the interpolation maths the scroll engine runs per frame XTIER.unexplored (#9a9a9a) is byte-identical to --state-neutral today but is kept literal, following the same reasoning as --fallback-muted in globals.css: it is a distinct concept, so a future change to the app's neutral state must not silently move a landing-page mastery tier. lerpTint keeps its hue-borrowing rule — grey has no hue of its own, so it borrows the destination's and lets saturation carry the change, which is what stops an unexplored node washing through a dead desaturated midpoint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The imperative core of the landing page. Kept out of React state and out of an animation library on purpose: put() memoises every style write onto el.__p_<prop> so an unchanged value never touches the DOM, and cv() caches canvas context + size so no frame does a layout read. Wrapping this in framer-motion would add a layer and make it slower, not faster. Three loops, matching the source: - LandingEngine.tick drives all three acts, the section nav, the ambient canvas, the marquee and the plant field - LandingEngine.simTick the drag-field force sim, gated on cluster visibility - hero.ts the hero point cloud, gated past one viewport of scroll measure.ts is the only module that reads layout, throttled to 600ms. Scroll position is a double-lerp (raw -> 0.05 -> 0.08) with a bypass so programmatic nav jumps land 1:1 instead of being smoothed into a glide. sim.ts is hand-rolled rather than delegated to d3-force, which is already a dependency. The forces are expressible in d3 and its default velocityDecay even matches, but this integrator uses a linear alpha ramp instead of d3's exponential decay, resolves collide in a single pass, and adds an idle drift plus an anchor spring. Same forces, different settling. graphView.ts draws the scroll view and explore mode from one projection. Entering explore continues each node's grey-to-colour reveal from wherever it had reached rather than restarting it, which is why it doesn't flash. No mount point yet — components and the route follow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First mounted sections, on their own /v4 route so the existing landing keeps
working and the two can be compared side by side.
useLandingV4 is the React/engine bridge. React owns the discrete state (open
card, tutor mode, open FAQ); the engine owns everything per-frame and writes
straight to the DOM. State the engine reads is mirrored into a ref so its
getters never close over a stale render.
- IntroOverlay: the six s1* animations, 2.4s with `both` fill. Ordering is
causal — wordmark, stem, bud at the moment the stem tops out, then each leaf
from the joint the stem already passed, rule last.
- Navbar: fades in with the hero, auto-hides on downward scroll (imperative,
off the scroll listener rather than state), collapses under 1180px, hides in
explore mode.
- Hero: the four .floating-card surfaces are positioned in CSS but transformed
by the engine per frame, so they carry data-base-rot / data-float-delay /
data-float-dur instead of CSS animations.
The design's `style-hover` attribute has no inline equivalent in React, so
those 62 rules become real CSS under .landing-v4 — same values.
Refs are declared flat rather than in an object: React's refs lint rule
rejects `ref={obj.someRef}` in JSX.
Marketing links point at /about (exists) plus /team, /wiki, /gallery, /news
and /faq, which are the design's intended routes and are not built yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>…ng-dc scope The v5 port needs the same palette, fonts and 35 keyframes the v4 port already landed. Rather than carry a second copy, the shared foundation loses its version number: - `.landing-v4` -> `.landing-dc`, `--v4-*` -> `--ld-*`, and the four hover utilities `.v4-navlink/-btn-solid/-btn-ghost/-navmenu-item/-kofi` -> `.ld-*`. Purely mechanical; the tokens were referenced only inside globals.css and the class only by the /v4 page, which now wears `landing-dc landing-v4`. Then the v5-only layer on top: the `heroRise` cascade, the `cueDrop` scroll cue, five responsive rules (two of them height-based) and the two hover states whose values differ from v4. `heroWipe` and `heroChar` are deliberately NOT ported. The source defines both keyframes but its markup never references either — the wordmark arrives through a character scramble instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughReplaced the landing page with a component-based v5 experience, added interactive feature labs and companion pages, introduced shared animation engines and content data, and updated SSR coverage. Removed the former marketing graph, surface components, and related tests. ChangesLanding v5 experience
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This pull request has been ignored for the connected project Preview Branches by Supabase. |
The v5 title screen is a ground-up rebuild, not a revision, so the v4 one
is deleted rather than kept alongside: `components/landing-v4/{Hero,
IntroOverlay,Navbar}.tsx` and the `/v4` preview route all go. v5 lands
directly at `/`, replacing the previous marketing landing.
What actually differs from v4, read off the source rather than the brief
that came with the task:
- The wordmark is Playfair Display 600 in brand forest (#0C5638), not
Archivo 800 in ink, and it arrives through a character scramble. The
source defines a `heroWipe` clip-path keyframe and never references it.
- The cascade reverses: the tagline leads at 760ms and the wordmark
follows 260ms behind over 1000ms. v4 ran the wordmark first.
- Layout is a three-row grid (lockup / lede / bottom band), not a centred
flex column, and the DOM `.floating-card` rig is gone — a WebGL canvas
sits in its place (scene lands next).
- The right-hand bloom is teal rgba(43,140,150,0.2), not a mirror of the
green one.
- The intro lockup survives intact but gains a LOADING 000->100 readout,
ticking in ragged +4..+12 jumps every 62ms.
- The navbar loses its max-width wrapper. Its horizontal padding is now
max(4.2vw,22px), exactly the hero grid's, which is what puts the leaf
mark and Get Started on the wordmark's optical edges.
The 537-line hook is shared rather than duplicated: `useLandingV4` moves
to `components/landing/useLanding.ts`, and the intro cascade becomes a
prop so the sequence reads at the call site.
One deliberate divergence: renderVals() builds the navbar's full two-theme
colour table, then the scroll handler pins it with `const wantDark =
false`, so the dark half never runs. navTheme.ts wires it to the section
under the viewport centre, as the design calls for.
Sign In still opens the real OAuth modal — carried over from the page this
replaced rather than dropped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>Ports `_initHero3D` and `_heroCardTex` from `Sapling Landing v5.dc.html`: five product cards painted to offscreen 2D canvases, hung as textured planes in a three.js scene behind the hero copy. `three` and `@types/three` were already dependencies, and the module is dynamically imported so it stays out of the server bundle. Only one of the source's three scenes is ported. `_initHero3D` builds floating panels (heroMode 0), an orbitable knowledge constellation (1) and a shader growth field (2), cross-faded by a weight vector — but the mode switcher that would move between them exists only in `renderVals()` as `setHero0/1/2` and the mBg*/mFg* pill styles. No markup in the file references any of them, so heroMode is pinned at 0 for the life of the page and the other two scenes, several hundred lines of GLSL, are unreachable. Porting them would be porting dead code; they are recoverable from the source if a switcher is ever added. This is the third dead branch in the same file, after `wantDark = false` and the unreferenced `heroWipe`/`heroChar` keyframes. The rig is scroll-driven only: `ease` fades the panels to 8% and spreads each one further along its own x, and scale is deliberately untouched. The card canvas carries a 56px bleed so the drop shadow isn't clipped, which is where the 692/572 plane aspect comes from. Textures repaint once `document.fonts.ready` resolves, because webfont metrics re-wrap the copy. Verified against a real install (the worktree needed `npm_config_engine_strict=false npm ci`, per frontend/.npmrc): tsc exit 0 with no output, eslint exit 0, and the page renders — scramble locks in left-to-right, LOADING counts to 100 zero-padded, and the overlay leaves the tree once faded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deleting the page at `/` orphaned everything only it rendered. Left in place these would read as maintained code — `graph/` in particular ships ~95KB of passing unit tests for a surface no route mounts. Removed: marketing/graph/ (KnowledgeGraphDemo, courseGraphs, layout + the three test files), marketing/surfaces/, FeatureBand, SurfaceBento, featureBands and their tests. HeroCard and SignInModal stay — v5's navbar opens the real OAuth modal, and SignInModal renders HeroCard. e2e/landing-graph.spec.ts goes with the component it pinned. public-seo.spec.ts's SSR guard is retargeted rather than deleted, because its failure mode outlived its selectors: it exists so that reaching for `ssr: false` on a dynamic import can't silently drop the landing's copy out of crawled HTML while every other spec stays green. That risk is sharper on v5, whose visuals are canvas and WebGL — the prose it now asserts is essentially the entire crawlable payload. It deliberately does NOT assert the wordmark or tagline: both are empty server-side because they scramble in on the client, so the h1's aria-label is checked instead. All seven assertions verified against the running server's raw HTML. Suite: 59 files / 423 tests passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
460vh section over a sticky 100vh stage: the engine assembles the graph into a canvas as scroll progresses, four captions cross-fade through windows [0,.22] [.24,.47] [.49,.73] [.72,1.01], and a stage rail ticks along. Clicking swaps to explore mode with an orbitable camera and a concept inspector. The graph instance is published from the engine through the hook rather than rebuilt in the component. `buildGraph()` seeds every node with `Math.random()` and shuffles the reveal order the same way, so a second call produces different node indices — the explore HUD would have described a different concept than the one the canvas highlighted. It is null until the engine mounts, which the component handles. Verified in the browser at 79% act progress: caption 3 at opacity 1 with the other three at 0, tick 3 lit #8FD9A8, no console errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
320vh pinned to a sticky stage: a syllabus document on the left, four destination tiles on the right, and twelve concept chips that fly from a document line to the tile that consumes it as scroll advances. Almost entirely static markup — every moving part is engine-driven through data attributes the measure pass already reads (`data-ingest-fit` for the stage scale, `data-docline` for per-line dimming, `data-chip` with `data-dest`/`data-line` for the flight, `data-tilecount` for the counters, `data-depth` for node-field parallax). Nothing here reads layout. The `.drag-field` layer is `height:0` so the field can never add page height; in this act it holds no draggable clusters, only the parallax motes in the sibling layer. Verified at 55% act progress: counters read +3/+2/+2/+2 with three chips in flight, no console errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nchoring Act III is 340vh pinned to a sticky stage. Three tutor panels sit on the faces of a triangular prism — rotateY(0|120|240deg) translateZ(310px) in a preserve-3d box — and the engine spins it as scroll advances, cross-fading the matching caption. Past the act the pills take over. Note this is NOT the horizontally-clipped 918x348 card track described in the brief that came with the task; the source has a rotating carousel. The eight draggable course clusters ship as data (`lib/landing/ dragClusters.ts`) plus one component, rather than the ~2KB of hand-written inline SVG each that the source repeats eight times. Two bugs fixed along the way, both pre-existing in the v4 engine port and both invisible until a section actually had clusters in it: 1. `sim.ts` reads a ring's halo via `previousElementSibling` and its caption via `nextElementSibling`. Wrapping each node in a <g> broke that adjacency and the sim threw on a null label. Nodes now render flat, strictly glow -> ring -> label. 2. The clusters were anchored once, in page coordinates, straight onto an overlay appended to root. The source instead captures each cluster's offset within its FIELD, parks the overlay inside a `position:fixed` shell, and re-applies `anchor + field.rect` every frame — which is what lets a cluster track its sticky section, and where the up-to-150px scroll drift inside a pinned act comes from. Ported that faithfully as `syncClusters()`. Verified against the original design component served side by side: at 45% through act-tutor the source puts its seven rings at y = 121/177/165/159/ 568/569/608 and all seven on screen. This port now reports 121/177/159/164 and 7 of 7 on screen; before the fix it was 0 of 7, parked 8752px down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deploying with |
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs | frontend-staging | fc36068 | Commit Preview URL Branch Preview URL | Aug 16 2026, 09:58 PM |
Eight bespoke feature cards on two rails drifting in opposite directions, each opening a full-bleed lab panel that expands from the clicked card via the engine's FLIP. The card miniatures are generated, not retyped. Each is a dense decorative animation -- scan sweeps, chip swaps, gauge floats, ring draws, cursor drifts -- where the exact geometry and per-element keyframe delays are the entire effect, and transcribing ~500 lines of that by hand would be slower and less faithful than converting it. `galleryMinis.tsx` is mechanically derived from the source's `<article data-tk>` blocks; regenerate rather than hand-edit. Each rail lists its four cards twice. That is the loop, not a mistake: the marquee translates by exactly half the track width and wraps, so the second copy is already in position when the first leaves. The duplicates are aria-hidden. One deliberate gap: the lab's demo pane. The source mounts a separate `FeatureLab` design component per kind through `<dc-import>` — a distinct 75KB file that the task's file list did not scope in. The pane shows the card's own miniature enlarged instead; layout, FLIP, rail, copy and close are all faithful. Verified: 16 card nodes (8 unique, doubled), lab opens with its 8-item rail, Escape closes it, no console errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The HTML-to-JSX converter only wrapped text runs containing braces or angle brackets, so two bare apostrophes reached the output and tripped react/no-unescaped-entities. Text runs are now wrapped whenever they carry any character JSX or the linter objects to, quotes included. tsc exit 0, eslint exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n nav
Completes the page. The FAQ accordion animates a shared 300px max-height
ceiling rather than a measured height, which is what keeps the transition
interruptible without a layout read. Journal photos move from the source's
`image-slot` authoring component to next/image against the assets already
in public/, keeping the 860/574 aspect so card geometry is unchanged.
The section nav and reading-progress hairline are pure engine surfaces:
React owns only whether the menu is open, and `[data-stem]`,
`[data-jumpdot]`, `[data-jumptick]` and `[data-jumplabel]` are written per
frame.
The final CTA is heading, line, button — nothing more. The source's logic
class still carries an entire "plant a concept" interaction (`plantVal`,
`plantedCount`, `_plant()`, a localStorage-backed grove, a canvas ref) that
no markup references, so it can never run. That is the fourth dead branch
in this file, after `wantDark = false`, the unreferenced heroWipe/heroChar
keyframes, and the hero mode switcher. Not ported.
Verified in the browser: reading progress reaches 99.88% at the foot, the
jump pill's label tracks the section ("Get started" at the bottom), all 8
menu items open, and no console errors. tsc exit 0, eslint exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>The source inlines the hero gradient literal on the newsletter panel, and porting it verbatim reintroduced exactly what #288 removed: that value had been pasted at five sites and drifted at four of them, so it now lives behind `--surface-hero` / `.hero-surface` and HeroCard.test.tsx fails if the literal reappears anywhere under src/. Uses the class instead. `(public)/layout.tsx` wraps the page in `.public-surface`, which carries the token, so it resolves rather than falling back — same pixels, one owner. Full suite green: 59 files / 423 tests. Production build exits 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Once v5 took over `/`, five of its seven nav links pointed at routes that did not exist. Dead links out of the primary navigation of the live landing page is not something to ship, so the companion pages ship with it: /team, /wiki, /gallery, /news, /faq. `/about` already existed with the same copy and is left alone rather than duplicated. These use the WARM paper palette (#f4f1ea / #1a1814 / #1B6C42), not the landing's cool one. That is the design's intent and it matches the app's existing public pages — the two palettes are not a mistake to unify. All six sibling components repeat the same header and footer verbatim, so that lives once in `CompanionShell`, with the shared type styles beside it. Copy is lifted verbatim into `companionContent.ts` (18 arrays) rather than retyped. Two places the port deliberately departs from the source: - The FAQ tracks one open item by a flat index spanning all three groups. The source increments a counter inside its render loop; in React that mutates during render (and eslint's react-hooks/immutability rejects it), so the indices are resolved up front. - Gallery tiles have no artwork. The source fills them with its image-slot authoring component and the import ships no screenshots, so each tile shows its route over the paper surface. Grid, filters, copy and routes are real; dropping images in later is a one-line change. All nine public routes now return 200. Build prerenders all five as static. Suite green at 59 files / 423 tests, tsc 0, eslint 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fills the lab's right pane with the real thing. `FeatureLab.dc.html` is the 987-line design component the landing mounts through `<dc-import>`, and it is genuinely interactive rather than a mock — this ports all eight of its `kind` branches: quiz pick, submit, read the explanation; difficulty is derived from the streak, so it opens MEDIUM then moves to HARD or EASY cards flip on click or space, rate 1/2/3, interval follows notes debounced autosave; SUMMARIZE and EXTRACT run on what you typed guide course + exam selects, generate, topics weighted by weakness rooms swap partner and every ring recolours you/them/both/neither grades every score editable, weighted grade and letter move live calendar upload the syllabus and six dated items appear tutor three modes, scripted branching, sourced replies The source is one class carrying ~20 state keys behind eight `sc-if` branches. Here each demo owns its own state and the switch keys on kind, so moving through the rail unmounts one and mounts the next clean — which is what the shared-state original only appeared to do. Two things worth noting for review: - The gradebook excludes categories with no rows from the denominator. Final carries 25% but has no scores, so counting it as zero would open the demo on an F rather than the 88.7 it should show. - The calendar's leading blanks come from a fixed `new Date(2026, 9, 1)`, not today, so the grid never shifts. `labFlash` is defined in the source's stylesheet and never referenced by its markup — not ported, the fifth such dead branch across these files. Verified in the browser, per demo: quiz renders a verdict after submit, cards advance through all four to the summary, notes extract Eigenvalues and Determinant from the seeded body, guide generates, gradebook recomputes 88.7 -> 75.5 when a pset drops to 10/50 (matches the weighting by hand), calendar reports 6 assignments extracted, tutor answers a picked reply. No console errors on any of the eight. tsc 0, eslint 0, 59 files / 423 tests, build 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 8
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🟡 Minor comments (14)
frontend/src/components/landing-v5/lab/NotesDemo.tsx-57-80 (1)
57-80: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
extractandsummarizetimeouts are untracked.
saveTis cleared on unmount, but the 700 ms timeouts at line 60 and line 71 are not stored and not cleared. If the lab panel closes within that window, the callbacks still run and set state on an unmounted component. Hold both handles in refs and clear them in the existing unmount effect.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/landing-v5/lab/NotesDemo.tsx` around lines 57 - 80, Update the extract and summarize timeout flows to store their setTimeout handles in dedicated refs, and clear both refs in the existing unmount effect alongside saveT. Ensure the callbacks still perform their current state updates when they complete, while preventing them from running after the component unmounts.frontend/src/components/landing-v5/lab/CardsDemo.tsx-46-53 (1)
46-53: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winThe global space handler blocks button activation.
The
keydownlistener callse.preventDefault()for every space press onwindow. A focused button then cannot be activated with space, including "Study again" at line 90. Skip the flip and thepreventDefaultcall when the event target is an interactive element.♿ Proposed fix
const onKey = (e: KeyboardEvent) => { + const el = e.target as HTMLElement | null;+ if (el?.closest('button, input, textarea, select, [contenteditable]')) return; if (e.key === ' ') { e.preventDefault(); flip(); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx` around lines 46 - 53, Update the keydown handler in the useEffect to detect when the event target is an interactive element, such as a button or link, and skip both flip() and preventDefault() for those targets. Preserve the existing global space-to-flip and rate-key behavior for non-interactive targets.frontend/src/components/landing-v5/lab/NotesDemo.tsx-63-63 (1)
63-63: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winThe "no concepts" sentinel is counted as a linked concept.
When no matcher hits,
conceptsreceives one placeholder entry. The footer at line 141 then renders "1 concepts linked to your MA 242 graph." The count is wrong and contradicts the placeholder. Keepconceptsempty and track the empty result separately.🔧 Proposed fix
+ const [ran, setRan] = useState(false); @@ - setConcepts(found.length ? found : [{ name: 'No concepts found', tier: 'unexplored' }]);+ setConcepts(found);+ setRan(true); setBusy(false); @@ + {ran && concepts.length === 0 && !busy && (+ <span style={{ fontSize: 12, color: '`#9AA5A0`' }}>No concepts found.</span>+ )}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/landing-v5/lab/NotesDemo.tsx` at line 63, Update the concept-loading logic around the setConcepts call to keep concepts empty when found has no matches instead of inserting a “No concepts found” sentinel; track the empty-result state separately and use it for the empty-state display while ensuring the footer count reflects only actual linked concepts.frontend/src/lib/landing/engine/dom.ts-49-73 (1)
49-73: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winDo not cache a zero-sized canvas measurement.
cv()cachesw/hfromclientWidth/clientHeighton the first call. The cache is dropped only byinvalidateCanvas, whichmeasure()calls only when the viewport dimensions change (seefrontend/src/lib/landing/engine/measure.tslines 98-104). If the firstcv()call happens while the canvas is not laid out yet, or while its section is hidden, the cache stores0 × 0. Every later frame then draws into a 0-pixel backing store, and the canvas stays blank until the viewport itself changes.Skip the cache write when the measured size is zero, so the next frame re-reads it.
🐛 Proposed fix
if (!c) { const w = canvas.clientWidth; const h = canvas.clientHeight; const dpr = Math.min(window.devicePixelRatio || 1, 1.5); let ctx = rec.__ctx as CanvasRenderingContext2D | undefined; if (!ctx) { ctx = canvas.getContext('2d') as CanvasRenderingContext2D; rec.__ctx = ctx; } c = { ctx, w, h, dpr }; - rec.__cv = c;+ // An unlaid-out or hidden canvas measures 0; don't freeze that in the+ // cache, or the next frames draw into a 0-pixel backing store.+ if (w > 0 && h > 0) rec.__cv = c; const pw = Math.round(w * dpr);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/lib/landing/engine/dom.ts` around lines 49 - 73, Update cv so zero-sized measurements are not stored in the CanvasCache: when clientWidth or clientHeight is zero, avoid assigning the newly measured cache to rec.__cv while preserving the current context and sizing behavior. Ensure subsequent calls re-read dimensions until both measurements are nonzero, while retaining normal caching for valid sizes.frontend/src/components/landing-v5/Journal.tsx-96-101 (1)
96-101: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winEach post card links back to its own section.
href="#newsletter"points at the section that contains the card. The card presents as a link to an article, and activating it does nothing. This PR adds a/newsroute. Point each card at the article, or at/newsuntil per-post routes exist.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/landing-v5/Journal.tsx` around lines 96 - 101, Update the post card anchor in the Journal component to navigate to the new /news route instead of the containing `#newsletter` section, while preserving the existing card styling and behavior.frontend/src/lib/landing/engine/sim.ts-184-203 (1)
184-203: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winLabel binding depends on an SVG sibling order that the markup does not guarantee.
engine/sim.tslocates a node's caption throughring.nextElementSiblingand casts it toSVGTextElement, whileDragFieldrenders that<text>only when a label exists for the node. When a label is missing, the sim binds the following node's glow<circle>and writesx/yonto it every frame, andlybecomesNaN.
frontend/src/lib/landing/engine/sim.ts#L184-L203: checknextElementSibling.tagName === 'text'before the cast, typeSimNode.labelasSVGTextElement | null, and skip the label writes at lines 377-378 when it is null.frontend/src/components/landing-v5/DragField.tsx#L60-L92: alternatively, or in addition, always emit a<text>element per node so the documented glow → ring → label order holds for every node.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/lib/landing/engine/sim.ts` around lines 184 - 203, Prevent incorrect label binding in sim.ts by verifying ring.nextElementSibling is a text element before assigning it, type SimNode.label as SVGTextElement | null, and skip label position updates when it is null; update DragField.tsx to always render a text element per node so the expected glow, ring, label order is maintained.frontend/src/components/landing-v5/Faq.tsx-66-71 (1)
66-71: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win"Read the full FAQ" links to its own section.
href="#faq"targets the section that contains the link, so the control does nothing. This PR adds a/faqroute. Point the link there.🔗 Proposed fix
- <a href="`#faq`" style={{ color: '`#0C5638`', textDecoration: 'underline' }}>Read the full FAQ</a>+ <a href="/faq" style={{ color: '`#0C5638`', textDecoration: 'underline' }}>Read the full FAQ</a>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/landing-v5/Faq.tsx` around lines 66 - 71, Update the “Read the full FAQ” anchor in the FAQ component to navigate to the newly added /faq route instead of the local `#faq` section, preserving its existing styling and text.frontend/src/components/landing-v5/Journal.tsx-184-199 (1)
184-199: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winSet
type="email"and guard the current submit flow.The Journal input is an email address, so use
type="email"for mobile keyboard and browser validation. The currentactions.subscribe, atfrontend/src/components/landing/useLanding.ts:624, only checksemail.includes('@')and sets localsubscribedstate. If this should be a stub, make that intent explicit; otherwise wireonSubscribeto a newsletter endpoint before showing “You’re on the tree”.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/landing-v5/Journal.tsx` around lines 184 - 199, Update the Journal email input in the landing component to use type="email" and preserve the existing onSubscribe flow only as an explicit stub, or wire onSubscribe to the newsletter endpoint and show the subscribed state only after a successful submission. Locate the handler through actions.subscribe/useLanding and ensure invalid emails cannot enter the success state.frontend/src/components/landing-v5/Faq.tsx-79-116 (1)
79-116: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winClosed answers stay in the accessibility tree, and the button has no
aria-controls.The collapsed panel uses
maxHeight: 0,opacity: 0, andoverflow: hidden. None of these remove the content from assistive technology, so a screen reader reads every answer even when the accordion is closed. The button also declaresaria-expandedwithout naming the region it controls.Add
aria-controlswith a stable id, and setaria-hiddenon the panel while it is closed.aria-hiddendoes not disturb themax-heighttransition, because the content holds no focusable elements.♿ Proposed fix
<button onClick={() => onToggle(open ? -1 : i)} aria-expanded={open} + aria-controls={`faq-answer-${i}`} style={{ width: '100%', background: 'none', border: 'none', cursor: 'pointer', padding: '22px 4px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 20, textAlign: 'left' }} ><div + id={`faq-answer-${i}`}+ role="region"+ aria-hidden={!open} style={{🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/landing-v5/Faq.tsx` around lines 79 - 116, Update the FAQ accordion around the toggle button and answer panel to give each panel a stable unique id, reference it from the button via aria-controls, and set aria-hidden based on the open state so closed answers are excluded from the accessibility tree. Preserve the existing visual transition and aria-expanded behavior.frontend/src/components/landing-v5/Navbar.tsx-199-235 (1)
199-235: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
role="menu"is applied to navigation disclosures whose children are not menu items. Both new navigation panels declarerole="menu", but their children are plainbuttonelements andnext/linkanchors. The ARIA menu pattern permits onlymenuitem,menuitemradio, andmenuitemcheckboxchildren, so screen readers report both groups incorrectly. Both panels are page navigation, not application menus, so the correct fix in each place is to remove the role rather than to addmenuitemroles.
frontend/src/components/landing-v5/Navbar.tsx#L199-L235: removerole="menu"from the compact panel, and removearia-haspopup="menu"from the trigger at line 120. Keeparia-expanded.frontend/src/components/landing-v5/Closing.tsx#L154-L182: removerole="menu"from the jump panel, and removearia-haspopup="menu"from the pill at line 188. The enclosingnavwitharia-label="Page sections"already carries the correct semantics.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/landing-v5/Navbar.tsx` around lines 199 - 235, The navigation disclosures use menu semantics with ordinary links and buttons; remove those menu roles and triggers’ menu-haspopup attributes. In frontend/src/components/landing-v5/Navbar.tsx lines 199-235, remove role="menu" from the compact panel and remove aria-haspopup="menu" from its trigger while retaining aria-expanded. In frontend/src/components/landing-v5/Closing.tsx lines 154-182, remove role="menu" from the jump panel and remove aria-haspopup="menu" from the pill trigger; keep the enclosing navigation semantics and existing expansion state.frontend/src/app/(public)/page.tsx-115-116 (1)
115-116: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winClear the deferred
openGaltimers on unmount.
actions.openGalschedulesactions.enterExplore/actions.openGal260 ms later fromonQuizandonLearn. If the page unmounts before those timers fire,openGalruns after cleanup and writesdocument.body.style.overflow = 'hidden'after the landing cleanup reset that overflow. Keep the timer refs inuseLandingso cleanup can callclearTimeoutfor them.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/app/`(public)/page.tsx around lines 115 - 116, Update useLanding and the onQuiz/onLearn handlers to store each 260 ms openGal timeout in refs, rather than discarding the timer IDs. In the landing cleanup, clear both deferred timers before resetting document.body overflow, preventing actions.openGal from running after unmount.frontend/src/components/landing-v5/ActTutor.tsx-93-109 (1)
93-109: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winExpose the selected tutor mode, and keep the hidden pills out of the tab order.
Two gaps in this control group:
- The pills are a mode selector, but the selected state is carried only by
backgroundandcolor. Assistive technology cannot report which mode is active. Addaria-pressed={tutorMode === i}.- The container starts at
opacity: 0withpointerEvents: 'none', and the engine fades it in later. The buttons stay focusable while invisible, so a keyboard user can focus a control that is not visible. Mirror the visual state in the tab order.Also add
type="button", which the other sections in this PR set consistently.♻️ Proposed fix for the pill group
- {CAPTIONS.map(([tag], i) => (- <button- key={tag}- onClick={() => onSetMode(i)}+ {CAPTIONS.map(([tag], i) => (+ <button+ key={tag}+ type="button"+ aria-pressed={tutorMode === i}+ onClick={() => onSetMode(i)}The engine already writes
opacityon the container. Have it also clearvisibilityor theinertattribute so the pills leave the tab order until the handover:- <div data-tutor-pills="1" style={{ display: 'flex', gap: 8, marginTop: 8, opacity: 0, pointerEvents: 'none', transition: 'opacity 400ms ease' }}>+ <div data-tutor-pills="1" inert style={{ display: 'flex', gap: 8, marginTop: 8, opacity: 0, pointerEvents: 'none', transition: 'opacity 400ms ease' }}>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/landing-v5/ActTutor.tsx` around lines 93 - 109, Update the tutor pill group in ActTutor’s CAPTIONS map to add type="button" and aria-pressed={tutorMode === i} to each button. Keep the hidden container’s buttons out of the tab order while the engine-controlled opacity is zero by mirroring its hidden state with inert or an equivalent visibility-based mechanism, and ensure the existing handover clears that state when the pills become visible.frontend/src/components/landing-v5/ActGraph.tsx-333-340 (1)
333-340: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winAdd an Escape key exit for explore mode.
Explore mode covers the act and closes the navbar, but
useLandingonly handlesEscapefor gallery and jump controls. If explore mode is active, callonExitExplorefrom the key handler so keyboard-only users can leave the mode safely.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/landing-v5/ActGraph.tsx` around lines 333 - 340, Update the existing keyboard handler in useLanding to detect Escape while explore mode is active and call onExitExplore. Preserve the current Escape behavior for gallery and jump controls, and ensure the explore-mode exit path is available to keyboard-only users.frontend/src/lib/landing/hero3d/index.ts-175-189 (1)
175-189: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winRelease the WebGL context in
stop().
stop()already disposes three.js resources and cancels the render loop.WebGLRenderer.dispose()does not release the WebGL context, and this rig creates a new context on its canvas each mount. Addrenderer.forceContextLoss()afterrenderer.dispose()so repeated navigation can reclaim the browser contexts sooner.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/lib/landing/hero3d/index.ts` around lines 175 - 189, Update the returned stop() method to call renderer.forceContextLoss() immediately after renderer.dispose(), ensuring the WebGL context is released during cleanup while preserving the existing resource disposal and animation cancellation behavior.
🧹 Nitpick comments (14)
frontend/src/components/landing-v5/lab/LabShell.tsx (1)
51-84: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Segmentedis unused; two demos duplicate it inline.The comment states that the tutor and calendar demos use this control. They do not import it.
CalendarDemo.tsxlines 61-76 andTutorDemo.tsxlines 65-80 each contain an inline copy of the same markup and style logic. UseSegmentedin both demos, or delete it and correct the comment.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/landing-v5/lab/LabShell.tsx` around lines 51 - 84, Resolve the unused Segmented component by replacing the duplicated inline segmented-control markup in CalendarDemo and TutorDemo with the shared Segmented component, passing each demo’s options, current value, and change handler. Preserve the existing labels, selection behavior, and styling, and keep the component’s public API unchanged.frontend/src/components/landing-v5/lab/QuizDemo.tsx (1)
33-35: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueDifficulty badge changes while the same question is shown.
submitupdatesstreakbefore it setsphasetoreview.qidoes not change. The badge therefore flips from MEDIUM to HARD or EASY while the user still reads question 1 and its explanation. Derive the badge from the difficulty that applied when the question was served, for example fromanswers.lengthcaptured at question start.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/landing-v5/lab/QuizDemo.tsx` around lines 33 - 35, Update the difficulty derivation in QuizDemo so the badge remains tied to the difficulty assigned when the current question was served, rather than recomputing from the mutable streak during review. Capture or derive the question-start difficulty using the existing answer/question state (such as answers.length), and keep the displayed difficulty stable until qi advances.frontend/src/components/landing-v5/lab/GradesDemo.tsx (1)
62-65: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueRow percentage is unclamped while the weighted grade is clamped.
Line 25 clamps the score into
[0, r.possible]. Line 64 uses the unclampedraw. If the user types70in a 50-point row, the row shows140%while the category bar and the overall grade cap at 100%. Apply the same clamp in both places.♻️ Proposed fix
const raw = parseFloat(earned[i]); - const pct = isNaN(raw) ? null : Math.round((raw / r.possible) * 100);+ const pct = isNaN(raw) ? null : Math.round((Math.max(0, Math.min(raw, r.possible)) / r.possible) * 100);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/landing-v5/lab/GradesDemo.tsx` around lines 62 - 65, Clamp the parsed score used in the row percentage calculation within the same [0, r.possible] bounds already applied to the weighted grade. Update the percentage logic in the GB_ROWS map callback, preserving the existing null handling and color thresholds while ensuring values above or below the row limits cannot produce percentages outside 0–100.frontend/src/components/landing-v5/lab/labData.ts (1)
13-13: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRemove duplicated
TIERfromlabData.ts.
labData.tsexportsTIER, but the lab demos importLAB_TIERand render withlabData.tscontent. The four hex values and keys matchLAB_TIER, so two copies of this swatch set can drift. DropTIERfromlabData.tsand keep usingLAB_TIER.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/landing-v5/lab/labData.ts` at line 13, Remove the exported TIER constant from labData.ts, since it duplicates the matching LAB_TIER swatch set. Update any references in labData.ts to use LAB_TIER instead, preserving the existing keys and color values.frontend/src/lib/landing/engine/graph.ts (1)
122-127: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winReplace the random-comparator shuffle with Fisher–Yates.
sort(() => Math.random() - 0.5)uses an inconsistent comparator. The resulting permutation is biased and depends on the engine's sort implementation, so many nodes keep positions close to their original index. That works against the documented intent of an organic reveal order.♻️ Proposed fix
- // Shuffle the reveal order so the graph assembles organically.- const order = nodes.map((_, i) => i).sort(() => Math.random() - 0.5);+ // Shuffle the reveal order so the graph assembles organically.+ const order = nodes.map((_, i) => i);+ for (let i = order.length - 1; i > 0; i--) {+ const j = Math.floor(Math.random() * (i + 1));+ [order[i], order[j]] = [order[j], order[i]];+ }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/lib/landing/engine/graph.ts` around lines 122 - 127, Replace the random-comparator sort in the reveal-order logic with an unbiased Fisher–Yates shuffle of the index array. Keep the existing spawnAt and colorAt calculations and node ordering behavior after shuffling unchanged.frontend/src/app/(public)/wiki/page.tsx (1)
31-39: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMake
ledeoptional so the privacy section does not render an empty paragraph.The privacy section passes
lede="".Sectionalways renders the<p>, so the page emits an empty paragraph with0 0 20pxmargins. That adds dead space and an empty node in the accessibility tree.♻️ Proposed fix
-function Section({ id, title, lede, children }: { id: string; title: string; lede: string; children?: React.ReactNode }) {+function Section({ id, title, lede, children }: { id: string; title: string; lede?: string; children?: React.ReactNode }) { return ( <section style={{ marginTop: 56, scrollMarginTop: 110 }} id={id}> <SectionHeading>{title}</SectionHeading> - <p style={{ margin: '0 0 20px', fontFamily: SERIF, fontSize: 15, lineHeight: 1.65, color: '`#3f3b31`' }}>{lede}</p>+ {lede ? (+ <p style={{ margin: '0 0 20px', fontFamily: SERIF, fontSize: 15, lineHeight: 1.65, color: '`#3f3b31`' }}>{lede}</p>+ ) : null} {children}Then drop
lede=""at line 147.Also applies to: 147-149
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/app/`(public)/wiki/page.tsx around lines 31 - 39, Make the lede prop optional in Section and render the paragraph only when lede has content, so the privacy section does not produce an empty element or spacing. Remove the lede="" argument from the privacy Section usage while preserving existing lede rendering for other sections.frontend/src/lib/landing/engine/marquee.ts (1)
136-144: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winAvoid the per-frame layout read in
update().
update()runs from the engine tick on every frame the gallery is on screen. Line 136 readswrap.clientWidthon each of those frames, and line 156 writestrack.style.transform. The write dirties style, so the next frame's read forces a style recalculation and layout. That contradicts the engine invariant documented infrontend/src/lib/landing/engine/index.tslines 4-8, which states the tick never reads layout.Observe the container width instead, and re-measure only when it changes.
♻️ Proposed approach
- // measure one card set only when the container width changed- if (!m.setW || m.measuredW !== wrap.clientWidth) {+ // measure one card set only when a ResizeObserver marked it stale+ if (!m.setW) {Then in
bind, mark the state stale from an observer and add its teardown tocleanups:constro=newResizeObserver(()=>{m.setW=0;});if(wrap)ro.observe(wrap);cleanups.push(()=>ro.disconnect());🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/lib/landing/engine/marquee.ts` around lines 136 - 144, Remove the per-frame wrap.clientWidth read from update() and move width-change detection to bind using a ResizeObserver on wrap. Have the observer mark m.setW stale, register ro.disconnect in cleanups, and preserve the existing measurement logic when update() sees stale state without reading layout every tick.frontend/src/components/landing-v5/FeatureLab.tsx (1)
94-119: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMark the active rail button for assistive technology.
onchanges only the colors. Addaria-current="true"on the selected button so the active tool is conveyed without color.♿ Proposed change
data-rail={i} type="button" + aria-current={on || undefined} onClick={() => onPick(i)}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/landing-v5/FeatureLab.tsx` around lines 94 - 119, Add an aria-current="true" attribute to the selected rail button rendered in the GAL.map callback, using the existing on condition so assistive technology identifies the active tool without relying on color.frontend/src/components/landing-v5/Gallery.tsx (1)
71-73: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueThe wrapper duplicates the miniature's own container.
The
<div>here setsheight: 166,borderRadius: 13,overflow: hidden, the same background, and the same border. Every entry inGALLERY_MINISopens with a<div>carrying that identical style set (seegalleryMinis.tsxline 16). One of the two layers is redundant. Because the minis are generated, drop the wrapper here rather than editing the generated file.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/landing-v5/Gallery.tsx` around lines 71 - 73, Remove the redundant wrapper div around GALLERY_MINIS[i] in Gallery, rendering the miniature directly while preserving its existing placement and aria-hidden behavior. Do not modify the generated galleryMinis.tsx entries.frontend/src/lib/landing/engine/flip.ts (1)
24-25: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
FlipState.ranis never read.
armFlipsetsran = falseandflipCloseresets it, but no code in this module or infrontend/src/components/landing/useLanding.tsreads it. The consumer tracks the same guard with its ownflipRanref (seeuseLanding.tslines 158, 552, 574-575). Two guards for one invariant can drift. Either removeranfromFlipStateor makeflipOpenown the guard and deleteflipRanfrom the hook.Also applies to: 78-78
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/lib/landing/engine/flip.ts` around lines 24 - 25, Remove the unused FlipState.ran field and its assignments in armFlip and flipClose, since useLanding already owns this guard through flipRan. Keep the existing flipRan-based behavior unchanged and avoid maintaining duplicate state for the same invariant.frontend/src/lib/landing/engine/hero.ts (1)
99-136: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valuePoints behind the camera reach the link pass with a degenerate scale.
sc = FL / (FL + z)is computed for every node. Rotation mixesoxintoz, andoxreaches about ±740 whileozreaches about ±340, sozcan pass -1000. Atz === -FLthe divide producesInfinity, and below-FLit produces a negative scale.The point pass guards this at line 139 with
p.z > -FL. The link pass at lines 116-136 does not. A node withInfinityscale givesreach === Infinity, so thebreakat line 122 never fires for that row and the inner loop degrades to a full scan.Filter the projection once, so both passes share the guard.
♻️ Proposed change
const proj = nodes .map((n) => { ... }) + // drop anything at or behind the projection plane: `sc` is not finite+ // there, which breaks both the link reach and the point radius+ .filter((p) => p.z > -FL) .sort((a, b) => b.z - a.z);The
p.z > -FLtest at line 139 then becomes redundant and can go.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/lib/landing/engine/hero.ts` around lines 99 - 136, Filter the projected nodes immediately after the `proj` mapping so only entries with `p.z > -FL` remain before sorting and running the link pass. Reuse this filtered collection for both link rendering and the later point-rendering pass, then remove the now-redundant `p.z > -FL` guard in the point pass.frontend/src/components/landing/useLanding.ts (1)
378-392: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueThe reveal scan polls every 900ms for the page lifetime.
scan()re-queries[data-reveal]across the whole landing root twice per second, forever, to catch elements that mount later. AMutationObserveronrootwould react to the same additions without the standing timer. This is a nice-to-have; the current cost is onequerySelectorAllper tick.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/landing/useLanding.ts` around lines 378 - 392, Replace the lifetime polling interval in the reveal setup around scan with a MutationObserver watching root for added elements, invoking scan when matching content mounts later. Keep the initial scan, existing seen/io behavior, and cleanup, but disconnect the mutation observer instead of clearing an interval.frontend/src/lib/landing/engine/sim.ts (1)
282-299: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value
upignorespointerId, and the window listeners are registered once per cluster.Two small points in
bindDrag:
updoes not comparee.pointerIdwithf.drag.id. A second pointer that lifts cancels the active drag.onMovedoes compare the id, so the handlers are inconsistent.onMoveandupare added towindowinside the per-cluster loop. With N clusters in one group, every window pointer event runs N identical handlers. Bind the window listeners once per group instead.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/lib/landing/engine/sim.ts` around lines 282 - 299, Update bindDrag so up ignores pointer events whose pointerId does not match f.drag.id, matching onMove’s active-pointer validation. Move the window-level pointermove, pointerup, and pointercancel listener registration out of the per-cluster binding loop and register each once per group, while keeping the per-SVG listeners on the individual cluster SVG.frontend/src/components/landing-v5/Closing.tsx (1)
75-92: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winShare the companion-page list, and make the footer icon decorative.
Two points:
FOOTER_LINKSrepeatsPAGESinNavbar.tsx(lines 26-33) with the same six label and href pairs. Both files are new in this PR, so the two lists will drift the first time a companion page is added or renamed. Export one list and import it in both places.- Line 91 sets
alt="Sapling"on the icon, and the adjacent text already reads "Sapling · © 2026". A screen reader announces the name twice.Navbar.tsxline 100 already usesalt=""for the same icon beside the same wordmark.♻️ Proposed fix
- <Image src="/sapling-icon.svg" alt="Sapling" width={20} height={20} />+ <Image src="/sapling-icon.svg" alt="" width={20} height={20} />🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/landing-v5/Closing.tsx` around lines 75 - 92, Share the companion-page link list currently duplicated as FOOTER_LINKS in SiteFooter and PAGES in Navbar by exporting one canonical list and importing it in both components, preserving all six label/href pairs. Update the footer Image in SiteFooter to use an empty alt value so the adjacent Sapling text is announced only once.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/app/globals.css`:
- Around line 1957-2005: Rename the camelCase keyframe declarations in the
affected stylesheet to kebab-case, including symbols such as s1Stem,
saplingBlob, introOrbit, popHold, and the additional reported ranges. Update
every corresponding animation and animation-name reference to the new kebab-case
identifiers, preserving all animation behavior.
In `@frontend/src/components/landing-v5/FeatureLab.tsx`:
- Around line 41-50: Update the modal container identified by panelRef in
FeatureLab to add role="dialog", aria-modal="true", and an accessible name. On
mount, move focus into the panel; while open, trap Tab navigation within it so
focus cannot reach the page behind the overlay. Capture and restore focus to the
triggering element stored in FlipState.el when the panel closes, while
preserving the existing Escape close behavior.
In `@frontend/src/components/landing-v5/Gallery.tsx`:
- Around line 62-85: Update Card so non-ghost cards are keyboard-focusable and
activatable, using the existing track click-delegation contract, while ghost
duplicates remain excluded from the tab order and accessibility tree. Add the
corresponding keyboard activation handling to the card or track using the
existing feature-lab target behavior, and correct the nearby doc comment to
match the implemented accessibility behavior. Add a visible
.ld-galcard:focus-visible style in globals.css.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 37-44: Update CardsDemo.tsx at lines 37-44 so the 300 ms timeout
in the rate handler is stored in a useRef, cleared before scheduling each new
rating and during unmount, and remove the ineffective returned cleanup. Update
NotesDemo.tsx at lines 57-80 so the 700 ms extract and summarize timeouts use
refs and are cleared in the existing unmount effect alongside saveT.
In `@frontend/src/components/landing-v5/lab/GuideDemo.tsx`:
- Around line 21-24: Update the RECENT entries’ pick values to exactly match an
existing exam option in the corresponding GUIDES course fixture, preserving
openRecent’s assignment and keeping the select value consistent with the guide
heading.
In `@frontend/src/components/landing-v5/lab/QuizDemo.tsx`:
- Around line 104-124: Fix the quiz option accessibility semantics in the
q.options mapping: either make each button a radio with role="radio" and
aria-checked bound to isPicked while retaining the radiogroup, or remove the
radiogroup role and expose isPicked through aria-pressed. Preserve the existing
selection and reviewing behavior in the option onClick handler.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 213-224: Update the hero canvas useEffect around startHeroCanvas
to track a cancelled/unmounted flag. Set it during cleanup, check it after the
asynchronous hero module import resolves before starting the canvas, and stop
the returned controller when appropriate so no animation loop or resize listener
remains after unmount.
In `@frontend/src/lib/landing/engine/sim.ts`:
- Around line 423-430: Update the initialization and destroy flow around
ensureInit and destroy to retain each cluster element’s original parent before
moving it into the overlay. During destroy, restore every cluster to that
parent, clear the inline styles applied by ensureInit, then remove the overlay
and reset state so a later ensureInit can rebuild the field without disrupting
React-owned nodes.
---
Minor comments:
In `@frontend/src/app/`(public)/page.tsx:
- Around line 115-116: Update useLanding and the onQuiz/onLearn handlers to
store each 260 ms openGal timeout in refs, rather than discarding the timer IDs.
In the landing cleanup, clear both deferred timers before resetting
document.body overflow, preventing actions.openGal from running after unmount.
In `@frontend/src/components/landing-v5/ActGraph.tsx`:
- Around line 333-340: Update the existing keyboard handler in useLanding to
detect Escape while explore mode is active and call onExitExplore. Preserve the
current Escape behavior for gallery and jump controls, and ensure the
explore-mode exit path is available to keyboard-only users.
In `@frontend/src/components/landing-v5/ActTutor.tsx`:
- Around line 93-109: Update the tutor pill group in ActTutor’s CAPTIONS map to
add type="button" and aria-pressed={tutorMode === i} to each button. Keep the
hidden container’s buttons out of the tab order while the engine-controlled
opacity is zero by mirroring its hidden state with inert or an equivalent
visibility-based mechanism, and ensure the existing handover clears that state
when the pills become visible.
In `@frontend/src/components/landing-v5/Faq.tsx`:
- Around line 66-71: Update the “Read the full FAQ” anchor in the FAQ component
to navigate to the newly added /faq route instead of the local `#faq` section,
preserving its existing styling and text.
- Around line 79-116: Update the FAQ accordion around the toggle button and
answer panel to give each panel a stable unique id, reference it from the button
via aria-controls, and set aria-hidden based on the open state so closed answers
are excluded from the accessibility tree. Preserve the existing visual
transition and aria-expanded behavior.
In `@frontend/src/components/landing-v5/Journal.tsx`:
- Around line 96-101: Update the post card anchor in the Journal component to
navigate to the new /news route instead of the containing `#newsletter` section,
while preserving the existing card styling and behavior.
- Around line 184-199: Update the Journal email input in the landing component
to use type="email" and preserve the existing onSubscribe flow only as an
explicit stub, or wire onSubscribe to the newsletter endpoint and show the
subscribed state only after a successful submission. Locate the handler through
actions.subscribe/useLanding and ensure invalid emails cannot enter the success
state.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 46-53: Update the keydown handler in the useEffect to detect when
the event target is an interactive element, such as a button or link, and skip
both flip() and preventDefault() for those targets. Preserve the existing global
space-to-flip and rate-key behavior for non-interactive targets.
In `@frontend/src/components/landing-v5/lab/NotesDemo.tsx`:
- Around line 57-80: Update the extract and summarize timeout flows to store
their setTimeout handles in dedicated refs, and clear both refs in the existing
unmount effect alongside saveT. Ensure the callbacks still perform their current
state updates when they complete, while preventing them from running after the
component unmounts.
- Line 63: Update the concept-loading logic around the setConcepts call to keep
concepts empty when found has no matches instead of inserting a “No concepts
found” sentinel; track the empty-result state separately and use it for the
empty-state display while ensuring the footer count reflects only actual linked
concepts.
In `@frontend/src/components/landing-v5/Navbar.tsx`:
- Around line 199-235: The navigation disclosures use menu semantics with
ordinary links and buttons; remove those menu roles and triggers’ menu-haspopup
attributes. In frontend/src/components/landing-v5/Navbar.tsx lines 199-235,
remove role="menu" from the compact panel and remove aria-haspopup="menu" from
its trigger while retaining aria-expanded. In
frontend/src/components/landing-v5/Closing.tsx lines 154-182, remove role="menu"
from the jump panel and remove aria-haspopup="menu" from the pill trigger; keep
the enclosing navigation semantics and existing expansion state.
In `@frontend/src/lib/landing/engine/dom.ts`:
- Around line 49-73: Update cv so zero-sized measurements are not stored in the
CanvasCache: when clientWidth or clientHeight is zero, avoid assigning the newly
measured cache to rec.__cv while preserving the current context and sizing
behavior. Ensure subsequent calls re-read dimensions until both measurements are
nonzero, while retaining normal caching for valid sizes.
In `@frontend/src/lib/landing/engine/sim.ts`:
- Around line 184-203: Prevent incorrect label binding in sim.ts by verifying
ring.nextElementSibling is a text element before assigning it, type
SimNode.label as SVGTextElement | null, and skip label position updates when it
is null; update DragField.tsx to always render a text element per node so the
expected glow, ring, label order is maintained.
In `@frontend/src/lib/landing/hero3d/index.ts`:
- Around line 175-189: Update the returned stop() method to call
renderer.forceContextLoss() immediately after renderer.dispose(), ensuring the
WebGL context is released during cleanup while preserving the existing resource
disposal and animation cancellation behavior.
---
Nitpick comments:
In `@frontend/src/app/`(public)/wiki/page.tsx:
- Around line 31-39: Make the lede prop optional in Section and render the
paragraph only when lede has content, so the privacy section does not produce an
empty element or spacing. Remove the lede="" argument from the privacy Section
usage while preserving existing lede rendering for other sections.
In `@frontend/src/components/landing-v5/Closing.tsx`:
- Around line 75-92: Share the companion-page link list currently duplicated as
FOOTER_LINKS in SiteFooter and PAGES in Navbar by exporting one canonical list
and importing it in both components, preserving all six label/href pairs. Update
the footer Image in SiteFooter to use an empty alt value so the adjacent Sapling
text is announced only once.
In `@frontend/src/components/landing-v5/FeatureLab.tsx`:
- Around line 94-119: Add an aria-current="true" attribute to the selected rail
button rendered in the GAL.map callback, using the existing on condition so
assistive technology identifies the active tool without relying on color.
In `@frontend/src/components/landing-v5/Gallery.tsx`:
- Around line 71-73: Remove the redundant wrapper div around GALLERY_MINIS[i] in
Gallery, rendering the miniature directly while preserving its existing
placement and aria-hidden behavior. Do not modify the generated galleryMinis.tsx
entries.
In `@frontend/src/components/landing-v5/lab/GradesDemo.tsx`:
- Around line 62-65: Clamp the parsed score used in the row percentage
calculation within the same [0, r.possible] bounds already applied to the
weighted grade. Update the percentage logic in the GB_ROWS map callback,
preserving the existing null handling and color thresholds while ensuring values
above or below the row limits cannot produce percentages outside 0–100.
In `@frontend/src/components/landing-v5/lab/labData.ts`:
- Line 13: Remove the exported TIER constant from labData.ts, since it
duplicates the matching LAB_TIER swatch set. Update any references in labData.ts
to use LAB_TIER instead, preserving the existing keys and color values.
In `@frontend/src/components/landing-v5/lab/LabShell.tsx`:
- Around line 51-84: Resolve the unused Segmented component by replacing the
duplicated inline segmented-control markup in CalendarDemo and TutorDemo with
the shared Segmented component, passing each demo’s options, current value, and
change handler. Preserve the existing labels, selection behavior, and styling,
and keep the component’s public API unchanged.
In `@frontend/src/components/landing-v5/lab/QuizDemo.tsx`:
- Around line 33-35: Update the difficulty derivation in QuizDemo so the badge
remains tied to the difficulty assigned when the current question was served,
rather than recomputing from the mutable streak during review. Capture or derive
the question-start difficulty using the existing answer/question state (such as
answers.length), and keep the displayed difficulty stable until qi advances.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 378-392: Replace the lifetime polling interval in the reveal setup
around scan with a MutationObserver watching root for added elements, invoking
scan when matching content mounts later. Keep the initial scan, existing seen/io
behavior, and cleanup, but disconnect the mutation observer instead of clearing
an interval.
In `@frontend/src/lib/landing/engine/flip.ts`:
- Around line 24-25: Remove the unused FlipState.ran field and its assignments
in armFlip and flipClose, since useLanding already owns this guard through
flipRan. Keep the existing flipRan-based behavior unchanged and avoid
maintaining duplicate state for the same invariant.
In `@frontend/src/lib/landing/engine/graph.ts`:
- Around line 122-127: Replace the random-comparator sort in the reveal-order
logic with an unbiased Fisher–Yates shuffle of the index array. Keep the
existing spawnAt and colorAt calculations and node ordering behavior after
shuffling unchanged.
In `@frontend/src/lib/landing/engine/hero.ts`:
- Around line 99-136: Filter the projected nodes immediately after the `proj`
mapping so only entries with `p.z > -FL` remain before sorting and running the
link pass. Reuse this filtered collection for both link rendering and the later
point-rendering pass, then remove the now-redundant `p.z > -FL` guard in the
point pass.
In `@frontend/src/lib/landing/engine/marquee.ts`:
- Around line 136-144: Remove the per-frame wrap.clientWidth read from update()
and move width-change detection to bind using a ResizeObserver on wrap. Have the
observer mark m.setW stale, register ro.disconnect in cleanups, and preserve the
existing measurement logic when update() sees stale state without reading layout
every tick.
In `@frontend/src/lib/landing/engine/sim.ts`:
- Around line 282-299: Update bindDrag so up ignores pointer events whose
pointerId does not match f.drag.id, matching onMove’s active-pointer validation.
Move the window-level pointermove, pointerup, and pointercancel listener
registration out of the per-cluster binding loop and register each once per
group, while keeping the per-SVG listeners on the individual cluster SVG.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 865084a7-2f4d-472c-accd-6f67530e2a35
⛔ Files ignored due to path filters (3)
frontend/public/journal-ai-homework.pngis excluded by!**/*.pngfrontend/public/journal-founding.pngis excluded by!**/*.pngfrontend/public/kofi-symbol.pngis excluded by!**/*.png
📒 Files selected for processing (75)
frontend/e2e/landing-graph.spec.tsfrontend/e2e/public-seo.spec.tsfrontend/eslint-suppressions.jsonfrontend/src/app/(public)/faq/page.tsxfrontend/src/app/(public)/gallery/page.tsxfrontend/src/app/(public)/news/page.tsxfrontend/src/app/(public)/page.tsxfrontend/src/app/(public)/team/page.tsxfrontend/src/app/(public)/wiki/page.tsxfrontend/src/app/globals.cssfrontend/src/components/companion/CompanionShell.tsxfrontend/src/components/companion/primitives.tsxfrontend/src/components/landing-v5/ActGraph.tsxfrontend/src/components/landing-v5/ActIngest.tsxfrontend/src/components/landing-v5/ActTutor.tsxfrontend/src/components/landing-v5/Closing.tsxfrontend/src/components/landing-v5/DragField.tsxfrontend/src/components/landing-v5/Faq.tsxfrontend/src/components/landing-v5/FeatureLab.tsxfrontend/src/components/landing-v5/Gallery.tsxfrontend/src/components/landing-v5/Hero.tsxfrontend/src/components/landing-v5/IntroOverlay.tsxfrontend/src/components/landing-v5/Journal.tsxfrontend/src/components/landing-v5/Navbar.tsxfrontend/src/components/landing-v5/galleryMinis.tsxfrontend/src/components/landing-v5/lab/CalendarDemo.tsxfrontend/src/components/landing-v5/lab/CardsDemo.tsxfrontend/src/components/landing-v5/lab/GradesDemo.tsxfrontend/src/components/landing-v5/lab/GuideDemo.tsxfrontend/src/components/landing-v5/lab/LabShell.tsxfrontend/src/components/landing-v5/lab/NotesDemo.tsxfrontend/src/components/landing-v5/lab/QuizDemo.tsxfrontend/src/components/landing-v5/lab/RoomsDemo.tsxfrontend/src/components/landing-v5/lab/TutorDemo.tsxfrontend/src/components/landing-v5/lab/index.tsxfrontend/src/components/landing-v5/lab/labData.tsfrontend/src/components/landing-v5/navTheme.tsfrontend/src/components/landing/useLanding.tsfrontend/src/components/marketing/FeatureBand.test.tsxfrontend/src/components/marketing/FeatureBand.tsxfrontend/src/components/marketing/SurfaceBento.test.tsxfrontend/src/components/marketing/SurfaceBento.tsxfrontend/src/components/marketing/featureBands.tsxfrontend/src/components/marketing/graph/KnowledgeGraphDemo.test.tsxfrontend/src/components/marketing/graph/KnowledgeGraphDemo.tsxfrontend/src/components/marketing/graph/courseGraphs.test.tsfrontend/src/components/marketing/graph/courseGraphs.tsfrontend/src/components/marketing/graph/layout.test.tsfrontend/src/components/marketing/graph/layout.tsfrontend/src/components/marketing/surfaces/GradebookSurface.tsxfrontend/src/components/marketing/surfaces/NotesSurface.tsxfrontend/src/components/marketing/surfaces/QuizSurface.tsxfrontend/src/components/marketing/surfaces/ReviewSurface.tsxfrontend/src/components/marketing/surfaces/RoomsSurface.tsxfrontend/src/components/marketing/surfaces/Surface.tsxfrontend/src/components/marketing/surfaces/TutorSurface.tsxfrontend/src/components/marketing/surfaces/UploadSurface.tsxfrontend/src/lib/landing/color.tsfrontend/src/lib/landing/companionContent.tsfrontend/src/lib/landing/content.tsfrontend/src/lib/landing/course.tsfrontend/src/lib/landing/dragClusters.tsfrontend/src/lib/landing/engine/ambient.tsfrontend/src/lib/landing/engine/dom.tsfrontend/src/lib/landing/engine/flip.tsfrontend/src/lib/landing/engine/graph.tsfrontend/src/lib/landing/engine/graphView.tsfrontend/src/lib/landing/engine/hero.tsfrontend/src/lib/landing/engine/index.tsfrontend/src/lib/landing/engine/marquee.tsfrontend/src/lib/landing/engine/measure.tsfrontend/src/lib/landing/engine/plant.tsfrontend/src/lib/landing/engine/sim.tsfrontend/src/lib/landing/hero3d/cardTexture.tsfrontend/src/lib/landing/hero3d/index.ts
💤 Files with no reviewable changes (20)
- frontend/src/components/marketing/surfaces/RoomsSurface.tsx
- frontend/src/components/marketing/FeatureBand.tsx
- frontend/src/components/marketing/FeatureBand.test.tsx
- frontend/src/components/marketing/graph/layout.test.ts
- frontend/src/components/marketing/surfaces/TutorSurface.tsx
- frontend/src/components/marketing/graph/KnowledgeGraphDemo.tsx
- frontend/src/components/marketing/graph/courseGraphs.test.ts
- frontend/src/components/marketing/surfaces/NotesSurface.tsx
- frontend/src/components/marketing/SurfaceBento.tsx
- frontend/src/components/marketing/featureBands.tsx
- frontend/src/components/marketing/surfaces/GradebookSurface.tsx
- frontend/src/components/marketing/surfaces/UploadSurface.tsx
- frontend/src/components/marketing/surfaces/Surface.tsx
- frontend/src/components/marketing/graph/courseGraphs.ts
- frontend/e2e/landing-graph.spec.ts
- frontend/src/components/marketing/surfaces/QuizSurface.tsx
- frontend/src/components/marketing/graph/layout.ts
- frontend/src/components/marketing/graph/KnowledgeGraphDemo.test.tsx
- frontend/src/components/marketing/SurfaceBento.test.tsx
- frontend/src/components/marketing/surfaces/ReviewSurface.tsx
| @keyframes s1Stem { 0%,15% { stroke-dashoffset:27; } 34%,100% { stroke-dashoffset:0; } } | ||
| @keyframes s1LeafLo { | ||
| 0%,27% { transform:scale(0.05) rotate(9deg); opacity:0; animation-timing-function:cubic-bezier(0.16,0.9,0.3,1); } | ||
| 36% { transform:scale(1.03) rotate(5deg); opacity:1; animation-timing-function:cubic-bezier(0.5,0,0.85,0.3); } | ||
| 42% { transform:scale(1) rotate(-4.5deg); opacity:1; animation-timing-function:cubic-bezier(0.4,0,0.6,1); } | ||
| 46% { transform:scale(1) rotate(-0.4deg); opacity:1; animation-timing-function:cubic-bezier(0.4,0,0.6,1); } | ||
| 51%,100% { transform:scale(1) rotate(-2.4deg); opacity:1; } } | ||
| @keyframes s1LeafHi { | ||
| 0%,33% { transform:scale(0.05) rotate(-9deg); opacity:0; animation-timing-function:cubic-bezier(0.16,0.9,0.3,1); } | ||
| 42% { transform:scale(1.03) rotate(-5deg); opacity:1; animation-timing-function:cubic-bezier(0.5,0,0.85,0.3); } | ||
| 48% { transform:scale(1) rotate(5.4deg); opacity:1; animation-timing-function:cubic-bezier(0.4,0,0.6,1); } | ||
| 52% { transform:scale(1) rotate(0.8deg); opacity:1; animation-timing-function:cubic-bezier(0.4,0,0.6,1); } | ||
| 57%,100% { transform:scale(1) rotate(3.2deg); opacity:1; } } | ||
| @keyframes s1Bud { 0%,29% { transform:scale(0); } 35%,100% { transform:scale(1); } } | ||
| @keyframes s1Word { 0%,2% { opacity:0; transform:translateY(8px); } 14%,100% { opacity:1; transform:translateY(0); } } | ||
| @keyframes s1Rule { 0%,60% { transform:scaleX(0); } 74%,100% { transform:scaleX(1); } } | ||
| /* ── Ambient / hero ────────────────────────────────────────────────── */ | ||
| @keyframes saplingBlob { 0% { transform:translate(0,0) scale(1); } 33% { transform:translate(30px,-50px) scale(1.1); } 66% { transform:translate(-20px,20px) scale(0.9); } 100% { transform:translate(0,0) scale(1); } } | ||
| @keyframes introOrbit { to { transform:rotate(360deg); } } | ||
| @keyframes floatIndicator { 0%,100% { transform:translateY(0) translateX(-50%); } 50% { transform:translateY(6px) translateX(-50%); } } | ||
| @keyframes betaGlow { 0%,100% { box-shadow:0 0 0 0 rgba(14,158,90,0), 0 4px 20px rgba(14,158,90,0.15); } 50% { box-shadow:0 0 0 5px rgba(14,158,90,0.45), 0 4px 28px rgba(14,158,90,0.3); } } | ||
| @keyframes nodeFloatA { 0%,100% { transform:translate(0,0); } 33% { transform:translate(16px,-24px); } 66% { transform:translate(-12px,14px); } } | ||
| @keyframes nodeFloatB { 0%,100% { transform:translate(0,0); } 40% { transform:translate(-18px,18px); } 75% { transform:translate(10px,-12px); } } | ||
| /* ── Gallery card miniatures ───────────────────────────────────────── */ | ||
| @keyframes popHold { 0% { opacity:0; transform:translateY(-10px) scale(0.94); } 6% { opacity:1; transform:translateY(0) scale(1); } 86% { opacity:1; } 96% { opacity:0; } 100% { opacity:0; } } | ||
| @keyframes chipSwapA { 0%,42% { opacity:1; transform:translateY(0); } 50%,92% { opacity:0; transform:translateY(-8px); } 100% { opacity:1; transform:translateY(0); } } | ||
| @keyframes chipSwapB { 0%,42% { opacity:0; transform:translateY(8px); } 50%,92% { opacity:1; transform:translateY(0); } 100% { opacity:0; transform:translateY(8px); } } | ||
| @keyframes flipLoop { 0%,38% { transform:rotateY(0deg); } 50%,88% { transform:rotateY(180deg); } 100% { transform:rotateY(360deg); } } | ||
| @keyframes lineReveal { 0% { clip-path:inset(0 100% 0 0); } 12% { clip-path:inset(0 -4px 0 0); } 90% { clip-path:inset(0 -4px 0 0); } 100% { clip-path:inset(0 100% 0 0); } } | ||
| @keyframes underSweep { 0%,8% { transform:scaleX(0); } 18% { transform:scaleX(1); } 88% { transform:scaleX(1); } 100% { transform:scaleX(0); } } | ||
| @keyframes barGrow { 0% { transform:scaleY(0.08); } 14% { transform:scaleY(1); } 86% { transform:scaleY(1); } 100% { transform:scaleY(0.08); } } | ||
| @keyframes coverRise { 0% { transform:translateY(46px) rotate(-1deg); opacity:0; } 16% { transform:translateY(0) rotate(-1deg); opacity:1; } 84% { transform:translateY(0) rotate(-1deg); opacity:1; } 100% { transform:translateY(46px) rotate(-1deg); opacity:0; } } | ||
| @keyframes ringDraw { 0% { stroke-dashoffset:100; } 30%,85% { stroke-dashoffset:8.8; } 100% { stroke-dashoffset:100; } } | ||
| @keyframes gaugeFloat { 0%,100% { transform:translateY(0); } 30% { transform:translateY(-52px); } 65% { transform:translateY(20px); } } | ||
| @keyframes answerTick { 0%,55% { box-shadow:0 0 0 0 rgba(14,158,90,0); } 62% { box-shadow:0 0 0 7px rgba(14,158,90,0.22); } 72%,100% { box-shadow:0 0 0 0 rgba(14,158,90,0); } } | ||
| @keyframes waveBar { 0%,100% { transform:scaleY(0.25); } 50% { transform:scaleY(1); } } | ||
| @keyframes gaugeMini { 0%,100% { transform:translate(-50%,0); } 30% { transform:translate(-50%,-32px); } 65% { transform:translate(-50%,12px); } } | ||
| @keyframes coverMini { 0% { transform:translateY(30px); opacity:0; } 16% { transform:translateY(0); opacity:1; } 84% { transform:translateY(0); opacity:1; } 100% { transform:translateY(30px); opacity:0; } } | ||
| @keyframes cursorMini { 0%,100% { transform:translate(0,0); } 30% { transform:translate(22px,-14px); } 70% { transform:translate(-16px,10px); } } | ||
| @keyframes cardFloat { 0%,100% { transform:translateY(0); } 50% { transform:translateY(-9px); } } | ||
| @keyframes scanSweep { 0% { transform:translateY(-14px); opacity:0; } 8% { opacity:1; } 92% { opacity:1; } 100% { transform:translateY(212px); opacity:0; } } | ||
| @keyframes ocrBlink { 0%,100% { opacity:0.35; } 50% { opacity:1; } } | ||
| @keyframes panelFade { from { opacity:0; } to { opacity:1; } } | ||
| @keyframes fillX { 0% { transform:scaleX(0); } 18% { transform:scaleX(1); } 86% { transform:scaleX(1); } 100% { transform:scaleX(0); } } | ||
| @keyframes typingDot { 0%,60%,100% { opacity:0.25; transform:translateY(0); } 30% { opacity:1; transform:translateY(-3px); } } | ||
| @keyframes ringPop { 0% { opacity:0; r:0; } 12% { opacity:1; } 88% { opacity:1; } 100% { opacity:0; } } | ||
| @keyframes cursorDrift { 0%,100% { transform:translate(0,0); } 30% { transform:translate(38px,-22px); } 70% { transform:translate(-26px,16px); } } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift
Use kebab-case keyframe names.
Stylelint reports every new camelCase keyframe identifier against keyframes-name-pattern. This leaves lint errors in the changed stylesheet. Rename the declarations to kebab case and update every matching animation or animation-name reference in the same change.
Also applies to: 2031-2033, 2082-2082, 2094-2096
🧰 Tools
🪛 Stylelint (17.14.0)
[error] 1957-1957: Expected keyframe name "s1Stem" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1958-1958: Expected keyframe name "s1LeafLo" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1964-1964: Expected keyframe name "s1LeafHi" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1970-1970: Expected keyframe name "s1Bud" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1971-1971: Expected keyframe name "s1Word" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1972-1972: Expected keyframe name "s1Rule" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1975-1975: Expected keyframe name "saplingBlob" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1976-1976: Expected keyframe name "introOrbit" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1977-1977: Expected keyframe name "floatIndicator" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1978-1978: Expected keyframe name "betaGlow" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1979-1979: Expected keyframe name "nodeFloatA" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1980-1980: Expected keyframe name "nodeFloatB" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1983-1983: Expected keyframe name "popHold" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1984-1984: Expected keyframe name "chipSwapA" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1985-1985: Expected keyframe name "chipSwapB" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1986-1986: Expected keyframe name "flipLoop" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1987-1987: Expected keyframe name "lineReveal" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1988-1988: Expected keyframe name "underSweep" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1989-1989: Expected keyframe name "barGrow" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1990-1990: Expected keyframe name "coverRise" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1991-1991: Expected keyframe name "ringDraw" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1992-1992: Expected keyframe name "gaugeFloat" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1993-1993: Expected keyframe name "answerTick" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1994-1994: Expected keyframe name "waveBar" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1995-1995: Expected keyframe name "gaugeMini" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1996-1996: Expected keyframe name "coverMini" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1997-1997: Expected keyframe name "cursorMini" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1998-1998: Expected keyframe name "cardFloat" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 1999-1999: Expected keyframe name "scanSweep" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 2000-2000: Expected keyframe name "ocrBlink" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 2001-2001: Expected keyframe name "panelFade" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 2002-2002: Expected keyframe name "fillX" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 2003-2003: Expected keyframe name "typingDot" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 2004-2004: Expected keyframe name "ringPop" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
[error] 2005-2005: Expected keyframe name "cursorDrift" to be kebab-case (keyframes-name-pattern)
(keyframes-name-pattern)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/app/globals.css` around lines 1957 - 2005, Rename the camelCase
keyframe declarations in the affected stylesheet to kebab-case, including
symbols such as s1Stem, saplingBlob, introOrbit, popHold, and the additional
reported ranges. Update every corresponding animation and animation-name
reference to the new kebab-case identifiers, preserving all animation behavior.
Source: Linters/SAST tools
| <div style={{ position: 'fixed', inset: 0, zIndex: 80 }}> | ||
| <div | ||
| onClick={onClose} | ||
| style={{ position: 'absolute', inset: 0, background: 'rgba(8,31,20,0.62)', animation: 'panelFade 420ms ease both' }} | ||
| /> | ||
| <div | ||
| ref={panelRef} | ||
| style={{ position: 'absolute', inset: 0, background: '#FDFCF9', overflow: 'hidden', display: 'flex', flexDirection: 'column', willChange: 'transform' }} | ||
| > |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift
The overlay is a modal without dialog semantics or focus management.
The panel covers the viewport at zIndex: 80 and the caller locks body scroll. Three parts are missing:
- No
role="dialog"and noaria-modal="true", so assistive technology does not announce a modal context. - No accessible name on the container.
- No focus management. Focus stays on the gallery card behind the overlay, tabbing walks the page underneath, and closing does not return focus to the card that opened the panel.
Escape already closes the panel through useLanding.ts lines 272-276, so only the semantics and focus handling remain.
♿ Proposed direction
- <div style={{ position: 'fixed', inset: 0, zIndex: 80 }}>+ <div+ role="dialog"+ aria-modal="true"+ aria-label={`${g.kicker} demo`}+ style={{ position: 'fixed', inset: 0, zIndex: 80 }}+ >Then move focus into the panel when it mounts, keep Tab inside it while it is open, and restore focus to the element captured by armFlip when it closes. frontend/src/lib/landing/engine/flip.ts already holds that element in FlipState.el.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/landing-v5/FeatureLab.tsx` around lines 41 - 50,
Update the modal container identified by panelRef in FeatureLab to add
role="dialog", aria-modal="true", and an accessible name. On mount, move focus
into the panel; while open, trap Tab navigation within it so focus cannot reach
the page behind the overlay. Capture and restore focus to the triggering element
stored in FlipState.el when the panel closes, while preserving the existing
Escape close behavior.
| function Card({ i, ghost }: { i: number; ghost: boolean }) { | ||
| const c = CARDS[i]; | ||
| return ( | ||
| <article | ||
| data-tk={i} | ||
| aria-hidden={ghost || undefined} | ||
| style={CARD} | ||
| className="ld-galcard" | ||
| > | ||
| <div aria-hidden="true" style={{ position: 'relative', height: 166, borderRadius: 13, overflow: 'hidden', background: '#FDFCF9', border: '1px solid #EBF1EC' }}> | ||
| {GALLERY_MINIS[i]} | ||
| </div> | ||
| <div style={{ padding: '15px 12px 0', display: 'flex', flexDirection: 'column' }}> | ||
| <span style={{ ...MONO, fontSize: 9.5, letterSpacing: '0.28em', color: '#0C5638' }}>{c.kicker}</span> | ||
| <h3 style={{ margin: '10px 0 0', fontFamily: "'Playfair Display',serif", fontSize: 22, fontWeight: 600, lineHeight: 1.2, letterSpacing: '-0.015em', color: '#12201A' }}> | ||
| {c.title} | ||
| </h3> | ||
| <p style={{ margin: '9px 0 0', fontSize: 12.5, lineHeight: 1.6, color: '#61726A', maxWidth: '40ch', textWrap: 'pretty' }}> | ||
| {c.desc} | ||
| </p> | ||
| </div> | ||
| </article> | ||
| ); | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
The gallery cards cannot be opened with a keyboard.
Opening the feature lab depends on the onClick delegation on the track div at lines 99-102. Card renders an <article> with no tabIndex, no role, and no key handler, and the track div has no key handler either. A keyboard user cannot reach or activate any card, so the feature lab is unreachable without a pointer. The doc comment at lines 14-15 states that the first copy is reachable by keyboard, which does not match the markup.
Make each non-ghost card an activatable control, keep the duplicate copies out of the tab order, and then correct the doc comment.
♿ Proposed fix
-function Card({ i, ghost }: { i: number; ghost: boolean }) {+function Card({ i, ghost, onOpen }: { i: number; ghost: boolean; onOpen: (i: number, el: HTMLElement | null) => void }) {
const c = CARDS[i];
return (
<article
data-tk={i}
aria-hidden={ghost || undefined}
+ role="button"+ tabIndex={ghost ? -1 : 0}+ aria-label={`Open ${c.kicker}`}+ onKeyDown={(e) => {+ if (e.key !== 'Enter' && e.key !== ' ') return;+ e.preventDefault();+ onOpen(i, e.currentTarget);+ }}
style={CARD}
className="ld-galcard"
>- {order.map((i) => <Card key={`a${i}`} i={i} ghost={false} />)}- {order.map((i) => <Card key={`b${i}`} i={i} ghost />)}+ {order.map((i) => <Card key={`a${i}`} i={i} ghost={false} onOpen={onOpen} />)}+ {order.map((i) => <Card key={`b${i}`} i={i} ghost onOpen={onOpen} />)}Add a visible focus style for .ld-galcard:focus-visible in globals.css.
Also applies to: 96-107
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/landing-v5/Gallery.tsx` around lines 62 - 85, Update
Card so non-ghost cards are keyboard-focusable and activatable, using the
existing track click-delegation contract, while ghost duplicates remain excluded
from the tab order and accessibility tree. Add the corresponding keyboard
activation handling to the card or track using the existing feature-lab target
behavior, and correct the nearby doc comment to match the implemented
accessibility behavior. Add a visible .ld-galcard:focus-visible style in
globals.css.
| const rate = useCallback((key: RateKey) => { | ||
| if (idx >= DECK.length || !flipped) return; | ||
| setLog((l) => [...l, { q: DECK[idx].q, key }]); | ||
| setFlipped(false); | ||
| // the card turns back before the next one arrives, so it reads as a deal | ||
| const t = setTimeout(() => setIdx((i) => i + 1), 300); | ||
| return () => clearTimeout(t); | ||
| }, [idx, flipped]); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Handler timeouts are created without a ref, so they are never cancelled. Both demos schedule a state update from an event handler without storing the handle. The timeout cannot be cleared on unmount, and a second click cannot cancel a pending one. The other demos in this layer already hold each handle in a useRef and clear it in an unmount effect.
frontend/src/components/landing-v5/lab/CardsDemo.tsx#L37-L44: store the 300 ms advance timeout in a ref, clear it before each new rating, and clear it on unmount; delete the returned cleanup, which React never calls.frontend/src/components/landing-v5/lab/NotesDemo.tsx#L57-L80: store the 700 msextractandsummarizetimeouts in refs and clear them in the existing unmount effect besidesaveT.
📍 Affects 2 files
frontend/src/components/landing-v5/lab/CardsDemo.tsx#L37-L44(this comment)frontend/src/components/landing-v5/lab/NotesDemo.tsx#L57-L80
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx` around lines 37 - 44,
Update CardsDemo.tsx at lines 37-44 so the 300 ms timeout in the rate handler is
stored in a useRef, cleared before scheduling each new rating and during
unmount, and remove the ineffective returned cleanup. Update NotesDemo.tsx at
lines 57-80 so the 700 ms extract and summarize timeouts use refs and are
cleared in the existing unmount effect alongside saveT.
| const RECENT = [ | ||
| { i: 0, exam: 'MA 242 · Midterm 1', meta: 'GENERATED SEP 28', course: 'MA 242' as CourseKey, pick: 'Midterm 1 · Sep 30' }, | ||
| { i: 1, exam: 'CS 201 · Quiz 2', meta: 'GENERATED OCT 03', course: 'CS 201' as CourseKey, pick: 'Quiz 2 · Oct 03' }, | ||
| ]; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
RECENT[].pick values are not options of the exam select.
openRecent at line 53 sets exam to r.pick. 'Midterm 1 · Sep 30' is not in GUIDES['MA 242'].exams, and 'Quiz 2 · Oct 03' is not in GUIDES['CS 201'].exams. The controlled select at line 76 then falls back to displaying its first option while the guide heading at line 104 prints exam. The control and the heading disagree, and the user cannot select the displayed exam again.
Use an exam that exists in the course fixture, or add the recent exam to the option list when it is selected.
🔧 Proposed fix using existing fixture values
const RECENT = [
- { i: 0, exam: 'MA 242 · Midterm 1', meta: 'GENERATED SEP 28', course: 'MA 242' as CourseKey, pick: 'Midterm 1 · Sep 30' },- { i: 1, exam: 'CS 201 · Quiz 2', meta: 'GENERATED OCT 03', course: 'CS 201' as CourseKey, pick: 'Quiz 2 · Oct 03' },+ { i: 0, exam: 'MA 242 · Final', meta: 'GENERATED SEP 28', course: 'MA 242' as CourseKey, pick: 'Final · Dec 12' },+ { i: 1, exam: 'CS 201 · Midterm 1', meta: 'GENERATED OCT 03', course: 'CS 201' as CourseKey, pick: 'Midterm 1 · Oct 17' },
];📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| constRECENT=[ | |
| {i: 0,exam: 'MA 242 · Midterm 1',meta: 'GENERATED SEP 28',course: 'MA 242'asCourseKey,pick: 'Midterm 1 · Sep 30'}, | |
| {i: 1,exam: 'CS 201 · Quiz 2',meta: 'GENERATED OCT 03',course: 'CS 201'asCourseKey,pick: 'Quiz 2 · Oct 03'}, | |
| ]; | |
| constRECENT=[ | |
| {i: 0,exam: 'MA 242 · Final',meta: 'GENERATED SEP 28',course: 'MA 242'asCourseKey,pick: 'Final · Dec 12'}, | |
| {i: 1,exam: 'CS 201 · Midterm 1',meta: 'GENERATED OCT 03',course: 'CS 201'asCourseKey,pick: 'Midterm 1 · Oct 17'}, | |
| ]; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/landing-v5/lab/GuideDemo.tsx` around lines 21 - 24,
Update the RECENT entries’ pick values to exactly match an existing exam option
in the corresponding GUIDES course fixture, preserving openRecent’s assignment
and keeping the select value consistent with the guide heading.
| <div role="radiogroup" style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 8 }}> | ||
| {q.options.map((o) => { | ||
| const isPicked = o.label === picked; | ||
| const correct = 'correct' in o && o.correct; | ||
| let bd = '#E3E0D5', bg = '#FDFCF9', fg = '#33443B', mark = '', markCol = ''; | ||
| if (reviewing && correct) { bd = '#0E9E5A'; bg = '#E6F2E8'; fg = '#0C5638'; mark = 'CORRECT'; markCol = '#0C5638'; } | ||
| else if (reviewing && isPicked) { bd = '#b25855'; bg = 'rgba(178,88,85,0.09)'; fg = '#9c4b48'; mark = 'YOUR PICK'; markCol = '#9c4b48'; } | ||
| else if (isPicked) { bd = '#0E9E5A'; bg = '#E6F2E8'; fg = '#0C5638'; } | ||
| return ( | ||
| <button | ||
| key={o.label} | ||
| type="button" | ||
| onClick={() => { if (!reviewing) setPicked(o.label); }} | ||
| style={{ textAlign: 'left', display: 'flex', gap: 10, alignItems: 'flex-start', padding: '12px 14px', borderRadius: 10, fontFamily: "'DM Sans',sans-serif", fontSize: 14, transition: 'all 180ms', border: `1px solid ${bd}`, background: bg, color: fg, cursor: reviewing ? 'default' : 'pointer' }} | ||
| > | ||
| <span style={{ ...MONO, fontSize: 12, color: isPicked || (reviewing && correct) ? '#0E9E5A' : '#9AA5A0' }}>{o.label}.</span> | ||
| <span>{o.text}</span> | ||
| <span style={{ marginLeft: 'auto', ...MONO, fontSize: 11, letterSpacing: '0.14em', color: markCol }}>{mark}</span> | ||
| </button> | ||
| ); | ||
| })} |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
role="radiogroup" has no radio children.
The container declares role="radiogroup", but each option is a plain <button>. A radiogroup requires children with role="radio" and aria-checked. Assistive technology cannot report which option is selected. Add the roles and state, or drop role="radiogroup" and expose selection with aria-pressed.
♿ Proposed fix
<button
key={o.label}
type="button"
+ role="radio"+ aria-checked={isPicked}+ aria-disabled={reviewing}
onClick={() => { if (!reviewing) setPicked(o.label); }}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <divrole="radiogroup"style={{marginTop: 16,display: 'flex',flexDirection: 'column',gap: 8}}> | |
| {q.options.map((o)=>{ | |
| constisPicked=o.label===picked; | |
| constcorrect='correct'ino&&o.correct; | |
| letbd='#E3E0D5',bg='#FDFCF9',fg='#33443B',mark='',markCol=''; | |
| if(reviewing&&correct){bd='#0E9E5A';bg='#E6F2E8';fg='#0C5638';mark='CORRECT';markCol='#0C5638';} | |
| elseif(reviewing&&isPicked){bd='#b25855';bg='rgba(178,88,85,0.09)';fg='#9c4b48';mark='YOUR PICK';markCol='#9c4b48';} | |
| elseif(isPicked){bd='#0E9E5A';bg='#E6F2E8';fg='#0C5638';} | |
| return( | |
| <button | |
| key={o.label} | |
| type="button" | |
| onClick={()=>{if(!reviewing)setPicked(o.label);}} | |
| style={{textAlign: 'left',display: 'flex',gap: 10,alignItems: 'flex-start',padding: '12px 14px',borderRadius: 10,fontFamily: "'DM Sans',sans-serif",fontSize: 14,transition: 'all 180ms',border: `1px solid ${bd}`,background: bg,color: fg,cursor: reviewing ? 'default' : 'pointer'}} | |
| > | |
| <spanstyle={{ ...MONO,fontSize: 12,color: isPicked||(reviewing&&correct) ? '#0E9E5A' : '#9AA5A0'}}>{o.label}.</span> | |
| <span>{o.text}</span> | |
| <spanstyle={{marginLeft: 'auto', ...MONO,fontSize: 11,letterSpacing: '0.14em',color: markCol}}>{mark}</span> | |
| </button> | |
| ); | |
| })} | |
| <divrole="radiogroup"style={{marginTop: 16,display: 'flex',flexDirection: 'column',gap: 8}}> | |
| {q.options.map((o)=>{ | |
| constisPicked=o.label===picked; | |
| constcorrect='correct'ino&&o.correct; | |
| letbd='`#E3E0D5`',bg='`#FDFCF9`',fg='`#33443B`',mark='',markCol=''; | |
| if(reviewing&&correct){bd='`#0E9E5A`';bg='`#E6F2E8`';fg='`#0C5638`';mark='CORRECT';markCol='`#0C5638`';} | |
| elseif(reviewing&&isPicked){bd='`#b25855`';bg='rgba(178,88,85,0.09)';fg='`#9c4b48`';mark='YOUR PICK';markCol='`#9c4b48`';} | |
| elseif(isPicked){bd='`#0E9E5A`';bg='`#E6F2E8`';fg='`#0C5638`';} | |
| return( | |
| <button | |
| key={o.label} | |
| type="button" | |
| role="radio" | |
| aria-checked={isPicked} | |
| aria-disabled={reviewing} | |
| onClick={()=>{if(!reviewing)setPicked(o.label);}} | |
| style={{textAlign: 'left',display: 'flex',gap: 10,alignItems: 'flex-start',padding: '12px 14px',borderRadius: 10,fontFamily: "'DM Sans',sans-serif",fontSize: 14,transition: 'all 180ms',border: `1px solid ${bd}`,background: bg,color: fg,cursor: reviewing ? 'default' : 'pointer'}} | |
| > | |
| <spanstyle={{ ...MONO,fontSize: 12,color: isPicked||(reviewing&&correct) ? '`#0E9E5A`' : '`#9AA5A0`'}}>{o.label}.</span> | |
| <span>{o.text}</span> | |
| <spanstyle={{marginLeft: 'auto', ...MONO,fontSize: 11,letterSpacing: '0.14em',color: markCol}}>{mark}</span> | |
| </button> | |
| ); | |
| })} |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/landing-v5/lab/QuizDemo.tsx` around lines 104 - 124,
Fix the quiz option accessibility semantics in the q.options mapping: either
make each button a radio with role="radio" and aria-checked bound to isPicked
while retaining the radiogroup, or remove the radiogroup role and expose
isPicked through aria-pressed. Preserve the existing selection and reviewing
behavior in the option onClick handler.
| useEffect(() => { | ||
| const canvas = refs.heroCanvas.current; | ||
| const engine = engineRef.current; | ||
| if (!canvas || !engine) return; | ||
| let stopped: { stop(): void } | null = null; | ||
| // imported lazily so the module isn't pulled into the server bundle | ||
| import('@/lib/landing/engine/hero').then(({ startHeroCanvas }) => { | ||
| stopped = startHeroCanvas(canvas, () => engine.mouse, () => engine.parallaxY); | ||
| }); | ||
| return () => stopped?.stop(); | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, []); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
The hero canvas effect can start a loop that is never stopped.
import('@/lib/landing/engine/hero') resolves asynchronously. If the component unmounts before the promise resolves, the cleanup at line 222 runs while stopped is still null. The then callback then calls startHeroCanvas, which installs a requestAnimationFrame loop and a resize listener (see frontend/src/lib/landing/engine/hero.ts lines 83-87). Nothing cancels them afterwards, so the loop keeps drawing after navigation away from the landing page.
The WebGL effect at lines 227-244 already uses a cancelled flag. Apply the same pattern here.
🛠️ Proposed fix
let stopped: { stop(): void } | null = null;
+ let cancelled = false;
// imported lazily so the module isn't pulled into the server bundle
import('`@/lib/landing/engine/hero`').then(({ startHeroCanvas }) => {
+ if (cancelled) return;
stopped = startHeroCanvas(canvas, () => engine.mouse, () => engine.parallaxY);
});
- return () => stopped?.stop();+ return () => {+ cancelled = true;+ stopped?.stop();+ };📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| useEffect(()=>{ | |
| constcanvas=refs.heroCanvas.current; | |
| constengine=engineRef.current; | |
| if(!canvas||!engine)return; | |
| letstopped: {stop(): void}|null=null; | |
| // imported lazily so the module isn't pulled into the server bundle | |
| import('@/lib/landing/engine/hero').then(({ startHeroCanvas })=>{ | |
| stopped=startHeroCanvas(canvas,()=>engine.mouse,()=>engine.parallaxY); | |
| }); | |
| return()=>stopped?.stop(); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| },[]); | |
| useEffect(()=>{ | |
| constcanvas=refs.heroCanvas.current; | |
| constengine=engineRef.current; | |
| if(!canvas||!engine)return; | |
| letstopped: {stop(): void}|null=null; | |
| letcancelled=false; | |
| // imported lazily so the module isn't pulled into the server bundle | |
| import('`@/lib/landing/engine/hero`').then(({ startHeroCanvas })=>{ | |
| if(cancelled)return; | |
| stopped=startHeroCanvas(canvas,()=>engine.mouse,()=>engine.parallaxY); | |
| }); | |
| return()=>{ | |
| cancelled=true; | |
| stopped?.stop(); | |
| }; | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| },[]); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/landing/useLanding.ts` around lines 213 - 224, Update
the hero canvas useEffect around startHeroCanvas to track a cancelled/unmounted
flag. Set it during cleanup, check it after the asynchronous hero module import
resolves before starting the canvas, and stop the returned controller when
appropriate so no animation loop or resize listener remains after unmount.
| function destroy(): void { | ||
| cleanups.forEach((fn) => fn()); | ||
| cleanups.length = 0; | ||
| anchors.length = 0; | ||
| groups = null; | ||
| if (overlay && overlay.parentElement) overlay.parentElement.removeChild(overlay); | ||
| overlay = null; | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
destroy() discards the re-homed clusters, so the field cannot be rebuilt.
ensureInit moves every cluster element out of its .drag-field and into the overlay (line 172), and it mutates the cluster's inline styles (lines 165-171). destroy() removes the shell through the queued cleanup, which deletes those cluster elements from the document. It does not put them back.
Two consequences:
- A second
ensureIniton the same DOM finds.drag-fieldelements with no[data-dragnode]children, returnsfalseat line 232, and the field stays empty forever. This happens on any stop/start cycle, including the React StrictMode double-invoke of the engine effect infrontend/src/components/landing/useLanding.tslines 162-210. - React still owns those cluster nodes, because
DragFieldrendered them. React records the original parent for each child. Removing or reparenting the nodes outside React can make the later unmount removal fail.
Restore each cluster to its original parent and clear the inline styles that ensureInit set.
🛠️ Proposed fix: remember the original parent and restore it on destroy
interface ClusterAnchor {
el: HTMLElement;
field: HTMLElement;
+ /** Where the cluster lived before it was re-homed into the overlay. */+ home: Node | null;+ before: Node | null;
/** The pinned act this field belongs to, if any — drives the scroll drift. */
sect: HTMLElement | null;
left: number;
top: number;
} anchors.push({
el: cl, field, sect,
+ home: cl.parentNode,+ before: cl.nextSibling,
left: b.left - fb.left,
top: b.top - fb.top,
}); function destroy(): void {
+ // put the clusters back before the overlay goes away, otherwise React+ // still owns nodes that no longer exist and a later ensureInit finds+ // empty fields+ for (const a of anchors) {+ a.el.style.cssText = '';+ if (a.home) a.home.insertBefore(a.el, a.before);+ }
cleanups.forEach((fn) => fn());
cleanups.length = 0;
anchors.length = 0;
groups = null;
- if (overlay && overlay.parentElement) overlay.parentElement.removeChild(overlay);
overlay = null;
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/lib/landing/engine/sim.ts` around lines 423 - 430, Update the
initialization and destroy flow around ensureInit and destroy to retain each
cluster element’s original parent before moving it into the overlay. During
destroy, restore every cluster to that parent, clear the inline styles applied
by ensureInit, then remove the overlay and reset state so a later ensureInit can
rebuild the field without disrupting React-owned nodes.
…rop the lab tagline Three copy changes, all requested: - `Reality check` -> `Grade Calculator`, which says what the tool is rather than editorialising about it. - Title-case the two labels that were sentence case: `Exam prep` -> `Exam Prep`, `AI tutor` -> `AI Tutor`. All eight now read consistently in the lab rail. - Remove the "TRY IT · THIS ONE ACTUALLY WORKS" line and its pulsing dot from the feature-lab panel. These are the only departures from the design's copy, which the brief otherwise froze. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he footer The page scrolled ~295px past the footer into empty space. `DragField` rendered one shape everywhere: a `position:sticky; height:0` box with a `height:100vh` absolute layer inside it. That is correct for the two pinned acts, where the field has to track a sticky stage without occupying space in it — but the source uses a second shape for the three static sections (faq, newsletter, cta): `position:absolute; inset:0` with the clusters held directly, no 100vh layer. An absolutely positioned child still extends the document's scrollHeight, so on `cta` — the last section — that 100vh layer hung a full viewport below the footer. Exactly the "the field layer must never add page height" constraint, missed by using the pinned shape on a static section. Document height now equals the footer's bottom exactly (0px overshoot, was 295). Clusters still render in all four sections: 7 rings on screen in act-tutor, faq and cta, 6 in newsletter. No console errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mponent The previous commit typed `PINNED` as `DragCluster['section']`, which is the union of sections that actually contain clusters — and act-ingest is not one of them, so listing it there did not compile. I committed on a `;`-separated line that let a tsc failure through; that is on me. Rather than drop act-ingest from the set, widen the prop to `FieldSection` (= the cluster sections plus act-ingest) and delete the bespoke inline field that act-ingest was carrying. One component now renders every field, and the section decides its shape. tsc 0, eslint 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first pass invented a vertical list. `News.dc.html` is a responsive card grid, and the difference was visible at a glance. Now matches the source: the "Notes from the build" eyebrow, a Subscribe link opposite the title, and one combined control holding the query field, its clear button and a category listbox that check-marks the active option — not the row of pills I had. Cards are `repeat(auto-fill, minmax(330px,1fr))` with a 16/10 image, date + tag, title, excerpt and a "Read article ->" footer, closing on the Journal call-out. Posts without artwork get a plain tinted panel. The source fills those with its `image-slot` drop zone, which is an authoring affordance and does not belong in the shipped page; the import carries images for two of six. Verified: 6 cards, 5 filter options, Releases narrows to 2, an unmatched query shows the empty state, no console errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ources Same fault as the News page: I had the right copy on each of these but had invented the layout around it. All four now follow their `.dc.html`. - **Gallery** is a grid of `<figure>` tiles, each a 16/10 frame with the route badged over the top-left corner and a caption pairing title with group — not the link cards I had. Tiles stagger at 50ms, capped at 400ms so a large filter result still settles quickly. - **Team** is square portrait tiles at `minmax(230px,1fr)`, then How we work, Recognition, and a byline row with a beta link. Two motes drift at the page edges (`nodeDrift`, which was missing). - **Wiki** is a sticky contents rail beside seven sections, each laid out for what it holds: two-column term/definition rows for the graph and tutor modes, a four-column row with a tier swatch for mastery, cards for the review intervals, a numbered list for ingestion, chips for the grade bands. - **FAQ** gains the "Straight answers" eyebrow it was missing and the real two-button call-out (Read the Wiki / Join the beta) in place of my one-line footnote. Every eyebrow was wrong or absent before: Inside the product, The people, Reference, Straight answers. `companion/primitives.tsx` and `CompanionBody` are deleted — they existed only to serve my invented layouts, and each page now states its own column width the way the source does. Image frames stay empty on Gallery and Team. The source fills them with its `image-slot` drop zone, an authoring affordance, and the import ships no screenshots or portraits. Gallery keeps the route badge so each tile still says what it is. Verified per page: eyebrow, h1, figure/section counts and call-out labels all match the source; 12 gallery figures, 7 wiki sections, 3 faq groups, no console errors. tsc 0, eslint 0, build 0, 59 files / 423 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Requested, and a deliberate departure from the design: the source wires "Sign up for Beta Testing" to scroll down to the newsletter section, and the final CTA the same way. Both now open a dialog. Built on the shared `Dialog` rather than hand-rolled, so it inherits the portal, scroll lock, Escape, focus restore and aria wiring the app's other modals use. `initialFocusRef` puts focus on the email field instead of the close button — the field is what you opened it for. It shares `email`/`subscribed` with the inline newsletter through `useLanding`, so signing up in the dialog also settles that section into its subscribed state. One signup, not two places to do it. `labelledBy` points at the panel's own Playfair heading rather than passing `title`, which Dialog renders visibly and would have duplicated the heading. The success state carries the same id, so the label does not dangle when the form is replaced. Verified end to end: opens from the hero, focus lands on the email field, body scroll locks, Escape closes, a valid address flips it to "You're on the tree.", and the inline newsletter reads subscribed afterwards. No console errors. tsc 0, eslint 0, build 0, 59 files / 423 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/landing-v5/BetaModal.tsx`:
- Around line 52-70: Update the subscribed confirmation branch in BetaModal so
the rendered Close button receives focus after the form is replaced. Add a ref
to that button and focus it after the subscribed state commits, using the
component’s existing lifecycle pattern; preserve the current onClose behavior
and button styling.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: ee1ca146-7600-423b-98c7-56bcec09e4e1
📒 Files selected for processing (13)
frontend/src/app/(public)/faq/page.tsxfrontend/src/app/(public)/gallery/page.tsxfrontend/src/app/(public)/news/page.tsxfrontend/src/app/(public)/page.tsxfrontend/src/app/(public)/team/page.tsxfrontend/src/app/(public)/wiki/page.tsxfrontend/src/app/globals.cssfrontend/src/components/companion/CompanionShell.tsxfrontend/src/components/landing-v5/ActIngest.tsxfrontend/src/components/landing-v5/BetaModal.tsxfrontend/src/components/landing-v5/DragField.tsxfrontend/src/components/landing-v5/FeatureLab.tsxfrontend/src/lib/landing/content.ts
💤 Files with no reviewable changes (2)
- frontend/src/components/companion/CompanionShell.tsx
- frontend/src/components/landing-v5/FeatureLab.tsx
🚧 Files skipped from review as they are similar to previous changes (5)
- frontend/src/app/(public)/wiki/page.tsx
- frontend/src/app/(public)/news/page.tsx
- frontend/src/components/landing-v5/ActIngest.tsx
- frontend/src/app/(public)/gallery/page.tsx
- frontend/src/lib/landing/content.ts
| {subscribed ? ( | ||
| <div style={{ textAlign: 'center', padding: '10px 4px 6px' }}> | ||
| {/* carries headingId too — it is what `aria-labelledby` points at | ||
| once the form is replaced by this state */} | ||
| <h2 id={headingId} style={{ margin: 0, fontFamily: "'Playfair Display',serif", fontSize: 30, fontWeight: 600, color: '#12201A', lineHeight: 1.1 }}> | ||
| You’re on the <em style={{ color: '#0C5638' }}>tree.</em> | ||
| </h2> | ||
| <p style={{ margin: '10px 0 0', fontSize: 14, color: '#61726A', fontStyle: 'italic' }}> | ||
| See you in the inbox · The Team | ||
| </p> | ||
| <button | ||
| onClick={onClose} | ||
| type="button" | ||
| className="ld-btn-solid" | ||
| style={{ marginTop: 22, background: '#0C5638', color: '#fff', border: 'none', borderRadius: 6, padding: '13px 26px', fontFamily: "'DM Sans',sans-serif", fontWeight: 600, fontSize: 14, cursor: 'pointer', transition: 'filter 200ms' }} | ||
| > | ||
| Close | ||
| </button> | ||
| </div> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Restore modal focus after the subscribed state replaces the form.
When subscribed becomes true, this branch unmounts the focused email input while Dialog remains open. Dialog only applies its initial focus when open changes. Focus can then leave the dialog and bypass its Tab trap. Move focus to the Close button after the confirmation state renders.
Proposed fix
-import { useId, useRef } from 'react';+import { useEffect, useId, useRef } from 'react';
...
const inputRef = useRef<HTMLInputElement>(null);
+ const confirmationCloseRef = useRef<HTMLButtonElement>(null);
const headingId = useId();
const valid = email.includes('@');
++ useEffect(() => {+ if (open && subscribed) confirmationCloseRef.current?.focus();+ }, [open, subscribed]);
...
<button
+ ref={confirmationCloseRef}
onClick={onClose}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {subscribed ? ( | |
| <divstyle={{textAlign: 'center',padding: '10px 4px 6px'}}> | |
| {/*carriesheadingIdtoo—itiswhat`aria-labelledby`pointsat | |
| oncetheform is replacedbythisstate*/} | |
| <h2id={headingId}style={{margin: 0,fontFamily: "'Playfair Display',serif",fontSize: 30,fontWeight: 600,color: '#12201A',lineHeight: 1.1}}> | |
| You’reonthe<emstyle={{color: '#0C5638'}}>tree.</em> | |
| </h2> | |
| <pstyle={{margin: '10px 0 0',fontSize: 14,color: '#61726A',fontStyle: 'italic'}}> | |
| Seeyouintheinbox·TheTeam | |
| </p> | |
| <button | |
| onClick={onClose} | |
| type="button" | |
| className="ld-btn-solid" | |
| style={{marginTop: 22,background: '#0C5638',color: '#fff',border: 'none',borderRadius: 6,padding: '13px 26px',fontFamily: "'DM Sans',sans-serif",fontWeight: 600,fontSize: 14,cursor: 'pointer',transition: 'filter 200ms'}} | |
| > | |
| Close | |
| </button> | |
| </div> | |
| {subscribed ? ( | |
| <divstyle={{textAlign: 'center',padding: '10px 4px 6px'}}> | |
| {/*carriesheadingIdtoo—itiswhat`aria-labelledby`pointsat | |
| oncetheform is replacedbythisstate*/} | |
| <h2id={headingId}style={{margin: 0,fontFamily: "'Playfair Display',serif",fontSize: 30,fontWeight: 600,color: '`#12201A`',lineHeight: 1.1}}> | |
| You’reonthe<emstyle={{color: '`#0C5638`'}}>tree.</em> | |
| </h2> | |
| <pstyle={{margin: '10px 0 0',fontSize: 14,color: '`#61726A`',fontStyle: 'italic'}}> | |
| Seeyouintheinbox·TheTeam | |
| </p> | |
| <button | |
| ref={confirmationCloseRef} | |
| onClick={onClose} | |
| type="button" | |
| className="ld-btn-solid" | |
| style={{marginTop: 22,background: '`#0C5638`',color: '`#fff`',border: 'none',borderRadius: 6,padding: '13px 26px',fontFamily: "'DM Sans',sans-serif",fontWeight: 600,fontSize: 14,cursor: 'pointer',transition: 'filter 200ms'}} | |
| > | |
| Close | |
| </button> | |
| </div> |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/landing-v5/BetaModal.tsx` around lines 52 - 70,
Update the subscribed confirmation branch in BetaModal so the rendered Close
button receives focus after the form is replaced. Add a ref to that button and
focus it after the subscribed state commits, using the component’s existing
lifecycle pattern; preserve the current onClose behavior and button styling.
Both the dialog and the inline newsletter now POST to
`/api/newsletter/subscribe` — the route already existed
(backend/routes/newsletter.py) and upserts on `email`, so a repeat signup
is a no-op there and a second submit lands on the same success state
rather than an error. No backend change needed.
`subscribe` moves from a synchronous flag flip to a real async action with
`subscribing` and `subscribeError` alongside it, and success is only
reported on a 2xx. Both forms became real <form>s so Enter submits, and
both disable their field and button while in flight rather than allowing a
double-post.
Errors go through `humanizeError` rather than rendering `err.message`.
`lib/api.ts` rejects with the raw response body, so the first cut put
"Internal Server Error" in front of a user; the helper keeps a
server-supplied `detail` when there is one — a 422 on a malformed address
is worth reading — and swaps anything else for a plain sentence.
Verified against the running app: the POST fires as
`POST /api/newsletter/subscribe {"email":"jack@bu.edu"}`. With no backend
up it 500s and renders "Something went wrong on our end. Try again in a
moment." rather than the raw body. Against a stubbed 200 the button reads
"Signing you up…" with the field disabled, then flips to "You're on the
tree.", and the inline newsletter reads subscribed afterwards.
Nothing is stored client-side, so a reload starts fresh — the upsert makes
that harmless.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>Two ways a cluster ended up somewhere the visitor did not put it. The faq field could not see its own copy pinning. `act-tutor` and `act-ingest` already match their field's sticky box to the act's stage, but `faq` is a static section: the field gets the section's rect, which is the right box for PLACING clusters and the wrong one for MOVING them. The question column is `sticky; top:110`, so it holds for `grid 733px - column 358px` = 375px while the section keeps scrolling, and CS 112 and PH 150 slid 374px out from under the words they belong to. Measured across 12 frames before and after. Rather than restate the column's geometry in a second place — the failure mode the DragField docstring already warns about — a field can now name the copy it travels with. `data-drag-track` on the field, `data-drag-anchor` on the column, and `syncClusters()` adds back that element's own travel. Opt-in, so newsletter and cta keep the old maths, and the page's scrollHeight is unchanged at 15744. The rejoin radius is gone. A drop within 70px of a node's home counted as "put back", which re-floated it: the anchor spring retargeted to the original spot and the ambient breathing restarted. Short drags are most drags, so most drags crawled home — 14px of travel still climbing 4s after a 40px drag, against 0px for a 90px one. Every drop now places the node at any distance. The cost, and the intended trade, is that a node can only rejoin its cluster on a reload; `ox`/`oy` existed only for that test and go with it. Verified in a browser at 15/40/60/150px: 0px drift at all four, and the faq pair now holds a constant gap to its heading through the whole pin and release. 460 frontend tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dropping a course puck stranded its concepts. The link force tows them while the drag is live and stops the instant the puck is placed, so the only pull left was each concept's own spring back to the original layout: they crawled home and left one edge stretched right across the field to a puck sitting somewhere else entirely. The puck now carries its cluster, and the formation it rebuilds is the one snapshotted when the puck was GRABBED. The two obvious alternatives are both wrong on screen: - the authored svg layout pops the cluster open, because the link force's 34-86px rest lengths settle it tighter than it was drawn (measured 48/48/50px live against 67/130/64px authored); - the concepts' live positions at the drop strand them behind a fast flick, which outruns the tow — 613px behind on a 3-frame drag. The whole cluster is placed, not just the puck. An unplaced concept is still in the charge and collide loops with its siblings but has lost the link force that balanced them, so it creeps outward: measured drifting 74/113/72px to 96/135/120px over 5s before this. A concept dragged on its own still moves alone. Verified in a browser on both an 18-step drag and a 2-step flick: gaps 49/47/50px before, 50/48/51px after the drop, unchanged 5s later. 461 frontend tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dragging a course puck into the edge band tore its cluster apart. The concepts are towed by the link force, which is a spring, so it only ever closes a fraction of the gap per frame — while autoscroll refills that gap every frame for as long as the puck is held. The concepts are welded to their field and travel with the page, so the separation grew without bound and drew one edge clean across the screen. Measured at 50/48/51px at rest going to 224/439/188px through a single autoscroll. The drop already rebuilt the formation, so this was purely the picture during the drag — which is the whole interaction. A held puck now carries its concepts rigidly, at the grab-time offsets `carry` already recorded, instead of leaving them on the spring. That matches what the drop does, so releasing changes nothing about the picture. A concept dragged on its own is not in `carry` and still travels alone, including out to the edge band. Verified in a browser: 50/48/51px held flat through eight samples of a continuous autoscroll, and unchanged after the drop. 463 frontend tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous fix stopped the branch stretching by seating each concept at a fixed offset from the held puck. It held the shape, but the cluster stopped reading as a thing being towed — no swing out behind the puck, no catching up when it stops. A pull is the point. The concepts now chase their grab-time offsets on a spring stiff enough to keep up (CARRY_PULL, ~20x the link force's gain) with a hard leash on top (CARRY_MAX_LAG_PX). The spring is what it looks like; the leash is what makes the guarantee. Under a constant speed V the spring settles at a lag of about 2.2V, so hand speed trails ~20px and rides free, while autoscroll's 22px/frame would reach ~49px and is held at 34 instead. The branch can never draw further than that, however long the page scrolls. The tow integrates its own velocity rather than reusing the node's. `p.vx` already carries the frame's charge and collide impulses from the concept's siblings, and inheriting them put the spring in a tug of war it only half won — the lag settled at 6-12px and crept rather than closing, which cost a while to find because the branch was running correctly the whole time. Verified in a browser: gaps 49/48/50px at rest, unchanged through a continuous autoscroll (dipping to 39/43/44px as the concepts swing in behind the puck, never wider), and 49/48/50px again after the drop. An ordinary drag and drop holds 49/48/50px throughout. 463 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The clusters ran the same d3 force model as the real graph but on different numbers, and walled the drag. Both are now the product's. Constants are `components/graph/KnowledgeGraph2D.tsx`'s, verbatim: link distance `40 + (1 - strength) * 90` (was `34 + (1 - s) * 52`) and collide radii 36 for a root, 18 upward for a concept (was 30 and 15). Link strength, the -400/-120 charge split, the 0.6 velocity decay and the 0.3 alpha target on grab already matched. The clusters settle wider for it — 49/48/50px to 59/105/60px on MA 242 — which reads better than it measures: the concept labels used to collide and now do not. The wall is gone. `clampHeldToViewport` held the dragged node inside the visible box, so a node could not be taken past the window edge. It was solving something real: `fx`/`fy` are local svg coords written only on pointermove, so the edge-band autoscroll — which scrolls the page with the pointer parked — left a held node pinned to coordinates sliding away under the cursor, and the clamp hauled it back. `repinHeld` fixes that at the cause instead, re-deriving `fx`/`fy` from the live client position through this frame's box, every frame. It is what the real graph does on every move, and it needs no wall: the node is wherever the pointer is, and the pointer cannot leave the window. Verified in a browser: a held node tracks the pointer to all four window corners at 0px offset, where the clamp used to stop it; the cluster holds 59/104/60px through a continuous autoscroll; and 59/105/60px across an ordinary drag, drop and five seconds after. 463 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The field had gone inert. Two things I added to stop a stretched branch did it, and both were the wrong shape of fix. A placed node dropped out of the link, charge and collide forces entirely, and the drop placed the node's WHOLE cluster — so after a single drag that cluster was out of the simulation for good. No sway, no reaction to anything, a dead ornament. On top of that a held puck towed its concepts at a fixed offset, bypassing the forces outright, so even mid-drag the cluster moved as one rigid piece. Now: a placed node ANCHORS instead of leaving. It still pulls and pushes its neighbours, it simply never moves itself, and only the node actually dropped is placed. That one change is what the tow was standing in for — drag a concept and the puck and its siblings swing after it, drop the puck and its concepts gather back to rest length around wherever it landed — so the tow, its leash and the grab-time snapshot are all gone. The breathing drift also runs for the rest of a cluster while one of its nodes is held, instead of freezing the field for the length of every interaction. That leaves the scroll, which was the real cause all along and is not a force. A cluster is welded to its field and travels with the page while the held node is pinned to a pointer that may not have moved; the difference hit the springs as motion the visitor never made, 22px every frame for as long as a node sat in the autoscroll band. It did not settle at a lag, it diverged — 59/104/60px to 243/488/207px through one autoscroll. `compensateScroll` takes it back out, read from the cluster's own screen movement so it is right whoever scrolled and in whatever order the frame ran. Measured in a browser: idle sway 2-20px per node over 3s; dragging one concept moves all four nodes 193-261px and they stay together after the drop; the autoscroll that stretched to 243/488/207px now peaks at 67/150/106px against a 59/104/60px rest; and a held node still tracks the pointer to all four window corners at 0px offset. 463 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A placed node had its idle drift cut to nothing, on the reading that "it stays where I put it" ruled out any movement at all. What that bought was a node that went dead the instant it was released — the one moment it is being watched closely — which reads as a bug rather than as precision. It keeps a third of the amplitude now (PLACED_SWAY). The anchor spring is what makes that a sway rather than a departure, and the free spring is far too weak to serve: it settles at `0.012 * 0.03`, against which even the reduced breathing walked a placed node 3.6px to 17px over 8.4s and was still climbing when the measurement stopped. PLACED_ANCHOR tethers a placed node at a fixed gain about 8x that, and the ratio of the two constants is the excursion — around 7px by construction, 9.1px measured in a browser, oscillating around the drop point rather than leaving it. Only the node actually under the pointer is now held perfectly still, and only while it is held. The tests that asserted exact stillness after a drop asserted the bug. They take a SWAY_PX envelope instead, in both the unit fixture and the browser journey, and the one that pinned "does not move at all" now pins the opposite: it must move, and it must stay inside the envelope. 463 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pulling a concept drew its cluster into a line across the section, and pulling the puck did not. The asymmetry was never intended: the link force is a spring, so it answers a gap with a fraction of the correction, and an arm under continuous load just keeps opening. No tuning fixes that, because a spring cannot promise a maximum. `holdArms()` promises it outright. After the forces run, any arm longer than the length it was drawn at is shortened back to it, moving whichever end is free. That is also what carries a cluster: pull one node, the arm reaches its limit, and its neighbour is dragged bodily along, the sweep passing it down the chain. The constraint has no idea which node is the puck, so a concept and a puck now behave identically — measured 18px and 17px of stretch against a 59/45/60px rest. Four things had to be true at once, and each one cost a revision: - Arms between two PLACED nodes were skipped as immovable, so nothing could ever shorten them. Every drop places a node, so after a couple of drags that was most arms — `CS 112 - Memoize` drawn clean across the FAQ. A placed node yields now, by PLACED_YIELD. - It has to yield a LITTLE. Sharing corrections evenly hauled a dropped node 40px back toward its cluster; refusing outright left an arm stretched whenever the geometry was infeasible, which three nodes on two arms often is. - Resting the spring exactly at the ceiling pinned a dragged-out cluster against it: every outward breath clipped the same frame, so a node dragged far away sat dead still. ARM_REST rests it inside. - A position solve has no memory, so the cluster snapped to the pointer with no follow-through. ARM_MOMENTUM hands the correction back as velocity — free nodes only, since a placed one coasting is the opposite of holding its spot. The drop also re-homes the whole cluster where the drag left it. Without that the free nodes spring back to the layout the page loaded with and, arms being capped, haul the placed node along: 515px. Verified in a browser over three successive drags of different nodes — arms 59/46/60, 63/62/59, 62/63/59, worst 67/63/64 against a drawn ceiling of 67/63/65 — and a far-dragged node still breathing 11px. 467 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tutor told a CS132 student "Markov chains are not in the course description" instead of teaching them. Root cause is framing, not retrieval: RAG correctly returned nothing (0.55 threshold), but the unconditionally-injected catalog block reads as a boundary, so the model falls back to closed-book RAG behavior and declines. Spec separates course *information* (catalog metadata — silent unless asked) from course *material* (teaching substance — used when relevant), and defines the fallback when material is thin: behave as the original Gemini-era tutor did. Also restores the formatting toolkit from prompts/preamble.txt, which the frontend still renders in full. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five TDD tasks: optional header on format_rag_context (keeps quiz byte-identical), relabel the chat tutor's catalog/RAG blocks, add the SCOPE rule to the shared preamble, restore the formatting toolkit from the legacy preamble, and a behavioral eval case for the refusal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Andres owns full stack, Jack AI architecture and new features, Luke API integration, Jose frontend; the blurbs move with the labels so a tile does not describe work its owner no longer does. Recognition on /team was a verbatim second copy of the awards already on /about, so it and its now-unused TEAM_AWARDS export are gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stagger delays move with the tiles so the fade still runs in reading order rather than jumping back up the grid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Her blurb is drawn from the Marketing Intern posting in careers/jobs.ts (social, content, student orgs, short-form video) rather than invented achievements, since nothing in the repo records her work yet. A fifth tile also retires the three "four of us" claims the page made. The intro no longer fixes a headcount and does not assert she is a BU student, which the repo does not establish either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four uncommitted fixes that had been sitting in the worktree since Aug 8, each independent: - ActTutor pushes the prism back by RADIUS and scales the subtree with `zoom`, so the front face lands on the perspective origin plane and rasterises 1:1 instead of being resampled 24% larger than it was drawn. - The hero's two washes veil by screen position, not by element, so they were catching whatever panel drifted under them (~35% on the knowledge graph card, ~20% on mastery). Cut so no panel sits under ~0.12. - The flashcard miniature clips its faces and drops a half-step of type, so the back face stops overflowing its card. - The WebGL panel rig now rides `engine.heroShiftPx` — the same scroll curve as the hero copy — instead of running its own parallax. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
) The scroll-follow half of "a dropped node stays where it was put, and scrolls with the page" has been red on main since the landing-v5 port (#524) merged, taking the whole e2e browser lane with it. It is not flaky: three CI retries and every local run land within 0.2px of each other, ~98px short of where the assertion expects the node. The product is right and the journey was wrong. `faq` is the one section in DragField's `TRACKS`, and `engine/sim.ts::syncClusters()` positions a tracked cluster at `field.top + a.top + dy`, where `dy = (track.top - field.top) - trackTop`. That collapses to `cluster_y = track.top + const`: the cluster is welded to the FAQ question column, which is `sticky; top:110`. Instrumented over the journey's own 300px scroll: scrollY +300.00 #faq top -300.00 track top -202.81 <- pinned at top:110 partway through cluster top -202.80 probe node y -202.60 error 97.40 (CI: 98.2-98.4) The column had exactly 202.81px of travel left before its pin, so ~97px of the scroll happened with the copy held still and the cluster correctly held still with it. Welding those clusters to their copy is deliberate — 8bb3486 added it because they slid 374px out from under the words they belong to. So the assertion was measuring the one coupling this section does not have. The other two scroll journeys in this file already measure relatively — a ring against its own cluster, a cluster against its act's stage — and both pass; this one reached for raw scrollY, on the single cluster where that is untrue. It picked cluster 4 for elbow room, not knowing `faq` is also the only tracked field. Re-frames it against the weld instead, read from the field's own `data-drag-track` so the test follows the product rather than duplicating it: drop the weld and `weldTop()` falls back to the field, which is the plain 1:1 page-scroll reference. The intent is unchanged — placed, not detached — and it keeps its teeth: a guard that the weld target really moved (>100px) plus the exact 300px scroll, so a node pinned to the screen over a page that never scrolled still fails. Pointing the same assertion at the untracked section reproduces the 97.40px failure, so it is load-bearing, not vacuous. Verified on the full local stack: this spec 6/6 twice, full suite 47/48 with one unrelated gradebook flake that passed 3/3 on re-run (retries are CI-only). Closes#566 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
) (#568) * test(e2e): measure a dropped node against the copy it is welded to (#566) The scroll-follow half of "a dropped node stays where it was put, and scrolls with the page" has been red on main since the landing-v5 port (#524) merged, taking the whole e2e browser lane with it. It is not flaky: three CI retries and every local run land within 0.2px of each other, ~98px short of where the assertion expects the node. The product is right and the journey was wrong. `faq` is the one section in DragField's `TRACKS`, and `engine/sim.ts::syncClusters()` positions a tracked cluster at `field.top + a.top + dy`, where `dy = (track.top - field.top) - trackTop`. That collapses to `cluster_y = track.top + const`: the cluster is welded to the FAQ question column, which is `sticky; top:110`. Instrumented over the journey's own 300px scroll: scrollY +300.00 #faq top -300.00 track top -202.81 <- pinned at top:110 partway through cluster top -202.80 probe node y -202.60 error 97.40 (CI: 98.2-98.4) The column had exactly 202.81px of travel left before its pin, so ~97px of the scroll happened with the copy held still and the cluster correctly held still with it. Welding those clusters to their copy is deliberate — 8bb3486 added it because they slid 374px out from under the words they belong to. So the assertion was measuring the one coupling this section does not have. The other two scroll journeys in this file already measure relatively — a ring against its own cluster, a cluster against its act's stage — and both pass; this one reached for raw scrollY, on the single cluster where that is untrue. It picked cluster 4 for elbow room, not knowing `faq` is also the only tracked field. Re-frames it against the weld instead, read from the field's own `data-drag-track` so the test follows the product rather than duplicating it: drop the weld and `weldTop()` falls back to the field, which is the plain 1:1 page-scroll reference. The intent is unchanged — placed, not detached — and it keeps its teeth: a guard that the weld target really moved (>100px) plus the exact 300px scroll, so a node pinned to the screen over a page that never scrolled still fails. Pointing the same assertion at the untracked section reproduces the 97.40px failure, so it is load-bearing, not vacuous. Verified on the full local stack: this spec 6/6 twice, full suite 47/48 with one unrelated gradebook flake that passed 3/3 on re-run (retries are CI-only). Closes#566 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(e2e): let the journey own its oracle, and put the 1:1 case back (#566) Review of the first commit found the fix had bought its elegance with the thing the test is for. `weldTop()` read the reference out of `data-drag-track` — the product's own declaration — and fell back to the field when it was absent. So deleting the `faq` entry from DragField's `TRACKS` would regress the product (clusters slide 374px out from under the words again, the bug 8bb3486 fixed) while the test re-framed itself to match and stayed green. A test that derives its expectation from the code under test cannot fail when that code is wrong. Proven, not argued: emptying `TRACKS` against the first version left it passing; against this one the offset drifts 96.69px and it fails, while the unmutated page sits at 0.45px. The oracle now belongs to the test — `FAQ_COPY` names the column — and the reference is resolved section-scoped, the way `engine/sim.ts:296` resolves it (`(field.closest('section') ?? document)`), so the journey measures against the element the sim actually bound to instead of whatever matches first document-wide. Also from the review: - The invariant is expressed as an offset that must not change, reusing the idiom the act-tutor journey already uses, rather than a signed delta that only worked out because the page scrolls downward. - The "did the reference move" guard is signed (`< -100`), not `Math.abs(...) > 100`: scrolling down must carry content up, and an absolute value would bless a copy that translated the wrong way. - Preconditions run before the guard, so a page that failed to scroll says so instead of blaming the weld. - Node and reference are sampled in ONE `page.evaluate`; two round trips compared two different frames of a still-integrating sim. - `toBeCloseTo(300, 0)` rather than exact float equality on a browser-computed scroll offset. - The dead `section` parameter is gone. It was never passed, and `weldTop(page, CLUSTER)` — plausible, given the adjacent constant — built `#4 .drag-field` and threw a raw SyntaxError. The deeper finding was coverage: re-framing the only page-relative assertion in the file left nothing tying a cluster to raw document scroll, while the file header still teaches that symptom 4 survived every earlier test because they measured against a field instead of the page. So the plain case is restored where it is actually true — a new journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no sticky stage, and unlike `cta` has room below it for the scroll. It carries the original assertion verbatim; measured 1:1 error is 0.58px. Verified on the full local stack, rebased onto main at 7863210. Control first, on UNPATCHED main: 47 passed, 1 failed — and the one failure is this journey, so the lane is still red on current main and this is still the fix it needs. With the patch applied, two consecutive full suites: 49 passed, 0 failed. The spec alone, three consecutive runs: 7/7 each time. That also closes out the gradebook.spec.ts:35 question raised against the previous revision. It failed 2 of 3 full-suite runs then, always on a tree carrying an in-flight #553, and I could not tell a suite-context flake from a real intermittent in enrollment resolution. #553 has since merged; across the three full suites above it passed every time. It has not reproduced on current main, so there is nothing to file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Ports
Sapling Landing v5.dc.html(fromdocs/frontend-audit/Sapling landing page import (1).zip) into the Next.js frontend. v5 is now the landing page at/, replacing the previous marketing landing.Supersedes #523. Based on
feat/landing-v4-port, so its four commits ride along — v5 reuses that branch's scroll engine, course-graph model and editorial content. If v5 is the direction, #523 can be closed unmerged.The title screen is a ground-up rebuild
Not a v4 tweak. Reading the source rather than the summary that accompanied the task turned up six divergences:
#12201A#0C5638heroWipeclip-pathheroWipeis defined, never referencedrgba(43,140,150,0.2)Act III is likewise not the clipped 918×348 card track described in the brief — the source has a rotating triangular-prism carousel.
The v4 title screen was deleted rather than kept alongside.
three/@types/threewere already dependencies.Four dead branches in the source
The design component carries a lot of machinery its own markup never reaches. Documented at each site:
const wantDark = false— the navbar's full two-theme colour table is built, then pinned to light. Wired live here, driven off the section under the viewport centre, as the brief calls for.heroWipe/heroCharkeyframes — defined, unreferenced. Not ported.setHero0/1/2+ mode pills — exist only inrenderVals(), soheroModeis pinned at 0 and only the panel scene can ever be seen. The constellation and growth-field shaders are unreachable; porting them would be porting dead code.plantVal,plantedCount,_plant(), a localStorage-backed grove and a canvas ref, none of it referenced. The CTA is heading + line + button.Two engine bugs fixed
Both pre-existing in the v4 port, both invisible until a section actually had drag clusters:
sim.tsreads a ring's halo viapreviousElementSiblingand its caption vianextElementSibling; a<g>wrapper broke the adjacency and it threw on a null label.position:fixedshell, and re-appliesanchor + field.rectevery frame — which is what lets a cluster track its sticky section, and where the ≤150px drift inside a pinned act comes from.Verified against the original served side by side: at 45% through act-tutor the source puts its 7 rings on screen at y≈121–608. This port now matches; before the fix it was 0 of 7, parked 8752px down.
Generated, not retyped
galleryMinis.tsx— the 8 card miniatures, mechanically converted from the source's<article data-tk>blocks. Dense decorative animation where exact geometry and per-element delays are the effect. Regenerate rather than hand-edit.dragClusters.ts— the 8 course clusters, ~2KB of inline SVG each in the source, reduced to data + one component.companionContent.ts— 18 copy arrays lifted verbatim from the sibling pages.Also in this PR
/orphanedmarketing/graph/(~95KB of passing tests for a surface no route mounts),marketing/surfaces/,FeatureBand,SurfaceBento,featureBands.HeroCard/SignInModalstay — the nav's Sign In opens the real OAuth modal.e2e/landing-graph.spec.tsgoes with the component it pinned.public-seo.spec.ts's SSR guard is retargeted, not deleted: its failure mode (anssr: falsesilently dropping the landing's copy from crawled HTML) outlived its selectors, and is sharper on a canvas/WebGL page. It deliberately does not assert the wordmark — that scrambles in client-side, so the h1'saria-labelis checked instead. All 7 assertions verified against raw SSR output./team,/wiki,/gallery,/news,/faq). Once v5 took/, those links 404'd. They use the warm paper palette deliberately — that is not a mistake to unify.Known gaps
FeatureLabdesign component per kind via<dc-import>— a distinct 75KB file the task's file list did not scope in. Layout, FLIP, rail, copy and close are faithful; the pane shows the card's miniature enlarged.Verification
tsc --noEmitexit 0 ·eslintexit 0 ·next buildexit 0, all 5 new routes prerendered static🤖 Generated with Claude Code
Summary by CodeRabbit