Skip to content

feat(landing): port Sapling Landing v5 - #524

Merged
Darkest-Teddy merged 48 commits into
mainfrom
feat/landing-v5-port
Aug 16, 2026
Merged

feat(landing): port Sapling Landing v5#524
Darkest-Teddy merged 48 commits into
mainfrom
feat/landing-v5-port

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Ports Sapling Landing v5.dc.html (from docs/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:

AssumedActual v5 source
WordmarkArchivo 800, ink #12201APlayfair Display 600, #0C5638
RevealheroWipe clip-pathcharacter scrambleheroWipe is defined, never referenced
Eyebrowmono "GROW YOUR KNOWLEDGE"Playfair "Grow Your Knowledge", title case, scrambled
Cascadetagline leads (760ms), wordmark follows 260ms behind — v4 ran the reverse
Right bloommirrored greenteal rgba(43,140,150,0.2)
Panel rigCSS perspective cardsthree.js WebGL scene

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/three were 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:

  1. 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.
  2. heroWipe / heroChar keyframes — defined, unreferenced. Not ported.
  3. setHero0/1/2 + mode pills — exist only in renderVals(), so heroMode is 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.
  4. "Plant a concept"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.ts reads a ring's halo via previousElementSibling and its caption via nextElementSibling; a <g> wrapper broke the adjacency and it threw on a null label.
  • Clusters were anchored once, in page coordinates. The source captures each cluster's offset within its field, parks the overlay in 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 ≤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

  • The old marketing landing is retired. Deleting / orphaned marketing/graph/ (~95KB of passing tests for a surface no route mounts), marketing/surfaces/, FeatureBand, SurfaceBento, featureBands. HeroCard/SignInModal stay — the nav's Sign In opens the real OAuth modal.
  • e2e/landing-graph.spec.ts goes with the component it pinned. public-seo.spec.ts's SSR guard is retargeted, not deleted: its failure mode (an ssr: false silently 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's aria-label is checked instead. All 7 assertions verified against raw SSR output.
  • The five companion pages the nav links to (/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

  • Feature lab demo pane. The source mounts a separate FeatureLab design 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.
  • Gallery tiles have no artwork — the import ships no screenshots.

Verification

  • tsc --noEmit exit 0 · eslint exit 0 · next build exit 0, all 5 new routes prerendered static
  • Unit suite 59 files / 423 tests passing
  • Every section walked in a real browser at multiple scroll positions, no console errors; all 9 public routes return 200

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced a redesigned landing experience with interactive knowledge graphs, tutoring modes, feature demos, gallery, journal, FAQ, and beta signup.
    • Added public FAQ, Gallery, News, Team, and Wiki pages with responsive navigation and searchable or filterable content.
    • Added interactive demonstrations for quizzes, flashcards, grades, calendars, notes, study rooms, guides, and tutoring.
  • Documentation
    • Improved server-rendered landing-page checks for key accessible content and beta messaging.
  • Refactor
    • Retired the previous marketing sections and legacy landing-page presentation.

Darkest-Teddyand others added 5 commits August 3, 2026 22:11
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>
@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ebab108d-0405-4ae1-8d00-108ba83a0144

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

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

Changes

Landing v5 experience

Layer / File(s)Summary
Landing content and data contracts
frontend/src/lib/landing/*
Added landing content, course graph data, drag clusters, color utilities, and companion-page datasets.
Animation and interaction engines
frontend/src/lib/landing/engine/*, frontend/src/lib/landing/hero3d/*
Added graph rendering, canvas effects, layout measurement, marquee and FLIP behavior, planting, drag simulation, and WebGL hero support.
Landing composition
frontend/src/app/(public)/page.tsx, frontend/src/components/landing-v5/*, frontend/src/components/landing/useLanding.ts
Replaced the previous landing implementation with v5 hero, graph, ingest, tutor, gallery, FAQ, journal, CTA, navigation, modal, and lifecycle components.
Interactive feature labs
frontend/src/components/landing-v5/lab/*
Added interactive quiz, flashcard, guide, grade, calendar, notes, rooms, and tutor demos with shared fixture data and controls.
Companion pages
frontend/src/components/companion/*, frontend/src/app/(public)/{about,team,wiki,faq,gallery,news}/*
Added shared companion navigation and public About, Team, Wiki, FAQ, Gallery, and News routes.
Styling and SSR validation
frontend/src/app/globals.css, frontend/e2e/public-seo.spec.ts, frontend/eslint-suppressions.json, frontend/src/lib/api.ts
Added scoped landing and companion styles, responsive animations, newsletter API wiring, and SSR checks for landing-page copy. Removed obsolete ESLint suppressions.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers:andresl230

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 36.59% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the primary change: porting Sapling Landing v5 into the landing page.
Description check✅ PassedThe description explains the purpose, major changes, known gaps, related issue context, and verification results in substantial detail.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/landing-v5-port

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Aug 4, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

Darkest-Teddyand others added 6 commits August 4, 2026 15:34
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 4, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingfc36068Commit Preview URL

Branch Preview URL
Aug 16 2026, 09:58 PM

Darkest-Teddyand others added 6 commits August 4, 2026 16:21
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>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review August 5, 2026 00:09

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

extract and summarize timeouts are untracked.

saveT is 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 win

The global space handler blocks button activation.

The keydown listener calls e.preventDefault() for every space press on window. A focused button then cannot be activated with space, including "Study again" at line 90. Skip the flip and the preventDefault call 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 win

The "no concepts" sentinel is counted as a linked concept.

When no matcher hits, concepts receives 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. Keep concepts empty 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 win

Do not cache a zero-sized canvas measurement.

cv() caches w/h from clientWidth/clientHeight on the first call. The cache is dropped only by invalidateCanvas, which measure() calls only when the viewport dimensions change (see frontend/src/lib/landing/engine/measure.ts lines 98-104). If the first cv() call happens while the canvas is not laid out yet, or while its section is hidden, the cache stores 0 × 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 win

Each 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 /news route. Point each card at the article, or at /news until 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 win

Label binding depends on an SVG sibling order that the markup does not guarantee.engine/sim.ts locates a node's caption through ring.nextElementSibling and casts it to SVGTextElement, while DragField renders 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 writes x/y onto it every frame, and ly becomes NaN.

  • frontend/src/lib/landing/engine/sim.ts#L184-L203: check nextElementSibling.tagName === 'text' before the cast, type SimNode.label as SVGTextElement | 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 /faq route. 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 win

Set 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 current actions.subscribe, at frontend/src/components/landing/useLanding.ts:624, only checks email.includes('@') and sets local subscribed state. If this should be a stub, make that intent explicit; otherwise wire onSubscribe to 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 win

Closed answers stay in the accessibility tree, and the button has no aria-controls.

The collapsed panel uses maxHeight: 0, opacity: 0, and overflow: 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 declares aria-expanded without naming the region it controls.

Add aria-controls with a stable id, and set aria-hidden on the panel while it is closed. aria-hidden does not disturb the max-height transition, 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 declare role="menu", but their children are plain button elements and next/link anchors. The ARIA menu pattern permits only menuitem, menuitemradio, and menuitemcheckbox children, 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 add menuitem roles.

  • frontend/src/components/landing-v5/Navbar.tsx#L199-L235: remove role="menu" from the compact panel, and remove aria-haspopup="menu" from the trigger at line 120. Keep aria-expanded.
  • frontend/src/components/landing-v5/Closing.tsx#L154-L182: remove role="menu" from the jump panel, and remove aria-haspopup="menu" from the pill at line 188. The enclosing nav with aria-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 win

Clear the deferred openGal timers on unmount.

actions.openGal schedules actions.enterExplore / actions.openGal 260 ms later from onQuiz and onLearn. If the page unmounts before those timers fire, openGal runs after cleanup and writes document.body.style.overflow = 'hidden' after the landing cleanup reset that overflow. Keep the timer refs in useLanding so cleanup can call clearTimeout for 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 win

Expose the selected tutor mode, and keep the hidden pills out of the tab order.

Two gaps in this control group:

  1. The pills are a mode selector, but the selected state is carried only by background and color. Assistive technology cannot report which mode is active. Add aria-pressed={tutorMode === i}.
  2. The container starts at opacity: 0 with pointerEvents: '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 opacity on the container. Have it also clear visibility or the inert attribute 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 win

Add an Escape key exit for explore mode.

Explore mode covers the act and closes the navbar, but useLanding only handles Escape for gallery and jump controls. If explore mode is active, call onExitExplore from 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 win

Release 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. Add renderer.forceContextLoss() after renderer.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

Segmented is unused; two demos duplicate it inline.

The comment states that the tutor and calendar demos use this control. They do not import it. CalendarDemo.tsx lines 61-76 and TutorDemo.tsx lines 65-80 each contain an inline copy of the same markup and style logic. Use Segmented in 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 value

Difficulty badge changes while the same question is shown.

submit updates streak before it sets phase to review. qi does 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 from answers.length captured 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 value

Row percentage is unclamped while the weighted grade is clamped.

Line 25 clamps the score into [0, r.possible]. Line 64 uses the unclamped raw. If the user types 70 in a 50-point row, the row shows 140% 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 value

Remove duplicated TIER from labData.ts.

labData.ts exports TIER, but the lab demos import LAB_TIER and render with labData.ts content. The four hex values and keys match LAB_TIER, so two copies of this swatch set can drift. Drop TIER from labData.ts and keep using LAB_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 win

Replace 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 value

Make lede optional so the privacy section does not render an empty paragraph.

The privacy section passes lede="". Section always renders the <p>, so the page emits an empty paragraph with 0 0 20px margins. 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 win

Avoid the per-frame layout read in update().

update() runs from the engine tick on every frame the gallery is on screen. Line 136 reads wrap.clientWidth on each of those frames, and line 156 writes track.style.transform. The write dirties style, so the next frame's read forces a style recalculation and layout. That contradicts the engine invariant documented in frontend/src/lib/landing/engine/index.ts lines 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 to cleanups:

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 value

Mark the active rail button for assistive technology.

on changes only the colors. Add aria-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 value

The wrapper duplicates the miniature's own container.

The <div> here sets height: 166, borderRadius: 13, overflow: hidden, the same background, and the same border. Every entry in GALLERY_MINIS opens with a <div> carrying that identical style set (see galleryMinis.tsx line 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.ran is never read.

armFlip sets ran = false and flipClose resets it, but no code in this module or in frontend/src/components/landing/useLanding.ts reads it. The consumer tracks the same guard with its own flipRan ref (see useLanding.ts lines 158, 552, 574-575). Two guards for one invariant can drift. Either remove ran from FlipState or make flipOpen own the guard and delete flipRan from 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 value

Points behind the camera reach the link pass with a degenerate scale.

sc = FL / (FL + z) is computed for every node. Rotation mixes ox into z, and ox reaches about ±740 while oz reaches about ±340, so z can pass -1000. At z === -FL the divide produces Infinity, and below -FL it 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 with Infinity scale gives reach === Infinity, so the break at 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 > -FL test 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 value

The 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. A MutationObserver on root would react to the same additions without the standing timer. This is a nice-to-have; the current cost is one querySelectorAll per 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

up ignores pointerId, and the window listeners are registered once per cluster.

Two small points in bindDrag:

  • up does not compare e.pointerId with f.drag.id. A second pointer that lifts cancels the active drag. onMove does compare the id, so the handlers are inconsistent.
  • onMove and up are added to window inside 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 win

Share the companion-page list, and make the footer icon decorative.

Two points:

  1. FOOTER_LINKS repeats PAGES in Navbar.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.
  2. Line 91 sets alt="Sapling" on the icon, and the adjacent text already reads "Sapling · © 2026". A screen reader announces the name twice. Navbar.tsx line 100 already uses alt="" 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

📥 Commits

Reviewing files that changed from the base of the PR and between ec34bf1 and 741ec10.

⛔ Files ignored due to path filters (3)
  • frontend/public/journal-ai-homework.png is excluded by !**/*.png
  • frontend/public/journal-founding.png is excluded by !**/*.png
  • frontend/public/kofi-symbol.png is excluded by !**/*.png
📒 Files selected for processing (75)
  • frontend/e2e/landing-graph.spec.ts
  • frontend/e2e/public-seo.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/gallery/page.tsx
  • frontend/src/app/(public)/news/page.tsx
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/(public)/team/page.tsx
  • frontend/src/app/(public)/wiki/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/companion/CompanionShell.tsx
  • frontend/src/components/companion/primitives.tsx
  • frontend/src/components/landing-v5/ActGraph.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/ActTutor.tsx
  • frontend/src/components/landing-v5/Closing.tsx
  • frontend/src/components/landing-v5/DragField.tsx
  • frontend/src/components/landing-v5/Faq.tsx
  • frontend/src/components/landing-v5/FeatureLab.tsx
  • frontend/src/components/landing-v5/Gallery.tsx
  • frontend/src/components/landing-v5/Hero.tsx
  • frontend/src/components/landing-v5/IntroOverlay.tsx
  • frontend/src/components/landing-v5/Journal.tsx
  • frontend/src/components/landing-v5/Navbar.tsx
  • frontend/src/components/landing-v5/galleryMinis.tsx
  • frontend/src/components/landing-v5/lab/CalendarDemo.tsx
  • frontend/src/components/landing-v5/lab/CardsDemo.tsx
  • frontend/src/components/landing-v5/lab/GradesDemo.tsx
  • frontend/src/components/landing-v5/lab/GuideDemo.tsx
  • frontend/src/components/landing-v5/lab/LabShell.tsx
  • frontend/src/components/landing-v5/lab/NotesDemo.tsx
  • frontend/src/components/landing-v5/lab/QuizDemo.tsx
  • frontend/src/components/landing-v5/lab/RoomsDemo.tsx
  • frontend/src/components/landing-v5/lab/TutorDemo.tsx
  • frontend/src/components/landing-v5/lab/index.tsx
  • frontend/src/components/landing-v5/lab/labData.ts
  • frontend/src/components/landing-v5/navTheme.ts
  • frontend/src/components/landing/useLanding.ts
  • frontend/src/components/marketing/FeatureBand.test.tsx
  • frontend/src/components/marketing/FeatureBand.tsx
  • frontend/src/components/marketing/SurfaceBento.test.tsx
  • frontend/src/components/marketing/SurfaceBento.tsx
  • frontend/src/components/marketing/featureBands.tsx
  • frontend/src/components/marketing/graph/KnowledgeGraphDemo.test.tsx
  • frontend/src/components/marketing/graph/KnowledgeGraphDemo.tsx
  • frontend/src/components/marketing/graph/courseGraphs.test.ts
  • frontend/src/components/marketing/graph/courseGraphs.ts
  • frontend/src/components/marketing/graph/layout.test.ts
  • frontend/src/components/marketing/graph/layout.ts
  • frontend/src/components/marketing/surfaces/GradebookSurface.tsx
  • frontend/src/components/marketing/surfaces/NotesSurface.tsx
  • frontend/src/components/marketing/surfaces/QuizSurface.tsx
  • frontend/src/components/marketing/surfaces/ReviewSurface.tsx
  • frontend/src/components/marketing/surfaces/RoomsSurface.tsx
  • frontend/src/components/marketing/surfaces/Surface.tsx
  • frontend/src/components/marketing/surfaces/TutorSurface.tsx
  • frontend/src/components/marketing/surfaces/UploadSurface.tsx
  • frontend/src/lib/landing/color.ts
  • frontend/src/lib/landing/companionContent.ts
  • frontend/src/lib/landing/content.ts
  • frontend/src/lib/landing/course.ts
  • frontend/src/lib/landing/dragClusters.ts
  • frontend/src/lib/landing/engine/ambient.ts
  • frontend/src/lib/landing/engine/dom.ts
  • frontend/src/lib/landing/engine/flip.ts
  • frontend/src/lib/landing/engine/graph.ts
  • frontend/src/lib/landing/engine/graphView.ts
  • frontend/src/lib/landing/engine/hero.ts
  • frontend/src/lib/landing/engine/index.ts
  • frontend/src/lib/landing/engine/marquee.ts
  • frontend/src/lib/landing/engine/measure.ts
  • frontend/src/lib/landing/engine/plant.ts
  • frontend/src/lib/landing/engine/sim.ts
  • frontend/src/lib/landing/hero3d/cardTexture.ts
  • frontend/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

Comment on lines +1957 to +2005
@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); } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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

Comment on lines +41 to +50
<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' }}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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 no aria-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.

Comment on lines +62 to +85
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>
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Comment on lines +37 to +44
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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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 ms extract and summarize timeouts in refs and clear them in the existing unmount effect beside saveT.
📍 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.

Comment on lines +21 to +24
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' },
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
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.

Comment on lines +104 to +124
<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>
);
})}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
<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.

Comment on lines +213 to +224
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
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
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.

Comment on lines +423 to +430
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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:

  1. A second ensureInit on the same DOM finds .drag-field elements with no [data-dragnode] children, returns false at 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 in frontend/src/components/landing/useLanding.ts lines 162-210.
  2. React still owns those cluster nodes, because DragField rendered 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.

Darkest-Teddyand others added 6 commits August 4, 2026 20:26
…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>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 741ec10 and e506827.

📒 Files selected for processing (13)
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/gallery/page.tsx
  • frontend/src/app/(public)/news/page.tsx
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/(public)/team/page.tsx
  • frontend/src/app/(public)/wiki/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/companion/CompanionShell.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/BetaModal.tsx
  • frontend/src/components/landing-v5/DragField.tsx
  • frontend/src/components/landing-v5/FeatureLab.tsx
  • frontend/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

