Skip to content

feat(landing): port the v5 marketing landing onto staging - #535

Open
Darkest-Teddy wants to merge 5 commits into
stagingfrom
feat/landing-v5-on-staging
Open

feat(landing): port the v5 marketing landing onto staging#535
Darkest-Teddy wants to merge 5 commits into
stagingfrom
feat/landing-v5-on-staging

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Puts the v5 marketing landing on stagingwithout merging feat/landing-v5-port.

Why not just merge the v5 branch

feat/landing-v5-port is 217 commits ahead of origin/staging and the diff is mostly not the landing page — 179 backend test files, 31 agents, 27 services, 17 routes, and 15 new migrations, including two colliding 0032_* and two colliding 0033_* basenames. It also isn't a fast-forward: staging has one commit that branch lacks (82787ca, gradescope). None of that is needed to show the new landing, so this ports the public surface only — no backend changes, no migrations.

What's in here

  • components/landing-v5/** and lib/landing/** — the landing engine, hero3d, drag simulation, and the Act sections
  • The (public) route group: the new /, plus /team, /wiki, /gallery, /news, /faq — the v5 navbar links to these and staging didn't have them
  • components/marketing/** and components/companion/CompanionShell
  • globals.css and app/layout.tsx (OG/metadata), lib/deployGuard.tswith its matching test, lib/errorMessage.ts
  • api.ts gains only subscribeToNewsletter — staging's backend already mounts /api/newsletter

One deliberate divergence from upstream

marketing/SignInModal calls the 3-argsetActiveUser. Upstream passes a 4th { persist } option that exists only on that branch's UserContext, whose wider change (+127/−21) also adds app-wide session recovery and shell-route redirects. Changing auth behaviour for every screen is out of scope for a landing port, so the option is dropped here and should come back in the same commit that brings that UserContext over. The call site carries the same note.

eslint-suppressions.json gains an entry for the new components/marketing/SignInModal.tsx path; staging only suppressed that rule at the old components/SignInModal.tsx path.

Verification

  • tsc --noEmit — clean
  • next build with staging's build vars (DEPLOY_ENV/BACKEND_URL/NEXT_PUBLIC_API_URL/COOKIE_DOMAIN) — succeeds, emitting /team, /wiki, /gallery, /news, /faq
  • eslint — clean except one pre-existing error: react-hooks/exhaustive-deps in settings/GradescopeConnect.tsx:65, which arrives untouched from origin/staging (it is neither modified nor suppressed on that branch). CI will likely go red on it until it's fixed separately — deliberately not touched here.
  • vitest was not run locally — it needs Node 22 and this machine is on 20. CI runs it on Node 22.

Deploy note

Per wrangler.toml and ADR 0020, the staging Workers Build must set DEPLOY_ENV=staging as a build variable, not just a runtime var — the /api rewrite and NEXT_PUBLIC_* are baked at build time. That omission is what previously put prod config on the staging worker.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added public About, Team, Wiki, Gallery, News, FAQ, and Careers pages.
    • Introduced interactive galleries, article search and filtering, FAQ accordions, career listings, and rich knowledge-base content.
    • Redesigned the landing experience with interactive learning demonstrations, knowledge graphs, tutor modes, quizzes, flashcards, study tools, and beta signup.
    • Added Google sign-in and newsletter subscription flows.
  • Improvements

    • Standardized navigation, page layouts, accessibility, responsive styling, animations, and site metadata.
    • Improved error messaging and deployment-aware canonical links.

Brings the v5 public site to `staging` WITHOUT the rest of
`feat/landing-v5-port`: that branch is 217 commits ahead of staging and
carries backend routes, services, agents and 15 migrations (including two
colliding 0032_* and two 0033_* basenames). None of that is needed to show
the new landing, so this ports the public surface only.
What's here:
- components/landing-v5/** + lib/landing/** (the landing engine, hero3d,
drag simulation and the Act sections)
- the (public) route group: the new / plus /team, /wiki, /gallery, /news
and /faq, which the v5 navbar links to and staging did not have
- components/marketing/** and components/companion/CompanionShell
- globals.css and app/layout.tsx (OG/metadata), lib/deployGuard.ts with
its matching test, lib/errorMessage.ts
- api.ts gains only `subscribeToNewsletter`; staging's backend already
mounts /api/newsletter
One deliberate divergence from upstream: marketing/SignInModal calls the
3-arg `setActiveUser`. Upstream passes a 4th `{ persist }` option that only
exists on that branch's UserContext, whose wider change also adds app-wide
session recovery and shell-route redirects. Changing auth for every screen
is out of scope for a landing port, so the option is dropped here and should
return with that UserContext. The call site carries the same note.
eslint-suppressions.json gains an entry for the new
components/marketing/SignInModal.tsx path; staging only suppressed the rule
at the old components/SignInModal.tsx path.
Verified: tsc --noEmit clean, and `next build` succeeds with staging's build
vars (DEPLOY_ENV/BACKEND_URL/NEXT_PUBLIC_API_URL/COOKIE_DOMAIN), emitting
/team, /wiki, /gallery, /news and /faq. eslint is clean except for a
pre-existing react-hooks/exhaustive-deps error in
settings/GradescopeConnect.tsx, which arrives untouched from origin/staging.
vitest was not run locally (needs Node 22; this machine is on 20) - CI runs it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 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 ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:37 seconds

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 101dbfe3-a2c2-4287-842f-6e2c86a82b2e

📥 Commits

Reviewing files that changed from the base of the PR and between 395588d and 46f7930.

⛔ Files ignored due to path filters (2)
  • frontend/public/journal-ai-homework.png is excluded by !**/*.png
  • frontend/public/journal-founding.png is excluded by !**/*.png
📒 Files selected for processing (57)
  • docs/frontend-testids.md
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/package.json
  • frontend/src/app/(public)/about/page.tsx
  • frontend/src/app/(public)/careers/CareersList.tsx
  • frontend/src/app/(public)/careers/[slug]/page.tsx
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/layout.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/ActGraph.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/ActTutor.tsx
  • frontend/src/components/landing-v5/BetaModal.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/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/landing-v5-interaction.test.tsx
  • frontend/src/components/landing-v5/navTheme.ts
  • frontend/src/components/landing/useLanding.ts
  • frontend/src/components/marketing/HeroCard.test.tsx
  • frontend/src/components/marketing/SignInModal.tsx
  • frontend/src/lib/deployGuard.test.ts
  • frontend/src/lib/deployGuard.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/dom.ts
  • frontend/src/lib/landing/engine/flip.ts
  • frontend/src/lib/landing/engine/index.ts
  • frontend/src/lib/landing/engine/marquee.ts
  • frontend/src/lib/landing/engine/plant.ts
  • frontend/src/lib/landing/engine/sim.test.ts
  • frontend/src/lib/landing/engine/sim.ts
  • frontend/src/middleware.ts
  • frontend/wrangler.toml
📝 Walkthrough

Walkthrough

The PR replaces the landing page with Landing v5, adds companion and careers pages, introduces shared public layouts and content, expands deployment metadata, and adds OAuth, newsletter, error-handling, CSS, and interaction infrastructure.

Changes

Landing v5 experience

Layer / File(s)Summary
Landing contracts and content
frontend/src/lib/landing/*
Adds typed landing content, course graphs, drag clusters, lab fixtures, color utilities, and navigation themes.
Landing sections and demos
frontend/src/components/landing-v5/*
Adds the v5 hero, graph, ingest, tutor, gallery, FAQ, journal, CTA, navigation, modal, and feature-lab components.
Landing runtime and composition
frontend/src/components/landing/useLanding.ts, frontend/src/lib/landing/engine/*, frontend/src/app/(public)/page.tsx
Connects React state to scroll, canvas, WebGL, graph, marquee, FLIP, planting, newsletter, and drag-field behavior.
Landing styles and tests
frontend/src/app/globals.css, frontend/src/components/landing-v5/*.test.tsx, frontend/src/lib/landing/engine/sim.test.ts
Adds responsive landing layouts, finite animations, and interaction regression coverage.

Companion and public pages

Layer / File(s)Summary
Shared companion shell and content
frontend/src/components/companion/CompanionShell.tsx, frontend/src/lib/landing/companionContent.ts
Adds shared navigation, footer links, and typed content collections.
Companion pages
frontend/src/app/(public)/about/*, team/*, faq/*, gallery/*, news/*, wiki/*
Adds shared-shell pages with data-driven content, filters, accordions, navigation, and metadata.
Careers routes
frontend/src/app/(public)/careers/*
Moves interactive job listings into CareersList, adds route metadata, and returns not-found responses for invalid slugs.

Platform and marketing infrastructure

Layer / File(s)Summary
Deployment and metadata
frontend/src/lib/deployGuard.ts, frontend/src/lib/deployGuard.test.ts, frontend/src/app/layout.tsx, frontend/src/app/(public)/privacy/page.tsx, frontend/src/app/(public)/terms/page.tsx
Adds environment resolution, canonical site URLs, host mismatch detection, structured application metadata, and legal-page metadata.
Authentication, newsletter, and marketing UI
frontend/src/components/marketing/SignInModal.tsx, frontend/src/components/marketing/HeroCard.tsx, frontend/src/lib/api.ts, frontend/src/lib/errorMessage.ts
Adds OAuth popup handling, accessible modal behavior, reusable hero-card rendering, newsletter subscription requests, and defensive error normalization.

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

Sequence Diagram(s)

sequenceDiagram
participant Visitor
participant LandingPage
participant useLanding
participant LandingEngine
participant CanvasAndWebGL
Visitor->>LandingPage: open landing page
LandingPage->>useLanding: initialize refs and state
useLanding->>LandingEngine: start landing lifecycle
LandingEngine->>CanvasAndWebGL: render hero and graph visuals
Visitor->>LandingPage: explore graph or submit newsletter
LandingPage->>useLanding: dispatch interaction
useLanding->>LandingEngine: update interaction state
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 26.42% 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 main change: porting the v5 marketing landing to staging.
Description check✅ PassedThe description covers scope, rationale, changes, testing limits, and deployment requirements, with only minor template omissions.
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 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/landing-v5-on-staging

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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 12, 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-staging46f7930Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:19 PM

@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: 11

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/lib/deployGuard.ts (1)

39-46: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Normalize trailing slashes before environment classification.

A canonical API URL with a trailing slash classifies as unknown. Then detectHostConfigMismatch returns null for a staging host wired to https://api.saplinglearn.com/. This bypasses the runtime guard for a valid URL spelling.

Normalize URL fields before comparison. Add regression tests for trailing-slash API and site URLs.

Proposed fix
 function classify(
value: string | undefined,
field: 'apiUrl' | 'cookieDomain' | 'siteUrl',
): FrontendEnv | null {
- const v = (value ?? '').trim().toLowerCase();+ const v = (value ?? '')+ .trim()+ .toLowerCase()+ .replace(field === 'cookieDomain' ? /$/ : /\/+$/, '');
if (!v) return null;
for (const env of Object.keys(FRONTEND_ENVS) as FrontendEnv[]) {
if (FRONTEND_ENVS[env][field].toLowerCase() === v) return env;
🤖 Prompt for AI Agents
Verify each 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/deployGuard.ts` around lines 39 - 46, Update classify to
normalize URL values by removing trailing slashes before comparing against
FRONTEND_ENVS fields, while preserving existing trimming and case normalization.
Ensure both API and site URL classifications recognize trailing-slash variants,
and add regression coverage for trailing-slash API and site URLs.
🟡 Minor comments (24)
frontend/src/app/(public)/news/page.tsx-175-199 (1)

175-199: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The card link text does not match its destination.

Each card is a <Link href="/#newsletter"> and its call to action reads "Read article →". A reader who selects a headline arrives at the newsletter signup on the home page, not at the post. All six cards lead to the same anchor.

Change the call to action to state the real destination, for example "Get notified →", until article 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/app/`(public)/news/page.tsx around lines 175 - 199, The news
cards’ CTA text incorrectly promises article navigation while each Link in the
posts.map block targets /#newsletter. Update the CTA span text from “Read
article →” to wording that reflects newsletter signup, such as “Get notified →”,
while preserving the existing link destination and styling.
frontend/src/app/(public)/about/page.tsx-80-84 (1)

80-84: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update the copy that points to a feedback button in the navbar.

The paragraph tells the reader to use "a feedback button in the navbar". The ported chrome in frontend/src/components/companion/CompanionShell.tsx renders Home, About, Team, Wiki, Gallery, News, FAQ, GitHub, Ko-fi, Sign In, and Get Started. It has no feedback control. Point the reader at a control that exists, for example the GitHub link or a contact address.

🤖 Prompt for AI Agents
Verify each 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)/about/page.tsx around lines 80 - 84, Update the
paragraph near the prose content to remove the nonexistent navbar
feedback-button reference, and direct readers to an available control such as
the GitHub link or a contact address instead. Preserve the surrounding message
about reporting issues and sharing ideas.
frontend/src/app/(public)/team/page.tsx-18-22 (1)

18-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Two companion routes omit alternates.canonical. The port sets a canonical URL on frontend/src/app/(public)/about/page.tsx, frontend/src/app/(public)/careers/page.tsx, and frontend/src/app/(public)/careers/[slug]/page.tsx, but not on these two routes. Without a canonical URL, these pages can be indexed under query-string or alternate-host variants.

  • frontend/src/app/(public)/team/page.tsx#L18-L22: add alternates: { canonical: '/team' } to the metadata export.
  • frontend/src/app/(public)/wiki/page.tsx#L18-L22: add alternates: { canonical: '/wiki' } to the metadata export.
🤖 Prompt for AI Agents
Verify each 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)/team/page.tsx around lines 18 - 22, Update the
metadata exports in frontend/src/app/(public)/team/page.tsx lines 18-22 and
frontend/src/app/(public)/wiki/page.tsx lines 18-22 to include
alternates.canonical, using /team for the team route and /wiki for the wiki
route.
frontend/src/app/(public)/careers/[slug]/page.tsx-18-22 (1)

18-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The meta description appends an ellipsis even when it truncates nothing.

Line 20 always appends after job.description.slice(0, 155). If a description is shorter than 155 characters, the full sentence still ends with a truncation marker. Search results then show a complete sentence that looks cut off.

Append the ellipsis only when the slice removed text.

🐛 Proposed fix
+ const blurb =+ job.description.length > 155 ? `${job.description.slice(0, 155)}…` : job.description;
return {
title: `${job.title} — Careers`,
- description: `${job.department} · ${job.location} · ${job.type}. ${job.description.slice(0, 155)}…`,+ description: `${job.department} · ${job.location} · ${job.type}. ${blurb}`,
alternates: { canonical: `/careers/${job.slug}` },
};
🤖 Prompt for AI Agents
Verify each 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)/careers/[slug]/page.tsx around lines 18 - 22,
Update the metadata construction in the careers page’s generateMetadata flow so
the description appends “…” only when job.description exceeds 155 characters;
leave shorter or exactly 155-character descriptions without a truncation marker
while preserving the existing 155-character slice.
frontend/src/app/(public)/wiki/page.tsx-28-31 (1)

28-31: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

scrollMarginTop is applied to the wrong element.

The H2 style sets scrollMarginTop: 84. The TOC links on line 67 target the fragments #graph, #mastery, #review, #tutor, #ingestion, #grades, and #privacy. Those ids are on the <section> elements, not on the <h2> elements. The browser applies scroll margin to the element it scrolls into view, so the section top lands under the sticky 92px header from CompanionShell and the heading is hidden.

Move the offset to the sections.

🐛 Proposed fix
 const H2: React.CSSProperties = {
margin: 0, fontFamily: DISPLAY, fontWeight: 500, fontSize: 26,
- lineHeight: 1.2, letterSpacing: '-0.015em', scrollMarginTop: 84,+ lineHeight: 1.2, letterSpacing: '-0.015em',
};
+/** Clears the sticky companion header after an in-page anchor jump. */+const SECTION: React.CSSProperties = { scrollMarginTop: 104 };

Then apply it to each section, for example:

- <section id="graph">+ <section id="graph" style={SECTION}>
🤖 Prompt for AI Agents
Verify each 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 28 - 31, Move
scrollMarginTop: 84 from the H2 style to the section styling used by the
sections targeted by the TOC fragment links (`#graph`, `#mastery`, `#review`, `#tutor`,
`#ingestion`, `#grades`, and `#privacy`). Keep the heading typography unchanged and
ensure each target section receives the offset so it clears the sticky header.
frontend/src/components/companion/CompanionShell.tsx-86-108 (1)

86-108: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Apply the responsive nav rules to CompanionShell. The media query is scoped to .landing-dc, but the CompanionShell root does not have that class. The rules do not match, despite using !important, so the compact nav remains hidden and the tab row remains visible below 1180px.

🤖 Prompt for AI Agents
Verify each 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/companion/CompanionShell.tsx` around lines 86 - 108,
Update the CompanionShell root element to include the landing-dc class so the
existing responsive media-query rules apply to its nav-compact and nav-tabs
elements. Preserve the current navigation structure and styling.

Source: Linters/SAST tools

frontend/src/lib/landing/engine/dom.ts-49-73 (1)

49-73: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

A layout measurement taken before layout is ready is cached permanently. Both sites store a measured size on first read and re-measure only when a resize signal arrives. If the first read happens before the element has its final box, the wrong value is kept for the rest of the session. Reject an implausible measurement instead of caching it.

  • frontend/src/lib/landing/engine/dom.ts#L49-L73: do not assign rec.__cv when w or h is 0. invalidateCanvas() runs only on a viewport size change, so a zero-size cache leaves the canvas blank.
  • frontend/src/lib/landing/engine/marquee.ts#L136-L144: do not accept m.setW when the summed child offsetWidth is 0. The re-measure trigger is a wrap.clientWidth change, so a gap-only width leaves the track wrapping over a few pixels.
🤖 Prompt for AI Agents
Verify each 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, Reject
zero-sized measurements before caching them: in
frontend/src/lib/landing/engine/dom.ts lines 49-73, ensure cv does not assign
rec.__cv when w or h is 0; in frontend/src/lib/landing/engine/marquee.ts lines
136-144, ensure the marquee measurement does not call m.setW when the summed
child offsetWidth is 0. Preserve normal caching and measurement behavior for
positive dimensions.
frontend/src/lib/landing/engine/sim.ts-330-369 (1)

330-369: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard the SVG structure assumptions during build.

Three unchecked assumptions read the authored SVG:

  • Line 332: ring.previousElementSibling as SVGCircleElement. If the ring is the first child, glow is null. Line 839 then calls p.glow.setAttribute on every frame and throws a TypeError.
  • Line 333 and 345: label is dereferenced immediately by label.getAttribute('y'). A missing next sibling throws during ensureInit().
  • Line 362-367: near() returns -1 when the cluster has line elements but no [data-sim] rings. nodes[b] is then undefined and line 367 throws.

holdArms() at line 653 and stepGroup() at line 752 already guard with if (!s0 || !t0) continue;, so missing nodes are anticipated later in the pipeline but not at build time. Skip a ring or a line when its expected siblings are absent.

🤖 Prompt for AI Agents
Verify each 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 330 - 369, Update the
SVG build logic in the rings and lines loops to validate required structure
before creating simulation entries: skip rings when the previous glow element or
next label element is missing, and skip lines when either endpoint lookup in
near() returns -1. Keep valid ring and line processing unchanged, ensuring no
nodes or links are created with null siblings or invalid endpoint indices.
frontend/src/lib/landing/engine/marquee.ts-69-114 (1)

69-114: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Bind pointerup and pointercancel on window as well.

setPointerCapture is called inside a try/catch at line 80. If the capture fails, later pointer events are not retargeted to track. A drag that ends outside track then never runs end(). m.drag stays non-null, so update() skips both drift and momentum for that track and the cursor stays grabbing until the next pointerdown.

frontend/src/lib/landing/engine/sim.ts lines 507-510 already binds the release handlers on both the element and window for this reason. Apply the same pattern here.

🛡️ Proposed fix
 track.addEventListener('pointerdown', onDown);
track.addEventListener('pointermove', onMove);
track.addEventListener('pointerup', onUp);
track.addEventListener('pointercancel', onCancel);
+ window.addEventListener('pointerup', onUp);+ window.addEventListener('pointercancel', onCancel);
cleanups.push(() => {
wrap?.removeEventListener('pointerenter', onEnter);
wrap?.removeEventListener('pointerleave', onLeave);
track.removeEventListener('pointerdown', onDown);
track.removeEventListener('pointermove', onMove);
track.removeEventListener('pointerup', onUp);
track.removeEventListener('pointercancel', onCancel);
+ window.removeEventListener('pointerup', onUp);+ window.removeEventListener('pointercancel', onCancel);
STATE.delete(track);
});
🤖 Prompt for AI Agents
Verify each 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 69 - 114, Bind the
existing onUp and onCancel release handlers to window in addition to track,
matching the dual registration pattern used by sim.ts. Keep the current track
listeners and end() behavior unchanged so drags are finalized even when pointer
capture fails or the pointer leaves the track.
frontend/src/components/landing-v5/BetaModal.tsx-244-260 (1)

244-260: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use type="email" for the email field.

The field is labelled "Email address" and validated as an email, but it is declared type="text". That suppresses the email keyboard on mobile and blocks browser autofill. The manual check at lines 86-96 continues to work unchanged.

🔧 Proposed fix
 ref={inputRef}
- type="text"+ type="email"+ autoComplete="email"+ inputMode="email"
aria-label="Email address"
🤖 Prompt for AI Agents
Verify each 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 244 - 260,
Update the email input in BetaModal’s form to use type="email" instead of
type="text", while preserving its existing validation, value handling, and other
attributes.
frontend/src/components/landing/useLanding.ts-593-597 (1)

593-597: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report invalid input instead of returning silently.

If value has no @, the function returns without setting subscribeError and without any other state change. The user presses the button and sees no response. An empty non-required type="email" field passes browser validation and reaches this branch.

🐛 Proposed fix
 const value = email.trim();
- if (!value.includes('@') || subscribing) return;+ if (subscribing) return;+ if (!value.includes('@')) {+ setSubscribeError('Enter a valid email address.');+ return;+ }
🤖 Prompt for AI Agents
Verify each 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 593 - 597, Update
subscribeNow so invalid email input, including an empty trimmed value or a value
without "@", sets subscribeError to the existing user-facing validation message
before returning. Keep the subscribing guard and valid-input flow unchanged,
ensuring the user receives feedback instead of a silent no-op.
frontend/src/components/landing-v5/Navbar.tsx-74-79 (1)

74-79: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The navbar stays interactive while it is invisible.

While heroMounted is false, opacity is 0 but pointerEvents remains 'auto'. For the first ~1.9s of the intro, every navbar control is focusable and clickable while nothing is visible.

♿ Proposed fix
 opacity: exploring ? 0 : heroMounted ? 1 : 0,
- pointerEvents: exploring ? 'none' : 'auto',+ pointerEvents: exploring || !heroMounted ? 'none' : 'auto',+ visibility: heroMounted ? 'visible' : 'hidden',
🤖 Prompt for AI Agents
Verify each 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 74 - 79, Update
the navbar style logic in the visible component so pointerEvents is set to
'none' whenever the navbar is invisible, including while heroMounted is false
and while exploring is true; restore 'auto' only when the navbar is visible and
interactive.
frontend/src/app/(public)/page.tsx-91-100 (1)

91-100: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear the subscription error when the modal closes.

state.subscribeError is shared by BetaModal and Journal and is never reset. After a failed attempt, the message stays visible in the Journal form and reappears the next time the modal opens. useLanding already exposes actions.resetSubscribeError for this, and it is currently unused.

🐛 Proposed fix
- onClose={() => setBetaOpen(false)}+ onClose={() => { actions.resetSubscribeError(); setBetaOpen(false); }}
🤖 Prompt for AI Agents
Verify each 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 91 - 100, Update the
BetaModal onClose handler to call actions.resetSubscribeError before closing the
modal with setBetaOpen(false), ensuring the shared subscription error is cleared
when the modal closes.
frontend/src/app/globals.css-1563-1569 (1)

1563-1569: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stylelint fails on the quoted Spectral family name.

font-family-name-quotes reports four errors: Lines 1565, 1713, 1784, and 1836. Spectral is a single identifier, so it needs no quotes.

🔧 Proposed fix (apply to all four sites)
- font-family: var(--font-spectral), 'Spectral', Georgia, serif;+ font-family: var(--font-spectral), Spectral, Georgia, serif;

As per static analysis hints, font-family-name-quotes is enabled for this file.

Also applies to: 1713-1713, 1784-1784, 1836-1836

🤖 Prompt for AI Agents
Verify each 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 1563 - 1569, Remove the
unnecessary quotes around the single-identifier Spectral font family name in all
four font-family declarations, including the declarations associated with the
visible landing-page style and the other reported sites, so they satisfy
font-family-name-quotes.

Source: Linters/SAST tools

frontend/src/components/landing-v5/lab/RoomsDemo.tsx-42-54 (1)

42-54: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The typing indicator and the delayed reply can name different partners.

send captures partner in the timeout closure. The indicator at Line 127 reads the live partner. If the user changes the select during the 1600 ms delay, the indicator shows the new partner and the message arrives from the old one.

♻️ Proposed fix
 const [partner, setPartner] = useState<Partner>('maya');
+ const partnerRef = useRef<Partner>('maya');+ partnerRef.current = partner;
 typeT.current = setTimeout(() => {
- const reply = `my ${partner.charAt(0).toUpperCase() + partner.slice(1)} graph has eigenvalues green — want me to quiz you on it?`;+ const who = partnerRef.current;+ const reply = `my ${who.charAt(0).toUpperCase() + who.slice(1)} graph has eigenvalues green — want me to quiz you on it?`;
setTyping(false);
- setMsgs((m) => [...m, { who: partner, text: reply, mine: false }]);+ setMsgs((m) => [...m, { who, text: reply, mine: false }]);
}, 1600);

Also applies to: 125-132

🤖 Prompt for AI Agents
Verify each 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/RoomsDemo.tsx` around lines 42 - 54,
Update send so the delayed reply and typing indicator use the same partner
snapshot captured when the message is sent. Store that snapshot in the relevant
state or timeout flow, and ensure the indicator rendering near the partner
display uses it until the delayed reply completes; preserve the existing
behavior when the partner does not change.
frontend/src/app/globals.css-1950-1957 (1)

1950-1957: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stylelint reports 45 keyframes-name-pattern errors in the ported blocks.

The ported keyframe names are camelCase, for example s1Stem, popHold, heroRise, and labIn. The rule expects kebab-case. Renaming is not safe here, because the names are referenced from inline animation strings in the landing components and in labData-driven markup.

Scope a rule disable to the ported region instead, so the rest of the file keeps the check.

🔧 Proposed approach
+/* stylelint-disable keyframes-name-pattern -- names are ported verbatim from the+ design components and are referenced by inline `animation` strings in TSX. */
`@keyframes` s1Stem { 0%,15% { stroke-dashoffset:27; } 34%,100% { stroke-dashoffset:0; } }

Close the block with /* stylelint-enable keyframes-name-pattern */ after the last ported keyframe.

As per static analysis hints, keyframes-name-pattern is enabled for this file.

Also applies to: 2094-2096

🤖 Prompt for AI Agents
Verify each 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 1950 - 1957, Scope-disable
Stylelint’s keyframes-name-pattern rule immediately before the ported keyframe
region containing names such as s1Stem, popHold, heroRise, and labIn, then
re-enable it after the final ported keyframe. Keep the rule active for all other
sections of globals.css and do not rename the animation identifiers.

Source: Linters/SAST tools

frontend/src/lib/landing/course.ts-363-376 (1)

363-376: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle the singular case for degree.

If degree is 1, the text reads "its 1 neighbours". A sub-topic linked only to its parent produces that value.

✏️ Proposed fix
- ' pulls this node plus its ' +- degree +- ' neighbours into context, then retrieves the chunks of your ' ++ ' pulls this node plus its ' ++ degree ++ (degree === 1 ? ' neighbour' : ' neighbours') ++ ' into context, then retrieves the chunks of your ' +
🤖 Prompt for AI Agents
Verify each 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/course.ts` around lines 363 - 376, Update nodeUses
so the text generated for degree uses singular “neighbour” when degree is 1 and
plural “neighbours” otherwise, preserving the existing wording and behavior for
all other values.
frontend/src/components/landing-v5/lab/CardsDemo.tsx-46-53 (1)

46-53: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard the window key listener against editable targets.

The listener runs for every keydown on the window. It calls e.preventDefault() for Space and rates the card for 1, 2, and 3. While this demo is mounted, a Space or a digit typed in any other input on the page is captured.

🛡️ Proposed fix
 const onKey = (e: KeyboardEvent) => {
+ const el = e.target as HTMLElement | null;+ if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) 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 around onKey to ignore events whose
target is an editable control, such as input, textarea, select, or
contenteditable elements, before handling Space or rate keys; preserve the
existing flip and rate behavior for non-editable targets.
frontend/src/components/landing-v5/lab/GradesDemo.tsx-62-65 (1)

62-65: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The row percentage is not clamped, but the weighted grade is.

Line 25 clamps each score to [0, r.possible]. Line 64 does not. If the user types 500 into a row worth 50, the row shows 1000% while the overall grade stays at the clamped value. Use the same clamp for both readouts.

🐛 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,
Update the row percentage calculation in the GB_ROWS.map callback to clamp the
parsed earned score to the range [0, r.possible] before computing the
percentage, matching the existing clamping used for the weighted grade and
keeping both readouts consistent.
frontend/src/components/landing-v5/lab/NotesDemo.tsx-57-66 (1)

57-66: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The "No concepts found" sentinel corrupts the footer count.

Line 63 pushes { name: 'No concepts found', tier: 'unexplored' } into the same concepts array that holds real matches. The footer at Lines 141-143 then reads concepts.length and prints 1 concepts linked to your MA 242 graph. after an empty extraction. The text is wrong, and the plural is wrong for a single match too.

Track the empty result separately instead of encoding it as a concept.

🐛 Proposed fix
- const [concepts, setConcepts] = useState<{ name: string; tier: keyof typeof LAB_TIER }[]>([]);+ const [concepts, setConcepts] = useState<{ name: string; tier: keyof typeof LAB_TIER }[]>([]);+ const [ran, setRan] = useState(false);
 const found = MATCHERS.filter((m) => m.hit.test(lower)).map((m) => ({ name: m.name, tier: m.tier }));
- setConcepts(found.length ? found : [{ name: 'No concepts found', tier: 'unexplored' }]);+ setConcepts(found);+ setRan(true);
setBusy(false);
- {concepts.length- ? `${concepts.length} concepts linked to your MA 242 graph.`- : 'Concepts you extract are linked to your graph, not just tagged on the note.'}+ {concepts.length+ ? `${concepts.length} concept${concepts.length === 1 ? '' : 's'} linked to your MA 242 graph.`+ : ran+ ? 'No concepts found in this note.'+ : 'Concepts you extract are linked to your graph, not just tagged on the note.'}

Also applies to: 140-144

🤖 Prompt for AI Agents
Verify each 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 - 66,
Update extract so an empty MATCHERS result is tracked separately rather than
added to concepts; keep concepts containing only real matches and render the
empty-state message independently. Adjust the footer rendering near the concepts
count to display the correct zero count and use singular/plural wording based on
the actual number of linked concepts.
frontend/src/components/landing-v5/Closing.tsx-154-182 (1)

154-182: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Composite ARIA roles are declared without their required child roles. Both sites put a composite role on a container and leave the children as plain <button> elements. ARIA requires owned children with the matching role, so assistive technology announces an empty widget and the selected item is not conveyed.

  • frontend/src/components/landing-v5/Closing.tsx#L154-L182: add role="menuitem" to each section button inside the role="menu" container.
  • frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124: add role="radio" and aria-checked={isPicked} to each option button inside the role="radiogroup" container.
🤖 Prompt for AI Agents
Verify each 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 154 - 182, Add
role="menuitem" to each section button rendered by SECTIONS.map in Closing.tsx
(frontend/src/components/landing-v5/Closing.tsx#L154-L182). In QuizDemo.tsx
(frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124), add
role="radio" and bind aria-checked to isPicked on each option button within the
radiogroup.
frontend/src/components/landing-v5/lab/CalendarDemo.tsx-86-112 (1)

86-112: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Blank leading cells are focusable empty buttons.

Every cell renders as a <button>, including the LEAD_BLANKS placeholders. The blank cells only set cursor: 'default', which does not remove them from the tab order. A keyboard user tabs through up to six empty buttons before reaching 1 October. The selected day also carries no aria-pressed, so the current selection is not announced.

♿ Proposed change
 <button
key={i}
onClick={() => { if (!c.blank) setDay(c.day as number); }}
type="button"
+ disabled={c.blank}+ aria-hidden={c.blank || undefined}+ aria-pressed={c.blank ? undefined : selected}
🤖 Prompt for AI Agents
Verify each 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/CalendarDemo.tsx` around lines 86 -
112, Update the day-cell buttons rendered by the shown.map callback so blank
placeholder cells are removed from keyboard focus, while retaining interactive
keyboard behavior for real days. Add aria-pressed to each non-blank day button
and set it according to the selected value so assistive technologies announce
the current selection.
frontend/src/components/landing-v5/Faq.tsx-71-76 (1)

71-76: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

"Read the full FAQ" links back to itself.

The anchor targets #faq, and the enclosing <section> already has id="faq" (Line 40). Clicking the link keeps the user on the same section, so the call to action does nothing.

This PR adds the /faq public route, and Closing.tsx Line 81 already links to it. Point this link at /faq.

🔗 Proposed change
- <a href="`#faq`" style={{ color: '`#0C5638`', textDecoration: 'underline' }}>Read the full FAQ</a>+ <Link href="/faq" style={{ color: '`#0C5638`', textDecoration: 'underline' }}>Read the full FAQ</Link>

Add the import:

 import { FAQS } from '`@/lib/landing/content`';
+import Link from 'next/link';
import { DragField } from './DragField';
🤖 Prompt for AI Agents
Verify each 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 71 - 76, Update the
“Read the full FAQ” anchor in the Faq component to navigate to the public /faq
route instead of the local `#faq` section, matching the existing link behavior in
Closing.tsx.
frontend/src/components/landing-v5/lab/GuideDemo.tsx-21-24 (1)

21-24: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Align RECENT.pick with the course exam lists. Neither Midterm 1 · Sep 30 nor Quiz 2 · Oct 03 exists in its course's GUIDES.exams. Opening either recent item sets an invalid select value while the guide heading displays that value.

🤖 Prompt for AI Agents
Verify each 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 in GuideDemo so each pick exactly matches an existing
exam value in the corresponding course’s GUIDES.exams list, preserving the
displayed recent-item behavior and ensuring the select value and guide heading
use valid options.
🤖 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/`(public)/page.tsx:
- Around line 130-131: Update the onLearn handler in the page component to open
the tutor panel at GAL index 7 instead of index 2, while leaving onQuiz
unchanged. Prefer the named GAL lookup or constant from content.ts rather than a
hard-coded index so card ordering changes cannot reintroduce this mismatch.
In `@frontend/src/components/landing-v5/ActTutor.tsx`:
- Around line 92-109: Update the tutor pills container in ActTutor to remove its
buttons from keyboard navigation while hidden by binding the engine’s docked
state to inert (or visibility). Preserve the existing opacity and pointerEvents
behavior, and ensure the pills become focusable again when docked is false.
In `@frontend/src/components/landing-v5/BetaModal.tsx`:
- Around line 132-143: Update the HeroCard responsive layout to use a single
column below the medium breakpoint and the existing two-column structure at
wider widths, using its CSS class rather than an unconditional
gridTemplateColumns style. Enable vertical scrolling for constrained viewports,
and apply the 560px minimum height only at the wider breakpoint so mobile
content, including the email field and submit button, remains accessible.
In `@frontend/src/components/landing-v5/Gallery.tsx`:
- Around line 62-85: Update the Card component so non-ghost cards are
keyboard-focusable and expose button-like semantics, with a key handler that
triggers the existing delegated Track click behavior for Enter and Space. Keep
ghost cards inert by omitting focusability, interactive semantics, and
activation handling, while preserving the current data-tk targeting.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 37-44: Update the rate callback to store its 300 ms timeout in a
ref instead of returning a cleanup function, and clear that timeout in an
unmount cleanup effect. Preserve the existing delayed setIdx behavior while
preventing it from firing after the component unmounts.
In `@frontend/src/components/landing-v5/navTheme.ts`:
- Around line 68-69: Update DARK_SECTIONS to contain only the section that
currently renders on a dark ground, act-graph, and revise its comment to
describe the single dark section accurately. Keep act-ingest and act-tutor out
of the list until their backgrounds are dark.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 218-229: Update the hero canvas effect around the lazy
`import('`@/lib/landing/engine/hero`')` to track whether cleanup has run, prevent
`startHeroCanvas` from starting after unmount, and stop it immediately if
initialization races with cancellation. Preserve the existing cleanup behavior
for normal mounted execution.
- Around line 277-282: Update the onKey handler in useLanding to handle Escape
while explore mode is active: after closing the gallery or jump menu as
appropriate, call exitExplore when s.current.exploring is true and neither
overlay was open. Ensure exitExplore is available at handler execution time
despite its later declaration, using the existing callback ordering or a ref as
needed.
In `@frontend/src/components/marketing/SignInModal.tsx`:
- Around line 130-153: Update the OAuth URL construction in the sign-in flow,
including sameTabUrl and popupUrl, to use the frontend-relative /api/auth/google
path instead of prepending API_URL. Preserve the existing popup_id query
parameter and same-tab fallback behavior so OAuth requests remain same-origin
and retain the session cookie.
In `@frontend/src/lib/landing/content.ts`:
- Around line 191-218: In frontend/src/lib/landing/content.ts lines 191-218,
mark the unavailable journal-founding.png and journal-ai-homework.png entries
with empty src values and isGraph: true so they use the graph fallback until
assets are shipped. In frontend/src/components/landing-v5/Journal.tsx lines
106-118, preserve the meta.isGraph branch that renders GraphThumb instead of
passing unusable paths to next/image.
In `@frontend/src/lib/landing/engine/sim.ts`:
- Around line 897-905: Update destroy() cleanup ordering so every cluster
element is re-parented to its original field before the cleanup that removes the
shell/overlay runs. Preserve the existing cleanup registration in ensureInit(),
but ensure repeated createSim() calls can still find the original
[data-dragnode] elements after destroy().
---
Outside diff comments:
In `@frontend/src/lib/deployGuard.ts`:
- Around line 39-46: Update classify to normalize URL values by removing
trailing slashes before comparing against FRONTEND_ENVS fields, while preserving
existing trimming and case normalization. Ensure both API and site URL
classifications recognize trailing-slash variants, and add regression coverage
for trailing-slash API and site URLs.
---
Minor comments:
In `@frontend/src/app/`(public)/about/page.tsx:
- Around line 80-84: Update the paragraph near the prose content to remove the
nonexistent navbar feedback-button reference, and direct readers to an available
control such as the GitHub link or a contact address instead. Preserve the
surrounding message about reporting issues and sharing ideas.
In `@frontend/src/app/`(public)/careers/[slug]/page.tsx:
- Around line 18-22: Update the metadata construction in the careers page’s
generateMetadata flow so the description appends “…” only when job.description
exceeds 155 characters; leave shorter or exactly 155-character descriptions
without a truncation marker while preserving the existing 155-character slice.
In `@frontend/src/app/`(public)/news/page.tsx:
- Around line 175-199: The news cards’ CTA text incorrectly promises article
navigation while each Link in the posts.map block targets /#newsletter. Update
the CTA span text from “Read article →” to wording that reflects newsletter
signup, such as “Get notified →”, while preserving the existing link destination
and styling.
In `@frontend/src/app/`(public)/page.tsx:
- Around line 91-100: Update the BetaModal onClose handler to call
actions.resetSubscribeError before closing the modal with setBetaOpen(false),
ensuring the shared subscription error is cleared when the modal closes.
In `@frontend/src/app/`(public)/team/page.tsx:
- Around line 18-22: Update the metadata exports in
frontend/src/app/(public)/team/page.tsx lines 18-22 and
frontend/src/app/(public)/wiki/page.tsx lines 18-22 to include
alternates.canonical, using /team for the team route and /wiki for the wiki
route.
In `@frontend/src/app/`(public)/wiki/page.tsx:
- Around line 28-31: Move scrollMarginTop: 84 from the H2 style to the section
styling used by the sections targeted by the TOC fragment links (`#graph`,
`#mastery`, `#review`, `#tutor`, `#ingestion`, `#grades`, and `#privacy`). Keep the heading
typography unchanged and ensure each target section receives the offset so it
clears the sticky header.
In `@frontend/src/app/globals.css`:
- Around line 1563-1569: Remove the unnecessary quotes around the
single-identifier Spectral font family name in all four font-family
declarations, including the declarations associated with the visible
landing-page style and the other reported sites, so they satisfy
font-family-name-quotes.
- Around line 1950-1957: Scope-disable Stylelint’s keyframes-name-pattern rule
immediately before the ported keyframe region containing names such as s1Stem,
popHold, heroRise, and labIn, then re-enable it after the final ported keyframe.
Keep the rule active for all other sections of globals.css and do not rename the
animation identifiers.
In `@frontend/src/components/companion/CompanionShell.tsx`:
- Around line 86-108: Update the CompanionShell root element to include the
landing-dc class so the existing responsive media-query rules apply to its
nav-compact and nav-tabs elements. Preserve the current navigation structure and
styling.
In `@frontend/src/components/landing-v5/BetaModal.tsx`:
- Around line 244-260: Update the email input in BetaModal’s form to use
type="email" instead of type="text", while preserving its existing validation,
value handling, and other attributes.
In `@frontend/src/components/landing-v5/Closing.tsx`:
- Around line 154-182: Add role="menuitem" to each section button rendered by
SECTIONS.map in Closing.tsx
(frontend/src/components/landing-v5/Closing.tsx#L154-L182). In QuizDemo.tsx
(frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124), add
role="radio" and bind aria-checked to isPicked on each option button within the
radiogroup.
In `@frontend/src/components/landing-v5/Faq.tsx`:
- Around line 71-76: Update the “Read the full FAQ” anchor in the Faq component
to navigate to the public /faq route instead of the local `#faq` section, matching
the existing link behavior in Closing.tsx.
In `@frontend/src/components/landing-v5/lab/CalendarDemo.tsx`:
- Around line 86-112: Update the day-cell buttons rendered by the shown.map
callback so blank placeholder cells are removed from keyboard focus, while
retaining interactive keyboard behavior for real days. Add aria-pressed to each
non-blank day button and set it according to the selected value so assistive
technologies announce the current selection.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 46-53: Update the keydown handler in the useEffect around onKey to
ignore events whose target is an editable control, such as input, textarea,
select, or contenteditable elements, before handling Space or rate keys;
preserve the existing flip and rate behavior for non-editable targets.
In `@frontend/src/components/landing-v5/lab/GradesDemo.tsx`:
- Around line 62-65: Update the row percentage calculation in the GB_ROWS.map
callback to clamp the parsed earned score to the range [0, r.possible] before
computing the percentage, matching the existing clamping used for the weighted
grade and keeping both readouts consistent.
In `@frontend/src/components/landing-v5/lab/GuideDemo.tsx`:
- Around line 21-24: Update the RECENT entries in GuideDemo so each pick exactly
matches an existing exam value in the corresponding course’s GUIDES.exams list,
preserving the displayed recent-item behavior and ensuring the select value and
guide heading use valid options.
In `@frontend/src/components/landing-v5/lab/NotesDemo.tsx`:
- Around line 57-66: Update extract so an empty MATCHERS result is tracked
separately rather than added to concepts; keep concepts containing only real
matches and render the empty-state message independently. Adjust the footer
rendering near the concepts count to display the correct zero count and use
singular/plural wording based on the actual number of linked concepts.
In `@frontend/src/components/landing-v5/lab/RoomsDemo.tsx`:
- Around line 42-54: Update send so the delayed reply and typing indicator use
the same partner snapshot captured when the message is sent. Store that snapshot
in the relevant state or timeout flow, and ensure the indicator rendering near
the partner display uses it until the delayed reply completes; preserve the
existing behavior when the partner does not change.
In `@frontend/src/components/landing-v5/Navbar.tsx`:
- Around line 74-79: Update the navbar style logic in the visible component so
pointerEvents is set to 'none' whenever the navbar is invisible, including while
heroMounted is false and while exploring is true; restore 'auto' only when the
navbar is visible and interactive.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 593-597: Update subscribeNow so invalid email input, including an
empty trimmed value or a value without "@", sets subscribeError to the existing
user-facing validation message before returning. Keep the subscribing guard and
valid-input flow unchanged, ensuring the user receives feedback instead of a
silent no-op.
In `@frontend/src/lib/landing/course.ts`:
- Around line 363-376: Update nodeUses so the text generated for degree uses
singular “neighbour” when degree is 1 and plural “neighbours” otherwise,
preserving the existing wording and behavior for all other values.
In `@frontend/src/lib/landing/engine/dom.ts`:
- Around line 49-73: Reject zero-sized measurements before caching them: in
frontend/src/lib/landing/engine/dom.ts lines 49-73, ensure cv does not assign
rec.__cv when w or h is 0; in frontend/src/lib/landing/engine/marquee.ts lines
136-144, ensure the marquee measurement does not call m.setW when the summed
child offsetWidth is 0. Preserve normal caching and measurement behavior for
positive dimensions.
In `@frontend/src/lib/landing/engine/marquee.ts`:
- Around line 69-114: Bind the existing onUp and onCancel release handlers to
window in addition to track, matching the dual registration pattern used by
sim.ts. Keep the current track listeners and end() behavior unchanged so drags
are finalized even when pointer capture fails or the pointer leaves the track.
In `@frontend/src/lib/landing/engine/sim.ts`:
- Around line 330-369: Update the SVG build logic in the rings and lines loops
to validate required structure before creating simulation entries: skip rings
when the previous glow element or next label element is missing, and skip lines
when either endpoint lookup in near() returns -1. Keep valid ring and line
processing unchanged, ensuring no nodes or links are created with null siblings
or invalid endpoint indices.
---
Nitpick comments:
In `@frontend/src/app/`(public)/careers/CareersList.tsx:
- Around line 217-284: Update the collapsible panel style in the job card
rendering to include visibility based on isOpen: visible when expanded and
hidden when collapsed. Add visibility to the existing transition so the state
changes with the panel animation while keeping the Apply for this role link and
other content inaccessible during collapse.
- Around line 51-55: Replace both raw img elements in CareersList with the
next/image component, matching the existing usage in CompanionShell for
/sapling-icon.svg. Preserve the current alt text and styling, and set the second
occurrence’s dimensions to width={20} and height={20} while retaining the first
occurrence’s 26×26 sizing.
In `@frontend/src/app/`(public)/faq/page.tsx:
- Around line 60-89: Update the FAQ answer panel associated with the button
using item.i: assign it a unique id, add matching aria-controls to the button,
and include visibility in the panel styles so it is hidden when collapsed and
visible when open. Preserve the existing max-height and opacity transitions.
In `@frontend/src/app/`(public)/news/page.tsx:
- Around line 111-153: Update the category menu logic around the existing
outside pointerdown listener to handle Escape while the menu is open, close it,
and return focus to the trigger button. Add a ref to the trigger button and
focus it both when Escape dismisses the menu and when selecting an option via
the NEWS_FILTERS buttons, while preserving the existing outside-click behavior.
In `@frontend/src/components/companion/CompanionShell.tsx`:
- Around line 84-141: Wrap the primary link group currently rendered by the
`nav-tabs` container in a `<nav>` landmark with a descriptive `aria-label`,
closing it after the GitHub and Ko-fi links. Also locate the header’s footer
link row and wrap it in a separate appropriately labelled `<nav>` element,
preserving the existing links and layout.
In `@frontend/src/components/landing-v5/ActIngest.tsx`:
- Around line 259-282: Add aria-hidden="true" to the decorative flying chip
elements rendered in the CHIPS.map callback within ActIngest, ensuring assistive
technologies ignore these duplicate labels while leaving their visual animation
and styling unchanged.
In `@frontend/src/components/landing-v5/ActTutor.tsx`:
- Around line 95-97: Add type="button" to the pill button rendered in ActTutor’s
button map alongside its existing key and onClick props, matching the explicit
button type used by the other buttons in this PR.
In `@frontend/src/components/landing-v5/Closing.tsx`:
- Line 92: Replace the hardcoded “© 2026” values in the Closing component with a
single dynamically derived current year, and reuse that value in both copyright
locations near the existing spans.
In `@frontend/src/components/landing-v5/DragField.tsx`:
- Around line 70-103: Update the label branch in the nodes mapping within
DragField’s render flow to always create a text element, including when label is
missing. Preserve the glow, ring, and label ordering and use an empty
placeholder for missing label content while retaining valid label positioning
and styling.
In `@frontend/src/components/landing-v5/Faq.tsx`:
- Around line 84-121: Update the FAQ trigger and answer panel so each button in
the mapped FAQ item has an aria-controls reference matching that panel’s unique
id. Add hidden or equivalent visibility handling to the panel when open is
false, while preserving the existing expanded state and visual transition
behavior.
In `@frontend/src/components/landing-v5/FeatureLab.tsx`:
- Around line 41-50: Add dialog semantics to the fixed overlay in FeatureLab by
assigning role="dialog", aria-modal="true", and an accessible name tied to the
panel heading or another existing label; implement focus movement to the close
button when open and restore focus when closed. In the tool rail, add an
explicit active-state attribute such as aria-current or aria-selected to the
open tool instead of relying on background color alone.
In `@frontend/src/components/landing-v5/Gallery.tsx`:
- Around line 87-109: Add an explicit accessible role to the div rendered by
Track so its existing aria-label is announced by assistive technology; use
role="group" without changing the current card rendering or interaction
behavior.
- Around line 71-73: Remove the redundant frame styling from the wrapper div
rendering GALLERY_MINIS[i] in Gallery: eliminate its duplicated height,
borderRadius, background, border, and overflow/position container behavior as
appropriate, while preserving the mini’s own frame and layout. Keep the
aria-hidden wrapper only if needed without adding a second visual frame.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 100-115: Update the flip target around the onClick={flip} handler
to be keyboard accessible: prefer a button element, or add role="button",
tabIndex={0}, and an onKeyDown handler that invokes flip for Enter and Space
while preventing the default Space behavior. Preserve the existing click
interaction and visual styling.
In `@frontend/src/components/landing-v5/lab/GradesDemo.tsx`:
- Around line 74-83: Update the onChange sanitizer in the GradesDemo input to
allow at most one decimal point while preserving digits and the existing state
update behavior. Ensure values such as “1.2.3” are normalized so the displayed
text matches the value used by score computation.
In `@frontend/src/components/landing-v5/lab/GuideDemo.tsx`:
- Line 40: Use a consistent lookup style for the GUIDES map in the GuideDemo
component: update the initialization around set and the lookup in the relevant
course iteration around c so both rely on the CourseKey type without an
unnecessary nullish fallback, or both use the same fallback pattern. Preserve
the existing guide-selection behavior while removing the inconsistent
implication that GUIDES[course] may be missing.
In `@frontend/src/components/landing-v5/lab/NotesDemo.tsx`:
- Around line 57-80: Store the timers created by extract and summarize in refs
alongside saveT, clear their handles on unmount, and reset each ref after its
callback runs. Follow the existing timer-ref cleanup pattern in FeatureLabDemo
so callbacks cannot update state after the component unmounts.
In `@frontend/src/components/landing-v5/lab/QuizDemo.tsx`:
- Line 31: Extract the repeated q.options correctness check into a shared
predicate or derived value near the existing right calculation, then reuse it in
both locations. Update submit’s setStreak call to use the functional updater
form based on the previous streak value, preserving the existing
increment-or-reset behavior without reading streak from the render closure.
In `@frontend/src/components/landing-v5/lab/RoomsDemo.tsx`:
- Around line 76-106: Update the message-list container in RoomsDemo so it has
aria-live="polite" and automatically scrolls to the newest message after each
append. Use a ref and an effect tied to the message-list state, preserving the
existing rendering and styling.
In `@frontend/src/components/landing-v5/Navbar.tsx`:
- Around line 199-234: Remove the incomplete ARIA menu pattern from the navbar:
delete role="menu" from the panel and aria-haspopup="menu" from its trigger,
while preserving the existing links and open/close behavior. Expose the panel as
a plain navigation group without adding menuitem roles or keyboard navigation.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 383-399: Optimize both scanning effects in useLanding: replace the
first effect’s recurring setInterval(scan, 900) polling with a MutationObserver
watching root and invoking scan for relevant DOM additions, while preserving
cleanup and observer behavior. In the second effect around the section
descendant traversal, query only elements marked with the intended animation
attribute (such as [data-anim]) instead of '*', and retain the existing
animation logic for matched elements.
In `@frontend/src/lib/landing/companionContent.ts`:
- Around line 149-160: Update WIKI_TIERS.dot and WIKI_RATINGS.tone to store the
raw hex color values from TIER instead of CSS declaration strings. In the wiki
page consumer, remove the cssColor regex and parsing/fallback logic, and use
t.dot and r.tone directly as colors.
In `@frontend/src/lib/landing/content.ts`:
- Around line 38-135: Document that GAL’s array order is a positional contract
and must not be reordered or have entries inserted without updating callers. Add
and export a galIndexOf helper keyed by GalleryKind, then update the page.tsx
actions.openGal calls to resolve indices through that helper instead of
hard-coded positions.
In `@frontend/src/lib/landing/dragClusters.ts`:
- Line 36: Remove the redundant `as DragCluster[]` assertion from the
`DRAG_CLUSTERS` array literal, preserving its existing `DragCluster[]` type
annotation so excess-property and required-field checks remain enabled.
In `@frontend/src/lib/landing/engine/flip.ts`:
- Around line 24-37: Remove the unused ran field from the FlipState interface
and its initialization in createFlipState, and remove all assignments to st.ran,
including in armFlip and flipClose. Keep the actual animation guard in
useLanding.ts via flipRan unchanged.
In `@frontend/src/lib/landing/engine/plant.ts`:
- Around line 78-91: Update restore() to validate the parsed localStorage value
as an array, retain only string entries, and truncate each label to MAX_LABEL
before calling add() or updating labels. Ensure invalid or non-array data
produces an empty restored label set without partially processing it, while
preserving the seven seed nodes and normal return count.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

Comment threadfrontend/src/app/(public)/page.tsx Outdated
Comment on lines +92 to +109
{/* the engine fades these in once the act's scroll is spent */}
<div data-tutor-pills="1" style={{ display: 'flex', gap: 8, marginTop: 8, opacity: 0, pointerEvents: 'none', transition: 'opacity 400ms ease' }}>
{CAPTIONS.map(([tag], i) => (
<button
key={tag}
onClick={() => onSetMode(i)}
style={{
border: '1px solid rgba(12,86,56,0.3)', borderRadius: 99, padding: '9px 18px',
...MONO, fontSize: 10.5, letterSpacing: '0.12em', cursor: 'pointer',
background: tutorMode === i ? PILL_ON : 'transparent',
color: tutorMode === i ? PILL_ON_FG : '#33443B',
transition: 'all 250ms',
}}
>
{tag}
</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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Find what the engine writes on the tutor pills element.
rg -n -C 6 'tutor-pills|tutorPills' --glob '*.ts' --glob '*.tsx'

Repository: SaplingLearn/Sapling

Length of output: 158


🏁 Script executed:

#!/bin/bashset -e
printf'%s\n''--- candidate files ---'
fd -i 'ActTutor|tutor|landing-v5'.| head -80
printf'%s\n''--- all tutor-pills references ---'
rg -n -C 8 --hidden --glob '!node_modules' --glob '!dist' --glob '!build''tutor-pills|tutorPills|data-tutor'.printf'%s\n''--- ActTutor structure ---'
ACT=$(fd -t f -i 'ActTutor.tsx'.| head -1)if [ -n"$ACT" ];then
ast-grep outline "$ACT"
sed -n '1,180p'"$ACT"fi

Repository: SaplingLearn/Sapling

Length of output: 19055


🏁 Script executed:

#!/bin/bashset -e
printf'%s\n''--- engine writes to M.pills ---'
rg -n -C 10 'M\.pills|pills\.(style|classList)|style\.(opacity|pointerEvents|visibility)|opacity.*pills|pills.*opacity' frontend/src/lib/landing frontend/src/components/landing-v5 --glob '*.ts' --glob '*.tsx'printf'%s\n''--- engine files and relevant structure ---'
fd -t f . frontend/src/lib/landing/engine -x sh -c 'echo --- "$1"; wc -l "$1"; ast-grep outline "$1" | head -80' sh
printf'%s\n''--- direct pills-related engine context ---'
rg -n -C 20 'pills|handover|spent|scroll' frontend/src/lib/landing/engine --glob '*.ts' --glob '*.tsx'

Repository: SaplingLearn/Sapling

Length of output: 50377


Remove hidden pills from the keyboard tab order.

The engine changes only opacity and pointerEvents, so hidden buttons remain focusable. Toggle inert with the engine’s docked state, or toggle visibility there as well.

🤖 Prompt for AI Agents
Verify each 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 92 - 109,
Update the tutor pills container in ActTutor to remove its buttons from keyboard
navigation while hidden by binding the engine’s docked state to inert (or
visibility). Preserve the existing opacity and pointerEvents behavior, and
ensure the pills become focusable again when docked is false.

Comment threadfrontend/src/components/landing-v5/BetaModal.tsx
Comment threadfrontend/src/components/landing-v5/Gallery.tsx
Comment threadfrontend/src/components/landing-v5/lab/CardsDemo.tsx
Comment threadfrontend/src/components/landing/useLanding.ts
Comment threadfrontend/src/components/landing/useLanding.ts
Comment threadfrontend/src/components/marketing/SignInModal.tsx Outdated
Comment threadfrontend/src/lib/landing/content.ts Outdated
Comment on lines +191 to +218
/**
* Presentation metadata paired positionally with POSTS.
*
* NOTE: `journal-founding.png` and `journal-ai-homework.png` are not in
* `public/` yet — both exceed the design API's 256 KiB read cap, so they
* ship with the project export alongside the rest of the source.
*/
export const POST_META: PostMeta[] = [
{
slot: 'journal-1',
src: '/journal-founding.png',
slotHint: 'Drop the founding photo',
isPhoto: true,
isGraph: false,
comments: 12,
likes: 84,
floatDelay: '0s',
},
{
slot: 'journal-2',
src: '/journal-ai-homework.png',
slotHint: 'Drop an image for this essay',
isPhoto: true,
isGraph: false,
comments: 31,
likes: 126,
floatDelay: '-2.5s',
},

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

Two journal images are declared but may not ship.POST_META points at /journal-founding.png and /journal-ai-homework.png, and the file's own comment states both assets are absent from public/. Journal.tsx renders those paths through next/image with no fallback, so two of the three journal cards can show a broken image.

  • frontend/src/lib/landing/content.ts#L191-L218: add the two PNG files to public/, or set src: '' and isGraph: true on those entries until the assets ship.
  • frontend/src/components/landing-v5/Journal.tsx#L106-L118: keep the meta.isGraph branch as the fallback path, so an entry without a usable src renders GraphThumb instead of a broken image.
📍 Affects 2 files
  • frontend/src/lib/landing/content.ts#L191-L218 (this comment)
  • frontend/src/components/landing-v5/Journal.tsx#L106-L118
🤖 Prompt for AI Agents
Verify each 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/content.ts` around lines 191 - 218, In
frontend/src/lib/landing/content.ts lines 191-218, mark the unavailable
journal-founding.png and journal-ai-homework.png entries with empty src values
and isGraph: true so they use the graph fallback until assets are shipped. In
frontend/src/components/landing-v5/Journal.tsx lines 106-118, preserve the
meta.isGraph branch that renders GraphThumb instead of passing unusable paths to
next/image.

Comment threadfrontend/src/lib/landing/engine/sim.ts
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — v5 marketing landing onto staging

This ports the v5 marketing landing onto staging as a public-surface-only change, and the central claim in the description holds up: I verified there are no backend/** files, no migrations, no middleware.ts, no next.config.ts and no wrangler.toml in the diff (74/74 changed files are under frontend/), and git diff --diff-filter=D is empty — nothing is deleted out from under another surface. The Layer-2 token scoping is done properly (.public-surface, .landing-page in globals.css:828, with deliberate literal fallbacks on .card--hero), and every new landing-v5/marketing/companion component carries "use client". The problems are elsewhere: a dropped auth-error path, staging metadata that points at production, a colour system that bypasses the design tokens entirely, and — the big one — this branch is a stale snapshot of a landing that is already merged on main.

Findings

P0

[P0] Both CI jobs are red, and the failures hide the fact that nothing was typechecked or tested.github/workflows/ci.yml:41,90-94

Backend (ruff check ., run 31553863104):

F401 [*] `pydantic.Field` imported but unused
--> models/gradescope.py:12:33
Found 1 error.
##[error]Process completed with exit code 1.

Frontend (npx eslint ., same run):

frontend/src/components/settings/GradescopeConnect.tsx
65:6 error React Hook useEffect has a missing dependency: 'refreshAll'. Either include it or
remove the dependency array react-hooks/exhaustive-deps
✖ 33 problems (1 error, 32 warnings)
##[error]Process completed with exit code 2.

To be clear about attribution: neither is yours. Both reproduce verbatim at the base commit 82787cabackend/models/gradescope.py imports Field with zero Field( uses there, and GradescopeConnect.tsx:65 is already }, []);. gh run list --branch staging shows staging's last green run was 8bce5b02 on 2026-07-10; 82787ca never ran CI, so it landed red. The reason this is still P0 for this PR: the workflow runs ruff before pytest, and eslint before npx tsc --noEmit and npm test. Both jobs die at the lint step, so the type checker and both test suites never executed against these 14,954 added lines. You currently have no signal that the port compiles. Fixing base (or rebasing once it is fixed) is a prerequisite for this PR meaning anything.

P1

[P1] ?error=session_expired no longer reaches the sign-in modalfrontend/src/app/(public)/page.tsx:89

<SignInModalopen={signInOpen}onClose={()=>setSignInOpen(false)}/>

middleware.ts (untouched here) bounces expired sessions to the landing with a query param:

// middleware.ts:38if(errorCode)u.searchParams.set('error',errorCode)// middleware.ts:52 and :67if(!session)returnredirectToSignin(request,'session_expired')

The page this replaces read that param and threaded it through — base (public)/page.tsx:90const err = params.get('error'), then :879errorCode={signInError}. The new page never reads ?error (no searchParams/URLSearchParams anywhere in page.tsx or useLanding.ts), so the prop is never supplied. Meanwhile components/marketing/SignInModal.tsx still ships the whole error surface — ERROR_COPY at :11 including session_expired at :16, and :122const visibleError = localError ?? errorCode ?? null — all now dead. Net effect: a user kicked out mid-session lands on the marketing page with no explanation of why they were signed out. Restore the ?error= read and pass it through.

[P1] The staging deploy will advertise production canonical and OG URLsfrontend/src/app/layout.tsx:43

metadataBase: newURL(resolveSiteUrl(process.env)),alternates: {canonical: '/'},

resolveSiteUrl falls through to production when neither knob is set:

// deployGuard.ts:98-106exportfunctionresolveSiteUrl(env: EnvSource): string{constdeployEnv=(env.DEPLOY_ENV??'').trim().toLowerCase();if(deployEnv&&deployEnvinFRONTEND_ENVS)returnFRONTEND_ENVS[deployEnvasFrontendEnv].siteUrl;constexplicit=(env.NEXT_PUBLIC_SITE_URL??'').trim();if(explicit)returnexplicit.replace(/\/+$/,'');returnFRONTEND_ENVS.production.siteUrl;}

frontend/wrangler.toml is not modified by this PR, and its [env.staging.vars] block sets only NEXT_PUBLIC_API_URL, BACKEND_URL and COOKIE_DOMAINneither DEPLOY_ENV nor NEXT_PUBLIC_SITE_URL. So on frontend-staging every page resolves metadataBase to https://saplinglearn.com, making canonical, og:url and og:image all point at production. Since the entire point of this PR is "show the new landing on staging", any staging link shared for review will unfurl with production's card, and staging self-canonicalises to prod. Set DEPLOY_ENV = "staging" in [env.staging.vars] (which also arms the existing checkFrontendDeployEnv lock in next.config.ts:46).

[P1] The landing runs a parallel colour palette that shares no value with the design tokensfrontend/src/components/landing-v5/*

Counting hex literals across landing-v5 (excluding the galleryMinis illustration art) gives ~900 hardcoded colours. The most frequent:

79 #0c5638 48 #fdfcf9 46 #0e9e5a 40 #12201a
37 #33443b 34 #9aa5a0 31 #8b9891 30 #8fd9a8

Not one of these is a token. The core set in globals.css is --brand-forest: #1B6C42, --brand-forest-bright: #2D8F5C, --sap-500: #4e873c, --ink-0: #faf8f3, --ink-800: #1a1814. Note the near-misses in particular: #fdfcf9 vs --ink-0 #faf8f3, and #12201a vs --ink-800 #1a1814. They are close enough to look like the brand and different enough to be visibly off it at the seam where a user crosses from the landing into the app shell. The Engineering Style Guide §3/§4 is explicit — "inline style objects referencing global CSS-variable tokens … Use the tokens; don't hardcode values." This is doubly odd because the PR itself builds the correct scaffolding: (public)/layout.tsx mounts .public-surface and globals.css:839-841 defines --surface-hero/--brand-glow on it, and then no component uses any of it.

[P1] This is a stale snapshot of a landing already merged on main, and promoting it will fight main — whole landing-v5 tree

origin/main is 4f5a7f9 Merge pull request #524 from SaplingLearn/feat/landing-v5-port — the v5 landing is already there. git merge-base origin/staging origin/main is 57469ad, which contains nofrontend/src/components/landing-v5/, so the directory is added independently on both sides: promoting stagingmain is an add/add conflict across ~50 files, not a fast merge. main has taken 19 commits on the landing since the merge-base, and git diff origin/main 395588da over the landing tree is only 4 files / +9 −38 — i.e. this branch is behind, missing a3d0371 fix(landing): raster, veil and scroll-coupling polish on the v5 hero (the MAG/zoom raster correction in ActTutor.tsx, and the re-tuned hero washes in Hero.tsx).

The dangerous part is that one of those divergences is invisible to the type checker:

// this branch — hero3d/index.ts:52-56 (an intensity multiplier, 0..2)exportfunctionstartHeroRig(canvas,getMouse,getParallax: ()=>number)// origin/main — hero3d/index.ts:76-80 (a scroll offset in pixels)exportfunctionstartHeroRig(canvas,getMouse,getHeroShiftPx: ()=>number)

Both are () => number, and both hero3d/index.ts and useLanding.ts are in the conflict set. Resolving that conflict by taking one file from each side compiles cleanly and silently breaks the hero. Please rebase this branch onto main's landing rather than re-adding an older copy, or state explicitly how the promotion merge will be resolved.

P2

[P2] Three of the four new deployGuard exports have no production callerfrontend/src/lib/deployGuard.ts:52-146

resolveFrontendEnv, expectedEnvForHost and detectHostConfigMismatch are referenced only by deployGuard.test.ts. resolveSiteUrl is the only one wired into anything (layout.tsx:4). detectHostConfigMismatch's own docstring bills it as "Runtime defence-in-depth for what the build-time guard cannot see", citing "the exact failure behind staging's session_expired" — but middleware.ts is not in this diff, so nothing calls it and it defends nothing. That is ~100 lines of inert code plus 131 lines of tests landing in a shared lib on a marketing PR. Either wire it into the middleware or drop it from this PR.

[P2] components/SignInModal.tsx is now orphaned, and its replacement is a near-copyfrontend/src/components/SignInModal.tsx

After this PR the only SignInModal import in the tree is (public)/page.tsx:14@/components/marketing/SignInModal. The original components/SignInModal.tsx (347 lines) has zero importers but is still in the tree; the new components/marketing/SignInModal.tsx is 352 lines and diff between the two is 67 lines. Two copies of the OAuth modal, one of them dead, will drift — and the dead one is the one that still gets fed errorCode in nobody's code path. The Engineering Style Guide §3 calls this out by name as the reason components/ui/ exists ("before they landed the app had 5 distinct button shapes and 18+ pill/badge implementations"). Delete the orphan or make the marketing one a thin wrapper.

[P2] A new eslint suppression was added for code introduced in this PRfrontend/eslint-suppressions.json

"src/components/marketing/SignInModal.tsx": {
"react-hooks/set-state-in-effect": { "count": 1 }
},

Style Guide §7 states the ratchet's purpose as "don't fix the whole backlog to land a change, but never add to it." The baseline exists to grandfather legacy violations; marketing/SignInModal.tsx is created by this PR, so this is a new violation being suppressed rather than fixed. set-state-in-effect is also usually a real render-loop smell worth a look rather than a waiver.

[P2] .landing-page no longer exists, leaving dead CSS and a comment that now says the opposite of the truthfrontend/src/app/(public)/layout.tsx:7

// The landing page keeps its own .landing-page class for the mesh-background visual.exportdefaultfunctionPublicLayout({ children }: {children: React.ReactNode}){return<divclassName="public-surface">{children}</div>;}

No element in the tree carries className="landing-page" at this HEAD — the only remaining mentions are three comments. But globals.css:866-874 still ships the whole mesh block that depends on it:

.landing-page .sapling-mesh-blob { position: absolute; border-radius:50%; filter:blur(100px); … }
.landing-page .sapling-mesh-blob--1 { top:-10%; left:-10%; … }

along with the other .landing-page-prefixed rules the diff kept. All unreachable. Either the wrapper should still apply the class (if the mesh backdrop is meant to render) or the rules and the comment should go.

P3

[P3] 1.3 MB of unoptimized PNGs committedfrontend/public/

journal-founding.png is 821,816 bytes and journal-ai-homework.png is 500,189 bytes. They are rendered through next/image with a sizes hint (Journal.tsx:110-114), so delivery is fine, but the repo and the OpenNext asset bundle both carry the full-size originals. Worth running them through a compressor or shipping WebP/AVIF sources.

What's good

  • The scope discipline is real and verifiable: no backend files, no migrations, no middleware.ts, no wrangler.toml, and nothing deleted — exactly what the description promises, and a much better call than merging the 217-commit branch with its colliding 0032_*/0033_* migrations.
  • The Layer-2 token boundary is set up correctly — .public-surface scoping in (public)/layout.tsx mirroring (shell), with the literal fallbacks on .card--hero and a comment explaining exactly why they are there. That is the right structure; the components just need to start using it.
  • "use client" is correct on every new hook-using component, and the deployGuard additions are genuinely well-tested (131 lines of focused unit tests) even though most of the code they cover is not yet wired up.

Verdict: request changes — the auth-error regression and the staging-metadata bug are both user-visible, and the branch needs to be reconciled with the v5 landing already on main before it can be promoted.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…PLOY_ENV
CI was red at the LINT step on both jobs, so tsc, pytest and vitest never
ran on this port at all.
- backend/models/gradescope.py: drop the unused pydantic Field import (F401).
- settings/GradescopeConnect.tsx: stabilise refreshAll with useCallback and
list it in the mount effect (exhaustive-deps is an error here). Keying on
userId also fixes the stale-account bug the empty dep array hid.
- (public)/page.tsx: read the ?error= code middleware.ts redirects with and
thread it into the modal, opening it when a code is present. Read through
useSyncExternalStore so the page stays statically prerenderable and no
setState lands in an effect; dismissing the modal strips the param.
- page.tsx: openGal indices now come from galIndexOf('quiz'/'tutor') — the
'Ask the tutor' button opened the notetaker panel. BetaModal onClose now
calls resetSubscribeError so a failed subscribe stops leaking into Journal.
- marketing/SignInModal.tsx: fix set-state-in-effect at source (reset during
render on the open prop change) and remove the suppression entry it was
given. OAuth now uses the frontend-relative /api/auth/google for both the
popup and the same-tab fallback, so the session cookie is not third-party.
- delete the orphaned components/SignInModal.tsx (zero importers) and point
the eslint data-testid rule at its replacement.
- deployGuard.classify: normalise trailing slashes on URL fields (not on
cookieDomain) so a valid slashed API/site URL cannot silently disarm the
guard. Five regression tests, all of which fail without the fix.
- middleware.ts: wire resolveFrontendEnv + detectHostConfigMismatch in, so a
host/backend mismatch reports env_misconfig instead of looping the user
through session_expired forever.
- wrangler.toml/package.json: set DEPLOY_ENV per environment so staging stops
advertising production canonical/OG URLs and the build-time guard is armed.
- globals.css/(public)/layout.tsx: delete the ~950 lines of .landing-page CSS
the replaced page made unreachable, fix the inverted comment, unquote the
single-identifier Spectral, and scope a keyframes-name-pattern disable to
the ported region only.
The a11y fixes in this PR all defend behaviour that is invisible on screen
and in review: a gallery card that only opens on click, a navbar that stays
clickable while transparent, an accordion whose closed answers remain in the
tab order, a role="radiogroup" containing no radios.
Two lab-demo state bugs get the same treatment — a reply naming a different
partner than the typing indicator, and a footer counting an empty-result
sentinel as "1 concepts" — plus the galIndexOf contract that replaced the
hard-coded GAL indices.
Renamed from the scratch file that commit b6c3a8a swept in mid-edit.
- news: card CTA said 'Read article' while every card links to /#newsletter;
it now says 'Get notified' and the Link carries a matching aria-label. The
category listbox closes on Escape and returns focus to its trigger (also on
option select) — dismissing it used to drop focus to <body>.
- about: the copy sent readers to 'a feedback button in the navbar' that
CompanionShell does not render; it now links the GitHub issue tracker.
- team/wiki: add the alternates.canonical their siblings already had.
- careers/[slug]: only append the meta-description ellipsis when slice(0,155)
actually truncated, so short blurbs stop reading as cut off.
- wiki: move scrollMarginTop from the H2 to the <section> elements — the TOC
fragments target the sections, so anchors landed under the sticky header.
Drop cssColor(): companionContent.ts now stores bare hex.
- faq: unique panel ids + aria-controls, and 'visibility' in the collapsed
style so collapsed answers are neither focusable nor announced.
- careers/CareersList: same collapsed-panel fix, so 'Apply for this role' is
not tabbable while closed; both raw <img> replaced with next/image.
- CompanionShell: wrap the header link group and the footer link row in
labelled <nav> landmarks. The compact-nav media query was scoped to
.landing-dc, which this shell must never wear, so below 1180px the
overflowing tab row stayed and the disclosure stayed hidden — unscoped it.
- page.tsx: read ?signin=1 too. CompanionShell's header 'Sign In' links to
/?signin=1 and nothing read it, so that control did nothing at all.
- public/journal-*.png: 1.29 MB -> 432 KB (-67.8% / -64.6%) via libimagequant
256-colour quantisation with Floyd-Steinberg dither (same engine pngquant
uses; RMSE 2.0 and 2.8 of 255). A fully lossless re-encode was tried first
and was larger than the originals. Filenames and paths unchanged.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Blocker

  • Both CI jobs died at the lint step, before tsc / pytest / vitest ever ran — so none of this port had been typechecked or tested. Both base failures fixed properly: the unused pydantic.Field import, and GradescopeConnect.tsx's exhaustive-deps (via useCallback keyed on userId, which also fixes a real bug the empty dep array hid — switching user kept the previous account's state).

Major

  • ?error=session_expired never reached the modal.middleware.ts sets it and marketing/SignInModal still shipped the whole ERROR_COPY surface, all dead. Now read and threaded through, opening the modal on an inbound code and stripping the param on dismiss.
  • Staging advertised production canonical/OG URLsresolveSiteUrl fell through to prod because [env.staging.vars] set neither knob. DEPLOY_ENV = "staging" added, which also arms the existing build-time guard.
  • deployGuard.classify now normalises trailing slashes, so a valid https://api.saplinglearn.com/ no longer bypasses the runtime guard; detectHostConfigMismatch is wired into middleware.ts so it actually defends something.
  • The orphaned components/SignInModal.tsx (zero importers, 67-line drift from its replacement) is deleted.

CodeRabbit majors

onLearn opened the notetaker instead of the tutor (now via the new galIndexOf lookup, not a magic index) · OAuth URLs kept same-origin · DARK_SECTIONS reduced to the one genuinely dark section · Escape now exits explore mode (was a keyboard trap with body { overflow: hidden }) · sim.destroy() re-homes clusters so a StrictMode remount no longer kills the drag field permanently · hero-canvas unmount race · CardsDemo timer leak, global key capture and keyboard-inoperable flip · Gallery cards keyboard-openable · BetaModal single-column below the breakpoint + type="email" · hidden nav/pills removed from the tab order.

Minor / nits

Dead .landing-page CSS removed · stylelint font-family-name-quotes 5→0 and keyframes-name-pattern 42→0 · lab-demo correctness (clamped percentages, partner snapshot, concept count, invalid guide picks) · ARIA roles and labels across FAQ/careers/calendar/quiz/rooms · localStorage input validated · journal PNGs recompressed 1.32 MB → 442 KB.

Still open (deliberately not done)

This branch is a stale snapshot of a landing already merged on main (#524) — promoting it is an add/add conflict across ~50 files, and startHeroRig's third parameter changed meaning while keeping an identical () => number type, so a file-by-file conflict resolution compiles cleanly and silently breaks the hero. That is a merge-strategy decision, not a code fix.

Verificationruff check . clean · tsc clean · eslint 0 errors (base had 1) · 184 frontend tests pass

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

This branch now carries a backend change, via #565

staging picked up the Gradescope repair in #565, so this PR is no longer frontend-only. That fix restored routes/gradescope.py and services/gradescope_service.py from main because the version on staging was unreachable (router prefix declared twice → every endpoint served at /api/gradescope/api/gradescope/...), called a DB client that does not exist (.eq().execute() on a PostgREST helper), and had no rate limiting or bu-sso.

I checked statically that every table the restored route touches exists on staging — gradescope_credentials, gradescope_course_links, assignments, gradebook_categories — so there is no missing-table hazard. But that is a check of the repo's migration files, not of the staging database's actual ledger. Please confirm on the real database:

SELECT table_name FROMinformation_schema.tablesWHERE table_schema ='public'AND table_name IN ('gradescope_credentials','gradescope_course_links','gradescope_links');

All three should be present. Then exercise one endpoint end to end and confirm it resolves at a single/api/gradescope/... prefix rather than 404ing.

Two things #565 deliberately left behind

SELECTcount(*) FROM gradescope_links;

gradescope_links (created by staging's 0033_add_gradescope_links.sql) now has no reader — the restored route uses gradescope_course_links. services/gradescope_sync_service.py has zero importers, and models/gradescope.py is imported only by that dead module. Nothing breaks, but if the table holds rows they are stranded, and the two modules are dead code worth removing in a follow-up.

Ledger divergence between staging and main

Staging carries two migrations main does not, at numeric prefixes main reuses for different files:

prefixstagingmain
00320032_add_gradescope_credentials.sql0032_rooms_missing_columns.sql, 0032_retire_summer_2026.sql
00330033_add_gradescope_links.sql0033_offering_section_not_null.sql, 0033_realtime_publish_room_messages.sql

schema_migrations keys on the full basename, so these coexist without a primary-key collision — but main has 23 migrations staging lacks, and staging is 239 commits behind. Whenever these branches are reconciled, both migration sets apply and the relative ordering within 0032/0033 is alphabetical by accident rather than by design. Worth settling deliberately rather than discovering it during a promotion.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

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.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, '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 the v5 marketing landing onto staging by Darkest-Teddy · Pull Request #535 · SaplingLearn/Sapling · GitHub
Skip to content

feat(landing): port the v5 marketing landing onto staging - #535

Open
Darkest-Teddy wants to merge 5 commits into
stagingfrom
feat/landing-v5-on-staging
Open

feat(landing): port the v5 marketing landing onto staging#535
Darkest-Teddy wants to merge 5 commits into
stagingfrom
feat/landing-v5-on-staging

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Puts the v5 marketing landing on stagingwithout merging feat/landing-v5-port.

Why not just merge the v5 branch

feat/landing-v5-port is 217 commits ahead of origin/staging and the diff is mostly not the landing page — 179 backend test files, 31 agents, 27 services, 17 routes, and 15 new migrations, including two colliding 0032_* and two colliding 0033_* basenames. It also isn't a fast-forward: staging has one commit that branch lacks (82787ca, gradescope). None of that is needed to show the new landing, so this ports the public surface only — no backend changes, no migrations.

What's in here

  • components/landing-v5/** and lib/landing/** — the landing engine, hero3d, drag simulation, and the Act sections
  • The (public) route group: the new /, plus /team, /wiki, /gallery, /news, /faq — the v5 navbar links to these and staging didn't have them
  • components/marketing/** and components/companion/CompanionShell
  • globals.css and app/layout.tsx (OG/metadata), lib/deployGuard.tswith its matching test, lib/errorMessage.ts
  • api.ts gains only subscribeToNewsletter — staging's backend already mounts /api/newsletter

One deliberate divergence from upstream

marketing/SignInModal calls the 3-argsetActiveUser. Upstream passes a 4th { persist } option that exists only on that branch's UserContext, whose wider change (+127/−21) also adds app-wide session recovery and shell-route redirects. Changing auth behaviour for every screen is out of scope for a landing port, so the option is dropped here and should come back in the same commit that brings that UserContext over. The call site carries the same note.

eslint-suppressions.json gains an entry for the new components/marketing/SignInModal.tsx path; staging only suppressed that rule at the old components/SignInModal.tsx path.

Verification

  • tsc --noEmit — clean
  • next build with staging's build vars (DEPLOY_ENV/BACKEND_URL/NEXT_PUBLIC_API_URL/COOKIE_DOMAIN) — succeeds, emitting /team, /wiki, /gallery, /news, /faq
  • eslint — clean except one pre-existing error: react-hooks/exhaustive-deps in settings/GradescopeConnect.tsx:65, which arrives untouched from origin/staging (it is neither modified nor suppressed on that branch). CI will likely go red on it until it's fixed separately — deliberately not touched here.
  • vitest was not run locally — it needs Node 22 and this machine is on 20. CI runs it on Node 22.

Deploy note

Per wrangler.toml and ADR 0020, the staging Workers Build must set DEPLOY_ENV=staging as a build variable, not just a runtime var — the /api rewrite and NEXT_PUBLIC_* are baked at build time. That omission is what previously put prod config on the staging worker.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added public About, Team, Wiki, Gallery, News, FAQ, and Careers pages.
    • Introduced interactive galleries, article search and filtering, FAQ accordions, career listings, and rich knowledge-base content.
    • Redesigned the landing experience with interactive learning demonstrations, knowledge graphs, tutor modes, quizzes, flashcards, study tools, and beta signup.
    • Added Google sign-in and newsletter subscription flows.
  • Improvements

    • Standardized navigation, page layouts, accessibility, responsive styling, animations, and site metadata.
    • Improved error messaging and deployment-aware canonical links.

Brings the v5 public site to `staging` WITHOUT the rest of
`feat/landing-v5-port`: that branch is 217 commits ahead of staging and
carries backend routes, services, agents and 15 migrations (including two
colliding 0032_* and two 0033_* basenames). None of that is needed to show
the new landing, so this ports the public surface only.
What's here:
- components/landing-v5/** + lib/landing/** (the landing engine, hero3d,
drag simulation and the Act sections)
- the (public) route group: the new / plus /team, /wiki, /gallery, /news
and /faq, which the v5 navbar links to and staging did not have
- components/marketing/** and components/companion/CompanionShell
- globals.css and app/layout.tsx (OG/metadata), lib/deployGuard.ts with
its matching test, lib/errorMessage.ts
- api.ts gains only `subscribeToNewsletter`; staging's backend already
mounts /api/newsletter
One deliberate divergence from upstream: marketing/SignInModal calls the
3-arg `setActiveUser`. Upstream passes a 4th `{ persist }` option that only
exists on that branch's UserContext, whose wider change also adds app-wide
session recovery and shell-route redirects. Changing auth for every screen
is out of scope for a landing port, so the option is dropped here and should
return with that UserContext. The call site carries the same note.
eslint-suppressions.json gains an entry for the new
components/marketing/SignInModal.tsx path; staging only suppressed the rule
at the old components/SignInModal.tsx path.
Verified: tsc --noEmit clean, and `next build` succeeds with staging's build
vars (DEPLOY_ENV/BACKEND_URL/NEXT_PUBLIC_API_URL/COOKIE_DOMAIN), emitting
/team, /wiki, /gallery, /news and /faq. eslint is clean except for a
pre-existing react-hooks/exhaustive-deps error in
settings/GradescopeConnect.tsx, which arrives untouched from origin/staging.
vitest was not run locally (needs Node 22; this machine is on 20) - CI runs it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 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 ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:37 seconds

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 101dbfe3-a2c2-4287-842f-6e2c86a82b2e

📥 Commits

Reviewing files that changed from the base of the PR and between 395588d and 46f7930.

⛔ Files ignored due to path filters (2)
  • frontend/public/journal-ai-homework.png is excluded by !**/*.png
  • frontend/public/journal-founding.png is excluded by !**/*.png
📒 Files selected for processing (57)
  • docs/frontend-testids.md
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/package.json
  • frontend/src/app/(public)/about/page.tsx
  • frontend/src/app/(public)/careers/CareersList.tsx
  • frontend/src/app/(public)/careers/[slug]/page.tsx
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/layout.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/ActGraph.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/ActTutor.tsx
  • frontend/src/components/landing-v5/BetaModal.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/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/landing-v5-interaction.test.tsx
  • frontend/src/components/landing-v5/navTheme.ts
  • frontend/src/components/landing/useLanding.ts
  • frontend/src/components/marketing/HeroCard.test.tsx
  • frontend/src/components/marketing/SignInModal.tsx
  • frontend/src/lib/deployGuard.test.ts
  • frontend/src/lib/deployGuard.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/dom.ts
  • frontend/src/lib/landing/engine/flip.ts
  • frontend/src/lib/landing/engine/index.ts
  • frontend/src/lib/landing/engine/marquee.ts
  • frontend/src/lib/landing/engine/plant.ts
  • frontend/src/lib/landing/engine/sim.test.ts
  • frontend/src/lib/landing/engine/sim.ts
  • frontend/src/middleware.ts
  • frontend/wrangler.toml
📝 Walkthrough

Walkthrough

The PR replaces the landing page with Landing v5, adds companion and careers pages, introduces shared public layouts and content, expands deployment metadata, and adds OAuth, newsletter, error-handling, CSS, and interaction infrastructure.

Changes

Landing v5 experience

Layer / File(s)Summary
Landing contracts and content
frontend/src/lib/landing/*
Adds typed landing content, course graphs, drag clusters, lab fixtures, color utilities, and navigation themes.
Landing sections and demos
frontend/src/components/landing-v5/*
Adds the v5 hero, graph, ingest, tutor, gallery, FAQ, journal, CTA, navigation, modal, and feature-lab components.
Landing runtime and composition
frontend/src/components/landing/useLanding.ts, frontend/src/lib/landing/engine/*, frontend/src/app/(public)/page.tsx
Connects React state to scroll, canvas, WebGL, graph, marquee, FLIP, planting, newsletter, and drag-field behavior.
Landing styles and tests
frontend/src/app/globals.css, frontend/src/components/landing-v5/*.test.tsx, frontend/src/lib/landing/engine/sim.test.ts
Adds responsive landing layouts, finite animations, and interaction regression coverage.

Companion and public pages

Layer / File(s)Summary
Shared companion shell and content
frontend/src/components/companion/CompanionShell.tsx, frontend/src/lib/landing/companionContent.ts
Adds shared navigation, footer links, and typed content collections.
Companion pages
frontend/src/app/(public)/about/*, team/*, faq/*, gallery/*, news/*, wiki/*
Adds shared-shell pages with data-driven content, filters, accordions, navigation, and metadata.
Careers routes
frontend/src/app/(public)/careers/*
Moves interactive job listings into CareersList, adds route metadata, and returns not-found responses for invalid slugs.

Platform and marketing infrastructure

Layer / File(s)Summary
Deployment and metadata
frontend/src/lib/deployGuard.ts, frontend/src/lib/deployGuard.test.ts, frontend/src/app/layout.tsx, frontend/src/app/(public)/privacy/page.tsx, frontend/src/app/(public)/terms/page.tsx
Adds environment resolution, canonical site URLs, host mismatch detection, structured application metadata, and legal-page metadata.
Authentication, newsletter, and marketing UI
frontend/src/components/marketing/SignInModal.tsx, frontend/src/components/marketing/HeroCard.tsx, frontend/src/lib/api.ts, frontend/src/lib/errorMessage.ts
Adds OAuth popup handling, accessible modal behavior, reusable hero-card rendering, newsletter subscription requests, and defensive error normalization.

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

Sequence Diagram(s)

sequenceDiagram
participant Visitor
participant LandingPage
participant useLanding
participant LandingEngine
participant CanvasAndWebGL
Visitor->>LandingPage: open landing page
LandingPage->>useLanding: initialize refs and state
useLanding->>LandingEngine: start landing lifecycle
LandingEngine->>CanvasAndWebGL: render hero and graph visuals
Visitor->>LandingPage: explore graph or submit newsletter
LandingPage->>useLanding: dispatch interaction
useLanding->>LandingEngine: update interaction state
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 26.42% 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 main change: porting the v5 marketing landing to staging.
Description check✅ PassedThe description covers scope, rationale, changes, testing limits, and deployment requirements, with only minor template omissions.
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 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/landing-v5-on-staging

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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 12, 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-staging46f7930Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:19 PM

@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: 11

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/lib/deployGuard.ts (1)

39-46: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Normalize trailing slashes before environment classification.

A canonical API URL with a trailing slash classifies as unknown. Then detectHostConfigMismatch returns null for a staging host wired to https://api.saplinglearn.com/. This bypasses the runtime guard for a valid URL spelling.

Normalize URL fields before comparison. Add regression tests for trailing-slash API and site URLs.

Proposed fix
 function classify(
value: string | undefined,
field: 'apiUrl' | 'cookieDomain' | 'siteUrl',
): FrontendEnv | null {
- const v = (value ?? '').trim().toLowerCase();+ const v = (value ?? '')+ .trim()+ .toLowerCase()+ .replace(field === 'cookieDomain' ? /$/ : /\/+$/, '');
if (!v) return null;
for (const env of Object.keys(FRONTEND_ENVS) as FrontendEnv[]) {
if (FRONTEND_ENVS[env][field].toLowerCase() === v) return env;
🤖 Prompt for AI Agents
Verify each 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/deployGuard.ts` around lines 39 - 46, Update classify to
normalize URL values by removing trailing slashes before comparing against
FRONTEND_ENVS fields, while preserving existing trimming and case normalization.
Ensure both API and site URL classifications recognize trailing-slash variants,
and add regression coverage for trailing-slash API and site URLs.
🟡 Minor comments (24)
frontend/src/app/(public)/news/page.tsx-175-199 (1)

175-199: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The card link text does not match its destination.

Each card is a <Link href="/#newsletter"> and its call to action reads "Read article →". A reader who selects a headline arrives at the newsletter signup on the home page, not at the post. All six cards lead to the same anchor.

Change the call to action to state the real destination, for example "Get notified →", until article 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/app/`(public)/news/page.tsx around lines 175 - 199, The news
cards’ CTA text incorrectly promises article navigation while each Link in the
posts.map block targets /#newsletter. Update the CTA span text from “Read
article →” to wording that reflects newsletter signup, such as “Get notified →”,
while preserving the existing link destination and styling.
frontend/src/app/(public)/about/page.tsx-80-84 (1)

80-84: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update the copy that points to a feedback button in the navbar.

The paragraph tells the reader to use "a feedback button in the navbar". The ported chrome in frontend/src/components/companion/CompanionShell.tsx renders Home, About, Team, Wiki, Gallery, News, FAQ, GitHub, Ko-fi, Sign In, and Get Started. It has no feedback control. Point the reader at a control that exists, for example the GitHub link or a contact address.

🤖 Prompt for AI Agents
Verify each 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)/about/page.tsx around lines 80 - 84, Update the
paragraph near the prose content to remove the nonexistent navbar
feedback-button reference, and direct readers to an available control such as
the GitHub link or a contact address instead. Preserve the surrounding message
about reporting issues and sharing ideas.
frontend/src/app/(public)/team/page.tsx-18-22 (1)

18-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Two companion routes omit alternates.canonical. The port sets a canonical URL on frontend/src/app/(public)/about/page.tsx, frontend/src/app/(public)/careers/page.tsx, and frontend/src/app/(public)/careers/[slug]/page.tsx, but not on these two routes. Without a canonical URL, these pages can be indexed under query-string or alternate-host variants.

  • frontend/src/app/(public)/team/page.tsx#L18-L22: add alternates: { canonical: '/team' } to the metadata export.
  • frontend/src/app/(public)/wiki/page.tsx#L18-L22: add alternates: { canonical: '/wiki' } to the metadata export.
🤖 Prompt for AI Agents
Verify each 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)/team/page.tsx around lines 18 - 22, Update the
metadata exports in frontend/src/app/(public)/team/page.tsx lines 18-22 and
frontend/src/app/(public)/wiki/page.tsx lines 18-22 to include
alternates.canonical, using /team for the team route and /wiki for the wiki
route.
frontend/src/app/(public)/careers/[slug]/page.tsx-18-22 (1)

18-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The meta description appends an ellipsis even when it truncates nothing.

Line 20 always appends after job.description.slice(0, 155). If a description is shorter than 155 characters, the full sentence still ends with a truncation marker. Search results then show a complete sentence that looks cut off.

Append the ellipsis only when the slice removed text.

🐛 Proposed fix
+ const blurb =+ job.description.length > 155 ? `${job.description.slice(0, 155)}…` : job.description;
return {
title: `${job.title} — Careers`,
- description: `${job.department} · ${job.location} · ${job.type}. ${job.description.slice(0, 155)}…`,+ description: `${job.department} · ${job.location} · ${job.type}. ${blurb}`,
alternates: { canonical: `/careers/${job.slug}` },
};
🤖 Prompt for AI Agents
Verify each 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)/careers/[slug]/page.tsx around lines 18 - 22,
Update the metadata construction in the careers page’s generateMetadata flow so
the description appends “…” only when job.description exceeds 155 characters;
leave shorter or exactly 155-character descriptions without a truncation marker
while preserving the existing 155-character slice.
frontend/src/app/(public)/wiki/page.tsx-28-31 (1)

28-31: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

scrollMarginTop is applied to the wrong element.

The H2 style sets scrollMarginTop: 84. The TOC links on line 67 target the fragments #graph, #mastery, #review, #tutor, #ingestion, #grades, and #privacy. Those ids are on the <section> elements, not on the <h2> elements. The browser applies scroll margin to the element it scrolls into view, so the section top lands under the sticky 92px header from CompanionShell and the heading is hidden.

Move the offset to the sections.

🐛 Proposed fix
 const H2: React.CSSProperties = {
margin: 0, fontFamily: DISPLAY, fontWeight: 500, fontSize: 26,
- lineHeight: 1.2, letterSpacing: '-0.015em', scrollMarginTop: 84,+ lineHeight: 1.2, letterSpacing: '-0.015em',
};
+/** Clears the sticky companion header after an in-page anchor jump. */+const SECTION: React.CSSProperties = { scrollMarginTop: 104 };

Then apply it to each section, for example:

- <section id="graph">+ <section id="graph" style={SECTION}>
🤖 Prompt for AI Agents
Verify each 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 28 - 31, Move
scrollMarginTop: 84 from the H2 style to the section styling used by the
sections targeted by the TOC fragment links (`#graph`, `#mastery`, `#review`, `#tutor`,
`#ingestion`, `#grades`, and `#privacy`). Keep the heading typography unchanged and
ensure each target section receives the offset so it clears the sticky header.
frontend/src/components/companion/CompanionShell.tsx-86-108 (1)

86-108: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Apply the responsive nav rules to CompanionShell. The media query is scoped to .landing-dc, but the CompanionShell root does not have that class. The rules do not match, despite using !important, so the compact nav remains hidden and the tab row remains visible below 1180px.

🤖 Prompt for AI Agents
Verify each 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/companion/CompanionShell.tsx` around lines 86 - 108,
Update the CompanionShell root element to include the landing-dc class so the
existing responsive media-query rules apply to its nav-compact and nav-tabs
elements. Preserve the current navigation structure and styling.

Source: Linters/SAST tools

frontend/src/lib/landing/engine/dom.ts-49-73 (1)

49-73: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

A layout measurement taken before layout is ready is cached permanently. Both sites store a measured size on first read and re-measure only when a resize signal arrives. If the first read happens before the element has its final box, the wrong value is kept for the rest of the session. Reject an implausible measurement instead of caching it.

  • frontend/src/lib/landing/engine/dom.ts#L49-L73: do not assign rec.__cv when w or h is 0. invalidateCanvas() runs only on a viewport size change, so a zero-size cache leaves the canvas blank.
  • frontend/src/lib/landing/engine/marquee.ts#L136-L144: do not accept m.setW when the summed child offsetWidth is 0. The re-measure trigger is a wrap.clientWidth change, so a gap-only width leaves the track wrapping over a few pixels.
🤖 Prompt for AI Agents
Verify each 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, Reject
zero-sized measurements before caching them: in
frontend/src/lib/landing/engine/dom.ts lines 49-73, ensure cv does not assign
rec.__cv when w or h is 0; in frontend/src/lib/landing/engine/marquee.ts lines
136-144, ensure the marquee measurement does not call m.setW when the summed
child offsetWidth is 0. Preserve normal caching and measurement behavior for
positive dimensions.
frontend/src/lib/landing/engine/sim.ts-330-369 (1)

330-369: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard the SVG structure assumptions during build.

Three unchecked assumptions read the authored SVG:

  • Line 332: ring.previousElementSibling as SVGCircleElement. If the ring is the first child, glow is null. Line 839 then calls p.glow.setAttribute on every frame and throws a TypeError.
  • Line 333 and 345: label is dereferenced immediately by label.getAttribute('y'). A missing next sibling throws during ensureInit().
  • Line 362-367: near() returns -1 when the cluster has line elements but no [data-sim] rings. nodes[b] is then undefined and line 367 throws.

holdArms() at line 653 and stepGroup() at line 752 already guard with if (!s0 || !t0) continue;, so missing nodes are anticipated later in the pipeline but not at build time. Skip a ring or a line when its expected siblings are absent.

🤖 Prompt for AI Agents
Verify each 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 330 - 369, Update the
SVG build logic in the rings and lines loops to validate required structure
before creating simulation entries: skip rings when the previous glow element or
next label element is missing, and skip lines when either endpoint lookup in
near() returns -1. Keep valid ring and line processing unchanged, ensuring no
nodes or links are created with null siblings or invalid endpoint indices.
frontend/src/lib/landing/engine/marquee.ts-69-114 (1)

69-114: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Bind pointerup and pointercancel on window as well.

setPointerCapture is called inside a try/catch at line 80. If the capture fails, later pointer events are not retargeted to track. A drag that ends outside track then never runs end(). m.drag stays non-null, so update() skips both drift and momentum for that track and the cursor stays grabbing until the next pointerdown.

frontend/src/lib/landing/engine/sim.ts lines 507-510 already binds the release handlers on both the element and window for this reason. Apply the same pattern here.

🛡️ Proposed fix
 track.addEventListener('pointerdown', onDown);
track.addEventListener('pointermove', onMove);
track.addEventListener('pointerup', onUp);
track.addEventListener('pointercancel', onCancel);
+ window.addEventListener('pointerup', onUp);+ window.addEventListener('pointercancel', onCancel);
cleanups.push(() => {
wrap?.removeEventListener('pointerenter', onEnter);
wrap?.removeEventListener('pointerleave', onLeave);
track.removeEventListener('pointerdown', onDown);
track.removeEventListener('pointermove', onMove);
track.removeEventListener('pointerup', onUp);
track.removeEventListener('pointercancel', onCancel);
+ window.removeEventListener('pointerup', onUp);+ window.removeEventListener('pointercancel', onCancel);
STATE.delete(track);
});
🤖 Prompt for AI Agents
Verify each 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 69 - 114, Bind the
existing onUp and onCancel release handlers to window in addition to track,
matching the dual registration pattern used by sim.ts. Keep the current track
listeners and end() behavior unchanged so drags are finalized even when pointer
capture fails or the pointer leaves the track.
frontend/src/components/landing-v5/BetaModal.tsx-244-260 (1)

244-260: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use type="email" for the email field.

The field is labelled "Email address" and validated as an email, but it is declared type="text". That suppresses the email keyboard on mobile and blocks browser autofill. The manual check at lines 86-96 continues to work unchanged.

🔧 Proposed fix
 ref={inputRef}
- type="text"+ type="email"+ autoComplete="email"+ inputMode="email"
aria-label="Email address"
🤖 Prompt for AI Agents
Verify each 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 244 - 260,
Update the email input in BetaModal’s form to use type="email" instead of
type="text", while preserving its existing validation, value handling, and other
attributes.
frontend/src/components/landing/useLanding.ts-593-597 (1)

593-597: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report invalid input instead of returning silently.

If value has no @, the function returns without setting subscribeError and without any other state change. The user presses the button and sees no response. An empty non-required type="email" field passes browser validation and reaches this branch.

🐛 Proposed fix
 const value = email.trim();
- if (!value.includes('@') || subscribing) return;+ if (subscribing) return;+ if (!value.includes('@')) {+ setSubscribeError('Enter a valid email address.');+ return;+ }
🤖 Prompt for AI Agents
Verify each 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 593 - 597, Update
subscribeNow so invalid email input, including an empty trimmed value or a value
without "@", sets subscribeError to the existing user-facing validation message
before returning. Keep the subscribing guard and valid-input flow unchanged,
ensuring the user receives feedback instead of a silent no-op.
frontend/src/components/landing-v5/Navbar.tsx-74-79 (1)

74-79: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The navbar stays interactive while it is invisible.

While heroMounted is false, opacity is 0 but pointerEvents remains 'auto'. For the first ~1.9s of the intro, every navbar control is focusable and clickable while nothing is visible.

♿ Proposed fix
 opacity: exploring ? 0 : heroMounted ? 1 : 0,
- pointerEvents: exploring ? 'none' : 'auto',+ pointerEvents: exploring || !heroMounted ? 'none' : 'auto',+ visibility: heroMounted ? 'visible' : 'hidden',
🤖 Prompt for AI Agents
Verify each 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 74 - 79, Update
the navbar style logic in the visible component so pointerEvents is set to
'none' whenever the navbar is invisible, including while heroMounted is false
and while exploring is true; restore 'auto' only when the navbar is visible and
interactive.
frontend/src/app/(public)/page.tsx-91-100 (1)

91-100: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear the subscription error when the modal closes.

state.subscribeError is shared by BetaModal and Journal and is never reset. After a failed attempt, the message stays visible in the Journal form and reappears the next time the modal opens. useLanding already exposes actions.resetSubscribeError for this, and it is currently unused.

🐛 Proposed fix
- onClose={() => setBetaOpen(false)}+ onClose={() => { actions.resetSubscribeError(); setBetaOpen(false); }}
🤖 Prompt for AI Agents
Verify each 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 91 - 100, Update the
BetaModal onClose handler to call actions.resetSubscribeError before closing the
modal with setBetaOpen(false), ensuring the shared subscription error is cleared
when the modal closes.
frontend/src/app/globals.css-1563-1569 (1)

1563-1569: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stylelint fails on the quoted Spectral family name.

font-family-name-quotes reports four errors: Lines 1565, 1713, 1784, and 1836. Spectral is a single identifier, so it needs no quotes.

🔧 Proposed fix (apply to all four sites)
- font-family: var(--font-spectral), 'Spectral', Georgia, serif;+ font-family: var(--font-spectral), Spectral, Georgia, serif;

As per static analysis hints, font-family-name-quotes is enabled for this file.

Also applies to: 1713-1713, 1784-1784, 1836-1836

🤖 Prompt for AI Agents
Verify each 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 1563 - 1569, Remove the
unnecessary quotes around the single-identifier Spectral font family name in all
four font-family declarations, including the declarations associated with the
visible landing-page style and the other reported sites, so they satisfy
font-family-name-quotes.

Source: Linters/SAST tools

frontend/src/components/landing-v5/lab/RoomsDemo.tsx-42-54 (1)

42-54: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The typing indicator and the delayed reply can name different partners.

send captures partner in the timeout closure. The indicator at Line 127 reads the live partner. If the user changes the select during the 1600 ms delay, the indicator shows the new partner and the message arrives from the old one.

♻️ Proposed fix
 const [partner, setPartner] = useState<Partner>('maya');
+ const partnerRef = useRef<Partner>('maya');+ partnerRef.current = partner;
 typeT.current = setTimeout(() => {
- const reply = `my ${partner.charAt(0).toUpperCase() + partner.slice(1)} graph has eigenvalues green — want me to quiz you on it?`;+ const who = partnerRef.current;+ const reply = `my ${who.charAt(0).toUpperCase() + who.slice(1)} graph has eigenvalues green — want me to quiz you on it?`;
setTyping(false);
- setMsgs((m) => [...m, { who: partner, text: reply, mine: false }]);+ setMsgs((m) => [...m, { who, text: reply, mine: false }]);
}, 1600);

Also applies to: 125-132

🤖 Prompt for AI Agents
Verify each 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/RoomsDemo.tsx` around lines 42 - 54,
Update send so the delayed reply and typing indicator use the same partner
snapshot captured when the message is sent. Store that snapshot in the relevant
state or timeout flow, and ensure the indicator rendering near the partner
display uses it until the delayed reply completes; preserve the existing
behavior when the partner does not change.
frontend/src/app/globals.css-1950-1957 (1)

1950-1957: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stylelint reports 45 keyframes-name-pattern errors in the ported blocks.

The ported keyframe names are camelCase, for example s1Stem, popHold, heroRise, and labIn. The rule expects kebab-case. Renaming is not safe here, because the names are referenced from inline animation strings in the landing components and in labData-driven markup.

Scope a rule disable to the ported region instead, so the rest of the file keeps the check.

🔧 Proposed approach
+/* stylelint-disable keyframes-name-pattern -- names are ported verbatim from the+ design components and are referenced by inline `animation` strings in TSX. */
`@keyframes` s1Stem { 0%,15% { stroke-dashoffset:27; } 34%,100% { stroke-dashoffset:0; } }

Close the block with /* stylelint-enable keyframes-name-pattern */ after the last ported keyframe.

As per static analysis hints, keyframes-name-pattern is enabled for this file.

Also applies to: 2094-2096

🤖 Prompt for AI Agents
Verify each 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 1950 - 1957, Scope-disable
Stylelint’s keyframes-name-pattern rule immediately before the ported keyframe
region containing names such as s1Stem, popHold, heroRise, and labIn, then
re-enable it after the final ported keyframe. Keep the rule active for all other
sections of globals.css and do not rename the animation identifiers.

Source: Linters/SAST tools

frontend/src/lib/landing/course.ts-363-376 (1)

363-376: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle the singular case for degree.

If degree is 1, the text reads "its 1 neighbours". A sub-topic linked only to its parent produces that value.

✏️ Proposed fix
- ' pulls this node plus its ' +- degree +- ' neighbours into context, then retrieves the chunks of your ' ++ ' pulls this node plus its ' ++ degree ++ (degree === 1 ? ' neighbour' : ' neighbours') ++ ' into context, then retrieves the chunks of your ' +
🤖 Prompt for AI Agents
Verify each 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/course.ts` around lines 363 - 376, Update nodeUses
so the text generated for degree uses singular “neighbour” when degree is 1 and
plural “neighbours” otherwise, preserving the existing wording and behavior for
all other values.
frontend/src/components/landing-v5/lab/CardsDemo.tsx-46-53 (1)

46-53: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard the window key listener against editable targets.

The listener runs for every keydown on the window. It calls e.preventDefault() for Space and rates the card for 1, 2, and 3. While this demo is mounted, a Space or a digit typed in any other input on the page is captured.

🛡️ Proposed fix
 const onKey = (e: KeyboardEvent) => {
+ const el = e.target as HTMLElement | null;+ if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) 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 around onKey to ignore events whose
target is an editable control, such as input, textarea, select, or
contenteditable elements, before handling Space or rate keys; preserve the
existing flip and rate behavior for non-editable targets.
frontend/src/components/landing-v5/lab/GradesDemo.tsx-62-65 (1)

62-65: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The row percentage is not clamped, but the weighted grade is.

Line 25 clamps each score to [0, r.possible]. Line 64 does not. If the user types 500 into a row worth 50, the row shows 1000% while the overall grade stays at the clamped value. Use the same clamp for both readouts.

🐛 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,
Update the row percentage calculation in the GB_ROWS.map callback to clamp the
parsed earned score to the range [0, r.possible] before computing the
percentage, matching the existing clamping used for the weighted grade and
keeping both readouts consistent.
frontend/src/components/landing-v5/lab/NotesDemo.tsx-57-66 (1)

57-66: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The "No concepts found" sentinel corrupts the footer count.

Line 63 pushes { name: 'No concepts found', tier: 'unexplored' } into the same concepts array that holds real matches. The footer at Lines 141-143 then reads concepts.length and prints 1 concepts linked to your MA 242 graph. after an empty extraction. The text is wrong, and the plural is wrong for a single match too.

Track the empty result separately instead of encoding it as a concept.

🐛 Proposed fix
- const [concepts, setConcepts] = useState<{ name: string; tier: keyof typeof LAB_TIER }[]>([]);+ const [concepts, setConcepts] = useState<{ name: string; tier: keyof typeof LAB_TIER }[]>([]);+ const [ran, setRan] = useState(false);
 const found = MATCHERS.filter((m) => m.hit.test(lower)).map((m) => ({ name: m.name, tier: m.tier }));
- setConcepts(found.length ? found : [{ name: 'No concepts found', tier: 'unexplored' }]);+ setConcepts(found);+ setRan(true);
setBusy(false);
- {concepts.length- ? `${concepts.length} concepts linked to your MA 242 graph.`- : 'Concepts you extract are linked to your graph, not just tagged on the note.'}+ {concepts.length+ ? `${concepts.length} concept${concepts.length === 1 ? '' : 's'} linked to your MA 242 graph.`+ : ran+ ? 'No concepts found in this note.'+ : 'Concepts you extract are linked to your graph, not just tagged on the note.'}

Also applies to: 140-144

🤖 Prompt for AI Agents
Verify each 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 - 66,
Update extract so an empty MATCHERS result is tracked separately rather than
added to concepts; keep concepts containing only real matches and render the
empty-state message independently. Adjust the footer rendering near the concepts
count to display the correct zero count and use singular/plural wording based on
the actual number of linked concepts.
frontend/src/components/landing-v5/Closing.tsx-154-182 (1)

154-182: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Composite ARIA roles are declared without their required child roles. Both sites put a composite role on a container and leave the children as plain <button> elements. ARIA requires owned children with the matching role, so assistive technology announces an empty widget and the selected item is not conveyed.

  • frontend/src/components/landing-v5/Closing.tsx#L154-L182: add role="menuitem" to each section button inside the role="menu" container.
  • frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124: add role="radio" and aria-checked={isPicked} to each option button inside the role="radiogroup" container.
🤖 Prompt for AI Agents
Verify each 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 154 - 182, Add
role="menuitem" to each section button rendered by SECTIONS.map in Closing.tsx
(frontend/src/components/landing-v5/Closing.tsx#L154-L182). In QuizDemo.tsx
(frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124), add
role="radio" and bind aria-checked to isPicked on each option button within the
radiogroup.
frontend/src/components/landing-v5/lab/CalendarDemo.tsx-86-112 (1)

86-112: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Blank leading cells are focusable empty buttons.

Every cell renders as a <button>, including the LEAD_BLANKS placeholders. The blank cells only set cursor: 'default', which does not remove them from the tab order. A keyboard user tabs through up to six empty buttons before reaching 1 October. The selected day also carries no aria-pressed, so the current selection is not announced.

♿ Proposed change
 <button
key={i}
onClick={() => { if (!c.blank) setDay(c.day as number); }}
type="button"
+ disabled={c.blank}+ aria-hidden={c.blank || undefined}+ aria-pressed={c.blank ? undefined : selected}
🤖 Prompt for AI Agents
Verify each 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/CalendarDemo.tsx` around lines 86 -
112, Update the day-cell buttons rendered by the shown.map callback so blank
placeholder cells are removed from keyboard focus, while retaining interactive
keyboard behavior for real days. Add aria-pressed to each non-blank day button
and set it according to the selected value so assistive technologies announce
the current selection.
frontend/src/components/landing-v5/Faq.tsx-71-76 (1)

71-76: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

"Read the full FAQ" links back to itself.

The anchor targets #faq, and the enclosing <section> already has id="faq" (Line 40). Clicking the link keeps the user on the same section, so the call to action does nothing.

This PR adds the /faq public route, and Closing.tsx Line 81 already links to it. Point this link at /faq.

🔗 Proposed change
- <a href="`#faq`" style={{ color: '`#0C5638`', textDecoration: 'underline' }}>Read the full FAQ</a>+ <Link href="/faq" style={{ color: '`#0C5638`', textDecoration: 'underline' }}>Read the full FAQ</Link>

Add the import:

 import { FAQS } from '`@/lib/landing/content`';
+import Link from 'next/link';
import { DragField } from './DragField';
🤖 Prompt for AI Agents
Verify each 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 71 - 76, Update the
“Read the full FAQ” anchor in the Faq component to navigate to the public /faq
route instead of the local `#faq` section, matching the existing link behavior in
Closing.tsx.
frontend/src/components/landing-v5/lab/GuideDemo.tsx-21-24 (1)

21-24: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Align RECENT.pick with the course exam lists. Neither Midterm 1 · Sep 30 nor Quiz 2 · Oct 03 exists in its course's GUIDES.exams. Opening either recent item sets an invalid select value while the guide heading displays that value.

🤖 Prompt for AI Agents
Verify each 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 in GuideDemo so each pick exactly matches an existing
exam value in the corresponding course’s GUIDES.exams list, preserving the
displayed recent-item behavior and ensuring the select value and guide heading
use valid options.
🤖 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/`(public)/page.tsx:
- Around line 130-131: Update the onLearn handler in the page component to open
the tutor panel at GAL index 7 instead of index 2, while leaving onQuiz
unchanged. Prefer the named GAL lookup or constant from content.ts rather than a
hard-coded index so card ordering changes cannot reintroduce this mismatch.
In `@frontend/src/components/landing-v5/ActTutor.tsx`:
- Around line 92-109: Update the tutor pills container in ActTutor to remove its
buttons from keyboard navigation while hidden by binding the engine’s docked
state to inert (or visibility). Preserve the existing opacity and pointerEvents
behavior, and ensure the pills become focusable again when docked is false.
In `@frontend/src/components/landing-v5/BetaModal.tsx`:
- Around line 132-143: Update the HeroCard responsive layout to use a single
column below the medium breakpoint and the existing two-column structure at
wider widths, using its CSS class rather than an unconditional
gridTemplateColumns style. Enable vertical scrolling for constrained viewports,
and apply the 560px minimum height only at the wider breakpoint so mobile
content, including the email field and submit button, remains accessible.
In `@frontend/src/components/landing-v5/Gallery.tsx`:
- Around line 62-85: Update the Card component so non-ghost cards are
keyboard-focusable and expose button-like semantics, with a key handler that
triggers the existing delegated Track click behavior for Enter and Space. Keep
ghost cards inert by omitting focusability, interactive semantics, and
activation handling, while preserving the current data-tk targeting.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 37-44: Update the rate callback to store its 300 ms timeout in a
ref instead of returning a cleanup function, and clear that timeout in an
unmount cleanup effect. Preserve the existing delayed setIdx behavior while
preventing it from firing after the component unmounts.
In `@frontend/src/components/landing-v5/navTheme.ts`:
- Around line 68-69: Update DARK_SECTIONS to contain only the section that
currently renders on a dark ground, act-graph, and revise its comment to
describe the single dark section accurately. Keep act-ingest and act-tutor out
of the list until their backgrounds are dark.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 218-229: Update the hero canvas effect around the lazy
`import('`@/lib/landing/engine/hero`')` to track whether cleanup has run, prevent
`startHeroCanvas` from starting after unmount, and stop it immediately if
initialization races with cancellation. Preserve the existing cleanup behavior
for normal mounted execution.
- Around line 277-282: Update the onKey handler in useLanding to handle Escape
while explore mode is active: after closing the gallery or jump menu as
appropriate, call exitExplore when s.current.exploring is true and neither
overlay was open. Ensure exitExplore is available at handler execution time
despite its later declaration, using the existing callback ordering or a ref as
needed.
In `@frontend/src/components/marketing/SignInModal.tsx`:
- Around line 130-153: Update the OAuth URL construction in the sign-in flow,
including sameTabUrl and popupUrl, to use the frontend-relative /api/auth/google
path instead of prepending API_URL. Preserve the existing popup_id query
parameter and same-tab fallback behavior so OAuth requests remain same-origin
and retain the session cookie.
In `@frontend/src/lib/landing/content.ts`:
- Around line 191-218: In frontend/src/lib/landing/content.ts lines 191-218,
mark the unavailable journal-founding.png and journal-ai-homework.png entries
with empty src values and isGraph: true so they use the graph fallback until
assets are shipped. In frontend/src/components/landing-v5/Journal.tsx lines
106-118, preserve the meta.isGraph branch that renders GraphThumb instead of
passing unusable paths to next/image.
In `@frontend/src/lib/landing/engine/sim.ts`:
- Around line 897-905: Update destroy() cleanup ordering so every cluster
element is re-parented to its original field before the cleanup that removes the
shell/overlay runs. Preserve the existing cleanup registration in ensureInit(),
but ensure repeated createSim() calls can still find the original
[data-dragnode] elements after destroy().
---
Outside diff comments:
In `@frontend/src/lib/deployGuard.ts`:
- Around line 39-46: Update classify to normalize URL values by removing
trailing slashes before comparing against FRONTEND_ENVS fields, while preserving
existing trimming and case normalization. Ensure both API and site URL
classifications recognize trailing-slash variants, and add regression coverage
for trailing-slash API and site URLs.
---
Minor comments:
In `@frontend/src/app/`(public)/about/page.tsx:
- Around line 80-84: Update the paragraph near the prose content to remove the
nonexistent navbar feedback-button reference, and direct readers to an available
control such as the GitHub link or a contact address instead. Preserve the
surrounding message about reporting issues and sharing ideas.
In `@frontend/src/app/`(public)/careers/[slug]/page.tsx:
- Around line 18-22: Update the metadata construction in the careers page’s
generateMetadata flow so the description appends “…” only when job.description
exceeds 155 characters; leave shorter or exactly 155-character descriptions
without a truncation marker while preserving the existing 155-character slice.
In `@frontend/src/app/`(public)/news/page.tsx:
- Around line 175-199: The news cards’ CTA text incorrectly promises article
navigation while each Link in the posts.map block targets /#newsletter. Update
the CTA span text from “Read article →” to wording that reflects newsletter
signup, such as “Get notified →”, while preserving the existing link destination
and styling.
In `@frontend/src/app/`(public)/page.tsx:
- Around line 91-100: Update the BetaModal onClose handler to call
actions.resetSubscribeError before closing the modal with setBetaOpen(false),
ensuring the shared subscription error is cleared when the modal closes.
In `@frontend/src/app/`(public)/team/page.tsx:
- Around line 18-22: Update the metadata exports in
frontend/src/app/(public)/team/page.tsx lines 18-22 and
frontend/src/app/(public)/wiki/page.tsx lines 18-22 to include
alternates.canonical, using /team for the team route and /wiki for the wiki
route.
In `@frontend/src/app/`(public)/wiki/page.tsx:
- Around line 28-31: Move scrollMarginTop: 84 from the H2 style to the section
styling used by the sections targeted by the TOC fragment links (`#graph`,
`#mastery`, `#review`, `#tutor`, `#ingestion`, `#grades`, and `#privacy`). Keep the heading
typography unchanged and ensure each target section receives the offset so it
clears the sticky header.
In `@frontend/src/app/globals.css`:
- Around line 1563-1569: Remove the unnecessary quotes around the
single-identifier Spectral font family name in all four font-family
declarations, including the declarations associated with the visible
landing-page style and the other reported sites, so they satisfy
font-family-name-quotes.
- Around line 1950-1957: Scope-disable Stylelint’s keyframes-name-pattern rule
immediately before the ported keyframe region containing names such as s1Stem,
popHold, heroRise, and labIn, then re-enable it after the final ported keyframe.
Keep the rule active for all other sections of globals.css and do not rename the
animation identifiers.
In `@frontend/src/components/companion/CompanionShell.tsx`:
- Around line 86-108: Update the CompanionShell root element to include the
landing-dc class so the existing responsive media-query rules apply to its
nav-compact and nav-tabs elements. Preserve the current navigation structure and
styling.
In `@frontend/src/components/landing-v5/BetaModal.tsx`:
- Around line 244-260: Update the email input in BetaModal’s form to use
type="email" instead of type="text", while preserving its existing validation,
value handling, and other attributes.
In `@frontend/src/components/landing-v5/Closing.tsx`:
- Around line 154-182: Add role="menuitem" to each section button rendered by
SECTIONS.map in Closing.tsx
(frontend/src/components/landing-v5/Closing.tsx#L154-L182). In QuizDemo.tsx
(frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124), add
role="radio" and bind aria-checked to isPicked on each option button within the
radiogroup.
In `@frontend/src/components/landing-v5/Faq.tsx`:
- Around line 71-76: Update the “Read the full FAQ” anchor in the Faq component
to navigate to the public /faq route instead of the local `#faq` section, matching
the existing link behavior in Closing.tsx.
In `@frontend/src/components/landing-v5/lab/CalendarDemo.tsx`:
- Around line 86-112: Update the day-cell buttons rendered by the shown.map
callback so blank placeholder cells are removed from keyboard focus, while
retaining interactive keyboard behavior for real days. Add aria-pressed to each
non-blank day button and set it according to the selected value so assistive
technologies announce the current selection.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 46-53: Update the keydown handler in the useEffect around onKey to
ignore events whose target is an editable control, such as input, textarea,
select, or contenteditable elements, before handling Space or rate keys;
preserve the existing flip and rate behavior for non-editable targets.
In `@frontend/src/components/landing-v5/lab/GradesDemo.tsx`:
- Around line 62-65: Update the row percentage calculation in the GB_ROWS.map
callback to clamp the parsed earned score to the range [0, r.possible] before
computing the percentage, matching the existing clamping used for the weighted
grade and keeping both readouts consistent.
In `@frontend/src/components/landing-v5/lab/GuideDemo.tsx`:
- Around line 21-24: Update the RECENT entries in GuideDemo so each pick exactly
matches an existing exam value in the corresponding course’s GUIDES.exams list,
preserving the displayed recent-item behavior and ensuring the select value and
guide heading use valid options.
In `@frontend/src/components/landing-v5/lab/NotesDemo.tsx`:
- Around line 57-66: Update extract so an empty MATCHERS result is tracked
separately rather than added to concepts; keep concepts containing only real
matches and render the empty-state message independently. Adjust the footer
rendering near the concepts count to display the correct zero count and use
singular/plural wording based on the actual number of linked concepts.
In `@frontend/src/components/landing-v5/lab/RoomsDemo.tsx`:
- Around line 42-54: Update send so the delayed reply and typing indicator use
the same partner snapshot captured when the message is sent. Store that snapshot
in the relevant state or timeout flow, and ensure the indicator rendering near
the partner display uses it until the delayed reply completes; preserve the
existing behavior when the partner does not change.
In `@frontend/src/components/landing-v5/Navbar.tsx`:
- Around line 74-79: Update the navbar style logic in the visible component so
pointerEvents is set to 'none' whenever the navbar is invisible, including while
heroMounted is false and while exploring is true; restore 'auto' only when the
navbar is visible and interactive.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 593-597: Update subscribeNow so invalid email input, including an
empty trimmed value or a value without "@", sets subscribeError to the existing
user-facing validation message before returning. Keep the subscribing guard and
valid-input flow unchanged, ensuring the user receives feedback instead of a
silent no-op.
In `@frontend/src/lib/landing/course.ts`:
- Around line 363-376: Update nodeUses so the text generated for degree uses
singular “neighbour” when degree is 1 and plural “neighbours” otherwise,
preserving the existing wording and behavior for all other values.
In `@frontend/src/lib/landing/engine/dom.ts`:
- Around line 49-73: Reject zero-sized measurements before caching them: in
frontend/src/lib/landing/engine/dom.ts lines 49-73, ensure cv does not assign
rec.__cv when w or h is 0; in frontend/src/lib/landing/engine/marquee.ts lines
136-144, ensure the marquee measurement does not call m.setW when the summed
child offsetWidth is 0. Preserve normal caching and measurement behavior for
positive dimensions.
In `@frontend/src/lib/landing/engine/marquee.ts`:
- Around line 69-114: Bind the existing onUp and onCancel release handlers to
window in addition to track, matching the dual registration pattern used by
sim.ts. Keep the current track listeners and end() behavior unchanged so drags
are finalized even when pointer capture fails or the pointer leaves the track.
In `@frontend/src/lib/landing/engine/sim.ts`:
- Around line 330-369: Update the SVG build logic in the rings and lines loops
to validate required structure before creating simulation entries: skip rings
when the previous glow element or next label element is missing, and skip lines
when either endpoint lookup in near() returns -1. Keep valid ring and line
processing unchanged, ensuring no nodes or links are created with null siblings
or invalid endpoint indices.
---
Nitpick comments:
In `@frontend/src/app/`(public)/careers/CareersList.tsx:
- Around line 217-284: Update the collapsible panel style in the job card
rendering to include visibility based on isOpen: visible when expanded and
hidden when collapsed. Add visibility to the existing transition so the state
changes with the panel animation while keeping the Apply for this role link and
other content inaccessible during collapse.
- Around line 51-55: Replace both raw img elements in CareersList with the
next/image component, matching the existing usage in CompanionShell for
/sapling-icon.svg. Preserve the current alt text and styling, and set the second
occurrence’s dimensions to width={20} and height={20} while retaining the first
occurrence’s 26×26 sizing.
In `@frontend/src/app/`(public)/faq/page.tsx:
- Around line 60-89: Update the FAQ answer panel associated with the button
using item.i: assign it a unique id, add matching aria-controls to the button,
and include visibility in the panel styles so it is hidden when collapsed and
visible when open. Preserve the existing max-height and opacity transitions.
In `@frontend/src/app/`(public)/news/page.tsx:
- Around line 111-153: Update the category menu logic around the existing
outside pointerdown listener to handle Escape while the menu is open, close it,
and return focus to the trigger button. Add a ref to the trigger button and
focus it both when Escape dismisses the menu and when selecting an option via
the NEWS_FILTERS buttons, while preserving the existing outside-click behavior.
In `@frontend/src/components/companion/CompanionShell.tsx`:
- Around line 84-141: Wrap the primary link group currently rendered by the
`nav-tabs` container in a `<nav>` landmark with a descriptive `aria-label`,
closing it after the GitHub and Ko-fi links. Also locate the header’s footer
link row and wrap it in a separate appropriately labelled `<nav>` element,
preserving the existing links and layout.
In `@frontend/src/components/landing-v5/ActIngest.tsx`:
- Around line 259-282: Add aria-hidden="true" to the decorative flying chip
elements rendered in the CHIPS.map callback within ActIngest, ensuring assistive
technologies ignore these duplicate labels while leaving their visual animation
and styling unchanged.
In `@frontend/src/components/landing-v5/ActTutor.tsx`:
- Around line 95-97: Add type="button" to the pill button rendered in ActTutor’s
button map alongside its existing key and onClick props, matching the explicit
button type used by the other buttons in this PR.
In `@frontend/src/components/landing-v5/Closing.tsx`:
- Line 92: Replace the hardcoded “© 2026” values in the Closing component with a
single dynamically derived current year, and reuse that value in both copyright
locations near the existing spans.
In `@frontend/src/components/landing-v5/DragField.tsx`:
- Around line 70-103: Update the label branch in the nodes mapping within
DragField’s render flow to always create a text element, including when label is
missing. Preserve the glow, ring, and label ordering and use an empty
placeholder for missing label content while retaining valid label positioning
and styling.
In `@frontend/src/components/landing-v5/Faq.tsx`:
- Around line 84-121: Update the FAQ trigger and answer panel so each button in
the mapped FAQ item has an aria-controls reference matching that panel’s unique
id. Add hidden or equivalent visibility handling to the panel when open is
false, while preserving the existing expanded state and visual transition
behavior.
In `@frontend/src/components/landing-v5/FeatureLab.tsx`:
- Around line 41-50: Add dialog semantics to the fixed overlay in FeatureLab by
assigning role="dialog", aria-modal="true", and an accessible name tied to the
panel heading or another existing label; implement focus movement to the close
button when open and restore focus when closed. In the tool rail, add an
explicit active-state attribute such as aria-current or aria-selected to the
open tool instead of relying on background color alone.
In `@frontend/src/components/landing-v5/Gallery.tsx`:
- Around line 87-109: Add an explicit accessible role to the div rendered by
Track so its existing aria-label is announced by assistive technology; use
role="group" without changing the current card rendering or interaction
behavior.
- Around line 71-73: Remove the redundant frame styling from the wrapper div
rendering GALLERY_MINIS[i] in Gallery: eliminate its duplicated height,
borderRadius, background, border, and overflow/position container behavior as
appropriate, while preserving the mini’s own frame and layout. Keep the
aria-hidden wrapper only if needed without adding a second visual frame.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 100-115: Update the flip target around the onClick={flip} handler
to be keyboard accessible: prefer a button element, or add role="button",
tabIndex={0}, and an onKeyDown handler that invokes flip for Enter and Space
while preventing the default Space behavior. Preserve the existing click
interaction and visual styling.
In `@frontend/src/components/landing-v5/lab/GradesDemo.tsx`:
- Around line 74-83: Update the onChange sanitizer in the GradesDemo input to
allow at most one decimal point while preserving digits and the existing state
update behavior. Ensure values such as “1.2.3” are normalized so the displayed
text matches the value used by score computation.
In `@frontend/src/components/landing-v5/lab/GuideDemo.tsx`:
- Line 40: Use a consistent lookup style for the GUIDES map in the GuideDemo
component: update the initialization around set and the lookup in the relevant
course iteration around c so both rely on the CourseKey type without an
unnecessary nullish fallback, or both use the same fallback pattern. Preserve
the existing guide-selection behavior while removing the inconsistent
implication that GUIDES[course] may be missing.
In `@frontend/src/components/landing-v5/lab/NotesDemo.tsx`:
- Around line 57-80: Store the timers created by extract and summarize in refs
alongside saveT, clear their handles on unmount, and reset each ref after its
callback runs. Follow the existing timer-ref cleanup pattern in FeatureLabDemo
so callbacks cannot update state after the component unmounts.
In `@frontend/src/components/landing-v5/lab/QuizDemo.tsx`:
- Line 31: Extract the repeated q.options correctness check into a shared
predicate or derived value near the existing right calculation, then reuse it in
both locations. Update submit’s setStreak call to use the functional updater
form based on the previous streak value, preserving the existing
increment-or-reset behavior without reading streak from the render closure.
In `@frontend/src/components/landing-v5/lab/RoomsDemo.tsx`:
- Around line 76-106: Update the message-list container in RoomsDemo so it has
aria-live="polite" and automatically scrolls to the newest message after each
append. Use a ref and an effect tied to the message-list state, preserving the
existing rendering and styling.
In `@frontend/src/components/landing-v5/Navbar.tsx`:
- Around line 199-234: Remove the incomplete ARIA menu pattern from the navbar:
delete role="menu" from the panel and aria-haspopup="menu" from its trigger,
while preserving the existing links and open/close behavior. Expose the panel as
a plain navigation group without adding menuitem roles or keyboard navigation.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 383-399: Optimize both scanning effects in useLanding: replace the
first effect’s recurring setInterval(scan, 900) polling with a MutationObserver
watching root and invoking scan for relevant DOM additions, while preserving
cleanup and observer behavior. In the second effect around the section
descendant traversal, query only elements marked with the intended animation
attribute (such as [data-anim]) instead of '*', and retain the existing
animation logic for matched elements.
In `@frontend/src/lib/landing/companionContent.ts`:
- Around line 149-160: Update WIKI_TIERS.dot and WIKI_RATINGS.tone to store the
raw hex color values from TIER instead of CSS declaration strings. In the wiki
page consumer, remove the cssColor regex and parsing/fallback logic, and use
t.dot and r.tone directly as colors.
In `@frontend/src/lib/landing/content.ts`:
- Around line 38-135: Document that GAL’s array order is a positional contract
and must not be reordered or have entries inserted without updating callers. Add
and export a galIndexOf helper keyed by GalleryKind, then update the page.tsx
actions.openGal calls to resolve indices through that helper instead of
hard-coded positions.
In `@frontend/src/lib/landing/dragClusters.ts`:
- Line 36: Remove the redundant `as DragCluster[]` assertion from the
`DRAG_CLUSTERS` array literal, preserving its existing `DragCluster[]` type
annotation so excess-property and required-field checks remain enabled.
In `@frontend/src/lib/landing/engine/flip.ts`:
- Around line 24-37: Remove the unused ran field from the FlipState interface
and its initialization in createFlipState, and remove all assignments to st.ran,
including in armFlip and flipClose. Keep the actual animation guard in
useLanding.ts via flipRan unchanged.
In `@frontend/src/lib/landing/engine/plant.ts`:
- Around line 78-91: Update restore() to validate the parsed localStorage value
as an array, retain only string entries, and truncate each label to MAX_LABEL
before calling add() or updating labels. Ensure invalid or non-array data
produces an empty restored label set without partially processing it, while
preserving the seven seed nodes and normal return count.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

Comment threadfrontend/src/app/(public)/page.tsx Outdated
Comment on lines +92 to +109
{/* the engine fades these in once the act's scroll is spent */}
<div data-tutor-pills="1" style={{ display: 'flex', gap: 8, marginTop: 8, opacity: 0, pointerEvents: 'none', transition: 'opacity 400ms ease' }}>
{CAPTIONS.map(([tag], i) => (
<button
key={tag}
onClick={() => onSetMode(i)}
style={{
border: '1px solid rgba(12,86,56,0.3)', borderRadius: 99, padding: '9px 18px',
...MONO, fontSize: 10.5, letterSpacing: '0.12em', cursor: 'pointer',
background: tutorMode === i ? PILL_ON : 'transparent',
color: tutorMode === i ? PILL_ON_FG : '#33443B',
transition: 'all 250ms',
}}
>
{tag}
</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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Find what the engine writes on the tutor pills element.
rg -n -C 6 'tutor-pills|tutorPills' --glob '*.ts' --glob '*.tsx'

Repository: SaplingLearn/Sapling

Length of output: 158


🏁 Script executed:

#!/bin/bashset -e
printf'%s\n''--- candidate files ---'
fd -i 'ActTutor|tutor|landing-v5'.| head -80
printf'%s\n''--- all tutor-pills references ---'
rg -n -C 8 --hidden --glob '!node_modules' --glob '!dist' --glob '!build''tutor-pills|tutorPills|data-tutor'.printf'%s\n''--- ActTutor structure ---'
ACT=$(fd -t f -i 'ActTutor.tsx'.| head -1)if [ -n"$ACT" ];then
ast-grep outline "$ACT"
sed -n '1,180p'"$ACT"fi

Repository: SaplingLearn/Sapling

Length of output: 19055


🏁 Script executed:

#!/bin/bashset -e
printf'%s\n''--- engine writes to M.pills ---'
rg -n -C 10 'M\.pills|pills\.(style|classList)|style\.(opacity|pointerEvents|visibility)|opacity.*pills|pills.*opacity' frontend/src/lib/landing frontend/src/components/landing-v5 --glob '*.ts' --glob '*.tsx'printf'%s\n''--- engine files and relevant structure ---'
fd -t f . frontend/src/lib/landing/engine -x sh -c 'echo --- "$1"; wc -l "$1"; ast-grep outline "$1" | head -80' sh
printf'%s\n''--- direct pills-related engine context ---'
rg -n -C 20 'pills|handover|spent|scroll' frontend/src/lib/landing/engine --glob '*.ts' --glob '*.tsx'

Repository: SaplingLearn/Sapling

Length of output: 50377


Remove hidden pills from the keyboard tab order.

The engine changes only opacity and pointerEvents, so hidden buttons remain focusable. Toggle inert with the engine’s docked state, or toggle visibility there as well.

🤖 Prompt for AI Agents
Verify each 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 92 - 109,
Update the tutor pills container in ActTutor to remove its buttons from keyboard
navigation while hidden by binding the engine’s docked state to inert (or
visibility). Preserve the existing opacity and pointerEvents behavior, and
ensure the pills become focusable again when docked is false.

Comment threadfrontend/src/components/landing-v5/BetaModal.tsx
Comment threadfrontend/src/components/landing-v5/Gallery.tsx
Comment threadfrontend/src/components/landing-v5/lab/CardsDemo.tsx
Comment threadfrontend/src/components/landing/useLanding.ts
Comment threadfrontend/src/components/landing/useLanding.ts
Comment threadfrontend/src/components/marketing/SignInModal.tsx Outdated
Comment threadfrontend/src/lib/landing/content.ts Outdated
Comment on lines +191 to +218
/**
* Presentation metadata paired positionally with POSTS.
*
* NOTE: `journal-founding.png` and `journal-ai-homework.png` are not in
* `public/` yet — both exceed the design API's 256 KiB read cap, so they
* ship with the project export alongside the rest of the source.
*/
export const POST_META: PostMeta[] = [
{
slot: 'journal-1',
src: '/journal-founding.png',
slotHint: 'Drop the founding photo',
isPhoto: true,
isGraph: false,
comments: 12,
likes: 84,
floatDelay: '0s',
},
{
slot: 'journal-2',
src: '/journal-ai-homework.png',
slotHint: 'Drop an image for this essay',
isPhoto: true,
isGraph: false,
comments: 31,
likes: 126,
floatDelay: '-2.5s',
},

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

Two journal images are declared but may not ship.POST_META points at /journal-founding.png and /journal-ai-homework.png, and the file's own comment states both assets are absent from public/. Journal.tsx renders those paths through next/image with no fallback, so two of the three journal cards can show a broken image.

  • frontend/src/lib/landing/content.ts#L191-L218: add the two PNG files to public/, or set src: '' and isGraph: true on those entries until the assets ship.
  • frontend/src/components/landing-v5/Journal.tsx#L106-L118: keep the meta.isGraph branch as the fallback path, so an entry without a usable src renders GraphThumb instead of a broken image.
📍 Affects 2 files
  • frontend/src/lib/landing/content.ts#L191-L218 (this comment)
  • frontend/src/components/landing-v5/Journal.tsx#L106-L118
🤖 Prompt for AI Agents
Verify each 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/content.ts` around lines 191 - 218, In
frontend/src/lib/landing/content.ts lines 191-218, mark the unavailable
journal-founding.png and journal-ai-homework.png entries with empty src values
and isGraph: true so they use the graph fallback until assets are shipped. In
frontend/src/components/landing-v5/Journal.tsx lines 106-118, preserve the
meta.isGraph branch that renders GraphThumb instead of passing unusable paths to
next/image.

Comment threadfrontend/src/lib/landing/engine/sim.ts
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — v5 marketing landing onto staging

This ports the v5 marketing landing onto staging as a public-surface-only change, and the central claim in the description holds up: I verified there are no backend/** files, no migrations, no middleware.ts, no next.config.ts and no wrangler.toml in the diff (74/74 changed files are under frontend/), and git diff --diff-filter=D is empty — nothing is deleted out from under another surface. The Layer-2 token scoping is done properly (.public-surface, .landing-page in globals.css:828, with deliberate literal fallbacks on .card--hero), and every new landing-v5/marketing/companion component carries "use client". The problems are elsewhere: a dropped auth-error path, staging metadata that points at production, a colour system that bypasses the design tokens entirely, and — the big one — this branch is a stale snapshot of a landing that is already merged on main.

Findings

P0

[P0] Both CI jobs are red, and the failures hide the fact that nothing was typechecked or tested.github/workflows/ci.yml:41,90-94

Backend (ruff check ., run 31553863104):

F401 [*] `pydantic.Field` imported but unused
--> models/gradescope.py:12:33
Found 1 error.
##[error]Process completed with exit code 1.

Frontend (npx eslint ., same run):

frontend/src/components/settings/GradescopeConnect.tsx
65:6 error React Hook useEffect has a missing dependency: 'refreshAll'. Either include it or
remove the dependency array react-hooks/exhaustive-deps
✖ 33 problems (1 error, 32 warnings)
##[error]Process completed with exit code 2.

To be clear about attribution: neither is yours. Both reproduce verbatim at the base commit 82787cabackend/models/gradescope.py imports Field with zero Field( uses there, and GradescopeConnect.tsx:65 is already }, []);. gh run list --branch staging shows staging's last green run was 8bce5b02 on 2026-07-10; 82787ca never ran CI, so it landed red. The reason this is still P0 for this PR: the workflow runs ruff before pytest, and eslint before npx tsc --noEmit and npm test. Both jobs die at the lint step, so the type checker and both test suites never executed against these 14,954 added lines. You currently have no signal that the port compiles. Fixing base (or rebasing once it is fixed) is a prerequisite for this PR meaning anything.

P1

[P1] ?error=session_expired no longer reaches the sign-in modalfrontend/src/app/(public)/page.tsx:89

<SignInModalopen={signInOpen}onClose={()=>setSignInOpen(false)}/>

middleware.ts (untouched here) bounces expired sessions to the landing with a query param:

// middleware.ts:38if(errorCode)u.searchParams.set('error',errorCode)// middleware.ts:52 and :67if(!session)returnredirectToSignin(request,'session_expired')

The page this replaces read that param and threaded it through — base (public)/page.tsx:90const err = params.get('error'), then :879errorCode={signInError}. The new page never reads ?error (no searchParams/URLSearchParams anywhere in page.tsx or useLanding.ts), so the prop is never supplied. Meanwhile components/marketing/SignInModal.tsx still ships the whole error surface — ERROR_COPY at :11 including session_expired at :16, and :122const visibleError = localError ?? errorCode ?? null — all now dead. Net effect: a user kicked out mid-session lands on the marketing page with no explanation of why they were signed out. Restore the ?error= read and pass it through.

[P1] The staging deploy will advertise production canonical and OG URLsfrontend/src/app/layout.tsx:43

metadataBase: newURL(resolveSiteUrl(process.env)),alternates: {canonical: '/'},

resolveSiteUrl falls through to production when neither knob is set:

// deployGuard.ts:98-106exportfunctionresolveSiteUrl(env: EnvSource): string{constdeployEnv=(env.DEPLOY_ENV??'').trim().toLowerCase();if(deployEnv&&deployEnvinFRONTEND_ENVS)returnFRONTEND_ENVS[deployEnvasFrontendEnv].siteUrl;constexplicit=(env.NEXT_PUBLIC_SITE_URL??'').trim();if(explicit)returnexplicit.replace(/\/+$/,'');returnFRONTEND_ENVS.production.siteUrl;}

frontend/wrangler.toml is not modified by this PR, and its [env.staging.vars] block sets only NEXT_PUBLIC_API_URL, BACKEND_URL and COOKIE_DOMAINneither DEPLOY_ENV nor NEXT_PUBLIC_SITE_URL. So on frontend-staging every page resolves metadataBase to https://saplinglearn.com, making canonical, og:url and og:image all point at production. Since the entire point of this PR is "show the new landing on staging", any staging link shared for review will unfurl with production's card, and staging self-canonicalises to prod. Set DEPLOY_ENV = "staging" in [env.staging.vars] (which also arms the existing checkFrontendDeployEnv lock in next.config.ts:46).

[P1] The landing runs a parallel colour palette that shares no value with the design tokensfrontend/src/components/landing-v5/*

Counting hex literals across landing-v5 (excluding the galleryMinis illustration art) gives ~900 hardcoded colours. The most frequent:

79 #0c5638 48 #fdfcf9 46 #0e9e5a 40 #12201a
37 #33443b 34 #9aa5a0 31 #8b9891 30 #8fd9a8

Not one of these is a token. The core set in globals.css is --brand-forest: #1B6C42, --brand-forest-bright: #2D8F5C, --sap-500: #4e873c, --ink-0: #faf8f3, --ink-800: #1a1814. Note the near-misses in particular: #fdfcf9 vs --ink-0 #faf8f3, and #12201a vs --ink-800 #1a1814. They are close enough to look like the brand and different enough to be visibly off it at the seam where a user crosses from the landing into the app shell. The Engineering Style Guide §3/§4 is explicit — "inline style objects referencing global CSS-variable tokens … Use the tokens; don't hardcode values." This is doubly odd because the PR itself builds the correct scaffolding: (public)/layout.tsx mounts .public-surface and globals.css:839-841 defines --surface-hero/--brand-glow on it, and then no component uses any of it.

[P1] This is a stale snapshot of a landing already merged on main, and promoting it will fight main — whole landing-v5 tree

origin/main is 4f5a7f9 Merge pull request #524 from SaplingLearn/feat/landing-v5-port — the v5 landing is already there. git merge-base origin/staging origin/main is 57469ad, which contains nofrontend/src/components/landing-v5/, so the directory is added independently on both sides: promoting stagingmain is an add/add conflict across ~50 files, not a fast merge. main has taken 19 commits on the landing since the merge-base, and git diff origin/main 395588da over the landing tree is only 4 files / +9 −38 — i.e. this branch is behind, missing a3d0371 fix(landing): raster, veil and scroll-coupling polish on the v5 hero (the MAG/zoom raster correction in ActTutor.tsx, and the re-tuned hero washes in Hero.tsx).

The dangerous part is that one of those divergences is invisible to the type checker:

// this branch — hero3d/index.ts:52-56 (an intensity multiplier, 0..2)exportfunctionstartHeroRig(canvas,getMouse,getParallax: ()=>number)// origin/main — hero3d/index.ts:76-80 (a scroll offset in pixels)exportfunctionstartHeroRig(canvas,getMouse,getHeroShiftPx: ()=>number)

Both are () => number, and both hero3d/index.ts and useLanding.ts are in the conflict set. Resolving that conflict by taking one file from each side compiles cleanly and silently breaks the hero. Please rebase this branch onto main's landing rather than re-adding an older copy, or state explicitly how the promotion merge will be resolved.

P2

[P2] Three of the four new deployGuard exports have no production callerfrontend/src/lib/deployGuard.ts:52-146

resolveFrontendEnv, expectedEnvForHost and detectHostConfigMismatch are referenced only by deployGuard.test.ts. resolveSiteUrl is the only one wired into anything (layout.tsx:4). detectHostConfigMismatch's own docstring bills it as "Runtime defence-in-depth for what the build-time guard cannot see", citing "the exact failure behind staging's session_expired" — but middleware.ts is not in this diff, so nothing calls it and it defends nothing. That is ~100 lines of inert code plus 131 lines of tests landing in a shared lib on a marketing PR. Either wire it into the middleware or drop it from this PR.

[P2] components/SignInModal.tsx is now orphaned, and its replacement is a near-copyfrontend/src/components/SignInModal.tsx

After this PR the only SignInModal import in the tree is (public)/page.tsx:14@/components/marketing/SignInModal. The original components/SignInModal.tsx (347 lines) has zero importers but is still in the tree; the new components/marketing/SignInModal.tsx is 352 lines and diff between the two is 67 lines. Two copies of the OAuth modal, one of them dead, will drift — and the dead one is the one that still gets fed errorCode in nobody's code path. The Engineering Style Guide §3 calls this out by name as the reason components/ui/ exists ("before they landed the app had 5 distinct button shapes and 18+ pill/badge implementations"). Delete the orphan or make the marketing one a thin wrapper.

[P2] A new eslint suppression was added for code introduced in this PRfrontend/eslint-suppressions.json

"src/components/marketing/SignInModal.tsx": {
"react-hooks/set-state-in-effect": { "count": 1 }
},

Style Guide §7 states the ratchet's purpose as "don't fix the whole backlog to land a change, but never add to it." The baseline exists to grandfather legacy violations; marketing/SignInModal.tsx is created by this PR, so this is a new violation being suppressed rather than fixed. set-state-in-effect is also usually a real render-loop smell worth a look rather than a waiver.

[P2] .landing-page no longer exists, leaving dead CSS and a comment that now says the opposite of the truthfrontend/src/app/(public)/layout.tsx:7

// The landing page keeps its own .landing-page class for the mesh-background visual.exportdefaultfunctionPublicLayout({ children }: {children: React.ReactNode}){return<divclassName="public-surface">{children}</div>;}

No element in the tree carries className="landing-page" at this HEAD — the only remaining mentions are three comments. But globals.css:866-874 still ships the whole mesh block that depends on it:

.landing-page .sapling-mesh-blob { position: absolute; border-radius:50%; filter:blur(100px); … }
.landing-page .sapling-mesh-blob--1 { top:-10%; left:-10%; … }

along with the other .landing-page-prefixed rules the diff kept. All unreachable. Either the wrapper should still apply the class (if the mesh backdrop is meant to render) or the rules and the comment should go.

P3

[P3] 1.3 MB of unoptimized PNGs committedfrontend/public/

journal-founding.png is 821,816 bytes and journal-ai-homework.png is 500,189 bytes. They are rendered through next/image with a sizes hint (Journal.tsx:110-114), so delivery is fine, but the repo and the OpenNext asset bundle both carry the full-size originals. Worth running them through a compressor or shipping WebP/AVIF sources.

What's good

  • The scope discipline is real and verifiable: no backend files, no migrations, no middleware.ts, no wrangler.toml, and nothing deleted — exactly what the description promises, and a much better call than merging the 217-commit branch with its colliding 0032_*/0033_* migrations.
  • The Layer-2 token boundary is set up correctly — .public-surface scoping in (public)/layout.tsx mirroring (shell), with the literal fallbacks on .card--hero and a comment explaining exactly why they are there. That is the right structure; the components just need to start using it.
  • "use client" is correct on every new hook-using component, and the deployGuard additions are genuinely well-tested (131 lines of focused unit tests) even though most of the code they cover is not yet wired up.

Verdict: request changes — the auth-error regression and the staging-metadata bug are both user-visible, and the branch needs to be reconciled with the v5 landing already on main before it can be promoted.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…PLOY_ENV
CI was red at the LINT step on both jobs, so tsc, pytest and vitest never
ran on this port at all.
- backend/models/gradescope.py: drop the unused pydantic Field import (F401).
- settings/GradescopeConnect.tsx: stabilise refreshAll with useCallback and
list it in the mount effect (exhaustive-deps is an error here). Keying on
userId also fixes the stale-account bug the empty dep array hid.
- (public)/page.tsx: read the ?error= code middleware.ts redirects with and
thread it into the modal, opening it when a code is present. Read through
useSyncExternalStore so the page stays statically prerenderable and no
setState lands in an effect; dismissing the modal strips the param.
- page.tsx: openGal indices now come from galIndexOf('quiz'/'tutor') — the
'Ask the tutor' button opened the notetaker panel. BetaModal onClose now
calls resetSubscribeError so a failed subscribe stops leaking into Journal.
- marketing/SignInModal.tsx: fix set-state-in-effect at source (reset during
render on the open prop change) and remove the suppression entry it was
given. OAuth now uses the frontend-relative /api/auth/google for both the
popup and the same-tab fallback, so the session cookie is not third-party.
- delete the orphaned components/SignInModal.tsx (zero importers) and point
the eslint data-testid rule at its replacement.
- deployGuard.classify: normalise trailing slashes on URL fields (not on
cookieDomain) so a valid slashed API/site URL cannot silently disarm the
guard. Five regression tests, all of which fail without the fix.
- middleware.ts: wire resolveFrontendEnv + detectHostConfigMismatch in, so a
host/backend mismatch reports env_misconfig instead of looping the user
through session_expired forever.
- wrangler.toml/package.json: set DEPLOY_ENV per environment so staging stops
advertising production canonical/OG URLs and the build-time guard is armed.
- globals.css/(public)/layout.tsx: delete the ~950 lines of .landing-page CSS
the replaced page made unreachable, fix the inverted comment, unquote the
single-identifier Spectral, and scope a keyframes-name-pattern disable to
the ported region only.
The a11y fixes in this PR all defend behaviour that is invisible on screen
and in review: a gallery card that only opens on click, a navbar that stays
clickable while transparent, an accordion whose closed answers remain in the
tab order, a role="radiogroup" containing no radios.
Two lab-demo state bugs get the same treatment — a reply naming a different
partner than the typing indicator, and a footer counting an empty-result
sentinel as "1 concepts" — plus the galIndexOf contract that replaced the
hard-coded GAL indices.
Renamed from the scratch file that commit b6c3a8a swept in mid-edit.
- news: card CTA said 'Read article' while every card links to /#newsletter;
it now says 'Get notified' and the Link carries a matching aria-label. The
category listbox closes on Escape and returns focus to its trigger (also on
option select) — dismissing it used to drop focus to <body>.
- about: the copy sent readers to 'a feedback button in the navbar' that
CompanionShell does not render; it now links the GitHub issue tracker.
- team/wiki: add the alternates.canonical their siblings already had.
- careers/[slug]: only append the meta-description ellipsis when slice(0,155)
actually truncated, so short blurbs stop reading as cut off.
- wiki: move scrollMarginTop from the H2 to the <section> elements — the TOC
fragments target the sections, so anchors landed under the sticky header.
Drop cssColor(): companionContent.ts now stores bare hex.
- faq: unique panel ids + aria-controls, and 'visibility' in the collapsed
style so collapsed answers are neither focusable nor announced.
- careers/CareersList: same collapsed-panel fix, so 'Apply for this role' is
not tabbable while closed; both raw <img> replaced with next/image.
- CompanionShell: wrap the header link group and the footer link row in
labelled <nav> landmarks. The compact-nav media query was scoped to
.landing-dc, which this shell must never wear, so below 1180px the
overflowing tab row stayed and the disclosure stayed hidden — unscoped it.
- page.tsx: read ?signin=1 too. CompanionShell's header 'Sign In' links to
/?signin=1 and nothing read it, so that control did nothing at all.
- public/journal-*.png: 1.29 MB -> 432 KB (-67.8% / -64.6%) via libimagequant
256-colour quantisation with Floyd-Steinberg dither (same engine pngquant
uses; RMSE 2.0 and 2.8 of 255). A fully lossless re-encode was tried first
and was larger than the originals. Filenames and paths unchanged.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Blocker

  • Both CI jobs died at the lint step, before tsc / pytest / vitest ever ran — so none of this port had been typechecked or tested. Both base failures fixed properly: the unused pydantic.Field import, and GradescopeConnect.tsx's exhaustive-deps (via useCallback keyed on userId, which also fixes a real bug the empty dep array hid — switching user kept the previous account's state).

Major

  • ?error=session_expired never reached the modal.middleware.ts sets it and marketing/SignInModal still shipped the whole ERROR_COPY surface, all dead. Now read and threaded through, opening the modal on an inbound code and stripping the param on dismiss.
  • Staging advertised production canonical/OG URLsresolveSiteUrl fell through to prod because [env.staging.vars] set neither knob. DEPLOY_ENV = "staging" added, which also arms the existing build-time guard.
  • deployGuard.classify now normalises trailing slashes, so a valid https://api.saplinglearn.com/ no longer bypasses the runtime guard; detectHostConfigMismatch is wired into middleware.ts so it actually defends something.
  • The orphaned components/SignInModal.tsx (zero importers, 67-line drift from its replacement) is deleted.

CodeRabbit majors

onLearn opened the notetaker instead of the tutor (now via the new galIndexOf lookup, not a magic index) · OAuth URLs kept same-origin · DARK_SECTIONS reduced to the one genuinely dark section · Escape now exits explore mode (was a keyboard trap with body { overflow: hidden }) · sim.destroy() re-homes clusters so a StrictMode remount no longer kills the drag field permanently · hero-canvas unmount race · CardsDemo timer leak, global key capture and keyboard-inoperable flip · Gallery cards keyboard-openable · BetaModal single-column below the breakpoint + type="email" · hidden nav/pills removed from the tab order.

Minor / nits

Dead .landing-page CSS removed · stylelint font-family-name-quotes 5→0 and keyframes-name-pattern 42→0 · lab-demo correctness (clamped percentages, partner snapshot, concept count, invalid guide picks) · ARIA roles and labels across FAQ/careers/calendar/quiz/rooms · localStorage input validated · journal PNGs recompressed 1.32 MB → 442 KB.

Still open (deliberately not done)

This branch is a stale snapshot of a landing already merged on main (#524) — promoting it is an add/add conflict across ~50 files, and startHeroRig's third parameter changed meaning while keeping an identical () => number type, so a file-by-file conflict resolution compiles cleanly and silently breaks the hero. That is a merge-strategy decision, not a code fix.

Verificationruff check . clean · tsc clean · eslint 0 errors (base had 1) · 184 frontend tests pass

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

This branch now carries a backend change, via #565

staging picked up the Gradescope repair in #565, so this PR is no longer frontend-only. That fix restored routes/gradescope.py and services/gradescope_service.py from main because the version on staging was unreachable (router prefix declared twice → every endpoint served at /api/gradescope/api/gradescope/...), called a DB client that does not exist (.eq().execute() on a PostgREST helper), and had no rate limiting or bu-sso.

I checked statically that every table the restored route touches exists on staging — gradescope_credentials, gradescope_course_links, assignments, gradebook_categories — so there is no missing-table hazard. But that is a check of the repo's migration files, not of the staging database's actual ledger. Please confirm on the real database:

SELECT table_name FROMinformation_schema.tablesWHERE table_schema ='public'AND table_name IN ('gradescope_credentials','gradescope_course_links','gradescope_links');

All three should be present. Then exercise one endpoint end to end and confirm it resolves at a single/api/gradescope/... prefix rather than 404ing.

Two things #565 deliberately left behind

SELECTcount(*) FROM gradescope_links;

gradescope_links (created by staging's 0033_add_gradescope_links.sql) now has no reader — the restored route uses gradescope_course_links. services/gradescope_sync_service.py has zero importers, and models/gradescope.py is imported only by that dead module. Nothing breaks, but if the table holds rows they are stranded, and the two modules are dead code worth removing in a follow-up.

Ledger divergence between staging and main

Staging carries two migrations main does not, at numeric prefixes main reuses for different files:

prefixstagingmain
00320032_add_gradescope_credentials.sql0032_rooms_missing_columns.sql, 0032_retire_summer_2026.sql
00330033_add_gradescope_links.sql0033_offering_section_not_null.sql, 0033_realtime_publish_room_messages.sql

schema_migrations keys on the full basename, so these coexist without a primary-key collision — but main has 23 migrations staging lacks, and staging is 239 commits behind. Whenever these branches are reconciled, both migration sets apply and the relative ordering within 0032/0033 is alphabetical by accident rather than by design. Worth settling deliberately rather than discovering it during a promotion.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

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.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, '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 the v5 marketing landing onto staging by Darkest-Teddy · Pull Request #535 · SaplingLearn/Sapling · GitHub
Skip to content

feat(landing): port the v5 marketing landing onto staging - #535

Open
Darkest-Teddy wants to merge 5 commits into
stagingfrom
feat/landing-v5-on-staging
Open

feat(landing): port the v5 marketing landing onto staging#535
Darkest-Teddy wants to merge 5 commits into
stagingfrom
feat/landing-v5-on-staging

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Puts the v5 marketing landing on stagingwithout merging feat/landing-v5-port.

Why not just merge the v5 branch

feat/landing-v5-port is 217 commits ahead of origin/staging and the diff is mostly not the landing page — 179 backend test files, 31 agents, 27 services, 17 routes, and 15 new migrations, including two colliding 0032_* and two colliding 0033_* basenames. It also isn't a fast-forward: staging has one commit that branch lacks (82787ca, gradescope). None of that is needed to show the new landing, so this ports the public surface only — no backend changes, no migrations.

What's in here

  • components/landing-v5/** and lib/landing/** — the landing engine, hero3d, drag simulation, and the Act sections
  • The (public) route group: the new /, plus /team, /wiki, /gallery, /news, /faq — the v5 navbar links to these and staging didn't have them
  • components/marketing/** and components/companion/CompanionShell
  • globals.css and app/layout.tsx (OG/metadata), lib/deployGuard.tswith its matching test, lib/errorMessage.ts
  • api.ts gains only subscribeToNewsletter — staging's backend already mounts /api/newsletter

One deliberate divergence from upstream

marketing/SignInModal calls the 3-argsetActiveUser. Upstream passes a 4th { persist } option that exists only on that branch's UserContext, whose wider change (+127/−21) also adds app-wide session recovery and shell-route redirects. Changing auth behaviour for every screen is out of scope for a landing port, so the option is dropped here and should come back in the same commit that brings that UserContext over. The call site carries the same note.

eslint-suppressions.json gains an entry for the new components/marketing/SignInModal.tsx path; staging only suppressed that rule at the old components/SignInModal.tsx path.

Verification

  • tsc --noEmit — clean
  • next build with staging's build vars (DEPLOY_ENV/BACKEND_URL/NEXT_PUBLIC_API_URL/COOKIE_DOMAIN) — succeeds, emitting /team, /wiki, /gallery, /news, /faq
  • eslint — clean except one pre-existing error: react-hooks/exhaustive-deps in settings/GradescopeConnect.tsx:65, which arrives untouched from origin/staging (it is neither modified nor suppressed on that branch). CI will likely go red on it until it's fixed separately — deliberately not touched here.
  • vitest was not run locally — it needs Node 22 and this machine is on 20. CI runs it on Node 22.

Deploy note

Per wrangler.toml and ADR 0020, the staging Workers Build must set DEPLOY_ENV=staging as a build variable, not just a runtime var — the /api rewrite and NEXT_PUBLIC_* are baked at build time. That omission is what previously put prod config on the staging worker.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added public About, Team, Wiki, Gallery, News, FAQ, and Careers pages.
    • Introduced interactive galleries, article search and filtering, FAQ accordions, career listings, and rich knowledge-base content.
    • Redesigned the landing experience with interactive learning demonstrations, knowledge graphs, tutor modes, quizzes, flashcards, study tools, and beta signup.
    • Added Google sign-in and newsletter subscription flows.
  • Improvements

    • Standardized navigation, page layouts, accessibility, responsive styling, animations, and site metadata.
    • Improved error messaging and deployment-aware canonical links.

Brings the v5 public site to `staging` WITHOUT the rest of
`feat/landing-v5-port`: that branch is 217 commits ahead of staging and
carries backend routes, services, agents and 15 migrations (including two
colliding 0032_* and two 0033_* basenames). None of that is needed to show
the new landing, so this ports the public surface only.
What's here:
- components/landing-v5/** + lib/landing/** (the landing engine, hero3d,
drag simulation and the Act sections)
- the (public) route group: the new / plus /team, /wiki, /gallery, /news
and /faq, which the v5 navbar links to and staging did not have
- components/marketing/** and components/companion/CompanionShell
- globals.css and app/layout.tsx (OG/metadata), lib/deployGuard.ts with
its matching test, lib/errorMessage.ts
- api.ts gains only `subscribeToNewsletter`; staging's backend already
mounts /api/newsletter
One deliberate divergence from upstream: marketing/SignInModal calls the
3-arg `setActiveUser`. Upstream passes a 4th `{ persist }` option that only
exists on that branch's UserContext, whose wider change also adds app-wide
session recovery and shell-route redirects. Changing auth for every screen
is out of scope for a landing port, so the option is dropped here and should
return with that UserContext. The call site carries the same note.
eslint-suppressions.json gains an entry for the new
components/marketing/SignInModal.tsx path; staging only suppressed the rule
at the old components/SignInModal.tsx path.
Verified: tsc --noEmit clean, and `next build` succeeds with staging's build
vars (DEPLOY_ENV/BACKEND_URL/NEXT_PUBLIC_API_URL/COOKIE_DOMAIN), emitting
/team, /wiki, /gallery, /news and /faq. eslint is clean except for a
pre-existing react-hooks/exhaustive-deps error in
settings/GradescopeConnect.tsx, which arrives untouched from origin/staging.
vitest was not run locally (needs Node 22; this machine is on 20) - CI runs it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 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 ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:37 seconds

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 101dbfe3-a2c2-4287-842f-6e2c86a82b2e

📥 Commits

Reviewing files that changed from the base of the PR and between 395588d and 46f7930.

⛔ Files ignored due to path filters (2)
  • frontend/public/journal-ai-homework.png is excluded by !**/*.png
  • frontend/public/journal-founding.png is excluded by !**/*.png
📒 Files selected for processing (57)
  • docs/frontend-testids.md
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/package.json
  • frontend/src/app/(public)/about/page.tsx
  • frontend/src/app/(public)/careers/CareersList.tsx
  • frontend/src/app/(public)/careers/[slug]/page.tsx
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/layout.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/ActGraph.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/ActTutor.tsx
  • frontend/src/components/landing-v5/BetaModal.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/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/landing-v5-interaction.test.tsx
  • frontend/src/components/landing-v5/navTheme.ts
  • frontend/src/components/landing/useLanding.ts
  • frontend/src/components/marketing/HeroCard.test.tsx
  • frontend/src/components/marketing/SignInModal.tsx
  • frontend/src/lib/deployGuard.test.ts
  • frontend/src/lib/deployGuard.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/dom.ts
  • frontend/src/lib/landing/engine/flip.ts
  • frontend/src/lib/landing/engine/index.ts
  • frontend/src/lib/landing/engine/marquee.ts
  • frontend/src/lib/landing/engine/plant.ts
  • frontend/src/lib/landing/engine/sim.test.ts
  • frontend/src/lib/landing/engine/sim.ts
  • frontend/src/middleware.ts
  • frontend/wrangler.toml
📝 Walkthrough

Walkthrough

The PR replaces the landing page with Landing v5, adds companion and careers pages, introduces shared public layouts and content, expands deployment metadata, and adds OAuth, newsletter, error-handling, CSS, and interaction infrastructure.

Changes

Landing v5 experience

Layer / File(s)Summary
Landing contracts and content
frontend/src/lib/landing/*
Adds typed landing content, course graphs, drag clusters, lab fixtures, color utilities, and navigation themes.
Landing sections and demos
frontend/src/components/landing-v5/*
Adds the v5 hero, graph, ingest, tutor, gallery, FAQ, journal, CTA, navigation, modal, and feature-lab components.
Landing runtime and composition
frontend/src/components/landing/useLanding.ts, frontend/src/lib/landing/engine/*, frontend/src/app/(public)/page.tsx
Connects React state to scroll, canvas, WebGL, graph, marquee, FLIP, planting, newsletter, and drag-field behavior.
Landing styles and tests
frontend/src/app/globals.css, frontend/src/components/landing-v5/*.test.tsx, frontend/src/lib/landing/engine/sim.test.ts
Adds responsive landing layouts, finite animations, and interaction regression coverage.

Companion and public pages

Layer / File(s)Summary
Shared companion shell and content
frontend/src/components/companion/CompanionShell.tsx, frontend/src/lib/landing/companionContent.ts
Adds shared navigation, footer links, and typed content collections.
Companion pages
frontend/src/app/(public)/about/*, team/*, faq/*, gallery/*, news/*, wiki/*
Adds shared-shell pages with data-driven content, filters, accordions, navigation, and metadata.
Careers routes
frontend/src/app/(public)/careers/*
Moves interactive job listings into CareersList, adds route metadata, and returns not-found responses for invalid slugs.

Platform and marketing infrastructure

Layer / File(s)Summary
Deployment and metadata
frontend/src/lib/deployGuard.ts, frontend/src/lib/deployGuard.test.ts, frontend/src/app/layout.tsx, frontend/src/app/(public)/privacy/page.tsx, frontend/src/app/(public)/terms/page.tsx
Adds environment resolution, canonical site URLs, host mismatch detection, structured application metadata, and legal-page metadata.
Authentication, newsletter, and marketing UI
frontend/src/components/marketing/SignInModal.tsx, frontend/src/components/marketing/HeroCard.tsx, frontend/src/lib/api.ts, frontend/src/lib/errorMessage.ts
Adds OAuth popup handling, accessible modal behavior, reusable hero-card rendering, newsletter subscription requests, and defensive error normalization.

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

Sequence Diagram(s)

sequenceDiagram
participant Visitor
participant LandingPage
participant useLanding
participant LandingEngine
participant CanvasAndWebGL
Visitor->>LandingPage: open landing page
LandingPage->>useLanding: initialize refs and state
useLanding->>LandingEngine: start landing lifecycle
LandingEngine->>CanvasAndWebGL: render hero and graph visuals
Visitor->>LandingPage: explore graph or submit newsletter
LandingPage->>useLanding: dispatch interaction
useLanding->>LandingEngine: update interaction state
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 26.42% 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 main change: porting the v5 marketing landing to staging.
Description check✅ PassedThe description covers scope, rationale, changes, testing limits, and deployment requirements, with only minor template omissions.
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 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/landing-v5-on-staging

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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 12, 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-staging46f7930Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:19 PM

@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: 11

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/lib/deployGuard.ts (1)

39-46: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Normalize trailing slashes before environment classification.

A canonical API URL with a trailing slash classifies as unknown. Then detectHostConfigMismatch returns null for a staging host wired to https://api.saplinglearn.com/. This bypasses the runtime guard for a valid URL spelling.

Normalize URL fields before comparison. Add regression tests for trailing-slash API and site URLs.

Proposed fix
 function classify(
value: string | undefined,
field: 'apiUrl' | 'cookieDomain' | 'siteUrl',
): FrontendEnv | null {
- const v = (value ?? '').trim().toLowerCase();+ const v = (value ?? '')+ .trim()+ .toLowerCase()+ .replace(field === 'cookieDomain' ? /$/ : /\/+$/, '');
if (!v) return null;
for (const env of Object.keys(FRONTEND_ENVS) as FrontendEnv[]) {
if (FRONTEND_ENVS[env][field].toLowerCase() === v) return env;
🤖 Prompt for AI Agents
Verify each 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/deployGuard.ts` around lines 39 - 46, Update classify to
normalize URL values by removing trailing slashes before comparing against
FRONTEND_ENVS fields, while preserving existing trimming and case normalization.
Ensure both API and site URL classifications recognize trailing-slash variants,
and add regression coverage for trailing-slash API and site URLs.
🟡 Minor comments (24)
frontend/src/app/(public)/news/page.tsx-175-199 (1)

175-199: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The card link text does not match its destination.

Each card is a <Link href="/#newsletter"> and its call to action reads "Read article →". A reader who selects a headline arrives at the newsletter signup on the home page, not at the post. All six cards lead to the same anchor.

Change the call to action to state the real destination, for example "Get notified →", until article 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/app/`(public)/news/page.tsx around lines 175 - 199, The news
cards’ CTA text incorrectly promises article navigation while each Link in the
posts.map block targets /#newsletter. Update the CTA span text from “Read
article →” to wording that reflects newsletter signup, such as “Get notified →”,
while preserving the existing link destination and styling.
frontend/src/app/(public)/about/page.tsx-80-84 (1)

80-84: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update the copy that points to a feedback button in the navbar.

The paragraph tells the reader to use "a feedback button in the navbar". The ported chrome in frontend/src/components/companion/CompanionShell.tsx renders Home, About, Team, Wiki, Gallery, News, FAQ, GitHub, Ko-fi, Sign In, and Get Started. It has no feedback control. Point the reader at a control that exists, for example the GitHub link or a contact address.

🤖 Prompt for AI Agents
Verify each 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)/about/page.tsx around lines 80 - 84, Update the
paragraph near the prose content to remove the nonexistent navbar
feedback-button reference, and direct readers to an available control such as
the GitHub link or a contact address instead. Preserve the surrounding message
about reporting issues and sharing ideas.
frontend/src/app/(public)/team/page.tsx-18-22 (1)

18-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Two companion routes omit alternates.canonical. The port sets a canonical URL on frontend/src/app/(public)/about/page.tsx, frontend/src/app/(public)/careers/page.tsx, and frontend/src/app/(public)/careers/[slug]/page.tsx, but not on these two routes. Without a canonical URL, these pages can be indexed under query-string or alternate-host variants.

  • frontend/src/app/(public)/team/page.tsx#L18-L22: add alternates: { canonical: '/team' } to the metadata export.
  • frontend/src/app/(public)/wiki/page.tsx#L18-L22: add alternates: { canonical: '/wiki' } to the metadata export.
🤖 Prompt for AI Agents
Verify each 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)/team/page.tsx around lines 18 - 22, Update the
metadata exports in frontend/src/app/(public)/team/page.tsx lines 18-22 and
frontend/src/app/(public)/wiki/page.tsx lines 18-22 to include
alternates.canonical, using /team for the team route and /wiki for the wiki
route.
frontend/src/app/(public)/careers/[slug]/page.tsx-18-22 (1)

18-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The meta description appends an ellipsis even when it truncates nothing.

Line 20 always appends after job.description.slice(0, 155). If a description is shorter than 155 characters, the full sentence still ends with a truncation marker. Search results then show a complete sentence that looks cut off.

Append the ellipsis only when the slice removed text.

🐛 Proposed fix
+ const blurb =+ job.description.length > 155 ? `${job.description.slice(0, 155)}…` : job.description;
return {
title: `${job.title} — Careers`,
- description: `${job.department} · ${job.location} · ${job.type}. ${job.description.slice(0, 155)}…`,+ description: `${job.department} · ${job.location} · ${job.type}. ${blurb}`,
alternates: { canonical: `/careers/${job.slug}` },
};
🤖 Prompt for AI Agents
Verify each 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)/careers/[slug]/page.tsx around lines 18 - 22,
Update the metadata construction in the careers page’s generateMetadata flow so
the description appends “…” only when job.description exceeds 155 characters;
leave shorter or exactly 155-character descriptions without a truncation marker
while preserving the existing 155-character slice.
frontend/src/app/(public)/wiki/page.tsx-28-31 (1)

28-31: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

scrollMarginTop is applied to the wrong element.

The H2 style sets scrollMarginTop: 84. The TOC links on line 67 target the fragments #graph, #mastery, #review, #tutor, #ingestion, #grades, and #privacy. Those ids are on the <section> elements, not on the <h2> elements. The browser applies scroll margin to the element it scrolls into view, so the section top lands under the sticky 92px header from CompanionShell and the heading is hidden.

Move the offset to the sections.

🐛 Proposed fix
 const H2: React.CSSProperties = {
margin: 0, fontFamily: DISPLAY, fontWeight: 500, fontSize: 26,
- lineHeight: 1.2, letterSpacing: '-0.015em', scrollMarginTop: 84,+ lineHeight: 1.2, letterSpacing: '-0.015em',
};
+/** Clears the sticky companion header after an in-page anchor jump. */+const SECTION: React.CSSProperties = { scrollMarginTop: 104 };

Then apply it to each section, for example:

- <section id="graph">+ <section id="graph" style={SECTION}>
🤖 Prompt for AI Agents
Verify each 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 28 - 31, Move
scrollMarginTop: 84 from the H2 style to the section styling used by the
sections targeted by the TOC fragment links (`#graph`, `#mastery`, `#review`, `#tutor`,
`#ingestion`, `#grades`, and `#privacy`). Keep the heading typography unchanged and
ensure each target section receives the offset so it clears the sticky header.
frontend/src/components/companion/CompanionShell.tsx-86-108 (1)

86-108: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Apply the responsive nav rules to CompanionShell. The media query is scoped to .landing-dc, but the CompanionShell root does not have that class. The rules do not match, despite using !important, so the compact nav remains hidden and the tab row remains visible below 1180px.

🤖 Prompt for AI Agents
Verify each 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/companion/CompanionShell.tsx` around lines 86 - 108,
Update the CompanionShell root element to include the landing-dc class so the
existing responsive media-query rules apply to its nav-compact and nav-tabs
elements. Preserve the current navigation structure and styling.

Source: Linters/SAST tools

frontend/src/lib/landing/engine/dom.ts-49-73 (1)

49-73: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

A layout measurement taken before layout is ready is cached permanently. Both sites store a measured size on first read and re-measure only when a resize signal arrives. If the first read happens before the element has its final box, the wrong value is kept for the rest of the session. Reject an implausible measurement instead of caching it.

  • frontend/src/lib/landing/engine/dom.ts#L49-L73: do not assign rec.__cv when w or h is 0. invalidateCanvas() runs only on a viewport size change, so a zero-size cache leaves the canvas blank.
  • frontend/src/lib/landing/engine/marquee.ts#L136-L144: do not accept m.setW when the summed child offsetWidth is 0. The re-measure trigger is a wrap.clientWidth change, so a gap-only width leaves the track wrapping over a few pixels.
🤖 Prompt for AI Agents
Verify each 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, Reject
zero-sized measurements before caching them: in
frontend/src/lib/landing/engine/dom.ts lines 49-73, ensure cv does not assign
rec.__cv when w or h is 0; in frontend/src/lib/landing/engine/marquee.ts lines
136-144, ensure the marquee measurement does not call m.setW when the summed
child offsetWidth is 0. Preserve normal caching and measurement behavior for
positive dimensions.
frontend/src/lib/landing/engine/sim.ts-330-369 (1)

330-369: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard the SVG structure assumptions during build.

Three unchecked assumptions read the authored SVG:

  • Line 332: ring.previousElementSibling as SVGCircleElement. If the ring is the first child, glow is null. Line 839 then calls p.glow.setAttribute on every frame and throws a TypeError.
  • Line 333 and 345: label is dereferenced immediately by label.getAttribute('y'). A missing next sibling throws during ensureInit().
  • Line 362-367: near() returns -1 when the cluster has line elements but no [data-sim] rings. nodes[b] is then undefined and line 367 throws.

holdArms() at line 653 and stepGroup() at line 752 already guard with if (!s0 || !t0) continue;, so missing nodes are anticipated later in the pipeline but not at build time. Skip a ring or a line when its expected siblings are absent.

🤖 Prompt for AI Agents
Verify each 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 330 - 369, Update the
SVG build logic in the rings and lines loops to validate required structure
before creating simulation entries: skip rings when the previous glow element or
next label element is missing, and skip lines when either endpoint lookup in
near() returns -1. Keep valid ring and line processing unchanged, ensuring no
nodes or links are created with null siblings or invalid endpoint indices.
frontend/src/lib/landing/engine/marquee.ts-69-114 (1)

69-114: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Bind pointerup and pointercancel on window as well.

setPointerCapture is called inside a try/catch at line 80. If the capture fails, later pointer events are not retargeted to track. A drag that ends outside track then never runs end(). m.drag stays non-null, so update() skips both drift and momentum for that track and the cursor stays grabbing until the next pointerdown.

frontend/src/lib/landing/engine/sim.ts lines 507-510 already binds the release handlers on both the element and window for this reason. Apply the same pattern here.

🛡️ Proposed fix
 track.addEventListener('pointerdown', onDown);
track.addEventListener('pointermove', onMove);
track.addEventListener('pointerup', onUp);
track.addEventListener('pointercancel', onCancel);
+ window.addEventListener('pointerup', onUp);+ window.addEventListener('pointercancel', onCancel);
cleanups.push(() => {
wrap?.removeEventListener('pointerenter', onEnter);
wrap?.removeEventListener('pointerleave', onLeave);
track.removeEventListener('pointerdown', onDown);
track.removeEventListener('pointermove', onMove);
track.removeEventListener('pointerup', onUp);
track.removeEventListener('pointercancel', onCancel);
+ window.removeEventListener('pointerup', onUp);+ window.removeEventListener('pointercancel', onCancel);
STATE.delete(track);
});
🤖 Prompt for AI Agents
Verify each 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 69 - 114, Bind the
existing onUp and onCancel release handlers to window in addition to track,
matching the dual registration pattern used by sim.ts. Keep the current track
listeners and end() behavior unchanged so drags are finalized even when pointer
capture fails or the pointer leaves the track.
frontend/src/components/landing-v5/BetaModal.tsx-244-260 (1)

244-260: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use type="email" for the email field.

The field is labelled "Email address" and validated as an email, but it is declared type="text". That suppresses the email keyboard on mobile and blocks browser autofill. The manual check at lines 86-96 continues to work unchanged.

🔧 Proposed fix
 ref={inputRef}
- type="text"+ type="email"+ autoComplete="email"+ inputMode="email"
aria-label="Email address"
🤖 Prompt for AI Agents
Verify each 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 244 - 260,
Update the email input in BetaModal’s form to use type="email" instead of
type="text", while preserving its existing validation, value handling, and other
attributes.
frontend/src/components/landing/useLanding.ts-593-597 (1)

593-597: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report invalid input instead of returning silently.

If value has no @, the function returns without setting subscribeError and without any other state change. The user presses the button and sees no response. An empty non-required type="email" field passes browser validation and reaches this branch.

🐛 Proposed fix
 const value = email.trim();
- if (!value.includes('@') || subscribing) return;+ if (subscribing) return;+ if (!value.includes('@')) {+ setSubscribeError('Enter a valid email address.');+ return;+ }
🤖 Prompt for AI Agents
Verify each 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 593 - 597, Update
subscribeNow so invalid email input, including an empty trimmed value or a value
without "@", sets subscribeError to the existing user-facing validation message
before returning. Keep the subscribing guard and valid-input flow unchanged,
ensuring the user receives feedback instead of a silent no-op.
frontend/src/components/landing-v5/Navbar.tsx-74-79 (1)

74-79: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The navbar stays interactive while it is invisible.

While heroMounted is false, opacity is 0 but pointerEvents remains 'auto'. For the first ~1.9s of the intro, every navbar control is focusable and clickable while nothing is visible.

♿ Proposed fix
 opacity: exploring ? 0 : heroMounted ? 1 : 0,
- pointerEvents: exploring ? 'none' : 'auto',+ pointerEvents: exploring || !heroMounted ? 'none' : 'auto',+ visibility: heroMounted ? 'visible' : 'hidden',
🤖 Prompt for AI Agents
Verify each 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 74 - 79, Update
the navbar style logic in the visible component so pointerEvents is set to
'none' whenever the navbar is invisible, including while heroMounted is false
and while exploring is true; restore 'auto' only when the navbar is visible and
interactive.
frontend/src/app/(public)/page.tsx-91-100 (1)

91-100: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear the subscription error when the modal closes.

state.subscribeError is shared by BetaModal and Journal and is never reset. After a failed attempt, the message stays visible in the Journal form and reappears the next time the modal opens. useLanding already exposes actions.resetSubscribeError for this, and it is currently unused.

🐛 Proposed fix
- onClose={() => setBetaOpen(false)}+ onClose={() => { actions.resetSubscribeError(); setBetaOpen(false); }}
🤖 Prompt for AI Agents
Verify each 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 91 - 100, Update the
BetaModal onClose handler to call actions.resetSubscribeError before closing the
modal with setBetaOpen(false), ensuring the shared subscription error is cleared
when the modal closes.
frontend/src/app/globals.css-1563-1569 (1)

1563-1569: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stylelint fails on the quoted Spectral family name.

font-family-name-quotes reports four errors: Lines 1565, 1713, 1784, and 1836. Spectral is a single identifier, so it needs no quotes.

🔧 Proposed fix (apply to all four sites)
- font-family: var(--font-spectral), 'Spectral', Georgia, serif;+ font-family: var(--font-spectral), Spectral, Georgia, serif;

As per static analysis hints, font-family-name-quotes is enabled for this file.

Also applies to: 1713-1713, 1784-1784, 1836-1836

🤖 Prompt for AI Agents
Verify each 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 1563 - 1569, Remove the
unnecessary quotes around the single-identifier Spectral font family name in all
four font-family declarations, including the declarations associated with the
visible landing-page style and the other reported sites, so they satisfy
font-family-name-quotes.

Source: Linters/SAST tools

frontend/src/components/landing-v5/lab/RoomsDemo.tsx-42-54 (1)

42-54: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The typing indicator and the delayed reply can name different partners.

send captures partner in the timeout closure. The indicator at Line 127 reads the live partner. If the user changes the select during the 1600 ms delay, the indicator shows the new partner and the message arrives from the old one.

♻️ Proposed fix
 const [partner, setPartner] = useState<Partner>('maya');
+ const partnerRef = useRef<Partner>('maya');+ partnerRef.current = partner;
 typeT.current = setTimeout(() => {
- const reply = `my ${partner.charAt(0).toUpperCase() + partner.slice(1)} graph has eigenvalues green — want me to quiz you on it?`;+ const who = partnerRef.current;+ const reply = `my ${who.charAt(0).toUpperCase() + who.slice(1)} graph has eigenvalues green — want me to quiz you on it?`;
setTyping(false);
- setMsgs((m) => [...m, { who: partner, text: reply, mine: false }]);+ setMsgs((m) => [...m, { who, text: reply, mine: false }]);
}, 1600);

Also applies to: 125-132

🤖 Prompt for AI Agents
Verify each 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/RoomsDemo.tsx` around lines 42 - 54,
Update send so the delayed reply and typing indicator use the same partner
snapshot captured when the message is sent. Store that snapshot in the relevant
state or timeout flow, and ensure the indicator rendering near the partner
display uses it until the delayed reply completes; preserve the existing
behavior when the partner does not change.
frontend/src/app/globals.css-1950-1957 (1)

1950-1957: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stylelint reports 45 keyframes-name-pattern errors in the ported blocks.

The ported keyframe names are camelCase, for example s1Stem, popHold, heroRise, and labIn. The rule expects kebab-case. Renaming is not safe here, because the names are referenced from inline animation strings in the landing components and in labData-driven markup.

Scope a rule disable to the ported region instead, so the rest of the file keeps the check.

🔧 Proposed approach
+/* stylelint-disable keyframes-name-pattern -- names are ported verbatim from the+ design components and are referenced by inline `animation` strings in TSX. */
`@keyframes` s1Stem { 0%,15% { stroke-dashoffset:27; } 34%,100% { stroke-dashoffset:0; } }

Close the block with /* stylelint-enable keyframes-name-pattern */ after the last ported keyframe.

As per static analysis hints, keyframes-name-pattern is enabled for this file.

Also applies to: 2094-2096

🤖 Prompt for AI Agents
Verify each 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 1950 - 1957, Scope-disable
Stylelint’s keyframes-name-pattern rule immediately before the ported keyframe
region containing names such as s1Stem, popHold, heroRise, and labIn, then
re-enable it after the final ported keyframe. Keep the rule active for all other
sections of globals.css and do not rename the animation identifiers.

Source: Linters/SAST tools

frontend/src/lib/landing/course.ts-363-376 (1)

363-376: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle the singular case for degree.

If degree is 1, the text reads "its 1 neighbours". A sub-topic linked only to its parent produces that value.

✏️ Proposed fix
- ' pulls this node plus its ' +- degree +- ' neighbours into context, then retrieves the chunks of your ' ++ ' pulls this node plus its ' ++ degree ++ (degree === 1 ? ' neighbour' : ' neighbours') ++ ' into context, then retrieves the chunks of your ' +
🤖 Prompt for AI Agents
Verify each 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/course.ts` around lines 363 - 376, Update nodeUses
so the text generated for degree uses singular “neighbour” when degree is 1 and
plural “neighbours” otherwise, preserving the existing wording and behavior for
all other values.
frontend/src/components/landing-v5/lab/CardsDemo.tsx-46-53 (1)

46-53: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard the window key listener against editable targets.

The listener runs for every keydown on the window. It calls e.preventDefault() for Space and rates the card for 1, 2, and 3. While this demo is mounted, a Space or a digit typed in any other input on the page is captured.

🛡️ Proposed fix
 const onKey = (e: KeyboardEvent) => {
+ const el = e.target as HTMLElement | null;+ if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) 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 around onKey to ignore events whose
target is an editable control, such as input, textarea, select, or
contenteditable elements, before handling Space or rate keys; preserve the
existing flip and rate behavior for non-editable targets.
frontend/src/components/landing-v5/lab/GradesDemo.tsx-62-65 (1)

62-65: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The row percentage is not clamped, but the weighted grade is.

Line 25 clamps each score to [0, r.possible]. Line 64 does not. If the user types 500 into a row worth 50, the row shows 1000% while the overall grade stays at the clamped value. Use the same clamp for both readouts.

🐛 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,
Update the row percentage calculation in the GB_ROWS.map callback to clamp the
parsed earned score to the range [0, r.possible] before computing the
percentage, matching the existing clamping used for the weighted grade and
keeping both readouts consistent.
frontend/src/components/landing-v5/lab/NotesDemo.tsx-57-66 (1)

57-66: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The "No concepts found" sentinel corrupts the footer count.

Line 63 pushes { name: 'No concepts found', tier: 'unexplored' } into the same concepts array that holds real matches. The footer at Lines 141-143 then reads concepts.length and prints 1 concepts linked to your MA 242 graph. after an empty extraction. The text is wrong, and the plural is wrong for a single match too.

Track the empty result separately instead of encoding it as a concept.

🐛 Proposed fix
- const [concepts, setConcepts] = useState<{ name: string; tier: keyof typeof LAB_TIER }[]>([]);+ const [concepts, setConcepts] = useState<{ name: string; tier: keyof typeof LAB_TIER }[]>([]);+ const [ran, setRan] = useState(false);
 const found = MATCHERS.filter((m) => m.hit.test(lower)).map((m) => ({ name: m.name, tier: m.tier }));
- setConcepts(found.length ? found : [{ name: 'No concepts found', tier: 'unexplored' }]);+ setConcepts(found);+ setRan(true);
setBusy(false);
- {concepts.length- ? `${concepts.length} concepts linked to your MA 242 graph.`- : 'Concepts you extract are linked to your graph, not just tagged on the note.'}+ {concepts.length+ ? `${concepts.length} concept${concepts.length === 1 ? '' : 's'} linked to your MA 242 graph.`+ : ran+ ? 'No concepts found in this note.'+ : 'Concepts you extract are linked to your graph, not just tagged on the note.'}

Also applies to: 140-144

🤖 Prompt for AI Agents
Verify each 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 - 66,
Update extract so an empty MATCHERS result is tracked separately rather than
added to concepts; keep concepts containing only real matches and render the
empty-state message independently. Adjust the footer rendering near the concepts
count to display the correct zero count and use singular/plural wording based on
the actual number of linked concepts.
frontend/src/components/landing-v5/Closing.tsx-154-182 (1)

154-182: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Composite ARIA roles are declared without their required child roles. Both sites put a composite role on a container and leave the children as plain <button> elements. ARIA requires owned children with the matching role, so assistive technology announces an empty widget and the selected item is not conveyed.

  • frontend/src/components/landing-v5/Closing.tsx#L154-L182: add role="menuitem" to each section button inside the role="menu" container.
  • frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124: add role="radio" and aria-checked={isPicked} to each option button inside the role="radiogroup" container.
🤖 Prompt for AI Agents
Verify each 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 154 - 182, Add
role="menuitem" to each section button rendered by SECTIONS.map in Closing.tsx
(frontend/src/components/landing-v5/Closing.tsx#L154-L182). In QuizDemo.tsx
(frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124), add
role="radio" and bind aria-checked to isPicked on each option button within the
radiogroup.
frontend/src/components/landing-v5/lab/CalendarDemo.tsx-86-112 (1)

86-112: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Blank leading cells are focusable empty buttons.

Every cell renders as a <button>, including the LEAD_BLANKS placeholders. The blank cells only set cursor: 'default', which does not remove them from the tab order. A keyboard user tabs through up to six empty buttons before reaching 1 October. The selected day also carries no aria-pressed, so the current selection is not announced.

♿ Proposed change
 <button
key={i}
onClick={() => { if (!c.blank) setDay(c.day as number); }}
type="button"
+ disabled={c.blank}+ aria-hidden={c.blank || undefined}+ aria-pressed={c.blank ? undefined : selected}
🤖 Prompt for AI Agents
Verify each 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/CalendarDemo.tsx` around lines 86 -
112, Update the day-cell buttons rendered by the shown.map callback so blank
placeholder cells are removed from keyboard focus, while retaining interactive
keyboard behavior for real days. Add aria-pressed to each non-blank day button
and set it according to the selected value so assistive technologies announce
the current selection.
frontend/src/components/landing-v5/Faq.tsx-71-76 (1)

71-76: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

"Read the full FAQ" links back to itself.

The anchor targets #faq, and the enclosing <section> already has id="faq" (Line 40). Clicking the link keeps the user on the same section, so the call to action does nothing.

This PR adds the /faq public route, and Closing.tsx Line 81 already links to it. Point this link at /faq.

🔗 Proposed change
- <a href="`#faq`" style={{ color: '`#0C5638`', textDecoration: 'underline' }}>Read the full FAQ</a>+ <Link href="/faq" style={{ color: '`#0C5638`', textDecoration: 'underline' }}>Read the full FAQ</Link>

Add the import:

 import { FAQS } from '`@/lib/landing/content`';
+import Link from 'next/link';
import { DragField } from './DragField';
🤖 Prompt for AI Agents
Verify each 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 71 - 76, Update the
“Read the full FAQ” anchor in the Faq component to navigate to the public /faq
route instead of the local `#faq` section, matching the existing link behavior in
Closing.tsx.
frontend/src/components/landing-v5/lab/GuideDemo.tsx-21-24 (1)

21-24: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Align RECENT.pick with the course exam lists. Neither Midterm 1 · Sep 30 nor Quiz 2 · Oct 03 exists in its course's GUIDES.exams. Opening either recent item sets an invalid select value while the guide heading displays that value.

🤖 Prompt for AI Agents
Verify each 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 in GuideDemo so each pick exactly matches an existing
exam value in the corresponding course’s GUIDES.exams list, preserving the
displayed recent-item behavior and ensuring the select value and guide heading
use valid options.
🤖 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/`(public)/page.tsx:
- Around line 130-131: Update the onLearn handler in the page component to open
the tutor panel at GAL index 7 instead of index 2, while leaving onQuiz
unchanged. Prefer the named GAL lookup or constant from content.ts rather than a
hard-coded index so card ordering changes cannot reintroduce this mismatch.
In `@frontend/src/components/landing-v5/ActTutor.tsx`:
- Around line 92-109: Update the tutor pills container in ActTutor to remove its
buttons from keyboard navigation while hidden by binding the engine’s docked
state to inert (or visibility). Preserve the existing opacity and pointerEvents
behavior, and ensure the pills become focusable again when docked is false.
In `@frontend/src/components/landing-v5/BetaModal.tsx`:
- Around line 132-143: Update the HeroCard responsive layout to use a single
column below the medium breakpoint and the existing two-column structure at
wider widths, using its CSS class rather than an unconditional
gridTemplateColumns style. Enable vertical scrolling for constrained viewports,
and apply the 560px minimum height only at the wider breakpoint so mobile
content, including the email field and submit button, remains accessible.
In `@frontend/src/components/landing-v5/Gallery.tsx`:
- Around line 62-85: Update the Card component so non-ghost cards are
keyboard-focusable and expose button-like semantics, with a key handler that
triggers the existing delegated Track click behavior for Enter and Space. Keep
ghost cards inert by omitting focusability, interactive semantics, and
activation handling, while preserving the current data-tk targeting.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 37-44: Update the rate callback to store its 300 ms timeout in a
ref instead of returning a cleanup function, and clear that timeout in an
unmount cleanup effect. Preserve the existing delayed setIdx behavior while
preventing it from firing after the component unmounts.
In `@frontend/src/components/landing-v5/navTheme.ts`:
- Around line 68-69: Update DARK_SECTIONS to contain only the section that
currently renders on a dark ground, act-graph, and revise its comment to
describe the single dark section accurately. Keep act-ingest and act-tutor out
of the list until their backgrounds are dark.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 218-229: Update the hero canvas effect around the lazy
`import('`@/lib/landing/engine/hero`')` to track whether cleanup has run, prevent
`startHeroCanvas` from starting after unmount, and stop it immediately if
initialization races with cancellation. Preserve the existing cleanup behavior
for normal mounted execution.
- Around line 277-282: Update the onKey handler in useLanding to handle Escape
while explore mode is active: after closing the gallery or jump menu as
appropriate, call exitExplore when s.current.exploring is true and neither
overlay was open. Ensure exitExplore is available at handler execution time
despite its later declaration, using the existing callback ordering or a ref as
needed.
In `@frontend/src/components/marketing/SignInModal.tsx`:
- Around line 130-153: Update the OAuth URL construction in the sign-in flow,
including sameTabUrl and popupUrl, to use the frontend-relative /api/auth/google
path instead of prepending API_URL. Preserve the existing popup_id query
parameter and same-tab fallback behavior so OAuth requests remain same-origin
and retain the session cookie.
In `@frontend/src/lib/landing/content.ts`:
- Around line 191-218: In frontend/src/lib/landing/content.ts lines 191-218,
mark the unavailable journal-founding.png and journal-ai-homework.png entries
with empty src values and isGraph: true so they use the graph fallback until
assets are shipped. In frontend/src/components/landing-v5/Journal.tsx lines
106-118, preserve the meta.isGraph branch that renders GraphThumb instead of
passing unusable paths to next/image.
In `@frontend/src/lib/landing/engine/sim.ts`:
- Around line 897-905: Update destroy() cleanup ordering so every cluster
element is re-parented to its original field before the cleanup that removes the
shell/overlay runs. Preserve the existing cleanup registration in ensureInit(),
but ensure repeated createSim() calls can still find the original
[data-dragnode] elements after destroy().
---
Outside diff comments:
In `@frontend/src/lib/deployGuard.ts`:
- Around line 39-46: Update classify to normalize URL values by removing
trailing slashes before comparing against FRONTEND_ENVS fields, while preserving
existing trimming and case normalization. Ensure both API and site URL
classifications recognize trailing-slash variants, and add regression coverage
for trailing-slash API and site URLs.
---
Minor comments:
In `@frontend/src/app/`(public)/about/page.tsx:
- Around line 80-84: Update the paragraph near the prose content to remove the
nonexistent navbar feedback-button reference, and direct readers to an available
control such as the GitHub link or a contact address instead. Preserve the
surrounding message about reporting issues and sharing ideas.
In `@frontend/src/app/`(public)/careers/[slug]/page.tsx:
- Around line 18-22: Update the metadata construction in the careers page’s
generateMetadata flow so the description appends “…” only when job.description
exceeds 155 characters; leave shorter or exactly 155-character descriptions
without a truncation marker while preserving the existing 155-character slice.
In `@frontend/src/app/`(public)/news/page.tsx:
- Around line 175-199: The news cards’ CTA text incorrectly promises article
navigation while each Link in the posts.map block targets /#newsletter. Update
the CTA span text from “Read article →” to wording that reflects newsletter
signup, such as “Get notified →”, while preserving the existing link destination
and styling.
In `@frontend/src/app/`(public)/page.tsx:
- Around line 91-100: Update the BetaModal onClose handler to call
actions.resetSubscribeError before closing the modal with setBetaOpen(false),
ensuring the shared subscription error is cleared when the modal closes.
In `@frontend/src/app/`(public)/team/page.tsx:
- Around line 18-22: Update the metadata exports in
frontend/src/app/(public)/team/page.tsx lines 18-22 and
frontend/src/app/(public)/wiki/page.tsx lines 18-22 to include
alternates.canonical, using /team for the team route and /wiki for the wiki
route.
In `@frontend/src/app/`(public)/wiki/page.tsx:
- Around line 28-31: Move scrollMarginTop: 84 from the H2 style to the section
styling used by the sections targeted by the TOC fragment links (`#graph`,
`#mastery`, `#review`, `#tutor`, `#ingestion`, `#grades`, and `#privacy`). Keep the heading
typography unchanged and ensure each target section receives the offset so it
clears the sticky header.
In `@frontend/src/app/globals.css`:
- Around line 1563-1569: Remove the unnecessary quotes around the
single-identifier Spectral font family name in all four font-family
declarations, including the declarations associated with the visible
landing-page style and the other reported sites, so they satisfy
font-family-name-quotes.
- Around line 1950-1957: Scope-disable Stylelint’s keyframes-name-pattern rule
immediately before the ported keyframe region containing names such as s1Stem,
popHold, heroRise, and labIn, then re-enable it after the final ported keyframe.
Keep the rule active for all other sections of globals.css and do not rename the
animation identifiers.
In `@frontend/src/components/companion/CompanionShell.tsx`:
- Around line 86-108: Update the CompanionShell root element to include the
landing-dc class so the existing responsive media-query rules apply to its
nav-compact and nav-tabs elements. Preserve the current navigation structure and
styling.
In `@frontend/src/components/landing-v5/BetaModal.tsx`:
- Around line 244-260: Update the email input in BetaModal’s form to use
type="email" instead of type="text", while preserving its existing validation,
value handling, and other attributes.
In `@frontend/src/components/landing-v5/Closing.tsx`:
- Around line 154-182: Add role="menuitem" to each section button rendered by
SECTIONS.map in Closing.tsx
(frontend/src/components/landing-v5/Closing.tsx#L154-L182). In QuizDemo.tsx
(frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124), add
role="radio" and bind aria-checked to isPicked on each option button within the
radiogroup.
In `@frontend/src/components/landing-v5/Faq.tsx`:
- Around line 71-76: Update the “Read the full FAQ” anchor in the Faq component
to navigate to the public /faq route instead of the local `#faq` section, matching
the existing link behavior in Closing.tsx.
In `@frontend/src/components/landing-v5/lab/CalendarDemo.tsx`:
- Around line 86-112: Update the day-cell buttons rendered by the shown.map
callback so blank placeholder cells are removed from keyboard focus, while
retaining interactive keyboard behavior for real days. Add aria-pressed to each
non-blank day button and set it according to the selected value so assistive
technologies announce the current selection.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 46-53: Update the keydown handler in the useEffect around onKey to
ignore events whose target is an editable control, such as input, textarea,
select, or contenteditable elements, before handling Space or rate keys;
preserve the existing flip and rate behavior for non-editable targets.
In `@frontend/src/components/landing-v5/lab/GradesDemo.tsx`:
- Around line 62-65: Update the row percentage calculation in the GB_ROWS.map
callback to clamp the parsed earned score to the range [0, r.possible] before
computing the percentage, matching the existing clamping used for the weighted
grade and keeping both readouts consistent.
In `@frontend/src/components/landing-v5/lab/GuideDemo.tsx`:
- Around line 21-24: Update the RECENT entries in GuideDemo so each pick exactly
matches an existing exam value in the corresponding course’s GUIDES.exams list,
preserving the displayed recent-item behavior and ensuring the select value and
guide heading use valid options.
In `@frontend/src/components/landing-v5/lab/NotesDemo.tsx`:
- Around line 57-66: Update extract so an empty MATCHERS result is tracked
separately rather than added to concepts; keep concepts containing only real
matches and render the empty-state message independently. Adjust the footer
rendering near the concepts count to display the correct zero count and use
singular/plural wording based on the actual number of linked concepts.
In `@frontend/src/components/landing-v5/lab/RoomsDemo.tsx`:
- Around line 42-54: Update send so the delayed reply and typing indicator use
the same partner snapshot captured when the message is sent. Store that snapshot
in the relevant state or timeout flow, and ensure the indicator rendering near
the partner display uses it until the delayed reply completes; preserve the
existing behavior when the partner does not change.
In `@frontend/src/components/landing-v5/Navbar.tsx`:
- Around line 74-79: Update the navbar style logic in the visible component so
pointerEvents is set to 'none' whenever the navbar is invisible, including while
heroMounted is false and while exploring is true; restore 'auto' only when the
navbar is visible and interactive.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 593-597: Update subscribeNow so invalid email input, including an
empty trimmed value or a value without "@", sets subscribeError to the existing
user-facing validation message before returning. Keep the subscribing guard and
valid-input flow unchanged, ensuring the user receives feedback instead of a
silent no-op.
In `@frontend/src/lib/landing/course.ts`:
- Around line 363-376: Update nodeUses so the text generated for degree uses
singular “neighbour” when degree is 1 and plural “neighbours” otherwise,
preserving the existing wording and behavior for all other values.
In `@frontend/src/lib/landing/engine/dom.ts`:
- Around line 49-73: Reject zero-sized measurements before caching them: in
frontend/src/lib/landing/engine/dom.ts lines 49-73, ensure cv does not assign
rec.__cv when w or h is 0; in frontend/src/lib/landing/engine/marquee.ts lines
136-144, ensure the marquee measurement does not call m.setW when the summed
child offsetWidth is 0. Preserve normal caching and measurement behavior for
positive dimensions.
In `@frontend/src/lib/landing/engine/marquee.ts`:
- Around line 69-114: Bind the existing onUp and onCancel release handlers to
window in addition to track, matching the dual registration pattern used by
sim.ts. Keep the current track listeners and end() behavior unchanged so drags
are finalized even when pointer capture fails or the pointer leaves the track.
In `@frontend/src/lib/landing/engine/sim.ts`:
- Around line 330-369: Update the SVG build logic in the rings and lines loops
to validate required structure before creating simulation entries: skip rings
when the previous glow element or next label element is missing, and skip lines
when either endpoint lookup in near() returns -1. Keep valid ring and line
processing unchanged, ensuring no nodes or links are created with null siblings
or invalid endpoint indices.
---
Nitpick comments:
In `@frontend/src/app/`(public)/careers/CareersList.tsx:
- Around line 217-284: Update the collapsible panel style in the job card
rendering to include visibility based on isOpen: visible when expanded and
hidden when collapsed. Add visibility to the existing transition so the state
changes with the panel animation while keeping the Apply for this role link and
other content inaccessible during collapse.
- Around line 51-55: Replace both raw img elements in CareersList with the
next/image component, matching the existing usage in CompanionShell for
/sapling-icon.svg. Preserve the current alt text and styling, and set the second
occurrence’s dimensions to width={20} and height={20} while retaining the first
occurrence’s 26×26 sizing.
In `@frontend/src/app/`(public)/faq/page.tsx:
- Around line 60-89: Update the FAQ answer panel associated with the button
using item.i: assign it a unique id, add matching aria-controls to the button,
and include visibility in the panel styles so it is hidden when collapsed and
visible when open. Preserve the existing max-height and opacity transitions.
In `@frontend/src/app/`(public)/news/page.tsx:
- Around line 111-153: Update the category menu logic around the existing
outside pointerdown listener to handle Escape while the menu is open, close it,
and return focus to the trigger button. Add a ref to the trigger button and
focus it both when Escape dismisses the menu and when selecting an option via
the NEWS_FILTERS buttons, while preserving the existing outside-click behavior.
In `@frontend/src/components/companion/CompanionShell.tsx`:
- Around line 84-141: Wrap the primary link group currently rendered by the
`nav-tabs` container in a `<nav>` landmark with a descriptive `aria-label`,
closing it after the GitHub and Ko-fi links. Also locate the header’s footer
link row and wrap it in a separate appropriately labelled `<nav>` element,
preserving the existing links and layout.
In `@frontend/src/components/landing-v5/ActIngest.tsx`:
- Around line 259-282: Add aria-hidden="true" to the decorative flying chip
elements rendered in the CHIPS.map callback within ActIngest, ensuring assistive
technologies ignore these duplicate labels while leaving their visual animation
and styling unchanged.
In `@frontend/src/components/landing-v5/ActTutor.tsx`:
- Around line 95-97: Add type="button" to the pill button rendered in ActTutor’s
button map alongside its existing key and onClick props, matching the explicit
button type used by the other buttons in this PR.
In `@frontend/src/components/landing-v5/Closing.tsx`:
- Line 92: Replace the hardcoded “© 2026” values in the Closing component with a
single dynamically derived current year, and reuse that value in both copyright
locations near the existing spans.
In `@frontend/src/components/landing-v5/DragField.tsx`:
- Around line 70-103: Update the label branch in the nodes mapping within
DragField’s render flow to always create a text element, including when label is
missing. Preserve the glow, ring, and label ordering and use an empty
placeholder for missing label content while retaining valid label positioning
and styling.
In `@frontend/src/components/landing-v5/Faq.tsx`:
- Around line 84-121: Update the FAQ trigger and answer panel so each button in
the mapped FAQ item has an aria-controls reference matching that panel’s unique
id. Add hidden or equivalent visibility handling to the panel when open is
false, while preserving the existing expanded state and visual transition
behavior.
In `@frontend/src/components/landing-v5/FeatureLab.tsx`:
- Around line 41-50: Add dialog semantics to the fixed overlay in FeatureLab by
assigning role="dialog", aria-modal="true", and an accessible name tied to the
panel heading or another existing label; implement focus movement to the close
button when open and restore focus when closed. In the tool rail, add an
explicit active-state attribute such as aria-current or aria-selected to the
open tool instead of relying on background color alone.
In `@frontend/src/components/landing-v5/Gallery.tsx`:
- Around line 87-109: Add an explicit accessible role to the div rendered by
Track so its existing aria-label is announced by assistive technology; use
role="group" without changing the current card rendering or interaction
behavior.
- Around line 71-73: Remove the redundant frame styling from the wrapper div
rendering GALLERY_MINIS[i] in Gallery: eliminate its duplicated height,
borderRadius, background, border, and overflow/position container behavior as
appropriate, while preserving the mini’s own frame and layout. Keep the
aria-hidden wrapper only if needed without adding a second visual frame.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 100-115: Update the flip target around the onClick={flip} handler
to be keyboard accessible: prefer a button element, or add role="button",
tabIndex={0}, and an onKeyDown handler that invokes flip for Enter and Space
while preventing the default Space behavior. Preserve the existing click
interaction and visual styling.
In `@frontend/src/components/landing-v5/lab/GradesDemo.tsx`:
- Around line 74-83: Update the onChange sanitizer in the GradesDemo input to
allow at most one decimal point while preserving digits and the existing state
update behavior. Ensure values such as “1.2.3” are normalized so the displayed
text matches the value used by score computation.
In `@frontend/src/components/landing-v5/lab/GuideDemo.tsx`:
- Line 40: Use a consistent lookup style for the GUIDES map in the GuideDemo
component: update the initialization around set and the lookup in the relevant
course iteration around c so both rely on the CourseKey type without an
unnecessary nullish fallback, or both use the same fallback pattern. Preserve
the existing guide-selection behavior while removing the inconsistent
implication that GUIDES[course] may be missing.
In `@frontend/src/components/landing-v5/lab/NotesDemo.tsx`:
- Around line 57-80: Store the timers created by extract and summarize in refs
alongside saveT, clear their handles on unmount, and reset each ref after its
callback runs. Follow the existing timer-ref cleanup pattern in FeatureLabDemo
so callbacks cannot update state after the component unmounts.
In `@frontend/src/components/landing-v5/lab/QuizDemo.tsx`:
- Line 31: Extract the repeated q.options correctness check into a shared
predicate or derived value near the existing right calculation, then reuse it in
both locations. Update submit’s setStreak call to use the functional updater
form based on the previous streak value, preserving the existing
increment-or-reset behavior without reading streak from the render closure.
In `@frontend/src/components/landing-v5/lab/RoomsDemo.tsx`:
- Around line 76-106: Update the message-list container in RoomsDemo so it has
aria-live="polite" and automatically scrolls to the newest message after each
append. Use a ref and an effect tied to the message-list state, preserving the
existing rendering and styling.
In `@frontend/src/components/landing-v5/Navbar.tsx`:
- Around line 199-234: Remove the incomplete ARIA menu pattern from the navbar:
delete role="menu" from the panel and aria-haspopup="menu" from its trigger,
while preserving the existing links and open/close behavior. Expose the panel as
a plain navigation group without adding menuitem roles or keyboard navigation.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 383-399: Optimize both scanning effects in useLanding: replace the
first effect’s recurring setInterval(scan, 900) polling with a MutationObserver
watching root and invoking scan for relevant DOM additions, while preserving
cleanup and observer behavior. In the second effect around the section
descendant traversal, query only elements marked with the intended animation
attribute (such as [data-anim]) instead of '*', and retain the existing
animation logic for matched elements.
In `@frontend/src/lib/landing/companionContent.ts`:
- Around line 149-160: Update WIKI_TIERS.dot and WIKI_RATINGS.tone to store the
raw hex color values from TIER instead of CSS declaration strings. In the wiki
page consumer, remove the cssColor regex and parsing/fallback logic, and use
t.dot and r.tone directly as colors.
In `@frontend/src/lib/landing/content.ts`:
- Around line 38-135: Document that GAL’s array order is a positional contract
and must not be reordered or have entries inserted without updating callers. Add
and export a galIndexOf helper keyed by GalleryKind, then update the page.tsx
actions.openGal calls to resolve indices through that helper instead of
hard-coded positions.
In `@frontend/src/lib/landing/dragClusters.ts`:
- Line 36: Remove the redundant `as DragCluster[]` assertion from the
`DRAG_CLUSTERS` array literal, preserving its existing `DragCluster[]` type
annotation so excess-property and required-field checks remain enabled.
In `@frontend/src/lib/landing/engine/flip.ts`:
- Around line 24-37: Remove the unused ran field from the FlipState interface
and its initialization in createFlipState, and remove all assignments to st.ran,
including in armFlip and flipClose. Keep the actual animation guard in
useLanding.ts via flipRan unchanged.
In `@frontend/src/lib/landing/engine/plant.ts`:
- Around line 78-91: Update restore() to validate the parsed localStorage value
as an array, retain only string entries, and truncate each label to MAX_LABEL
before calling add() or updating labels. Ensure invalid or non-array data
produces an empty restored label set without partially processing it, while
preserving the seven seed nodes and normal return count.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

Comment threadfrontend/src/app/(public)/page.tsx Outdated
Comment on lines +92 to +109
{/* the engine fades these in once the act's scroll is spent */}
<div data-tutor-pills="1" style={{ display: 'flex', gap: 8, marginTop: 8, opacity: 0, pointerEvents: 'none', transition: 'opacity 400ms ease' }}>
{CAPTIONS.map(([tag], i) => (
<button
key={tag}
onClick={() => onSetMode(i)}
style={{
border: '1px solid rgba(12,86,56,0.3)', borderRadius: 99, padding: '9px 18px',
...MONO, fontSize: 10.5, letterSpacing: '0.12em', cursor: 'pointer',
background: tutorMode === i ? PILL_ON : 'transparent',
color: tutorMode === i ? PILL_ON_FG : '#33443B',
transition: 'all 250ms',
}}
>
{tag}
</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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Find what the engine writes on the tutor pills element.
rg -n -C 6 'tutor-pills|tutorPills' --glob '*.ts' --glob '*.tsx'

Repository: SaplingLearn/Sapling

Length of output: 158


🏁 Script executed:

#!/bin/bashset -e
printf'%s\n''--- candidate files ---'
fd -i 'ActTutor|tutor|landing-v5'.| head -80
printf'%s\n''--- all tutor-pills references ---'
rg -n -C 8 --hidden --glob '!node_modules' --glob '!dist' --glob '!build''tutor-pills|tutorPills|data-tutor'.printf'%s\n''--- ActTutor structure ---'
ACT=$(fd -t f -i 'ActTutor.tsx'.| head -1)if [ -n"$ACT" ];then
ast-grep outline "$ACT"
sed -n '1,180p'"$ACT"fi

Repository: SaplingLearn/Sapling

Length of output: 19055


🏁 Script executed:

#!/bin/bashset -e
printf'%s\n''--- engine writes to M.pills ---'
rg -n -C 10 'M\.pills|pills\.(style|classList)|style\.(opacity|pointerEvents|visibility)|opacity.*pills|pills.*opacity' frontend/src/lib/landing frontend/src/components/landing-v5 --glob '*.ts' --glob '*.tsx'printf'%s\n''--- engine files and relevant structure ---'
fd -t f . frontend/src/lib/landing/engine -x sh -c 'echo --- "$1"; wc -l "$1"; ast-grep outline "$1" | head -80' sh
printf'%s\n''--- direct pills-related engine context ---'
rg -n -C 20 'pills|handover|spent|scroll' frontend/src/lib/landing/engine --glob '*.ts' --glob '*.tsx'

Repository: SaplingLearn/Sapling

Length of output: 50377


Remove hidden pills from the keyboard tab order.

The engine changes only opacity and pointerEvents, so hidden buttons remain focusable. Toggle inert with the engine’s docked state, or toggle visibility there as well.

🤖 Prompt for AI Agents
Verify each 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 92 - 109,
Update the tutor pills container in ActTutor to remove its buttons from keyboard
navigation while hidden by binding the engine’s docked state to inert (or
visibility). Preserve the existing opacity and pointerEvents behavior, and
ensure the pills become focusable again when docked is false.

Comment threadfrontend/src/components/landing-v5/BetaModal.tsx
Comment threadfrontend/src/components/landing-v5/Gallery.tsx
Comment threadfrontend/src/components/landing-v5/lab/CardsDemo.tsx
Comment threadfrontend/src/components/landing/useLanding.ts
Comment threadfrontend/src/components/landing/useLanding.ts
Comment threadfrontend/src/components/marketing/SignInModal.tsx Outdated
Comment threadfrontend/src/lib/landing/content.ts Outdated
Comment on lines +191 to +218
/**
* Presentation metadata paired positionally with POSTS.
*
* NOTE: `journal-founding.png` and `journal-ai-homework.png` are not in
* `public/` yet — both exceed the design API's 256 KiB read cap, so they
* ship with the project export alongside the rest of the source.
*/
export const POST_META: PostMeta[] = [
{
slot: 'journal-1',
src: '/journal-founding.png',
slotHint: 'Drop the founding photo',
isPhoto: true,
isGraph: false,
comments: 12,
likes: 84,
floatDelay: '0s',
},
{
slot: 'journal-2',
src: '/journal-ai-homework.png',
slotHint: 'Drop an image for this essay',
isPhoto: true,
isGraph: false,
comments: 31,
likes: 126,
floatDelay: '-2.5s',
},

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

Two journal images are declared but may not ship.POST_META points at /journal-founding.png and /journal-ai-homework.png, and the file's own comment states both assets are absent from public/. Journal.tsx renders those paths through next/image with no fallback, so two of the three journal cards can show a broken image.

  • frontend/src/lib/landing/content.ts#L191-L218: add the two PNG files to public/, or set src: '' and isGraph: true on those entries until the assets ship.
  • frontend/src/components/landing-v5/Journal.tsx#L106-L118: keep the meta.isGraph branch as the fallback path, so an entry without a usable src renders GraphThumb instead of a broken image.
📍 Affects 2 files
  • frontend/src/lib/landing/content.ts#L191-L218 (this comment)
  • frontend/src/components/landing-v5/Journal.tsx#L106-L118
🤖 Prompt for AI Agents
Verify each 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/content.ts` around lines 191 - 218, In
frontend/src/lib/landing/content.ts lines 191-218, mark the unavailable
journal-founding.png and journal-ai-homework.png entries with empty src values
and isGraph: true so they use the graph fallback until assets are shipped. In
frontend/src/components/landing-v5/Journal.tsx lines 106-118, preserve the
meta.isGraph branch that renders GraphThumb instead of passing unusable paths to
next/image.

Comment threadfrontend/src/lib/landing/engine/sim.ts
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — v5 marketing landing onto staging

This ports the v5 marketing landing onto staging as a public-surface-only change, and the central claim in the description holds up: I verified there are no backend/** files, no migrations, no middleware.ts, no next.config.ts and no wrangler.toml in the diff (74/74 changed files are under frontend/), and git diff --diff-filter=D is empty — nothing is deleted out from under another surface. The Layer-2 token scoping is done properly (.public-surface, .landing-page in globals.css:828, with deliberate literal fallbacks on .card--hero), and every new landing-v5/marketing/companion component carries "use client". The problems are elsewhere: a dropped auth-error path, staging metadata that points at production, a colour system that bypasses the design tokens entirely, and — the big one — this branch is a stale snapshot of a landing that is already merged on main.

Findings

P0

[P0] Both CI jobs are red, and the failures hide the fact that nothing was typechecked or tested.github/workflows/ci.yml:41,90-94

Backend (ruff check ., run 31553863104):

F401 [*] `pydantic.Field` imported but unused
--> models/gradescope.py:12:33
Found 1 error.
##[error]Process completed with exit code 1.

Frontend (npx eslint ., same run):

frontend/src/components/settings/GradescopeConnect.tsx
65:6 error React Hook useEffect has a missing dependency: 'refreshAll'. Either include it or
remove the dependency array react-hooks/exhaustive-deps
✖ 33 problems (1 error, 32 warnings)
##[error]Process completed with exit code 2.

To be clear about attribution: neither is yours. Both reproduce verbatim at the base commit 82787cabackend/models/gradescope.py imports Field with zero Field( uses there, and GradescopeConnect.tsx:65 is already }, []);. gh run list --branch staging shows staging's last green run was 8bce5b02 on 2026-07-10; 82787ca never ran CI, so it landed red. The reason this is still P0 for this PR: the workflow runs ruff before pytest, and eslint before npx tsc --noEmit and npm test. Both jobs die at the lint step, so the type checker and both test suites never executed against these 14,954 added lines. You currently have no signal that the port compiles. Fixing base (or rebasing once it is fixed) is a prerequisite for this PR meaning anything.

P1

[P1] ?error=session_expired no longer reaches the sign-in modalfrontend/src/app/(public)/page.tsx:89

<SignInModalopen={signInOpen}onClose={()=>setSignInOpen(false)}/>

middleware.ts (untouched here) bounces expired sessions to the landing with a query param:

// middleware.ts:38if(errorCode)u.searchParams.set('error',errorCode)// middleware.ts:52 and :67if(!session)returnredirectToSignin(request,'session_expired')

The page this replaces read that param and threaded it through — base (public)/page.tsx:90const err = params.get('error'), then :879errorCode={signInError}. The new page never reads ?error (no searchParams/URLSearchParams anywhere in page.tsx or useLanding.ts), so the prop is never supplied. Meanwhile components/marketing/SignInModal.tsx still ships the whole error surface — ERROR_COPY at :11 including session_expired at :16, and :122const visibleError = localError ?? errorCode ?? null — all now dead. Net effect: a user kicked out mid-session lands on the marketing page with no explanation of why they were signed out. Restore the ?error= read and pass it through.

[P1] The staging deploy will advertise production canonical and OG URLsfrontend/src/app/layout.tsx:43

metadataBase: newURL(resolveSiteUrl(process.env)),alternates: {canonical: '/'},

resolveSiteUrl falls through to production when neither knob is set:

// deployGuard.ts:98-106exportfunctionresolveSiteUrl(env: EnvSource): string{constdeployEnv=(env.DEPLOY_ENV??'').trim().toLowerCase();if(deployEnv&&deployEnvinFRONTEND_ENVS)returnFRONTEND_ENVS[deployEnvasFrontendEnv].siteUrl;constexplicit=(env.NEXT_PUBLIC_SITE_URL??'').trim();if(explicit)returnexplicit.replace(/\/+$/,'');returnFRONTEND_ENVS.production.siteUrl;}

frontend/wrangler.toml is not modified by this PR, and its [env.staging.vars] block sets only NEXT_PUBLIC_API_URL, BACKEND_URL and COOKIE_DOMAINneither DEPLOY_ENV nor NEXT_PUBLIC_SITE_URL. So on frontend-staging every page resolves metadataBase to https://saplinglearn.com, making canonical, og:url and og:image all point at production. Since the entire point of this PR is "show the new landing on staging", any staging link shared for review will unfurl with production's card, and staging self-canonicalises to prod. Set DEPLOY_ENV = "staging" in [env.staging.vars] (which also arms the existing checkFrontendDeployEnv lock in next.config.ts:46).

[P1] The landing runs a parallel colour palette that shares no value with the design tokensfrontend/src/components/landing-v5/*

Counting hex literals across landing-v5 (excluding the galleryMinis illustration art) gives ~900 hardcoded colours. The most frequent:

79 #0c5638 48 #fdfcf9 46 #0e9e5a 40 #12201a
37 #33443b 34 #9aa5a0 31 #8b9891 30 #8fd9a8

Not one of these is a token. The core set in globals.css is --brand-forest: #1B6C42, --brand-forest-bright: #2D8F5C, --sap-500: #4e873c, --ink-0: #faf8f3, --ink-800: #1a1814. Note the near-misses in particular: #fdfcf9 vs --ink-0 #faf8f3, and #12201a vs --ink-800 #1a1814. They are close enough to look like the brand and different enough to be visibly off it at the seam where a user crosses from the landing into the app shell. The Engineering Style Guide §3/§4 is explicit — "inline style objects referencing global CSS-variable tokens … Use the tokens; don't hardcode values." This is doubly odd because the PR itself builds the correct scaffolding: (public)/layout.tsx mounts .public-surface and globals.css:839-841 defines --surface-hero/--brand-glow on it, and then no component uses any of it.

[P1] This is a stale snapshot of a landing already merged on main, and promoting it will fight main — whole landing-v5 tree

origin/main is 4f5a7f9 Merge pull request #524 from SaplingLearn/feat/landing-v5-port — the v5 landing is already there. git merge-base origin/staging origin/main is 57469ad, which contains nofrontend/src/components/landing-v5/, so the directory is added independently on both sides: promoting stagingmain is an add/add conflict across ~50 files, not a fast merge. main has taken 19 commits on the landing since the merge-base, and git diff origin/main 395588da over the landing tree is only 4 files / +9 −38 — i.e. this branch is behind, missing a3d0371 fix(landing): raster, veil and scroll-coupling polish on the v5 hero (the MAG/zoom raster correction in ActTutor.tsx, and the re-tuned hero washes in Hero.tsx).

The dangerous part is that one of those divergences is invisible to the type checker:

// this branch — hero3d/index.ts:52-56 (an intensity multiplier, 0..2)exportfunctionstartHeroRig(canvas,getMouse,getParallax: ()=>number)// origin/main — hero3d/index.ts:76-80 (a scroll offset in pixels)exportfunctionstartHeroRig(canvas,getMouse,getHeroShiftPx: ()=>number)

Both are () => number, and both hero3d/index.ts and useLanding.ts are in the conflict set. Resolving that conflict by taking one file from each side compiles cleanly and silently breaks the hero. Please rebase this branch onto main's landing rather than re-adding an older copy, or state explicitly how the promotion merge will be resolved.

P2

[P2] Three of the four new deployGuard exports have no production callerfrontend/src/lib/deployGuard.ts:52-146

resolveFrontendEnv, expectedEnvForHost and detectHostConfigMismatch are referenced only by deployGuard.test.ts. resolveSiteUrl is the only one wired into anything (layout.tsx:4). detectHostConfigMismatch's own docstring bills it as "Runtime defence-in-depth for what the build-time guard cannot see", citing "the exact failure behind staging's session_expired" — but middleware.ts is not in this diff, so nothing calls it and it defends nothing. That is ~100 lines of inert code plus 131 lines of tests landing in a shared lib on a marketing PR. Either wire it into the middleware or drop it from this PR.

[P2] components/SignInModal.tsx is now orphaned, and its replacement is a near-copyfrontend/src/components/SignInModal.tsx

After this PR the only SignInModal import in the tree is (public)/page.tsx:14@/components/marketing/SignInModal. The original components/SignInModal.tsx (347 lines) has zero importers but is still in the tree; the new components/marketing/SignInModal.tsx is 352 lines and diff between the two is 67 lines. Two copies of the OAuth modal, one of them dead, will drift — and the dead one is the one that still gets fed errorCode in nobody's code path. The Engineering Style Guide §3 calls this out by name as the reason components/ui/ exists ("before they landed the app had 5 distinct button shapes and 18+ pill/badge implementations"). Delete the orphan or make the marketing one a thin wrapper.

[P2] A new eslint suppression was added for code introduced in this PRfrontend/eslint-suppressions.json

"src/components/marketing/SignInModal.tsx": {
"react-hooks/set-state-in-effect": { "count": 1 }
},

Style Guide §7 states the ratchet's purpose as "don't fix the whole backlog to land a change, but never add to it." The baseline exists to grandfather legacy violations; marketing/SignInModal.tsx is created by this PR, so this is a new violation being suppressed rather than fixed. set-state-in-effect is also usually a real render-loop smell worth a look rather than a waiver.

[P2] .landing-page no longer exists, leaving dead CSS and a comment that now says the opposite of the truthfrontend/src/app/(public)/layout.tsx:7

// The landing page keeps its own .landing-page class for the mesh-background visual.exportdefaultfunctionPublicLayout({ children }: {children: React.ReactNode}){return<divclassName="public-surface">{children}</div>;}

No element in the tree carries className="landing-page" at this HEAD — the only remaining mentions are three comments. But globals.css:866-874 still ships the whole mesh block that depends on it:

.landing-page .sapling-mesh-blob { position: absolute; border-radius:50%; filter:blur(100px); … }
.landing-page .sapling-mesh-blob--1 { top:-10%; left:-10%; … }

along with the other .landing-page-prefixed rules the diff kept. All unreachable. Either the wrapper should still apply the class (if the mesh backdrop is meant to render) or the rules and the comment should go.

P3

[P3] 1.3 MB of unoptimized PNGs committedfrontend/public/

journal-founding.png is 821,816 bytes and journal-ai-homework.png is 500,189 bytes. They are rendered through next/image with a sizes hint (Journal.tsx:110-114), so delivery is fine, but the repo and the OpenNext asset bundle both carry the full-size originals. Worth running them through a compressor or shipping WebP/AVIF sources.

What's good

  • The scope discipline is real and verifiable: no backend files, no migrations, no middleware.ts, no wrangler.toml, and nothing deleted — exactly what the description promises, and a much better call than merging the 217-commit branch with its colliding 0032_*/0033_* migrations.
  • The Layer-2 token boundary is set up correctly — .public-surface scoping in (public)/layout.tsx mirroring (shell), with the literal fallbacks on .card--hero and a comment explaining exactly why they are there. That is the right structure; the components just need to start using it.
  • "use client" is correct on every new hook-using component, and the deployGuard additions are genuinely well-tested (131 lines of focused unit tests) even though most of the code they cover is not yet wired up.

Verdict: request changes — the auth-error regression and the staging-metadata bug are both user-visible, and the branch needs to be reconciled with the v5 landing already on main before it can be promoted.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…PLOY_ENV
CI was red at the LINT step on both jobs, so tsc, pytest and vitest never
ran on this port at all.
- backend/models/gradescope.py: drop the unused pydantic Field import (F401).
- settings/GradescopeConnect.tsx: stabilise refreshAll with useCallback and
list it in the mount effect (exhaustive-deps is an error here). Keying on
userId also fixes the stale-account bug the empty dep array hid.
- (public)/page.tsx: read the ?error= code middleware.ts redirects with and
thread it into the modal, opening it when a code is present. Read through
useSyncExternalStore so the page stays statically prerenderable and no
setState lands in an effect; dismissing the modal strips the param.
- page.tsx: openGal indices now come from galIndexOf('quiz'/'tutor') — the
'Ask the tutor' button opened the notetaker panel. BetaModal onClose now
calls resetSubscribeError so a failed subscribe stops leaking into Journal.
- marketing/SignInModal.tsx: fix set-state-in-effect at source (reset during
render on the open prop change) and remove the suppression entry it was
given. OAuth now uses the frontend-relative /api/auth/google for both the
popup and the same-tab fallback, so the session cookie is not third-party.
- delete the orphaned components/SignInModal.tsx (zero importers) and point
the eslint data-testid rule at its replacement.
- deployGuard.classify: normalise trailing slashes on URL fields (not on
cookieDomain) so a valid slashed API/site URL cannot silently disarm the
guard. Five regression tests, all of which fail without the fix.
- middleware.ts: wire resolveFrontendEnv + detectHostConfigMismatch in, so a
host/backend mismatch reports env_misconfig instead of looping the user
through session_expired forever.
- wrangler.toml/package.json: set DEPLOY_ENV per environment so staging stops
advertising production canonical/OG URLs and the build-time guard is armed.
- globals.css/(public)/layout.tsx: delete the ~950 lines of .landing-page CSS
the replaced page made unreachable, fix the inverted comment, unquote the
single-identifier Spectral, and scope a keyframes-name-pattern disable to
the ported region only.
The a11y fixes in this PR all defend behaviour that is invisible on screen
and in review: a gallery card that only opens on click, a navbar that stays
clickable while transparent, an accordion whose closed answers remain in the
tab order, a role="radiogroup" containing no radios.
Two lab-demo state bugs get the same treatment — a reply naming a different
partner than the typing indicator, and a footer counting an empty-result
sentinel as "1 concepts" — plus the galIndexOf contract that replaced the
hard-coded GAL indices.
Renamed from the scratch file that commit b6c3a8a swept in mid-edit.
- news: card CTA said 'Read article' while every card links to /#newsletter;
it now says 'Get notified' and the Link carries a matching aria-label. The
category listbox closes on Escape and returns focus to its trigger (also on
option select) — dismissing it used to drop focus to <body>.
- about: the copy sent readers to 'a feedback button in the navbar' that
CompanionShell does not render; it now links the GitHub issue tracker.
- team/wiki: add the alternates.canonical their siblings already had.
- careers/[slug]: only append the meta-description ellipsis when slice(0,155)
actually truncated, so short blurbs stop reading as cut off.
- wiki: move scrollMarginTop from the H2 to the <section> elements — the TOC
fragments target the sections, so anchors landed under the sticky header.
Drop cssColor(): companionContent.ts now stores bare hex.
- faq: unique panel ids + aria-controls, and 'visibility' in the collapsed
style so collapsed answers are neither focusable nor announced.
- careers/CareersList: same collapsed-panel fix, so 'Apply for this role' is
not tabbable while closed; both raw <img> replaced with next/image.
- CompanionShell: wrap the header link group and the footer link row in
labelled <nav> landmarks. The compact-nav media query was scoped to
.landing-dc, which this shell must never wear, so below 1180px the
overflowing tab row stayed and the disclosure stayed hidden — unscoped it.
- page.tsx: read ?signin=1 too. CompanionShell's header 'Sign In' links to
/?signin=1 and nothing read it, so that control did nothing at all.
- public/journal-*.png: 1.29 MB -> 432 KB (-67.8% / -64.6%) via libimagequant
256-colour quantisation with Floyd-Steinberg dither (same engine pngquant
uses; RMSE 2.0 and 2.8 of 255). A fully lossless re-encode was tried first
and was larger than the originals. Filenames and paths unchanged.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Blocker

  • Both CI jobs died at the lint step, before tsc / pytest / vitest ever ran — so none of this port had been typechecked or tested. Both base failures fixed properly: the unused pydantic.Field import, and GradescopeConnect.tsx's exhaustive-deps (via useCallback keyed on userId, which also fixes a real bug the empty dep array hid — switching user kept the previous account's state).

Major

  • ?error=session_expired never reached the modal.middleware.ts sets it and marketing/SignInModal still shipped the whole ERROR_COPY surface, all dead. Now read and threaded through, opening the modal on an inbound code and stripping the param on dismiss.
  • Staging advertised production canonical/OG URLsresolveSiteUrl fell through to prod because [env.staging.vars] set neither knob. DEPLOY_ENV = "staging" added, which also arms the existing build-time guard.
  • deployGuard.classify now normalises trailing slashes, so a valid https://api.saplinglearn.com/ no longer bypasses the runtime guard; detectHostConfigMismatch is wired into middleware.ts so it actually defends something.
  • The orphaned components/SignInModal.tsx (zero importers, 67-line drift from its replacement) is deleted.

CodeRabbit majors

onLearn opened the notetaker instead of the tutor (now via the new galIndexOf lookup, not a magic index) · OAuth URLs kept same-origin · DARK_SECTIONS reduced to the one genuinely dark section · Escape now exits explore mode (was a keyboard trap with body { overflow: hidden }) · sim.destroy() re-homes clusters so a StrictMode remount no longer kills the drag field permanently · hero-canvas unmount race · CardsDemo timer leak, global key capture and keyboard-inoperable flip · Gallery cards keyboard-openable · BetaModal single-column below the breakpoint + type="email" · hidden nav/pills removed from the tab order.

Minor / nits

Dead .landing-page CSS removed · stylelint font-family-name-quotes 5→0 and keyframes-name-pattern 42→0 · lab-demo correctness (clamped percentages, partner snapshot, concept count, invalid guide picks) · ARIA roles and labels across FAQ/careers/calendar/quiz/rooms · localStorage input validated · journal PNGs recompressed 1.32 MB → 442 KB.

Still open (deliberately not done)

This branch is a stale snapshot of a landing already merged on main (#524) — promoting it is an add/add conflict across ~50 files, and startHeroRig's third parameter changed meaning while keeping an identical () => number type, so a file-by-file conflict resolution compiles cleanly and silently breaks the hero. That is a merge-strategy decision, not a code fix.

Verificationruff check . clean · tsc clean · eslint 0 errors (base had 1) · 184 frontend tests pass

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

This branch now carries a backend change, via #565

staging picked up the Gradescope repair in #565, so this PR is no longer frontend-only. That fix restored routes/gradescope.py and services/gradescope_service.py from main because the version on staging was unreachable (router prefix declared twice → every endpoint served at /api/gradescope/api/gradescope/...), called a DB client that does not exist (.eq().execute() on a PostgREST helper), and had no rate limiting or bu-sso.

I checked statically that every table the restored route touches exists on staging — gradescope_credentials, gradescope_course_links, assignments, gradebook_categories — so there is no missing-table hazard. But that is a check of the repo's migration files, not of the staging database's actual ledger. Please confirm on the real database:

SELECT table_name FROMinformation_schema.tablesWHERE table_schema ='public'AND table_name IN ('gradescope_credentials','gradescope_course_links','gradescope_links');

All three should be present. Then exercise one endpoint end to end and confirm it resolves at a single/api/gradescope/... prefix rather than 404ing.

Two things #565 deliberately left behind

SELECTcount(*) FROM gradescope_links;

gradescope_links (created by staging's 0033_add_gradescope_links.sql) now has no reader — the restored route uses gradescope_course_links. services/gradescope_sync_service.py has zero importers, and models/gradescope.py is imported only by that dead module. Nothing breaks, but if the table holds rows they are stranded, and the two modules are dead code worth removing in a follow-up.

Ledger divergence between staging and main

Staging carries two migrations main does not, at numeric prefixes main reuses for different files:

prefixstagingmain
00320032_add_gradescope_credentials.sql0032_rooms_missing_columns.sql, 0032_retire_summer_2026.sql
00330033_add_gradescope_links.sql0033_offering_section_not_null.sql, 0033_realtime_publish_room_messages.sql

schema_migrations keys on the full basename, so these coexist without a primary-key collision — but main has 23 migrations staging lacks, and staging is 239 commits behind. Whenever these branches are reconciled, both migration sets apply and the relative ordering within 0032/0033 is alphabetical by accident rather than by design. Worth settling deliberately rather than discovering it during a promotion.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

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.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, '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 the v5 marketing landing onto staging by Darkest-Teddy · Pull Request #535 · SaplingLearn/Sapling · GitHub
Skip to content

feat(landing): port the v5 marketing landing onto staging - #535

Open
Darkest-Teddy wants to merge 5 commits into
stagingfrom
feat/landing-v5-on-staging
Open

feat(landing): port the v5 marketing landing onto staging#535
Darkest-Teddy wants to merge 5 commits into
stagingfrom
feat/landing-v5-on-staging

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Puts the v5 marketing landing on stagingwithout merging feat/landing-v5-port.

Why not just merge the v5 branch

feat/landing-v5-port is 217 commits ahead of origin/staging and the diff is mostly not the landing page — 179 backend test files, 31 agents, 27 services, 17 routes, and 15 new migrations, including two colliding 0032_* and two colliding 0033_* basenames. It also isn't a fast-forward: staging has one commit that branch lacks (82787ca, gradescope). None of that is needed to show the new landing, so this ports the public surface only — no backend changes, no migrations.

What's in here

  • components/landing-v5/** and lib/landing/** — the landing engine, hero3d, drag simulation, and the Act sections
  • The (public) route group: the new /, plus /team, /wiki, /gallery, /news, /faq — the v5 navbar links to these and staging didn't have them
  • components/marketing/** and components/companion/CompanionShell
  • globals.css and app/layout.tsx (OG/metadata), lib/deployGuard.tswith its matching test, lib/errorMessage.ts
  • api.ts gains only subscribeToNewsletter — staging's backend already mounts /api/newsletter

One deliberate divergence from upstream

marketing/SignInModal calls the 3-argsetActiveUser. Upstream passes a 4th { persist } option that exists only on that branch's UserContext, whose wider change (+127/−21) also adds app-wide session recovery and shell-route redirects. Changing auth behaviour for every screen is out of scope for a landing port, so the option is dropped here and should come back in the same commit that brings that UserContext over. The call site carries the same note.

eslint-suppressions.json gains an entry for the new components/marketing/SignInModal.tsx path; staging only suppressed that rule at the old components/SignInModal.tsx path.

Verification

  • tsc --noEmit — clean
  • next build with staging's build vars (DEPLOY_ENV/BACKEND_URL/NEXT_PUBLIC_API_URL/COOKIE_DOMAIN) — succeeds, emitting /team, /wiki, /gallery, /news, /faq
  • eslint — clean except one pre-existing error: react-hooks/exhaustive-deps in settings/GradescopeConnect.tsx:65, which arrives untouched from origin/staging (it is neither modified nor suppressed on that branch). CI will likely go red on it until it's fixed separately — deliberately not touched here.
  • vitest was not run locally — it needs Node 22 and this machine is on 20. CI runs it on Node 22.

Deploy note

Per wrangler.toml and ADR 0020, the staging Workers Build must set DEPLOY_ENV=staging as a build variable, not just a runtime var — the /api rewrite and NEXT_PUBLIC_* are baked at build time. That omission is what previously put prod config on the staging worker.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added public About, Team, Wiki, Gallery, News, FAQ, and Careers pages.
    • Introduced interactive galleries, article search and filtering, FAQ accordions, career listings, and rich knowledge-base content.
    • Redesigned the landing experience with interactive learning demonstrations, knowledge graphs, tutor modes, quizzes, flashcards, study tools, and beta signup.
    • Added Google sign-in and newsletter subscription flows.
  • Improvements

    • Standardized navigation, page layouts, accessibility, responsive styling, animations, and site metadata.
    • Improved error messaging and deployment-aware canonical links.

Brings the v5 public site to `staging` WITHOUT the rest of
`feat/landing-v5-port`: that branch is 217 commits ahead of staging and
carries backend routes, services, agents and 15 migrations (including two
colliding 0032_* and two 0033_* basenames). None of that is needed to show
the new landing, so this ports the public surface only.
What's here:
- components/landing-v5/** + lib/landing/** (the landing engine, hero3d,
drag simulation and the Act sections)
- the (public) route group: the new / plus /team, /wiki, /gallery, /news
and /faq, which the v5 navbar links to and staging did not have
- components/marketing/** and components/companion/CompanionShell
- globals.css and app/layout.tsx (OG/metadata), lib/deployGuard.ts with
its matching test, lib/errorMessage.ts
- api.ts gains only `subscribeToNewsletter`; staging's backend already
mounts /api/newsletter
One deliberate divergence from upstream: marketing/SignInModal calls the
3-arg `setActiveUser`. Upstream passes a 4th `{ persist }` option that only
exists on that branch's UserContext, whose wider change also adds app-wide
session recovery and shell-route redirects. Changing auth for every screen
is out of scope for a landing port, so the option is dropped here and should
return with that UserContext. The call site carries the same note.
eslint-suppressions.json gains an entry for the new
components/marketing/SignInModal.tsx path; staging only suppressed the rule
at the old components/SignInModal.tsx path.
Verified: tsc --noEmit clean, and `next build` succeeds with staging's build
vars (DEPLOY_ENV/BACKEND_URL/NEXT_PUBLIC_API_URL/COOKIE_DOMAIN), emitting
/team, /wiki, /gallery, /news and /faq. eslint is clean except for a
pre-existing react-hooks/exhaustive-deps error in
settings/GradescopeConnect.tsx, which arrives untouched from origin/staging.
vitest was not run locally (needs Node 22; this machine is on 20) - CI runs it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 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 ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:37 seconds

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 101dbfe3-a2c2-4287-842f-6e2c86a82b2e

📥 Commits

Reviewing files that changed from the base of the PR and between 395588d and 46f7930.

⛔ Files ignored due to path filters (2)
  • frontend/public/journal-ai-homework.png is excluded by !**/*.png
  • frontend/public/journal-founding.png is excluded by !**/*.png
📒 Files selected for processing (57)
  • docs/frontend-testids.md
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/package.json
  • frontend/src/app/(public)/about/page.tsx
  • frontend/src/app/(public)/careers/CareersList.tsx
  • frontend/src/app/(public)/careers/[slug]/page.tsx
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/layout.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/ActGraph.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/ActTutor.tsx
  • frontend/src/components/landing-v5/BetaModal.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/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/landing-v5-interaction.test.tsx
  • frontend/src/components/landing-v5/navTheme.ts
  • frontend/src/components/landing/useLanding.ts
  • frontend/src/components/marketing/HeroCard.test.tsx
  • frontend/src/components/marketing/SignInModal.tsx
  • frontend/src/lib/deployGuard.test.ts
  • frontend/src/lib/deployGuard.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/dom.ts
  • frontend/src/lib/landing/engine/flip.ts
  • frontend/src/lib/landing/engine/index.ts
  • frontend/src/lib/landing/engine/marquee.ts
  • frontend/src/lib/landing/engine/plant.ts
  • frontend/src/lib/landing/engine/sim.test.ts
  • frontend/src/lib/landing/engine/sim.ts
  • frontend/src/middleware.ts
  • frontend/wrangler.toml
📝 Walkthrough

Walkthrough

The PR replaces the landing page with Landing v5, adds companion and careers pages, introduces shared public layouts and content, expands deployment metadata, and adds OAuth, newsletter, error-handling, CSS, and interaction infrastructure.

Changes

Landing v5 experience

Layer / File(s)Summary
Landing contracts and content
frontend/src/lib/landing/*
Adds typed landing content, course graphs, drag clusters, lab fixtures, color utilities, and navigation themes.
Landing sections and demos
frontend/src/components/landing-v5/*
Adds the v5 hero, graph, ingest, tutor, gallery, FAQ, journal, CTA, navigation, modal, and feature-lab components.
Landing runtime and composition
frontend/src/components/landing/useLanding.ts, frontend/src/lib/landing/engine/*, frontend/src/app/(public)/page.tsx
Connects React state to scroll, canvas, WebGL, graph, marquee, FLIP, planting, newsletter, and drag-field behavior.
Landing styles and tests
frontend/src/app/globals.css, frontend/src/components/landing-v5/*.test.tsx, frontend/src/lib/landing/engine/sim.test.ts
Adds responsive landing layouts, finite animations, and interaction regression coverage.

Companion and public pages

Layer / File(s)Summary
Shared companion shell and content
frontend/src/components/companion/CompanionShell.tsx, frontend/src/lib/landing/companionContent.ts
Adds shared navigation, footer links, and typed content collections.
Companion pages
frontend/src/app/(public)/about/*, team/*, faq/*, gallery/*, news/*, wiki/*
Adds shared-shell pages with data-driven content, filters, accordions, navigation, and metadata.
Careers routes
frontend/src/app/(public)/careers/*
Moves interactive job listings into CareersList, adds route metadata, and returns not-found responses for invalid slugs.

Platform and marketing infrastructure

Layer / File(s)Summary
Deployment and metadata
frontend/src/lib/deployGuard.ts, frontend/src/lib/deployGuard.test.ts, frontend/src/app/layout.tsx, frontend/src/app/(public)/privacy/page.tsx, frontend/src/app/(public)/terms/page.tsx
Adds environment resolution, canonical site URLs, host mismatch detection, structured application metadata, and legal-page metadata.
Authentication, newsletter, and marketing UI
frontend/src/components/marketing/SignInModal.tsx, frontend/src/components/marketing/HeroCard.tsx, frontend/src/lib/api.ts, frontend/src/lib/errorMessage.ts
Adds OAuth popup handling, accessible modal behavior, reusable hero-card rendering, newsletter subscription requests, and defensive error normalization.

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

Sequence Diagram(s)

sequenceDiagram
participant Visitor
participant LandingPage
participant useLanding
participant LandingEngine
participant CanvasAndWebGL
Visitor->>LandingPage: open landing page
LandingPage->>useLanding: initialize refs and state
useLanding->>LandingEngine: start landing lifecycle
LandingEngine->>CanvasAndWebGL: render hero and graph visuals
Visitor->>LandingPage: explore graph or submit newsletter
LandingPage->>useLanding: dispatch interaction
useLanding->>LandingEngine: update interaction state
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 26.42% 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 main change: porting the v5 marketing landing to staging.
Description check✅ PassedThe description covers scope, rationale, changes, testing limits, and deployment requirements, with only minor template omissions.
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 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/landing-v5-on-staging

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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 12, 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-staging46f7930Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:19 PM

@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: 11

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/lib/deployGuard.ts (1)

39-46: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Normalize trailing slashes before environment classification.

A canonical API URL with a trailing slash classifies as unknown. Then detectHostConfigMismatch returns null for a staging host wired to https://api.saplinglearn.com/. This bypasses the runtime guard for a valid URL spelling.

Normalize URL fields before comparison. Add regression tests for trailing-slash API and site URLs.

Proposed fix
 function classify(
value: string | undefined,
field: 'apiUrl' | 'cookieDomain' | 'siteUrl',
): FrontendEnv | null {
- const v = (value ?? '').trim().toLowerCase();+ const v = (value ?? '')+ .trim()+ .toLowerCase()+ .replace(field === 'cookieDomain' ? /$/ : /\/+$/, '');
if (!v) return null;
for (const env of Object.keys(FRONTEND_ENVS) as FrontendEnv[]) {
if (FRONTEND_ENVS[env][field].toLowerCase() === v) return env;
🤖 Prompt for AI Agents
Verify each 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/deployGuard.ts` around lines 39 - 46, Update classify to
normalize URL values by removing trailing slashes before comparing against
FRONTEND_ENVS fields, while preserving existing trimming and case normalization.
Ensure both API and site URL classifications recognize trailing-slash variants,
and add regression coverage for trailing-slash API and site URLs.
🟡 Minor comments (24)
frontend/src/app/(public)/news/page.tsx-175-199 (1)

175-199: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The card link text does not match its destination.

Each card is a <Link href="/#newsletter"> and its call to action reads "Read article →". A reader who selects a headline arrives at the newsletter signup on the home page, not at the post. All six cards lead to the same anchor.

Change the call to action to state the real destination, for example "Get notified →", until article 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/app/`(public)/news/page.tsx around lines 175 - 199, The news
cards’ CTA text incorrectly promises article navigation while each Link in the
posts.map block targets /#newsletter. Update the CTA span text from “Read
article →” to wording that reflects newsletter signup, such as “Get notified →”,
while preserving the existing link destination and styling.
frontend/src/app/(public)/about/page.tsx-80-84 (1)

80-84: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update the copy that points to a feedback button in the navbar.

The paragraph tells the reader to use "a feedback button in the navbar". The ported chrome in frontend/src/components/companion/CompanionShell.tsx renders Home, About, Team, Wiki, Gallery, News, FAQ, GitHub, Ko-fi, Sign In, and Get Started. It has no feedback control. Point the reader at a control that exists, for example the GitHub link or a contact address.

🤖 Prompt for AI Agents
Verify each 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)/about/page.tsx around lines 80 - 84, Update the
paragraph near the prose content to remove the nonexistent navbar
feedback-button reference, and direct readers to an available control such as
the GitHub link or a contact address instead. Preserve the surrounding message
about reporting issues and sharing ideas.
frontend/src/app/(public)/team/page.tsx-18-22 (1)

18-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Two companion routes omit alternates.canonical. The port sets a canonical URL on frontend/src/app/(public)/about/page.tsx, frontend/src/app/(public)/careers/page.tsx, and frontend/src/app/(public)/careers/[slug]/page.tsx, but not on these two routes. Without a canonical URL, these pages can be indexed under query-string or alternate-host variants.

  • frontend/src/app/(public)/team/page.tsx#L18-L22: add alternates: { canonical: '/team' } to the metadata export.
  • frontend/src/app/(public)/wiki/page.tsx#L18-L22: add alternates: { canonical: '/wiki' } to the metadata export.
🤖 Prompt for AI Agents
Verify each 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)/team/page.tsx around lines 18 - 22, Update the
metadata exports in frontend/src/app/(public)/team/page.tsx lines 18-22 and
frontend/src/app/(public)/wiki/page.tsx lines 18-22 to include
alternates.canonical, using /team for the team route and /wiki for the wiki
route.
frontend/src/app/(public)/careers/[slug]/page.tsx-18-22 (1)

18-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The meta description appends an ellipsis even when it truncates nothing.

Line 20 always appends after job.description.slice(0, 155). If a description is shorter than 155 characters, the full sentence still ends with a truncation marker. Search results then show a complete sentence that looks cut off.

Append the ellipsis only when the slice removed text.

🐛 Proposed fix
+ const blurb =+ job.description.length > 155 ? `${job.description.slice(0, 155)}…` : job.description;
return {
title: `${job.title} — Careers`,
- description: `${job.department} · ${job.location} · ${job.type}. ${job.description.slice(0, 155)}…`,+ description: `${job.department} · ${job.location} · ${job.type}. ${blurb}`,
alternates: { canonical: `/careers/${job.slug}` },
};
🤖 Prompt for AI Agents
Verify each 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)/careers/[slug]/page.tsx around lines 18 - 22,
Update the metadata construction in the careers page’s generateMetadata flow so
the description appends “…” only when job.description exceeds 155 characters;
leave shorter or exactly 155-character descriptions without a truncation marker
while preserving the existing 155-character slice.
frontend/src/app/(public)/wiki/page.tsx-28-31 (1)

28-31: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

scrollMarginTop is applied to the wrong element.

The H2 style sets scrollMarginTop: 84. The TOC links on line 67 target the fragments #graph, #mastery, #review, #tutor, #ingestion, #grades, and #privacy. Those ids are on the <section> elements, not on the <h2> elements. The browser applies scroll margin to the element it scrolls into view, so the section top lands under the sticky 92px header from CompanionShell and the heading is hidden.

Move the offset to the sections.

🐛 Proposed fix
 const H2: React.CSSProperties = {
margin: 0, fontFamily: DISPLAY, fontWeight: 500, fontSize: 26,
- lineHeight: 1.2, letterSpacing: '-0.015em', scrollMarginTop: 84,+ lineHeight: 1.2, letterSpacing: '-0.015em',
};
+/** Clears the sticky companion header after an in-page anchor jump. */+const SECTION: React.CSSProperties = { scrollMarginTop: 104 };

Then apply it to each section, for example:

- <section id="graph">+ <section id="graph" style={SECTION}>
🤖 Prompt for AI Agents
Verify each 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 28 - 31, Move
scrollMarginTop: 84 from the H2 style to the section styling used by the
sections targeted by the TOC fragment links (`#graph`, `#mastery`, `#review`, `#tutor`,
`#ingestion`, `#grades`, and `#privacy`). Keep the heading typography unchanged and
ensure each target section receives the offset so it clears the sticky header.
frontend/src/components/companion/CompanionShell.tsx-86-108 (1)

86-108: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Apply the responsive nav rules to CompanionShell. The media query is scoped to .landing-dc, but the CompanionShell root does not have that class. The rules do not match, despite using !important, so the compact nav remains hidden and the tab row remains visible below 1180px.

🤖 Prompt for AI Agents
Verify each 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/companion/CompanionShell.tsx` around lines 86 - 108,
Update the CompanionShell root element to include the landing-dc class so the
existing responsive media-query rules apply to its nav-compact and nav-tabs
elements. Preserve the current navigation structure and styling.

Source: Linters/SAST tools

frontend/src/lib/landing/engine/dom.ts-49-73 (1)

49-73: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

A layout measurement taken before layout is ready is cached permanently. Both sites store a measured size on first read and re-measure only when a resize signal arrives. If the first read happens before the element has its final box, the wrong value is kept for the rest of the session. Reject an implausible measurement instead of caching it.

  • frontend/src/lib/landing/engine/dom.ts#L49-L73: do not assign rec.__cv when w or h is 0. invalidateCanvas() runs only on a viewport size change, so a zero-size cache leaves the canvas blank.
  • frontend/src/lib/landing/engine/marquee.ts#L136-L144: do not accept m.setW when the summed child offsetWidth is 0. The re-measure trigger is a wrap.clientWidth change, so a gap-only width leaves the track wrapping over a few pixels.
🤖 Prompt for AI Agents
Verify each 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, Reject
zero-sized measurements before caching them: in
frontend/src/lib/landing/engine/dom.ts lines 49-73, ensure cv does not assign
rec.__cv when w or h is 0; in frontend/src/lib/landing/engine/marquee.ts lines
136-144, ensure the marquee measurement does not call m.setW when the summed
child offsetWidth is 0. Preserve normal caching and measurement behavior for
positive dimensions.
frontend/src/lib/landing/engine/sim.ts-330-369 (1)

330-369: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard the SVG structure assumptions during build.

Three unchecked assumptions read the authored SVG:

  • Line 332: ring.previousElementSibling as SVGCircleElement. If the ring is the first child, glow is null. Line 839 then calls p.glow.setAttribute on every frame and throws a TypeError.
  • Line 333 and 345: label is dereferenced immediately by label.getAttribute('y'). A missing next sibling throws during ensureInit().
  • Line 362-367: near() returns -1 when the cluster has line elements but no [data-sim] rings. nodes[b] is then undefined and line 367 throws.

holdArms() at line 653 and stepGroup() at line 752 already guard with if (!s0 || !t0) continue;, so missing nodes are anticipated later in the pipeline but not at build time. Skip a ring or a line when its expected siblings are absent.

🤖 Prompt for AI Agents
Verify each 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 330 - 369, Update the
SVG build logic in the rings and lines loops to validate required structure
before creating simulation entries: skip rings when the previous glow element or
next label element is missing, and skip lines when either endpoint lookup in
near() returns -1. Keep valid ring and line processing unchanged, ensuring no
nodes or links are created with null siblings or invalid endpoint indices.
frontend/src/lib/landing/engine/marquee.ts-69-114 (1)

69-114: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Bind pointerup and pointercancel on window as well.

setPointerCapture is called inside a try/catch at line 80. If the capture fails, later pointer events are not retargeted to track. A drag that ends outside track then never runs end(). m.drag stays non-null, so update() skips both drift and momentum for that track and the cursor stays grabbing until the next pointerdown.

frontend/src/lib/landing/engine/sim.ts lines 507-510 already binds the release handlers on both the element and window for this reason. Apply the same pattern here.

🛡️ Proposed fix
 track.addEventListener('pointerdown', onDown);
track.addEventListener('pointermove', onMove);
track.addEventListener('pointerup', onUp);
track.addEventListener('pointercancel', onCancel);
+ window.addEventListener('pointerup', onUp);+ window.addEventListener('pointercancel', onCancel);
cleanups.push(() => {
wrap?.removeEventListener('pointerenter', onEnter);
wrap?.removeEventListener('pointerleave', onLeave);
track.removeEventListener('pointerdown', onDown);
track.removeEventListener('pointermove', onMove);
track.removeEventListener('pointerup', onUp);
track.removeEventListener('pointercancel', onCancel);
+ window.removeEventListener('pointerup', onUp);+ window.removeEventListener('pointercancel', onCancel);
STATE.delete(track);
});
🤖 Prompt for AI Agents
Verify each 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 69 - 114, Bind the
existing onUp and onCancel release handlers to window in addition to track,
matching the dual registration pattern used by sim.ts. Keep the current track
listeners and end() behavior unchanged so drags are finalized even when pointer
capture fails or the pointer leaves the track.
frontend/src/components/landing-v5/BetaModal.tsx-244-260 (1)

244-260: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use type="email" for the email field.

The field is labelled "Email address" and validated as an email, but it is declared type="text". That suppresses the email keyboard on mobile and blocks browser autofill. The manual check at lines 86-96 continues to work unchanged.

🔧 Proposed fix
 ref={inputRef}
- type="text"+ type="email"+ autoComplete="email"+ inputMode="email"
aria-label="Email address"
🤖 Prompt for AI Agents
Verify each 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 244 - 260,
Update the email input in BetaModal’s form to use type="email" instead of
type="text", while preserving its existing validation, value handling, and other
attributes.
frontend/src/components/landing/useLanding.ts-593-597 (1)

593-597: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report invalid input instead of returning silently.

If value has no @, the function returns without setting subscribeError and without any other state change. The user presses the button and sees no response. An empty non-required type="email" field passes browser validation and reaches this branch.

🐛 Proposed fix
 const value = email.trim();
- if (!value.includes('@') || subscribing) return;+ if (subscribing) return;+ if (!value.includes('@')) {+ setSubscribeError('Enter a valid email address.');+ return;+ }
🤖 Prompt for AI Agents
Verify each 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 593 - 597, Update
subscribeNow so invalid email input, including an empty trimmed value or a value
without "@", sets subscribeError to the existing user-facing validation message
before returning. Keep the subscribing guard and valid-input flow unchanged,
ensuring the user receives feedback instead of a silent no-op.
frontend/src/components/landing-v5/Navbar.tsx-74-79 (1)

74-79: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The navbar stays interactive while it is invisible.

While heroMounted is false, opacity is 0 but pointerEvents remains 'auto'. For the first ~1.9s of the intro, every navbar control is focusable and clickable while nothing is visible.

♿ Proposed fix
 opacity: exploring ? 0 : heroMounted ? 1 : 0,
- pointerEvents: exploring ? 'none' : 'auto',+ pointerEvents: exploring || !heroMounted ? 'none' : 'auto',+ visibility: heroMounted ? 'visible' : 'hidden',
🤖 Prompt for AI Agents
Verify each 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 74 - 79, Update
the navbar style logic in the visible component so pointerEvents is set to
'none' whenever the navbar is invisible, including while heroMounted is false
and while exploring is true; restore 'auto' only when the navbar is visible and
interactive.
frontend/src/app/(public)/page.tsx-91-100 (1)

91-100: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear the subscription error when the modal closes.

state.subscribeError is shared by BetaModal and Journal and is never reset. After a failed attempt, the message stays visible in the Journal form and reappears the next time the modal opens. useLanding already exposes actions.resetSubscribeError for this, and it is currently unused.

🐛 Proposed fix
- onClose={() => setBetaOpen(false)}+ onClose={() => { actions.resetSubscribeError(); setBetaOpen(false); }}
🤖 Prompt for AI Agents
Verify each 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 91 - 100, Update the
BetaModal onClose handler to call actions.resetSubscribeError before closing the
modal with setBetaOpen(false), ensuring the shared subscription error is cleared
when the modal closes.
frontend/src/app/globals.css-1563-1569 (1)

1563-1569: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stylelint fails on the quoted Spectral family name.

font-family-name-quotes reports four errors: Lines 1565, 1713, 1784, and 1836. Spectral is a single identifier, so it needs no quotes.

🔧 Proposed fix (apply to all four sites)
- font-family: var(--font-spectral), 'Spectral', Georgia, serif;+ font-family: var(--font-spectral), Spectral, Georgia, serif;

As per static analysis hints, font-family-name-quotes is enabled for this file.

Also applies to: 1713-1713, 1784-1784, 1836-1836

🤖 Prompt for AI Agents
Verify each 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 1563 - 1569, Remove the
unnecessary quotes around the single-identifier Spectral font family name in all
four font-family declarations, including the declarations associated with the
visible landing-page style and the other reported sites, so they satisfy
font-family-name-quotes.

Source: Linters/SAST tools

frontend/src/components/landing-v5/lab/RoomsDemo.tsx-42-54 (1)

42-54: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The typing indicator and the delayed reply can name different partners.

send captures partner in the timeout closure. The indicator at Line 127 reads the live partner. If the user changes the select during the 1600 ms delay, the indicator shows the new partner and the message arrives from the old one.

♻️ Proposed fix
 const [partner, setPartner] = useState<Partner>('maya');
+ const partnerRef = useRef<Partner>('maya');+ partnerRef.current = partner;
 typeT.current = setTimeout(() => {
- const reply = `my ${partner.charAt(0).toUpperCase() + partner.slice(1)} graph has eigenvalues green — want me to quiz you on it?`;+ const who = partnerRef.current;+ const reply = `my ${who.charAt(0).toUpperCase() + who.slice(1)} graph has eigenvalues green — want me to quiz you on it?`;
setTyping(false);
- setMsgs((m) => [...m, { who: partner, text: reply, mine: false }]);+ setMsgs((m) => [...m, { who, text: reply, mine: false }]);
}, 1600);

Also applies to: 125-132

🤖 Prompt for AI Agents
Verify each 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/RoomsDemo.tsx` around lines 42 - 54,
Update send so the delayed reply and typing indicator use the same partner
snapshot captured when the message is sent. Store that snapshot in the relevant
state or timeout flow, and ensure the indicator rendering near the partner
display uses it until the delayed reply completes; preserve the existing
behavior when the partner does not change.
frontend/src/app/globals.css-1950-1957 (1)

1950-1957: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stylelint reports 45 keyframes-name-pattern errors in the ported blocks.

The ported keyframe names are camelCase, for example s1Stem, popHold, heroRise, and labIn. The rule expects kebab-case. Renaming is not safe here, because the names are referenced from inline animation strings in the landing components and in labData-driven markup.

Scope a rule disable to the ported region instead, so the rest of the file keeps the check.

🔧 Proposed approach
+/* stylelint-disable keyframes-name-pattern -- names are ported verbatim from the+ design components and are referenced by inline `animation` strings in TSX. */
`@keyframes` s1Stem { 0%,15% { stroke-dashoffset:27; } 34%,100% { stroke-dashoffset:0; } }

Close the block with /* stylelint-enable keyframes-name-pattern */ after the last ported keyframe.

As per static analysis hints, keyframes-name-pattern is enabled for this file.

Also applies to: 2094-2096

🤖 Prompt for AI Agents
Verify each 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 1950 - 1957, Scope-disable
Stylelint’s keyframes-name-pattern rule immediately before the ported keyframe
region containing names such as s1Stem, popHold, heroRise, and labIn, then
re-enable it after the final ported keyframe. Keep the rule active for all other
sections of globals.css and do not rename the animation identifiers.

Source: Linters/SAST tools

frontend/src/lib/landing/course.ts-363-376 (1)

363-376: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle the singular case for degree.

If degree is 1, the text reads "its 1 neighbours". A sub-topic linked only to its parent produces that value.

✏️ Proposed fix
- ' pulls this node plus its ' +- degree +- ' neighbours into context, then retrieves the chunks of your ' ++ ' pulls this node plus its ' ++ degree ++ (degree === 1 ? ' neighbour' : ' neighbours') ++ ' into context, then retrieves the chunks of your ' +
🤖 Prompt for AI Agents
Verify each 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/course.ts` around lines 363 - 376, Update nodeUses
so the text generated for degree uses singular “neighbour” when degree is 1 and
plural “neighbours” otherwise, preserving the existing wording and behavior for
all other values.
frontend/src/components/landing-v5/lab/CardsDemo.tsx-46-53 (1)

46-53: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard the window key listener against editable targets.

The listener runs for every keydown on the window. It calls e.preventDefault() for Space and rates the card for 1, 2, and 3. While this demo is mounted, a Space or a digit typed in any other input on the page is captured.

🛡️ Proposed fix
 const onKey = (e: KeyboardEvent) => {
+ const el = e.target as HTMLElement | null;+ if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) 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 around onKey to ignore events whose
target is an editable control, such as input, textarea, select, or
contenteditable elements, before handling Space or rate keys; preserve the
existing flip and rate behavior for non-editable targets.
frontend/src/components/landing-v5/lab/GradesDemo.tsx-62-65 (1)

62-65: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The row percentage is not clamped, but the weighted grade is.

Line 25 clamps each score to [0, r.possible]. Line 64 does not. If the user types 500 into a row worth 50, the row shows 1000% while the overall grade stays at the clamped value. Use the same clamp for both readouts.

🐛 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,
Update the row percentage calculation in the GB_ROWS.map callback to clamp the
parsed earned score to the range [0, r.possible] before computing the
percentage, matching the existing clamping used for the weighted grade and
keeping both readouts consistent.
frontend/src/components/landing-v5/lab/NotesDemo.tsx-57-66 (1)

57-66: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The "No concepts found" sentinel corrupts the footer count.

Line 63 pushes { name: 'No concepts found', tier: 'unexplored' } into the same concepts array that holds real matches. The footer at Lines 141-143 then reads concepts.length and prints 1 concepts linked to your MA 242 graph. after an empty extraction. The text is wrong, and the plural is wrong for a single match too.

Track the empty result separately instead of encoding it as a concept.

🐛 Proposed fix
- const [concepts, setConcepts] = useState<{ name: string; tier: keyof typeof LAB_TIER }[]>([]);+ const [concepts, setConcepts] = useState<{ name: string; tier: keyof typeof LAB_TIER }[]>([]);+ const [ran, setRan] = useState(false);
 const found = MATCHERS.filter((m) => m.hit.test(lower)).map((m) => ({ name: m.name, tier: m.tier }));
- setConcepts(found.length ? found : [{ name: 'No concepts found', tier: 'unexplored' }]);+ setConcepts(found);+ setRan(true);
setBusy(false);
- {concepts.length- ? `${concepts.length} concepts linked to your MA 242 graph.`- : 'Concepts you extract are linked to your graph, not just tagged on the note.'}+ {concepts.length+ ? `${concepts.length} concept${concepts.length === 1 ? '' : 's'} linked to your MA 242 graph.`+ : ran+ ? 'No concepts found in this note.'+ : 'Concepts you extract are linked to your graph, not just tagged on the note.'}

Also applies to: 140-144

🤖 Prompt for AI Agents
Verify each 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 - 66,
Update extract so an empty MATCHERS result is tracked separately rather than
added to concepts; keep concepts containing only real matches and render the
empty-state message independently. Adjust the footer rendering near the concepts
count to display the correct zero count and use singular/plural wording based on
the actual number of linked concepts.
frontend/src/components/landing-v5/Closing.tsx-154-182 (1)

154-182: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Composite ARIA roles are declared without their required child roles. Both sites put a composite role on a container and leave the children as plain <button> elements. ARIA requires owned children with the matching role, so assistive technology announces an empty widget and the selected item is not conveyed.

  • frontend/src/components/landing-v5/Closing.tsx#L154-L182: add role="menuitem" to each section button inside the role="menu" container.
  • frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124: add role="radio" and aria-checked={isPicked} to each option button inside the role="radiogroup" container.
🤖 Prompt for AI Agents
Verify each 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 154 - 182, Add
role="menuitem" to each section button rendered by SECTIONS.map in Closing.tsx
(frontend/src/components/landing-v5/Closing.tsx#L154-L182). In QuizDemo.tsx
(frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124), add
role="radio" and bind aria-checked to isPicked on each option button within the
radiogroup.
frontend/src/components/landing-v5/lab/CalendarDemo.tsx-86-112 (1)

86-112: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Blank leading cells are focusable empty buttons.

Every cell renders as a <button>, including the LEAD_BLANKS placeholders. The blank cells only set cursor: 'default', which does not remove them from the tab order. A keyboard user tabs through up to six empty buttons before reaching 1 October. The selected day also carries no aria-pressed, so the current selection is not announced.

♿ Proposed change
 <button
key={i}
onClick={() => { if (!c.blank) setDay(c.day as number); }}
type="button"
+ disabled={c.blank}+ aria-hidden={c.blank || undefined}+ aria-pressed={c.blank ? undefined : selected}
🤖 Prompt for AI Agents
Verify each 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/CalendarDemo.tsx` around lines 86 -
112, Update the day-cell buttons rendered by the shown.map callback so blank
placeholder cells are removed from keyboard focus, while retaining interactive
keyboard behavior for real days. Add aria-pressed to each non-blank day button
and set it according to the selected value so assistive technologies announce
the current selection.
frontend/src/components/landing-v5/Faq.tsx-71-76 (1)

71-76: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

"Read the full FAQ" links back to itself.

The anchor targets #faq, and the enclosing <section> already has id="faq" (Line 40). Clicking the link keeps the user on the same section, so the call to action does nothing.

This PR adds the /faq public route, and Closing.tsx Line 81 already links to it. Point this link at /faq.

🔗 Proposed change
- <a href="`#faq`" style={{ color: '`#0C5638`', textDecoration: 'underline' }}>Read the full FAQ</a>+ <Link href="/faq" style={{ color: '`#0C5638`', textDecoration: 'underline' }}>Read the full FAQ</Link>

Add the import:

 import { FAQS } from '`@/lib/landing/content`';
+import Link from 'next/link';
import { DragField } from './DragField';
🤖 Prompt for AI Agents
Verify each 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 71 - 76, Update the
“Read the full FAQ” anchor in the Faq component to navigate to the public /faq
route instead of the local `#faq` section, matching the existing link behavior in
Closing.tsx.
frontend/src/components/landing-v5/lab/GuideDemo.tsx-21-24 (1)

21-24: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Align RECENT.pick with the course exam lists. Neither Midterm 1 · Sep 30 nor Quiz 2 · Oct 03 exists in its course's GUIDES.exams. Opening either recent item sets an invalid select value while the guide heading displays that value.

🤖 Prompt for AI Agents
Verify each 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 in GuideDemo so each pick exactly matches an existing
exam value in the corresponding course’s GUIDES.exams list, preserving the
displayed recent-item behavior and ensuring the select value and guide heading
use valid options.
🤖 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/`(public)/page.tsx:
- Around line 130-131: Update the onLearn handler in the page component to open
the tutor panel at GAL index 7 instead of index 2, while leaving onQuiz
unchanged. Prefer the named GAL lookup or constant from content.ts rather than a
hard-coded index so card ordering changes cannot reintroduce this mismatch.
In `@frontend/src/components/landing-v5/ActTutor.tsx`:
- Around line 92-109: Update the tutor pills container in ActTutor to remove its
buttons from keyboard navigation while hidden by binding the engine’s docked
state to inert (or visibility). Preserve the existing opacity and pointerEvents
behavior, and ensure the pills become focusable again when docked is false.
In `@frontend/src/components/landing-v5/BetaModal.tsx`:
- Around line 132-143: Update the HeroCard responsive layout to use a single
column below the medium breakpoint and the existing two-column structure at
wider widths, using its CSS class rather than an unconditional
gridTemplateColumns style. Enable vertical scrolling for constrained viewports,
and apply the 560px minimum height only at the wider breakpoint so mobile
content, including the email field and submit button, remains accessible.
In `@frontend/src/components/landing-v5/Gallery.tsx`:
- Around line 62-85: Update the Card component so non-ghost cards are
keyboard-focusable and expose button-like semantics, with a key handler that
triggers the existing delegated Track click behavior for Enter and Space. Keep
ghost cards inert by omitting focusability, interactive semantics, and
activation handling, while preserving the current data-tk targeting.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 37-44: Update the rate callback to store its 300 ms timeout in a
ref instead of returning a cleanup function, and clear that timeout in an
unmount cleanup effect. Preserve the existing delayed setIdx behavior while
preventing it from firing after the component unmounts.
In `@frontend/src/components/landing-v5/navTheme.ts`:
- Around line 68-69: Update DARK_SECTIONS to contain only the section that
currently renders on a dark ground, act-graph, and revise its comment to
describe the single dark section accurately. Keep act-ingest and act-tutor out
of the list until their backgrounds are dark.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 218-229: Update the hero canvas effect around the lazy
`import('`@/lib/landing/engine/hero`')` to track whether cleanup has run, prevent
`startHeroCanvas` from starting after unmount, and stop it immediately if
initialization races with cancellation. Preserve the existing cleanup behavior
for normal mounted execution.
- Around line 277-282: Update the onKey handler in useLanding to handle Escape
while explore mode is active: after closing the gallery or jump menu as
appropriate, call exitExplore when s.current.exploring is true and neither
overlay was open. Ensure exitExplore is available at handler execution time
despite its later declaration, using the existing callback ordering or a ref as
needed.
In `@frontend/src/components/marketing/SignInModal.tsx`:
- Around line 130-153: Update the OAuth URL construction in the sign-in flow,
including sameTabUrl and popupUrl, to use the frontend-relative /api/auth/google
path instead of prepending API_URL. Preserve the existing popup_id query
parameter and same-tab fallback behavior so OAuth requests remain same-origin
and retain the session cookie.
In `@frontend/src/lib/landing/content.ts`:
- Around line 191-218: In frontend/src/lib/landing/content.ts lines 191-218,
mark the unavailable journal-founding.png and journal-ai-homework.png entries
with empty src values and isGraph: true so they use the graph fallback until
assets are shipped. In frontend/src/components/landing-v5/Journal.tsx lines
106-118, preserve the meta.isGraph branch that renders GraphThumb instead of
passing unusable paths to next/image.
In `@frontend/src/lib/landing/engine/sim.ts`:
- Around line 897-905: Update destroy() cleanup ordering so every cluster
element is re-parented to its original field before the cleanup that removes the
shell/overlay runs. Preserve the existing cleanup registration in ensureInit(),
but ensure repeated createSim() calls can still find the original
[data-dragnode] elements after destroy().
---
Outside diff comments:
In `@frontend/src/lib/deployGuard.ts`:
- Around line 39-46: Update classify to normalize URL values by removing
trailing slashes before comparing against FRONTEND_ENVS fields, while preserving
existing trimming and case normalization. Ensure both API and site URL
classifications recognize trailing-slash variants, and add regression coverage
for trailing-slash API and site URLs.
---
Minor comments:
In `@frontend/src/app/`(public)/about/page.tsx:
- Around line 80-84: Update the paragraph near the prose content to remove the
nonexistent navbar feedback-button reference, and direct readers to an available
control such as the GitHub link or a contact address instead. Preserve the
surrounding message about reporting issues and sharing ideas.
In `@frontend/src/app/`(public)/careers/[slug]/page.tsx:
- Around line 18-22: Update the metadata construction in the careers page’s
generateMetadata flow so the description appends “…” only when job.description
exceeds 155 characters; leave shorter or exactly 155-character descriptions
without a truncation marker while preserving the existing 155-character slice.
In `@frontend/src/app/`(public)/news/page.tsx:
- Around line 175-199: The news cards’ CTA text incorrectly promises article
navigation while each Link in the posts.map block targets /#newsletter. Update
the CTA span text from “Read article →” to wording that reflects newsletter
signup, such as “Get notified →”, while preserving the existing link destination
and styling.
In `@frontend/src/app/`(public)/page.tsx:
- Around line 91-100: Update the BetaModal onClose handler to call
actions.resetSubscribeError before closing the modal with setBetaOpen(false),
ensuring the shared subscription error is cleared when the modal closes.
In `@frontend/src/app/`(public)/team/page.tsx:
- Around line 18-22: Update the metadata exports in
frontend/src/app/(public)/team/page.tsx lines 18-22 and
frontend/src/app/(public)/wiki/page.tsx lines 18-22 to include
alternates.canonical, using /team for the team route and /wiki for the wiki
route.
In `@frontend/src/app/`(public)/wiki/page.tsx:
- Around line 28-31: Move scrollMarginTop: 84 from the H2 style to the section
styling used by the sections targeted by the TOC fragment links (`#graph`,
`#mastery`, `#review`, `#tutor`, `#ingestion`, `#grades`, and `#privacy`). Keep the heading
typography unchanged and ensure each target section receives the offset so it
clears the sticky header.
In `@frontend/src/app/globals.css`:
- Around line 1563-1569: Remove the unnecessary quotes around the
single-identifier Spectral font family name in all four font-family
declarations, including the declarations associated with the visible
landing-page style and the other reported sites, so they satisfy
font-family-name-quotes.
- Around line 1950-1957: Scope-disable Stylelint’s keyframes-name-pattern rule
immediately before the ported keyframe region containing names such as s1Stem,
popHold, heroRise, and labIn, then re-enable it after the final ported keyframe.
Keep the rule active for all other sections of globals.css and do not rename the
animation identifiers.
In `@frontend/src/components/companion/CompanionShell.tsx`:
- Around line 86-108: Update the CompanionShell root element to include the
landing-dc class so the existing responsive media-query rules apply to its
nav-compact and nav-tabs elements. Preserve the current navigation structure and
styling.
In `@frontend/src/components/landing-v5/BetaModal.tsx`:
- Around line 244-260: Update the email input in BetaModal’s form to use
type="email" instead of type="text", while preserving its existing validation,
value handling, and other attributes.
In `@frontend/src/components/landing-v5/Closing.tsx`:
- Around line 154-182: Add role="menuitem" to each section button rendered by
SECTIONS.map in Closing.tsx
(frontend/src/components/landing-v5/Closing.tsx#L154-L182). In QuizDemo.tsx
(frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124), add
role="radio" and bind aria-checked to isPicked on each option button within the
radiogroup.
In `@frontend/src/components/landing-v5/Faq.tsx`:
- Around line 71-76: Update the “Read the full FAQ” anchor in the Faq component
to navigate to the public /faq route instead of the local `#faq` section, matching
the existing link behavior in Closing.tsx.
In `@frontend/src/components/landing-v5/lab/CalendarDemo.tsx`:
- Around line 86-112: Update the day-cell buttons rendered by the shown.map
callback so blank placeholder cells are removed from keyboard focus, while
retaining interactive keyboard behavior for real days. Add aria-pressed to each
non-blank day button and set it according to the selected value so assistive
technologies announce the current selection.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 46-53: Update the keydown handler in the useEffect around onKey to
ignore events whose target is an editable control, such as input, textarea,
select, or contenteditable elements, before handling Space or rate keys;
preserve the existing flip and rate behavior for non-editable targets.
In `@frontend/src/components/landing-v5/lab/GradesDemo.tsx`:
- Around line 62-65: Update the row percentage calculation in the GB_ROWS.map
callback to clamp the parsed earned score to the range [0, r.possible] before
computing the percentage, matching the existing clamping used for the weighted
grade and keeping both readouts consistent.
In `@frontend/src/components/landing-v5/lab/GuideDemo.tsx`:
- Around line 21-24: Update the RECENT entries in GuideDemo so each pick exactly
matches an existing exam value in the corresponding course’s GUIDES.exams list,
preserving the displayed recent-item behavior and ensuring the select value and
guide heading use valid options.
In `@frontend/src/components/landing-v5/lab/NotesDemo.tsx`:
- Around line 57-66: Update extract so an empty MATCHERS result is tracked
separately rather than added to concepts; keep concepts containing only real
matches and render the empty-state message independently. Adjust the footer
rendering near the concepts count to display the correct zero count and use
singular/plural wording based on the actual number of linked concepts.
In `@frontend/src/components/landing-v5/lab/RoomsDemo.tsx`:
- Around line 42-54: Update send so the delayed reply and typing indicator use
the same partner snapshot captured when the message is sent. Store that snapshot
in the relevant state or timeout flow, and ensure the indicator rendering near
the partner display uses it until the delayed reply completes; preserve the
existing behavior when the partner does not change.
In `@frontend/src/components/landing-v5/Navbar.tsx`:
- Around line 74-79: Update the navbar style logic in the visible component so
pointerEvents is set to 'none' whenever the navbar is invisible, including while
heroMounted is false and while exploring is true; restore 'auto' only when the
navbar is visible and interactive.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 593-597: Update subscribeNow so invalid email input, including an
empty trimmed value or a value without "@", sets subscribeError to the existing
user-facing validation message before returning. Keep the subscribing guard and
valid-input flow unchanged, ensuring the user receives feedback instead of a
silent no-op.
In `@frontend/src/lib/landing/course.ts`:
- Around line 363-376: Update nodeUses so the text generated for degree uses
singular “neighbour” when degree is 1 and plural “neighbours” otherwise,
preserving the existing wording and behavior for all other values.
In `@frontend/src/lib/landing/engine/dom.ts`:
- Around line 49-73: Reject zero-sized measurements before caching them: in
frontend/src/lib/landing/engine/dom.ts lines 49-73, ensure cv does not assign
rec.__cv when w or h is 0; in frontend/src/lib/landing/engine/marquee.ts lines
136-144, ensure the marquee measurement does not call m.setW when the summed
child offsetWidth is 0. Preserve normal caching and measurement behavior for
positive dimensions.
In `@frontend/src/lib/landing/engine/marquee.ts`:
- Around line 69-114: Bind the existing onUp and onCancel release handlers to
window in addition to track, matching the dual registration pattern used by
sim.ts. Keep the current track listeners and end() behavior unchanged so drags
are finalized even when pointer capture fails or the pointer leaves the track.
In `@frontend/src/lib/landing/engine/sim.ts`:
- Around line 330-369: Update the SVG build logic in the rings and lines loops
to validate required structure before creating simulation entries: skip rings
when the previous glow element or next label element is missing, and skip lines
when either endpoint lookup in near() returns -1. Keep valid ring and line
processing unchanged, ensuring no nodes or links are created with null siblings
or invalid endpoint indices.
---
Nitpick comments:
In `@frontend/src/app/`(public)/careers/CareersList.tsx:
- Around line 217-284: Update the collapsible panel style in the job card
rendering to include visibility based on isOpen: visible when expanded and
hidden when collapsed. Add visibility to the existing transition so the state
changes with the panel animation while keeping the Apply for this role link and
other content inaccessible during collapse.
- Around line 51-55: Replace both raw img elements in CareersList with the
next/image component, matching the existing usage in CompanionShell for
/sapling-icon.svg. Preserve the current alt text and styling, and set the second
occurrence’s dimensions to width={20} and height={20} while retaining the first
occurrence’s 26×26 sizing.
In `@frontend/src/app/`(public)/faq/page.tsx:
- Around line 60-89: Update the FAQ answer panel associated with the button
using item.i: assign it a unique id, add matching aria-controls to the button,
and include visibility in the panel styles so it is hidden when collapsed and
visible when open. Preserve the existing max-height and opacity transitions.
In `@frontend/src/app/`(public)/news/page.tsx:
- Around line 111-153: Update the category menu logic around the existing
outside pointerdown listener to handle Escape while the menu is open, close it,
and return focus to the trigger button. Add a ref to the trigger button and
focus it both when Escape dismisses the menu and when selecting an option via
the NEWS_FILTERS buttons, while preserving the existing outside-click behavior.
In `@frontend/src/components/companion/CompanionShell.tsx`:
- Around line 84-141: Wrap the primary link group currently rendered by the
`nav-tabs` container in a `<nav>` landmark with a descriptive `aria-label`,
closing it after the GitHub and Ko-fi links. Also locate the header’s footer
link row and wrap it in a separate appropriately labelled `<nav>` element,
preserving the existing links and layout.
In `@frontend/src/components/landing-v5/ActIngest.tsx`:
- Around line 259-282: Add aria-hidden="true" to the decorative flying chip
elements rendered in the CHIPS.map callback within ActIngest, ensuring assistive
technologies ignore these duplicate labels while leaving their visual animation
and styling unchanged.
In `@frontend/src/components/landing-v5/ActTutor.tsx`:
- Around line 95-97: Add type="button" to the pill button rendered in ActTutor’s
button map alongside its existing key and onClick props, matching the explicit
button type used by the other buttons in this PR.
In `@frontend/src/components/landing-v5/Closing.tsx`:
- Line 92: Replace the hardcoded “© 2026” values in the Closing component with a
single dynamically derived current year, and reuse that value in both copyright
locations near the existing spans.
In `@frontend/src/components/landing-v5/DragField.tsx`:
- Around line 70-103: Update the label branch in the nodes mapping within
DragField’s render flow to always create a text element, including when label is
missing. Preserve the glow, ring, and label ordering and use an empty
placeholder for missing label content while retaining valid label positioning
and styling.
In `@frontend/src/components/landing-v5/Faq.tsx`:
- Around line 84-121: Update the FAQ trigger and answer panel so each button in
the mapped FAQ item has an aria-controls reference matching that panel’s unique
id. Add hidden or equivalent visibility handling to the panel when open is
false, while preserving the existing expanded state and visual transition
behavior.
In `@frontend/src/components/landing-v5/FeatureLab.tsx`:
- Around line 41-50: Add dialog semantics to the fixed overlay in FeatureLab by
assigning role="dialog", aria-modal="true", and an accessible name tied to the
panel heading or another existing label; implement focus movement to the close
button when open and restore focus when closed. In the tool rail, add an
explicit active-state attribute such as aria-current or aria-selected to the
open tool instead of relying on background color alone.
In `@frontend/src/components/landing-v5/Gallery.tsx`:
- Around line 87-109: Add an explicit accessible role to the div rendered by
Track so its existing aria-label is announced by assistive technology; use
role="group" without changing the current card rendering or interaction
behavior.
- Around line 71-73: Remove the redundant frame styling from the wrapper div
rendering GALLERY_MINIS[i] in Gallery: eliminate its duplicated height,
borderRadius, background, border, and overflow/position container behavior as
appropriate, while preserving the mini’s own frame and layout. Keep the
aria-hidden wrapper only if needed without adding a second visual frame.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 100-115: Update the flip target around the onClick={flip} handler
to be keyboard accessible: prefer a button element, or add role="button",
tabIndex={0}, and an onKeyDown handler that invokes flip for Enter and Space
while preventing the default Space behavior. Preserve the existing click
interaction and visual styling.
In `@frontend/src/components/landing-v5/lab/GradesDemo.tsx`:
- Around line 74-83: Update the onChange sanitizer in the GradesDemo input to
allow at most one decimal point while preserving digits and the existing state
update behavior. Ensure values such as “1.2.3” are normalized so the displayed
text matches the value used by score computation.
In `@frontend/src/components/landing-v5/lab/GuideDemo.tsx`:
- Line 40: Use a consistent lookup style for the GUIDES map in the GuideDemo
component: update the initialization around set and the lookup in the relevant
course iteration around c so both rely on the CourseKey type without an
unnecessary nullish fallback, or both use the same fallback pattern. Preserve
the existing guide-selection behavior while removing the inconsistent
implication that GUIDES[course] may be missing.
In `@frontend/src/components/landing-v5/lab/NotesDemo.tsx`:
- Around line 57-80: Store the timers created by extract and summarize in refs
alongside saveT, clear their handles on unmount, and reset each ref after its
callback runs. Follow the existing timer-ref cleanup pattern in FeatureLabDemo
so callbacks cannot update state after the component unmounts.
In `@frontend/src/components/landing-v5/lab/QuizDemo.tsx`:
- Line 31: Extract the repeated q.options correctness check into a shared
predicate or derived value near the existing right calculation, then reuse it in
both locations. Update submit’s setStreak call to use the functional updater
form based on the previous streak value, preserving the existing
increment-or-reset behavior without reading streak from the render closure.
In `@frontend/src/components/landing-v5/lab/RoomsDemo.tsx`:
- Around line 76-106: Update the message-list container in RoomsDemo so it has
aria-live="polite" and automatically scrolls to the newest message after each
append. Use a ref and an effect tied to the message-list state, preserving the
existing rendering and styling.
In `@frontend/src/components/landing-v5/Navbar.tsx`:
- Around line 199-234: Remove the incomplete ARIA menu pattern from the navbar:
delete role="menu" from the panel and aria-haspopup="menu" from its trigger,
while preserving the existing links and open/close behavior. Expose the panel as
a plain navigation group without adding menuitem roles or keyboard navigation.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 383-399: Optimize both scanning effects in useLanding: replace the
first effect’s recurring setInterval(scan, 900) polling with a MutationObserver
watching root and invoking scan for relevant DOM additions, while preserving
cleanup and observer behavior. In the second effect around the section
descendant traversal, query only elements marked with the intended animation
attribute (such as [data-anim]) instead of '*', and retain the existing
animation logic for matched elements.
In `@frontend/src/lib/landing/companionContent.ts`:
- Around line 149-160: Update WIKI_TIERS.dot and WIKI_RATINGS.tone to store the
raw hex color values from TIER instead of CSS declaration strings. In the wiki
page consumer, remove the cssColor regex and parsing/fallback logic, and use
t.dot and r.tone directly as colors.
In `@frontend/src/lib/landing/content.ts`:
- Around line 38-135: Document that GAL’s array order is a positional contract
and must not be reordered or have entries inserted without updating callers. Add
and export a galIndexOf helper keyed by GalleryKind, then update the page.tsx
actions.openGal calls to resolve indices through that helper instead of
hard-coded positions.
In `@frontend/src/lib/landing/dragClusters.ts`:
- Line 36: Remove the redundant `as DragCluster[]` assertion from the
`DRAG_CLUSTERS` array literal, preserving its existing `DragCluster[]` type
annotation so excess-property and required-field checks remain enabled.
In `@frontend/src/lib/landing/engine/flip.ts`:
- Around line 24-37: Remove the unused ran field from the FlipState interface
and its initialization in createFlipState, and remove all assignments to st.ran,
including in armFlip and flipClose. Keep the actual animation guard in
useLanding.ts via flipRan unchanged.
In `@frontend/src/lib/landing/engine/plant.ts`:
- Around line 78-91: Update restore() to validate the parsed localStorage value
as an array, retain only string entries, and truncate each label to MAX_LABEL
before calling add() or updating labels. Ensure invalid or non-array data
produces an empty restored label set without partially processing it, while
preserving the seven seed nodes and normal return count.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

Comment threadfrontend/src/app/(public)/page.tsx Outdated
Comment on lines +92 to +109
{/* the engine fades these in once the act's scroll is spent */}
<div data-tutor-pills="1" style={{ display: 'flex', gap: 8, marginTop: 8, opacity: 0, pointerEvents: 'none', transition: 'opacity 400ms ease' }}>
{CAPTIONS.map(([tag], i) => (
<button
key={tag}
onClick={() => onSetMode(i)}
style={{
border: '1px solid rgba(12,86,56,0.3)', borderRadius: 99, padding: '9px 18px',
...MONO, fontSize: 10.5, letterSpacing: '0.12em', cursor: 'pointer',
background: tutorMode === i ? PILL_ON : 'transparent',
color: tutorMode === i ? PILL_ON_FG : '#33443B',
transition: 'all 250ms',
}}
>
{tag}
</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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Find what the engine writes on the tutor pills element.
rg -n -C 6 'tutor-pills|tutorPills' --glob '*.ts' --glob '*.tsx'

Repository: SaplingLearn/Sapling

Length of output: 158


🏁 Script executed:

#!/bin/bashset -e
printf'%s\n''--- candidate files ---'
fd -i 'ActTutor|tutor|landing-v5'.| head -80
printf'%s\n''--- all tutor-pills references ---'
rg -n -C 8 --hidden --glob '!node_modules' --glob '!dist' --glob '!build''tutor-pills|tutorPills|data-tutor'.printf'%s\n''--- ActTutor structure ---'
ACT=$(fd -t f -i 'ActTutor.tsx'.| head -1)if [ -n"$ACT" ];then
ast-grep outline "$ACT"
sed -n '1,180p'"$ACT"fi

Repository: SaplingLearn/Sapling

Length of output: 19055


🏁 Script executed:

#!/bin/bashset -e
printf'%s\n''--- engine writes to M.pills ---'
rg -n -C 10 'M\.pills|pills\.(style|classList)|style\.(opacity|pointerEvents|visibility)|opacity.*pills|pills.*opacity' frontend/src/lib/landing frontend/src/components/landing-v5 --glob '*.ts' --glob '*.tsx'printf'%s\n''--- engine files and relevant structure ---'
fd -t f . frontend/src/lib/landing/engine -x sh -c 'echo --- "$1"; wc -l "$1"; ast-grep outline "$1" | head -80' sh
printf'%s\n''--- direct pills-related engine context ---'
rg -n -C 20 'pills|handover|spent|scroll' frontend/src/lib/landing/engine --glob '*.ts' --glob '*.tsx'

Repository: SaplingLearn/Sapling

Length of output: 50377


Remove hidden pills from the keyboard tab order.

The engine changes only opacity and pointerEvents, so hidden buttons remain focusable. Toggle inert with the engine’s docked state, or toggle visibility there as well.

🤖 Prompt for AI Agents
Verify each 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 92 - 109,
Update the tutor pills container in ActTutor to remove its buttons from keyboard
navigation while hidden by binding the engine’s docked state to inert (or
visibility). Preserve the existing opacity and pointerEvents behavior, and
ensure the pills become focusable again when docked is false.

Comment threadfrontend/src/components/landing-v5/BetaModal.tsx
Comment threadfrontend/src/components/landing-v5/Gallery.tsx
Comment threadfrontend/src/components/landing-v5/lab/CardsDemo.tsx
Comment threadfrontend/src/components/landing/useLanding.ts
Comment threadfrontend/src/components/landing/useLanding.ts
Comment threadfrontend/src/components/marketing/SignInModal.tsx Outdated
Comment threadfrontend/src/lib/landing/content.ts Outdated
Comment on lines +191 to +218
/**
* Presentation metadata paired positionally with POSTS.
*
* NOTE: `journal-founding.png` and `journal-ai-homework.png` are not in
* `public/` yet — both exceed the design API's 256 KiB read cap, so they
* ship with the project export alongside the rest of the source.
*/
export const POST_META: PostMeta[] = [
{
slot: 'journal-1',
src: '/journal-founding.png',
slotHint: 'Drop the founding photo',
isPhoto: true,
isGraph: false,
comments: 12,
likes: 84,
floatDelay: '0s',
},
{
slot: 'journal-2',
src: '/journal-ai-homework.png',
slotHint: 'Drop an image for this essay',
isPhoto: true,
isGraph: false,
comments: 31,
likes: 126,
floatDelay: '-2.5s',
},

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

Two journal images are declared but may not ship.POST_META points at /journal-founding.png and /journal-ai-homework.png, and the file's own comment states both assets are absent from public/. Journal.tsx renders those paths through next/image with no fallback, so two of the three journal cards can show a broken image.

  • frontend/src/lib/landing/content.ts#L191-L218: add the two PNG files to public/, or set src: '' and isGraph: true on those entries until the assets ship.
  • frontend/src/components/landing-v5/Journal.tsx#L106-L118: keep the meta.isGraph branch as the fallback path, so an entry without a usable src renders GraphThumb instead of a broken image.
📍 Affects 2 files
  • frontend/src/lib/landing/content.ts#L191-L218 (this comment)
  • frontend/src/components/landing-v5/Journal.tsx#L106-L118
🤖 Prompt for AI Agents
Verify each 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/content.ts` around lines 191 - 218, In
frontend/src/lib/landing/content.ts lines 191-218, mark the unavailable
journal-founding.png and journal-ai-homework.png entries with empty src values
and isGraph: true so they use the graph fallback until assets are shipped. In
frontend/src/components/landing-v5/Journal.tsx lines 106-118, preserve the
meta.isGraph branch that renders GraphThumb instead of passing unusable paths to
next/image.

Comment threadfrontend/src/lib/landing/engine/sim.ts
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — v5 marketing landing onto staging

This ports the v5 marketing landing onto staging as a public-surface-only change, and the central claim in the description holds up: I verified there are no backend/** files, no migrations, no middleware.ts, no next.config.ts and no wrangler.toml in the diff (74/74 changed files are under frontend/), and git diff --diff-filter=D is empty — nothing is deleted out from under another surface. The Layer-2 token scoping is done properly (.public-surface, .landing-page in globals.css:828, with deliberate literal fallbacks on .card--hero), and every new landing-v5/marketing/companion component carries "use client". The problems are elsewhere: a dropped auth-error path, staging metadata that points at production, a colour system that bypasses the design tokens entirely, and — the big one — this branch is a stale snapshot of a landing that is already merged on main.

Findings

P0

[P0] Both CI jobs are red, and the failures hide the fact that nothing was typechecked or tested.github/workflows/ci.yml:41,90-94

Backend (ruff check ., run 31553863104):

F401 [*] `pydantic.Field` imported but unused
--> models/gradescope.py:12:33
Found 1 error.
##[error]Process completed with exit code 1.

Frontend (npx eslint ., same run):

frontend/src/components/settings/GradescopeConnect.tsx
65:6 error React Hook useEffect has a missing dependency: 'refreshAll'. Either include it or
remove the dependency array react-hooks/exhaustive-deps
✖ 33 problems (1 error, 32 warnings)
##[error]Process completed with exit code 2.

To be clear about attribution: neither is yours. Both reproduce verbatim at the base commit 82787cabackend/models/gradescope.py imports Field with zero Field( uses there, and GradescopeConnect.tsx:65 is already }, []);. gh run list --branch staging shows staging's last green run was 8bce5b02 on 2026-07-10; 82787ca never ran CI, so it landed red. The reason this is still P0 for this PR: the workflow runs ruff before pytest, and eslint before npx tsc --noEmit and npm test. Both jobs die at the lint step, so the type checker and both test suites never executed against these 14,954 added lines. You currently have no signal that the port compiles. Fixing base (or rebasing once it is fixed) is a prerequisite for this PR meaning anything.

P1

[P1] ?error=session_expired no longer reaches the sign-in modalfrontend/src/app/(public)/page.tsx:89

<SignInModalopen={signInOpen}onClose={()=>setSignInOpen(false)}/>

middleware.ts (untouched here) bounces expired sessions to the landing with a query param:

// middleware.ts:38if(errorCode)u.searchParams.set('error',errorCode)// middleware.ts:52 and :67if(!session)returnredirectToSignin(request,'session_expired')

The page this replaces read that param and threaded it through — base (public)/page.tsx:90const err = params.get('error'), then :879errorCode={signInError}. The new page never reads ?error (no searchParams/URLSearchParams anywhere in page.tsx or useLanding.ts), so the prop is never supplied. Meanwhile components/marketing/SignInModal.tsx still ships the whole error surface — ERROR_COPY at :11 including session_expired at :16, and :122const visibleError = localError ?? errorCode ?? null — all now dead. Net effect: a user kicked out mid-session lands on the marketing page with no explanation of why they were signed out. Restore the ?error= read and pass it through.

[P1] The staging deploy will advertise production canonical and OG URLsfrontend/src/app/layout.tsx:43

metadataBase: newURL(resolveSiteUrl(process.env)),alternates: {canonical: '/'},

resolveSiteUrl falls through to production when neither knob is set:

// deployGuard.ts:98-106exportfunctionresolveSiteUrl(env: EnvSource): string{constdeployEnv=(env.DEPLOY_ENV??'').trim().toLowerCase();if(deployEnv&&deployEnvinFRONTEND_ENVS)returnFRONTEND_ENVS[deployEnvasFrontendEnv].siteUrl;constexplicit=(env.NEXT_PUBLIC_SITE_URL??'').trim();if(explicit)returnexplicit.replace(/\/+$/,'');returnFRONTEND_ENVS.production.siteUrl;}

frontend/wrangler.toml is not modified by this PR, and its [env.staging.vars] block sets only NEXT_PUBLIC_API_URL, BACKEND_URL and COOKIE_DOMAINneither DEPLOY_ENV nor NEXT_PUBLIC_SITE_URL. So on frontend-staging every page resolves metadataBase to https://saplinglearn.com, making canonical, og:url and og:image all point at production. Since the entire point of this PR is "show the new landing on staging", any staging link shared for review will unfurl with production's card, and staging self-canonicalises to prod. Set DEPLOY_ENV = "staging" in [env.staging.vars] (which also arms the existing checkFrontendDeployEnv lock in next.config.ts:46).

[P1] The landing runs a parallel colour palette that shares no value with the design tokensfrontend/src/components/landing-v5/*

Counting hex literals across landing-v5 (excluding the galleryMinis illustration art) gives ~900 hardcoded colours. The most frequent:

79 #0c5638 48 #fdfcf9 46 #0e9e5a 40 #12201a
37 #33443b 34 #9aa5a0 31 #8b9891 30 #8fd9a8

Not one of these is a token. The core set in globals.css is --brand-forest: #1B6C42, --brand-forest-bright: #2D8F5C, --sap-500: #4e873c, --ink-0: #faf8f3, --ink-800: #1a1814. Note the near-misses in particular: #fdfcf9 vs --ink-0 #faf8f3, and #12201a vs --ink-800 #1a1814. They are close enough to look like the brand and different enough to be visibly off it at the seam where a user crosses from the landing into the app shell. The Engineering Style Guide §3/§4 is explicit — "inline style objects referencing global CSS-variable tokens … Use the tokens; don't hardcode values." This is doubly odd because the PR itself builds the correct scaffolding: (public)/layout.tsx mounts .public-surface and globals.css:839-841 defines --surface-hero/--brand-glow on it, and then no component uses any of it.

[P1] This is a stale snapshot of a landing already merged on main, and promoting it will fight main — whole landing-v5 tree

origin/main is 4f5a7f9 Merge pull request #524 from SaplingLearn/feat/landing-v5-port — the v5 landing is already there. git merge-base origin/staging origin/main is 57469ad, which contains nofrontend/src/components/landing-v5/, so the directory is added independently on both sides: promoting stagingmain is an add/add conflict across ~50 files, not a fast merge. main has taken 19 commits on the landing since the merge-base, and git diff origin/main 395588da over the landing tree is only 4 files / +9 −38 — i.e. this branch is behind, missing a3d0371 fix(landing): raster, veil and scroll-coupling polish on the v5 hero (the MAG/zoom raster correction in ActTutor.tsx, and the re-tuned hero washes in Hero.tsx).

The dangerous part is that one of those divergences is invisible to the type checker:

// this branch — hero3d/index.ts:52-56 (an intensity multiplier, 0..2)exportfunctionstartHeroRig(canvas,getMouse,getParallax: ()=>number)// origin/main — hero3d/index.ts:76-80 (a scroll offset in pixels)exportfunctionstartHeroRig(canvas,getMouse,getHeroShiftPx: ()=>number)

Both are () => number, and both hero3d/index.ts and useLanding.ts are in the conflict set. Resolving that conflict by taking one file from each side compiles cleanly and silently breaks the hero. Please rebase this branch onto main's landing rather than re-adding an older copy, or state explicitly how the promotion merge will be resolved.

P2

[P2] Three of the four new deployGuard exports have no production callerfrontend/src/lib/deployGuard.ts:52-146

resolveFrontendEnv, expectedEnvForHost and detectHostConfigMismatch are referenced only by deployGuard.test.ts. resolveSiteUrl is the only one wired into anything (layout.tsx:4). detectHostConfigMismatch's own docstring bills it as "Runtime defence-in-depth for what the build-time guard cannot see", citing "the exact failure behind staging's session_expired" — but middleware.ts is not in this diff, so nothing calls it and it defends nothing. That is ~100 lines of inert code plus 131 lines of tests landing in a shared lib on a marketing PR. Either wire it into the middleware or drop it from this PR.

[P2] components/SignInModal.tsx is now orphaned, and its replacement is a near-copyfrontend/src/components/SignInModal.tsx

After this PR the only SignInModal import in the tree is (public)/page.tsx:14@/components/marketing/SignInModal. The original components/SignInModal.tsx (347 lines) has zero importers but is still in the tree; the new components/marketing/SignInModal.tsx is 352 lines and diff between the two is 67 lines. Two copies of the OAuth modal, one of them dead, will drift — and the dead one is the one that still gets fed errorCode in nobody's code path. The Engineering Style Guide §3 calls this out by name as the reason components/ui/ exists ("before they landed the app had 5 distinct button shapes and 18+ pill/badge implementations"). Delete the orphan or make the marketing one a thin wrapper.

[P2] A new eslint suppression was added for code introduced in this PRfrontend/eslint-suppressions.json

"src/components/marketing/SignInModal.tsx": {
"react-hooks/set-state-in-effect": { "count": 1 }
},

Style Guide §7 states the ratchet's purpose as "don't fix the whole backlog to land a change, but never add to it." The baseline exists to grandfather legacy violations; marketing/SignInModal.tsx is created by this PR, so this is a new violation being suppressed rather than fixed. set-state-in-effect is also usually a real render-loop smell worth a look rather than a waiver.

[P2] .landing-page no longer exists, leaving dead CSS and a comment that now says the opposite of the truthfrontend/src/app/(public)/layout.tsx:7

// The landing page keeps its own .landing-page class for the mesh-background visual.exportdefaultfunctionPublicLayout({ children }: {children: React.ReactNode}){return<divclassName="public-surface">{children}</div>;}

No element in the tree carries className="landing-page" at this HEAD — the only remaining mentions are three comments. But globals.css:866-874 still ships the whole mesh block that depends on it:

.landing-page .sapling-mesh-blob { position: absolute; border-radius:50%; filter:blur(100px); … }
.landing-page .sapling-mesh-blob--1 { top:-10%; left:-10%; … }

along with the other .landing-page-prefixed rules the diff kept. All unreachable. Either the wrapper should still apply the class (if the mesh backdrop is meant to render) or the rules and the comment should go.

P3

[P3] 1.3 MB of unoptimized PNGs committedfrontend/public/

journal-founding.png is 821,816 bytes and journal-ai-homework.png is 500,189 bytes. They are rendered through next/image with a sizes hint (Journal.tsx:110-114), so delivery is fine, but the repo and the OpenNext asset bundle both carry the full-size originals. Worth running them through a compressor or shipping WebP/AVIF sources.

What's good

  • The scope discipline is real and verifiable: no backend files, no migrations, no middleware.ts, no wrangler.toml, and nothing deleted — exactly what the description promises, and a much better call than merging the 217-commit branch with its colliding 0032_*/0033_* migrations.
  • The Layer-2 token boundary is set up correctly — .public-surface scoping in (public)/layout.tsx mirroring (shell), with the literal fallbacks on .card--hero and a comment explaining exactly why they are there. That is the right structure; the components just need to start using it.
  • "use client" is correct on every new hook-using component, and the deployGuard additions are genuinely well-tested (131 lines of focused unit tests) even though most of the code they cover is not yet wired up.

Verdict: request changes — the auth-error regression and the staging-metadata bug are both user-visible, and the branch needs to be reconciled with the v5 landing already on main before it can be promoted.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…PLOY_ENV
CI was red at the LINT step on both jobs, so tsc, pytest and vitest never
ran on this port at all.
- backend/models/gradescope.py: drop the unused pydantic Field import (F401).
- settings/GradescopeConnect.tsx: stabilise refreshAll with useCallback and
list it in the mount effect (exhaustive-deps is an error here). Keying on
userId also fixes the stale-account bug the empty dep array hid.
- (public)/page.tsx: read the ?error= code middleware.ts redirects with and
thread it into the modal, opening it when a code is present. Read through
useSyncExternalStore so the page stays statically prerenderable and no
setState lands in an effect; dismissing the modal strips the param.
- page.tsx: openGal indices now come from galIndexOf('quiz'/'tutor') — the
'Ask the tutor' button opened the notetaker panel. BetaModal onClose now
calls resetSubscribeError so a failed subscribe stops leaking into Journal.
- marketing/SignInModal.tsx: fix set-state-in-effect at source (reset during
render on the open prop change) and remove the suppression entry it was
given. OAuth now uses the frontend-relative /api/auth/google for both the
popup and the same-tab fallback, so the session cookie is not third-party.
- delete the orphaned components/SignInModal.tsx (zero importers) and point
the eslint data-testid rule at its replacement.
- deployGuard.classify: normalise trailing slashes on URL fields (not on
cookieDomain) so a valid slashed API/site URL cannot silently disarm the
guard. Five regression tests, all of which fail without the fix.
- middleware.ts: wire resolveFrontendEnv + detectHostConfigMismatch in, so a
host/backend mismatch reports env_misconfig instead of looping the user
through session_expired forever.
- wrangler.toml/package.json: set DEPLOY_ENV per environment so staging stops
advertising production canonical/OG URLs and the build-time guard is armed.
- globals.css/(public)/layout.tsx: delete the ~950 lines of .landing-page CSS
the replaced page made unreachable, fix the inverted comment, unquote the
single-identifier Spectral, and scope a keyframes-name-pattern disable to
the ported region only.
The a11y fixes in this PR all defend behaviour that is invisible on screen
and in review: a gallery card that only opens on click, a navbar that stays
clickable while transparent, an accordion whose closed answers remain in the
tab order, a role="radiogroup" containing no radios.
Two lab-demo state bugs get the same treatment — a reply naming a different
partner than the typing indicator, and a footer counting an empty-result
sentinel as "1 concepts" — plus the galIndexOf contract that replaced the
hard-coded GAL indices.
Renamed from the scratch file that commit b6c3a8a swept in mid-edit.
- news: card CTA said 'Read article' while every card links to /#newsletter;
it now says 'Get notified' and the Link carries a matching aria-label. The
category listbox closes on Escape and returns focus to its trigger (also on
option select) — dismissing it used to drop focus to <body>.
- about: the copy sent readers to 'a feedback button in the navbar' that
CompanionShell does not render; it now links the GitHub issue tracker.
- team/wiki: add the alternates.canonical their siblings already had.
- careers/[slug]: only append the meta-description ellipsis when slice(0,155)
actually truncated, so short blurbs stop reading as cut off.
- wiki: move scrollMarginTop from the H2 to the <section> elements — the TOC
fragments target the sections, so anchors landed under the sticky header.
Drop cssColor(): companionContent.ts now stores bare hex.
- faq: unique panel ids + aria-controls, and 'visibility' in the collapsed
style so collapsed answers are neither focusable nor announced.
- careers/CareersList: same collapsed-panel fix, so 'Apply for this role' is
not tabbable while closed; both raw <img> replaced with next/image.
- CompanionShell: wrap the header link group and the footer link row in
labelled <nav> landmarks. The compact-nav media query was scoped to
.landing-dc, which this shell must never wear, so below 1180px the
overflowing tab row stayed and the disclosure stayed hidden — unscoped it.
- page.tsx: read ?signin=1 too. CompanionShell's header 'Sign In' links to
/?signin=1 and nothing read it, so that control did nothing at all.
- public/journal-*.png: 1.29 MB -> 432 KB (-67.8% / -64.6%) via libimagequant
256-colour quantisation with Floyd-Steinberg dither (same engine pngquant
uses; RMSE 2.0 and 2.8 of 255). A fully lossless re-encode was tried first
and was larger than the originals. Filenames and paths unchanged.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Blocker

  • Both CI jobs died at the lint step, before tsc / pytest / vitest ever ran — so none of this port had been typechecked or tested. Both base failures fixed properly: the unused pydantic.Field import, and GradescopeConnect.tsx's exhaustive-deps (via useCallback keyed on userId, which also fixes a real bug the empty dep array hid — switching user kept the previous account's state).

Major

  • ?error=session_expired never reached the modal.middleware.ts sets it and marketing/SignInModal still shipped the whole ERROR_COPY surface, all dead. Now read and threaded through, opening the modal on an inbound code and stripping the param on dismiss.
  • Staging advertised production canonical/OG URLsresolveSiteUrl fell through to prod because [env.staging.vars] set neither knob. DEPLOY_ENV = "staging" added, which also arms the existing build-time guard.
  • deployGuard.classify now normalises trailing slashes, so a valid https://api.saplinglearn.com/ no longer bypasses the runtime guard; detectHostConfigMismatch is wired into middleware.ts so it actually defends something.
  • The orphaned components/SignInModal.tsx (zero importers, 67-line drift from its replacement) is deleted.

CodeRabbit majors

onLearn opened the notetaker instead of the tutor (now via the new galIndexOf lookup, not a magic index) · OAuth URLs kept same-origin · DARK_SECTIONS reduced to the one genuinely dark section · Escape now exits explore mode (was a keyboard trap with body { overflow: hidden }) · sim.destroy() re-homes clusters so a StrictMode remount no longer kills the drag field permanently · hero-canvas unmount race · CardsDemo timer leak, global key capture and keyboard-inoperable flip · Gallery cards keyboard-openable · BetaModal single-column below the breakpoint + type="email" · hidden nav/pills removed from the tab order.

Minor / nits

Dead .landing-page CSS removed · stylelint font-family-name-quotes 5→0 and keyframes-name-pattern 42→0 · lab-demo correctness (clamped percentages, partner snapshot, concept count, invalid guide picks) · ARIA roles and labels across FAQ/careers/calendar/quiz/rooms · localStorage input validated · journal PNGs recompressed 1.32 MB → 442 KB.

Still open (deliberately not done)

This branch is a stale snapshot of a landing already merged on main (#524) — promoting it is an add/add conflict across ~50 files, and startHeroRig's third parameter changed meaning while keeping an identical () => number type, so a file-by-file conflict resolution compiles cleanly and silently breaks the hero. That is a merge-strategy decision, not a code fix.

Verificationruff check . clean · tsc clean · eslint 0 errors (base had 1) · 184 frontend tests pass

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

This branch now carries a backend change, via #565

staging picked up the Gradescope repair in #565, so this PR is no longer frontend-only. That fix restored routes/gradescope.py and services/gradescope_service.py from main because the version on staging was unreachable (router prefix declared twice → every endpoint served at /api/gradescope/api/gradescope/...), called a DB client that does not exist (.eq().execute() on a PostgREST helper), and had no rate limiting or bu-sso.

I checked statically that every table the restored route touches exists on staging — gradescope_credentials, gradescope_course_links, assignments, gradebook_categories — so there is no missing-table hazard. But that is a check of the repo's migration files, not of the staging database's actual ledger. Please confirm on the real database:

SELECT table_name FROMinformation_schema.tablesWHERE table_schema ='public'AND table_name IN ('gradescope_credentials','gradescope_course_links','gradescope_links');

All three should be present. Then exercise one endpoint end to end and confirm it resolves at a single/api/gradescope/... prefix rather than 404ing.

Two things #565 deliberately left behind

SELECTcount(*) FROM gradescope_links;

gradescope_links (created by staging's 0033_add_gradescope_links.sql) now has no reader — the restored route uses gradescope_course_links. services/gradescope_sync_service.py has zero importers, and models/gradescope.py is imported only by that dead module. Nothing breaks, but if the table holds rows they are stranded, and the two modules are dead code worth removing in a follow-up.

Ledger divergence between staging and main

Staging carries two migrations main does not, at numeric prefixes main reuses for different files:

prefixstagingmain
00320032_add_gradescope_credentials.sql0032_rooms_missing_columns.sql, 0032_retire_summer_2026.sql
00330033_add_gradescope_links.sql0033_offering_section_not_null.sql, 0033_realtime_publish_room_messages.sql

schema_migrations keys on the full basename, so these coexist without a primary-key collision — but main has 23 migrations staging lacks, and staging is 239 commits behind. Whenever these branches are reconciled, both migration sets apply and the relative ordering within 0032/0033 is alphabetical by accident rather than by design. Worth settling deliberately rather than discovering it during a promotion.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

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.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, '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 the v5 marketing landing onto staging by Darkest-Teddy · Pull Request #535 · SaplingLearn/Sapling · GitHub
Skip to content

feat(landing): port the v5 marketing landing onto staging - #535

Open
Darkest-Teddy wants to merge 5 commits into
stagingfrom
feat/landing-v5-on-staging
Open

feat(landing): port the v5 marketing landing onto staging#535
Darkest-Teddy wants to merge 5 commits into
stagingfrom
feat/landing-v5-on-staging

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Puts the v5 marketing landing on stagingwithout merging feat/landing-v5-port.

Why not just merge the v5 branch

feat/landing-v5-port is 217 commits ahead of origin/staging and the diff is mostly not the landing page — 179 backend test files, 31 agents, 27 services, 17 routes, and 15 new migrations, including two colliding 0032_* and two colliding 0033_* basenames. It also isn't a fast-forward: staging has one commit that branch lacks (82787ca, gradescope). None of that is needed to show the new landing, so this ports the public surface only — no backend changes, no migrations.

What's in here

  • components/landing-v5/** and lib/landing/** — the landing engine, hero3d, drag simulation, and the Act sections
  • The (public) route group: the new /, plus /team, /wiki, /gallery, /news, /faq — the v5 navbar links to these and staging didn't have them
  • components/marketing/** and components/companion/CompanionShell
  • globals.css and app/layout.tsx (OG/metadata), lib/deployGuard.tswith its matching test, lib/errorMessage.ts
  • api.ts gains only subscribeToNewsletter — staging's backend already mounts /api/newsletter

One deliberate divergence from upstream

marketing/SignInModal calls the 3-argsetActiveUser. Upstream passes a 4th { persist } option that exists only on that branch's UserContext, whose wider change (+127/−21) also adds app-wide session recovery and shell-route redirects. Changing auth behaviour for every screen is out of scope for a landing port, so the option is dropped here and should come back in the same commit that brings that UserContext over. The call site carries the same note.

eslint-suppressions.json gains an entry for the new components/marketing/SignInModal.tsx path; staging only suppressed that rule at the old components/SignInModal.tsx path.

Verification

  • tsc --noEmit — clean
  • next build with staging's build vars (DEPLOY_ENV/BACKEND_URL/NEXT_PUBLIC_API_URL/COOKIE_DOMAIN) — succeeds, emitting /team, /wiki, /gallery, /news, /faq
  • eslint — clean except one pre-existing error: react-hooks/exhaustive-deps in settings/GradescopeConnect.tsx:65, which arrives untouched from origin/staging (it is neither modified nor suppressed on that branch). CI will likely go red on it until it's fixed separately — deliberately not touched here.
  • vitest was not run locally — it needs Node 22 and this machine is on 20. CI runs it on Node 22.

Deploy note

Per wrangler.toml and ADR 0020, the staging Workers Build must set DEPLOY_ENV=staging as a build variable, not just a runtime var — the /api rewrite and NEXT_PUBLIC_* are baked at build time. That omission is what previously put prod config on the staging worker.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added public About, Team, Wiki, Gallery, News, FAQ, and Careers pages.
    • Introduced interactive galleries, article search and filtering, FAQ accordions, career listings, and rich knowledge-base content.
    • Redesigned the landing experience with interactive learning demonstrations, knowledge graphs, tutor modes, quizzes, flashcards, study tools, and beta signup.
    • Added Google sign-in and newsletter subscription flows.
  • Improvements

    • Standardized navigation, page layouts, accessibility, responsive styling, animations, and site metadata.
    • Improved error messaging and deployment-aware canonical links.

Brings the v5 public site to `staging` WITHOUT the rest of
`feat/landing-v5-port`: that branch is 217 commits ahead of staging and
carries backend routes, services, agents and 15 migrations (including two
colliding 0032_* and two 0033_* basenames). None of that is needed to show
the new landing, so this ports the public surface only.
What's here:
- components/landing-v5/** + lib/landing/** (the landing engine, hero3d,
drag simulation and the Act sections)
- the (public) route group: the new / plus /team, /wiki, /gallery, /news
and /faq, which the v5 navbar links to and staging did not have
- components/marketing/** and components/companion/CompanionShell
- globals.css and app/layout.tsx (OG/metadata), lib/deployGuard.ts with
its matching test, lib/errorMessage.ts
- api.ts gains only `subscribeToNewsletter`; staging's backend already
mounts /api/newsletter
One deliberate divergence from upstream: marketing/SignInModal calls the
3-arg `setActiveUser`. Upstream passes a 4th `{ persist }` option that only
exists on that branch's UserContext, whose wider change also adds app-wide
session recovery and shell-route redirects. Changing auth for every screen
is out of scope for a landing port, so the option is dropped here and should
return with that UserContext. The call site carries the same note.
eslint-suppressions.json gains an entry for the new
components/marketing/SignInModal.tsx path; staging only suppressed the rule
at the old components/SignInModal.tsx path.
Verified: tsc --noEmit clean, and `next build` succeeds with staging's build
vars (DEPLOY_ENV/BACKEND_URL/NEXT_PUBLIC_API_URL/COOKIE_DOMAIN), emitting
/team, /wiki, /gallery, /news and /faq. eslint is clean except for a
pre-existing react-hooks/exhaustive-deps error in
settings/GradescopeConnect.tsx, which arrives untouched from origin/staging.
vitest was not run locally (needs Node 22; this machine is on 20) - CI runs it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 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 ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:37 seconds

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 101dbfe3-a2c2-4287-842f-6e2c86a82b2e

📥 Commits

Reviewing files that changed from the base of the PR and between 395588d and 46f7930.

⛔ Files ignored due to path filters (2)
  • frontend/public/journal-ai-homework.png is excluded by !**/*.png
  • frontend/public/journal-founding.png is excluded by !**/*.png
📒 Files selected for processing (57)
  • docs/frontend-testids.md
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/package.json
  • frontend/src/app/(public)/about/page.tsx
  • frontend/src/app/(public)/careers/CareersList.tsx
  • frontend/src/app/(public)/careers/[slug]/page.tsx
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/layout.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/ActGraph.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/ActTutor.tsx
  • frontend/src/components/landing-v5/BetaModal.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/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/landing-v5-interaction.test.tsx
  • frontend/src/components/landing-v5/navTheme.ts
  • frontend/src/components/landing/useLanding.ts
  • frontend/src/components/marketing/HeroCard.test.tsx
  • frontend/src/components/marketing/SignInModal.tsx
  • frontend/src/lib/deployGuard.test.ts
  • frontend/src/lib/deployGuard.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/dom.ts
  • frontend/src/lib/landing/engine/flip.ts
  • frontend/src/lib/landing/engine/index.ts
  • frontend/src/lib/landing/engine/marquee.ts
  • frontend/src/lib/landing/engine/plant.ts
  • frontend/src/lib/landing/engine/sim.test.ts
  • frontend/src/lib/landing/engine/sim.ts
  • frontend/src/middleware.ts
  • frontend/wrangler.toml
📝 Walkthrough

Walkthrough

The PR replaces the landing page with Landing v5, adds companion and careers pages, introduces shared public layouts and content, expands deployment metadata, and adds OAuth, newsletter, error-handling, CSS, and interaction infrastructure.

Changes

Landing v5 experience

Layer / File(s)Summary
Landing contracts and content
frontend/src/lib/landing/*
Adds typed landing content, course graphs, drag clusters, lab fixtures, color utilities, and navigation themes.
Landing sections and demos
frontend/src/components/landing-v5/*
Adds the v5 hero, graph, ingest, tutor, gallery, FAQ, journal, CTA, navigation, modal, and feature-lab components.
Landing runtime and composition
frontend/src/components/landing/useLanding.ts, frontend/src/lib/landing/engine/*, frontend/src/app/(public)/page.tsx
Connects React state to scroll, canvas, WebGL, graph, marquee, FLIP, planting, newsletter, and drag-field behavior.
Landing styles and tests
frontend/src/app/globals.css, frontend/src/components/landing-v5/*.test.tsx, frontend/src/lib/landing/engine/sim.test.ts
Adds responsive landing layouts, finite animations, and interaction regression coverage.

Companion and public pages

Layer / File(s)Summary
Shared companion shell and content
frontend/src/components/companion/CompanionShell.tsx, frontend/src/lib/landing/companionContent.ts
Adds shared navigation, footer links, and typed content collections.
Companion pages
frontend/src/app/(public)/about/*, team/*, faq/*, gallery/*, news/*, wiki/*
Adds shared-shell pages with data-driven content, filters, accordions, navigation, and metadata.
Careers routes
frontend/src/app/(public)/careers/*
Moves interactive job listings into CareersList, adds route metadata, and returns not-found responses for invalid slugs.

Platform and marketing infrastructure

Layer / File(s)Summary
Deployment and metadata
frontend/src/lib/deployGuard.ts, frontend/src/lib/deployGuard.test.ts, frontend/src/app/layout.tsx, frontend/src/app/(public)/privacy/page.tsx, frontend/src/app/(public)/terms/page.tsx
Adds environment resolution, canonical site URLs, host mismatch detection, structured application metadata, and legal-page metadata.
Authentication, newsletter, and marketing UI
frontend/src/components/marketing/SignInModal.tsx, frontend/src/components/marketing/HeroCard.tsx, frontend/src/lib/api.ts, frontend/src/lib/errorMessage.ts
Adds OAuth popup handling, accessible modal behavior, reusable hero-card rendering, newsletter subscription requests, and defensive error normalization.

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

Sequence Diagram(s)

sequenceDiagram
participant Visitor
participant LandingPage
participant useLanding
participant LandingEngine
participant CanvasAndWebGL
Visitor->>LandingPage: open landing page
LandingPage->>useLanding: initialize refs and state
useLanding->>LandingEngine: start landing lifecycle
LandingEngine->>CanvasAndWebGL: render hero and graph visuals
Visitor->>LandingPage: explore graph or submit newsletter
LandingPage->>useLanding: dispatch interaction
useLanding->>LandingEngine: update interaction state
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 26.42% 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 main change: porting the v5 marketing landing to staging.
Description check✅ PassedThe description covers scope, rationale, changes, testing limits, and deployment requirements, with only minor template omissions.
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 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/landing-v5-on-staging

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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 12, 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-staging46f7930Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:19 PM

@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: 11

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/lib/deployGuard.ts (1)

39-46: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Normalize trailing slashes before environment classification.

A canonical API URL with a trailing slash classifies as unknown. Then detectHostConfigMismatch returns null for a staging host wired to https://api.saplinglearn.com/. This bypasses the runtime guard for a valid URL spelling.

Normalize URL fields before comparison. Add regression tests for trailing-slash API and site URLs.

Proposed fix
 function classify(
value: string | undefined,
field: 'apiUrl' | 'cookieDomain' | 'siteUrl',
): FrontendEnv | null {
- const v = (value ?? '').trim().toLowerCase();+ const v = (value ?? '')+ .trim()+ .toLowerCase()+ .replace(field === 'cookieDomain' ? /$/ : /\/+$/, '');
if (!v) return null;
for (const env of Object.keys(FRONTEND_ENVS) as FrontendEnv[]) {
if (FRONTEND_ENVS[env][field].toLowerCase() === v) return env;
🤖 Prompt for AI Agents
Verify each 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/deployGuard.ts` around lines 39 - 46, Update classify to
normalize URL values by removing trailing slashes before comparing against
FRONTEND_ENVS fields, while preserving existing trimming and case normalization.
Ensure both API and site URL classifications recognize trailing-slash variants,
and add regression coverage for trailing-slash API and site URLs.
🟡 Minor comments (24)
frontend/src/app/(public)/news/page.tsx-175-199 (1)

175-199: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The card link text does not match its destination.

Each card is a <Link href="/#newsletter"> and its call to action reads "Read article →". A reader who selects a headline arrives at the newsletter signup on the home page, not at the post. All six cards lead to the same anchor.

Change the call to action to state the real destination, for example "Get notified →", until article 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/app/`(public)/news/page.tsx around lines 175 - 199, The news
cards’ CTA text incorrectly promises article navigation while each Link in the
posts.map block targets /#newsletter. Update the CTA span text from “Read
article →” to wording that reflects newsletter signup, such as “Get notified →”,
while preserving the existing link destination and styling.
frontend/src/app/(public)/about/page.tsx-80-84 (1)

80-84: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update the copy that points to a feedback button in the navbar.

The paragraph tells the reader to use "a feedback button in the navbar". The ported chrome in frontend/src/components/companion/CompanionShell.tsx renders Home, About, Team, Wiki, Gallery, News, FAQ, GitHub, Ko-fi, Sign In, and Get Started. It has no feedback control. Point the reader at a control that exists, for example the GitHub link or a contact address.

🤖 Prompt for AI Agents
Verify each 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)/about/page.tsx around lines 80 - 84, Update the
paragraph near the prose content to remove the nonexistent navbar
feedback-button reference, and direct readers to an available control such as
the GitHub link or a contact address instead. Preserve the surrounding message
about reporting issues and sharing ideas.
frontend/src/app/(public)/team/page.tsx-18-22 (1)

18-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Two companion routes omit alternates.canonical. The port sets a canonical URL on frontend/src/app/(public)/about/page.tsx, frontend/src/app/(public)/careers/page.tsx, and frontend/src/app/(public)/careers/[slug]/page.tsx, but not on these two routes. Without a canonical URL, these pages can be indexed under query-string or alternate-host variants.

  • frontend/src/app/(public)/team/page.tsx#L18-L22: add alternates: { canonical: '/team' } to the metadata export.
  • frontend/src/app/(public)/wiki/page.tsx#L18-L22: add alternates: { canonical: '/wiki' } to the metadata export.
🤖 Prompt for AI Agents
Verify each 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)/team/page.tsx around lines 18 - 22, Update the
metadata exports in frontend/src/app/(public)/team/page.tsx lines 18-22 and
frontend/src/app/(public)/wiki/page.tsx lines 18-22 to include
alternates.canonical, using /team for the team route and /wiki for the wiki
route.
frontend/src/app/(public)/careers/[slug]/page.tsx-18-22 (1)

18-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The meta description appends an ellipsis even when it truncates nothing.

Line 20 always appends after job.description.slice(0, 155). If a description is shorter than 155 characters, the full sentence still ends with a truncation marker. Search results then show a complete sentence that looks cut off.

Append the ellipsis only when the slice removed text.

🐛 Proposed fix
+ const blurb =+ job.description.length > 155 ? `${job.description.slice(0, 155)}…` : job.description;
return {
title: `${job.title} — Careers`,
- description: `${job.department} · ${job.location} · ${job.type}. ${job.description.slice(0, 155)}…`,+ description: `${job.department} · ${job.location} · ${job.type}. ${blurb}`,
alternates: { canonical: `/careers/${job.slug}` },
};
🤖 Prompt for AI Agents
Verify each 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)/careers/[slug]/page.tsx around lines 18 - 22,
Update the metadata construction in the careers page’s generateMetadata flow so
the description appends “…” only when job.description exceeds 155 characters;
leave shorter or exactly 155-character descriptions without a truncation marker
while preserving the existing 155-character slice.
frontend/src/app/(public)/wiki/page.tsx-28-31 (1)

28-31: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

scrollMarginTop is applied to the wrong element.

The H2 style sets scrollMarginTop: 84. The TOC links on line 67 target the fragments #graph, #mastery, #review, #tutor, #ingestion, #grades, and #privacy. Those ids are on the <section> elements, not on the <h2> elements. The browser applies scroll margin to the element it scrolls into view, so the section top lands under the sticky 92px header from CompanionShell and the heading is hidden.

Move the offset to the sections.

🐛 Proposed fix
 const H2: React.CSSProperties = {
margin: 0, fontFamily: DISPLAY, fontWeight: 500, fontSize: 26,
- lineHeight: 1.2, letterSpacing: '-0.015em', scrollMarginTop: 84,+ lineHeight: 1.2, letterSpacing: '-0.015em',
};
+/** Clears the sticky companion header after an in-page anchor jump. */+const SECTION: React.CSSProperties = { scrollMarginTop: 104 };

Then apply it to each section, for example:

- <section id="graph">+ <section id="graph" style={SECTION}>
🤖 Prompt for AI Agents
Verify each 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 28 - 31, Move
scrollMarginTop: 84 from the H2 style to the section styling used by the
sections targeted by the TOC fragment links (`#graph`, `#mastery`, `#review`, `#tutor`,
`#ingestion`, `#grades`, and `#privacy`). Keep the heading typography unchanged and
ensure each target section receives the offset so it clears the sticky header.
frontend/src/components/companion/CompanionShell.tsx-86-108 (1)

86-108: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Apply the responsive nav rules to CompanionShell. The media query is scoped to .landing-dc, but the CompanionShell root does not have that class. The rules do not match, despite using !important, so the compact nav remains hidden and the tab row remains visible below 1180px.

🤖 Prompt for AI Agents
Verify each 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/companion/CompanionShell.tsx` around lines 86 - 108,
Update the CompanionShell root element to include the landing-dc class so the
existing responsive media-query rules apply to its nav-compact and nav-tabs
elements. Preserve the current navigation structure and styling.

Source: Linters/SAST tools

frontend/src/lib/landing/engine/dom.ts-49-73 (1)

49-73: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

A layout measurement taken before layout is ready is cached permanently. Both sites store a measured size on first read and re-measure only when a resize signal arrives. If the first read happens before the element has its final box, the wrong value is kept for the rest of the session. Reject an implausible measurement instead of caching it.

  • frontend/src/lib/landing/engine/dom.ts#L49-L73: do not assign rec.__cv when w or h is 0. invalidateCanvas() runs only on a viewport size change, so a zero-size cache leaves the canvas blank.
  • frontend/src/lib/landing/engine/marquee.ts#L136-L144: do not accept m.setW when the summed child offsetWidth is 0. The re-measure trigger is a wrap.clientWidth change, so a gap-only width leaves the track wrapping over a few pixels.
🤖 Prompt for AI Agents
Verify each 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, Reject
zero-sized measurements before caching them: in
frontend/src/lib/landing/engine/dom.ts lines 49-73, ensure cv does not assign
rec.__cv when w or h is 0; in frontend/src/lib/landing/engine/marquee.ts lines
136-144, ensure the marquee measurement does not call m.setW when the summed
child offsetWidth is 0. Preserve normal caching and measurement behavior for
positive dimensions.
frontend/src/lib/landing/engine/sim.ts-330-369 (1)

330-369: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard the SVG structure assumptions during build.

Three unchecked assumptions read the authored SVG:

  • Line 332: ring.previousElementSibling as SVGCircleElement. If the ring is the first child, glow is null. Line 839 then calls p.glow.setAttribute on every frame and throws a TypeError.
  • Line 333 and 345: label is dereferenced immediately by label.getAttribute('y'). A missing next sibling throws during ensureInit().
  • Line 362-367: near() returns -1 when the cluster has line elements but no [data-sim] rings. nodes[b] is then undefined and line 367 throws.

holdArms() at line 653 and stepGroup() at line 752 already guard with if (!s0 || !t0) continue;, so missing nodes are anticipated later in the pipeline but not at build time. Skip a ring or a line when its expected siblings are absent.

🤖 Prompt for AI Agents
Verify each 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 330 - 369, Update the
SVG build logic in the rings and lines loops to validate required structure
before creating simulation entries: skip rings when the previous glow element or
next label element is missing, and skip lines when either endpoint lookup in
near() returns -1. Keep valid ring and line processing unchanged, ensuring no
nodes or links are created with null siblings or invalid endpoint indices.
frontend/src/lib/landing/engine/marquee.ts-69-114 (1)

69-114: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Bind pointerup and pointercancel on window as well.

setPointerCapture is called inside a try/catch at line 80. If the capture fails, later pointer events are not retargeted to track. A drag that ends outside track then never runs end(). m.drag stays non-null, so update() skips both drift and momentum for that track and the cursor stays grabbing until the next pointerdown.

frontend/src/lib/landing/engine/sim.ts lines 507-510 already binds the release handlers on both the element and window for this reason. Apply the same pattern here.

🛡️ Proposed fix
 track.addEventListener('pointerdown', onDown);
track.addEventListener('pointermove', onMove);
track.addEventListener('pointerup', onUp);
track.addEventListener('pointercancel', onCancel);
+ window.addEventListener('pointerup', onUp);+ window.addEventListener('pointercancel', onCancel);
cleanups.push(() => {
wrap?.removeEventListener('pointerenter', onEnter);
wrap?.removeEventListener('pointerleave', onLeave);
track.removeEventListener('pointerdown', onDown);
track.removeEventListener('pointermove', onMove);
track.removeEventListener('pointerup', onUp);
track.removeEventListener('pointercancel', onCancel);
+ window.removeEventListener('pointerup', onUp);+ window.removeEventListener('pointercancel', onCancel);
STATE.delete(track);
});
🤖 Prompt for AI Agents
Verify each 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 69 - 114, Bind the
existing onUp and onCancel release handlers to window in addition to track,
matching the dual registration pattern used by sim.ts. Keep the current track
listeners and end() behavior unchanged so drags are finalized even when pointer
capture fails or the pointer leaves the track.
frontend/src/components/landing-v5/BetaModal.tsx-244-260 (1)

244-260: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use type="email" for the email field.

The field is labelled "Email address" and validated as an email, but it is declared type="text". That suppresses the email keyboard on mobile and blocks browser autofill. The manual check at lines 86-96 continues to work unchanged.

🔧 Proposed fix
 ref={inputRef}
- type="text"+ type="email"+ autoComplete="email"+ inputMode="email"
aria-label="Email address"
🤖 Prompt for AI Agents
Verify each 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 244 - 260,
Update the email input in BetaModal’s form to use type="email" instead of
type="text", while preserving its existing validation, value handling, and other
attributes.
frontend/src/components/landing/useLanding.ts-593-597 (1)

593-597: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report invalid input instead of returning silently.

If value has no @, the function returns without setting subscribeError and without any other state change. The user presses the button and sees no response. An empty non-required type="email" field passes browser validation and reaches this branch.

🐛 Proposed fix
 const value = email.trim();
- if (!value.includes('@') || subscribing) return;+ if (subscribing) return;+ if (!value.includes('@')) {+ setSubscribeError('Enter a valid email address.');+ return;+ }
🤖 Prompt for AI Agents
Verify each 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 593 - 597, Update
subscribeNow so invalid email input, including an empty trimmed value or a value
without "@", sets subscribeError to the existing user-facing validation message
before returning. Keep the subscribing guard and valid-input flow unchanged,
ensuring the user receives feedback instead of a silent no-op.
frontend/src/components/landing-v5/Navbar.tsx-74-79 (1)

74-79: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The navbar stays interactive while it is invisible.

While heroMounted is false, opacity is 0 but pointerEvents remains 'auto'. For the first ~1.9s of the intro, every navbar control is focusable and clickable while nothing is visible.

♿ Proposed fix
 opacity: exploring ? 0 : heroMounted ? 1 : 0,
- pointerEvents: exploring ? 'none' : 'auto',+ pointerEvents: exploring || !heroMounted ? 'none' : 'auto',+ visibility: heroMounted ? 'visible' : 'hidden',
🤖 Prompt for AI Agents
Verify each 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 74 - 79, Update
the navbar style logic in the visible component so pointerEvents is set to
'none' whenever the navbar is invisible, including while heroMounted is false
and while exploring is true; restore 'auto' only when the navbar is visible and
interactive.
frontend/src/app/(public)/page.tsx-91-100 (1)

91-100: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear the subscription error when the modal closes.

state.subscribeError is shared by BetaModal and Journal and is never reset. After a failed attempt, the message stays visible in the Journal form and reappears the next time the modal opens. useLanding already exposes actions.resetSubscribeError for this, and it is currently unused.

🐛 Proposed fix
- onClose={() => setBetaOpen(false)}+ onClose={() => { actions.resetSubscribeError(); setBetaOpen(false); }}
🤖 Prompt for AI Agents
Verify each 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 91 - 100, Update the
BetaModal onClose handler to call actions.resetSubscribeError before closing the
modal with setBetaOpen(false), ensuring the shared subscription error is cleared
when the modal closes.
frontend/src/app/globals.css-1563-1569 (1)

1563-1569: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stylelint fails on the quoted Spectral family name.

font-family-name-quotes reports four errors: Lines 1565, 1713, 1784, and 1836. Spectral is a single identifier, so it needs no quotes.

🔧 Proposed fix (apply to all four sites)
- font-family: var(--font-spectral), 'Spectral', Georgia, serif;+ font-family: var(--font-spectral), Spectral, Georgia, serif;

As per static analysis hints, font-family-name-quotes is enabled for this file.

Also applies to: 1713-1713, 1784-1784, 1836-1836

🤖 Prompt for AI Agents
Verify each 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 1563 - 1569, Remove the
unnecessary quotes around the single-identifier Spectral font family name in all
four font-family declarations, including the declarations associated with the
visible landing-page style and the other reported sites, so they satisfy
font-family-name-quotes.

Source: Linters/SAST tools

frontend/src/components/landing-v5/lab/RoomsDemo.tsx-42-54 (1)

42-54: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The typing indicator and the delayed reply can name different partners.

send captures partner in the timeout closure. The indicator at Line 127 reads the live partner. If the user changes the select during the 1600 ms delay, the indicator shows the new partner and the message arrives from the old one.

♻️ Proposed fix
 const [partner, setPartner] = useState<Partner>('maya');
+ const partnerRef = useRef<Partner>('maya');+ partnerRef.current = partner;
 typeT.current = setTimeout(() => {
- const reply = `my ${partner.charAt(0).toUpperCase() + partner.slice(1)} graph has eigenvalues green — want me to quiz you on it?`;+ const who = partnerRef.current;+ const reply = `my ${who.charAt(0).toUpperCase() + who.slice(1)} graph has eigenvalues green — want me to quiz you on it?`;
setTyping(false);
- setMsgs((m) => [...m, { who: partner, text: reply, mine: false }]);+ setMsgs((m) => [...m, { who, text: reply, mine: false }]);
}, 1600);

Also applies to: 125-132

🤖 Prompt for AI Agents
Verify each 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/RoomsDemo.tsx` around lines 42 - 54,
Update send so the delayed reply and typing indicator use the same partner
snapshot captured when the message is sent. Store that snapshot in the relevant
state or timeout flow, and ensure the indicator rendering near the partner
display uses it until the delayed reply completes; preserve the existing
behavior when the partner does not change.
frontend/src/app/globals.css-1950-1957 (1)

1950-1957: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stylelint reports 45 keyframes-name-pattern errors in the ported blocks.

The ported keyframe names are camelCase, for example s1Stem, popHold, heroRise, and labIn. The rule expects kebab-case. Renaming is not safe here, because the names are referenced from inline animation strings in the landing components and in labData-driven markup.

Scope a rule disable to the ported region instead, so the rest of the file keeps the check.

🔧 Proposed approach
+/* stylelint-disable keyframes-name-pattern -- names are ported verbatim from the+ design components and are referenced by inline `animation` strings in TSX. */
`@keyframes` s1Stem { 0%,15% { stroke-dashoffset:27; } 34%,100% { stroke-dashoffset:0; } }

Close the block with /* stylelint-enable keyframes-name-pattern */ after the last ported keyframe.

As per static analysis hints, keyframes-name-pattern is enabled for this file.

Also applies to: 2094-2096

🤖 Prompt for AI Agents
Verify each 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 1950 - 1957, Scope-disable
Stylelint’s keyframes-name-pattern rule immediately before the ported keyframe
region containing names such as s1Stem, popHold, heroRise, and labIn, then
re-enable it after the final ported keyframe. Keep the rule active for all other
sections of globals.css and do not rename the animation identifiers.

Source: Linters/SAST tools

frontend/src/lib/landing/course.ts-363-376 (1)

363-376: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle the singular case for degree.

If degree is 1, the text reads "its 1 neighbours". A sub-topic linked only to its parent produces that value.

✏️ Proposed fix
- ' pulls this node plus its ' +- degree +- ' neighbours into context, then retrieves the chunks of your ' ++ ' pulls this node plus its ' ++ degree ++ (degree === 1 ? ' neighbour' : ' neighbours') ++ ' into context, then retrieves the chunks of your ' +
🤖 Prompt for AI Agents
Verify each 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/course.ts` around lines 363 - 376, Update nodeUses
so the text generated for degree uses singular “neighbour” when degree is 1 and
plural “neighbours” otherwise, preserving the existing wording and behavior for
all other values.
frontend/src/components/landing-v5/lab/CardsDemo.tsx-46-53 (1)

46-53: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard the window key listener against editable targets.

The listener runs for every keydown on the window. It calls e.preventDefault() for Space and rates the card for 1, 2, and 3. While this demo is mounted, a Space or a digit typed in any other input on the page is captured.

🛡️ Proposed fix
 const onKey = (e: KeyboardEvent) => {
+ const el = e.target as HTMLElement | null;+ if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) 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 around onKey to ignore events whose
target is an editable control, such as input, textarea, select, or
contenteditable elements, before handling Space or rate keys; preserve the
existing flip and rate behavior for non-editable targets.
frontend/src/components/landing-v5/lab/GradesDemo.tsx-62-65 (1)

62-65: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The row percentage is not clamped, but the weighted grade is.

Line 25 clamps each score to [0, r.possible]. Line 64 does not. If the user types 500 into a row worth 50, the row shows 1000% while the overall grade stays at the clamped value. Use the same clamp for both readouts.

🐛 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,
Update the row percentage calculation in the GB_ROWS.map callback to clamp the
parsed earned score to the range [0, r.possible] before computing the
percentage, matching the existing clamping used for the weighted grade and
keeping both readouts consistent.
frontend/src/components/landing-v5/lab/NotesDemo.tsx-57-66 (1)

57-66: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The "No concepts found" sentinel corrupts the footer count.

Line 63 pushes { name: 'No concepts found', tier: 'unexplored' } into the same concepts array that holds real matches. The footer at Lines 141-143 then reads concepts.length and prints 1 concepts linked to your MA 242 graph. after an empty extraction. The text is wrong, and the plural is wrong for a single match too.

Track the empty result separately instead of encoding it as a concept.

🐛 Proposed fix
- const [concepts, setConcepts] = useState<{ name: string; tier: keyof typeof LAB_TIER }[]>([]);+ const [concepts, setConcepts] = useState<{ name: string; tier: keyof typeof LAB_TIER }[]>([]);+ const [ran, setRan] = useState(false);
 const found = MATCHERS.filter((m) => m.hit.test(lower)).map((m) => ({ name: m.name, tier: m.tier }));
- setConcepts(found.length ? found : [{ name: 'No concepts found', tier: 'unexplored' }]);+ setConcepts(found);+ setRan(true);
setBusy(false);
- {concepts.length- ? `${concepts.length} concepts linked to your MA 242 graph.`- : 'Concepts you extract are linked to your graph, not just tagged on the note.'}+ {concepts.length+ ? `${concepts.length} concept${concepts.length === 1 ? '' : 's'} linked to your MA 242 graph.`+ : ran+ ? 'No concepts found in this note.'+ : 'Concepts you extract are linked to your graph, not just tagged on the note.'}

Also applies to: 140-144

🤖 Prompt for AI Agents
Verify each 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 - 66,
Update extract so an empty MATCHERS result is tracked separately rather than
added to concepts; keep concepts containing only real matches and render the
empty-state message independently. Adjust the footer rendering near the concepts
count to display the correct zero count and use singular/plural wording based on
the actual number of linked concepts.
frontend/src/components/landing-v5/Closing.tsx-154-182 (1)

154-182: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Composite ARIA roles are declared without their required child roles. Both sites put a composite role on a container and leave the children as plain <button> elements. ARIA requires owned children with the matching role, so assistive technology announces an empty widget and the selected item is not conveyed.

  • frontend/src/components/landing-v5/Closing.tsx#L154-L182: add role="menuitem" to each section button inside the role="menu" container.
  • frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124: add role="radio" and aria-checked={isPicked} to each option button inside the role="radiogroup" container.
🤖 Prompt for AI Agents
Verify each 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 154 - 182, Add
role="menuitem" to each section button rendered by SECTIONS.map in Closing.tsx
(frontend/src/components/landing-v5/Closing.tsx#L154-L182). In QuizDemo.tsx
(frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124), add
role="radio" and bind aria-checked to isPicked on each option button within the
radiogroup.
frontend/src/components/landing-v5/lab/CalendarDemo.tsx-86-112 (1)

86-112: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Blank leading cells are focusable empty buttons.

Every cell renders as a <button>, including the LEAD_BLANKS placeholders. The blank cells only set cursor: 'default', which does not remove them from the tab order. A keyboard user tabs through up to six empty buttons before reaching 1 October. The selected day also carries no aria-pressed, so the current selection is not announced.

♿ Proposed change
 <button
key={i}
onClick={() => { if (!c.blank) setDay(c.day as number); }}
type="button"
+ disabled={c.blank}+ aria-hidden={c.blank || undefined}+ aria-pressed={c.blank ? undefined : selected}
🤖 Prompt for AI Agents
Verify each 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/CalendarDemo.tsx` around lines 86 -
112, Update the day-cell buttons rendered by the shown.map callback so blank
placeholder cells are removed from keyboard focus, while retaining interactive
keyboard behavior for real days. Add aria-pressed to each non-blank day button
and set it according to the selected value so assistive technologies announce
the current selection.
frontend/src/components/landing-v5/Faq.tsx-71-76 (1)

71-76: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

"Read the full FAQ" links back to itself.

The anchor targets #faq, and the enclosing <section> already has id="faq" (Line 40). Clicking the link keeps the user on the same section, so the call to action does nothing.

This PR adds the /faq public route, and Closing.tsx Line 81 already links to it. Point this link at /faq.

🔗 Proposed change
- <a href="`#faq`" style={{ color: '`#0C5638`', textDecoration: 'underline' }}>Read the full FAQ</a>+ <Link href="/faq" style={{ color: '`#0C5638`', textDecoration: 'underline' }}>Read the full FAQ</Link>

Add the import:

 import { FAQS } from '`@/lib/landing/content`';
+import Link from 'next/link';
import { DragField } from './DragField';
🤖 Prompt for AI Agents
Verify each 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 71 - 76, Update the
“Read the full FAQ” anchor in the Faq component to navigate to the public /faq
route instead of the local `#faq` section, matching the existing link behavior in
Closing.tsx.
frontend/src/components/landing-v5/lab/GuideDemo.tsx-21-24 (1)

21-24: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Align RECENT.pick with the course exam lists. Neither Midterm 1 · Sep 30 nor Quiz 2 · Oct 03 exists in its course's GUIDES.exams. Opening either recent item sets an invalid select value while the guide heading displays that value.

🤖 Prompt for AI Agents
Verify each 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 in GuideDemo so each pick exactly matches an existing
exam value in the corresponding course’s GUIDES.exams list, preserving the
displayed recent-item behavior and ensuring the select value and guide heading
use valid options.
🤖 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/`(public)/page.tsx:
- Around line 130-131: Update the onLearn handler in the page component to open
the tutor panel at GAL index 7 instead of index 2, while leaving onQuiz
unchanged. Prefer the named GAL lookup or constant from content.ts rather than a
hard-coded index so card ordering changes cannot reintroduce this mismatch.
In `@frontend/src/components/landing-v5/ActTutor.tsx`:
- Around line 92-109: Update the tutor pills container in ActTutor to remove its
buttons from keyboard navigation while hidden by binding the engine’s docked
state to inert (or visibility). Preserve the existing opacity and pointerEvents
behavior, and ensure the pills become focusable again when docked is false.
In `@frontend/src/components/landing-v5/BetaModal.tsx`:
- Around line 132-143: Update the HeroCard responsive layout to use a single
column below the medium breakpoint and the existing two-column structure at
wider widths, using its CSS class rather than an unconditional
gridTemplateColumns style. Enable vertical scrolling for constrained viewports,
and apply the 560px minimum height only at the wider breakpoint so mobile
content, including the email field and submit button, remains accessible.
In `@frontend/src/components/landing-v5/Gallery.tsx`:
- Around line 62-85: Update the Card component so non-ghost cards are
keyboard-focusable and expose button-like semantics, with a key handler that
triggers the existing delegated Track click behavior for Enter and Space. Keep
ghost cards inert by omitting focusability, interactive semantics, and
activation handling, while preserving the current data-tk targeting.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 37-44: Update the rate callback to store its 300 ms timeout in a
ref instead of returning a cleanup function, and clear that timeout in an
unmount cleanup effect. Preserve the existing delayed setIdx behavior while
preventing it from firing after the component unmounts.
In `@frontend/src/components/landing-v5/navTheme.ts`:
- Around line 68-69: Update DARK_SECTIONS to contain only the section that
currently renders on a dark ground, act-graph, and revise its comment to
describe the single dark section accurately. Keep act-ingest and act-tutor out
of the list until their backgrounds are dark.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 218-229: Update the hero canvas effect around the lazy
`import('`@/lib/landing/engine/hero`')` to track whether cleanup has run, prevent
`startHeroCanvas` from starting after unmount, and stop it immediately if
initialization races with cancellation. Preserve the existing cleanup behavior
for normal mounted execution.
- Around line 277-282: Update the onKey handler in useLanding to handle Escape
while explore mode is active: after closing the gallery or jump menu as
appropriate, call exitExplore when s.current.exploring is true and neither
overlay was open. Ensure exitExplore is available at handler execution time
despite its later declaration, using the existing callback ordering or a ref as
needed.
In `@frontend/src/components/marketing/SignInModal.tsx`:
- Around line 130-153: Update the OAuth URL construction in the sign-in flow,
including sameTabUrl and popupUrl, to use the frontend-relative /api/auth/google
path instead of prepending API_URL. Preserve the existing popup_id query
parameter and same-tab fallback behavior so OAuth requests remain same-origin
and retain the session cookie.
In `@frontend/src/lib/landing/content.ts`:
- Around line 191-218: In frontend/src/lib/landing/content.ts lines 191-218,
mark the unavailable journal-founding.png and journal-ai-homework.png entries
with empty src values and isGraph: true so they use the graph fallback until
assets are shipped. In frontend/src/components/landing-v5/Journal.tsx lines
106-118, preserve the meta.isGraph branch that renders GraphThumb instead of
passing unusable paths to next/image.
In `@frontend/src/lib/landing/engine/sim.ts`:
- Around line 897-905: Update destroy() cleanup ordering so every cluster
element is re-parented to its original field before the cleanup that removes the
shell/overlay runs. Preserve the existing cleanup registration in ensureInit(),
but ensure repeated createSim() calls can still find the original
[data-dragnode] elements after destroy().
---
Outside diff comments:
In `@frontend/src/lib/deployGuard.ts`:
- Around line 39-46: Update classify to normalize URL values by removing
trailing slashes before comparing against FRONTEND_ENVS fields, while preserving
existing trimming and case normalization. Ensure both API and site URL
classifications recognize trailing-slash variants, and add regression coverage
for trailing-slash API and site URLs.
---
Minor comments:
In `@frontend/src/app/`(public)/about/page.tsx:
- Around line 80-84: Update the paragraph near the prose content to remove the
nonexistent navbar feedback-button reference, and direct readers to an available
control such as the GitHub link or a contact address instead. Preserve the
surrounding message about reporting issues and sharing ideas.
In `@frontend/src/app/`(public)/careers/[slug]/page.tsx:
- Around line 18-22: Update the metadata construction in the careers page’s
generateMetadata flow so the description appends “…” only when job.description
exceeds 155 characters; leave shorter or exactly 155-character descriptions
without a truncation marker while preserving the existing 155-character slice.
In `@frontend/src/app/`(public)/news/page.tsx:
- Around line 175-199: The news cards’ CTA text incorrectly promises article
navigation while each Link in the posts.map block targets /#newsletter. Update
the CTA span text from “Read article →” to wording that reflects newsletter
signup, such as “Get notified →”, while preserving the existing link destination
and styling.
In `@frontend/src/app/`(public)/page.tsx:
- Around line 91-100: Update the BetaModal onClose handler to call
actions.resetSubscribeError before closing the modal with setBetaOpen(false),
ensuring the shared subscription error is cleared when the modal closes.
In `@frontend/src/app/`(public)/team/page.tsx:
- Around line 18-22: Update the metadata exports in
frontend/src/app/(public)/team/page.tsx lines 18-22 and
frontend/src/app/(public)/wiki/page.tsx lines 18-22 to include
alternates.canonical, using /team for the team route and /wiki for the wiki
route.
In `@frontend/src/app/`(public)/wiki/page.tsx:
- Around line 28-31: Move scrollMarginTop: 84 from the H2 style to the section
styling used by the sections targeted by the TOC fragment links (`#graph`,
`#mastery`, `#review`, `#tutor`, `#ingestion`, `#grades`, and `#privacy`). Keep the heading
typography unchanged and ensure each target section receives the offset so it
clears the sticky header.
In `@frontend/src/app/globals.css`:
- Around line 1563-1569: Remove the unnecessary quotes around the
single-identifier Spectral font family name in all four font-family
declarations, including the declarations associated with the visible
landing-page style and the other reported sites, so they satisfy
font-family-name-quotes.
- Around line 1950-1957: Scope-disable Stylelint’s keyframes-name-pattern rule
immediately before the ported keyframe region containing names such as s1Stem,
popHold, heroRise, and labIn, then re-enable it after the final ported keyframe.
Keep the rule active for all other sections of globals.css and do not rename the
animation identifiers.
In `@frontend/src/components/companion/CompanionShell.tsx`:
- Around line 86-108: Update the CompanionShell root element to include the
landing-dc class so the existing responsive media-query rules apply to its
nav-compact and nav-tabs elements. Preserve the current navigation structure and
styling.
In `@frontend/src/components/landing-v5/BetaModal.tsx`:
- Around line 244-260: Update the email input in BetaModal’s form to use
type="email" instead of type="text", while preserving its existing validation,
value handling, and other attributes.
In `@frontend/src/components/landing-v5/Closing.tsx`:
- Around line 154-182: Add role="menuitem" to each section button rendered by
SECTIONS.map in Closing.tsx
(frontend/src/components/landing-v5/Closing.tsx#L154-L182). In QuizDemo.tsx
(frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124), add
role="radio" and bind aria-checked to isPicked on each option button within the
radiogroup.
In `@frontend/src/components/landing-v5/Faq.tsx`:
- Around line 71-76: Update the “Read the full FAQ” anchor in the Faq component
to navigate to the public /faq route instead of the local `#faq` section, matching
the existing link behavior in Closing.tsx.
In `@frontend/src/components/landing-v5/lab/CalendarDemo.tsx`:
- Around line 86-112: Update the day-cell buttons rendered by the shown.map
callback so blank placeholder cells are removed from keyboard focus, while
retaining interactive keyboard behavior for real days. Add aria-pressed to each
non-blank day button and set it according to the selected value so assistive
technologies announce the current selection.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 46-53: Update the keydown handler in the useEffect around onKey to
ignore events whose target is an editable control, such as input, textarea,
select, or contenteditable elements, before handling Space or rate keys;
preserve the existing flip and rate behavior for non-editable targets.
In `@frontend/src/components/landing-v5/lab/GradesDemo.tsx`:
- Around line 62-65: Update the row percentage calculation in the GB_ROWS.map
callback to clamp the parsed earned score to the range [0, r.possible] before
computing the percentage, matching the existing clamping used for the weighted
grade and keeping both readouts consistent.
In `@frontend/src/components/landing-v5/lab/GuideDemo.tsx`:
- Around line 21-24: Update the RECENT entries in GuideDemo so each pick exactly
matches an existing exam value in the corresponding course’s GUIDES.exams list,
preserving the displayed recent-item behavior and ensuring the select value and
guide heading use valid options.
In `@frontend/src/components/landing-v5/lab/NotesDemo.tsx`:
- Around line 57-66: Update extract so an empty MATCHERS result is tracked
separately rather than added to concepts; keep concepts containing only real
matches and render the empty-state message independently. Adjust the footer
rendering near the concepts count to display the correct zero count and use
singular/plural wording based on the actual number of linked concepts.
In `@frontend/src/components/landing-v5/lab/RoomsDemo.tsx`:
- Around line 42-54: Update send so the delayed reply and typing indicator use
the same partner snapshot captured when the message is sent. Store that snapshot
in the relevant state or timeout flow, and ensure the indicator rendering near
the partner display uses it until the delayed reply completes; preserve the
existing behavior when the partner does not change.
In `@frontend/src/components/landing-v5/Navbar.tsx`:
- Around line 74-79: Update the navbar style logic in the visible component so
pointerEvents is set to 'none' whenever the navbar is invisible, including while
heroMounted is false and while exploring is true; restore 'auto' only when the
navbar is visible and interactive.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 593-597: Update subscribeNow so invalid email input, including an
empty trimmed value or a value without "@", sets subscribeError to the existing
user-facing validation message before returning. Keep the subscribing guard and
valid-input flow unchanged, ensuring the user receives feedback instead of a
silent no-op.
In `@frontend/src/lib/landing/course.ts`:
- Around line 363-376: Update nodeUses so the text generated for degree uses
singular “neighbour” when degree is 1 and plural “neighbours” otherwise,
preserving the existing wording and behavior for all other values.
In `@frontend/src/lib/landing/engine/dom.ts`:
- Around line 49-73: Reject zero-sized measurements before caching them: in
frontend/src/lib/landing/engine/dom.ts lines 49-73, ensure cv does not assign
rec.__cv when w or h is 0; in frontend/src/lib/landing/engine/marquee.ts lines
136-144, ensure the marquee measurement does not call m.setW when the summed
child offsetWidth is 0. Preserve normal caching and measurement behavior for
positive dimensions.
In `@frontend/src/lib/landing/engine/marquee.ts`:
- Around line 69-114: Bind the existing onUp and onCancel release handlers to
window in addition to track, matching the dual registration pattern used by
sim.ts. Keep the current track listeners and end() behavior unchanged so drags
are finalized even when pointer capture fails or the pointer leaves the track.
In `@frontend/src/lib/landing/engine/sim.ts`:
- Around line 330-369: Update the SVG build logic in the rings and lines loops
to validate required structure before creating simulation entries: skip rings
when the previous glow element or next label element is missing, and skip lines
when either endpoint lookup in near() returns -1. Keep valid ring and line
processing unchanged, ensuring no nodes or links are created with null siblings
or invalid endpoint indices.
---
Nitpick comments:
In `@frontend/src/app/`(public)/careers/CareersList.tsx:
- Around line 217-284: Update the collapsible panel style in the job card
rendering to include visibility based on isOpen: visible when expanded and
hidden when collapsed. Add visibility to the existing transition so the state
changes with the panel animation while keeping the Apply for this role link and
other content inaccessible during collapse.
- Around line 51-55: Replace both raw img elements in CareersList with the
next/image component, matching the existing usage in CompanionShell for
/sapling-icon.svg. Preserve the current alt text and styling, and set the second
occurrence’s dimensions to width={20} and height={20} while retaining the first
occurrence’s 26×26 sizing.
In `@frontend/src/app/`(public)/faq/page.tsx:
- Around line 60-89: Update the FAQ answer panel associated with the button
using item.i: assign it a unique id, add matching aria-controls to the button,
and include visibility in the panel styles so it is hidden when collapsed and
visible when open. Preserve the existing max-height and opacity transitions.
In `@frontend/src/app/`(public)/news/page.tsx:
- Around line 111-153: Update the category menu logic around the existing
outside pointerdown listener to handle Escape while the menu is open, close it,
and return focus to the trigger button. Add a ref to the trigger button and
focus it both when Escape dismisses the menu and when selecting an option via
the NEWS_FILTERS buttons, while preserving the existing outside-click behavior.
In `@frontend/src/components/companion/CompanionShell.tsx`:
- Around line 84-141: Wrap the primary link group currently rendered by the
`nav-tabs` container in a `<nav>` landmark with a descriptive `aria-label`,
closing it after the GitHub and Ko-fi links. Also locate the header’s footer
link row and wrap it in a separate appropriately labelled `<nav>` element,
preserving the existing links and layout.
In `@frontend/src/components/landing-v5/ActIngest.tsx`:
- Around line 259-282: Add aria-hidden="true" to the decorative flying chip
elements rendered in the CHIPS.map callback within ActIngest, ensuring assistive
technologies ignore these duplicate labels while leaving their visual animation
and styling unchanged.
In `@frontend/src/components/landing-v5/ActTutor.tsx`:
- Around line 95-97: Add type="button" to the pill button rendered in ActTutor’s
button map alongside its existing key and onClick props, matching the explicit
button type used by the other buttons in this PR.
In `@frontend/src/components/landing-v5/Closing.tsx`:
- Line 92: Replace the hardcoded “© 2026” values in the Closing component with a
single dynamically derived current year, and reuse that value in both copyright
locations near the existing spans.
In `@frontend/src/components/landing-v5/DragField.tsx`:
- Around line 70-103: Update the label branch in the nodes mapping within
DragField’s render flow to always create a text element, including when label is
missing. Preserve the glow, ring, and label ordering and use an empty
placeholder for missing label content while retaining valid label positioning
and styling.
In `@frontend/src/components/landing-v5/Faq.tsx`:
- Around line 84-121: Update the FAQ trigger and answer panel so each button in
the mapped FAQ item has an aria-controls reference matching that panel’s unique
id. Add hidden or equivalent visibility handling to the panel when open is
false, while preserving the existing expanded state and visual transition
behavior.
In `@frontend/src/components/landing-v5/FeatureLab.tsx`:
- Around line 41-50: Add dialog semantics to the fixed overlay in FeatureLab by
assigning role="dialog", aria-modal="true", and an accessible name tied to the
panel heading or another existing label; implement focus movement to the close
button when open and restore focus when closed. In the tool rail, add an
explicit active-state attribute such as aria-current or aria-selected to the
open tool instead of relying on background color alone.
In `@frontend/src/components/landing-v5/Gallery.tsx`:
- Around line 87-109: Add an explicit accessible role to the div rendered by
Track so its existing aria-label is announced by assistive technology; use
role="group" without changing the current card rendering or interaction
behavior.
- Around line 71-73: Remove the redundant frame styling from the wrapper div
rendering GALLERY_MINIS[i] in Gallery: eliminate its duplicated height,
borderRadius, background, border, and overflow/position container behavior as
appropriate, while preserving the mini’s own frame and layout. Keep the
aria-hidden wrapper only if needed without adding a second visual frame.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 100-115: Update the flip target around the onClick={flip} handler
to be keyboard accessible: prefer a button element, or add role="button",
tabIndex={0}, and an onKeyDown handler that invokes flip for Enter and Space
while preventing the default Space behavior. Preserve the existing click
interaction and visual styling.
In `@frontend/src/components/landing-v5/lab/GradesDemo.tsx`:
- Around line 74-83: Update the onChange sanitizer in the GradesDemo input to
allow at most one decimal point while preserving digits and the existing state
update behavior. Ensure values such as “1.2.3” are normalized so the displayed
text matches the value used by score computation.
In `@frontend/src/components/landing-v5/lab/GuideDemo.tsx`:
- Line 40: Use a consistent lookup style for the GUIDES map in the GuideDemo
component: update the initialization around set and the lookup in the relevant
course iteration around c so both rely on the CourseKey type without an
unnecessary nullish fallback, or both use the same fallback pattern. Preserve
the existing guide-selection behavior while removing the inconsistent
implication that GUIDES[course] may be missing.
In `@frontend/src/components/landing-v5/lab/NotesDemo.tsx`:
- Around line 57-80: Store the timers created by extract and summarize in refs
alongside saveT, clear their handles on unmount, and reset each ref after its
callback runs. Follow the existing timer-ref cleanup pattern in FeatureLabDemo
so callbacks cannot update state after the component unmounts.
In `@frontend/src/components/landing-v5/lab/QuizDemo.tsx`:
- Line 31: Extract the repeated q.options correctness check into a shared
predicate or derived value near the existing right calculation, then reuse it in
both locations. Update submit’s setStreak call to use the functional updater
form based on the previous streak value, preserving the existing
increment-or-reset behavior without reading streak from the render closure.
In `@frontend/src/components/landing-v5/lab/RoomsDemo.tsx`:
- Around line 76-106: Update the message-list container in RoomsDemo so it has
aria-live="polite" and automatically scrolls to the newest message after each
append. Use a ref and an effect tied to the message-list state, preserving the
existing rendering and styling.
In `@frontend/src/components/landing-v5/Navbar.tsx`:
- Around line 199-234: Remove the incomplete ARIA menu pattern from the navbar:
delete role="menu" from the panel and aria-haspopup="menu" from its trigger,
while preserving the existing links and open/close behavior. Expose the panel as
a plain navigation group without adding menuitem roles or keyboard navigation.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 383-399: Optimize both scanning effects in useLanding: replace the
first effect’s recurring setInterval(scan, 900) polling with a MutationObserver
watching root and invoking scan for relevant DOM additions, while preserving
cleanup and observer behavior. In the second effect around the section
descendant traversal, query only elements marked with the intended animation
attribute (such as [data-anim]) instead of '*', and retain the existing
animation logic for matched elements.
In `@frontend/src/lib/landing/companionContent.ts`:
- Around line 149-160: Update WIKI_TIERS.dot and WIKI_RATINGS.tone to store the
raw hex color values from TIER instead of CSS declaration strings. In the wiki
page consumer, remove the cssColor regex and parsing/fallback logic, and use
t.dot and r.tone directly as colors.
In `@frontend/src/lib/landing/content.ts`:
- Around line 38-135: Document that GAL’s array order is a positional contract
and must not be reordered or have entries inserted without updating callers. Add
and export a galIndexOf helper keyed by GalleryKind, then update the page.tsx
actions.openGal calls to resolve indices through that helper instead of
hard-coded positions.
In `@frontend/src/lib/landing/dragClusters.ts`:
- Line 36: Remove the redundant `as DragCluster[]` assertion from the
`DRAG_CLUSTERS` array literal, preserving its existing `DragCluster[]` type
annotation so excess-property and required-field checks remain enabled.
In `@frontend/src/lib/landing/engine/flip.ts`:
- Around line 24-37: Remove the unused ran field from the FlipState interface
and its initialization in createFlipState, and remove all assignments to st.ran,
including in armFlip and flipClose. Keep the actual animation guard in
useLanding.ts via flipRan unchanged.
In `@frontend/src/lib/landing/engine/plant.ts`:
- Around line 78-91: Update restore() to validate the parsed localStorage value
as an array, retain only string entries, and truncate each label to MAX_LABEL
before calling add() or updating labels. Ensure invalid or non-array data
produces an empty restored label set without partially processing it, while
preserving the seven seed nodes and normal return count.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

Comment threadfrontend/src/app/(public)/page.tsx Outdated
Comment on lines +92 to +109
{/* the engine fades these in once the act's scroll is spent */}
<div data-tutor-pills="1" style={{ display: 'flex', gap: 8, marginTop: 8, opacity: 0, pointerEvents: 'none', transition: 'opacity 400ms ease' }}>
{CAPTIONS.map(([tag], i) => (
<button
key={tag}
onClick={() => onSetMode(i)}
style={{
border: '1px solid rgba(12,86,56,0.3)', borderRadius: 99, padding: '9px 18px',
...MONO, fontSize: 10.5, letterSpacing: '0.12em', cursor: 'pointer',
background: tutorMode === i ? PILL_ON : 'transparent',
color: tutorMode === i ? PILL_ON_FG : '#33443B',
transition: 'all 250ms',
}}
>
{tag}
</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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Find what the engine writes on the tutor pills element.
rg -n -C 6 'tutor-pills|tutorPills' --glob '*.ts' --glob '*.tsx'

Repository: SaplingLearn/Sapling

Length of output: 158


🏁 Script executed:

#!/bin/bashset -e
printf'%s\n''--- candidate files ---'
fd -i 'ActTutor|tutor|landing-v5'.| head -80
printf'%s\n''--- all tutor-pills references ---'
rg -n -C 8 --hidden --glob '!node_modules' --glob '!dist' --glob '!build''tutor-pills|tutorPills|data-tutor'.printf'%s\n''--- ActTutor structure ---'
ACT=$(fd -t f -i 'ActTutor.tsx'.| head -1)if [ -n"$ACT" ];then
ast-grep outline "$ACT"
sed -n '1,180p'"$ACT"fi

Repository: SaplingLearn/Sapling

Length of output: 19055


🏁 Script executed:

#!/bin/bashset -e
printf'%s\n''--- engine writes to M.pills ---'
rg -n -C 10 'M\.pills|pills\.(style|classList)|style\.(opacity|pointerEvents|visibility)|opacity.*pills|pills.*opacity' frontend/src/lib/landing frontend/src/components/landing-v5 --glob '*.ts' --glob '*.tsx'printf'%s\n''--- engine files and relevant structure ---'
fd -t f . frontend/src/lib/landing/engine -x sh -c 'echo --- "$1"; wc -l "$1"; ast-grep outline "$1" | head -80' sh
printf'%s\n''--- direct pills-related engine context ---'
rg -n -C 20 'pills|handover|spent|scroll' frontend/src/lib/landing/engine --glob '*.ts' --glob '*.tsx'

Repository: SaplingLearn/Sapling

Length of output: 50377


Remove hidden pills from the keyboard tab order.

The engine changes only opacity and pointerEvents, so hidden buttons remain focusable. Toggle inert with the engine’s docked state, or toggle visibility there as well.

🤖 Prompt for AI Agents
Verify each 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 92 - 109,
Update the tutor pills container in ActTutor to remove its buttons from keyboard
navigation while hidden by binding the engine’s docked state to inert (or
visibility). Preserve the existing opacity and pointerEvents behavior, and
ensure the pills become focusable again when docked is false.

Comment threadfrontend/src/components/landing-v5/BetaModal.tsx
Comment threadfrontend/src/components/landing-v5/Gallery.tsx
Comment threadfrontend/src/components/landing-v5/lab/CardsDemo.tsx
Comment threadfrontend/src/components/landing/useLanding.ts
Comment threadfrontend/src/components/landing/useLanding.ts
Comment threadfrontend/src/components/marketing/SignInModal.tsx Outdated
Comment threadfrontend/src/lib/landing/content.ts Outdated
Comment on lines +191 to +218
/**
* Presentation metadata paired positionally with POSTS.
*
* NOTE: `journal-founding.png` and `journal-ai-homework.png` are not in
* `public/` yet — both exceed the design API's 256 KiB read cap, so they
* ship with the project export alongside the rest of the source.
*/
export const POST_META: PostMeta[] = [
{
slot: 'journal-1',
src: '/journal-founding.png',
slotHint: 'Drop the founding photo',
isPhoto: true,
isGraph: false,
comments: 12,
likes: 84,
floatDelay: '0s',
},
{
slot: 'journal-2',
src: '/journal-ai-homework.png',
slotHint: 'Drop an image for this essay',
isPhoto: true,
isGraph: false,
comments: 31,
likes: 126,
floatDelay: '-2.5s',
},

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

Two journal images are declared but may not ship.POST_META points at /journal-founding.png and /journal-ai-homework.png, and the file's own comment states both assets are absent from public/. Journal.tsx renders those paths through next/image with no fallback, so two of the three journal cards can show a broken image.

  • frontend/src/lib/landing/content.ts#L191-L218: add the two PNG files to public/, or set src: '' and isGraph: true on those entries until the assets ship.
  • frontend/src/components/landing-v5/Journal.tsx#L106-L118: keep the meta.isGraph branch as the fallback path, so an entry without a usable src renders GraphThumb instead of a broken image.
📍 Affects 2 files
  • frontend/src/lib/landing/content.ts#L191-L218 (this comment)
  • frontend/src/components/landing-v5/Journal.tsx#L106-L118
🤖 Prompt for AI Agents
Verify each 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/content.ts` around lines 191 - 218, In
frontend/src/lib/landing/content.ts lines 191-218, mark the unavailable
journal-founding.png and journal-ai-homework.png entries with empty src values
and isGraph: true so they use the graph fallback until assets are shipped. In
frontend/src/components/landing-v5/Journal.tsx lines 106-118, preserve the
meta.isGraph branch that renders GraphThumb instead of passing unusable paths to
next/image.

Comment threadfrontend/src/lib/landing/engine/sim.ts
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — v5 marketing landing onto staging

This ports the v5 marketing landing onto staging as a public-surface-only change, and the central claim in the description holds up: I verified there are no backend/** files, no migrations, no middleware.ts, no next.config.ts and no wrangler.toml in the diff (74/74 changed files are under frontend/), and git diff --diff-filter=D is empty — nothing is deleted out from under another surface. The Layer-2 token scoping is done properly (.public-surface, .landing-page in globals.css:828, with deliberate literal fallbacks on .card--hero), and every new landing-v5/marketing/companion component carries "use client". The problems are elsewhere: a dropped auth-error path, staging metadata that points at production, a colour system that bypasses the design tokens entirely, and — the big one — this branch is a stale snapshot of a landing that is already merged on main.

Findings

P0

[P0] Both CI jobs are red, and the failures hide the fact that nothing was typechecked or tested.github/workflows/ci.yml:41,90-94

Backend (ruff check ., run 31553863104):

F401 [*] `pydantic.Field` imported but unused
--> models/gradescope.py:12:33
Found 1 error.
##[error]Process completed with exit code 1.

Frontend (npx eslint ., same run):

frontend/src/components/settings/GradescopeConnect.tsx
65:6 error React Hook useEffect has a missing dependency: 'refreshAll'. Either include it or
remove the dependency array react-hooks/exhaustive-deps
✖ 33 problems (1 error, 32 warnings)
##[error]Process completed with exit code 2.

To be clear about attribution: neither is yours. Both reproduce verbatim at the base commit 82787cabackend/models/gradescope.py imports Field with zero Field( uses there, and GradescopeConnect.tsx:65 is already }, []);. gh run list --branch staging shows staging's last green run was 8bce5b02 on 2026-07-10; 82787ca never ran CI, so it landed red. The reason this is still P0 for this PR: the workflow runs ruff before pytest, and eslint before npx tsc --noEmit and npm test. Both jobs die at the lint step, so the type checker and both test suites never executed against these 14,954 added lines. You currently have no signal that the port compiles. Fixing base (or rebasing once it is fixed) is a prerequisite for this PR meaning anything.

P1

[P1] ?error=session_expired no longer reaches the sign-in modalfrontend/src/app/(public)/page.tsx:89

<SignInModalopen={signInOpen}onClose={()=>setSignInOpen(false)}/>

middleware.ts (untouched here) bounces expired sessions to the landing with a query param:

// middleware.ts:38if(errorCode)u.searchParams.set('error',errorCode)// middleware.ts:52 and :67if(!session)returnredirectToSignin(request,'session_expired')

The page this replaces read that param and threaded it through — base (public)/page.tsx:90const err = params.get('error'), then :879errorCode={signInError}. The new page never reads ?error (no searchParams/URLSearchParams anywhere in page.tsx or useLanding.ts), so the prop is never supplied. Meanwhile components/marketing/SignInModal.tsx still ships the whole error surface — ERROR_COPY at :11 including session_expired at :16, and :122const visibleError = localError ?? errorCode ?? null — all now dead. Net effect: a user kicked out mid-session lands on the marketing page with no explanation of why they were signed out. Restore the ?error= read and pass it through.

[P1] The staging deploy will advertise production canonical and OG URLsfrontend/src/app/layout.tsx:43

metadataBase: newURL(resolveSiteUrl(process.env)),alternates: {canonical: '/'},

resolveSiteUrl falls through to production when neither knob is set:

// deployGuard.ts:98-106exportfunctionresolveSiteUrl(env: EnvSource): string{constdeployEnv=(env.DEPLOY_ENV??'').trim().toLowerCase();if(deployEnv&&deployEnvinFRONTEND_ENVS)returnFRONTEND_ENVS[deployEnvasFrontendEnv].siteUrl;constexplicit=(env.NEXT_PUBLIC_SITE_URL??'').trim();if(explicit)returnexplicit.replace(/\/+$/,'');returnFRONTEND_ENVS.production.siteUrl;}

frontend/wrangler.toml is not modified by this PR, and its [env.staging.vars] block sets only NEXT_PUBLIC_API_URL, BACKEND_URL and COOKIE_DOMAINneither DEPLOY_ENV nor NEXT_PUBLIC_SITE_URL. So on frontend-staging every page resolves metadataBase to https://saplinglearn.com, making canonical, og:url and og:image all point at production. Since the entire point of this PR is "show the new landing on staging", any staging link shared for review will unfurl with production's card, and staging self-canonicalises to prod. Set DEPLOY_ENV = "staging" in [env.staging.vars] (which also arms the existing checkFrontendDeployEnv lock in next.config.ts:46).

[P1] The landing runs a parallel colour palette that shares no value with the design tokensfrontend/src/components/landing-v5/*

Counting hex literals across landing-v5 (excluding the galleryMinis illustration art) gives ~900 hardcoded colours. The most frequent:

79 #0c5638 48 #fdfcf9 46 #0e9e5a 40 #12201a
37 #33443b 34 #9aa5a0 31 #8b9891 30 #8fd9a8

Not one of these is a token. The core set in globals.css is --brand-forest: #1B6C42, --brand-forest-bright: #2D8F5C, --sap-500: #4e873c, --ink-0: #faf8f3, --ink-800: #1a1814. Note the near-misses in particular: #fdfcf9 vs --ink-0 #faf8f3, and #12201a vs --ink-800 #1a1814. They are close enough to look like the brand and different enough to be visibly off it at the seam where a user crosses from the landing into the app shell. The Engineering Style Guide §3/§4 is explicit — "inline style objects referencing global CSS-variable tokens … Use the tokens; don't hardcode values." This is doubly odd because the PR itself builds the correct scaffolding: (public)/layout.tsx mounts .public-surface and globals.css:839-841 defines --surface-hero/--brand-glow on it, and then no component uses any of it.

[P1] This is a stale snapshot of a landing already merged on main, and promoting it will fight main — whole landing-v5 tree

origin/main is 4f5a7f9 Merge pull request #524 from SaplingLearn/feat/landing-v5-port — the v5 landing is already there. git merge-base origin/staging origin/main is 57469ad, which contains nofrontend/src/components/landing-v5/, so the directory is added independently on both sides: promoting stagingmain is an add/add conflict across ~50 files, not a fast merge. main has taken 19 commits on the landing since the merge-base, and git diff origin/main 395588da over the landing tree is only 4 files / +9 −38 — i.e. this branch is behind, missing a3d0371 fix(landing): raster, veil and scroll-coupling polish on the v5 hero (the MAG/zoom raster correction in ActTutor.tsx, and the re-tuned hero washes in Hero.tsx).

The dangerous part is that one of those divergences is invisible to the type checker:

// this branch — hero3d/index.ts:52-56 (an intensity multiplier, 0..2)exportfunctionstartHeroRig(canvas,getMouse,getParallax: ()=>number)// origin/main — hero3d/index.ts:76-80 (a scroll offset in pixels)exportfunctionstartHeroRig(canvas,getMouse,getHeroShiftPx: ()=>number)

Both are () => number, and both hero3d/index.ts and useLanding.ts are in the conflict set. Resolving that conflict by taking one file from each side compiles cleanly and silently breaks the hero. Please rebase this branch onto main's landing rather than re-adding an older copy, or state explicitly how the promotion merge will be resolved.

P2

[P2] Three of the four new deployGuard exports have no production callerfrontend/src/lib/deployGuard.ts:52-146

resolveFrontendEnv, expectedEnvForHost and detectHostConfigMismatch are referenced only by deployGuard.test.ts. resolveSiteUrl is the only one wired into anything (layout.tsx:4). detectHostConfigMismatch's own docstring bills it as "Runtime defence-in-depth for what the build-time guard cannot see", citing "the exact failure behind staging's session_expired" — but middleware.ts is not in this diff, so nothing calls it and it defends nothing. That is ~100 lines of inert code plus 131 lines of tests landing in a shared lib on a marketing PR. Either wire it into the middleware or drop it from this PR.

[P2] components/SignInModal.tsx is now orphaned, and its replacement is a near-copyfrontend/src/components/SignInModal.tsx

After this PR the only SignInModal import in the tree is (public)/page.tsx:14@/components/marketing/SignInModal. The original components/SignInModal.tsx (347 lines) has zero importers but is still in the tree; the new components/marketing/SignInModal.tsx is 352 lines and diff between the two is 67 lines. Two copies of the OAuth modal, one of them dead, will drift — and the dead one is the one that still gets fed errorCode in nobody's code path. The Engineering Style Guide §3 calls this out by name as the reason components/ui/ exists ("before they landed the app had 5 distinct button shapes and 18+ pill/badge implementations"). Delete the orphan or make the marketing one a thin wrapper.

[P2] A new eslint suppression was added for code introduced in this PRfrontend/eslint-suppressions.json

"src/components/marketing/SignInModal.tsx": {
"react-hooks/set-state-in-effect": { "count": 1 }
},

Style Guide §7 states the ratchet's purpose as "don't fix the whole backlog to land a change, but never add to it." The baseline exists to grandfather legacy violations; marketing/SignInModal.tsx is created by this PR, so this is a new violation being suppressed rather than fixed. set-state-in-effect is also usually a real render-loop smell worth a look rather than a waiver.

[P2] .landing-page no longer exists, leaving dead CSS and a comment that now says the opposite of the truthfrontend/src/app/(public)/layout.tsx:7

// The landing page keeps its own .landing-page class for the mesh-background visual.exportdefaultfunctionPublicLayout({ children }: {children: React.ReactNode}){return<divclassName="public-surface">{children}</div>;}

No element in the tree carries className="landing-page" at this HEAD — the only remaining mentions are three comments. But globals.css:866-874 still ships the whole mesh block that depends on it:

.landing-page .sapling-mesh-blob { position: absolute; border-radius:50%; filter:blur(100px); … }
.landing-page .sapling-mesh-blob--1 { top:-10%; left:-10%; … }

along with the other .landing-page-prefixed rules the diff kept. All unreachable. Either the wrapper should still apply the class (if the mesh backdrop is meant to render) or the rules and the comment should go.

P3

[P3] 1.3 MB of unoptimized PNGs committedfrontend/public/

journal-founding.png is 821,816 bytes and journal-ai-homework.png is 500,189 bytes. They are rendered through next/image with a sizes hint (Journal.tsx:110-114), so delivery is fine, but the repo and the OpenNext asset bundle both carry the full-size originals. Worth running them through a compressor or shipping WebP/AVIF sources.

What's good

  • The scope discipline is real and verifiable: no backend files, no migrations, no middleware.ts, no wrangler.toml, and nothing deleted — exactly what the description promises, and a much better call than merging the 217-commit branch with its colliding 0032_*/0033_* migrations.
  • The Layer-2 token boundary is set up correctly — .public-surface scoping in (public)/layout.tsx mirroring (shell), with the literal fallbacks on .card--hero and a comment explaining exactly why they are there. That is the right structure; the components just need to start using it.
  • "use client" is correct on every new hook-using component, and the deployGuard additions are genuinely well-tested (131 lines of focused unit tests) even though most of the code they cover is not yet wired up.

Verdict: request changes — the auth-error regression and the staging-metadata bug are both user-visible, and the branch needs to be reconciled with the v5 landing already on main before it can be promoted.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…PLOY_ENV
CI was red at the LINT step on both jobs, so tsc, pytest and vitest never
ran on this port at all.
- backend/models/gradescope.py: drop the unused pydantic Field import (F401).
- settings/GradescopeConnect.tsx: stabilise refreshAll with useCallback and
list it in the mount effect (exhaustive-deps is an error here). Keying on
userId also fixes the stale-account bug the empty dep array hid.
- (public)/page.tsx: read the ?error= code middleware.ts redirects with and
thread it into the modal, opening it when a code is present. Read through
useSyncExternalStore so the page stays statically prerenderable and no
setState lands in an effect; dismissing the modal strips the param.
- page.tsx: openGal indices now come from galIndexOf('quiz'/'tutor') — the
'Ask the tutor' button opened the notetaker panel. BetaModal onClose now
calls resetSubscribeError so a failed subscribe stops leaking into Journal.
- marketing/SignInModal.tsx: fix set-state-in-effect at source (reset during
render on the open prop change) and remove the suppression entry it was
given. OAuth now uses the frontend-relative /api/auth/google for both the
popup and the same-tab fallback, so the session cookie is not third-party.
- delete the orphaned components/SignInModal.tsx (zero importers) and point
the eslint data-testid rule at its replacement.
- deployGuard.classify: normalise trailing slashes on URL fields (not on
cookieDomain) so a valid slashed API/site URL cannot silently disarm the
guard. Five regression tests, all of which fail without the fix.
- middleware.ts: wire resolveFrontendEnv + detectHostConfigMismatch in, so a
host/backend mismatch reports env_misconfig instead of looping the user
through session_expired forever.
- wrangler.toml/package.json: set DEPLOY_ENV per environment so staging stops
advertising production canonical/OG URLs and the build-time guard is armed.
- globals.css/(public)/layout.tsx: delete the ~950 lines of .landing-page CSS
the replaced page made unreachable, fix the inverted comment, unquote the
single-identifier Spectral, and scope a keyframes-name-pattern disable to
the ported region only.
The a11y fixes in this PR all defend behaviour that is invisible on screen
and in review: a gallery card that only opens on click, a navbar that stays
clickable while transparent, an accordion whose closed answers remain in the
tab order, a role="radiogroup" containing no radios.
Two lab-demo state bugs get the same treatment — a reply naming a different
partner than the typing indicator, and a footer counting an empty-result
sentinel as "1 concepts" — plus the galIndexOf contract that replaced the
hard-coded GAL indices.
Renamed from the scratch file that commit b6c3a8a swept in mid-edit.
- news: card CTA said 'Read article' while every card links to /#newsletter;
it now says 'Get notified' and the Link carries a matching aria-label. The
category listbox closes on Escape and returns focus to its trigger (also on
option select) — dismissing it used to drop focus to <body>.
- about: the copy sent readers to 'a feedback button in the navbar' that
CompanionShell does not render; it now links the GitHub issue tracker.
- team/wiki: add the alternates.canonical their siblings already had.
- careers/[slug]: only append the meta-description ellipsis when slice(0,155)
actually truncated, so short blurbs stop reading as cut off.
- wiki: move scrollMarginTop from the H2 to the <section> elements — the TOC
fragments target the sections, so anchors landed under the sticky header.
Drop cssColor(): companionContent.ts now stores bare hex.
- faq: unique panel ids + aria-controls, and 'visibility' in the collapsed
style so collapsed answers are neither focusable nor announced.
- careers/CareersList: same collapsed-panel fix, so 'Apply for this role' is
not tabbable while closed; both raw <img> replaced with next/image.
- CompanionShell: wrap the header link group and the footer link row in
labelled <nav> landmarks. The compact-nav media query was scoped to
.landing-dc, which this shell must never wear, so below 1180px the
overflowing tab row stayed and the disclosure stayed hidden — unscoped it.
- page.tsx: read ?signin=1 too. CompanionShell's header 'Sign In' links to
/?signin=1 and nothing read it, so that control did nothing at all.
- public/journal-*.png: 1.29 MB -> 432 KB (-67.8% / -64.6%) via libimagequant
256-colour quantisation with Floyd-Steinberg dither (same engine pngquant
uses; RMSE 2.0 and 2.8 of 255). A fully lossless re-encode was tried first
and was larger than the originals. Filenames and paths unchanged.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Blocker

  • Both CI jobs died at the lint step, before tsc / pytest / vitest ever ran — so none of this port had been typechecked or tested. Both base failures fixed properly: the unused pydantic.Field import, and GradescopeConnect.tsx's exhaustive-deps (via useCallback keyed on userId, which also fixes a real bug the empty dep array hid — switching user kept the previous account's state).

Major

  • ?error=session_expired never reached the modal.middleware.ts sets it and marketing/SignInModal still shipped the whole ERROR_COPY surface, all dead. Now read and threaded through, opening the modal on an inbound code and stripping the param on dismiss.
  • Staging advertised production canonical/OG URLsresolveSiteUrl fell through to prod because [env.staging.vars] set neither knob. DEPLOY_ENV = "staging" added, which also arms the existing build-time guard.
  • deployGuard.classify now normalises trailing slashes, so a valid https://api.saplinglearn.com/ no longer bypasses the runtime guard; detectHostConfigMismatch is wired into middleware.ts so it actually defends something.
  • The orphaned components/SignInModal.tsx (zero importers, 67-line drift from its replacement) is deleted.

CodeRabbit majors

onLearn opened the notetaker instead of the tutor (now via the new galIndexOf lookup, not a magic index) · OAuth URLs kept same-origin · DARK_SECTIONS reduced to the one genuinely dark section · Escape now exits explore mode (was a keyboard trap with body { overflow: hidden }) · sim.destroy() re-homes clusters so a StrictMode remount no longer kills the drag field permanently · hero-canvas unmount race · CardsDemo timer leak, global key capture and keyboard-inoperable flip · Gallery cards keyboard-openable · BetaModal single-column below the breakpoint + type="email" · hidden nav/pills removed from the tab order.

Minor / nits

Dead .landing-page CSS removed · stylelint font-family-name-quotes 5→0 and keyframes-name-pattern 42→0 · lab-demo correctness (clamped percentages, partner snapshot, concept count, invalid guide picks) · ARIA roles and labels across FAQ/careers/calendar/quiz/rooms · localStorage input validated · journal PNGs recompressed 1.32 MB → 442 KB.

Still open (deliberately not done)

This branch is a stale snapshot of a landing already merged on main (#524) — promoting it is an add/add conflict across ~50 files, and startHeroRig's third parameter changed meaning while keeping an identical () => number type, so a file-by-file conflict resolution compiles cleanly and silently breaks the hero. That is a merge-strategy decision, not a code fix.

Verificationruff check . clean · tsc clean · eslint 0 errors (base had 1) · 184 frontend tests pass

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

This branch now carries a backend change, via #565

staging picked up the Gradescope repair in #565, so this PR is no longer frontend-only. That fix restored routes/gradescope.py and services/gradescope_service.py from main because the version on staging was unreachable (router prefix declared twice → every endpoint served at /api/gradescope/api/gradescope/...), called a DB client that does not exist (.eq().execute() on a PostgREST helper), and had no rate limiting or bu-sso.

I checked statically that every table the restored route touches exists on staging — gradescope_credentials, gradescope_course_links, assignments, gradebook_categories — so there is no missing-table hazard. But that is a check of the repo's migration files, not of the staging database's actual ledger. Please confirm on the real database:

SELECT table_name FROMinformation_schema.tablesWHERE table_schema ='public'AND table_name IN ('gradescope_credentials','gradescope_course_links','gradescope_links');

All three should be present. Then exercise one endpoint end to end and confirm it resolves at a single/api/gradescope/... prefix rather than 404ing.

Two things #565 deliberately left behind

SELECTcount(*) FROM gradescope_links;

gradescope_links (created by staging's 0033_add_gradescope_links.sql) now has no reader — the restored route uses gradescope_course_links. services/gradescope_sync_service.py has zero importers, and models/gradescope.py is imported only by that dead module. Nothing breaks, but if the table holds rows they are stranded, and the two modules are dead code worth removing in a follow-up.

Ledger divergence between staging and main

Staging carries two migrations main does not, at numeric prefixes main reuses for different files:

prefixstagingmain
00320032_add_gradescope_credentials.sql0032_rooms_missing_columns.sql, 0032_retire_summer_2026.sql
00330033_add_gradescope_links.sql0033_offering_section_not_null.sql, 0033_realtime_publish_room_messages.sql

schema_migrations keys on the full basename, so these coexist without a primary-key collision — but main has 23 migrations staging lacks, and staging is 239 commits behind. Whenever these branches are reconciled, both migration sets apply and the relative ordering within 0032/0033 is alphabetical by accident rather than by design. Worth settling deliberately rather than discovering it during a promotion.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

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.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, '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 the v5 marketing landing onto staging by Darkest-Teddy · Pull Request #535 · SaplingLearn/Sapling · GitHub
Skip to content

feat(landing): port the v5 marketing landing onto staging - #535

Open
Darkest-Teddy wants to merge 5 commits into
stagingfrom
feat/landing-v5-on-staging
Open

feat(landing): port the v5 marketing landing onto staging#535
Darkest-Teddy wants to merge 5 commits into
stagingfrom
feat/landing-v5-on-staging

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Puts the v5 marketing landing on stagingwithout merging feat/landing-v5-port.

Why not just merge the v5 branch

feat/landing-v5-port is 217 commits ahead of origin/staging and the diff is mostly not the landing page — 179 backend test files, 31 agents, 27 services, 17 routes, and 15 new migrations, including two colliding 0032_* and two colliding 0033_* basenames. It also isn't a fast-forward: staging has one commit that branch lacks (82787ca, gradescope). None of that is needed to show the new landing, so this ports the public surface only — no backend changes, no migrations.

What's in here

  • components/landing-v5/** and lib/landing/** — the landing engine, hero3d, drag simulation, and the Act sections
  • The (public) route group: the new /, plus /team, /wiki, /gallery, /news, /faq — the v5 navbar links to these and staging didn't have them
  • components/marketing/** and components/companion/CompanionShell
  • globals.css and app/layout.tsx (OG/metadata), lib/deployGuard.tswith its matching test, lib/errorMessage.ts
  • api.ts gains only subscribeToNewsletter — staging's backend already mounts /api/newsletter

One deliberate divergence from upstream

marketing/SignInModal calls the 3-argsetActiveUser. Upstream passes a 4th { persist } option that exists only on that branch's UserContext, whose wider change (+127/−21) also adds app-wide session recovery and shell-route redirects. Changing auth behaviour for every screen is out of scope for a landing port, so the option is dropped here and should come back in the same commit that brings that UserContext over. The call site carries the same note.

eslint-suppressions.json gains an entry for the new components/marketing/SignInModal.tsx path; staging only suppressed that rule at the old components/SignInModal.tsx path.

Verification

  • tsc --noEmit — clean
  • next build with staging's build vars (DEPLOY_ENV/BACKEND_URL/NEXT_PUBLIC_API_URL/COOKIE_DOMAIN) — succeeds, emitting /team, /wiki, /gallery, /news, /faq
  • eslint — clean except one pre-existing error: react-hooks/exhaustive-deps in settings/GradescopeConnect.tsx:65, which arrives untouched from origin/staging (it is neither modified nor suppressed on that branch). CI will likely go red on it until it's fixed separately — deliberately not touched here.
  • vitest was not run locally — it needs Node 22 and this machine is on 20. CI runs it on Node 22.

Deploy note

Per wrangler.toml and ADR 0020, the staging Workers Build must set DEPLOY_ENV=staging as a build variable, not just a runtime var — the /api rewrite and NEXT_PUBLIC_* are baked at build time. That omission is what previously put prod config on the staging worker.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added public About, Team, Wiki, Gallery, News, FAQ, and Careers pages.
    • Introduced interactive galleries, article search and filtering, FAQ accordions, career listings, and rich knowledge-base content.
    • Redesigned the landing experience with interactive learning demonstrations, knowledge graphs, tutor modes, quizzes, flashcards, study tools, and beta signup.
    • Added Google sign-in and newsletter subscription flows.
  • Improvements

    • Standardized navigation, page layouts, accessibility, responsive styling, animations, and site metadata.
    • Improved error messaging and deployment-aware canonical links.

Brings the v5 public site to `staging` WITHOUT the rest of
`feat/landing-v5-port`: that branch is 217 commits ahead of staging and
carries backend routes, services, agents and 15 migrations (including two
colliding 0032_* and two 0033_* basenames). None of that is needed to show
the new landing, so this ports the public surface only.
What's here:
- components/landing-v5/** + lib/landing/** (the landing engine, hero3d,
drag simulation and the Act sections)
- the (public) route group: the new / plus /team, /wiki, /gallery, /news
and /faq, which the v5 navbar links to and staging did not have
- components/marketing/** and components/companion/CompanionShell
- globals.css and app/layout.tsx (OG/metadata), lib/deployGuard.ts with
its matching test, lib/errorMessage.ts
- api.ts gains only `subscribeToNewsletter`; staging's backend already
mounts /api/newsletter
One deliberate divergence from upstream: marketing/SignInModal calls the
3-arg `setActiveUser`. Upstream passes a 4th `{ persist }` option that only
exists on that branch's UserContext, whose wider change also adds app-wide
session recovery and shell-route redirects. Changing auth for every screen
is out of scope for a landing port, so the option is dropped here and should
return with that UserContext. The call site carries the same note.
eslint-suppressions.json gains an entry for the new
components/marketing/SignInModal.tsx path; staging only suppressed the rule
at the old components/SignInModal.tsx path.
Verified: tsc --noEmit clean, and `next build` succeeds with staging's build
vars (DEPLOY_ENV/BACKEND_URL/NEXT_PUBLIC_API_URL/COOKIE_DOMAIN), emitting
/team, /wiki, /gallery, /news and /faq. eslint is clean except for a
pre-existing react-hooks/exhaustive-deps error in
settings/GradescopeConnect.tsx, which arrives untouched from origin/staging.
vitest was not run locally (needs Node 22; this machine is on 20) - CI runs it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 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 ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:37 seconds

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 101dbfe3-a2c2-4287-842f-6e2c86a82b2e

📥 Commits

Reviewing files that changed from the base of the PR and between 395588d and 46f7930.

⛔ Files ignored due to path filters (2)
  • frontend/public/journal-ai-homework.png is excluded by !**/*.png
  • frontend/public/journal-founding.png is excluded by !**/*.png
📒 Files selected for processing (57)
  • docs/frontend-testids.md
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/package.json
  • frontend/src/app/(public)/about/page.tsx
  • frontend/src/app/(public)/careers/CareersList.tsx
  • frontend/src/app/(public)/careers/[slug]/page.tsx
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/layout.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/ActGraph.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/ActTutor.tsx
  • frontend/src/components/landing-v5/BetaModal.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/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/landing-v5-interaction.test.tsx
  • frontend/src/components/landing-v5/navTheme.ts
  • frontend/src/components/landing/useLanding.ts
  • frontend/src/components/marketing/HeroCard.test.tsx
  • frontend/src/components/marketing/SignInModal.tsx
  • frontend/src/lib/deployGuard.test.ts
  • frontend/src/lib/deployGuard.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/dom.ts
  • frontend/src/lib/landing/engine/flip.ts
  • frontend/src/lib/landing/engine/index.ts
  • frontend/src/lib/landing/engine/marquee.ts
  • frontend/src/lib/landing/engine/plant.ts
  • frontend/src/lib/landing/engine/sim.test.ts
  • frontend/src/lib/landing/engine/sim.ts
  • frontend/src/middleware.ts
  • frontend/wrangler.toml
📝 Walkthrough

Walkthrough

The PR replaces the landing page with Landing v5, adds companion and careers pages, introduces shared public layouts and content, expands deployment metadata, and adds OAuth, newsletter, error-handling, CSS, and interaction infrastructure.

Changes

Landing v5 experience

Layer / File(s)Summary
Landing contracts and content
frontend/src/lib/landing/*
Adds typed landing content, course graphs, drag clusters, lab fixtures, color utilities, and navigation themes.
Landing sections and demos
frontend/src/components/landing-v5/*
Adds the v5 hero, graph, ingest, tutor, gallery, FAQ, journal, CTA, navigation, modal, and feature-lab components.
Landing runtime and composition
frontend/src/components/landing/useLanding.ts, frontend/src/lib/landing/engine/*, frontend/src/app/(public)/page.tsx
Connects React state to scroll, canvas, WebGL, graph, marquee, FLIP, planting, newsletter, and drag-field behavior.
Landing styles and tests
frontend/src/app/globals.css, frontend/src/components/landing-v5/*.test.tsx, frontend/src/lib/landing/engine/sim.test.ts
Adds responsive landing layouts, finite animations, and interaction regression coverage.

Companion and public pages

Layer / File(s)Summary
Shared companion shell and content
frontend/src/components/companion/CompanionShell.tsx, frontend/src/lib/landing/companionContent.ts
Adds shared navigation, footer links, and typed content collections.
Companion pages
frontend/src/app/(public)/about/*, team/*, faq/*, gallery/*, news/*, wiki/*
Adds shared-shell pages with data-driven content, filters, accordions, navigation, and metadata.
Careers routes
frontend/src/app/(public)/careers/*
Moves interactive job listings into CareersList, adds route metadata, and returns not-found responses for invalid slugs.

Platform and marketing infrastructure

Layer / File(s)Summary
Deployment and metadata
frontend/src/lib/deployGuard.ts, frontend/src/lib/deployGuard.test.ts, frontend/src/app/layout.tsx, frontend/src/app/(public)/privacy/page.tsx, frontend/src/app/(public)/terms/page.tsx
Adds environment resolution, canonical site URLs, host mismatch detection, structured application metadata, and legal-page metadata.
Authentication, newsletter, and marketing UI
frontend/src/components/marketing/SignInModal.tsx, frontend/src/components/marketing/HeroCard.tsx, frontend/src/lib/api.ts, frontend/src/lib/errorMessage.ts
Adds OAuth popup handling, accessible modal behavior, reusable hero-card rendering, newsletter subscription requests, and defensive error normalization.

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

Sequence Diagram(s)

sequenceDiagram
participant Visitor
participant LandingPage
participant useLanding
participant LandingEngine
participant CanvasAndWebGL
Visitor->>LandingPage: open landing page
LandingPage->>useLanding: initialize refs and state
useLanding->>LandingEngine: start landing lifecycle
LandingEngine->>CanvasAndWebGL: render hero and graph visuals
Visitor->>LandingPage: explore graph or submit newsletter
LandingPage->>useLanding: dispatch interaction
useLanding->>LandingEngine: update interaction state
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 26.42% 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 main change: porting the v5 marketing landing to staging.
Description check✅ PassedThe description covers scope, rationale, changes, testing limits, and deployment requirements, with only minor template omissions.
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 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/landing-v5-on-staging

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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 12, 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-staging46f7930Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:19 PM

@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: 11

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/lib/deployGuard.ts (1)

39-46: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Normalize trailing slashes before environment classification.

A canonical API URL with a trailing slash classifies as unknown. Then detectHostConfigMismatch returns null for a staging host wired to https://api.saplinglearn.com/. This bypasses the runtime guard for a valid URL spelling.

Normalize URL fields before comparison. Add regression tests for trailing-slash API and site URLs.

Proposed fix
 function classify(
value: string | undefined,
field: 'apiUrl' | 'cookieDomain' | 'siteUrl',
): FrontendEnv | null {
- const v = (value ?? '').trim().toLowerCase();+ const v = (value ?? '')+ .trim()+ .toLowerCase()+ .replace(field === 'cookieDomain' ? /$/ : /\/+$/, '');
if (!v) return null;
for (const env of Object.keys(FRONTEND_ENVS) as FrontendEnv[]) {
if (FRONTEND_ENVS[env][field].toLowerCase() === v) return env;
🤖 Prompt for AI Agents
Verify each 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/deployGuard.ts` around lines 39 - 46, Update classify to
normalize URL values by removing trailing slashes before comparing against
FRONTEND_ENVS fields, while preserving existing trimming and case normalization.
Ensure both API and site URL classifications recognize trailing-slash variants,
and add regression coverage for trailing-slash API and site URLs.
🟡 Minor comments (24)
frontend/src/app/(public)/news/page.tsx-175-199 (1)

175-199: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The card link text does not match its destination.

Each card is a <Link href="/#newsletter"> and its call to action reads "Read article →". A reader who selects a headline arrives at the newsletter signup on the home page, not at the post. All six cards lead to the same anchor.

Change the call to action to state the real destination, for example "Get notified →", until article 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/app/`(public)/news/page.tsx around lines 175 - 199, The news
cards’ CTA text incorrectly promises article navigation while each Link in the
posts.map block targets /#newsletter. Update the CTA span text from “Read
article →” to wording that reflects newsletter signup, such as “Get notified →”,
while preserving the existing link destination and styling.
frontend/src/app/(public)/about/page.tsx-80-84 (1)

80-84: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update the copy that points to a feedback button in the navbar.

The paragraph tells the reader to use "a feedback button in the navbar". The ported chrome in frontend/src/components/companion/CompanionShell.tsx renders Home, About, Team, Wiki, Gallery, News, FAQ, GitHub, Ko-fi, Sign In, and Get Started. It has no feedback control. Point the reader at a control that exists, for example the GitHub link or a contact address.

🤖 Prompt for AI Agents
Verify each 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)/about/page.tsx around lines 80 - 84, Update the
paragraph near the prose content to remove the nonexistent navbar
feedback-button reference, and direct readers to an available control such as
the GitHub link or a contact address instead. Preserve the surrounding message
about reporting issues and sharing ideas.
frontend/src/app/(public)/team/page.tsx-18-22 (1)

18-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Two companion routes omit alternates.canonical. The port sets a canonical URL on frontend/src/app/(public)/about/page.tsx, frontend/src/app/(public)/careers/page.tsx, and frontend/src/app/(public)/careers/[slug]/page.tsx, but not on these two routes. Without a canonical URL, these pages can be indexed under query-string or alternate-host variants.

  • frontend/src/app/(public)/team/page.tsx#L18-L22: add alternates: { canonical: '/team' } to the metadata export.
  • frontend/src/app/(public)/wiki/page.tsx#L18-L22: add alternates: { canonical: '/wiki' } to the metadata export.
🤖 Prompt for AI Agents
Verify each 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)/team/page.tsx around lines 18 - 22, Update the
metadata exports in frontend/src/app/(public)/team/page.tsx lines 18-22 and
frontend/src/app/(public)/wiki/page.tsx lines 18-22 to include
alternates.canonical, using /team for the team route and /wiki for the wiki
route.
frontend/src/app/(public)/careers/[slug]/page.tsx-18-22 (1)

18-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The meta description appends an ellipsis even when it truncates nothing.

Line 20 always appends after job.description.slice(0, 155). If a description is shorter than 155 characters, the full sentence still ends with a truncation marker. Search results then show a complete sentence that looks cut off.

Append the ellipsis only when the slice removed text.

🐛 Proposed fix
+ const blurb =+ job.description.length > 155 ? `${job.description.slice(0, 155)}…` : job.description;
return {
title: `${job.title} — Careers`,
- description: `${job.department} · ${job.location} · ${job.type}. ${job.description.slice(0, 155)}…`,+ description: `${job.department} · ${job.location} · ${job.type}. ${blurb}`,
alternates: { canonical: `/careers/${job.slug}` },
};
🤖 Prompt for AI Agents
Verify each 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)/careers/[slug]/page.tsx around lines 18 - 22,
Update the metadata construction in the careers page’s generateMetadata flow so
the description appends “…” only when job.description exceeds 155 characters;
leave shorter or exactly 155-character descriptions without a truncation marker
while preserving the existing 155-character slice.
frontend/src/app/(public)/wiki/page.tsx-28-31 (1)

28-31: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

scrollMarginTop is applied to the wrong element.

The H2 style sets scrollMarginTop: 84. The TOC links on line 67 target the fragments #graph, #mastery, #review, #tutor, #ingestion, #grades, and #privacy. Those ids are on the <section> elements, not on the <h2> elements. The browser applies scroll margin to the element it scrolls into view, so the section top lands under the sticky 92px header from CompanionShell and the heading is hidden.

Move the offset to the sections.

🐛 Proposed fix
 const H2: React.CSSProperties = {
margin: 0, fontFamily: DISPLAY, fontWeight: 500, fontSize: 26,
- lineHeight: 1.2, letterSpacing: '-0.015em', scrollMarginTop: 84,+ lineHeight: 1.2, letterSpacing: '-0.015em',
};
+/** Clears the sticky companion header after an in-page anchor jump. */+const SECTION: React.CSSProperties = { scrollMarginTop: 104 };

Then apply it to each section, for example:

- <section id="graph">+ <section id="graph" style={SECTION}>
🤖 Prompt for AI Agents
Verify each 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 28 - 31, Move
scrollMarginTop: 84 from the H2 style to the section styling used by the
sections targeted by the TOC fragment links (`#graph`, `#mastery`, `#review`, `#tutor`,
`#ingestion`, `#grades`, and `#privacy`). Keep the heading typography unchanged and
ensure each target section receives the offset so it clears the sticky header.
frontend/src/components/companion/CompanionShell.tsx-86-108 (1)

86-108: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Apply the responsive nav rules to CompanionShell. The media query is scoped to .landing-dc, but the CompanionShell root does not have that class. The rules do not match, despite using !important, so the compact nav remains hidden and the tab row remains visible below 1180px.

🤖 Prompt for AI Agents
Verify each 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/companion/CompanionShell.tsx` around lines 86 - 108,
Update the CompanionShell root element to include the landing-dc class so the
existing responsive media-query rules apply to its nav-compact and nav-tabs
elements. Preserve the current navigation structure and styling.

Source: Linters/SAST tools

frontend/src/lib/landing/engine/dom.ts-49-73 (1)

49-73: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

A layout measurement taken before layout is ready is cached permanently. Both sites store a measured size on first read and re-measure only when a resize signal arrives. If the first read happens before the element has its final box, the wrong value is kept for the rest of the session. Reject an implausible measurement instead of caching it.

  • frontend/src/lib/landing/engine/dom.ts#L49-L73: do not assign rec.__cv when w or h is 0. invalidateCanvas() runs only on a viewport size change, so a zero-size cache leaves the canvas blank.
  • frontend/src/lib/landing/engine/marquee.ts#L136-L144: do not accept m.setW when the summed child offsetWidth is 0. The re-measure trigger is a wrap.clientWidth change, so a gap-only width leaves the track wrapping over a few pixels.
🤖 Prompt for AI Agents
Verify each 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, Reject
zero-sized measurements before caching them: in
frontend/src/lib/landing/engine/dom.ts lines 49-73, ensure cv does not assign
rec.__cv when w or h is 0; in frontend/src/lib/landing/engine/marquee.ts lines
136-144, ensure the marquee measurement does not call m.setW when the summed
child offsetWidth is 0. Preserve normal caching and measurement behavior for
positive dimensions.
frontend/src/lib/landing/engine/sim.ts-330-369 (1)

330-369: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard the SVG structure assumptions during build.

Three unchecked assumptions read the authored SVG:

  • Line 332: ring.previousElementSibling as SVGCircleElement. If the ring is the first child, glow is null. Line 839 then calls p.glow.setAttribute on every frame and throws a TypeError.
  • Line 333 and 345: label is dereferenced immediately by label.getAttribute('y'). A missing next sibling throws during ensureInit().
  • Line 362-367: near() returns -1 when the cluster has line elements but no [data-sim] rings. nodes[b] is then undefined and line 367 throws.

holdArms() at line 653 and stepGroup() at line 752 already guard with if (!s0 || !t0) continue;, so missing nodes are anticipated later in the pipeline but not at build time. Skip a ring or a line when its expected siblings are absent.

🤖 Prompt for AI Agents
Verify each 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 330 - 369, Update the
SVG build logic in the rings and lines loops to validate required structure
before creating simulation entries: skip rings when the previous glow element or
next label element is missing, and skip lines when either endpoint lookup in
near() returns -1. Keep valid ring and line processing unchanged, ensuring no
nodes or links are created with null siblings or invalid endpoint indices.
frontend/src/lib/landing/engine/marquee.ts-69-114 (1)

69-114: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Bind pointerup and pointercancel on window as well.

setPointerCapture is called inside a try/catch at line 80. If the capture fails, later pointer events are not retargeted to track. A drag that ends outside track then never runs end(). m.drag stays non-null, so update() skips both drift and momentum for that track and the cursor stays grabbing until the next pointerdown.

frontend/src/lib/landing/engine/sim.ts lines 507-510 already binds the release handlers on both the element and window for this reason. Apply the same pattern here.

🛡️ Proposed fix
 track.addEventListener('pointerdown', onDown);
track.addEventListener('pointermove', onMove);
track.addEventListener('pointerup', onUp);
track.addEventListener('pointercancel', onCancel);
+ window.addEventListener('pointerup', onUp);+ window.addEventListener('pointercancel', onCancel);
cleanups.push(() => {
wrap?.removeEventListener('pointerenter', onEnter);
wrap?.removeEventListener('pointerleave', onLeave);
track.removeEventListener('pointerdown', onDown);
track.removeEventListener('pointermove', onMove);
track.removeEventListener('pointerup', onUp);
track.removeEventListener('pointercancel', onCancel);
+ window.removeEventListener('pointerup', onUp);+ window.removeEventListener('pointercancel', onCancel);
STATE.delete(track);
});
🤖 Prompt for AI Agents
Verify each 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 69 - 114, Bind the
existing onUp and onCancel release handlers to window in addition to track,
matching the dual registration pattern used by sim.ts. Keep the current track
listeners and end() behavior unchanged so drags are finalized even when pointer
capture fails or the pointer leaves the track.
frontend/src/components/landing-v5/BetaModal.tsx-244-260 (1)

244-260: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use type="email" for the email field.

The field is labelled "Email address" and validated as an email, but it is declared type="text". That suppresses the email keyboard on mobile and blocks browser autofill. The manual check at lines 86-96 continues to work unchanged.

🔧 Proposed fix
 ref={inputRef}
- type="text"+ type="email"+ autoComplete="email"+ inputMode="email"
aria-label="Email address"
🤖 Prompt for AI Agents
Verify each 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 244 - 260,
Update the email input in BetaModal’s form to use type="email" instead of
type="text", while preserving its existing validation, value handling, and other
attributes.
frontend/src/components/landing/useLanding.ts-593-597 (1)

593-597: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report invalid input instead of returning silently.

If value has no @, the function returns without setting subscribeError and without any other state change. The user presses the button and sees no response. An empty non-required type="email" field passes browser validation and reaches this branch.

🐛 Proposed fix
 const value = email.trim();
- if (!value.includes('@') || subscribing) return;+ if (subscribing) return;+ if (!value.includes('@')) {+ setSubscribeError('Enter a valid email address.');+ return;+ }
🤖 Prompt for AI Agents
Verify each 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 593 - 597, Update
subscribeNow so invalid email input, including an empty trimmed value or a value
without "@", sets subscribeError to the existing user-facing validation message
before returning. Keep the subscribing guard and valid-input flow unchanged,
ensuring the user receives feedback instead of a silent no-op.
frontend/src/components/landing-v5/Navbar.tsx-74-79 (1)

74-79: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The navbar stays interactive while it is invisible.

While heroMounted is false, opacity is 0 but pointerEvents remains 'auto'. For the first ~1.9s of the intro, every navbar control is focusable and clickable while nothing is visible.

♿ Proposed fix
 opacity: exploring ? 0 : heroMounted ? 1 : 0,
- pointerEvents: exploring ? 'none' : 'auto',+ pointerEvents: exploring || !heroMounted ? 'none' : 'auto',+ visibility: heroMounted ? 'visible' : 'hidden',
🤖 Prompt for AI Agents
Verify each 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 74 - 79, Update
the navbar style logic in the visible component so pointerEvents is set to
'none' whenever the navbar is invisible, including while heroMounted is false
and while exploring is true; restore 'auto' only when the navbar is visible and
interactive.
frontend/src/app/(public)/page.tsx-91-100 (1)

91-100: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear the subscription error when the modal closes.

state.subscribeError is shared by BetaModal and Journal and is never reset. After a failed attempt, the message stays visible in the Journal form and reappears the next time the modal opens. useLanding already exposes actions.resetSubscribeError for this, and it is currently unused.

🐛 Proposed fix
- onClose={() => setBetaOpen(false)}+ onClose={() => { actions.resetSubscribeError(); setBetaOpen(false); }}
🤖 Prompt for AI Agents
Verify each 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 91 - 100, Update the
BetaModal onClose handler to call actions.resetSubscribeError before closing the
modal with setBetaOpen(false), ensuring the shared subscription error is cleared
when the modal closes.
frontend/src/app/globals.css-1563-1569 (1)

1563-1569: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stylelint fails on the quoted Spectral family name.

font-family-name-quotes reports four errors: Lines 1565, 1713, 1784, and 1836. Spectral is a single identifier, so it needs no quotes.

🔧 Proposed fix (apply to all four sites)
- font-family: var(--font-spectral), 'Spectral', Georgia, serif;+ font-family: var(--font-spectral), Spectral, Georgia, serif;

As per static analysis hints, font-family-name-quotes is enabled for this file.

Also applies to: 1713-1713, 1784-1784, 1836-1836

🤖 Prompt for AI Agents
Verify each 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 1563 - 1569, Remove the
unnecessary quotes around the single-identifier Spectral font family name in all
four font-family declarations, including the declarations associated with the
visible landing-page style and the other reported sites, so they satisfy
font-family-name-quotes.

Source: Linters/SAST tools

frontend/src/components/landing-v5/lab/RoomsDemo.tsx-42-54 (1)

42-54: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The typing indicator and the delayed reply can name different partners.

send captures partner in the timeout closure. The indicator at Line 127 reads the live partner. If the user changes the select during the 1600 ms delay, the indicator shows the new partner and the message arrives from the old one.

♻️ Proposed fix
 const [partner, setPartner] = useState<Partner>('maya');
+ const partnerRef = useRef<Partner>('maya');+ partnerRef.current = partner;
 typeT.current = setTimeout(() => {
- const reply = `my ${partner.charAt(0).toUpperCase() + partner.slice(1)} graph has eigenvalues green — want me to quiz you on it?`;+ const who = partnerRef.current;+ const reply = `my ${who.charAt(0).toUpperCase() + who.slice(1)} graph has eigenvalues green — want me to quiz you on it?`;
setTyping(false);
- setMsgs((m) => [...m, { who: partner, text: reply, mine: false }]);+ setMsgs((m) => [...m, { who, text: reply, mine: false }]);
}, 1600);

Also applies to: 125-132

🤖 Prompt for AI Agents
Verify each 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/RoomsDemo.tsx` around lines 42 - 54,
Update send so the delayed reply and typing indicator use the same partner
snapshot captured when the message is sent. Store that snapshot in the relevant
state or timeout flow, and ensure the indicator rendering near the partner
display uses it until the delayed reply completes; preserve the existing
behavior when the partner does not change.
frontend/src/app/globals.css-1950-1957 (1)

1950-1957: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stylelint reports 45 keyframes-name-pattern errors in the ported blocks.

The ported keyframe names are camelCase, for example s1Stem, popHold, heroRise, and labIn. The rule expects kebab-case. Renaming is not safe here, because the names are referenced from inline animation strings in the landing components and in labData-driven markup.

Scope a rule disable to the ported region instead, so the rest of the file keeps the check.

🔧 Proposed approach
+/* stylelint-disable keyframes-name-pattern -- names are ported verbatim from the+ design components and are referenced by inline `animation` strings in TSX. */
`@keyframes` s1Stem { 0%,15% { stroke-dashoffset:27; } 34%,100% { stroke-dashoffset:0; } }

Close the block with /* stylelint-enable keyframes-name-pattern */ after the last ported keyframe.

As per static analysis hints, keyframes-name-pattern is enabled for this file.

Also applies to: 2094-2096

🤖 Prompt for AI Agents
Verify each 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 1950 - 1957, Scope-disable
Stylelint’s keyframes-name-pattern rule immediately before the ported keyframe
region containing names such as s1Stem, popHold, heroRise, and labIn, then
re-enable it after the final ported keyframe. Keep the rule active for all other
sections of globals.css and do not rename the animation identifiers.

Source: Linters/SAST tools

frontend/src/lib/landing/course.ts-363-376 (1)

363-376: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle the singular case for degree.

If degree is 1, the text reads "its 1 neighbours". A sub-topic linked only to its parent produces that value.

✏️ Proposed fix
- ' pulls this node plus its ' +- degree +- ' neighbours into context, then retrieves the chunks of your ' ++ ' pulls this node plus its ' ++ degree ++ (degree === 1 ? ' neighbour' : ' neighbours') ++ ' into context, then retrieves the chunks of your ' +
🤖 Prompt for AI Agents
Verify each 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/course.ts` around lines 363 - 376, Update nodeUses
so the text generated for degree uses singular “neighbour” when degree is 1 and
plural “neighbours” otherwise, preserving the existing wording and behavior for
all other values.
frontend/src/components/landing-v5/lab/CardsDemo.tsx-46-53 (1)

46-53: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard the window key listener against editable targets.

The listener runs for every keydown on the window. It calls e.preventDefault() for Space and rates the card for 1, 2, and 3. While this demo is mounted, a Space or a digit typed in any other input on the page is captured.

🛡️ Proposed fix
 const onKey = (e: KeyboardEvent) => {
+ const el = e.target as HTMLElement | null;+ if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) 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 around onKey to ignore events whose
target is an editable control, such as input, textarea, select, or
contenteditable elements, before handling Space or rate keys; preserve the
existing flip and rate behavior for non-editable targets.
frontend/src/components/landing-v5/lab/GradesDemo.tsx-62-65 (1)

62-65: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The row percentage is not clamped, but the weighted grade is.

Line 25 clamps each score to [0, r.possible]. Line 64 does not. If the user types 500 into a row worth 50, the row shows 1000% while the overall grade stays at the clamped value. Use the same clamp for both readouts.

🐛 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,
Update the row percentage calculation in the GB_ROWS.map callback to clamp the
parsed earned score to the range [0, r.possible] before computing the
percentage, matching the existing clamping used for the weighted grade and
keeping both readouts consistent.
frontend/src/components/landing-v5/lab/NotesDemo.tsx-57-66 (1)

57-66: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The "No concepts found" sentinel corrupts the footer count.

Line 63 pushes { name: 'No concepts found', tier: 'unexplored' } into the same concepts array that holds real matches. The footer at Lines 141-143 then reads concepts.length and prints 1 concepts linked to your MA 242 graph. after an empty extraction. The text is wrong, and the plural is wrong for a single match too.

Track the empty result separately instead of encoding it as a concept.

🐛 Proposed fix
- const [concepts, setConcepts] = useState<{ name: string; tier: keyof typeof LAB_TIER }[]>([]);+ const [concepts, setConcepts] = useState<{ name: string; tier: keyof typeof LAB_TIER }[]>([]);+ const [ran, setRan] = useState(false);
 const found = MATCHERS.filter((m) => m.hit.test(lower)).map((m) => ({ name: m.name, tier: m.tier }));
- setConcepts(found.length ? found : [{ name: 'No concepts found', tier: 'unexplored' }]);+ setConcepts(found);+ setRan(true);
setBusy(false);
- {concepts.length- ? `${concepts.length} concepts linked to your MA 242 graph.`- : 'Concepts you extract are linked to your graph, not just tagged on the note.'}+ {concepts.length+ ? `${concepts.length} concept${concepts.length === 1 ? '' : 's'} linked to your MA 242 graph.`+ : ran+ ? 'No concepts found in this note.'+ : 'Concepts you extract are linked to your graph, not just tagged on the note.'}

Also applies to: 140-144

🤖 Prompt for AI Agents
Verify each 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 - 66,
Update extract so an empty MATCHERS result is tracked separately rather than
added to concepts; keep concepts containing only real matches and render the
empty-state message independently. Adjust the footer rendering near the concepts
count to display the correct zero count and use singular/plural wording based on
the actual number of linked concepts.
frontend/src/components/landing-v5/Closing.tsx-154-182 (1)

154-182: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Composite ARIA roles are declared without their required child roles. Both sites put a composite role on a container and leave the children as plain <button> elements. ARIA requires owned children with the matching role, so assistive technology announces an empty widget and the selected item is not conveyed.

  • frontend/src/components/landing-v5/Closing.tsx#L154-L182: add role="menuitem" to each section button inside the role="menu" container.
  • frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124: add role="radio" and aria-checked={isPicked} to each option button inside the role="radiogroup" container.
🤖 Prompt for AI Agents
Verify each 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 154 - 182, Add
role="menuitem" to each section button rendered by SECTIONS.map in Closing.tsx
(frontend/src/components/landing-v5/Closing.tsx#L154-L182). In QuizDemo.tsx
(frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124), add
role="radio" and bind aria-checked to isPicked on each option button within the
radiogroup.
frontend/src/components/landing-v5/lab/CalendarDemo.tsx-86-112 (1)

86-112: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Blank leading cells are focusable empty buttons.

Every cell renders as a <button>, including the LEAD_BLANKS placeholders. The blank cells only set cursor: 'default', which does not remove them from the tab order. A keyboard user tabs through up to six empty buttons before reaching 1 October. The selected day also carries no aria-pressed, so the current selection is not announced.

♿ Proposed change
 <button
key={i}
onClick={() => { if (!c.blank) setDay(c.day as number); }}
type="button"
+ disabled={c.blank}+ aria-hidden={c.blank || undefined}+ aria-pressed={c.blank ? undefined : selected}
🤖 Prompt for AI Agents
Verify each 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/CalendarDemo.tsx` around lines 86 -
112, Update the day-cell buttons rendered by the shown.map callback so blank
placeholder cells are removed from keyboard focus, while retaining interactive
keyboard behavior for real days. Add aria-pressed to each non-blank day button
and set it according to the selected value so assistive technologies announce
the current selection.
frontend/src/components/landing-v5/Faq.tsx-71-76 (1)

71-76: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

"Read the full FAQ" links back to itself.

The anchor targets #faq, and the enclosing <section> already has id="faq" (Line 40). Clicking the link keeps the user on the same section, so the call to action does nothing.

This PR adds the /faq public route, and Closing.tsx Line 81 already links to it. Point this link at /faq.

🔗 Proposed change
- <a href="`#faq`" style={{ color: '`#0C5638`', textDecoration: 'underline' }}>Read the full FAQ</a>+ <Link href="/faq" style={{ color: '`#0C5638`', textDecoration: 'underline' }}>Read the full FAQ</Link>

Add the import:

 import { FAQS } from '`@/lib/landing/content`';
+import Link from 'next/link';
import { DragField } from './DragField';
🤖 Prompt for AI Agents
Verify each 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 71 - 76, Update the
“Read the full FAQ” anchor in the Faq component to navigate to the public /faq
route instead of the local `#faq` section, matching the existing link behavior in
Closing.tsx.
frontend/src/components/landing-v5/lab/GuideDemo.tsx-21-24 (1)

21-24: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Align RECENT.pick with the course exam lists. Neither Midterm 1 · Sep 30 nor Quiz 2 · Oct 03 exists in its course's GUIDES.exams. Opening either recent item sets an invalid select value while the guide heading displays that value.

🤖 Prompt for AI Agents
Verify each 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 in GuideDemo so each pick exactly matches an existing
exam value in the corresponding course’s GUIDES.exams list, preserving the
displayed recent-item behavior and ensuring the select value and guide heading
use valid options.
🤖 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/`(public)/page.tsx:
- Around line 130-131: Update the onLearn handler in the page component to open
the tutor panel at GAL index 7 instead of index 2, while leaving onQuiz
unchanged. Prefer the named GAL lookup or constant from content.ts rather than a
hard-coded index so card ordering changes cannot reintroduce this mismatch.
In `@frontend/src/components/landing-v5/ActTutor.tsx`:
- Around line 92-109: Update the tutor pills container in ActTutor to remove its
buttons from keyboard navigation while hidden by binding the engine’s docked
state to inert (or visibility). Preserve the existing opacity and pointerEvents
behavior, and ensure the pills become focusable again when docked is false.
In `@frontend/src/components/landing-v5/BetaModal.tsx`:
- Around line 132-143: Update the HeroCard responsive layout to use a single
column below the medium breakpoint and the existing two-column structure at
wider widths, using its CSS class rather than an unconditional
gridTemplateColumns style. Enable vertical scrolling for constrained viewports,
and apply the 560px minimum height only at the wider breakpoint so mobile
content, including the email field and submit button, remains accessible.
In `@frontend/src/components/landing-v5/Gallery.tsx`:
- Around line 62-85: Update the Card component so non-ghost cards are
keyboard-focusable and expose button-like semantics, with a key handler that
triggers the existing delegated Track click behavior for Enter and Space. Keep
ghost cards inert by omitting focusability, interactive semantics, and
activation handling, while preserving the current data-tk targeting.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 37-44: Update the rate callback to store its 300 ms timeout in a
ref instead of returning a cleanup function, and clear that timeout in an
unmount cleanup effect. Preserve the existing delayed setIdx behavior while
preventing it from firing after the component unmounts.
In `@frontend/src/components/landing-v5/navTheme.ts`:
- Around line 68-69: Update DARK_SECTIONS to contain only the section that
currently renders on a dark ground, act-graph, and revise its comment to
describe the single dark section accurately. Keep act-ingest and act-tutor out
of the list until their backgrounds are dark.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 218-229: Update the hero canvas effect around the lazy
`import('`@/lib/landing/engine/hero`')` to track whether cleanup has run, prevent
`startHeroCanvas` from starting after unmount, and stop it immediately if
initialization races with cancellation. Preserve the existing cleanup behavior
for normal mounted execution.
- Around line 277-282: Update the onKey handler in useLanding to handle Escape
while explore mode is active: after closing the gallery or jump menu as
appropriate, call exitExplore when s.current.exploring is true and neither
overlay was open. Ensure exitExplore is available at handler execution time
despite its later declaration, using the existing callback ordering or a ref as
needed.
In `@frontend/src/components/marketing/SignInModal.tsx`:
- Around line 130-153: Update the OAuth URL construction in the sign-in flow,
including sameTabUrl and popupUrl, to use the frontend-relative /api/auth/google
path instead of prepending API_URL. Preserve the existing popup_id query
parameter and same-tab fallback behavior so OAuth requests remain same-origin
and retain the session cookie.
In `@frontend/src/lib/landing/content.ts`:
- Around line 191-218: In frontend/src/lib/landing/content.ts lines 191-218,
mark the unavailable journal-founding.png and journal-ai-homework.png entries
with empty src values and isGraph: true so they use the graph fallback until
assets are shipped. In frontend/src/components/landing-v5/Journal.tsx lines
106-118, preserve the meta.isGraph branch that renders GraphThumb instead of
passing unusable paths to next/image.
In `@frontend/src/lib/landing/engine/sim.ts`:
- Around line 897-905: Update destroy() cleanup ordering so every cluster
element is re-parented to its original field before the cleanup that removes the
shell/overlay runs. Preserve the existing cleanup registration in ensureInit(),
but ensure repeated createSim() calls can still find the original
[data-dragnode] elements after destroy().
---
Outside diff comments:
In `@frontend/src/lib/deployGuard.ts`:
- Around line 39-46: Update classify to normalize URL values by removing
trailing slashes before comparing against FRONTEND_ENVS fields, while preserving
existing trimming and case normalization. Ensure both API and site URL
classifications recognize trailing-slash variants, and add regression coverage
for trailing-slash API and site URLs.
---
Minor comments:
In `@frontend/src/app/`(public)/about/page.tsx:
- Around line 80-84: Update the paragraph near the prose content to remove the
nonexistent navbar feedback-button reference, and direct readers to an available
control such as the GitHub link or a contact address instead. Preserve the
surrounding message about reporting issues and sharing ideas.
In `@frontend/src/app/`(public)/careers/[slug]/page.tsx:
- Around line 18-22: Update the metadata construction in the careers page’s
generateMetadata flow so the description appends “…” only when job.description
exceeds 155 characters; leave shorter or exactly 155-character descriptions
without a truncation marker while preserving the existing 155-character slice.
In `@frontend/src/app/`(public)/news/page.tsx:
- Around line 175-199: The news cards’ CTA text incorrectly promises article
navigation while each Link in the posts.map block targets /#newsletter. Update
the CTA span text from “Read article →” to wording that reflects newsletter
signup, such as “Get notified →”, while preserving the existing link destination
and styling.
In `@frontend/src/app/`(public)/page.tsx:
- Around line 91-100: Update the BetaModal onClose handler to call
actions.resetSubscribeError before closing the modal with setBetaOpen(false),
ensuring the shared subscription error is cleared when the modal closes.
In `@frontend/src/app/`(public)/team/page.tsx:
- Around line 18-22: Update the metadata exports in
frontend/src/app/(public)/team/page.tsx lines 18-22 and
frontend/src/app/(public)/wiki/page.tsx lines 18-22 to include
alternates.canonical, using /team for the team route and /wiki for the wiki
route.
In `@frontend/src/app/`(public)/wiki/page.tsx:
- Around line 28-31: Move scrollMarginTop: 84 from the H2 style to the section
styling used by the sections targeted by the TOC fragment links (`#graph`,
`#mastery`, `#review`, `#tutor`, `#ingestion`, `#grades`, and `#privacy`). Keep the heading
typography unchanged and ensure each target section receives the offset so it
clears the sticky header.
In `@frontend/src/app/globals.css`:
- Around line 1563-1569: Remove the unnecessary quotes around the
single-identifier Spectral font family name in all four font-family
declarations, including the declarations associated with the visible
landing-page style and the other reported sites, so they satisfy
font-family-name-quotes.
- Around line 1950-1957: Scope-disable Stylelint’s keyframes-name-pattern rule
immediately before the ported keyframe region containing names such as s1Stem,
popHold, heroRise, and labIn, then re-enable it after the final ported keyframe.
Keep the rule active for all other sections of globals.css and do not rename the
animation identifiers.
In `@frontend/src/components/companion/CompanionShell.tsx`:
- Around line 86-108: Update the CompanionShell root element to include the
landing-dc class so the existing responsive media-query rules apply to its
nav-compact and nav-tabs elements. Preserve the current navigation structure and
styling.
In `@frontend/src/components/landing-v5/BetaModal.tsx`:
- Around line 244-260: Update the email input in BetaModal’s form to use
type="email" instead of type="text", while preserving its existing validation,
value handling, and other attributes.
In `@frontend/src/components/landing-v5/Closing.tsx`:
- Around line 154-182: Add role="menuitem" to each section button rendered by
SECTIONS.map in Closing.tsx
(frontend/src/components/landing-v5/Closing.tsx#L154-L182). In QuizDemo.tsx
(frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124), add
role="radio" and bind aria-checked to isPicked on each option button within the
radiogroup.
In `@frontend/src/components/landing-v5/Faq.tsx`:
- Around line 71-76: Update the “Read the full FAQ” anchor in the Faq component
to navigate to the public /faq route instead of the local `#faq` section, matching
the existing link behavior in Closing.tsx.
In `@frontend/src/components/landing-v5/lab/CalendarDemo.tsx`:
- Around line 86-112: Update the day-cell buttons rendered by the shown.map
callback so blank placeholder cells are removed from keyboard focus, while
retaining interactive keyboard behavior for real days. Add aria-pressed to each
non-blank day button and set it according to the selected value so assistive
technologies announce the current selection.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 46-53: Update the keydown handler in the useEffect around onKey to
ignore events whose target is an editable control, such as input, textarea,
select, or contenteditable elements, before handling Space or rate keys;
preserve the existing flip and rate behavior for non-editable targets.
In `@frontend/src/components/landing-v5/lab/GradesDemo.tsx`:
- Around line 62-65: Update the row percentage calculation in the GB_ROWS.map
callback to clamp the parsed earned score to the range [0, r.possible] before
computing the percentage, matching the existing clamping used for the weighted
grade and keeping both readouts consistent.
In `@frontend/src/components/landing-v5/lab/GuideDemo.tsx`:
- Around line 21-24: Update the RECENT entries in GuideDemo so each pick exactly
matches an existing exam value in the corresponding course’s GUIDES.exams list,
preserving the displayed recent-item behavior and ensuring the select value and
guide heading use valid options.
In `@frontend/src/components/landing-v5/lab/NotesDemo.tsx`:
- Around line 57-66: Update extract so an empty MATCHERS result is tracked
separately rather than added to concepts; keep concepts containing only real
matches and render the empty-state message independently. Adjust the footer
rendering near the concepts count to display the correct zero count and use
singular/plural wording based on the actual number of linked concepts.
In `@frontend/src/components/landing-v5/lab/RoomsDemo.tsx`:
- Around line 42-54: Update send so the delayed reply and typing indicator use
the same partner snapshot captured when the message is sent. Store that snapshot
in the relevant state or timeout flow, and ensure the indicator rendering near
the partner display uses it until the delayed reply completes; preserve the
existing behavior when the partner does not change.
In `@frontend/src/components/landing-v5/Navbar.tsx`:
- Around line 74-79: Update the navbar style logic in the visible component so
pointerEvents is set to 'none' whenever the navbar is invisible, including while
heroMounted is false and while exploring is true; restore 'auto' only when the
navbar is visible and interactive.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 593-597: Update subscribeNow so invalid email input, including an
empty trimmed value or a value without "@", sets subscribeError to the existing
user-facing validation message before returning. Keep the subscribing guard and
valid-input flow unchanged, ensuring the user receives feedback instead of a
silent no-op.
In `@frontend/src/lib/landing/course.ts`:
- Around line 363-376: Update nodeUses so the text generated for degree uses
singular “neighbour” when degree is 1 and plural “neighbours” otherwise,
preserving the existing wording and behavior for all other values.
In `@frontend/src/lib/landing/engine/dom.ts`:
- Around line 49-73: Reject zero-sized measurements before caching them: in
frontend/src/lib/landing/engine/dom.ts lines 49-73, ensure cv does not assign
rec.__cv when w or h is 0; in frontend/src/lib/landing/engine/marquee.ts lines
136-144, ensure the marquee measurement does not call m.setW when the summed
child offsetWidth is 0. Preserve normal caching and measurement behavior for
positive dimensions.
In `@frontend/src/lib/landing/engine/marquee.ts`:
- Around line 69-114: Bind the existing onUp and onCancel release handlers to
window in addition to track, matching the dual registration pattern used by
sim.ts. Keep the current track listeners and end() behavior unchanged so drags
are finalized even when pointer capture fails or the pointer leaves the track.
In `@frontend/src/lib/landing/engine/sim.ts`:
- Around line 330-369: Update the SVG build logic in the rings and lines loops
to validate required structure before creating simulation entries: skip rings
when the previous glow element or next label element is missing, and skip lines
when either endpoint lookup in near() returns -1. Keep valid ring and line
processing unchanged, ensuring no nodes or links are created with null siblings
or invalid endpoint indices.
---
Nitpick comments:
In `@frontend/src/app/`(public)/careers/CareersList.tsx:
- Around line 217-284: Update the collapsible panel style in the job card
rendering to include visibility based on isOpen: visible when expanded and
hidden when collapsed. Add visibility to the existing transition so the state
changes with the panel animation while keeping the Apply for this role link and
other content inaccessible during collapse.
- Around line 51-55: Replace both raw img elements in CareersList with the
next/image component, matching the existing usage in CompanionShell for
/sapling-icon.svg. Preserve the current alt text and styling, and set the second
occurrence’s dimensions to width={20} and height={20} while retaining the first
occurrence’s 26×26 sizing.
In `@frontend/src/app/`(public)/faq/page.tsx:
- Around line 60-89: Update the FAQ answer panel associated with the button
using item.i: assign it a unique id, add matching aria-controls to the button,
and include visibility in the panel styles so it is hidden when collapsed and
visible when open. Preserve the existing max-height and opacity transitions.
In `@frontend/src/app/`(public)/news/page.tsx:
- Around line 111-153: Update the category menu logic around the existing
outside pointerdown listener to handle Escape while the menu is open, close it,
and return focus to the trigger button. Add a ref to the trigger button and
focus it both when Escape dismisses the menu and when selecting an option via
the NEWS_FILTERS buttons, while preserving the existing outside-click behavior.
In `@frontend/src/components/companion/CompanionShell.tsx`:
- Around line 84-141: Wrap the primary link group currently rendered by the
`nav-tabs` container in a `<nav>` landmark with a descriptive `aria-label`,
closing it after the GitHub and Ko-fi links. Also locate the header’s footer
link row and wrap it in a separate appropriately labelled `<nav>` element,
preserving the existing links and layout.
In `@frontend/src/components/landing-v5/ActIngest.tsx`:
- Around line 259-282: Add aria-hidden="true" to the decorative flying chip
elements rendered in the CHIPS.map callback within ActIngest, ensuring assistive
technologies ignore these duplicate labels while leaving their visual animation
and styling unchanged.
In `@frontend/src/components/landing-v5/ActTutor.tsx`:
- Around line 95-97: Add type="button" to the pill button rendered in ActTutor’s
button map alongside its existing key and onClick props, matching the explicit
button type used by the other buttons in this PR.
In `@frontend/src/components/landing-v5/Closing.tsx`:
- Line 92: Replace the hardcoded “© 2026” values in the Closing component with a
single dynamically derived current year, and reuse that value in both copyright
locations near the existing spans.
In `@frontend/src/components/landing-v5/DragField.tsx`:
- Around line 70-103: Update the label branch in the nodes mapping within
DragField’s render flow to always create a text element, including when label is
missing. Preserve the glow, ring, and label ordering and use an empty
placeholder for missing label content while retaining valid label positioning
and styling.
In `@frontend/src/components/landing-v5/Faq.tsx`:
- Around line 84-121: Update the FAQ trigger and answer panel so each button in
the mapped FAQ item has an aria-controls reference matching that panel’s unique
id. Add hidden or equivalent visibility handling to the panel when open is
false, while preserving the existing expanded state and visual transition
behavior.
In `@frontend/src/components/landing-v5/FeatureLab.tsx`:
- Around line 41-50: Add dialog semantics to the fixed overlay in FeatureLab by
assigning role="dialog", aria-modal="true", and an accessible name tied to the
panel heading or another existing label; implement focus movement to the close
button when open and restore focus when closed. In the tool rail, add an
explicit active-state attribute such as aria-current or aria-selected to the
open tool instead of relying on background color alone.
In `@frontend/src/components/landing-v5/Gallery.tsx`:
- Around line 87-109: Add an explicit accessible role to the div rendered by
Track so its existing aria-label is announced by assistive technology; use
role="group" without changing the current card rendering or interaction
behavior.
- Around line 71-73: Remove the redundant frame styling from the wrapper div
rendering GALLERY_MINIS[i] in Gallery: eliminate its duplicated height,
borderRadius, background, border, and overflow/position container behavior as
appropriate, while preserving the mini’s own frame and layout. Keep the
aria-hidden wrapper only if needed without adding a second visual frame.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 100-115: Update the flip target around the onClick={flip} handler
to be keyboard accessible: prefer a button element, or add role="button",
tabIndex={0}, and an onKeyDown handler that invokes flip for Enter and Space
while preventing the default Space behavior. Preserve the existing click
interaction and visual styling.
In `@frontend/src/components/landing-v5/lab/GradesDemo.tsx`:
- Around line 74-83: Update the onChange sanitizer in the GradesDemo input to
allow at most one decimal point while preserving digits and the existing state
update behavior. Ensure values such as “1.2.3” are normalized so the displayed
text matches the value used by score computation.
In `@frontend/src/components/landing-v5/lab/GuideDemo.tsx`:
- Line 40: Use a consistent lookup style for the GUIDES map in the GuideDemo
component: update the initialization around set and the lookup in the relevant
course iteration around c so both rely on the CourseKey type without an
unnecessary nullish fallback, or both use the same fallback pattern. Preserve
the existing guide-selection behavior while removing the inconsistent
implication that GUIDES[course] may be missing.
In `@frontend/src/components/landing-v5/lab/NotesDemo.tsx`:
- Around line 57-80: Store the timers created by extract and summarize in refs
alongside saveT, clear their handles on unmount, and reset each ref after its
callback runs. Follow the existing timer-ref cleanup pattern in FeatureLabDemo
so callbacks cannot update state after the component unmounts.
In `@frontend/src/components/landing-v5/lab/QuizDemo.tsx`:
- Line 31: Extract the repeated q.options correctness check into a shared
predicate or derived value near the existing right calculation, then reuse it in
both locations. Update submit’s setStreak call to use the functional updater
form based on the previous streak value, preserving the existing
increment-or-reset behavior without reading streak from the render closure.
In `@frontend/src/components/landing-v5/lab/RoomsDemo.tsx`:
- Around line 76-106: Update the message-list container in RoomsDemo so it has
aria-live="polite" and automatically scrolls to the newest message after each
append. Use a ref and an effect tied to the message-list state, preserving the
existing rendering and styling.
In `@frontend/src/components/landing-v5/Navbar.tsx`:
- Around line 199-234: Remove the incomplete ARIA menu pattern from the navbar:
delete role="menu" from the panel and aria-haspopup="menu" from its trigger,
while preserving the existing links and open/close behavior. Expose the panel as
a plain navigation group without adding menuitem roles or keyboard navigation.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 383-399: Optimize both scanning effects in useLanding: replace the
first effect’s recurring setInterval(scan, 900) polling with a MutationObserver
watching root and invoking scan for relevant DOM additions, while preserving
cleanup and observer behavior. In the second effect around the section
descendant traversal, query only elements marked with the intended animation
attribute (such as [data-anim]) instead of '*', and retain the existing
animation logic for matched elements.
In `@frontend/src/lib/landing/companionContent.ts`:
- Around line 149-160: Update WIKI_TIERS.dot and WIKI_RATINGS.tone to store the
raw hex color values from TIER instead of CSS declaration strings. In the wiki
page consumer, remove the cssColor regex and parsing/fallback logic, and use
t.dot and r.tone directly as colors.
In `@frontend/src/lib/landing/content.ts`:
- Around line 38-135: Document that GAL’s array order is a positional contract
and must not be reordered or have entries inserted without updating callers. Add
and export a galIndexOf helper keyed by GalleryKind, then update the page.tsx
actions.openGal calls to resolve indices through that helper instead of
hard-coded positions.
In `@frontend/src/lib/landing/dragClusters.ts`:
- Line 36: Remove the redundant `as DragCluster[]` assertion from the
`DRAG_CLUSTERS` array literal, preserving its existing `DragCluster[]` type
annotation so excess-property and required-field checks remain enabled.
In `@frontend/src/lib/landing/engine/flip.ts`:
- Around line 24-37: Remove the unused ran field from the FlipState interface
and its initialization in createFlipState, and remove all assignments to st.ran,
including in armFlip and flipClose. Keep the actual animation guard in
useLanding.ts via flipRan unchanged.
In `@frontend/src/lib/landing/engine/plant.ts`:
- Around line 78-91: Update restore() to validate the parsed localStorage value
as an array, retain only string entries, and truncate each label to MAX_LABEL
before calling add() or updating labels. Ensure invalid or non-array data
produces an empty restored label set without partially processing it, while
preserving the seven seed nodes and normal return count.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

Comment threadfrontend/src/app/(public)/page.tsx Outdated
Comment on lines +92 to +109
{/* the engine fades these in once the act's scroll is spent */}
<div data-tutor-pills="1" style={{ display: 'flex', gap: 8, marginTop: 8, opacity: 0, pointerEvents: 'none', transition: 'opacity 400ms ease' }}>
{CAPTIONS.map(([tag], i) => (
<button
key={tag}
onClick={() => onSetMode(i)}
style={{
border: '1px solid rgba(12,86,56,0.3)', borderRadius: 99, padding: '9px 18px',
...MONO, fontSize: 10.5, letterSpacing: '0.12em', cursor: 'pointer',
background: tutorMode === i ? PILL_ON : 'transparent',
color: tutorMode === i ? PILL_ON_FG : '#33443B',
transition: 'all 250ms',
}}
>
{tag}
</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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Find what the engine writes on the tutor pills element.
rg -n -C 6 'tutor-pills|tutorPills' --glob '*.ts' --glob '*.tsx'

Repository: SaplingLearn/Sapling

Length of output: 158


🏁 Script executed:

#!/bin/bashset -e
printf'%s\n''--- candidate files ---'
fd -i 'ActTutor|tutor|landing-v5'.| head -80
printf'%s\n''--- all tutor-pills references ---'
rg -n -C 8 --hidden --glob '!node_modules' --glob '!dist' --glob '!build''tutor-pills|tutorPills|data-tutor'.printf'%s\n''--- ActTutor structure ---'
ACT=$(fd -t f -i 'ActTutor.tsx'.| head -1)if [ -n"$ACT" ];then
ast-grep outline "$ACT"
sed -n '1,180p'"$ACT"fi

Repository: SaplingLearn/Sapling

Length of output: 19055


🏁 Script executed:

#!/bin/bashset -e
printf'%s\n''--- engine writes to M.pills ---'
rg -n -C 10 'M\.pills|pills\.(style|classList)|style\.(opacity|pointerEvents|visibility)|opacity.*pills|pills.*opacity' frontend/src/lib/landing frontend/src/components/landing-v5 --glob '*.ts' --glob '*.tsx'printf'%s\n''--- engine files and relevant structure ---'
fd -t f . frontend/src/lib/landing/engine -x sh -c 'echo --- "$1"; wc -l "$1"; ast-grep outline "$1" | head -80' sh
printf'%s\n''--- direct pills-related engine context ---'
rg -n -C 20 'pills|handover|spent|scroll' frontend/src/lib/landing/engine --glob '*.ts' --glob '*.tsx'

Repository: SaplingLearn/Sapling

Length of output: 50377


Remove hidden pills from the keyboard tab order.

The engine changes only opacity and pointerEvents, so hidden buttons remain focusable. Toggle inert with the engine’s docked state, or toggle visibility there as well.

🤖 Prompt for AI Agents
Verify each 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 92 - 109,
Update the tutor pills container in ActTutor to remove its buttons from keyboard
navigation while hidden by binding the engine’s docked state to inert (or
visibility). Preserve the existing opacity and pointerEvents behavior, and
ensure the pills become focusable again when docked is false.

Comment threadfrontend/src/components/landing-v5/BetaModal.tsx
Comment threadfrontend/src/components/landing-v5/Gallery.tsx
Comment threadfrontend/src/components/landing-v5/lab/CardsDemo.tsx
Comment threadfrontend/src/components/landing/useLanding.ts
Comment threadfrontend/src/components/landing/useLanding.ts
Comment threadfrontend/src/components/marketing/SignInModal.tsx Outdated
Comment threadfrontend/src/lib/landing/content.ts Outdated
Comment on lines +191 to +218
/**
* Presentation metadata paired positionally with POSTS.
*
* NOTE: `journal-founding.png` and `journal-ai-homework.png` are not in
* `public/` yet — both exceed the design API's 256 KiB read cap, so they
* ship with the project export alongside the rest of the source.
*/
export const POST_META: PostMeta[] = [
{
slot: 'journal-1',
src: '/journal-founding.png',
slotHint: 'Drop the founding photo',
isPhoto: true,
isGraph: false,
comments: 12,
likes: 84,
floatDelay: '0s',
},
{
slot: 'journal-2',
src: '/journal-ai-homework.png',
slotHint: 'Drop an image for this essay',
isPhoto: true,
isGraph: false,
comments: 31,
likes: 126,
floatDelay: '-2.5s',
},

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

Two journal images are declared but may not ship.POST_META points at /journal-founding.png and /journal-ai-homework.png, and the file's own comment states both assets are absent from public/. Journal.tsx renders those paths through next/image with no fallback, so two of the three journal cards can show a broken image.

  • frontend/src/lib/landing/content.ts#L191-L218: add the two PNG files to public/, or set src: '' and isGraph: true on those entries until the assets ship.
  • frontend/src/components/landing-v5/Journal.tsx#L106-L118: keep the meta.isGraph branch as the fallback path, so an entry without a usable src renders GraphThumb instead of a broken image.
📍 Affects 2 files
  • frontend/src/lib/landing/content.ts#L191-L218 (this comment)
  • frontend/src/components/landing-v5/Journal.tsx#L106-L118
🤖 Prompt for AI Agents
Verify each 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/content.ts` around lines 191 - 218, In
frontend/src/lib/landing/content.ts lines 191-218, mark the unavailable
journal-founding.png and journal-ai-homework.png entries with empty src values
and isGraph: true so they use the graph fallback until assets are shipped. In
frontend/src/components/landing-v5/Journal.tsx lines 106-118, preserve the
meta.isGraph branch that renders GraphThumb instead of passing unusable paths to
next/image.

Comment threadfrontend/src/lib/landing/engine/sim.ts
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — v5 marketing landing onto staging

This ports the v5 marketing landing onto staging as a public-surface-only change, and the central claim in the description holds up: I verified there are no backend/** files, no migrations, no middleware.ts, no next.config.ts and no wrangler.toml in the diff (74/74 changed files are under frontend/), and git diff --diff-filter=D is empty — nothing is deleted out from under another surface. The Layer-2 token scoping is done properly (.public-surface, .landing-page in globals.css:828, with deliberate literal fallbacks on .card--hero), and every new landing-v5/marketing/companion component carries "use client". The problems are elsewhere: a dropped auth-error path, staging metadata that points at production, a colour system that bypasses the design tokens entirely, and — the big one — this branch is a stale snapshot of a landing that is already merged on main.

Findings

P0

[P0] Both CI jobs are red, and the failures hide the fact that nothing was typechecked or tested.github/workflows/ci.yml:41,90-94

Backend (ruff check ., run 31553863104):

F401 [*] `pydantic.Field` imported but unused
--> models/gradescope.py:12:33
Found 1 error.
##[error]Process completed with exit code 1.

Frontend (npx eslint ., same run):

frontend/src/components/settings/GradescopeConnect.tsx
65:6 error React Hook useEffect has a missing dependency: 'refreshAll'. Either include it or
remove the dependency array react-hooks/exhaustive-deps
✖ 33 problems (1 error, 32 warnings)
##[error]Process completed with exit code 2.

To be clear about attribution: neither is yours. Both reproduce verbatim at the base commit 82787cabackend/models/gradescope.py imports Field with zero Field( uses there, and GradescopeConnect.tsx:65 is already }, []);. gh run list --branch staging shows staging's last green run was 8bce5b02 on 2026-07-10; 82787ca never ran CI, so it landed red. The reason this is still P0 for this PR: the workflow runs ruff before pytest, and eslint before npx tsc --noEmit and npm test. Both jobs die at the lint step, so the type checker and both test suites never executed against these 14,954 added lines. You currently have no signal that the port compiles. Fixing base (or rebasing once it is fixed) is a prerequisite for this PR meaning anything.

P1

[P1] ?error=session_expired no longer reaches the sign-in modalfrontend/src/app/(public)/page.tsx:89

<SignInModalopen={signInOpen}onClose={()=>setSignInOpen(false)}/>

middleware.ts (untouched here) bounces expired sessions to the landing with a query param:

// middleware.ts:38if(errorCode)u.searchParams.set('error',errorCode)// middleware.ts:52 and :67if(!session)returnredirectToSignin(request,'session_expired')

The page this replaces read that param and threaded it through — base (public)/page.tsx:90const err = params.get('error'), then :879errorCode={signInError}. The new page never reads ?error (no searchParams/URLSearchParams anywhere in page.tsx or useLanding.ts), so the prop is never supplied. Meanwhile components/marketing/SignInModal.tsx still ships the whole error surface — ERROR_COPY at :11 including session_expired at :16, and :122const visibleError = localError ?? errorCode ?? null — all now dead. Net effect: a user kicked out mid-session lands on the marketing page with no explanation of why they were signed out. Restore the ?error= read and pass it through.

[P1] The staging deploy will advertise production canonical and OG URLsfrontend/src/app/layout.tsx:43

metadataBase: newURL(resolveSiteUrl(process.env)),alternates: {canonical: '/'},

resolveSiteUrl falls through to production when neither knob is set:

// deployGuard.ts:98-106exportfunctionresolveSiteUrl(env: EnvSource): string{constdeployEnv=(env.DEPLOY_ENV??'').trim().toLowerCase();if(deployEnv&&deployEnvinFRONTEND_ENVS)returnFRONTEND_ENVS[deployEnvasFrontendEnv].siteUrl;constexplicit=(env.NEXT_PUBLIC_SITE_URL??'').trim();if(explicit)returnexplicit.replace(/\/+$/,'');returnFRONTEND_ENVS.production.siteUrl;}

frontend/wrangler.toml is not modified by this PR, and its [env.staging.vars] block sets only NEXT_PUBLIC_API_URL, BACKEND_URL and COOKIE_DOMAINneither DEPLOY_ENV nor NEXT_PUBLIC_SITE_URL. So on frontend-staging every page resolves metadataBase to https://saplinglearn.com, making canonical, og:url and og:image all point at production. Since the entire point of this PR is "show the new landing on staging", any staging link shared for review will unfurl with production's card, and staging self-canonicalises to prod. Set DEPLOY_ENV = "staging" in [env.staging.vars] (which also arms the existing checkFrontendDeployEnv lock in next.config.ts:46).

[P1] The landing runs a parallel colour palette that shares no value with the design tokensfrontend/src/components/landing-v5/*

Counting hex literals across landing-v5 (excluding the galleryMinis illustration art) gives ~900 hardcoded colours. The most frequent:

79 #0c5638 48 #fdfcf9 46 #0e9e5a 40 #12201a
37 #33443b 34 #9aa5a0 31 #8b9891 30 #8fd9a8

Not one of these is a token. The core set in globals.css is --brand-forest: #1B6C42, --brand-forest-bright: #2D8F5C, --sap-500: #4e873c, --ink-0: #faf8f3, --ink-800: #1a1814. Note the near-misses in particular: #fdfcf9 vs --ink-0 #faf8f3, and #12201a vs --ink-800 #1a1814. They are close enough to look like the brand and different enough to be visibly off it at the seam where a user crosses from the landing into the app shell. The Engineering Style Guide §3/§4 is explicit — "inline style objects referencing global CSS-variable tokens … Use the tokens; don't hardcode values." This is doubly odd because the PR itself builds the correct scaffolding: (public)/layout.tsx mounts .public-surface and globals.css:839-841 defines --surface-hero/--brand-glow on it, and then no component uses any of it.

[P1] This is a stale snapshot of a landing already merged on main, and promoting it will fight main — whole landing-v5 tree

origin/main is 4f5a7f9 Merge pull request #524 from SaplingLearn/feat/landing-v5-port — the v5 landing is already there. git merge-base origin/staging origin/main is 57469ad, which contains nofrontend/src/components/landing-v5/, so the directory is added independently on both sides: promoting stagingmain is an add/add conflict across ~50 files, not a fast merge. main has taken 19 commits on the landing since the merge-base, and git diff origin/main 395588da over the landing tree is only 4 files / +9 −38 — i.e. this branch is behind, missing a3d0371 fix(landing): raster, veil and scroll-coupling polish on the v5 hero (the MAG/zoom raster correction in ActTutor.tsx, and the re-tuned hero washes in Hero.tsx).

The dangerous part is that one of those divergences is invisible to the type checker:

// this branch — hero3d/index.ts:52-56 (an intensity multiplier, 0..2)exportfunctionstartHeroRig(canvas,getMouse,getParallax: ()=>number)// origin/main — hero3d/index.ts:76-80 (a scroll offset in pixels)exportfunctionstartHeroRig(canvas,getMouse,getHeroShiftPx: ()=>number)

Both are () => number, and both hero3d/index.ts and useLanding.ts are in the conflict set. Resolving that conflict by taking one file from each side compiles cleanly and silently breaks the hero. Please rebase this branch onto main's landing rather than re-adding an older copy, or state explicitly how the promotion merge will be resolved.

P2

[P2] Three of the four new deployGuard exports have no production callerfrontend/src/lib/deployGuard.ts:52-146

resolveFrontendEnv, expectedEnvForHost and detectHostConfigMismatch are referenced only by deployGuard.test.ts. resolveSiteUrl is the only one wired into anything (layout.tsx:4). detectHostConfigMismatch's own docstring bills it as "Runtime defence-in-depth for what the build-time guard cannot see", citing "the exact failure behind staging's session_expired" — but middleware.ts is not in this diff, so nothing calls it and it defends nothing. That is ~100 lines of inert code plus 131 lines of tests landing in a shared lib on a marketing PR. Either wire it into the middleware or drop it from this PR.

[P2] components/SignInModal.tsx is now orphaned, and its replacement is a near-copyfrontend/src/components/SignInModal.tsx

After this PR the only SignInModal import in the tree is (public)/page.tsx:14@/components/marketing/SignInModal. The original components/SignInModal.tsx (347 lines) has zero importers but is still in the tree; the new components/marketing/SignInModal.tsx is 352 lines and diff between the two is 67 lines. Two copies of the OAuth modal, one of them dead, will drift — and the dead one is the one that still gets fed errorCode in nobody's code path. The Engineering Style Guide §3 calls this out by name as the reason components/ui/ exists ("before they landed the app had 5 distinct button shapes and 18+ pill/badge implementations"). Delete the orphan or make the marketing one a thin wrapper.

[P2] A new eslint suppression was added for code introduced in this PRfrontend/eslint-suppressions.json

"src/components/marketing/SignInModal.tsx": {
"react-hooks/set-state-in-effect": { "count": 1 }
},

Style Guide §7 states the ratchet's purpose as "don't fix the whole backlog to land a change, but never add to it." The baseline exists to grandfather legacy violations; marketing/SignInModal.tsx is created by this PR, so this is a new violation being suppressed rather than fixed. set-state-in-effect is also usually a real render-loop smell worth a look rather than a waiver.

[P2] .landing-page no longer exists, leaving dead CSS and a comment that now says the opposite of the truthfrontend/src/app/(public)/layout.tsx:7

// The landing page keeps its own .landing-page class for the mesh-background visual.exportdefaultfunctionPublicLayout({ children }: {children: React.ReactNode}){return<divclassName="public-surface">{children}</div>;}

No element in the tree carries className="landing-page" at this HEAD — the only remaining mentions are three comments. But globals.css:866-874 still ships the whole mesh block that depends on it:

.landing-page .sapling-mesh-blob { position: absolute; border-radius:50%; filter:blur(100px); … }
.landing-page .sapling-mesh-blob--1 { top:-10%; left:-10%; … }

along with the other .landing-page-prefixed rules the diff kept. All unreachable. Either the wrapper should still apply the class (if the mesh backdrop is meant to render) or the rules and the comment should go.

P3

[P3] 1.3 MB of unoptimized PNGs committedfrontend/public/

journal-founding.png is 821,816 bytes and journal-ai-homework.png is 500,189 bytes. They are rendered through next/image with a sizes hint (Journal.tsx:110-114), so delivery is fine, but the repo and the OpenNext asset bundle both carry the full-size originals. Worth running them through a compressor or shipping WebP/AVIF sources.

What's good

  • The scope discipline is real and verifiable: no backend files, no migrations, no middleware.ts, no wrangler.toml, and nothing deleted — exactly what the description promises, and a much better call than merging the 217-commit branch with its colliding 0032_*/0033_* migrations.
  • The Layer-2 token boundary is set up correctly — .public-surface scoping in (public)/layout.tsx mirroring (shell), with the literal fallbacks on .card--hero and a comment explaining exactly why they are there. That is the right structure; the components just need to start using it.
  • "use client" is correct on every new hook-using component, and the deployGuard additions are genuinely well-tested (131 lines of focused unit tests) even though most of the code they cover is not yet wired up.

Verdict: request changes — the auth-error regression and the staging-metadata bug are both user-visible, and the branch needs to be reconciled with the v5 landing already on main before it can be promoted.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…PLOY_ENV
CI was red at the LINT step on both jobs, so tsc, pytest and vitest never
ran on this port at all.
- backend/models/gradescope.py: drop the unused pydantic Field import (F401).
- settings/GradescopeConnect.tsx: stabilise refreshAll with useCallback and
list it in the mount effect (exhaustive-deps is an error here). Keying on
userId also fixes the stale-account bug the empty dep array hid.
- (public)/page.tsx: read the ?error= code middleware.ts redirects with and
thread it into the modal, opening it when a code is present. Read through
useSyncExternalStore so the page stays statically prerenderable and no
setState lands in an effect; dismissing the modal strips the param.
- page.tsx: openGal indices now come from galIndexOf('quiz'/'tutor') — the
'Ask the tutor' button opened the notetaker panel. BetaModal onClose now
calls resetSubscribeError so a failed subscribe stops leaking into Journal.
- marketing/SignInModal.tsx: fix set-state-in-effect at source (reset during
render on the open prop change) and remove the suppression entry it was
given. OAuth now uses the frontend-relative /api/auth/google for both the
popup and the same-tab fallback, so the session cookie is not third-party.
- delete the orphaned components/SignInModal.tsx (zero importers) and point
the eslint data-testid rule at its replacement.
- deployGuard.classify: normalise trailing slashes on URL fields (not on
cookieDomain) so a valid slashed API/site URL cannot silently disarm the
guard. Five regression tests, all of which fail without the fix.
- middleware.ts: wire resolveFrontendEnv + detectHostConfigMismatch in, so a
host/backend mismatch reports env_misconfig instead of looping the user
through session_expired forever.
- wrangler.toml/package.json: set DEPLOY_ENV per environment so staging stops
advertising production canonical/OG URLs and the build-time guard is armed.
- globals.css/(public)/layout.tsx: delete the ~950 lines of .landing-page CSS
the replaced page made unreachable, fix the inverted comment, unquote the
single-identifier Spectral, and scope a keyframes-name-pattern disable to
the ported region only.
The a11y fixes in this PR all defend behaviour that is invisible on screen
and in review: a gallery card that only opens on click, a navbar that stays
clickable while transparent, an accordion whose closed answers remain in the
tab order, a role="radiogroup" containing no radios.
Two lab-demo state bugs get the same treatment — a reply naming a different
partner than the typing indicator, and a footer counting an empty-result
sentinel as "1 concepts" — plus the galIndexOf contract that replaced the
hard-coded GAL indices.
Renamed from the scratch file that commit b6c3a8a swept in mid-edit.
- news: card CTA said 'Read article' while every card links to /#newsletter;
it now says 'Get notified' and the Link carries a matching aria-label. The
category listbox closes on Escape and returns focus to its trigger (also on
option select) — dismissing it used to drop focus to <body>.
- about: the copy sent readers to 'a feedback button in the navbar' that
CompanionShell does not render; it now links the GitHub issue tracker.
- team/wiki: add the alternates.canonical their siblings already had.
- careers/[slug]: only append the meta-description ellipsis when slice(0,155)
actually truncated, so short blurbs stop reading as cut off.
- wiki: move scrollMarginTop from the H2 to the <section> elements — the TOC
fragments target the sections, so anchors landed under the sticky header.
Drop cssColor(): companionContent.ts now stores bare hex.
- faq: unique panel ids + aria-controls, and 'visibility' in the collapsed
style so collapsed answers are neither focusable nor announced.
- careers/CareersList: same collapsed-panel fix, so 'Apply for this role' is
not tabbable while closed; both raw <img> replaced with next/image.
- CompanionShell: wrap the header link group and the footer link row in
labelled <nav> landmarks. The compact-nav media query was scoped to
.landing-dc, which this shell must never wear, so below 1180px the
overflowing tab row stayed and the disclosure stayed hidden — unscoped it.
- page.tsx: read ?signin=1 too. CompanionShell's header 'Sign In' links to
/?signin=1 and nothing read it, so that control did nothing at all.
- public/journal-*.png: 1.29 MB -> 432 KB (-67.8% / -64.6%) via libimagequant
256-colour quantisation with Floyd-Steinberg dither (same engine pngquant
uses; RMSE 2.0 and 2.8 of 255). A fully lossless re-encode was tried first
and was larger than the originals. Filenames and paths unchanged.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Blocker

  • Both CI jobs died at the lint step, before tsc / pytest / vitest ever ran — so none of this port had been typechecked or tested. Both base failures fixed properly: the unused pydantic.Field import, and GradescopeConnect.tsx's exhaustive-deps (via useCallback keyed on userId, which also fixes a real bug the empty dep array hid — switching user kept the previous account's state).

Major

  • ?error=session_expired never reached the modal.middleware.ts sets it and marketing/SignInModal still shipped the whole ERROR_COPY surface, all dead. Now read and threaded through, opening the modal on an inbound code and stripping the param on dismiss.
  • Staging advertised production canonical/OG URLsresolveSiteUrl fell through to prod because [env.staging.vars] set neither knob. DEPLOY_ENV = "staging" added, which also arms the existing build-time guard.
  • deployGuard.classify now normalises trailing slashes, so a valid https://api.saplinglearn.com/ no longer bypasses the runtime guard; detectHostConfigMismatch is wired into middleware.ts so it actually defends something.
  • The orphaned components/SignInModal.tsx (zero importers, 67-line drift from its replacement) is deleted.

CodeRabbit majors

onLearn opened the notetaker instead of the tutor (now via the new galIndexOf lookup, not a magic index) · OAuth URLs kept same-origin · DARK_SECTIONS reduced to the one genuinely dark section · Escape now exits explore mode (was a keyboard trap with body { overflow: hidden }) · sim.destroy() re-homes clusters so a StrictMode remount no longer kills the drag field permanently · hero-canvas unmount race · CardsDemo timer leak, global key capture and keyboard-inoperable flip · Gallery cards keyboard-openable · BetaModal single-column below the breakpoint + type="email" · hidden nav/pills removed from the tab order.

Minor / nits

Dead .landing-page CSS removed · stylelint font-family-name-quotes 5→0 and keyframes-name-pattern 42→0 · lab-demo correctness (clamped percentages, partner snapshot, concept count, invalid guide picks) · ARIA roles and labels across FAQ/careers/calendar/quiz/rooms · localStorage input validated · journal PNGs recompressed 1.32 MB → 442 KB.

Still open (deliberately not done)

This branch is a stale snapshot of a landing already merged on main (#524) — promoting it is an add/add conflict across ~50 files, and startHeroRig's third parameter changed meaning while keeping an identical () => number type, so a file-by-file conflict resolution compiles cleanly and silently breaks the hero. That is a merge-strategy decision, not a code fix.

Verificationruff check . clean · tsc clean · eslint 0 errors (base had 1) · 184 frontend tests pass

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

This branch now carries a backend change, via #565

staging picked up the Gradescope repair in #565, so this PR is no longer frontend-only. That fix restored routes/gradescope.py and services/gradescope_service.py from main because the version on staging was unreachable (router prefix declared twice → every endpoint served at /api/gradescope/api/gradescope/...), called a DB client that does not exist (.eq().execute() on a PostgREST helper), and had no rate limiting or bu-sso.

I checked statically that every table the restored route touches exists on staging — gradescope_credentials, gradescope_course_links, assignments, gradebook_categories — so there is no missing-table hazard. But that is a check of the repo's migration files, not of the staging database's actual ledger. Please confirm on the real database:

SELECT table_name FROMinformation_schema.tablesWHERE table_schema ='public'AND table_name IN ('gradescope_credentials','gradescope_course_links','gradescope_links');

All three should be present. Then exercise one endpoint end to end and confirm it resolves at a single/api/gradescope/... prefix rather than 404ing.

Two things #565 deliberately left behind

SELECTcount(*) FROM gradescope_links;

gradescope_links (created by staging's 0033_add_gradescope_links.sql) now has no reader — the restored route uses gradescope_course_links. services/gradescope_sync_service.py has zero importers, and models/gradescope.py is imported only by that dead module. Nothing breaks, but if the table holds rows they are stranded, and the two modules are dead code worth removing in a follow-up.

Ledger divergence between staging and main

Staging carries two migrations main does not, at numeric prefixes main reuses for different files:

prefixstagingmain
00320032_add_gradescope_credentials.sql0032_rooms_missing_columns.sql, 0032_retire_summer_2026.sql
00330033_add_gradescope_links.sql0033_offering_section_not_null.sql, 0033_realtime_publish_room_messages.sql

schema_migrations keys on the full basename, so these coexist without a primary-key collision — but main has 23 migrations staging lacks, and staging is 239 commits behind. Whenever these branches are reconciled, both migration sets apply and the relative ordering within 0032/0033 is alphabetical by accident rather than by design. Worth settling deliberately rather than discovering it during a promotion.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

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.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, '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 the v5 marketing landing onto staging by Darkest-Teddy · Pull Request #535 · SaplingLearn/Sapling · GitHub
Skip to content

feat(landing): port the v5 marketing landing onto staging - #535

Open
Darkest-Teddy wants to merge 5 commits into
stagingfrom
feat/landing-v5-on-staging
Open

feat(landing): port the v5 marketing landing onto staging#535
Darkest-Teddy wants to merge 5 commits into
stagingfrom
feat/landing-v5-on-staging

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Puts the v5 marketing landing on stagingwithout merging feat/landing-v5-port.

Why not just merge the v5 branch

feat/landing-v5-port is 217 commits ahead of origin/staging and the diff is mostly not the landing page — 179 backend test files, 31 agents, 27 services, 17 routes, and 15 new migrations, including two colliding 0032_* and two colliding 0033_* basenames. It also isn't a fast-forward: staging has one commit that branch lacks (82787ca, gradescope). None of that is needed to show the new landing, so this ports the public surface only — no backend changes, no migrations.

What's in here

  • components/landing-v5/** and lib/landing/** — the landing engine, hero3d, drag simulation, and the Act sections
  • The (public) route group: the new /, plus /team, /wiki, /gallery, /news, /faq — the v5 navbar links to these and staging didn't have them
  • components/marketing/** and components/companion/CompanionShell
  • globals.css and app/layout.tsx (OG/metadata), lib/deployGuard.tswith its matching test, lib/errorMessage.ts
  • api.ts gains only subscribeToNewsletter — staging's backend already mounts /api/newsletter

One deliberate divergence from upstream

marketing/SignInModal calls the 3-argsetActiveUser. Upstream passes a 4th { persist } option that exists only on that branch's UserContext, whose wider change (+127/−21) also adds app-wide session recovery and shell-route redirects. Changing auth behaviour for every screen is out of scope for a landing port, so the option is dropped here and should come back in the same commit that brings that UserContext over. The call site carries the same note.

eslint-suppressions.json gains an entry for the new components/marketing/SignInModal.tsx path; staging only suppressed that rule at the old components/SignInModal.tsx path.

Verification

  • tsc --noEmit — clean
  • next build with staging's build vars (DEPLOY_ENV/BACKEND_URL/NEXT_PUBLIC_API_URL/COOKIE_DOMAIN) — succeeds, emitting /team, /wiki, /gallery, /news, /faq
  • eslint — clean except one pre-existing error: react-hooks/exhaustive-deps in settings/GradescopeConnect.tsx:65, which arrives untouched from origin/staging (it is neither modified nor suppressed on that branch). CI will likely go red on it until it's fixed separately — deliberately not touched here.
  • vitest was not run locally — it needs Node 22 and this machine is on 20. CI runs it on Node 22.

Deploy note

Per wrangler.toml and ADR 0020, the staging Workers Build must set DEPLOY_ENV=staging as a build variable, not just a runtime var — the /api rewrite and NEXT_PUBLIC_* are baked at build time. That omission is what previously put prod config on the staging worker.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added public About, Team, Wiki, Gallery, News, FAQ, and Careers pages.
    • Introduced interactive galleries, article search and filtering, FAQ accordions, career listings, and rich knowledge-base content.
    • Redesigned the landing experience with interactive learning demonstrations, knowledge graphs, tutor modes, quizzes, flashcards, study tools, and beta signup.
    • Added Google sign-in and newsletter subscription flows.
  • Improvements

    • Standardized navigation, page layouts, accessibility, responsive styling, animations, and site metadata.
    • Improved error messaging and deployment-aware canonical links.

Brings the v5 public site to `staging` WITHOUT the rest of
`feat/landing-v5-port`: that branch is 217 commits ahead of staging and
carries backend routes, services, agents and 15 migrations (including two
colliding 0032_* and two 0033_* basenames). None of that is needed to show
the new landing, so this ports the public surface only.
What's here:
- components/landing-v5/** + lib/landing/** (the landing engine, hero3d,
drag simulation and the Act sections)
- the (public) route group: the new / plus /team, /wiki, /gallery, /news
and /faq, which the v5 navbar links to and staging did not have
- components/marketing/** and components/companion/CompanionShell
- globals.css and app/layout.tsx (OG/metadata), lib/deployGuard.ts with
its matching test, lib/errorMessage.ts
- api.ts gains only `subscribeToNewsletter`; staging's backend already
mounts /api/newsletter
One deliberate divergence from upstream: marketing/SignInModal calls the
3-arg `setActiveUser`. Upstream passes a 4th `{ persist }` option that only
exists on that branch's UserContext, whose wider change also adds app-wide
session recovery and shell-route redirects. Changing auth for every screen
is out of scope for a landing port, so the option is dropped here and should
return with that UserContext. The call site carries the same note.
eslint-suppressions.json gains an entry for the new
components/marketing/SignInModal.tsx path; staging only suppressed the rule
at the old components/SignInModal.tsx path.
Verified: tsc --noEmit clean, and `next build` succeeds with staging's build
vars (DEPLOY_ENV/BACKEND_URL/NEXT_PUBLIC_API_URL/COOKIE_DOMAIN), emitting
/team, /wiki, /gallery, /news and /faq. eslint is clean except for a
pre-existing react-hooks/exhaustive-deps error in
settings/GradescopeConnect.tsx, which arrives untouched from origin/staging.
vitest was not run locally (needs Node 22; this machine is on 20) - CI runs it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 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 ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:37 seconds

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 101dbfe3-a2c2-4287-842f-6e2c86a82b2e

📥 Commits

Reviewing files that changed from the base of the PR and between 395588d and 46f7930.

⛔ Files ignored due to path filters (2)
  • frontend/public/journal-ai-homework.png is excluded by !**/*.png
  • frontend/public/journal-founding.png is excluded by !**/*.png
📒 Files selected for processing (57)
  • docs/frontend-testids.md
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/package.json
  • frontend/src/app/(public)/about/page.tsx
  • frontend/src/app/(public)/careers/CareersList.tsx
  • frontend/src/app/(public)/careers/[slug]/page.tsx
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/layout.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/ActGraph.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/ActTutor.tsx
  • frontend/src/components/landing-v5/BetaModal.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/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/landing-v5-interaction.test.tsx
  • frontend/src/components/landing-v5/navTheme.ts
  • frontend/src/components/landing/useLanding.ts
  • frontend/src/components/marketing/HeroCard.test.tsx
  • frontend/src/components/marketing/SignInModal.tsx
  • frontend/src/lib/deployGuard.test.ts
  • frontend/src/lib/deployGuard.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/dom.ts
  • frontend/src/lib/landing/engine/flip.ts
  • frontend/src/lib/landing/engine/index.ts
  • frontend/src/lib/landing/engine/marquee.ts
  • frontend/src/lib/landing/engine/plant.ts
  • frontend/src/lib/landing/engine/sim.test.ts
  • frontend/src/lib/landing/engine/sim.ts
  • frontend/src/middleware.ts
  • frontend/wrangler.toml
📝 Walkthrough

Walkthrough

The PR replaces the landing page with Landing v5, adds companion and careers pages, introduces shared public layouts and content, expands deployment metadata, and adds OAuth, newsletter, error-handling, CSS, and interaction infrastructure.

Changes

Landing v5 experience

Layer / File(s)Summary
Landing contracts and content
frontend/src/lib/landing/*
Adds typed landing content, course graphs, drag clusters, lab fixtures, color utilities, and navigation themes.
Landing sections and demos
frontend/src/components/landing-v5/*
Adds the v5 hero, graph, ingest, tutor, gallery, FAQ, journal, CTA, navigation, modal, and feature-lab components.
Landing runtime and composition
frontend/src/components/landing/useLanding.ts, frontend/src/lib/landing/engine/*, frontend/src/app/(public)/page.tsx
Connects React state to scroll, canvas, WebGL, graph, marquee, FLIP, planting, newsletter, and drag-field behavior.
Landing styles and tests
frontend/src/app/globals.css, frontend/src/components/landing-v5/*.test.tsx, frontend/src/lib/landing/engine/sim.test.ts
Adds responsive landing layouts, finite animations, and interaction regression coverage.

Companion and public pages

Layer / File(s)Summary
Shared companion shell and content
frontend/src/components/companion/CompanionShell.tsx, frontend/src/lib/landing/companionContent.ts
Adds shared navigation, footer links, and typed content collections.
Companion pages
frontend/src/app/(public)/about/*, team/*, faq/*, gallery/*, news/*, wiki/*
Adds shared-shell pages with data-driven content, filters, accordions, navigation, and metadata.
Careers routes
frontend/src/app/(public)/careers/*
Moves interactive job listings into CareersList, adds route metadata, and returns not-found responses for invalid slugs.

Platform and marketing infrastructure

Layer / File(s)Summary
Deployment and metadata
frontend/src/lib/deployGuard.ts, frontend/src/lib/deployGuard.test.ts, frontend/src/app/layout.tsx, frontend/src/app/(public)/privacy/page.tsx, frontend/src/app/(public)/terms/page.tsx
Adds environment resolution, canonical site URLs, host mismatch detection, structured application metadata, and legal-page metadata.
Authentication, newsletter, and marketing UI
frontend/src/components/marketing/SignInModal.tsx, frontend/src/components/marketing/HeroCard.tsx, frontend/src/lib/api.ts, frontend/src/lib/errorMessage.ts
Adds OAuth popup handling, accessible modal behavior, reusable hero-card rendering, newsletter subscription requests, and defensive error normalization.

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

Sequence Diagram(s)

sequenceDiagram
participant Visitor
participant LandingPage
participant useLanding
participant LandingEngine
participant CanvasAndWebGL
Visitor->>LandingPage: open landing page
LandingPage->>useLanding: initialize refs and state
useLanding->>LandingEngine: start landing lifecycle
LandingEngine->>CanvasAndWebGL: render hero and graph visuals
Visitor->>LandingPage: explore graph or submit newsletter
LandingPage->>useLanding: dispatch interaction
useLanding->>LandingEngine: update interaction state
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 26.42% 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 main change: porting the v5 marketing landing to staging.
Description check✅ PassedThe description covers scope, rationale, changes, testing limits, and deployment requirements, with only minor template omissions.
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 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/landing-v5-on-staging

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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 12, 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-staging46f7930Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:19 PM

@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: 11

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/lib/deployGuard.ts (1)

39-46: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Normalize trailing slashes before environment classification.

A canonical API URL with a trailing slash classifies as unknown. Then detectHostConfigMismatch returns null for a staging host wired to https://api.saplinglearn.com/. This bypasses the runtime guard for a valid URL spelling.

Normalize URL fields before comparison. Add regression tests for trailing-slash API and site URLs.

Proposed fix
 function classify(
value: string | undefined,
field: 'apiUrl' | 'cookieDomain' | 'siteUrl',
): FrontendEnv | null {
- const v = (value ?? '').trim().toLowerCase();+ const v = (value ?? '')+ .trim()+ .toLowerCase()+ .replace(field === 'cookieDomain' ? /$/ : /\/+$/, '');
if (!v) return null;
for (const env of Object.keys(FRONTEND_ENVS) as FrontendEnv[]) {
if (FRONTEND_ENVS[env][field].toLowerCase() === v) return env;
🤖 Prompt for AI Agents
Verify each 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/deployGuard.ts` around lines 39 - 46, Update classify to
normalize URL values by removing trailing slashes before comparing against
FRONTEND_ENVS fields, while preserving existing trimming and case normalization.
Ensure both API and site URL classifications recognize trailing-slash variants,
and add regression coverage for trailing-slash API and site URLs.
🟡 Minor comments (24)
frontend/src/app/(public)/news/page.tsx-175-199 (1)

175-199: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The card link text does not match its destination.

Each card is a <Link href="/#newsletter"> and its call to action reads "Read article →". A reader who selects a headline arrives at the newsletter signup on the home page, not at the post. All six cards lead to the same anchor.

Change the call to action to state the real destination, for example "Get notified →", until article 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/app/`(public)/news/page.tsx around lines 175 - 199, The news
cards’ CTA text incorrectly promises article navigation while each Link in the
posts.map block targets /#newsletter. Update the CTA span text from “Read
article →” to wording that reflects newsletter signup, such as “Get notified →”,
while preserving the existing link destination and styling.
frontend/src/app/(public)/about/page.tsx-80-84 (1)

80-84: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update the copy that points to a feedback button in the navbar.

The paragraph tells the reader to use "a feedback button in the navbar". The ported chrome in frontend/src/components/companion/CompanionShell.tsx renders Home, About, Team, Wiki, Gallery, News, FAQ, GitHub, Ko-fi, Sign In, and Get Started. It has no feedback control. Point the reader at a control that exists, for example the GitHub link or a contact address.

🤖 Prompt for AI Agents
Verify each 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)/about/page.tsx around lines 80 - 84, Update the
paragraph near the prose content to remove the nonexistent navbar
feedback-button reference, and direct readers to an available control such as
the GitHub link or a contact address instead. Preserve the surrounding message
about reporting issues and sharing ideas.
frontend/src/app/(public)/team/page.tsx-18-22 (1)

18-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Two companion routes omit alternates.canonical. The port sets a canonical URL on frontend/src/app/(public)/about/page.tsx, frontend/src/app/(public)/careers/page.tsx, and frontend/src/app/(public)/careers/[slug]/page.tsx, but not on these two routes. Without a canonical URL, these pages can be indexed under query-string or alternate-host variants.

  • frontend/src/app/(public)/team/page.tsx#L18-L22: add alternates: { canonical: '/team' } to the metadata export.
  • frontend/src/app/(public)/wiki/page.tsx#L18-L22: add alternates: { canonical: '/wiki' } to the metadata export.
🤖 Prompt for AI Agents
Verify each 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)/team/page.tsx around lines 18 - 22, Update the
metadata exports in frontend/src/app/(public)/team/page.tsx lines 18-22 and
frontend/src/app/(public)/wiki/page.tsx lines 18-22 to include
alternates.canonical, using /team for the team route and /wiki for the wiki
route.
frontend/src/app/(public)/careers/[slug]/page.tsx-18-22 (1)

18-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The meta description appends an ellipsis even when it truncates nothing.

Line 20 always appends after job.description.slice(0, 155). If a description is shorter than 155 characters, the full sentence still ends with a truncation marker. Search results then show a complete sentence that looks cut off.

Append the ellipsis only when the slice removed text.

🐛 Proposed fix
+ const blurb =+ job.description.length > 155 ? `${job.description.slice(0, 155)}…` : job.description;
return {
title: `${job.title} — Careers`,
- description: `${job.department} · ${job.location} · ${job.type}. ${job.description.slice(0, 155)}…`,+ description: `${job.department} · ${job.location} · ${job.type}. ${blurb}`,
alternates: { canonical: `/careers/${job.slug}` },
};
🤖 Prompt for AI Agents
Verify each 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)/careers/[slug]/page.tsx around lines 18 - 22,
Update the metadata construction in the careers page’s generateMetadata flow so
the description appends “…” only when job.description exceeds 155 characters;
leave shorter or exactly 155-character descriptions without a truncation marker
while preserving the existing 155-character slice.
frontend/src/app/(public)/wiki/page.tsx-28-31 (1)

28-31: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

scrollMarginTop is applied to the wrong element.

The H2 style sets scrollMarginTop: 84. The TOC links on line 67 target the fragments #graph, #mastery, #review, #tutor, #ingestion, #grades, and #privacy. Those ids are on the <section> elements, not on the <h2> elements. The browser applies scroll margin to the element it scrolls into view, so the section top lands under the sticky 92px header from CompanionShell and the heading is hidden.

Move the offset to the sections.

🐛 Proposed fix
 const H2: React.CSSProperties = {
margin: 0, fontFamily: DISPLAY, fontWeight: 500, fontSize: 26,
- lineHeight: 1.2, letterSpacing: '-0.015em', scrollMarginTop: 84,+ lineHeight: 1.2, letterSpacing: '-0.015em',
};
+/** Clears the sticky companion header after an in-page anchor jump. */+const SECTION: React.CSSProperties = { scrollMarginTop: 104 };

Then apply it to each section, for example:

- <section id="graph">+ <section id="graph" style={SECTION}>
🤖 Prompt for AI Agents
Verify each 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 28 - 31, Move
scrollMarginTop: 84 from the H2 style to the section styling used by the
sections targeted by the TOC fragment links (`#graph`, `#mastery`, `#review`, `#tutor`,
`#ingestion`, `#grades`, and `#privacy`). Keep the heading typography unchanged and
ensure each target section receives the offset so it clears the sticky header.
frontend/src/components/companion/CompanionShell.tsx-86-108 (1)

86-108: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Apply the responsive nav rules to CompanionShell. The media query is scoped to .landing-dc, but the CompanionShell root does not have that class. The rules do not match, despite using !important, so the compact nav remains hidden and the tab row remains visible below 1180px.

🤖 Prompt for AI Agents
Verify each 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/companion/CompanionShell.tsx` around lines 86 - 108,
Update the CompanionShell root element to include the landing-dc class so the
existing responsive media-query rules apply to its nav-compact and nav-tabs
elements. Preserve the current navigation structure and styling.

Source: Linters/SAST tools

frontend/src/lib/landing/engine/dom.ts-49-73 (1)

49-73: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

A layout measurement taken before layout is ready is cached permanently. Both sites store a measured size on first read and re-measure only when a resize signal arrives. If the first read happens before the element has its final box, the wrong value is kept for the rest of the session. Reject an implausible measurement instead of caching it.

  • frontend/src/lib/landing/engine/dom.ts#L49-L73: do not assign rec.__cv when w or h is 0. invalidateCanvas() runs only on a viewport size change, so a zero-size cache leaves the canvas blank.
  • frontend/src/lib/landing/engine/marquee.ts#L136-L144: do not accept m.setW when the summed child offsetWidth is 0. The re-measure trigger is a wrap.clientWidth change, so a gap-only width leaves the track wrapping over a few pixels.
🤖 Prompt for AI Agents
Verify each 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, Reject
zero-sized measurements before caching them: in
frontend/src/lib/landing/engine/dom.ts lines 49-73, ensure cv does not assign
rec.__cv when w or h is 0; in frontend/src/lib/landing/engine/marquee.ts lines
136-144, ensure the marquee measurement does not call m.setW when the summed
child offsetWidth is 0. Preserve normal caching and measurement behavior for
positive dimensions.
frontend/src/lib/landing/engine/sim.ts-330-369 (1)

330-369: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard the SVG structure assumptions during build.

Three unchecked assumptions read the authored SVG:

  • Line 332: ring.previousElementSibling as SVGCircleElement. If the ring is the first child, glow is null. Line 839 then calls p.glow.setAttribute on every frame and throws a TypeError.
  • Line 333 and 345: label is dereferenced immediately by label.getAttribute('y'). A missing next sibling throws during ensureInit().
  • Line 362-367: near() returns -1 when the cluster has line elements but no [data-sim] rings. nodes[b] is then undefined and line 367 throws.

holdArms() at line 653 and stepGroup() at line 752 already guard with if (!s0 || !t0) continue;, so missing nodes are anticipated later in the pipeline but not at build time. Skip a ring or a line when its expected siblings are absent.

🤖 Prompt for AI Agents
Verify each 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 330 - 369, Update the
SVG build logic in the rings and lines loops to validate required structure
before creating simulation entries: skip rings when the previous glow element or
next label element is missing, and skip lines when either endpoint lookup in
near() returns -1. Keep valid ring and line processing unchanged, ensuring no
nodes or links are created with null siblings or invalid endpoint indices.
frontend/src/lib/landing/engine/marquee.ts-69-114 (1)

69-114: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Bind pointerup and pointercancel on window as well.

setPointerCapture is called inside a try/catch at line 80. If the capture fails, later pointer events are not retargeted to track. A drag that ends outside track then never runs end(). m.drag stays non-null, so update() skips both drift and momentum for that track and the cursor stays grabbing until the next pointerdown.

frontend/src/lib/landing/engine/sim.ts lines 507-510 already binds the release handlers on both the element and window for this reason. Apply the same pattern here.

🛡️ Proposed fix
 track.addEventListener('pointerdown', onDown);
track.addEventListener('pointermove', onMove);
track.addEventListener('pointerup', onUp);
track.addEventListener('pointercancel', onCancel);
+ window.addEventListener('pointerup', onUp);+ window.addEventListener('pointercancel', onCancel);
cleanups.push(() => {
wrap?.removeEventListener('pointerenter', onEnter);
wrap?.removeEventListener('pointerleave', onLeave);
track.removeEventListener('pointerdown', onDown);
track.removeEventListener('pointermove', onMove);
track.removeEventListener('pointerup', onUp);
track.removeEventListener('pointercancel', onCancel);
+ window.removeEventListener('pointerup', onUp);+ window.removeEventListener('pointercancel', onCancel);
STATE.delete(track);
});
🤖 Prompt for AI Agents
Verify each 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 69 - 114, Bind the
existing onUp and onCancel release handlers to window in addition to track,
matching the dual registration pattern used by sim.ts. Keep the current track
listeners and end() behavior unchanged so drags are finalized even when pointer
capture fails or the pointer leaves the track.
frontend/src/components/landing-v5/BetaModal.tsx-244-260 (1)

244-260: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use type="email" for the email field.

The field is labelled "Email address" and validated as an email, but it is declared type="text". That suppresses the email keyboard on mobile and blocks browser autofill. The manual check at lines 86-96 continues to work unchanged.

🔧 Proposed fix
 ref={inputRef}
- type="text"+ type="email"+ autoComplete="email"+ inputMode="email"
aria-label="Email address"
🤖 Prompt for AI Agents
Verify each 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 244 - 260,
Update the email input in BetaModal’s form to use type="email" instead of
type="text", while preserving its existing validation, value handling, and other
attributes.
frontend/src/components/landing/useLanding.ts-593-597 (1)

593-597: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report invalid input instead of returning silently.

If value has no @, the function returns without setting subscribeError and without any other state change. The user presses the button and sees no response. An empty non-required type="email" field passes browser validation and reaches this branch.

🐛 Proposed fix
 const value = email.trim();
- if (!value.includes('@') || subscribing) return;+ if (subscribing) return;+ if (!value.includes('@')) {+ setSubscribeError('Enter a valid email address.');+ return;+ }
🤖 Prompt for AI Agents
Verify each 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 593 - 597, Update
subscribeNow so invalid email input, including an empty trimmed value or a value
without "@", sets subscribeError to the existing user-facing validation message
before returning. Keep the subscribing guard and valid-input flow unchanged,
ensuring the user receives feedback instead of a silent no-op.
frontend/src/components/landing-v5/Navbar.tsx-74-79 (1)

74-79: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The navbar stays interactive while it is invisible.

While heroMounted is false, opacity is 0 but pointerEvents remains 'auto'. For the first ~1.9s of the intro, every navbar control is focusable and clickable while nothing is visible.

♿ Proposed fix
 opacity: exploring ? 0 : heroMounted ? 1 : 0,
- pointerEvents: exploring ? 'none' : 'auto',+ pointerEvents: exploring || !heroMounted ? 'none' : 'auto',+ visibility: heroMounted ? 'visible' : 'hidden',
🤖 Prompt for AI Agents
Verify each 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 74 - 79, Update
the navbar style logic in the visible component so pointerEvents is set to
'none' whenever the navbar is invisible, including while heroMounted is false
and while exploring is true; restore 'auto' only when the navbar is visible and
interactive.
frontend/src/app/(public)/page.tsx-91-100 (1)

91-100: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear the subscription error when the modal closes.

state.subscribeError is shared by BetaModal and Journal and is never reset. After a failed attempt, the message stays visible in the Journal form and reappears the next time the modal opens. useLanding already exposes actions.resetSubscribeError for this, and it is currently unused.

🐛 Proposed fix
- onClose={() => setBetaOpen(false)}+ onClose={() => { actions.resetSubscribeError(); setBetaOpen(false); }}
🤖 Prompt for AI Agents
Verify each 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 91 - 100, Update the
BetaModal onClose handler to call actions.resetSubscribeError before closing the
modal with setBetaOpen(false), ensuring the shared subscription error is cleared
when the modal closes.
frontend/src/app/globals.css-1563-1569 (1)

1563-1569: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stylelint fails on the quoted Spectral family name.

font-family-name-quotes reports four errors: Lines 1565, 1713, 1784, and 1836. Spectral is a single identifier, so it needs no quotes.

🔧 Proposed fix (apply to all four sites)
- font-family: var(--font-spectral), 'Spectral', Georgia, serif;+ font-family: var(--font-spectral), Spectral, Georgia, serif;

As per static analysis hints, font-family-name-quotes is enabled for this file.

Also applies to: 1713-1713, 1784-1784, 1836-1836

🤖 Prompt for AI Agents
Verify each 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 1563 - 1569, Remove the
unnecessary quotes around the single-identifier Spectral font family name in all
four font-family declarations, including the declarations associated with the
visible landing-page style and the other reported sites, so they satisfy
font-family-name-quotes.

Source: Linters/SAST tools

frontend/src/components/landing-v5/lab/RoomsDemo.tsx-42-54 (1)

42-54: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The typing indicator and the delayed reply can name different partners.

send captures partner in the timeout closure. The indicator at Line 127 reads the live partner. If the user changes the select during the 1600 ms delay, the indicator shows the new partner and the message arrives from the old one.

♻️ Proposed fix
 const [partner, setPartner] = useState<Partner>('maya');
+ const partnerRef = useRef<Partner>('maya');+ partnerRef.current = partner;
 typeT.current = setTimeout(() => {
- const reply = `my ${partner.charAt(0).toUpperCase() + partner.slice(1)} graph has eigenvalues green — want me to quiz you on it?`;+ const who = partnerRef.current;+ const reply = `my ${who.charAt(0).toUpperCase() + who.slice(1)} graph has eigenvalues green — want me to quiz you on it?`;
setTyping(false);
- setMsgs((m) => [...m, { who: partner, text: reply, mine: false }]);+ setMsgs((m) => [...m, { who, text: reply, mine: false }]);
}, 1600);

Also applies to: 125-132

🤖 Prompt for AI Agents
Verify each 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/RoomsDemo.tsx` around lines 42 - 54,
Update send so the delayed reply and typing indicator use the same partner
snapshot captured when the message is sent. Store that snapshot in the relevant
state or timeout flow, and ensure the indicator rendering near the partner
display uses it until the delayed reply completes; preserve the existing
behavior when the partner does not change.
frontend/src/app/globals.css-1950-1957 (1)

1950-1957: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stylelint reports 45 keyframes-name-pattern errors in the ported blocks.

The ported keyframe names are camelCase, for example s1Stem, popHold, heroRise, and labIn. The rule expects kebab-case. Renaming is not safe here, because the names are referenced from inline animation strings in the landing components and in labData-driven markup.

Scope a rule disable to the ported region instead, so the rest of the file keeps the check.

🔧 Proposed approach
+/* stylelint-disable keyframes-name-pattern -- names are ported verbatim from the+ design components and are referenced by inline `animation` strings in TSX. */
`@keyframes` s1Stem { 0%,15% { stroke-dashoffset:27; } 34%,100% { stroke-dashoffset:0; } }

Close the block with /* stylelint-enable keyframes-name-pattern */ after the last ported keyframe.

As per static analysis hints, keyframes-name-pattern is enabled for this file.

Also applies to: 2094-2096

🤖 Prompt for AI Agents
Verify each 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 1950 - 1957, Scope-disable
Stylelint’s keyframes-name-pattern rule immediately before the ported keyframe
region containing names such as s1Stem, popHold, heroRise, and labIn, then
re-enable it after the final ported keyframe. Keep the rule active for all other
sections of globals.css and do not rename the animation identifiers.

Source: Linters/SAST tools

frontend/src/lib/landing/course.ts-363-376 (1)

363-376: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle the singular case for degree.

If degree is 1, the text reads "its 1 neighbours". A sub-topic linked only to its parent produces that value.

✏️ Proposed fix
- ' pulls this node plus its ' +- degree +- ' neighbours into context, then retrieves the chunks of your ' ++ ' pulls this node plus its ' ++ degree ++ (degree === 1 ? ' neighbour' : ' neighbours') ++ ' into context, then retrieves the chunks of your ' +
🤖 Prompt for AI Agents
Verify each 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/course.ts` around lines 363 - 376, Update nodeUses
so the text generated for degree uses singular “neighbour” when degree is 1 and
plural “neighbours” otherwise, preserving the existing wording and behavior for
all other values.
frontend/src/components/landing-v5/lab/CardsDemo.tsx-46-53 (1)

46-53: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard the window key listener against editable targets.

The listener runs for every keydown on the window. It calls e.preventDefault() for Space and rates the card for 1, 2, and 3. While this demo is mounted, a Space or a digit typed in any other input on the page is captured.

🛡️ Proposed fix
 const onKey = (e: KeyboardEvent) => {
+ const el = e.target as HTMLElement | null;+ if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) 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 around onKey to ignore events whose
target is an editable control, such as input, textarea, select, or
contenteditable elements, before handling Space or rate keys; preserve the
existing flip and rate behavior for non-editable targets.
frontend/src/components/landing-v5/lab/GradesDemo.tsx-62-65 (1)

62-65: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The row percentage is not clamped, but the weighted grade is.

Line 25 clamps each score to [0, r.possible]. Line 64 does not. If the user types 500 into a row worth 50, the row shows 1000% while the overall grade stays at the clamped value. Use the same clamp for both readouts.

🐛 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,
Update the row percentage calculation in the GB_ROWS.map callback to clamp the
parsed earned score to the range [0, r.possible] before computing the
percentage, matching the existing clamping used for the weighted grade and
keeping both readouts consistent.
frontend/src/components/landing-v5/lab/NotesDemo.tsx-57-66 (1)

57-66: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The "No concepts found" sentinel corrupts the footer count.

Line 63 pushes { name: 'No concepts found', tier: 'unexplored' } into the same concepts array that holds real matches. The footer at Lines 141-143 then reads concepts.length and prints 1 concepts linked to your MA 242 graph. after an empty extraction. The text is wrong, and the plural is wrong for a single match too.

Track the empty result separately instead of encoding it as a concept.

🐛 Proposed fix
- const [concepts, setConcepts] = useState<{ name: string; tier: keyof typeof LAB_TIER }[]>([]);+ const [concepts, setConcepts] = useState<{ name: string; tier: keyof typeof LAB_TIER }[]>([]);+ const [ran, setRan] = useState(false);
 const found = MATCHERS.filter((m) => m.hit.test(lower)).map((m) => ({ name: m.name, tier: m.tier }));
- setConcepts(found.length ? found : [{ name: 'No concepts found', tier: 'unexplored' }]);+ setConcepts(found);+ setRan(true);
setBusy(false);
- {concepts.length- ? `${concepts.length} concepts linked to your MA 242 graph.`- : 'Concepts you extract are linked to your graph, not just tagged on the note.'}+ {concepts.length+ ? `${concepts.length} concept${concepts.length === 1 ? '' : 's'} linked to your MA 242 graph.`+ : ran+ ? 'No concepts found in this note.'+ : 'Concepts you extract are linked to your graph, not just tagged on the note.'}

Also applies to: 140-144

🤖 Prompt for AI Agents
Verify each 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 - 66,
Update extract so an empty MATCHERS result is tracked separately rather than
added to concepts; keep concepts containing only real matches and render the
empty-state message independently. Adjust the footer rendering near the concepts
count to display the correct zero count and use singular/plural wording based on
the actual number of linked concepts.
frontend/src/components/landing-v5/Closing.tsx-154-182 (1)

154-182: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Composite ARIA roles are declared without their required child roles. Both sites put a composite role on a container and leave the children as plain <button> elements. ARIA requires owned children with the matching role, so assistive technology announces an empty widget and the selected item is not conveyed.

  • frontend/src/components/landing-v5/Closing.tsx#L154-L182: add role="menuitem" to each section button inside the role="menu" container.
  • frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124: add role="radio" and aria-checked={isPicked} to each option button inside the role="radiogroup" container.
🤖 Prompt for AI Agents
Verify each 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 154 - 182, Add
role="menuitem" to each section button rendered by SECTIONS.map in Closing.tsx
(frontend/src/components/landing-v5/Closing.tsx#L154-L182). In QuizDemo.tsx
(frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124), add
role="radio" and bind aria-checked to isPicked on each option button within the
radiogroup.
frontend/src/components/landing-v5/lab/CalendarDemo.tsx-86-112 (1)

86-112: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Blank leading cells are focusable empty buttons.

Every cell renders as a <button>, including the LEAD_BLANKS placeholders. The blank cells only set cursor: 'default', which does not remove them from the tab order. A keyboard user tabs through up to six empty buttons before reaching 1 October. The selected day also carries no aria-pressed, so the current selection is not announced.

♿ Proposed change
 <button
key={i}
onClick={() => { if (!c.blank) setDay(c.day as number); }}
type="button"
+ disabled={c.blank}+ aria-hidden={c.blank || undefined}+ aria-pressed={c.blank ? undefined : selected}
🤖 Prompt for AI Agents
Verify each 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/CalendarDemo.tsx` around lines 86 -
112, Update the day-cell buttons rendered by the shown.map callback so blank
placeholder cells are removed from keyboard focus, while retaining interactive
keyboard behavior for real days. Add aria-pressed to each non-blank day button
and set it according to the selected value so assistive technologies announce
the current selection.
frontend/src/components/landing-v5/Faq.tsx-71-76 (1)

71-76: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

"Read the full FAQ" links back to itself.

The anchor targets #faq, and the enclosing <section> already has id="faq" (Line 40). Clicking the link keeps the user on the same section, so the call to action does nothing.

This PR adds the /faq public route, and Closing.tsx Line 81 already links to it. Point this link at /faq.

🔗 Proposed change
- <a href="`#faq`" style={{ color: '`#0C5638`', textDecoration: 'underline' }}>Read the full FAQ</a>+ <Link href="/faq" style={{ color: '`#0C5638`', textDecoration: 'underline' }}>Read the full FAQ</Link>

Add the import:

 import { FAQS } from '`@/lib/landing/content`';
+import Link from 'next/link';
import { DragField } from './DragField';
🤖 Prompt for AI Agents
Verify each 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 71 - 76, Update the
“Read the full FAQ” anchor in the Faq component to navigate to the public /faq
route instead of the local `#faq` section, matching the existing link behavior in
Closing.tsx.
frontend/src/components/landing-v5/lab/GuideDemo.tsx-21-24 (1)

21-24: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Align RECENT.pick with the course exam lists. Neither Midterm 1 · Sep 30 nor Quiz 2 · Oct 03 exists in its course's GUIDES.exams. Opening either recent item sets an invalid select value while the guide heading displays that value.

🤖 Prompt for AI Agents
Verify each 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 in GuideDemo so each pick exactly matches an existing
exam value in the corresponding course’s GUIDES.exams list, preserving the
displayed recent-item behavior and ensuring the select value and guide heading
use valid options.
🤖 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/`(public)/page.tsx:
- Around line 130-131: Update the onLearn handler in the page component to open
the tutor panel at GAL index 7 instead of index 2, while leaving onQuiz
unchanged. Prefer the named GAL lookup or constant from content.ts rather than a
hard-coded index so card ordering changes cannot reintroduce this mismatch.
In `@frontend/src/components/landing-v5/ActTutor.tsx`:
- Around line 92-109: Update the tutor pills container in ActTutor to remove its
buttons from keyboard navigation while hidden by binding the engine’s docked
state to inert (or visibility). Preserve the existing opacity and pointerEvents
behavior, and ensure the pills become focusable again when docked is false.
In `@frontend/src/components/landing-v5/BetaModal.tsx`:
- Around line 132-143: Update the HeroCard responsive layout to use a single
column below the medium breakpoint and the existing two-column structure at
wider widths, using its CSS class rather than an unconditional
gridTemplateColumns style. Enable vertical scrolling for constrained viewports,
and apply the 560px minimum height only at the wider breakpoint so mobile
content, including the email field and submit button, remains accessible.
In `@frontend/src/components/landing-v5/Gallery.tsx`:
- Around line 62-85: Update the Card component so non-ghost cards are
keyboard-focusable and expose button-like semantics, with a key handler that
triggers the existing delegated Track click behavior for Enter and Space. Keep
ghost cards inert by omitting focusability, interactive semantics, and
activation handling, while preserving the current data-tk targeting.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 37-44: Update the rate callback to store its 300 ms timeout in a
ref instead of returning a cleanup function, and clear that timeout in an
unmount cleanup effect. Preserve the existing delayed setIdx behavior while
preventing it from firing after the component unmounts.
In `@frontend/src/components/landing-v5/navTheme.ts`:
- Around line 68-69: Update DARK_SECTIONS to contain only the section that
currently renders on a dark ground, act-graph, and revise its comment to
describe the single dark section accurately. Keep act-ingest and act-tutor out
of the list until their backgrounds are dark.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 218-229: Update the hero canvas effect around the lazy
`import('`@/lib/landing/engine/hero`')` to track whether cleanup has run, prevent
`startHeroCanvas` from starting after unmount, and stop it immediately if
initialization races with cancellation. Preserve the existing cleanup behavior
for normal mounted execution.
- Around line 277-282: Update the onKey handler in useLanding to handle Escape
while explore mode is active: after closing the gallery or jump menu as
appropriate, call exitExplore when s.current.exploring is true and neither
overlay was open. Ensure exitExplore is available at handler execution time
despite its later declaration, using the existing callback ordering or a ref as
needed.
In `@frontend/src/components/marketing/SignInModal.tsx`:
- Around line 130-153: Update the OAuth URL construction in the sign-in flow,
including sameTabUrl and popupUrl, to use the frontend-relative /api/auth/google
path instead of prepending API_URL. Preserve the existing popup_id query
parameter and same-tab fallback behavior so OAuth requests remain same-origin
and retain the session cookie.
In `@frontend/src/lib/landing/content.ts`:
- Around line 191-218: In frontend/src/lib/landing/content.ts lines 191-218,
mark the unavailable journal-founding.png and journal-ai-homework.png entries
with empty src values and isGraph: true so they use the graph fallback until
assets are shipped. In frontend/src/components/landing-v5/Journal.tsx lines
106-118, preserve the meta.isGraph branch that renders GraphThumb instead of
passing unusable paths to next/image.
In `@frontend/src/lib/landing/engine/sim.ts`:
- Around line 897-905: Update destroy() cleanup ordering so every cluster
element is re-parented to its original field before the cleanup that removes the
shell/overlay runs. Preserve the existing cleanup registration in ensureInit(),
but ensure repeated createSim() calls can still find the original
[data-dragnode] elements after destroy().
---
Outside diff comments:
In `@frontend/src/lib/deployGuard.ts`:
- Around line 39-46: Update classify to normalize URL values by removing
trailing slashes before comparing against FRONTEND_ENVS fields, while preserving
existing trimming and case normalization. Ensure both API and site URL
classifications recognize trailing-slash variants, and add regression coverage
for trailing-slash API and site URLs.
---
Minor comments:
In `@frontend/src/app/`(public)/about/page.tsx:
- Around line 80-84: Update the paragraph near the prose content to remove the
nonexistent navbar feedback-button reference, and direct readers to an available
control such as the GitHub link or a contact address instead. Preserve the
surrounding message about reporting issues and sharing ideas.
In `@frontend/src/app/`(public)/careers/[slug]/page.tsx:
- Around line 18-22: Update the metadata construction in the careers page’s
generateMetadata flow so the description appends “…” only when job.description
exceeds 155 characters; leave shorter or exactly 155-character descriptions
without a truncation marker while preserving the existing 155-character slice.
In `@frontend/src/app/`(public)/news/page.tsx:
- Around line 175-199: The news cards’ CTA text incorrectly promises article
navigation while each Link in the posts.map block targets /#newsletter. Update
the CTA span text from “Read article →” to wording that reflects newsletter
signup, such as “Get notified →”, while preserving the existing link destination
and styling.
In `@frontend/src/app/`(public)/page.tsx:
- Around line 91-100: Update the BetaModal onClose handler to call
actions.resetSubscribeError before closing the modal with setBetaOpen(false),
ensuring the shared subscription error is cleared when the modal closes.
In `@frontend/src/app/`(public)/team/page.tsx:
- Around line 18-22: Update the metadata exports in
frontend/src/app/(public)/team/page.tsx lines 18-22 and
frontend/src/app/(public)/wiki/page.tsx lines 18-22 to include
alternates.canonical, using /team for the team route and /wiki for the wiki
route.
In `@frontend/src/app/`(public)/wiki/page.tsx:
- Around line 28-31: Move scrollMarginTop: 84 from the H2 style to the section
styling used by the sections targeted by the TOC fragment links (`#graph`,
`#mastery`, `#review`, `#tutor`, `#ingestion`, `#grades`, and `#privacy`). Keep the heading
typography unchanged and ensure each target section receives the offset so it
clears the sticky header.
In `@frontend/src/app/globals.css`:
- Around line 1563-1569: Remove the unnecessary quotes around the
single-identifier Spectral font family name in all four font-family
declarations, including the declarations associated with the visible
landing-page style and the other reported sites, so they satisfy
font-family-name-quotes.
- Around line 1950-1957: Scope-disable Stylelint’s keyframes-name-pattern rule
immediately before the ported keyframe region containing names such as s1Stem,
popHold, heroRise, and labIn, then re-enable it after the final ported keyframe.
Keep the rule active for all other sections of globals.css and do not rename the
animation identifiers.
In `@frontend/src/components/companion/CompanionShell.tsx`:
- Around line 86-108: Update the CompanionShell root element to include the
landing-dc class so the existing responsive media-query rules apply to its
nav-compact and nav-tabs elements. Preserve the current navigation structure and
styling.
In `@frontend/src/components/landing-v5/BetaModal.tsx`:
- Around line 244-260: Update the email input in BetaModal’s form to use
type="email" instead of type="text", while preserving its existing validation,
value handling, and other attributes.
In `@frontend/src/components/landing-v5/Closing.tsx`:
- Around line 154-182: Add role="menuitem" to each section button rendered by
SECTIONS.map in Closing.tsx
(frontend/src/components/landing-v5/Closing.tsx#L154-L182). In QuizDemo.tsx
(frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124), add
role="radio" and bind aria-checked to isPicked on each option button within the
radiogroup.
In `@frontend/src/components/landing-v5/Faq.tsx`:
- Around line 71-76: Update the “Read the full FAQ” anchor in the Faq component
to navigate to the public /faq route instead of the local `#faq` section, matching
the existing link behavior in Closing.tsx.
In `@frontend/src/components/landing-v5/lab/CalendarDemo.tsx`:
- Around line 86-112: Update the day-cell buttons rendered by the shown.map
callback so blank placeholder cells are removed from keyboard focus, while
retaining interactive keyboard behavior for real days. Add aria-pressed to each
non-blank day button and set it according to the selected value so assistive
technologies announce the current selection.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 46-53: Update the keydown handler in the useEffect around onKey to
ignore events whose target is an editable control, such as input, textarea,
select, or contenteditable elements, before handling Space or rate keys;
preserve the existing flip and rate behavior for non-editable targets.
In `@frontend/src/components/landing-v5/lab/GradesDemo.tsx`:
- Around line 62-65: Update the row percentage calculation in the GB_ROWS.map
callback to clamp the parsed earned score to the range [0, r.possible] before
computing the percentage, matching the existing clamping used for the weighted
grade and keeping both readouts consistent.
In `@frontend/src/components/landing-v5/lab/GuideDemo.tsx`:
- Around line 21-24: Update the RECENT entries in GuideDemo so each pick exactly
matches an existing exam value in the corresponding course’s GUIDES.exams list,
preserving the displayed recent-item behavior and ensuring the select value and
guide heading use valid options.
In `@frontend/src/components/landing-v5/lab/NotesDemo.tsx`:
- Around line 57-66: Update extract so an empty MATCHERS result is tracked
separately rather than added to concepts; keep concepts containing only real
matches and render the empty-state message independently. Adjust the footer
rendering near the concepts count to display the correct zero count and use
singular/plural wording based on the actual number of linked concepts.
In `@frontend/src/components/landing-v5/lab/RoomsDemo.tsx`:
- Around line 42-54: Update send so the delayed reply and typing indicator use
the same partner snapshot captured when the message is sent. Store that snapshot
in the relevant state or timeout flow, and ensure the indicator rendering near
the partner display uses it until the delayed reply completes; preserve the
existing behavior when the partner does not change.
In `@frontend/src/components/landing-v5/Navbar.tsx`:
- Around line 74-79: Update the navbar style logic in the visible component so
pointerEvents is set to 'none' whenever the navbar is invisible, including while
heroMounted is false and while exploring is true; restore 'auto' only when the
navbar is visible and interactive.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 593-597: Update subscribeNow so invalid email input, including an
empty trimmed value or a value without "@", sets subscribeError to the existing
user-facing validation message before returning. Keep the subscribing guard and
valid-input flow unchanged, ensuring the user receives feedback instead of a
silent no-op.
In `@frontend/src/lib/landing/course.ts`:
- Around line 363-376: Update nodeUses so the text generated for degree uses
singular “neighbour” when degree is 1 and plural “neighbours” otherwise,
preserving the existing wording and behavior for all other values.
In `@frontend/src/lib/landing/engine/dom.ts`:
- Around line 49-73: Reject zero-sized measurements before caching them: in
frontend/src/lib/landing/engine/dom.ts lines 49-73, ensure cv does not assign
rec.__cv when w or h is 0; in frontend/src/lib/landing/engine/marquee.ts lines
136-144, ensure the marquee measurement does not call m.setW when the summed
child offsetWidth is 0. Preserve normal caching and measurement behavior for
positive dimensions.
In `@frontend/src/lib/landing/engine/marquee.ts`:
- Around line 69-114: Bind the existing onUp and onCancel release handlers to
window in addition to track, matching the dual registration pattern used by
sim.ts. Keep the current track listeners and end() behavior unchanged so drags
are finalized even when pointer capture fails or the pointer leaves the track.
In `@frontend/src/lib/landing/engine/sim.ts`:
- Around line 330-369: Update the SVG build logic in the rings and lines loops
to validate required structure before creating simulation entries: skip rings
when the previous glow element or next label element is missing, and skip lines
when either endpoint lookup in near() returns -1. Keep valid ring and line
processing unchanged, ensuring no nodes or links are created with null siblings
or invalid endpoint indices.
---
Nitpick comments:
In `@frontend/src/app/`(public)/careers/CareersList.tsx:
- Around line 217-284: Update the collapsible panel style in the job card
rendering to include visibility based on isOpen: visible when expanded and
hidden when collapsed. Add visibility to the existing transition so the state
changes with the panel animation while keeping the Apply for this role link and
other content inaccessible during collapse.
- Around line 51-55: Replace both raw img elements in CareersList with the
next/image component, matching the existing usage in CompanionShell for
/sapling-icon.svg. Preserve the current alt text and styling, and set the second
occurrence’s dimensions to width={20} and height={20} while retaining the first
occurrence’s 26×26 sizing.
In `@frontend/src/app/`(public)/faq/page.tsx:
- Around line 60-89: Update the FAQ answer panel associated with the button
using item.i: assign it a unique id, add matching aria-controls to the button,
and include visibility in the panel styles so it is hidden when collapsed and
visible when open. Preserve the existing max-height and opacity transitions.
In `@frontend/src/app/`(public)/news/page.tsx:
- Around line 111-153: Update the category menu logic around the existing
outside pointerdown listener to handle Escape while the menu is open, close it,
and return focus to the trigger button. Add a ref to the trigger button and
focus it both when Escape dismisses the menu and when selecting an option via
the NEWS_FILTERS buttons, while preserving the existing outside-click behavior.
In `@frontend/src/components/companion/CompanionShell.tsx`:
- Around line 84-141: Wrap the primary link group currently rendered by the
`nav-tabs` container in a `<nav>` landmark with a descriptive `aria-label`,
closing it after the GitHub and Ko-fi links. Also locate the header’s footer
link row and wrap it in a separate appropriately labelled `<nav>` element,
preserving the existing links and layout.
In `@frontend/src/components/landing-v5/ActIngest.tsx`:
- Around line 259-282: Add aria-hidden="true" to the decorative flying chip
elements rendered in the CHIPS.map callback within ActIngest, ensuring assistive
technologies ignore these duplicate labels while leaving their visual animation
and styling unchanged.
In `@frontend/src/components/landing-v5/ActTutor.tsx`:
- Around line 95-97: Add type="button" to the pill button rendered in ActTutor’s
button map alongside its existing key and onClick props, matching the explicit
button type used by the other buttons in this PR.
In `@frontend/src/components/landing-v5/Closing.tsx`:
- Line 92: Replace the hardcoded “© 2026” values in the Closing component with a
single dynamically derived current year, and reuse that value in both copyright
locations near the existing spans.
In `@frontend/src/components/landing-v5/DragField.tsx`:
- Around line 70-103: Update the label branch in the nodes mapping within
DragField’s render flow to always create a text element, including when label is
missing. Preserve the glow, ring, and label ordering and use an empty
placeholder for missing label content while retaining valid label positioning
and styling.
In `@frontend/src/components/landing-v5/Faq.tsx`:
- Around line 84-121: Update the FAQ trigger and answer panel so each button in
the mapped FAQ item has an aria-controls reference matching that panel’s unique
id. Add hidden or equivalent visibility handling to the panel when open is
false, while preserving the existing expanded state and visual transition
behavior.
In `@frontend/src/components/landing-v5/FeatureLab.tsx`:
- Around line 41-50: Add dialog semantics to the fixed overlay in FeatureLab by
assigning role="dialog", aria-modal="true", and an accessible name tied to the
panel heading or another existing label; implement focus movement to the close
button when open and restore focus when closed. In the tool rail, add an
explicit active-state attribute such as aria-current or aria-selected to the
open tool instead of relying on background color alone.
In `@frontend/src/components/landing-v5/Gallery.tsx`:
- Around line 87-109: Add an explicit accessible role to the div rendered by
Track so its existing aria-label is announced by assistive technology; use
role="group" without changing the current card rendering or interaction
behavior.
- Around line 71-73: Remove the redundant frame styling from the wrapper div
rendering GALLERY_MINIS[i] in Gallery: eliminate its duplicated height,
borderRadius, background, border, and overflow/position container behavior as
appropriate, while preserving the mini’s own frame and layout. Keep the
aria-hidden wrapper only if needed without adding a second visual frame.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 100-115: Update the flip target around the onClick={flip} handler
to be keyboard accessible: prefer a button element, or add role="button",
tabIndex={0}, and an onKeyDown handler that invokes flip for Enter and Space
while preventing the default Space behavior. Preserve the existing click
interaction and visual styling.
In `@frontend/src/components/landing-v5/lab/GradesDemo.tsx`:
- Around line 74-83: Update the onChange sanitizer in the GradesDemo input to
allow at most one decimal point while preserving digits and the existing state
update behavior. Ensure values such as “1.2.3” are normalized so the displayed
text matches the value used by score computation.
In `@frontend/src/components/landing-v5/lab/GuideDemo.tsx`:
- Line 40: Use a consistent lookup style for the GUIDES map in the GuideDemo
component: update the initialization around set and the lookup in the relevant
course iteration around c so both rely on the CourseKey type without an
unnecessary nullish fallback, or both use the same fallback pattern. Preserve
the existing guide-selection behavior while removing the inconsistent
implication that GUIDES[course] may be missing.
In `@frontend/src/components/landing-v5/lab/NotesDemo.tsx`:
- Around line 57-80: Store the timers created by extract and summarize in refs
alongside saveT, clear their handles on unmount, and reset each ref after its
callback runs. Follow the existing timer-ref cleanup pattern in FeatureLabDemo
so callbacks cannot update state after the component unmounts.
In `@frontend/src/components/landing-v5/lab/QuizDemo.tsx`:
- Line 31: Extract the repeated q.options correctness check into a shared
predicate or derived value near the existing right calculation, then reuse it in
both locations. Update submit’s setStreak call to use the functional updater
form based on the previous streak value, preserving the existing
increment-or-reset behavior without reading streak from the render closure.
In `@frontend/src/components/landing-v5/lab/RoomsDemo.tsx`:
- Around line 76-106: Update the message-list container in RoomsDemo so it has
aria-live="polite" and automatically scrolls to the newest message after each
append. Use a ref and an effect tied to the message-list state, preserving the
existing rendering and styling.
In `@frontend/src/components/landing-v5/Navbar.tsx`:
- Around line 199-234: Remove the incomplete ARIA menu pattern from the navbar:
delete role="menu" from the panel and aria-haspopup="menu" from its trigger,
while preserving the existing links and open/close behavior. Expose the panel as
a plain navigation group without adding menuitem roles or keyboard navigation.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 383-399: Optimize both scanning effects in useLanding: replace the
first effect’s recurring setInterval(scan, 900) polling with a MutationObserver
watching root and invoking scan for relevant DOM additions, while preserving
cleanup and observer behavior. In the second effect around the section
descendant traversal, query only elements marked with the intended animation
attribute (such as [data-anim]) instead of '*', and retain the existing
animation logic for matched elements.
In `@frontend/src/lib/landing/companionContent.ts`:
- Around line 149-160: Update WIKI_TIERS.dot and WIKI_RATINGS.tone to store the
raw hex color values from TIER instead of CSS declaration strings. In the wiki
page consumer, remove the cssColor regex and parsing/fallback logic, and use
t.dot and r.tone directly as colors.
In `@frontend/src/lib/landing/content.ts`:
- Around line 38-135: Document that GAL’s array order is a positional contract
and must not be reordered or have entries inserted without updating callers. Add
and export a galIndexOf helper keyed by GalleryKind, then update the page.tsx
actions.openGal calls to resolve indices through that helper instead of
hard-coded positions.
In `@frontend/src/lib/landing/dragClusters.ts`:
- Line 36: Remove the redundant `as DragCluster[]` assertion from the
`DRAG_CLUSTERS` array literal, preserving its existing `DragCluster[]` type
annotation so excess-property and required-field checks remain enabled.
In `@frontend/src/lib/landing/engine/flip.ts`:
- Around line 24-37: Remove the unused ran field from the FlipState interface
and its initialization in createFlipState, and remove all assignments to st.ran,
including in armFlip and flipClose. Keep the actual animation guard in
useLanding.ts via flipRan unchanged.
In `@frontend/src/lib/landing/engine/plant.ts`:
- Around line 78-91: Update restore() to validate the parsed localStorage value
as an array, retain only string entries, and truncate each label to MAX_LABEL
before calling add() or updating labels. Ensure invalid or non-array data
produces an empty restored label set without partially processing it, while
preserving the seven seed nodes and normal return count.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

Comment threadfrontend/src/app/(public)/page.tsx Outdated
Comment on lines +92 to +109
{/* the engine fades these in once the act's scroll is spent */}
<div data-tutor-pills="1" style={{ display: 'flex', gap: 8, marginTop: 8, opacity: 0, pointerEvents: 'none', transition: 'opacity 400ms ease' }}>
{CAPTIONS.map(([tag], i) => (
<button
key={tag}
onClick={() => onSetMode(i)}
style={{
border: '1px solid rgba(12,86,56,0.3)', borderRadius: 99, padding: '9px 18px',
...MONO, fontSize: 10.5, letterSpacing: '0.12em', cursor: 'pointer',
background: tutorMode === i ? PILL_ON : 'transparent',
color: tutorMode === i ? PILL_ON_FG : '#33443B',
transition: 'all 250ms',
}}
>
{tag}
</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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Find what the engine writes on the tutor pills element.
rg -n -C 6 'tutor-pills|tutorPills' --glob '*.ts' --glob '*.tsx'

Repository: SaplingLearn/Sapling

Length of output: 158


🏁 Script executed:

#!/bin/bashset -e
printf'%s\n''--- candidate files ---'
fd -i 'ActTutor|tutor|landing-v5'.| head -80
printf'%s\n''--- all tutor-pills references ---'
rg -n -C 8 --hidden --glob '!node_modules' --glob '!dist' --glob '!build''tutor-pills|tutorPills|data-tutor'.printf'%s\n''--- ActTutor structure ---'
ACT=$(fd -t f -i 'ActTutor.tsx'.| head -1)if [ -n"$ACT" ];then
ast-grep outline "$ACT"
sed -n '1,180p'"$ACT"fi

Repository: SaplingLearn/Sapling

Length of output: 19055


🏁 Script executed:

#!/bin/bashset -e
printf'%s\n''--- engine writes to M.pills ---'
rg -n -C 10 'M\.pills|pills\.(style|classList)|style\.(opacity|pointerEvents|visibility)|opacity.*pills|pills.*opacity' frontend/src/lib/landing frontend/src/components/landing-v5 --glob '*.ts' --glob '*.tsx'printf'%s\n''--- engine files and relevant structure ---'
fd -t f . frontend/src/lib/landing/engine -x sh -c 'echo --- "$1"; wc -l "$1"; ast-grep outline "$1" | head -80' sh
printf'%s\n''--- direct pills-related engine context ---'
rg -n -C 20 'pills|handover|spent|scroll' frontend/src/lib/landing/engine --glob '*.ts' --glob '*.tsx'

Repository: SaplingLearn/Sapling

Length of output: 50377


Remove hidden pills from the keyboard tab order.

The engine changes only opacity and pointerEvents, so hidden buttons remain focusable. Toggle inert with the engine’s docked state, or toggle visibility there as well.

🤖 Prompt for AI Agents
Verify each 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 92 - 109,
Update the tutor pills container in ActTutor to remove its buttons from keyboard
navigation while hidden by binding the engine’s docked state to inert (or
visibility). Preserve the existing opacity and pointerEvents behavior, and
ensure the pills become focusable again when docked is false.

Comment threadfrontend/src/components/landing-v5/BetaModal.tsx
Comment threadfrontend/src/components/landing-v5/Gallery.tsx
Comment threadfrontend/src/components/landing-v5/lab/CardsDemo.tsx
Comment threadfrontend/src/components/landing/useLanding.ts
Comment threadfrontend/src/components/landing/useLanding.ts
Comment threadfrontend/src/components/marketing/SignInModal.tsx Outdated
Comment threadfrontend/src/lib/landing/content.ts Outdated
Comment on lines +191 to +218
/**
* Presentation metadata paired positionally with POSTS.
*
* NOTE: `journal-founding.png` and `journal-ai-homework.png` are not in
* `public/` yet — both exceed the design API's 256 KiB read cap, so they
* ship with the project export alongside the rest of the source.
*/
export const POST_META: PostMeta[] = [
{
slot: 'journal-1',
src: '/journal-founding.png',
slotHint: 'Drop the founding photo',
isPhoto: true,
isGraph: false,
comments: 12,
likes: 84,
floatDelay: '0s',
},
{
slot: 'journal-2',
src: '/journal-ai-homework.png',
slotHint: 'Drop an image for this essay',
isPhoto: true,
isGraph: false,
comments: 31,
likes: 126,
floatDelay: '-2.5s',
},

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

Two journal images are declared but may not ship.POST_META points at /journal-founding.png and /journal-ai-homework.png, and the file's own comment states both assets are absent from public/. Journal.tsx renders those paths through next/image with no fallback, so two of the three journal cards can show a broken image.

  • frontend/src/lib/landing/content.ts#L191-L218: add the two PNG files to public/, or set src: '' and isGraph: true on those entries until the assets ship.
  • frontend/src/components/landing-v5/Journal.tsx#L106-L118: keep the meta.isGraph branch as the fallback path, so an entry without a usable src renders GraphThumb instead of a broken image.
📍 Affects 2 files
  • frontend/src/lib/landing/content.ts#L191-L218 (this comment)
  • frontend/src/components/landing-v5/Journal.tsx#L106-L118
🤖 Prompt for AI Agents
Verify each 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/content.ts` around lines 191 - 218, In
frontend/src/lib/landing/content.ts lines 191-218, mark the unavailable
journal-founding.png and journal-ai-homework.png entries with empty src values
and isGraph: true so they use the graph fallback until assets are shipped. In
frontend/src/components/landing-v5/Journal.tsx lines 106-118, preserve the
meta.isGraph branch that renders GraphThumb instead of passing unusable paths to
next/image.

Comment threadfrontend/src/lib/landing/engine/sim.ts
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — v5 marketing landing onto staging

This ports the v5 marketing landing onto staging as a public-surface-only change, and the central claim in the description holds up: I verified there are no backend/** files, no migrations, no middleware.ts, no next.config.ts and no wrangler.toml in the diff (74/74 changed files are under frontend/), and git diff --diff-filter=D is empty — nothing is deleted out from under another surface. The Layer-2 token scoping is done properly (.public-surface, .landing-page in globals.css:828, with deliberate literal fallbacks on .card--hero), and every new landing-v5/marketing/companion component carries "use client". The problems are elsewhere: a dropped auth-error path, staging metadata that points at production, a colour system that bypasses the design tokens entirely, and — the big one — this branch is a stale snapshot of a landing that is already merged on main.

Findings

P0

[P0] Both CI jobs are red, and the failures hide the fact that nothing was typechecked or tested.github/workflows/ci.yml:41,90-94

Backend (ruff check ., run 31553863104):

F401 [*] `pydantic.Field` imported but unused
--> models/gradescope.py:12:33
Found 1 error.
##[error]Process completed with exit code 1.

Frontend (npx eslint ., same run):

frontend/src/components/settings/GradescopeConnect.tsx
65:6 error React Hook useEffect has a missing dependency: 'refreshAll'. Either include it or
remove the dependency array react-hooks/exhaustive-deps
✖ 33 problems (1 error, 32 warnings)
##[error]Process completed with exit code 2.

To be clear about attribution: neither is yours. Both reproduce verbatim at the base commit 82787cabackend/models/gradescope.py imports Field with zero Field( uses there, and GradescopeConnect.tsx:65 is already }, []);. gh run list --branch staging shows staging's last green run was 8bce5b02 on 2026-07-10; 82787ca never ran CI, so it landed red. The reason this is still P0 for this PR: the workflow runs ruff before pytest, and eslint before npx tsc --noEmit and npm test. Both jobs die at the lint step, so the type checker and both test suites never executed against these 14,954 added lines. You currently have no signal that the port compiles. Fixing base (or rebasing once it is fixed) is a prerequisite for this PR meaning anything.

P1

[P1] ?error=session_expired no longer reaches the sign-in modalfrontend/src/app/(public)/page.tsx:89

<SignInModalopen={signInOpen}onClose={()=>setSignInOpen(false)}/>

middleware.ts (untouched here) bounces expired sessions to the landing with a query param:

// middleware.ts:38if(errorCode)u.searchParams.set('error',errorCode)// middleware.ts:52 and :67if(!session)returnredirectToSignin(request,'session_expired')

The page this replaces read that param and threaded it through — base (public)/page.tsx:90const err = params.get('error'), then :879errorCode={signInError}. The new page never reads ?error (no searchParams/URLSearchParams anywhere in page.tsx or useLanding.ts), so the prop is never supplied. Meanwhile components/marketing/SignInModal.tsx still ships the whole error surface — ERROR_COPY at :11 including session_expired at :16, and :122const visibleError = localError ?? errorCode ?? null — all now dead. Net effect: a user kicked out mid-session lands on the marketing page with no explanation of why they were signed out. Restore the ?error= read and pass it through.

[P1] The staging deploy will advertise production canonical and OG URLsfrontend/src/app/layout.tsx:43

metadataBase: newURL(resolveSiteUrl(process.env)),alternates: {canonical: '/'},

resolveSiteUrl falls through to production when neither knob is set:

// deployGuard.ts:98-106exportfunctionresolveSiteUrl(env: EnvSource): string{constdeployEnv=(env.DEPLOY_ENV??'').trim().toLowerCase();if(deployEnv&&deployEnvinFRONTEND_ENVS)returnFRONTEND_ENVS[deployEnvasFrontendEnv].siteUrl;constexplicit=(env.NEXT_PUBLIC_SITE_URL??'').trim();if(explicit)returnexplicit.replace(/\/+$/,'');returnFRONTEND_ENVS.production.siteUrl;}

frontend/wrangler.toml is not modified by this PR, and its [env.staging.vars] block sets only NEXT_PUBLIC_API_URL, BACKEND_URL and COOKIE_DOMAINneither DEPLOY_ENV nor NEXT_PUBLIC_SITE_URL. So on frontend-staging every page resolves metadataBase to https://saplinglearn.com, making canonical, og:url and og:image all point at production. Since the entire point of this PR is "show the new landing on staging", any staging link shared for review will unfurl with production's card, and staging self-canonicalises to prod. Set DEPLOY_ENV = "staging" in [env.staging.vars] (which also arms the existing checkFrontendDeployEnv lock in next.config.ts:46).

[P1] The landing runs a parallel colour palette that shares no value with the design tokensfrontend/src/components/landing-v5/*

Counting hex literals across landing-v5 (excluding the galleryMinis illustration art) gives ~900 hardcoded colours. The most frequent:

79 #0c5638 48 #fdfcf9 46 #0e9e5a 40 #12201a
37 #33443b 34 #9aa5a0 31 #8b9891 30 #8fd9a8

Not one of these is a token. The core set in globals.css is --brand-forest: #1B6C42, --brand-forest-bright: #2D8F5C, --sap-500: #4e873c, --ink-0: #faf8f3, --ink-800: #1a1814. Note the near-misses in particular: #fdfcf9 vs --ink-0 #faf8f3, and #12201a vs --ink-800 #1a1814. They are close enough to look like the brand and different enough to be visibly off it at the seam where a user crosses from the landing into the app shell. The Engineering Style Guide §3/§4 is explicit — "inline style objects referencing global CSS-variable tokens … Use the tokens; don't hardcode values." This is doubly odd because the PR itself builds the correct scaffolding: (public)/layout.tsx mounts .public-surface and globals.css:839-841 defines --surface-hero/--brand-glow on it, and then no component uses any of it.

[P1] This is a stale snapshot of a landing already merged on main, and promoting it will fight main — whole landing-v5 tree

origin/main is 4f5a7f9 Merge pull request #524 from SaplingLearn/feat/landing-v5-port — the v5 landing is already there. git merge-base origin/staging origin/main is 57469ad, which contains nofrontend/src/components/landing-v5/, so the directory is added independently on both sides: promoting stagingmain is an add/add conflict across ~50 files, not a fast merge. main has taken 19 commits on the landing since the merge-base, and git diff origin/main 395588da over the landing tree is only 4 files / +9 −38 — i.e. this branch is behind, missing a3d0371 fix(landing): raster, veil and scroll-coupling polish on the v5 hero (the MAG/zoom raster correction in ActTutor.tsx, and the re-tuned hero washes in Hero.tsx).

The dangerous part is that one of those divergences is invisible to the type checker:

// this branch — hero3d/index.ts:52-56 (an intensity multiplier, 0..2)exportfunctionstartHeroRig(canvas,getMouse,getParallax: ()=>number)// origin/main — hero3d/index.ts:76-80 (a scroll offset in pixels)exportfunctionstartHeroRig(canvas,getMouse,getHeroShiftPx: ()=>number)

Both are () => number, and both hero3d/index.ts and useLanding.ts are in the conflict set. Resolving that conflict by taking one file from each side compiles cleanly and silently breaks the hero. Please rebase this branch onto main's landing rather than re-adding an older copy, or state explicitly how the promotion merge will be resolved.

P2

[P2] Three of the four new deployGuard exports have no production callerfrontend/src/lib/deployGuard.ts:52-146

resolveFrontendEnv, expectedEnvForHost and detectHostConfigMismatch are referenced only by deployGuard.test.ts. resolveSiteUrl is the only one wired into anything (layout.tsx:4). detectHostConfigMismatch's own docstring bills it as "Runtime defence-in-depth for what the build-time guard cannot see", citing "the exact failure behind staging's session_expired" — but middleware.ts is not in this diff, so nothing calls it and it defends nothing. That is ~100 lines of inert code plus 131 lines of tests landing in a shared lib on a marketing PR. Either wire it into the middleware or drop it from this PR.

[P2] components/SignInModal.tsx is now orphaned, and its replacement is a near-copyfrontend/src/components/SignInModal.tsx

After this PR the only SignInModal import in the tree is (public)/page.tsx:14@/components/marketing/SignInModal. The original components/SignInModal.tsx (347 lines) has zero importers but is still in the tree; the new components/marketing/SignInModal.tsx is 352 lines and diff between the two is 67 lines. Two copies of the OAuth modal, one of them dead, will drift — and the dead one is the one that still gets fed errorCode in nobody's code path. The Engineering Style Guide §3 calls this out by name as the reason components/ui/ exists ("before they landed the app had 5 distinct button shapes and 18+ pill/badge implementations"). Delete the orphan or make the marketing one a thin wrapper.

[P2] A new eslint suppression was added for code introduced in this PRfrontend/eslint-suppressions.json

"src/components/marketing/SignInModal.tsx": {
"react-hooks/set-state-in-effect": { "count": 1 }
},

Style Guide §7 states the ratchet's purpose as "don't fix the whole backlog to land a change, but never add to it." The baseline exists to grandfather legacy violations; marketing/SignInModal.tsx is created by this PR, so this is a new violation being suppressed rather than fixed. set-state-in-effect is also usually a real render-loop smell worth a look rather than a waiver.

[P2] .landing-page no longer exists, leaving dead CSS and a comment that now says the opposite of the truthfrontend/src/app/(public)/layout.tsx:7

// The landing page keeps its own .landing-page class for the mesh-background visual.exportdefaultfunctionPublicLayout({ children }: {children: React.ReactNode}){return<divclassName="public-surface">{children}</div>;}

No element in the tree carries className="landing-page" at this HEAD — the only remaining mentions are three comments. But globals.css:866-874 still ships the whole mesh block that depends on it:

.landing-page .sapling-mesh-blob { position: absolute; border-radius:50%; filter:blur(100px); … }
.landing-page .sapling-mesh-blob--1 { top:-10%; left:-10%; … }

along with the other .landing-page-prefixed rules the diff kept. All unreachable. Either the wrapper should still apply the class (if the mesh backdrop is meant to render) or the rules and the comment should go.

P3

[P3] 1.3 MB of unoptimized PNGs committedfrontend/public/

journal-founding.png is 821,816 bytes and journal-ai-homework.png is 500,189 bytes. They are rendered through next/image with a sizes hint (Journal.tsx:110-114), so delivery is fine, but the repo and the OpenNext asset bundle both carry the full-size originals. Worth running them through a compressor or shipping WebP/AVIF sources.

What's good

  • The scope discipline is real and verifiable: no backend files, no migrations, no middleware.ts, no wrangler.toml, and nothing deleted — exactly what the description promises, and a much better call than merging the 217-commit branch with its colliding 0032_*/0033_* migrations.
  • The Layer-2 token boundary is set up correctly — .public-surface scoping in (public)/layout.tsx mirroring (shell), with the literal fallbacks on .card--hero and a comment explaining exactly why they are there. That is the right structure; the components just need to start using it.
  • "use client" is correct on every new hook-using component, and the deployGuard additions are genuinely well-tested (131 lines of focused unit tests) even though most of the code they cover is not yet wired up.

Verdict: request changes — the auth-error regression and the staging-metadata bug are both user-visible, and the branch needs to be reconciled with the v5 landing already on main before it can be promoted.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…PLOY_ENV
CI was red at the LINT step on both jobs, so tsc, pytest and vitest never
ran on this port at all.
- backend/models/gradescope.py: drop the unused pydantic Field import (F401).
- settings/GradescopeConnect.tsx: stabilise refreshAll with useCallback and
list it in the mount effect (exhaustive-deps is an error here). Keying on
userId also fixes the stale-account bug the empty dep array hid.
- (public)/page.tsx: read the ?error= code middleware.ts redirects with and
thread it into the modal, opening it when a code is present. Read through
useSyncExternalStore so the page stays statically prerenderable and no
setState lands in an effect; dismissing the modal strips the param.
- page.tsx: openGal indices now come from galIndexOf('quiz'/'tutor') — the
'Ask the tutor' button opened the notetaker panel. BetaModal onClose now
calls resetSubscribeError so a failed subscribe stops leaking into Journal.
- marketing/SignInModal.tsx: fix set-state-in-effect at source (reset during
render on the open prop change) and remove the suppression entry it was
given. OAuth now uses the frontend-relative /api/auth/google for both the
popup and the same-tab fallback, so the session cookie is not third-party.
- delete the orphaned components/SignInModal.tsx (zero importers) and point
the eslint data-testid rule at its replacement.
- deployGuard.classify: normalise trailing slashes on URL fields (not on
cookieDomain) so a valid slashed API/site URL cannot silently disarm the
guard. Five regression tests, all of which fail without the fix.
- middleware.ts: wire resolveFrontendEnv + detectHostConfigMismatch in, so a
host/backend mismatch reports env_misconfig instead of looping the user
through session_expired forever.
- wrangler.toml/package.json: set DEPLOY_ENV per environment so staging stops
advertising production canonical/OG URLs and the build-time guard is armed.
- globals.css/(public)/layout.tsx: delete the ~950 lines of .landing-page CSS
the replaced page made unreachable, fix the inverted comment, unquote the
single-identifier Spectral, and scope a keyframes-name-pattern disable to
the ported region only.
The a11y fixes in this PR all defend behaviour that is invisible on screen
and in review: a gallery card that only opens on click, a navbar that stays
clickable while transparent, an accordion whose closed answers remain in the
tab order, a role="radiogroup" containing no radios.
Two lab-demo state bugs get the same treatment — a reply naming a different
partner than the typing indicator, and a footer counting an empty-result
sentinel as "1 concepts" — plus the galIndexOf contract that replaced the
hard-coded GAL indices.
Renamed from the scratch file that commit b6c3a8a swept in mid-edit.
- news: card CTA said 'Read article' while every card links to /#newsletter;
it now says 'Get notified' and the Link carries a matching aria-label. The
category listbox closes on Escape and returns focus to its trigger (also on
option select) — dismissing it used to drop focus to <body>.
- about: the copy sent readers to 'a feedback button in the navbar' that
CompanionShell does not render; it now links the GitHub issue tracker.
- team/wiki: add the alternates.canonical their siblings already had.
- careers/[slug]: only append the meta-description ellipsis when slice(0,155)
actually truncated, so short blurbs stop reading as cut off.
- wiki: move scrollMarginTop from the H2 to the <section> elements — the TOC
fragments target the sections, so anchors landed under the sticky header.
Drop cssColor(): companionContent.ts now stores bare hex.
- faq: unique panel ids + aria-controls, and 'visibility' in the collapsed
style so collapsed answers are neither focusable nor announced.
- careers/CareersList: same collapsed-panel fix, so 'Apply for this role' is
not tabbable while closed; both raw <img> replaced with next/image.
- CompanionShell: wrap the header link group and the footer link row in
labelled <nav> landmarks. The compact-nav media query was scoped to
.landing-dc, which this shell must never wear, so below 1180px the
overflowing tab row stayed and the disclosure stayed hidden — unscoped it.
- page.tsx: read ?signin=1 too. CompanionShell's header 'Sign In' links to
/?signin=1 and nothing read it, so that control did nothing at all.
- public/journal-*.png: 1.29 MB -> 432 KB (-67.8% / -64.6%) via libimagequant
256-colour quantisation with Floyd-Steinberg dither (same engine pngquant
uses; RMSE 2.0 and 2.8 of 255). A fully lossless re-encode was tried first
and was larger than the originals. Filenames and paths unchanged.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Blocker

  • Both CI jobs died at the lint step, before tsc / pytest / vitest ever ran — so none of this port had been typechecked or tested. Both base failures fixed properly: the unused pydantic.Field import, and GradescopeConnect.tsx's exhaustive-deps (via useCallback keyed on userId, which also fixes a real bug the empty dep array hid — switching user kept the previous account's state).

Major

  • ?error=session_expired never reached the modal.middleware.ts sets it and marketing/SignInModal still shipped the whole ERROR_COPY surface, all dead. Now read and threaded through, opening the modal on an inbound code and stripping the param on dismiss.
  • Staging advertised production canonical/OG URLsresolveSiteUrl fell through to prod because [env.staging.vars] set neither knob. DEPLOY_ENV = "staging" added, which also arms the existing build-time guard.
  • deployGuard.classify now normalises trailing slashes, so a valid https://api.saplinglearn.com/ no longer bypasses the runtime guard; detectHostConfigMismatch is wired into middleware.ts so it actually defends something.
  • The orphaned components/SignInModal.tsx (zero importers, 67-line drift from its replacement) is deleted.

CodeRabbit majors

onLearn opened the notetaker instead of the tutor (now via the new galIndexOf lookup, not a magic index) · OAuth URLs kept same-origin · DARK_SECTIONS reduced to the one genuinely dark section · Escape now exits explore mode (was a keyboard trap with body { overflow: hidden }) · sim.destroy() re-homes clusters so a StrictMode remount no longer kills the drag field permanently · hero-canvas unmount race · CardsDemo timer leak, global key capture and keyboard-inoperable flip · Gallery cards keyboard-openable · BetaModal single-column below the breakpoint + type="email" · hidden nav/pills removed from the tab order.

Minor / nits

Dead .landing-page CSS removed · stylelint font-family-name-quotes 5→0 and keyframes-name-pattern 42→0 · lab-demo correctness (clamped percentages, partner snapshot, concept count, invalid guide picks) · ARIA roles and labels across FAQ/careers/calendar/quiz/rooms · localStorage input validated · journal PNGs recompressed 1.32 MB → 442 KB.

Still open (deliberately not done)

This branch is a stale snapshot of a landing already merged on main (#524) — promoting it is an add/add conflict across ~50 files, and startHeroRig's third parameter changed meaning while keeping an identical () => number type, so a file-by-file conflict resolution compiles cleanly and silently breaks the hero. That is a merge-strategy decision, not a code fix.

Verificationruff check . clean · tsc clean · eslint 0 errors (base had 1) · 184 frontend tests pass

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

This branch now carries a backend change, via #565

staging picked up the Gradescope repair in #565, so this PR is no longer frontend-only. That fix restored routes/gradescope.py and services/gradescope_service.py from main because the version on staging was unreachable (router prefix declared twice → every endpoint served at /api/gradescope/api/gradescope/...), called a DB client that does not exist (.eq().execute() on a PostgREST helper), and had no rate limiting or bu-sso.

I checked statically that every table the restored route touches exists on staging — gradescope_credentials, gradescope_course_links, assignments, gradebook_categories — so there is no missing-table hazard. But that is a check of the repo's migration files, not of the staging database's actual ledger. Please confirm on the real database:

SELECT table_name FROMinformation_schema.tablesWHERE table_schema ='public'AND table_name IN ('gradescope_credentials','gradescope_course_links','gradescope_links');

All three should be present. Then exercise one endpoint end to end and confirm it resolves at a single/api/gradescope/... prefix rather than 404ing.

Two things #565 deliberately left behind

SELECTcount(*) FROM gradescope_links;

gradescope_links (created by staging's 0033_add_gradescope_links.sql) now has no reader — the restored route uses gradescope_course_links. services/gradescope_sync_service.py has zero importers, and models/gradescope.py is imported only by that dead module. Nothing breaks, but if the table holds rows they are stranded, and the two modules are dead code worth removing in a follow-up.

Ledger divergence between staging and main

Staging carries two migrations main does not, at numeric prefixes main reuses for different files:

prefixstagingmain
00320032_add_gradescope_credentials.sql0032_rooms_missing_columns.sql, 0032_retire_summer_2026.sql
00330033_add_gradescope_links.sql0033_offering_section_not_null.sql, 0033_realtime_publish_room_messages.sql

schema_migrations keys on the full basename, so these coexist without a primary-key collision — but main has 23 migrations staging lacks, and staging is 239 commits behind. Whenever these branches are reconciled, both migration sets apply and the relative ordering within 0032/0033 is alphabetical by accident rather than by design. Worth settling deliberately rather than discovering it during a promotion.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

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.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, '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 the v5 marketing landing onto staging by Darkest-Teddy · Pull Request #535 · SaplingLearn/Sapling · GitHub
Skip to content

feat(landing): port the v5 marketing landing onto staging - #535

Open
Darkest-Teddy wants to merge 5 commits into
stagingfrom
feat/landing-v5-on-staging
Open

feat(landing): port the v5 marketing landing onto staging#535
Darkest-Teddy wants to merge 5 commits into
stagingfrom
feat/landing-v5-on-staging

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Puts the v5 marketing landing on stagingwithout merging feat/landing-v5-port.

Why not just merge the v5 branch

feat/landing-v5-port is 217 commits ahead of origin/staging and the diff is mostly not the landing page — 179 backend test files, 31 agents, 27 services, 17 routes, and 15 new migrations, including two colliding 0032_* and two colliding 0033_* basenames. It also isn't a fast-forward: staging has one commit that branch lacks (82787ca, gradescope). None of that is needed to show the new landing, so this ports the public surface only — no backend changes, no migrations.

What's in here

  • components/landing-v5/** and lib/landing/** — the landing engine, hero3d, drag simulation, and the Act sections
  • The (public) route group: the new /, plus /team, /wiki, /gallery, /news, /faq — the v5 navbar links to these and staging didn't have them
  • components/marketing/** and components/companion/CompanionShell
  • globals.css and app/layout.tsx (OG/metadata), lib/deployGuard.tswith its matching test, lib/errorMessage.ts
  • api.ts gains only subscribeToNewsletter — staging's backend already mounts /api/newsletter

One deliberate divergence from upstream

marketing/SignInModal calls the 3-argsetActiveUser. Upstream passes a 4th { persist } option that exists only on that branch's UserContext, whose wider change (+127/−21) also adds app-wide session recovery and shell-route redirects. Changing auth behaviour for every screen is out of scope for a landing port, so the option is dropped here and should come back in the same commit that brings that UserContext over. The call site carries the same note.

eslint-suppressions.json gains an entry for the new components/marketing/SignInModal.tsx path; staging only suppressed that rule at the old components/SignInModal.tsx path.

Verification

  • tsc --noEmit — clean
  • next build with staging's build vars (DEPLOY_ENV/BACKEND_URL/NEXT_PUBLIC_API_URL/COOKIE_DOMAIN) — succeeds, emitting /team, /wiki, /gallery, /news, /faq
  • eslint — clean except one pre-existing error: react-hooks/exhaustive-deps in settings/GradescopeConnect.tsx:65, which arrives untouched from origin/staging (it is neither modified nor suppressed on that branch). CI will likely go red on it until it's fixed separately — deliberately not touched here.
  • vitest was not run locally — it needs Node 22 and this machine is on 20. CI runs it on Node 22.

Deploy note

Per wrangler.toml and ADR 0020, the staging Workers Build must set DEPLOY_ENV=staging as a build variable, not just a runtime var — the /api rewrite and NEXT_PUBLIC_* are baked at build time. That omission is what previously put prod config on the staging worker.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added public About, Team, Wiki, Gallery, News, FAQ, and Careers pages.
    • Introduced interactive galleries, article search and filtering, FAQ accordions, career listings, and rich knowledge-base content.
    • Redesigned the landing experience with interactive learning demonstrations, knowledge graphs, tutor modes, quizzes, flashcards, study tools, and beta signup.
    • Added Google sign-in and newsletter subscription flows.
  • Improvements

    • Standardized navigation, page layouts, accessibility, responsive styling, animations, and site metadata.
    • Improved error messaging and deployment-aware canonical links.

Brings the v5 public site to `staging` WITHOUT the rest of
`feat/landing-v5-port`: that branch is 217 commits ahead of staging and
carries backend routes, services, agents and 15 migrations (including two
colliding 0032_* and two 0033_* basenames). None of that is needed to show
the new landing, so this ports the public surface only.
What's here:
- components/landing-v5/** + lib/landing/** (the landing engine, hero3d,
drag simulation and the Act sections)
- the (public) route group: the new / plus /team, /wiki, /gallery, /news
and /faq, which the v5 navbar links to and staging did not have
- components/marketing/** and components/companion/CompanionShell
- globals.css and app/layout.tsx (OG/metadata), lib/deployGuard.ts with
its matching test, lib/errorMessage.ts
- api.ts gains only `subscribeToNewsletter`; staging's backend already
mounts /api/newsletter
One deliberate divergence from upstream: marketing/SignInModal calls the
3-arg `setActiveUser`. Upstream passes a 4th `{ persist }` option that only
exists on that branch's UserContext, whose wider change also adds app-wide
session recovery and shell-route redirects. Changing auth for every screen
is out of scope for a landing port, so the option is dropped here and should
return with that UserContext. The call site carries the same note.
eslint-suppressions.json gains an entry for the new
components/marketing/SignInModal.tsx path; staging only suppressed the rule
at the old components/SignInModal.tsx path.
Verified: tsc --noEmit clean, and `next build` succeeds with staging's build
vars (DEPLOY_ENV/BACKEND_URL/NEXT_PUBLIC_API_URL/COOKIE_DOMAIN), emitting
/team, /wiki, /gallery, /news and /faq. eslint is clean except for a
pre-existing react-hooks/exhaustive-deps error in
settings/GradescopeConnect.tsx, which arrives untouched from origin/staging.
vitest was not run locally (needs Node 22; this machine is on 20) - CI runs it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 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 ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:37 seconds

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 101dbfe3-a2c2-4287-842f-6e2c86a82b2e

📥 Commits

Reviewing files that changed from the base of the PR and between 395588d and 46f7930.

⛔ Files ignored due to path filters (2)
  • frontend/public/journal-ai-homework.png is excluded by !**/*.png
  • frontend/public/journal-founding.png is excluded by !**/*.png
📒 Files selected for processing (57)
  • docs/frontend-testids.md
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/package.json
  • frontend/src/app/(public)/about/page.tsx
  • frontend/src/app/(public)/careers/CareersList.tsx
  • frontend/src/app/(public)/careers/[slug]/page.tsx
  • frontend/src/app/(public)/faq/page.tsx
  • frontend/src/app/(public)/layout.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/ActGraph.tsx
  • frontend/src/components/landing-v5/ActIngest.tsx
  • frontend/src/components/landing-v5/ActTutor.tsx
  • frontend/src/components/landing-v5/BetaModal.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/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/landing-v5-interaction.test.tsx
  • frontend/src/components/landing-v5/navTheme.ts
  • frontend/src/components/landing/useLanding.ts
  • frontend/src/components/marketing/HeroCard.test.tsx
  • frontend/src/components/marketing/SignInModal.tsx
  • frontend/src/lib/deployGuard.test.ts
  • frontend/src/lib/deployGuard.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/dom.ts
  • frontend/src/lib/landing/engine/flip.ts
  • frontend/src/lib/landing/engine/index.ts
  • frontend/src/lib/landing/engine/marquee.ts
  • frontend/src/lib/landing/engine/plant.ts
  • frontend/src/lib/landing/engine/sim.test.ts
  • frontend/src/lib/landing/engine/sim.ts
  • frontend/src/middleware.ts
  • frontend/wrangler.toml
📝 Walkthrough

Walkthrough

The PR replaces the landing page with Landing v5, adds companion and careers pages, introduces shared public layouts and content, expands deployment metadata, and adds OAuth, newsletter, error-handling, CSS, and interaction infrastructure.

Changes

Landing v5 experience

Layer / File(s)Summary
Landing contracts and content
frontend/src/lib/landing/*
Adds typed landing content, course graphs, drag clusters, lab fixtures, color utilities, and navigation themes.
Landing sections and demos
frontend/src/components/landing-v5/*
Adds the v5 hero, graph, ingest, tutor, gallery, FAQ, journal, CTA, navigation, modal, and feature-lab components.
Landing runtime and composition
frontend/src/components/landing/useLanding.ts, frontend/src/lib/landing/engine/*, frontend/src/app/(public)/page.tsx
Connects React state to scroll, canvas, WebGL, graph, marquee, FLIP, planting, newsletter, and drag-field behavior.
Landing styles and tests
frontend/src/app/globals.css, frontend/src/components/landing-v5/*.test.tsx, frontend/src/lib/landing/engine/sim.test.ts
Adds responsive landing layouts, finite animations, and interaction regression coverage.

Companion and public pages

Layer / File(s)Summary
Shared companion shell and content
frontend/src/components/companion/CompanionShell.tsx, frontend/src/lib/landing/companionContent.ts
Adds shared navigation, footer links, and typed content collections.
Companion pages
frontend/src/app/(public)/about/*, team/*, faq/*, gallery/*, news/*, wiki/*
Adds shared-shell pages with data-driven content, filters, accordions, navigation, and metadata.
Careers routes
frontend/src/app/(public)/careers/*
Moves interactive job listings into CareersList, adds route metadata, and returns not-found responses for invalid slugs.

Platform and marketing infrastructure

Layer / File(s)Summary
Deployment and metadata
frontend/src/lib/deployGuard.ts, frontend/src/lib/deployGuard.test.ts, frontend/src/app/layout.tsx, frontend/src/app/(public)/privacy/page.tsx, frontend/src/app/(public)/terms/page.tsx
Adds environment resolution, canonical site URLs, host mismatch detection, structured application metadata, and legal-page metadata.
Authentication, newsletter, and marketing UI
frontend/src/components/marketing/SignInModal.tsx, frontend/src/components/marketing/HeroCard.tsx, frontend/src/lib/api.ts, frontend/src/lib/errorMessage.ts
Adds OAuth popup handling, accessible modal behavior, reusable hero-card rendering, newsletter subscription requests, and defensive error normalization.

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

Sequence Diagram(s)

sequenceDiagram
participant Visitor
participant LandingPage
participant useLanding
participant LandingEngine
participant CanvasAndWebGL
Visitor->>LandingPage: open landing page
LandingPage->>useLanding: initialize refs and state
useLanding->>LandingEngine: start landing lifecycle
LandingEngine->>CanvasAndWebGL: render hero and graph visuals
Visitor->>LandingPage: explore graph or submit newsletter
LandingPage->>useLanding: dispatch interaction
useLanding->>LandingEngine: update interaction state
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 26.42% 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 main change: porting the v5 marketing landing to staging.
Description check✅ PassedThe description covers scope, rationale, changes, testing limits, and deployment requirements, with only minor template omissions.
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 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/landing-v5-on-staging

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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 12, 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-staging46f7930Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:19 PM

@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: 11

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/lib/deployGuard.ts (1)

39-46: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Normalize trailing slashes before environment classification.

A canonical API URL with a trailing slash classifies as unknown. Then detectHostConfigMismatch returns null for a staging host wired to https://api.saplinglearn.com/. This bypasses the runtime guard for a valid URL spelling.

Normalize URL fields before comparison. Add regression tests for trailing-slash API and site URLs.

Proposed fix
 function classify(
value: string | undefined,
field: 'apiUrl' | 'cookieDomain' | 'siteUrl',
): FrontendEnv | null {
- const v = (value ?? '').trim().toLowerCase();+ const v = (value ?? '')+ .trim()+ .toLowerCase()+ .replace(field === 'cookieDomain' ? /$/ : /\/+$/, '');
if (!v) return null;
for (const env of Object.keys(FRONTEND_ENVS) as FrontendEnv[]) {
if (FRONTEND_ENVS[env][field].toLowerCase() === v) return env;
🤖 Prompt for AI Agents
Verify each 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/deployGuard.ts` around lines 39 - 46, Update classify to
normalize URL values by removing trailing slashes before comparing against
FRONTEND_ENVS fields, while preserving existing trimming and case normalization.
Ensure both API and site URL classifications recognize trailing-slash variants,
and add regression coverage for trailing-slash API and site URLs.
🟡 Minor comments (24)
frontend/src/app/(public)/news/page.tsx-175-199 (1)

175-199: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The card link text does not match its destination.

Each card is a <Link href="/#newsletter"> and its call to action reads "Read article →". A reader who selects a headline arrives at the newsletter signup on the home page, not at the post. All six cards lead to the same anchor.

Change the call to action to state the real destination, for example "Get notified →", until article 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/app/`(public)/news/page.tsx around lines 175 - 199, The news
cards’ CTA text incorrectly promises article navigation while each Link in the
posts.map block targets /#newsletter. Update the CTA span text from “Read
article →” to wording that reflects newsletter signup, such as “Get notified →”,
while preserving the existing link destination and styling.
frontend/src/app/(public)/about/page.tsx-80-84 (1)

80-84: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update the copy that points to a feedback button in the navbar.

The paragraph tells the reader to use "a feedback button in the navbar". The ported chrome in frontend/src/components/companion/CompanionShell.tsx renders Home, About, Team, Wiki, Gallery, News, FAQ, GitHub, Ko-fi, Sign In, and Get Started. It has no feedback control. Point the reader at a control that exists, for example the GitHub link or a contact address.

🤖 Prompt for AI Agents
Verify each 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)/about/page.tsx around lines 80 - 84, Update the
paragraph near the prose content to remove the nonexistent navbar
feedback-button reference, and direct readers to an available control such as
the GitHub link or a contact address instead. Preserve the surrounding message
about reporting issues and sharing ideas.
frontend/src/app/(public)/team/page.tsx-18-22 (1)

18-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Two companion routes omit alternates.canonical. The port sets a canonical URL on frontend/src/app/(public)/about/page.tsx, frontend/src/app/(public)/careers/page.tsx, and frontend/src/app/(public)/careers/[slug]/page.tsx, but not on these two routes. Without a canonical URL, these pages can be indexed under query-string or alternate-host variants.

  • frontend/src/app/(public)/team/page.tsx#L18-L22: add alternates: { canonical: '/team' } to the metadata export.
  • frontend/src/app/(public)/wiki/page.tsx#L18-L22: add alternates: { canonical: '/wiki' } to the metadata export.
🤖 Prompt for AI Agents
Verify each 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)/team/page.tsx around lines 18 - 22, Update the
metadata exports in frontend/src/app/(public)/team/page.tsx lines 18-22 and
frontend/src/app/(public)/wiki/page.tsx lines 18-22 to include
alternates.canonical, using /team for the team route and /wiki for the wiki
route.
frontend/src/app/(public)/careers/[slug]/page.tsx-18-22 (1)

18-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The meta description appends an ellipsis even when it truncates nothing.

Line 20 always appends after job.description.slice(0, 155). If a description is shorter than 155 characters, the full sentence still ends with a truncation marker. Search results then show a complete sentence that looks cut off.

Append the ellipsis only when the slice removed text.

🐛 Proposed fix
+ const blurb =+ job.description.length > 155 ? `${job.description.slice(0, 155)}…` : job.description;
return {
title: `${job.title} — Careers`,
- description: `${job.department} · ${job.location} · ${job.type}. ${job.description.slice(0, 155)}…`,+ description: `${job.department} · ${job.location} · ${job.type}. ${blurb}`,
alternates: { canonical: `/careers/${job.slug}` },
};
🤖 Prompt for AI Agents
Verify each 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)/careers/[slug]/page.tsx around lines 18 - 22,
Update the metadata construction in the careers page’s generateMetadata flow so
the description appends “…” only when job.description exceeds 155 characters;
leave shorter or exactly 155-character descriptions without a truncation marker
while preserving the existing 155-character slice.
frontend/src/app/(public)/wiki/page.tsx-28-31 (1)

28-31: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

scrollMarginTop is applied to the wrong element.

The H2 style sets scrollMarginTop: 84. The TOC links on line 67 target the fragments #graph, #mastery, #review, #tutor, #ingestion, #grades, and #privacy. Those ids are on the <section> elements, not on the <h2> elements. The browser applies scroll margin to the element it scrolls into view, so the section top lands under the sticky 92px header from CompanionShell and the heading is hidden.

Move the offset to the sections.

🐛 Proposed fix
 const H2: React.CSSProperties = {
margin: 0, fontFamily: DISPLAY, fontWeight: 500, fontSize: 26,
- lineHeight: 1.2, letterSpacing: '-0.015em', scrollMarginTop: 84,+ lineHeight: 1.2, letterSpacing: '-0.015em',
};
+/** Clears the sticky companion header after an in-page anchor jump. */+const SECTION: React.CSSProperties = { scrollMarginTop: 104 };

Then apply it to each section, for example:

- <section id="graph">+ <section id="graph" style={SECTION}>
🤖 Prompt for AI Agents
Verify each 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 28 - 31, Move
scrollMarginTop: 84 from the H2 style to the section styling used by the
sections targeted by the TOC fragment links (`#graph`, `#mastery`, `#review`, `#tutor`,
`#ingestion`, `#grades`, and `#privacy`). Keep the heading typography unchanged and
ensure each target section receives the offset so it clears the sticky header.
frontend/src/components/companion/CompanionShell.tsx-86-108 (1)

86-108: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Apply the responsive nav rules to CompanionShell. The media query is scoped to .landing-dc, but the CompanionShell root does not have that class. The rules do not match, despite using !important, so the compact nav remains hidden and the tab row remains visible below 1180px.

🤖 Prompt for AI Agents
Verify each 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/companion/CompanionShell.tsx` around lines 86 - 108,
Update the CompanionShell root element to include the landing-dc class so the
existing responsive media-query rules apply to its nav-compact and nav-tabs
elements. Preserve the current navigation structure and styling.

Source: Linters/SAST tools

frontend/src/lib/landing/engine/dom.ts-49-73 (1)

49-73: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

A layout measurement taken before layout is ready is cached permanently. Both sites store a measured size on first read and re-measure only when a resize signal arrives. If the first read happens before the element has its final box, the wrong value is kept for the rest of the session. Reject an implausible measurement instead of caching it.

  • frontend/src/lib/landing/engine/dom.ts#L49-L73: do not assign rec.__cv when w or h is 0. invalidateCanvas() runs only on a viewport size change, so a zero-size cache leaves the canvas blank.
  • frontend/src/lib/landing/engine/marquee.ts#L136-L144: do not accept m.setW when the summed child offsetWidth is 0. The re-measure trigger is a wrap.clientWidth change, so a gap-only width leaves the track wrapping over a few pixels.
🤖 Prompt for AI Agents
Verify each 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, Reject
zero-sized measurements before caching them: in
frontend/src/lib/landing/engine/dom.ts lines 49-73, ensure cv does not assign
rec.__cv when w or h is 0; in frontend/src/lib/landing/engine/marquee.ts lines
136-144, ensure the marquee measurement does not call m.setW when the summed
child offsetWidth is 0. Preserve normal caching and measurement behavior for
positive dimensions.
frontend/src/lib/landing/engine/sim.ts-330-369 (1)

330-369: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard the SVG structure assumptions during build.

Three unchecked assumptions read the authored SVG:

  • Line 332: ring.previousElementSibling as SVGCircleElement. If the ring is the first child, glow is null. Line 839 then calls p.glow.setAttribute on every frame and throws a TypeError.
  • Line 333 and 345: label is dereferenced immediately by label.getAttribute('y'). A missing next sibling throws during ensureInit().
  • Line 362-367: near() returns -1 when the cluster has line elements but no [data-sim] rings. nodes[b] is then undefined and line 367 throws.

holdArms() at line 653 and stepGroup() at line 752 already guard with if (!s0 || !t0) continue;, so missing nodes are anticipated later in the pipeline but not at build time. Skip a ring or a line when its expected siblings are absent.

🤖 Prompt for AI Agents
Verify each 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 330 - 369, Update the
SVG build logic in the rings and lines loops to validate required structure
before creating simulation entries: skip rings when the previous glow element or
next label element is missing, and skip lines when either endpoint lookup in
near() returns -1. Keep valid ring and line processing unchanged, ensuring no
nodes or links are created with null siblings or invalid endpoint indices.
frontend/src/lib/landing/engine/marquee.ts-69-114 (1)

69-114: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Bind pointerup and pointercancel on window as well.

setPointerCapture is called inside a try/catch at line 80. If the capture fails, later pointer events are not retargeted to track. A drag that ends outside track then never runs end(). m.drag stays non-null, so update() skips both drift and momentum for that track and the cursor stays grabbing until the next pointerdown.

frontend/src/lib/landing/engine/sim.ts lines 507-510 already binds the release handlers on both the element and window for this reason. Apply the same pattern here.

🛡️ Proposed fix
 track.addEventListener('pointerdown', onDown);
track.addEventListener('pointermove', onMove);
track.addEventListener('pointerup', onUp);
track.addEventListener('pointercancel', onCancel);
+ window.addEventListener('pointerup', onUp);+ window.addEventListener('pointercancel', onCancel);
cleanups.push(() => {
wrap?.removeEventListener('pointerenter', onEnter);
wrap?.removeEventListener('pointerleave', onLeave);
track.removeEventListener('pointerdown', onDown);
track.removeEventListener('pointermove', onMove);
track.removeEventListener('pointerup', onUp);
track.removeEventListener('pointercancel', onCancel);
+ window.removeEventListener('pointerup', onUp);+ window.removeEventListener('pointercancel', onCancel);
STATE.delete(track);
});
🤖 Prompt for AI Agents
Verify each 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 69 - 114, Bind the
existing onUp and onCancel release handlers to window in addition to track,
matching the dual registration pattern used by sim.ts. Keep the current track
listeners and end() behavior unchanged so drags are finalized even when pointer
capture fails or the pointer leaves the track.
frontend/src/components/landing-v5/BetaModal.tsx-244-260 (1)

244-260: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use type="email" for the email field.

The field is labelled "Email address" and validated as an email, but it is declared type="text". That suppresses the email keyboard on mobile and blocks browser autofill. The manual check at lines 86-96 continues to work unchanged.

🔧 Proposed fix
 ref={inputRef}
- type="text"+ type="email"+ autoComplete="email"+ inputMode="email"
aria-label="Email address"
🤖 Prompt for AI Agents
Verify each 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 244 - 260,
Update the email input in BetaModal’s form to use type="email" instead of
type="text", while preserving its existing validation, value handling, and other
attributes.
frontend/src/components/landing/useLanding.ts-593-597 (1)

593-597: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report invalid input instead of returning silently.

If value has no @, the function returns without setting subscribeError and without any other state change. The user presses the button and sees no response. An empty non-required type="email" field passes browser validation and reaches this branch.

🐛 Proposed fix
 const value = email.trim();
- if (!value.includes('@') || subscribing) return;+ if (subscribing) return;+ if (!value.includes('@')) {+ setSubscribeError('Enter a valid email address.');+ return;+ }
🤖 Prompt for AI Agents
Verify each 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 593 - 597, Update
subscribeNow so invalid email input, including an empty trimmed value or a value
without "@", sets subscribeError to the existing user-facing validation message
before returning. Keep the subscribing guard and valid-input flow unchanged,
ensuring the user receives feedback instead of a silent no-op.
frontend/src/components/landing-v5/Navbar.tsx-74-79 (1)

74-79: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The navbar stays interactive while it is invisible.

While heroMounted is false, opacity is 0 but pointerEvents remains 'auto'. For the first ~1.9s of the intro, every navbar control is focusable and clickable while nothing is visible.

♿ Proposed fix
 opacity: exploring ? 0 : heroMounted ? 1 : 0,
- pointerEvents: exploring ? 'none' : 'auto',+ pointerEvents: exploring || !heroMounted ? 'none' : 'auto',+ visibility: heroMounted ? 'visible' : 'hidden',
🤖 Prompt for AI Agents
Verify each 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 74 - 79, Update
the navbar style logic in the visible component so pointerEvents is set to
'none' whenever the navbar is invisible, including while heroMounted is false
and while exploring is true; restore 'auto' only when the navbar is visible and
interactive.
frontend/src/app/(public)/page.tsx-91-100 (1)

91-100: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear the subscription error when the modal closes.

state.subscribeError is shared by BetaModal and Journal and is never reset. After a failed attempt, the message stays visible in the Journal form and reappears the next time the modal opens. useLanding already exposes actions.resetSubscribeError for this, and it is currently unused.

🐛 Proposed fix
- onClose={() => setBetaOpen(false)}+ onClose={() => { actions.resetSubscribeError(); setBetaOpen(false); }}
🤖 Prompt for AI Agents
Verify each 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 91 - 100, Update the
BetaModal onClose handler to call actions.resetSubscribeError before closing the
modal with setBetaOpen(false), ensuring the shared subscription error is cleared
when the modal closes.
frontend/src/app/globals.css-1563-1569 (1)

1563-1569: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stylelint fails on the quoted Spectral family name.

font-family-name-quotes reports four errors: Lines 1565, 1713, 1784, and 1836. Spectral is a single identifier, so it needs no quotes.

🔧 Proposed fix (apply to all four sites)
- font-family: var(--font-spectral), 'Spectral', Georgia, serif;+ font-family: var(--font-spectral), Spectral, Georgia, serif;

As per static analysis hints, font-family-name-quotes is enabled for this file.

Also applies to: 1713-1713, 1784-1784, 1836-1836

🤖 Prompt for AI Agents
Verify each 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 1563 - 1569, Remove the
unnecessary quotes around the single-identifier Spectral font family name in all
four font-family declarations, including the declarations associated with the
visible landing-page style and the other reported sites, so they satisfy
font-family-name-quotes.

Source: Linters/SAST tools

frontend/src/components/landing-v5/lab/RoomsDemo.tsx-42-54 (1)

42-54: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The typing indicator and the delayed reply can name different partners.

send captures partner in the timeout closure. The indicator at Line 127 reads the live partner. If the user changes the select during the 1600 ms delay, the indicator shows the new partner and the message arrives from the old one.

♻️ Proposed fix
 const [partner, setPartner] = useState<Partner>('maya');
+ const partnerRef = useRef<Partner>('maya');+ partnerRef.current = partner;
 typeT.current = setTimeout(() => {
- const reply = `my ${partner.charAt(0).toUpperCase() + partner.slice(1)} graph has eigenvalues green — want me to quiz you on it?`;+ const who = partnerRef.current;+ const reply = `my ${who.charAt(0).toUpperCase() + who.slice(1)} graph has eigenvalues green — want me to quiz you on it?`;
setTyping(false);
- setMsgs((m) => [...m, { who: partner, text: reply, mine: false }]);+ setMsgs((m) => [...m, { who, text: reply, mine: false }]);
}, 1600);

Also applies to: 125-132

🤖 Prompt for AI Agents
Verify each 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/RoomsDemo.tsx` around lines 42 - 54,
Update send so the delayed reply and typing indicator use the same partner
snapshot captured when the message is sent. Store that snapshot in the relevant
state or timeout flow, and ensure the indicator rendering near the partner
display uses it until the delayed reply completes; preserve the existing
behavior when the partner does not change.
frontend/src/app/globals.css-1950-1957 (1)

1950-1957: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stylelint reports 45 keyframes-name-pattern errors in the ported blocks.

The ported keyframe names are camelCase, for example s1Stem, popHold, heroRise, and labIn. The rule expects kebab-case. Renaming is not safe here, because the names are referenced from inline animation strings in the landing components and in labData-driven markup.

Scope a rule disable to the ported region instead, so the rest of the file keeps the check.

🔧 Proposed approach
+/* stylelint-disable keyframes-name-pattern -- names are ported verbatim from the+ design components and are referenced by inline `animation` strings in TSX. */
`@keyframes` s1Stem { 0%,15% { stroke-dashoffset:27; } 34%,100% { stroke-dashoffset:0; } }

Close the block with /* stylelint-enable keyframes-name-pattern */ after the last ported keyframe.

As per static analysis hints, keyframes-name-pattern is enabled for this file.

Also applies to: 2094-2096

🤖 Prompt for AI Agents
Verify each 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 1950 - 1957, Scope-disable
Stylelint’s keyframes-name-pattern rule immediately before the ported keyframe
region containing names such as s1Stem, popHold, heroRise, and labIn, then
re-enable it after the final ported keyframe. Keep the rule active for all other
sections of globals.css and do not rename the animation identifiers.

Source: Linters/SAST tools

frontend/src/lib/landing/course.ts-363-376 (1)

363-376: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle the singular case for degree.

If degree is 1, the text reads "its 1 neighbours". A sub-topic linked only to its parent produces that value.

✏️ Proposed fix
- ' pulls this node plus its ' +- degree +- ' neighbours into context, then retrieves the chunks of your ' ++ ' pulls this node plus its ' ++ degree ++ (degree === 1 ? ' neighbour' : ' neighbours') ++ ' into context, then retrieves the chunks of your ' +
🤖 Prompt for AI Agents
Verify each 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/course.ts` around lines 363 - 376, Update nodeUses
so the text generated for degree uses singular “neighbour” when degree is 1 and
plural “neighbours” otherwise, preserving the existing wording and behavior for
all other values.
frontend/src/components/landing-v5/lab/CardsDemo.tsx-46-53 (1)

46-53: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard the window key listener against editable targets.

The listener runs for every keydown on the window. It calls e.preventDefault() for Space and rates the card for 1, 2, and 3. While this demo is mounted, a Space or a digit typed in any other input on the page is captured.

🛡️ Proposed fix
 const onKey = (e: KeyboardEvent) => {
+ const el = e.target as HTMLElement | null;+ if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) 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 around onKey to ignore events whose
target is an editable control, such as input, textarea, select, or
contenteditable elements, before handling Space or rate keys; preserve the
existing flip and rate behavior for non-editable targets.
frontend/src/components/landing-v5/lab/GradesDemo.tsx-62-65 (1)

62-65: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The row percentage is not clamped, but the weighted grade is.

Line 25 clamps each score to [0, r.possible]. Line 64 does not. If the user types 500 into a row worth 50, the row shows 1000% while the overall grade stays at the clamped value. Use the same clamp for both readouts.

🐛 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,
Update the row percentage calculation in the GB_ROWS.map callback to clamp the
parsed earned score to the range [0, r.possible] before computing the
percentage, matching the existing clamping used for the weighted grade and
keeping both readouts consistent.
frontend/src/components/landing-v5/lab/NotesDemo.tsx-57-66 (1)

57-66: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The "No concepts found" sentinel corrupts the footer count.

Line 63 pushes { name: 'No concepts found', tier: 'unexplored' } into the same concepts array that holds real matches. The footer at Lines 141-143 then reads concepts.length and prints 1 concepts linked to your MA 242 graph. after an empty extraction. The text is wrong, and the plural is wrong for a single match too.

Track the empty result separately instead of encoding it as a concept.

🐛 Proposed fix
- const [concepts, setConcepts] = useState<{ name: string; tier: keyof typeof LAB_TIER }[]>([]);+ const [concepts, setConcepts] = useState<{ name: string; tier: keyof typeof LAB_TIER }[]>([]);+ const [ran, setRan] = useState(false);
 const found = MATCHERS.filter((m) => m.hit.test(lower)).map((m) => ({ name: m.name, tier: m.tier }));
- setConcepts(found.length ? found : [{ name: 'No concepts found', tier: 'unexplored' }]);+ setConcepts(found);+ setRan(true);
setBusy(false);
- {concepts.length- ? `${concepts.length} concepts linked to your MA 242 graph.`- : 'Concepts you extract are linked to your graph, not just tagged on the note.'}+ {concepts.length+ ? `${concepts.length} concept${concepts.length === 1 ? '' : 's'} linked to your MA 242 graph.`+ : ran+ ? 'No concepts found in this note.'+ : 'Concepts you extract are linked to your graph, not just tagged on the note.'}

Also applies to: 140-144

🤖 Prompt for AI Agents
Verify each 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 - 66,
Update extract so an empty MATCHERS result is tracked separately rather than
added to concepts; keep concepts containing only real matches and render the
empty-state message independently. Adjust the footer rendering near the concepts
count to display the correct zero count and use singular/plural wording based on
the actual number of linked concepts.
frontend/src/components/landing-v5/Closing.tsx-154-182 (1)

154-182: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Composite ARIA roles are declared without their required child roles. Both sites put a composite role on a container and leave the children as plain <button> elements. ARIA requires owned children with the matching role, so assistive technology announces an empty widget and the selected item is not conveyed.

  • frontend/src/components/landing-v5/Closing.tsx#L154-L182: add role="menuitem" to each section button inside the role="menu" container.
  • frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124: add role="radio" and aria-checked={isPicked} to each option button inside the role="radiogroup" container.
🤖 Prompt for AI Agents
Verify each 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 154 - 182, Add
role="menuitem" to each section button rendered by SECTIONS.map in Closing.tsx
(frontend/src/components/landing-v5/Closing.tsx#L154-L182). In QuizDemo.tsx
(frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124), add
role="radio" and bind aria-checked to isPicked on each option button within the
radiogroup.
frontend/src/components/landing-v5/lab/CalendarDemo.tsx-86-112 (1)

86-112: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Blank leading cells are focusable empty buttons.

Every cell renders as a <button>, including the LEAD_BLANKS placeholders. The blank cells only set cursor: 'default', which does not remove them from the tab order. A keyboard user tabs through up to six empty buttons before reaching 1 October. The selected day also carries no aria-pressed, so the current selection is not announced.

♿ Proposed change
 <button
key={i}
onClick={() => { if (!c.blank) setDay(c.day as number); }}
type="button"
+ disabled={c.blank}+ aria-hidden={c.blank || undefined}+ aria-pressed={c.blank ? undefined : selected}
🤖 Prompt for AI Agents
Verify each 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/CalendarDemo.tsx` around lines 86 -
112, Update the day-cell buttons rendered by the shown.map callback so blank
placeholder cells are removed from keyboard focus, while retaining interactive
keyboard behavior for real days. Add aria-pressed to each non-blank day button
and set it according to the selected value so assistive technologies announce
the current selection.
frontend/src/components/landing-v5/Faq.tsx-71-76 (1)

71-76: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

"Read the full FAQ" links back to itself.

The anchor targets #faq, and the enclosing <section> already has id="faq" (Line 40). Clicking the link keeps the user on the same section, so the call to action does nothing.

This PR adds the /faq public route, and Closing.tsx Line 81 already links to it. Point this link at /faq.

🔗 Proposed change
- <a href="`#faq`" style={{ color: '`#0C5638`', textDecoration: 'underline' }}>Read the full FAQ</a>+ <Link href="/faq" style={{ color: '`#0C5638`', textDecoration: 'underline' }}>Read the full FAQ</Link>

Add the import:

 import { FAQS } from '`@/lib/landing/content`';
+import Link from 'next/link';
import { DragField } from './DragField';
🤖 Prompt for AI Agents
Verify each 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 71 - 76, Update the
“Read the full FAQ” anchor in the Faq component to navigate to the public /faq
route instead of the local `#faq` section, matching the existing link behavior in
Closing.tsx.
frontend/src/components/landing-v5/lab/GuideDemo.tsx-21-24 (1)

21-24: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Align RECENT.pick with the course exam lists. Neither Midterm 1 · Sep 30 nor Quiz 2 · Oct 03 exists in its course's GUIDES.exams. Opening either recent item sets an invalid select value while the guide heading displays that value.

🤖 Prompt for AI Agents
Verify each 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 in GuideDemo so each pick exactly matches an existing
exam value in the corresponding course’s GUIDES.exams list, preserving the
displayed recent-item behavior and ensuring the select value and guide heading
use valid options.
🤖 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/`(public)/page.tsx:
- Around line 130-131: Update the onLearn handler in the page component to open
the tutor panel at GAL index 7 instead of index 2, while leaving onQuiz
unchanged. Prefer the named GAL lookup or constant from content.ts rather than a
hard-coded index so card ordering changes cannot reintroduce this mismatch.
In `@frontend/src/components/landing-v5/ActTutor.tsx`:
- Around line 92-109: Update the tutor pills container in ActTutor to remove its
buttons from keyboard navigation while hidden by binding the engine’s docked
state to inert (or visibility). Preserve the existing opacity and pointerEvents
behavior, and ensure the pills become focusable again when docked is false.
In `@frontend/src/components/landing-v5/BetaModal.tsx`:
- Around line 132-143: Update the HeroCard responsive layout to use a single
column below the medium breakpoint and the existing two-column structure at
wider widths, using its CSS class rather than an unconditional
gridTemplateColumns style. Enable vertical scrolling for constrained viewports,
and apply the 560px minimum height only at the wider breakpoint so mobile
content, including the email field and submit button, remains accessible.
In `@frontend/src/components/landing-v5/Gallery.tsx`:
- Around line 62-85: Update the Card component so non-ghost cards are
keyboard-focusable and expose button-like semantics, with a key handler that
triggers the existing delegated Track click behavior for Enter and Space. Keep
ghost cards inert by omitting focusability, interactive semantics, and
activation handling, while preserving the current data-tk targeting.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 37-44: Update the rate callback to store its 300 ms timeout in a
ref instead of returning a cleanup function, and clear that timeout in an
unmount cleanup effect. Preserve the existing delayed setIdx behavior while
preventing it from firing after the component unmounts.
In `@frontend/src/components/landing-v5/navTheme.ts`:
- Around line 68-69: Update DARK_SECTIONS to contain only the section that
currently renders on a dark ground, act-graph, and revise its comment to
describe the single dark section accurately. Keep act-ingest and act-tutor out
of the list until their backgrounds are dark.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 218-229: Update the hero canvas effect around the lazy
`import('`@/lib/landing/engine/hero`')` to track whether cleanup has run, prevent
`startHeroCanvas` from starting after unmount, and stop it immediately if
initialization races with cancellation. Preserve the existing cleanup behavior
for normal mounted execution.
- Around line 277-282: Update the onKey handler in useLanding to handle Escape
while explore mode is active: after closing the gallery or jump menu as
appropriate, call exitExplore when s.current.exploring is true and neither
overlay was open. Ensure exitExplore is available at handler execution time
despite its later declaration, using the existing callback ordering or a ref as
needed.
In `@frontend/src/components/marketing/SignInModal.tsx`:
- Around line 130-153: Update the OAuth URL construction in the sign-in flow,
including sameTabUrl and popupUrl, to use the frontend-relative /api/auth/google
path instead of prepending API_URL. Preserve the existing popup_id query
parameter and same-tab fallback behavior so OAuth requests remain same-origin
and retain the session cookie.
In `@frontend/src/lib/landing/content.ts`:
- Around line 191-218: In frontend/src/lib/landing/content.ts lines 191-218,
mark the unavailable journal-founding.png and journal-ai-homework.png entries
with empty src values and isGraph: true so they use the graph fallback until
assets are shipped. In frontend/src/components/landing-v5/Journal.tsx lines
106-118, preserve the meta.isGraph branch that renders GraphThumb instead of
passing unusable paths to next/image.
In `@frontend/src/lib/landing/engine/sim.ts`:
- Around line 897-905: Update destroy() cleanup ordering so every cluster
element is re-parented to its original field before the cleanup that removes the
shell/overlay runs. Preserve the existing cleanup registration in ensureInit(),
but ensure repeated createSim() calls can still find the original
[data-dragnode] elements after destroy().
---
Outside diff comments:
In `@frontend/src/lib/deployGuard.ts`:
- Around line 39-46: Update classify to normalize URL values by removing
trailing slashes before comparing against FRONTEND_ENVS fields, while preserving
existing trimming and case normalization. Ensure both API and site URL
classifications recognize trailing-slash variants, and add regression coverage
for trailing-slash API and site URLs.
---
Minor comments:
In `@frontend/src/app/`(public)/about/page.tsx:
- Around line 80-84: Update the paragraph near the prose content to remove the
nonexistent navbar feedback-button reference, and direct readers to an available
control such as the GitHub link or a contact address instead. Preserve the
surrounding message about reporting issues and sharing ideas.
In `@frontend/src/app/`(public)/careers/[slug]/page.tsx:
- Around line 18-22: Update the metadata construction in the careers page’s
generateMetadata flow so the description appends “…” only when job.description
exceeds 155 characters; leave shorter or exactly 155-character descriptions
without a truncation marker while preserving the existing 155-character slice.
In `@frontend/src/app/`(public)/news/page.tsx:
- Around line 175-199: The news cards’ CTA text incorrectly promises article
navigation while each Link in the posts.map block targets /#newsletter. Update
the CTA span text from “Read article →” to wording that reflects newsletter
signup, such as “Get notified →”, while preserving the existing link destination
and styling.
In `@frontend/src/app/`(public)/page.tsx:
- Around line 91-100: Update the BetaModal onClose handler to call
actions.resetSubscribeError before closing the modal with setBetaOpen(false),
ensuring the shared subscription error is cleared when the modal closes.
In `@frontend/src/app/`(public)/team/page.tsx:
- Around line 18-22: Update the metadata exports in
frontend/src/app/(public)/team/page.tsx lines 18-22 and
frontend/src/app/(public)/wiki/page.tsx lines 18-22 to include
alternates.canonical, using /team for the team route and /wiki for the wiki
route.
In `@frontend/src/app/`(public)/wiki/page.tsx:
- Around line 28-31: Move scrollMarginTop: 84 from the H2 style to the section
styling used by the sections targeted by the TOC fragment links (`#graph`,
`#mastery`, `#review`, `#tutor`, `#ingestion`, `#grades`, and `#privacy`). Keep the heading
typography unchanged and ensure each target section receives the offset so it
clears the sticky header.
In `@frontend/src/app/globals.css`:
- Around line 1563-1569: Remove the unnecessary quotes around the
single-identifier Spectral font family name in all four font-family
declarations, including the declarations associated with the visible
landing-page style and the other reported sites, so they satisfy
font-family-name-quotes.
- Around line 1950-1957: Scope-disable Stylelint’s keyframes-name-pattern rule
immediately before the ported keyframe region containing names such as s1Stem,
popHold, heroRise, and labIn, then re-enable it after the final ported keyframe.
Keep the rule active for all other sections of globals.css and do not rename the
animation identifiers.
In `@frontend/src/components/companion/CompanionShell.tsx`:
- Around line 86-108: Update the CompanionShell root element to include the
landing-dc class so the existing responsive media-query rules apply to its
nav-compact and nav-tabs elements. Preserve the current navigation structure and
styling.
In `@frontend/src/components/landing-v5/BetaModal.tsx`:
- Around line 244-260: Update the email input in BetaModal’s form to use
type="email" instead of type="text", while preserving its existing validation,
value handling, and other attributes.
In `@frontend/src/components/landing-v5/Closing.tsx`:
- Around line 154-182: Add role="menuitem" to each section button rendered by
SECTIONS.map in Closing.tsx
(frontend/src/components/landing-v5/Closing.tsx#L154-L182). In QuizDemo.tsx
(frontend/src/components/landing-v5/lab/QuizDemo.tsx#L104-L124), add
role="radio" and bind aria-checked to isPicked on each option button within the
radiogroup.
In `@frontend/src/components/landing-v5/Faq.tsx`:
- Around line 71-76: Update the “Read the full FAQ” anchor in the Faq component
to navigate to the public /faq route instead of the local `#faq` section, matching
the existing link behavior in Closing.tsx.
In `@frontend/src/components/landing-v5/lab/CalendarDemo.tsx`:
- Around line 86-112: Update the day-cell buttons rendered by the shown.map
callback so blank placeholder cells are removed from keyboard focus, while
retaining interactive keyboard behavior for real days. Add aria-pressed to each
non-blank day button and set it according to the selected value so assistive
technologies announce the current selection.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 46-53: Update the keydown handler in the useEffect around onKey to
ignore events whose target is an editable control, such as input, textarea,
select, or contenteditable elements, before handling Space or rate keys;
preserve the existing flip and rate behavior for non-editable targets.
In `@frontend/src/components/landing-v5/lab/GradesDemo.tsx`:
- Around line 62-65: Update the row percentage calculation in the GB_ROWS.map
callback to clamp the parsed earned score to the range [0, r.possible] before
computing the percentage, matching the existing clamping used for the weighted
grade and keeping both readouts consistent.
In `@frontend/src/components/landing-v5/lab/GuideDemo.tsx`:
- Around line 21-24: Update the RECENT entries in GuideDemo so each pick exactly
matches an existing exam value in the corresponding course’s GUIDES.exams list,
preserving the displayed recent-item behavior and ensuring the select value and
guide heading use valid options.
In `@frontend/src/components/landing-v5/lab/NotesDemo.tsx`:
- Around line 57-66: Update extract so an empty MATCHERS result is tracked
separately rather than added to concepts; keep concepts containing only real
matches and render the empty-state message independently. Adjust the footer
rendering near the concepts count to display the correct zero count and use
singular/plural wording based on the actual number of linked concepts.
In `@frontend/src/components/landing-v5/lab/RoomsDemo.tsx`:
- Around line 42-54: Update send so the delayed reply and typing indicator use
the same partner snapshot captured when the message is sent. Store that snapshot
in the relevant state or timeout flow, and ensure the indicator rendering near
the partner display uses it until the delayed reply completes; preserve the
existing behavior when the partner does not change.
In `@frontend/src/components/landing-v5/Navbar.tsx`:
- Around line 74-79: Update the navbar style logic in the visible component so
pointerEvents is set to 'none' whenever the navbar is invisible, including while
heroMounted is false and while exploring is true; restore 'auto' only when the
navbar is visible and interactive.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 593-597: Update subscribeNow so invalid email input, including an
empty trimmed value or a value without "@", sets subscribeError to the existing
user-facing validation message before returning. Keep the subscribing guard and
valid-input flow unchanged, ensuring the user receives feedback instead of a
silent no-op.
In `@frontend/src/lib/landing/course.ts`:
- Around line 363-376: Update nodeUses so the text generated for degree uses
singular “neighbour” when degree is 1 and plural “neighbours” otherwise,
preserving the existing wording and behavior for all other values.
In `@frontend/src/lib/landing/engine/dom.ts`:
- Around line 49-73: Reject zero-sized measurements before caching them: in
frontend/src/lib/landing/engine/dom.ts lines 49-73, ensure cv does not assign
rec.__cv when w or h is 0; in frontend/src/lib/landing/engine/marquee.ts lines
136-144, ensure the marquee measurement does not call m.setW when the summed
child offsetWidth is 0. Preserve normal caching and measurement behavior for
positive dimensions.
In `@frontend/src/lib/landing/engine/marquee.ts`:
- Around line 69-114: Bind the existing onUp and onCancel release handlers to
window in addition to track, matching the dual registration pattern used by
sim.ts. Keep the current track listeners and end() behavior unchanged so drags
are finalized even when pointer capture fails or the pointer leaves the track.
In `@frontend/src/lib/landing/engine/sim.ts`:
- Around line 330-369: Update the SVG build logic in the rings and lines loops
to validate required structure before creating simulation entries: skip rings
when the previous glow element or next label element is missing, and skip lines
when either endpoint lookup in near() returns -1. Keep valid ring and line
processing unchanged, ensuring no nodes or links are created with null siblings
or invalid endpoint indices.
---
Nitpick comments:
In `@frontend/src/app/`(public)/careers/CareersList.tsx:
- Around line 217-284: Update the collapsible panel style in the job card
rendering to include visibility based on isOpen: visible when expanded and
hidden when collapsed. Add visibility to the existing transition so the state
changes with the panel animation while keeping the Apply for this role link and
other content inaccessible during collapse.
- Around line 51-55: Replace both raw img elements in CareersList with the
next/image component, matching the existing usage in CompanionShell for
/sapling-icon.svg. Preserve the current alt text and styling, and set the second
occurrence’s dimensions to width={20} and height={20} while retaining the first
occurrence’s 26×26 sizing.
In `@frontend/src/app/`(public)/faq/page.tsx:
- Around line 60-89: Update the FAQ answer panel associated with the button
using item.i: assign it a unique id, add matching aria-controls to the button,
and include visibility in the panel styles so it is hidden when collapsed and
visible when open. Preserve the existing max-height and opacity transitions.
In `@frontend/src/app/`(public)/news/page.tsx:
- Around line 111-153: Update the category menu logic around the existing
outside pointerdown listener to handle Escape while the menu is open, close it,
and return focus to the trigger button. Add a ref to the trigger button and
focus it both when Escape dismisses the menu and when selecting an option via
the NEWS_FILTERS buttons, while preserving the existing outside-click behavior.
In `@frontend/src/components/companion/CompanionShell.tsx`:
- Around line 84-141: Wrap the primary link group currently rendered by the
`nav-tabs` container in a `<nav>` landmark with a descriptive `aria-label`,
closing it after the GitHub and Ko-fi links. Also locate the header’s footer
link row and wrap it in a separate appropriately labelled `<nav>` element,
preserving the existing links and layout.
In `@frontend/src/components/landing-v5/ActIngest.tsx`:
- Around line 259-282: Add aria-hidden="true" to the decorative flying chip
elements rendered in the CHIPS.map callback within ActIngest, ensuring assistive
technologies ignore these duplicate labels while leaving their visual animation
and styling unchanged.
In `@frontend/src/components/landing-v5/ActTutor.tsx`:
- Around line 95-97: Add type="button" to the pill button rendered in ActTutor’s
button map alongside its existing key and onClick props, matching the explicit
button type used by the other buttons in this PR.
In `@frontend/src/components/landing-v5/Closing.tsx`:
- Line 92: Replace the hardcoded “© 2026” values in the Closing component with a
single dynamically derived current year, and reuse that value in both copyright
locations near the existing spans.
In `@frontend/src/components/landing-v5/DragField.tsx`:
- Around line 70-103: Update the label branch in the nodes mapping within
DragField’s render flow to always create a text element, including when label is
missing. Preserve the glow, ring, and label ordering and use an empty
placeholder for missing label content while retaining valid label positioning
and styling.
In `@frontend/src/components/landing-v5/Faq.tsx`:
- Around line 84-121: Update the FAQ trigger and answer panel so each button in
the mapped FAQ item has an aria-controls reference matching that panel’s unique
id. Add hidden or equivalent visibility handling to the panel when open is
false, while preserving the existing expanded state and visual transition
behavior.
In `@frontend/src/components/landing-v5/FeatureLab.tsx`:
- Around line 41-50: Add dialog semantics to the fixed overlay in FeatureLab by
assigning role="dialog", aria-modal="true", and an accessible name tied to the
panel heading or another existing label; implement focus movement to the close
button when open and restore focus when closed. In the tool rail, add an
explicit active-state attribute such as aria-current or aria-selected to the
open tool instead of relying on background color alone.
In `@frontend/src/components/landing-v5/Gallery.tsx`:
- Around line 87-109: Add an explicit accessible role to the div rendered by
Track so its existing aria-label is announced by assistive technology; use
role="group" without changing the current card rendering or interaction
behavior.
- Around line 71-73: Remove the redundant frame styling from the wrapper div
rendering GALLERY_MINIS[i] in Gallery: eliminate its duplicated height,
borderRadius, background, border, and overflow/position container behavior as
appropriate, while preserving the mini’s own frame and layout. Keep the
aria-hidden wrapper only if needed without adding a second visual frame.
In `@frontend/src/components/landing-v5/lab/CardsDemo.tsx`:
- Around line 100-115: Update the flip target around the onClick={flip} handler
to be keyboard accessible: prefer a button element, or add role="button",
tabIndex={0}, and an onKeyDown handler that invokes flip for Enter and Space
while preventing the default Space behavior. Preserve the existing click
interaction and visual styling.
In `@frontend/src/components/landing-v5/lab/GradesDemo.tsx`:
- Around line 74-83: Update the onChange sanitizer in the GradesDemo input to
allow at most one decimal point while preserving digits and the existing state
update behavior. Ensure values such as “1.2.3” are normalized so the displayed
text matches the value used by score computation.
In `@frontend/src/components/landing-v5/lab/GuideDemo.tsx`:
- Line 40: Use a consistent lookup style for the GUIDES map in the GuideDemo
component: update the initialization around set and the lookup in the relevant
course iteration around c so both rely on the CourseKey type without an
unnecessary nullish fallback, or both use the same fallback pattern. Preserve
the existing guide-selection behavior while removing the inconsistent
implication that GUIDES[course] may be missing.
In `@frontend/src/components/landing-v5/lab/NotesDemo.tsx`:
- Around line 57-80: Store the timers created by extract and summarize in refs
alongside saveT, clear their handles on unmount, and reset each ref after its
callback runs. Follow the existing timer-ref cleanup pattern in FeatureLabDemo
so callbacks cannot update state after the component unmounts.
In `@frontend/src/components/landing-v5/lab/QuizDemo.tsx`:
- Line 31: Extract the repeated q.options correctness check into a shared
predicate or derived value near the existing right calculation, then reuse it in
both locations. Update submit’s setStreak call to use the functional updater
form based on the previous streak value, preserving the existing
increment-or-reset behavior without reading streak from the render closure.
In `@frontend/src/components/landing-v5/lab/RoomsDemo.tsx`:
- Around line 76-106: Update the message-list container in RoomsDemo so it has
aria-live="polite" and automatically scrolls to the newest message after each
append. Use a ref and an effect tied to the message-list state, preserving the
existing rendering and styling.
In `@frontend/src/components/landing-v5/Navbar.tsx`:
- Around line 199-234: Remove the incomplete ARIA menu pattern from the navbar:
delete role="menu" from the panel and aria-haspopup="menu" from its trigger,
while preserving the existing links and open/close behavior. Expose the panel as
a plain navigation group without adding menuitem roles or keyboard navigation.
In `@frontend/src/components/landing/useLanding.ts`:
- Around line 383-399: Optimize both scanning effects in useLanding: replace the
first effect’s recurring setInterval(scan, 900) polling with a MutationObserver
watching root and invoking scan for relevant DOM additions, while preserving
cleanup and observer behavior. In the second effect around the section
descendant traversal, query only elements marked with the intended animation
attribute (such as [data-anim]) instead of '*', and retain the existing
animation logic for matched elements.
In `@frontend/src/lib/landing/companionContent.ts`:
- Around line 149-160: Update WIKI_TIERS.dot and WIKI_RATINGS.tone to store the
raw hex color values from TIER instead of CSS declaration strings. In the wiki
page consumer, remove the cssColor regex and parsing/fallback logic, and use
t.dot and r.tone directly as colors.
In `@frontend/src/lib/landing/content.ts`:
- Around line 38-135: Document that GAL’s array order is a positional contract
and must not be reordered or have entries inserted without updating callers. Add
and export a galIndexOf helper keyed by GalleryKind, then update the page.tsx
actions.openGal calls to resolve indices through that helper instead of
hard-coded positions.
In `@frontend/src/lib/landing/dragClusters.ts`:
- Line 36: Remove the redundant `as DragCluster[]` assertion from the
`DRAG_CLUSTERS` array literal, preserving its existing `DragCluster[]` type
annotation so excess-property and required-field checks remain enabled.
In `@frontend/src/lib/landing/engine/flip.ts`:
- Around line 24-37: Remove the unused ran field from the FlipState interface
and its initialization in createFlipState, and remove all assignments to st.ran,
including in armFlip and flipClose. Keep the actual animation guard in
useLanding.ts via flipRan unchanged.
In `@frontend/src/lib/landing/engine/plant.ts`:
- Around line 78-91: Update restore() to validate the parsed localStorage value
as an array, retain only string entries, and truncate each label to MAX_LABEL
before calling add() or updating labels. Ensure invalid or non-array data
produces an empty restored label set without partially processing it, while
preserving the seven seed nodes and normal return count.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

Comment threadfrontend/src/app/(public)/page.tsx Outdated
Comment on lines +92 to +109
{/* the engine fades these in once the act's scroll is spent */}
<div data-tutor-pills="1" style={{ display: 'flex', gap: 8, marginTop: 8, opacity: 0, pointerEvents: 'none', transition: 'opacity 400ms ease' }}>
{CAPTIONS.map(([tag], i) => (
<button
key={tag}
onClick={() => onSetMode(i)}
style={{
border: '1px solid rgba(12,86,56,0.3)', borderRadius: 99, padding: '9px 18px',
...MONO, fontSize: 10.5, letterSpacing: '0.12em', cursor: 'pointer',
background: tutorMode === i ? PILL_ON : 'transparent',
color: tutorMode === i ? PILL_ON_FG : '#33443B',
transition: 'all 250ms',
}}
>
{tag}
</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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Find what the engine writes on the tutor pills element.
rg -n -C 6 'tutor-pills|tutorPills' --glob '*.ts' --glob '*.tsx'

Repository: SaplingLearn/Sapling

Length of output: 158


🏁 Script executed:

#!/bin/bashset -e
printf'%s\n''--- candidate files ---'
fd -i 'ActTutor|tutor|landing-v5'.| head -80
printf'%s\n''--- all tutor-pills references ---'
rg -n -C 8 --hidden --glob '!node_modules' --glob '!dist' --glob '!build''tutor-pills|tutorPills|data-tutor'.printf'%s\n''--- ActTutor structure ---'
ACT=$(fd -t f -i 'ActTutor.tsx'.| head -1)if [ -n"$ACT" ];then
ast-grep outline "$ACT"
sed -n '1,180p'"$ACT"fi

Repository: SaplingLearn/Sapling

Length of output: 19055


🏁 Script executed:

#!/bin/bashset -e
printf'%s\n''--- engine writes to M.pills ---'
rg -n -C 10 'M\.pills|pills\.(style|classList)|style\.(opacity|pointerEvents|visibility)|opacity.*pills|pills.*opacity' frontend/src/lib/landing frontend/src/components/landing-v5 --glob '*.ts' --glob '*.tsx'printf'%s\n''--- engine files and relevant structure ---'
fd -t f . frontend/src/lib/landing/engine -x sh -c 'echo --- "$1"; wc -l "$1"; ast-grep outline "$1" | head -80' sh
printf'%s\n''--- direct pills-related engine context ---'
rg -n -C 20 'pills|handover|spent|scroll' frontend/src/lib/landing/engine --glob '*.ts' --glob '*.tsx'

Repository: SaplingLearn/Sapling

Length of output: 50377


Remove hidden pills from the keyboard tab order.

The engine changes only opacity and pointerEvents, so hidden buttons remain focusable. Toggle inert with the engine’s docked state, or toggle visibility there as well.

🤖 Prompt for AI Agents
Verify each 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 92 - 109,
Update the tutor pills container in ActTutor to remove its buttons from keyboard
navigation while hidden by binding the engine’s docked state to inert (or
visibility). Preserve the existing opacity and pointerEvents behavior, and
ensure the pills become focusable again when docked is false.

Comment threadfrontend/src/components/landing-v5/BetaModal.tsx
Comment threadfrontend/src/components/landing-v5/Gallery.tsx
Comment threadfrontend/src/components/landing-v5/lab/CardsDemo.tsx
Comment threadfrontend/src/components/landing/useLanding.ts
Comment threadfrontend/src/components/landing/useLanding.ts
Comment threadfrontend/src/components/marketing/SignInModal.tsx Outdated
Comment threadfrontend/src/lib/landing/content.ts Outdated
Comment on lines +191 to +218
/**
* Presentation metadata paired positionally with POSTS.
*
* NOTE: `journal-founding.png` and `journal-ai-homework.png` are not in
* `public/` yet — both exceed the design API's 256 KiB read cap, so they
* ship with the project export alongside the rest of the source.
*/
export const POST_META: PostMeta[] = [
{
slot: 'journal-1',
src: '/journal-founding.png',
slotHint: 'Drop the founding photo',
isPhoto: true,
isGraph: false,
comments: 12,
likes: 84,
floatDelay: '0s',
},
{
slot: 'journal-2',
src: '/journal-ai-homework.png',
slotHint: 'Drop an image for this essay',
isPhoto: true,
isGraph: false,
comments: 31,
likes: 126,
floatDelay: '-2.5s',
},

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

Two journal images are declared but may not ship.POST_META points at /journal-founding.png and /journal-ai-homework.png, and the file's own comment states both assets are absent from public/. Journal.tsx renders those paths through next/image with no fallback, so two of the three journal cards can show a broken image.

  • frontend/src/lib/landing/content.ts#L191-L218: add the two PNG files to public/, or set src: '' and isGraph: true on those entries until the assets ship.
  • frontend/src/components/landing-v5/Journal.tsx#L106-L118: keep the meta.isGraph branch as the fallback path, so an entry without a usable src renders GraphThumb instead of a broken image.
📍 Affects 2 files
  • frontend/src/lib/landing/content.ts#L191-L218 (this comment)
  • frontend/src/components/landing-v5/Journal.tsx#L106-L118
🤖 Prompt for AI Agents
Verify each 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/content.ts` around lines 191 - 218, In
frontend/src/lib/landing/content.ts lines 191-218, mark the unavailable
journal-founding.png and journal-ai-homework.png entries with empty src values
and isGraph: true so they use the graph fallback until assets are shipped. In
frontend/src/components/landing-v5/Journal.tsx lines 106-118, preserve the
meta.isGraph branch that renders GraphThumb instead of passing unusable paths to
next/image.

Comment threadfrontend/src/lib/landing/engine/sim.ts
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — v5 marketing landing onto staging

This ports the v5 marketing landing onto staging as a public-surface-only change, and the central claim in the description holds up: I verified there are no backend/** files, no migrations, no middleware.ts, no next.config.ts and no wrangler.toml in the diff (74/74 changed files are under frontend/), and git diff --diff-filter=D is empty — nothing is deleted out from under another surface. The Layer-2 token scoping is done properly (.public-surface, .landing-page in globals.css:828, with deliberate literal fallbacks on .card--hero), and every new landing-v5/marketing/companion component carries "use client". The problems are elsewhere: a dropped auth-error path, staging metadata that points at production, a colour system that bypasses the design tokens entirely, and — the big one — this branch is a stale snapshot of a landing that is already merged on main.

Findings

P0

[P0] Both CI jobs are red, and the failures hide the fact that nothing was typechecked or tested.github/workflows/ci.yml:41,90-94

Backend (ruff check ., run 31553863104):

F401 [*] `pydantic.Field` imported but unused
--> models/gradescope.py:12:33
Found 1 error.
##[error]Process completed with exit code 1.

Frontend (npx eslint ., same run):

frontend/src/components/settings/GradescopeConnect.tsx
65:6 error React Hook useEffect has a missing dependency: 'refreshAll'. Either include it or
remove the dependency array react-hooks/exhaustive-deps
✖ 33 problems (1 error, 32 warnings)
##[error]Process completed with exit code 2.

To be clear about attribution: neither is yours. Both reproduce verbatim at the base commit 82787cabackend/models/gradescope.py imports Field with zero Field( uses there, and GradescopeConnect.tsx:65 is already }, []);. gh run list --branch staging shows staging's last green run was 8bce5b02 on 2026-07-10; 82787ca never ran CI, so it landed red. The reason this is still P0 for this PR: the workflow runs ruff before pytest, and eslint before npx tsc --noEmit and npm test. Both jobs die at the lint step, so the type checker and both test suites never executed against these 14,954 added lines. You currently have no signal that the port compiles. Fixing base (or rebasing once it is fixed) is a prerequisite for this PR meaning anything.

P1

[P1] ?error=session_expired no longer reaches the sign-in modalfrontend/src/app/(public)/page.tsx:89

<SignInModalopen={signInOpen}onClose={()=>setSignInOpen(false)}/>

middleware.ts (untouched here) bounces expired sessions to the landing with a query param:

// middleware.ts:38if(errorCode)u.searchParams.set('error',errorCode)// middleware.ts:52 and :67if(!session)returnredirectToSignin(request,'session_expired')

The page this replaces read that param and threaded it through — base (public)/page.tsx:90const err = params.get('error'), then :879errorCode={signInError}. The new page never reads ?error (no searchParams/URLSearchParams anywhere in page.tsx or useLanding.ts), so the prop is never supplied. Meanwhile components/marketing/SignInModal.tsx still ships the whole error surface — ERROR_COPY at :11 including session_expired at :16, and :122const visibleError = localError ?? errorCode ?? null — all now dead. Net effect: a user kicked out mid-session lands on the marketing page with no explanation of why they were signed out. Restore the ?error= read and pass it through.

[P1] The staging deploy will advertise production canonical and OG URLsfrontend/src/app/layout.tsx:43

metadataBase: newURL(resolveSiteUrl(process.env)),alternates: {canonical: '/'},

resolveSiteUrl falls through to production when neither knob is set:

// deployGuard.ts:98-106exportfunctionresolveSiteUrl(env: EnvSource): string{constdeployEnv=(env.DEPLOY_ENV??'').trim().toLowerCase();if(deployEnv&&deployEnvinFRONTEND_ENVS)returnFRONTEND_ENVS[deployEnvasFrontendEnv].siteUrl;constexplicit=(env.NEXT_PUBLIC_SITE_URL??'').trim();if(explicit)returnexplicit.replace(/\/+$/,'');returnFRONTEND_ENVS.production.siteUrl;}

frontend/wrangler.toml is not modified by this PR, and its [env.staging.vars] block sets only NEXT_PUBLIC_API_URL, BACKEND_URL and COOKIE_DOMAINneither DEPLOY_ENV nor NEXT_PUBLIC_SITE_URL. So on frontend-staging every page resolves metadataBase to https://saplinglearn.com, making canonical, og:url and og:image all point at production. Since the entire point of this PR is "show the new landing on staging", any staging link shared for review will unfurl with production's card, and staging self-canonicalises to prod. Set DEPLOY_ENV = "staging" in [env.staging.vars] (which also arms the existing checkFrontendDeployEnv lock in next.config.ts:46).

[P1] The landing runs a parallel colour palette that shares no value with the design tokensfrontend/src/components/landing-v5/*

Counting hex literals across landing-v5 (excluding the galleryMinis illustration art) gives ~900 hardcoded colours. The most frequent:

79 #0c5638 48 #fdfcf9 46 #0e9e5a 40 #12201a
37 #33443b 34 #9aa5a0 31 #8b9891 30 #8fd9a8

Not one of these is a token. The core set in globals.css is --brand-forest: #1B6C42, --brand-forest-bright: #2D8F5C, --sap-500: #4e873c, --ink-0: #faf8f3, --ink-800: #1a1814. Note the near-misses in particular: #fdfcf9 vs --ink-0 #faf8f3, and #12201a vs --ink-800 #1a1814. They are close enough to look like the brand and different enough to be visibly off it at the seam where a user crosses from the landing into the app shell. The Engineering Style Guide §3/§4 is explicit — "inline style objects referencing global CSS-variable tokens … Use the tokens; don't hardcode values." This is doubly odd because the PR itself builds the correct scaffolding: (public)/layout.tsx mounts .public-surface and globals.css:839-841 defines --surface-hero/--brand-glow on it, and then no component uses any of it.

[P1] This is a stale snapshot of a landing already merged on main, and promoting it will fight main — whole landing-v5 tree

origin/main is 4f5a7f9 Merge pull request #524 from SaplingLearn/feat/landing-v5-port — the v5 landing is already there. git merge-base origin/staging origin/main is 57469ad, which contains nofrontend/src/components/landing-v5/, so the directory is added independently on both sides: promoting stagingmain is an add/add conflict across ~50 files, not a fast merge. main has taken 19 commits on the landing since the merge-base, and git diff origin/main 395588da over the landing tree is only 4 files / +9 −38 — i.e. this branch is behind, missing a3d0371 fix(landing): raster, veil and scroll-coupling polish on the v5 hero (the MAG/zoom raster correction in ActTutor.tsx, and the re-tuned hero washes in Hero.tsx).

The dangerous part is that one of those divergences is invisible to the type checker:

// this branch — hero3d/index.ts:52-56 (an intensity multiplier, 0..2)exportfunctionstartHeroRig(canvas,getMouse,getParallax: ()=>number)// origin/main — hero3d/index.ts:76-80 (a scroll offset in pixels)exportfunctionstartHeroRig(canvas,getMouse,getHeroShiftPx: ()=>number)

Both are () => number, and both hero3d/index.ts and useLanding.ts are in the conflict set. Resolving that conflict by taking one file from each side compiles cleanly and silently breaks the hero. Please rebase this branch onto main's landing rather than re-adding an older copy, or state explicitly how the promotion merge will be resolved.

P2

[P2] Three of the four new deployGuard exports have no production callerfrontend/src/lib/deployGuard.ts:52-146

resolveFrontendEnv, expectedEnvForHost and detectHostConfigMismatch are referenced only by deployGuard.test.ts. resolveSiteUrl is the only one wired into anything (layout.tsx:4). detectHostConfigMismatch's own docstring bills it as "Runtime defence-in-depth for what the build-time guard cannot see", citing "the exact failure behind staging's session_expired" — but middleware.ts is not in this diff, so nothing calls it and it defends nothing. That is ~100 lines of inert code plus 131 lines of tests landing in a shared lib on a marketing PR. Either wire it into the middleware or drop it from this PR.

[P2] components/SignInModal.tsx is now orphaned, and its replacement is a near-copyfrontend/src/components/SignInModal.tsx

After this PR the only SignInModal import in the tree is (public)/page.tsx:14@/components/marketing/SignInModal. The original components/SignInModal.tsx (347 lines) has zero importers but is still in the tree; the new components/marketing/SignInModal.tsx is 352 lines and diff between the two is 67 lines. Two copies of the OAuth modal, one of them dead, will drift — and the dead one is the one that still gets fed errorCode in nobody's code path. The Engineering Style Guide §3 calls this out by name as the reason components/ui/ exists ("before they landed the app had 5 distinct button shapes and 18+ pill/badge implementations"). Delete the orphan or make the marketing one a thin wrapper.

[P2] A new eslint suppression was added for code introduced in this PRfrontend/eslint-suppressions.json

"src/components/marketing/SignInModal.tsx": {
"react-hooks/set-state-in-effect": { "count": 1 }
},

Style Guide §7 states the ratchet's purpose as "don't fix the whole backlog to land a change, but never add to it." The baseline exists to grandfather legacy violations; marketing/SignInModal.tsx is created by this PR, so this is a new violation being suppressed rather than fixed. set-state-in-effect is also usually a real render-loop smell worth a look rather than a waiver.

[P2] .landing-page no longer exists, leaving dead CSS and a comment that now says the opposite of the truthfrontend/src/app/(public)/layout.tsx:7

// The landing page keeps its own .landing-page class for the mesh-background visual.exportdefaultfunctionPublicLayout({ children }: {children: React.ReactNode}){return<divclassName="public-surface">{children}</div>;}

No element in the tree carries className="landing-page" at this HEAD — the only remaining mentions are three comments. But globals.css:866-874 still ships the whole mesh block that depends on it:

.landing-page .sapling-mesh-blob { position: absolute; border-radius:50%; filter:blur(100px); … }
.landing-page .sapling-mesh-blob--1 { top:-10%; left:-10%; … }

along with the other .landing-page-prefixed rules the diff kept. All unreachable. Either the wrapper should still apply the class (if the mesh backdrop is meant to render) or the rules and the comment should go.

P3

[P3] 1.3 MB of unoptimized PNGs committedfrontend/public/

journal-founding.png is 821,816 bytes and journal-ai-homework.png is 500,189 bytes. They are rendered through next/image with a sizes hint (Journal.tsx:110-114), so delivery is fine, but the repo and the OpenNext asset bundle both carry the full-size originals. Worth running them through a compressor or shipping WebP/AVIF sources.

What's good

  • The scope discipline is real and verifiable: no backend files, no migrations, no middleware.ts, no wrangler.toml, and nothing deleted — exactly what the description promises, and a much better call than merging the 217-commit branch with its colliding 0032_*/0033_* migrations.
  • The Layer-2 token boundary is set up correctly — .public-surface scoping in (public)/layout.tsx mirroring (shell), with the literal fallbacks on .card--hero and a comment explaining exactly why they are there. That is the right structure; the components just need to start using it.
  • "use client" is correct on every new hook-using component, and the deployGuard additions are genuinely well-tested (131 lines of focused unit tests) even though most of the code they cover is not yet wired up.

Verdict: request changes — the auth-error regression and the staging-metadata bug are both user-visible, and the branch needs to be reconciled with the v5 landing already on main before it can be promoted.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…PLOY_ENV
CI was red at the LINT step on both jobs, so tsc, pytest and vitest never
ran on this port at all.
- backend/models/gradescope.py: drop the unused pydantic Field import (F401).
- settings/GradescopeConnect.tsx: stabilise refreshAll with useCallback and
list it in the mount effect (exhaustive-deps is an error here). Keying on
userId also fixes the stale-account bug the empty dep array hid.
- (public)/page.tsx: read the ?error= code middleware.ts redirects with and
thread it into the modal, opening it when a code is present. Read through
useSyncExternalStore so the page stays statically prerenderable and no
setState lands in an effect; dismissing the modal strips the param.
- page.tsx: openGal indices now come from galIndexOf('quiz'/'tutor') — the
'Ask the tutor' button opened the notetaker panel. BetaModal onClose now
calls resetSubscribeError so a failed subscribe stops leaking into Journal.
- marketing/SignInModal.tsx: fix set-state-in-effect at source (reset during
render on the open prop change) and remove the suppression entry it was
given. OAuth now uses the frontend-relative /api/auth/google for both the
popup and the same-tab fallback, so the session cookie is not third-party.
- delete the orphaned components/SignInModal.tsx (zero importers) and point
the eslint data-testid rule at its replacement.
- deployGuard.classify: normalise trailing slashes on URL fields (not on
cookieDomain) so a valid slashed API/site URL cannot silently disarm the
guard. Five regression tests, all of which fail without the fix.
- middleware.ts: wire resolveFrontendEnv + detectHostConfigMismatch in, so a
host/backend mismatch reports env_misconfig instead of looping the user
through session_expired forever.
- wrangler.toml/package.json: set DEPLOY_ENV per environment so staging stops
advertising production canonical/OG URLs and the build-time guard is armed.
- globals.css/(public)/layout.tsx: delete the ~950 lines of .landing-page CSS
the replaced page made unreachable, fix the inverted comment, unquote the
single-identifier Spectral, and scope a keyframes-name-pattern disable to
the ported region only.
The a11y fixes in this PR all defend behaviour that is invisible on screen
and in review: a gallery card that only opens on click, a navbar that stays
clickable while transparent, an accordion whose closed answers remain in the
tab order, a role="radiogroup" containing no radios.
Two lab-demo state bugs get the same treatment — a reply naming a different
partner than the typing indicator, and a footer counting an empty-result
sentinel as "1 concepts" — plus the galIndexOf contract that replaced the
hard-coded GAL indices.
Renamed from the scratch file that commit b6c3a8a swept in mid-edit.
- news: card CTA said 'Read article' while every card links to /#newsletter;
it now says 'Get notified' and the Link carries a matching aria-label. The
category listbox closes on Escape and returns focus to its trigger (also on
option select) — dismissing it used to drop focus to <body>.
- about: the copy sent readers to 'a feedback button in the navbar' that
CompanionShell does not render; it now links the GitHub issue tracker.
- team/wiki: add the alternates.canonical their siblings already had.
- careers/[slug]: only append the meta-description ellipsis when slice(0,155)
actually truncated, so short blurbs stop reading as cut off.
- wiki: move scrollMarginTop from the H2 to the <section> elements — the TOC
fragments target the sections, so anchors landed under the sticky header.
Drop cssColor(): companionContent.ts now stores bare hex.
- faq: unique panel ids + aria-controls, and 'visibility' in the collapsed
style so collapsed answers are neither focusable nor announced.
- careers/CareersList: same collapsed-panel fix, so 'Apply for this role' is
not tabbable while closed; both raw <img> replaced with next/image.
- CompanionShell: wrap the header link group and the footer link row in
labelled <nav> landmarks. The compact-nav media query was scoped to
.landing-dc, which this shell must never wear, so below 1180px the
overflowing tab row stayed and the disclosure stayed hidden — unscoped it.
- page.tsx: read ?signin=1 too. CompanionShell's header 'Sign In' links to
/?signin=1 and nothing read it, so that control did nothing at all.
- public/journal-*.png: 1.29 MB -> 432 KB (-67.8% / -64.6%) via libimagequant
256-colour quantisation with Floyd-Steinberg dither (same engine pngquant
uses; RMSE 2.0 and 2.8 of 255). A fully lossless re-encode was tried first
and was larger than the originals. Filenames and paths unchanged.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Blocker

  • Both CI jobs died at the lint step, before tsc / pytest / vitest ever ran — so none of this port had been typechecked or tested. Both base failures fixed properly: the unused pydantic.Field import, and GradescopeConnect.tsx's exhaustive-deps (via useCallback keyed on userId, which also fixes a real bug the empty dep array hid — switching user kept the previous account's state).

Major

  • ?error=session_expired never reached the modal.middleware.ts sets it and marketing/SignInModal still shipped the whole ERROR_COPY surface, all dead. Now read and threaded through, opening the modal on an inbound code and stripping the param on dismiss.
  • Staging advertised production canonical/OG URLsresolveSiteUrl fell through to prod because [env.staging.vars] set neither knob. DEPLOY_ENV = "staging" added, which also arms the existing build-time guard.
  • deployGuard.classify now normalises trailing slashes, so a valid https://api.saplinglearn.com/ no longer bypasses the runtime guard; detectHostConfigMismatch is wired into middleware.ts so it actually defends something.
  • The orphaned components/SignInModal.tsx (zero importers, 67-line drift from its replacement) is deleted.

CodeRabbit majors

onLearn opened the notetaker instead of the tutor (now via the new galIndexOf lookup, not a magic index) · OAuth URLs kept same-origin · DARK_SECTIONS reduced to the one genuinely dark section · Escape now exits explore mode (was a keyboard trap with body { overflow: hidden }) · sim.destroy() re-homes clusters so a StrictMode remount no longer kills the drag field permanently · hero-canvas unmount race · CardsDemo timer leak, global key capture and keyboard-inoperable flip · Gallery cards keyboard-openable · BetaModal single-column below the breakpoint + type="email" · hidden nav/pills removed from the tab order.

Minor / nits

Dead .landing-page CSS removed · stylelint font-family-name-quotes 5→0 and keyframes-name-pattern 42→0 · lab-demo correctness (clamped percentages, partner snapshot, concept count, invalid guide picks) · ARIA roles and labels across FAQ/careers/calendar/quiz/rooms · localStorage input validated · journal PNGs recompressed 1.32 MB → 442 KB.

Still open (deliberately not done)

This branch is a stale snapshot of a landing already merged on main (#524) — promoting it is an add/add conflict across ~50 files, and startHeroRig's third parameter changed meaning while keeping an identical () => number type, so a file-by-file conflict resolution compiles cleanly and silently breaks the hero. That is a merge-strategy decision, not a code fix.

Verificationruff check . clean · tsc clean · eslint 0 errors (base had 1) · 184 frontend tests pass

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

This branch now carries a backend change, via #565

staging picked up the Gradescope repair in #565, so this PR is no longer frontend-only. That fix restored routes/gradescope.py and services/gradescope_service.py from main because the version on staging was unreachable (router prefix declared twice → every endpoint served at /api/gradescope/api/gradescope/...), called a DB client that does not exist (.eq().execute() on a PostgREST helper), and had no rate limiting or bu-sso.

I checked statically that every table the restored route touches exists on staging — gradescope_credentials, gradescope_course_links, assignments, gradebook_categories — so there is no missing-table hazard. But that is a check of the repo's migration files, not of the staging database's actual ledger. Please confirm on the real database:

SELECT table_name FROMinformation_schema.tablesWHERE table_schema ='public'AND table_name IN ('gradescope_credentials','gradescope_course_links','gradescope_links');

All three should be present. Then exercise one endpoint end to end and confirm it resolves at a single/api/gradescope/... prefix rather than 404ing.

Two things #565 deliberately left behind

SELECTcount(*) FROM gradescope_links;

gradescope_links (created by staging's 0033_add_gradescope_links.sql) now has no reader — the restored route uses gradescope_course_links. services/gradescope_sync_service.py has zero importers, and models/gradescope.py is imported only by that dead module. Nothing breaks, but if the table holds rows they are stranded, and the two modules are dead code worth removing in a follow-up.

Ledger divergence between staging and main

Staging carries two migrations main does not, at numeric prefixes main reuses for different files:

prefixstagingmain
00320032_add_gradescope_credentials.sql0032_rooms_missing_columns.sql, 0032_retire_summer_2026.sql
00330033_add_gradescope_links.sql0033_offering_section_not_null.sql, 0033_realtime_publish_room_messages.sql

schema_migrations keys on the full basename, so these coexist without a primary-key collision — but main has 23 migrations staging lacks, and staging is 239 commits behind. Whenever these branches are reconciled, both migration sets apply and the relative ordering within 0032/0033 is alphabetical by accident rather than by design. Worth settling deliberately rather than discovering it during a promotion.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

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.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez