Skip to content

perf(frontend): #111 runtime animations + lazy/next-image loading - #490

Merged
AndresL230 merged 4 commits into
mainfrom
feat/111-runtime-anim-image-perf
Jul 31, 2026
Merged

perf(frontend): #111 runtime animations + lazy/next-image loading#490
AndresL230 merged 4 commits into
mainfrom
feat/111-runtime-anim-image-perf

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

Implements the four acceptance items of #111 (June frontend-UI audit, Performance dimension). Much of the audit had already been fixed in the interim — this PR closes what was still real, verified finding-by-finding against current code:

  • Landing hero canvas ((public)/page.tsx): reduced-motion gating, per-node shadowBlur, floating-card querySelectorAll-per-frame, and spotlight rect caching were already fixed. Still real and fixed here: the O(N²) link pass now hoists per-node bounds, rejects on |dx|/|dy| axes, and compares squared distances — Math.sqrt only runs for the tiny fraction of pairs that draw a link (was Math.hypot on ~27k pairs/frame). Pixel-identical output.
  • framer-motion out of the landing chunk: HowItWorks is now next/dynamic (ssr:false) with a height-preserving placeholder, mirroring the repo's MarkdownChat/react-force-graph split pattern.
  • AtmosphericBackdrop: 14 radial gradients were rebuilt at 60fps forever. Now each orb is pre-baked to an offscreen sprite (re-baked only on DPR change), the loop is throttled to ~30fps with drift speed preserved via elapsed-frame scaling, and it fully pauses on visibilitychange. Reduced-motion semantics unchanged.
  • KnowledgeGraph2D: simulation ticks no longer re-render the SVG through React — positions are written directly to DOM (one transform per node group, four attrs per edge). Tooltip setState fires only on open/reseed; moves are direct style writes. nodeById Map replaces the per-edge .find(). e2e/graph.spec.ts DOM contracts (testids, counts, labels, opacity) verified untouched; testmode tests updated to read the new transform position carrier. One-line eslint-suppressions.json baseline prune (13→12, same maintenance as 47950e5).
  • Images: every raw <img> in the audit's scope now has loading="lazy" + decoding="async" (nav logos excepted post-review — permanent chrome loads eagerly; raw imgs on the static public pages and in SignInModal/toasts were outside [P2] Optimize runtime animations + add lazy/next-image loading #111's enumerated scope); intrinsic width/height added where the rendered box is fixed (TopNav/SideNav logos, Settings avatar + banner preview). Deliberately no fixed dims on natural-aspect user uploads (Social attachments, Admin issue screenshots) — hardcoding both would distort content with no stored dimensions to derive from. No next/image conversion (no loader configured; per issue).
  • Study: framer-motion subtree extracted to StudyMotion.tsx behind next/dynamic (ssr:false) with visually identical static fallbacks; the feat(frontend): NEXT_PUBLIC_TEST_MODE determinism flag #383skipAnimations test seam moved with it so it still runs before any motion component renders.

Verification

  • vitest: 55 files / 399 tests pass
  • eslint: 0 errors (pre-existing warnings only); suppressions baseline pruned, not grown
  • tsc --noEmit: clean in src/
  • production build (next build): green
  • ⚠️Local E2E lane could not run on this machine — the local stack is unprovisioned here (no backend/.env; installed Supabase CLI 2.90.0 rejects config.toml's auto_expose_new_tables/local_smtp keys; macOS bash 3.2 hits its known quote-in-$() parser bug at scripts/lib/local-common.sh:99, so make e2e-up dies at source time). e2e.yml runs the full browser lane on the push to main. The graph journey's DOM assertions were manually cross-checked against the KnowledgeGraph2D changes.

Perf-only PR: no visual/behavioral change for non-reduced-motion users.

Closes#111

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Performance Improvements

    • Improved page loading with deferred interactive sections and optimized image loading across navigation, settings, admin, and social screens.
    • Reduced rendering work for animated backgrounds and knowledge graphs, providing smoother interactions.
    • Background animation now pauses when pages are hidden and respects reduced-motion preferences.
  • User Experience

    • Added smoother study-mode transitions and toggle animations.
    • Preserved knowledge graph tooltips, dragging, links, and layout behavior while improving responsiveness.
  • Bug Fixes

    • Improved knowledge graph rendering reliability and updated visual tests for node positioning.

- Landing hero: squared-distance link pass with axis rejects (no more
Math.hypot over all ~27k node pairs/frame); framer-motion split out of
the landing chunk via next/dynamic (HowItWorks) with a layout-stable
placeholder.
- AtmosphericBackdrop: orb gradients pre-baked to offscreen sprites
(re-baked only on DPR change), RAF throttled to ~30fps with drift
speed preserved, loop paused while the tab is hidden.
- KnowledgeGraph2D: simulation ticks write node/edge positions directly
to the DOM (zero React work per tick), tooltip setState only on
open/reseed with direct style writes while shown, id->node Map
replaces O(E*N) find(); testmode tests read the new transform
position carrier. eslint-suppressions baseline pruned (13->12).
- Images: loading="lazy" + decoding="async" on all raw <img> sites;
intrinsic width/height added where the rendered box is fixed
(TopNav/SideNav logos, Settings avatar/banner) and deliberately
omitted for natural-aspect user uploads (Social attachments, Admin
screenshots).
- Study: framer-motion subtree moved to StudyMotion.tsx behind
next/dynamic (ssr:false) with visually identical fallbacks; the #383
skipAnimations test seam moved with it.
Verified: 55 files / 399 vitest tests, eslint 0 errors, tsc clean,
production build green. Local E2E lane not runnable on this machine
(unprovisioned: no backend/.env, supabase CLI/config.toml mismatch,
bash 3.2 parser bug in scripts/lib/local-common.sh:99); e2e.yml covers
the lane on push to main.
Closes#111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

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


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

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d79f821d-a071-4ad6-92ed-6dc7ce3d16a8

📥 Commits

Reviewing files that changed from the base of the PR and between 426be67 and 5809465.

📒 Files selected for processing (8)
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/AtmosphericBackdrop.tsx
  • frontend/src/components/SideNav.tsx
  • frontend/src/components/TopNav.tsx
  • frontend/src/components/flashcards/FlashcardImportModal.test.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/StudyMotion.tsx

📝 Walkthrough

Walkthrough

Changes

Frontend performance updates

Layer / File(s)Summary
Landing-page deferred content and canvas links
frontend/src/app/(public)/page.tsx
HowItWorks loads dynamically on the client. Canvas link checks reject distant pairs before calculating square roots.
Atmospheric backdrop sprite animation
frontend/src/components/AtmosphericBackdrop.tsx
Orb gradients use DPR-specific cached sprites. Rendering is throttled and pauses for hidden documents.
Knowledge graph direct DOM updates
frontend/src/components/KnowledgeGraph2D.tsx, frontend/src/components/KnowledgeGraph2D.testmode.test.tsx, frontend/eslint-suppressions.json
Simulation ticks update SVG elements directly. Node IDs use a map. Tooltips use direct positioning. Tests inspect translated node groups.
Study motion client loading
frontend/src/components/screens/Study.tsx, frontend/src/components/screens/StudyMotion.tsx, frontend/src/app/globals.css
The toggle highlight loads dynamically. Study mode panels use a keyed CSS enter animation.
Image metadata and test teardown
frontend/src/components/SideNav.tsx, frontend/src/components/TopNav.tsx, frontend/src/components/screens/Admin.tsx, frontend/src/components/screens/Settings.tsx, frontend/src/components/screens/Social.tsx, frontend/src/components/flashcards/FlashcardImportModal.test.tsx
Images now include dimensions or asynchronous decoding attributes. Flashcard modal tests explicitly unmount rendered trees during teardown.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
participant LandingPage
participant AtmosphericBackdrop
participant KnowledgeGraph2D
participant Study
LandingPage->>AtmosphericBackdrop: load optimized animated backdrop
LandingPage->>KnowledgeGraph2D: render graph with direct SVG updates
Study->>Study: load motion highlight and animate mode panel
Loading

Possibly related PRs

  • SaplingLearn/Sapling#92: Both PRs modify knowledge-graph rendering, but this PR optimizes the existing 2D SVG graph.
  • SaplingLearn/Sapling#286: Both PRs modify shared frontend UI files, including Study.tsx, navigation, social screens, and the public page.
  • SaplingLearn/Sapling#492: Both PRs extend optimizations in page.tsx, AtmosphericBackdrop.tsx, and KnowledgeGraph2D.tsx.

Suggested reviewers:andresl230

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost performance objectives are addressed, but several issue-listed images still lack intrinsic dimensions, including Social and Admin images.Add intrinsic dimensions or documented aspect-ratio handling for every applicable image listed in issue #111, including Social, Admin, Avatar, and AvatarFrame.
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Out of Scope Changes check✅ PassedThe changes remain within issue #111, covering frontend performance, image loading, animation behavior, graph rendering, and related lint maintenance.
Title check✅ PassedThe title clearly identifies the frontend performance work, runtime animation changes, and lazy loading, although it inaccurately suggests a next/image change.
Description check✅ PassedThe description explains the changes, linked issue, verification results, and E2E limitation in sufficient detail, despite using different section headings than the template.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/111-runtime-anim-image-perf

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/AtmosphericBackdrop.tsx`:
- Around line 170-175: Update onReducedChange to restart the animation when
reduced motion changes to disabled: if the document is visible and
rafRef.current is null, reset lastFrameRef.current and schedule
requestAnimationFrame(tick). Preserve the existing reduced-motion behavior in
tick, including painting one still frame and clearing the RAF reference.
In `@frontend/src/components/screens/Study.tsx`:
- Around line 32-36: Update the StudyModePanel dynamic import fallback so it
renders the currently active Study pane content, not just an empty flex wrapper,
while the motion component is pending; preserve the wrapper layout and replace
it with the loaded StudyModePanel once available. Add a regression test covering
the pending-import state and verifying the active GuideMode or FlashcardsMode
remains visible.
In `@frontend/src/components/SideNav.tsx`:
- Around line 114-117: Remove the loading="lazy" attribute from both
above-the-fold navigation logos: the sidebar logo in
frontend/src/components/SideNav.tsx lines 114-117 and the top navigation logo in
frontend/src/components/TopNav.tsx lines 172-175. Leave their other image
attributes unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 934e3205-2249-4a4b-8124-5b86673a0b85

📥 Commits

Reviewing files that changed from the base of the PR and between 432c207 and 426be67.

📒 Files selected for processing (12)
  • frontend/eslint-suppressions.json
  • frontend/src/app/(public)/page.tsx
  • frontend/src/components/AtmosphericBackdrop.tsx
  • frontend/src/components/KnowledgeGraph2D.testmode.test.tsx
  • frontend/src/components/KnowledgeGraph2D.tsx
  • frontend/src/components/SideNav.tsx
  • frontend/src/components/TopNav.tsx
  • frontend/src/components/screens/Admin.tsx
  • frontend/src/components/screens/Settings.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/StudyMotion.tsx

Comment threadfrontend/src/components/AtmosphericBackdrop.tsx
Comment threadfrontend/src/components/screens/Study.tsx Outdated
Comment threadfrontend/src/components/SideNav.tsx
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

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

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

Branch Preview URL
Jul 31 2026, 06:39 AM

AndresL230 added a commit that referenced this pull request Jul 31, 2026
#111)
The throttle returned before the physics step, so orb positions advanced once
per PAINTED frame rather than once per rAF — at 30fps that is half the
original drift speed. A visible change, in a PR whose whole claim is that
there isn't one.
Each step is now scaled by how many 60fps-equivalent frames actually elapsed,
so the apparent speed matches the 60fps original at any cadence. Clamped to 4
frames so a backgrounded tab (or the very first frame, when lastPaint is
still 0) cannot teleport the field on resume.
Caught by reading PR #490, which independently hit the same trap and handled
it — my own review pass checked the throttle for stalls and for the
reduced-motion still frame, but not for the drift rate.
part of #111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

Heads-up: I opened #492 against issue #111 before seeing this PR — my mistake, and entirely my own for not checking open PRs first. The two overlap on nine files, including the hero canvas, AtmosphericBackdrop and KnowledgeGraph2D.

I've posted a side-by-side comparison on #492 rather than duplicating it here. Short version: this PR is the more thorough pass — direct DOM writes for the graph ticks (what issue #111's own proposal recommended, and deeper than my rAF coalescing), the visibilitychange pause, the StudyMotion extraction, and pruning the suppressions baseline rather than growing it.

Two things from #492 that may be worth porting here:

  1. lib/linkPairs.ts — spatial binning makes the link pass sub-quadratic rather than a cheaper O(N²); measured 0.356ms → 0.100ms per frame on the real 226-node cloud, where only ~213 of 25,425 pairs ever qualify. It comes with a test proving it emits identical pairs in identical order against the naive version (order matters — the strokes are translucent).
  2. Dropping ssr: false from the HowItWorks dynamic import. Plain next/dynamic still code-splits the JS but keeps the section in the server-rendered HTML; ssr: false removes it from what crawlers see on the one page that needs SEO.

Also worth flagging, since you hit the same trap and handled it and I didn't: the 30fps cap halving the drift speed. Your elapsed-frame scaling is the right fix — I'd missed it until I read this PR.

No action needed from you; @AndresL230 should decide which lands.

…s, eager nav logos
- AtmosphericBackdrop: cap sprite backing resolution at 512px (was full
device resolution — ~100 MB of canvas memory at DPR 2 across 14 orbs);
the soft gradients upscale indistinguishably. Also repaint the static
frame on resize under reduced motion (canvas.width resets cleared it).
- Study: the mode transition no longer routes pane content through the
lazy motion chunk — next/dynamic's fallback can't carry children, which
gated the panes behind the chunk fetch, and un-gating them remounted
the pane mid-session, wiping state (caught by Study.test.tsx). Replaced
with a keyed CSS enter-fade (`study-mode-enter`) that needs no chunk;
AnimatePresence initial={false} semantics preserved via render-phase
state derivation. StudyMotion.tsx keeps only the toggle highlight and
the #383 test seam.
- TopNav/SideNav: drop loading="lazy" from the always-visible nav logos
(lazy only delays permanent chrome); keep decoding + dimensions.
- Landing: document the ssr:false SEO tradeoff on the HowItWorks split.
Verified: 399/399 vitest, eslint 0 errors, tsc clean, production build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

Automated review pass (Claude Code) on 426be67. The reviewer independently reproduced every gate (399/399 vitest, eslint 0 errors, tsc clean in a throwaway worktree) and verified the hard parts: the link-pass rewrite is provably pixel-identical (squared-distance ⟺ distance for the threshold, alpha formula byte-for-byte), the KnowledgeGraph2D element registries are leak-free with React renders and direct DOM writes structurally unable to disagree, the reduced-motion/test-mode settle path renders final positions, the e2e graph spec reads no geometry, and the backdrop's dtFrames math is genuinely frame-rate-independent.

No criticals. Two Important findings — both real, both fixed in f92e0ce:

  1. Sprite memory (~100 MB at DPR 2). Full-device-resolution baking across 14 orbs traded CPU for a serious RAM/GPU footprint held app-wide, risky under iOS Safari's canvas budgets. Fixed: sprite backing resolution capped at 512px; the soft gradients upscale indistinguishably at ~50-100× less memory.
  2. Study panes gated behind the motion chunk.next/dynamic's loading fallback can't receive children, so StudyModePanel blanked the pane (and stalled its data fetching) until the framer-motion chunk resolved — a new waterfall the PR body's "visually identical fallbacks" missed. First-attempt fix (render children immediately, hand off to the motion panel on load) traded it for a worse bug our own test caught: the handoff remounted the pane and wiped its state. Final fix: the crossfade is now a keyed CSS enter-fade (study-mode-enter) with no chunk dependency at all; initial={false} semantics preserved (no first-mount animation), reduced motion covered by the global reset, and StudyMotion.tsx retains only the toggle-highlight spring + the feat(frontend): NEXT_PUBLIC_TEST_MODE determinism flag #383 test seam.

Minors also addressed: nav logos no longer loading="lazy" (permanent above-the-fold chrome — lazy only delays it), reduced-motion + resize no longer leaves the backdrop canvas blank, the ssr:false SEO tradeoff on HowItWorks is documented in-code, and the PR body's image-sweep wording is corrected (the audit's enumerated list is fully covered; public static pages/SignInModal/toasts were never in scope).

Watch on the post-merge e2e.yml run: graph.spec.ts and study-semester.spec.ts (exercises the reworked Study path).

…Works copy
- AtmosphericBackdrop: disabling reduced motion mid-session now restarts
the parked RAF loop (tick() parks itself on a still frame; the old
always-running loop resumed implicitly, so the backdrop stayed frozen).
- Landing: drop ssr:false from the HowItWorks dynamic import (#492
review) — next/dynamic still splits the motion stack into its own
chunk, but the section's marketing copy is back in the server-rendered
HTML; verified "Upload Your Materials" present in the prerendered
index.html.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

Re: the #492 overlap — ported the ssr: false drop in 23b773f: next/dynamic still splits the motion stack into its own chunk, and the HowItWorks copy is back in the server HTML (verified "Upload Your Materials" present in the prerendered index.html after a production build).

Deliberately did not port lib/linkPairs.ts: it's your implementation on a competing PR and the which-lands call is yours. If #490 is the one that lands, the spatial-binning module + its identical-pairs-identical-order test would make a clean follow-up on top of the cheap-reject pass here (0.356→0.100ms is a real win; the squared-distance rewrite in this PR gets a chunk of that without the new module).

CodeRabbit's three findings are resolved: the reduced-motion revive was a real regression vs. the old always-running loop (fixed in 23b773f); the Study fallback and nav-logo findings were fixed in f92e0ce (Study went further — keyed CSS enter-fade, no chunk dependency at all, after the handoff approach proved to remount panes and wipe state).

Brings in #341 (FullHeightScreen viewport-math), #290 (approval gate) and
#288 (hero card).
The globals.css conflict is resolved by keeping BOTH added blocks: this
branch's .study-mode-enter and main's .pending-* beat. Ordering invariants
re-verified after the merge — .card--hero still follows .card, and .pending-*
still precedes the .anim-d* delay utilities (the animation shorthand resets
animation-delay, so those have to come later to win).
Also fixes the intermittent frontend-lane CI failure, which is unrelated to
either PR but was red-flagging both. FlashcardImportModal.test.tsx wiped
document.body in afterEach but never unmounted; with `globals: false` in
vitest.config.ts, @testing-library/react does not auto-register its cleanup,
so React kept the tree and its pending scheduler work alive past the end of
the file and jsdom teardown landed it on a missing `window`:
ReferenceError: window is not defined
vitest counts those as unhandled errors and exits non-zero even with a fully
green suite — which is exactly how #492 failed with 416/416 tests passing.
Now calls cleanup() before the body sweep (both are needed; the sweep clears
the Dialog/ToastProvider portal siblings cleanup() leaves behind). Verified
3/3 clean full-suite runs, exit 0, zero unhandled errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

Merged main in, ran the E2E lane this PR was missing, and fixed the CI flake

Per @AndresL230's call: take this PR as the base, port anything worthwhile from #492, verify, merge. Pushed three things here.

1. Merged main in. This branch was based on 432c207, before #487/#488/#489 landed. One conflict in globals.css, resolved by keeping both added blocks — your .study-mode-enter and main's .pending-* beat. Both ordering invariants re-verified after the merge: .card--hero still follows .card (242/246 vs 210), and .pending-* still precedes .anim-d* (282-309 vs 310) — that one is load-bearing, since the animation shorthand resets animation-delay.

2. The full local E2E cycle — the gap in this PR's own verification note.

35 passed (1.3m)
0 finding(s), 1 suppressed (allowlisted)

Run at 5809465 inside one flock of the stack lock with SAPLING_MODEL_MODE=function. This matters most for KnowledgeGraph2D: graph.spec.ts is the app's strictest DOM contract and the direct-DOM-write refactor is the riskiest change in either PR. It passes.

3. Fixed the intermittent frontend-lane CI failure. Unrelated to either PR but red-flagging both — #492 failed with 416/416 tests passing and Errors 3. FlashcardImportModal.test.tsx wiped document.body in afterEach but never unmounted; with globals: false in vitest.config.ts, RTL does not auto-register its cleanup, so React kept the tree and its pending scheduler work alive past the end of the file and jsdom teardown landed it on a missing window. Now calls cleanup() before the body sweep. 3/3 clean full-suite runs, exit 0.

On porting from #492 — one thing went in, one did not

Ported: nothing, in the end — you'd already made the ssr: false change in 23b773f while I was working, crediting the #492 review. Keeping the 340vh placeholder alongside SSR is the better call than my version, which dropped it.

Dropped, and worth recording why: I was going to port #492's spatially-binned lib/linkPairs.ts over your axis-reject loop. I benchmarked it instead of assuming, and your version is faster at the real workload.

N=226 axis 0.068 ms | binned 0.113 ms <- the actual hero cloud (220 bg + 6 cluster)
N=500 axis 0.469 ms | binned 0.513 ms
N=1000 axis 2.034 ms | binned 2.065 ms
N=2000 axis 8.579 ms | binned 7.796 ms <- binning finally wins

Identical pairs at every N. Map allocation and per-point sorting cost more than binning saves until roughly N=2000, and the node count here is fixed at 226. So the asymptotically-better algorithm is the slower one in practice, and your implementation stays. My recommendation to Andres to port it was wrong.

Review

Ran the multi-agent review over the merged head, aimed at the riskiest area — React re-renders clobbering the direct DOM writes, or the reverse. No bugs found: both paths read and write the same mutable SimNode objects, so there is no second copy of position state to go stale. Testids, mastery-tier opacity, and the #383 deterministic settle all verified intact (simulation.tick() doesn't dispatch tick events, so the synchronous settle is untouched). Also confirmed StudyMotion.tsx needs no eslint testid-enforcement entry, and that the MotionGlobalConfig.skipAnimations seam still runs before any motion component renders.

Nice work on this — the direct-DOM-write graph and the visibilitychange pause are both better than what I had.

@AndresL230
AndresL230 merged commit 9c2f1a1 into mainJul 31, 2026
6 of 7 checks passed
@AndresL230
AndresL230 deleted the feat/111-runtime-anim-image-perf branch August 2, 2026 18:30
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.

[P2] Optimize runtime animations + add lazy/next-image loading

2 participants

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

perf(frontend): #111 runtime animations + lazy/next-image loading - #490

Merged
AndresL230 merged 4 commits into
mainfrom
feat/111-runtime-anim-image-perf
Jul 31, 2026
Merged

perf(frontend): #111 runtime animations + lazy/next-image loading#490
AndresL230 merged 4 commits into
mainfrom
feat/111-runtime-anim-image-perf

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

Implements the four acceptance items of #111 (June frontend-UI audit, Performance dimension). Much of the audit had already been fixed in the interim — this PR closes what was still real, verified finding-by-finding against current code:

  • Landing hero canvas ((public)/page.tsx): reduced-motion gating, per-node shadowBlur, floating-card querySelectorAll-per-frame, and spotlight rect caching were already fixed. Still real and fixed here: the O(N²) link pass now hoists per-node bounds, rejects on |dx|/|dy| axes, and compares squared distances — Math.sqrt only runs for the tiny fraction of pairs that draw a link (was Math.hypot on ~27k pairs/frame). Pixel-identical output.
  • framer-motion out of the landing chunk: HowItWorks is now next/dynamic (ssr:false) with a height-preserving placeholder, mirroring the repo's MarkdownChat/react-force-graph split pattern.
  • AtmosphericBackdrop: 14 radial gradients were rebuilt at 60fps forever. Now each orb is pre-baked to an offscreen sprite (re-baked only on DPR change), the loop is throttled to ~30fps with drift speed preserved via elapsed-frame scaling, and it fully pauses on visibilitychange. Reduced-motion semantics unchanged.
  • KnowledgeGraph2D: simulation ticks no longer re-render the SVG through React — positions are written directly to DOM (one transform per node group, four attrs per edge). Tooltip setState fires only on open/reseed; moves are direct style writes. nodeById Map replaces the per-edge .find(). e2e/graph.spec.ts DOM contracts (testids, counts, labels, opacity) verified untouched; testmode tests updated to read the new transform position carrier. One-line eslint-suppressions.json baseline prune (13→12, same maintenance as 47950e5).
  • Images: every raw <img> in the audit's scope now has loading="lazy" + decoding="async" (nav logos excepted post-review — permanent chrome loads eagerly; raw imgs on the static public pages and in SignInModal/toasts were outside [P2] Optimize runtime animations + add lazy/next-image loading #111's enumerated scope); intrinsic width/height added where the rendered box is fixed (TopNav/SideNav logos, Settings avatar + banner preview). Deliberately no fixed dims on natural-aspect user uploads (Social attachments, Admin issue screenshots) — hardcoding both would distort content with no stored dimensions to derive from. No next/image conversion (no loader configured; per issue).
  • Study: framer-motion subtree extracted to StudyMotion.tsx behind next/dynamic (ssr:false) with visually identical static fallbacks; the feat(frontend): NEXT_PUBLIC_TEST_MODE determinism flag #383skipAnimations test seam moved with it so it still runs before any motion component renders.

Verification

  • vitest: 55 files / 399 tests pass
  • eslint: 0 errors (pre-existing warnings only); suppressions baseline pruned, not grown
  • tsc --noEmit: clean in src/
  • production build (next build): green
  • ⚠️Local E2E lane could not run on this machine — the local stack is unprovisioned here (no backend/.env; installed Supabase CLI 2.90.0 rejects config.toml's auto_expose_new_tables/local_smtp keys; macOS bash 3.2 hits its known quote-in-$() parser bug at scripts/lib/local-common.sh:99, so make e2e-up dies at source time). e2e.yml runs the full browser lane on the push to main. The graph journey's DOM assertions were manually cross-checked against the KnowledgeGraph2D changes.

Perf-only PR: no visual/behavioral change for non-reduced-motion users.

Closes#111

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Performance Improvements

    • Improved page loading with deferred interactive sections and optimized image loading across navigation, settings, admin, and social screens.
    • Reduced rendering work for animated backgrounds and knowledge graphs, providing smoother interactions.
    • Background animation now pauses when pages are hidden and respects reduced-motion preferences.
  • User Experience

    • Added smoother study-mode transitions and toggle animations.
    • Preserved knowledge graph tooltips, dragging, links, and layout behavior while improving responsiveness.
  • Bug Fixes

    • Improved knowledge graph rendering reliability and updated visual tests for node positioning.

- Landing hero: squared-distance link pass with axis rejects (no more
Math.hypot over all ~27k node pairs/frame); framer-motion split out of
the landing chunk via next/dynamic (HowItWorks) with a layout-stable
placeholder.
- AtmosphericBackdrop: orb gradients pre-baked to offscreen sprites
(re-baked only on DPR change), RAF throttled to ~30fps with drift
speed preserved, loop paused while the tab is hidden.
- KnowledgeGraph2D: simulation ticks write node/edge positions directly
to the DOM (zero React work per tick), tooltip setState only on
open/reseed with direct style writes while shown, id->node Map
replaces O(E*N) find(); testmode tests read the new transform
position carrier. eslint-suppressions baseline pruned (13->12).
- Images: loading="lazy" + decoding="async" on all raw <img> sites;
intrinsic width/height added where the rendered box is fixed
(TopNav/SideNav logos, Settings avatar/banner) and deliberately
omitted for natural-aspect user uploads (Social attachments, Admin
screenshots).
- Study: framer-motion subtree moved to StudyMotion.tsx behind
next/dynamic (ssr:false) with visually identical fallbacks; the #383
skipAnimations test seam moved with it.
Verified: 55 files / 399 vitest tests, eslint 0 errors, tsc clean,
production build green. Local E2E lane not runnable on this machine
(unprovisioned: no backend/.env, supabase CLI/config.toml mismatch,
bash 3.2 parser bug in scripts/lib/local-common.sh:99); e2e.yml covers
the lane on push to main.
Closes#111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

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


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

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d79f821d-a071-4ad6-92ed-6dc7ce3d16a8

📥 Commits

Reviewing files that changed from the base of the PR and between 426be67 and 5809465.

📒 Files selected for processing (8)
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/AtmosphericBackdrop.tsx
  • frontend/src/components/SideNav.tsx
  • frontend/src/components/TopNav.tsx
  • frontend/src/components/flashcards/FlashcardImportModal.test.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/StudyMotion.tsx

📝 Walkthrough

Walkthrough

Changes

Frontend performance updates

Layer / File(s)Summary
Landing-page deferred content and canvas links
frontend/src/app/(public)/page.tsx
HowItWorks loads dynamically on the client. Canvas link checks reject distant pairs before calculating square roots.
Atmospheric backdrop sprite animation
frontend/src/components/AtmosphericBackdrop.tsx
Orb gradients use DPR-specific cached sprites. Rendering is throttled and pauses for hidden documents.
Knowledge graph direct DOM updates
frontend/src/components/KnowledgeGraph2D.tsx, frontend/src/components/KnowledgeGraph2D.testmode.test.tsx, frontend/eslint-suppressions.json
Simulation ticks update SVG elements directly. Node IDs use a map. Tooltips use direct positioning. Tests inspect translated node groups.
Study motion client loading
frontend/src/components/screens/Study.tsx, frontend/src/components/screens/StudyMotion.tsx, frontend/src/app/globals.css
The toggle highlight loads dynamically. Study mode panels use a keyed CSS enter animation.
Image metadata and test teardown
frontend/src/components/SideNav.tsx, frontend/src/components/TopNav.tsx, frontend/src/components/screens/Admin.tsx, frontend/src/components/screens/Settings.tsx, frontend/src/components/screens/Social.tsx, frontend/src/components/flashcards/FlashcardImportModal.test.tsx
Images now include dimensions or asynchronous decoding attributes. Flashcard modal tests explicitly unmount rendered trees during teardown.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
participant LandingPage
participant AtmosphericBackdrop
participant KnowledgeGraph2D
participant Study
LandingPage->>AtmosphericBackdrop: load optimized animated backdrop
LandingPage->>KnowledgeGraph2D: render graph with direct SVG updates
Study->>Study: load motion highlight and animate mode panel
Loading

Possibly related PRs

  • SaplingLearn/Sapling#92: Both PRs modify knowledge-graph rendering, but this PR optimizes the existing 2D SVG graph.
  • SaplingLearn/Sapling#286: Both PRs modify shared frontend UI files, including Study.tsx, navigation, social screens, and the public page.
  • SaplingLearn/Sapling#492: Both PRs extend optimizations in page.tsx, AtmosphericBackdrop.tsx, and KnowledgeGraph2D.tsx.

Suggested reviewers:andresl230

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost performance objectives are addressed, but several issue-listed images still lack intrinsic dimensions, including Social and Admin images.Add intrinsic dimensions or documented aspect-ratio handling for every applicable image listed in issue #111, including Social, Admin, Avatar, and AvatarFrame.
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Out of Scope Changes check✅ PassedThe changes remain within issue #111, covering frontend performance, image loading, animation behavior, graph rendering, and related lint maintenance.
Title check✅ PassedThe title clearly identifies the frontend performance work, runtime animation changes, and lazy loading, although it inaccurately suggests a next/image change.
Description check✅ PassedThe description explains the changes, linked issue, verification results, and E2E limitation in sufficient detail, despite using different section headings than the template.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/111-runtime-anim-image-perf

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/AtmosphericBackdrop.tsx`:
- Around line 170-175: Update onReducedChange to restart the animation when
reduced motion changes to disabled: if the document is visible and
rafRef.current is null, reset lastFrameRef.current and schedule
requestAnimationFrame(tick). Preserve the existing reduced-motion behavior in
tick, including painting one still frame and clearing the RAF reference.
In `@frontend/src/components/screens/Study.tsx`:
- Around line 32-36: Update the StudyModePanel dynamic import fallback so it
renders the currently active Study pane content, not just an empty flex wrapper,
while the motion component is pending; preserve the wrapper layout and replace
it with the loaded StudyModePanel once available. Add a regression test covering
the pending-import state and verifying the active GuideMode or FlashcardsMode
remains visible.
In `@frontend/src/components/SideNav.tsx`:
- Around line 114-117: Remove the loading="lazy" attribute from both
above-the-fold navigation logos: the sidebar logo in
frontend/src/components/SideNav.tsx lines 114-117 and the top navigation logo in
frontend/src/components/TopNav.tsx lines 172-175. Leave their other image
attributes unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 934e3205-2249-4a4b-8124-5b86673a0b85

📥 Commits

Reviewing files that changed from the base of the PR and between 432c207 and 426be67.

📒 Files selected for processing (12)
  • frontend/eslint-suppressions.json
  • frontend/src/app/(public)/page.tsx
  • frontend/src/components/AtmosphericBackdrop.tsx
  • frontend/src/components/KnowledgeGraph2D.testmode.test.tsx
  • frontend/src/components/KnowledgeGraph2D.tsx
  • frontend/src/components/SideNav.tsx
  • frontend/src/components/TopNav.tsx
  • frontend/src/components/screens/Admin.tsx
  • frontend/src/components/screens/Settings.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/StudyMotion.tsx

Comment threadfrontend/src/components/AtmosphericBackdrop.tsx
Comment threadfrontend/src/components/screens/Study.tsx Outdated
Comment threadfrontend/src/components/SideNav.tsx
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

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

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

Branch Preview URL
Jul 31 2026, 06:39 AM

AndresL230 added a commit that referenced this pull request Jul 31, 2026
#111)
The throttle returned before the physics step, so orb positions advanced once
per PAINTED frame rather than once per rAF — at 30fps that is half the
original drift speed. A visible change, in a PR whose whole claim is that
there isn't one.
Each step is now scaled by how many 60fps-equivalent frames actually elapsed,
so the apparent speed matches the 60fps original at any cadence. Clamped to 4
frames so a backgrounded tab (or the very first frame, when lastPaint is
still 0) cannot teleport the field on resume.
Caught by reading PR #490, which independently hit the same trap and handled
it — my own review pass checked the throttle for stalls and for the
reduced-motion still frame, but not for the drift rate.
part of #111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

Heads-up: I opened #492 against issue #111 before seeing this PR — my mistake, and entirely my own for not checking open PRs first. The two overlap on nine files, including the hero canvas, AtmosphericBackdrop and KnowledgeGraph2D.

I've posted a side-by-side comparison on #492 rather than duplicating it here. Short version: this PR is the more thorough pass — direct DOM writes for the graph ticks (what issue #111's own proposal recommended, and deeper than my rAF coalescing), the visibilitychange pause, the StudyMotion extraction, and pruning the suppressions baseline rather than growing it.