Comment on lines +52 to +70
{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&rsquo;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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
{subscribed ? (
<divstyle={{textAlign: 'center',padding: '10px 4px 6px'}}>
{/*carriesheadingIdtooitiswhat`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&rsquo;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'}}>
{/*carriesheadingIdtooitiswhat`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&rsquo;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>
Darkest-Teddyand others added 13 commits August 9, 2026 20:13
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>
Darkest-Teddyand others added 2 commits August 16, 2026 17:48
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>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review August 16, 2026 21:59
@Darkest-Teddy
Darkest-Teddy merged commit 4f5a7f9 into mainAug 16, 2026
6 of 7 checks passed
AndresL230 added a commit that referenced this pull request Aug 22, 2026
)
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>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
) (#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>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
* Revert "test(e2e): measure a dropped node against the copy it is welded to (#566) (#568)"
This reverts commit 752a82c.
* Revert "Merge pull request #524 from SaplingLearn/feat/landing-v5-port"
This reverts commit 4f5a7f9, reversing
changes made to 0effc9e.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Darkest-Teddy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(landing): port Sapling Landing v5 by Darkest-Teddy · Pull Request #524 · SaplingLearn/Sapling · GitHub
Skip to content

feat(landing): port Sapling Landing v5 - #524

Merged
Darkest-Teddy merged 48 commits into
mainfrom
feat/landing-v5-port
Aug 16, 2026
Merged

feat(landing): port Sapling Landing v5#524
Darkest-Teddy merged 48 commits into
mainfrom
feat/landing-v5-port

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Ports Sapling Landing v5.dc.html (from docs/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:

AssumedActual v5 source
WordmarkArchivo 800, ink #12201APlayfair Display 600, #0C5638
RevealheroWipe clip-pathcharacter scrambleheroWipe is defined, never referenced
Eyebrowmono "GROW YOUR KNOWLEDGE"Playfair "Grow Your Knowledge", title case, scrambled
Cascadetagline leads (760ms), wordmark follows 260ms behind — v4 ran the reverse
Right bloommirrored greenteal rgba(43,140,150,0.2)
Panel rigCSS perspective cardsthree.js WebGL scene

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/three were 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:

  1. 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.
  2. heroWipe / heroChar keyframes — defined, unreferenced. Not ported.
  3. setHero0/1/2 + mode pills — exist only in renderVals(), so heroMode is 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.
  4. "Plant a concept"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.ts reads a ring's halo via previousElementSibling and its caption via nextElementSibling; a <g> wrapper broke the adjacency and it threw on a null label.
  • Clusters were anchored once, in page coordinates. The source captures each cluster's offset within its field, parks the overlay in 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 ≤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

  • The old marketing landing is retired. Deleting / orphaned marketing/graph/ (~95KB of passing tests for a surface no route mounts), marketing/surfaces/, FeatureBand, SurfaceBento, featureBands. HeroCard/SignInModal stay — the nav's Sign In opens the real OAuth modal.
  • e2e/landing-graph.spec.ts goes with the component it pinned. public-seo.spec.ts's SSR guard is retargeted, not deleted: its failure mode (an ssr: false silently 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's aria-label is checked instead. All 7 assertions verified against raw SSR output.
  • The five companion pages the nav links to (/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

  • Feature lab demo pane. The source mounts a separate FeatureLab design 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.
  • Gallery tiles have no artwork — the import ships no screenshots.

Verification

  • tsc --noEmit exit 0 · eslint exit 0 · next build exit 0, all 5 new routes prerendered static
  • Unit suite 59 files / 423 tests passing
  • Every section walked in a real browser at multiple scroll positions, no console errors; all 9 public routes return 200

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced a redesigned landing experience with interactive knowledge graphs, tutoring modes, feature demos, gallery, journal, FAQ, and beta signup.
    • Added public FAQ, Gallery, News, Team, and Wiki pages with responsive navigation and searchable or filterable content.
    • Added interactive demonstrations for quizzes, flashcards, grades, calendars, notes, study rooms, guides, and tutoring.
  • Documentation
    • Improved server-rendered landing-page checks for key accessible content and beta messaging.
  • Refactor
    • Retired the previous marketing sections and legacy landing-page presentation.

Darkest-Teddyand others added 5 commits August 3, 2026 22:11
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>
@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ebab108d-0405-4ae1-8d00-108ba83a0144

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

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

Changes

Landing v5 experience

Layer / File(s)Summary
Landing content and data contracts
frontend/src/lib/landing/*
Added landing content, course graph data, drag clusters, color utilities, and companion-page datasets.
Animation and interaction engines
frontend/src/lib/landing/engine/*, frontend/src/lib/landing/hero3d/*
Added graph rendering, canvas effects, layout measurement, marquee and FLIP behavior, planting, drag simulation, and WebGL hero support.
Landing composition
frontend/src/app/(public)/page.tsx, frontend/src/components/landing-v5/*, frontend/src/components/landing/useLanding.ts
Replaced the previous landing implementation with v5 hero, graph, ingest, tutor, gallery, FAQ, journal, CTA, navigation, modal, and lifecycle components.
Interactive feature labs
frontend/src/components/landing-v5/lab/*
Added interactive quiz, flashcard, guide, grade, calendar, notes, rooms, and tutor demos with shared fixture data and controls.
Companion pages
frontend/src/components/companion/*, frontend/src/app/(public)/{about,team,wiki,faq,gallery,news}/*
Added shared companion navigation and public About, Team, Wiki, FAQ, Gallery, and News routes.
Styling and SSR validation
frontend/src/app/globals.css, frontend/e2e/public-seo.spec.ts, frontend/eslint-suppressions.json, frontend/src/lib/api.ts
Added scoped landing and companion styles, responsive animations, newsletter API wiring, and SSR checks for landing-page copy. Removed obsolete ESLint suppressions.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers:andresl230

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 36.59% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the primary change: porting Sapling Landing v5 into the landing page.
Description check✅ PassedThe description explains the purpose, major changes, known gaps, related issue context, and verification results in substantial detail.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/landing-v5-port

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Aug 4, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

Darkest-Teddyand others added 6 commits August 4, 2026 15:34
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 4, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingfc36068Commit Preview URL

Branch Preview URL
Aug 16 2026, 09:58 PM

Darkest-Teddyand others added 6 commits August 4, 2026 16:21
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>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review August 5, 2026 00:09

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

extract and summarize timeouts are untracked.

saveT is 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 win

The global space handler blocks button activation.

The keydown listener calls e.preventDefault() for every space press on window. A focused button then cannot be activated with space, including "Study again" at line 90. Skip the flip and the preventDefault call 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 win

The "no concepts" sentinel is counted as a linked concept.

When no matcher hits, concepts receives 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. Keep concepts empty 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 win

Do not cache a zero-sized canvas measurement.

cv() caches w/h from clientWidth/clientHeight on the first call. The cache is dropped only by invalidateCanvas, which measure() calls only when the viewport dimensions change (see frontend/src/lib/landing/engine/measure.ts lines 98-104). If the first cv() call happens while the canvas is not laid out yet, or while its section is hidden, the cache stores 0 × 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 win

Each 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 /news route. Point each card at the article, or at /news until 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 win

Label binding depends on an SVG sibling order that the markup does not guarantee.engine/sim.ts locates a node's caption through ring.nextElementSibling and casts it to SVGTextElement, while DragField renders 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 writes x/y onto it every frame, and ly becomes NaN.

  • frontend/src/lib/landing/engine/sim.ts#L184-L203: check nextElementSibling.tagName === 'text' before the cast, type SimNode.label as SVGTextElement | 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 /faq route. 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 win

Set 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 current actions.subscribe, at frontend/src/components/landing/useLanding.ts:624, only checks email.includes('@') and sets local subscribed state. If this should be a stub, make that intent explicit; otherwise wire onSubscribe to 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 win

Closed answers stay in the accessibility tree, and the button has no aria-controls.

The collapsed panel uses maxHeight: 0, opacity: 0, and overflow: 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 declares aria-expanded without naming the region it controls.

Add aria-controls with a stable id, and set aria-hidden on the panel while it is closed. aria-hidden does not disturb the max-height transition, 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 declare role="menu", but their children are plain button elements and next/link anchors. The ARIA menu pattern permits only menuitem, menuitemradio, and menuitemcheckbox children, 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 add menuitem roles.

  • frontend/src/components/landing-v5/Navbar.tsx#L199-L235: remove role="menu" from the compact panel, and remove aria-haspopup="menu" from the trigger at line 120. Keep aria-expanded.
  • frontend/src/components/landing-v5/Closing.tsx#L154-L182: remove role="menu" from the jump panel, and remove aria-haspopup="menu" from the pill at line 188. The enclosing nav with aria-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 win

Clear the deferred openGal timers on unmount.

actions.openGal schedules actions.enterExplore / actions.openGal 260 ms later from onQuiz and onLearn. If the page unmounts before those timers fire, openGal runs after cleanup and writes document.body.style.overflow = 'hidden' after the landing cleanup reset that overflow. Keep the timer refs in useLanding so cleanup can call clearTimeout for 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 win

Expose the selected tutor mode, and keep the hidden pills out of the tab order.

Two gaps in this control group:

  1. The pills are a mode selector, but the selected state is carried only by background and color. Assistive technology cannot report which mode is active. Add aria-pressed={tutorMode === i}.
  2. The container starts at opacity: 0 with pointerEvents: '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 opacity on the container. Have it also clear visibility or the inert attribute 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 win

Add an Escape key exit for explore mode.

Explore mode covers the act and closes the navbar, but useLanding only handles Escape for gallery and jump controls. If explore mode is active, call onExitExplore from 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 win

Release 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. Add renderer.forceContextLoss() after renderer.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

Segmented is unused; two demos duplicate it inline.

The comment states that the tutor and calendar demos use this control. They do not import it. CalendarDemo.tsx lines 61-76 and TutorDemo.tsx lines 65-80 each contain an inline copy of the same markup and style logic. Use Segmented in 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 value

Difficulty badge changes while the same question is shown.

submit updates streak before it sets phase to review. qi does 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 from answers.length captured 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 value

Row percentage is unclamped while the weighted grade is clamped.

Line 25 clamps the score into [0, r.possible]. Line 64 uses the unclamped raw. If the user types 70 in a 50-point row, the row shows 140% 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 value

Remove duplicated TIER from labData.ts.

labData.ts exports TIER, but the lab demos import LAB_TIER and render with labData.ts content. The four hex values and keys match LAB_TIER, so two copies of this swatch set can drift. Drop TIER from labData.ts and keep using LAB_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 win

Replace 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 value

Make lede optional so the privacy section does not render an empty paragraph.

The privacy section passes lede="". Section always renders the <p>, so the page emits an empty paragraph with 0 0 20px margins. 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 win

Avoid the per-frame layout read in update().

update() runs from the engine tick on every frame the gallery is on screen. Line 136 reads wrap.clientWidth on each of those frames, and line 156 writes track.style.transform. The write dirties style, so the next frame's read forces a style recalculation and layout. That contradicts the engine invariant documented in frontend/src/lib/landing/engine/index.ts lines 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 to cleanups:

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 value

Mark the active rail button for assistive technology.

on changes only the colors. Add aria-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 value

The wrapper duplicates the miniature's own container.

The <div> here sets height: 166, borderRadius: 13, overflow: hidden, the same background, and the same border. Every entry in GALLERY_MINIS opens with a <div> carrying that identical style set (see galleryMinis.tsx line 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.ran is never read.

armFlip sets ran = false and flipClose resets it, but no code in this module or in frontend/src/components/landing/useLanding.ts reads it. The consumer tracks the same guard with its own flipRan ref (see useLanding.ts lines 158, 552, 574-575). Two guards for one invariant can drift. Either remove ran from FlipState or make flipOpen own the guard and delete flipRan from 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 value

Points behind the camera reach the link pass with a degenerate scale.

sc = FL / (FL + z) is computed for every node. Rotation mixes ox into z, and ox reaches about ±740 while oz reaches about ±340, so z can pass -1000. At z === -FL the divide produces Infinity, and below -FL it 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 with Infinity scale gives reach === Infinity, so the break at 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 > -FL test 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 value

The 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. A MutationObserver on root would react to the same additions without the standing timer. This is a nice-to-have; the current cost is one querySelectorAll per 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

up ignores pointerId, and the window listeners are registered once per cluster.

Two small points in bindDrag:

  • up does not compare e.pointerId with f.drag.id. A second pointer that lifts cancels the active drag. onMove does compare the id, so the handlers are inconsistent.
  • onMove and up are added to window inside 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 win

Share the companion-page list, and make the footer icon decorative.

Two points:

  1. FOOTER_LINKS repeats PAGES in Navbar.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.
  2. Line 91 sets alt="Sapling" on the icon, and the adjacent text already reads "Sapling · © 2026". A screen reader announces the name twice. Navbar.tsx line 100 already uses alt="" 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

📥 Commits

Reviewing files that changed from the base of the PR and between ec34bf1 and 741ec10.

⛔ Files ignored due to path filters (3)
  • frontend/public/journal-ai-homework.png is excluded by !**/*.png
  • frontend/public/journal-founding.png is excluded by !**/*.png
  • frontend/public/kofi-symbol.png is excluded by !**/*.png
📒 Files selected for processing (75)
  • frontend/e2e/landing-graph.spec.ts
  • frontend/e2e/public-seo.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/gallery/page.tsx
  • frontend/src/app/(public)/news/page.tsx
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/(public)/team/page.tsx
  • frontend/src/app/(public)/wiki/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/companion/CompanionShell.tsx
  • frontend/src/components/companion/primitives.tsx
  • frontend/src/components/landing-v5/ActGraph.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/ActTutor.tsx
  • frontend/src/components/landing-v5/Closing.tsx
  • frontend/src/components/landing-v5/DragField.tsx
  • frontend/src/components/landing-v5/Faq.tsx
  • frontend/src/components/landing-v5/FeatureLab.tsx
  • frontend/src/components/landing-v5/Gallery.tsx
  • frontend/src/components/landing-v5/Hero.tsx
  • frontend/src/components/landing-v5/IntroOverlay.tsx
  • frontend/src/components/landing-v5/Journal.tsx
  • frontend/src/components/landing-v5/Navbar.tsx
  • frontend/src/components/landing-v5/galleryMinis.tsx
  • frontend/src/components/landing-v5/lab/CalendarDemo.tsx
  • frontend/src/components/landing-v5/lab/CardsDemo.tsx
  • frontend/src/components/landing-v5/lab/GradesDemo.tsx
  • frontend/src/components/landing-v5/lab/GuideDemo.tsx
  • frontend/src/components/landing-v5/lab/LabShell.tsx
  • frontend/src/components/landing-v5/lab/NotesDemo.tsx
  • frontend/src/components/landing-v5/lab/QuizDemo.tsx
  • frontend/src/components/landing-v5/lab/RoomsDemo.tsx
  • frontend/src/components/landing-v5/lab/TutorDemo.tsx
  • frontend/src/components/landing-v5/lab/index.tsx
  • frontend/src/components/landing-v5/lab/labData.ts
  • frontend/src/components/landing-v5/navTheme.ts
  • frontend/src/components/landing/useLanding.ts
  • frontend/src/components/marketing/FeatureBand.test.tsx
  • frontend/src/components/marketing/FeatureBand.tsx
  • frontend/src/components/marketing/SurfaceBento.test.tsx
  • frontend/src/components/marketing/SurfaceBento.tsx
  • frontend/src/components/marketing/featureBands.tsx
  • frontend/src/components/marketing/graph/KnowledgeGraphDemo.test.tsx
  • frontend/src/components/marketing/graph/KnowledgeGraphDemo.tsx
  • frontend/src/components/marketing/graph/courseGraphs.test.ts
  • frontend/src/components/marketing/graph/courseGraphs.ts
  • frontend/src/components/marketing/graph/layout.test.ts
  • frontend/src/components/marketing/graph/layout.ts
  • frontend/src/components/marketing/surfaces/GradebookSurface.tsx
  • frontend/src/components/marketing/surfaces/NotesSurface.tsx
  • frontend/src/components/marketing/surfaces/QuizSurface.tsx
  • frontend/src/components/marketing/surfaces/ReviewSurface.tsx
  • frontend/src/components/marketing/surfaces/RoomsSurface.tsx
  • frontend/src/components/marketing/surfaces/Surface.tsx
  • frontend/src/components/marketing/surfaces/TutorSurface.tsx
  • frontend/src/components/marketing/surfaces/UploadSurface.tsx
  • frontend/src/lib/landing/color.ts
  • frontend/src/lib/landing/companionContent.ts
  • frontend/src/lib/landing/content.ts
  • frontend/src/lib/landing/course.ts
  • frontend/src/lib/landing/dragClusters.ts
  • frontend/src/lib/landing/engine/ambient.ts
  • frontend/src/lib/landing/engine/dom.ts
  • frontend/src/lib/landing/engine/flip.ts
  • frontend/src/lib/landing/engine/graph.ts
  • frontend/src/lib/landing/engine/graphView.ts
  • frontend/src/lib/landing/engine/hero.ts
  • frontend/src/lib/landing/engine/index.ts
  • frontend/src/lib/landing/engine/marquee.ts
  • frontend/src/lib/landing/engine/measure.ts
  • frontend/src/lib/landing/engine/plant.ts
  • frontend/src/lib/landing/engine/sim.ts
  • frontend/src/lib/landing/hero3d/cardTexture.ts
  • frontend/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

Comment on lines +1957 to +2005
@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); } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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

Comment on lines +41 to +50
<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' }}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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 no aria-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.

Comment on lines +62 to +85
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>
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Comment on lines +37 to +44
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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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 ms extract and summarize timeouts in refs and clear them in the existing unmount effect beside saveT.
📍 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.

Comment on lines +21 to +24
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' },
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
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.

Comment on lines +104 to +124
<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>
);
})}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
<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.

Comment on lines +213 to +224
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
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
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.

Comment on lines +423 to +430
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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:

  1. A second ensureInit on the same DOM finds .drag-field elements with no [data-dragnode] children, returns false at 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 in frontend/src/components/landing/useLanding.ts lines 162-210.
  2. React still owns those cluster nodes, because DragField rendered 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.

Darkest-Teddyand others added 6 commits August 4, 2026 20:26
…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>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 741ec10 and e506827.

📒 Files selected for processing (13)
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/gallery/page.tsx
  • frontend/src/app/(public)/news/page.tsx
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/(public)/team/page.tsx
  • frontend/src/app/(public)/wiki/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/companion/CompanionShell.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/BetaModal.tsx
  • frontend/src/components/landing-v5/DragField.tsx
  • frontend/src/components/landing-v5/FeatureLab.tsx
  • frontend/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

Comment on lines +52 to +70
{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&rsquo;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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
{subscribed ? (
<divstyle={{textAlign: 'center',padding: '10px 4px 6px'}}>
{/*carriesheadingIdtooitiswhat`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&rsquo;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'}}>
{/*carriesheadingIdtooitiswhat`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&rsquo;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>
Darkest-Teddyand others added 13 commits August 9, 2026 20:13
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>
Darkest-Teddyand others added 2 commits August 16, 2026 17:48
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>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review August 16, 2026 21:59
@Darkest-Teddy
Darkest-Teddy merged commit 4f5a7f9 into mainAug 16, 2026
6 of 7 checks passed
AndresL230 added a commit that referenced this pull request Aug 22, 2026
)
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>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
) (#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>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
* Revert "test(e2e): measure a dropped node against the copy it is welded to (#566) (#568)"
This reverts commit 752a82c.
* Revert "Merge pull request #524 from SaplingLearn/feat/landing-v5-port"
This reverts commit 4f5a7f9, reversing
changes made to 0effc9e.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Darkest-Teddy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(landing): port Sapling Landing v5 by Darkest-Teddy · Pull Request #524 · SaplingLearn/Sapling · GitHub
Skip to content

feat(landing): port Sapling Landing v5 - #524

Merged
Darkest-Teddy merged 48 commits into
mainfrom
feat/landing-v5-port
Aug 16, 2026
Merged

feat(landing): port Sapling Landing v5#524
Darkest-Teddy merged 48 commits into
mainfrom
feat/landing-v5-port

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Ports Sapling Landing v5.dc.html (from docs/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:

AssumedActual v5 source
WordmarkArchivo 800, ink #12201APlayfair Display 600, #0C5638
RevealheroWipe clip-pathcharacter scrambleheroWipe is defined, never referenced
Eyebrowmono "GROW YOUR KNOWLEDGE"Playfair "Grow Your Knowledge", title case, scrambled
Cascadetagline leads (760ms), wordmark follows 260ms behind — v4 ran the reverse
Right bloommirrored greenteal rgba(43,140,150,0.2)
Panel rigCSS perspective cardsthree.js WebGL scene

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/three were 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:

  1. 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.
  2. heroWipe / heroChar keyframes — defined, unreferenced. Not ported.
  3. setHero0/1/2 + mode pills — exist only in renderVals(), so heroMode is 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.
  4. "Plant a concept"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.ts reads a ring's halo via previousElementSibling and its caption via nextElementSibling; a <g> wrapper broke the adjacency and it threw on a null label.
  • Clusters were anchored once, in page coordinates. The source captures each cluster's offset within its field, parks the overlay in 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 ≤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

  • The old marketing landing is retired. Deleting / orphaned marketing/graph/ (~95KB of passing tests for a surface no route mounts), marketing/surfaces/, FeatureBand, SurfaceBento, featureBands. HeroCard/SignInModal stay — the nav's Sign In opens the real OAuth modal.
  • e2e/landing-graph.spec.ts goes with the component it pinned. public-seo.spec.ts's SSR guard is retargeted, not deleted: its failure mode (an ssr: false silently 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's aria-label is checked instead. All 7 assertions verified against raw SSR output.
  • The five companion pages the nav links to (/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

  • Feature lab demo pane. The source mounts a separate FeatureLab design 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.
  • Gallery tiles have no artwork — the import ships no screenshots.

Verification

  • tsc --noEmit exit 0 · eslint exit 0 · next build exit 0, all 5 new routes prerendered static
  • Unit suite 59 files / 423 tests passing
  • Every section walked in a real browser at multiple scroll positions, no console errors; all 9 public routes return 200

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced a redesigned landing experience with interactive knowledge graphs, tutoring modes, feature demos, gallery, journal, FAQ, and beta signup.
    • Added public FAQ, Gallery, News, Team, and Wiki pages with responsive navigation and searchable or filterable content.
    • Added interactive demonstrations for quizzes, flashcards, grades, calendars, notes, study rooms, guides, and tutoring.
  • Documentation
    • Improved server-rendered landing-page checks for key accessible content and beta messaging.
  • Refactor
    • Retired the previous marketing sections and legacy landing-page presentation.

Darkest-Teddyand others added 5 commits August 3, 2026 22:11
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>
@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ebab108d-0405-4ae1-8d00-108ba83a0144

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

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

Changes

Landing v5 experience

Layer / File(s)Summary
Landing content and data contracts
frontend/src/lib/landing/*
Added landing content, course graph data, drag clusters, color utilities, and companion-page datasets.
Animation and interaction engines
frontend/src/lib/landing/engine/*, frontend/src/lib/landing/hero3d/*
Added graph rendering, canvas effects, layout measurement, marquee and FLIP behavior, planting, drag simulation, and WebGL hero support.
Landing composition
frontend/src/app/(public)/page.tsx, frontend/src/components/landing-v5/*, frontend/src/components/landing/useLanding.ts
Replaced the previous landing implementation with v5 hero, graph, ingest, tutor, gallery, FAQ, journal, CTA, navigation, modal, and lifecycle components.
Interactive feature labs
frontend/src/components/landing-v5/lab/*
Added interactive quiz, flashcard, guide, grade, calendar, notes, rooms, and tutor demos with shared fixture data and controls.
Companion pages
frontend/src/components/companion/*, frontend/src/app/(public)/{about,team,wiki,faq,gallery,news}/*
Added shared companion navigation and public About, Team, Wiki, FAQ, Gallery, and News routes.
Styling and SSR validation
frontend/src/app/globals.css, frontend/e2e/public-seo.spec.ts, frontend/eslint-suppressions.json, frontend/src/lib/api.ts
Added scoped landing and companion styles, responsive animations, newsletter API wiring, and SSR checks for landing-page copy. Removed obsolete ESLint suppressions.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers:andresl230

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 36.59% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the primary change: porting Sapling Landing v5 into the landing page.
Description check✅ PassedThe description explains the purpose, major changes, known gaps, related issue context, and verification results in substantial detail.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/landing-v5-port

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Aug 4, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

Darkest-Teddyand others added 6 commits August 4, 2026 15:34
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 4, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingfc36068Commit Preview URL

Branch Preview URL
Aug 16 2026, 09:58 PM

Darkest-Teddyand others added 6 commits August 4, 2026 16:21
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>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review August 5, 2026 00:09

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

extract and summarize timeouts are untracked.

saveT is 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 win

The global space handler blocks button activation.

The keydown listener calls e.preventDefault() for every space press on window. A focused button then cannot be activated with space, including "Study again" at line 90. Skip the flip and the preventDefault call 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 win

The "no concepts" sentinel is counted as a linked concept.

When no matcher hits, concepts receives 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. Keep concepts empty 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 win

Do not cache a zero-sized canvas measurement.

cv() caches w/h from clientWidth/clientHeight on the first call. The cache is dropped only by invalidateCanvas, which measure() calls only when the viewport dimensions change (see frontend/src/lib/landing/engine/measure.ts lines 98-104). If the first cv() call happens while the canvas is not laid out yet, or while its section is hidden, the cache stores 0 × 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 win

Each 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 /news route. Point each card at the article, or at /news until 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 win

Label binding depends on an SVG sibling order that the markup does not guarantee.engine/sim.ts locates a node's caption through ring.nextElementSibling and casts it to SVGTextElement, while DragField renders 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 writes x/y onto it every frame, and ly becomes NaN.

  • frontend/src/lib/landing/engine/sim.ts#L184-L203: check nextElementSibling.tagName === 'text' before the cast, type SimNode.label as SVGTextElement | 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 /faq route. 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 win

Set 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 current actions.subscribe, at frontend/src/components/landing/useLanding.ts:624, only checks email.includes('@') and sets local subscribed state. If this should be a stub, make that intent explicit; otherwise wire onSubscribe to 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 win

Closed answers stay in the accessibility tree, and the button has no aria-controls.

The collapsed panel uses maxHeight: 0, opacity: 0, and overflow: 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 declares aria-expanded without naming the region it controls.

Add aria-controls with a stable id, and set aria-hidden on the panel while it is closed. aria-hidden does not disturb the max-height transition, 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 declare role="menu", but their children are plain button elements and next/link anchors. The ARIA menu pattern permits only menuitem, menuitemradio, and menuitemcheckbox children, 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 add menuitem roles.

  • frontend/src/components/landing-v5/Navbar.tsx#L199-L235: remove role="menu" from the compact panel, and remove aria-haspopup="menu" from the trigger at line 120. Keep aria-expanded.
  • frontend/src/components/landing-v5/Closing.tsx#L154-L182: remove role="menu" from the jump panel, and remove aria-haspopup="menu" from the pill at line 188. The enclosing nav with aria-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 win

Clear the deferred openGal timers on unmount.

actions.openGal schedules actions.enterExplore / actions.openGal 260 ms later from onQuiz and onLearn. If the page unmounts before those timers fire, openGal runs after cleanup and writes document.body.style.overflow = 'hidden' after the landing cleanup reset that overflow. Keep the timer refs in useLanding so cleanup can call clearTimeout for 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 win

Expose the selected tutor mode, and keep the hidden pills out of the tab order.

Two gaps in this control group:

  1. The pills are a mode selector, but the selected state is carried only by background and color. Assistive technology cannot report which mode is active. Add aria-pressed={tutorMode === i}.
  2. The container starts at opacity: 0 with pointerEvents: '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 opacity on the container. Have it also clear visibility or the inert attribute 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 win

Add an Escape key exit for explore mode.

Explore mode covers the act and closes the navbar, but useLanding only handles Escape for gallery and jump controls. If explore mode is active, call onExitExplore from 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 win

Release 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. Add renderer.forceContextLoss() after renderer.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

Segmented is unused; two demos duplicate it inline.

The comment states that the tutor and calendar demos use this control. They do not import it. CalendarDemo.tsx lines 61-76 and TutorDemo.tsx lines 65-80 each contain an inline copy of the same markup and style logic. Use Segmented in 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 value

Difficulty badge changes while the same question is shown.

submit updates streak before it sets phase to review. qi does 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 from answers.length captured 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 value

Row percentage is unclamped while the weighted grade is clamped.

Line 25 clamps the score into [0, r.possible]. Line 64 uses the unclamped raw. If the user types 70 in a 50-point row, the row shows 140% 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 value

Remove duplicated TIER from labData.ts.

labData.ts exports TIER, but the lab demos import LAB_TIER and render with labData.ts content. The four hex values and keys match LAB_TIER, so two copies of this swatch set can drift. Drop TIER from labData.ts and keep using LAB_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 win

Replace 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 value

Make lede optional so the privacy section does not render an empty paragraph.

The privacy section passes lede="". Section always renders the <p>, so the page emits an empty paragraph with 0 0 20px margins. 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 win

Avoid the per-frame layout read in update().

update() runs from the engine tick on every frame the gallery is on screen. Line 136 reads wrap.clientWidth on each of those frames, and line 156 writes track.style.transform. The write dirties style, so the next frame's read forces a style recalculation and layout. That contradicts the engine invariant documented in frontend/src/lib/landing/engine/index.ts lines 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 to cleanups:

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 value

Mark the active rail button for assistive technology.

on changes only the colors. Add aria-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 value

The wrapper duplicates the miniature's own container.

The <div> here sets height: 166, borderRadius: 13, overflow: hidden, the same background, and the same border. Every entry in GALLERY_MINIS opens with a <div> carrying that identical style set (see galleryMinis.tsx line 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.ran is never read.

armFlip sets ran = false and flipClose resets it, but no code in this module or in frontend/src/components/landing/useLanding.ts reads it. The consumer tracks the same guard with its own flipRan ref (see useLanding.ts lines 158, 552, 574-575). Two guards for one invariant can drift. Either remove ran from FlipState or make flipOpen own the guard and delete flipRan from 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 value

Points behind the camera reach the link pass with a degenerate scale.

sc = FL / (FL + z) is computed for every node. Rotation mixes ox into z, and ox reaches about ±740 while oz reaches about ±340, so z can pass -1000. At z === -FL the divide produces Infinity, and below -FL it 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 with Infinity scale gives reach === Infinity, so the break at 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 > -FL test 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 value

The 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. A MutationObserver on root would react to the same additions without the standing timer. This is a nice-to-have; the current cost is one querySelectorAll per 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

up ignores pointerId, and the window listeners are registered once per cluster.

Two small points in bindDrag:

  • up does not compare e.pointerId with f.drag.id. A second pointer that lifts cancels the active drag. onMove does compare the id, so the handlers are inconsistent.
  • onMove and up are added to window inside 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 win

Share the companion-page list, and make the footer icon decorative.

Two points:

  1. FOOTER_LINKS repeats PAGES in Navbar.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.
  2. Line 91 sets alt="Sapling" on the icon, and the adjacent text already reads "Sapling · © 2026". A screen reader announces the name twice. Navbar.tsx line 100 already uses alt="" 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

📥 Commits

Reviewing files that changed from the base of the PR and between ec34bf1 and 741ec10.

⛔ Files ignored due to path filters (3)
  • frontend/public/journal-ai-homework.png is excluded by !**/*.png
  • frontend/public/journal-founding.png is excluded by !**/*.png
  • frontend/public/kofi-symbol.png is excluded by !**/*.png
📒 Files selected for processing (75)
  • frontend/e2e/landing-graph.spec.ts
  • frontend/e2e/public-seo.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/gallery/page.tsx
  • frontend/src/app/(public)/news/page.tsx
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/(public)/team/page.tsx
  • frontend/src/app/(public)/wiki/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/companion/CompanionShell.tsx
  • frontend/src/components/companion/primitives.tsx
  • frontend/src/components/landing-v5/ActGraph.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/ActTutor.tsx
  • frontend/src/components/landing-v5/Closing.tsx
  • frontend/src/components/landing-v5/DragField.tsx
  • frontend/src/components/landing-v5/Faq.tsx
  • frontend/src/components/landing-v5/FeatureLab.tsx
  • frontend/src/components/landing-v5/Gallery.tsx
  • frontend/src/components/landing-v5/Hero.tsx
  • frontend/src/components/landing-v5/IntroOverlay.tsx
  • frontend/src/components/landing-v5/Journal.tsx
  • frontend/src/components/landing-v5/Navbar.tsx
  • frontend/src/components/landing-v5/galleryMinis.tsx
  • frontend/src/components/landing-v5/lab/CalendarDemo.tsx
  • frontend/src/components/landing-v5/lab/CardsDemo.tsx
  • frontend/src/components/landing-v5/lab/GradesDemo.tsx
  • frontend/src/components/landing-v5/lab/GuideDemo.tsx
  • frontend/src/components/landing-v5/lab/LabShell.tsx
  • frontend/src/components/landing-v5/lab/NotesDemo.tsx
  • frontend/src/components/landing-v5/lab/QuizDemo.tsx
  • frontend/src/components/landing-v5/lab/RoomsDemo.tsx
  • frontend/src/components/landing-v5/lab/TutorDemo.tsx
  • frontend/src/components/landing-v5/lab/index.tsx
  • frontend/src/components/landing-v5/lab/labData.ts
  • frontend/src/components/landing-v5/navTheme.ts
  • frontend/src/components/landing/useLanding.ts
  • frontend/src/components/marketing/FeatureBand.test.tsx
  • frontend/src/components/marketing/FeatureBand.tsx
  • frontend/src/components/marketing/SurfaceBento.test.tsx
  • frontend/src/components/marketing/SurfaceBento.tsx
  • frontend/src/components/marketing/featureBands.tsx
  • frontend/src/components/marketing/graph/KnowledgeGraphDemo.test.tsx
  • frontend/src/components/marketing/graph/KnowledgeGraphDemo.tsx
  • frontend/src/components/marketing/graph/courseGraphs.test.ts
  • frontend/src/components/marketing/graph/courseGraphs.ts
  • frontend/src/components/marketing/graph/layout.test.ts
  • frontend/src/components/marketing/graph/layout.ts
  • frontend/src/components/marketing/surfaces/GradebookSurface.tsx
  • frontend/src/components/marketing/surfaces/NotesSurface.tsx
  • frontend/src/components/marketing/surfaces/QuizSurface.tsx
  • frontend/src/components/marketing/surfaces/ReviewSurface.tsx
  • frontend/src/components/marketing/surfaces/RoomsSurface.tsx
  • frontend/src/components/marketing/surfaces/Surface.tsx
  • frontend/src/components/marketing/surfaces/TutorSurface.tsx
  • frontend/src/components/marketing/surfaces/UploadSurface.tsx
  • frontend/src/lib/landing/color.ts
  • frontend/src/lib/landing/companionContent.ts
  • frontend/src/lib/landing/content.ts
  • frontend/src/lib/landing/course.ts
  • frontend/src/lib/landing/dragClusters.ts
  • frontend/src/lib/landing/engine/ambient.ts
  • frontend/src/lib/landing/engine/dom.ts
  • frontend/src/lib/landing/engine/flip.ts
  • frontend/src/lib/landing/engine/graph.ts
  • frontend/src/lib/landing/engine/graphView.ts
  • frontend/src/lib/landing/engine/hero.ts
  • frontend/src/lib/landing/engine/index.ts
  • frontend/src/lib/landing/engine/marquee.ts
  • frontend/src/lib/landing/engine/measure.ts
  • frontend/src/lib/landing/engine/plant.ts
  • frontend/src/lib/landing/engine/sim.ts
  • frontend/src/lib/landing/hero3d/cardTexture.ts
  • frontend/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

Comment on lines +1957 to +2005
@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); } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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

Comment on lines +41 to +50
<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' }}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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 no aria-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.

Comment on lines +62 to +85
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>
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Comment on lines +37 to +44
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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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 ms extract and summarize timeouts in refs and clear them in the existing unmount effect beside saveT.
📍 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.

Comment on lines +21 to +24
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' },
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
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.

Comment on lines +104 to +124
<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>
);
})}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
<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.

Comment on lines +213 to +224
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
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
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.

Comment on lines +423 to +430
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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:

  1. A second ensureInit on the same DOM finds .drag-field elements with no [data-dragnode] children, returns false at 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 in frontend/src/components/landing/useLanding.ts lines 162-210.
  2. React still owns those cluster nodes, because DragField rendered 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.

Darkest-Teddyand others added 6 commits August 4, 2026 20:26
…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>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 741ec10 and e506827.

📒 Files selected for processing (13)
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/gallery/page.tsx
  • frontend/src/app/(public)/news/page.tsx
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/(public)/team/page.tsx
  • frontend/src/app/(public)/wiki/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/companion/CompanionShell.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/BetaModal.tsx
  • frontend/src/components/landing-v5/DragField.tsx
  • frontend/src/components/landing-v5/FeatureLab.tsx
  • frontend/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

