Add landing page with liquid glass effects - #4

Merged
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
feature/landing-page-liquid-glass
Mar 18, 2026
Merged

Add landing page with liquid glass effects#4
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
feature/landing-page-liquid-glass

Conversation

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Summary

  • New landing page at / with a marketing-focused design: 3D canvas knowledge graph background, scramble text hero animation, floating glass accent cards, feature cards grid, sticky scroll cinema "How It Works" section, animated stats counter bar, and a multi-step onboarding modal
  • Liquid glass visual system applied to all panel surfaces — frosted translucency with backdrop-filter: blur(), inner specular highlights, hover shimmer sweep animation, and ambient gradient blobs behind sections for depth
  • Dashboard moved from / to /dashboard with Navbar routing updated to hide on the landing page and redirect unauthenticated users appropriately
  • New fonts added: Playfair Display (hero headings), Inter (body), JetBrains Mono (labels/stats)

Files changed

FileChange
frontend/src/app/page.tsxReplaced dashboard with new landing page component
frontend/src/app/dashboard/page.tsxNew file — moved original dashboard here
frontend/src/app/globals.cssAdded landing page animations + liquid glass CSS system
frontend/src/app/layout.tsxAdded Inter, Playfair Display, JetBrains Mono font imports
frontend/src/components/Navbar.tsxHide on landing page, update dashboard link to /dashboard
frontend/package.jsonAdded lucide-react dependency

Test plan

  • Visit http://localhost:3000/ — landing page renders with hero animation, floating cards, and liquid glass effects
  • Scroll through all sections — features, sticky scroll cinema (3 steps transition), stats counter, CTA
  • Click "Get Started" / "Sign In" — onboarding modal opens with multi-step flow
  • Resize browser — responsive layout works on mobile/tablet/desktop
  • Sign in with Google — redirects to /dashboard (original dashboard)
  • Verify /dashboard route still works correctly for authenticated users
  • Check that Navbar is hidden on the landing page but visible on all other routes

Made with Cursor

…hboard
Replace the root route with a new marketing landing page featuring:
- Hero section with 3D canvas knowledge graph, scramble text animation,
and floating glass accent cards
- Feature cards grid with spotlight hover effects and floating animation
- Sticky scroll cinema "How It Works" section (3 steps)
- Stats counter bar with intersection observer triggers
- Onboarding modal with multi-step sign-up/sign-in flow
- Liquid glass visual system (frosted translucency, backdrop blur,
specular shimmer on hover) applied to all panel surfaces
- Ambient gradient blobs behind sections for glass depth
- Responsive design with Playfair Display, Inter, and JetBrains Mono fonts
Move the original dashboard to /dashboard route and update Navbar
routing to hide on the landing page and redirect accordingly.
Made-with: Cursor
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Mar 18, 2026

Copy link
Copy Markdown

Deploying sapling with Cloudflare Pages Cloudflare Pages

Latest commit:fcdb2e0
Status:⚡️ Build in progress...

View logs