Two things from #492 that may be worth porting here:

  1. lib/linkPairs.ts — spatial binning makes the link pass sub-quadratic rather than a cheaper O(N²); measured 0.356ms → 0.100ms per frame on the real 226-node cloud, where only ~213 of 25,425 pairs ever qualify. It comes with a test proving it emits identical pairs in identical order against the naive version (order matters — the strokes are translucent).
  2. Dropping ssr: false from the HowItWorks dynamic import. Plain next/dynamic still code-splits the JS but keeps the section in the server-rendered HTML; ssr: false removes it from what crawlers see on the one page that needs SEO.

Also worth flagging, since you hit the same trap and handled it and I didn't: the 30fps cap halving the drift speed. Your elapsed-frame scaling is the right fix — I'd missed it until I read this PR.

No action needed from you; @AndresL230 should decide which lands.

…s, eager nav logos
- AtmosphericBackdrop: cap sprite backing resolution at 512px (was full
device resolution — ~100 MB of canvas memory at DPR 2 across 14 orbs);
the soft gradients upscale indistinguishably. Also repaint the static
frame on resize under reduced motion (canvas.width resets cleared it).
- Study: the mode transition no longer routes pane content through the
lazy motion chunk — next/dynamic's fallback can't carry children, which
gated the panes behind the chunk fetch, and un-gating them remounted
the pane mid-session, wiping state (caught by Study.test.tsx). Replaced
with a keyed CSS enter-fade (`study-mode-enter`) that needs no chunk;
AnimatePresence initial={false} semantics preserved via render-phase
state derivation. StudyMotion.tsx keeps only the toggle highlight and
the #383 test seam.
- TopNav/SideNav: drop loading="lazy" from the always-visible nav logos
(lazy only delays permanent chrome); keep decoding + dimensions.
- Landing: document the ssr:false SEO tradeoff on the HowItWorks split.
Verified: 399/399 vitest, eslint 0 errors, tsc clean, production build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

Automated review pass (Claude Code) on 426be67. The reviewer independently reproduced every gate (399/399 vitest, eslint 0 errors, tsc clean in a throwaway worktree) and verified the hard parts: the link-pass rewrite is provably pixel-identical (squared-distance ⟺ distance for the threshold, alpha formula byte-for-byte), the KnowledgeGraph2D element registries are leak-free with React renders and direct DOM writes structurally unable to disagree, the reduced-motion/test-mode settle path renders final positions, the e2e graph spec reads no geometry, and the backdrop's dtFrames math is genuinely frame-rate-independent.

No criticals. Two Important findings — both real, both fixed in f92e0ce:

  1. Sprite memory (~100 MB at DPR 2). Full-device-resolution baking across 14 orbs traded CPU for a serious RAM/GPU footprint held app-wide, risky under iOS Safari's canvas budgets. Fixed: sprite backing resolution capped at 512px; the soft gradients upscale indistinguishably at ~50-100× less memory.
  2. Study panes gated behind the motion chunk.next/dynamic's loading fallback can't receive children, so StudyModePanel blanked the pane (and stalled its data fetching) until the framer-motion chunk resolved — a new waterfall the PR body's "visually identical fallbacks" missed. First-attempt fix (render children immediately, hand off to the motion panel on load) traded it for a worse bug our own test caught: the handoff remounted the pane and wiped its state. Final fix: the crossfade is now a keyed CSS enter-fade (study-mode-enter) with no chunk dependency at all; initial={false} semantics preserved (no first-mount animation), reduced motion covered by the global reset, and StudyMotion.tsx retains only the toggle-highlight spring + the feat(frontend): NEXT_PUBLIC_TEST_MODE determinism flag #383 test seam.

Minors also addressed: nav logos no longer loading="lazy" (permanent above-the-fold chrome — lazy only delays it), reduced-motion + resize no longer leaves the backdrop canvas blank, the ssr:false SEO tradeoff on HowItWorks is documented in-code, and the PR body's image-sweep wording is corrected (the audit's enumerated list is fully covered; public static pages/SignInModal/toasts were never in scope).

Watch on the post-merge e2e.yml run: graph.spec.ts and study-semester.spec.ts (exercises the reworked Study path).

…Works copy
- AtmosphericBackdrop: disabling reduced motion mid-session now restarts
the parked RAF loop (tick() parks itself on a still frame; the old
always-running loop resumed implicitly, so the backdrop stayed frozen).
- Landing: drop ssr:false from the HowItWorks dynamic import (#492
review) — next/dynamic still splits the motion stack into its own
chunk, but the section's marketing copy is back in the server-rendered
HTML; verified "Upload Your Materials" present in the prerendered
index.html.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

Re: the #492 overlap — ported the ssr: false drop in 23b773f: next/dynamic still splits the motion stack into its own chunk, and the HowItWorks copy is back in the server HTML (verified "Upload Your Materials" present in the prerendered index.html after a production build).

Deliberately did not port lib/linkPairs.ts: it's your implementation on a competing PR and the which-lands call is yours. If #490 is the one that lands, the spatial-binning module + its identical-pairs-identical-order test would make a clean follow-up on top of the cheap-reject pass here (0.356→0.100ms is a real win; the squared-distance rewrite in this PR gets a chunk of that without the new module).

CodeRabbit's three findings are resolved: the reduced-motion revive was a real regression vs. the old always-running loop (fixed in 23b773f); the Study fallback and nav-logo findings were fixed in f92e0ce (Study went further — keyed CSS enter-fade, no chunk dependency at all, after the handoff approach proved to remount panes and wipe state).

Brings in #341 (FullHeightScreen viewport-math), #290 (approval gate) and
#288 (hero card).
The globals.css conflict is resolved by keeping BOTH added blocks: this
branch's .study-mode-enter and main's .pending-* beat. Ordering invariants
re-verified after the merge — .card--hero still follows .card, and .pending-*
still precedes the .anim-d* delay utilities (the animation shorthand resets
animation-delay, so those have to come later to win).
Also fixes the intermittent frontend-lane CI failure, which is unrelated to
either PR but was red-flagging both. FlashcardImportModal.test.tsx wiped
document.body in afterEach but never unmounted; with `globals: false` in
vitest.config.ts, @testing-library/react does not auto-register its cleanup,
so React kept the tree and its pending scheduler work alive past the end of
the file and jsdom teardown landed it on a missing `window`:
ReferenceError: window is not defined
vitest counts those as unhandled errors and exits non-zero even with a fully
green suite — which is exactly how #492 failed with 416/416 tests passing.
Now calls cleanup() before the body sweep (both are needed; the sweep clears
the Dialog/ToastProvider portal siblings cleanup() leaves behind). Verified
3/3 clean full-suite runs, exit 0, zero unhandled errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

Merged main in, ran the E2E lane this PR was missing, and fixed the CI flake

Per @AndresL230's call: take this PR as the base, port anything worthwhile from #492, verify, merge. Pushed three things here.

1. Merged main in. This branch was based on 432c207, before #487/#488/#489 landed. One conflict in globals.css, resolved by keeping both added blocks — your .study-mode-enter and main's .pending-* beat. Both ordering invariants re-verified after the merge: .card--hero still follows .card (242/246 vs 210), and .pending-* still precedes .anim-d* (282-309 vs 310) — that one is load-bearing, since the animation shorthand resets animation-delay.

2. The full local E2E cycle — the gap in this PR's own verification note.

35 passed (1.3m)
0 finding(s), 1 suppressed (allowlisted)

Run at 5809465 inside one flock of the stack lock with SAPLING_MODEL_MODE=function. This matters most for KnowledgeGraph2D: graph.spec.ts is the app's strictest DOM contract and the direct-DOM-write refactor is the riskiest change in either PR. It passes.

3. Fixed the intermittent frontend-lane CI failure. Unrelated to either PR but red-flagging both — #492 failed with 416/416 tests passing and Errors 3. FlashcardImportModal.test.tsx wiped document.body in afterEach but never unmounted; with globals: false in vitest.config.ts, RTL does not auto-register its cleanup, so React kept the tree and its pending scheduler work alive past the end of the file and jsdom teardown landed it on a missing window. Now calls cleanup() before the body sweep. 3/3 clean full-suite runs, exit 0.

On porting from #492 — one thing went in, one did not

Ported: nothing, in the end — you'd already made the ssr: false change in 23b773f while I was working, crediting the #492 review. Keeping the 340vh placeholder alongside SSR is the better call than my version, which dropped it.

Dropped, and worth recording why: I was going to port #492's spatially-binned lib/linkPairs.ts over your axis-reject loop. I benchmarked it instead of assuming, and your version is faster at the real workload.

N=226 axis 0.068 ms | binned 0.113 ms <- the actual hero cloud (220 bg + 6 cluster)
N=500 axis 0.469 ms | binned 0.513 ms
N=1000 axis 2.034 ms | binned 2.065 ms
N=2000 axis 8.579 ms | binned 7.796 ms <- binning finally wins

Identical pairs at every N. Map allocation and per-point sorting cost more than binning saves until roughly N=2000, and the node count here is fixed at 226. So the asymptotically-better algorithm is the slower one in practice, and your implementation stays. My recommendation to Andres to port it was wrong.

Review

Ran the multi-agent review over the merged head, aimed at the riskiest area — React re-renders clobbering the direct DOM writes, or the reverse. No bugs found: both paths read and write the same mutable SimNode objects, so there is no second copy of position state to go stale. Testids, mastery-tier opacity, and the #383 deterministic settle all verified intact (simulation.tick() doesn't dispatch tick events, so the synchronous settle is untouched). Also confirmed StudyMotion.tsx needs no eslint testid-enforcement entry, and that the MotionGlobalConfig.skipAnimations seam still runs before any motion component renders.

Nice work on this — the direct-DOM-write graph and the visibilitychange pause are both better than what I had.

@AndresL230
AndresL230 merged commit 9c2f1a1 into mainJul 31, 2026
6 of 7 checks passed
@AndresL230
AndresL230 deleted the feat/111-runtime-anim-image-perf branch August 2, 2026 18:30
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.

[P2] Optimize runtime animations + add lazy/next-image loading

2 participants

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

perf(frontend): #111 runtime animations + lazy/next-image loading - #490