Comment on lines +52 to +70
{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&rsquo;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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
{subscribed ? (
<divstyle={{textAlign: 'center',padding: '10px 4px 6px'}}>
{/*carriesheadingIdtooitiswhat`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&rsquo;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'}}>
{/*carriesheadingIdtooitiswhat`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&rsquo;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>
Darkest-Teddyand others added 13 commits August 9, 2026 20:13
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>
Darkest-Teddyand others added 2 commits August 16, 2026 17:48
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>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review August 16, 2026 21:59
@Darkest-Teddy
Darkest-Teddy merged commit 4f5a7f9 into mainAug 16, 2026
6 of 7 checks passed
AndresL230 added a commit that referenced this pull request Aug 22, 2026
)
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>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
) (#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>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
* Revert "test(e2e): measure a dropped node against the copy it is welded to (#566) (#568)"
This reverts commit 752a82c.
* Revert "Merge pull request #524 from SaplingLearn/feat/landing-v5-port"
This reverts commit 4f5a7f9, reversing
changes made to 0effc9e.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Darkest-Teddy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(landing): port Sapling Landing v5 by Darkest-Teddy · Pull Request #524 · SaplingLearn/Sapling · GitHub
Skip to content

feat(landing): port Sapling Landing v5 - #524

Merged
Darkest-Teddy merged 48 commits into
mainfrom
feat/landing-v5-port
Aug 16, 2026
Merged

feat(landing): port Sapling Landing v5#524
Darkest-Teddy merged 48 commits into
mainfrom
feat/landing-v5-port

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Ports Sapling Landing v5.dc.html (from docs/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:

AssumedActual v5 source
WordmarkArchivo 800, ink #12201APlayfair Display 600, #0C5638
RevealheroWipe clip-pathcharacter scrambleheroWipe is defined, never referenced
Eyebrowmono "GROW YOUR KNOWLEDGE"Playfair "Grow Your Knowledge", title case, scrambled
Cascadetagline leads (760ms), wordmark follows 260ms behind — v4 ran the reverse
Right bloommirrored greenteal rgba(43,140,150,0.2)
Panel rigCSS perspective cardsthree.js WebGL scene

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/three were 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:

  1. 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.
  2. heroWipe / heroChar keyframes — defined, unreferenced. Not ported.
  3. setHero0/1/2 + mode pills — exist only in renderVals(), so heroMode is 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.
  4. "Plant a concept"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.ts reads a ring's halo via previousElementSibling and its caption via nextElementSibling; a <g> wrapper broke the adjacency and it threw on a null label.
  • Clusters were anchored once, in page coordinates. The source captures each cluster's offset within its field, parks the overlay in 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 ≤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

  • The old marketing landing is retired. Deleting / orphaned marketing/graph/ (~95KB of passing tests for a surface no route mounts), marketing/surfaces/, FeatureBand, SurfaceBento, featureBands. HeroCard/SignInModal stay — the nav's Sign In opens the real OAuth modal.
  • e2e/landing-graph.spec.ts goes with the component it pinned. public-seo.spec.ts's SSR guard is retargeted, not deleted: its failure mode (an ssr: false silently 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's aria-label is checked instead. All 7 assertions verified against raw SSR output.
  • The five companion pages the nav links to (/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

  • Feature lab demo pane. The source mounts a separate FeatureLab design 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.
  • Gallery tiles have no artwork — the import ships no screenshots.

Verification

  • tsc --noEmit exit 0 · eslint exit 0 · next build exit 0, all 5 new routes prerendered static
  • Unit suite 59 files / 423 tests passing
  • Every section walked in a real browser at multiple scroll positions, no console errors; all 9 public routes return 200

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced a redesigned landing experience with interactive knowledge graphs, tutoring modes, feature demos, gallery, journal, FAQ, and beta signup.
    • Added public FAQ, Gallery, News, Team, and Wiki pages with responsive navigation and searchable or filterable content.
    • Added interactive demonstrations for quizzes, flashcards, grades, calendars, notes, study rooms, guides, and tutoring.
  • Documentation
    • Improved server-rendered landing-page checks for key accessible content and beta messaging.
  • Refactor
    • Retired the previous marketing sections and legacy landing-page presentation.

Darkest-Teddyand others added 5 commits August 3, 2026 22:11
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>
@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ebab108d-0405-4ae1-8d00-108ba83a0144

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

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

Changes

Landing v5 experience

Layer / File(s)Summary
Landing content and data contracts
frontend/src/lib/landing/*
Added landing content, course graph data, drag clusters, color utilities, and companion-page datasets.
Animation and interaction engines
frontend/src/lib/landing/engine/*, frontend/src/lib/landing/hero3d/*
Added graph rendering, canvas effects, layout measurement, marquee and FLIP behavior, planting, drag simulation, and WebGL hero support.
Landing composition
frontend/src/app/(public)/page.tsx, frontend/src/components/landing-v5/*, frontend/src/components/landing/useLanding.ts
Replaced the previous landing implementation with v5 hero, graph, ingest, tutor, gallery, FAQ, journal, CTA, navigation, modal, and lifecycle components.
Interactive feature labs
frontend/src/components/landing-v5/lab/*
Added interactive quiz, flashcard, guide, grade, calendar, notes, rooms, and tutor demos with shared fixture data and controls.
Companion pages
frontend/src/components/companion/*, frontend/src/app/(public)/{about,team,wiki,faq,gallery,news}/*
Added shared companion navigation and public About, Team, Wiki, FAQ, Gallery, and News routes.
Styling and SSR validation
frontend/src/app/globals.css, frontend/e2e/public-seo.spec.ts, frontend/eslint-suppressions.json, frontend/src/lib/api.ts
Added scoped landing and companion styles, responsive animations, newsletter API wiring, and SSR checks for landing-page copy. Removed obsolete ESLint suppressions.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers:andresl230

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 36.59% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the primary change: porting Sapling Landing v5 into the landing page.
Description check✅ PassedThe description explains the purpose, major changes, known gaps, related issue context, and verification results in substantial detail.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/landing-v5-port

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Aug 4, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

Darkest-Teddyand others added 6 commits August 4, 2026 15:34
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 4, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingfc36068Commit Preview URL

Branch Preview URL
Aug 16 2026, 09:58 PM

Darkest-Teddyand others added 6 commits August 4, 2026 16:21
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>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review August 5, 2026 00:09

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

extract and summarize timeouts are untracked.

saveT is 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 win

The global space handler blocks button activation.

The keydown listener calls e.preventDefault() for every space press on window. A focused button then cannot be activated with space, including "Study again" at line 90. Skip the flip and the preventDefault call 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 win

The "no concepts" sentinel is counted as a linked concept.

When no matcher hits, concepts receives 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. Keep concepts empty 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 win

Do not cache a zero-sized canvas measurement.

cv() caches w/h from clientWidth/clientHeight on the first call. The cache is dropped only by invalidateCanvas, which measure() calls only when the viewport dimensions change (see frontend/src/lib/landing/engine/measure.ts lines 98-104). If the first cv() call happens while the canvas is not laid out yet, or while its section is hidden, the cache stores 0 × 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 win

Each 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 /news route. Point each card at the article, or at /news until 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 win

Label binding depends on an SVG sibling order that the markup does not guarantee.engine/sim.ts locates a node's caption through ring.nextElementSibling and casts it to SVGTextElement, while DragField renders 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 writes x/y onto it every frame, and ly becomes NaN.

  • frontend/src/lib/landing/engine/sim.ts#L184-L203: check nextElementSibling.tagName === 'text' before the cast, type SimNode.label as SVGTextElement | 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 /faq route. 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 win

Set 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 current actions.subscribe, at frontend/src/components/landing/useLanding.ts:624, only checks email.includes('@') and sets local subscribed state. If this should be a stub, make that intent explicit; otherwise wire onSubscribe to 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 win

Closed answers stay in the accessibility tree, and the button has no aria-controls.

The collapsed panel uses maxHeight: 0, opacity: 0, and overflow: 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 declares aria-expanded without naming the region it controls.

Add aria-controls with a stable id, and set aria-hidden on the panel while it is closed. aria-hidden does not disturb the max-height transition, 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 declare role="menu", but their children are plain button elements and next/link anchors. The ARIA menu pattern permits only menuitem, menuitemradio, and menuitemcheckbox children, 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 add menuitem roles.

  • frontend/src/components/landing-v5/Navbar.tsx#L199-L235: remove role="menu" from the compact panel, and remove aria-haspopup="menu" from the trigger at line 120. Keep aria-expanded.
  • frontend/src/components/landing-v5/Closing.tsx#L154-L182: remove role="menu" from the jump panel, and remove aria-haspopup="menu" from the pill at line 188. The enclosing nav with aria-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 win

Clear the deferred openGal timers on unmount.

actions.openGal schedules actions.enterExplore / actions.openGal 260 ms later from onQuiz and onLearn. If the page unmounts before those timers fire, openGal runs after cleanup and writes document.body.style.overflow = 'hidden' after the landing cleanup reset that overflow. Keep the timer refs in useLanding so cleanup can call clearTimeout for 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 win

Expose the selected tutor mode, and keep the hidden pills out of the tab order.

Two gaps in this control group:

  1. The pills are a mode selector, but the selected state is carried only by background and color. Assistive technology cannot report which mode is active. Add aria-pressed={tutorMode === i}.
  2. The container starts at opacity: 0 with pointerEvents: '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 opacity on the container. Have it also clear visibility or the inert attribute 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 win

Add an Escape key exit for explore mode.

Explore mode covers the act and closes the navbar, but useLanding only handles Escape for gallery and jump controls. If explore mode is active, call onExitExplore from 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 win

Release 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. Add renderer.forceContextLoss() after renderer.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

Segmented is unused; two demos duplicate it inline.

The comment states that the tutor and calendar demos use this control. They do not import it. CalendarDemo.tsx lines 61-76 and TutorDemo.tsx lines 65-80 each contain an inline copy of the same markup and style logic. Use Segmented in 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 value

Difficulty badge changes while the same question is shown.

submit updates streak before it sets phase to review. qi does 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 from answers.length captured 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 value

Row percentage is unclamped while the weighted grade is clamped.

Line 25 clamps the score into [0, r.possible]. Line 64 uses the unclamped raw. If the user types 70 in a 50-point row, the row shows 140% 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 value

Remove duplicated TIER from labData.ts.

labData.ts exports TIER, but the lab demos import LAB_TIER and render with labData.ts content. The four hex values and keys match LAB_TIER, so two copies of this swatch set can drift. Drop TIER from labData.ts and keep using LAB_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 win

Replace 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 value

Make lede optional so the privacy section does not render an empty paragraph.

The privacy section passes lede="". Section always renders the <p>, so the page emits an empty paragraph with 0 0 20px margins. 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 win

Avoid the per-frame layout read in update().

update() runs from the engine tick on every frame the gallery is on screen. Line 136 reads wrap.clientWidth on each of those frames, and line 156 writes track.style.transform. The write dirties style, so the next frame's read forces a style recalculation and layout. That contradicts the engine invariant documented in frontend/src/lib/landing/engine/index.ts lines 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 to cleanups:

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 value

Mark the active rail button for assistive technology.

on changes only the colors. Add aria-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 value

The wrapper duplicates the miniature's own container.

The <div> here sets height: 166, borderRadius: 13, overflow: hidden, the same background, and the same border. Every entry in GALLERY_MINIS opens with a <div> carrying that identical style set (see galleryMinis.tsx line 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.ran is never read.

armFlip sets ran = false and flipClose resets it, but no code in this module or in frontend/src/components/landing/useLanding.ts reads it. The consumer tracks the same guard with its own flipRan ref (see useLanding.ts lines 158, 552, 574-575). Two guards for one invariant can drift. Either remove ran from FlipState or make flipOpen own the guard and delete flipRan from 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 value

Points behind the camera reach the link pass with a degenerate scale.

sc = FL / (FL + z) is computed for every node. Rotation mixes ox into z, and ox reaches about ±740 while oz reaches about ±340, so z can pass -1000. At z === -FL the divide produces Infinity, and below -FL it 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 with Infinity scale gives reach === Infinity, so the break at 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 > -FL test 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 value

The 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. A MutationObserver on root would react to the same additions without the standing timer. This is a nice-to-have; the current cost is one querySelectorAll per 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

up ignores pointerId, and the window listeners are registered once per cluster.

Two small points in bindDrag:

  • up does not compare e.pointerId with f.drag.id. A second pointer that lifts cancels the active drag. onMove does compare the id, so the handlers are inconsistent.
  • onMove and up are added to window inside 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 win

Share the companion-page list, and make the footer icon decorative.

Two points:

  1. FOOTER_LINKS repeats PAGES in Navbar.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.
  2. Line 91 sets alt="Sapling" on the icon, and the adjacent text already reads "Sapling · © 2026". A screen reader announces the name twice. Navbar.tsx line 100 already uses alt="" 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

📥 Commits

Reviewing files that changed from the base of the PR and between ec34bf1 and 741ec10.

⛔ Files ignored due to path filters (3)
  • frontend/public/journal-ai-homework.png is excluded by !**/*.png
  • frontend/public/journal-founding.png is excluded by !**/*.png
  • frontend/public/kofi-symbol.png is excluded by !**/*.png
📒 Files selected for processing (75)
  • frontend/e2e/landing-graph.spec.ts
  • frontend/e2e/public-seo.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/gallery/page.tsx
  • frontend/src/app/(public)/news/page.tsx
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/(public)/team/page.tsx
  • frontend/src/app/(public)/wiki/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/companion/CompanionShell.tsx
  • frontend/src/components/companion/primitives.tsx
  • frontend/src/components/landing-v5/ActGraph.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/ActTutor.tsx
  • frontend/src/components/landing-v5/Closing.tsx
  • frontend/src/components/landing-v5/DragField.tsx
  • frontend/src/components/landing-v5/Faq.tsx
  • frontend/src/components/landing-v5/FeatureLab.tsx
  • frontend/src/components/landing-v5/Gallery.tsx
  • frontend/src/components/landing-v5/Hero.tsx
  • frontend/src/components/landing-v5/IntroOverlay.tsx
  • frontend/src/components/landing-v5/Journal.tsx
  • frontend/src/components/landing-v5/Navbar.tsx
  • frontend/src/components/landing-v5/galleryMinis.tsx
  • frontend/src/components/landing-v5/lab/CalendarDemo.tsx
  • frontend/src/components/landing-v5/lab/CardsDemo.tsx
  • frontend/src/components/landing-v5/lab/GradesDemo.tsx
  • frontend/src/components/landing-v5/lab/GuideDemo.tsx
  • frontend/src/components/landing-v5/lab/LabShell.tsx
  • frontend/src/components/landing-v5/lab/NotesDemo.tsx
  • frontend/src/components/landing-v5/lab/QuizDemo.tsx
  • frontend/src/components/landing-v5/lab/RoomsDemo.tsx
  • frontend/src/components/landing-v5/lab/TutorDemo.tsx
  • frontend/src/components/landing-v5/lab/index.tsx
  • frontend/src/components/landing-v5/lab/labData.ts
  • frontend/src/components/landing-v5/navTheme.ts
  • frontend/src/components/landing/useLanding.ts
  • frontend/src/components/marketing/FeatureBand.test.tsx
  • frontend/src/components/marketing/FeatureBand.tsx
  • frontend/src/components/marketing/SurfaceBento.test.tsx
  • frontend/src/components/marketing/SurfaceBento.tsx
  • frontend/src/components/marketing/featureBands.tsx
  • frontend/src/components/marketing/graph/KnowledgeGraphDemo.test.tsx
  • frontend/src/components/marketing/graph/KnowledgeGraphDemo.tsx
  • frontend/src/components/marketing/graph/courseGraphs.test.ts
  • frontend/src/components/marketing/graph/courseGraphs.ts
  • frontend/src/components/marketing/graph/layout.test.ts
  • frontend/src/components/marketing/graph/layout.ts
  • frontend/src/components/marketing/surfaces/GradebookSurface.tsx
  • frontend/src/components/marketing/surfaces/NotesSurface.tsx
  • frontend/src/components/marketing/surfaces/QuizSurface.tsx
  • frontend/src/components/marketing/surfaces/ReviewSurface.tsx
  • frontend/src/components/marketing/surfaces/RoomsSurface.tsx
  • frontend/src/components/marketing/surfaces/Surface.tsx
  • frontend/src/components/marketing/surfaces/TutorSurface.tsx
  • frontend/src/components/marketing/surfaces/UploadSurface.tsx
  • frontend/src/lib/landing/color.ts
  • frontend/src/lib/landing/companionContent.ts
  • frontend/src/lib/landing/content.ts
  • frontend/src/lib/landing/course.ts
  • frontend/src/lib/landing/dragClusters.ts
  • frontend/src/lib/landing/engine/ambient.ts
  • frontend/src/lib/landing/engine/dom.ts
  • frontend/src/lib/landing/engine/flip.ts
  • frontend/src/lib/landing/engine/graph.ts
  • frontend/src/lib/landing/engine/graphView.ts
  • frontend/src/lib/landing/engine/hero.ts
  • frontend/src/lib/landing/engine/index.ts
  • frontend/src/lib/landing/engine/marquee.ts
  • frontend/src/lib/landing/engine/measure.ts
  • frontend/src/lib/landing/engine/plant.ts
  • frontend/src/lib/landing/engine/sim.ts
  • frontend/src/lib/landing/hero3d/cardTexture.ts
  • frontend/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

Comment on lines +1957 to +2005
@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); } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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

Comment on lines +41 to +50
<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' }}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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 no aria-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.

Comment on lines +62 to +85
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>
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Comment on lines +37 to +44
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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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 ms extract and summarize timeouts in refs and clear them in the existing unmount effect beside saveT.
📍 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.

Comment on lines +21 to +24
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' },
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
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.

Comment on lines +104 to +124
<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>
);
})}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
<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.

Comment on lines +213 to +224
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
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
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.

Comment on lines +423 to +430
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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:

  1. A second ensureInit on the same DOM finds .drag-field elements with no [data-dragnode] children, returns false at 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 in frontend/src/components/landing/useLanding.ts lines 162-210.
  2. React still owns those cluster nodes, because DragField rendered 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.

Darkest-Teddyand others added 6 commits August 4, 2026 20:26
…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>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 741ec10 and e506827.

📒 Files selected for processing (13)
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/gallery/page.tsx
  • frontend/src/app/(public)/news/page.tsx
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/(public)/team/page.tsx
  • frontend/src/app/(public)/wiki/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/companion/CompanionShell.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/BetaModal.tsx
  • frontend/src/components/landing-v5/DragField.tsx
  • frontend/src/components/landing-v5/FeatureLab.tsx
  • frontend/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