…anges
Keep landing page at / (our branch) and update dashboard/page.tsx
with main's latest dashboard code including the color picker feature.
Made-with: Cursor
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 9d2620f into mainMar 18, 2026
1 check was pending
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez deleted the feature/landing-page-liquid-glass branch March 18, 2026 04:44
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request May 5, 2026
refactor(calendar): unify syllabus extraction onto agent (refactor #4)
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request Jun 13, 2026
…ment-notes
fix(security): encrypt syllabus-extracted assignment notes at write boundary (#126#4)
AndresL230 added a commit that referenced this pull request Aug 2, 2026
#344 phase 2) (#513)
* docs(spec): landing page below the hero (#344 phase 2)
Interactive knowledge graph replaces the 340vh scroll-jacked HowItWorks, then
three feature bands and a four-tile bento of real app surfaces.
The structural argument is a density rhythm — the graph is the densest thing on
the page, bands decompress, the bento re-energizes, and a closing band gives the
CTA a run-up rather than ending on a grid tile.
The content argument matters more than the visual one. Tutor chat, Notes,
Gradebook and Flashcards are shipped surfaces the current six-feature list never
mentions, so the page undersells the product more than it under-designs it. That
is the likeliest root of #344's 'feels generic'.
Live LLM generation on the public page was considered and rejected for now: most
convincing option, but it puts an unauthenticated billable endpoint on the
most-crawled page on the site. Recorded as a second pass behind a rate-limited
endpoint.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(plan): landing interactive graph, step 1 (#344)
Seven tasks: fixtures, layout+helix math, component, assembly, interaction,
page wiring with the HowItWorks/catalog deletions, and the e2e journey.
Self-review found one real gap and it is recorded in the plan rather than
papered over: the spec lists drag alongside hover and expand, and Task 5
implements hover and the copy fade only. Dragging needs a pointer-capture and
SVG coordinate-mapping decision (getScreenCTM) that deserves its own review
gate, so it is called out as 5b/5c rather than hidden inside a step.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): course graph fixtures for the interactive demo (#344)
* feat(landing): deterministic radial layout + helical entry path (#344)
* feat(landing): knowledge graph demo — chips and laid-out render (#344)
Static, fully laid-out KnowledgeGraphDemo component: a course-picker
chip row plus an inline SVG render of the selected course's graph via
radialLayout. This is the parked frame reduced-motion visitors and the
E2E lane get; the assembly animation is a later task.
Registers the landing-graph E2E surface (docs/frontend-testids.md +
eslint.config.mjs) and adds the .landing-page-scoped chip/copy CSS.
* feat(landing): helical assembly, parked under reduced motion (#344)
Adds the RAF-driven helical assembly to KnowledgeGraphDemo, extracted into
an AssemblingGraph child keyed by course id so switching courses remounts
(fresh progress state, unmount-driven RAF cleanup) instead of resetting
state inside an effect — avoids the react-hooks/set-state-in-effect
anti-pattern that the brief's literal snippet would have tripped.
Parked (progress=1, full opacity, laid out) whenever IS_TEST_MODE or
prefers-reduced-motion. Adds a guarded window.matchMedia stub to
vitest.setup.ts (jsdom has none) defaulting to reduced-motion=true, so
every KnowledgeGraphDemo unit test asserts the parked/complete frame —
the correct target for reduced-motion visitors and the E2E lane alike.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): SSR-safe reduced-motion detection for the graph demo (#344)
Fix round 1: reading window.matchMedia directly in KnowledgeGraphDemo's
render body computed "no preference" server-side (no window) but the real
value client-side, producing a genuine React hydration mismatch for any
reduced-motion visitor once this component mounts with SSR on.
Adds usePrefersReducedMotion (frontend/src/lib/usePrefersReducedMotion.ts),
following the same useSyncExternalStore + fixed getServerSnapshot pattern
useIsMobile.ts already established for this bug class. Server snapshot
defaults to true (assume reduced motion) rather than useIsMobile's false
default: whichever direction is wrong pays a cost, and defaulting true means
the cost lands on no-preference visitors (one extra replay of the entrance
animation) rather than reduced-motion visitors (who would otherwise see a
blank or mid-assembly graph on first paint).
Also switches AssemblingGraph's progress from raw state to a value derived
from `parked` at render time, so a post-hydration correction of `parked`
(without a remount) resolves correctly in both directions instead of
potentially getting stuck.
Adds a renderToString -> hydrateRoot regression test using different
matchMedia values across the two phases, verified to fail against the old
render-body-read pattern before confirming it passes against the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(landing): cold-start the reduced-motion store cache per test (#344)
Fix round 2: usePrefersReducedMotion's module-level MediaQueryList cache
(correct for production) survived across it() blocks within
KnowledgeGraphDemo.test.tsx, so the round-0 test 'parks fully assembled
when reduced motion is requested' inherited an earlier test's warmed
cache instead of its own local matchMedia override. It still passed, but
for the wrong reason -- false confidence, not regression protection.
Reviewer proof: inverting that test's override to report "no preference"
left the whole file green.
Adds a file-level beforeEach calling __resetReducedMotionStoreForTests()
so every test in the file cold-starts the cache and actually depends on
its own window.matchMedia. Removes the now-redundant reset from the
round-1 describe block's nested beforeEach (the mid-test reset between
its two installReducedMotion() calls stays, since a beforeEach only runs
once per test). Verified by the same inversion the reviewer used: fails
whole-file with the override flipped, passes again once restored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): graph hover blurbs and copy fade on engagement (#344)
Hover a node to reveal its concept blurb below the graph; the first
interaction fades the instructional copy for the rest of the session.
Both `hovered` and `engaged` live in the KnowledgeGraphDemo parent, not
in the AssemblingGraph child, since that child remounts (keyed by
graph.id) on every course switch. engaged must survive that remount;
hovered's home follows since the blurb paragraph renders in the parent
too. AssemblingGraph just gets onNodeEnter/onNodeLeave callback props.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): replace HowItWorks and the feature catalog with the graph demo (#344)
* test(e2e): journey for the landing knowledge graph (#344)
* fix(landing): viewport-gate the graph assembly, never render it blank (#344)
Final whole-branch review of the landing knowledge-graph demo turned up six
findings. They interact, so this is one wave.
#1 The helix played where nobody could see it. The assembly effect fired on
mount, so it burned its full 1100ms during the hydration window — under the
page's own intro overlay, alongside the hero canvas RAF — and every visitor
who scrolled down found progress === 1 and a static picture. Gate the RAF on
an IntersectionObserver on the section. The root margin is a NEGATIVE bottom
inset, not the positive lead-in that looks natural: the section sits directly
after a min-h-screen hero, so its top edge is at exactly 100vh and any
positive bottom margin re-creates the bug at scroll 0.
#6 No-preference first paint went blank. usePrefersReducedMotion correcting
its SSR-safe `true` to the real `false` dropped progress onto a raw
animatedProgress of 0 — a committed frame with the whole graph at opacity 0 —
and gating #1 would have turned that into a section that stays blank until
scrolled to. animatedProgress is now `number | null`, and null (the assembly
has never run) reads back as 1. Leaving the viewport mid-assembly settles on
the complete frame rather than freezing a half-faded one.
#4 The helix threw the outer ring outside the viewBox. `1 + (1 - e) * 0.9`
put depth-2 nodes at y = 693 against a 560-unit viewBox, chopped by the svg
viewport at opacity ≈ 0.5. It now contracts (0.55x → 1x) instead of
stretching, so the whole sweep lives inside the disc radialLayout already fits
to the frame. helixEntry(target, centre, 1) === target is untouched.
#3 Illegible on phones. One 900x560 viewBox at every width renders at 0.38
scale on a 390px viewport: 4.6 CSS px labels, a 213px-tall smudge. Added a
GraphView descriptor and a 360x300 phone view selected by useIsMobile —
0.95 scale, 12.35 CSS px labels, 22.8px dots, 285px tall.
#2 The engaged copy fade failed WCAG AA permanently (engaged never resets).
--text at 0.35 over the paper bg is 2.20:1 against a 3:1 bar. The fade moves
to the headline alone at 0.55 (3.88:1). The eyebrow is not faded: at 0.7rem
it needs 4.5:1, and --brand-forest only holds that to alpha 0.86 — the
review's suggested 0.75 is 3.58:1 and still fails.
#5 The named SSR guard didn't guard. public-seo.spec.ts asserted only
Metadata API output, which survives `ssr: false` on the dynamic import. Added
a raw-HTML assertion on the graph section's server-rendered copy.
Tests: layout.test.ts sweeps every node of every fixture across the whole t
range in both views and asserts circle + label extents stay in frame (this is
the test that would have caught #4); KnowledgeGraphDemo.test.tsx drives a fake
IntersectionObserver and captured rAF to pin "complete before armed, never
blank", "no RAF until on screen", and the WCAG ratios computed from the
rendered opacity; landing-graph.spec.ts measures label/dot/height in CSS px at
390x844. Each was confirmed to fail against the pre-fix code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): brand-conform the graph section and fit its frame (#344)
Five visual/brand defects found by screenshotting the built page against the
brand guide. Every automated gate was already green, so none of these were
test-detectable.
1. TIER_COLOR was a fourth inlined copy of the mastery palette with four wrong
literals — the landing page advertised different mastery colours than the
product. Now consumes the canonical --state-* tokens (globals.css:80-89).
The hero legend's four swatches get the same treatment (colours only).
2. The course root inherited its fixture tier's amber, so the section's focal
point read as a warning. It now paints --brand-forest as an anchor; the
fixture tier is unchanged.
3. The SVG used a hardcoded viewBox="0 0 900 560" stretched to a ~1184px
container, reserving ~737px of height around content that clustered in the
middle. The frame is now DERIVED (fitViewBox) from the drawn content — dot,
label, halo — swept across the whole entry animation, unioned over all three
fixtures so a chip click can't change the section height, once per
breakpoint. Desktop resolves to "159 42 578 498", 36% narrower than the box
it replaced.
Fitted to the HELIX SWEEP, not the settled positions: helixEntry rotates
1.5 turns, so an outer node passes 0.925*maxRadius above and below the
centre at ~83% opacity — 215 units against a settled extent of 58. A
settled fit clips the assembly, which is #344 review #4 all over again. The
bounds test is retargeted at the derived box, and a new paired test proves
the sweep genuinely leaves the settled bounding box so that containment
check stays load-bearing.
The fit only pays off with a width cap — stretched to 1184px a tighter box
renders at 2x and makes the section taller. Capped at md:max-w-[720px].
4. The root's label lay along the outer-ring -> depth-1 edge, which crosses
the root's x at cy + 0.349*ring in every fixture. It moves above the node on
desktop; on the phone the ring is too small for that to clear the top
child's label, so it stays below (where the diagonal doesn't reach it). All
labels gain a paint-order halo in --bg-mesh. Two unreported collisions also
fixed: "Hypothesis Tests" overlapped "Distributions" by ~21 CSS px at 390px,
and the shipped phone geometry both overlapped and, under the fit, fell
under the E2E height gate. New suite pins label/label, label/dot and
label/edge clearance for every fixture in both views.
5. Left-aligned the graph to the headline's grid — the same width cap — so a
left-aligned copy block no longer sits above a centred diagram.
503 unit tests pass (up 6), tsc and eslint clean. No new CSS, no new dependency,
no assertion weakened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): take the outer ring off the horizontal axis (#344)
The ring phase was `offset = d % 2 === 0 ? step / 2 : 0` against a −π/2
start. Depth 2 holds exactly two nodes in all three fixtures, so
`step = π`, the half slot is `π/2`, and both outer nodes resolved to
exactly 0 and π — dead on the horizontal axis through the centre. The
settled layout was therefore a flat ellipse (aspect 0.40) inside a
near-circular entry sweep (0.86), and since the viewBox is fitted to the
sweep (correctly — a settled fit clips the assembly), the graph filled
only 45% of its own box height and left a dead band above and below it.
Every ring now starts three quarters of a slot back from 3 o'clock, so
its angles are odd multiples of `step/4 = π/(2·count)` and can never be a
multiple of π: no ring of any size flattens onto the horizontal axis. The
half-slot alternation is kept for what it was for — it now applies only
when a ring holds the same node count as the one inside it, which is the
only case that lines up into radial spokes.
Depth 1 is bit-identical to before (−3·(2π/3)/4 is exactly −π/2), so the
reviewed triangle and every constraint the mobile geometry was tuned
against are untouched. Depth 2 moves to the NW↔SE diagonal; the mirrored
diagonal is not equivalent — the fixtures hang their outer nodes off the
12 and 4 o'clock children, so SW↔NE drags an edge through the root label
(−7.2 units of overlap, measured).
Desktop, re-derived from the new geometry and still fitted to the sweep:
`159 42 578 498` → `185 33 526 516`; settled aspect 0.436/0.405/0.418 →
1.006/0.910/0.951; the drawing fills 73% of the frame height, up from
45% (515 of 706 rendered px, up from 277 of 620). Phone: `-19 5 394 319`
→ `-4 -1 364 330`, aspect 0.485/0.424/0.450 → 1.006/0.844/0.910, fill
48% → 73%, and every E2E legibility bar clears wider than before (12.8px
labels, 25.5px dots, 301px tall).
Worst label clearance is unchanged at +29.2 units desktop and +0.8
mobile — the new angles introduce no new binding pair. The "sweep leaves
the settled box" guard is restated in units per side (59.0/59.3 desktop,
33.2/33.9 mobile) instead of as a ratio: the ratio was a proxy that
shrinks precisely when the rest state stops being flat, which is the fix.
New tests pin the no-horizontal invariant over ring sizes 1–12, the
spoke-breaking branch, and the settled fill for every fixture × view.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): lay the graph out as a radial tree, not concentric rings (#344)
`radialLayout` placed nodes on global concentric rings: a node's angle came
from its index within its depth, with no relationship to where its parent
sat. On the shipped 6-node fixtures that scattered the graph — `cs-sorting`
landed 171 units from `cs-arrays` on a 232-unit ring, `cs-trees` was flung to
the opposite corner — and the picture read as a lopsided diagonal smear.
Tuning the ring angles cannot fix it; the previous wave tried.
Now: BFS a spanning tree over the undirected `edges` (never `children`, which
is dead data that disagrees with them), root at the centre, depth-1 spread
evenly around the circle, and every deeper node placed one ring step from ITS
OWN PARENT inside a wedge centred on that parent's outward direction. The tree
governs position only — every entry in `graph.edges` is still drawn, cross
edges included.
Tree-edge length, desktop: max 170.9 → 116.0, mean 128.5 → 116.0. The section
gets shorter at the 720px cap: 706px → 663px. The depth-1 ring phase moves
forward by one slot (`+step/4`, still an odd multiple, so the no-horizontal
proof is unchanged) so the two branch-bearing children sit at 4 and 8 o'clock
and grow downward, away from the root's label band.
`MOBILE_VIEW` is retuned, not optional: in a tree the flattest of three arms
is always 30° off horizontal, so a child's dot lands inside its parent's own
13-character label unless `0.866·ring > 3.9·font + halo/2 + nodeR`. The ring
grows 132 → 163 and `nodeR`/`fitPad` shrink to buy it; the phone renders at
11.2px labels / 20.9px dots / 285px tall, still over the E2E legibility gate
but with thin margins, documented at both ends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): grow the graph upward from its base, not down from the middle (#344)
The radial tree put the course root at the frame's centre and spread its
concepts around the full circle, so the drawing grew one arm up and two
down — an inverted Y, or a root system. The product is called Sapling and
the section headline is "Pick a course. Watch it grow."
Only the ANGULAR DOMAIN changes. The BFS spanning tree, the parent-relative
placement and the drawing of every edge (cross-edges included) are exactly
as the previous wave left them.
- depth-1 fans across the UPWARD half-plane, taking the interior gridlines
of an (n+1)-way split of it: 45/90/135 degrees for three children. Never
lands a child on the horizon at any fan size, and strictly further from
it than the previous rule at every size.
- seats are handed out outside-in, biggest subtree first, so the deep arms
get the open sky and the composition stays mirror-symmetric.
- the skeleton is laid out with the root at the origin and translated as a
rigid body until its own bounding box is centred on the layout centre —
the point helixEntry spirals around. The root ends up at the bottom-centre
of the content and the sweep-fitted frame stays centred on the drawing.
- both views retuned against the new geometry (the budget is now solved
from the type scale, the label clearances and the phone's legibility
floor, not inherited from the layout box), and the root's label moves
below its circle in both: above is now the direction the plant grows in.
Measured, desktop at the 720px cap: the <svg> is 637.6px tall against
662.8 (and 737 for the box this shipped with); cs210 fills 0.839 of the
frame's width against 0.828; the worst label clearance goes 8.80 -> 30.14
units. Phone: 11.99px labels and 22.48px dots against 11.23/20.85, and the
worst label clearance goes 0.99 -> 4.05 CSS px, retiring a margin that was
inside the noise of whether Chromium paints a scrollbar.
The frame's HEIGHT share does not improve (0.563 -> 0.532): fitViewBox
fits to the entry sweep, the sweep is very nearly a disc, so dead vertical
space is about (drawing width - drawing height)/2 for any layout and a
canopy is wider than tall. Documented at the assertion that was relaxed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): tighten the graph's entry sweep to a quarter turn (#344)
The frame is fitted to the helix sweep, not to the settled drawing — it has
to be, or the assembly gets clipped mid-flight (#344 review #4). At 1.5 turns
every node passed through every direction on the way in, so that sweep was
very nearly a DISC and the fitted frame very nearly SQUARE around a canopy
twice as wide as it is tall. The difference was dead paper: 162 and 136 CSS
px of it above and below the drawing, which the previous wave measured across
the whole layout family and logged as its closing concern — the lever is the
sweep, not the layout.
`helixEntry` now turns 0.25 (90°) and `ENTRY_CONTRACTION` is 1, so a node
starts on the centre and the radial term collapses to the straight-line
easing: the path is that line, rotated by a decaying quarter turn, and the
widest swing happens where the node is nearest the centre. No layout, fixture,
colour, copy, testid or component signature moved; every settled coordinate is
byte-identical.
desktop viewBox 161 17 603 534 -> 161 114 582 334
<svg> 720x637.6 -> 720x413.2 px (-224)
band/side 162.2/136.0 -> 48.1/13.4 px
phone viewBox -32 -25 443 365 -> -32 40 429 230
<svg> 332x273.5 -> 332x178.0 px (-95)
band/side 63.7/54.2 -> 15.5/1.8 px
The drawing did not shrink to get there — it grew, because the narrower frame
renders every unit bigger: 669x339 -> 694x352 CSS px on desktop, 12.0 -> 12.4
px phone labels, 22.5 -> 23.2 px dots. Worst label clearance is unchanged in
units (30.14 desktop, 5.40 phone) and better in pixels (36.0 -> 37.3, 4.05 ->
4.18).
Tests: the mid-flight helix assertion is restated as shape rather than
distance (>0.1 travel radii off the straight line, >5 deg of bearing swing;
measured 0.172 and 11.25 deg) so it bites at 0.146 turns instead of passing
anything; the sweep-vs-settled guard now asserts the top overhang with a
number and builds the settled-fit box to show it clips, because the overhang
is one-sided by construction (the fan's tips lift past the settled top when
they rotate through the vertical; nothing can swing below the course code);
the frame-height share floor goes 0.50 -> 0.75 (measured 0.851/0.903); and the
phone's third bar stops measuring the FRAME's height (it was passing on the
band) and measures the drawing's, with a frame floor kept at 170px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): restore atmospheric continuity between the sections (#344)
Deleting #features and HowItWorks took the middle of the page's atmosphere
with them, and nothing replaced it.
The hero carries mesh-blob--1 and --2. The old #features carried --3 and --2,
and HowItWorks carried a dark-green scroll tint. The CTA carries --1 and --2
PLUS a top gradient that started at rgba(20,83,45,0.08) on its very first pixel
— because it was designed to blend DOWN out of that dark-green tint.
So after the deletions the page ran: atmospheric hero -> a completely flat
graph section -> a CTA whose green tint faded in from bare paper. Two hard
seams, one on each side of the new section.
The graph section now carries its own blobs at lower opacity than the hero's,
so the graph itself stays the focus, and the CTA's wash starts transparent and
peaks below the boundary instead of on it.
Found by spinning up the dev server and looking at the whole page, not the
section in isolation — the section screenshots I had been judging could not
show a seam, because a seam only exists between two things.
94 graph tests pass; tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): three feature bands and a bento of built surfaces (#344)
Step 2 of the below-hero redesign. The page now runs hero → graph →
Universal Upload → Adaptive Quizzes → bento → Spaced Repetition → CTA.
The three bands carry ONE arc — material in → practice → retention —
rather than three disconnected pitches, and the closing band (not a grid
tile) hands off to the CTA: a grid's last tile is a weak place to ask for
a signup. Surfaces alternate sides, derived from position in
`featureBands.tsx` rather than written down per band, so inserting a band
can't silently put two surfaces in the same gutter.
Every tile is a RECREATED PRODUCT SURFACE, never an icon over a heading
over a sentence — the brand guide's hard anti-pattern. The seven
recreations are faithful to the shipped screens: the upload modal's file
rows and status wording, QuizPanel's radiogroup with its `A.` prefix and
selected treatment, Study's rating trio, ChatPanel's asymmetric bubbles,
the notetaker's linked-concept rail, Social's invite chip and sender
names, and the gradebook's letter grade over real assignment rows.
Brand constraints that shaped the code rather than just the CSS:
- No glassmorphism. `.liquid-glass` is deliberately not reused; surfaces
are solid warm paper with hairline borders.
- Colour is state. Every mastery mark reads `TIER_COLOR` — the same map
the graph section above paints its nodes with — and the only other hues
are `--grade-*`. Unit-tested, so a raw hex can't creep back in.
- Contrast forced two divergences from the app's own paint: the student
bubble takes `--brand-forest` (6.4:1 under white) not `--accent`
(4.04:1), and per-row grade letters keep `--text` lettering with the
band on the border/dot, since `--grade-b` is 3.39:1 as a glyph.
No new dependency, no framer-motion, no JS motion at all: the surfaces
are static pictures, so `prefers-reduced-motion` and `IS_TEST_MODE` have
no frame to park — there is only the complete one. The entrance is the
page's existing `.landing-fade-up` observer, which degrades to "visible"
rather than "invisible" when it never fires.
All new CSS is inside the `.landing-page` scope and defines no tokens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): give the knowledge graph product chrome, a dial and a rail (#344)
The graph section was the least convincing thing on a page whose bands and
bento each recreate a whole product screen. It was six flat circles and six
words, floating naked on the page background, left-aligned in a 1184px
container with the right 40% of the field empty, and its entire hover payoff
was one line of body text swapping under the drawing.
It is now one surface, full container width, split the way the app's own Tree
screen is split.
- CHROME. `KNOWLEDGE GRAPH` in the bento's mono micro-label, and the selected
course's real numbers on the right — `MA 242 · 19 concepts · 50% mastery`,
all read off the fixture.
- THE NODES ARE DIALS. A tier-tinted disc at the full node radius, a neutral
track ring, the mastery arc swept clockwise from 12 o'clock, and a solid
core. Every dimension is measured INWARD from `nodeRadius`, so the drawn
footprint is byte-identical to the flat disc it replaces: `fitViewBox`,
`labelBaselineY` and the phone's 5.40-unit label clearance are untouched,
and a node group's first `<circle>` is still the tier-painted disc the E2E
legibility gate measures.
- A LEGEND, which is a comprehension fix rather than decoration: four
`--state-*` hues were carrying the whole meaning of the picture and nothing
on the page said what any of them meant. It carries counts, so it reads as a
readout, and it names the tiers in the app's own words.
- AN INSPECTOR RAIL replaces the bare hover line: name, tier as a labelled
chip, blurb, mastery meter, and the neighbours listed with their own scores,
with the hovered node's edges lit in the canvas beside it. Never empty — at
rest it shows the course.
- AMBIENT DRIFT, ~3 units on a 13–22s per-node cycle with a negative delay so
nothing starts in phase. A CSS animation, not a rAF loop: it stays out of
the assembly's frame budget (which the suite counts to prove the helix
neither fires early nor replays), and rides the same `parked` switch, so
reduced-motion visitors and the E2E lane get nodes exactly on their
laid-out points. Off below the mobile breakpoint, where the frame pad is 2
units and the worst label clearance 5.40.
The fixtures gain a numeric `mastery` per node and a `conceptCount` per
course, held to the product's own cutoffs by a port of
`backend/config.py::get_mastery_tier`, so a ring can never say 90% while the
paint says "struggling" and the chrome's percentage can never contradict the
root's own dial.
The assembly, its viewport gate, the negative bottom rootMargin and the
never-blank derivation are untouched. The phone gate's three bars improve
(12.68 / 23.78 / 182.3 against 11 / 20 / 170) because the canvas is
full-bleed horizontally — deliberately, and the CSS says why.
576 tests green (+16), tsc clean, eslint clean on src/ and e2e/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 22, 2026
…566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack: this spec 7/7 in isolation, including
the new journey. The full suite is 48 passed / 1 failed, the failure
being gradebook.spec.ts:35, which is not this change: it sits earlier in
the same worker than landing-drag-field, so nothing here can reach it.
It has now failed 2 of 3 full-suite runs while passing 3 of 3 in
isolation. CI's two retries have been masking it. Whether that is a
suite-context flake or a real intermittent in enrollment resolution is
still open — the failing line is the `Exams` category, which exists only
on the fall-2025 enrollment, so the signature matches "resolved the
wrong enrollment" as readily as "rendered slowly". Handed to the session
working that area; not filed as a flake until the control run on clean
main says it is one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 22, 2026
…566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack, rebased onto main at 7863210.
Control first, on UNPATCHED main: 47 passed, 1 failed — and the one
failure is this journey, so the lane is still red on current main and
this is still the fix it needs. With the patch applied, two consecutive
full suites: 49 passed, 0 failed. The spec alone, three consecutive
runs: 7/7 each time.
That also closes out the gradebook.spec.ts:35 question raised against
the previous revision. It failed 2 of 3 full-suite runs then, always on
a tree carrying an in-flight #553, and I could not tell a suite-context
flake from a real intermittent in enrollment resolution. #553 has since
merged; across the three full suites above it passed every time. It has
not reproduced on current main, so there is nothing to file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
) (#568)
* test(e2e): measure a dropped node against the copy it is welded to (#566)
The scroll-follow half of "a dropped node stays where it was put, and
scrolls with the page" has been red on main since the landing-v5 port
(#524) merged, taking the whole e2e browser lane with it. It is not
flaky: three CI retries and every local run land within 0.2px of each
other, ~98px short of where the assertion expects the node.
The product is right and the journey was wrong. `faq` is the one section
in DragField's `TRACKS`, and `engine/sim.ts::syncClusters()` positions a
tracked cluster at `field.top + a.top + dy`, where
`dy = (track.top - field.top) - trackTop`. That collapses to
`cluster_y = track.top + const`: the cluster is welded to the FAQ
question column, which is `sticky; top:110`. Instrumented over the
journey's own 300px scroll:
scrollY +300.00
#faq top -300.00
track top -202.81 <- pinned at top:110 partway through
cluster top -202.80
probe node y -202.60
error 97.40 (CI: 98.2-98.4)
The column had exactly 202.81px of travel left before its pin, so ~97px
of the scroll happened with the copy held still and the cluster
correctly held still with it. Welding those clusters to their copy is
deliberate — 8bb3486 added it because they slid 374px out from under
the words they belong to.
So the assertion was measuring the one coupling this section does not
have. The other two scroll journeys in this file already measure
relatively — a ring against its own cluster, a cluster against its act's
stage — and both pass; this one reached for raw scrollY, on the single
cluster where that is untrue. It picked cluster 4 for elbow room, not
knowing `faq` is also the only tracked field.
Re-frames it against the weld instead, read from the field's own
`data-drag-track` so the test follows the product rather than
duplicating it: drop the weld and `weldTop()` falls back to the field,
which is the plain 1:1 page-scroll reference. The intent is unchanged —
placed, not detached — and it keeps its teeth: a guard that the weld
target really moved (>100px) plus the exact 300px scroll, so a node
pinned to the screen over a page that never scrolled still fails.
Pointing the same assertion at the untracked section reproduces the
97.40px failure, so it is load-bearing, not vacuous.
Verified on the full local stack: this spec 6/6 twice, full suite 47/48
with one unrelated gradebook flake that passed 3/3 on re-run (retries
are CI-only).
Closes#566
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(e2e): let the journey own its oracle, and put the 1:1 case back (#566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack, rebased onto main at 7863210.
Control first, on UNPATCHED main: 47 passed, 1 failed — and the one
failure is this journey, so the lane is still red on current main and
this is still the fix it needs. With the patch applied, two consecutive
full suites: 49 passed, 0 failed. The spec alone, three consecutive
runs: 7/7 each time.
That also closes out the gradebook.spec.ts:35 question raised against
the previous revision. It failed 2 of 3 full-suite runs then, always on
a tree carrying an in-flight #553, and I could not tell a suite-context
flake from a real intermittent in enrollment resolution. #553 has since
merged; across the three full suites above it passed every time. It has
not reproduced on current main, so there is nothing to file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n 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;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Add landing page with liquid glass effects - #4

Merged
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
feature/landing-page-liquid-glass
Mar 18, 2026
Merged

Add landing page with liquid glass effects#4
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
feature/landing-page-liquid-glass

Conversation

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Summary

  • New landing page at / with a marketing-focused design: 3D canvas knowledge graph background, scramble text hero animation, floating glass accent cards, feature cards grid, sticky scroll cinema "How It Works" section, animated stats counter bar, and a multi-step onboarding modal
  • Liquid glass visual system applied to all panel surfaces — frosted translucency with backdrop-filter: blur(), inner specular highlights, hover shimmer sweep animation, and ambient gradient blobs behind sections for depth
  • Dashboard moved from / to /dashboard with Navbar routing updated to hide on the landing page and redirect unauthenticated users appropriately
  • New fonts added: Playfair Display (hero headings), Inter (body), JetBrains Mono (labels/stats)

Files changed

FileChange
frontend/src/app/page.tsxReplaced dashboard with new landing page component
frontend/src/app/dashboard/page.tsxNew file — moved original dashboard here
frontend/src/app/globals.cssAdded landing page animations + liquid glass CSS system
frontend/src/app/layout.tsxAdded Inter, Playfair Display, JetBrains Mono font imports
frontend/src/components/Navbar.tsxHide on landing page, update dashboard link to /dashboard
frontend/package.jsonAdded lucide-react dependency

Test plan

  • Visit http://localhost:3000/ — landing page renders with hero animation, floating cards, and liquid glass effects
  • Scroll through all sections — features, sticky scroll cinema (3 steps transition), stats counter, CTA
  • Click "Get Started" / "Sign In" — onboarding modal opens with multi-step flow
  • Resize browser — responsive layout works on mobile/tablet/desktop
  • Sign in with Google — redirects to /dashboard (original dashboard)
  • Verify /dashboard route still works correctly for authenticated users
  • Check that Navbar is hidden on the landing page but visible on all other routes

Made with Cursor

…hboard
Replace the root route with a new marketing landing page featuring:
- Hero section with 3D canvas knowledge graph, scramble text animation,
and floating glass accent cards
- Feature cards grid with spotlight hover effects and floating animation
- Sticky scroll cinema "How It Works" section (3 steps)
- Stats counter bar with intersection observer triggers
- Onboarding modal with multi-step sign-up/sign-in flow
- Liquid glass visual system (frosted translucency, backdrop blur,
specular shimmer on hover) applied to all panel surfaces
- Ambient gradient blobs behind sections for glass depth
- Responsive design with Playfair Display, Inter, and JetBrains Mono fonts
Move the original dashboard to /dashboard route and update Navbar
routing to hide on the landing page and redirect accordingly.
Made-with: Cursor
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Mar 18, 2026

Copy link
Copy Markdown

Deploying sapling with Cloudflare Pages Cloudflare Pages

Latest commit:fcdb2e0
Status:⚡️ Build in progress...

View logs

…anges
Keep landing page at / (our branch) and update dashboard/page.tsx
with main's latest dashboard code including the color picker feature.
Made-with: Cursor
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 9d2620f into mainMar 18, 2026
1 check was pending
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez deleted the feature/landing-page-liquid-glass branch March 18, 2026 04:44
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request May 5, 2026
refactor(calendar): unify syllabus extraction onto agent (refactor #4)
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request Jun 13, 2026
…ment-notes
fix(security): encrypt syllabus-extracted assignment notes at write boundary (#126#4)
AndresL230 added a commit that referenced this pull request Aug 2, 2026
#344 phase 2) (#513)
* docs(spec): landing page below the hero (#344 phase 2)
Interactive knowledge graph replaces the 340vh scroll-jacked HowItWorks, then
three feature bands and a four-tile bento of real app surfaces.
The structural argument is a density rhythm — the graph is the densest thing on
the page, bands decompress, the bento re-energizes, and a closing band gives the
CTA a run-up rather than ending on a grid tile.
The content argument matters more than the visual one. Tutor chat, Notes,
Gradebook and Flashcards are shipped surfaces the current six-feature list never
mentions, so the page undersells the product more than it under-designs it. That
is the likeliest root of #344's 'feels generic'.
Live LLM generation on the public page was considered and rejected for now: most
convincing option, but it puts an unauthenticated billable endpoint on the
most-crawled page on the site. Recorded as a second pass behind a rate-limited
endpoint.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(plan): landing interactive graph, step 1 (#344)
Seven tasks: fixtures, layout+helix math, component, assembly, interaction,
page wiring with the HowItWorks/catalog deletions, and the e2e journey.
Self-review found one real gap and it is recorded in the plan rather than
papered over: the spec lists drag alongside hover and expand, and Task 5
implements hover and the copy fade only. Dragging needs a pointer-capture and
SVG coordinate-mapping decision (getScreenCTM) that deserves its own review
gate, so it is called out as 5b/5c rather than hidden inside a step.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): course graph fixtures for the interactive demo (#344)
* feat(landing): deterministic radial layout + helical entry path (#344)
* feat(landing): knowledge graph demo — chips and laid-out render (#344)
Static, fully laid-out KnowledgeGraphDemo component: a course-picker
chip row plus an inline SVG render of the selected course's graph via
radialLayout. This is the parked frame reduced-motion visitors and the
E2E lane get; the assembly animation is a later task.
Registers the landing-graph E2E surface (docs/frontend-testids.md +
eslint.config.mjs) and adds the .landing-page-scoped chip/copy CSS.
* feat(landing): helical assembly, parked under reduced motion (#344)
Adds the RAF-driven helical assembly to KnowledgeGraphDemo, extracted into
an AssemblingGraph child keyed by course id so switching courses remounts
(fresh progress state, unmount-driven RAF cleanup) instead of resetting
state inside an effect — avoids the react-hooks/set-state-in-effect
anti-pattern that the brief's literal snippet would have tripped.
Parked (progress=1, full opacity, laid out) whenever IS_TEST_MODE or
prefers-reduced-motion. Adds a guarded window.matchMedia stub to
vitest.setup.ts (jsdom has none) defaulting to reduced-motion=true, so
every KnowledgeGraphDemo unit test asserts the parked/complete frame —
the correct target for reduced-motion visitors and the E2E lane alike.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): SSR-safe reduced-motion detection for the graph demo (#344)
Fix round 1: reading window.matchMedia directly in KnowledgeGraphDemo's
render body computed "no preference" server-side (no window) but the real
value client-side, producing a genuine React hydration mismatch for any
reduced-motion visitor once this component mounts with SSR on.
Adds usePrefersReducedMotion (frontend/src/lib/usePrefersReducedMotion.ts),
following the same useSyncExternalStore + fixed getServerSnapshot pattern
useIsMobile.ts already established for this bug class. Server snapshot
defaults to true (assume reduced motion) rather than useIsMobile's false
default: whichever direction is wrong pays a cost, and defaulting true means
the cost lands on no-preference visitors (one extra replay of the entrance
animation) rather than reduced-motion visitors (who would otherwise see a
blank or mid-assembly graph on first paint).
Also switches AssemblingGraph's progress from raw state to a value derived
from `parked` at render time, so a post-hydration correction of `parked`
(without a remount) resolves correctly in both directions instead of
potentially getting stuck.
Adds a renderToString -> hydrateRoot regression test using different
matchMedia values across the two phases, verified to fail against the old
render-body-read pattern before confirming it passes against the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(landing): cold-start the reduced-motion store cache per test (#344)
Fix round 2: usePrefersReducedMotion's module-level MediaQueryList cache
(correct for production) survived across it() blocks within
KnowledgeGraphDemo.test.tsx, so the round-0 test 'parks fully assembled
when reduced motion is requested' inherited an earlier test's warmed
cache instead of its own local matchMedia override. It still passed, but
for the wrong reason -- false confidence, not regression protection.
Reviewer proof: inverting that test's override to report "no preference"
left the whole file green.
Adds a file-level beforeEach calling __resetReducedMotionStoreForTests()
so every test in the file cold-starts the cache and actually depends on
its own window.matchMedia. Removes the now-redundant reset from the
round-1 describe block's nested beforeEach (the mid-test reset between
its two installReducedMotion() calls stays, since a beforeEach only runs
once per test). Verified by the same inversion the reviewer used: fails
whole-file with the override flipped, passes again once restored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): graph hover blurbs and copy fade on engagement (#344)
Hover a node to reveal its concept blurb below the graph; the first
interaction fades the instructional copy for the rest of the session.
Both `hovered` and `engaged` live in the KnowledgeGraphDemo parent, not
in the AssemblingGraph child, since that child remounts (keyed by
graph.id) on every course switch. engaged must survive that remount;
hovered's home follows since the blurb paragraph renders in the parent
too. AssemblingGraph just gets onNodeEnter/onNodeLeave callback props.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): replace HowItWorks and the feature catalog with the graph demo (#344)
* test(e2e): journey for the landing knowledge graph (#344)
* fix(landing): viewport-gate the graph assembly, never render it blank (#344)
Final whole-branch review of the landing knowledge-graph demo turned up six
findings. They interact, so this is one wave.
#1 The helix played where nobody could see it. The assembly effect fired on
mount, so it burned its full 1100ms during the hydration window — under the
page's own intro overlay, alongside the hero canvas RAF — and every visitor
who scrolled down found progress === 1 and a static picture. Gate the RAF on
an IntersectionObserver on the section. The root margin is a NEGATIVE bottom
inset, not the positive lead-in that looks natural: the section sits directly
after a min-h-screen hero, so its top edge is at exactly 100vh and any
positive bottom margin re-creates the bug at scroll 0.
#6 No-preference first paint went blank. usePrefersReducedMotion correcting
its SSR-safe `true` to the real `false` dropped progress onto a raw
animatedProgress of 0 — a committed frame with the whole graph at opacity 0 —
and gating #1 would have turned that into a section that stays blank until
scrolled to. animatedProgress is now `number | null`, and null (the assembly
has never run) reads back as 1. Leaving the viewport mid-assembly settles on
the complete frame rather than freezing a half-faded one.
#4 The helix threw the outer ring outside the viewBox. `1 + (1 - e) * 0.9`
put depth-2 nodes at y = 693 against a 560-unit viewBox, chopped by the svg
viewport at opacity ≈ 0.5. It now contracts (0.55x → 1x) instead of
stretching, so the whole sweep lives inside the disc radialLayout already fits
to the frame. helixEntry(target, centre, 1) === target is untouched.
#3 Illegible on phones. One 900x560 viewBox at every width renders at 0.38
scale on a 390px viewport: 4.6 CSS px labels, a 213px-tall smudge. Added a
GraphView descriptor and a 360x300 phone view selected by useIsMobile —
0.95 scale, 12.35 CSS px labels, 22.8px dots, 285px tall.
#2 The engaged copy fade failed WCAG AA permanently (engaged never resets).
--text at 0.35 over the paper bg is 2.20:1 against a 3:1 bar. The fade moves
to the headline alone at 0.55 (3.88:1). The eyebrow is not faded: at 0.7rem
it needs 4.5:1, and --brand-forest only holds that to alpha 0.86 — the
review's suggested 0.75 is 3.58:1 and still fails.
#5 The named SSR guard didn't guard. public-seo.spec.ts asserted only
Metadata API output, which survives `ssr: false` on the dynamic import. Added
a raw-HTML assertion on the graph section's server-rendered copy.
Tests: layout.test.ts sweeps every node of every fixture across the whole t
range in both views and asserts circle + label extents stay in frame (this is
the test that would have caught #4); KnowledgeGraphDemo.test.tsx drives a fake
IntersectionObserver and captured rAF to pin "complete before armed, never
blank", "no RAF until on screen", and the WCAG ratios computed from the
rendered opacity; landing-graph.spec.ts measures label/dot/height in CSS px at
390x844. Each was confirmed to fail against the pre-fix code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): brand-conform the graph section and fit its frame (#344)
Five visual/brand defects found by screenshotting the built page against the
brand guide. Every automated gate was already green, so none of these were
test-detectable.
1. TIER_COLOR was a fourth inlined copy of the mastery palette with four wrong
literals — the landing page advertised different mastery colours than the
product. Now consumes the canonical --state-* tokens (globals.css:80-89).
The hero legend's four swatches get the same treatment (colours only).
2. The course root inherited its fixture tier's amber, so the section's focal
point read as a warning. It now paints --brand-forest as an anchor; the
fixture tier is unchanged.
3. The SVG used a hardcoded viewBox="0 0 900 560" stretched to a ~1184px
container, reserving ~737px of height around content that clustered in the
middle. The frame is now DERIVED (fitViewBox) from the drawn content — dot,
label, halo — swept across the whole entry animation, unioned over all three
fixtures so a chip click can't change the section height, once per
breakpoint. Desktop resolves to "159 42 578 498", 36% narrower than the box
it replaced.
Fitted to the HELIX SWEEP, not the settled positions: helixEntry rotates
1.5 turns, so an outer node passes 0.925*maxRadius above and below the
centre at ~83% opacity — 215 units against a settled extent of 58. A
settled fit clips the assembly, which is #344 review #4 all over again. The
bounds test is retargeted at the derived box, and a new paired test proves
the sweep genuinely leaves the settled bounding box so that containment
check stays load-bearing.
The fit only pays off with a width cap — stretched to 1184px a tighter box
renders at 2x and makes the section taller. Capped at md:max-w-[720px].
4. The root's label lay along the outer-ring -> depth-1 edge, which crosses
the root's x at cy + 0.349*ring in every fixture. It moves above the node on
desktop; on the phone the ring is too small for that to clear the top
child's label, so it stays below (where the diagonal doesn't reach it). All
labels gain a paint-order halo in --bg-mesh. Two unreported collisions also
fixed: "Hypothesis Tests" overlapped "Distributions" by ~21 CSS px at 390px,
and the shipped phone geometry both overlapped and, under the fit, fell
under the E2E height gate. New suite pins label/label, label/dot and
label/edge clearance for every fixture in both views.
5. Left-aligned the graph to the headline's grid — the same width cap — so a
left-aligned copy block no longer sits above a centred diagram.
503 unit tests pass (up 6), tsc and eslint clean. No new CSS, no new dependency,
no assertion weakened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): take the outer ring off the horizontal axis (#344)
The ring phase was `offset = d % 2 === 0 ? step / 2 : 0` against a −π/2
start. Depth 2 holds exactly two nodes in all three fixtures, so
`step = π`, the half slot is `π/2`, and both outer nodes resolved to
exactly 0 and π — dead on the horizontal axis through the centre. The
settled layout was therefore a flat ellipse (aspect 0.40) inside a
near-circular entry sweep (0.86), and since the viewBox is fitted to the
sweep (correctly — a settled fit clips the assembly), the graph filled
only 45% of its own box height and left a dead band above and below it.
Every ring now starts three quarters of a slot back from 3 o'clock, so
its angles are odd multiples of `step/4 = π/(2·count)` and can never be a
multiple of π: no ring of any size flattens onto the horizontal axis. The
half-slot alternation is kept for what it was for — it now applies only
when a ring holds the same node count as the one inside it, which is the
only case that lines up into radial spokes.
Depth 1 is bit-identical to before (−3·(2π/3)/4 is exactly −π/2), so the
reviewed triangle and every constraint the mobile geometry was tuned
against are untouched. Depth 2 moves to the NW↔SE diagonal; the mirrored
diagonal is not equivalent — the fixtures hang their outer nodes off the
12 and 4 o'clock children, so SW↔NE drags an edge through the root label
(−7.2 units of overlap, measured).
Desktop, re-derived from the new geometry and still fitted to the sweep:
`159 42 578 498` → `185 33 526 516`; settled aspect 0.436/0.405/0.418 →
1.006/0.910/0.951; the drawing fills 73% of the frame height, up from
45% (515 of 706 rendered px, up from 277 of 620). Phone: `-19 5 394 319`
→ `-4 -1 364 330`, aspect 0.485/0.424/0.450 → 1.006/0.844/0.910, fill
48% → 73%, and every E2E legibility bar clears wider than before (12.8px
labels, 25.5px dots, 301px tall).
Worst label clearance is unchanged at +29.2 units desktop and +0.8
mobile — the new angles introduce no new binding pair. The "sweep leaves
the settled box" guard is restated in units per side (59.0/59.3 desktop,
33.2/33.9 mobile) instead of as a ratio: the ratio was a proxy that
shrinks precisely when the rest state stops being flat, which is the fix.
New tests pin the no-horizontal invariant over ring sizes 1–12, the
spoke-breaking branch, and the settled fill for every fixture × view.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): lay the graph out as a radial tree, not concentric rings (#344)
`radialLayout` placed nodes on global concentric rings: a node's angle came
from its index within its depth, with no relationship to where its parent
sat. On the shipped 6-node fixtures that scattered the graph — `cs-sorting`
landed 171 units from `cs-arrays` on a 232-unit ring, `cs-trees` was flung to
the opposite corner — and the picture read as a lopsided diagonal smear.
Tuning the ring angles cannot fix it; the previous wave tried.
Now: BFS a spanning tree over the undirected `edges` (never `children`, which
is dead data that disagrees with them), root at the centre, depth-1 spread
evenly around the circle, and every deeper node placed one ring step from ITS
OWN PARENT inside a wedge centred on that parent's outward direction. The tree
governs position only — every entry in `graph.edges` is still drawn, cross
edges included.
Tree-edge length, desktop: max 170.9 → 116.0, mean 128.5 → 116.0. The section
gets shorter at the 720px cap: 706px → 663px. The depth-1 ring phase moves
forward by one slot (`+step/4`, still an odd multiple, so the no-horizontal
proof is unchanged) so the two branch-bearing children sit at 4 and 8 o'clock
and grow downward, away from the root's label band.
`MOBILE_VIEW` is retuned, not optional: in a tree the flattest of three arms
is always 30° off horizontal, so a child's dot lands inside its parent's own
13-character label unless `0.866·ring > 3.9·font + halo/2 + nodeR`. The ring
grows 132 → 163 and `nodeR`/`fitPad` shrink to buy it; the phone renders at
11.2px labels / 20.9px dots / 285px tall, still over the E2E legibility gate
but with thin margins, documented at both ends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): grow the graph upward from its base, not down from the middle (#344)
The radial tree put the course root at the frame's centre and spread its
concepts around the full circle, so the drawing grew one arm up and two
down — an inverted Y, or a root system. The product is called Sapling and
the section headline is "Pick a course. Watch it grow."
Only the ANGULAR DOMAIN changes. The BFS spanning tree, the parent-relative
placement and the drawing of every edge (cross-edges included) are exactly
as the previous wave left them.
- depth-1 fans across the UPWARD half-plane, taking the interior gridlines
of an (n+1)-way split of it: 45/90/135 degrees for three children. Never
lands a child on the horizon at any fan size, and strictly further from
it than the previous rule at every size.
- seats are handed out outside-in, biggest subtree first, so the deep arms
get the open sky and the composition stays mirror-symmetric.
- the skeleton is laid out with the root at the origin and translated as a
rigid body until its own bounding box is centred on the layout centre —
the point helixEntry spirals around. The root ends up at the bottom-centre
of the content and the sweep-fitted frame stays centred on the drawing.
- both views retuned against the new geometry (the budget is now solved
from the type scale, the label clearances and the phone's legibility
floor, not inherited from the layout box), and the root's label moves
below its circle in both: above is now the direction the plant grows in.
Measured, desktop at the 720px cap: the <svg> is 637.6px tall against
662.8 (and 737 for the box this shipped with); cs210 fills 0.839 of the
frame's width against 0.828; the worst label clearance goes 8.80 -> 30.14
units. Phone: 11.99px labels and 22.48px dots against 11.23/20.85, and the
worst label clearance goes 0.99 -> 4.05 CSS px, retiring a margin that was
inside the noise of whether Chromium paints a scrollbar.
The frame's HEIGHT share does not improve (0.563 -> 0.532): fitViewBox
fits to the entry sweep, the sweep is very nearly a disc, so dead vertical
space is about (drawing width - drawing height)/2 for any layout and a
canopy is wider than tall. Documented at the assertion that was relaxed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): tighten the graph's entry sweep to a quarter turn (#344)
The frame is fitted to the helix sweep, not to the settled drawing — it has
to be, or the assembly gets clipped mid-flight (#344 review #4). At 1.5 turns
every node passed through every direction on the way in, so that sweep was
very nearly a DISC and the fitted frame very nearly SQUARE around a canopy
twice as wide as it is tall. The difference was dead paper: 162 and 136 CSS
px of it above and below the drawing, which the previous wave measured across
the whole layout family and logged as its closing concern — the lever is the
sweep, not the layout.
`helixEntry` now turns 0.25 (90°) and `ENTRY_CONTRACTION` is 1, so a node
starts on the centre and the radial term collapses to the straight-line
easing: the path is that line, rotated by a decaying quarter turn, and the
widest swing happens where the node is nearest the centre. No layout, fixture,
colour, copy, testid or component signature moved; every settled coordinate is
byte-identical.
desktop viewBox 161 17 603 534 -> 161 114 582 334
<svg> 720x637.6 -> 720x413.2 px (-224)
band/side 162.2/136.0 -> 48.1/13.4 px
phone viewBox -32 -25 443 365 -> -32 40 429 230
<svg> 332x273.5 -> 332x178.0 px (-95)
band/side 63.7/54.2 -> 15.5/1.8 px
The drawing did not shrink to get there — it grew, because the narrower frame
renders every unit bigger: 669x339 -> 694x352 CSS px on desktop, 12.0 -> 12.4
px phone labels, 22.5 -> 23.2 px dots. Worst label clearance is unchanged in
units (30.14 desktop, 5.40 phone) and better in pixels (36.0 -> 37.3, 4.05 ->
4.18).
Tests: the mid-flight helix assertion is restated as shape rather than
distance (>0.1 travel radii off the straight line, >5 deg of bearing swing;
measured 0.172 and 11.25 deg) so it bites at 0.146 turns instead of passing
anything; the sweep-vs-settled guard now asserts the top overhang with a
number and builds the settled-fit box to show it clips, because the overhang
is one-sided by construction (the fan's tips lift past the settled top when
they rotate through the vertical; nothing can swing below the course code);
the frame-height share floor goes 0.50 -> 0.75 (measured 0.851/0.903); and the
phone's third bar stops measuring the FRAME's height (it was passing on the
band) and measures the drawing's, with a frame floor kept at 170px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): restore atmospheric continuity between the sections (#344)
Deleting #features and HowItWorks took the middle of the page's atmosphere
with them, and nothing replaced it.
The hero carries mesh-blob--1 and --2. The old #features carried --3 and --2,
and HowItWorks carried a dark-green scroll tint. The CTA carries --1 and --2
PLUS a top gradient that started at rgba(20,83,45,0.08) on its very first pixel
— because it was designed to blend DOWN out of that dark-green tint.
So after the deletions the page ran: atmospheric hero -> a completely flat
graph section -> a CTA whose green tint faded in from bare paper. Two hard
seams, one on each side of the new section.
The graph section now carries its own blobs at lower opacity than the hero's,
so the graph itself stays the focus, and the CTA's wash starts transparent and
peaks below the boundary instead of on it.
Found by spinning up the dev server and looking at the whole page, not the
section in isolation — the section screenshots I had been judging could not
show a seam, because a seam only exists between two things.
94 graph tests pass; tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): three feature bands and a bento of built surfaces (#344)
Step 2 of the below-hero redesign. The page now runs hero → graph →
Universal Upload → Adaptive Quizzes → bento → Spaced Repetition → CTA.
The three bands carry ONE arc — material in → practice → retention —
rather than three disconnected pitches, and the closing band (not a grid
tile) hands off to the CTA: a grid's last tile is a weak place to ask for
a signup. Surfaces alternate sides, derived from position in
`featureBands.tsx` rather than written down per band, so inserting a band
can't silently put two surfaces in the same gutter.
Every tile is a RECREATED PRODUCT SURFACE, never an icon over a heading
over a sentence — the brand guide's hard anti-pattern. The seven
recreations are faithful to the shipped screens: the upload modal's file
rows and status wording, QuizPanel's radiogroup with its `A.` prefix and
selected treatment, Study's rating trio, ChatPanel's asymmetric bubbles,
the notetaker's linked-concept rail, Social's invite chip and sender
names, and the gradebook's letter grade over real assignment rows.
Brand constraints that shaped the code rather than just the CSS:
- No glassmorphism. `.liquid-glass` is deliberately not reused; surfaces
are solid warm paper with hairline borders.
- Colour is state. Every mastery mark reads `TIER_COLOR` — the same map
the graph section above paints its nodes with — and the only other hues
are `--grade-*`. Unit-tested, so a raw hex can't creep back in.
- Contrast forced two divergences from the app's own paint: the student
bubble takes `--brand-forest` (6.4:1 under white) not `--accent`
(4.04:1), and per-row grade letters keep `--text` lettering with the
band on the border/dot, since `--grade-b` is 3.39:1 as a glyph.
No new dependency, no framer-motion, no JS motion at all: the surfaces
are static pictures, so `prefers-reduced-motion` and `IS_TEST_MODE` have
no frame to park — there is only the complete one. The entrance is the
page's existing `.landing-fade-up` observer, which degrades to "visible"
rather than "invisible" when it never fires.
All new CSS is inside the `.landing-page` scope and defines no tokens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): give the knowledge graph product chrome, a dial and a rail (#344)
The graph section was the least convincing thing on a page whose bands and
bento each recreate a whole product screen. It was six flat circles and six
words, floating naked on the page background, left-aligned in a 1184px
container with the right 40% of the field empty, and its entire hover payoff
was one line of body text swapping under the drawing.
It is now one surface, full container width, split the way the app's own Tree
screen is split.
- CHROME. `KNOWLEDGE GRAPH` in the bento's mono micro-label, and the selected
course's real numbers on the right — `MA 242 · 19 concepts · 50% mastery`,
all read off the fixture.
- THE NODES ARE DIALS. A tier-tinted disc at the full node radius, a neutral
track ring, the mastery arc swept clockwise from 12 o'clock, and a solid
core. Every dimension is measured INWARD from `nodeRadius`, so the drawn
footprint is byte-identical to the flat disc it replaces: `fitViewBox`,
`labelBaselineY` and the phone's 5.40-unit label clearance are untouched,
and a node group's first `<circle>` is still the tier-painted disc the E2E
legibility gate measures.
- A LEGEND, which is a comprehension fix rather than decoration: four
`--state-*` hues were carrying the whole meaning of the picture and nothing
on the page said what any of them meant. It carries counts, so it reads as a
readout, and it names the tiers in the app's own words.
- AN INSPECTOR RAIL replaces the bare hover line: name, tier as a labelled
chip, blurb, mastery meter, and the neighbours listed with their own scores,
with the hovered node's edges lit in the canvas beside it. Never empty — at
rest it shows the course.
- AMBIENT DRIFT, ~3 units on a 13–22s per-node cycle with a negative delay so
nothing starts in phase. A CSS animation, not a rAF loop: it stays out of
the assembly's frame budget (which the suite counts to prove the helix
neither fires early nor replays), and rides the same `parked` switch, so
reduced-motion visitors and the E2E lane get nodes exactly on their
laid-out points. Off below the mobile breakpoint, where the frame pad is 2
units and the worst label clearance 5.40.
The fixtures gain a numeric `mastery` per node and a `conceptCount` per
course, held to the product's own cutoffs by a port of
`backend/config.py::get_mastery_tier`, so a ring can never say 90% while the
paint says "struggling" and the chrome's percentage can never contradict the
root's own dial.
The assembly, its viewport gate, the negative bottom rootMargin and the
never-blank derivation are untouched. The phone gate's three bars improve
(12.68 / 23.78 / 182.3 against 11 / 20 / 170) because the canvas is
full-bleed horizontally — deliberately, and the CSS says why.
576 tests green (+16), tsc clean, eslint clean on src/ and e2e/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 22, 2026
…566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack: this spec 7/7 in isolation, including
the new journey. The full suite is 48 passed / 1 failed, the failure
being gradebook.spec.ts:35, which is not this change: it sits earlier in
the same worker than landing-drag-field, so nothing here can reach it.
It has now failed 2 of 3 full-suite runs while passing 3 of 3 in
isolation. CI's two retries have been masking it. Whether that is a
suite-context flake or a real intermittent in enrollment resolution is
still open — the failing line is the `Exams` category, which exists only
on the fall-2025 enrollment, so the signature matches "resolved the
wrong enrollment" as readily as "rendered slowly". Handed to the session
working that area; not filed as a flake until the control run on clean
main says it is one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 22, 2026
…566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack, rebased onto main at 7863210.
Control first, on UNPATCHED main: 47 passed, 1 failed — and the one
failure is this journey, so the lane is still red on current main and
this is still the fix it needs. With the patch applied, two consecutive
full suites: 49 passed, 0 failed. The spec alone, three consecutive
runs: 7/7 each time.
That also closes out the gradebook.spec.ts:35 question raised against
the previous revision. It failed 2 of 3 full-suite runs then, always on
a tree carrying an in-flight #553, and I could not tell a suite-context
flake from a real intermittent in enrollment resolution. #553 has since
merged; across the three full suites above it passed every time. It has
not reproduced on current main, so there is nothing to file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
) (#568)
* test(e2e): measure a dropped node against the copy it is welded to (#566)
The scroll-follow half of "a dropped node stays where it was put, and
scrolls with the page" has been red on main since the landing-v5 port
(#524) merged, taking the whole e2e browser lane with it. It is not
flaky: three CI retries and every local run land within 0.2px of each
other, ~98px short of where the assertion expects the node.
The product is right and the journey was wrong. `faq` is the one section
in DragField's `TRACKS`, and `engine/sim.ts::syncClusters()` positions a
tracked cluster at `field.top + a.top + dy`, where
`dy = (track.top - field.top) - trackTop`. That collapses to
`cluster_y = track.top + const`: the cluster is welded to the FAQ
question column, which is `sticky; top:110`. Instrumented over the
journey's own 300px scroll:
scrollY +300.00
#faq top -300.00
track top -202.81 <- pinned at top:110 partway through
cluster top -202.80
probe node y -202.60
error 97.40 (CI: 98.2-98.4)
The column had exactly 202.81px of travel left before its pin, so ~97px
of the scroll happened with the copy held still and the cluster
correctly held still with it. Welding those clusters to their copy is
deliberate — 8bb3486 added it because they slid 374px out from under
the words they belong to.
So the assertion was measuring the one coupling this section does not
have. The other two scroll journeys in this file already measure
relatively — a ring against its own cluster, a cluster against its act's
stage — and both pass; this one reached for raw scrollY, on the single
cluster where that is untrue. It picked cluster 4 for elbow room, not
knowing `faq` is also the only tracked field.
Re-frames it against the weld instead, read from the field's own
`data-drag-track` so the test follows the product rather than
duplicating it: drop the weld and `weldTop()` falls back to the field,
which is the plain 1:1 page-scroll reference. The intent is unchanged —
placed, not detached — and it keeps its teeth: a guard that the weld
target really moved (>100px) plus the exact 300px scroll, so a node
pinned to the screen over a page that never scrolled still fails.
Pointing the same assertion at the untracked section reproduces the
97.40px failure, so it is load-bearing, not vacuous.
Verified on the full local stack: this spec 6/6 twice, full suite 47/48
with one unrelated gradebook flake that passed 3/3 on re-run (retries
are CI-only).
Closes#566
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(e2e): let the journey own its oracle, and put the 1:1 case back (#566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack, rebased onto main at 7863210.
Control first, on UNPATCHED main: 47 passed, 1 failed — and the one
failure is this journey, so the lane is still red on current main and
this is still the fix it needs. With the patch applied, two consecutive
full suites: 49 passed, 0 failed. The spec alone, three consecutive
runs: 7/7 each time.
That also closes out the gradebook.spec.ts:35 question raised against
the previous revision. It failed 2 of 3 full-suite runs then, always on
a tree carrying an in-flight #553, and I could not tell a suite-context
flake from a real intermittent in enrollment resolution. #553 has since
merged; across the three full suites above it passed every time. It has
not reproduced on current main, so there is nothing to file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

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

Add landing page with liquid glass effects - #4

Merged
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
feature/landing-page-liquid-glass
Mar 18, 2026
Merged

Add landing page with liquid glass effects#4
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
feature/landing-page-liquid-glass

Conversation

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Summary

  • New landing page at / with a marketing-focused design: 3D canvas knowledge graph background, scramble text hero animation, floating glass accent cards, feature cards grid, sticky scroll cinema "How It Works" section, animated stats counter bar, and a multi-step onboarding modal
  • Liquid glass visual system applied to all panel surfaces — frosted translucency with backdrop-filter: blur(), inner specular highlights, hover shimmer sweep animation, and ambient gradient blobs behind sections for depth
  • Dashboard moved from / to /dashboard with Navbar routing updated to hide on the landing page and redirect unauthenticated users appropriately
  • New fonts added: Playfair Display (hero headings), Inter (body), JetBrains Mono (labels/stats)

Files changed

FileChange
frontend/src/app/page.tsxReplaced dashboard with new landing page component
frontend/src/app/dashboard/page.tsxNew file — moved original dashboard here
frontend/src/app/globals.cssAdded landing page animations + liquid glass CSS system
frontend/src/app/layout.tsxAdded Inter, Playfair Display, JetBrains Mono font imports
frontend/src/components/Navbar.tsxHide on landing page, update dashboard link to /dashboard
frontend/package.jsonAdded lucide-react dependency

Test plan

  • Visit http://localhost:3000/ — landing page renders with hero animation, floating cards, and liquid glass effects
  • Scroll through all sections — features, sticky scroll cinema (3 steps transition), stats counter, CTA
  • Click "Get Started" / "Sign In" — onboarding modal opens with multi-step flow
  • Resize browser — responsive layout works on mobile/tablet/desktop
  • Sign in with Google — redirects to /dashboard (original dashboard)
  • Verify /dashboard route still works correctly for authenticated users
  • Check that Navbar is hidden on the landing page but visible on all other routes

Made with Cursor

…hboard
Replace the root route with a new marketing landing page featuring:
- Hero section with 3D canvas knowledge graph, scramble text animation,
and floating glass accent cards
- Feature cards grid with spotlight hover effects and floating animation
- Sticky scroll cinema "How It Works" section (3 steps)
- Stats counter bar with intersection observer triggers
- Onboarding modal with multi-step sign-up/sign-in flow
- Liquid glass visual system (frosted translucency, backdrop blur,
specular shimmer on hover) applied to all panel surfaces
- Ambient gradient blobs behind sections for glass depth
- Responsive design with Playfair Display, Inter, and JetBrains Mono fonts
Move the original dashboard to /dashboard route and update Navbar
routing to hide on the landing page and redirect accordingly.
Made-with: Cursor
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Mar 18, 2026

Copy link
Copy Markdown

Deploying sapling with Cloudflare Pages Cloudflare Pages

Latest commit:fcdb2e0
Status:⚡️ Build in progress...

View logs

…anges
Keep landing page at / (our branch) and update dashboard/page.tsx
with main's latest dashboard code including the color picker feature.
Made-with: Cursor
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 9d2620f into mainMar 18, 2026
1 check was pending
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez deleted the feature/landing-page-liquid-glass branch March 18, 2026 04:44
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request May 5, 2026
refactor(calendar): unify syllabus extraction onto agent (refactor #4)
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request Jun 13, 2026
…ment-notes
fix(security): encrypt syllabus-extracted assignment notes at write boundary (#126#4)
AndresL230 added a commit that referenced this pull request Aug 2, 2026
#344 phase 2) (#513)
* docs(spec): landing page below the hero (#344 phase 2)
Interactive knowledge graph replaces the 340vh scroll-jacked HowItWorks, then
three feature bands and a four-tile bento of real app surfaces.
The structural argument is a density rhythm — the graph is the densest thing on
the page, bands decompress, the bento re-energizes, and a closing band gives the
CTA a run-up rather than ending on a grid tile.
The content argument matters more than the visual one. Tutor chat, Notes,
Gradebook and Flashcards are shipped surfaces the current six-feature list never
mentions, so the page undersells the product more than it under-designs it. That
is the likeliest root of #344's 'feels generic'.
Live LLM generation on the public page was considered and rejected for now: most
convincing option, but it puts an unauthenticated billable endpoint on the
most-crawled page on the site. Recorded as a second pass behind a rate-limited
endpoint.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(plan): landing interactive graph, step 1 (#344)
Seven tasks: fixtures, layout+helix math, component, assembly, interaction,
page wiring with the HowItWorks/catalog deletions, and the e2e journey.
Self-review found one real gap and it is recorded in the plan rather than
papered over: the spec lists drag alongside hover and expand, and Task 5
implements hover and the copy fade only. Dragging needs a pointer-capture and
SVG coordinate-mapping decision (getScreenCTM) that deserves its own review
gate, so it is called out as 5b/5c rather than hidden inside a step.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): course graph fixtures for the interactive demo (#344)
* feat(landing): deterministic radial layout + helical entry path (#344)
* feat(landing): knowledge graph demo — chips and laid-out render (#344)
Static, fully laid-out KnowledgeGraphDemo component: a course-picker
chip row plus an inline SVG render of the selected course's graph via
radialLayout. This is the parked frame reduced-motion visitors and the
E2E lane get; the assembly animation is a later task.
Registers the landing-graph E2E surface (docs/frontend-testids.md +
eslint.config.mjs) and adds the .landing-page-scoped chip/copy CSS.
* feat(landing): helical assembly, parked under reduced motion (#344)
Adds the RAF-driven helical assembly to KnowledgeGraphDemo, extracted into
an AssemblingGraph child keyed by course id so switching courses remounts
(fresh progress state, unmount-driven RAF cleanup) instead of resetting
state inside an effect — avoids the react-hooks/set-state-in-effect
anti-pattern that the brief's literal snippet would have tripped.
Parked (progress=1, full opacity, laid out) whenever IS_TEST_MODE or
prefers-reduced-motion. Adds a guarded window.matchMedia stub to
vitest.setup.ts (jsdom has none) defaulting to reduced-motion=true, so
every KnowledgeGraphDemo unit test asserts the parked/complete frame —
the correct target for reduced-motion visitors and the E2E lane alike.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): SSR-safe reduced-motion detection for the graph demo (#344)
Fix round 1: reading window.matchMedia directly in KnowledgeGraphDemo's
render body computed "no preference" server-side (no window) but the real
value client-side, producing a genuine React hydration mismatch for any
reduced-motion visitor once this component mounts with SSR on.
Adds usePrefersReducedMotion (frontend/src/lib/usePrefersReducedMotion.ts),
following the same useSyncExternalStore + fixed getServerSnapshot pattern
useIsMobile.ts already established for this bug class. Server snapshot
defaults to true (assume reduced motion) rather than useIsMobile's false
default: whichever direction is wrong pays a cost, and defaulting true means
the cost lands on no-preference visitors (one extra replay of the entrance
animation) rather than reduced-motion visitors (who would otherwise see a
blank or mid-assembly graph on first paint).
Also switches AssemblingGraph's progress from raw state to a value derived
from `parked` at render time, so a post-hydration correction of `parked`
(without a remount) resolves correctly in both directions instead of
potentially getting stuck.
Adds a renderToString -> hydrateRoot regression test using different
matchMedia values across the two phases, verified to fail against the old
render-body-read pattern before confirming it passes against the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(landing): cold-start the reduced-motion store cache per test (#344)
Fix round 2: usePrefersReducedMotion's module-level MediaQueryList cache
(correct for production) survived across it() blocks within
KnowledgeGraphDemo.test.tsx, so the round-0 test 'parks fully assembled
when reduced motion is requested' inherited an earlier test's warmed
cache instead of its own local matchMedia override. It still passed, but
for the wrong reason -- false confidence, not regression protection.
Reviewer proof: inverting that test's override to report "no preference"
left the whole file green.
Adds a file-level beforeEach calling __resetReducedMotionStoreForTests()
so every test in the file cold-starts the cache and actually depends on
its own window.matchMedia. Removes the now-redundant reset from the
round-1 describe block's nested beforeEach (the mid-test reset between
its two installReducedMotion() calls stays, since a beforeEach only runs
once per test). Verified by the same inversion the reviewer used: fails
whole-file with the override flipped, passes again once restored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): graph hover blurbs and copy fade on engagement (#344)
Hover a node to reveal its concept blurb below the graph; the first
interaction fades the instructional copy for the rest of the session.
Both `hovered` and `engaged` live in the KnowledgeGraphDemo parent, not
in the AssemblingGraph child, since that child remounts (keyed by
graph.id) on every course switch. engaged must survive that remount;
hovered's home follows since the blurb paragraph renders in the parent
too. AssemblingGraph just gets onNodeEnter/onNodeLeave callback props.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): replace HowItWorks and the feature catalog with the graph demo (#344)
* test(e2e): journey for the landing knowledge graph (#344)
* fix(landing): viewport-gate the graph assembly, never render it blank (#344)
Final whole-branch review of the landing knowledge-graph demo turned up six
findings. They interact, so this is one wave.
#1 The helix played where nobody could see it. The assembly effect fired on
mount, so it burned its full 1100ms during the hydration window — under the
page's own intro overlay, alongside the hero canvas RAF — and every visitor
who scrolled down found progress === 1 and a static picture. Gate the RAF on
an IntersectionObserver on the section. The root margin is a NEGATIVE bottom
inset, not the positive lead-in that looks natural: the section sits directly
after a min-h-screen hero, so its top edge is at exactly 100vh and any
positive bottom margin re-creates the bug at scroll 0.
#6 No-preference first paint went blank. usePrefersReducedMotion correcting
its SSR-safe `true` to the real `false` dropped progress onto a raw
animatedProgress of 0 — a committed frame with the whole graph at opacity 0 —
and gating #1 would have turned that into a section that stays blank until
scrolled to. animatedProgress is now `number | null`, and null (the assembly
has never run) reads back as 1. Leaving the viewport mid-assembly settles on
the complete frame rather than freezing a half-faded one.
#4 The helix threw the outer ring outside the viewBox. `1 + (1 - e) * 0.9`
put depth-2 nodes at y = 693 against a 560-unit viewBox, chopped by the svg
viewport at opacity ≈ 0.5. It now contracts (0.55x → 1x) instead of
stretching, so the whole sweep lives inside the disc radialLayout already fits
to the frame. helixEntry(target, centre, 1) === target is untouched.
#3 Illegible on phones. One 900x560 viewBox at every width renders at 0.38
scale on a 390px viewport: 4.6 CSS px labels, a 213px-tall smudge. Added a
GraphView descriptor and a 360x300 phone view selected by useIsMobile —
0.95 scale, 12.35 CSS px labels, 22.8px dots, 285px tall.
#2 The engaged copy fade failed WCAG AA permanently (engaged never resets).
--text at 0.35 over the paper bg is 2.20:1 against a 3:1 bar. The fade moves
to the headline alone at 0.55 (3.88:1). The eyebrow is not faded: at 0.7rem
it needs 4.5:1, and --brand-forest only holds that to alpha 0.86 — the
review's suggested 0.75 is 3.58:1 and still fails.
#5 The named SSR guard didn't guard. public-seo.spec.ts asserted only
Metadata API output, which survives `ssr: false` on the dynamic import. Added
a raw-HTML assertion on the graph section's server-rendered copy.
Tests: layout.test.ts sweeps every node of every fixture across the whole t
range in both views and asserts circle + label extents stay in frame (this is
the test that would have caught #4); KnowledgeGraphDemo.test.tsx drives a fake
IntersectionObserver and captured rAF to pin "complete before armed, never
blank", "no RAF until on screen", and the WCAG ratios computed from the
rendered opacity; landing-graph.spec.ts measures label/dot/height in CSS px at
390x844. Each was confirmed to fail against the pre-fix code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): brand-conform the graph section and fit its frame (#344)
Five visual/brand defects found by screenshotting the built page against the
brand guide. Every automated gate was already green, so none of these were
test-detectable.
1. TIER_COLOR was a fourth inlined copy of the mastery palette with four wrong
literals — the landing page advertised different mastery colours than the
product. Now consumes the canonical --state-* tokens (globals.css:80-89).
The hero legend's four swatches get the same treatment (colours only).
2. The course root inherited its fixture tier's amber, so the section's focal
point read as a warning. It now paints --brand-forest as an anchor; the
fixture tier is unchanged.
3. The SVG used a hardcoded viewBox="0 0 900 560" stretched to a ~1184px
container, reserving ~737px of height around content that clustered in the
middle. The frame is now DERIVED (fitViewBox) from the drawn content — dot,
label, halo — swept across the whole entry animation, unioned over all three
fixtures so a chip click can't change the section height, once per
breakpoint. Desktop resolves to "159 42 578 498", 36% narrower than the box
it replaced.
Fitted to the HELIX SWEEP, not the settled positions: helixEntry rotates
1.5 turns, so an outer node passes 0.925*maxRadius above and below the
centre at ~83% opacity — 215 units against a settled extent of 58. A
settled fit clips the assembly, which is #344 review #4 all over again. The
bounds test is retargeted at the derived box, and a new paired test proves
the sweep genuinely leaves the settled bounding box so that containment
check stays load-bearing.
The fit only pays off with a width cap — stretched to 1184px a tighter box
renders at 2x and makes the section taller. Capped at md:max-w-[720px].
4. The root's label lay along the outer-ring -> depth-1 edge, which crosses
the root's x at cy + 0.349*ring in every fixture. It moves above the node on
desktop; on the phone the ring is too small for that to clear the top
child's label, so it stays below (where the diagonal doesn't reach it). All
labels gain a paint-order halo in --bg-mesh. Two unreported collisions also
fixed: "Hypothesis Tests" overlapped "Distributions" by ~21 CSS px at 390px,
and the shipped phone geometry both overlapped and, under the fit, fell
under the E2E height gate. New suite pins label/label, label/dot and
label/edge clearance for every fixture in both views.
5. Left-aligned the graph to the headline's grid — the same width cap — so a
left-aligned copy block no longer sits above a centred diagram.
503 unit tests pass (up 6), tsc and eslint clean. No new CSS, no new dependency,
no assertion weakened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): take the outer ring off the horizontal axis (#344)
The ring phase was `offset = d % 2 === 0 ? step / 2 : 0` against a −π/2
start. Depth 2 holds exactly two nodes in all three fixtures, so
`step = π`, the half slot is `π/2`, and both outer nodes resolved to
exactly 0 and π — dead on the horizontal axis through the centre. The
settled layout was therefore a flat ellipse (aspect 0.40) inside a
near-circular entry sweep (0.86), and since the viewBox is fitted to the
sweep (correctly — a settled fit clips the assembly), the graph filled
only 45% of its own box height and left a dead band above and below it.
Every ring now starts three quarters of a slot back from 3 o'clock, so
its angles are odd multiples of `step/4 = π/(2·count)` and can never be a
multiple of π: no ring of any size flattens onto the horizontal axis. The
half-slot alternation is kept for what it was for — it now applies only
when a ring holds the same node count as the one inside it, which is the
only case that lines up into radial spokes.
Depth 1 is bit-identical to before (−3·(2π/3)/4 is exactly −π/2), so the
reviewed triangle and every constraint the mobile geometry was tuned
against are untouched. Depth 2 moves to the NW↔SE diagonal; the mirrored
diagonal is not equivalent — the fixtures hang their outer nodes off the
12 and 4 o'clock children, so SW↔NE drags an edge through the root label
(−7.2 units of overlap, measured).
Desktop, re-derived from the new geometry and still fitted to the sweep:
`159 42 578 498` → `185 33 526 516`; settled aspect 0.436/0.405/0.418 →
1.006/0.910/0.951; the drawing fills 73% of the frame height, up from
45% (515 of 706 rendered px, up from 277 of 620). Phone: `-19 5 394 319`
→ `-4 -1 364 330`, aspect 0.485/0.424/0.450 → 1.006/0.844/0.910, fill
48% → 73%, and every E2E legibility bar clears wider than before (12.8px
labels, 25.5px dots, 301px tall).
Worst label clearance is unchanged at +29.2 units desktop and +0.8
mobile — the new angles introduce no new binding pair. The "sweep leaves
the settled box" guard is restated in units per side (59.0/59.3 desktop,
33.2/33.9 mobile) instead of as a ratio: the ratio was a proxy that
shrinks precisely when the rest state stops being flat, which is the fix.
New tests pin the no-horizontal invariant over ring sizes 1–12, the
spoke-breaking branch, and the settled fill for every fixture × view.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): lay the graph out as a radial tree, not concentric rings (#344)
`radialLayout` placed nodes on global concentric rings: a node's angle came
from its index within its depth, with no relationship to where its parent
sat. On the shipped 6-node fixtures that scattered the graph — `cs-sorting`
landed 171 units from `cs-arrays` on a 232-unit ring, `cs-trees` was flung to
the opposite corner — and the picture read as a lopsided diagonal smear.
Tuning the ring angles cannot fix it; the previous wave tried.
Now: BFS a spanning tree over the undirected `edges` (never `children`, which
is dead data that disagrees with them), root at the centre, depth-1 spread
evenly around the circle, and every deeper node placed one ring step from ITS
OWN PARENT inside a wedge centred on that parent's outward direction. The tree
governs position only — every entry in `graph.edges` is still drawn, cross
edges included.
Tree-edge length, desktop: max 170.9 → 116.0, mean 128.5 → 116.0. The section
gets shorter at the 720px cap: 706px → 663px. The depth-1 ring phase moves
forward by one slot (`+step/4`, still an odd multiple, so the no-horizontal
proof is unchanged) so the two branch-bearing children sit at 4 and 8 o'clock
and grow downward, away from the root's label band.
`MOBILE_VIEW` is retuned, not optional: in a tree the flattest of three arms
is always 30° off horizontal, so a child's dot lands inside its parent's own
13-character label unless `0.866·ring > 3.9·font + halo/2 + nodeR`. The ring
grows 132 → 163 and `nodeR`/`fitPad` shrink to buy it; the phone renders at
11.2px labels / 20.9px dots / 285px tall, still over the E2E legibility gate
but with thin margins, documented at both ends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): grow the graph upward from its base, not down from the middle (#344)
The radial tree put the course root at the frame's centre and spread its
concepts around the full circle, so the drawing grew one arm up and two
down — an inverted Y, or a root system. The product is called Sapling and
the section headline is "Pick a course. Watch it grow."
Only the ANGULAR DOMAIN changes. The BFS spanning tree, the parent-relative
placement and the drawing of every edge (cross-edges included) are exactly
as the previous wave left them.
- depth-1 fans across the UPWARD half-plane, taking the interior gridlines
of an (n+1)-way split of it: 45/90/135 degrees for three children. Never
lands a child on the horizon at any fan size, and strictly further from
it than the previous rule at every size.
- seats are handed out outside-in, biggest subtree first, so the deep arms
get the open sky and the composition stays mirror-symmetric.
- the skeleton is laid out with the root at the origin and translated as a
rigid body until its own bounding box is centred on the layout centre —
the point helixEntry spirals around. The root ends up at the bottom-centre
of the content and the sweep-fitted frame stays centred on the drawing.
- both views retuned against the new geometry (the budget is now solved
from the type scale, the label clearances and the phone's legibility
floor, not inherited from the layout box), and the root's label moves
below its circle in both: above is now the direction the plant grows in.
Measured, desktop at the 720px cap: the <svg> is 637.6px tall against
662.8 (and 737 for the box this shipped with); cs210 fills 0.839 of the
frame's width against 0.828; the worst label clearance goes 8.80 -> 30.14
units. Phone: 11.99px labels and 22.48px dots against 11.23/20.85, and the
worst label clearance goes 0.99 -> 4.05 CSS px, retiring a margin that was
inside the noise of whether Chromium paints a scrollbar.
The frame's HEIGHT share does not improve (0.563 -> 0.532): fitViewBox
fits to the entry sweep, the sweep is very nearly a disc, so dead vertical
space is about (drawing width - drawing height)/2 for any layout and a
canopy is wider than tall. Documented at the assertion that was relaxed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): tighten the graph's entry sweep to a quarter turn (#344)
The frame is fitted to the helix sweep, not to the settled drawing — it has
to be, or the assembly gets clipped mid-flight (#344 review #4). At 1.5 turns
every node passed through every direction on the way in, so that sweep was
very nearly a DISC and the fitted frame very nearly SQUARE around a canopy
twice as wide as it is tall. The difference was dead paper: 162 and 136 CSS
px of it above and below the drawing, which the previous wave measured across
the whole layout family and logged as its closing concern — the lever is the
sweep, not the layout.
`helixEntry` now turns 0.25 (90°) and `ENTRY_CONTRACTION` is 1, so a node
starts on the centre and the radial term collapses to the straight-line
easing: the path is that line, rotated by a decaying quarter turn, and the
widest swing happens where the node is nearest the centre. No layout, fixture,
colour, copy, testid or component signature moved; every settled coordinate is
byte-identical.
desktop viewBox 161 17 603 534 -> 161 114 582 334
<svg> 720x637.6 -> 720x413.2 px (-224)
band/side 162.2/136.0 -> 48.1/13.4 px
phone viewBox -32 -25 443 365 -> -32 40 429 230
<svg> 332x273.5 -> 332x178.0 px (-95)
band/side 63.7/54.2 -> 15.5/1.8 px
The drawing did not shrink to get there — it grew, because the narrower frame
renders every unit bigger: 669x339 -> 694x352 CSS px on desktop, 12.0 -> 12.4
px phone labels, 22.5 -> 23.2 px dots. Worst label clearance is unchanged in
units (30.14 desktop, 5.40 phone) and better in pixels (36.0 -> 37.3, 4.05 ->
4.18).
Tests: the mid-flight helix assertion is restated as shape rather than
distance (>0.1 travel radii off the straight line, >5 deg of bearing swing;
measured 0.172 and 11.25 deg) so it bites at 0.146 turns instead of passing
anything; the sweep-vs-settled guard now asserts the top overhang with a
number and builds the settled-fit box to show it clips, because the overhang
is one-sided by construction (the fan's tips lift past the settled top when
they rotate through the vertical; nothing can swing below the course code);
the frame-height share floor goes 0.50 -> 0.75 (measured 0.851/0.903); and the
phone's third bar stops measuring the FRAME's height (it was passing on the
band) and measures the drawing's, with a frame floor kept at 170px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): restore atmospheric continuity between the sections (#344)
Deleting #features and HowItWorks took the middle of the page's atmosphere
with them, and nothing replaced it.
The hero carries mesh-blob--1 and --2. The old #features carried --3 and --2,
and HowItWorks carried a dark-green scroll tint. The CTA carries --1 and --2
PLUS a top gradient that started at rgba(20,83,45,0.08) on its very first pixel
— because it was designed to blend DOWN out of that dark-green tint.
So after the deletions the page ran: atmospheric hero -> a completely flat
graph section -> a CTA whose green tint faded in from bare paper. Two hard
seams, one on each side of the new section.
The graph section now carries its own blobs at lower opacity than the hero's,
so the graph itself stays the focus, and the CTA's wash starts transparent and
peaks below the boundary instead of on it.
Found by spinning up the dev server and looking at the whole page, not the
section in isolation — the section screenshots I had been judging could not
show a seam, because a seam only exists between two things.
94 graph tests pass; tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): three feature bands and a bento of built surfaces (#344)
Step 2 of the below-hero redesign. The page now runs hero → graph →
Universal Upload → Adaptive Quizzes → bento → Spaced Repetition → CTA.
The three bands carry ONE arc — material in → practice → retention —
rather than three disconnected pitches, and the closing band (not a grid
tile) hands off to the CTA: a grid's last tile is a weak place to ask for
a signup. Surfaces alternate sides, derived from position in
`featureBands.tsx` rather than written down per band, so inserting a band
can't silently put two surfaces in the same gutter.
Every tile is a RECREATED PRODUCT SURFACE, never an icon over a heading
over a sentence — the brand guide's hard anti-pattern. The seven
recreations are faithful to the shipped screens: the upload modal's file
rows and status wording, QuizPanel's radiogroup with its `A.` prefix and
selected treatment, Study's rating trio, ChatPanel's asymmetric bubbles,
the notetaker's linked-concept rail, Social's invite chip and sender
names, and the gradebook's letter grade over real assignment rows.
Brand constraints that shaped the code rather than just the CSS:
- No glassmorphism. `.liquid-glass` is deliberately not reused; surfaces
are solid warm paper with hairline borders.
- Colour is state. Every mastery mark reads `TIER_COLOR` — the same map
the graph section above paints its nodes with — and the only other hues
are `--grade-*`. Unit-tested, so a raw hex can't creep back in.
- Contrast forced two divergences from the app's own paint: the student
bubble takes `--brand-forest` (6.4:1 under white) not `--accent`
(4.04:1), and per-row grade letters keep `--text` lettering with the
band on the border/dot, since `--grade-b` is 3.39:1 as a glyph.
No new dependency, no framer-motion, no JS motion at all: the surfaces
are static pictures, so `prefers-reduced-motion` and `IS_TEST_MODE` have
no frame to park — there is only the complete one. The entrance is the
page's existing `.landing-fade-up` observer, which degrades to "visible"
rather than "invisible" when it never fires.
All new CSS is inside the `.landing-page` scope and defines no tokens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): give the knowledge graph product chrome, a dial and a rail (#344)
The graph section was the least convincing thing on a page whose bands and
bento each recreate a whole product screen. It was six flat circles and six
words, floating naked on the page background, left-aligned in a 1184px
container with the right 40% of the field empty, and its entire hover payoff
was one line of body text swapping under the drawing.
It is now one surface, full container width, split the way the app's own Tree
screen is split.
- CHROME. `KNOWLEDGE GRAPH` in the bento's mono micro-label, and the selected
course's real numbers on the right — `MA 242 · 19 concepts · 50% mastery`,
all read off the fixture.
- THE NODES ARE DIALS. A tier-tinted disc at the full node radius, a neutral
track ring, the mastery arc swept clockwise from 12 o'clock, and a solid
core. Every dimension is measured INWARD from `nodeRadius`, so the drawn
footprint is byte-identical to the flat disc it replaces: `fitViewBox`,
`labelBaselineY` and the phone's 5.40-unit label clearance are untouched,
and a node group's first `<circle>` is still the tier-painted disc the E2E
legibility gate measures.
- A LEGEND, which is a comprehension fix rather than decoration: four
`--state-*` hues were carrying the whole meaning of the picture and nothing
on the page said what any of them meant. It carries counts, so it reads as a
readout, and it names the tiers in the app's own words.
- AN INSPECTOR RAIL replaces the bare hover line: name, tier as a labelled
chip, blurb, mastery meter, and the neighbours listed with their own scores,
with the hovered node's edges lit in the canvas beside it. Never empty — at
rest it shows the course.
- AMBIENT DRIFT, ~3 units on a 13–22s per-node cycle with a negative delay so
nothing starts in phase. A CSS animation, not a rAF loop: it stays out of
the assembly's frame budget (which the suite counts to prove the helix
neither fires early nor replays), and rides the same `parked` switch, so
reduced-motion visitors and the E2E lane get nodes exactly on their
laid-out points. Off below the mobile breakpoint, where the frame pad is 2
units and the worst label clearance 5.40.
The fixtures gain a numeric `mastery` per node and a `conceptCount` per
course, held to the product's own cutoffs by a port of
`backend/config.py::get_mastery_tier`, so a ring can never say 90% while the
paint says "struggling" and the chrome's percentage can never contradict the
root's own dial.
The assembly, its viewport gate, the negative bottom rootMargin and the
never-blank derivation are untouched. The phone gate's three bars improve
(12.68 / 23.78 / 182.3 against 11 / 20 / 170) because the canvas is
full-bleed horizontally — deliberately, and the CSS says why.
576 tests green (+16), tsc clean, eslint clean on src/ and e2e/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 22, 2026
…566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack: this spec 7/7 in isolation, including
the new journey. The full suite is 48 passed / 1 failed, the failure
being gradebook.spec.ts:35, which is not this change: it sits earlier in
the same worker than landing-drag-field, so nothing here can reach it.
It has now failed 2 of 3 full-suite runs while passing 3 of 3 in
isolation. CI's two retries have been masking it. Whether that is a
suite-context flake or a real intermittent in enrollment resolution is
still open — the failing line is the `Exams` category, which exists only
on the fall-2025 enrollment, so the signature matches "resolved the
wrong enrollment" as readily as "rendered slowly". Handed to the session
working that area; not filed as a flake until the control run on clean
main says it is one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 22, 2026
…566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack, rebased onto main at 7863210.
Control first, on UNPATCHED main: 47 passed, 1 failed — and the one
failure is this journey, so the lane is still red on current main and
this is still the fix it needs. With the patch applied, two consecutive
full suites: 49 passed, 0 failed. The spec alone, three consecutive
runs: 7/7 each time.
That also closes out the gradebook.spec.ts:35 question raised against
the previous revision. It failed 2 of 3 full-suite runs then, always on
a tree carrying an in-flight #553, and I could not tell a suite-context
flake from a real intermittent in enrollment resolution. #553 has since
merged; across the three full suites above it passed every time. It has
not reproduced on current main, so there is nothing to file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
) (#568)
* test(e2e): measure a dropped node against the copy it is welded to (#566)
The scroll-follow half of "a dropped node stays where it was put, and
scrolls with the page" has been red on main since the landing-v5 port
(#524) merged, taking the whole e2e browser lane with it. It is not
flaky: three CI retries and every local run land within 0.2px of each
other, ~98px short of where the assertion expects the node.
The product is right and the journey was wrong. `faq` is the one section
in DragField's `TRACKS`, and `engine/sim.ts::syncClusters()` positions a
tracked cluster at `field.top + a.top + dy`, where
`dy = (track.top - field.top) - trackTop`. That collapses to
`cluster_y = track.top + const`: the cluster is welded to the FAQ
question column, which is `sticky; top:110`. Instrumented over the
journey's own 300px scroll:
scrollY +300.00
#faq top -300.00
track top -202.81 <- pinned at top:110 partway through
cluster top -202.80
probe node y -202.60
error 97.40 (CI: 98.2-98.4)
The column had exactly 202.81px of travel left before its pin, so ~97px
of the scroll happened with the copy held still and the cluster
correctly held still with it. Welding those clusters to their copy is
deliberate — 8bb3486 added it because they slid 374px out from under
the words they belong to.
So the assertion was measuring the one coupling this section does not
have. The other two scroll journeys in this file already measure
relatively — a ring against its own cluster, a cluster against its act's
stage — and both pass; this one reached for raw scrollY, on the single
cluster where that is untrue. It picked cluster 4 for elbow room, not
knowing `faq` is also the only tracked field.
Re-frames it against the weld instead, read from the field's own
`data-drag-track` so the test follows the product rather than
duplicating it: drop the weld and `weldTop()` falls back to the field,
which is the plain 1:1 page-scroll reference. The intent is unchanged —
placed, not detached — and it keeps its teeth: a guard that the weld
target really moved (>100px) plus the exact 300px scroll, so a node
pinned to the screen over a page that never scrolled still fails.
Pointing the same assertion at the untracked section reproduces the
97.40px failure, so it is load-bearing, not vacuous.
Verified on the full local stack: this spec 6/6 twice, full suite 47/48
with one unrelated gradebook flake that passed 3/3 on re-run (retries
are CI-only).
Closes#566
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(e2e): let the journey own its oracle, and put the 1:1 case back (#566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack, rebased onto main at 7863210.
Control first, on UNPATCHED main: 47 passed, 1 failed — and the one
failure is this journey, so the lane is still red on current main and
this is still the fix it needs. With the patch applied, two consecutive
full suites: 49 passed, 0 failed. The spec alone, three consecutive
runs: 7/7 each time.
That also closes out the gradebook.spec.ts:35 question raised against
the previous revision. It failed 2 of 3 full-suite runs then, always on
a tree carrying an in-flight #553, and I could not tell a suite-context
flake from a real intermittent in enrollment resolution. #553 has since
merged; across the three full suites above it passed every time. It has
not reproduced on current main, so there is nothing to file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

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

Add landing page with liquid glass effects - #4

Merged
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
feature/landing-page-liquid-glass
Mar 18, 2026
Merged

Add landing page with liquid glass effects#4
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
feature/landing-page-liquid-glass

Conversation

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Summary

  • New landing page at / with a marketing-focused design: 3D canvas knowledge graph background, scramble text hero animation, floating glass accent cards, feature cards grid, sticky scroll cinema "How It Works" section, animated stats counter bar, and a multi-step onboarding modal
  • Liquid glass visual system applied to all panel surfaces — frosted translucency with backdrop-filter: blur(), inner specular highlights, hover shimmer sweep animation, and ambient gradient blobs behind sections for depth
  • Dashboard moved from / to /dashboard with Navbar routing updated to hide on the landing page and redirect unauthenticated users appropriately
  • New fonts added: Playfair Display (hero headings), Inter (body), JetBrains Mono (labels/stats)

Files changed

FileChange
frontend/src/app/page.tsxReplaced dashboard with new landing page component
frontend/src/app/dashboard/page.tsxNew file — moved original dashboard here
frontend/src/app/globals.cssAdded landing page animations + liquid glass CSS system
frontend/src/app/layout.tsxAdded Inter, Playfair Display, JetBrains Mono font imports
frontend/src/components/Navbar.tsxHide on landing page, update dashboard link to /dashboard
frontend/package.jsonAdded lucide-react dependency

Test plan

  • Visit http://localhost:3000/ — landing page renders with hero animation, floating cards, and liquid glass effects
  • Scroll through all sections — features, sticky scroll cinema (3 steps transition), stats counter, CTA
  • Click "Get Started" / "Sign In" — onboarding modal opens with multi-step flow
  • Resize browser — responsive layout works on mobile/tablet/desktop
  • Sign in with Google — redirects to /dashboard (original dashboard)
  • Verify /dashboard route still works correctly for authenticated users
  • Check that Navbar is hidden on the landing page but visible on all other routes

Made with Cursor

…hboard
Replace the root route with a new marketing landing page featuring:
- Hero section with 3D canvas knowledge graph, scramble text animation,
and floating glass accent cards
- Feature cards grid with spotlight hover effects and floating animation
- Sticky scroll cinema "How It Works" section (3 steps)
- Stats counter bar with intersection observer triggers
- Onboarding modal with multi-step sign-up/sign-in flow
- Liquid glass visual system (frosted translucency, backdrop blur,
specular shimmer on hover) applied to all panel surfaces
- Ambient gradient blobs behind sections for glass depth
- Responsive design with Playfair Display, Inter, and JetBrains Mono fonts
Move the original dashboard to /dashboard route and update Navbar
routing to hide on the landing page and redirect accordingly.
Made-with: Cursor
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Mar 18, 2026

Copy link
Copy Markdown

Deploying sapling with Cloudflare Pages Cloudflare Pages

Latest commit:fcdb2e0
Status:⚡️ Build in progress...

View logs

…anges
Keep landing page at / (our branch) and update dashboard/page.tsx
with main's latest dashboard code including the color picker feature.
Made-with: Cursor
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 9d2620f into mainMar 18, 2026
1 check was pending
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez deleted the feature/landing-page-liquid-glass branch March 18, 2026 04:44
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request May 5, 2026
refactor(calendar): unify syllabus extraction onto agent (refactor #4)
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request Jun 13, 2026
…ment-notes
fix(security): encrypt syllabus-extracted assignment notes at write boundary (#126#4)
AndresL230 added a commit that referenced this pull request Aug 2, 2026
#344 phase 2) (#513)
* docs(spec): landing page below the hero (#344 phase 2)
Interactive knowledge graph replaces the 340vh scroll-jacked HowItWorks, then
three feature bands and a four-tile bento of real app surfaces.
The structural argument is a density rhythm — the graph is the densest thing on
the page, bands decompress, the bento re-energizes, and a closing band gives the
CTA a run-up rather than ending on a grid tile.
The content argument matters more than the visual one. Tutor chat, Notes,
Gradebook and Flashcards are shipped surfaces the current six-feature list never
mentions, so the page undersells the product more than it under-designs it. That
is the likeliest root of #344's 'feels generic'.
Live LLM generation on the public page was considered and rejected for now: most
convincing option, but it puts an unauthenticated billable endpoint on the
most-crawled page on the site. Recorded as a second pass behind a rate-limited
endpoint.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(plan): landing interactive graph, step 1 (#344)
Seven tasks: fixtures, layout+helix math, component, assembly, interaction,
page wiring with the HowItWorks/catalog deletions, and the e2e journey.
Self-review found one real gap and it is recorded in the plan rather than
papered over: the spec lists drag alongside hover and expand, and Task 5
implements hover and the copy fade only. Dragging needs a pointer-capture and
SVG coordinate-mapping decision (getScreenCTM) that deserves its own review
gate, so it is called out as 5b/5c rather than hidden inside a step.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): course graph fixtures for the interactive demo (#344)
* feat(landing): deterministic radial layout + helical entry path (#344)
* feat(landing): knowledge graph demo — chips and laid-out render (#344)
Static, fully laid-out KnowledgeGraphDemo component: a course-picker
chip row plus an inline SVG render of the selected course's graph via
radialLayout. This is the parked frame reduced-motion visitors and the
E2E lane get; the assembly animation is a later task.
Registers the landing-graph E2E surface (docs/frontend-testids.md +
eslint.config.mjs) and adds the .landing-page-scoped chip/copy CSS.
* feat(landing): helical assembly, parked under reduced motion (#344)
Adds the RAF-driven helical assembly to KnowledgeGraphDemo, extracted into
an AssemblingGraph child keyed by course id so switching courses remounts
(fresh progress state, unmount-driven RAF cleanup) instead of resetting
state inside an effect — avoids the react-hooks/set-state-in-effect
anti-pattern that the brief's literal snippet would have tripped.
Parked (progress=1, full opacity, laid out) whenever IS_TEST_MODE or
prefers-reduced-motion. Adds a guarded window.matchMedia stub to
vitest.setup.ts (jsdom has none) defaulting to reduced-motion=true, so
every KnowledgeGraphDemo unit test asserts the parked/complete frame —
the correct target for reduced-motion visitors and the E2E lane alike.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): SSR-safe reduced-motion detection for the graph demo (#344)
Fix round 1: reading window.matchMedia directly in KnowledgeGraphDemo's
render body computed "no preference" server-side (no window) but the real
value client-side, producing a genuine React hydration mismatch for any
reduced-motion visitor once this component mounts with SSR on.
Adds usePrefersReducedMotion (frontend/src/lib/usePrefersReducedMotion.ts),
following the same useSyncExternalStore + fixed getServerSnapshot pattern
useIsMobile.ts already established for this bug class. Server snapshot
defaults to true (assume reduced motion) rather than useIsMobile's false
default: whichever direction is wrong pays a cost, and defaulting true means
the cost lands on no-preference visitors (one extra replay of the entrance
animation) rather than reduced-motion visitors (who would otherwise see a
blank or mid-assembly graph on first paint).
Also switches AssemblingGraph's progress from raw state to a value derived
from `parked` at render time, so a post-hydration correction of `parked`
(without a remount) resolves correctly in both directions instead of
potentially getting stuck.
Adds a renderToString -> hydrateRoot regression test using different
matchMedia values across the two phases, verified to fail against the old
render-body-read pattern before confirming it passes against the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(landing): cold-start the reduced-motion store cache per test (#344)
Fix round 2: usePrefersReducedMotion's module-level MediaQueryList cache
(correct for production) survived across it() blocks within
KnowledgeGraphDemo.test.tsx, so the round-0 test 'parks fully assembled
when reduced motion is requested' inherited an earlier test's warmed
cache instead of its own local matchMedia override. It still passed, but
for the wrong reason -- false confidence, not regression protection.
Reviewer proof: inverting that test's override to report "no preference"
left the whole file green.
Adds a file-level beforeEach calling __resetReducedMotionStoreForTests()
so every test in the file cold-starts the cache and actually depends on
its own window.matchMedia. Removes the now-redundant reset from the
round-1 describe block's nested beforeEach (the mid-test reset between
its two installReducedMotion() calls stays, since a beforeEach only runs
once per test). Verified by the same inversion the reviewer used: fails
whole-file with the override flipped, passes again once restored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): graph hover blurbs and copy fade on engagement (#344)
Hover a node to reveal its concept blurb below the graph; the first
interaction fades the instructional copy for the rest of the session.
Both `hovered` and `engaged` live in the KnowledgeGraphDemo parent, not
in the AssemblingGraph child, since that child remounts (keyed by
graph.id) on every course switch. engaged must survive that remount;
hovered's home follows since the blurb paragraph renders in the parent
too. AssemblingGraph just gets onNodeEnter/onNodeLeave callback props.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): replace HowItWorks and the feature catalog with the graph demo (#344)
* test(e2e): journey for the landing knowledge graph (#344)
* fix(landing): viewport-gate the graph assembly, never render it blank (#344)
Final whole-branch review of the landing knowledge-graph demo turned up six
findings. They interact, so this is one wave.
#1 The helix played where nobody could see it. The assembly effect fired on
mount, so it burned its full 1100ms during the hydration window — under the
page's own intro overlay, alongside the hero canvas RAF — and every visitor
who scrolled down found progress === 1 and a static picture. Gate the RAF on
an IntersectionObserver on the section. The root margin is a NEGATIVE bottom
inset, not the positive lead-in that looks natural: the section sits directly
after a min-h-screen hero, so its top edge is at exactly 100vh and any
positive bottom margin re-creates the bug at scroll 0.
#6 No-preference first paint went blank. usePrefersReducedMotion correcting
its SSR-safe `true` to the real `false` dropped progress onto a raw
animatedProgress of 0 — a committed frame with the whole graph at opacity 0 —
and gating #1 would have turned that into a section that stays blank until
scrolled to. animatedProgress is now `number | null`, and null (the assembly
has never run) reads back as 1. Leaving the viewport mid-assembly settles on
the complete frame rather than freezing a half-faded one.
#4 The helix threw the outer ring outside the viewBox. `1 + (1 - e) * 0.9`
put depth-2 nodes at y = 693 against a 560-unit viewBox, chopped by the svg
viewport at opacity ≈ 0.5. It now contracts (0.55x → 1x) instead of
stretching, so the whole sweep lives inside the disc radialLayout already fits
to the frame. helixEntry(target, centre, 1) === target is untouched.
#3 Illegible on phones. One 900x560 viewBox at every width renders at 0.38
scale on a 390px viewport: 4.6 CSS px labels, a 213px-tall smudge. Added a
GraphView descriptor and a 360x300 phone view selected by useIsMobile —
0.95 scale, 12.35 CSS px labels, 22.8px dots, 285px tall.
#2 The engaged copy fade failed WCAG AA permanently (engaged never resets).
--text at 0.35 over the paper bg is 2.20:1 against a 3:1 bar. The fade moves
to the headline alone at 0.55 (3.88:1). The eyebrow is not faded: at 0.7rem
it needs 4.5:1, and --brand-forest only holds that to alpha 0.86 — the
review's suggested 0.75 is 3.58:1 and still fails.
#5 The named SSR guard didn't guard. public-seo.spec.ts asserted only
Metadata API output, which survives `ssr: false` on the dynamic import. Added
a raw-HTML assertion on the graph section's server-rendered copy.
Tests: layout.test.ts sweeps every node of every fixture across the whole t
range in both views and asserts circle + label extents stay in frame (this is
the test that would have caught #4); KnowledgeGraphDemo.test.tsx drives a fake
IntersectionObserver and captured rAF to pin "complete before armed, never
blank", "no RAF until on screen", and the WCAG ratios computed from the
rendered opacity; landing-graph.spec.ts measures label/dot/height in CSS px at
390x844. Each was confirmed to fail against the pre-fix code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): brand-conform the graph section and fit its frame (#344)
Five visual/brand defects found by screenshotting the built page against the
brand guide. Every automated gate was already green, so none of these were
test-detectable.
1. TIER_COLOR was a fourth inlined copy of the mastery palette with four wrong
literals — the landing page advertised different mastery colours than the
product. Now consumes the canonical --state-* tokens (globals.css:80-89).
The hero legend's four swatches get the same treatment (colours only).
2. The course root inherited its fixture tier's amber, so the section's focal
point read as a warning. It now paints --brand-forest as an anchor; the
fixture tier is unchanged.
3. The SVG used a hardcoded viewBox="0 0 900 560" stretched to a ~1184px
container, reserving ~737px of height around content that clustered in the
middle. The frame is now DERIVED (fitViewBox) from the drawn content — dot,
label, halo — swept across the whole entry animation, unioned over all three
fixtures so a chip click can't change the section height, once per
breakpoint. Desktop resolves to "159 42 578 498", 36% narrower than the box
it replaced.
Fitted to the HELIX SWEEP, not the settled positions: helixEntry rotates
1.5 turns, so an outer node passes 0.925*maxRadius above and below the
centre at ~83% opacity — 215 units against a settled extent of 58. A
settled fit clips the assembly, which is #344 review #4 all over again. The
bounds test is retargeted at the derived box, and a new paired test proves
the sweep genuinely leaves the settled bounding box so that containment
check stays load-bearing.
The fit only pays off with a width cap — stretched to 1184px a tighter box
renders at 2x and makes the section taller. Capped at md:max-w-[720px].
4. The root's label lay along the outer-ring -> depth-1 edge, which crosses
the root's x at cy + 0.349*ring in every fixture. It moves above the node on
desktop; on the phone the ring is too small for that to clear the top
child's label, so it stays below (where the diagonal doesn't reach it). All
labels gain a paint-order halo in --bg-mesh. Two unreported collisions also
fixed: "Hypothesis Tests" overlapped "Distributions" by ~21 CSS px at 390px,
and the shipped phone geometry both overlapped and, under the fit, fell
under the E2E height gate. New suite pins label/label, label/dot and
label/edge clearance for every fixture in both views.
5. Left-aligned the graph to the headline's grid — the same width cap — so a
left-aligned copy block no longer sits above a centred diagram.
503 unit tests pass (up 6), tsc and eslint clean. No new CSS, no new dependency,
no assertion weakened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): take the outer ring off the horizontal axis (#344)
The ring phase was `offset = d % 2 === 0 ? step / 2 : 0` against a −π/2
start. Depth 2 holds exactly two nodes in all three fixtures, so
`step = π`, the half slot is `π/2`, and both outer nodes resolved to
exactly 0 and π — dead on the horizontal axis through the centre. The
settled layout was therefore a flat ellipse (aspect 0.40) inside a
near-circular entry sweep (0.86), and since the viewBox is fitted to the
sweep (correctly — a settled fit clips the assembly), the graph filled
only 45% of its own box height and left a dead band above and below it.
Every ring now starts three quarters of a slot back from 3 o'clock, so
its angles are odd multiples of `step/4 = π/(2·count)` and can never be a
multiple of π: no ring of any size flattens onto the horizontal axis. The
half-slot alternation is kept for what it was for — it now applies only
when a ring holds the same node count as the one inside it, which is the
only case that lines up into radial spokes.
Depth 1 is bit-identical to before (−3·(2π/3)/4 is exactly −π/2), so the
reviewed triangle and every constraint the mobile geometry was tuned
against are untouched. Depth 2 moves to the NW↔SE diagonal; the mirrored
diagonal is not equivalent — the fixtures hang their outer nodes off the
12 and 4 o'clock children, so SW↔NE drags an edge through the root label
(−7.2 units of overlap, measured).
Desktop, re-derived from the new geometry and still fitted to the sweep:
`159 42 578 498` → `185 33 526 516`; settled aspect 0.436/0.405/0.418 →
1.006/0.910/0.951; the drawing fills 73% of the frame height, up from
45% (515 of 706 rendered px, up from 277 of 620). Phone: `-19 5 394 319`
→ `-4 -1 364 330`, aspect 0.485/0.424/0.450 → 1.006/0.844/0.910, fill
48% → 73%, and every E2E legibility bar clears wider than before (12.8px
labels, 25.5px dots, 301px tall).
Worst label clearance is unchanged at +29.2 units desktop and +0.8
mobile — the new angles introduce no new binding pair. The "sweep leaves
the settled box" guard is restated in units per side (59.0/59.3 desktop,
33.2/33.9 mobile) instead of as a ratio: the ratio was a proxy that
shrinks precisely when the rest state stops being flat, which is the fix.
New tests pin the no-horizontal invariant over ring sizes 1–12, the
spoke-breaking branch, and the settled fill for every fixture × view.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): lay the graph out as a radial tree, not concentric rings (#344)
`radialLayout` placed nodes on global concentric rings: a node's angle came
from its index within its depth, with no relationship to where its parent
sat. On the shipped 6-node fixtures that scattered the graph — `cs-sorting`
landed 171 units from `cs-arrays` on a 232-unit ring, `cs-trees` was flung to
the opposite corner — and the picture read as a lopsided diagonal smear.
Tuning the ring angles cannot fix it; the previous wave tried.
Now: BFS a spanning tree over the undirected `edges` (never `children`, which
is dead data that disagrees with them), root at the centre, depth-1 spread
evenly around the circle, and every deeper node placed one ring step from ITS
OWN PARENT inside a wedge centred on that parent's outward direction. The tree
governs position only — every entry in `graph.edges` is still drawn, cross
edges included.
Tree-edge length, desktop: max 170.9 → 116.0, mean 128.5 → 116.0. The section
gets shorter at the 720px cap: 706px → 663px. The depth-1 ring phase moves
forward by one slot (`+step/4`, still an odd multiple, so the no-horizontal
proof is unchanged) so the two branch-bearing children sit at 4 and 8 o'clock
and grow downward, away from the root's label band.
`MOBILE_VIEW` is retuned, not optional: in a tree the flattest of three arms
is always 30° off horizontal, so a child's dot lands inside its parent's own
13-character label unless `0.866·ring > 3.9·font + halo/2 + nodeR`. The ring
grows 132 → 163 and `nodeR`/`fitPad` shrink to buy it; the phone renders at
11.2px labels / 20.9px dots / 285px tall, still over the E2E legibility gate
but with thin margins, documented at both ends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): grow the graph upward from its base, not down from the middle (#344)
The radial tree put the course root at the frame's centre and spread its
concepts around the full circle, so the drawing grew one arm up and two
down — an inverted Y, or a root system. The product is called Sapling and
the section headline is "Pick a course. Watch it grow."
Only the ANGULAR DOMAIN changes. The BFS spanning tree, the parent-relative
placement and the drawing of every edge (cross-edges included) are exactly
as the previous wave left them.
- depth-1 fans across the UPWARD half-plane, taking the interior gridlines
of an (n+1)-way split of it: 45/90/135 degrees for three children. Never
lands a child on the horizon at any fan size, and strictly further from
it than the previous rule at every size.
- seats are handed out outside-in, biggest subtree first, so the deep arms
get the open sky and the composition stays mirror-symmetric.
- the skeleton is laid out with the root at the origin and translated as a
rigid body until its own bounding box is centred on the layout centre —
the point helixEntry spirals around. The root ends up at the bottom-centre
of the content and the sweep-fitted frame stays centred on the drawing.
- both views retuned against the new geometry (the budget is now solved
from the type scale, the label clearances and the phone's legibility
floor, not inherited from the layout box), and the root's label moves
below its circle in both: above is now the direction the plant grows in.
Measured, desktop at the 720px cap: the <svg> is 637.6px tall against
662.8 (and 737 for the box this shipped with); cs210 fills 0.839 of the
frame's width against 0.828; the worst label clearance goes 8.80 -> 30.14
units. Phone: 11.99px labels and 22.48px dots against 11.23/20.85, and the
worst label clearance goes 0.99 -> 4.05 CSS px, retiring a margin that was
inside the noise of whether Chromium paints a scrollbar.
The frame's HEIGHT share does not improve (0.563 -> 0.532): fitViewBox
fits to the entry sweep, the sweep is very nearly a disc, so dead vertical
space is about (drawing width - drawing height)/2 for any layout and a
canopy is wider than tall. Documented at the assertion that was relaxed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): tighten the graph's entry sweep to a quarter turn (#344)
The frame is fitted to the helix sweep, not to the settled drawing — it has
to be, or the assembly gets clipped mid-flight (#344 review #4). At 1.5 turns
every node passed through every direction on the way in, so that sweep was
very nearly a DISC and the fitted frame very nearly SQUARE around a canopy
twice as wide as it is tall. The difference was dead paper: 162 and 136 CSS
px of it above and below the drawing, which the previous wave measured across
the whole layout family and logged as its closing concern — the lever is the
sweep, not the layout.
`helixEntry` now turns 0.25 (90°) and `ENTRY_CONTRACTION` is 1, so a node
starts on the centre and the radial term collapses to the straight-line
easing: the path is that line, rotated by a decaying quarter turn, and the
widest swing happens where the node is nearest the centre. No layout, fixture,
colour, copy, testid or component signature moved; every settled coordinate is
byte-identical.
desktop viewBox 161 17 603 534 -> 161 114 582 334
<svg> 720x637.6 -> 720x413.2 px (-224)
band/side 162.2/136.0 -> 48.1/13.4 px
phone viewBox -32 -25 443 365 -> -32 40 429 230
<svg> 332x273.5 -> 332x178.0 px (-95)
band/side 63.7/54.2 -> 15.5/1.8 px
The drawing did not shrink to get there — it grew, because the narrower frame
renders every unit bigger: 669x339 -> 694x352 CSS px on desktop, 12.0 -> 12.4
px phone labels, 22.5 -> 23.2 px dots. Worst label clearance is unchanged in
units (30.14 desktop, 5.40 phone) and better in pixels (36.0 -> 37.3, 4.05 ->
4.18).
Tests: the mid-flight helix assertion is restated as shape rather than
distance (>0.1 travel radii off the straight line, >5 deg of bearing swing;
measured 0.172 and 11.25 deg) so it bites at 0.146 turns instead of passing
anything; the sweep-vs-settled guard now asserts the top overhang with a
number and builds the settled-fit box to show it clips, because the overhang
is one-sided by construction (the fan's tips lift past the settled top when
they rotate through the vertical; nothing can swing below the course code);
the frame-height share floor goes 0.50 -> 0.75 (measured 0.851/0.903); and the
phone's third bar stops measuring the FRAME's height (it was passing on the
band) and measures the drawing's, with a frame floor kept at 170px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): restore atmospheric continuity between the sections (#344)
Deleting #features and HowItWorks took the middle of the page's atmosphere
with them, and nothing replaced it.
The hero carries mesh-blob--1 and --2. The old #features carried --3 and --2,
and HowItWorks carried a dark-green scroll tint. The CTA carries --1 and --2
PLUS a top gradient that started at rgba(20,83,45,0.08) on its very first pixel
— because it was designed to blend DOWN out of that dark-green tint.
So after the deletions the page ran: atmospheric hero -> a completely flat
graph section -> a CTA whose green tint faded in from bare paper. Two hard
seams, one on each side of the new section.
The graph section now carries its own blobs at lower opacity than the hero's,
so the graph itself stays the focus, and the CTA's wash starts transparent and
peaks below the boundary instead of on it.
Found by spinning up the dev server and looking at the whole page, not the
section in isolation — the section screenshots I had been judging could not
show a seam, because a seam only exists between two things.
94 graph tests pass; tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): three feature bands and a bento of built surfaces (#344)
Step 2 of the below-hero redesign. The page now runs hero → graph →
Universal Upload → Adaptive Quizzes → bento → Spaced Repetition → CTA.
The three bands carry ONE arc — material in → practice → retention —
rather than three disconnected pitches, and the closing band (not a grid
tile) hands off to the CTA: a grid's last tile is a weak place to ask for
a signup. Surfaces alternate sides, derived from position in
`featureBands.tsx` rather than written down per band, so inserting a band
can't silently put two surfaces in the same gutter.
Every tile is a RECREATED PRODUCT SURFACE, never an icon over a heading
over a sentence — the brand guide's hard anti-pattern. The seven
recreations are faithful to the shipped screens: the upload modal's file
rows and status wording, QuizPanel's radiogroup with its `A.` prefix and
selected treatment, Study's rating trio, ChatPanel's asymmetric bubbles,
the notetaker's linked-concept rail, Social's invite chip and sender
names, and the gradebook's letter grade over real assignment rows.
Brand constraints that shaped the code rather than just the CSS:
- No glassmorphism. `.liquid-glass` is deliberately not reused; surfaces
are solid warm paper with hairline borders.
- Colour is state. Every mastery mark reads `TIER_COLOR` — the same map
the graph section above paints its nodes with — and the only other hues
are `--grade-*`. Unit-tested, so a raw hex can't creep back in.
- Contrast forced two divergences from the app's own paint: the student
bubble takes `--brand-forest` (6.4:1 under white) not `--accent`
(4.04:1), and per-row grade letters keep `--text` lettering with the
band on the border/dot, since `--grade-b` is 3.39:1 as a glyph.
No new dependency, no framer-motion, no JS motion at all: the surfaces
are static pictures, so `prefers-reduced-motion` and `IS_TEST_MODE` have
no frame to park — there is only the complete one. The entrance is the
page's existing `.landing-fade-up` observer, which degrades to "visible"
rather than "invisible" when it never fires.
All new CSS is inside the `.landing-page` scope and defines no tokens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): give the knowledge graph product chrome, a dial and a rail (#344)
The graph section was the least convincing thing on a page whose bands and
bento each recreate a whole product screen. It was six flat circles and six
words, floating naked on the page background, left-aligned in a 1184px
container with the right 40% of the field empty, and its entire hover payoff
was one line of body text swapping under the drawing.
It is now one surface, full container width, split the way the app's own Tree
screen is split.
- CHROME. `KNOWLEDGE GRAPH` in the bento's mono micro-label, and the selected
course's real numbers on the right — `MA 242 · 19 concepts · 50% mastery`,
all read off the fixture.
- THE NODES ARE DIALS. A tier-tinted disc at the full node radius, a neutral
track ring, the mastery arc swept clockwise from 12 o'clock, and a solid
core. Every dimension is measured INWARD from `nodeRadius`, so the drawn
footprint is byte-identical to the flat disc it replaces: `fitViewBox`,
`labelBaselineY` and the phone's 5.40-unit label clearance are untouched,
and a node group's first `<circle>` is still the tier-painted disc the E2E
legibility gate measures.
- A LEGEND, which is a comprehension fix rather than decoration: four
`--state-*` hues were carrying the whole meaning of the picture and nothing
on the page said what any of them meant. It carries counts, so it reads as a
readout, and it names the tiers in the app's own words.
- AN INSPECTOR RAIL replaces the bare hover line: name, tier as a labelled
chip, blurb, mastery meter, and the neighbours listed with their own scores,
with the hovered node's edges lit in the canvas beside it. Never empty — at
rest it shows the course.
- AMBIENT DRIFT, ~3 units on a 13–22s per-node cycle with a negative delay so
nothing starts in phase. A CSS animation, not a rAF loop: it stays out of
the assembly's frame budget (which the suite counts to prove the helix
neither fires early nor replays), and rides the same `parked` switch, so
reduced-motion visitors and the E2E lane get nodes exactly on their
laid-out points. Off below the mobile breakpoint, where the frame pad is 2
units and the worst label clearance 5.40.
The fixtures gain a numeric `mastery` per node and a `conceptCount` per
course, held to the product's own cutoffs by a port of
`backend/config.py::get_mastery_tier`, so a ring can never say 90% while the
paint says "struggling" and the chrome's percentage can never contradict the
root's own dial.
The assembly, its viewport gate, the negative bottom rootMargin and the
never-blank derivation are untouched. The phone gate's three bars improve
(12.68 / 23.78 / 182.3 against 11 / 20 / 170) because the canvas is
full-bleed horizontally — deliberately, and the CSS says why.
576 tests green (+16), tsc clean, eslint clean on src/ and e2e/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 22, 2026
…566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack: this spec 7/7 in isolation, including
the new journey. The full suite is 48 passed / 1 failed, the failure
being gradebook.spec.ts:35, which is not this change: it sits earlier in
the same worker than landing-drag-field, so nothing here can reach it.
It has now failed 2 of 3 full-suite runs while passing 3 of 3 in
isolation. CI's two retries have been masking it. Whether that is a
suite-context flake or a real intermittent in enrollment resolution is
still open — the failing line is the `Exams` category, which exists only
on the fall-2025 enrollment, so the signature matches "resolved the
wrong enrollment" as readily as "rendered slowly". Handed to the session
working that area; not filed as a flake until the control run on clean
main says it is one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 22, 2026
…566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack, rebased onto main at 7863210.
Control first, on UNPATCHED main: 47 passed, 1 failed — and the one
failure is this journey, so the lane is still red on current main and
this is still the fix it needs. With the patch applied, two consecutive
full suites: 49 passed, 0 failed. The spec alone, three consecutive
runs: 7/7 each time.
That also closes out the gradebook.spec.ts:35 question raised against
the previous revision. It failed 2 of 3 full-suite runs then, always on
a tree carrying an in-flight #553, and I could not tell a suite-context
flake from a real intermittent in enrollment resolution. #553 has since
merged; across the three full suites above it passed every time. It has
not reproduced on current main, so there is nothing to file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
) (#568)
* test(e2e): measure a dropped node against the copy it is welded to (#566)
The scroll-follow half of "a dropped node stays where it was put, and
scrolls with the page" has been red on main since the landing-v5 port
(#524) merged, taking the whole e2e browser lane with it. It is not
flaky: three CI retries and every local run land within 0.2px of each
other, ~98px short of where the assertion expects the node.
The product is right and the journey was wrong. `faq` is the one section
in DragField's `TRACKS`, and `engine/sim.ts::syncClusters()` positions a
tracked cluster at `field.top + a.top + dy`, where
`dy = (track.top - field.top) - trackTop`. That collapses to
`cluster_y = track.top + const`: the cluster is welded to the FAQ
question column, which is `sticky; top:110`. Instrumented over the
journey's own 300px scroll:
scrollY +300.00
#faq top -300.00
track top -202.81 <- pinned at top:110 partway through
cluster top -202.80
probe node y -202.60
error 97.40 (CI: 98.2-98.4)
The column had exactly 202.81px of travel left before its pin, so ~97px
of the scroll happened with the copy held still and the cluster
correctly held still with it. Welding those clusters to their copy is
deliberate — 8bb3486 added it because they slid 374px out from under
the words they belong to.
So the assertion was measuring the one coupling this section does not
have. The other two scroll journeys in this file already measure
relatively — a ring against its own cluster, a cluster against its act's
stage — and both pass; this one reached for raw scrollY, on the single
cluster where that is untrue. It picked cluster 4 for elbow room, not
knowing `faq` is also the only tracked field.
Re-frames it against the weld instead, read from the field's own
`data-drag-track` so the test follows the product rather than
duplicating it: drop the weld and `weldTop()` falls back to the field,
which is the plain 1:1 page-scroll reference. The intent is unchanged —
placed, not detached — and it keeps its teeth: a guard that the weld
target really moved (>100px) plus the exact 300px scroll, so a node
pinned to the screen over a page that never scrolled still fails.
Pointing the same assertion at the untracked section reproduces the
97.40px failure, so it is load-bearing, not vacuous.
Verified on the full local stack: this spec 6/6 twice, full suite 47/48
with one unrelated gradebook flake that passed 3/3 on re-run (retries
are CI-only).
Closes#566
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(e2e): let the journey own its oracle, and put the 1:1 case back (#566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack, rebased onto main at 7863210.
Control first, on UNPATCHED main: 47 passed, 1 failed — and the one
failure is this journey, so the lane is still red on current main and
this is still the fix it needs. With the patch applied, two consecutive
full suites: 49 passed, 0 failed. The spec alone, three consecutive
runs: 7/7 each time.
That also closes out the gradebook.spec.ts:35 question raised against
the previous revision. It failed 2 of 3 full-suite runs then, always on
a tree carrying an in-flight #553, and I could not tell a suite-context
flake from a real intermittent in enrollment resolution. #553 has since
merged; across the three full suites above it passed every time. It has
not reproduced on current main, so there is nothing to file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

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

Add landing page with liquid glass effects - #4

Merged
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
feature/landing-page-liquid-glass
Mar 18, 2026
Merged

Add landing page with liquid glass effects#4
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
feature/landing-page-liquid-glass

Conversation

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Summary

  • New landing page at / with a marketing-focused design: 3D canvas knowledge graph background, scramble text hero animation, floating glass accent cards, feature cards grid, sticky scroll cinema "How It Works" section, animated stats counter bar, and a multi-step onboarding modal
  • Liquid glass visual system applied to all panel surfaces — frosted translucency with backdrop-filter: blur(), inner specular highlights, hover shimmer sweep animation, and ambient gradient blobs behind sections for depth
  • Dashboard moved from / to /dashboard with Navbar routing updated to hide on the landing page and redirect unauthenticated users appropriately
  • New fonts added: Playfair Display (hero headings), Inter (body), JetBrains Mono (labels/stats)

Files changed

FileChange
frontend/src/app/page.tsxReplaced dashboard with new landing page component
frontend/src/app/dashboard/page.tsxNew file — moved original dashboard here
frontend/src/app/globals.cssAdded landing page animations + liquid glass CSS system
frontend/src/app/layout.tsxAdded Inter, Playfair Display, JetBrains Mono font imports
frontend/src/components/Navbar.tsxHide on landing page, update dashboard link to /dashboard
frontend/package.jsonAdded lucide-react dependency

Test plan

  • Visit http://localhost:3000/ — landing page renders with hero animation, floating cards, and liquid glass effects
  • Scroll through all sections — features, sticky scroll cinema (3 steps transition), stats counter, CTA
  • Click "Get Started" / "Sign In" — onboarding modal opens with multi-step flow
  • Resize browser — responsive layout works on mobile/tablet/desktop
  • Sign in with Google — redirects to /dashboard (original dashboard)
  • Verify /dashboard route still works correctly for authenticated users
  • Check that Navbar is hidden on the landing page but visible on all other routes

Made with Cursor

…hboard
Replace the root route with a new marketing landing page featuring:
- Hero section with 3D canvas knowledge graph, scramble text animation,
and floating glass accent cards
- Feature cards grid with spotlight hover effects and floating animation
- Sticky scroll cinema "How It Works" section (3 steps)
- Stats counter bar with intersection observer triggers
- Onboarding modal with multi-step sign-up/sign-in flow
- Liquid glass visual system (frosted translucency, backdrop blur,
specular shimmer on hover) applied to all panel surfaces
- Ambient gradient blobs behind sections for glass depth
- Responsive design with Playfair Display, Inter, and JetBrains Mono fonts
Move the original dashboard to /dashboard route and update Navbar
routing to hide on the landing page and redirect accordingly.
Made-with: Cursor
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Mar 18, 2026

Copy link
Copy Markdown

Deploying sapling with Cloudflare Pages Cloudflare Pages

Latest commit:fcdb2e0
Status:⚡️ Build in progress...

View logs

…anges
Keep landing page at / (our branch) and update dashboard/page.tsx
with main's latest dashboard code including the color picker feature.
Made-with: Cursor
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 9d2620f into mainMar 18, 2026
1 check was pending
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez deleted the feature/landing-page-liquid-glass branch March 18, 2026 04:44
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request May 5, 2026
refactor(calendar): unify syllabus extraction onto agent (refactor #4)
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request Jun 13, 2026
…ment-notes
fix(security): encrypt syllabus-extracted assignment notes at write boundary (#126#4)
AndresL230 added a commit that referenced this pull request Aug 2, 2026
#344 phase 2) (#513)
* docs(spec): landing page below the hero (#344 phase 2)
Interactive knowledge graph replaces the 340vh scroll-jacked HowItWorks, then
three feature bands and a four-tile bento of real app surfaces.
The structural argument is a density rhythm — the graph is the densest thing on
the page, bands decompress, the bento re-energizes, and a closing band gives the
CTA a run-up rather than ending on a grid tile.
The content argument matters more than the visual one. Tutor chat, Notes,
Gradebook and Flashcards are shipped surfaces the current six-feature list never
mentions, so the page undersells the product more than it under-designs it. That
is the likeliest root of #344's 'feels generic'.
Live LLM generation on the public page was considered and rejected for now: most
convincing option, but it puts an unauthenticated billable endpoint on the
most-crawled page on the site. Recorded as a second pass behind a rate-limited
endpoint.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(plan): landing interactive graph, step 1 (#344)
Seven tasks: fixtures, layout+helix math, component, assembly, interaction,
page wiring with the HowItWorks/catalog deletions, and the e2e journey.
Self-review found one real gap and it is recorded in the plan rather than
papered over: the spec lists drag alongside hover and expand, and Task 5
implements hover and the copy fade only. Dragging needs a pointer-capture and
SVG coordinate-mapping decision (getScreenCTM) that deserves its own review
gate, so it is called out as 5b/5c rather than hidden inside a step.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): course graph fixtures for the interactive demo (#344)
* feat(landing): deterministic radial layout + helical entry path (#344)
* feat(landing): knowledge graph demo — chips and laid-out render (#344)
Static, fully laid-out KnowledgeGraphDemo component: a course-picker
chip row plus an inline SVG render of the selected course's graph via
radialLayout. This is the parked frame reduced-motion visitors and the
E2E lane get; the assembly animation is a later task.
Registers the landing-graph E2E surface (docs/frontend-testids.md +
eslint.config.mjs) and adds the .landing-page-scoped chip/copy CSS.
* feat(landing): helical assembly, parked under reduced motion (#344)
Adds the RAF-driven helical assembly to KnowledgeGraphDemo, extracted into
an AssemblingGraph child keyed by course id so switching courses remounts
(fresh progress state, unmount-driven RAF cleanup) instead of resetting
state inside an effect — avoids the react-hooks/set-state-in-effect
anti-pattern that the brief's literal snippet would have tripped.
Parked (progress=1, full opacity, laid out) whenever IS_TEST_MODE or
prefers-reduced-motion. Adds a guarded window.matchMedia stub to
vitest.setup.ts (jsdom has none) defaulting to reduced-motion=true, so
every KnowledgeGraphDemo unit test asserts the parked/complete frame —
the correct target for reduced-motion visitors and the E2E lane alike.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): SSR-safe reduced-motion detection for the graph demo (#344)
Fix round 1: reading window.matchMedia directly in KnowledgeGraphDemo's
render body computed "no preference" server-side (no window) but the real
value client-side, producing a genuine React hydration mismatch for any
reduced-motion visitor once this component mounts with SSR on.
Adds usePrefersReducedMotion (frontend/src/lib/usePrefersReducedMotion.ts),
following the same useSyncExternalStore + fixed getServerSnapshot pattern
useIsMobile.ts already established for this bug class. Server snapshot
defaults to true (assume reduced motion) rather than useIsMobile's false
default: whichever direction is wrong pays a cost, and defaulting true means
the cost lands on no-preference visitors (one extra replay of the entrance
animation) rather than reduced-motion visitors (who would otherwise see a
blank or mid-assembly graph on first paint).
Also switches AssemblingGraph's progress from raw state to a value derived
from `parked` at render time, so a post-hydration correction of `parked`
(without a remount) resolves correctly in both directions instead of
potentially getting stuck.
Adds a renderToString -> hydrateRoot regression test using different
matchMedia values across the two phases, verified to fail against the old
render-body-read pattern before confirming it passes against the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(landing): cold-start the reduced-motion store cache per test (#344)
Fix round 2: usePrefersReducedMotion's module-level MediaQueryList cache
(correct for production) survived across it() blocks within
KnowledgeGraphDemo.test.tsx, so the round-0 test 'parks fully assembled
when reduced motion is requested' inherited an earlier test's warmed
cache instead of its own local matchMedia override. It still passed, but
for the wrong reason -- false confidence, not regression protection.
Reviewer proof: inverting that test's override to report "no preference"
left the whole file green.
Adds a file-level beforeEach calling __resetReducedMotionStoreForTests()
so every test in the file cold-starts the cache and actually depends on
its own window.matchMedia. Removes the now-redundant reset from the
round-1 describe block's nested beforeEach (the mid-test reset between
its two installReducedMotion() calls stays, since a beforeEach only runs
once per test). Verified by the same inversion the reviewer used: fails
whole-file with the override flipped, passes again once restored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): graph hover blurbs and copy fade on engagement (#344)
Hover a node to reveal its concept blurb below the graph; the first
interaction fades the instructional copy for the rest of the session.
Both `hovered` and `engaged` live in the KnowledgeGraphDemo parent, not
in the AssemblingGraph child, since that child remounts (keyed by
graph.id) on every course switch. engaged must survive that remount;
hovered's home follows since the blurb paragraph renders in the parent
too. AssemblingGraph just gets onNodeEnter/onNodeLeave callback props.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): replace HowItWorks and the feature catalog with the graph demo (#344)
* test(e2e): journey for the landing knowledge graph (#344)
* fix(landing): viewport-gate the graph assembly, never render it blank (#344)
Final whole-branch review of the landing knowledge-graph demo turned up six
findings. They interact, so this is one wave.
#1 The helix played where nobody could see it. The assembly effect fired on
mount, so it burned its full 1100ms during the hydration window — under the
page's own intro overlay, alongside the hero canvas RAF — and every visitor
who scrolled down found progress === 1 and a static picture. Gate the RAF on
an IntersectionObserver on the section. The root margin is a NEGATIVE bottom
inset, not the positive lead-in that looks natural: the section sits directly
after a min-h-screen hero, so its top edge is at exactly 100vh and any
positive bottom margin re-creates the bug at scroll 0.
#6 No-preference first paint went blank. usePrefersReducedMotion correcting
its SSR-safe `true` to the real `false` dropped progress onto a raw
animatedProgress of 0 — a committed frame with the whole graph at opacity 0 —
and gating #1 would have turned that into a section that stays blank until
scrolled to. animatedProgress is now `number | null`, and null (the assembly
has never run) reads back as 1. Leaving the viewport mid-assembly settles on
the complete frame rather than freezing a half-faded one.
#4 The helix threw the outer ring outside the viewBox. `1 + (1 - e) * 0.9`
put depth-2 nodes at y = 693 against a 560-unit viewBox, chopped by the svg
viewport at opacity ≈ 0.5. It now contracts (0.55x → 1x) instead of
stretching, so the whole sweep lives inside the disc radialLayout already fits
to the frame. helixEntry(target, centre, 1) === target is untouched.
#3 Illegible on phones. One 900x560 viewBox at every width renders at 0.38
scale on a 390px viewport: 4.6 CSS px labels, a 213px-tall smudge. Added a
GraphView descriptor and a 360x300 phone view selected by useIsMobile —
0.95 scale, 12.35 CSS px labels, 22.8px dots, 285px tall.
#2 The engaged copy fade failed WCAG AA permanently (engaged never resets).
--text at 0.35 over the paper bg is 2.20:1 against a 3:1 bar. The fade moves
to the headline alone at 0.55 (3.88:1). The eyebrow is not faded: at 0.7rem
it needs 4.5:1, and --brand-forest only holds that to alpha 0.86 — the
review's suggested 0.75 is 3.58:1 and still fails.
#5 The named SSR guard didn't guard. public-seo.spec.ts asserted only
Metadata API output, which survives `ssr: false` on the dynamic import. Added
a raw-HTML assertion on the graph section's server-rendered copy.
Tests: layout.test.ts sweeps every node of every fixture across the whole t
range in both views and asserts circle + label extents stay in frame (this is
the test that would have caught #4); KnowledgeGraphDemo.test.tsx drives a fake
IntersectionObserver and captured rAF to pin "complete before armed, never
blank", "no RAF until on screen", and the WCAG ratios computed from the
rendered opacity; landing-graph.spec.ts measures label/dot/height in CSS px at
390x844. Each was confirmed to fail against the pre-fix code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): brand-conform the graph section and fit its frame (#344)
Five visual/brand defects found by screenshotting the built page against the
brand guide. Every automated gate was already green, so none of these were
test-detectable.
1. TIER_COLOR was a fourth inlined copy of the mastery palette with four wrong
literals — the landing page advertised different mastery colours than the
product. Now consumes the canonical --state-* tokens (globals.css:80-89).
The hero legend's four swatches get the same treatment (colours only).
2. The course root inherited its fixture tier's amber, so the section's focal
point read as a warning. It now paints --brand-forest as an anchor; the
fixture tier is unchanged.
3. The SVG used a hardcoded viewBox="0 0 900 560" stretched to a ~1184px
container, reserving ~737px of height around content that clustered in the
middle. The frame is now DERIVED (fitViewBox) from the drawn content — dot,
label, halo — swept across the whole entry animation, unioned over all three
fixtures so a chip click can't change the section height, once per
breakpoint. Desktop resolves to "159 42 578 498", 36% narrower than the box
it replaced.
Fitted to the HELIX SWEEP, not the settled positions: helixEntry rotates
1.5 turns, so an outer node passes 0.925*maxRadius above and below the
centre at ~83% opacity — 215 units against a settled extent of 58. A
settled fit clips the assembly, which is #344 review #4 all over again. The
bounds test is retargeted at the derived box, and a new paired test proves
the sweep genuinely leaves the settled bounding box so that containment
check stays load-bearing.
The fit only pays off with a width cap — stretched to 1184px a tighter box
renders at 2x and makes the section taller. Capped at md:max-w-[720px].
4. The root's label lay along the outer-ring -> depth-1 edge, which crosses
the root's x at cy + 0.349*ring in every fixture. It moves above the node on
desktop; on the phone the ring is too small for that to clear the top
child's label, so it stays below (where the diagonal doesn't reach it). All
labels gain a paint-order halo in --bg-mesh. Two unreported collisions also
fixed: "Hypothesis Tests" overlapped "Distributions" by ~21 CSS px at 390px,
and the shipped phone geometry both overlapped and, under the fit, fell
under the E2E height gate. New suite pins label/label, label/dot and
label/edge clearance for every fixture in both views.
5. Left-aligned the graph to the headline's grid — the same width cap — so a
left-aligned copy block no longer sits above a centred diagram.
503 unit tests pass (up 6), tsc and eslint clean. No new CSS, no new dependency,
no assertion weakened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): take the outer ring off the horizontal axis (#344)
The ring phase was `offset = d % 2 === 0 ? step / 2 : 0` against a −π/2
start. Depth 2 holds exactly two nodes in all three fixtures, so
`step = π`, the half slot is `π/2`, and both outer nodes resolved to
exactly 0 and π — dead on the horizontal axis through the centre. The
settled layout was therefore a flat ellipse (aspect 0.40) inside a
near-circular entry sweep (0.86), and since the viewBox is fitted to the
sweep (correctly — a settled fit clips the assembly), the graph filled
only 45% of its own box height and left a dead band above and below it.
Every ring now starts three quarters of a slot back from 3 o'clock, so
its angles are odd multiples of `step/4 = π/(2·count)` and can never be a
multiple of π: no ring of any size flattens onto the horizontal axis. The
half-slot alternation is kept for what it was for — it now applies only
when a ring holds the same node count as the one inside it, which is the
only case that lines up into radial spokes.
Depth 1 is bit-identical to before (−3·(2π/3)/4 is exactly −π/2), so the
reviewed triangle and every constraint the mobile geometry was tuned
against are untouched. Depth 2 moves to the NW↔SE diagonal; the mirrored
diagonal is not equivalent — the fixtures hang their outer nodes off the
12 and 4 o'clock children, so SW↔NE drags an edge through the root label
(−7.2 units of overlap, measured).
Desktop, re-derived from the new geometry and still fitted to the sweep:
`159 42 578 498` → `185 33 526 516`; settled aspect 0.436/0.405/0.418 →
1.006/0.910/0.951; the drawing fills 73% of the frame height, up from
45% (515 of 706 rendered px, up from 277 of 620). Phone: `-19 5 394 319`
→ `-4 -1 364 330`, aspect 0.485/0.424/0.450 → 1.006/0.844/0.910, fill
48% → 73%, and every E2E legibility bar clears wider than before (12.8px
labels, 25.5px dots, 301px tall).
Worst label clearance is unchanged at +29.2 units desktop and +0.8
mobile — the new angles introduce no new binding pair. The "sweep leaves
the settled box" guard is restated in units per side (59.0/59.3 desktop,
33.2/33.9 mobile) instead of as a ratio: the ratio was a proxy that
shrinks precisely when the rest state stops being flat, which is the fix.
New tests pin the no-horizontal invariant over ring sizes 1–12, the
spoke-breaking branch, and the settled fill for every fixture × view.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): lay the graph out as a radial tree, not concentric rings (#344)
`radialLayout` placed nodes on global concentric rings: a node's angle came
from its index within its depth, with no relationship to where its parent
sat. On the shipped 6-node fixtures that scattered the graph — `cs-sorting`
landed 171 units from `cs-arrays` on a 232-unit ring, `cs-trees` was flung to
the opposite corner — and the picture read as a lopsided diagonal smear.
Tuning the ring angles cannot fix it; the previous wave tried.
Now: BFS a spanning tree over the undirected `edges` (never `children`, which
is dead data that disagrees with them), root at the centre, depth-1 spread
evenly around the circle, and every deeper node placed one ring step from ITS
OWN PARENT inside a wedge centred on that parent's outward direction. The tree
governs position only — every entry in `graph.edges` is still drawn, cross
edges included.
Tree-edge length, desktop: max 170.9 → 116.0, mean 128.5 → 116.0. The section
gets shorter at the 720px cap: 706px → 663px. The depth-1 ring phase moves
forward by one slot (`+step/4`, still an odd multiple, so the no-horizontal
proof is unchanged) so the two branch-bearing children sit at 4 and 8 o'clock
and grow downward, away from the root's label band.
`MOBILE_VIEW` is retuned, not optional: in a tree the flattest of three arms
is always 30° off horizontal, so a child's dot lands inside its parent's own
13-character label unless `0.866·ring > 3.9·font + halo/2 + nodeR`. The ring
grows 132 → 163 and `nodeR`/`fitPad` shrink to buy it; the phone renders at
11.2px labels / 20.9px dots / 285px tall, still over the E2E legibility gate
but with thin margins, documented at both ends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): grow the graph upward from its base, not down from the middle (#344)
The radial tree put the course root at the frame's centre and spread its
concepts around the full circle, so the drawing grew one arm up and two
down — an inverted Y, or a root system. The product is called Sapling and
the section headline is "Pick a course. Watch it grow."
Only the ANGULAR DOMAIN changes. The BFS spanning tree, the parent-relative
placement and the drawing of every edge (cross-edges included) are exactly
as the previous wave left them.
- depth-1 fans across the UPWARD half-plane, taking the interior gridlines
of an (n+1)-way split of it: 45/90/135 degrees for three children. Never
lands a child on the horizon at any fan size, and strictly further from
it than the previous rule at every size.
- seats are handed out outside-in, biggest subtree first, so the deep arms
get the open sky and the composition stays mirror-symmetric.
- the skeleton is laid out with the root at the origin and translated as a
rigid body until its own bounding box is centred on the layout centre —
the point helixEntry spirals around. The root ends up at the bottom-centre
of the content and the sweep-fitted frame stays centred on the drawing.
- both views retuned against the new geometry (the budget is now solved
from the type scale, the label clearances and the phone's legibility
floor, not inherited from the layout box), and the root's label moves
below its circle in both: above is now the direction the plant grows in.
Measured, desktop at the 720px cap: the <svg> is 637.6px tall against
662.8 (and 737 for the box this shipped with); cs210 fills 0.839 of the
frame's width against 0.828; the worst label clearance goes 8.80 -> 30.14
units. Phone: 11.99px labels and 22.48px dots against 11.23/20.85, and the
worst label clearance goes 0.99 -> 4.05 CSS px, retiring a margin that was
inside the noise of whether Chromium paints a scrollbar.
The frame's HEIGHT share does not improve (0.563 -> 0.532): fitViewBox
fits to the entry sweep, the sweep is very nearly a disc, so dead vertical
space is about (drawing width - drawing height)/2 for any layout and a
canopy is wider than tall. Documented at the assertion that was relaxed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): tighten the graph's entry sweep to a quarter turn (#344)
The frame is fitted to the helix sweep, not to the settled drawing — it has
to be, or the assembly gets clipped mid-flight (#344 review #4). At 1.5 turns
every node passed through every direction on the way in, so that sweep was
very nearly a DISC and the fitted frame very nearly SQUARE around a canopy
twice as wide as it is tall. The difference was dead paper: 162 and 136 CSS
px of it above and below the drawing, which the previous wave measured across
the whole layout family and logged as its closing concern — the lever is the
sweep, not the layout.
`helixEntry` now turns 0.25 (90°) and `ENTRY_CONTRACTION` is 1, so a node
starts on the centre and the radial term collapses to the straight-line
easing: the path is that line, rotated by a decaying quarter turn, and the
widest swing happens where the node is nearest the centre. No layout, fixture,
colour, copy, testid or component signature moved; every settled coordinate is
byte-identical.
desktop viewBox 161 17 603 534 -> 161 114 582 334
<svg> 720x637.6 -> 720x413.2 px (-224)
band/side 162.2/136.0 -> 48.1/13.4 px
phone viewBox -32 -25 443 365 -> -32 40 429 230
<svg> 332x273.5 -> 332x178.0 px (-95)
band/side 63.7/54.2 -> 15.5/1.8 px
The drawing did not shrink to get there — it grew, because the narrower frame
renders every unit bigger: 669x339 -> 694x352 CSS px on desktop, 12.0 -> 12.4
px phone labels, 22.5 -> 23.2 px dots. Worst label clearance is unchanged in
units (30.14 desktop, 5.40 phone) and better in pixels (36.0 -> 37.3, 4.05 ->
4.18).
Tests: the mid-flight helix assertion is restated as shape rather than
distance (>0.1 travel radii off the straight line, >5 deg of bearing swing;
measured 0.172 and 11.25 deg) so it bites at 0.146 turns instead of passing
anything; the sweep-vs-settled guard now asserts the top overhang with a
number and builds the settled-fit box to show it clips, because the overhang
is one-sided by construction (the fan's tips lift past the settled top when
they rotate through the vertical; nothing can swing below the course code);
the frame-height share floor goes 0.50 -> 0.75 (measured 0.851/0.903); and the
phone's third bar stops measuring the FRAME's height (it was passing on the
band) and measures the drawing's, with a frame floor kept at 170px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): restore atmospheric continuity between the sections (#344)
Deleting #features and HowItWorks took the middle of the page's atmosphere
with them, and nothing replaced it.
The hero carries mesh-blob--1 and --2. The old #features carried --3 and --2,
and HowItWorks carried a dark-green scroll tint. The CTA carries --1 and --2
PLUS a top gradient that started at rgba(20,83,45,0.08) on its very first pixel
— because it was designed to blend DOWN out of that dark-green tint.
So after the deletions the page ran: atmospheric hero -> a completely flat
graph section -> a CTA whose green tint faded in from bare paper. Two hard
seams, one on each side of the new section.
The graph section now carries its own blobs at lower opacity than the hero's,
so the graph itself stays the focus, and the CTA's wash starts transparent and
peaks below the boundary instead of on it.
Found by spinning up the dev server and looking at the whole page, not the
section in isolation — the section screenshots I had been judging could not
show a seam, because a seam only exists between two things.
94 graph tests pass; tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): three feature bands and a bento of built surfaces (#344)
Step 2 of the below-hero redesign. The page now runs hero → graph →
Universal Upload → Adaptive Quizzes → bento → Spaced Repetition → CTA.
The three bands carry ONE arc — material in → practice → retention —
rather than three disconnected pitches, and the closing band (not a grid
tile) hands off to the CTA: a grid's last tile is a weak place to ask for
a signup. Surfaces alternate sides, derived from position in
`featureBands.tsx` rather than written down per band, so inserting a band
can't silently put two surfaces in the same gutter.
Every tile is a RECREATED PRODUCT SURFACE, never an icon over a heading
over a sentence — the brand guide's hard anti-pattern. The seven
recreations are faithful to the shipped screens: the upload modal's file
rows and status wording, QuizPanel's radiogroup with its `A.` prefix and
selected treatment, Study's rating trio, ChatPanel's asymmetric bubbles,
the notetaker's linked-concept rail, Social's invite chip and sender
names, and the gradebook's letter grade over real assignment rows.
Brand constraints that shaped the code rather than just the CSS:
- No glassmorphism. `.liquid-glass` is deliberately not reused; surfaces
are solid warm paper with hairline borders.
- Colour is state. Every mastery mark reads `TIER_COLOR` — the same map
the graph section above paints its nodes with — and the only other hues
are `--grade-*`. Unit-tested, so a raw hex can't creep back in.
- Contrast forced two divergences from the app's own paint: the student
bubble takes `--brand-forest` (6.4:1 under white) not `--accent`
(4.04:1), and per-row grade letters keep `--text` lettering with the
band on the border/dot, since `--grade-b` is 3.39:1 as a glyph.
No new dependency, no framer-motion, no JS motion at all: the surfaces
are static pictures, so `prefers-reduced-motion` and `IS_TEST_MODE` have
no frame to park — there is only the complete one. The entrance is the
page's existing `.landing-fade-up` observer, which degrades to "visible"
rather than "invisible" when it never fires.
All new CSS is inside the `.landing-page` scope and defines no tokens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): give the knowledge graph product chrome, a dial and a rail (#344)
The graph section was the least convincing thing on a page whose bands and
bento each recreate a whole product screen. It was six flat circles and six
words, floating naked on the page background, left-aligned in a 1184px
container with the right 40% of the field empty, and its entire hover payoff
was one line of body text swapping under the drawing.
It is now one surface, full container width, split the way the app's own Tree
screen is split.
- CHROME. `KNOWLEDGE GRAPH` in the bento's mono micro-label, and the selected
course's real numbers on the right — `MA 242 · 19 concepts · 50% mastery`,
all read off the fixture.
- THE NODES ARE DIALS. A tier-tinted disc at the full node radius, a neutral
track ring, the mastery arc swept clockwise from 12 o'clock, and a solid
core. Every dimension is measured INWARD from `nodeRadius`, so the drawn
footprint is byte-identical to the flat disc it replaces: `fitViewBox`,
`labelBaselineY` and the phone's 5.40-unit label clearance are untouched,
and a node group's first `<circle>` is still the tier-painted disc the E2E
legibility gate measures.
- A LEGEND, which is a comprehension fix rather than decoration: four
`--state-*` hues were carrying the whole meaning of the picture and nothing
on the page said what any of them meant. It carries counts, so it reads as a
readout, and it names the tiers in the app's own words.
- AN INSPECTOR RAIL replaces the bare hover line: name, tier as a labelled
chip, blurb, mastery meter, and the neighbours listed with their own scores,
with the hovered node's edges lit in the canvas beside it. Never empty — at
rest it shows the course.
- AMBIENT DRIFT, ~3 units on a 13–22s per-node cycle with a negative delay so
nothing starts in phase. A CSS animation, not a rAF loop: it stays out of
the assembly's frame budget (which the suite counts to prove the helix
neither fires early nor replays), and rides the same `parked` switch, so
reduced-motion visitors and the E2E lane get nodes exactly on their
laid-out points. Off below the mobile breakpoint, where the frame pad is 2
units and the worst label clearance 5.40.
The fixtures gain a numeric `mastery` per node and a `conceptCount` per
course, held to the product's own cutoffs by a port of
`backend/config.py::get_mastery_tier`, so a ring can never say 90% while the
paint says "struggling" and the chrome's percentage can never contradict the
root's own dial.
The assembly, its viewport gate, the negative bottom rootMargin and the
never-blank derivation are untouched. The phone gate's three bars improve
(12.68 / 23.78 / 182.3 against 11 / 20 / 170) because the canvas is
full-bleed horizontally — deliberately, and the CSS says why.
576 tests green (+16), tsc clean, eslint clean on src/ and e2e/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 22, 2026
…566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack: this spec 7/7 in isolation, including
the new journey. The full suite is 48 passed / 1 failed, the failure
being gradebook.spec.ts:35, which is not this change: it sits earlier in
the same worker than landing-drag-field, so nothing here can reach it.
It has now failed 2 of 3 full-suite runs while passing 3 of 3 in
isolation. CI's two retries have been masking it. Whether that is a
suite-context flake or a real intermittent in enrollment resolution is
still open — the failing line is the `Exams` category, which exists only
on the fall-2025 enrollment, so the signature matches "resolved the
wrong enrollment" as readily as "rendered slowly". Handed to the session
working that area; not filed as a flake until the control run on clean
main says it is one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 22, 2026
…566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack, rebased onto main at 7863210.
Control first, on UNPATCHED main: 47 passed, 1 failed — and the one
failure is this journey, so the lane is still red on current main and
this is still the fix it needs. With the patch applied, two consecutive
full suites: 49 passed, 0 failed. The spec alone, three consecutive
runs: 7/7 each time.
That also closes out the gradebook.spec.ts:35 question raised against
the previous revision. It failed 2 of 3 full-suite runs then, always on
a tree carrying an in-flight #553, and I could not tell a suite-context
flake from a real intermittent in enrollment resolution. #553 has since
merged; across the three full suites above it passed every time. It has
not reproduced on current main, so there is nothing to file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
) (#568)
* test(e2e): measure a dropped node against the copy it is welded to (#566)
The scroll-follow half of "a dropped node stays where it was put, and
scrolls with the page" has been red on main since the landing-v5 port
(#524) merged, taking the whole e2e browser lane with it. It is not
flaky: three CI retries and every local run land within 0.2px of each
other, ~98px short of where the assertion expects the node.
The product is right and the journey was wrong. `faq` is the one section
in DragField's `TRACKS`, and `engine/sim.ts::syncClusters()` positions a
tracked cluster at `field.top + a.top + dy`, where
`dy = (track.top - field.top) - trackTop`. That collapses to
`cluster_y = track.top + const`: the cluster is welded to the FAQ
question column, which is `sticky; top:110`. Instrumented over the
journey's own 300px scroll:
scrollY +300.00
#faq top -300.00
track top -202.81 <- pinned at top:110 partway through
cluster top -202.80
probe node y -202.60
error 97.40 (CI: 98.2-98.4)
The column had exactly 202.81px of travel left before its pin, so ~97px
of the scroll happened with the copy held still and the cluster
correctly held still with it. Welding those clusters to their copy is
deliberate — 8bb3486 added it because they slid 374px out from under
the words they belong to.
So the assertion was measuring the one coupling this section does not
have. The other two scroll journeys in this file already measure
relatively — a ring against its own cluster, a cluster against its act's
stage — and both pass; this one reached for raw scrollY, on the single
cluster where that is untrue. It picked cluster 4 for elbow room, not
knowing `faq` is also the only tracked field.
Re-frames it against the weld instead, read from the field's own
`data-drag-track` so the test follows the product rather than
duplicating it: drop the weld and `weldTop()` falls back to the field,
which is the plain 1:1 page-scroll reference. The intent is unchanged —
placed, not detached — and it keeps its teeth: a guard that the weld
target really moved (>100px) plus the exact 300px scroll, so a node
pinned to the screen over a page that never scrolled still fails.
Pointing the same assertion at the untracked section reproduces the
97.40px failure, so it is load-bearing, not vacuous.
Verified on the full local stack: this spec 6/6 twice, full suite 47/48
with one unrelated gradebook flake that passed 3/3 on re-run (retries
are CI-only).
Closes#566
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(e2e): let the journey own its oracle, and put the 1:1 case back (#566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack, rebased onto main at 7863210.
Control first, on UNPATCHED main: 47 passed, 1 failed — and the one
failure is this journey, so the lane is still red on current main and
this is still the fix it needs. With the patch applied, two consecutive
full suites: 49 passed, 0 failed. The spec alone, three consecutive
runs: 7/7 each time.
That also closes out the gradebook.spec.ts:35 question raised against
the previous revision. It failed 2 of 3 full-suite runs then, always on
a tree carrying an in-flight #553, and I could not tell a suite-context
flake from a real intermittent in enrollment resolution. #553 has since
merged; across the three full suites above it passed every time. It has
not reproduced on current main, so there is nothing to file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add landing page with liquid glass effects - #4

Merged
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
feature/landing-page-liquid-glass
Mar 18, 2026
Merged

Add landing page with liquid glass effects#4
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
feature/landing-page-liquid-glass

Conversation

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Summary

  • New landing page at / with a marketing-focused design: 3D canvas knowledge graph background, scramble text hero animation, floating glass accent cards, feature cards grid, sticky scroll cinema "How It Works" section, animated stats counter bar, and a multi-step onboarding modal
  • Liquid glass visual system applied to all panel surfaces — frosted translucency with backdrop-filter: blur(), inner specular highlights, hover shimmer sweep animation, and ambient gradient blobs behind sections for depth
  • Dashboard moved from / to /dashboard with Navbar routing updated to hide on the landing page and redirect unauthenticated users appropriately
  • New fonts added: Playfair Display (hero headings), Inter (body), JetBrains Mono (labels/stats)

Files changed

FileChange
frontend/src/app/page.tsxReplaced dashboard with new landing page component
frontend/src/app/dashboard/page.tsxNew file — moved original dashboard here
frontend/src/app/globals.cssAdded landing page animations + liquid glass CSS system
frontend/src/app/layout.tsxAdded Inter, Playfair Display, JetBrains Mono font imports
frontend/src/components/Navbar.tsxHide on landing page, update dashboard link to /dashboard
frontend/package.jsonAdded lucide-react dependency

Test plan

  • Visit http://localhost:3000/ — landing page renders with hero animation, floating cards, and liquid glass effects
  • Scroll through all sections — features, sticky scroll cinema (3 steps transition), stats counter, CTA
  • Click "Get Started" / "Sign In" — onboarding modal opens with multi-step flow
  • Resize browser — responsive layout works on mobile/tablet/desktop
  • Sign in with Google — redirects to /dashboard (original dashboard)
  • Verify /dashboard route still works correctly for authenticated users
  • Check that Navbar is hidden on the landing page but visible on all other routes

Made with Cursor

…hboard
Replace the root route with a new marketing landing page featuring:
- Hero section with 3D canvas knowledge graph, scramble text animation,
and floating glass accent cards
- Feature cards grid with spotlight hover effects and floating animation
- Sticky scroll cinema "How It Works" section (3 steps)
- Stats counter bar with intersection observer triggers
- Onboarding modal with multi-step sign-up/sign-in flow
- Liquid glass visual system (frosted translucency, backdrop blur,
specular shimmer on hover) applied to all panel surfaces
- Ambient gradient blobs behind sections for glass depth
- Responsive design with Playfair Display, Inter, and JetBrains Mono fonts
Move the original dashboard to /dashboard route and update Navbar
routing to hide on the landing page and redirect accordingly.
Made-with: Cursor
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Mar 18, 2026

Copy link
Copy Markdown

Deploying sapling with Cloudflare Pages Cloudflare Pages

Latest commit:fcdb2e0
Status:⚡️ Build in progress...

View logs

…anges
Keep landing page at / (our branch) and update dashboard/page.tsx
with main's latest dashboard code including the color picker feature.
Made-with: Cursor
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 9d2620f into mainMar 18, 2026
1 check was pending
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez deleted the feature/landing-page-liquid-glass branch March 18, 2026 04:44
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request May 5, 2026
refactor(calendar): unify syllabus extraction onto agent (refactor #4)
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request Jun 13, 2026
…ment-notes
fix(security): encrypt syllabus-extracted assignment notes at write boundary (#126#4)
AndresL230 added a commit that referenced this pull request Aug 2, 2026
#344 phase 2) (#513)
* docs(spec): landing page below the hero (#344 phase 2)
Interactive knowledge graph replaces the 340vh scroll-jacked HowItWorks, then
three feature bands and a four-tile bento of real app surfaces.
The structural argument is a density rhythm — the graph is the densest thing on
the page, bands decompress, the bento re-energizes, and a closing band gives the
CTA a run-up rather than ending on a grid tile.
The content argument matters more than the visual one. Tutor chat, Notes,
Gradebook and Flashcards are shipped surfaces the current six-feature list never
mentions, so the page undersells the product more than it under-designs it. That
is the likeliest root of #344's 'feels generic'.
Live LLM generation on the public page was considered and rejected for now: most
convincing option, but it puts an unauthenticated billable endpoint on the
most-crawled page on the site. Recorded as a second pass behind a rate-limited
endpoint.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(plan): landing interactive graph, step 1 (#344)
Seven tasks: fixtures, layout+helix math, component, assembly, interaction,
page wiring with the HowItWorks/catalog deletions, and the e2e journey.
Self-review found one real gap and it is recorded in the plan rather than
papered over: the spec lists drag alongside hover and expand, and Task 5
implements hover and the copy fade only. Dragging needs a pointer-capture and
SVG coordinate-mapping decision (getScreenCTM) that deserves its own review
gate, so it is called out as 5b/5c rather than hidden inside a step.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): course graph fixtures for the interactive demo (#344)
* feat(landing): deterministic radial layout + helical entry path (#344)
* feat(landing): knowledge graph demo — chips and laid-out render (#344)
Static, fully laid-out KnowledgeGraphDemo component: a course-picker
chip row plus an inline SVG render of the selected course's graph via
radialLayout. This is the parked frame reduced-motion visitors and the
E2E lane get; the assembly animation is a later task.
Registers the landing-graph E2E surface (docs/frontend-testids.md +
eslint.config.mjs) and adds the .landing-page-scoped chip/copy CSS.
* feat(landing): helical assembly, parked under reduced motion (#344)
Adds the RAF-driven helical assembly to KnowledgeGraphDemo, extracted into
an AssemblingGraph child keyed by course id so switching courses remounts
(fresh progress state, unmount-driven RAF cleanup) instead of resetting
state inside an effect — avoids the react-hooks/set-state-in-effect
anti-pattern that the brief's literal snippet would have tripped.
Parked (progress=1, full opacity, laid out) whenever IS_TEST_MODE or
prefers-reduced-motion. Adds a guarded window.matchMedia stub to
vitest.setup.ts (jsdom has none) defaulting to reduced-motion=true, so
every KnowledgeGraphDemo unit test asserts the parked/complete frame —
the correct target for reduced-motion visitors and the E2E lane alike.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): SSR-safe reduced-motion detection for the graph demo (#344)
Fix round 1: reading window.matchMedia directly in KnowledgeGraphDemo's
render body computed "no preference" server-side (no window) but the real
value client-side, producing a genuine React hydration mismatch for any
reduced-motion visitor once this component mounts with SSR on.
Adds usePrefersReducedMotion (frontend/src/lib/usePrefersReducedMotion.ts),
following the same useSyncExternalStore + fixed getServerSnapshot pattern
useIsMobile.ts already established for this bug class. Server snapshot
defaults to true (assume reduced motion) rather than useIsMobile's false
default: whichever direction is wrong pays a cost, and defaulting true means
the cost lands on no-preference visitors (one extra replay of the entrance
animation) rather than reduced-motion visitors (who would otherwise see a
blank or mid-assembly graph on first paint).
Also switches AssemblingGraph's progress from raw state to a value derived
from `parked` at render time, so a post-hydration correction of `parked`
(without a remount) resolves correctly in both directions instead of
potentially getting stuck.
Adds a renderToString -> hydrateRoot regression test using different
matchMedia values across the two phases, verified to fail against the old
render-body-read pattern before confirming it passes against the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(landing): cold-start the reduced-motion store cache per test (#344)
Fix round 2: usePrefersReducedMotion's module-level MediaQueryList cache
(correct for production) survived across it() blocks within
KnowledgeGraphDemo.test.tsx, so the round-0 test 'parks fully assembled
when reduced motion is requested' inherited an earlier test's warmed
cache instead of its own local matchMedia override. It still passed, but
for the wrong reason -- false confidence, not regression protection.
Reviewer proof: inverting that test's override to report "no preference"
left the whole file green.
Adds a file-level beforeEach calling __resetReducedMotionStoreForTests()
so every test in the file cold-starts the cache and actually depends on
its own window.matchMedia. Removes the now-redundant reset from the
round-1 describe block's nested beforeEach (the mid-test reset between
its two installReducedMotion() calls stays, since a beforeEach only runs
once per test). Verified by the same inversion the reviewer used: fails
whole-file with the override flipped, passes again once restored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): graph hover blurbs and copy fade on engagement (#344)
Hover a node to reveal its concept blurb below the graph; the first
interaction fades the instructional copy for the rest of the session.
Both `hovered` and `engaged` live in the KnowledgeGraphDemo parent, not
in the AssemblingGraph child, since that child remounts (keyed by
graph.id) on every course switch. engaged must survive that remount;
hovered's home follows since the blurb paragraph renders in the parent
too. AssemblingGraph just gets onNodeEnter/onNodeLeave callback props.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): replace HowItWorks and the feature catalog with the graph demo (#344)
* test(e2e): journey for the landing knowledge graph (#344)
* fix(landing): viewport-gate the graph assembly, never render it blank (#344)
Final whole-branch review of the landing knowledge-graph demo turned up six
findings. They interact, so this is one wave.
#1 The helix played where nobody could see it. The assembly effect fired on
mount, so it burned its full 1100ms during the hydration window — under the
page's own intro overlay, alongside the hero canvas RAF — and every visitor
who scrolled down found progress === 1 and a static picture. Gate the RAF on
an IntersectionObserver on the section. The root margin is a NEGATIVE bottom
inset, not the positive lead-in that looks natural: the section sits directly
after a min-h-screen hero, so its top edge is at exactly 100vh and any
positive bottom margin re-creates the bug at scroll 0.
#6 No-preference first paint went blank. usePrefersReducedMotion correcting
its SSR-safe `true` to the real `false` dropped progress onto a raw
animatedProgress of 0 — a committed frame with the whole graph at opacity 0 —
and gating #1 would have turned that into a section that stays blank until
scrolled to. animatedProgress is now `number | null`, and null (the assembly
has never run) reads back as 1. Leaving the viewport mid-assembly settles on
the complete frame rather than freezing a half-faded one.
#4 The helix threw the outer ring outside the viewBox. `1 + (1 - e) * 0.9`
put depth-2 nodes at y = 693 against a 560-unit viewBox, chopped by the svg
viewport at opacity ≈ 0.5. It now contracts (0.55x → 1x) instead of
stretching, so the whole sweep lives inside the disc radialLayout already fits
to the frame. helixEntry(target, centre, 1) === target is untouched.
#3 Illegible on phones. One 900x560 viewBox at every width renders at 0.38
scale on a 390px viewport: 4.6 CSS px labels, a 213px-tall smudge. Added a
GraphView descriptor and a 360x300 phone view selected by useIsMobile —
0.95 scale, 12.35 CSS px labels, 22.8px dots, 285px tall.
#2 The engaged copy fade failed WCAG AA permanently (engaged never resets).
--text at 0.35 over the paper bg is 2.20:1 against a 3:1 bar. The fade moves
to the headline alone at 0.55 (3.88:1). The eyebrow is not faded: at 0.7rem
it needs 4.5:1, and --brand-forest only holds that to alpha 0.86 — the
review's suggested 0.75 is 3.58:1 and still fails.
#5 The named SSR guard didn't guard. public-seo.spec.ts asserted only
Metadata API output, which survives `ssr: false` on the dynamic import. Added
a raw-HTML assertion on the graph section's server-rendered copy.
Tests: layout.test.ts sweeps every node of every fixture across the whole t
range in both views and asserts circle + label extents stay in frame (this is
the test that would have caught #4); KnowledgeGraphDemo.test.tsx drives a fake
IntersectionObserver and captured rAF to pin "complete before armed, never
blank", "no RAF until on screen", and the WCAG ratios computed from the
rendered opacity; landing-graph.spec.ts measures label/dot/height in CSS px at
390x844. Each was confirmed to fail against the pre-fix code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): brand-conform the graph section and fit its frame (#344)
Five visual/brand defects found by screenshotting the built page against the
brand guide. Every automated gate was already green, so none of these were
test-detectable.
1. TIER_COLOR was a fourth inlined copy of the mastery palette with four wrong
literals — the landing page advertised different mastery colours than the
product. Now consumes the canonical --state-* tokens (globals.css:80-89).
The hero legend's four swatches get the same treatment (colours only).
2. The course root inherited its fixture tier's amber, so the section's focal
point read as a warning. It now paints --brand-forest as an anchor; the
fixture tier is unchanged.
3. The SVG used a hardcoded viewBox="0 0 900 560" stretched to a ~1184px
container, reserving ~737px of height around content that clustered in the
middle. The frame is now DERIVED (fitViewBox) from the drawn content — dot,
label, halo — swept across the whole entry animation, unioned over all three
fixtures so a chip click can't change the section height, once per
breakpoint. Desktop resolves to "159 42 578 498", 36% narrower than the box
it replaced.
Fitted to the HELIX SWEEP, not the settled positions: helixEntry rotates
1.5 turns, so an outer node passes 0.925*maxRadius above and below the
centre at ~83% opacity — 215 units against a settled extent of 58. A
settled fit clips the assembly, which is #344 review #4 all over again. The
bounds test is retargeted at the derived box, and a new paired test proves
the sweep genuinely leaves the settled bounding box so that containment
check stays load-bearing.
The fit only pays off with a width cap — stretched to 1184px a tighter box
renders at 2x and makes the section taller. Capped at md:max-w-[720px].
4. The root's label lay along the outer-ring -> depth-1 edge, which crosses
the root's x at cy + 0.349*ring in every fixture. It moves above the node on
desktop; on the phone the ring is too small for that to clear the top
child's label, so it stays below (where the diagonal doesn't reach it). All
labels gain a paint-order halo in --bg-mesh. Two unreported collisions also
fixed: "Hypothesis Tests" overlapped "Distributions" by ~21 CSS px at 390px,
and the shipped phone geometry both overlapped and, under the fit, fell
under the E2E height gate. New suite pins label/label, label/dot and
label/edge clearance for every fixture in both views.
5. Left-aligned the graph to the headline's grid — the same width cap — so a
left-aligned copy block no longer sits above a centred diagram.
503 unit tests pass (up 6), tsc and eslint clean. No new CSS, no new dependency,
no assertion weakened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): take the outer ring off the horizontal axis (#344)
The ring phase was `offset = d % 2 === 0 ? step / 2 : 0` against a −π/2
start. Depth 2 holds exactly two nodes in all three fixtures, so
`step = π`, the half slot is `π/2`, and both outer nodes resolved to
exactly 0 and π — dead on the horizontal axis through the centre. The
settled layout was therefore a flat ellipse (aspect 0.40) inside a
near-circular entry sweep (0.86), and since the viewBox is fitted to the
sweep (correctly — a settled fit clips the assembly), the graph filled
only 45% of its own box height and left a dead band above and below it.
Every ring now starts three quarters of a slot back from 3 o'clock, so
its angles are odd multiples of `step/4 = π/(2·count)` and can never be a
multiple of π: no ring of any size flattens onto the horizontal axis. The
half-slot alternation is kept for what it was for — it now applies only
when a ring holds the same node count as the one inside it, which is the
only case that lines up into radial spokes.
Depth 1 is bit-identical to before (−3·(2π/3)/4 is exactly −π/2), so the
reviewed triangle and every constraint the mobile geometry was tuned
against are untouched. Depth 2 moves to the NW↔SE diagonal; the mirrored
diagonal is not equivalent — the fixtures hang their outer nodes off the
12 and 4 o'clock children, so SW↔NE drags an edge through the root label
(−7.2 units of overlap, measured).
Desktop, re-derived from the new geometry and still fitted to the sweep:
`159 42 578 498` → `185 33 526 516`; settled aspect 0.436/0.405/0.418 →
1.006/0.910/0.951; the drawing fills 73% of the frame height, up from
45% (515 of 706 rendered px, up from 277 of 620). Phone: `-19 5 394 319`
→ `-4 -1 364 330`, aspect 0.485/0.424/0.450 → 1.006/0.844/0.910, fill
48% → 73%, and every E2E legibility bar clears wider than before (12.8px
labels, 25.5px dots, 301px tall).
Worst label clearance is unchanged at +29.2 units desktop and +0.8
mobile — the new angles introduce no new binding pair. The "sweep leaves
the settled box" guard is restated in units per side (59.0/59.3 desktop,
33.2/33.9 mobile) instead of as a ratio: the ratio was a proxy that
shrinks precisely when the rest state stops being flat, which is the fix.
New tests pin the no-horizontal invariant over ring sizes 1–12, the
spoke-breaking branch, and the settled fill for every fixture × view.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): lay the graph out as a radial tree, not concentric rings (#344)
`radialLayout` placed nodes on global concentric rings: a node's angle came
from its index within its depth, with no relationship to where its parent
sat. On the shipped 6-node fixtures that scattered the graph — `cs-sorting`
landed 171 units from `cs-arrays` on a 232-unit ring, `cs-trees` was flung to
the opposite corner — and the picture read as a lopsided diagonal smear.
Tuning the ring angles cannot fix it; the previous wave tried.
Now: BFS a spanning tree over the undirected `edges` (never `children`, which
is dead data that disagrees with them), root at the centre, depth-1 spread
evenly around the circle, and every deeper node placed one ring step from ITS
OWN PARENT inside a wedge centred on that parent's outward direction. The tree
governs position only — every entry in `graph.edges` is still drawn, cross
edges included.
Tree-edge length, desktop: max 170.9 → 116.0, mean 128.5 → 116.0. The section
gets shorter at the 720px cap: 706px → 663px. The depth-1 ring phase moves
forward by one slot (`+step/4`, still an odd multiple, so the no-horizontal
proof is unchanged) so the two branch-bearing children sit at 4 and 8 o'clock
and grow downward, away from the root's label band.
`MOBILE_VIEW` is retuned, not optional: in a tree the flattest of three arms
is always 30° off horizontal, so a child's dot lands inside its parent's own
13-character label unless `0.866·ring > 3.9·font + halo/2 + nodeR`. The ring
grows 132 → 163 and `nodeR`/`fitPad` shrink to buy it; the phone renders at
11.2px labels / 20.9px dots / 285px tall, still over the E2E legibility gate
but with thin margins, documented at both ends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): grow the graph upward from its base, not down from the middle (#344)
The radial tree put the course root at the frame's centre and spread its
concepts around the full circle, so the drawing grew one arm up and two
down — an inverted Y, or a root system. The product is called Sapling and
the section headline is "Pick a course. Watch it grow."
Only the ANGULAR DOMAIN changes. The BFS spanning tree, the parent-relative
placement and the drawing of every edge (cross-edges included) are exactly
as the previous wave left them.
- depth-1 fans across the UPWARD half-plane, taking the interior gridlines
of an (n+1)-way split of it: 45/90/135 degrees for three children. Never
lands a child on the horizon at any fan size, and strictly further from
it than the previous rule at every size.
- seats are handed out outside-in, biggest subtree first, so the deep arms
get the open sky and the composition stays mirror-symmetric.
- the skeleton is laid out with the root at the origin and translated as a
rigid body until its own bounding box is centred on the layout centre —
the point helixEntry spirals around. The root ends up at the bottom-centre
of the content and the sweep-fitted frame stays centred on the drawing.
- both views retuned against the new geometry (the budget is now solved
from the type scale, the label clearances and the phone's legibility
floor, not inherited from the layout box), and the root's label moves
below its circle in both: above is now the direction the plant grows in.
Measured, desktop at the 720px cap: the <svg> is 637.6px tall against
662.8 (and 737 for the box this shipped with); cs210 fills 0.839 of the
frame's width against 0.828; the worst label clearance goes 8.80 -> 30.14
units. Phone: 11.99px labels and 22.48px dots against 11.23/20.85, and the
worst label clearance goes 0.99 -> 4.05 CSS px, retiring a margin that was
inside the noise of whether Chromium paints a scrollbar.
The frame's HEIGHT share does not improve (0.563 -> 0.532): fitViewBox
fits to the entry sweep, the sweep is very nearly a disc, so dead vertical
space is about (drawing width - drawing height)/2 for any layout and a
canopy is wider than tall. Documented at the assertion that was relaxed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): tighten the graph's entry sweep to a quarter turn (#344)
The frame is fitted to the helix sweep, not to the settled drawing — it has
to be, or the assembly gets clipped mid-flight (#344 review #4). At 1.5 turns
every node passed through every direction on the way in, so that sweep was
very nearly a DISC and the fitted frame very nearly SQUARE around a canopy
twice as wide as it is tall. The difference was dead paper: 162 and 136 CSS
px of it above and below the drawing, which the previous wave measured across
the whole layout family and logged as its closing concern — the lever is the
sweep, not the layout.
`helixEntry` now turns 0.25 (90°) and `ENTRY_CONTRACTION` is 1, so a node
starts on the centre and the radial term collapses to the straight-line
easing: the path is that line, rotated by a decaying quarter turn, and the
widest swing happens where the node is nearest the centre. No layout, fixture,
colour, copy, testid or component signature moved; every settled coordinate is
byte-identical.
desktop viewBox 161 17 603 534 -> 161 114 582 334
<svg> 720x637.6 -> 720x413.2 px (-224)
band/side 162.2/136.0 -> 48.1/13.4 px
phone viewBox -32 -25 443 365 -> -32 40 429 230
<svg> 332x273.5 -> 332x178.0 px (-95)
band/side 63.7/54.2 -> 15.5/1.8 px
The drawing did not shrink to get there — it grew, because the narrower frame
renders every unit bigger: 669x339 -> 694x352 CSS px on desktop, 12.0 -> 12.4
px phone labels, 22.5 -> 23.2 px dots. Worst label clearance is unchanged in
units (30.14 desktop, 5.40 phone) and better in pixels (36.0 -> 37.3, 4.05 ->
4.18).
Tests: the mid-flight helix assertion is restated as shape rather than
distance (>0.1 travel radii off the straight line, >5 deg of bearing swing;
measured 0.172 and 11.25 deg) so it bites at 0.146 turns instead of passing
anything; the sweep-vs-settled guard now asserts the top overhang with a
number and builds the settled-fit box to show it clips, because the overhang
is one-sided by construction (the fan's tips lift past the settled top when
they rotate through the vertical; nothing can swing below the course code);
the frame-height share floor goes 0.50 -> 0.75 (measured 0.851/0.903); and the
phone's third bar stops measuring the FRAME's height (it was passing on the
band) and measures the drawing's, with a frame floor kept at 170px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): restore atmospheric continuity between the sections (#344)
Deleting #features and HowItWorks took the middle of the page's atmosphere
with them, and nothing replaced it.
The hero carries mesh-blob--1 and --2. The old #features carried --3 and --2,
and HowItWorks carried a dark-green scroll tint. The CTA carries --1 and --2
PLUS a top gradient that started at rgba(20,83,45,0.08) on its very first pixel
— because it was designed to blend DOWN out of that dark-green tint.
So after the deletions the page ran: atmospheric hero -> a completely flat
graph section -> a CTA whose green tint faded in from bare paper. Two hard
seams, one on each side of the new section.
The graph section now carries its own blobs at lower opacity than the hero's,
so the graph itself stays the focus, and the CTA's wash starts transparent and
peaks below the boundary instead of on it.
Found by spinning up the dev server and looking at the whole page, not the
section in isolation — the section screenshots I had been judging could not
show a seam, because a seam only exists between two things.
94 graph tests pass; tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): three feature bands and a bento of built surfaces (#344)
Step 2 of the below-hero redesign. The page now runs hero → graph →
Universal Upload → Adaptive Quizzes → bento → Spaced Repetition → CTA.
The three bands carry ONE arc — material in → practice → retention —
rather than three disconnected pitches, and the closing band (not a grid
tile) hands off to the CTA: a grid's last tile is a weak place to ask for
a signup. Surfaces alternate sides, derived from position in
`featureBands.tsx` rather than written down per band, so inserting a band
can't silently put two surfaces in the same gutter.
Every tile is a RECREATED PRODUCT SURFACE, never an icon over a heading
over a sentence — the brand guide's hard anti-pattern. The seven
recreations are faithful to the shipped screens: the upload modal's file
rows and status wording, QuizPanel's radiogroup with its `A.` prefix and
selected treatment, Study's rating trio, ChatPanel's asymmetric bubbles,
the notetaker's linked-concept rail, Social's invite chip and sender
names, and the gradebook's letter grade over real assignment rows.
Brand constraints that shaped the code rather than just the CSS:
- No glassmorphism. `.liquid-glass` is deliberately not reused; surfaces
are solid warm paper with hairline borders.
- Colour is state. Every mastery mark reads `TIER_COLOR` — the same map
the graph section above paints its nodes with — and the only other hues
are `--grade-*`. Unit-tested, so a raw hex can't creep back in.
- Contrast forced two divergences from the app's own paint: the student
bubble takes `--brand-forest` (6.4:1 under white) not `--accent`
(4.04:1), and per-row grade letters keep `--text` lettering with the
band on the border/dot, since `--grade-b` is 3.39:1 as a glyph.
No new dependency, no framer-motion, no JS motion at all: the surfaces
are static pictures, so `prefers-reduced-motion` and `IS_TEST_MODE` have
no frame to park — there is only the complete one. The entrance is the
page's existing `.landing-fade-up` observer, which degrades to "visible"
rather than "invisible" when it never fires.
All new CSS is inside the `.landing-page` scope and defines no tokens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): give the knowledge graph product chrome, a dial and a rail (#344)
The graph section was the least convincing thing on a page whose bands and
bento each recreate a whole product screen. It was six flat circles and six
words, floating naked on the page background, left-aligned in a 1184px
container with the right 40% of the field empty, and its entire hover payoff
was one line of body text swapping under the drawing.
It is now one surface, full container width, split the way the app's own Tree
screen is split.
- CHROME. `KNOWLEDGE GRAPH` in the bento's mono micro-label, and the selected
course's real numbers on the right — `MA 242 · 19 concepts · 50% mastery`,
all read off the fixture.
- THE NODES ARE DIALS. A tier-tinted disc at the full node radius, a neutral
track ring, the mastery arc swept clockwise from 12 o'clock, and a solid
core. Every dimension is measured INWARD from `nodeRadius`, so the drawn
footprint is byte-identical to the flat disc it replaces: `fitViewBox`,
`labelBaselineY` and the phone's 5.40-unit label clearance are untouched,
and a node group's first `<circle>` is still the tier-painted disc the E2E
legibility gate measures.
- A LEGEND, which is a comprehension fix rather than decoration: four
`--state-*` hues were carrying the whole meaning of the picture and nothing
on the page said what any of them meant. It carries counts, so it reads as a
readout, and it names the tiers in the app's own words.
- AN INSPECTOR RAIL replaces the bare hover line: name, tier as a labelled
chip, blurb, mastery meter, and the neighbours listed with their own scores,
with the hovered node's edges lit in the canvas beside it. Never empty — at
rest it shows the course.
- AMBIENT DRIFT, ~3 units on a 13–22s per-node cycle with a negative delay so
nothing starts in phase. A CSS animation, not a rAF loop: it stays out of
the assembly's frame budget (which the suite counts to prove the helix
neither fires early nor replays), and rides the same `parked` switch, so
reduced-motion visitors and the E2E lane get nodes exactly on their
laid-out points. Off below the mobile breakpoint, where the frame pad is 2
units and the worst label clearance 5.40.
The fixtures gain a numeric `mastery` per node and a `conceptCount` per
course, held to the product's own cutoffs by a port of
`backend/config.py::get_mastery_tier`, so a ring can never say 90% while the
paint says "struggling" and the chrome's percentage can never contradict the
root's own dial.
The assembly, its viewport gate, the negative bottom rootMargin and the
never-blank derivation are untouched. The phone gate's three bars improve
(12.68 / 23.78 / 182.3 against 11 / 20 / 170) because the canvas is
full-bleed horizontally — deliberately, and the CSS says why.
576 tests green (+16), tsc clean, eslint clean on src/ and e2e/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 22, 2026
…566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack: this spec 7/7 in isolation, including
the new journey. The full suite is 48 passed / 1 failed, the failure
being gradebook.spec.ts:35, which is not this change: it sits earlier in
the same worker than landing-drag-field, so nothing here can reach it.
It has now failed 2 of 3 full-suite runs while passing 3 of 3 in
isolation. CI's two retries have been masking it. Whether that is a
suite-context flake or a real intermittent in enrollment resolution is
still open — the failing line is the `Exams` category, which exists only
on the fall-2025 enrollment, so the signature matches "resolved the
wrong enrollment" as readily as "rendered slowly". Handed to the session
working that area; not filed as a flake until the control run on clean
main says it is one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 22, 2026
…566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack, rebased onto main at 7863210.
Control first, on UNPATCHED main: 47 passed, 1 failed — and the one
failure is this journey, so the lane is still red on current main and
this is still the fix it needs. With the patch applied, two consecutive
full suites: 49 passed, 0 failed. The spec alone, three consecutive
runs: 7/7 each time.
That also closes out the gradebook.spec.ts:35 question raised against
the previous revision. It failed 2 of 3 full-suite runs then, always on
a tree carrying an in-flight #553, and I could not tell a suite-context
flake from a real intermittent in enrollment resolution. #553 has since
merged; across the three full suites above it passed every time. It has
not reproduced on current main, so there is nothing to file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
) (#568)
* test(e2e): measure a dropped node against the copy it is welded to (#566)
The scroll-follow half of "a dropped node stays where it was put, and
scrolls with the page" has been red on main since the landing-v5 port
(#524) merged, taking the whole e2e browser lane with it. It is not
flaky: three CI retries and every local run land within 0.2px of each
other, ~98px short of where the assertion expects the node.
The product is right and the journey was wrong. `faq` is the one section
in DragField's `TRACKS`, and `engine/sim.ts::syncClusters()` positions a
tracked cluster at `field.top + a.top + dy`, where
`dy = (track.top - field.top) - trackTop`. That collapses to
`cluster_y = track.top + const`: the cluster is welded to the FAQ
question column, which is `sticky; top:110`. Instrumented over the
journey's own 300px scroll:
scrollY +300.00
#faq top -300.00
track top -202.81 <- pinned at top:110 partway through
cluster top -202.80
probe node y -202.60
error 97.40 (CI: 98.2-98.4)
The column had exactly 202.81px of travel left before its pin, so ~97px
of the scroll happened with the copy held still and the cluster
correctly held still with it. Welding those clusters to their copy is
deliberate — 8bb3486 added it because they slid 374px out from under
the words they belong to.
So the assertion was measuring the one coupling this section does not
have. The other two scroll journeys in this file already measure
relatively — a ring against its own cluster, a cluster against its act's
stage — and both pass; this one reached for raw scrollY, on the single
cluster where that is untrue. It picked cluster 4 for elbow room, not
knowing `faq` is also the only tracked field.
Re-frames it against the weld instead, read from the field's own
`data-drag-track` so the test follows the product rather than
duplicating it: drop the weld and `weldTop()` falls back to the field,
which is the plain 1:1 page-scroll reference. The intent is unchanged —
placed, not detached — and it keeps its teeth: a guard that the weld
target really moved (>100px) plus the exact 300px scroll, so a node
pinned to the screen over a page that never scrolled still fails.
Pointing the same assertion at the untracked section reproduces the
97.40px failure, so it is load-bearing, not vacuous.
Verified on the full local stack: this spec 6/6 twice, full suite 47/48
with one unrelated gradebook flake that passed 3/3 on re-run (retries
are CI-only).
Closes#566
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(e2e): let the journey own its oracle, and put the 1:1 case back (#566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack, rebased onto main at 7863210.
Control first, on UNPATCHED main: 47 passed, 1 failed — and the one
failure is this journey, so the lane is still red on current main and
this is still the fix it needs. With the patch applied, two consecutive
full suites: 49 passed, 0 failed. The spec alone, three consecutive
runs: 7/7 each time.
That also closes out the gradebook.spec.ts:35 question raised against
the previous revision. It failed 2 of 3 full-suite runs then, always on
a tree carrying an in-flight #553, and I could not tell a suite-context
flake from a real intermittent in enrollment resolution. #553 has since
merged; across the three full suites above it passed every time. It has
not reproduced on current main, so there is nothing to file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add landing page with liquid glass effects - #4

Merged
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
feature/landing-page-liquid-glass
Mar 18, 2026
Merged

Add landing page with liquid glass effects#4
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
feature/landing-page-liquid-glass

Conversation

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Summary

  • New landing page at / with a marketing-focused design: 3D canvas knowledge graph background, scramble text hero animation, floating glass accent cards, feature cards grid, sticky scroll cinema "How It Works" section, animated stats counter bar, and a multi-step onboarding modal
  • Liquid glass visual system applied to all panel surfaces — frosted translucency with backdrop-filter: blur(), inner specular highlights, hover shimmer sweep animation, and ambient gradient blobs behind sections for depth
  • Dashboard moved from / to /dashboard with Navbar routing updated to hide on the landing page and redirect unauthenticated users appropriately
  • New fonts added: Playfair Display (hero headings), Inter (body), JetBrains Mono (labels/stats)

Files changed

FileChange
frontend/src/app/page.tsxReplaced dashboard with new landing page component
frontend/src/app/dashboard/page.tsxNew file — moved original dashboard here
frontend/src/app/globals.cssAdded landing page animations + liquid glass CSS system
frontend/src/app/layout.tsxAdded Inter, Playfair Display, JetBrains Mono font imports
frontend/src/components/Navbar.tsxHide on landing page, update dashboard link to /dashboard
frontend/package.jsonAdded lucide-react dependency

Test plan

  • Visit http://localhost:3000/ — landing page renders with hero animation, floating cards, and liquid glass effects
  • Scroll through all sections — features, sticky scroll cinema (3 steps transition), stats counter, CTA
  • Click "Get Started" / "Sign In" — onboarding modal opens with multi-step flow
  • Resize browser — responsive layout works on mobile/tablet/desktop
  • Sign in with Google — redirects to /dashboard (original dashboard)
  • Verify /dashboard route still works correctly for authenticated users
  • Check that Navbar is hidden on the landing page but visible on all other routes

Made with Cursor

…hboard
Replace the root route with a new marketing landing page featuring:
- Hero section with 3D canvas knowledge graph, scramble text animation,
and floating glass accent cards
- Feature cards grid with spotlight hover effects and floating animation
- Sticky scroll cinema "How It Works" section (3 steps)
- Stats counter bar with intersection observer triggers
- Onboarding modal with multi-step sign-up/sign-in flow
- Liquid glass visual system (frosted translucency, backdrop blur,
specular shimmer on hover) applied to all panel surfaces
- Ambient gradient blobs behind sections for glass depth
- Responsive design with Playfair Display, Inter, and JetBrains Mono fonts
Move the original dashboard to /dashboard route and update Navbar
routing to hide on the landing page and redirect accordingly.
Made-with: Cursor
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Mar 18, 2026

Copy link
Copy Markdown

Deploying sapling with Cloudflare Pages Cloudflare Pages

Latest commit:fcdb2e0
Status:⚡️ Build in progress...

View logs

…anges
Keep landing page at / (our branch) and update dashboard/page.tsx
with main's latest dashboard code including the color picker feature.
Made-with: Cursor
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 9d2620f into mainMar 18, 2026
1 check was pending
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez deleted the feature/landing-page-liquid-glass branch March 18, 2026 04:44
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request May 5, 2026
refactor(calendar): unify syllabus extraction onto agent (refactor #4)
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request Jun 13, 2026
…ment-notes
fix(security): encrypt syllabus-extracted assignment notes at write boundary (#126#4)
AndresL230 added a commit that referenced this pull request Aug 2, 2026
#344 phase 2) (#513)
* docs(spec): landing page below the hero (#344 phase 2)
Interactive knowledge graph replaces the 340vh scroll-jacked HowItWorks, then
three feature bands and a four-tile bento of real app surfaces.
The structural argument is a density rhythm — the graph is the densest thing on
the page, bands decompress, the bento re-energizes, and a closing band gives the
CTA a run-up rather than ending on a grid tile.
The content argument matters more than the visual one. Tutor chat, Notes,
Gradebook and Flashcards are shipped surfaces the current six-feature list never
mentions, so the page undersells the product more than it under-designs it. That
is the likeliest root of #344's 'feels generic'.
Live LLM generation on the public page was considered and rejected for now: most
convincing option, but it puts an unauthenticated billable endpoint on the
most-crawled page on the site. Recorded as a second pass behind a rate-limited
endpoint.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(plan): landing interactive graph, step 1 (#344)
Seven tasks: fixtures, layout+helix math, component, assembly, interaction,
page wiring with the HowItWorks/catalog deletions, and the e2e journey.
Self-review found one real gap and it is recorded in the plan rather than
papered over: the spec lists drag alongside hover and expand, and Task 5
implements hover and the copy fade only. Dragging needs a pointer-capture and
SVG coordinate-mapping decision (getScreenCTM) that deserves its own review
gate, so it is called out as 5b/5c rather than hidden inside a step.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): course graph fixtures for the interactive demo (#344)
* feat(landing): deterministic radial layout + helical entry path (#344)
* feat(landing): knowledge graph demo — chips and laid-out render (#344)
Static, fully laid-out KnowledgeGraphDemo component: a course-picker
chip row plus an inline SVG render of the selected course's graph via
radialLayout. This is the parked frame reduced-motion visitors and the
E2E lane get; the assembly animation is a later task.
Registers the landing-graph E2E surface (docs/frontend-testids.md +
eslint.config.mjs) and adds the .landing-page-scoped chip/copy CSS.
* feat(landing): helical assembly, parked under reduced motion (#344)
Adds the RAF-driven helical assembly to KnowledgeGraphDemo, extracted into
an AssemblingGraph child keyed by course id so switching courses remounts
(fresh progress state, unmount-driven RAF cleanup) instead of resetting
state inside an effect — avoids the react-hooks/set-state-in-effect
anti-pattern that the brief's literal snippet would have tripped.
Parked (progress=1, full opacity, laid out) whenever IS_TEST_MODE or
prefers-reduced-motion. Adds a guarded window.matchMedia stub to
vitest.setup.ts (jsdom has none) defaulting to reduced-motion=true, so
every KnowledgeGraphDemo unit test asserts the parked/complete frame —
the correct target for reduced-motion visitors and the E2E lane alike.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): SSR-safe reduced-motion detection for the graph demo (#344)
Fix round 1: reading window.matchMedia directly in KnowledgeGraphDemo's
render body computed "no preference" server-side (no window) but the real
value client-side, producing a genuine React hydration mismatch for any
reduced-motion visitor once this component mounts with SSR on.
Adds usePrefersReducedMotion (frontend/src/lib/usePrefersReducedMotion.ts),
following the same useSyncExternalStore + fixed getServerSnapshot pattern
useIsMobile.ts already established for this bug class. Server snapshot
defaults to true (assume reduced motion) rather than useIsMobile's false
default: whichever direction is wrong pays a cost, and defaulting true means
the cost lands on no-preference visitors (one extra replay of the entrance
animation) rather than reduced-motion visitors (who would otherwise see a
blank or mid-assembly graph on first paint).
Also switches AssemblingGraph's progress from raw state to a value derived
from `parked` at render time, so a post-hydration correction of `parked`
(without a remount) resolves correctly in both directions instead of
potentially getting stuck.
Adds a renderToString -> hydrateRoot regression test using different
matchMedia values across the two phases, verified to fail against the old
render-body-read pattern before confirming it passes against the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(landing): cold-start the reduced-motion store cache per test (#344)
Fix round 2: usePrefersReducedMotion's module-level MediaQueryList cache
(correct for production) survived across it() blocks within
KnowledgeGraphDemo.test.tsx, so the round-0 test 'parks fully assembled
when reduced motion is requested' inherited an earlier test's warmed
cache instead of its own local matchMedia override. It still passed, but
for the wrong reason -- false confidence, not regression protection.
Reviewer proof: inverting that test's override to report "no preference"
left the whole file green.
Adds a file-level beforeEach calling __resetReducedMotionStoreForTests()
so every test in the file cold-starts the cache and actually depends on
its own window.matchMedia. Removes the now-redundant reset from the
round-1 describe block's nested beforeEach (the mid-test reset between
its two installReducedMotion() calls stays, since a beforeEach only runs
once per test). Verified by the same inversion the reviewer used: fails
whole-file with the override flipped, passes again once restored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): graph hover blurbs and copy fade on engagement (#344)
Hover a node to reveal its concept blurb below the graph; the first
interaction fades the instructional copy for the rest of the session.
Both `hovered` and `engaged` live in the KnowledgeGraphDemo parent, not
in the AssemblingGraph child, since that child remounts (keyed by
graph.id) on every course switch. engaged must survive that remount;
hovered's home follows since the blurb paragraph renders in the parent
too. AssemblingGraph just gets onNodeEnter/onNodeLeave callback props.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): replace HowItWorks and the feature catalog with the graph demo (#344)
* test(e2e): journey for the landing knowledge graph (#344)
* fix(landing): viewport-gate the graph assembly, never render it blank (#344)
Final whole-branch review of the landing knowledge-graph demo turned up six
findings. They interact, so this is one wave.
#1 The helix played where nobody could see it. The assembly effect fired on
mount, so it burned its full 1100ms during the hydration window — under the
page's own intro overlay, alongside the hero canvas RAF — and every visitor
who scrolled down found progress === 1 and a static picture. Gate the RAF on
an IntersectionObserver on the section. The root margin is a NEGATIVE bottom
inset, not the positive lead-in that looks natural: the section sits directly
after a min-h-screen hero, so its top edge is at exactly 100vh and any
positive bottom margin re-creates the bug at scroll 0.
#6 No-preference first paint went blank. usePrefersReducedMotion correcting
its SSR-safe `true` to the real `false` dropped progress onto a raw
animatedProgress of 0 — a committed frame with the whole graph at opacity 0 —
and gating #1 would have turned that into a section that stays blank until
scrolled to. animatedProgress is now `number | null`, and null (the assembly
has never run) reads back as 1. Leaving the viewport mid-assembly settles on
the complete frame rather than freezing a half-faded one.
#4 The helix threw the outer ring outside the viewBox. `1 + (1 - e) * 0.9`
put depth-2 nodes at y = 693 against a 560-unit viewBox, chopped by the svg
viewport at opacity ≈ 0.5. It now contracts (0.55x → 1x) instead of
stretching, so the whole sweep lives inside the disc radialLayout already fits
to the frame. helixEntry(target, centre, 1) === target is untouched.
#3 Illegible on phones. One 900x560 viewBox at every width renders at 0.38
scale on a 390px viewport: 4.6 CSS px labels, a 213px-tall smudge. Added a
GraphView descriptor and a 360x300 phone view selected by useIsMobile —
0.95 scale, 12.35 CSS px labels, 22.8px dots, 285px tall.
#2 The engaged copy fade failed WCAG AA permanently (engaged never resets).
--text at 0.35 over the paper bg is 2.20:1 against a 3:1 bar. The fade moves
to the headline alone at 0.55 (3.88:1). The eyebrow is not faded: at 0.7rem
it needs 4.5:1, and --brand-forest only holds that to alpha 0.86 — the
review's suggested 0.75 is 3.58:1 and still fails.
#5 The named SSR guard didn't guard. public-seo.spec.ts asserted only
Metadata API output, which survives `ssr: false` on the dynamic import. Added
a raw-HTML assertion on the graph section's server-rendered copy.
Tests: layout.test.ts sweeps every node of every fixture across the whole t
range in both views and asserts circle + label extents stay in frame (this is
the test that would have caught #4); KnowledgeGraphDemo.test.tsx drives a fake
IntersectionObserver and captured rAF to pin "complete before armed, never
blank", "no RAF until on screen", and the WCAG ratios computed from the
rendered opacity; landing-graph.spec.ts measures label/dot/height in CSS px at
390x844. Each was confirmed to fail against the pre-fix code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): brand-conform the graph section and fit its frame (#344)
Five visual/brand defects found by screenshotting the built page against the
brand guide. Every automated gate was already green, so none of these were
test-detectable.
1. TIER_COLOR was a fourth inlined copy of the mastery palette with four wrong
literals — the landing page advertised different mastery colours than the
product. Now consumes the canonical --state-* tokens (globals.css:80-89).
The hero legend's four swatches get the same treatment (colours only).
2. The course root inherited its fixture tier's amber, so the section's focal
point read as a warning. It now paints --brand-forest as an anchor; the
fixture tier is unchanged.
3. The SVG used a hardcoded viewBox="0 0 900 560" stretched to a ~1184px
container, reserving ~737px of height around content that clustered in the
middle. The frame is now DERIVED (fitViewBox) from the drawn content — dot,
label, halo — swept across the whole entry animation, unioned over all three
fixtures so a chip click can't change the section height, once per
breakpoint. Desktop resolves to "159 42 578 498", 36% narrower than the box
it replaced.
Fitted to the HELIX SWEEP, not the settled positions: helixEntry rotates
1.5 turns, so an outer node passes 0.925*maxRadius above and below the
centre at ~83% opacity — 215 units against a settled extent of 58. A
settled fit clips the assembly, which is #344 review #4 all over again. The
bounds test is retargeted at the derived box, and a new paired test proves
the sweep genuinely leaves the settled bounding box so that containment
check stays load-bearing.
The fit only pays off with a width cap — stretched to 1184px a tighter box
renders at 2x and makes the section taller. Capped at md:max-w-[720px].
4. The root's label lay along the outer-ring -> depth-1 edge, which crosses
the root's x at cy + 0.349*ring in every fixture. It moves above the node on
desktop; on the phone the ring is too small for that to clear the top
child's label, so it stays below (where the diagonal doesn't reach it). All
labels gain a paint-order halo in --bg-mesh. Two unreported collisions also
fixed: "Hypothesis Tests" overlapped "Distributions" by ~21 CSS px at 390px,
and the shipped phone geometry both overlapped and, under the fit, fell
under the E2E height gate. New suite pins label/label, label/dot and
label/edge clearance for every fixture in both views.
5. Left-aligned the graph to the headline's grid — the same width cap — so a
left-aligned copy block no longer sits above a centred diagram.
503 unit tests pass (up 6), tsc and eslint clean. No new CSS, no new dependency,
no assertion weakened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): take the outer ring off the horizontal axis (#344)
The ring phase was `offset = d % 2 === 0 ? step / 2 : 0` against a −π/2
start. Depth 2 holds exactly two nodes in all three fixtures, so
`step = π`, the half slot is `π/2`, and both outer nodes resolved to
exactly 0 and π — dead on the horizontal axis through the centre. The
settled layout was therefore a flat ellipse (aspect 0.40) inside a
near-circular entry sweep (0.86), and since the viewBox is fitted to the
sweep (correctly — a settled fit clips the assembly), the graph filled
only 45% of its own box height and left a dead band above and below it.
Every ring now starts three quarters of a slot back from 3 o'clock, so
its angles are odd multiples of `step/4 = π/(2·count)` and can never be a
multiple of π: no ring of any size flattens onto the horizontal axis. The
half-slot alternation is kept for what it was for — it now applies only
when a ring holds the same node count as the one inside it, which is the
only case that lines up into radial spokes.
Depth 1 is bit-identical to before (−3·(2π/3)/4 is exactly −π/2), so the
reviewed triangle and every constraint the mobile geometry was tuned
against are untouched. Depth 2 moves to the NW↔SE diagonal; the mirrored
diagonal is not equivalent — the fixtures hang their outer nodes off the
12 and 4 o'clock children, so SW↔NE drags an edge through the root label
(−7.2 units of overlap, measured).
Desktop, re-derived from the new geometry and still fitted to the sweep:
`159 42 578 498` → `185 33 526 516`; settled aspect 0.436/0.405/0.418 →
1.006/0.910/0.951; the drawing fills 73% of the frame height, up from
45% (515 of 706 rendered px, up from 277 of 620). Phone: `-19 5 394 319`
→ `-4 -1 364 330`, aspect 0.485/0.424/0.450 → 1.006/0.844/0.910, fill
48% → 73%, and every E2E legibility bar clears wider than before (12.8px
labels, 25.5px dots, 301px tall).
Worst label clearance is unchanged at +29.2 units desktop and +0.8
mobile — the new angles introduce no new binding pair. The "sweep leaves
the settled box" guard is restated in units per side (59.0/59.3 desktop,
33.2/33.9 mobile) instead of as a ratio: the ratio was a proxy that
shrinks precisely when the rest state stops being flat, which is the fix.
New tests pin the no-horizontal invariant over ring sizes 1–12, the
spoke-breaking branch, and the settled fill for every fixture × view.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): lay the graph out as a radial tree, not concentric rings (#344)
`radialLayout` placed nodes on global concentric rings: a node's angle came
from its index within its depth, with no relationship to where its parent
sat. On the shipped 6-node fixtures that scattered the graph — `cs-sorting`
landed 171 units from `cs-arrays` on a 232-unit ring, `cs-trees` was flung to
the opposite corner — and the picture read as a lopsided diagonal smear.
Tuning the ring angles cannot fix it; the previous wave tried.
Now: BFS a spanning tree over the undirected `edges` (never `children`, which
is dead data that disagrees with them), root at the centre, depth-1 spread
evenly around the circle, and every deeper node placed one ring step from ITS
OWN PARENT inside a wedge centred on that parent's outward direction. The tree
governs position only — every entry in `graph.edges` is still drawn, cross
edges included.
Tree-edge length, desktop: max 170.9 → 116.0, mean 128.5 → 116.0. The section
gets shorter at the 720px cap: 706px → 663px. The depth-1 ring phase moves
forward by one slot (`+step/4`, still an odd multiple, so the no-horizontal
proof is unchanged) so the two branch-bearing children sit at 4 and 8 o'clock
and grow downward, away from the root's label band.
`MOBILE_VIEW` is retuned, not optional: in a tree the flattest of three arms
is always 30° off horizontal, so a child's dot lands inside its parent's own
13-character label unless `0.866·ring > 3.9·font + halo/2 + nodeR`. The ring
grows 132 → 163 and `nodeR`/`fitPad` shrink to buy it; the phone renders at
11.2px labels / 20.9px dots / 285px tall, still over the E2E legibility gate
but with thin margins, documented at both ends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): grow the graph upward from its base, not down from the middle (#344)
The radial tree put the course root at the frame's centre and spread its
concepts around the full circle, so the drawing grew one arm up and two
down — an inverted Y, or a root system. The product is called Sapling and
the section headline is "Pick a course. Watch it grow."
Only the ANGULAR DOMAIN changes. The BFS spanning tree, the parent-relative
placement and the drawing of every edge (cross-edges included) are exactly
as the previous wave left them.
- depth-1 fans across the UPWARD half-plane, taking the interior gridlines
of an (n+1)-way split of it: 45/90/135 degrees for three children. Never
lands a child on the horizon at any fan size, and strictly further from
it than the previous rule at every size.
- seats are handed out outside-in, biggest subtree first, so the deep arms
get the open sky and the composition stays mirror-symmetric.
- the skeleton is laid out with the root at the origin and translated as a
rigid body until its own bounding box is centred on the layout centre —
the point helixEntry spirals around. The root ends up at the bottom-centre
of the content and the sweep-fitted frame stays centred on the drawing.
- both views retuned against the new geometry (the budget is now solved
from the type scale, the label clearances and the phone's legibility
floor, not inherited from the layout box), and the root's label moves
below its circle in both: above is now the direction the plant grows in.
Measured, desktop at the 720px cap: the <svg> is 637.6px tall against
662.8 (and 737 for the box this shipped with); cs210 fills 0.839 of the
frame's width against 0.828; the worst label clearance goes 8.80 -> 30.14
units. Phone: 11.99px labels and 22.48px dots against 11.23/20.85, and the
worst label clearance goes 0.99 -> 4.05 CSS px, retiring a margin that was
inside the noise of whether Chromium paints a scrollbar.
The frame's HEIGHT share does not improve (0.563 -> 0.532): fitViewBox
fits to the entry sweep, the sweep is very nearly a disc, so dead vertical
space is about (drawing width - drawing height)/2 for any layout and a
canopy is wider than tall. Documented at the assertion that was relaxed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): tighten the graph's entry sweep to a quarter turn (#344)
The frame is fitted to the helix sweep, not to the settled drawing — it has
to be, or the assembly gets clipped mid-flight (#344 review #4). At 1.5 turns
every node passed through every direction on the way in, so that sweep was
very nearly a DISC and the fitted frame very nearly SQUARE around a canopy
twice as wide as it is tall. The difference was dead paper: 162 and 136 CSS
px of it above and below the drawing, which the previous wave measured across
the whole layout family and logged as its closing concern — the lever is the
sweep, not the layout.
`helixEntry` now turns 0.25 (90°) and `ENTRY_CONTRACTION` is 1, so a node
starts on the centre and the radial term collapses to the straight-line
easing: the path is that line, rotated by a decaying quarter turn, and the
widest swing happens where the node is nearest the centre. No layout, fixture,
colour, copy, testid or component signature moved; every settled coordinate is
byte-identical.
desktop viewBox 161 17 603 534 -> 161 114 582 334
<svg> 720x637.6 -> 720x413.2 px (-224)
band/side 162.2/136.0 -> 48.1/13.4 px
phone viewBox -32 -25 443 365 -> -32 40 429 230
<svg> 332x273.5 -> 332x178.0 px (-95)
band/side 63.7/54.2 -> 15.5/1.8 px
The drawing did not shrink to get there — it grew, because the narrower frame
renders every unit bigger: 669x339 -> 694x352 CSS px on desktop, 12.0 -> 12.4
px phone labels, 22.5 -> 23.2 px dots. Worst label clearance is unchanged in
units (30.14 desktop, 5.40 phone) and better in pixels (36.0 -> 37.3, 4.05 ->
4.18).
Tests: the mid-flight helix assertion is restated as shape rather than
distance (>0.1 travel radii off the straight line, >5 deg of bearing swing;
measured 0.172 and 11.25 deg) so it bites at 0.146 turns instead of passing
anything; the sweep-vs-settled guard now asserts the top overhang with a
number and builds the settled-fit box to show it clips, because the overhang
is one-sided by construction (the fan's tips lift past the settled top when
they rotate through the vertical; nothing can swing below the course code);
the frame-height share floor goes 0.50 -> 0.75 (measured 0.851/0.903); and the
phone's third bar stops measuring the FRAME's height (it was passing on the
band) and measures the drawing's, with a frame floor kept at 170px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): restore atmospheric continuity between the sections (#344)
Deleting #features and HowItWorks took the middle of the page's atmosphere
with them, and nothing replaced it.
The hero carries mesh-blob--1 and --2. The old #features carried --3 and --2,
and HowItWorks carried a dark-green scroll tint. The CTA carries --1 and --2
PLUS a top gradient that started at rgba(20,83,45,0.08) on its very first pixel
— because it was designed to blend DOWN out of that dark-green tint.
So after the deletions the page ran: atmospheric hero -> a completely flat
graph section -> a CTA whose green tint faded in from bare paper. Two hard
seams, one on each side of the new section.
The graph section now carries its own blobs at lower opacity than the hero's,
so the graph itself stays the focus, and the CTA's wash starts transparent and
peaks below the boundary instead of on it.
Found by spinning up the dev server and looking at the whole page, not the
section in isolation — the section screenshots I had been judging could not
show a seam, because a seam only exists between two things.
94 graph tests pass; tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): three feature bands and a bento of built surfaces (#344)
Step 2 of the below-hero redesign. The page now runs hero → graph →
Universal Upload → Adaptive Quizzes → bento → Spaced Repetition → CTA.
The three bands carry ONE arc — material in → practice → retention —
rather than three disconnected pitches, and the closing band (not a grid
tile) hands off to the CTA: a grid's last tile is a weak place to ask for
a signup. Surfaces alternate sides, derived from position in
`featureBands.tsx` rather than written down per band, so inserting a band
can't silently put two surfaces in the same gutter.
Every tile is a RECREATED PRODUCT SURFACE, never an icon over a heading
over a sentence — the brand guide's hard anti-pattern. The seven
recreations are faithful to the shipped screens: the upload modal's file
rows and status wording, QuizPanel's radiogroup with its `A.` prefix and
selected treatment, Study's rating trio, ChatPanel's asymmetric bubbles,
the notetaker's linked-concept rail, Social's invite chip and sender
names, and the gradebook's letter grade over real assignment rows.
Brand constraints that shaped the code rather than just the CSS:
- No glassmorphism. `.liquid-glass` is deliberately not reused; surfaces
are solid warm paper with hairline borders.
- Colour is state. Every mastery mark reads `TIER_COLOR` — the same map
the graph section above paints its nodes with — and the only other hues
are `--grade-*`. Unit-tested, so a raw hex can't creep back in.
- Contrast forced two divergences from the app's own paint: the student
bubble takes `--brand-forest` (6.4:1 under white) not `--accent`
(4.04:1), and per-row grade letters keep `--text` lettering with the
band on the border/dot, since `--grade-b` is 3.39:1 as a glyph.
No new dependency, no framer-motion, no JS motion at all: the surfaces
are static pictures, so `prefers-reduced-motion` and `IS_TEST_MODE` have
no frame to park — there is only the complete one. The entrance is the
page's existing `.landing-fade-up` observer, which degrades to "visible"
rather than "invisible" when it never fires.
All new CSS is inside the `.landing-page` scope and defines no tokens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): give the knowledge graph product chrome, a dial and a rail (#344)
The graph section was the least convincing thing on a page whose bands and
bento each recreate a whole product screen. It was six flat circles and six
words, floating naked on the page background, left-aligned in a 1184px
container with the right 40% of the field empty, and its entire hover payoff
was one line of body text swapping under the drawing.
It is now one surface, full container width, split the way the app's own Tree
screen is split.
- CHROME. `KNOWLEDGE GRAPH` in the bento's mono micro-label, and the selected
course's real numbers on the right — `MA 242 · 19 concepts · 50% mastery`,
all read off the fixture.
- THE NODES ARE DIALS. A tier-tinted disc at the full node radius, a neutral
track ring, the mastery arc swept clockwise from 12 o'clock, and a solid
core. Every dimension is measured INWARD from `nodeRadius`, so the drawn
footprint is byte-identical to the flat disc it replaces: `fitViewBox`,
`labelBaselineY` and the phone's 5.40-unit label clearance are untouched,
and a node group's first `<circle>` is still the tier-painted disc the E2E
legibility gate measures.
- A LEGEND, which is a comprehension fix rather than decoration: four
`--state-*` hues were carrying the whole meaning of the picture and nothing
on the page said what any of them meant. It carries counts, so it reads as a
readout, and it names the tiers in the app's own words.
- AN INSPECTOR RAIL replaces the bare hover line: name, tier as a labelled
chip, blurb, mastery meter, and the neighbours listed with their own scores,
with the hovered node's edges lit in the canvas beside it. Never empty — at
rest it shows the course.
- AMBIENT DRIFT, ~3 units on a 13–22s per-node cycle with a negative delay so
nothing starts in phase. A CSS animation, not a rAF loop: it stays out of
the assembly's frame budget (which the suite counts to prove the helix
neither fires early nor replays), and rides the same `parked` switch, so
reduced-motion visitors and the E2E lane get nodes exactly on their
laid-out points. Off below the mobile breakpoint, where the frame pad is 2
units and the worst label clearance 5.40.
The fixtures gain a numeric `mastery` per node and a `conceptCount` per
course, held to the product's own cutoffs by a port of
`backend/config.py::get_mastery_tier`, so a ring can never say 90% while the
paint says "struggling" and the chrome's percentage can never contradict the
root's own dial.
The assembly, its viewport gate, the negative bottom rootMargin and the
never-blank derivation are untouched. The phone gate's three bars improve
(12.68 / 23.78 / 182.3 against 11 / 20 / 170) because the canvas is
full-bleed horizontally — deliberately, and the CSS says why.
576 tests green (+16), tsc clean, eslint clean on src/ and e2e/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 22, 2026
…566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack: this spec 7/7 in isolation, including
the new journey. The full suite is 48 passed / 1 failed, the failure
being gradebook.spec.ts:35, which is not this change: it sits earlier in
the same worker than landing-drag-field, so nothing here can reach it.
It has now failed 2 of 3 full-suite runs while passing 3 of 3 in
isolation. CI's two retries have been masking it. Whether that is a
suite-context flake or a real intermittent in enrollment resolution is
still open — the failing line is the `Exams` category, which exists only
on the fall-2025 enrollment, so the signature matches "resolved the
wrong enrollment" as readily as "rendered slowly". Handed to the session
working that area; not filed as a flake until the control run on clean
main says it is one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 22, 2026
…566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack, rebased onto main at 7863210.
Control first, on UNPATCHED main: 47 passed, 1 failed — and the one
failure is this journey, so the lane is still red on current main and
this is still the fix it needs. With the patch applied, two consecutive
full suites: 49 passed, 0 failed. The spec alone, three consecutive
runs: 7/7 each time.
That also closes out the gradebook.spec.ts:35 question raised against
the previous revision. It failed 2 of 3 full-suite runs then, always on
a tree carrying an in-flight #553, and I could not tell a suite-context
flake from a real intermittent in enrollment resolution. #553 has since
merged; across the three full suites above it passed every time. It has
not reproduced on current main, so there is nothing to file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
) (#568)
* test(e2e): measure a dropped node against the copy it is welded to (#566)
The scroll-follow half of "a dropped node stays where it was put, and
scrolls with the page" has been red on main since the landing-v5 port
(#524) merged, taking the whole e2e browser lane with it. It is not
flaky: three CI retries and every local run land within 0.2px of each
other, ~98px short of where the assertion expects the node.
The product is right and the journey was wrong. `faq` is the one section
in DragField's `TRACKS`, and `engine/sim.ts::syncClusters()` positions a
tracked cluster at `field.top + a.top + dy`, where
`dy = (track.top - field.top) - trackTop`. That collapses to
`cluster_y = track.top + const`: the cluster is welded to the FAQ
question column, which is `sticky; top:110`. Instrumented over the
journey's own 300px scroll:
scrollY +300.00
#faq top -300.00
track top -202.81 <- pinned at top:110 partway through
cluster top -202.80
probe node y -202.60
error 97.40 (CI: 98.2-98.4)
The column had exactly 202.81px of travel left before its pin, so ~97px
of the scroll happened with the copy held still and the cluster
correctly held still with it. Welding those clusters to their copy is
deliberate — 8bb3486 added it because they slid 374px out from under
the words they belong to.
So the assertion was measuring the one coupling this section does not
have. The other two scroll journeys in this file already measure
relatively — a ring against its own cluster, a cluster against its act's
stage — and both pass; this one reached for raw scrollY, on the single
cluster where that is untrue. It picked cluster 4 for elbow room, not
knowing `faq` is also the only tracked field.
Re-frames it against the weld instead, read from the field's own
`data-drag-track` so the test follows the product rather than
duplicating it: drop the weld and `weldTop()` falls back to the field,
which is the plain 1:1 page-scroll reference. The intent is unchanged —
placed, not detached — and it keeps its teeth: a guard that the weld
target really moved (>100px) plus the exact 300px scroll, so a node
pinned to the screen over a page that never scrolled still fails.
Pointing the same assertion at the untracked section reproduces the
97.40px failure, so it is load-bearing, not vacuous.
Verified on the full local stack: this spec 6/6 twice, full suite 47/48
with one unrelated gradebook flake that passed 3/3 on re-run (retries
are CI-only).
Closes#566
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(e2e): let the journey own its oracle, and put the 1:1 case back (#566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack, rebased onto main at 7863210.
Control first, on UNPATCHED main: 47 passed, 1 failed — and the one
failure is this journey, so the lane is still red on current main and
this is still the fix it needs. With the patch applied, two consecutive
full suites: 49 passed, 0 failed. The spec alone, three consecutive
runs: 7/7 each time.
That also closes out the gradebook.spec.ts:35 question raised against
the previous revision. It failed 2 of 3 full-suite runs then, always on
a tree carrying an in-flight #553, and I could not tell a suite-context
flake from a real intermittent in enrollment resolution. #553 has since
merged; across the three full suites above it passed every time. It has
not reproduced on current main, so there is nothing to file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

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

Add landing page with liquid glass effects - #4

Merged
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
feature/landing-page-liquid-glass
Mar 18, 2026
Merged

Add landing page with liquid glass effects#4
Jose-Gael-Cruz-Lopez merged 2 commits into
mainfrom
feature/landing-page-liquid-glass

Conversation

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Summary

  • New landing page at / with a marketing-focused design: 3D canvas knowledge graph background, scramble text hero animation, floating glass accent cards, feature cards grid, sticky scroll cinema "How It Works" section, animated stats counter bar, and a multi-step onboarding modal
  • Liquid glass visual system applied to all panel surfaces — frosted translucency with backdrop-filter: blur(), inner specular highlights, hover shimmer sweep animation, and ambient gradient blobs behind sections for depth
  • Dashboard moved from / to /dashboard with Navbar routing updated to hide on the landing page and redirect unauthenticated users appropriately
  • New fonts added: Playfair Display (hero headings), Inter (body), JetBrains Mono (labels/stats)

Files changed

FileChange
frontend/src/app/page.tsxReplaced dashboard with new landing page component
frontend/src/app/dashboard/page.tsxNew file — moved original dashboard here
frontend/src/app/globals.cssAdded landing page animations + liquid glass CSS system
frontend/src/app/layout.tsxAdded Inter, Playfair Display, JetBrains Mono font imports
frontend/src/components/Navbar.tsxHide on landing page, update dashboard link to /dashboard
frontend/package.jsonAdded lucide-react dependency

Test plan

  • Visit http://localhost:3000/ — landing page renders with hero animation, floating cards, and liquid glass effects
  • Scroll through all sections — features, sticky scroll cinema (3 steps transition), stats counter, CTA
  • Click "Get Started" / "Sign In" — onboarding modal opens with multi-step flow
  • Resize browser — responsive layout works on mobile/tablet/desktop
  • Sign in with Google — redirects to /dashboard (original dashboard)
  • Verify /dashboard route still works correctly for authenticated users
  • Check that Navbar is hidden on the landing page but visible on all other routes

Made with Cursor

…hboard
Replace the root route with a new marketing landing page featuring:
- Hero section with 3D canvas knowledge graph, scramble text animation,
and floating glass accent cards
- Feature cards grid with spotlight hover effects and floating animation
- Sticky scroll cinema "How It Works" section (3 steps)
- Stats counter bar with intersection observer triggers
- Onboarding modal with multi-step sign-up/sign-in flow
- Liquid glass visual system (frosted translucency, backdrop blur,
specular shimmer on hover) applied to all panel surfaces
- Ambient gradient blobs behind sections for glass depth
- Responsive design with Playfair Display, Inter, and JetBrains Mono fonts
Move the original dashboard to /dashboard route and update Navbar
routing to hide on the landing page and redirect accordingly.
Made-with: Cursor
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Mar 18, 2026

Copy link
Copy Markdown

Deploying sapling with Cloudflare Pages Cloudflare Pages

Latest commit:fcdb2e0
Status:⚡️ Build in progress...

View logs

…anges
Keep landing page at / (our branch) and update dashboard/page.tsx
with main's latest dashboard code including the color picker feature.
Made-with: Cursor
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez merged commit 9d2620f into mainMar 18, 2026
1 check was pending
@Jose-Gael-Cruz-Lopez
Jose-Gael-Cruz-Lopez deleted the feature/landing-page-liquid-glass branch March 18, 2026 04:44
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request May 5, 2026
refactor(calendar): unify syllabus extraction onto agent (refactor #4)
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request Jun 13, 2026
…ment-notes
fix(security): encrypt syllabus-extracted assignment notes at write boundary (#126#4)
AndresL230 added a commit that referenced this pull request Aug 2, 2026
#344 phase 2) (#513)
* docs(spec): landing page below the hero (#344 phase 2)
Interactive knowledge graph replaces the 340vh scroll-jacked HowItWorks, then
three feature bands and a four-tile bento of real app surfaces.
The structural argument is a density rhythm — the graph is the densest thing on
the page, bands decompress, the bento re-energizes, and a closing band gives the
CTA a run-up rather than ending on a grid tile.
The content argument matters more than the visual one. Tutor chat, Notes,
Gradebook and Flashcards are shipped surfaces the current six-feature list never
mentions, so the page undersells the product more than it under-designs it. That
is the likeliest root of #344's 'feels generic'.
Live LLM generation on the public page was considered and rejected for now: most
convincing option, but it puts an unauthenticated billable endpoint on the
most-crawled page on the site. Recorded as a second pass behind a rate-limited
endpoint.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(plan): landing interactive graph, step 1 (#344)
Seven tasks: fixtures, layout+helix math, component, assembly, interaction,
page wiring with the HowItWorks/catalog deletions, and the e2e journey.
Self-review found one real gap and it is recorded in the plan rather than
papered over: the spec lists drag alongside hover and expand, and Task 5
implements hover and the copy fade only. Dragging needs a pointer-capture and
SVG coordinate-mapping decision (getScreenCTM) that deserves its own review
gate, so it is called out as 5b/5c rather than hidden inside a step.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): course graph fixtures for the interactive demo (#344)
* feat(landing): deterministic radial layout + helical entry path (#344)
* feat(landing): knowledge graph demo — chips and laid-out render (#344)
Static, fully laid-out KnowledgeGraphDemo component: a course-picker
chip row plus an inline SVG render of the selected course's graph via
radialLayout. This is the parked frame reduced-motion visitors and the
E2E lane get; the assembly animation is a later task.
Registers the landing-graph E2E surface (docs/frontend-testids.md +
eslint.config.mjs) and adds the .landing-page-scoped chip/copy CSS.
* feat(landing): helical assembly, parked under reduced motion (#344)
Adds the RAF-driven helical assembly to KnowledgeGraphDemo, extracted into
an AssemblingGraph child keyed by course id so switching courses remounts
(fresh progress state, unmount-driven RAF cleanup) instead of resetting
state inside an effect — avoids the react-hooks/set-state-in-effect
anti-pattern that the brief's literal snippet would have tripped.
Parked (progress=1, full opacity, laid out) whenever IS_TEST_MODE or
prefers-reduced-motion. Adds a guarded window.matchMedia stub to
vitest.setup.ts (jsdom has none) defaulting to reduced-motion=true, so
every KnowledgeGraphDemo unit test asserts the parked/complete frame —
the correct target for reduced-motion visitors and the E2E lane alike.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): SSR-safe reduced-motion detection for the graph demo (#344)
Fix round 1: reading window.matchMedia directly in KnowledgeGraphDemo's
render body computed "no preference" server-side (no window) but the real
value client-side, producing a genuine React hydration mismatch for any
reduced-motion visitor once this component mounts with SSR on.
Adds usePrefersReducedMotion (frontend/src/lib/usePrefersReducedMotion.ts),
following the same useSyncExternalStore + fixed getServerSnapshot pattern
useIsMobile.ts already established for this bug class. Server snapshot
defaults to true (assume reduced motion) rather than useIsMobile's false
default: whichever direction is wrong pays a cost, and defaulting true means
the cost lands on no-preference visitors (one extra replay of the entrance
animation) rather than reduced-motion visitors (who would otherwise see a
blank or mid-assembly graph on first paint).
Also switches AssemblingGraph's progress from raw state to a value derived
from `parked` at render time, so a post-hydration correction of `parked`
(without a remount) resolves correctly in both directions instead of
potentially getting stuck.
Adds a renderToString -> hydrateRoot regression test using different
matchMedia values across the two phases, verified to fail against the old
render-body-read pattern before confirming it passes against the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(landing): cold-start the reduced-motion store cache per test (#344)
Fix round 2: usePrefersReducedMotion's module-level MediaQueryList cache
(correct for production) survived across it() blocks within
KnowledgeGraphDemo.test.tsx, so the round-0 test 'parks fully assembled
when reduced motion is requested' inherited an earlier test's warmed
cache instead of its own local matchMedia override. It still passed, but
for the wrong reason -- false confidence, not regression protection.
Reviewer proof: inverting that test's override to report "no preference"
left the whole file green.
Adds a file-level beforeEach calling __resetReducedMotionStoreForTests()
so every test in the file cold-starts the cache and actually depends on
its own window.matchMedia. Removes the now-redundant reset from the
round-1 describe block's nested beforeEach (the mid-test reset between
its two installReducedMotion() calls stays, since a beforeEach only runs
once per test). Verified by the same inversion the reviewer used: fails
whole-file with the override flipped, passes again once restored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): graph hover blurbs and copy fade on engagement (#344)
Hover a node to reveal its concept blurb below the graph; the first
interaction fades the instructional copy for the rest of the session.
Both `hovered` and `engaged` live in the KnowledgeGraphDemo parent, not
in the AssemblingGraph child, since that child remounts (keyed by
graph.id) on every course switch. engaged must survive that remount;
hovered's home follows since the blurb paragraph renders in the parent
too. AssemblingGraph just gets onNodeEnter/onNodeLeave callback props.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): replace HowItWorks and the feature catalog with the graph demo (#344)
* test(e2e): journey for the landing knowledge graph (#344)
* fix(landing): viewport-gate the graph assembly, never render it blank (#344)
Final whole-branch review of the landing knowledge-graph demo turned up six
findings. They interact, so this is one wave.
#1 The helix played where nobody could see it. The assembly effect fired on
mount, so it burned its full 1100ms during the hydration window — under the
page's own intro overlay, alongside the hero canvas RAF — and every visitor
who scrolled down found progress === 1 and a static picture. Gate the RAF on
an IntersectionObserver on the section. The root margin is a NEGATIVE bottom
inset, not the positive lead-in that looks natural: the section sits directly
after a min-h-screen hero, so its top edge is at exactly 100vh and any
positive bottom margin re-creates the bug at scroll 0.
#6 No-preference first paint went blank. usePrefersReducedMotion correcting
its SSR-safe `true` to the real `false` dropped progress onto a raw
animatedProgress of 0 — a committed frame with the whole graph at opacity 0 —
and gating #1 would have turned that into a section that stays blank until
scrolled to. animatedProgress is now `number | null`, and null (the assembly
has never run) reads back as 1. Leaving the viewport mid-assembly settles on
the complete frame rather than freezing a half-faded one.
#4 The helix threw the outer ring outside the viewBox. `1 + (1 - e) * 0.9`
put depth-2 nodes at y = 693 against a 560-unit viewBox, chopped by the svg
viewport at opacity ≈ 0.5. It now contracts (0.55x → 1x) instead of
stretching, so the whole sweep lives inside the disc radialLayout already fits
to the frame. helixEntry(target, centre, 1) === target is untouched.
#3 Illegible on phones. One 900x560 viewBox at every width renders at 0.38
scale on a 390px viewport: 4.6 CSS px labels, a 213px-tall smudge. Added a
GraphView descriptor and a 360x300 phone view selected by useIsMobile —
0.95 scale, 12.35 CSS px labels, 22.8px dots, 285px tall.
#2 The engaged copy fade failed WCAG AA permanently (engaged never resets).
--text at 0.35 over the paper bg is 2.20:1 against a 3:1 bar. The fade moves
to the headline alone at 0.55 (3.88:1). The eyebrow is not faded: at 0.7rem
it needs 4.5:1, and --brand-forest only holds that to alpha 0.86 — the
review's suggested 0.75 is 3.58:1 and still fails.
#5 The named SSR guard didn't guard. public-seo.spec.ts asserted only
Metadata API output, which survives `ssr: false` on the dynamic import. Added
a raw-HTML assertion on the graph section's server-rendered copy.
Tests: layout.test.ts sweeps every node of every fixture across the whole t
range in both views and asserts circle + label extents stay in frame (this is
the test that would have caught #4); KnowledgeGraphDemo.test.tsx drives a fake
IntersectionObserver and captured rAF to pin "complete before armed, never
blank", "no RAF until on screen", and the WCAG ratios computed from the
rendered opacity; landing-graph.spec.ts measures label/dot/height in CSS px at
390x844. Each was confirmed to fail against the pre-fix code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): brand-conform the graph section and fit its frame (#344)
Five visual/brand defects found by screenshotting the built page against the
brand guide. Every automated gate was already green, so none of these were
test-detectable.
1. TIER_COLOR was a fourth inlined copy of the mastery palette with four wrong
literals — the landing page advertised different mastery colours than the
product. Now consumes the canonical --state-* tokens (globals.css:80-89).
The hero legend's four swatches get the same treatment (colours only).
2. The course root inherited its fixture tier's amber, so the section's focal
point read as a warning. It now paints --brand-forest as an anchor; the
fixture tier is unchanged.
3. The SVG used a hardcoded viewBox="0 0 900 560" stretched to a ~1184px
container, reserving ~737px of height around content that clustered in the
middle. The frame is now DERIVED (fitViewBox) from the drawn content — dot,
label, halo — swept across the whole entry animation, unioned over all three
fixtures so a chip click can't change the section height, once per
breakpoint. Desktop resolves to "159 42 578 498", 36% narrower than the box
it replaced.
Fitted to the HELIX SWEEP, not the settled positions: helixEntry rotates
1.5 turns, so an outer node passes 0.925*maxRadius above and below the
centre at ~83% opacity — 215 units against a settled extent of 58. A
settled fit clips the assembly, which is #344 review #4 all over again. The
bounds test is retargeted at the derived box, and a new paired test proves
the sweep genuinely leaves the settled bounding box so that containment
check stays load-bearing.
The fit only pays off with a width cap — stretched to 1184px a tighter box
renders at 2x and makes the section taller. Capped at md:max-w-[720px].
4. The root's label lay along the outer-ring -> depth-1 edge, which crosses
the root's x at cy + 0.349*ring in every fixture. It moves above the node on
desktop; on the phone the ring is too small for that to clear the top
child's label, so it stays below (where the diagonal doesn't reach it). All
labels gain a paint-order halo in --bg-mesh. Two unreported collisions also
fixed: "Hypothesis Tests" overlapped "Distributions" by ~21 CSS px at 390px,
and the shipped phone geometry both overlapped and, under the fit, fell
under the E2E height gate. New suite pins label/label, label/dot and
label/edge clearance for every fixture in both views.
5. Left-aligned the graph to the headline's grid — the same width cap — so a
left-aligned copy block no longer sits above a centred diagram.
503 unit tests pass (up 6), tsc and eslint clean. No new CSS, no new dependency,
no assertion weakened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): take the outer ring off the horizontal axis (#344)
The ring phase was `offset = d % 2 === 0 ? step / 2 : 0` against a −π/2
start. Depth 2 holds exactly two nodes in all three fixtures, so
`step = π`, the half slot is `π/2`, and both outer nodes resolved to
exactly 0 and π — dead on the horizontal axis through the centre. The
settled layout was therefore a flat ellipse (aspect 0.40) inside a
near-circular entry sweep (0.86), and since the viewBox is fitted to the
sweep (correctly — a settled fit clips the assembly), the graph filled
only 45% of its own box height and left a dead band above and below it.
Every ring now starts three quarters of a slot back from 3 o'clock, so
its angles are odd multiples of `step/4 = π/(2·count)` and can never be a
multiple of π: no ring of any size flattens onto the horizontal axis. The
half-slot alternation is kept for what it was for — it now applies only
when a ring holds the same node count as the one inside it, which is the
only case that lines up into radial spokes.
Depth 1 is bit-identical to before (−3·(2π/3)/4 is exactly −π/2), so the
reviewed triangle and every constraint the mobile geometry was tuned
against are untouched. Depth 2 moves to the NW↔SE diagonal; the mirrored
diagonal is not equivalent — the fixtures hang their outer nodes off the
12 and 4 o'clock children, so SW↔NE drags an edge through the root label
(−7.2 units of overlap, measured).
Desktop, re-derived from the new geometry and still fitted to the sweep:
`159 42 578 498` → `185 33 526 516`; settled aspect 0.436/0.405/0.418 →
1.006/0.910/0.951; the drawing fills 73% of the frame height, up from
45% (515 of 706 rendered px, up from 277 of 620). Phone: `-19 5 394 319`
→ `-4 -1 364 330`, aspect 0.485/0.424/0.450 → 1.006/0.844/0.910, fill
48% → 73%, and every E2E legibility bar clears wider than before (12.8px
labels, 25.5px dots, 301px tall).
Worst label clearance is unchanged at +29.2 units desktop and +0.8
mobile — the new angles introduce no new binding pair. The "sweep leaves
the settled box" guard is restated in units per side (59.0/59.3 desktop,
33.2/33.9 mobile) instead of as a ratio: the ratio was a proxy that
shrinks precisely when the rest state stops being flat, which is the fix.
New tests pin the no-horizontal invariant over ring sizes 1–12, the
spoke-breaking branch, and the settled fill for every fixture × view.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): lay the graph out as a radial tree, not concentric rings (#344)
`radialLayout` placed nodes on global concentric rings: a node's angle came
from its index within its depth, with no relationship to where its parent
sat. On the shipped 6-node fixtures that scattered the graph — `cs-sorting`
landed 171 units from `cs-arrays` on a 232-unit ring, `cs-trees` was flung to
the opposite corner — and the picture read as a lopsided diagonal smear.
Tuning the ring angles cannot fix it; the previous wave tried.
Now: BFS a spanning tree over the undirected `edges` (never `children`, which
is dead data that disagrees with them), root at the centre, depth-1 spread
evenly around the circle, and every deeper node placed one ring step from ITS
OWN PARENT inside a wedge centred on that parent's outward direction. The tree
governs position only — every entry in `graph.edges` is still drawn, cross
edges included.
Tree-edge length, desktop: max 170.9 → 116.0, mean 128.5 → 116.0. The section
gets shorter at the 720px cap: 706px → 663px. The depth-1 ring phase moves
forward by one slot (`+step/4`, still an odd multiple, so the no-horizontal
proof is unchanged) so the two branch-bearing children sit at 4 and 8 o'clock
and grow downward, away from the root's label band.
`MOBILE_VIEW` is retuned, not optional: in a tree the flattest of three arms
is always 30° off horizontal, so a child's dot lands inside its parent's own
13-character label unless `0.866·ring > 3.9·font + halo/2 + nodeR`. The ring
grows 132 → 163 and `nodeR`/`fitPad` shrink to buy it; the phone renders at
11.2px labels / 20.9px dots / 285px tall, still over the E2E legibility gate
but with thin margins, documented at both ends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): grow the graph upward from its base, not down from the middle (#344)
The radial tree put the course root at the frame's centre and spread its
concepts around the full circle, so the drawing grew one arm up and two
down — an inverted Y, or a root system. The product is called Sapling and
the section headline is "Pick a course. Watch it grow."
Only the ANGULAR DOMAIN changes. The BFS spanning tree, the parent-relative
placement and the drawing of every edge (cross-edges included) are exactly
as the previous wave left them.
- depth-1 fans across the UPWARD half-plane, taking the interior gridlines
of an (n+1)-way split of it: 45/90/135 degrees for three children. Never
lands a child on the horizon at any fan size, and strictly further from
it than the previous rule at every size.
- seats are handed out outside-in, biggest subtree first, so the deep arms
get the open sky and the composition stays mirror-symmetric.
- the skeleton is laid out with the root at the origin and translated as a
rigid body until its own bounding box is centred on the layout centre —
the point helixEntry spirals around. The root ends up at the bottom-centre
of the content and the sweep-fitted frame stays centred on the drawing.
- both views retuned against the new geometry (the budget is now solved
from the type scale, the label clearances and the phone's legibility
floor, not inherited from the layout box), and the root's label moves
below its circle in both: above is now the direction the plant grows in.
Measured, desktop at the 720px cap: the <svg> is 637.6px tall against
662.8 (and 737 for the box this shipped with); cs210 fills 0.839 of the
frame's width against 0.828; the worst label clearance goes 8.80 -> 30.14
units. Phone: 11.99px labels and 22.48px dots against 11.23/20.85, and the
worst label clearance goes 0.99 -> 4.05 CSS px, retiring a margin that was
inside the noise of whether Chromium paints a scrollbar.
The frame's HEIGHT share does not improve (0.563 -> 0.532): fitViewBox
fits to the entry sweep, the sweep is very nearly a disc, so dead vertical
space is about (drawing width - drawing height)/2 for any layout and a
canopy is wider than tall. Documented at the assertion that was relaxed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): tighten the graph's entry sweep to a quarter turn (#344)
The frame is fitted to the helix sweep, not to the settled drawing — it has
to be, or the assembly gets clipped mid-flight (#344 review #4). At 1.5 turns
every node passed through every direction on the way in, so that sweep was
very nearly a DISC and the fitted frame very nearly SQUARE around a canopy
twice as wide as it is tall. The difference was dead paper: 162 and 136 CSS
px of it above and below the drawing, which the previous wave measured across
the whole layout family and logged as its closing concern — the lever is the
sweep, not the layout.
`helixEntry` now turns 0.25 (90°) and `ENTRY_CONTRACTION` is 1, so a node
starts on the centre and the radial term collapses to the straight-line
easing: the path is that line, rotated by a decaying quarter turn, and the
widest swing happens where the node is nearest the centre. No layout, fixture,
colour, copy, testid or component signature moved; every settled coordinate is
byte-identical.
desktop viewBox 161 17 603 534 -> 161 114 582 334
<svg> 720x637.6 -> 720x413.2 px (-224)
band/side 162.2/136.0 -> 48.1/13.4 px
phone viewBox -32 -25 443 365 -> -32 40 429 230
<svg> 332x273.5 -> 332x178.0 px (-95)
band/side 63.7/54.2 -> 15.5/1.8 px
The drawing did not shrink to get there — it grew, because the narrower frame
renders every unit bigger: 669x339 -> 694x352 CSS px on desktop, 12.0 -> 12.4
px phone labels, 22.5 -> 23.2 px dots. Worst label clearance is unchanged in
units (30.14 desktop, 5.40 phone) and better in pixels (36.0 -> 37.3, 4.05 ->
4.18).
Tests: the mid-flight helix assertion is restated as shape rather than
distance (>0.1 travel radii off the straight line, >5 deg of bearing swing;
measured 0.172 and 11.25 deg) so it bites at 0.146 turns instead of passing
anything; the sweep-vs-settled guard now asserts the top overhang with a
number and builds the settled-fit box to show it clips, because the overhang
is one-sided by construction (the fan's tips lift past the settled top when
they rotate through the vertical; nothing can swing below the course code);
the frame-height share floor goes 0.50 -> 0.75 (measured 0.851/0.903); and the
phone's third bar stops measuring the FRAME's height (it was passing on the
band) and measures the drawing's, with a frame floor kept at 170px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(landing): restore atmospheric continuity between the sections (#344)
Deleting #features and HowItWorks took the middle of the page's atmosphere
with them, and nothing replaced it.
The hero carries mesh-blob--1 and --2. The old #features carried --3 and --2,
and HowItWorks carried a dark-green scroll tint. The CTA carries --1 and --2
PLUS a top gradient that started at rgba(20,83,45,0.08) on its very first pixel
— because it was designed to blend DOWN out of that dark-green tint.
So after the deletions the page ran: atmospheric hero -> a completely flat
graph section -> a CTA whose green tint faded in from bare paper. Two hard
seams, one on each side of the new section.
The graph section now carries its own blobs at lower opacity than the hero's,
so the graph itself stays the focus, and the CTA's wash starts transparent and
peaks below the boundary instead of on it.
Found by spinning up the dev server and looking at the whole page, not the
section in isolation — the section screenshots I had been judging could not
show a seam, because a seam only exists between two things.
94 graph tests pass; tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): three feature bands and a bento of built surfaces (#344)
Step 2 of the below-hero redesign. The page now runs hero → graph →
Universal Upload → Adaptive Quizzes → bento → Spaced Repetition → CTA.
The three bands carry ONE arc — material in → practice → retention —
rather than three disconnected pitches, and the closing band (not a grid
tile) hands off to the CTA: a grid's last tile is a weak place to ask for
a signup. Surfaces alternate sides, derived from position in
`featureBands.tsx` rather than written down per band, so inserting a band
can't silently put two surfaces in the same gutter.
Every tile is a RECREATED PRODUCT SURFACE, never an icon over a heading
over a sentence — the brand guide's hard anti-pattern. The seven
recreations are faithful to the shipped screens: the upload modal's file
rows and status wording, QuizPanel's radiogroup with its `A.` prefix and
selected treatment, Study's rating trio, ChatPanel's asymmetric bubbles,
the notetaker's linked-concept rail, Social's invite chip and sender
names, and the gradebook's letter grade over real assignment rows.
Brand constraints that shaped the code rather than just the CSS:
- No glassmorphism. `.liquid-glass` is deliberately not reused; surfaces
are solid warm paper with hairline borders.
- Colour is state. Every mastery mark reads `TIER_COLOR` — the same map
the graph section above paints its nodes with — and the only other hues
are `--grade-*`. Unit-tested, so a raw hex can't creep back in.
- Contrast forced two divergences from the app's own paint: the student
bubble takes `--brand-forest` (6.4:1 under white) not `--accent`
(4.04:1), and per-row grade letters keep `--text` lettering with the
band on the border/dot, since `--grade-b` is 3.39:1 as a glyph.
No new dependency, no framer-motion, no JS motion at all: the surfaces
are static pictures, so `prefers-reduced-motion` and `IS_TEST_MODE` have
no frame to park — there is only the complete one. The entrance is the
page's existing `.landing-fade-up` observer, which degrades to "visible"
rather than "invisible" when it never fires.
All new CSS is inside the `.landing-page` scope and defines no tokens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(landing): give the knowledge graph product chrome, a dial and a rail (#344)
The graph section was the least convincing thing on a page whose bands and
bento each recreate a whole product screen. It was six flat circles and six
words, floating naked on the page background, left-aligned in a 1184px
container with the right 40% of the field empty, and its entire hover payoff
was one line of body text swapping under the drawing.
It is now one surface, full container width, split the way the app's own Tree
screen is split.
- CHROME. `KNOWLEDGE GRAPH` in the bento's mono micro-label, and the selected
course's real numbers on the right — `MA 242 · 19 concepts · 50% mastery`,
all read off the fixture.
- THE NODES ARE DIALS. A tier-tinted disc at the full node radius, a neutral
track ring, the mastery arc swept clockwise from 12 o'clock, and a solid
core. Every dimension is measured INWARD from `nodeRadius`, so the drawn
footprint is byte-identical to the flat disc it replaces: `fitViewBox`,
`labelBaselineY` and the phone's 5.40-unit label clearance are untouched,
and a node group's first `<circle>` is still the tier-painted disc the E2E
legibility gate measures.
- A LEGEND, which is a comprehension fix rather than decoration: four
`--state-*` hues were carrying the whole meaning of the picture and nothing
on the page said what any of them meant. It carries counts, so it reads as a
readout, and it names the tiers in the app's own words.
- AN INSPECTOR RAIL replaces the bare hover line: name, tier as a labelled
chip, blurb, mastery meter, and the neighbours listed with their own scores,
with the hovered node's edges lit in the canvas beside it. Never empty — at
rest it shows the course.
- AMBIENT DRIFT, ~3 units on a 13–22s per-node cycle with a negative delay so
nothing starts in phase. A CSS animation, not a rAF loop: it stays out of
the assembly's frame budget (which the suite counts to prove the helix
neither fires early nor replays), and rides the same `parked` switch, so
reduced-motion visitors and the E2E lane get nodes exactly on their
laid-out points. Off below the mobile breakpoint, where the frame pad is 2
units and the worst label clearance 5.40.
The fixtures gain a numeric `mastery` per node and a `conceptCount` per
course, held to the product's own cutoffs by a port of
`backend/config.py::get_mastery_tier`, so a ring can never say 90% while the
paint says "struggling" and the chrome's percentage can never contradict the
root's own dial.
The assembly, its viewport gate, the negative bottom rootMargin and the
never-blank derivation are untouched. The phone gate's three bars improve
(12.68 / 23.78 / 182.3 against 11 / 20 / 170) because the canvas is
full-bleed horizontally — deliberately, and the CSS says why.
576 tests green (+16), tsc clean, eslint clean on src/ and e2e/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 22, 2026
…566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack: this spec 7/7 in isolation, including
the new journey. The full suite is 48 passed / 1 failed, the failure
being gradebook.spec.ts:35, which is not this change: it sits earlier in
the same worker than landing-drag-field, so nothing here can reach it.
It has now failed 2 of 3 full-suite runs while passing 3 of 3 in
isolation. CI's two retries have been masking it. Whether that is a
suite-context flake or a real intermittent in enrollment resolution is
still open — the failing line is the `Exams` category, which exists only
on the fall-2025 enrollment, so the signature matches "resolved the
wrong enrollment" as readily as "rendered slowly". Handed to the session
working that area; not filed as a flake until the control run on clean
main says it is one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 22, 2026
…566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack, rebased onto main at 7863210.
Control first, on UNPATCHED main: 47 passed, 1 failed — and the one
failure is this journey, so the lane is still red on current main and
this is still the fix it needs. With the patch applied, two consecutive
full suites: 49 passed, 0 failed. The spec alone, three consecutive
runs: 7/7 each time.
That also closes out the gradebook.spec.ts:35 question raised against
the previous revision. It failed 2 of 3 full-suite runs then, always on
a tree carrying an in-flight #553, and I could not tell a suite-context
flake from a real intermittent in enrollment resolution. #553 has since
merged; across the three full suites above it passed every time. It has
not reproduced on current main, so there is nothing to file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 23, 2026
) (#568)
* test(e2e): measure a dropped node against the copy it is welded to (#566)
The scroll-follow half of "a dropped node stays where it was put, and
scrolls with the page" has been red on main since the landing-v5 port
(#524) merged, taking the whole e2e browser lane with it. It is not
flaky: three CI retries and every local run land within 0.2px of each
other, ~98px short of where the assertion expects the node.
The product is right and the journey was wrong. `faq` is the one section
in DragField's `TRACKS`, and `engine/sim.ts::syncClusters()` positions a
tracked cluster at `field.top + a.top + dy`, where
`dy = (track.top - field.top) - trackTop`. That collapses to
`cluster_y = track.top + const`: the cluster is welded to the FAQ
question column, which is `sticky; top:110`. Instrumented over the
journey's own 300px scroll:
scrollY +300.00
#faq top -300.00
track top -202.81 <- pinned at top:110 partway through
cluster top -202.80
probe node y -202.60
error 97.40 (CI: 98.2-98.4)
The column had exactly 202.81px of travel left before its pin, so ~97px
of the scroll happened with the copy held still and the cluster
correctly held still with it. Welding those clusters to their copy is
deliberate — 8bb3486 added it because they slid 374px out from under
the words they belong to.
So the assertion was measuring the one coupling this section does not
have. The other two scroll journeys in this file already measure
relatively — a ring against its own cluster, a cluster against its act's
stage — and both pass; this one reached for raw scrollY, on the single
cluster where that is untrue. It picked cluster 4 for elbow room, not
knowing `faq` is also the only tracked field.
Re-frames it against the weld instead, read from the field's own
`data-drag-track` so the test follows the product rather than
duplicating it: drop the weld and `weldTop()` falls back to the field,
which is the plain 1:1 page-scroll reference. The intent is unchanged —
placed, not detached — and it keeps its teeth: a guard that the weld
target really moved (>100px) plus the exact 300px scroll, so a node
pinned to the screen over a page that never scrolled still fails.
Pointing the same assertion at the untracked section reproduces the
97.40px failure, so it is load-bearing, not vacuous.
Verified on the full local stack: this spec 6/6 twice, full suite 47/48
with one unrelated gradebook flake that passed 3/3 on re-run (retries
are CI-only).
Closes#566
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(e2e): let the journey own its oracle, and put the 1:1 case back (#566)
Review of the first commit found the fix had bought its elegance with the
thing the test is for.
`weldTop()` read the reference out of `data-drag-track` — the product's
own declaration — and fell back to the field when it was absent. So
deleting the `faq` entry from DragField's `TRACKS` would regress the
product (clusters slide 374px out from under the words again, the bug
8bb3486 fixed) while the test re-framed itself to match and stayed
green. A test that derives its expectation from the code under test
cannot fail when that code is wrong. Proven, not argued: emptying
`TRACKS` against the first version left it passing; against this one the
offset drifts 96.69px and it fails, while the unmutated page sits at
0.45px.
The oracle now belongs to the test — `FAQ_COPY` names the column — and
the reference is resolved section-scoped, the way `engine/sim.ts:296`
resolves it (`(field.closest('section') ?? document)`), so the journey
measures against the element the sim actually bound to instead of
whatever matches first document-wide.
Also from the review:
- The invariant is expressed as an offset that must not change, reusing
the idiom the act-tutor journey already uses, rather than a signed
delta that only worked out because the page scrolls downward.
- The "did the reference move" guard is signed (`< -100`), not
`Math.abs(...) > 100`: scrolling down must carry content up, and an
absolute value would bless a copy that translated the wrong way.
- Preconditions run before the guard, so a page that failed to scroll
says so instead of blaming the weld.
- Node and reference are sampled in ONE `page.evaluate`; two round trips
compared two different frames of a still-integrating sim.
- `toBeCloseTo(300, 0)` rather than exact float equality on a
browser-computed scroll offset.
- The dead `section` parameter is gone. It was never passed, and
`weldTop(page, CLUSTER)` — plausible, given the adjacent constant —
built `#4 .drag-field` and threw a raw SyntaxError.
The deeper finding was coverage: re-framing the only page-relative
assertion in the file left nothing tying a cluster to raw document
scroll, while the file header still teaches that symptom 4 survived
every earlier test because they measured against a field instead of the
page. So the plain case is restored where it is actually true — a new
journey on cluster 6 in `newsletter`, which has no `TRACKS` entry, no
sticky stage, and unlike `cta` has room below it for the scroll. It
carries the original assertion verbatim; measured 1:1 error is 0.58px.
Verified on the full local stack, rebased onto main at 7863210.
Control first, on UNPATCHED main: 47 passed, 1 failed — and the one
failure is this journey, so the lane is still red on current main and
this is still the fix it needs. With the patch applied, two consecutive
full suites: 49 passed, 0 failed. The spec alone, three consecutive
runs: 7/7 each time.
That also closes out the gradebook.spec.ts:35 question raised against
the previous revision. It failed 2 of 3 full-suite runs then, always on
a tree carrying an in-flight #553, and I could not tell a suite-context
flake from a real intermittent in enrollment resolution. #553 has since
merged; across the three full suites above it passed every time. It has
not reproduced on current main, so there is nothing to file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Jose-Gael-Cruz-Lopez