Merged
AndresL230 merged 4 commits into
mainfrom
feat/111-runtime-anim-image-perf
Jul 31, 2026
Merged

perf(frontend): #111 runtime animations + lazy/next-image loading#490
AndresL230 merged 4 commits into
mainfrom
feat/111-runtime-anim-image-perf

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

Implements the four acceptance items of #111 (June frontend-UI audit, Performance dimension). Much of the audit had already been fixed in the interim — this PR closes what was still real, verified finding-by-finding against current code:

  • Landing hero canvas ((public)/page.tsx): reduced-motion gating, per-node shadowBlur, floating-card querySelectorAll-per-frame, and spotlight rect caching were already fixed. Still real and fixed here: the O(N²) link pass now hoists per-node bounds, rejects on |dx|/|dy| axes, and compares squared distances — Math.sqrt only runs for the tiny fraction of pairs that draw a link (was Math.hypot on ~27k pairs/frame). Pixel-identical output.
  • framer-motion out of the landing chunk: HowItWorks is now next/dynamic (ssr:false) with a height-preserving placeholder, mirroring the repo's MarkdownChat/react-force-graph split pattern.
  • AtmosphericBackdrop: 14 radial gradients were rebuilt at 60fps forever. Now each orb is pre-baked to an offscreen sprite (re-baked only on DPR change), the loop is throttled to ~30fps with drift speed preserved via elapsed-frame scaling, and it fully pauses on visibilitychange. Reduced-motion semantics unchanged.
  • KnowledgeGraph2D: simulation ticks no longer re-render the SVG through React — positions are written directly to DOM (one transform per node group, four attrs per edge). Tooltip setState fires only on open/reseed; moves are direct style writes. nodeById Map replaces the per-edge .find(). e2e/graph.spec.ts DOM contracts (testids, counts, labels, opacity) verified untouched; testmode tests updated to read the new transform position carrier. One-line eslint-suppressions.json baseline prune (13→12, same maintenance as 47950e5).
  • Images: every raw <img> in the audit's scope now has loading="lazy" + decoding="async" (nav logos excepted post-review — permanent chrome loads eagerly; raw imgs on the static public pages and in SignInModal/toasts were outside [P2] Optimize runtime animations + add lazy/next-image loading #111's enumerated scope); intrinsic width/height added where the rendered box is fixed (TopNav/SideNav logos, Settings avatar + banner preview). Deliberately no fixed dims on natural-aspect user uploads (Social attachments, Admin issue screenshots) — hardcoding both would distort content with no stored dimensions to derive from. No next/image conversion (no loader configured; per issue).
  • Study: framer-motion subtree extracted to StudyMotion.tsx behind next/dynamic (ssr:false) with visually identical static fallbacks; the feat(frontend): NEXT_PUBLIC_TEST_MODE determinism flag #383skipAnimations test seam moved with it so it still runs before any motion component renders.

Verification

  • vitest: 55 files / 399 tests pass
  • eslint: 0 errors (pre-existing warnings only); suppressions baseline pruned, not grown
  • tsc --noEmit: clean in src/
  • production build (next build): green
  • ⚠️Local E2E lane could not run on this machine — the local stack is unprovisioned here (no backend/.env; installed Supabase CLI 2.90.0 rejects config.toml's auto_expose_new_tables/local_smtp keys; macOS bash 3.2 hits its known quote-in-$() parser bug at scripts/lib/local-common.sh:99, so make e2e-up dies at source time). e2e.yml runs the full browser lane on the push to main. The graph journey's DOM assertions were manually cross-checked against the KnowledgeGraph2D changes.

Perf-only PR: no visual/behavioral change for non-reduced-motion users.

Closes#111

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Performance Improvements

    • Improved page loading with deferred interactive sections and optimized image loading across navigation, settings, admin, and social screens.
    • Reduced rendering work for animated backgrounds and knowledge graphs, providing smoother interactions.
    • Background animation now pauses when pages are hidden and respects reduced-motion preferences.
  • User Experience

    • Added smoother study-mode transitions and toggle animations.
    • Preserved knowledge graph tooltips, dragging, links, and layout behavior while improving responsiveness.
  • Bug Fixes

    • Improved knowledge graph rendering reliability and updated visual tests for node positioning.

- Landing hero: squared-distance link pass with axis rejects (no more
Math.hypot over all ~27k node pairs/frame); framer-motion split out of
the landing chunk via next/dynamic (HowItWorks) with a layout-stable
placeholder.
- AtmosphericBackdrop: orb gradients pre-baked to offscreen sprites
(re-baked only on DPR change), RAF throttled to ~30fps with drift
speed preserved, loop paused while the tab is hidden.
- KnowledgeGraph2D: simulation ticks write node/edge positions directly
to the DOM (zero React work per tick), tooltip setState only on
open/reseed with direct style writes while shown, id->node Map
replaces O(E*N) find(); testmode tests read the new transform
position carrier. eslint-suppressions baseline pruned (13->12).
- Images: loading="lazy" + decoding="async" on all raw <img> sites;
intrinsic width/height added where the rendered box is fixed
(TopNav/SideNav logos, Settings avatar/banner) and deliberately
omitted for natural-aspect user uploads (Social attachments, Admin
screenshots).
- Study: framer-motion subtree moved to StudyMotion.tsx behind
next/dynamic (ssr:false) with visually identical fallbacks; the #383
skipAnimations test seam moved with it.
Verified: 55 files / 399 vitest tests, eslint 0 errors, tsc clean,
production build green. Local E2E lane not runnable on this machine
(unprovisioned: no backend/.env, supabase CLI/config.toml mismatch,
bash 3.2 parser bug in scripts/lib/local-common.sh:99); e2e.yml covers
the lane on push to main.
Closes#111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

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


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

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d79f821d-a071-4ad6-92ed-6dc7ce3d16a8

📥 Commits

Reviewing files that changed from the base of the PR and between 426be67 and 5809465.

📒 Files selected for processing (8)
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/AtmosphericBackdrop.tsx
  • frontend/src/components/SideNav.tsx
  • frontend/src/components/TopNav.tsx
  • frontend/src/components/flashcards/FlashcardImportModal.test.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/StudyMotion.tsx

📝 Walkthrough

Walkthrough

Changes

Frontend performance updates

Layer / File(s)Summary
Landing-page deferred content and canvas links
frontend/src/app/(public)/page.tsx
HowItWorks loads dynamically on the client. Canvas link checks reject distant pairs before calculating square roots.
Atmospheric backdrop sprite animation
frontend/src/components/AtmosphericBackdrop.tsx
Orb gradients use DPR-specific cached sprites. Rendering is throttled and pauses for hidden documents.
Knowledge graph direct DOM updates
frontend/src/components/KnowledgeGraph2D.tsx, frontend/src/components/KnowledgeGraph2D.testmode.test.tsx, frontend/eslint-suppressions.json
Simulation ticks update SVG elements directly. Node IDs use a map. Tooltips use direct positioning. Tests inspect translated node groups.
Study motion client loading
frontend/src/components/screens/Study.tsx, frontend/src/components/screens/StudyMotion.tsx, frontend/src/app/globals.css
The toggle highlight loads dynamically. Study mode panels use a keyed CSS enter animation.
Image metadata and test teardown
frontend/src/components/SideNav.tsx, frontend/src/components/TopNav.tsx, frontend/src/components/screens/Admin.tsx, frontend/src/components/screens/Settings.tsx, frontend/src/components/screens/Social.tsx, frontend/src/components/flashcards/FlashcardImportModal.test.tsx
Images now include dimensions or asynchronous decoding attributes. Flashcard modal tests explicitly unmount rendered trees during teardown.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
participant LandingPage
participant AtmosphericBackdrop
participant KnowledgeGraph2D
participant Study
LandingPage->>AtmosphericBackdrop: load optimized animated backdrop
LandingPage->>KnowledgeGraph2D: render graph with direct SVG updates
Study->>Study: load motion highlight and animate mode panel
Loading

Possibly related PRs

  • SaplingLearn/Sapling#92: Both PRs modify knowledge-graph rendering, but this PR optimizes the existing 2D SVG graph.
  • SaplingLearn/Sapling#286: Both PRs modify shared frontend UI files, including Study.tsx, navigation, social screens, and the public page.
  • SaplingLearn/Sapling#492: Both PRs extend optimizations in page.tsx, AtmosphericBackdrop.tsx, and KnowledgeGraph2D.tsx.

Suggested reviewers:andresl230

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost performance objectives are addressed, but several issue-listed images still lack intrinsic dimensions, including Social and Admin images.Add intrinsic dimensions or documented aspect-ratio handling for every applicable image listed in issue #111, including Social, Admin, Avatar, and AvatarFrame.
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Out of Scope Changes check✅ PassedThe changes remain within issue #111, covering frontend performance, image loading, animation behavior, graph rendering, and related lint maintenance.
Title check✅ PassedThe title clearly identifies the frontend performance work, runtime animation changes, and lazy loading, although it inaccurately suggests a next/image change.
Description check✅ PassedThe description explains the changes, linked issue, verification results, and E2E limitation in sufficient detail, despite using different section headings than the template.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/111-runtime-anim-image-perf

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/AtmosphericBackdrop.tsx`:
- Around line 170-175: Update onReducedChange to restart the animation when
reduced motion changes to disabled: if the document is visible and
rafRef.current is null, reset lastFrameRef.current and schedule
requestAnimationFrame(tick). Preserve the existing reduced-motion behavior in
tick, including painting one still frame and clearing the RAF reference.
In `@frontend/src/components/screens/Study.tsx`:
- Around line 32-36: Update the StudyModePanel dynamic import fallback so it
renders the currently active Study pane content, not just an empty flex wrapper,
while the motion component is pending; preserve the wrapper layout and replace
it with the loaded StudyModePanel once available. Add a regression test covering
the pending-import state and verifying the active GuideMode or FlashcardsMode
remains visible.
In `@frontend/src/components/SideNav.tsx`:
- Around line 114-117: Remove the loading="lazy" attribute from both
above-the-fold navigation logos: the sidebar logo in
frontend/src/components/SideNav.tsx lines 114-117 and the top navigation logo in
frontend/src/components/TopNav.tsx lines 172-175. Leave their other image
attributes unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 934e3205-2249-4a4b-8124-5b86673a0b85

📥 Commits

Reviewing files that changed from the base of the PR and between 432c207 and 426be67.

📒 Files selected for processing (12)
  • frontend/eslint-suppressions.json
  • frontend/src/app/(public)/page.tsx
  • frontend/src/components/AtmosphericBackdrop.tsx
  • frontend/src/components/KnowledgeGraph2D.testmode.test.tsx
  • frontend/src/components/KnowledgeGraph2D.tsx
  • frontend/src/components/SideNav.tsx
  • frontend/src/components/TopNav.tsx
  • frontend/src/components/screens/Admin.tsx
  • frontend/src/components/screens/Settings.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/StudyMotion.tsx

Comment threadfrontend/src/components/AtmosphericBackdrop.tsx
Comment threadfrontend/src/components/screens/Study.tsx Outdated
Comment threadfrontend/src/components/SideNav.tsx
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

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

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

Branch Preview URL
Jul 31 2026, 06:39 AM

AndresL230 added a commit that referenced this pull request Jul 31, 2026
#111)
The throttle returned before the physics step, so orb positions advanced once
per PAINTED frame rather than once per rAF — at 30fps that is half the
original drift speed. A visible change, in a PR whose whole claim is that
there isn't one.
Each step is now scaled by how many 60fps-equivalent frames actually elapsed,
so the apparent speed matches the 60fps original at any cadence. Clamped to 4
frames so a backgrounded tab (or the very first frame, when lastPaint is
still 0) cannot teleport the field on resume.
Caught by reading PR #490, which independently hit the same trap and handled
it — my own review pass checked the throttle for stalls and for the
reduced-motion still frame, but not for the drift rate.
part of #111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

Heads-up: I opened #492 against issue #111 before seeing this PR — my mistake, and entirely my own for not checking open PRs first. The two overlap on nine files, including the hero canvas, AtmosphericBackdrop and KnowledgeGraph2D.

I've posted a side-by-side comparison on #492 rather than duplicating it here. Short version: this PR is the more thorough pass — direct DOM writes for the graph ticks (what issue #111's own proposal recommended, and deeper than my rAF coalescing), the visibilitychange pause, the StudyMotion extraction, and pruning the suppressions baseline rather than growing it.

Two things from #492 that may be worth porting here:

  1. lib/linkPairs.ts — spatial binning makes the link pass sub-quadratic rather than a cheaper O(N²); measured 0.356ms → 0.100ms per frame on the real 226-node cloud, where only ~213 of 25,425 pairs ever qualify. It comes with a test proving it emits identical pairs in identical order against the naive version (order matters — the strokes are translucent).
  2. Dropping ssr: false from the HowItWorks dynamic import. Plain next/dynamic still code-splits the JS but keeps the section in the server-rendered HTML; ssr: false removes it from what crawlers see on the one page that needs SEO.

Also worth flagging, since you hit the same trap and handled it and I didn't: the 30fps cap halving the drift speed. Your elapsed-frame scaling is the right fix — I'd missed it until I read this PR.

No action needed from you; @AndresL230 should decide which lands.

…s, eager nav logos
- AtmosphericBackdrop: cap sprite backing resolution at 512px (was full
device resolution — ~100 MB of canvas memory at DPR 2 across 14 orbs);
the soft gradients upscale indistinguishably. Also repaint the static
frame on resize under reduced motion (canvas.width resets cleared it).
- Study: the mode transition no longer routes pane content through the
lazy motion chunk — next/dynamic's fallback can't carry children, which
gated the panes behind the chunk fetch, and un-gating them remounted
the pane mid-session, wiping state (caught by Study.test.tsx). Replaced
with a keyed CSS enter-fade (`study-mode-enter`) that needs no chunk;
AnimatePresence initial={false} semantics preserved via render-phase
state derivation. StudyMotion.tsx keeps only the toggle highlight and
the #383 test seam.
- TopNav/SideNav: drop loading="lazy" from the always-visible nav logos
(lazy only delays permanent chrome); keep decoding + dimensions.
- Landing: document the ssr:false SEO tradeoff on the HowItWorks split.
Verified: 399/399 vitest, eslint 0 errors, tsc clean, production build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

Automated review pass (Claude Code) on 426be67. The reviewer independently reproduced every gate (399/399 vitest, eslint 0 errors, tsc clean in a throwaway worktree) and verified the hard parts: the link-pass rewrite is provably pixel-identical (squared-distance ⟺ distance for the threshold, alpha formula byte-for-byte), the KnowledgeGraph2D element registries are leak-free with React renders and direct DOM writes structurally unable to disagree, the reduced-motion/test-mode settle path renders final positions, the e2e graph spec reads no geometry, and the backdrop's dtFrames math is genuinely frame-rate-independent.

No criticals. Two Important findings — both real, both fixed in f92e0ce:

  1. Sprite memory (~100 MB at DPR 2). Full-device-resolution baking across 14 orbs traded CPU for a serious RAM/GPU footprint held app-wide, risky under iOS Safari's canvas budgets. Fixed: sprite backing resolution capped at 512px; the soft gradients upscale indistinguishably at ~50-100× less memory.
  2. Study panes gated behind the motion chunk.next/dynamic's loading fallback can't receive children, so StudyModePanel blanked the pane (and stalled its data fetching) until the framer-motion chunk resolved — a new waterfall the PR body's "visually identical fallbacks" missed. First-attempt fix (render children immediately, hand off to the motion panel on load) traded it for a worse bug our own test caught: the handoff remounted the pane and wiped its state. Final fix: the crossfade is now a keyed CSS enter-fade (study-mode-enter) with no chunk dependency at all; initial={false} semantics preserved (no first-mount animation), reduced motion covered by the global reset, and StudyMotion.tsx retains only the toggle-highlight spring + the feat(frontend): NEXT_PUBLIC_TEST_MODE determinism flag #383 test seam.

Minors also addressed: nav logos no longer loading="lazy" (permanent above-the-fold chrome — lazy only delays it), reduced-motion + resize no longer leaves the backdrop canvas blank, the ssr:false SEO tradeoff on HowItWorks is documented in-code, and the PR body's image-sweep wording is corrected (the audit's enumerated list is fully covered; public static pages/SignInModal/toasts were never in scope).

Watch on the post-merge e2e.yml run: graph.spec.ts and study-semester.spec.ts (exercises the reworked Study path).

…Works copy
- AtmosphericBackdrop: disabling reduced motion mid-session now restarts
the parked RAF loop (tick() parks itself on a still frame; the old
always-running loop resumed implicitly, so the backdrop stayed frozen).
- Landing: drop ssr:false from the HowItWorks dynamic import (#492
review) — next/dynamic still splits the motion stack into its own
chunk, but the section's marketing copy is back in the server-rendered
HTML; verified "Upload Your Materials" present in the prerendered
index.html.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

Re: the #492 overlap — ported the ssr: false drop in 23b773f: next/dynamic still splits the motion stack into its own chunk, and the HowItWorks copy is back in the server HTML (verified "Upload Your Materials" present in the prerendered index.html after a production build).

Deliberately did not port lib/linkPairs.ts: it's your implementation on a competing PR and the which-lands call is yours. If #490 is the one that lands, the spatial-binning module + its identical-pairs-identical-order test would make a clean follow-up on top of the cheap-reject pass here (0.356→0.100ms is a real win; the squared-distance rewrite in this PR gets a chunk of that without the new module).

CodeRabbit's three findings are resolved: the reduced-motion revive was a real regression vs. the old always-running loop (fixed in 23b773f); the Study fallback and nav-logo findings were fixed in f92e0ce (Study went further — keyed CSS enter-fade, no chunk dependency at all, after the handoff approach proved to remount panes and wipe state).

Brings in #341 (FullHeightScreen viewport-math), #290 (approval gate) and
#288 (hero card).
The globals.css conflict is resolved by keeping BOTH added blocks: this
branch's .study-mode-enter and main's .pending-* beat. Ordering invariants
re-verified after the merge — .card--hero still follows .card, and .pending-*
still precedes the .anim-d* delay utilities (the animation shorthand resets
animation-delay, so those have to come later to win).
Also fixes the intermittent frontend-lane CI failure, which is unrelated to
either PR but was red-flagging both. FlashcardImportModal.test.tsx wiped
document.body in afterEach but never unmounted; with `globals: false` in
vitest.config.ts, @testing-library/react does not auto-register its cleanup,
so React kept the tree and its pending scheduler work alive past the end of
the file and jsdom teardown landed it on a missing `window`:
ReferenceError: window is not defined
vitest counts those as unhandled errors and exits non-zero even with a fully
green suite — which is exactly how #492 failed with 416/416 tests passing.
Now calls cleanup() before the body sweep (both are needed; the sweep clears
the Dialog/ToastProvider portal siblings cleanup() leaves behind). Verified
3/3 clean full-suite runs, exit 0, zero unhandled errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

Merged main in, ran the E2E lane this PR was missing, and fixed the CI flake

Per @AndresL230's call: take this PR as the base, port anything worthwhile from #492, verify, merge. Pushed three things here.

1. Merged main in. This branch was based on 432c207, before #487/#488/#489 landed. One conflict in globals.css, resolved by keeping both added blocks — your .study-mode-enter and main's .pending-* beat. Both ordering invariants re-verified after the merge: .card--hero still follows .card (242/246 vs 210), and .pending-* still precedes .anim-d* (282-309 vs 310) — that one is load-bearing, since the animation shorthand resets animation-delay.

2. The full local E2E cycle — the gap in this PR's own verification note.

35 passed (1.3m)
0 finding(s), 1 suppressed (allowlisted)

Run at 5809465 inside one flock of the stack lock with SAPLING_MODEL_MODE=function. This matters most for KnowledgeGraph2D: graph.spec.ts is the app's strictest DOM contract and the direct-DOM-write refactor is the riskiest change in either PR. It passes.

3. Fixed the intermittent frontend-lane CI failure. Unrelated to either PR but red-flagging both — #492 failed with 416/416 tests passing and Errors 3. FlashcardImportModal.test.tsx wiped document.body in afterEach but never unmounted; with globals: false in vitest.config.ts, RTL does not auto-register its cleanup, so React kept the tree and its pending scheduler work alive past the end of the file and jsdom teardown landed it on a missing window. Now calls cleanup() before the body sweep. 3/3 clean full-suite runs, exit 0.

On porting from #492 — one thing went in, one did not

Ported: nothing, in the end — you'd already made the ssr: false change in 23b773f while I was working, crediting the #492 review. Keeping the 340vh placeholder alongside SSR is the better call than my version, which dropped it.

Dropped, and worth recording why: I was going to port #492's spatially-binned lib/linkPairs.ts over your axis-reject loop. I benchmarked it instead of assuming, and your version is faster at the real workload.

N=226 axis 0.068 ms | binned 0.113 ms <- the actual hero cloud (220 bg + 6 cluster)
N=500 axis 0.469 ms | binned 0.513 ms
N=1000 axis 2.034 ms | binned 2.065 ms
N=2000 axis 8.579 ms | binned 7.796 ms <- binning finally wins

Identical pairs at every N. Map allocation and per-point sorting cost more than binning saves until roughly N=2000, and the node count here is fixed at 226. So the asymptotically-better algorithm is the slower one in practice, and your implementation stays. My recommendation to Andres to port it was wrong.

Review

Ran the multi-agent review over the merged head, aimed at the riskiest area — React re-renders clobbering the direct DOM writes, or the reverse. No bugs found: both paths read and write the same mutable SimNode objects, so there is no second copy of position state to go stale. Testids, mastery-tier opacity, and the #383 deterministic settle all verified intact (simulation.tick() doesn't dispatch tick events, so the synchronous settle is untouched). Also confirmed StudyMotion.tsx needs no eslint testid-enforcement entry, and that the MotionGlobalConfig.skipAnimations seam still runs before any motion component renders.

Nice work on this — the direct-DOM-write graph and the visibilitychange pause are both better than what I had.

@AndresL230
AndresL230 merged commit 9c2f1a1 into mainJul 31, 2026
6 of 7 checks passed
@AndresL230
AndresL230 deleted the feat/111-runtime-anim-image-perf branch August 2, 2026 18:30
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.

[P2] Optimize runtime animations + add lazy/next-image loading

2 participants

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

perf(frontend): #111 runtime animations + lazy/next-image loading - #490

Merged
AndresL230 merged 4 commits into
mainfrom
feat/111-runtime-anim-image-perf
Jul 31, 2026
Merged

perf(frontend): #111 runtime animations + lazy/next-image loading#490
AndresL230 merged 4 commits into
mainfrom
feat/111-runtime-anim-image-perf

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

Implements the four acceptance items of #111 (June frontend-UI audit, Performance dimension). Much of the audit had already been fixed in the interim — this PR closes what was still real, verified finding-by-finding against current code:

  • Landing hero canvas ((public)/page.tsx): reduced-motion gating, per-node shadowBlur, floating-card querySelectorAll-per-frame, and spotlight rect caching were already fixed. Still real and fixed here: the O(N²) link pass now hoists per-node bounds, rejects on |dx|/|dy| axes, and compares squared distances — Math.sqrt only runs for the tiny fraction of pairs that draw a link (was Math.hypot on ~27k pairs/frame). Pixel-identical output.
  • framer-motion out of the landing chunk: HowItWorks is now next/dynamic (ssr:false) with a height-preserving placeholder, mirroring the repo's MarkdownChat/react-force-graph split pattern.
  • AtmosphericBackdrop: 14 radial gradients were rebuilt at 60fps forever. Now each orb is pre-baked to an offscreen sprite (re-baked only on DPR change), the loop is throttled to ~30fps with drift speed preserved via elapsed-frame scaling, and it fully pauses on visibilitychange. Reduced-motion semantics unchanged.
  • KnowledgeGraph2D: simulation ticks no longer re-render the SVG through React — positions are written directly to DOM (one transform per node group, four attrs per edge). Tooltip setState fires only on open/reseed; moves are direct style writes. nodeById Map replaces the per-edge .find(). e2e/graph.spec.ts DOM contracts (testids, counts, labels, opacity) verified untouched; testmode tests updated to read the new transform position carrier. One-line eslint-suppressions.json baseline prune (13→12, same maintenance as 47950e5).
  • Images: every raw <img> in the audit's scope now has loading="lazy" + decoding="async" (nav logos excepted post-review — permanent chrome loads eagerly; raw imgs on the static public pages and in SignInModal/toasts were outside [P2] Optimize runtime animations + add lazy/next-image loading #111's enumerated scope); intrinsic width/height added where the rendered box is fixed (TopNav/SideNav logos, Settings avatar + banner preview). Deliberately no fixed dims on natural-aspect user uploads (Social attachments, Admin issue screenshots) — hardcoding both would distort content with no stored dimensions to derive from. No next/image conversion (no loader configured; per issue).
  • Study: framer-motion subtree extracted to StudyMotion.tsx behind next/dynamic (ssr:false) with visually identical static fallbacks; the feat(frontend): NEXT_PUBLIC_TEST_MODE determinism flag #383skipAnimations test seam moved with it so it still runs before any motion component renders.

Verification

  • vitest: 55 files / 399 tests pass
  • eslint: 0 errors (pre-existing warnings only); suppressions baseline pruned, not grown
  • tsc --noEmit: clean in src/
  • production build (next build): green
  • ⚠️Local E2E lane could not run on this machine — the local stack is unprovisioned here (no backend/.env; installed Supabase CLI 2.90.0 rejects config.toml's auto_expose_new_tables/local_smtp keys; macOS bash 3.2 hits its known quote-in-$() parser bug at scripts/lib/local-common.sh:99, so make e2e-up dies at source time). e2e.yml runs the full browser lane on the push to main. The graph journey's DOM assertions were manually cross-checked against the KnowledgeGraph2D changes.

Perf-only PR: no visual/behavioral change for non-reduced-motion users.

Closes#111

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Performance Improvements

    • Improved page loading with deferred interactive sections and optimized image loading across navigation, settings, admin, and social screens.
    • Reduced rendering work for animated backgrounds and knowledge graphs, providing smoother interactions.
    • Background animation now pauses when pages are hidden and respects reduced-motion preferences.
  • User Experience

    • Added smoother study-mode transitions and toggle animations.
    • Preserved knowledge graph tooltips, dragging, links, and layout behavior while improving responsiveness.
  • Bug Fixes

    • Improved knowledge graph rendering reliability and updated visual tests for node positioning.

- Landing hero: squared-distance link pass with axis rejects (no more
Math.hypot over all ~27k node pairs/frame); framer-motion split out of
the landing chunk via next/dynamic (HowItWorks) with a layout-stable
placeholder.
- AtmosphericBackdrop: orb gradients pre-baked to offscreen sprites
(re-baked only on DPR change), RAF throttled to ~30fps with drift
speed preserved, loop paused while the tab is hidden.
- KnowledgeGraph2D: simulation ticks write node/edge positions directly
to the DOM (zero React work per tick), tooltip setState only on
open/reseed with direct style writes while shown, id->node Map
replaces O(E*N) find(); testmode tests read the new transform
position carrier. eslint-suppressions baseline pruned (13->12).
- Images: loading="lazy" + decoding="async" on all raw <img> sites;
intrinsic width/height added where the rendered box is fixed
(TopNav/SideNav logos, Settings avatar/banner) and deliberately
omitted for natural-aspect user uploads (Social attachments, Admin
screenshots).
- Study: framer-motion subtree moved to StudyMotion.tsx behind
next/dynamic (ssr:false) with visually identical fallbacks; the #383
skipAnimations test seam moved with it.
Verified: 55 files / 399 vitest tests, eslint 0 errors, tsc clean,
production build green. Local E2E lane not runnable on this machine
(unprovisioned: no backend/.env, supabase CLI/config.toml mismatch,
bash 3.2 parser bug in scripts/lib/local-common.sh:99); e2e.yml covers
the lane on push to main.
Closes#111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

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


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

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d79f821d-a071-4ad6-92ed-6dc7ce3d16a8

📥 Commits

Reviewing files that changed from the base of the PR and between 426be67 and 5809465.

📒 Files selected for processing (8)
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/AtmosphericBackdrop.tsx
  • frontend/src/components/SideNav.tsx
  • frontend/src/components/TopNav.tsx
  • frontend/src/components/flashcards/FlashcardImportModal.test.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/StudyMotion.tsx

📝 Walkthrough

Walkthrough

Changes

Frontend performance updates

Layer / File(s)Summary
Landing-page deferred content and canvas links
frontend/src/app/(public)/page.tsx
HowItWorks loads dynamically on the client. Canvas link checks reject distant pairs before calculating square roots.
Atmospheric backdrop sprite animation
frontend/src/components/AtmosphericBackdrop.tsx
Orb gradients use DPR-specific cached sprites. Rendering is throttled and pauses for hidden documents.
Knowledge graph direct DOM updates
frontend/src/components/KnowledgeGraph2D.tsx, frontend/src/components/KnowledgeGraph2D.testmode.test.tsx, frontend/eslint-suppressions.json
Simulation ticks update SVG elements directly. Node IDs use a map. Tooltips use direct positioning. Tests inspect translated node groups.
Study motion client loading
frontend/src/components/screens/Study.tsx, frontend/src/components/screens/StudyMotion.tsx, frontend/src/app/globals.css
The toggle highlight loads dynamically. Study mode panels use a keyed CSS enter animation.
Image metadata and test teardown
frontend/src/components/SideNav.tsx, frontend/src/components/TopNav.tsx, frontend/src/components/screens/Admin.tsx, frontend/src/components/screens/Settings.tsx, frontend/src/components/screens/Social.tsx, frontend/src/components/flashcards/FlashcardImportModal.test.tsx
Images now include dimensions or asynchronous decoding attributes. Flashcard modal tests explicitly unmount rendered trees during teardown.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
participant LandingPage
participant AtmosphericBackdrop
participant KnowledgeGraph2D
participant Study
LandingPage->>AtmosphericBackdrop: load optimized animated backdrop
LandingPage->>KnowledgeGraph2D: render graph with direct SVG updates
Study->>Study: load motion highlight and animate mode panel
Loading

Possibly related PRs

  • SaplingLearn/Sapling#92: Both PRs modify knowledge-graph rendering, but this PR optimizes the existing 2D SVG graph.
  • SaplingLearn/Sapling#286: Both PRs modify shared frontend UI files, including Study.tsx, navigation, social screens, and the public page.
  • SaplingLearn/Sapling#492: Both PRs extend optimizations in page.tsx, AtmosphericBackdrop.tsx, and KnowledgeGraph2D.tsx.

Suggested reviewers:andresl230

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost performance objectives are addressed, but several issue-listed images still lack intrinsic dimensions, including Social and Admin images.Add intrinsic dimensions or documented aspect-ratio handling for every applicable image listed in issue #111, including Social, Admin, Avatar, and AvatarFrame.
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Out of Scope Changes check✅ PassedThe changes remain within issue #111, covering frontend performance, image loading, animation behavior, graph rendering, and related lint maintenance.
Title check✅ PassedThe title clearly identifies the frontend performance work, runtime animation changes, and lazy loading, although it inaccurately suggests a next/image change.
Description check✅ PassedThe description explains the changes, linked issue, verification results, and E2E limitation in sufficient detail, despite using different section headings than the template.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/111-runtime-anim-image-perf

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/AtmosphericBackdrop.tsx`:
- Around line 170-175: Update onReducedChange to restart the animation when
reduced motion changes to disabled: if the document is visible and
rafRef.current is null, reset lastFrameRef.current and schedule
requestAnimationFrame(tick). Preserve the existing reduced-motion behavior in
tick, including painting one still frame and clearing the RAF reference.
In `@frontend/src/components/screens/Study.tsx`:
- Around line 32-36: Update the StudyModePanel dynamic import fallback so it
renders the currently active Study pane content, not just an empty flex wrapper,
while the motion component is pending; preserve the wrapper layout and replace
it with the loaded StudyModePanel once available. Add a regression test covering
the pending-import state and verifying the active GuideMode or FlashcardsMode
remains visible.
In `@frontend/src/components/SideNav.tsx`:
- Around line 114-117: Remove the loading="lazy" attribute from both
above-the-fold navigation logos: the sidebar logo in
frontend/src/components/SideNav.tsx lines 114-117 and the top navigation logo in
frontend/src/components/TopNav.tsx lines 172-175. Leave their other image
attributes unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 934e3205-2249-4a4b-8124-5b86673a0b85

📥 Commits

Reviewing files that changed from the base of the PR and between 432c207 and 426be67.

📒 Files selected for processing (12)
  • frontend/eslint-suppressions.json
  • frontend/src/app/(public)/page.tsx
  • frontend/src/components/AtmosphericBackdrop.tsx
  • frontend/src/components/KnowledgeGraph2D.testmode.test.tsx
  • frontend/src/components/KnowledgeGraph2D.tsx
  • frontend/src/components/SideNav.tsx
  • frontend/src/components/TopNav.tsx
  • frontend/src/components/screens/Admin.tsx
  • frontend/src/components/screens/Settings.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/StudyMotion.tsx

Comment threadfrontend/src/components/AtmosphericBackdrop.tsx
Comment threadfrontend/src/components/screens/Study.tsx Outdated
Comment threadfrontend/src/components/SideNav.tsx
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

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

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

Branch Preview URL
Jul 31 2026, 06:39 AM

AndresL230 added a commit that referenced this pull request Jul 31, 2026
#111)
The throttle returned before the physics step, so orb positions advanced once
per PAINTED frame rather than once per rAF — at 30fps that is half the
original drift speed. A visible change, in a PR whose whole claim is that
there isn't one.
Each step is now scaled by how many 60fps-equivalent frames actually elapsed,
so the apparent speed matches the 60fps original at any cadence. Clamped to 4
frames so a backgrounded tab (or the very first frame, when lastPaint is
still 0) cannot teleport the field on resume.
Caught by reading PR #490, which independently hit the same trap and handled
it — my own review pass checked the throttle for stalls and for the
reduced-motion still frame, but not for the drift rate.
part of #111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

Heads-up: I opened #492 against issue #111 before seeing this PR — my mistake, and entirely my own for not checking open PRs first. The two overlap on nine files, including the hero canvas, AtmosphericBackdrop and KnowledgeGraph2D.

I've posted a side-by-side comparison on #492 rather than duplicating it here. Short version: this PR is the more thorough pass — direct DOM writes for the graph ticks (what issue #111's own proposal recommended, and deeper than my rAF coalescing), the visibilitychange pause, the StudyMotion extraction, and pruning the suppressions baseline rather than growing it.