Comment on lines +52 to +70
{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&rsquo;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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
{subscribed ? (
<divstyle={{textAlign: 'center',padding: '10px 4px 6px'}}>
{/*carriesheadingIdtooitiswhat`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&rsquo;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'}}>
{/*carriesheadingIdtooitiswhat`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&rsquo;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>
Darkest-Teddyand others added 13 commits August 9, 2026 20:13
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>
Darkest-Teddyand others added 2 commits August 16, 2026 17:48
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>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review August 16, 2026 21:59
@Darkest-Teddy
Darkest-Teddy merged commit 4f5a7f9 into mainAug 16, 2026
6 of 7 checks passed
AndresL230 added a commit that referenced this pull request Aug 22, 2026
)
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>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
) (#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>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
* Revert "test(e2e): measure a dropped node against the copy it is welded to (#566) (#568)"
This reverts commit 752a82c.
* Revert "Merge pull request #524 from SaplingLearn/feat/landing-v5-port"
This reverts commit 4f5a7f9, reversing
changes made to 0effc9e.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Darkest-Teddy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(landing): port Sapling Landing v5 by Darkest-Teddy · Pull Request #524 · SaplingLearn/Sapling · GitHub
Skip to content

feat(landing): port Sapling Landing v5 - #524

Merged
Darkest-Teddy merged 48 commits into
mainfrom
feat/landing-v5-port
Aug 16, 2026
Merged

feat(landing): port Sapling Landing v5#524
Darkest-Teddy merged 48 commits into
mainfrom
feat/landing-v5-port

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Ports Sapling Landing v5.dc.html (from docs/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:

AssumedActual v5 source
WordmarkArchivo 800, ink #12201APlayfair Display 600, #0C5638
RevealheroWipe clip-pathcharacter scrambleheroWipe is defined, never referenced
Eyebrowmono "GROW YOUR KNOWLEDGE"Playfair "Grow Your Knowledge", title case, scrambled
Cascadetagline leads (760ms), wordmark follows 260ms behind — v4 ran the reverse
Right bloommirrored greenteal rgba(43,140,150,0.2)
Panel rigCSS perspective cardsthree.js WebGL scene

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/three were 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:

  1. 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.
  2. heroWipe / heroChar keyframes — defined, unreferenced. Not ported.
  3. setHero0/1/2 + mode pills — exist only in renderVals(), so heroMode is 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.
  4. "Plant a concept"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.ts reads a ring's halo via previousElementSibling and its caption via nextElementSibling; a <g> wrapper broke the adjacency and it threw on a null label.
  • Clusters were anchored once, in page coordinates. The source captures each cluster's offset within its field, parks the overlay in 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 ≤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

  • The old marketing landing is retired. Deleting / orphaned marketing/graph/ (~95KB of passing tests for a surface no route mounts), marketing/surfaces/, FeatureBand, SurfaceBento, featureBands. HeroCard/SignInModal stay — the nav's Sign In opens the real OAuth modal.
  • e2e/landing-graph.spec.ts goes with the component it pinned. public-seo.spec.ts's SSR guard is retargeted, not deleted: its failure mode (an ssr: false silently 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's aria-label is checked instead. All 7 assertions verified against raw SSR output.
  • The five companion pages the nav links to (/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

  • Feature lab demo pane. The source mounts a separate FeatureLab design 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.
  • Gallery tiles have no artwork — the import ships no screenshots.

Verification

  • tsc --noEmit exit 0 · eslint exit 0 · next build exit 0, all 5 new routes prerendered static
  • Unit suite 59 files / 423 tests passing
  • Every section walked in a real browser at multiple scroll positions, no console errors; all 9 public routes return 200

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced a redesigned landing experience with interactive knowledge graphs, tutoring modes, feature demos, gallery, journal, FAQ, and beta signup.
    • Added public FAQ, Gallery, News, Team, and Wiki pages with responsive navigation and searchable or filterable content.
    • Added interactive demonstrations for quizzes, flashcards, grades, calendars, notes, study rooms, guides, and tutoring.
  • Documentation
    • Improved server-rendered landing-page checks for key accessible content and beta messaging.
  • Refactor
    • Retired the previous marketing sections and legacy landing-page presentation.

Darkest-Teddyand others added 5 commits August 3, 2026 22:11
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>
@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ebab108d-0405-4ae1-8d00-108ba83a0144

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

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

Changes

Landing v5 experience

Layer / File(s)Summary
Landing content and data contracts
frontend/src/lib/landing/*
Added landing content, course graph data, drag clusters, color utilities, and companion-page datasets.
Animation and interaction engines
frontend/src/lib/landing/engine/*, frontend/src/lib/landing/hero3d/*
Added graph rendering, canvas effects, layout measurement, marquee and FLIP behavior, planting, drag simulation, and WebGL hero support.
Landing composition
frontend/src/app/(public)/page.tsx, frontend/src/components/landing-v5/*, frontend/src/components/landing/useLanding.ts
Replaced the previous landing implementation with v5 hero, graph, ingest, tutor, gallery, FAQ, journal, CTA, navigation, modal, and lifecycle components.
Interactive feature labs
frontend/src/components/landing-v5/lab/*
Added interactive quiz, flashcard, guide, grade, calendar, notes, rooms, and tutor demos with shared fixture data and controls.
Companion pages
frontend/src/components/companion/*, frontend/src/app/(public)/{about,team,wiki,faq,gallery,news}/*
Added shared companion navigation and public About, Team, Wiki, FAQ, Gallery, and News routes.
Styling and SSR validation
frontend/src/app/globals.css, frontend/e2e/public-seo.spec.ts, frontend/eslint-suppressions.json, frontend/src/lib/api.ts
Added scoped landing and companion styles, responsive animations, newsletter API wiring, and SSR checks for landing-page copy. Removed obsolete ESLint suppressions.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers:andresl230

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 36.59% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the primary change: porting Sapling Landing v5 into the landing page.
Description check✅ PassedThe description explains the purpose, major changes, known gaps, related issue context, and verification results in substantial detail.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/landing-v5-port

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Aug 4, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

Darkest-Teddyand others added 6 commits August 4, 2026 15:34
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 4, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingfc36068Commit Preview URL

Branch Preview URL
Aug 16 2026, 09:58 PM

Darkest-Teddyand others added 6 commits August 4, 2026 16:21
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>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review August 5, 2026 00:09

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

extract and summarize timeouts are untracked.

saveT is 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 win

The global space handler blocks button activation.

The keydown listener calls e.preventDefault() for every space press on window. A focused button then cannot be activated with space, including "Study again" at line 90. Skip the flip and the preventDefault call 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 win

The "no concepts" sentinel is counted as a linked concept.

When no matcher hits, concepts receives 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. Keep concepts empty 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 win

Do not cache a zero-sized canvas measurement.

cv() caches w/h from clientWidth/clientHeight on the first call. The cache is dropped only by invalidateCanvas, which measure() calls only when the viewport dimensions change (see frontend/src/lib/landing/engine/measure.ts lines 98-104). If the first cv() call happens while the canvas is not laid out yet, or while its section is hidden, the cache stores 0 × 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 win

Each 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 /news route. Point each card at the article, or at /news until 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 win

Label binding depends on an SVG sibling order that the markup does not guarantee.engine/sim.ts locates a node's caption through ring.nextElementSibling and casts it to SVGTextElement, while DragField renders 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 writes x/y onto it every frame, and ly becomes NaN.

  • frontend/src/lib/landing/engine/sim.ts#L184-L203: check nextElementSibling.tagName === 'text' before the cast, type SimNode.label as SVGTextElement | 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 /faq route. 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 win

Set 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 current actions.subscribe, at frontend/src/components/landing/useLanding.ts:624, only checks email.includes('@') and sets local subscribed state. If this should be a stub, make that intent explicit; otherwise wire onSubscribe to 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 win

Closed answers stay in the accessibility tree, and the button has no aria-controls.

The collapsed panel uses maxHeight: 0, opacity: 0, and overflow: 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 declares aria-expanded without naming the region it controls.

Add aria-controls with a stable id, and set aria-hidden on the panel while it is closed. aria-hidden does not disturb the max-height transition, 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 declare role="menu", but their children are plain button elements and next/link anchors. The ARIA menu pattern permits only menuitem, menuitemradio, and menuitemcheckbox children, 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 add menuitem roles.

  • frontend/src/components/landing-v5/Navbar.tsx#L199-L235: remove role="menu" from the compact panel, and remove aria-haspopup="menu" from the trigger at line 120. Keep aria-expanded.
  • frontend/src/components/landing-v5/Closing.tsx#L154-L182: remove role="menu" from the jump panel, and remove aria-haspopup="menu" from the pill at line 188. The enclosing nav with aria-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 win

Clear the deferred openGal timers on unmount.

actions.openGal schedules actions.enterExplore / actions.openGal 260 ms later from onQuiz and onLearn. If the page unmounts before those timers fire, openGal runs after cleanup and writes document.body.style.overflow = 'hidden' after the landing cleanup reset that overflow. Keep the timer refs in useLanding so cleanup can call clearTimeout for 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 win

Expose the selected tutor mode, and keep the hidden pills out of the tab order.

Two gaps in this control group:

  1. The pills are a mode selector, but the selected state is carried only by background and color. Assistive technology cannot report which mode is active. Add aria-pressed={tutorMode === i}.
  2. The container starts at opacity: 0 with pointerEvents: '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 opacity on the container. Have it also clear visibility or the inert attribute 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 win

Add an Escape key exit for explore mode.

Explore mode covers the act and closes the navbar, but useLanding only handles Escape for gallery and jump controls. If explore mode is active, call onExitExplore from 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 win

Release 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. Add renderer.forceContextLoss() after renderer.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

Segmented is unused; two demos duplicate it inline.

The comment states that the tutor and calendar demos use this control. They do not import it. CalendarDemo.tsx lines 61-76 and TutorDemo.tsx lines 65-80 each contain an inline copy of the same markup and style logic. Use Segmented in 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 value

Difficulty badge changes while the same question is shown.

submit updates streak before it sets phase to review. qi does 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 from answers.length captured 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 value

Row percentage is unclamped while the weighted grade is clamped.

Line 25 clamps the score into [0, r.possible]. Line 64 uses the unclamped raw. If the user types 70 in a 50-point row, the row shows 140% 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 value

Remove duplicated TIER from labData.ts.

labData.ts exports TIER, but the lab demos import LAB_TIER and render with labData.ts content. The four hex values and keys match LAB_TIER, so two copies of this swatch set can drift. Drop TIER from labData.ts and keep using LAB_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 win

Replace 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 value

Make lede optional so the privacy section does not render an empty paragraph.

The privacy section passes lede="". Section always renders the <p>, so the page emits an empty paragraph with 0 0 20px margins. 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 win

Avoid the per-frame layout read in update().

update() runs from the engine tick on every frame the gallery is on screen. Line 136 reads wrap.clientWidth on each of those frames, and line 156 writes track.style.transform. The write dirties style, so the next frame's read forces a style recalculation and layout. That contradicts the engine invariant documented in frontend/src/lib/landing/engine/index.ts lines 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 to cleanups:

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 value

Mark the active rail button for assistive technology.

on changes only the colors. Add aria-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 value

The wrapper duplicates the miniature's own container.

The <div> here sets height: 166, borderRadius: 13, overflow: hidden, the same background, and the same border. Every entry in GALLERY_MINIS opens with a <div> carrying that identical style set (see galleryMinis.tsx line 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.ran is never read.

armFlip sets ran = false and flipClose resets it, but no code in this module or in frontend/src/components/landing/useLanding.ts reads it. The consumer tracks the same guard with its own flipRan ref (see useLanding.ts lines 158, 552, 574-575). Two guards for one invariant can drift. Either remove ran from FlipState or make flipOpen own the guard and delete flipRan from 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 value

Points behind the camera reach the link pass with a degenerate scale.

sc = FL / (FL + z) is computed for every node. Rotation mixes ox into z, and ox reaches about ±740 while oz reaches about ±340, so z can pass -1000. At z === -FL the divide produces Infinity, and below -FL it 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 with Infinity scale gives reach === Infinity, so the break at 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 > -FL test 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 value

The 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. A MutationObserver on root would react to the same additions without the standing timer. This is a nice-to-have; the current cost is one querySelectorAll per 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

up ignores pointerId, and the window listeners are registered once per cluster.

Two small points in bindDrag:

  • up does not compare e.pointerId with f.drag.id. A second pointer that lifts cancels the active drag. onMove does compare the id, so the handlers are inconsistent.
  • onMove and up are added to window inside 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 win

Share the companion-page list, and make the footer icon decorative.

Two points:

  1. FOOTER_LINKS repeats PAGES in Navbar.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.
  2. Line 91 sets alt="Sapling" on the icon, and the adjacent text already reads "Sapling · © 2026". A screen reader announces the name twice. Navbar.tsx line 100 already uses alt="" 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

📥 Commits

Reviewing files that changed from the base of the PR and between ec34bf1 and 741ec10.

⛔ Files ignored due to path filters (3)
  • frontend/public/journal-ai-homework.png is excluded by !**/*.png
  • frontend/public/journal-founding.png is excluded by !**/*.png
  • frontend/public/kofi-symbol.png is excluded by !**/*.png
📒 Files selected for processing (75)
  • frontend/e2e/landing-graph.spec.ts
  • frontend/e2e/public-seo.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/gallery/page.tsx
  • frontend/src/app/(public)/news/page.tsx
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/(public)/team/page.tsx
  • frontend/src/app/(public)/wiki/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/companion/CompanionShell.tsx
  • frontend/src/components/companion/primitives.tsx
  • frontend/src/components/landing-v5/ActGraph.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/ActTutor.tsx
  • frontend/src/components/landing-v5/Closing.tsx
  • frontend/src/components/landing-v5/DragField.tsx
  • frontend/src/components/landing-v5/Faq.tsx
  • frontend/src/components/landing-v5/FeatureLab.tsx
  • frontend/src/components/landing-v5/Gallery.tsx
  • frontend/src/components/landing-v5/Hero.tsx
  • frontend/src/components/landing-v5/IntroOverlay.tsx
  • frontend/src/components/landing-v5/Journal.tsx
  • frontend/src/components/landing-v5/Navbar.tsx
  • frontend/src/components/landing-v5/galleryMinis.tsx
  • frontend/src/components/landing-v5/lab/CalendarDemo.tsx
  • frontend/src/components/landing-v5/lab/CardsDemo.tsx
  • frontend/src/components/landing-v5/lab/GradesDemo.tsx
  • frontend/src/components/landing-v5/lab/GuideDemo.tsx
  • frontend/src/components/landing-v5/lab/LabShell.tsx
  • frontend/src/components/landing-v5/lab/NotesDemo.tsx
  • frontend/src/components/landing-v5/lab/QuizDemo.tsx
  • frontend/src/components/landing-v5/lab/RoomsDemo.tsx
  • frontend/src/components/landing-v5/lab/TutorDemo.tsx
  • frontend/src/components/landing-v5/lab/index.tsx
  • frontend/src/components/landing-v5/lab/labData.ts
  • frontend/src/components/landing-v5/navTheme.ts
  • frontend/src/components/landing/useLanding.ts
  • frontend/src/components/marketing/FeatureBand.test.tsx
  • frontend/src/components/marketing/FeatureBand.tsx
  • frontend/src/components/marketing/SurfaceBento.test.tsx
  • frontend/src/components/marketing/SurfaceBento.tsx
  • frontend/src/components/marketing/featureBands.tsx
  • frontend/src/components/marketing/graph/KnowledgeGraphDemo.test.tsx
  • frontend/src/components/marketing/graph/KnowledgeGraphDemo.tsx
  • frontend/src/components/marketing/graph/courseGraphs.test.ts
  • frontend/src/components/marketing/graph/courseGraphs.ts
  • frontend/src/components/marketing/graph/layout.test.ts
  • frontend/src/components/marketing/graph/layout.ts
  • frontend/src/components/marketing/surfaces/GradebookSurface.tsx
  • frontend/src/components/marketing/surfaces/NotesSurface.tsx
  • frontend/src/components/marketing/surfaces/QuizSurface.tsx
  • frontend/src/components/marketing/surfaces/ReviewSurface.tsx
  • frontend/src/components/marketing/surfaces/RoomsSurface.tsx
  • frontend/src/components/marketing/surfaces/Surface.tsx
  • frontend/src/components/marketing/surfaces/TutorSurface.tsx
  • frontend/src/components/marketing/surfaces/UploadSurface.tsx
  • frontend/src/lib/landing/color.ts
  • frontend/src/lib/landing/companionContent.ts
  • frontend/src/lib/landing/content.ts
  • frontend/src/lib/landing/course.ts
  • frontend/src/lib/landing/dragClusters.ts
  • frontend/src/lib/landing/engine/ambient.ts
  • frontend/src/lib/landing/engine/dom.ts
  • frontend/src/lib/landing/engine/flip.ts
  • frontend/src/lib/landing/engine/graph.ts
  • frontend/src/lib/landing/engine/graphView.ts
  • frontend/src/lib/landing/engine/hero.ts
  • frontend/src/lib/landing/engine/index.ts
  • frontend/src/lib/landing/engine/marquee.ts
  • frontend/src/lib/landing/engine/measure.ts
  • frontend/src/lib/landing/engine/plant.ts
  • frontend/src/lib/landing/engine/sim.ts
  • frontend/src/lib/landing/hero3d/cardTexture.ts
  • frontend/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

Comment on lines +1957 to +2005
@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); } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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

Comment on lines +41 to +50
<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' }}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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 no aria-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.

Comment on lines +62 to +85
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>
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Comment on lines +37 to +44
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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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 ms extract and summarize timeouts in refs and clear them in the existing unmount effect beside saveT.
📍 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.

Comment on lines +21 to +24
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' },
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
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.

Comment on lines +104 to +124
<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>
);
})}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
<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.

Comment on lines +213 to +224
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
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
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.

Comment on lines +423 to +430
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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:

  1. A second ensureInit on the same DOM finds .drag-field elements with no [data-dragnode] children, returns false at 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 in frontend/src/components/landing/useLanding.ts lines 162-210.
  2. React still owns those cluster nodes, because DragField rendered 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.

Darkest-Teddyand others added 6 commits August 4, 2026 20:26
…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>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 741ec10 and e506827.

📒 Files selected for processing (13)
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/gallery/page.tsx
  • frontend/src/app/(public)/news/page.tsx
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/(public)/team/page.tsx
  • frontend/src/app/(public)/wiki/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/companion/CompanionShell.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/BetaModal.tsx
  • frontend/src/components/landing-v5/DragField.tsx
  • frontend/src/components/landing-v5/FeatureLab.tsx
  • frontend/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

