Uh oh!
There was an error while loading. Please reload this page.
1 add personal client side definitive study guides - #5
Merged
Darkest-Teddy merged 2 commits intoMar 18, 2026
Merged
Conversation
Deploying sapling with |
| Latest commit: | 05be31a |
| Status: | ✅ Deploy successful! |
| Preview URL: | https://9c71caf5.sapling-12n.pages.dev |
| Branch Preview URL: | https://1-add-personal-client-side-d.sapling-12n.pages.dev |
Uh oh!
There was an error while loading. Please reload this page.
AndresL230
deleted the
1-add-personal-client-side-definitive-study-guides
branch
March 18, 2026 05:43
Jose-Gael-Cruz-Lopez pushed a commit
that referenced
this pull request
Apr 19, 2026
Deep audit turned up six deploy-fragile issues that passed local dev
and `next build` but would surface in a real HTTPS deploy:
1. Hydration mismatch on Dashboard greeting.
getGreetingPrefix(new Date()) was computed during render, so the
server's timezone would diverge from the client's and trigger a
React hydration error. Moved behind a useEffect.
2. Hydration mismatch on Dashboard random quote.
useMemo(() => QUOTES[Math.floor(Math.random()*...)]) picked a
different quote on server vs client. Moved to useState + useEffect
seeded after mount.
3. Session cookie missing Secure flag.
api/auth/session POST and DELETE set sapling_session without
`secure: true`; browsers drop the cookie on HTTPS origins, so
users appear logged-out after OAuth. Now set based on NODE_ENV.
4. Localhost fallback in Auth.tsx and auth/callback/page.tsx.
`process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5000'` would
silently route production OAuth redirects to localhost. Replaced
with empty-string fallback so Next.js's /api/:path* rewrite takes
over when the env var is unset in the deploy environment.
5. UserContext direct fetches produced undefined URLs.
`${process.env.NEXT_PUBLIC_API_URL}/api/users` with an unset var
becomes the literal string `undefined/api/users`. Added the same
empty-fallback pattern so calls become relative and Next.js
rewrites them server-side.
6. Settings data-export fetch: same fix as #5.
Local verification: tsc --noEmit clean, `next build` clean (18
routes compiled, Middleware -> Proxy deprecation warning unchanged
and non-blocking).
False positives investigated and dismissed:
- page.tsx files without 'use client' are fine; Next.js lets server
page components render client component children.
- UserContext localStorage access is inside useEffect, not a
hydration risk.
- Supabase proxy client is lazy; only crashes if Social is opened
without NEXT_PUBLIC_SUPABASE_* set, which is the desired behavior.4 tasks
AndresL230 pushed a commit
that referenced
this pull request
Jul 9, 2026
… usage limits Three regressions introduced when routes moved from legacy <graph_update> XML parsing to Pydantic AI tools. Closes#5 — Chat-tutor agent can now raise concept mastery. The only registered graph tool was emitting new_nodes at initial_mastery 0.0 and never calling the updated_nodes branch that actually moves scores. Added update_mastery_tool (ConceptMasteryUpdate + MasteryUpdateInput) which forwards updated_nodes with mastery_delta to apply_graph_update, and registered it on all three chat-tutor agents (socratic/expository/teachback). Updated _SHARED_PREAMBLE to instruct the model when to call each tool. Closes#13 — end_session concepts_covered now populated for agent-path chats. Agent path was calling save_message() with no graph_update argument, leaving graph_update_json NULL in every message row. end_session derives concepts_covered entirely from that column, so it always returned []. Fixed by adding a graph_updates: list accumulator field to SaplingDeps; both graph tools append their payload during a run; _chat_via_agent merges and returns the combined dict; chat() passes it to save_message() so the column is populated. Closes#14 — ORCHESTRATOR_LIMITS is no longer dead code. UsageLimits(8 req / 10 tool-calls / 100k tokens) was defined in agents/__init__.py but never passed to .run(). Tool-using agents ran with no token or call ceiling. Added usage_limits=ORCHESTRATOR_LIMITS to run_kwargs in both _chat_via_agent (learn.py) and _quiz_via_agent (quiz.py). Tests: 140 tests pass (0 new failures). New test file test_graph_tools_bugs.py covers all three bugs with 14 targeted tests; test_chat_tutor_imports.py updated to expect 5 tools; pre-existing test_skips_self_edges KeyError fixed in test_graph_service.py. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
AndresL230 added a commit
that referenced
this pull request
Jul 9, 2026
…imits (#243) * fix(agents): restore mastery updates, persist graph_update_json, wire usage limits Three regressions introduced when routes moved from legacy <graph_update> XML parsing to Pydantic AI tools. Closes#5 — Chat-tutor agent can now raise concept mastery. The only registered graph tool was emitting new_nodes at initial_mastery 0.0 and never calling the updated_nodes branch that actually moves scores. Added update_mastery_tool (ConceptMasteryUpdate + MasteryUpdateInput) which forwards updated_nodes with mastery_delta to apply_graph_update, and registered it on all three chat-tutor agents (socratic/expository/teachback). Updated _SHARED_PREAMBLE to instruct the model when to call each tool. Closes#13 — end_session concepts_covered now populated for agent-path chats. Agent path was calling save_message() with no graph_update argument, leaving graph_update_json NULL in every message row. end_session derives concepts_covered entirely from that column, so it always returned []. Fixed by adding a graph_updates: list accumulator field to SaplingDeps; both graph tools append their payload during a run; _chat_via_agent merges and returns the combined dict; chat() passes it to save_message() so the column is populated. Closes#14 — ORCHESTRATOR_LIMITS is no longer dead code. UsageLimits(8 req / 10 tool-calls / 100k tokens) was defined in agents/__init__.py but never passed to .run(). Tool-using agents ran with no token or call ceiling. Added usage_limits=ORCHESTRATOR_LIMITS to run_kwargs in both _chat_via_agent (learn.py) and _quiz_via_agent (quiz.py). Tests: 140 tests pass (0 new failures). New test file test_graph_tools_bugs.py covers all three bugs with 14 targeted tests; test_chat_tutor_imports.py updated to expect 5 tools; pre-existing test_skips_self_edges KeyError fixed in test_graph_service.py. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(agents): only persist mastery updates that actually changed the graph update_mastery_tool appended {updated_nodes} to deps.graph_updates unconditionally, so a concept the model named but that does not exist in the graph (no changes returned by apply_graph_update) still leaked into graph_update_json and was over-reported as concepts_covered in end_session. Gate the append on the concepts that genuinely changed, rebuilding updated_nodes from apply_graph_update's returned changes. Also accumulate the real before/after deltas on deps.mastery_changes so the route can surface them for parity with the legacy path. * fix(learn): surface real mastery_changes from agent chat path _chat_via_agent returned mastery_changes: [] even when update_mastery_tool produced real before/after deltas during the run, leaving the agent path asymmetric with _legacy_chat (which returns apply_graph_update's changes). Echo deps.mastery_changes back to the client for parity, and correct the docstring that claimed the empty value was intentional. * style(tests): remove unused imports and vars flagged by ruff (F401/F841) * fix(agents): bound mastery_delta and normalize the persisted-node gate Two review nits on the update_mastery_tool added in this PR: - Constrain ConceptMasteryUpdate.mastery_delta to [-1.0, 1.0] (ge/le). The score is already clamped in apply_graph_update, but the raw model delta is also written verbatim into node_mastery_events and feeds the 14-day mastery-velocity metric — an out-of-range delta would distort it. - Match the persisted-node over-report gate on the normalized concept name. apply_graph_update dedups case/whitespace-insensitively and returns the *stored* name, while updated_nodes carries the model's spelling; the exact-string gate dropped a genuinely-changed concept on casing/spacing drift, under-reporting concepts_covered in end_session. Reuse _normalize_concept on both sides. Adds a regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tests): reconcile onto main — offering rename + CodeRabbit nits Rebased onto current main (f788ed3). Adjustments the 398-commit drift required: - Tests patched routes.learn._get_session_course_id, which main renamed to _get_session_offering_id in the offering redesign. _chat_via_agent takes course_id directly and never calls it, so drop the incidental patch in test_learn_chat_via_agent_passes_usage_limits; repoint the endpoint test test_agent_path_save_message_receives_graph_update to _get_session_offering_id. This was the sole CI (Backend pytest) blocker. CodeRabbit review nits: - Constrain ConceptMasteryUpdate.event_type to Literal[interaction,correction, quiz] so invalid labels can't reach node_mastery_events. - Trim persisted concept names in both graph tools so " DFS " can't reach graph_update_json with surrounding whitespace. - Replace the tautological assertion in test_returns_fallback_message_when_no_score_change with a real check. Full backend suite: 859 passed, 1 skipped (2 pre-existing storage_service failures + 1 OCR event-loop flake are unrelated; they also fail on clean main). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Jose Cruz <jose.colorstack@gmail.com> Co-authored-by: AndresL230 <190146319+AndresL230@users.noreply.github.com>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Brief summary of what this PR does and why.
Changes Made
Related Issues
Closes #
Testing
Screenshots (if applicable)
Notes for Reviewers