Two things from #492 that may be worth porting here:

  1. lib/linkPairs.ts — spatial binning makes the link pass sub-quadratic rather than a cheaper O(N²); measured 0.356ms → 0.100ms per frame on the real 226-node cloud, where only ~213 of 25,425 pairs ever qualify. It comes with a test proving it emits identical pairs in identical order against the naive version (order matters — the strokes are translucent).
  2. Dropping ssr: false from the HowItWorks dynamic import. Plain next/dynamic still code-splits the JS but keeps the section in the server-rendered HTML; ssr: false removes it from what crawlers see on the one page that needs SEO.

Also worth flagging, since you hit the same trap and handled it and I didn't: the 30fps cap halving the drift speed. Your elapsed-frame scaling is the right fix — I'd missed it until I read this PR.

No action needed from you; @AndresL230 should decide which lands.

…s, eager nav logos
- AtmosphericBackdrop: cap sprite backing resolution at 512px (was full
device resolution — ~100 MB of canvas memory at DPR 2 across 14 orbs);
the soft gradients upscale indistinguishably. Also repaint the static
frame on resize under reduced motion (canvas.width resets cleared it).
- Study: the mode transition no longer routes pane content through the
lazy motion chunk — next/dynamic's fallback can't carry children, which
gated the panes behind the chunk fetch, and un-gating them remounted
the pane mid-session, wiping state (caught by Study.test.tsx). Replaced
with a keyed CSS enter-fade (`study-mode-enter`) that needs no chunk;
AnimatePresence initial={false} semantics preserved via render-phase
state derivation. StudyMotion.tsx keeps only the toggle highlight and
the #383 test seam.
- TopNav/SideNav: drop loading="lazy" from the always-visible nav logos
(lazy only delays permanent chrome); keep decoding + dimensions.
- Landing: document the ssr:false SEO tradeoff on the HowItWorks split.
Verified: 399/399 vitest, eslint 0 errors, tsc clean, production build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

Automated review pass (Claude Code) on 426be67. The reviewer independently reproduced every gate (399/399 vitest, eslint 0 errors, tsc clean in a throwaway worktree) and verified the hard parts: the link-pass rewrite is provably pixel-identical (squared-distance ⟺ distance for the threshold, alpha formula byte-for-byte), the KnowledgeGraph2D element registries are leak-free with React renders and direct DOM writes structurally unable to disagree, the reduced-motion/test-mode settle path renders final positions, the e2e graph spec reads no geometry, and the backdrop's dtFrames math is genuinely frame-rate-independent.

No criticals. Two Important findings — both real, both fixed in f92e0ce:

  1. Sprite memory (~100 MB at DPR 2). Full-device-resolution baking across 14 orbs traded CPU for a serious RAM/GPU footprint held app-wide, risky under iOS Safari's canvas budgets. Fixed: sprite backing resolution capped at 512px; the soft gradients upscale indistinguishably at ~50-100× less memory.
  2. Study panes gated behind the motion chunk.next/dynamic's loading fallback can't receive children, so StudyModePanel blanked the pane (and stalled its data fetching) until the framer-motion chunk resolved — a new waterfall the PR body's "visually identical fallbacks" missed. First-attempt fix (render children immediately, hand off to the motion panel on load) traded it for a worse bug our own test caught: the handoff remounted the pane and wiped its state. Final fix: the crossfade is now a keyed CSS enter-fade (study-mode-enter) with no chunk dependency at all; initial={false} semantics preserved (no first-mount animation), reduced motion covered by the global reset, and StudyMotion.tsx retains only the toggle-highlight spring + the feat(frontend): NEXT_PUBLIC_TEST_MODE determinism flag #383 test seam.

Minors also addressed: nav logos no longer loading="lazy" (permanent above-the-fold chrome — lazy only delays it), reduced-motion + resize no longer leaves the backdrop canvas blank, the ssr:false SEO tradeoff on HowItWorks is documented in-code, and the PR body's image-sweep wording is corrected (the audit's enumerated list is fully covered; public static pages/SignInModal/toasts were never in scope).

Watch on the post-merge e2e.yml run: graph.spec.ts and study-semester.spec.ts (exercises the reworked Study path).

…Works copy
- AtmosphericBackdrop: disabling reduced motion mid-session now restarts
the parked RAF loop (tick() parks itself on a still frame; the old
always-running loop resumed implicitly, so the backdrop stayed frozen).
- Landing: drop ssr:false from the HowItWorks dynamic import (#492
review) — next/dynamic still splits the motion stack into its own
chunk, but the section's marketing copy is back in the server-rendered
HTML; verified "Upload Your Materials" present in the prerendered
index.html.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

Re: the #492 overlap — ported the ssr: false drop in 23b773f: next/dynamic still splits the motion stack into its own chunk, and the HowItWorks copy is back in the server HTML (verified "Upload Your Materials" present in the prerendered index.html after a production build).

Deliberately did not port lib/linkPairs.ts: it's your implementation on a competing PR and the which-lands call is yours. If #490 is the one that lands, the spatial-binning module + its identical-pairs-identical-order test would make a clean follow-up on top of the cheap-reject pass here (0.356→0.100ms is a real win; the squared-distance rewrite in this PR gets a chunk of that without the new module).

CodeRabbit's three findings are resolved: the reduced-motion revive was a real regression vs. the old always-running loop (fixed in 23b773f); the Study fallback and nav-logo findings were fixed in f92e0ce (Study went further — keyed CSS enter-fade, no chunk dependency at all, after the handoff approach proved to remount panes and wipe state).

Brings in #341 (FullHeightScreen viewport-math), #290 (approval gate) and
#288 (hero card).
The globals.css conflict is resolved by keeping BOTH added blocks: this
branch's .study-mode-enter and main's .pending-* beat. Ordering invariants
re-verified after the merge — .card--hero still follows .card, and .pending-*
still precedes the .anim-d* delay utilities (the animation shorthand resets
animation-delay, so those have to come later to win).
Also fixes the intermittent frontend-lane CI failure, which is unrelated to
either PR but was red-flagging both. FlashcardImportModal.test.tsx wiped
document.body in afterEach but never unmounted; with `globals: false` in
vitest.config.ts, @testing-library/react does not auto-register its cleanup,
so React kept the tree and its pending scheduler work alive past the end of
the file and jsdom teardown landed it on a missing `window`:
ReferenceError: window is not defined
vitest counts those as unhandled errors and exits non-zero even with a fully
green suite — which is exactly how #492 failed with 416/416 tests passing.
Now calls cleanup() before the body sweep (both are needed; the sweep clears
the Dialog/ToastProvider portal siblings cleanup() leaves behind). Verified
3/3 clean full-suite runs, exit 0, zero unhandled errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

Merged main in, ran the E2E lane this PR was missing, and fixed the CI flake

Per @AndresL230's call: take this PR as the base, port anything worthwhile from #492, verify, merge. Pushed three things here.

1. Merged main in. This branch was based on 432c207, before #487/#488/#489 landed. One conflict in globals.css, resolved by keeping both added blocks — your .study-mode-enter and main's .pending-* beat. Both ordering invariants re-verified after the merge: .card--hero still follows .card (242/246 vs 210), and .pending-* still precedes .anim-d* (282-309 vs 310) — that one is load-bearing, since the animation shorthand resets animation-delay.

2. The full local E2E cycle — the gap in this PR's own verification note.

35 passed (1.3m)
0 finding(s), 1 suppressed (allowlisted)

Run at 5809465 inside one flock of the stack lock with SAPLING_MODEL_MODE=function. This matters most for KnowledgeGraph2D: graph.spec.ts is the app's strictest DOM contract and the direct-DOM-write refactor is the riskiest change in either PR. It passes.

3. Fixed the intermittent frontend-lane CI failure. Unrelated to either PR but red-flagging both — #492 failed with 416/416 tests passing and Errors 3. FlashcardImportModal.test.tsx wiped document.body in afterEach but never unmounted; with globals: false in vitest.config.ts, RTL does not auto-register its cleanup, so React kept the tree and its pending scheduler work alive past the end of the file and jsdom teardown landed it on a missing window. Now calls cleanup() before the body sweep. 3/3 clean full-suite runs, exit 0.

On porting from #492 — one thing went in, one did not

Ported: nothing, in the end — you'd already made the ssr: false change in 23b773f while I was working, crediting the #492 review. Keeping the 340vh placeholder alongside SSR is the better call than my version, which dropped it.

Dropped, and worth recording why: I was going to port #492's spatially-binned lib/linkPairs.ts over your axis-reject loop. I benchmarked it instead of assuming, and your version is faster at the real workload.

N=226 axis 0.068 ms | binned 0.113 ms <- the actual hero cloud (220 bg + 6 cluster)
N=500 axis 0.469 ms | binned 0.513 ms
N=1000 axis 2.034 ms | binned 2.065 ms
N=2000 axis 8.579 ms | binned 7.796 ms <- binning finally wins

Identical pairs at every N. Map allocation and per-point sorting cost more than binning saves until roughly N=2000, and the node count here is fixed at 226. So the asymptotically-better algorithm is the slower one in practice, and your implementation stays. My recommendation to Andres to port it was wrong.

Review

Ran the multi-agent review over the merged head, aimed at the riskiest area — React re-renders clobbering the direct DOM writes, or the reverse. No bugs found: both paths read and write the same mutable SimNode objects, so there is no second copy of position state to go stale. Testids, mastery-tier opacity, and the #383 deterministic settle all verified intact (simulation.tick() doesn't dispatch tick events, so the synchronous settle is untouched). Also confirmed StudyMotion.tsx needs no eslint testid-enforcement entry, and that the MotionGlobalConfig.skipAnimations seam still runs before any motion component renders.

Nice work on this — the direct-DOM-write graph and the visibilitychange pause are both better than what I had.

@AndresL230
AndresL230 merged commit 9c2f1a1 into mainJul 31, 2026
6 of 7 checks passed
@AndresL230
AndresL230 deleted the feat/111-runtime-anim-image-perf branch August 2, 2026 18:30
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.

[P2] Optimize runtime animations + add lazy/next-image loading

2 participants

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

perf(frontend): #111 runtime animations + lazy/next-image loading - #490

Merged
AndresL230 merged 4 commits into
mainfrom
feat/111-runtime-anim-image-perf
Jul 31, 2026
Merged