Comment on lines +52 to +70
{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&rsquo;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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
{subscribed ? (
<divstyle={{textAlign: 'center',padding: '10px 4px 6px'}}>
{/*carriesheadingIdtooitiswhat`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&rsquo;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'}}>
{/*carriesheadingIdtooitiswhat`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&rsquo;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>
Darkest-Teddyand others added 13 commits August 9, 2026 20:13
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>
Darkest-Teddyand others added 2 commits August 16, 2026 17:48
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>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review August 16, 2026 21:59
@Darkest-Teddy
Darkest-Teddy merged commit 4f5a7f9 into mainAug 16, 2026
6 of 7 checks passed
AndresL230 added a commit that referenced this pull request Aug 22, 2026
)
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>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
) (#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>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
* Revert "test(e2e): measure a dropped node against the copy it is welded to (#566) (#568)"
This reverts commit 752a82c.
* Revert "Merge pull request #524 from SaplingLearn/feat/landing-v5-port"
This reverts commit 4f5a7f9, reversing
changes made to 0effc9e.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Darkest-Teddy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(landing): port Sapling Landing v5 by Darkest-Teddy · Pull Request #524 · SaplingLearn/Sapling · GitHub
Skip to content

feat(landing): port Sapling Landing v5 - #524

Merged
Darkest-Teddy merged 48 commits into
mainfrom
feat/landing-v5-port
Aug 16, 2026
Merged

feat(landing): port Sapling Landing v5#524
Darkest-Teddy merged 48 commits into
mainfrom
feat/landing-v5-port

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Ports Sapling Landing v5.dc.html (from docs/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:

AssumedActual v5 source
WordmarkArchivo 800, ink #12201APlayfair Display 600, #0C5638
RevealheroWipe clip-pathcharacter scrambleheroWipe is defined, never referenced
Eyebrowmono "GROW YOUR KNOWLEDGE"Playfair "Grow Your Knowledge", title case, scrambled
Cascadetagline leads (760ms), wordmark follows 260ms behind — v4 ran the reverse
Right bloommirrored greenteal rgba(43,140,150,0.2)
Panel rigCSS perspective cardsthree.js WebGL scene

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/three were 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:

  1. 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.
  2. heroWipe / heroChar keyframes — defined, unreferenced. Not ported.
  3. setHero0/1/2 + mode pills — exist only in renderVals(), so heroMode is 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.
  4. "Plant a concept"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.ts reads a ring's halo via previousElementSibling and its caption via nextElementSibling; a <g> wrapper broke the adjacency and it threw on a null label.
  • Clusters were anchored once, in page coordinates. The source captures each cluster's offset within its field, parks the overlay in 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 ≤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

  • The old marketing landing is retired. Deleting / orphaned marketing/graph/ (~95KB of passing tests for a surface no route mounts), marketing/surfaces/, FeatureBand, SurfaceBento, featureBands. HeroCard/SignInModal stay — the nav's Sign In opens the real OAuth modal.
  • e2e/landing-graph.spec.ts goes with the component it pinned. public-seo.spec.ts's SSR guard is retargeted, not deleted: its failure mode (an ssr: false silently 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's aria-label is checked instead. All 7 assertions verified against raw SSR output.
  • The five companion pages the nav links to (/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

  • Feature lab demo pane. The source mounts a separate FeatureLab design 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.
  • Gallery tiles have no artwork — the import ships no screenshots.

Verification

  • tsc --noEmit exit 0 · eslint exit 0 · next build exit 0, all 5 new routes prerendered static
  • Unit suite 59 files / 423 tests passing
  • Every section walked in a real browser at multiple scroll positions, no console errors; all 9 public routes return 200

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced a redesigned landing experience with interactive knowledge graphs, tutoring modes, feature demos, gallery, journal, FAQ, and beta signup.
    • Added public FAQ, Gallery, News, Team, and Wiki pages with responsive navigation and searchable or filterable content.
    • Added interactive demonstrations for quizzes, flashcards, grades, calendars, notes, study rooms, guides, and tutoring.
  • Documentation
    • Improved server-rendered landing-page checks for key accessible content and beta messaging.
  • Refactor
    • Retired the previous marketing sections and legacy landing-page presentation.

Darkest-Teddyand others added 5 commits August 3, 2026 22:11
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>
@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ebab108d-0405-4ae1-8d00-108ba83a0144

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

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

Changes

Landing v5 experience

Layer / File(s)Summary
Landing content and data contracts
frontend/src/lib/landing/*
Added landing content, course graph data, drag clusters, color utilities, and companion-page datasets.
Animation and interaction engines
frontend/src/lib/landing/engine/*, frontend/src/lib/landing/hero3d/*
Added graph rendering, canvas effects, layout measurement, marquee and FLIP behavior, planting, drag simulation, and WebGL hero support.
Landing composition
frontend/src/app/(public)/page.tsx, frontend/src/components/landing-v5/*, frontend/src/components/landing/useLanding.ts
Replaced the previous landing implementation with v5 hero, graph, ingest, tutor, gallery, FAQ, journal, CTA, navigation, modal, and lifecycle components.
Interactive feature labs
frontend/src/components/landing-v5/lab/*
Added interactive quiz, flashcard, guide, grade, calendar, notes, rooms, and tutor demos with shared fixture data and controls.
Companion pages
frontend/src/components/companion/*, frontend/src/app/(public)/{about,team,wiki,faq,gallery,news}/*
Added shared companion navigation and public About, Team, Wiki, FAQ, Gallery, and News routes.
Styling and SSR validation
frontend/src/app/globals.css, frontend/e2e/public-seo.spec.ts, frontend/eslint-suppressions.json, frontend/src/lib/api.ts
Added scoped landing and companion styles, responsive animations, newsletter API wiring, and SSR checks for landing-page copy. Removed obsolete ESLint suppressions.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers:andresl230

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 36.59% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the primary change: porting Sapling Landing v5 into the landing page.
Description check✅ PassedThe description explains the purpose, major changes, known gaps, related issue context, and verification results in substantial detail.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/landing-v5-port

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Aug 4, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

Darkest-Teddyand others added 6 commits August 4, 2026 15:34
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 4, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingfc36068Commit Preview URL

Branch Preview URL
Aug 16 2026, 09:58 PM

Darkest-Teddyand others added 6 commits August 4, 2026 16:21
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>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review August 5, 2026 00:09

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

extract and summarize timeouts are untracked.

saveT is 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 win

The global space handler blocks button activation.

The keydown listener calls e.preventDefault() for every space press on window. A focused button then cannot be activated with space, including "Study again" at line 90. Skip the flip and the preventDefault call 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 win

The "no concepts" sentinel is counted as a linked concept.

When no matcher hits, concepts receives 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. Keep concepts empty 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 win

Do not cache a zero-sized canvas measurement.

cv() caches w/h from clientWidth/clientHeight on the first call. The cache is dropped only by invalidateCanvas, which measure() calls only when the viewport dimensions change (see frontend/src/lib/landing/engine/measure.ts lines 98-104). If the first cv() call happens while the canvas is not laid out yet, or while its section is hidden, the cache stores 0 × 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 win

Each 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 /news route. Point each card at the article, or at /news until 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 win

Label binding depends on an SVG sibling order that the markup does not guarantee.engine/sim.ts locates a node's caption through ring.nextElementSibling and casts it to SVGTextElement, while DragField renders 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 writes x/y onto it every frame, and ly becomes NaN.

  • frontend/src/lib/landing/engine/sim.ts#L184-L203: check nextElementSibling.tagName === 'text' before the cast, type SimNode.label as SVGTextElement | 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 /faq route. 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 win

Set 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 current actions.subscribe, at frontend/src/components/landing/useLanding.ts:624, only checks email.includes('@') and sets local subscribed state. If this should be a stub, make that intent explicit; otherwise wire onSubscribe to 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 win

Closed answers stay in the accessibility tree, and the button has no aria-controls.

The collapsed panel uses maxHeight: 0, opacity: 0, and overflow: 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 declares aria-expanded without naming the region it controls.

Add aria-controls with a stable id, and set aria-hidden on the panel while it is closed. aria-hidden does not disturb the max-height transition, 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 declare role="menu", but their children are plain button elements and next/link anchors. The ARIA menu pattern permits only menuitem, menuitemradio, and menuitemcheckbox children, 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 add menuitem roles.

  • frontend/src/components/landing-v5/Navbar.tsx#L199-L235: remove role="menu" from the compact panel, and remove aria-haspopup="menu" from the trigger at line 120. Keep aria-expanded.
  • frontend/src/components/landing-v5/Closing.tsx#L154-L182: remove role="menu" from the jump panel, and remove aria-haspopup="menu" from the pill at line 188. The enclosing nav with aria-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 win

Clear the deferred openGal timers on unmount.

actions.openGal schedules actions.enterExplore / actions.openGal 260 ms later from onQuiz and onLearn. If the page unmounts before those timers fire, openGal runs after cleanup and writes document.body.style.overflow = 'hidden' after the landing cleanup reset that overflow. Keep the timer refs in useLanding so cleanup can call clearTimeout for 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 win

Expose the selected tutor mode, and keep the hidden pills out of the tab order.

Two gaps in this control group:

  1. The pills are a mode selector, but the selected state is carried only by background and color. Assistive technology cannot report which mode is active. Add aria-pressed={tutorMode === i}.
  2. The container starts at opacity: 0 with pointerEvents: '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 opacity on the container. Have it also clear visibility or the inert attribute 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 win

Add an Escape key exit for explore mode.

Explore mode covers the act and closes the navbar, but useLanding only handles Escape for gallery and jump controls. If explore mode is active, call onExitExplore from 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 win

Release 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. Add renderer.forceContextLoss() after renderer.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

Segmented is unused; two demos duplicate it inline.

The comment states that the tutor and calendar demos use this control. They do not import it. CalendarDemo.tsx lines 61-76 and TutorDemo.tsx lines 65-80 each contain an inline copy of the same markup and style logic. Use Segmented in 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 value

Difficulty badge changes while the same question is shown.

submit updates streak before it sets phase to review. qi does 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 from answers.length captured 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 value

Row percentage is unclamped while the weighted grade is clamped.

Line 25 clamps the score into [0, r.possible]. Line 64 uses the unclamped raw. If the user types 70 in a 50-point row, the row shows 140% 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 value

Remove duplicated TIER from labData.ts.

labData.ts exports TIER, but the lab demos import LAB_TIER and render with labData.ts content. The four hex values and keys match LAB_TIER, so two copies of this swatch set can drift. Drop TIER from labData.ts and keep using LAB_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 win

Replace 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 value

Make lede optional so the privacy section does not render an empty paragraph.

The privacy section passes lede="". Section always renders the <p>, so the page emits an empty paragraph with 0 0 20px margins. 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 win

Avoid the per-frame layout read in update().

update() runs from the engine tick on every frame the gallery is on screen. Line 136 reads wrap.clientWidth on each of those frames, and line 156 writes track.style.transform. The write dirties style, so the next frame's read forces a style recalculation and layout. That contradicts the engine invariant documented in frontend/src/lib/landing/engine/index.ts lines 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 to cleanups:

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 value

Mark the active rail button for assistive technology.

on changes only the colors. Add aria-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 value

The wrapper duplicates the miniature's own container.

The <div> here sets height: 166, borderRadius: 13, overflow: hidden, the same background, and the same border. Every entry in GALLERY_MINIS opens with a <div> carrying that identical style set (see galleryMinis.tsx line 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.ran is never read.

armFlip sets ran = false and flipClose resets it, but no code in this module or in frontend/src/components/landing/useLanding.ts reads it. The consumer tracks the same guard with its own flipRan ref (see useLanding.ts lines 158, 552, 574-575). Two guards for one invariant can drift. Either remove ran from FlipState or make flipOpen own the guard and delete flipRan from 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 value

Points behind the camera reach the link pass with a degenerate scale.

sc = FL / (FL + z) is computed for every node. Rotation mixes ox into z, and ox reaches about ±740 while oz reaches about ±340, so z can pass -1000. At z === -FL the divide produces Infinity, and below -FL it 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 with Infinity scale gives reach === Infinity, so the break at 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 > -FL test 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 value

The 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. A MutationObserver on root would react to the same additions without the standing timer. This is a nice-to-have; the current cost is one querySelectorAll per 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

up ignores pointerId, and the window listeners are registered once per cluster.

Two small points in bindDrag:

  • up does not compare e.pointerId with f.drag.id. A second pointer that lifts cancels the active drag. onMove does compare the id, so the handlers are inconsistent.
  • onMove and up are added to window inside 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 win

Share the companion-page list, and make the footer icon decorative.

Two points:

  1. FOOTER_LINKS repeats PAGES in Navbar.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.
  2. Line 91 sets alt="Sapling" on the icon, and the adjacent text already reads "Sapling · © 2026". A screen reader announces the name twice. Navbar.tsx line 100 already uses alt="" 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

📥 Commits

Reviewing files that changed from the base of the PR and between ec34bf1 and 741ec10.

⛔ Files ignored due to path filters (3)
  • frontend/public/journal-ai-homework.png is excluded by !**/*.png
  • frontend/public/journal-founding.png is excluded by !**/*.png
  • frontend/public/kofi-symbol.png is excluded by !**/*.png
📒 Files selected for processing (75)
  • frontend/e2e/landing-graph.spec.ts
  • frontend/e2e/public-seo.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/gallery/page.tsx
  • frontend/src/app/(public)/news/page.tsx
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/(public)/team/page.tsx
  • frontend/src/app/(public)/wiki/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/companion/CompanionShell.tsx
  • frontend/src/components/companion/primitives.tsx
  • frontend/src/components/landing-v5/ActGraph.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/ActTutor.tsx
  • frontend/src/components/landing-v5/Closing.tsx
  • frontend/src/components/landing-v5/DragField.tsx
  • frontend/src/components/landing-v5/Faq.tsx
  • frontend/src/components/landing-v5/FeatureLab.tsx
  • frontend/src/components/landing-v5/Gallery.tsx
  • frontend/src/components/landing-v5/Hero.tsx
  • frontend/src/components/landing-v5/IntroOverlay.tsx
  • frontend/src/components/landing-v5/Journal.tsx
  • frontend/src/components/landing-v5/Navbar.tsx
  • frontend/src/components/landing-v5/galleryMinis.tsx
  • frontend/src/components/landing-v5/lab/CalendarDemo.tsx
  • frontend/src/components/landing-v5/lab/CardsDemo.tsx
  • frontend/src/components/landing-v5/lab/GradesDemo.tsx
  • frontend/src/components/landing-v5/lab/GuideDemo.tsx
  • frontend/src/components/landing-v5/lab/LabShell.tsx
  • frontend/src/components/landing-v5/lab/NotesDemo.tsx
  • frontend/src/components/landing-v5/lab/QuizDemo.tsx
  • frontend/src/components/landing-v5/lab/RoomsDemo.tsx
  • frontend/src/components/landing-v5/lab/TutorDemo.tsx
  • frontend/src/components/landing-v5/lab/index.tsx
  • frontend/src/components/landing-v5/lab/labData.ts
  • frontend/src/components/landing-v5/navTheme.ts
  • frontend/src/components/landing/useLanding.ts
  • frontend/src/components/marketing/FeatureBand.test.tsx
  • frontend/src/components/marketing/FeatureBand.tsx
  • frontend/src/components/marketing/SurfaceBento.test.tsx
  • frontend/src/components/marketing/SurfaceBento.tsx
  • frontend/src/components/marketing/featureBands.tsx
  • frontend/src/components/marketing/graph/KnowledgeGraphDemo.test.tsx
  • frontend/src/components/marketing/graph/KnowledgeGraphDemo.tsx
  • frontend/src/components/marketing/graph/courseGraphs.test.ts
  • frontend/src/components/marketing/graph/courseGraphs.ts
  • frontend/src/components/marketing/graph/layout.test.ts
  • frontend/src/components/marketing/graph/layout.ts
  • frontend/src/components/marketing/surfaces/GradebookSurface.tsx
  • frontend/src/components/marketing/surfaces/NotesSurface.tsx
  • frontend/src/components/marketing/surfaces/QuizSurface.tsx
  • frontend/src/components/marketing/surfaces/ReviewSurface.tsx
  • frontend/src/components/marketing/surfaces/RoomsSurface.tsx
  • frontend/src/components/marketing/surfaces/Surface.tsx
  • frontend/src/components/marketing/surfaces/TutorSurface.tsx
  • frontend/src/components/marketing/surfaces/UploadSurface.tsx
  • frontend/src/lib/landing/color.ts
  • frontend/src/lib/landing/companionContent.ts
  • frontend/src/lib/landing/content.ts
  • frontend/src/lib/landing/course.ts
  • frontend/src/lib/landing/dragClusters.ts
  • frontend/src/lib/landing/engine/ambient.ts
  • frontend/src/lib/landing/engine/dom.ts
  • frontend/src/lib/landing/engine/flip.ts
  • frontend/src/lib/landing/engine/graph.ts
  • frontend/src/lib/landing/engine/graphView.ts
  • frontend/src/lib/landing/engine/hero.ts
  • frontend/src/lib/landing/engine/index.ts
  • frontend/src/lib/landing/engine/marquee.ts
  • frontend/src/lib/landing/engine/measure.ts
  • frontend/src/lib/landing/engine/plant.ts
  • frontend/src/lib/landing/engine/sim.ts
  • frontend/src/lib/landing/hero3d/cardTexture.ts
  • frontend/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

Comment on lines +1957 to +2005
@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); } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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

Comment on lines +41 to +50
<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' }}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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 no aria-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.

Comment on lines +62 to +85
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>
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Comment on lines +37 to +44
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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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 ms extract and summarize timeouts in refs and clear them in the existing unmount effect beside saveT.
📍 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.

Comment on lines +21 to +24
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' },
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
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.

Comment on lines +104 to +124
<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>
);
})}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
<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.

Comment on lines +213 to +224
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
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
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.

Comment on lines +423 to +430
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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:

  1. A second ensureInit on the same DOM finds .drag-field elements with no [data-dragnode] children, returns false at 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 in frontend/src/components/landing/useLanding.ts lines 162-210.
  2. React still owns those cluster nodes, because DragField rendered 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.

Darkest-Teddyand others added 6 commits August 4, 2026 20:26
…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>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 741ec10 and e506827.

📒 Files selected for processing (13)
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/gallery/page.tsx
  • frontend/src/app/(public)/news/page.tsx
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/(public)/team/page.tsx
  • frontend/src/app/(public)/wiki/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/companion/CompanionShell.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/BetaModal.tsx
  • frontend/src/components/landing-v5/DragField.tsx
  • frontend/src/components/landing-v5/FeatureLab.tsx
  • frontend/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