perf(frontend): #111 runtime animations + lazy/next-image loading#490
AndresL230 merged 4 commits into
mainfrom
feat/111-runtime-anim-image-perf

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

Implements the four acceptance items of #111 (June frontend-UI audit, Performance dimension). Much of the audit had already been fixed in the interim — this PR closes what was still real, verified finding-by-finding against current code:

  • Landing hero canvas ((public)/page.tsx): reduced-motion gating, per-node shadowBlur, floating-card querySelectorAll-per-frame, and spotlight rect caching were already fixed. Still real and fixed here: the O(N²) link pass now hoists per-node bounds, rejects on |dx|/|dy| axes, and compares squared distances — Math.sqrt only runs for the tiny fraction of pairs that draw a link (was Math.hypot on ~27k pairs/frame). Pixel-identical output.
  • framer-motion out of the landing chunk: HowItWorks is now next/dynamic (ssr:false) with a height-preserving placeholder, mirroring the repo's MarkdownChat/react-force-graph split pattern.
  • AtmosphericBackdrop: 14 radial gradients were rebuilt at 60fps forever. Now each orb is pre-baked to an offscreen sprite (re-baked only on DPR change), the loop is throttled to ~30fps with drift speed preserved via elapsed-frame scaling, and it fully pauses on visibilitychange. Reduced-motion semantics unchanged.
  • KnowledgeGraph2D: simulation ticks no longer re-render the SVG through React — positions are written directly to DOM (one transform per node group, four attrs per edge). Tooltip setState fires only on open/reseed; moves are direct style writes. nodeById Map replaces the per-edge .find(). e2e/graph.spec.ts DOM contracts (testids, counts, labels, opacity) verified untouched; testmode tests updated to read the new transform position carrier. One-line eslint-suppressions.json baseline prune (13→12, same maintenance as 47950e5).
  • Images: every raw <img> in the audit's scope now has loading="lazy" + decoding="async" (nav logos excepted post-review — permanent chrome loads eagerly; raw imgs on the static public pages and in SignInModal/toasts were outside [P2] Optimize runtime animations + add lazy/next-image loading #111's enumerated scope); intrinsic width/height added where the rendered box is fixed (TopNav/SideNav logos, Settings avatar + banner preview). Deliberately no fixed dims on natural-aspect user uploads (Social attachments, Admin issue screenshots) — hardcoding both would distort content with no stored dimensions to derive from. No next/image conversion (no loader configured; per issue).
  • Study: framer-motion subtree extracted to StudyMotion.tsx behind next/dynamic (ssr:false) with visually identical static fallbacks; the feat(frontend): NEXT_PUBLIC_TEST_MODE determinism flag #383skipAnimations test seam moved with it so it still runs before any motion component renders.

Verification

  • vitest: 55 files / 399 tests pass
  • eslint: 0 errors (pre-existing warnings only); suppressions baseline pruned, not grown
  • tsc --noEmit: clean in src/
  • production build (next build): green
  • ⚠️Local E2E lane could not run on this machine — the local stack is unprovisioned here (no backend/.env; installed Supabase CLI 2.90.0 rejects config.toml's auto_expose_new_tables/local_smtp keys; macOS bash 3.2 hits its known quote-in-$() parser bug at scripts/lib/local-common.sh:99, so make e2e-up dies at source time). e2e.yml runs the full browser lane on the push to main. The graph journey's DOM assertions were manually cross-checked against the KnowledgeGraph2D changes.

Perf-only PR: no visual/behavioral change for non-reduced-motion users.

Closes#111

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Performance Improvements

    • Improved page loading with deferred interactive sections and optimized image loading across navigation, settings, admin, and social screens.
    • Reduced rendering work for animated backgrounds and knowledge graphs, providing smoother interactions.
    • Background animation now pauses when pages are hidden and respects reduced-motion preferences.
  • User Experience

    • Added smoother study-mode transitions and toggle animations.
    • Preserved knowledge graph tooltips, dragging, links, and layout behavior while improving responsiveness.
  • Bug Fixes

    • Improved knowledge graph rendering reliability and updated visual tests for node positioning.

- Landing hero: squared-distance link pass with axis rejects (no more
Math.hypot over all ~27k node pairs/frame); framer-motion split out of
the landing chunk via next/dynamic (HowItWorks) with a layout-stable
placeholder.
- AtmosphericBackdrop: orb gradients pre-baked to offscreen sprites
(re-baked only on DPR change), RAF throttled to ~30fps with drift
speed preserved, loop paused while the tab is hidden.
- KnowledgeGraph2D: simulation ticks write node/edge positions directly
to the DOM (zero React work per tick), tooltip setState only on
open/reseed with direct style writes while shown, id->node Map
replaces O(E*N) find(); testmode tests read the new transform
position carrier. eslint-suppressions baseline pruned (13->12).
- Images: loading="lazy" + decoding="async" on all raw <img> sites;
intrinsic width/height added where the rendered box is fixed
(TopNav/SideNav logos, Settings avatar/banner) and deliberately
omitted for natural-aspect user uploads (Social attachments, Admin
screenshots).
- Study: framer-motion subtree moved to StudyMotion.tsx behind
next/dynamic (ssr:false) with visually identical fallbacks; the #383
skipAnimations test seam moved with it.
Verified: 55 files / 399 vitest tests, eslint 0 errors, tsc clean,
production build green. Local E2E lane not runnable on this machine
(unprovisioned: no backend/.env, supabase CLI/config.toml mismatch,
bash 3.2 parser bug in scripts/lib/local-common.sh:99); e2e.yml covers
the lane on push to main.
Closes#111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

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


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

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d79f821d-a071-4ad6-92ed-6dc7ce3d16a8

📥 Commits

Reviewing files that changed from the base of the PR and between 426be67 and 5809465.

📒 Files selected for processing (8)
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/AtmosphericBackdrop.tsx
  • frontend/src/components/SideNav.tsx
  • frontend/src/components/TopNav.tsx
  • frontend/src/components/flashcards/FlashcardImportModal.test.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/StudyMotion.tsx

📝 Walkthrough

Walkthrough

Changes

Frontend performance updates

Layer / File(s)Summary
Landing-page deferred content and canvas links
frontend/src/app/(public)/page.tsx
HowItWorks loads dynamically on the client. Canvas link checks reject distant pairs before calculating square roots.
Atmospheric backdrop sprite animation
frontend/src/components/AtmosphericBackdrop.tsx
Orb gradients use DPR-specific cached sprites. Rendering is throttled and pauses for hidden documents.
Knowledge graph direct DOM updates
frontend/src/components/KnowledgeGraph2D.tsx, frontend/src/components/KnowledgeGraph2D.testmode.test.tsx, frontend/eslint-suppressions.json
Simulation ticks update SVG elements directly. Node IDs use a map. Tooltips use direct positioning. Tests inspect translated node groups.
Study motion client loading
frontend/src/components/screens/Study.tsx, frontend/src/components/screens/StudyMotion.tsx, frontend/src/app/globals.css
The toggle highlight loads dynamically. Study mode panels use a keyed CSS enter animation.
Image metadata and test teardown
frontend/src/components/SideNav.tsx, frontend/src/components/TopNav.tsx, frontend/src/components/screens/Admin.tsx, frontend/src/components/screens/Settings.tsx, frontend/src/components/screens/Social.tsx, frontend/src/components/flashcards/FlashcardImportModal.test.tsx
Images now include dimensions or asynchronous decoding attributes. Flashcard modal tests explicitly unmount rendered trees during teardown.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
participant LandingPage
participant AtmosphericBackdrop
participant KnowledgeGraph2D
participant Study
LandingPage->>AtmosphericBackdrop: load optimized animated backdrop
LandingPage->>KnowledgeGraph2D: render graph with direct SVG updates
Study->>Study: load motion highlight and animate mode panel
Loading

Possibly related PRs

  • SaplingLearn/Sapling#92: Both PRs modify knowledge-graph rendering, but this PR optimizes the existing 2D SVG graph.
  • SaplingLearn/Sapling#286: Both PRs modify shared frontend UI files, including Study.tsx, navigation, social screens, and the public page.
  • SaplingLearn/Sapling#492: Both PRs extend optimizations in page.tsx, AtmosphericBackdrop.tsx, and KnowledgeGraph2D.tsx.

Suggested reviewers:andresl230

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost performance objectives are addressed, but several issue-listed images still lack intrinsic dimensions, including Social and Admin images.Add intrinsic dimensions or documented aspect-ratio handling for every applicable image listed in issue #111, including Social, Admin, Avatar, and AvatarFrame.
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Out of Scope Changes check✅ PassedThe changes remain within issue #111, covering frontend performance, image loading, animation behavior, graph rendering, and related lint maintenance.
Title check✅ PassedThe title clearly identifies the frontend performance work, runtime animation changes, and lazy loading, although it inaccurately suggests a next/image change.
Description check✅ PassedThe description explains the changes, linked issue, verification results, and E2E limitation in sufficient detail, despite using different section headings than the template.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/111-runtime-anim-image-perf

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/AtmosphericBackdrop.tsx`:
- Around line 170-175: Update onReducedChange to restart the animation when
reduced motion changes to disabled: if the document is visible and
rafRef.current is null, reset lastFrameRef.current and schedule
requestAnimationFrame(tick). Preserve the existing reduced-motion behavior in
tick, including painting one still frame and clearing the RAF reference.
In `@frontend/src/components/screens/Study.tsx`:
- Around line 32-36: Update the StudyModePanel dynamic import fallback so it
renders the currently active Study pane content, not just an empty flex wrapper,
while the motion component is pending; preserve the wrapper layout and replace
it with the loaded StudyModePanel once available. Add a regression test covering
the pending-import state and verifying the active GuideMode or FlashcardsMode
remains visible.
In `@frontend/src/components/SideNav.tsx`:
- Around line 114-117: Remove the loading="lazy" attribute from both
above-the-fold navigation logos: the sidebar logo in
frontend/src/components/SideNav.tsx lines 114-117 and the top navigation logo in
frontend/src/components/TopNav.tsx lines 172-175. Leave their other image
attributes unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 934e3205-2249-4a4b-8124-5b86673a0b85

📥 Commits

Reviewing files that changed from the base of the PR and between 432c207 and 426be67.

📒 Files selected for processing (12)
  • frontend/eslint-suppressions.json
  • frontend/src/app/(public)/page.tsx
  • frontend/src/components/AtmosphericBackdrop.tsx
  • frontend/src/components/KnowledgeGraph2D.testmode.test.tsx
  • frontend/src/components/KnowledgeGraph2D.tsx
  • frontend/src/components/SideNav.tsx
  • frontend/src/components/TopNav.tsx
  • frontend/src/components/screens/Admin.tsx
  • frontend/src/components/screens/Settings.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/StudyMotion.tsx

Comment threadfrontend/src/components/AtmosphericBackdrop.tsx
Comment threadfrontend/src/components/screens/Study.tsx Outdated
Comment threadfrontend/src/components/SideNav.tsx
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

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

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

Branch Preview URL
Jul 31 2026, 06:39 AM

AndresL230 added a commit that referenced this pull request Jul 31, 2026
#111)
The throttle returned before the physics step, so orb positions advanced once
per PAINTED frame rather than once per rAF — at 30fps that is half the
original drift speed. A visible change, in a PR whose whole claim is that
there isn't one.
Each step is now scaled by how many 60fps-equivalent frames actually elapsed,
so the apparent speed matches the 60fps original at any cadence. Clamped to 4
frames so a backgrounded tab (or the very first frame, when lastPaint is
still 0) cannot teleport the field on resume.
Caught by reading PR #490, which independently hit the same trap and handled
it — my own review pass checked the throttle for stalls and for the
reduced-motion still frame, but not for the drift rate.
part of #111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

Heads-up: I opened #492 against issue #111 before seeing this PR — my mistake, and entirely my own for not checking open PRs first. The two overlap on nine files, including the hero canvas, AtmosphericBackdrop and KnowledgeGraph2D.

I've posted a side-by-side comparison on #492 rather than duplicating it here. Short version: this PR is the more thorough pass — direct DOM writes for the graph ticks (what issue #111's own proposal recommended, and deeper than my rAF coalescing), the visibilitychange pause, the StudyMotion extraction, and pruning the suppressions baseline rather than growing it.

Two things from #492 that may be worth porting here:

  1. lib/linkPairs.ts — spatial binning makes the link pass sub-quadratic rather than a cheaper O(N²); measured 0.356ms → 0.100ms per frame on the real 226-node cloud, where only ~213 of 25,425 pairs ever qualify. It comes with a test proving it emits identical pairs in identical order against the naive version (order matters — the strokes are translucent).
  2. Dropping ssr: false from the HowItWorks dynamic import. Plain next/dynamic still code-splits the JS but keeps the section in the server-rendered HTML; ssr: false removes it from what crawlers see on the one page that needs SEO.

Also worth flagging, since you hit the same trap and handled it and I didn't: the 30fps cap halving the drift speed. Your elapsed-frame scaling is the right fix — I'd missed it until I read this PR.

No action needed from you; @AndresL230 should decide which lands.

…s, eager nav logos
- AtmosphericBackdrop: cap sprite backing resolution at 512px (was full
device resolution — ~100 MB of canvas memory at DPR 2 across 14 orbs);
the soft gradients upscale indistinguishably. Also repaint the static
frame on resize under reduced motion (canvas.width resets cleared it).
- Study: the mode transition no longer routes pane content through the
lazy motion chunk — next/dynamic's fallback can't carry children, which
gated the panes behind the chunk fetch, and un-gating them remounted
the pane mid-session, wiping state (caught by Study.test.tsx). Replaced
with a keyed CSS enter-fade (`study-mode-enter`) that needs no chunk;
AnimatePresence initial={false} semantics preserved via render-phase
state derivation. StudyMotion.tsx keeps only the toggle highlight and
the #383 test seam.
- TopNav/SideNav: drop loading="lazy" from the always-visible nav logos
(lazy only delays permanent chrome); keep decoding + dimensions.
- Landing: document the ssr:false SEO tradeoff on the HowItWorks split.
Verified: 399/399 vitest, eslint 0 errors, tsc clean, production build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

Automated review pass (Claude Code) on 426be67. The reviewer independently reproduced every gate (399/399 vitest, eslint 0 errors, tsc clean in a throwaway worktree) and verified the hard parts: the link-pass rewrite is provably pixel-identical (squared-distance ⟺ distance for the threshold, alpha formula byte-for-byte), the KnowledgeGraph2D element registries are leak-free with React renders and direct DOM writes structurally unable to disagree, the reduced-motion/test-mode settle path renders final positions, the e2e graph spec reads no geometry, and the backdrop's dtFrames math is genuinely frame-rate-independent.

No criticals. Two Important findings — both real, both fixed in f92e0ce:

  1. Sprite memory (~100 MB at DPR 2). Full-device-resolution baking across 14 orbs traded CPU for a serious RAM/GPU footprint held app-wide, risky under iOS Safari's canvas budgets. Fixed: sprite backing resolution capped at 512px; the soft gradients upscale indistinguishably at ~50-100× less memory.
  2. Study panes gated behind the motion chunk.next/dynamic's loading fallback can't receive children, so StudyModePanel blanked the pane (and stalled its data fetching) until the framer-motion chunk resolved — a new waterfall the PR body's "visually identical fallbacks" missed. First-attempt fix (render children immediately, hand off to the motion panel on load) traded it for a worse bug our own test caught: the handoff remounted the pane and wiped its state. Final fix: the crossfade is now a keyed CSS enter-fade (study-mode-enter) with no chunk dependency at all; initial={false} semantics preserved (no first-mount animation), reduced motion covered by the global reset, and StudyMotion.tsx retains only the toggle-highlight spring + the feat(frontend): NEXT_PUBLIC_TEST_MODE determinism flag #383 test seam.

Minors also addressed: nav logos no longer loading="lazy" (permanent above-the-fold chrome — lazy only delays it), reduced-motion + resize no longer leaves the backdrop canvas blank, the ssr:false SEO tradeoff on HowItWorks is documented in-code, and the PR body's image-sweep wording is corrected (the audit's enumerated list is fully covered; public static pages/SignInModal/toasts were never in scope).

Watch on the post-merge e2e.yml run: graph.spec.ts and study-semester.spec.ts (exercises the reworked Study path).

…Works copy
- AtmosphericBackdrop: disabling reduced motion mid-session now restarts
the parked RAF loop (tick() parks itself on a still frame; the old
always-running loop resumed implicitly, so the backdrop stayed frozen).
- Landing: drop ssr:false from the HowItWorks dynamic import (#492
review) — next/dynamic still splits the motion stack into its own
chunk, but the section's marketing copy is back in the server-rendered
HTML; verified "Upload Your Materials" present in the prerendered
index.html.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

Re: the #492 overlap — ported the ssr: false drop in 23b773f: next/dynamic still splits the motion stack into its own chunk, and the HowItWorks copy is back in the server HTML (verified "Upload Your Materials" present in the prerendered index.html after a production build).

Deliberately did not port lib/linkPairs.ts: it's your implementation on a competing PR and the which-lands call is yours. If #490 is the one that lands, the spatial-binning module + its identical-pairs-identical-order test would make a clean follow-up on top of the cheap-reject pass here (0.356→0.100ms is a real win; the squared-distance rewrite in this PR gets a chunk of that without the new module).

CodeRabbit's three findings are resolved: the reduced-motion revive was a real regression vs. the old always-running loop (fixed in 23b773f); the Study fallback and nav-logo findings were fixed in f92e0ce (Study went further — keyed CSS enter-fade, no chunk dependency at all, after the handoff approach proved to remount panes and wipe state).

Brings in #341 (FullHeightScreen viewport-math), #290 (approval gate) and
#288 (hero card).
The globals.css conflict is resolved by keeping BOTH added blocks: this
branch's .study-mode-enter and main's .pending-* beat. Ordering invariants
re-verified after the merge — .card--hero still follows .card, and .pending-*
still precedes the .anim-d* delay utilities (the animation shorthand resets
animation-delay, so those have to come later to win).
Also fixes the intermittent frontend-lane CI failure, which is unrelated to
either PR but was red-flagging both. FlashcardImportModal.test.tsx wiped
document.body in afterEach but never unmounted; with `globals: false` in
vitest.config.ts, @testing-library/react does not auto-register its cleanup,
so React kept the tree and its pending scheduler work alive past the end of
the file and jsdom teardown landed it on a missing `window`:
ReferenceError: window is not defined
vitest counts those as unhandled errors and exits non-zero even with a fully
green suite — which is exactly how #492 failed with 416/416 tests passing.
Now calls cleanup() before the body sweep (both are needed; the sweep clears
the Dialog/ToastProvider portal siblings cleanup() leaves behind). Verified
3/3 clean full-suite runs, exit 0, zero unhandled errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

Merged main in, ran the E2E lane this PR was missing, and fixed the CI flake

Per @AndresL230's call: take this PR as the base, port anything worthwhile from #492, verify, merge. Pushed three things here.

1. Merged main in. This branch was based on 432c207, before #487/#488/#489 landed. One conflict in globals.css, resolved by keeping both added blocks — your .study-mode-enter and main's .pending-* beat. Both ordering invariants re-verified after the merge: .card--hero still follows .card (242/246 vs 210), and .pending-* still precedes .anim-d* (282-309 vs 310) — that one is load-bearing, since the animation shorthand resets animation-delay.

2. The full local E2E cycle — the gap in this PR's own verification note.

35 passed (1.3m)
0 finding(s), 1 suppressed (allowlisted)

Run at 5809465 inside one flock of the stack lock with SAPLING_MODEL_MODE=function. This matters most for KnowledgeGraph2D: graph.spec.ts is the app's strictest DOM contract and the direct-DOM-write refactor is the riskiest change in either PR. It passes.

3. Fixed the intermittent frontend-lane CI failure. Unrelated to either PR but red-flagging both — #492 failed with 416/416 tests passing and Errors 3. FlashcardImportModal.test.tsx wiped document.body in afterEach but never unmounted; with globals: false in vitest.config.ts, RTL does not auto-register its cleanup, so React kept the tree and its pending scheduler work alive past the end of the file and jsdom teardown landed it on a missing window. Now calls cleanup() before the body sweep. 3/3 clean full-suite runs, exit 0.

On porting from #492 — one thing went in, one did not

Ported: nothing, in the end — you'd already made the ssr: false change in 23b773f while I was working, crediting the #492 review. Keeping the 340vh placeholder alongside SSR is the better call than my version, which dropped it.

Dropped, and worth recording why: I was going to port #492's spatially-binned lib/linkPairs.ts over your axis-reject loop. I benchmarked it instead of assuming, and your version is faster at the real workload.

N=226 axis 0.068 ms | binned 0.113 ms <- the actual hero cloud (220 bg + 6 cluster)
N=500 axis 0.469 ms | binned 0.513 ms
N=1000 axis 2.034 ms | binned 2.065 ms
N=2000 axis 8.579 ms | binned 7.796 ms <- binning finally wins

Identical pairs at every N. Map allocation and per-point sorting cost more than binning saves until roughly N=2000, and the node count here is fixed at 226. So the asymptotically-better algorithm is the slower one in practice, and your implementation stays. My recommendation to Andres to port it was wrong.

Review

Ran the multi-agent review over the merged head, aimed at the riskiest area — React re-renders clobbering the direct DOM writes, or the reverse. No bugs found: both paths read and write the same mutable SimNode objects, so there is no second copy of position state to go stale. Testids, mastery-tier opacity, and the #383 deterministic settle all verified intact (simulation.tick() doesn't dispatch tick events, so the synchronous settle is untouched). Also confirmed StudyMotion.tsx needs no eslint testid-enforcement entry, and that the MotionGlobalConfig.skipAnimations seam still runs before any motion component renders.

Nice work on this — the direct-DOM-write graph and the visibilitychange pause are both better than what I had.

@AndresL230
AndresL230 merged commit 9c2f1a1 into mainJul 31, 2026
6 of 7 checks passed
@AndresL230
AndresL230 deleted the feat/111-runtime-anim-image-perf branch August 2, 2026 18:30
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.

[P2] Optimize runtime animations + add lazy/next-image loading

2 participants

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

perf(frontend): #111 runtime animations + lazy/next-image loading - #490

Merged
AndresL230 merged 4 commits into
mainfrom
feat/111-runtime-anim-image-perf
Jul 31, 2026
Merged

perf(frontend): #111 runtime animations + lazy/next-image loading#490
AndresL230 merged 4 commits into
mainfrom
feat/111-runtime-anim-image-perf

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

Implements the four acceptance items of #111 (June frontend-UI audit, Performance dimension). Much of the audit had already been fixed in the interim — this PR closes what was still real, verified finding-by-finding against current code:

  • Landing hero canvas ((public)/page.tsx): reduced-motion gating, per-node shadowBlur, floating-card querySelectorAll-per-frame, and spotlight rect caching were already fixed. Still real and fixed here: the O(N²) link pass now hoists per-node bounds, rejects on |dx|/|dy| axes, and compares squared distances — Math.sqrt only runs for the tiny fraction of pairs that draw a link (was Math.hypot on ~27k pairs/frame). Pixel-identical output.
  • framer-motion out of the landing chunk: HowItWorks is now next/dynamic (ssr:false) with a height-preserving placeholder, mirroring the repo's MarkdownChat/react-force-graph split pattern.
  • AtmosphericBackdrop: 14 radial gradients were rebuilt at 60fps forever. Now each orb is pre-baked to an offscreen sprite (re-baked only on DPR change), the loop is throttled to ~30fps with drift speed preserved via elapsed-frame scaling, and it fully pauses on visibilitychange. Reduced-motion semantics unchanged.
  • KnowledgeGraph2D: simulation ticks no longer re-render the SVG through React — positions are written directly to DOM (one transform per node group, four attrs per edge). Tooltip setState fires only on open/reseed; moves are direct style writes. nodeById Map replaces the per-edge .find(). e2e/graph.spec.ts DOM contracts (testids, counts, labels, opacity) verified untouched; testmode tests updated to read the new transform position carrier. One-line eslint-suppressions.json baseline prune (13→12, same maintenance as 47950e5).
  • Images: every raw <img> in the audit's scope now has loading="lazy" + decoding="async" (nav logos excepted post-review — permanent chrome loads eagerly; raw imgs on the static public pages and in SignInModal/toasts were outside [P2] Optimize runtime animations + add lazy/next-image loading #111's enumerated scope); intrinsic width/height added where the rendered box is fixed (TopNav/SideNav logos, Settings avatar + banner preview). Deliberately no fixed dims on natural-aspect user uploads (Social attachments, Admin issue screenshots) — hardcoding both would distort content with no stored dimensions to derive from. No next/image conversion (no loader configured; per issue).
  • Study: framer-motion subtree extracted to StudyMotion.tsx behind next/dynamic (ssr:false) with visually identical static fallbacks; the feat(frontend): NEXT_PUBLIC_TEST_MODE determinism flag #383skipAnimations test seam moved with it so it still runs before any motion component renders.

Verification

  • vitest: 55 files / 399 tests pass
  • eslint: 0 errors (pre-existing warnings only); suppressions baseline pruned, not grown
  • tsc --noEmit: clean in src/
  • production build (next build): green
  • ⚠️Local E2E lane could not run on this machine — the local stack is unprovisioned here (no backend/.env; installed Supabase CLI 2.90.0 rejects config.toml's auto_expose_new_tables/local_smtp keys; macOS bash 3.2 hits its known quote-in-$() parser bug at scripts/lib/local-common.sh:99, so make e2e-up dies at source time). e2e.yml runs the full browser lane on the push to main. The graph journey's DOM assertions were manually cross-checked against the KnowledgeGraph2D changes.

Perf-only PR: no visual/behavioral change for non-reduced-motion users.

Closes#111

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Performance Improvements

    • Improved page loading with deferred interactive sections and optimized image loading across navigation, settings, admin, and social screens.
    • Reduced rendering work for animated backgrounds and knowledge graphs, providing smoother interactions.
    • Background animation now pauses when pages are hidden and respects reduced-motion preferences.
  • User Experience

    • Added smoother study-mode transitions and toggle animations.
    • Preserved knowledge graph tooltips, dragging, links, and layout behavior while improving responsiveness.
  • Bug Fixes

    • Improved knowledge graph rendering reliability and updated visual tests for node positioning.

- Landing hero: squared-distance link pass with axis rejects (no more
Math.hypot over all ~27k node pairs/frame); framer-motion split out of
the landing chunk via next/dynamic (HowItWorks) with a layout-stable
placeholder.
- AtmosphericBackdrop: orb gradients pre-baked to offscreen sprites
(re-baked only on DPR change), RAF throttled to ~30fps with drift
speed preserved, loop paused while the tab is hidden.
- KnowledgeGraph2D: simulation ticks write node/edge positions directly
to the DOM (zero React work per tick), tooltip setState only on
open/reseed with direct style writes while shown, id->node Map
replaces O(E*N) find(); testmode tests read the new transform
position carrier. eslint-suppressions baseline pruned (13->12).
- Images: loading="lazy" + decoding="async" on all raw <img> sites;
intrinsic width/height added where the rendered box is fixed
(TopNav/SideNav logos, Settings avatar/banner) and deliberately
omitted for natural-aspect user uploads (Social attachments, Admin
screenshots).
- Study: framer-motion subtree moved to StudyMotion.tsx behind
next/dynamic (ssr:false) with visually identical fallbacks; the #383
skipAnimations test seam moved with it.
Verified: 55 files / 399 vitest tests, eslint 0 errors, tsc clean,
production build green. Local E2E lane not runnable on this machine
(unprovisioned: no backend/.env, supabase CLI/config.toml mismatch,
bash 3.2 parser bug in scripts/lib/local-common.sh:99); e2e.yml covers
the lane on push to main.
Closes#111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

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


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

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d79f821d-a071-4ad6-92ed-6dc7ce3d16a8

📥 Commits

Reviewing files that changed from the base of the PR and between 426be67 and 5809465.

📒 Files selected for processing (8)
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/AtmosphericBackdrop.tsx
  • frontend/src/components/SideNav.tsx
  • frontend/src/components/TopNav.tsx
  • frontend/src/components/flashcards/FlashcardImportModal.test.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/StudyMotion.tsx

📝 Walkthrough

Walkthrough

Changes

Frontend performance updates

Layer / File(s)Summary
Landing-page deferred content and canvas links
frontend/src/app/(public)/page.tsx
HowItWorks loads dynamically on the client. Canvas link checks reject distant pairs before calculating square roots.
Atmospheric backdrop sprite animation
frontend/src/components/AtmosphericBackdrop.tsx
Orb gradients use DPR-specific cached sprites. Rendering is throttled and pauses for hidden documents.
Knowledge graph direct DOM updates
frontend/src/components/KnowledgeGraph2D.tsx, frontend/src/components/KnowledgeGraph2D.testmode.test.tsx, frontend/eslint-suppressions.json
Simulation ticks update SVG elements directly. Node IDs use a map. Tooltips use direct positioning. Tests inspect translated node groups.
Study motion client loading
frontend/src/components/screens/Study.tsx, frontend/src/components/screens/StudyMotion.tsx, frontend/src/app/globals.css
The toggle highlight loads dynamically. Study mode panels use a keyed CSS enter animation.
Image metadata and test teardown
frontend/src/components/SideNav.tsx, frontend/src/components/TopNav.tsx, frontend/src/components/screens/Admin.tsx, frontend/src/components/screens/Settings.tsx, frontend/src/components/screens/Social.tsx, frontend/src/components/flashcards/FlashcardImportModal.test.tsx
Images now include dimensions or asynchronous decoding attributes. Flashcard modal tests explicitly unmount rendered trees during teardown.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
participant LandingPage
participant AtmosphericBackdrop
participant KnowledgeGraph2D
participant Study
LandingPage->>AtmosphericBackdrop: load optimized animated backdrop
LandingPage->>KnowledgeGraph2D: render graph with direct SVG updates
Study->>Study: load motion highlight and animate mode panel
Loading

Possibly related PRs

  • SaplingLearn/Sapling#92: Both PRs modify knowledge-graph rendering, but this PR optimizes the existing 2D SVG graph.
  • SaplingLearn/Sapling#286: Both PRs modify shared frontend UI files, including Study.tsx, navigation, social screens, and the public page.
  • SaplingLearn/Sapling#492: Both PRs extend optimizations in page.tsx, AtmosphericBackdrop.tsx, and KnowledgeGraph2D.tsx.

Suggested reviewers:andresl230

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost performance objectives are addressed, but several issue-listed images still lack intrinsic dimensions, including Social and Admin images.Add intrinsic dimensions or documented aspect-ratio handling for every applicable image listed in issue #111, including Social, Admin, Avatar, and AvatarFrame.
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Out of Scope Changes check✅ PassedThe changes remain within issue #111, covering frontend performance, image loading, animation behavior, graph rendering, and related lint maintenance.
Title check✅ PassedThe title clearly identifies the frontend performance work, runtime animation changes, and lazy loading, although it inaccurately suggests a next/image change.
Description check✅ PassedThe description explains the changes, linked issue, verification results, and E2E limitation in sufficient detail, despite using different section headings than the template.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/111-runtime-anim-image-perf

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/AtmosphericBackdrop.tsx`:
- Around line 170-175: Update onReducedChange to restart the animation when
reduced motion changes to disabled: if the document is visible and
rafRef.current is null, reset lastFrameRef.current and schedule
requestAnimationFrame(tick). Preserve the existing reduced-motion behavior in
tick, including painting one still frame and clearing the RAF reference.
In `@frontend/src/components/screens/Study.tsx`:
- Around line 32-36: Update the StudyModePanel dynamic import fallback so it
renders the currently active Study pane content, not just an empty flex wrapper,
while the motion component is pending; preserve the wrapper layout and replace
it with the loaded StudyModePanel once available. Add a regression test covering
the pending-import state and verifying the active GuideMode or FlashcardsMode
remains visible.
In `@frontend/src/components/SideNav.tsx`:
- Around line 114-117: Remove the loading="lazy" attribute from both
above-the-fold navigation logos: the sidebar logo in
frontend/src/components/SideNav.tsx lines 114-117 and the top navigation logo in
frontend/src/components/TopNav.tsx lines 172-175. Leave their other image
attributes unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 934e3205-2249-4a4b-8124-5b86673a0b85

📥 Commits

Reviewing files that changed from the base of the PR and between 432c207 and 426be67.

📒 Files selected for processing (12)
  • frontend/eslint-suppressions.json
  • frontend/src/app/(public)/page.tsx
  • frontend/src/components/AtmosphericBackdrop.tsx
  • frontend/src/components/KnowledgeGraph2D.testmode.test.tsx
  • frontend/src/components/KnowledgeGraph2D.tsx
  • frontend/src/components/SideNav.tsx
  • frontend/src/components/TopNav.tsx
  • frontend/src/components/screens/Admin.tsx
  • frontend/src/components/screens/Settings.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/StudyMotion.tsx

Comment threadfrontend/src/components/AtmosphericBackdrop.tsx
Comment threadfrontend/src/components/screens/Study.tsx Outdated
Comment threadfrontend/src/components/SideNav.tsx
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

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

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

Branch Preview URL
Jul 31 2026, 06:39 AM

AndresL230 added a commit that referenced this pull request Jul 31, 2026
#111)
The throttle returned before the physics step, so orb positions advanced once
per PAINTED frame rather than once per rAF — at 30fps that is half the
original drift speed. A visible change, in a PR whose whole claim is that
there isn't one.
Each step is now scaled by how many 60fps-equivalent frames actually elapsed,
so the apparent speed matches the 60fps original at any cadence. Clamped to 4
frames so a backgrounded tab (or the very first frame, when lastPaint is
still 0) cannot teleport the field on resume.
Caught by reading PR #490, which independently hit the same trap and handled
it — my own review pass checked the throttle for stalls and for the
reduced-motion still frame, but not for the drift rate.
part of #111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

Heads-up: I opened #492 against issue #111 before seeing this PR — my mistake, and entirely my own for not checking open PRs first. The two overlap on nine files, including the hero canvas, AtmosphericBackdrop and KnowledgeGraph2D.

I've posted a side-by-side comparison on #492 rather than duplicating it here. Short version: this PR is the more thorough pass — direct DOM writes for the graph ticks (what issue #111's own proposal recommended, and deeper than my rAF coalescing), the visibilitychange pause, the StudyMotion extraction, and pruning the suppressions baseline rather than growing it.

Two things from #492 that may be worth porting here:

  1. lib/linkPairs.ts — spatial binning makes the link pass sub-quadratic rather than a cheaper O(N²); measured 0.356ms → 0.100ms per frame on the real 226-node cloud, where only ~213 of 25,425 pairs ever qualify. It comes with a test proving it emits identical pairs in identical order against the naive version (order matters — the strokes are translucent).
  2. Dropping ssr: false from the HowItWorks dynamic import. Plain next/dynamic still code-splits the JS but keeps the section in the server-rendered HTML; ssr: false removes it from what crawlers see on the one page that needs SEO.

Also worth flagging, since you hit the same trap and handled it and I didn't: the 30fps cap halving the drift speed. Your elapsed-frame scaling is the right fix — I'd missed it until I read this PR.

No action needed from you; @AndresL230 should decide which lands.

…s, eager nav logos
- AtmosphericBackdrop: cap sprite backing resolution at 512px (was full
device resolution — ~100 MB of canvas memory at DPR 2 across 14 orbs);
the soft gradients upscale indistinguishably. Also repaint the static
frame on resize under reduced motion (canvas.width resets cleared it).
- Study: the mode transition no longer routes pane content through the
lazy motion chunk — next/dynamic's fallback can't carry children, which
gated the panes behind the chunk fetch, and un-gating them remounted
the pane mid-session, wiping state (caught by Study.test.tsx). Replaced
with a keyed CSS enter-fade (`study-mode-enter`) that needs no chunk;
AnimatePresence initial={false} semantics preserved via render-phase
state derivation. StudyMotion.tsx keeps only the toggle highlight and
the #383 test seam.
- TopNav/SideNav: drop loading="lazy" from the always-visible nav logos
(lazy only delays permanent chrome); keep decoding + dimensions.
- Landing: document the ssr:false SEO tradeoff on the HowItWorks split.
Verified: 399/399 vitest, eslint 0 errors, tsc clean, production build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

Automated review pass (Claude Code) on 426be67. The reviewer independently reproduced every gate (399/399 vitest, eslint 0 errors, tsc clean in a throwaway worktree) and verified the hard parts: the link-pass rewrite is provably pixel-identical (squared-distance ⟺ distance for the threshold, alpha formula byte-for-byte), the KnowledgeGraph2D element registries are leak-free with React renders and direct DOM writes structurally unable to disagree, the reduced-motion/test-mode settle path renders final positions, the e2e graph spec reads no geometry, and the backdrop's dtFrames math is genuinely frame-rate-independent.

No criticals. Two Important findings — both real, both fixed in f92e0ce:

  1. Sprite memory (~100 MB at DPR 2). Full-device-resolution baking across 14 orbs traded CPU for a serious RAM/GPU footprint held app-wide, risky under iOS Safari's canvas budgets. Fixed: sprite backing resolution capped at 512px; the soft gradients upscale indistinguishably at ~50-100× less memory.
  2. Study panes gated behind the motion chunk.next/dynamic's loading fallback can't receive children, so StudyModePanel blanked the pane (and stalled its data fetching) until the framer-motion chunk resolved — a new waterfall the PR body's "visually identical fallbacks" missed. First-attempt fix (render children immediately, hand off to the motion panel on load) traded it for a worse bug our own test caught: the handoff remounted the pane and wiped its state. Final fix: the crossfade is now a keyed CSS enter-fade (study-mode-enter) with no chunk dependency at all; initial={false} semantics preserved (no first-mount animation), reduced motion covered by the global reset, and StudyMotion.tsx retains only the toggle-highlight spring + the feat(frontend): NEXT_PUBLIC_TEST_MODE determinism flag #383 test seam.

Minors also addressed: nav logos no longer loading="lazy" (permanent above-the-fold chrome — lazy only delays it), reduced-motion + resize no longer leaves the backdrop canvas blank, the ssr:false SEO tradeoff on HowItWorks is documented in-code, and the PR body's image-sweep wording is corrected (the audit's enumerated list is fully covered; public static pages/SignInModal/toasts were never in scope).

Watch on the post-merge e2e.yml run: graph.spec.ts and study-semester.spec.ts (exercises the reworked Study path).

…Works copy
- AtmosphericBackdrop: disabling reduced motion mid-session now restarts
the parked RAF loop (tick() parks itself on a still frame; the old
always-running loop resumed implicitly, so the backdrop stayed frozen).
- Landing: drop ssr:false from the HowItWorks dynamic import (#492
review) — next/dynamic still splits the motion stack into its own
chunk, but the section's marketing copy is back in the server-rendered
HTML; verified "Upload Your Materials" present in the prerendered
index.html.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

Re: the #492 overlap — ported the ssr: false drop in 23b773f: next/dynamic still splits the motion stack into its own chunk, and the HowItWorks copy is back in the server HTML (verified "Upload Your Materials" present in the prerendered index.html after a production build).

Deliberately did not port lib/linkPairs.ts: it's your implementation on a competing PR and the which-lands call is yours. If #490 is the one that lands, the spatial-binning module + its identical-pairs-identical-order test would make a clean follow-up on top of the cheap-reject pass here (0.356→0.100ms is a real win; the squared-distance rewrite in this PR gets a chunk of that without the new module).

CodeRabbit's three findings are resolved: the reduced-motion revive was a real regression vs. the old always-running loop (fixed in 23b773f); the Study fallback and nav-logo findings were fixed in f92e0ce (Study went further — keyed CSS enter-fade, no chunk dependency at all, after the handoff approach proved to remount panes and wipe state).

Brings in #341 (FullHeightScreen viewport-math), #290 (approval gate) and
#288 (hero card).
The globals.css conflict is resolved by keeping BOTH added blocks: this
branch's .study-mode-enter and main's .pending-* beat. Ordering invariants
re-verified after the merge — .card--hero still follows .card, and .pending-*
still precedes the .anim-d* delay utilities (the animation shorthand resets
animation-delay, so those have to come later to win).
Also fixes the intermittent frontend-lane CI failure, which is unrelated to
either PR but was red-flagging both. FlashcardImportModal.test.tsx wiped
document.body in afterEach but never unmounted; with `globals: false` in
vitest.config.ts, @testing-library/react does not auto-register its cleanup,
so React kept the tree and its pending scheduler work alive past the end of
the file and jsdom teardown landed it on a missing `window`:
ReferenceError: window is not defined
vitest counts those as unhandled errors and exits non-zero even with a fully
green suite — which is exactly how #492 failed with 416/416 tests passing.
Now calls cleanup() before the body sweep (both are needed; the sweep clears
the Dialog/ToastProvider portal siblings cleanup() leaves behind). Verified
3/3 clean full-suite runs, exit 0, zero unhandled errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

Merged main in, ran the E2E lane this PR was missing, and fixed the CI flake

Per @AndresL230's call: take this PR as the base, port anything worthwhile from #492, verify, merge. Pushed three things here.

1. Merged main in. This branch was based on 432c207, before #487/#488/#489 landed. One conflict in globals.css, resolved by keeping both added blocks — your .study-mode-enter and main's .pending-* beat. Both ordering invariants re-verified after the merge: .card--hero still follows .card (242/246 vs 210), and .pending-* still precedes .anim-d* (282-309 vs 310) — that one is load-bearing, since the animation shorthand resets animation-delay.

2. The full local E2E cycle — the gap in this PR's own verification note.

35 passed (1.3m)
0 finding(s), 1 suppressed (allowlisted)

Run at 5809465 inside one flock of the stack lock with SAPLING_MODEL_MODE=function. This matters most for KnowledgeGraph2D: graph.spec.ts is the app's strictest DOM contract and the direct-DOM-write refactor is the riskiest change in either PR. It passes.

3. Fixed the intermittent frontend-lane CI failure. Unrelated to either PR but red-flagging both — #492 failed with 416/416 tests passing and Errors 3. FlashcardImportModal.test.tsx wiped document.body in afterEach but never unmounted; with globals: false in vitest.config.ts, RTL does not auto-register its cleanup, so React kept the tree and its pending scheduler work alive past the end of the file and jsdom teardown landed it on a missing window. Now calls cleanup() before the body sweep. 3/3 clean full-suite runs, exit 0.

On porting from #492 — one thing went in, one did not

Ported: nothing, in the end — you'd already made the ssr: false change in 23b773f while I was working, crediting the #492 review. Keeping the 340vh placeholder alongside SSR is the better call than my version, which dropped it.

Dropped, and worth recording why: I was going to port #492's spatially-binned lib/linkPairs.ts over your axis-reject loop. I benchmarked it instead of assuming, and your version is faster at the real workload.

N=226 axis 0.068 ms | binned 0.113 ms <- the actual hero cloud (220 bg + 6 cluster)
N=500 axis 0.469 ms | binned 0.513 ms
N=1000 axis 2.034 ms | binned 2.065 ms
N=2000 axis 8.579 ms | binned 7.796 ms <- binning finally wins

Identical pairs at every N. Map allocation and per-point sorting cost more than binning saves until roughly N=2000, and the node count here is fixed at 226. So the asymptotically-better algorithm is the slower one in practice, and your implementation stays. My recommendation to Andres to port it was wrong.

Review

Ran the multi-agent review over the merged head, aimed at the riskiest area — React re-renders clobbering the direct DOM writes, or the reverse. No bugs found: both paths read and write the same mutable SimNode objects, so there is no second copy of position state to go stale. Testids, mastery-tier opacity, and the #383 deterministic settle all verified intact (simulation.tick() doesn't dispatch tick events, so the synchronous settle is untouched). Also confirmed StudyMotion.tsx needs no eslint testid-enforcement entry, and that the MotionGlobalConfig.skipAnimations seam still runs before any motion component renders.

Nice work on this — the direct-DOM-write graph and the visibilitychange pause are both better than what I had.

@AndresL230
AndresL230 merged commit 9c2f1a1 into mainJul 31, 2026
6 of 7 checks passed
@AndresL230
AndresL230 deleted the feat/111-runtime-anim-image-perf branch August 2, 2026 18:30
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.

[P2] Optimize runtime animations + add lazy/next-image loading

2 participants

@Jose-Gael-Cruz-Lopez@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' perf(frontend): #111 runtime animations + lazy/next-image loading by Jose-Gael-Cruz-Lopez · Pull Request #490 · SaplingLearn/Sapling · GitHub
Skip to content

perf(frontend): #111 runtime animations + lazy/next-image loading - #490