Comment on lines +52 to +70
{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&rsquo;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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
{subscribed ? (
<divstyle={{textAlign: 'center',padding: '10px 4px 6px'}}>
{/*carriesheadingIdtooitiswhat`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&rsquo;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'}}>
{/*carriesheadingIdtooitiswhat`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&rsquo;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>
Darkest-Teddyand others added 13 commits August 9, 2026 20:13
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>
Darkest-Teddyand others added 2 commits August 16, 2026 17:48
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>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review August 16, 2026 21:59
@Darkest-Teddy
Darkest-Teddy merged commit 4f5a7f9 into mainAug 16, 2026
6 of 7 checks passed
AndresL230 added a commit that referenced this pull request Aug 22, 2026
)
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>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
) (#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>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
* Revert "test(e2e): measure a dropped node against the copy it is welded to (#566) (#568)"
This reverts commit 752a82c.
* Revert "Merge pull request #524 from SaplingLearn/feat/landing-v5-port"
This reverts commit 4f5a7f9, reversing
changes made to 0effc9e.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Darkest-Teddy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(landing): port Sapling Landing v5 by Darkest-Teddy · Pull Request #524 · SaplingLearn/Sapling · GitHub
Skip to content

feat(landing): port Sapling Landing v5 - #524

Merged
Darkest-Teddy merged 48 commits into
mainfrom
feat/landing-v5-port
Aug 16, 2026
Merged

feat(landing): port Sapling Landing v5#524
Darkest-Teddy merged 48 commits into
mainfrom
feat/landing-v5-port

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Ports Sapling Landing v5.dc.html (from docs/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:

AssumedActual v5 source
WordmarkArchivo 800, ink #12201APlayfair Display 600, #0C5638
RevealheroWipe clip-pathcharacter scrambleheroWipe is defined, never referenced
Eyebrowmono "GROW YOUR KNOWLEDGE"Playfair "Grow Your Knowledge", title case, scrambled
Cascadetagline leads (760ms), wordmark follows 260ms behind — v4 ran the reverse
Right bloommirrored greenteal rgba(43,140,150,0.2)
Panel rigCSS perspective cardsthree.js WebGL scene

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/three were 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:

  1. 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.
  2. heroWipe / heroChar keyframes — defined, unreferenced. Not ported.
  3. setHero0/1/2 + mode pills — exist only in renderVals(), so heroMode is 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.
  4. "Plant a concept"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.ts reads a ring's halo via previousElementSibling and its caption via nextElementSibling; a <g> wrapper broke the adjacency and it threw on a null label.
  • Clusters were anchored once, in page coordinates. The source captures each cluster's offset within its field, parks the overlay in 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 ≤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

  • The old marketing landing is retired. Deleting / orphaned marketing/graph/ (~95KB of passing tests for a surface no route mounts), marketing/surfaces/, FeatureBand, SurfaceBento, featureBands. HeroCard/SignInModal stay — the nav's Sign In opens the real OAuth modal.
  • e2e/landing-graph.spec.ts goes with the component it pinned. public-seo.spec.ts's SSR guard is retargeted, not deleted: its failure mode (an ssr: false silently 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's aria-label is checked instead. All 7 assertions verified against raw SSR output.
  • The five companion pages the nav links to (/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

  • Feature lab demo pane. The source mounts a separate FeatureLab design 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.
  • Gallery tiles have no artwork — the import ships no screenshots.

Verification

  • tsc --noEmit exit 0 · eslint exit 0 · next build exit 0, all 5 new routes prerendered static
  • Unit suite 59 files / 423 tests passing
  • Every section walked in a real browser at multiple scroll positions, no console errors; all 9 public routes return 200

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced a redesigned landing experience with interactive knowledge graphs, tutoring modes, feature demos, gallery, journal, FAQ, and beta signup.
    • Added public FAQ, Gallery, News, Team, and Wiki pages with responsive navigation and searchable or filterable content.
    • Added interactive demonstrations for quizzes, flashcards, grades, calendars, notes, study rooms, guides, and tutoring.
  • Documentation
    • Improved server-rendered landing-page checks for key accessible content and beta messaging.
  • Refactor
    • Retired the previous marketing sections and legacy landing-page presentation.

Darkest-Teddyand others added 5 commits August 3, 2026 22:11
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>
@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ebab108d-0405-4ae1-8d00-108ba83a0144

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

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

Changes

Landing v5 experience

Layer / File(s)Summary
Landing content and data contracts
frontend/src/lib/landing/*
Added landing content, course graph data, drag clusters, color utilities, and companion-page datasets.
Animation and interaction engines
frontend/src/lib/landing/engine/*, frontend/src/lib/landing/hero3d/*
Added graph rendering, canvas effects, layout measurement, marquee and FLIP behavior, planting, drag simulation, and WebGL hero support.
Landing composition
frontend/src/app/(public)/page.tsx, frontend/src/components/landing-v5/*, frontend/src/components/landing/useLanding.ts
Replaced the previous landing implementation with v5 hero, graph, ingest, tutor, gallery, FAQ, journal, CTA, navigation, modal, and lifecycle components.
Interactive feature labs
frontend/src/components/landing-v5/lab/*
Added interactive quiz, flashcard, guide, grade, calendar, notes, rooms, and tutor demos with shared fixture data and controls.
Companion pages
frontend/src/components/companion/*, frontend/src/app/(public)/{about,team,wiki,faq,gallery,news}/*
Added shared companion navigation and public About, Team, Wiki, FAQ, Gallery, and News routes.
Styling and SSR validation
frontend/src/app/globals.css, frontend/e2e/public-seo.spec.ts, frontend/eslint-suppressions.json, frontend/src/lib/api.ts
Added scoped landing and companion styles, responsive animations, newsletter API wiring, and SSR checks for landing-page copy. Removed obsolete ESLint suppressions.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers:andresl230

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 36.59% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the primary change: porting Sapling Landing v5 into the landing page.
Description check✅ PassedThe description explains the purpose, major changes, known gaps, related issue context, and verification results in substantial detail.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/landing-v5-port

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Aug 4, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

Darkest-Teddyand others added 6 commits August 4, 2026 15:34
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 4, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingfc36068Commit Preview URL

Branch Preview URL
Aug 16 2026, 09:58 PM

Darkest-Teddyand others added 6 commits August 4, 2026 16:21
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>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review August 5, 2026 00:09

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

extract and summarize timeouts are untracked.

saveT is 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 win

The global space handler blocks button activation.

The keydown listener calls e.preventDefault() for every space press on window. A focused button then cannot be activated with space, including "Study again" at line 90. Skip the flip and the preventDefault call 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 win

The "no concepts" sentinel is counted as a linked concept.

When no matcher hits, concepts receives 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. Keep concepts empty 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 win

Do not cache a zero-sized canvas measurement.

cv() caches w/h from clientWidth/clientHeight on the first call. The cache is dropped only by invalidateCanvas, which measure() calls only when the viewport dimensions change (see frontend/src/lib/landing/engine/measure.ts lines 98-104). If the first cv() call happens while the canvas is not laid out yet, or while its section is hidden, the cache stores 0 × 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 win

Each 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 /news route. Point each card at the article, or at /news until 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 win

Label binding depends on an SVG sibling order that the markup does not guarantee.engine/sim.ts locates a node's caption through ring.nextElementSibling and casts it to SVGTextElement, while DragField renders 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 writes x/y onto it every frame, and ly becomes NaN.

  • frontend/src/lib/landing/engine/sim.ts#L184-L203: check nextElementSibling.tagName === 'text' before the cast, type SimNode.label as SVGTextElement | 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 /faq route. 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 win

Set 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 current actions.subscribe, at frontend/src/components/landing/useLanding.ts:624, only checks email.includes('@') and sets local subscribed state. If this should be a stub, make that intent explicit; otherwise wire onSubscribe to 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 win

Closed answers stay in the accessibility tree, and the button has no aria-controls.

The collapsed panel uses maxHeight: 0, opacity: 0, and overflow: 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 declares aria-expanded without naming the region it controls.

Add aria-controls with a stable id, and set aria-hidden on the panel while it is closed. aria-hidden does not disturb the max-height transition, 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 declare role="menu", but their children are plain button elements and next/link anchors. The ARIA menu pattern permits only menuitem, menuitemradio, and menuitemcheckbox children, 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 add menuitem roles.

  • frontend/src/components/landing-v5/Navbar.tsx#L199-L235: remove role="menu" from the compact panel, and remove aria-haspopup="menu" from the trigger at line 120. Keep aria-expanded.
  • frontend/src/components/landing-v5/Closing.tsx#L154-L182: remove role="menu" from the jump panel, and remove aria-haspopup="menu" from the pill at line 188. The enclosing nav with aria-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 win

Clear the deferred openGal timers on unmount.

actions.openGal schedules actions.enterExplore / actions.openGal 260 ms later from onQuiz and onLearn. If the page unmounts before those timers fire, openGal runs after cleanup and writes document.body.style.overflow = 'hidden' after the landing cleanup reset that overflow. Keep the timer refs in useLanding so cleanup can call clearTimeout for 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 win

Expose the selected tutor mode, and keep the hidden pills out of the tab order.

Two gaps in this control group:

  1. The pills are a mode selector, but the selected state is carried only by background and color. Assistive technology cannot report which mode is active. Add aria-pressed={tutorMode === i}.
  2. The container starts at opacity: 0 with pointerEvents: '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 opacity on the container. Have it also clear visibility or the inert attribute 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 win

Add an Escape key exit for explore mode.

Explore mode covers the act and closes the navbar, but useLanding only handles Escape for gallery and jump controls. If explore mode is active, call onExitExplore from 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 win

Release 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. Add renderer.forceContextLoss() after renderer.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

Segmented is unused; two demos duplicate it inline.

The comment states that the tutor and calendar demos use this control. They do not import it. CalendarDemo.tsx lines 61-76 and TutorDemo.tsx lines 65-80 each contain an inline copy of the same markup and style logic. Use Segmented in 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 value

Difficulty badge changes while the same question is shown.

submit updates streak before it sets phase to review. qi does 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 from answers.length captured 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 value

Row percentage is unclamped while the weighted grade is clamped.

Line 25 clamps the score into [0, r.possible]. Line 64 uses the unclamped raw. If the user types 70 in a 50-point row, the row shows 140% 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 value

Remove duplicated TIER from labData.ts.

labData.ts exports TIER, but the lab demos import LAB_TIER and render with labData.ts content. The four hex values and keys match LAB_TIER, so two copies of this swatch set can drift. Drop TIER from labData.ts and keep using LAB_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 win

Replace 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 value

Make lede optional so the privacy section does not render an empty paragraph.

The privacy section passes lede="". Section always renders the <p>, so the page emits an empty paragraph with 0 0 20px margins. 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 win

Avoid the per-frame layout read in update().

update() runs from the engine tick on every frame the gallery is on screen. Line 136 reads wrap.clientWidth on each of those frames, and line 156 writes track.style.transform. The write dirties style, so the next frame's read forces a style recalculation and layout. That contradicts the engine invariant documented in frontend/src/lib/landing/engine/index.ts lines 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 to cleanups:

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 value

Mark the active rail button for assistive technology.

on changes only the colors. Add aria-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 value

The wrapper duplicates the miniature's own container.

The <div> here sets height: 166, borderRadius: 13, overflow: hidden, the same background, and the same border. Every entry in GALLERY_MINIS opens with a <div> carrying that identical style set (see galleryMinis.tsx line 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.ran is never read.

armFlip sets ran = false and flipClose resets it, but no code in this module or in frontend/src/components/landing/useLanding.ts reads it. The consumer tracks the same guard with its own flipRan ref (see useLanding.ts lines 158, 552, 574-575). Two guards for one invariant can drift. Either remove ran from FlipState or make flipOpen own the guard and delete flipRan from 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 value

Points behind the camera reach the link pass with a degenerate scale.

sc = FL / (FL + z) is computed for every node. Rotation mixes ox into z, and ox reaches about ±740 while oz reaches about ±340, so z can pass -1000. At z === -FL the divide produces Infinity, and below -FL it 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 with Infinity scale gives reach === Infinity, so the break at 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 > -FL test 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 value

The 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. A MutationObserver on root would react to the same additions without the standing timer. This is a nice-to-have; the current cost is one querySelectorAll per 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

up ignores pointerId, and the window listeners are registered once per cluster.

Two small points in bindDrag:

  • up does not compare e.pointerId with f.drag.id. A second pointer that lifts cancels the active drag. onMove does compare the id, so the handlers are inconsistent.
  • onMove and up are added to window inside 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 win

Share the companion-page list, and make the footer icon decorative.

Two points:

  1. FOOTER_LINKS repeats PAGES in Navbar.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.
  2. Line 91 sets alt="Sapling" on the icon, and the adjacent text already reads "Sapling · © 2026". A screen reader announces the name twice. Navbar.tsx line 100 already uses alt="" 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

📥 Commits

Reviewing files that changed from the base of the PR and between ec34bf1 and 741ec10.

⛔ Files ignored due to path filters (3)
  • frontend/public/journal-ai-homework.png is excluded by !**/*.png
  • frontend/public/journal-founding.png is excluded by !**/*.png
  • frontend/public/kofi-symbol.png is excluded by !**/*.png
📒 Files selected for processing (75)
  • frontend/e2e/landing-graph.spec.ts
  • frontend/e2e/public-seo.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/gallery/page.tsx
  • frontend/src/app/(public)/news/page.tsx
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/(public)/team/page.tsx
  • frontend/src/app/(public)/wiki/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/companion/CompanionShell.tsx
  • frontend/src/components/companion/primitives.tsx
  • frontend/src/components/landing-v5/ActGraph.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/ActTutor.tsx
  • frontend/src/components/landing-v5/Closing.tsx
  • frontend/src/components/landing-v5/DragField.tsx
  • frontend/src/components/landing-v5/Faq.tsx
  • frontend/src/components/landing-v5/FeatureLab.tsx
  • frontend/src/components/landing-v5/Gallery.tsx
  • frontend/src/components/landing-v5/Hero.tsx
  • frontend/src/components/landing-v5/IntroOverlay.tsx
  • frontend/src/components/landing-v5/Journal.tsx
  • frontend/src/components/landing-v5/Navbar.tsx
  • frontend/src/components/landing-v5/galleryMinis.tsx
  • frontend/src/components/landing-v5/lab/CalendarDemo.tsx
  • frontend/src/components/landing-v5/lab/CardsDemo.tsx
  • frontend/src/components/landing-v5/lab/GradesDemo.tsx
  • frontend/src/components/landing-v5/lab/GuideDemo.tsx
  • frontend/src/components/landing-v5/lab/LabShell.tsx
  • frontend/src/components/landing-v5/lab/NotesDemo.tsx
  • frontend/src/components/landing-v5/lab/QuizDemo.tsx
  • frontend/src/components/landing-v5/lab/RoomsDemo.tsx
  • frontend/src/components/landing-v5/lab/TutorDemo.tsx
  • frontend/src/components/landing-v5/lab/index.tsx
  • frontend/src/components/landing-v5/lab/labData.ts
  • frontend/src/components/landing-v5/navTheme.ts
  • frontend/src/components/landing/useLanding.ts
  • frontend/src/components/marketing/FeatureBand.test.tsx
  • frontend/src/components/marketing/FeatureBand.tsx
  • frontend/src/components/marketing/SurfaceBento.test.tsx
  • frontend/src/components/marketing/SurfaceBento.tsx
  • frontend/src/components/marketing/featureBands.tsx
  • frontend/src/components/marketing/graph/KnowledgeGraphDemo.test.tsx
  • frontend/src/components/marketing/graph/KnowledgeGraphDemo.tsx
  • frontend/src/components/marketing/graph/courseGraphs.test.ts
  • frontend/src/components/marketing/graph/courseGraphs.ts
  • frontend/src/components/marketing/graph/layout.test.ts
  • frontend/src/components/marketing/graph/layout.ts
  • frontend/src/components/marketing/surfaces/GradebookSurface.tsx
  • frontend/src/components/marketing/surfaces/NotesSurface.tsx
  • frontend/src/components/marketing/surfaces/QuizSurface.tsx
  • frontend/src/components/marketing/surfaces/ReviewSurface.tsx
  • frontend/src/components/marketing/surfaces/RoomsSurface.tsx
  • frontend/src/components/marketing/surfaces/Surface.tsx
  • frontend/src/components/marketing/surfaces/TutorSurface.tsx
  • frontend/src/components/marketing/surfaces/UploadSurface.tsx
  • frontend/src/lib/landing/color.ts
  • frontend/src/lib/landing/companionContent.ts
  • frontend/src/lib/landing/content.ts
  • frontend/src/lib/landing/course.ts
  • frontend/src/lib/landing/dragClusters.ts
  • frontend/src/lib/landing/engine/ambient.ts
  • frontend/src/lib/landing/engine/dom.ts
  • frontend/src/lib/landing/engine/flip.ts
  • frontend/src/lib/landing/engine/graph.ts
  • frontend/src/lib/landing/engine/graphView.ts
  • frontend/src/lib/landing/engine/hero.ts
  • frontend/src/lib/landing/engine/index.ts
  • frontend/src/lib/landing/engine/marquee.ts
  • frontend/src/lib/landing/engine/measure.ts
  • frontend/src/lib/landing/engine/plant.ts
  • frontend/src/lib/landing/engine/sim.ts
  • frontend/src/lib/landing/hero3d/cardTexture.ts
  • frontend/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

Comment on lines +1957 to +2005
@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); } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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

Comment on lines +41 to +50
<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' }}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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 no aria-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.

Comment on lines +62 to +85
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>
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Comment on lines +37 to +44
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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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 ms extract and summarize timeouts in refs and clear them in the existing unmount effect beside saveT.
📍 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.

Comment on lines +21 to +24
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' },
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
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.

Comment on lines +104 to +124
<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>
);
})}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
<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.

Comment on lines +213 to +224
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
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
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.

Comment on lines +423 to +430
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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:

  1. A second ensureInit on the same DOM finds .drag-field elements with no [data-dragnode] children, returns false at 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 in frontend/src/components/landing/useLanding.ts lines 162-210.
  2. React still owns those cluster nodes, because DragField rendered 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.

Darkest-Teddyand others added 6 commits August 4, 2026 20:26
…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>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 741ec10 and e506827.

📒 Files selected for processing (13)
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/gallery/page.tsx
  • frontend/src/app/(public)/news/page.tsx
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/(public)/team/page.tsx
  • frontend/src/app/(public)/wiki/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/companion/CompanionShell.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/BetaModal.tsx
  • frontend/src/components/landing-v5/DragField.tsx
  • frontend/src/components/landing-v5/FeatureLab.tsx
  • frontend/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