Merged
AndresL230 merged 4 commits into
mainfrom
feat/111-runtime-anim-image-perf
Jul 31, 2026
Merged

perf(frontend): #111 runtime animations + lazy/next-image loading#490
AndresL230 merged 4 commits into
mainfrom
feat/111-runtime-anim-image-perf

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

Implements the four acceptance items of #111 (June frontend-UI audit, Performance dimension). Much of the audit had already been fixed in the interim — this PR closes what was still real, verified finding-by-finding against current code:

  • Landing hero canvas ((public)/page.tsx): reduced-motion gating, per-node shadowBlur, floating-card querySelectorAll-per-frame, and spotlight rect caching were already fixed. Still real and fixed here: the O(N²) link pass now hoists per-node bounds, rejects on |dx|/|dy| axes, and compares squared distances — Math.sqrt only runs for the tiny fraction of pairs that draw a link (was Math.hypot on ~27k pairs/frame). Pixel-identical output.
  • framer-motion out of the landing chunk: HowItWorks is now next/dynamic (ssr:false) with a height-preserving placeholder, mirroring the repo's MarkdownChat/react-force-graph split pattern.
  • AtmosphericBackdrop: 14 radial gradients were rebuilt at 60fps forever. Now each orb is pre-baked to an offscreen sprite (re-baked only on DPR change), the loop is throttled to ~30fps with drift speed preserved via elapsed-frame scaling, and it fully pauses on visibilitychange. Reduced-motion semantics unchanged.
  • KnowledgeGraph2D: simulation ticks no longer re-render the SVG through React — positions are written directly to DOM (one transform per node group, four attrs per edge). Tooltip setState fires only on open/reseed; moves are direct style writes. nodeById Map replaces the per-edge .find(). e2e/graph.spec.ts DOM contracts (testids, counts, labels, opacity) verified untouched; testmode tests updated to read the new transform position carrier. One-line eslint-suppressions.json baseline prune (13→12, same maintenance as 47950e5).
  • Images: every raw <img> in the audit's scope now has loading="lazy" + decoding="async" (nav logos excepted post-review — permanent chrome loads eagerly; raw imgs on the static public pages and in SignInModal/toasts were outside [P2] Optimize runtime animations + add lazy/next-image loading #111's enumerated scope); intrinsic width/height added where the rendered box is fixed (TopNav/SideNav logos, Settings avatar + banner preview). Deliberately no fixed dims on natural-aspect user uploads (Social attachments, Admin issue screenshots) — hardcoding both would distort content with no stored dimensions to derive from. No next/image conversion (no loader configured; per issue).
  • Study: framer-motion subtree extracted to StudyMotion.tsx behind next/dynamic (ssr:false) with visually identical static fallbacks; the feat(frontend): NEXT_PUBLIC_TEST_MODE determinism flag #383skipAnimations test seam moved with it so it still runs before any motion component renders.

Verification

  • vitest: 55 files / 399 tests pass
  • eslint: 0 errors (pre-existing warnings only); suppressions baseline pruned, not grown
  • tsc --noEmit: clean in src/
  • production build (next build): green
  • ⚠️Local E2E lane could not run on this machine — the local stack is unprovisioned here (no backend/.env; installed Supabase CLI 2.90.0 rejects config.toml's auto_expose_new_tables/local_smtp keys; macOS bash 3.2 hits its known quote-in-$() parser bug at scripts/lib/local-common.sh:99, so make e2e-up dies at source time). e2e.yml runs the full browser lane on the push to main. The graph journey's DOM assertions were manually cross-checked against the KnowledgeGraph2D changes.

Perf-only PR: no visual/behavioral change for non-reduced-motion users.

Closes#111

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Performance Improvements

    • Improved page loading with deferred interactive sections and optimized image loading across navigation, settings, admin, and social screens.
    • Reduced rendering work for animated backgrounds and knowledge graphs, providing smoother interactions.
    • Background animation now pauses when pages are hidden and respects reduced-motion preferences.
  • User Experience

    • Added smoother study-mode transitions and toggle animations.
    • Preserved knowledge graph tooltips, dragging, links, and layout behavior while improving responsiveness.
  • Bug Fixes

    • Improved knowledge graph rendering reliability and updated visual tests for node positioning.

- Landing hero: squared-distance link pass with axis rejects (no more
Math.hypot over all ~27k node pairs/frame); framer-motion split out of
the landing chunk via next/dynamic (HowItWorks) with a layout-stable
placeholder.
- AtmosphericBackdrop: orb gradients pre-baked to offscreen sprites
(re-baked only on DPR change), RAF throttled to ~30fps with drift
speed preserved, loop paused while the tab is hidden.
- KnowledgeGraph2D: simulation ticks write node/edge positions directly
to the DOM (zero React work per tick), tooltip setState only on
open/reseed with direct style writes while shown, id->node Map
replaces O(E*N) find(); testmode tests read the new transform
position carrier. eslint-suppressions baseline pruned (13->12).
- Images: loading="lazy" + decoding="async" on all raw <img> sites;
intrinsic width/height added where the rendered box is fixed
(TopNav/SideNav logos, Settings avatar/banner) and deliberately
omitted for natural-aspect user uploads (Social attachments, Admin
screenshots).
- Study: framer-motion subtree moved to StudyMotion.tsx behind
next/dynamic (ssr:false) with visually identical fallbacks; the #383
skipAnimations test seam moved with it.
Verified: 55 files / 399 vitest tests, eslint 0 errors, tsc clean,
production build green. Local E2E lane not runnable on this machine
(unprovisioned: no backend/.env, supabase CLI/config.toml mismatch,
bash 3.2 parser bug in scripts/lib/local-common.sh:99); e2e.yml covers
the lane on push to main.
Closes#111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

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


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

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d79f821d-a071-4ad6-92ed-6dc7ce3d16a8

📥 Commits

Reviewing files that changed from the base of the PR and between 426be67 and 5809465.

📒 Files selected for processing (8)
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/AtmosphericBackdrop.tsx
  • frontend/src/components/SideNav.tsx
  • frontend/src/components/TopNav.tsx
  • frontend/src/components/flashcards/FlashcardImportModal.test.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/StudyMotion.tsx

📝 Walkthrough

Walkthrough

Changes

Frontend performance updates

Layer / File(s)Summary
Landing-page deferred content and canvas links
frontend/src/app/(public)/page.tsx
HowItWorks loads dynamically on the client. Canvas link checks reject distant pairs before calculating square roots.
Atmospheric backdrop sprite animation
frontend/src/components/AtmosphericBackdrop.tsx
Orb gradients use DPR-specific cached sprites. Rendering is throttled and pauses for hidden documents.
Knowledge graph direct DOM updates
frontend/src/components/KnowledgeGraph2D.tsx, frontend/src/components/KnowledgeGraph2D.testmode.test.tsx, frontend/eslint-suppressions.json
Simulation ticks update SVG elements directly. Node IDs use a map. Tooltips use direct positioning. Tests inspect translated node groups.
Study motion client loading
frontend/src/components/screens/Study.tsx, frontend/src/components/screens/StudyMotion.tsx, frontend/src/app/globals.css
The toggle highlight loads dynamically. Study mode panels use a keyed CSS enter animation.
Image metadata and test teardown
frontend/src/components/SideNav.tsx, frontend/src/components/TopNav.tsx, frontend/src/components/screens/Admin.tsx, frontend/src/components/screens/Settings.tsx, frontend/src/components/screens/Social.tsx, frontend/src/components/flashcards/FlashcardImportModal.test.tsx
Images now include dimensions or asynchronous decoding attributes. Flashcard modal tests explicitly unmount rendered trees during teardown.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
participant LandingPage
participant AtmosphericBackdrop
participant KnowledgeGraph2D
participant Study
LandingPage->>AtmosphericBackdrop: load optimized animated backdrop
LandingPage->>KnowledgeGraph2D: render graph with direct SVG updates
Study->>Study: load motion highlight and animate mode panel
Loading

Possibly related PRs

  • SaplingLearn/Sapling#92: Both PRs modify knowledge-graph rendering, but this PR optimizes the existing 2D SVG graph.
  • SaplingLearn/Sapling#286: Both PRs modify shared frontend UI files, including Study.tsx, navigation, social screens, and the public page.
  • SaplingLearn/Sapling#492: Both PRs extend optimizations in page.tsx, AtmosphericBackdrop.tsx, and KnowledgeGraph2D.tsx.

Suggested reviewers:andresl230

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost performance objectives are addressed, but several issue-listed images still lack intrinsic dimensions, including Social and Admin images.Add intrinsic dimensions or documented aspect-ratio handling for every applicable image listed in issue #111, including Social, Admin, Avatar, and AvatarFrame.
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Out of Scope Changes check✅ PassedThe changes remain within issue #111, covering frontend performance, image loading, animation behavior, graph rendering, and related lint maintenance.
Title check✅ PassedThe title clearly identifies the frontend performance work, runtime animation changes, and lazy loading, although it inaccurately suggests a next/image change.
Description check✅ PassedThe description explains the changes, linked issue, verification results, and E2E limitation in sufficient detail, despite using different section headings than the template.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/111-runtime-anim-image-perf

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/AtmosphericBackdrop.tsx`:
- Around line 170-175: Update onReducedChange to restart the animation when
reduced motion changes to disabled: if the document is visible and
rafRef.current is null, reset lastFrameRef.current and schedule
requestAnimationFrame(tick). Preserve the existing reduced-motion behavior in
tick, including painting one still frame and clearing the RAF reference.
In `@frontend/src/components/screens/Study.tsx`:
- Around line 32-36: Update the StudyModePanel dynamic import fallback so it
renders the currently active Study pane content, not just an empty flex wrapper,
while the motion component is pending; preserve the wrapper layout and replace
it with the loaded StudyModePanel once available. Add a regression test covering
the pending-import state and verifying the active GuideMode or FlashcardsMode
remains visible.
In `@frontend/src/components/SideNav.tsx`:
- Around line 114-117: Remove the loading="lazy" attribute from both
above-the-fold navigation logos: the sidebar logo in
frontend/src/components/SideNav.tsx lines 114-117 and the top navigation logo in
frontend/src/components/TopNav.tsx lines 172-175. Leave their other image
attributes unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 934e3205-2249-4a4b-8124-5b86673a0b85

📥 Commits

Reviewing files that changed from the base of the PR and between 432c207 and 426be67.

📒 Files selected for processing (12)
  • frontend/eslint-suppressions.json
  • frontend/src/app/(public)/page.tsx
  • frontend/src/components/AtmosphericBackdrop.tsx
  • frontend/src/components/KnowledgeGraph2D.testmode.test.tsx
  • frontend/src/components/KnowledgeGraph2D.tsx
  • frontend/src/components/SideNav.tsx
  • frontend/src/components/TopNav.tsx
  • frontend/src/components/screens/Admin.tsx
  • frontend/src/components/screens/Settings.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/StudyMotion.tsx

Comment threadfrontend/src/components/AtmosphericBackdrop.tsx
Comment threadfrontend/src/components/screens/Study.tsx Outdated
Comment threadfrontend/src/components/SideNav.tsx
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

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

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

Branch Preview URL
Jul 31 2026, 06:39 AM

AndresL230 added a commit that referenced this pull request Jul 31, 2026
#111)
The throttle returned before the physics step, so orb positions advanced once
per PAINTED frame rather than once per rAF — at 30fps that is half the
original drift speed. A visible change, in a PR whose whole claim is that
there isn't one.
Each step is now scaled by how many 60fps-equivalent frames actually elapsed,
so the apparent speed matches the 60fps original at any cadence. Clamped to 4
frames so a backgrounded tab (or the very first frame, when lastPaint is
still 0) cannot teleport the field on resume.
Caught by reading PR #490, which independently hit the same trap and handled
it — my own review pass checked the throttle for stalls and for the
reduced-motion still frame, but not for the drift rate.
part of #111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

Heads-up: I opened #492 against issue #111 before seeing this PR — my mistake, and entirely my own for not checking open PRs first. The two overlap on nine files, including the hero canvas, AtmosphericBackdrop and KnowledgeGraph2D.

I've posted a side-by-side comparison on #492 rather than duplicating it here. Short version: this PR is the more thorough pass — direct DOM writes for the graph ticks (what issue #111's own proposal recommended, and deeper than my rAF coalescing), the visibilitychange pause, the StudyMotion extraction, and pruning the suppressions baseline rather than growing it.

Two things from #492 that may be worth porting here:

  1. lib/linkPairs.ts — spatial binning makes the link pass sub-quadratic rather than a cheaper O(N²); measured 0.356ms → 0.100ms per frame on the real 226-node cloud, where only ~213 of 25,425 pairs ever qualify. It comes with a test proving it emits identical pairs in identical order against the naive version (order matters — the strokes are translucent).
  2. Dropping ssr: false from the HowItWorks dynamic import. Plain next/dynamic still code-splits the JS but keeps the section in the server-rendered HTML; ssr: false removes it from what crawlers see on the one page that needs SEO.

Also worth flagging, since you hit the same trap and handled it and I didn't: the 30fps cap halving the drift speed. Your elapsed-frame scaling is the right fix — I'd missed it until I read this PR.

No action needed from you; @AndresL230 should decide which lands.

…s, eager nav logos
- AtmosphericBackdrop: cap sprite backing resolution at 512px (was full
device resolution — ~100 MB of canvas memory at DPR 2 across 14 orbs);
the soft gradients upscale indistinguishably. Also repaint the static
frame on resize under reduced motion (canvas.width resets cleared it).
- Study: the mode transition no longer routes pane content through the
lazy motion chunk — next/dynamic's fallback can't carry children, which
gated the panes behind the chunk fetch, and un-gating them remounted
the pane mid-session, wiping state (caught by Study.test.tsx). Replaced
with a keyed CSS enter-fade (`study-mode-enter`) that needs no chunk;
AnimatePresence initial={false} semantics preserved via render-phase
state derivation. StudyMotion.tsx keeps only the toggle highlight and
the #383 test seam.
- TopNav/SideNav: drop loading="lazy" from the always-visible nav logos
(lazy only delays permanent chrome); keep decoding + dimensions.
- Landing: document the ssr:false SEO tradeoff on the HowItWorks split.
Verified: 399/399 vitest, eslint 0 errors, tsc clean, production build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

Automated review pass (Claude Code) on 426be67. The reviewer independently reproduced every gate (399/399 vitest, eslint 0 errors, tsc clean in a throwaway worktree) and verified the hard parts: the link-pass rewrite is provably pixel-identical (squared-distance ⟺ distance for the threshold, alpha formula byte-for-byte), the KnowledgeGraph2D element registries are leak-free with React renders and direct DOM writes structurally unable to disagree, the reduced-motion/test-mode settle path renders final positions, the e2e graph spec reads no geometry, and the backdrop's dtFrames math is genuinely frame-rate-independent.

No criticals. Two Important findings — both real, both fixed in f92e0ce:

  1. Sprite memory (~100 MB at DPR 2). Full-device-resolution baking across 14 orbs traded CPU for a serious RAM/GPU footprint held app-wide, risky under iOS Safari's canvas budgets. Fixed: sprite backing resolution capped at 512px; the soft gradients upscale indistinguishably at ~50-100× less memory.
  2. Study panes gated behind the motion chunk.next/dynamic's loading fallback can't receive children, so StudyModePanel blanked the pane (and stalled its data fetching) until the framer-motion chunk resolved — a new waterfall the PR body's "visually identical fallbacks" missed. First-attempt fix (render children immediately, hand off to the motion panel on load) traded it for a worse bug our own test caught: the handoff remounted the pane and wiped its state. Final fix: the crossfade is now a keyed CSS enter-fade (study-mode-enter) with no chunk dependency at all; initial={false} semantics preserved (no first-mount animation), reduced motion covered by the global reset, and StudyMotion.tsx retains only the toggle-highlight spring + the feat(frontend): NEXT_PUBLIC_TEST_MODE determinism flag #383 test seam.

Minors also addressed: nav logos no longer loading="lazy" (permanent above-the-fold chrome — lazy only delays it), reduced-motion + resize no longer leaves the backdrop canvas blank, the ssr:false SEO tradeoff on HowItWorks is documented in-code, and the PR body's image-sweep wording is corrected (the audit's enumerated list is fully covered; public static pages/SignInModal/toasts were never in scope).

Watch on the post-merge e2e.yml run: graph.spec.ts and study-semester.spec.ts (exercises the reworked Study path).

…Works copy
- AtmosphericBackdrop: disabling reduced motion mid-session now restarts
the parked RAF loop (tick() parks itself on a still frame; the old
always-running loop resumed implicitly, so the backdrop stayed frozen).
- Landing: drop ssr:false from the HowItWorks dynamic import (#492
review) — next/dynamic still splits the motion stack into its own
chunk, but the section's marketing copy is back in the server-rendered
HTML; verified "Upload Your Materials" present in the prerendered
index.html.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

Re: the #492 overlap — ported the ssr: false drop in 23b773f: next/dynamic still splits the motion stack into its own chunk, and the HowItWorks copy is back in the server HTML (verified "Upload Your Materials" present in the prerendered index.html after a production build).

Deliberately did not port lib/linkPairs.ts: it's your implementation on a competing PR and the which-lands call is yours. If #490 is the one that lands, the spatial-binning module + its identical-pairs-identical-order test would make a clean follow-up on top of the cheap-reject pass here (0.356→0.100ms is a real win; the squared-distance rewrite in this PR gets a chunk of that without the new module).

CodeRabbit's three findings are resolved: the reduced-motion revive was a real regression vs. the old always-running loop (fixed in 23b773f); the Study fallback and nav-logo findings were fixed in f92e0ce (Study went further — keyed CSS enter-fade, no chunk dependency at all, after the handoff approach proved to remount panes and wipe state).

Brings in #341 (FullHeightScreen viewport-math), #290 (approval gate) and
#288 (hero card).
The globals.css conflict is resolved by keeping BOTH added blocks: this
branch's .study-mode-enter and main's .pending-* beat. Ordering invariants
re-verified after the merge — .card--hero still follows .card, and .pending-*
still precedes the .anim-d* delay utilities (the animation shorthand resets
animation-delay, so those have to come later to win).
Also fixes the intermittent frontend-lane CI failure, which is unrelated to
either PR but was red-flagging both. FlashcardImportModal.test.tsx wiped
document.body in afterEach but never unmounted; with `globals: false` in
vitest.config.ts, @testing-library/react does not auto-register its cleanup,
so React kept the tree and its pending scheduler work alive past the end of
the file and jsdom teardown landed it on a missing `window`:
ReferenceError: window is not defined
vitest counts those as unhandled errors and exits non-zero even with a fully
green suite — which is exactly how #492 failed with 416/416 tests passing.
Now calls cleanup() before the body sweep (both are needed; the sweep clears
the Dialog/ToastProvider portal siblings cleanup() leaves behind). Verified
3/3 clean full-suite runs, exit 0, zero unhandled errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

Merged main in, ran the E2E lane this PR was missing, and fixed the CI flake

Per @AndresL230's call: take this PR as the base, port anything worthwhile from #492, verify, merge. Pushed three things here.

1. Merged main in. This branch was based on 432c207, before #487/#488/#489 landed. One conflict in globals.css, resolved by keeping both added blocks — your .study-mode-enter and main's .pending-* beat. Both ordering invariants re-verified after the merge: .card--hero still follows .card (242/246 vs 210), and .pending-* still precedes .anim-d* (282-309 vs 310) — that one is load-bearing, since the animation shorthand resets animation-delay.

2. The full local E2E cycle — the gap in this PR's own verification note.

35 passed (1.3m)
0 finding(s), 1 suppressed (allowlisted)

Run at 5809465 inside one flock of the stack lock with SAPLING_MODEL_MODE=function. This matters most for KnowledgeGraph2D: graph.spec.ts is the app's strictest DOM contract and the direct-DOM-write refactor is the riskiest change in either PR. It passes.

3. Fixed the intermittent frontend-lane CI failure. Unrelated to either PR but red-flagging both — #492 failed with 416/416 tests passing and Errors 3. FlashcardImportModal.test.tsx wiped document.body in afterEach but never unmounted; with globals: false in vitest.config.ts, RTL does not auto-register its cleanup, so React kept the tree and its pending scheduler work alive past the end of the file and jsdom teardown landed it on a missing window. Now calls cleanup() before the body sweep. 3/3 clean full-suite runs, exit 0.

On porting from #492 — one thing went in, one did not

Ported: nothing, in the end — you'd already made the ssr: false change in 23b773f while I was working, crediting the #492 review. Keeping the 340vh placeholder alongside SSR is the better call than my version, which dropped it.

Dropped, and worth recording why: I was going to port #492's spatially-binned lib/linkPairs.ts over your axis-reject loop. I benchmarked it instead of assuming, and your version is faster at the real workload.

N=226 axis 0.068 ms | binned 0.113 ms <- the actual hero cloud (220 bg + 6 cluster)
N=500 axis 0.469 ms | binned 0.513 ms
N=1000 axis 2.034 ms | binned 2.065 ms
N=2000 axis 8.579 ms | binned 7.796 ms <- binning finally wins

Identical pairs at every N. Map allocation and per-point sorting cost more than binning saves until roughly N=2000, and the node count here is fixed at 226. So the asymptotically-better algorithm is the slower one in practice, and your implementation stays. My recommendation to Andres to port it was wrong.

Review

Ran the multi-agent review over the merged head, aimed at the riskiest area — React re-renders clobbering the direct DOM writes, or the reverse. No bugs found: both paths read and write the same mutable SimNode objects, so there is no second copy of position state to go stale. Testids, mastery-tier opacity, and the #383 deterministic settle all verified intact (simulation.tick() doesn't dispatch tick events, so the synchronous settle is untouched). Also confirmed StudyMotion.tsx needs no eslint testid-enforcement entry, and that the MotionGlobalConfig.skipAnimations seam still runs before any motion component renders.

Nice work on this — the direct-DOM-write graph and the visibilitychange pause are both better than what I had.

@AndresL230
AndresL230 merged commit 9c2f1a1 into mainJul 31, 2026
6 of 7 checks passed
@AndresL230
AndresL230 deleted the feat/111-runtime-anim-image-perf branch August 2, 2026 18:30
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.

[P2] Optimize runtime animations + add lazy/next-image loading

2 participants

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

perf(frontend): #111 runtime animations + lazy/next-image loading - #490

Merged
AndresL230 merged 4 commits into
mainfrom
feat/111-runtime-anim-image-perf
Jul 31, 2026
Merged

perf(frontend): #111 runtime animations + lazy/next-image loading#490
AndresL230 merged 4 commits into
mainfrom
feat/111-runtime-anim-image-perf

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-LopezJose-Gael-Cruz-Lopez commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

Implements the four acceptance items of #111 (June frontend-UI audit, Performance dimension). Much of the audit had already been fixed in the interim — this PR closes what was still real, verified finding-by-finding against current code:

  • Landing hero canvas ((public)/page.tsx): reduced-motion gating, per-node shadowBlur, floating-card querySelectorAll-per-frame, and spotlight rect caching were already fixed. Still real and fixed here: the O(N²) link pass now hoists per-node bounds, rejects on |dx|/|dy| axes, and compares squared distances — Math.sqrt only runs for the tiny fraction of pairs that draw a link (was Math.hypot on ~27k pairs/frame). Pixel-identical output.
  • framer-motion out of the landing chunk: HowItWorks is now next/dynamic (ssr:false) with a height-preserving placeholder, mirroring the repo's MarkdownChat/react-force-graph split pattern.
  • AtmosphericBackdrop: 14 radial gradients were rebuilt at 60fps forever. Now each orb is pre-baked to an offscreen sprite (re-baked only on DPR change), the loop is throttled to ~30fps with drift speed preserved via elapsed-frame scaling, and it fully pauses on visibilitychange. Reduced-motion semantics unchanged.
  • KnowledgeGraph2D: simulation ticks no longer re-render the SVG through React — positions are written directly to DOM (one transform per node group, four attrs per edge). Tooltip setState fires only on open/reseed; moves are direct style writes. nodeById Map replaces the per-edge .find(). e2e/graph.spec.ts DOM contracts (testids, counts, labels, opacity) verified untouched; testmode tests updated to read the new transform position carrier. One-line eslint-suppressions.json baseline prune (13→12, same maintenance as 47950e5).
  • Images: every raw <img> in the audit's scope now has loading="lazy" + decoding="async" (nav logos excepted post-review — permanent chrome loads eagerly; raw imgs on the static public pages and in SignInModal/toasts were outside [P2] Optimize runtime animations + add lazy/next-image loading #111's enumerated scope); intrinsic width/height added where the rendered box is fixed (TopNav/SideNav logos, Settings avatar + banner preview). Deliberately no fixed dims on natural-aspect user uploads (Social attachments, Admin issue screenshots) — hardcoding both would distort content with no stored dimensions to derive from. No next/image conversion (no loader configured; per issue).
  • Study: framer-motion subtree extracted to StudyMotion.tsx behind next/dynamic (ssr:false) with visually identical static fallbacks; the feat(frontend): NEXT_PUBLIC_TEST_MODE determinism flag #383skipAnimations test seam moved with it so it still runs before any motion component renders.

Verification

  • vitest: 55 files / 399 tests pass
  • eslint: 0 errors (pre-existing warnings only); suppressions baseline pruned, not grown
  • tsc --noEmit: clean in src/
  • production build (next build): green
  • ⚠️Local E2E lane could not run on this machine — the local stack is unprovisioned here (no backend/.env; installed Supabase CLI 2.90.0 rejects config.toml's auto_expose_new_tables/local_smtp keys; macOS bash 3.2 hits its known quote-in-$() parser bug at scripts/lib/local-common.sh:99, so make e2e-up dies at source time). e2e.yml runs the full browser lane on the push to main. The graph journey's DOM assertions were manually cross-checked against the KnowledgeGraph2D changes.

Perf-only PR: no visual/behavioral change for non-reduced-motion users.

Closes#111

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Performance Improvements

    • Improved page loading with deferred interactive sections and optimized image loading across navigation, settings, admin, and social screens.
    • Reduced rendering work for animated backgrounds and knowledge graphs, providing smoother interactions.
    • Background animation now pauses when pages are hidden and respects reduced-motion preferences.
  • User Experience

    • Added smoother study-mode transitions and toggle animations.
    • Preserved knowledge graph tooltips, dragging, links, and layout behavior while improving responsiveness.
  • Bug Fixes

    • Improved knowledge graph rendering reliability and updated visual tests for node positioning.

- Landing hero: squared-distance link pass with axis rejects (no more
Math.hypot over all ~27k node pairs/frame); framer-motion split out of
the landing chunk via next/dynamic (HowItWorks) with a layout-stable
placeholder.
- AtmosphericBackdrop: orb gradients pre-baked to offscreen sprites
(re-baked only on DPR change), RAF throttled to ~30fps with drift
speed preserved, loop paused while the tab is hidden.
- KnowledgeGraph2D: simulation ticks write node/edge positions directly
to the DOM (zero React work per tick), tooltip setState only on
open/reseed with direct style writes while shown, id->node Map
replaces O(E*N) find(); testmode tests read the new transform
position carrier. eslint-suppressions baseline pruned (13->12).
- Images: loading="lazy" + decoding="async" on all raw <img> sites;
intrinsic width/height added where the rendered box is fixed
(TopNav/SideNav logos, Settings avatar/banner) and deliberately
omitted for natural-aspect user uploads (Social attachments, Admin
screenshots).
- Study: framer-motion subtree moved to StudyMotion.tsx behind
next/dynamic (ssr:false) with visually identical fallbacks; the #383
skipAnimations test seam moved with it.
Verified: 55 files / 399 vitest tests, eslint 0 errors, tsc clean,
production build green. Local E2E lane not runnable on this machine
(unprovisioned: no backend/.env, supabase CLI/config.toml mismatch,
bash 3.2 parser bug in scripts/lib/local-common.sh:99); e2e.yml covers
the lane on push to main.
Closes#111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

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


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

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d79f821d-a071-4ad6-92ed-6dc7ce3d16a8

📥 Commits

Reviewing files that changed from the base of the PR and between 426be67 and 5809465.

📒 Files selected for processing (8)
  • frontend/src/app/(public)/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/AtmosphericBackdrop.tsx
  • frontend/src/components/SideNav.tsx
  • frontend/src/components/TopNav.tsx
  • frontend/src/components/flashcards/FlashcardImportModal.test.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/StudyMotion.tsx

📝 Walkthrough

Walkthrough

Changes

Frontend performance updates

Layer / File(s)Summary
Landing-page deferred content and canvas links
frontend/src/app/(public)/page.tsx
HowItWorks loads dynamically on the client. Canvas link checks reject distant pairs before calculating square roots.
Atmospheric backdrop sprite animation
frontend/src/components/AtmosphericBackdrop.tsx
Orb gradients use DPR-specific cached sprites. Rendering is throttled and pauses for hidden documents.
Knowledge graph direct DOM updates
frontend/src/components/KnowledgeGraph2D.tsx, frontend/src/components/KnowledgeGraph2D.testmode.test.tsx, frontend/eslint-suppressions.json
Simulation ticks update SVG elements directly. Node IDs use a map. Tooltips use direct positioning. Tests inspect translated node groups.
Study motion client loading
frontend/src/components/screens/Study.tsx, frontend/src/components/screens/StudyMotion.tsx, frontend/src/app/globals.css
The toggle highlight loads dynamically. Study mode panels use a keyed CSS enter animation.
Image metadata and test teardown
frontend/src/components/SideNav.tsx, frontend/src/components/TopNav.tsx, frontend/src/components/screens/Admin.tsx, frontend/src/components/screens/Settings.tsx, frontend/src/components/screens/Social.tsx, frontend/src/components/flashcards/FlashcardImportModal.test.tsx
Images now include dimensions or asynchronous decoding attributes. Flashcard modal tests explicitly unmount rendered trees during teardown.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
participant LandingPage
participant AtmosphericBackdrop
participant KnowledgeGraph2D
participant Study
LandingPage->>AtmosphericBackdrop: load optimized animated backdrop
LandingPage->>KnowledgeGraph2D: render graph with direct SVG updates
Study->>Study: load motion highlight and animate mode panel
Loading

Possibly related PRs

  • SaplingLearn/Sapling#92: Both PRs modify knowledge-graph rendering, but this PR optimizes the existing 2D SVG graph.
  • SaplingLearn/Sapling#286: Both PRs modify shared frontend UI files, including Study.tsx, navigation, social screens, and the public page.
  • SaplingLearn/Sapling#492: Both PRs extend optimizations in page.tsx, AtmosphericBackdrop.tsx, and KnowledgeGraph2D.tsx.

Suggested reviewers:andresl230

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost performance objectives are addressed, but several issue-listed images still lack intrinsic dimensions, including Social and Admin images.Add intrinsic dimensions or documented aspect-ratio handling for every applicable image listed in issue #111, including Social, Admin, Avatar, and AvatarFrame.
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Out of Scope Changes check✅ PassedThe changes remain within issue #111, covering frontend performance, image loading, animation behavior, graph rendering, and related lint maintenance.
Title check✅ PassedThe title clearly identifies the frontend performance work, runtime animation changes, and lazy loading, although it inaccurately suggests a next/image change.
Description check✅ PassedThe description explains the changes, linked issue, verification results, and E2E limitation in sufficient detail, despite using different section headings than the template.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/111-runtime-anim-image-perf

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/AtmosphericBackdrop.tsx`:
- Around line 170-175: Update onReducedChange to restart the animation when
reduced motion changes to disabled: if the document is visible and
rafRef.current is null, reset lastFrameRef.current and schedule
requestAnimationFrame(tick). Preserve the existing reduced-motion behavior in
tick, including painting one still frame and clearing the RAF reference.
In `@frontend/src/components/screens/Study.tsx`:
- Around line 32-36: Update the StudyModePanel dynamic import fallback so it
renders the currently active Study pane content, not just an empty flex wrapper,
while the motion component is pending; preserve the wrapper layout and replace
it with the loaded StudyModePanel once available. Add a regression test covering
the pending-import state and verifying the active GuideMode or FlashcardsMode
remains visible.
In `@frontend/src/components/SideNav.tsx`:
- Around line 114-117: Remove the loading="lazy" attribute from both
above-the-fold navigation logos: the sidebar logo in
frontend/src/components/SideNav.tsx lines 114-117 and the top navigation logo in
frontend/src/components/TopNav.tsx lines 172-175. Leave their other image
attributes unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 934e3205-2249-4a4b-8124-5b86673a0b85

📥 Commits

Reviewing files that changed from the base of the PR and between 432c207 and 426be67.

📒 Files selected for processing (12)
  • frontend/eslint-suppressions.json
  • frontend/src/app/(public)/page.tsx
  • frontend/src/components/AtmosphericBackdrop.tsx
  • frontend/src/components/KnowledgeGraph2D.testmode.test.tsx
  • frontend/src/components/KnowledgeGraph2D.tsx
  • frontend/src/components/SideNav.tsx
  • frontend/src/components/TopNav.tsx
  • frontend/src/components/screens/Admin.tsx
  • frontend/src/components/screens/Settings.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/StudyMotion.tsx

Comment threadfrontend/src/components/AtmosphericBackdrop.tsx
Comment threadfrontend/src/components/screens/Study.tsx Outdated
Comment threadfrontend/src/components/SideNav.tsx
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

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

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

Branch Preview URL
Jul 31 2026, 06:39 AM

AndresL230 added a commit that referenced this pull request Jul 31, 2026
#111)
The throttle returned before the physics step, so orb positions advanced once
per PAINTED frame rather than once per rAF — at 30fps that is half the
original drift speed. A visible change, in a PR whose whole claim is that
there isn't one.
Each step is now scaled by how many 60fps-equivalent frames actually elapsed,
so the apparent speed matches the 60fps original at any cadence. Clamped to 4
frames so a backgrounded tab (or the very first frame, when lastPaint is
still 0) cannot teleport the field on resume.
Caught by reading PR #490, which independently hit the same trap and handled
it — my own review pass checked the throttle for stalls and for the
reduced-motion still frame, but not for the drift rate.
part of #111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

Heads-up: I opened #492 against issue #111 before seeing this PR — my mistake, and entirely my own for not checking open PRs first. The two overlap on nine files, including the hero canvas, AtmosphericBackdrop and KnowledgeGraph2D.

I've posted a side-by-side comparison on #492 rather than duplicating it here. Short version: this PR is the more thorough pass — direct DOM writes for the graph ticks (what issue #111's own proposal recommended, and deeper than my rAF coalescing), the visibilitychange pause, the StudyMotion extraction, and pruning the suppressions baseline rather than growing it.

Two things from #492 that may be worth porting here:

  1. lib/linkPairs.ts — spatial binning makes the link pass sub-quadratic rather than a cheaper O(N²); measured 0.356ms → 0.100ms per frame on the real 226-node cloud, where only ~213 of 25,425 pairs ever qualify. It comes with a test proving it emits identical pairs in identical order against the naive version (order matters — the strokes are translucent).
  2. Dropping ssr: false from the HowItWorks dynamic import. Plain next/dynamic still code-splits the JS but keeps the section in the server-rendered HTML; ssr: false removes it from what crawlers see on the one page that needs SEO.

Also worth flagging, since you hit the same trap and handled it and I didn't: the 30fps cap halving the drift speed. Your elapsed-frame scaling is the right fix — I'd missed it until I read this PR.

No action needed from you; @AndresL230 should decide which lands.

…s, eager nav logos
- AtmosphericBackdrop: cap sprite backing resolution at 512px (was full
device resolution — ~100 MB of canvas memory at DPR 2 across 14 orbs);
the soft gradients upscale indistinguishably. Also repaint the static
frame on resize under reduced motion (canvas.width resets cleared it).
- Study: the mode transition no longer routes pane content through the
lazy motion chunk — next/dynamic's fallback can't carry children, which
gated the panes behind the chunk fetch, and un-gating them remounted
the pane mid-session, wiping state (caught by Study.test.tsx). Replaced
with a keyed CSS enter-fade (`study-mode-enter`) that needs no chunk;
AnimatePresence initial={false} semantics preserved via render-phase
state derivation. StudyMotion.tsx keeps only the toggle highlight and
the #383 test seam.
- TopNav/SideNav: drop loading="lazy" from the always-visible nav logos
(lazy only delays permanent chrome); keep decoding + dimensions.
- Landing: document the ssr:false SEO tradeoff on the HowItWorks split.
Verified: 399/399 vitest, eslint 0 errors, tsc clean, production build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

Automated review pass (Claude Code) on 426be67. The reviewer independently reproduced every gate (399/399 vitest, eslint 0 errors, tsc clean in a throwaway worktree) and verified the hard parts: the link-pass rewrite is provably pixel-identical (squared-distance ⟺ distance for the threshold, alpha formula byte-for-byte), the KnowledgeGraph2D element registries are leak-free with React renders and direct DOM writes structurally unable to disagree, the reduced-motion/test-mode settle path renders final positions, the e2e graph spec reads no geometry, and the backdrop's dtFrames math is genuinely frame-rate-independent.

No criticals. Two Important findings — both real, both fixed in f92e0ce:

  1. Sprite memory (~100 MB at DPR 2). Full-device-resolution baking across 14 orbs traded CPU for a serious RAM/GPU footprint held app-wide, risky under iOS Safari's canvas budgets. Fixed: sprite backing resolution capped at 512px; the soft gradients upscale indistinguishably at ~50-100× less memory.
  2. Study panes gated behind the motion chunk.next/dynamic's loading fallback can't receive children, so StudyModePanel blanked the pane (and stalled its data fetching) until the framer-motion chunk resolved — a new waterfall the PR body's "visually identical fallbacks" missed. First-attempt fix (render children immediately, hand off to the motion panel on load) traded it for a worse bug our own test caught: the handoff remounted the pane and wiped its state. Final fix: the crossfade is now a keyed CSS enter-fade (study-mode-enter) with no chunk dependency at all; initial={false} semantics preserved (no first-mount animation), reduced motion covered by the global reset, and StudyMotion.tsx retains only the toggle-highlight spring + the feat(frontend): NEXT_PUBLIC_TEST_MODE determinism flag #383 test seam.

Minors also addressed: nav logos no longer loading="lazy" (permanent above-the-fold chrome — lazy only delays it), reduced-motion + resize no longer leaves the backdrop canvas blank, the ssr:false SEO tradeoff on HowItWorks is documented in-code, and the PR body's image-sweep wording is corrected (the audit's enumerated list is fully covered; public static pages/SignInModal/toasts were never in scope).

Watch on the post-merge e2e.yml run: graph.spec.ts and study-semester.spec.ts (exercises the reworked Study path).

…Works copy
- AtmosphericBackdrop: disabling reduced motion mid-session now restarts
the parked RAF loop (tick() parks itself on a still frame; the old
always-running loop resumed implicitly, so the backdrop stayed frozen).
- Landing: drop ssr:false from the HowItWorks dynamic import (#492
review) — next/dynamic still splits the motion stack into its own
chunk, but the section's marketing copy is back in the server-rendered
HTML; verified "Upload Your Materials" present in the prerendered
index.html.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
MemberAuthor

Re: the #492 overlap — ported the ssr: false drop in 23b773f: next/dynamic still splits the motion stack into its own chunk, and the HowItWorks copy is back in the server HTML (verified "Upload Your Materials" present in the prerendered index.html after a production build).

Deliberately did not port lib/linkPairs.ts: it's your implementation on a competing PR and the which-lands call is yours. If #490 is the one that lands, the spatial-binning module + its identical-pairs-identical-order test would make a clean follow-up on top of the cheap-reject pass here (0.356→0.100ms is a real win; the squared-distance rewrite in this PR gets a chunk of that without the new module).

CodeRabbit's three findings are resolved: the reduced-motion revive was a real regression vs. the old always-running loop (fixed in 23b773f); the Study fallback and nav-logo findings were fixed in f92e0ce (Study went further — keyed CSS enter-fade, no chunk dependency at all, after the handoff approach proved to remount panes and wipe state).

Brings in #341 (FullHeightScreen viewport-math), #290 (approval gate) and
#288 (hero card).
The globals.css conflict is resolved by keeping BOTH added blocks: this
branch's .study-mode-enter and main's .pending-* beat. Ordering invariants
re-verified after the merge — .card--hero still follows .card, and .pending-*
still precedes the .anim-d* delay utilities (the animation shorthand resets
animation-delay, so those have to come later to win).
Also fixes the intermittent frontend-lane CI failure, which is unrelated to
either PR but was red-flagging both. FlashcardImportModal.test.tsx wiped
document.body in afterEach but never unmounted; with `globals: false` in
vitest.config.ts, @testing-library/react does not auto-register its cleanup,
so React kept the tree and its pending scheduler work alive past the end of
the file and jsdom teardown landed it on a missing `window`:
ReferenceError: window is not defined
vitest counts those as unhandled errors and exits non-zero even with a fully
green suite — which is exactly how #492 failed with 416/416 tests passing.
Now calls cleanup() before the body sweep (both are needed; the sweep clears
the Dialog/ToastProvider portal siblings cleanup() leaves behind). Verified
3/3 clean full-suite runs, exit 0, zero unhandled errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

Merged main in, ran the E2E lane this PR was missing, and fixed the CI flake

Per @AndresL230's call: take this PR as the base, port anything worthwhile from #492, verify, merge. Pushed three things here.

1. Merged main in. This branch was based on 432c207, before #487/#488/#489 landed. One conflict in globals.css, resolved by keeping both added blocks — your .study-mode-enter and main's .pending-* beat. Both ordering invariants re-verified after the merge: .card--hero still follows .card (242/246 vs 210), and .pending-* still precedes .anim-d* (282-309 vs 310) — that one is load-bearing, since the animation shorthand resets animation-delay.

2. The full local E2E cycle — the gap in this PR's own verification note.

35 passed (1.3m)
0 finding(s), 1 suppressed (allowlisted)

Run at 5809465 inside one flock of the stack lock with SAPLING_MODEL_MODE=function. This matters most for KnowledgeGraph2D: graph.spec.ts is the app's strictest DOM contract and the direct-DOM-write refactor is the riskiest change in either PR. It passes.

3. Fixed the intermittent frontend-lane CI failure. Unrelated to either PR but red-flagging both — #492 failed with 416/416 tests passing and Errors 3. FlashcardImportModal.test.tsx wiped document.body in afterEach but never unmounted; with globals: false in vitest.config.ts, RTL does not auto-register its cleanup, so React kept the tree and its pending scheduler work alive past the end of the file and jsdom teardown landed it on a missing window. Now calls cleanup() before the body sweep. 3/3 clean full-suite runs, exit 0.

On porting from #492 — one thing went in, one did not

Ported: nothing, in the end — you'd already made the ssr: false change in 23b773f while I was working, crediting the #492 review. Keeping the 340vh placeholder alongside SSR is the better call than my version, which dropped it.

Dropped, and worth recording why: I was going to port #492's spatially-binned lib/linkPairs.ts over your axis-reject loop. I benchmarked it instead of assuming, and your version is faster at the real workload.

N=226 axis 0.068 ms | binned 0.113 ms <- the actual hero cloud (220 bg + 6 cluster)
N=500 axis 0.469 ms | binned 0.513 ms
N=1000 axis 2.034 ms | binned 2.065 ms
N=2000 axis 8.579 ms | binned 7.796 ms <- binning finally wins

Identical pairs at every N. Map allocation and per-point sorting cost more than binning saves until roughly N=2000, and the node count here is fixed at 226. So the asymptotically-better algorithm is the slower one in practice, and your implementation stays. My recommendation to Andres to port it was wrong.

Review

Ran the multi-agent review over the merged head, aimed at the riskiest area — React re-renders clobbering the direct DOM writes, or the reverse. No bugs found: both paths read and write the same mutable SimNode objects, so there is no second copy of position state to go stale. Testids, mastery-tier opacity, and the #383 deterministic settle all verified intact (simulation.tick() doesn't dispatch tick events, so the synchronous settle is untouched). Also confirmed StudyMotion.tsx needs no eslint testid-enforcement entry, and that the MotionGlobalConfig.skipAnimations seam still runs before any motion component renders.

Nice work on this — the direct-DOM-write graph and the visibilitychange pause are both better than what I had.

@AndresL230
AndresL230 merged commit 9c2f1a1 into mainJul 31, 2026
6 of 7 checks passed
@AndresL230
AndresL230 deleted the feat/111-runtime-anim-image-perf branch August 2, 2026 18:30
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.

[P2] Optimize runtime animations + add lazy/next-image loading

2 participants

@Jose-Gael-Cruz-Lopez@AndresL230