Comment on lines +52 to +70
{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&rsquo;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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
{subscribed ? (
<divstyle={{textAlign: 'center',padding: '10px 4px 6px'}}>
{/*carriesheadingIdtooitiswhat`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&rsquo;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'}}>
{/*carriesheadingIdtooitiswhat`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&rsquo;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>
Darkest-Teddyand others added 13 commits August 9, 2026 20:13
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>
Darkest-Teddyand others added 2 commits August 16, 2026 17:48
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>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review August 16, 2026 21:59
@Darkest-Teddy
Darkest-Teddy merged commit 4f5a7f9 into mainAug 16, 2026
6 of 7 checks passed
AndresL230 added a commit that referenced this pull request Aug 22, 2026
)
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>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
) (#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>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
* Revert "test(e2e): measure a dropped node against the copy it is welded to (#566) (#568)"
This reverts commit 752a82c.
* Revert "Merge pull request #524 from SaplingLearn/feat/landing-v5-port"
This reverts commit 4f5a7f9, reversing
changes made to 0effc9e.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Darkest-Teddy
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(landing): port Sapling Landing v5 by Darkest-Teddy · Pull Request #524 · SaplingLearn/Sapling · GitHub
Skip to content

feat(landing): port Sapling Landing v5 - #524

Merged
Darkest-Teddy merged 48 commits into
mainfrom
feat/landing-v5-port
Aug 16, 2026
Merged

feat(landing): port Sapling Landing v5#524
Darkest-Teddy merged 48 commits into
mainfrom
feat/landing-v5-port

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Ports Sapling Landing v5.dc.html (from docs/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:

AssumedActual v5 source
WordmarkArchivo 800, ink #12201APlayfair Display 600, #0C5638
RevealheroWipe clip-pathcharacter scrambleheroWipe is defined, never referenced
Eyebrowmono "GROW YOUR KNOWLEDGE"Playfair "Grow Your Knowledge", title case, scrambled
Cascadetagline leads (760ms), wordmark follows 260ms behind — v4 ran the reverse
Right bloommirrored greenteal rgba(43,140,150,0.2)
Panel rigCSS perspective cardsthree.js WebGL scene

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/three were 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:

  1. 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.
  2. heroWipe / heroChar keyframes — defined, unreferenced. Not ported.
  3. setHero0/1/2 + mode pills — exist only in renderVals(), so heroMode is 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.
  4. "Plant a concept"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.ts reads a ring's halo via previousElementSibling and its caption via nextElementSibling; a <g> wrapper broke the adjacency and it threw on a null label.
  • Clusters were anchored once, in page coordinates. The source captures each cluster's offset within its field, parks the overlay in 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 ≤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

  • The old marketing landing is retired. Deleting / orphaned marketing/graph/ (~95KB of passing tests for a surface no route mounts), marketing/surfaces/, FeatureBand, SurfaceBento, featureBands. HeroCard/SignInModal stay — the nav's Sign In opens the real OAuth modal.
  • e2e/landing-graph.spec.ts goes with the component it pinned. public-seo.spec.ts's SSR guard is retargeted, not deleted: its failure mode (an ssr: false silently 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's aria-label is checked instead. All 7 assertions verified against raw SSR output.
  • The five companion pages the nav links to (/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

  • Feature lab demo pane. The source mounts a separate FeatureLab design 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.
  • Gallery tiles have no artwork — the import ships no screenshots.

Verification

  • tsc --noEmit exit 0 · eslint exit 0 · next build exit 0, all 5 new routes prerendered static
  • Unit suite 59 files / 423 tests passing
  • Every section walked in a real browser at multiple scroll positions, no console errors; all 9 public routes return 200

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced a redesigned landing experience with interactive knowledge graphs, tutoring modes, feature demos, gallery, journal, FAQ, and beta signup.
    • Added public FAQ, Gallery, News, Team, and Wiki pages with responsive navigation and searchable or filterable content.
    • Added interactive demonstrations for quizzes, flashcards, grades, calendars, notes, study rooms, guides, and tutoring.
  • Documentation
    • Improved server-rendered landing-page checks for key accessible content and beta messaging.
  • Refactor
    • Retired the previous marketing sections and legacy landing-page presentation.

Darkest-Teddyand others added 5 commits August 3, 2026 22:11
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>
@coderabbitai

coderabbitaiBot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ebab108d-0405-4ae1-8d00-108ba83a0144

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

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

Changes

Landing v5 experience

Layer / File(s)Summary
Landing content and data contracts
frontend/src/lib/landing/*
Added landing content, course graph data, drag clusters, color utilities, and companion-page datasets.
Animation and interaction engines
frontend/src/lib/landing/engine/*, frontend/src/lib/landing/hero3d/*
Added graph rendering, canvas effects, layout measurement, marquee and FLIP behavior, planting, drag simulation, and WebGL hero support.
Landing composition
frontend/src/app/(public)/page.tsx, frontend/src/components/landing-v5/*, frontend/src/components/landing/useLanding.ts
Replaced the previous landing implementation with v5 hero, graph, ingest, tutor, gallery, FAQ, journal, CTA, navigation, modal, and lifecycle components.
Interactive feature labs
frontend/src/components/landing-v5/lab/*
Added interactive quiz, flashcard, guide, grade, calendar, notes, rooms, and tutor demos with shared fixture data and controls.
Companion pages
frontend/src/components/companion/*, frontend/src/app/(public)/{about,team,wiki,faq,gallery,news}/*
Added shared companion navigation and public About, Team, Wiki, FAQ, Gallery, and News routes.
Styling and SSR validation
frontend/src/app/globals.css, frontend/e2e/public-seo.spec.ts, frontend/eslint-suppressions.json, frontend/src/lib/api.ts
Added scoped landing and companion styles, responsive animations, newsletter API wiring, and SSR checks for landing-page copy. Removed obsolete ESLint suppressions.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers:andresl230

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 36.59% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the primary change: porting Sapling Landing v5 into the landing page.
Description check✅ PassedThe description explains the purpose, major changes, known gaps, related issue context, and verification results in substantial detail.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/landing-v5-port

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Aug 4, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

Darkest-Teddyand others added 6 commits August 4, 2026 15:34
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 4, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingfc36068Commit Preview URL

Branch Preview URL
Aug 16 2026, 09:58 PM

Darkest-Teddyand others added 6 commits August 4, 2026 16:21
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>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review August 5, 2026 00:09

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

extract and summarize timeouts are untracked.

saveT is 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 win

The global space handler blocks button activation.

The keydown listener calls e.preventDefault() for every space press on window. A focused button then cannot be activated with space, including "Study again" at line 90. Skip the flip and the preventDefault call 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 win

The "no concepts" sentinel is counted as a linked concept.

When no matcher hits, concepts receives 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. Keep concepts empty 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 win

Do not cache a zero-sized canvas measurement.

cv() caches w/h from clientWidth/clientHeight on the first call. The cache is dropped only by invalidateCanvas, which measure() calls only when the viewport dimensions change (see frontend/src/lib/landing/engine/measure.ts lines 98-104). If the first cv() call happens while the canvas is not laid out yet, or while its section is hidden, the cache stores 0 × 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 win

Each 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 /news route. Point each card at the article, or at /news until 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 win

Label binding depends on an SVG sibling order that the markup does not guarantee.engine/sim.ts locates a node's caption through ring.nextElementSibling and casts it to SVGTextElement, while DragField renders 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 writes x/y onto it every frame, and ly becomes NaN.

  • frontend/src/lib/landing/engine/sim.ts#L184-L203: check nextElementSibling.tagName === 'text' before the cast, type SimNode.label as SVGTextElement | 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 /faq route. 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 win

Set 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 current actions.subscribe, at frontend/src/components/landing/useLanding.ts:624, only checks email.includes('@') and sets local subscribed state. If this should be a stub, make that intent explicit; otherwise wire onSubscribe to 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 win

Closed answers stay in the accessibility tree, and the button has no aria-controls.

The collapsed panel uses maxHeight: 0, opacity: 0, and overflow: 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 declares aria-expanded without naming the region it controls.

Add aria-controls with a stable id, and set aria-hidden on the panel while it is closed. aria-hidden does not disturb the max-height transition, 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 declare role="menu", but their children are plain button elements and next/link anchors. The ARIA menu pattern permits only menuitem, menuitemradio, and menuitemcheckbox children, 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 add menuitem roles.

  • frontend/src/components/landing-v5/Navbar.tsx#L199-L235: remove role="menu" from the compact panel, and remove aria-haspopup="menu" from the trigger at line 120. Keep aria-expanded.
  • frontend/src/components/landing-v5/Closing.tsx#L154-L182: remove role="menu" from the jump panel, and remove aria-haspopup="menu" from the pill at line 188. The enclosing nav with aria-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 win

Clear the deferred openGal timers on unmount.

actions.openGal schedules actions.enterExplore / actions.openGal 260 ms later from onQuiz and onLearn. If the page unmounts before those timers fire, openGal runs after cleanup and writes document.body.style.overflow = 'hidden' after the landing cleanup reset that overflow. Keep the timer refs in useLanding so cleanup can call clearTimeout for 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 win

Expose the selected tutor mode, and keep the hidden pills out of the tab order.

Two gaps in this control group:

  1. The pills are a mode selector, but the selected state is carried only by background and color. Assistive technology cannot report which mode is active. Add aria-pressed={tutorMode === i}.
  2. The container starts at opacity: 0 with pointerEvents: '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 opacity on the container. Have it also clear visibility or the inert attribute 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 win

Add an Escape key exit for explore mode.

Explore mode covers the act and closes the navbar, but useLanding only handles Escape for gallery and jump controls. If explore mode is active, call onExitExplore from 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 win

Release 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. Add renderer.forceContextLoss() after renderer.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

Segmented is unused; two demos duplicate it inline.

The comment states that the tutor and calendar demos use this control. They do not import it. CalendarDemo.tsx lines 61-76 and TutorDemo.tsx lines 65-80 each contain an inline copy of the same markup and style logic. Use Segmented in 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 value

Difficulty badge changes while the same question is shown.

submit updates streak before it sets phase to review. qi does 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 from answers.length captured 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 value

Row percentage is unclamped while the weighted grade is clamped.

Line 25 clamps the score into [0, r.possible]. Line 64 uses the unclamped raw. If the user types 70 in a 50-point row, the row shows 140% 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 value

Remove duplicated TIER from labData.ts.

labData.ts exports TIER, but the lab demos import LAB_TIER and render with labData.ts content. The four hex values and keys match LAB_TIER, so two copies of this swatch set can drift. Drop TIER from labData.ts and keep using LAB_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 win

Replace 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 value

Make lede optional so the privacy section does not render an empty paragraph.

The privacy section passes lede="". Section always renders the <p>, so the page emits an empty paragraph with 0 0 20px margins. 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 win

Avoid the per-frame layout read in update().

update() runs from the engine tick on every frame the gallery is on screen. Line 136 reads wrap.clientWidth on each of those frames, and line 156 writes track.style.transform. The write dirties style, so the next frame's read forces a style recalculation and layout. That contradicts the engine invariant documented in frontend/src/lib/landing/engine/index.ts lines 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 to cleanups:

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 value

Mark the active rail button for assistive technology.

on changes only the colors. Add aria-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 value

The wrapper duplicates the miniature's own container.

The <div> here sets height: 166, borderRadius: 13, overflow: hidden, the same background, and the same border. Every entry in GALLERY_MINIS opens with a <div> carrying that identical style set (see galleryMinis.tsx line 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.ran is never read.

armFlip sets ran = false and flipClose resets it, but no code in this module or in frontend/src/components/landing/useLanding.ts reads it. The consumer tracks the same guard with its own flipRan ref (see useLanding.ts lines 158, 552, 574-575). Two guards for one invariant can drift. Either remove ran from FlipState or make flipOpen own the guard and delete flipRan from 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 value

Points behind the camera reach the link pass with a degenerate scale.

sc = FL / (FL + z) is computed for every node. Rotation mixes ox into z, and ox reaches about ±740 while oz reaches about ±340, so z can pass -1000. At z === -FL the divide produces Infinity, and below -FL it 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 with Infinity scale gives reach === Infinity, so the break at 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 > -FL test 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 value

The 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. A MutationObserver on root would react to the same additions without the standing timer. This is a nice-to-have; the current cost is one querySelectorAll per 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

up ignores pointerId, and the window listeners are registered once per cluster.

Two small points in bindDrag:

  • up does not compare e.pointerId with f.drag.id. A second pointer that lifts cancels the active drag. onMove does compare the id, so the handlers are inconsistent.
  • onMove and up are added to window inside 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 win

Share the companion-page list, and make the footer icon decorative.

Two points:

  1. FOOTER_LINKS repeats PAGES in Navbar.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.
  2. Line 91 sets alt="Sapling" on the icon, and the adjacent text already reads "Sapling · © 2026". A screen reader announces the name twice. Navbar.tsx line 100 already uses alt="" 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

📥 Commits

Reviewing files that changed from the base of the PR and between ec34bf1 and 741ec10.

⛔ Files ignored due to path filters (3)
  • frontend/public/journal-ai-homework.png is excluded by !**/*.png
  • frontend/public/journal-founding.png is excluded by !**/*.png
  • frontend/public/kofi-symbol.png is excluded by !**/*.png
📒 Files selected for processing (75)
  • frontend/e2e/landing-graph.spec.ts
  • frontend/e2e/public-seo.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/gallery/page.tsx
  • frontend/src/app/(public)/news/page.tsx
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/(public)/team/page.tsx
  • frontend/src/app/(public)/wiki/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/companion/CompanionShell.tsx
  • frontend/src/components/companion/primitives.tsx
  • frontend/src/components/landing-v5/ActGraph.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/ActTutor.tsx
  • frontend/src/components/landing-v5/Closing.tsx
  • frontend/src/components/landing-v5/DragField.tsx
  • frontend/src/components/landing-v5/Faq.tsx
  • frontend/src/components/landing-v5/FeatureLab.tsx
  • frontend/src/components/landing-v5/Gallery.tsx
  • frontend/src/components/landing-v5/Hero.tsx
  • frontend/src/components/landing-v5/IntroOverlay.tsx
  • frontend/src/components/landing-v5/Journal.tsx
  • frontend/src/components/landing-v5/Navbar.tsx
  • frontend/src/components/landing-v5/galleryMinis.tsx
  • frontend/src/components/landing-v5/lab/CalendarDemo.tsx
  • frontend/src/components/landing-v5/lab/CardsDemo.tsx
  • frontend/src/components/landing-v5/lab/GradesDemo.tsx
  • frontend/src/components/landing-v5/lab/GuideDemo.tsx
  • frontend/src/components/landing-v5/lab/LabShell.tsx
  • frontend/src/components/landing-v5/lab/NotesDemo.tsx
  • frontend/src/components/landing-v5/lab/QuizDemo.tsx
  • frontend/src/components/landing-v5/lab/RoomsDemo.tsx
  • frontend/src/components/landing-v5/lab/TutorDemo.tsx
  • frontend/src/components/landing-v5/lab/index.tsx
  • frontend/src/components/landing-v5/lab/labData.ts
  • frontend/src/components/landing-v5/navTheme.ts
  • frontend/src/components/landing/useLanding.ts
  • frontend/src/components/marketing/FeatureBand.test.tsx
  • frontend/src/components/marketing/FeatureBand.tsx
  • frontend/src/components/marketing/SurfaceBento.test.tsx
  • frontend/src/components/marketing/SurfaceBento.tsx
  • frontend/src/components/marketing/featureBands.tsx
  • frontend/src/components/marketing/graph/KnowledgeGraphDemo.test.tsx
  • frontend/src/components/marketing/graph/KnowledgeGraphDemo.tsx
  • frontend/src/components/marketing/graph/courseGraphs.test.ts
  • frontend/src/components/marketing/graph/courseGraphs.ts
  • frontend/src/components/marketing/graph/layout.test.ts
  • frontend/src/components/marketing/graph/layout.ts
  • frontend/src/components/marketing/surfaces/GradebookSurface.tsx
  • frontend/src/components/marketing/surfaces/NotesSurface.tsx
  • frontend/src/components/marketing/surfaces/QuizSurface.tsx
  • frontend/src/components/marketing/surfaces/ReviewSurface.tsx
  • frontend/src/components/marketing/surfaces/RoomsSurface.tsx
  • frontend/src/components/marketing/surfaces/Surface.tsx
  • frontend/src/components/marketing/surfaces/TutorSurface.tsx
  • frontend/src/components/marketing/surfaces/UploadSurface.tsx
  • frontend/src/lib/landing/color.ts
  • frontend/src/lib/landing/companionContent.ts
  • frontend/src/lib/landing/content.ts
  • frontend/src/lib/landing/course.ts
  • frontend/src/lib/landing/dragClusters.ts
  • frontend/src/lib/landing/engine/ambient.ts
  • frontend/src/lib/landing/engine/dom.ts
  • frontend/src/lib/landing/engine/flip.ts
  • frontend/src/lib/landing/engine/graph.ts
  • frontend/src/lib/landing/engine/graphView.ts
  • frontend/src/lib/landing/engine/hero.ts
  • frontend/src/lib/landing/engine/index.ts
  • frontend/src/lib/landing/engine/marquee.ts
  • frontend/src/lib/landing/engine/measure.ts
  • frontend/src/lib/landing/engine/plant.ts
  • frontend/src/lib/landing/engine/sim.ts
  • frontend/src/lib/landing/hero3d/cardTexture.ts
  • frontend/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

Comment on lines +1957 to +2005
@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); } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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

Comment on lines +41 to +50
<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' }}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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 no aria-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.

Comment on lines +62 to +85
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>
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Comment on lines +37 to +44
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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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 ms extract and summarize timeouts in refs and clear them in the existing unmount effect beside saveT.
📍 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.

Comment on lines +21 to +24
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' },
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
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.

Comment on lines +104 to +124
<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>
);
})}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
<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.

Comment on lines +213 to +224
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
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
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.

Comment on lines +423 to +430
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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:

  1. A second ensureInit on the same DOM finds .drag-field elements with no [data-dragnode] children, returns false at 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 in frontend/src/components/landing/useLanding.ts lines 162-210.
  2. React still owns those cluster nodes, because DragField rendered 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.

Darkest-Teddyand others added 6 commits August 4, 2026 20:26
…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>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 741ec10 and e506827.

📒 Files selected for processing (13)
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/gallery/page.tsx
  • frontend/src/app/(public)/news/page.tsx
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/(public)/team/page.tsx
  • frontend/src/app/(public)/wiki/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/companion/CompanionShell.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/BetaModal.tsx
  • frontend/src/components/landing-v5/DragField.tsx
  • frontend/src/components/landing-v5/FeatureLab.tsx
  • frontend/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

Comment on lines +52 to +70
{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&rsquo;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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
{subscribed ? (
<divstyle={{textAlign: 'center',padding: '10px 4px 6px'}}>
{/*carriesheadingIdtooitiswhat`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&rsquo;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'}}>
{/*carriesheadingIdtooitiswhat`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&rsquo;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>
Darkest-Teddyand others added 13 commits August 9, 2026 20:13
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>
Darkest-Teddyand others added 2 commits August 16, 2026 17:48
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>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review August 16, 2026 21:59
@Darkest-Teddy
Darkest-Teddy merged commit 4f5a7f9 into mainAug 16, 2026
6 of 7 checks passed
AndresL230 added a commit that referenced this pull request Aug 22, 2026
)
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>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
) (#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>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
* Revert "test(e2e): measure a dropped node against the copy it is welded to (#566) (#568)"
This reverts commit 752a82c.
* Revert "Merge pull request #524 from SaplingLearn/feat/landing-v5-port"
This reverts commit 4f5a7f9, reversing
changes made to 0effc9e.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Darkest-Teddy