perf(frontend): cut per-frame work in the hero canvas, backdrop and graph (#111) - #492

Closed
AndresL230 wants to merge 3 commits into
mainfrom
feat/111-performance
Closed

perf(frontend): cut per-frame work in the hero canvas, backdrop and graph (#111)#492
AndresL230 wants to merge 3 commits into
mainfrom
feat/111-performance

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Four hot spots, no visual change. Half of #111 was already fixed on main and is untouched here — the landing canvas already respects reduced-motion, per-node shadowBlur is gone (0 hits repo-wide), the floating-cards RAF already resolves its NodeList once, and the spotlight rect is already cached. Those claims in the issue body are stale; this PR is only the genuinely remaining work.

1. Hero canvas link pass — measured 0.353ms → 0.097ms per frame (3.6×)

An uncapped double loop measured all 25,425 pairs of the 226 projected nodes every frame, to draw the ~213 that actually qualify. 99.2% of the distance checks were wasted.

A node can only link within 70 * sc px, so binning into a grid whose cell is the largest such radius means every possible partner sits in the 3×3 block around it and nothing else is ever measured.

Extracted to lib/linkPairs.ts so "identical output" is provable rather than asserted: linkPairs.test.ts compares it to the all-pairs reference across 40 random clouds plus dense, sparse, negative-coordinate, asymmetric and degenerate cases — and checks the emission order matches, because these are translucent overlapping strokes and order decides the composited image. One test also guards against a future "simplification" quietly restoring the double loop.

2. AtmosphericBackdrop — 14 gradients per frame → 14 blits, at half the rate

It rebuilt 14 full-viewport radial gradients every frame at 60fps, app-wide, mounted twice by ShellFrame. Everything a gradient depends on — colour, radius, depth-derived alpha — is fixed when the orb is created; only x/y move.

Each orb is baked once into an offscreen canvas and blitted; the loop is capped at ~30fps (ambient drift moves well under a pixel per frame). Re-bakes only when the device pixel ratio actually changes.

The reduced-motion still-frame path is preserved exactly, including the existing behaviour that switching reduced-motion on mid-session stops the loop.

3. KnowledgeGraph2D — three problems

  • Tick storm..on("tick", forceRerender) reconciled the whole SVG per physics tick. Ticks now coalesce into at most one render per animation frame. This does not touch the deterministic settle: simulation.tick() does not dispatch tick events (only the internal timer does), so the reduced-motion/test-mode path still renders once, synchronously, and stays deterministic.
  • Tooltip churn.setTooltipPos was the unconditional first statement of onPointerMove — moving the mouse over empty canvas re-rendered the entire graph to update a value nothing reads unless hovered is set. Now gated, with the position seeded on pointer-enter so the first frame still lands correctly.
  • O(E·N) lookups. Edge rendering used .find() per endpoint per edge per frame; the link filter did two .some() scans per edge. Both now use a Map/Set.

The offscreen IntersectionObserver pause and unmount cleanup are preserved, and the queued tick render is cancelled on unmount so it can't fire against a dead tree.

4. Images and framer-motion

Intrinsic width/height on the 19 brand-icon sites (a fixed-size asset whose box was only pinned in inline CSS, so nothing reserved it before styles resolved), and decoding="async" on the lazily-loaded ones. next/image deliberately not adopted, per your call.

HowItWorks (~54 motion.*, the landing page's only framer-motion consumer) is now next/dynamic, so the library leaves the critical path for a section below the fold. Notssr: false — the markup still server-renders.

Study.tsx and Calendar.tsx are deliberately left alone. The brief listed them, but both wrap their screen's primary content in the motion element — deferring it would defer the content itself, which is worse than the bundle cost it saves. Calendar.test.tsx's framer-motion mock stays green either way.

One suppression count moves

KnowledgeGraph2Dreact-hooks/refs 13 → 14. Building the id→node Map is one more use of a ref-derived value during render, in a file where that pattern is already baselined 13 times. I verified in isolation that the Map is the sole cause (restoring .find() returns it to 13), and tried three formulations — all cost the same. The trade is one more instance of an existing suppressed pattern in exchange for deleting a per-frame O(E·N) scan.

Gates

  • tsc --noEmit clean · npm run lint 0 errors (35 warnings, one fewer than before) · npx vitest run 59 files, 415 tests
  • Full local e2e cycle — recorded in a comment below

part of #111

Summary by CodeRabbit

  • Performance Improvements

    • Improved responsiveness of the interactive knowledge graph and animated background effects.
    • Optimized homepage canvas link rendering for smoother visual performance.
    • Improved image loading and decoding across chat, reports, settings, and other screens.
    • Added image dimensions to logos to reduce layout shifts during page loading.
  • Tests

    • Added comprehensive coverage for visual link-generation behavior and edge cases.

…raph (#111)
Four hot spots, no visual change. Half of #111 was already fixed on main
(landing canvas reduced-motion, per-node shadowBlur, the floating-cards
NodeList, the cached spotlight rect) and is untouched here.
**Hero canvas link pass — 0.353ms -> 0.097ms per frame (3.6x).**
An uncapped double loop measured all 25,425 pairs of the 226 projected nodes
every frame to draw the ~213 that qualify — 99.2% of the distance checks were
wasted. A node can only link within 70*sc px, so binning into a grid whose
cell is the largest such radius means every possible partner sits in the 3x3
block around it. Extracted to lib/linkPairs.ts so the fast version can be
PROVED equivalent to the obvious one rather than believed to be: the test
compares them across 40 random clouds plus dense, sparse, negative-coordinate
and degenerate cases, and asserts the emission ORDER matches too — these are
translucent overlapping strokes, so order decides the composited image.
**AtmosphericBackdrop — 14 gradients per frame -> 14 blits, at half the rate.**
It rebuilt 14 full-viewport radial gradients every frame at 60fps, app-wide,
mounted twice by ShellFrame. Everything a gradient depends on (colour, radius,
depth-derived alpha) is fixed when the orb is created; only x/y move. Each orb
is now baked once into an offscreen canvas and blitted, and the loop is capped
at ~30fps — ambient drift moves well under a pixel per frame. Re-bakes only if
the device pixel ratio changes. The reduced-motion still-frame path is
preserved exactly, including the behaviour that turning reduced-motion on
mid-session stops the loop.
**KnowledgeGraph2D — three separate problems.**
Ticks now coalesce into at most one React render per animation frame; d3's
timer can fire more than once per frame and each render reconciled the entire
SVG. This does not touch the deterministic settle: simulation.tick() does not
dispatch tick events, so the reduced-motion/test-mode path still renders once,
synchronously. setTooltipPos was the unconditional first statement of
onPointerMove, so moving the mouse across empty canvas re-rendered the whole
graph to update a value nothing reads unless a node is hovered; it is now
gated on `hovered`, with the position seeded on pointer-enter so the first
frame still lands in the right place. Edge rendering and the link filter both
did O(E x N) linear scans; both now use a Map/Set.
**Images.** Intrinsic width/height on the 19 brand-icon sites (a fixed-size
asset whose box was only pinned in inline CSS, so nothing reserved it before
styles resolved), and decoding="async" on the lazily-loaded ones. next/image
is deliberately not adopted — zero usages repo-wide and it needs a Cloudflare
Workers loader of its own.
**framer-motion.** HowItWorks (~54 motion.* elements, the landing page's only
consumer) is now next/dynamic, so the library leaves the critical path for a
section below the fold. NOT `ssr: false` — the markup still server-renders.
Study.tsx and Calendar.tsx are deliberately left alone: both wrap their
screen's PRIMARY content in the motion element, so deferring it would defer
the content itself, which is worse than the bundle cost it saves.
One lint-suppression count moves (KnowledgeGraph2D react-hooks/refs 13 -> 14):
building the id->node Map is one more use of a ref-derived value during render,
in a file where that pattern is already baselined 13 times. Verified in
isolation that the map is the only cause. The trade is one more instance of an
existing suppressed pattern for removing a per-frame O(E x N) scan.
part of #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

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6b16f5bf-7585-4348-9020-48b0593f57a0

📥 Commits

Reviewing files that changed from the base of the PR and between abf1c04 and 463facd.

📒 Files selected for processing (1)
  • frontend/src/components/AtmosphericBackdrop.tsx
📝 Walkthrough

Walkthrough

The PR adds spatial link-pair generation, cached canvas sprites, coalesced knowledge graph renders, dynamic loading for HowItWorks, intrinsic image dimensions, and asynchronous image decoding across frontend surfaces.

Changes

Frontend rendering performance

Layer / File(s)Summary
Spatial link generation and page integration
frontend/src/lib/linkPairs.ts, frontend/src/lib/linkPairs.test.ts, frontend/src/app/(public)/page.tsx
Adds naive and spatially binned link generation. Tests cover equivalence, ordering, edge cases, duplicate prevention, and distance-count reduction. The public page uses the optimized function and dynamically loads HowItWorks.
Cached atmospheric backdrop rendering
frontend/src/components/AtmosphericBackdrop.tsx
Caches orb gradients as offscreen sprites, rebuilds them when orb count or DPR changes, and throttles painting to approximately 30 FPS.
Knowledge graph render scheduling and lookup
frontend/src/components/KnowledgeGraph2D.tsx, frontend/eslint-suppressions.json
Coalesces simulation updates with requestAnimationFrame, cancels pending renders on unmount, replaces repeated scans with Set/Map lookups, and limits tooltip updates to active hovers.
Image dimensions and asynchronous decoding
frontend/src/app/(public)/*, frontend/src/components/{SideNav,SignInModal,TopNav,MarkdownChat,ReportIssueFlow}.tsx, frontend/src/components/screens/*
Adds intrinsic dimensions to Sapling logos and enables asynchronous decoding for content, preview, cosmetic, and chat images.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

Sequence Diagram(s)

sequenceDiagram
participant KnowledgeGraph2D
participant Simulation
participant requestAnimationFrame
participant Canvas
Simulation->>KnowledgeGraph2D: emit simulation tick
KnowledgeGraph2D->>requestAnimationFrame: queue one render
requestAnimationFrame->>KnowledgeGraph2D: execute render callback
KnowledgeGraph2D->>Canvas: draw resolved nodes and edges
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 38.89% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the primary performance changes to the hero canvas, backdrop, and graph.
Description check✅ PassedThe description provides a detailed change summary, rationale, testing results, issue reference, and reviewer notes, despite not using the template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/111-performance

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

❤️ Share

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented 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-staging463facdCommit Preview URL

Branch Preview URL
Jul 31 2026, 03:47 AM

AndresL230and others added 2 commits July 30, 2026 20:43
Two review findings.
The spatial grid packed (gx, gy) into one integer, `((gx + 1024) << 12) |
(gy + 1024)`, on the assumption both stay inside a bound. linkPairs is an
exported general-purpose helper with no such precondition: a small `reach`
makes cells small, the grid coordinates blow past the bound, two different
cells hash together, the same bucket is visited twice inside one 3x3 scan —
and the pair is emitted TWICE. Reproduced in review:
points = [(0,0,sc1), (0,5000,sc1), (0.5,5000.4,sc1)], reach 1
naive -> 1 pair; binned -> the same pair, duplicated
Not reachable from the hero canvas (its coordinates and scales keep the grid
tiny), but the file claims equivalence for all inputs, and "not reachable
today" is not a property an exported helper should rely on. Now a
Map-of-columns, which has no bound to blow. Same speed: 0.356ms -> 0.100ms,
3.5x, unchanged from the packed version. The repro is now a regression test.
The nodeById comment claimed memoising would trade one lint violation for
another. Measured during review: `useMemo` reports the SAME react-hooks/refs
count, so that reasoning was simply wrong. The real reason not to memoise is
that `simNodes` is a ref's array mutated in place — its identity would not
change when its contents do, which makes it an unsound dependency. Comment
now says that instead.
part of #111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#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
CollaboratorAuthor

⚠️ This overlaps PR #490 almost entirely — do not merge either without a decision

I did not know #490 existed when I opened this; my 5-agent review found it. @Jose-Gael-Cruz-Lopez opened #490 at 02:38Z against issue #111, ~50 minutes before this one, from the same base (432c207). The two PRs touch nine of the same files, including all three hot spots. Whichever merges first leaves the other with a large, error-prone conflict across page.tsx, AtmosphericBackdrop.tsx and KnowledgeGraph2D.tsx.

Posting an honest comparison rather than arguing for mine.

#490#492 (this)
hero link passaxis-reject + squared distance — still O(N²), cheaper constantspatial binning, sub-quadratic, extracted + equivalence-tested
KnowledgeGraph2D ticksdirect DOM writes, zero React work per tickrAF-coalesced forceRerender — React still reconciles once per frame
backdropsprites + 30fps + pauses on visibilitychangesprites + 30fps, no visibility pause
HowItWorksnext/dynamicssr: false + placeholdernext/dynamic, SSR kept
Study.tsxmotion subtree extracted to StudyMotion.tsxleft alone deliberately
suppressions baselinepruned 13 → 12grown 13 → 14
local E2E lanecould not run (machine unprovisioned)ran green, repeatedly

Where #490 is better: the direct-DOM-write graph is the deeper fix and is what the issue's own proposal recommended; the visibilitychange pause is a real win this PR lacks; extracting StudyMotion is more than I did; and pruning the suppressions baseline beats growing it.

Where this PR is better: the link pass is asymptotically better, not just cheaper per pair, and its equivalence to the naive version is proved by test rather than asserted. ssr: false on HowItWorks removes a landing-page section from the server-rendered HTML — a crawler regression on the one page that needs SEO. And this branch has actually been through the local E2E lane.

Reading #490 also caught a real bug in this PR that my own review missed: my 30fps cap advanced the orb physics once per painted frame, so drift ran at half speed — a visual change, in a PR claiming none. #490 handled that explicitly ("drift speed preserved via elapsed-frame scaling"). Fixed in 463facd.

My suggestion, for whoever decides: take #490 as the base — it is the more thorough pass and the graph fix is better — and port two things onto it from here: the binned lib/linkPairs.ts with its equivalence suite, and dropping ssr: false from the HowItWorks dynamic import. But that is a call for @AndresL230, not for me.

Jose-Gael-Cruz-Lopez added a commit that referenced this pull request Jul 31, 2026
…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>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
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 added a commit that referenced this pull request Jul 31, 2026
* perf(frontend): #111 runtime animations + lazy image loading
- 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>
* fix(perf): #490 review round — sprite memory cap, un-gated Study panes, 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>
* fix(perf): #490 round 2 — revive backdrop on un-reduce, SSR the HowItWorks 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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: AndresL230 <190146319+AndresL230@users.noreply.github.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Superseded by #490, which is merged.

@AndresL230's call was to take Jose's PR as the base, port anything worthwhile from here, verify it, and merge — which is what happened. Recording what carried over and what didn't:

Carried over: the ssr: false → SSR-preserving HowItWorks import (Jose made that change himself in 23b773f, crediting this PR's review), and the intermittent CI fix from this branch's investigation (FlashcardImportModal.test.tsx never unmounting — the reason this very PR failed CI with 416/416 tests passing).

Deliberately not carried over: the spatially-binned lib/linkPairs.ts. I benchmarked it against #490's axis-reject loop rather than assuming, and #490's is faster at the real workload — 0.068ms vs 0.113ms per frame at the actual 226-node cloud. Binning only wins past ~N=2000; below that, Map allocation and per-point sorting cost more than it saves. Identical pairs at every N. The asymptotically better algorithm was the slower one here, so it stayed out.

Also not carried over: the rAF-coalesced tick render. #490 writes simulation positions directly to the DOM, which is strictly better — and is what issue #111's own proposal recommended.

The one thing this PR did that #490 hadn't: run the full local E2E cycle. That has now been run against the merged head — 35/35 journeys, oracles clean.

Opening this without checking for an existing open PR on the same issue was my mistake.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

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

perf(frontend): cut per-frame work in the hero canvas, backdrop and graph (#111) - #492

Closed
AndresL230 wants to merge 3 commits into
mainfrom
feat/111-performance
Closed

perf(frontend): cut per-frame work in the hero canvas, backdrop and graph (#111)#492
AndresL230 wants to merge 3 commits into
mainfrom
feat/111-performance

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Four hot spots, no visual change. Half of #111 was already fixed on main and is untouched here — the landing canvas already respects reduced-motion, per-node shadowBlur is gone (0 hits repo-wide), the floating-cards RAF already resolves its NodeList once, and the spotlight rect is already cached. Those claims in the issue body are stale; this PR is only the genuinely remaining work.

1. Hero canvas link pass — measured 0.353ms → 0.097ms per frame (3.6×)

An uncapped double loop measured all 25,425 pairs of the 226 projected nodes every frame, to draw the ~213 that actually qualify. 99.2% of the distance checks were wasted.

A node can only link within 70 * sc px, so binning into a grid whose cell is the largest such radius means every possible partner sits in the 3×3 block around it and nothing else is ever measured.

Extracted to lib/linkPairs.ts so "identical output" is provable rather than asserted: linkPairs.test.ts compares it to the all-pairs reference across 40 random clouds plus dense, sparse, negative-coordinate, asymmetric and degenerate cases — and checks the emission order matches, because these are translucent overlapping strokes and order decides the composited image. One test also guards against a future "simplification" quietly restoring the double loop.

2. AtmosphericBackdrop — 14 gradients per frame → 14 blits, at half the rate

It rebuilt 14 full-viewport radial gradients every frame at 60fps, app-wide, mounted twice by ShellFrame. Everything a gradient depends on — colour, radius, depth-derived alpha — is fixed when the orb is created; only x/y move.

Each orb is baked once into an offscreen canvas and blitted; the loop is capped at ~30fps (ambient drift moves well under a pixel per frame). Re-bakes only when the device pixel ratio actually changes.

The reduced-motion still-frame path is preserved exactly, including the existing behaviour that switching reduced-motion on mid-session stops the loop.

3. KnowledgeGraph2D — three problems

  • Tick storm..on("tick", forceRerender) reconciled the whole SVG per physics tick. Ticks now coalesce into at most one render per animation frame. This does not touch the deterministic settle: simulation.tick() does not dispatch tick events (only the internal timer does), so the reduced-motion/test-mode path still renders once, synchronously, and stays deterministic.
  • Tooltip churn.setTooltipPos was the unconditional first statement of onPointerMove — moving the mouse over empty canvas re-rendered the entire graph to update a value nothing reads unless hovered is set. Now gated, with the position seeded on pointer-enter so the first frame still lands correctly.
  • O(E·N) lookups. Edge rendering used .find() per endpoint per edge per frame; the link filter did two .some() scans per edge. Both now use a Map/Set.

The offscreen IntersectionObserver pause and unmount cleanup are preserved, and the queued tick render is cancelled on unmount so it can't fire against a dead tree.

4. Images and framer-motion

Intrinsic width/height on the 19 brand-icon sites (a fixed-size asset whose box was only pinned in inline CSS, so nothing reserved it before styles resolved), and decoding="async" on the lazily-loaded ones. next/image deliberately not adopted, per your call.

HowItWorks (~54 motion.*, the landing page's only framer-motion consumer) is now next/dynamic, so the library leaves the critical path for a section below the fold. Notssr: false — the markup still server-renders.

Study.tsx and Calendar.tsx are deliberately left alone. The brief listed them, but both wrap their screen's primary content in the motion element — deferring it would defer the content itself, which is worse than the bundle cost it saves. Calendar.test.tsx's framer-motion mock stays green either way.

One suppression count moves

KnowledgeGraph2Dreact-hooks/refs 13 → 14. Building the id→node Map is one more use of a ref-derived value during render, in a file where that pattern is already baselined 13 times. I verified in isolation that the Map is the sole cause (restoring .find() returns it to 13), and tried three formulations — all cost the same. The trade is one more instance of an existing suppressed pattern in exchange for deleting a per-frame O(E·N) scan.

Gates

  • tsc --noEmit clean · npm run lint 0 errors (35 warnings, one fewer than before) · npx vitest run 59 files, 415 tests
  • Full local e2e cycle — recorded in a comment below

part of #111

Summary by CodeRabbit

  • Performance Improvements

    • Improved responsiveness of the interactive knowledge graph and animated background effects.
    • Optimized homepage canvas link rendering for smoother visual performance.
    • Improved image loading and decoding across chat, reports, settings, and other screens.
    • Added image dimensions to logos to reduce layout shifts during page loading.
  • Tests

    • Added comprehensive coverage for visual link-generation behavior and edge cases.

…raph (#111)
Four hot spots, no visual change. Half of #111 was already fixed on main
(landing canvas reduced-motion, per-node shadowBlur, the floating-cards
NodeList, the cached spotlight rect) and is untouched here.
**Hero canvas link pass — 0.353ms -> 0.097ms per frame (3.6x).**
An uncapped double loop measured all 25,425 pairs of the 226 projected nodes
every frame to draw the ~213 that qualify — 99.2% of the distance checks were
wasted. A node can only link within 70*sc px, so binning into a grid whose
cell is the largest such radius means every possible partner sits in the 3x3
block around it. Extracted to lib/linkPairs.ts so the fast version can be
PROVED equivalent to the obvious one rather than believed to be: the test
compares them across 40 random clouds plus dense, sparse, negative-coordinate
and degenerate cases, and asserts the emission ORDER matches too — these are
translucent overlapping strokes, so order decides the composited image.
**AtmosphericBackdrop — 14 gradients per frame -> 14 blits, at half the rate.**
It rebuilt 14 full-viewport radial gradients every frame at 60fps, app-wide,
mounted twice by ShellFrame. Everything a gradient depends on (colour, radius,
depth-derived alpha) is fixed when the orb is created; only x/y move. Each orb
is now baked once into an offscreen canvas and blitted, and the loop is capped
at ~30fps — ambient drift moves well under a pixel per frame. Re-bakes only if
the device pixel ratio changes. The reduced-motion still-frame path is
preserved exactly, including the behaviour that turning reduced-motion on
mid-session stops the loop.
**KnowledgeGraph2D — three separate problems.**
Ticks now coalesce into at most one React render per animation frame; d3's
timer can fire more than once per frame and each render reconciled the entire
SVG. This does not touch the deterministic settle: simulation.tick() does not
dispatch tick events, so the reduced-motion/test-mode path still renders once,
synchronously. setTooltipPos was the unconditional first statement of
onPointerMove, so moving the mouse across empty canvas re-rendered the whole
graph to update a value nothing reads unless a node is hovered; it is now
gated on `hovered`, with the position seeded on pointer-enter so the first
frame still lands in the right place. Edge rendering and the link filter both
did O(E x N) linear scans; both now use a Map/Set.
**Images.** Intrinsic width/height on the 19 brand-icon sites (a fixed-size
asset whose box was only pinned in inline CSS, so nothing reserved it before
styles resolved), and decoding="async" on the lazily-loaded ones. next/image
is deliberately not adopted — zero usages repo-wide and it needs a Cloudflare
Workers loader of its own.
**framer-motion.** HowItWorks (~54 motion.* elements, the landing page's only
consumer) is now next/dynamic, so the library leaves the critical path for a
section below the fold. NOT `ssr: false` — the markup still server-renders.
Study.tsx and Calendar.tsx are deliberately left alone: both wrap their
screen's PRIMARY content in the motion element, so deferring it would defer
the content itself, which is worse than the bundle cost it saves.
One lint-suppression count moves (KnowledgeGraph2D react-hooks/refs 13 -> 14):
building the id->node Map is one more use of a ref-derived value during render,
in a file where that pattern is already baselined 13 times. Verified in
isolation that the map is the only cause. The trade is one more instance of an
existing suppressed pattern for removing a per-frame O(E x N) scan.
part of #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

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6b16f5bf-7585-4348-9020-48b0593f57a0

📥 Commits

Reviewing files that changed from the base of the PR and between abf1c04 and 463facd.

📒 Files selected for processing (1)
  • frontend/src/components/AtmosphericBackdrop.tsx
📝 Walkthrough

Walkthrough

The PR adds spatial link-pair generation, cached canvas sprites, coalesced knowledge graph renders, dynamic loading for HowItWorks, intrinsic image dimensions, and asynchronous image decoding across frontend surfaces.

Changes

Frontend rendering performance

Layer / File(s)Summary
Spatial link generation and page integration
frontend/src/lib/linkPairs.ts, frontend/src/lib/linkPairs.test.ts, frontend/src/app/(public)/page.tsx
Adds naive and spatially binned link generation. Tests cover equivalence, ordering, edge cases, duplicate prevention, and distance-count reduction. The public page uses the optimized function and dynamically loads HowItWorks.
Cached atmospheric backdrop rendering
frontend/src/components/AtmosphericBackdrop.tsx
Caches orb gradients as offscreen sprites, rebuilds them when orb count or DPR changes, and throttles painting to approximately 30 FPS.
Knowledge graph render scheduling and lookup
frontend/src/components/KnowledgeGraph2D.tsx, frontend/eslint-suppressions.json
Coalesces simulation updates with requestAnimationFrame, cancels pending renders on unmount, replaces repeated scans with Set/Map lookups, and limits tooltip updates to active hovers.
Image dimensions and asynchronous decoding
frontend/src/app/(public)/*, frontend/src/components/{SideNav,SignInModal,TopNav,MarkdownChat,ReportIssueFlow}.tsx, frontend/src/components/screens/*
Adds intrinsic dimensions to Sapling logos and enables asynchronous decoding for content, preview, cosmetic, and chat images.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

Sequence Diagram(s)

sequenceDiagram
participant KnowledgeGraph2D
participant Simulation
participant requestAnimationFrame
participant Canvas
Simulation->>KnowledgeGraph2D: emit simulation tick
KnowledgeGraph2D->>requestAnimationFrame: queue one render
requestAnimationFrame->>KnowledgeGraph2D: execute render callback
KnowledgeGraph2D->>Canvas: draw resolved nodes and edges
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 38.89% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the primary performance changes to the hero canvas, backdrop, and graph.
Description check✅ PassedThe description provides a detailed change summary, rationale, testing results, issue reference, and reviewer notes, despite not using the template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/111-performance

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

❤️ Share

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented 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-staging463facdCommit Preview URL

Branch Preview URL
Jul 31 2026, 03:47 AM

AndresL230and others added 2 commits July 30, 2026 20:43
Two review findings.
The spatial grid packed (gx, gy) into one integer, `((gx + 1024) << 12) |
(gy + 1024)`, on the assumption both stay inside a bound. linkPairs is an
exported general-purpose helper with no such precondition: a small `reach`
makes cells small, the grid coordinates blow past the bound, two different
cells hash together, the same bucket is visited twice inside one 3x3 scan —
and the pair is emitted TWICE. Reproduced in review:
points = [(0,0,sc1), (0,5000,sc1), (0.5,5000.4,sc1)], reach 1
naive -> 1 pair; binned -> the same pair, duplicated
Not reachable from the hero canvas (its coordinates and scales keep the grid
tiny), but the file claims equivalence for all inputs, and "not reachable
today" is not a property an exported helper should rely on. Now a
Map-of-columns, which has no bound to blow. Same speed: 0.356ms -> 0.100ms,
3.5x, unchanged from the packed version. The repro is now a regression test.
The nodeById comment claimed memoising would trade one lint violation for
another. Measured during review: `useMemo` reports the SAME react-hooks/refs
count, so that reasoning was simply wrong. The real reason not to memoise is
that `simNodes` is a ref's array mutated in place — its identity would not
change when its contents do, which makes it an unsound dependency. Comment
now says that instead.
part of #111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#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
CollaboratorAuthor

⚠️ This overlaps PR #490 almost entirely — do not merge either without a decision

I did not know #490 existed when I opened this; my 5-agent review found it. @Jose-Gael-Cruz-Lopez opened #490 at 02:38Z against issue #111, ~50 minutes before this one, from the same base (432c207). The two PRs touch nine of the same files, including all three hot spots. Whichever merges first leaves the other with a large, error-prone conflict across page.tsx, AtmosphericBackdrop.tsx and KnowledgeGraph2D.tsx.

Posting an honest comparison rather than arguing for mine.

#490#492 (this)
hero link passaxis-reject + squared distance — still O(N²), cheaper constantspatial binning, sub-quadratic, extracted + equivalence-tested
KnowledgeGraph2D ticksdirect DOM writes, zero React work per tickrAF-coalesced forceRerender — React still reconciles once per frame
backdropsprites + 30fps + pauses on visibilitychangesprites + 30fps, no visibility pause
HowItWorksnext/dynamicssr: false + placeholdernext/dynamic, SSR kept
Study.tsxmotion subtree extracted to StudyMotion.tsxleft alone deliberately
suppressions baselinepruned 13 → 12grown 13 → 14
local E2E lanecould not run (machine unprovisioned)ran green, repeatedly

Where #490 is better: the direct-DOM-write graph is the deeper fix and is what the issue's own proposal recommended; the visibilitychange pause is a real win this PR lacks; extracting StudyMotion is more than I did; and pruning the suppressions baseline beats growing it.

Where this PR is better: the link pass is asymptotically better, not just cheaper per pair, and its equivalence to the naive version is proved by test rather than asserted. ssr: false on HowItWorks removes a landing-page section from the server-rendered HTML — a crawler regression on the one page that needs SEO. And this branch has actually been through the local E2E lane.

Reading #490 also caught a real bug in this PR that my own review missed: my 30fps cap advanced the orb physics once per painted frame, so drift ran at half speed — a visual change, in a PR claiming none. #490 handled that explicitly ("drift speed preserved via elapsed-frame scaling"). Fixed in 463facd.

My suggestion, for whoever decides: take #490 as the base — it is the more thorough pass and the graph fix is better — and port two things onto it from here: the binned lib/linkPairs.ts with its equivalence suite, and dropping ssr: false from the HowItWorks dynamic import. But that is a call for @AndresL230, not for me.

Jose-Gael-Cruz-Lopez added a commit that referenced this pull request Jul 31, 2026
…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>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
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 added a commit that referenced this pull request Jul 31, 2026
* perf(frontend): #111 runtime animations + lazy image loading
- 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>
* fix(perf): #490 review round — sprite memory cap, un-gated Study panes, 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>
* fix(perf): #490 round 2 — revive backdrop on un-reduce, SSR the HowItWorks 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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: AndresL230 <190146319+AndresL230@users.noreply.github.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Superseded by #490, which is merged.

@AndresL230's call was to take Jose's PR as the base, port anything worthwhile from here, verify it, and merge — which is what happened. Recording what carried over and what didn't:

Carried over: the ssr: false → SSR-preserving HowItWorks import (Jose made that change himself in 23b773f, crediting this PR's review), and the intermittent CI fix from this branch's investigation (FlashcardImportModal.test.tsx never unmounting — the reason this very PR failed CI with 416/416 tests passing).

Deliberately not carried over: the spatially-binned lib/linkPairs.ts. I benchmarked it against #490's axis-reject loop rather than assuming, and #490's is faster at the real workload — 0.068ms vs 0.113ms per frame at the actual 226-node cloud. Binning only wins past ~N=2000; below that, Map allocation and per-point sorting cost more than it saves. Identical pairs at every N. The asymptotically better algorithm was the slower one here, so it stayed out.

Also not carried over: the rAF-coalesced tick render. #490 writes simulation positions directly to the DOM, which is strictly better — and is what issue #111's own proposal recommended.

The one thing this PR did that #490 hadn't: run the full local E2E cycle. That has now been run against the merged head — 35/35 journeys, oracles clean.

Opening this without checking for an existing open PR on the same issue was my mistake.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

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

perf(frontend): cut per-frame work in the hero canvas, backdrop and graph (#111) - #492

Closed
AndresL230 wants to merge 3 commits into
mainfrom
feat/111-performance
Closed

perf(frontend): cut per-frame work in the hero canvas, backdrop and graph (#111)#492
AndresL230 wants to merge 3 commits into
mainfrom
feat/111-performance

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Four hot spots, no visual change. Half of #111 was already fixed on main and is untouched here — the landing canvas already respects reduced-motion, per-node shadowBlur is gone (0 hits repo-wide), the floating-cards RAF already resolves its NodeList once, and the spotlight rect is already cached. Those claims in the issue body are stale; this PR is only the genuinely remaining work.

1. Hero canvas link pass — measured 0.353ms → 0.097ms per frame (3.6×)

An uncapped double loop measured all 25,425 pairs of the 226 projected nodes every frame, to draw the ~213 that actually qualify. 99.2% of the distance checks were wasted.

A node can only link within 70 * sc px, so binning into a grid whose cell is the largest such radius means every possible partner sits in the 3×3 block around it and nothing else is ever measured.

Extracted to lib/linkPairs.ts so "identical output" is provable rather than asserted: linkPairs.test.ts compares it to the all-pairs reference across 40 random clouds plus dense, sparse, negative-coordinate, asymmetric and degenerate cases — and checks the emission order matches, because these are translucent overlapping strokes and order decides the composited image. One test also guards against a future "simplification" quietly restoring the double loop.

2. AtmosphericBackdrop — 14 gradients per frame → 14 blits, at half the rate

It rebuilt 14 full-viewport radial gradients every frame at 60fps, app-wide, mounted twice by ShellFrame. Everything a gradient depends on — colour, radius, depth-derived alpha — is fixed when the orb is created; only x/y move.

Each orb is baked once into an offscreen canvas and blitted; the loop is capped at ~30fps (ambient drift moves well under a pixel per frame). Re-bakes only when the device pixel ratio actually changes.

The reduced-motion still-frame path is preserved exactly, including the existing behaviour that switching reduced-motion on mid-session stops the loop.

3. KnowledgeGraph2D — three problems

  • Tick storm..on("tick", forceRerender) reconciled the whole SVG per physics tick. Ticks now coalesce into at most one render per animation frame. This does not touch the deterministic settle: simulation.tick() does not dispatch tick events (only the internal timer does), so the reduced-motion/test-mode path still renders once, synchronously, and stays deterministic.
  • Tooltip churn.setTooltipPos was the unconditional first statement of onPointerMove — moving the mouse over empty canvas re-rendered the entire graph to update a value nothing reads unless hovered is set. Now gated, with the position seeded on pointer-enter so the first frame still lands correctly.
  • O(E·N) lookups. Edge rendering used .find() per endpoint per edge per frame; the link filter did two .some() scans per edge. Both now use a Map/Set.

The offscreen IntersectionObserver pause and unmount cleanup are preserved, and the queued tick render is cancelled on unmount so it can't fire against a dead tree.

4. Images and framer-motion

Intrinsic width/height on the 19 brand-icon sites (a fixed-size asset whose box was only pinned in inline CSS, so nothing reserved it before styles resolved), and decoding="async" on the lazily-loaded ones. next/image deliberately not adopted, per your call.

HowItWorks (~54 motion.*, the landing page's only framer-motion consumer) is now next/dynamic, so the library leaves the critical path for a section below the fold. Notssr: false — the markup still server-renders.

Study.tsx and Calendar.tsx are deliberately left alone. The brief listed them, but both wrap their screen's primary content in the motion element — deferring it would defer the content itself, which is worse than the bundle cost it saves. Calendar.test.tsx's framer-motion mock stays green either way.

One suppression count moves

KnowledgeGraph2Dreact-hooks/refs 13 → 14. Building the id→node Map is one more use of a ref-derived value during render, in a file where that pattern is already baselined 13 times. I verified in isolation that the Map is the sole cause (restoring .find() returns it to 13), and tried three formulations — all cost the same. The trade is one more instance of an existing suppressed pattern in exchange for deleting a per-frame O(E·N) scan.

Gates

  • tsc --noEmit clean · npm run lint 0 errors (35 warnings, one fewer than before) · npx vitest run 59 files, 415 tests
  • Full local e2e cycle — recorded in a comment below

part of #111

Summary by CodeRabbit

  • Performance Improvements

    • Improved responsiveness of the interactive knowledge graph and animated background effects.
    • Optimized homepage canvas link rendering for smoother visual performance.
    • Improved image loading and decoding across chat, reports, settings, and other screens.
    • Added image dimensions to logos to reduce layout shifts during page loading.
  • Tests

    • Added comprehensive coverage for visual link-generation behavior and edge cases.

…raph (#111)
Four hot spots, no visual change. Half of #111 was already fixed on main
(landing canvas reduced-motion, per-node shadowBlur, the floating-cards
NodeList, the cached spotlight rect) and is untouched here.
**Hero canvas link pass — 0.353ms -> 0.097ms per frame (3.6x).**
An uncapped double loop measured all 25,425 pairs of the 226 projected nodes
every frame to draw the ~213 that qualify — 99.2% of the distance checks were
wasted. A node can only link within 70*sc px, so binning into a grid whose
cell is the largest such radius means every possible partner sits in the 3x3
block around it. Extracted to lib/linkPairs.ts so the fast version can be
PROVED equivalent to the obvious one rather than believed to be: the test
compares them across 40 random clouds plus dense, sparse, negative-coordinate
and degenerate cases, and asserts the emission ORDER matches too — these are
translucent overlapping strokes, so order decides the composited image.
**AtmosphericBackdrop — 14 gradients per frame -> 14 blits, at half the rate.**
It rebuilt 14 full-viewport radial gradients every frame at 60fps, app-wide,
mounted twice by ShellFrame. Everything a gradient depends on (colour, radius,
depth-derived alpha) is fixed when the orb is created; only x/y move. Each orb
is now baked once into an offscreen canvas and blitted, and the loop is capped
at ~30fps — ambient drift moves well under a pixel per frame. Re-bakes only if
the device pixel ratio changes. The reduced-motion still-frame path is
preserved exactly, including the behaviour that turning reduced-motion on
mid-session stops the loop.
**KnowledgeGraph2D — three separate problems.**
Ticks now coalesce into at most one React render per animation frame; d3's
timer can fire more than once per frame and each render reconciled the entire
SVG. This does not touch the deterministic settle: simulation.tick() does not
dispatch tick events, so the reduced-motion/test-mode path still renders once,
synchronously. setTooltipPos was the unconditional first statement of
onPointerMove, so moving the mouse across empty canvas re-rendered the whole
graph to update a value nothing reads unless a node is hovered; it is now
gated on `hovered`, with the position seeded on pointer-enter so the first
frame still lands in the right place. Edge rendering and the link filter both
did O(E x N) linear scans; both now use a Map/Set.
**Images.** Intrinsic width/height on the 19 brand-icon sites (a fixed-size
asset whose box was only pinned in inline CSS, so nothing reserved it before
styles resolved), and decoding="async" on the lazily-loaded ones. next/image
is deliberately not adopted — zero usages repo-wide and it needs a Cloudflare
Workers loader of its own.
**framer-motion.** HowItWorks (~54 motion.* elements, the landing page's only
consumer) is now next/dynamic, so the library leaves the critical path for a
section below the fold. NOT `ssr: false` — the markup still server-renders.
Study.tsx and Calendar.tsx are deliberately left alone: both wrap their
screen's PRIMARY content in the motion element, so deferring it would defer
the content itself, which is worse than the bundle cost it saves.
One lint-suppression count moves (KnowledgeGraph2D react-hooks/refs 13 -> 14):
building the id->node Map is one more use of a ref-derived value during render,
in a file where that pattern is already baselined 13 times. Verified in
isolation that the map is the only cause. The trade is one more instance of an
existing suppressed pattern for removing a per-frame O(E x N) scan.
part of #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

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6b16f5bf-7585-4348-9020-48b0593f57a0

📥 Commits

Reviewing files that changed from the base of the PR and between abf1c04 and 463facd.

📒 Files selected for processing (1)
  • frontend/src/components/AtmosphericBackdrop.tsx
📝 Walkthrough

Walkthrough

The PR adds spatial link-pair generation, cached canvas sprites, coalesced knowledge graph renders, dynamic loading for HowItWorks, intrinsic image dimensions, and asynchronous image decoding across frontend surfaces.

Changes

Frontend rendering performance

Layer / File(s)Summary
Spatial link generation and page integration
frontend/src/lib/linkPairs.ts, frontend/src/lib/linkPairs.test.ts, frontend/src/app/(public)/page.tsx
Adds naive and spatially binned link generation. Tests cover equivalence, ordering, edge cases, duplicate prevention, and distance-count reduction. The public page uses the optimized function and dynamically loads HowItWorks.
Cached atmospheric backdrop rendering
frontend/src/components/AtmosphericBackdrop.tsx
Caches orb gradients as offscreen sprites, rebuilds them when orb count or DPR changes, and throttles painting to approximately 30 FPS.
Knowledge graph render scheduling and lookup
frontend/src/components/KnowledgeGraph2D.tsx, frontend/eslint-suppressions.json
Coalesces simulation updates with requestAnimationFrame, cancels pending renders on unmount, replaces repeated scans with Set/Map lookups, and limits tooltip updates to active hovers.
Image dimensions and asynchronous decoding
frontend/src/app/(public)/*, frontend/src/components/{SideNav,SignInModal,TopNav,MarkdownChat,ReportIssueFlow}.tsx, frontend/src/components/screens/*
Adds intrinsic dimensions to Sapling logos and enables asynchronous decoding for content, preview, cosmetic, and chat images.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

Sequence Diagram(s)

sequenceDiagram
participant KnowledgeGraph2D
participant Simulation
participant requestAnimationFrame
participant Canvas
Simulation->>KnowledgeGraph2D: emit simulation tick
KnowledgeGraph2D->>requestAnimationFrame: queue one render
requestAnimationFrame->>KnowledgeGraph2D: execute render callback
KnowledgeGraph2D->>Canvas: draw resolved nodes and edges
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 38.89% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the primary performance changes to the hero canvas, backdrop, and graph.
Description check✅ PassedThe description provides a detailed change summary, rationale, testing results, issue reference, and reviewer notes, despite not using the template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/111-performance

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

❤️ Share

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented 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-staging463facdCommit Preview URL

Branch Preview URL
Jul 31 2026, 03:47 AM

AndresL230and others added 2 commits July 30, 2026 20:43
Two review findings.
The spatial grid packed (gx, gy) into one integer, `((gx + 1024) << 12) |
(gy + 1024)`, on the assumption both stay inside a bound. linkPairs is an
exported general-purpose helper with no such precondition: a small `reach`
makes cells small, the grid coordinates blow past the bound, two different
cells hash together, the same bucket is visited twice inside one 3x3 scan —
and the pair is emitted TWICE. Reproduced in review:
points = [(0,0,sc1), (0,5000,sc1), (0.5,5000.4,sc1)], reach 1
naive -> 1 pair; binned -> the same pair, duplicated
Not reachable from the hero canvas (its coordinates and scales keep the grid
tiny), but the file claims equivalence for all inputs, and "not reachable
today" is not a property an exported helper should rely on. Now a
Map-of-columns, which has no bound to blow. Same speed: 0.356ms -> 0.100ms,
3.5x, unchanged from the packed version. The repro is now a regression test.
The nodeById comment claimed memoising would trade one lint violation for
another. Measured during review: `useMemo` reports the SAME react-hooks/refs
count, so that reasoning was simply wrong. The real reason not to memoise is
that `simNodes` is a ref's array mutated in place — its identity would not
change when its contents do, which makes it an unsound dependency. Comment
now says that instead.
part of #111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#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
CollaboratorAuthor

⚠️ This overlaps PR #490 almost entirely — do not merge either without a decision

I did not know #490 existed when I opened this; my 5-agent review found it. @Jose-Gael-Cruz-Lopez opened #490 at 02:38Z against issue #111, ~50 minutes before this one, from the same base (432c207). The two PRs touch nine of the same files, including all three hot spots. Whichever merges first leaves the other with a large, error-prone conflict across page.tsx, AtmosphericBackdrop.tsx and KnowledgeGraph2D.tsx.

Posting an honest comparison rather than arguing for mine.

#490#492 (this)
hero link passaxis-reject + squared distance — still O(N²), cheaper constantspatial binning, sub-quadratic, extracted + equivalence-tested
KnowledgeGraph2D ticksdirect DOM writes, zero React work per tickrAF-coalesced forceRerender — React still reconciles once per frame
backdropsprites + 30fps + pauses on visibilitychangesprites + 30fps, no visibility pause
HowItWorksnext/dynamicssr: false + placeholdernext/dynamic, SSR kept
Study.tsxmotion subtree extracted to StudyMotion.tsxleft alone deliberately
suppressions baselinepruned 13 → 12grown 13 → 14
local E2E lanecould not run (machine unprovisioned)ran green, repeatedly

Where #490 is better: the direct-DOM-write graph is the deeper fix and is what the issue's own proposal recommended; the visibilitychange pause is a real win this PR lacks; extracting StudyMotion is more than I did; and pruning the suppressions baseline beats growing it.

Where this PR is better: the link pass is asymptotically better, not just cheaper per pair, and its equivalence to the naive version is proved by test rather than asserted. ssr: false on HowItWorks removes a landing-page section from the server-rendered HTML — a crawler regression on the one page that needs SEO. And this branch has actually been through the local E2E lane.

Reading #490 also caught a real bug in this PR that my own review missed: my 30fps cap advanced the orb physics once per painted frame, so drift ran at half speed — a visual change, in a PR claiming none. #490 handled that explicitly ("drift speed preserved via elapsed-frame scaling"). Fixed in 463facd.

My suggestion, for whoever decides: take #490 as the base — it is the more thorough pass and the graph fix is better — and port two things onto it from here: the binned lib/linkPairs.ts with its equivalence suite, and dropping ssr: false from the HowItWorks dynamic import. But that is a call for @AndresL230, not for me.

Jose-Gael-Cruz-Lopez added a commit that referenced this pull request Jul 31, 2026
…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>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
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 added a commit that referenced this pull request Jul 31, 2026
* perf(frontend): #111 runtime animations + lazy image loading
- 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>
* fix(perf): #490 review round — sprite memory cap, un-gated Study panes, 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>
* fix(perf): #490 round 2 — revive backdrop on un-reduce, SSR the HowItWorks 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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: AndresL230 <190146319+AndresL230@users.noreply.github.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Superseded by #490, which is merged.

@AndresL230's call was to take Jose's PR as the base, port anything worthwhile from here, verify it, and merge — which is what happened. Recording what carried over and what didn't:

Carried over: the ssr: false → SSR-preserving HowItWorks import (Jose made that change himself in 23b773f, crediting this PR's review), and the intermittent CI fix from this branch's investigation (FlashcardImportModal.test.tsx never unmounting — the reason this very PR failed CI with 416/416 tests passing).

Deliberately not carried over: the spatially-binned lib/linkPairs.ts. I benchmarked it against #490's axis-reject loop rather than assuming, and #490's is faster at the real workload — 0.068ms vs 0.113ms per frame at the actual 226-node cloud. Binning only wins past ~N=2000; below that, Map allocation and per-point sorting cost more than it saves. Identical pairs at every N. The asymptotically better algorithm was the slower one here, so it stayed out.

Also not carried over: the rAF-coalesced tick render. #490 writes simulation positions directly to the DOM, which is strictly better — and is what issue #111's own proposal recommended.

The one thing this PR did that #490 hadn't: run the full local E2E cycle. That has now been run against the merged head — 35/35 journeys, oracles clean.

Opening this without checking for an existing open PR on the same issue was my mistake.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

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

perf(frontend): cut per-frame work in the hero canvas, backdrop and graph (#111) - #492

Closed
AndresL230 wants to merge 3 commits into
mainfrom
feat/111-performance
Closed

perf(frontend): cut per-frame work in the hero canvas, backdrop and graph (#111)#492
AndresL230 wants to merge 3 commits into
mainfrom
feat/111-performance

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Four hot spots, no visual change. Half of #111 was already fixed on main and is untouched here — the landing canvas already respects reduced-motion, per-node shadowBlur is gone (0 hits repo-wide), the floating-cards RAF already resolves its NodeList once, and the spotlight rect is already cached. Those claims in the issue body are stale; this PR is only the genuinely remaining work.

1. Hero canvas link pass — measured 0.353ms → 0.097ms per frame (3.6×)

An uncapped double loop measured all 25,425 pairs of the 226 projected nodes every frame, to draw the ~213 that actually qualify. 99.2% of the distance checks were wasted.

A node can only link within 70 * sc px, so binning into a grid whose cell is the largest such radius means every possible partner sits in the 3×3 block around it and nothing else is ever measured.

Extracted to lib/linkPairs.ts so "identical output" is provable rather than asserted: linkPairs.test.ts compares it to the all-pairs reference across 40 random clouds plus dense, sparse, negative-coordinate, asymmetric and degenerate cases — and checks the emission order matches, because these are translucent overlapping strokes and order decides the composited image. One test also guards against a future "simplification" quietly restoring the double loop.

2. AtmosphericBackdrop — 14 gradients per frame → 14 blits, at half the rate

It rebuilt 14 full-viewport radial gradients every frame at 60fps, app-wide, mounted twice by ShellFrame. Everything a gradient depends on — colour, radius, depth-derived alpha — is fixed when the orb is created; only x/y move.

Each orb is baked once into an offscreen canvas and blitted; the loop is capped at ~30fps (ambient drift moves well under a pixel per frame). Re-bakes only when the device pixel ratio actually changes.

The reduced-motion still-frame path is preserved exactly, including the existing behaviour that switching reduced-motion on mid-session stops the loop.

3. KnowledgeGraph2D — three problems

  • Tick storm..on("tick", forceRerender) reconciled the whole SVG per physics tick. Ticks now coalesce into at most one render per animation frame. This does not touch the deterministic settle: simulation.tick() does not dispatch tick events (only the internal timer does), so the reduced-motion/test-mode path still renders once, synchronously, and stays deterministic.
  • Tooltip churn.setTooltipPos was the unconditional first statement of onPointerMove — moving the mouse over empty canvas re-rendered the entire graph to update a value nothing reads unless hovered is set. Now gated, with the position seeded on pointer-enter so the first frame still lands correctly.
  • O(E·N) lookups. Edge rendering used .find() per endpoint per edge per frame; the link filter did two .some() scans per edge. Both now use a Map/Set.

The offscreen IntersectionObserver pause and unmount cleanup are preserved, and the queued tick render is cancelled on unmount so it can't fire against a dead tree.

4. Images and framer-motion

Intrinsic width/height on the 19 brand-icon sites (a fixed-size asset whose box was only pinned in inline CSS, so nothing reserved it before styles resolved), and decoding="async" on the lazily-loaded ones. next/image deliberately not adopted, per your call.

HowItWorks (~54 motion.*, the landing page's only framer-motion consumer) is now next/dynamic, so the library leaves the critical path for a section below the fold. Notssr: false — the markup still server-renders.

Study.tsx and Calendar.tsx are deliberately left alone. The brief listed them, but both wrap their screen's primary content in the motion element — deferring it would defer the content itself, which is worse than the bundle cost it saves. Calendar.test.tsx's framer-motion mock stays green either way.

One suppression count moves

KnowledgeGraph2Dreact-hooks/refs 13 → 14. Building the id→node Map is one more use of a ref-derived value during render, in a file where that pattern is already baselined 13 times. I verified in isolation that the Map is the sole cause (restoring .find() returns it to 13), and tried three formulations — all cost the same. The trade is one more instance of an existing suppressed pattern in exchange for deleting a per-frame O(E·N) scan.

Gates

  • tsc --noEmit clean · npm run lint 0 errors (35 warnings, one fewer than before) · npx vitest run 59 files, 415 tests
  • Full local e2e cycle — recorded in a comment below

part of #111

Summary by CodeRabbit

  • Performance Improvements

    • Improved responsiveness of the interactive knowledge graph and animated background effects.
    • Optimized homepage canvas link rendering for smoother visual performance.
    • Improved image loading and decoding across chat, reports, settings, and other screens.
    • Added image dimensions to logos to reduce layout shifts during page loading.
  • Tests

    • Added comprehensive coverage for visual link-generation behavior and edge cases.

…raph (#111)
Four hot spots, no visual change. Half of #111 was already fixed on main
(landing canvas reduced-motion, per-node shadowBlur, the floating-cards
NodeList, the cached spotlight rect) and is untouched here.
**Hero canvas link pass — 0.353ms -> 0.097ms per frame (3.6x).**
An uncapped double loop measured all 25,425 pairs of the 226 projected nodes
every frame to draw the ~213 that qualify — 99.2% of the distance checks were
wasted. A node can only link within 70*sc px, so binning into a grid whose
cell is the largest such radius means every possible partner sits in the 3x3
block around it. Extracted to lib/linkPairs.ts so the fast version can be
PROVED equivalent to the obvious one rather than believed to be: the test
compares them across 40 random clouds plus dense, sparse, negative-coordinate
and degenerate cases, and asserts the emission ORDER matches too — these are
translucent overlapping strokes, so order decides the composited image.
**AtmosphericBackdrop — 14 gradients per frame -> 14 blits, at half the rate.**
It rebuilt 14 full-viewport radial gradients every frame at 60fps, app-wide,
mounted twice by ShellFrame. Everything a gradient depends on (colour, radius,
depth-derived alpha) is fixed when the orb is created; only x/y move. Each orb
is now baked once into an offscreen canvas and blitted, and the loop is capped
at ~30fps — ambient drift moves well under a pixel per frame. Re-bakes only if
the device pixel ratio changes. The reduced-motion still-frame path is
preserved exactly, including the behaviour that turning reduced-motion on
mid-session stops the loop.
**KnowledgeGraph2D — three separate problems.**
Ticks now coalesce into at most one React render per animation frame; d3's
timer can fire more than once per frame and each render reconciled the entire
SVG. This does not touch the deterministic settle: simulation.tick() does not
dispatch tick events, so the reduced-motion/test-mode path still renders once,
synchronously. setTooltipPos was the unconditional first statement of
onPointerMove, so moving the mouse across empty canvas re-rendered the whole
graph to update a value nothing reads unless a node is hovered; it is now
gated on `hovered`, with the position seeded on pointer-enter so the first
frame still lands in the right place. Edge rendering and the link filter both
did O(E x N) linear scans; both now use a Map/Set.
**Images.** Intrinsic width/height on the 19 brand-icon sites (a fixed-size
asset whose box was only pinned in inline CSS, so nothing reserved it before
styles resolved), and decoding="async" on the lazily-loaded ones. next/image
is deliberately not adopted — zero usages repo-wide and it needs a Cloudflare
Workers loader of its own.
**framer-motion.** HowItWorks (~54 motion.* elements, the landing page's only
consumer) is now next/dynamic, so the library leaves the critical path for a
section below the fold. NOT `ssr: false` — the markup still server-renders.
Study.tsx and Calendar.tsx are deliberately left alone: both wrap their
screen's PRIMARY content in the motion element, so deferring it would defer
the content itself, which is worse than the bundle cost it saves.
One lint-suppression count moves (KnowledgeGraph2D react-hooks/refs 13 -> 14):
building the id->node Map is one more use of a ref-derived value during render,
in a file where that pattern is already baselined 13 times. Verified in
isolation that the map is the only cause. The trade is one more instance of an
existing suppressed pattern for removing a per-frame O(E x N) scan.
part of #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

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6b16f5bf-7585-4348-9020-48b0593f57a0

📥 Commits

Reviewing files that changed from the base of the PR and between abf1c04 and 463facd.

📒 Files selected for processing (1)
  • frontend/src/components/AtmosphericBackdrop.tsx
📝 Walkthrough

Walkthrough

The PR adds spatial link-pair generation, cached canvas sprites, coalesced knowledge graph renders, dynamic loading for HowItWorks, intrinsic image dimensions, and asynchronous image decoding across frontend surfaces.

Changes

Frontend rendering performance

Layer / File(s)Summary
Spatial link generation and page integration
frontend/src/lib/linkPairs.ts, frontend/src/lib/linkPairs.test.ts, frontend/src/app/(public)/page.tsx
Adds naive and spatially binned link generation. Tests cover equivalence, ordering, edge cases, duplicate prevention, and distance-count reduction. The public page uses the optimized function and dynamically loads HowItWorks.
Cached atmospheric backdrop rendering
frontend/src/components/AtmosphericBackdrop.tsx
Caches orb gradients as offscreen sprites, rebuilds them when orb count or DPR changes, and throttles painting to approximately 30 FPS.
Knowledge graph render scheduling and lookup
frontend/src/components/KnowledgeGraph2D.tsx, frontend/eslint-suppressions.json
Coalesces simulation updates with requestAnimationFrame, cancels pending renders on unmount, replaces repeated scans with Set/Map lookups, and limits tooltip updates to active hovers.
Image dimensions and asynchronous decoding
frontend/src/app/(public)/*, frontend/src/components/{SideNav,SignInModal,TopNav,MarkdownChat,ReportIssueFlow}.tsx, frontend/src/components/screens/*
Adds intrinsic dimensions to Sapling logos and enables asynchronous decoding for content, preview, cosmetic, and chat images.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

Sequence Diagram(s)

sequenceDiagram
participant KnowledgeGraph2D
participant Simulation
participant requestAnimationFrame
participant Canvas
Simulation->>KnowledgeGraph2D: emit simulation tick
KnowledgeGraph2D->>requestAnimationFrame: queue one render
requestAnimationFrame->>KnowledgeGraph2D: execute render callback
KnowledgeGraph2D->>Canvas: draw resolved nodes and edges
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 38.89% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the primary performance changes to the hero canvas, backdrop, and graph.
Description check✅ PassedThe description provides a detailed change summary, rationale, testing results, issue reference, and reviewer notes, despite not using the template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/111-performance

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

❤️ Share

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented 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-staging463facdCommit Preview URL

Branch Preview URL
Jul 31 2026, 03:47 AM

AndresL230and others added 2 commits July 30, 2026 20:43
Two review findings.
The spatial grid packed (gx, gy) into one integer, `((gx + 1024) << 12) |
(gy + 1024)`, on the assumption both stay inside a bound. linkPairs is an
exported general-purpose helper with no such precondition: a small `reach`
makes cells small, the grid coordinates blow past the bound, two different
cells hash together, the same bucket is visited twice inside one 3x3 scan —
and the pair is emitted TWICE. Reproduced in review:
points = [(0,0,sc1), (0,5000,sc1), (0.5,5000.4,sc1)], reach 1
naive -> 1 pair; binned -> the same pair, duplicated
Not reachable from the hero canvas (its coordinates and scales keep the grid
tiny), but the file claims equivalence for all inputs, and "not reachable
today" is not a property an exported helper should rely on. Now a
Map-of-columns, which has no bound to blow. Same speed: 0.356ms -> 0.100ms,
3.5x, unchanged from the packed version. The repro is now a regression test.
The nodeById comment claimed memoising would trade one lint violation for
another. Measured during review: `useMemo` reports the SAME react-hooks/refs
count, so that reasoning was simply wrong. The real reason not to memoise is
that `simNodes` is a ref's array mutated in place — its identity would not
change when its contents do, which makes it an unsound dependency. Comment
now says that instead.
part of #111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#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
CollaboratorAuthor

⚠️ This overlaps PR #490 almost entirely — do not merge either without a decision

I did not know #490 existed when I opened this; my 5-agent review found it. @Jose-Gael-Cruz-Lopez opened #490 at 02:38Z against issue #111, ~50 minutes before this one, from the same base (432c207). The two PRs touch nine of the same files, including all three hot spots. Whichever merges first leaves the other with a large, error-prone conflict across page.tsx, AtmosphericBackdrop.tsx and KnowledgeGraph2D.tsx.

Posting an honest comparison rather than arguing for mine.

#490#492 (this)
hero link passaxis-reject + squared distance — still O(N²), cheaper constantspatial binning, sub-quadratic, extracted + equivalence-tested
KnowledgeGraph2D ticksdirect DOM writes, zero React work per tickrAF-coalesced forceRerender — React still reconciles once per frame
backdropsprites + 30fps + pauses on visibilitychangesprites + 30fps, no visibility pause
HowItWorksnext/dynamicssr: false + placeholdernext/dynamic, SSR kept
Study.tsxmotion subtree extracted to StudyMotion.tsxleft alone deliberately
suppressions baselinepruned 13 → 12grown 13 → 14
local E2E lanecould not run (machine unprovisioned)ran green, repeatedly

Where #490 is better: the direct-DOM-write graph is the deeper fix and is what the issue's own proposal recommended; the visibilitychange pause is a real win this PR lacks; extracting StudyMotion is more than I did; and pruning the suppressions baseline beats growing it.

Where this PR is better: the link pass is asymptotically better, not just cheaper per pair, and its equivalence to the naive version is proved by test rather than asserted. ssr: false on HowItWorks removes a landing-page section from the server-rendered HTML — a crawler regression on the one page that needs SEO. And this branch has actually been through the local E2E lane.

Reading #490 also caught a real bug in this PR that my own review missed: my 30fps cap advanced the orb physics once per painted frame, so drift ran at half speed — a visual change, in a PR claiming none. #490 handled that explicitly ("drift speed preserved via elapsed-frame scaling"). Fixed in 463facd.

My suggestion, for whoever decides: take #490 as the base — it is the more thorough pass and the graph fix is better — and port two things onto it from here: the binned lib/linkPairs.ts with its equivalence suite, and dropping ssr: false from the HowItWorks dynamic import. But that is a call for @AndresL230, not for me.

Jose-Gael-Cruz-Lopez added a commit that referenced this pull request Jul 31, 2026
…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>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
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 added a commit that referenced this pull request Jul 31, 2026
* perf(frontend): #111 runtime animations + lazy image loading
- 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>
* fix(perf): #490 review round — sprite memory cap, un-gated Study panes, 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>
* fix(perf): #490 round 2 — revive backdrop on un-reduce, SSR the HowItWorks 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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: AndresL230 <190146319+AndresL230@users.noreply.github.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Superseded by #490, which is merged.

@AndresL230's call was to take Jose's PR as the base, port anything worthwhile from here, verify it, and merge — which is what happened. Recording what carried over and what didn't:

Carried over: the ssr: false → SSR-preserving HowItWorks import (Jose made that change himself in 23b773f, crediting this PR's review), and the intermittent CI fix from this branch's investigation (FlashcardImportModal.test.tsx never unmounting — the reason this very PR failed CI with 416/416 tests passing).

Deliberately not carried over: the spatially-binned lib/linkPairs.ts. I benchmarked it against #490's axis-reject loop rather than assuming, and #490's is faster at the real workload — 0.068ms vs 0.113ms per frame at the actual 226-node cloud. Binning only wins past ~N=2000; below that, Map allocation and per-point sorting cost more than it saves. Identical pairs at every N. The asymptotically better algorithm was the slower one here, so it stayed out.

Also not carried over: the rAF-coalesced tick render. #490 writes simulation positions directly to the DOM, which is strictly better — and is what issue #111's own proposal recommended.

The one thing this PR did that #490 hadn't: run the full local E2E cycle. That has now been run against the merged head — 35/35 journeys, oracles clean.

Opening this without checking for an existing open PR on the same issue was my mistake.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

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

perf(frontend): cut per-frame work in the hero canvas, backdrop and graph (#111) - #492

Closed
AndresL230 wants to merge 3 commits into
mainfrom
feat/111-performance
Closed

perf(frontend): cut per-frame work in the hero canvas, backdrop and graph (#111)#492
AndresL230 wants to merge 3 commits into
mainfrom
feat/111-performance

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Four hot spots, no visual change. Half of #111 was already fixed on main and is untouched here — the landing canvas already respects reduced-motion, per-node shadowBlur is gone (0 hits repo-wide), the floating-cards RAF already resolves its NodeList once, and the spotlight rect is already cached. Those claims in the issue body are stale; this PR is only the genuinely remaining work.

1. Hero canvas link pass — measured 0.353ms → 0.097ms per frame (3.6×)

An uncapped double loop measured all 25,425 pairs of the 226 projected nodes every frame, to draw the ~213 that actually qualify. 99.2% of the distance checks were wasted.

A node can only link within 70 * sc px, so binning into a grid whose cell is the largest such radius means every possible partner sits in the 3×3 block around it and nothing else is ever measured.

Extracted to lib/linkPairs.ts so "identical output" is provable rather than asserted: linkPairs.test.ts compares it to the all-pairs reference across 40 random clouds plus dense, sparse, negative-coordinate, asymmetric and degenerate cases — and checks the emission order matches, because these are translucent overlapping strokes and order decides the composited image. One test also guards against a future "simplification" quietly restoring the double loop.

2. AtmosphericBackdrop — 14 gradients per frame → 14 blits, at half the rate

It rebuilt 14 full-viewport radial gradients every frame at 60fps, app-wide, mounted twice by ShellFrame. Everything a gradient depends on — colour, radius, depth-derived alpha — is fixed when the orb is created; only x/y move.

Each orb is baked once into an offscreen canvas and blitted; the loop is capped at ~30fps (ambient drift moves well under a pixel per frame). Re-bakes only when the device pixel ratio actually changes.

The reduced-motion still-frame path is preserved exactly, including the existing behaviour that switching reduced-motion on mid-session stops the loop.

3. KnowledgeGraph2D — three problems

  • Tick storm..on("tick", forceRerender) reconciled the whole SVG per physics tick. Ticks now coalesce into at most one render per animation frame. This does not touch the deterministic settle: simulation.tick() does not dispatch tick events (only the internal timer does), so the reduced-motion/test-mode path still renders once, synchronously, and stays deterministic.
  • Tooltip churn.setTooltipPos was the unconditional first statement of onPointerMove — moving the mouse over empty canvas re-rendered the entire graph to update a value nothing reads unless hovered is set. Now gated, with the position seeded on pointer-enter so the first frame still lands correctly.
  • O(E·N) lookups. Edge rendering used .find() per endpoint per edge per frame; the link filter did two .some() scans per edge. Both now use a Map/Set.

The offscreen IntersectionObserver pause and unmount cleanup are preserved, and the queued tick render is cancelled on unmount so it can't fire against a dead tree.

4. Images and framer-motion

Intrinsic width/height on the 19 brand-icon sites (a fixed-size asset whose box was only pinned in inline CSS, so nothing reserved it before styles resolved), and decoding="async" on the lazily-loaded ones. next/image deliberately not adopted, per your call.

HowItWorks (~54 motion.*, the landing page's only framer-motion consumer) is now next/dynamic, so the library leaves the critical path for a section below the fold. Notssr: false — the markup still server-renders.

Study.tsx and Calendar.tsx are deliberately left alone. The brief listed them, but both wrap their screen's primary content in the motion element — deferring it would defer the content itself, which is worse than the bundle cost it saves. Calendar.test.tsx's framer-motion mock stays green either way.

One suppression count moves

KnowledgeGraph2Dreact-hooks/refs 13 → 14. Building the id→node Map is one more use of a ref-derived value during render, in a file where that pattern is already baselined 13 times. I verified in isolation that the Map is the sole cause (restoring .find() returns it to 13), and tried three formulations — all cost the same. The trade is one more instance of an existing suppressed pattern in exchange for deleting a per-frame O(E·N) scan.

Gates

  • tsc --noEmit clean · npm run lint 0 errors (35 warnings, one fewer than before) · npx vitest run 59 files, 415 tests
  • Full local e2e cycle — recorded in a comment below

part of #111

Summary by CodeRabbit

  • Performance Improvements

    • Improved responsiveness of the interactive knowledge graph and animated background effects.
    • Optimized homepage canvas link rendering for smoother visual performance.
    • Improved image loading and decoding across chat, reports, settings, and other screens.
    • Added image dimensions to logos to reduce layout shifts during page loading.
  • Tests

    • Added comprehensive coverage for visual link-generation behavior and edge cases.

…raph (#111)
Four hot spots, no visual change. Half of #111 was already fixed on main
(landing canvas reduced-motion, per-node shadowBlur, the floating-cards
NodeList, the cached spotlight rect) and is untouched here.
**Hero canvas link pass — 0.353ms -> 0.097ms per frame (3.6x).**
An uncapped double loop measured all 25,425 pairs of the 226 projected nodes
every frame to draw the ~213 that qualify — 99.2% of the distance checks were
wasted. A node can only link within 70*sc px, so binning into a grid whose
cell is the largest such radius means every possible partner sits in the 3x3
block around it. Extracted to lib/linkPairs.ts so the fast version can be
PROVED equivalent to the obvious one rather than believed to be: the test
compares them across 40 random clouds plus dense, sparse, negative-coordinate
and degenerate cases, and asserts the emission ORDER matches too — these are
translucent overlapping strokes, so order decides the composited image.
**AtmosphericBackdrop — 14 gradients per frame -> 14 blits, at half the rate.**
It rebuilt 14 full-viewport radial gradients every frame at 60fps, app-wide,
mounted twice by ShellFrame. Everything a gradient depends on (colour, radius,
depth-derived alpha) is fixed when the orb is created; only x/y move. Each orb
is now baked once into an offscreen canvas and blitted, and the loop is capped
at ~30fps — ambient drift moves well under a pixel per frame. Re-bakes only if
the device pixel ratio changes. The reduced-motion still-frame path is
preserved exactly, including the behaviour that turning reduced-motion on
mid-session stops the loop.
**KnowledgeGraph2D — three separate problems.**
Ticks now coalesce into at most one React render per animation frame; d3's
timer can fire more than once per frame and each render reconciled the entire
SVG. This does not touch the deterministic settle: simulation.tick() does not
dispatch tick events, so the reduced-motion/test-mode path still renders once,
synchronously. setTooltipPos was the unconditional first statement of
onPointerMove, so moving the mouse across empty canvas re-rendered the whole
graph to update a value nothing reads unless a node is hovered; it is now
gated on `hovered`, with the position seeded on pointer-enter so the first
frame still lands in the right place. Edge rendering and the link filter both
did O(E x N) linear scans; both now use a Map/Set.
**Images.** Intrinsic width/height on the 19 brand-icon sites (a fixed-size
asset whose box was only pinned in inline CSS, so nothing reserved it before
styles resolved), and decoding="async" on the lazily-loaded ones. next/image
is deliberately not adopted — zero usages repo-wide and it needs a Cloudflare
Workers loader of its own.
**framer-motion.** HowItWorks (~54 motion.* elements, the landing page's only
consumer) is now next/dynamic, so the library leaves the critical path for a
section below the fold. NOT `ssr: false` — the markup still server-renders.
Study.tsx and Calendar.tsx are deliberately left alone: both wrap their
screen's PRIMARY content in the motion element, so deferring it would defer
the content itself, which is worse than the bundle cost it saves.
One lint-suppression count moves (KnowledgeGraph2D react-hooks/refs 13 -> 14):
building the id->node Map is one more use of a ref-derived value during render,
in a file where that pattern is already baselined 13 times. Verified in
isolation that the map is the only cause. The trade is one more instance of an
existing suppressed pattern for removing a per-frame O(E x N) scan.
part of #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

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6b16f5bf-7585-4348-9020-48b0593f57a0

📥 Commits

Reviewing files that changed from the base of the PR and between abf1c04 and 463facd.

📒 Files selected for processing (1)
  • frontend/src/components/AtmosphericBackdrop.tsx
📝 Walkthrough

Walkthrough

The PR adds spatial link-pair generation, cached canvas sprites, coalesced knowledge graph renders, dynamic loading for HowItWorks, intrinsic image dimensions, and asynchronous image decoding across frontend surfaces.

Changes

Frontend rendering performance

Layer / File(s)Summary
Spatial link generation and page integration
frontend/src/lib/linkPairs.ts, frontend/src/lib/linkPairs.test.ts, frontend/src/app/(public)/page.tsx
Adds naive and spatially binned link generation. Tests cover equivalence, ordering, edge cases, duplicate prevention, and distance-count reduction. The public page uses the optimized function and dynamically loads HowItWorks.
Cached atmospheric backdrop rendering
frontend/src/components/AtmosphericBackdrop.tsx
Caches orb gradients as offscreen sprites, rebuilds them when orb count or DPR changes, and throttles painting to approximately 30 FPS.
Knowledge graph render scheduling and lookup
frontend/src/components/KnowledgeGraph2D.tsx, frontend/eslint-suppressions.json
Coalesces simulation updates with requestAnimationFrame, cancels pending renders on unmount, replaces repeated scans with Set/Map lookups, and limits tooltip updates to active hovers.
Image dimensions and asynchronous decoding
frontend/src/app/(public)/*, frontend/src/components/{SideNav,SignInModal,TopNav,MarkdownChat,ReportIssueFlow}.tsx, frontend/src/components/screens/*
Adds intrinsic dimensions to Sapling logos and enables asynchronous decoding for content, preview, cosmetic, and chat images.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

Sequence Diagram(s)

sequenceDiagram
participant KnowledgeGraph2D
participant Simulation
participant requestAnimationFrame
participant Canvas
Simulation->>KnowledgeGraph2D: emit simulation tick
KnowledgeGraph2D->>requestAnimationFrame: queue one render
requestAnimationFrame->>KnowledgeGraph2D: execute render callback
KnowledgeGraph2D->>Canvas: draw resolved nodes and edges
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 38.89% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the primary performance changes to the hero canvas, backdrop, and graph.
Description check✅ PassedThe description provides a detailed change summary, rationale, testing results, issue reference, and reviewer notes, despite not using the template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/111-performance

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

❤️ Share

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented 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-staging463facdCommit Preview URL

Branch Preview URL
Jul 31 2026, 03:47 AM

AndresL230and others added 2 commits July 30, 2026 20:43
Two review findings.
The spatial grid packed (gx, gy) into one integer, `((gx + 1024) << 12) |
(gy + 1024)`, on the assumption both stay inside a bound. linkPairs is an
exported general-purpose helper with no such precondition: a small `reach`
makes cells small, the grid coordinates blow past the bound, two different
cells hash together, the same bucket is visited twice inside one 3x3 scan —
and the pair is emitted TWICE. Reproduced in review:
points = [(0,0,sc1), (0,5000,sc1), (0.5,5000.4,sc1)], reach 1
naive -> 1 pair; binned -> the same pair, duplicated
Not reachable from the hero canvas (its coordinates and scales keep the grid
tiny), but the file claims equivalence for all inputs, and "not reachable
today" is not a property an exported helper should rely on. Now a
Map-of-columns, which has no bound to blow. Same speed: 0.356ms -> 0.100ms,
3.5x, unchanged from the packed version. The repro is now a regression test.
The nodeById comment claimed memoising would trade one lint violation for
another. Measured during review: `useMemo` reports the SAME react-hooks/refs
count, so that reasoning was simply wrong. The real reason not to memoise is
that `simNodes` is a ref's array mutated in place — its identity would not
change when its contents do, which makes it an unsound dependency. Comment
now says that instead.
part of #111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#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
CollaboratorAuthor

⚠️ This overlaps PR #490 almost entirely — do not merge either without a decision

I did not know #490 existed when I opened this; my 5-agent review found it. @Jose-Gael-Cruz-Lopez opened #490 at 02:38Z against issue #111, ~50 minutes before this one, from the same base (432c207). The two PRs touch nine of the same files, including all three hot spots. Whichever merges first leaves the other with a large, error-prone conflict across page.tsx, AtmosphericBackdrop.tsx and KnowledgeGraph2D.tsx.

Posting an honest comparison rather than arguing for mine.

#490#492 (this)
hero link passaxis-reject + squared distance — still O(N²), cheaper constantspatial binning, sub-quadratic, extracted + equivalence-tested
KnowledgeGraph2D ticksdirect DOM writes, zero React work per tickrAF-coalesced forceRerender — React still reconciles once per frame
backdropsprites + 30fps + pauses on visibilitychangesprites + 30fps, no visibility pause
HowItWorksnext/dynamicssr: false + placeholdernext/dynamic, SSR kept
Study.tsxmotion subtree extracted to StudyMotion.tsxleft alone deliberately
suppressions baselinepruned 13 → 12grown 13 → 14
local E2E lanecould not run (machine unprovisioned)ran green, repeatedly

Where #490 is better: the direct-DOM-write graph is the deeper fix and is what the issue's own proposal recommended; the visibilitychange pause is a real win this PR lacks; extracting StudyMotion is more than I did; and pruning the suppressions baseline beats growing it.

Where this PR is better: the link pass is asymptotically better, not just cheaper per pair, and its equivalence to the naive version is proved by test rather than asserted. ssr: false on HowItWorks removes a landing-page section from the server-rendered HTML — a crawler regression on the one page that needs SEO. And this branch has actually been through the local E2E lane.

Reading #490 also caught a real bug in this PR that my own review missed: my 30fps cap advanced the orb physics once per painted frame, so drift ran at half speed — a visual change, in a PR claiming none. #490 handled that explicitly ("drift speed preserved via elapsed-frame scaling"). Fixed in 463facd.

My suggestion, for whoever decides: take #490 as the base — it is the more thorough pass and the graph fix is better — and port two things onto it from here: the binned lib/linkPairs.ts with its equivalence suite, and dropping ssr: false from the HowItWorks dynamic import. But that is a call for @AndresL230, not for me.

Jose-Gael-Cruz-Lopez added a commit that referenced this pull request Jul 31, 2026
…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>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
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 added a commit that referenced this pull request Jul 31, 2026
* perf(frontend): #111 runtime animations + lazy image loading
- 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>
* fix(perf): #490 review round — sprite memory cap, un-gated Study panes, 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>
* fix(perf): #490 round 2 — revive backdrop on un-reduce, SSR the HowItWorks 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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: AndresL230 <190146319+AndresL230@users.noreply.github.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Superseded by #490, which is merged.

@AndresL230's call was to take Jose's PR as the base, port anything worthwhile from here, verify it, and merge — which is what happened. Recording what carried over and what didn't:

Carried over: the ssr: false → SSR-preserving HowItWorks import (Jose made that change himself in 23b773f, crediting this PR's review), and the intermittent CI fix from this branch's investigation (FlashcardImportModal.test.tsx never unmounting — the reason this very PR failed CI with 416/416 tests passing).

Deliberately not carried over: the spatially-binned lib/linkPairs.ts. I benchmarked it against #490's axis-reject loop rather than assuming, and #490's is faster at the real workload — 0.068ms vs 0.113ms per frame at the actual 226-node cloud. Binning only wins past ~N=2000; below that, Map allocation and per-point sorting cost more than it saves. Identical pairs at every N. The asymptotically better algorithm was the slower one here, so it stayed out.

Also not carried over: the rAF-coalesced tick render. #490 writes simulation positions directly to the DOM, which is strictly better — and is what issue #111's own proposal recommended.

The one thing this PR did that #490 hadn't: run the full local E2E cycle. That has now been run against the merged head — 35/35 journeys, oracles clean.

Opening this without checking for an existing open PR on the same issue was my mistake.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

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

perf(frontend): cut per-frame work in the hero canvas, backdrop and graph (#111) - #492

Closed
AndresL230 wants to merge 3 commits into
mainfrom
feat/111-performance
Closed

perf(frontend): cut per-frame work in the hero canvas, backdrop and graph (#111)#492
AndresL230 wants to merge 3 commits into
mainfrom
feat/111-performance

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Four hot spots, no visual change. Half of #111 was already fixed on main and is untouched here — the landing canvas already respects reduced-motion, per-node shadowBlur is gone (0 hits repo-wide), the floating-cards RAF already resolves its NodeList once, and the spotlight rect is already cached. Those claims in the issue body are stale; this PR is only the genuinely remaining work.

1. Hero canvas link pass — measured 0.353ms → 0.097ms per frame (3.6×)

An uncapped double loop measured all 25,425 pairs of the 226 projected nodes every frame, to draw the ~213 that actually qualify. 99.2% of the distance checks were wasted.

A node can only link within 70 * sc px, so binning into a grid whose cell is the largest such radius means every possible partner sits in the 3×3 block around it and nothing else is ever measured.

Extracted to lib/linkPairs.ts so "identical output" is provable rather than asserted: linkPairs.test.ts compares it to the all-pairs reference across 40 random clouds plus dense, sparse, negative-coordinate, asymmetric and degenerate cases — and checks the emission order matches, because these are translucent overlapping strokes and order decides the composited image. One test also guards against a future "simplification" quietly restoring the double loop.

2. AtmosphericBackdrop — 14 gradients per frame → 14 blits, at half the rate

It rebuilt 14 full-viewport radial gradients every frame at 60fps, app-wide, mounted twice by ShellFrame. Everything a gradient depends on — colour, radius, depth-derived alpha — is fixed when the orb is created; only x/y move.

Each orb is baked once into an offscreen canvas and blitted; the loop is capped at ~30fps (ambient drift moves well under a pixel per frame). Re-bakes only when the device pixel ratio actually changes.

The reduced-motion still-frame path is preserved exactly, including the existing behaviour that switching reduced-motion on mid-session stops the loop.

3. KnowledgeGraph2D — three problems

  • Tick storm..on("tick", forceRerender) reconciled the whole SVG per physics tick. Ticks now coalesce into at most one render per animation frame. This does not touch the deterministic settle: simulation.tick() does not dispatch tick events (only the internal timer does), so the reduced-motion/test-mode path still renders once, synchronously, and stays deterministic.
  • Tooltip churn.setTooltipPos was the unconditional first statement of onPointerMove — moving the mouse over empty canvas re-rendered the entire graph to update a value nothing reads unless hovered is set. Now gated, with the position seeded on pointer-enter so the first frame still lands correctly.
  • O(E·N) lookups. Edge rendering used .find() per endpoint per edge per frame; the link filter did two .some() scans per edge. Both now use a Map/Set.

The offscreen IntersectionObserver pause and unmount cleanup are preserved, and the queued tick render is cancelled on unmount so it can't fire against a dead tree.

4. Images and framer-motion

Intrinsic width/height on the 19 brand-icon sites (a fixed-size asset whose box was only pinned in inline CSS, so nothing reserved it before styles resolved), and decoding="async" on the lazily-loaded ones. next/image deliberately not adopted, per your call.

HowItWorks (~54 motion.*, the landing page's only framer-motion consumer) is now next/dynamic, so the library leaves the critical path for a section below the fold. Notssr: false — the markup still server-renders.

Study.tsx and Calendar.tsx are deliberately left alone. The brief listed them, but both wrap their screen's primary content in the motion element — deferring it would defer the content itself, which is worse than the bundle cost it saves. Calendar.test.tsx's framer-motion mock stays green either way.

One suppression count moves

KnowledgeGraph2Dreact-hooks/refs 13 → 14. Building the id→node Map is one more use of a ref-derived value during render, in a file where that pattern is already baselined 13 times. I verified in isolation that the Map is the sole cause (restoring .find() returns it to 13), and tried three formulations — all cost the same. The trade is one more instance of an existing suppressed pattern in exchange for deleting a per-frame O(E·N) scan.

Gates

  • tsc --noEmit clean · npm run lint 0 errors (35 warnings, one fewer than before) · npx vitest run 59 files, 415 tests
  • Full local e2e cycle — recorded in a comment below

part of #111

Summary by CodeRabbit

  • Performance Improvements

    • Improved responsiveness of the interactive knowledge graph and animated background effects.
    • Optimized homepage canvas link rendering for smoother visual performance.
    • Improved image loading and decoding across chat, reports, settings, and other screens.
    • Added image dimensions to logos to reduce layout shifts during page loading.
  • Tests

    • Added comprehensive coverage for visual link-generation behavior and edge cases.

…raph (#111)
Four hot spots, no visual change. Half of #111 was already fixed on main
(landing canvas reduced-motion, per-node shadowBlur, the floating-cards
NodeList, the cached spotlight rect) and is untouched here.
**Hero canvas link pass — 0.353ms -> 0.097ms per frame (3.6x).**
An uncapped double loop measured all 25,425 pairs of the 226 projected nodes
every frame to draw the ~213 that qualify — 99.2% of the distance checks were
wasted. A node can only link within 70*sc px, so binning into a grid whose
cell is the largest such radius means every possible partner sits in the 3x3
block around it. Extracted to lib/linkPairs.ts so the fast version can be
PROVED equivalent to the obvious one rather than believed to be: the test
compares them across 40 random clouds plus dense, sparse, negative-coordinate
and degenerate cases, and asserts the emission ORDER matches too — these are
translucent overlapping strokes, so order decides the composited image.
**AtmosphericBackdrop — 14 gradients per frame -> 14 blits, at half the rate.**
It rebuilt 14 full-viewport radial gradients every frame at 60fps, app-wide,
mounted twice by ShellFrame. Everything a gradient depends on (colour, radius,
depth-derived alpha) is fixed when the orb is created; only x/y move. Each orb
is now baked once into an offscreen canvas and blitted, and the loop is capped
at ~30fps — ambient drift moves well under a pixel per frame. Re-bakes only if
the device pixel ratio changes. The reduced-motion still-frame path is
preserved exactly, including the behaviour that turning reduced-motion on
mid-session stops the loop.
**KnowledgeGraph2D — three separate problems.**
Ticks now coalesce into at most one React render per animation frame; d3's
timer can fire more than once per frame and each render reconciled the entire
SVG. This does not touch the deterministic settle: simulation.tick() does not
dispatch tick events, so the reduced-motion/test-mode path still renders once,
synchronously. setTooltipPos was the unconditional first statement of
onPointerMove, so moving the mouse across empty canvas re-rendered the whole
graph to update a value nothing reads unless a node is hovered; it is now
gated on `hovered`, with the position seeded on pointer-enter so the first
frame still lands in the right place. Edge rendering and the link filter both
did O(E x N) linear scans; both now use a Map/Set.
**Images.** Intrinsic width/height on the 19 brand-icon sites (a fixed-size
asset whose box was only pinned in inline CSS, so nothing reserved it before
styles resolved), and decoding="async" on the lazily-loaded ones. next/image
is deliberately not adopted — zero usages repo-wide and it needs a Cloudflare
Workers loader of its own.
**framer-motion.** HowItWorks (~54 motion.* elements, the landing page's only
consumer) is now next/dynamic, so the library leaves the critical path for a
section below the fold. NOT `ssr: false` — the markup still server-renders.
Study.tsx and Calendar.tsx are deliberately left alone: both wrap their
screen's PRIMARY content in the motion element, so deferring it would defer
the content itself, which is worse than the bundle cost it saves.
One lint-suppression count moves (KnowledgeGraph2D react-hooks/refs 13 -> 14):
building the id->node Map is one more use of a ref-derived value during render,
in a file where that pattern is already baselined 13 times. Verified in
isolation that the map is the only cause. The trade is one more instance of an
existing suppressed pattern for removing a per-frame O(E x N) scan.
part of #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

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6b16f5bf-7585-4348-9020-48b0593f57a0

📥 Commits

Reviewing files that changed from the base of the PR and between abf1c04 and 463facd.

📒 Files selected for processing (1)
  • frontend/src/components/AtmosphericBackdrop.tsx
📝 Walkthrough

Walkthrough

The PR adds spatial link-pair generation, cached canvas sprites, coalesced knowledge graph renders, dynamic loading for HowItWorks, intrinsic image dimensions, and asynchronous image decoding across frontend surfaces.

Changes

Frontend rendering performance

Layer / File(s)Summary
Spatial link generation and page integration
frontend/src/lib/linkPairs.ts, frontend/src/lib/linkPairs.test.ts, frontend/src/app/(public)/page.tsx
Adds naive and spatially binned link generation. Tests cover equivalence, ordering, edge cases, duplicate prevention, and distance-count reduction. The public page uses the optimized function and dynamically loads HowItWorks.
Cached atmospheric backdrop rendering
frontend/src/components/AtmosphericBackdrop.tsx
Caches orb gradients as offscreen sprites, rebuilds them when orb count or DPR changes, and throttles painting to approximately 30 FPS.
Knowledge graph render scheduling and lookup
frontend/src/components/KnowledgeGraph2D.tsx, frontend/eslint-suppressions.json
Coalesces simulation updates with requestAnimationFrame, cancels pending renders on unmount, replaces repeated scans with Set/Map lookups, and limits tooltip updates to active hovers.
Image dimensions and asynchronous decoding
frontend/src/app/(public)/*, frontend/src/components/{SideNav,SignInModal,TopNav,MarkdownChat,ReportIssueFlow}.tsx, frontend/src/components/screens/*
Adds intrinsic dimensions to Sapling logos and enables asynchronous decoding for content, preview, cosmetic, and chat images.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

Sequence Diagram(s)

sequenceDiagram
participant KnowledgeGraph2D
participant Simulation
participant requestAnimationFrame
participant Canvas
Simulation->>KnowledgeGraph2D: emit simulation tick
KnowledgeGraph2D->>requestAnimationFrame: queue one render
requestAnimationFrame->>KnowledgeGraph2D: execute render callback
KnowledgeGraph2D->>Canvas: draw resolved nodes and edges
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 38.89% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the primary performance changes to the hero canvas, backdrop, and graph.
Description check✅ PassedThe description provides a detailed change summary, rationale, testing results, issue reference, and reviewer notes, despite not using the template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/111-performance

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

❤️ Share

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented 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-staging463facdCommit Preview URL

Branch Preview URL
Jul 31 2026, 03:47 AM

AndresL230and others added 2 commits July 30, 2026 20:43
Two review findings.
The spatial grid packed (gx, gy) into one integer, `((gx + 1024) << 12) |
(gy + 1024)`, on the assumption both stay inside a bound. linkPairs is an
exported general-purpose helper with no such precondition: a small `reach`
makes cells small, the grid coordinates blow past the bound, two different
cells hash together, the same bucket is visited twice inside one 3x3 scan —
and the pair is emitted TWICE. Reproduced in review:
points = [(0,0,sc1), (0,5000,sc1), (0.5,5000.4,sc1)], reach 1
naive -> 1 pair; binned -> the same pair, duplicated
Not reachable from the hero canvas (its coordinates and scales keep the grid
tiny), but the file claims equivalence for all inputs, and "not reachable
today" is not a property an exported helper should rely on. Now a
Map-of-columns, which has no bound to blow. Same speed: 0.356ms -> 0.100ms,
3.5x, unchanged from the packed version. The repro is now a regression test.
The nodeById comment claimed memoising would trade one lint violation for
another. Measured during review: `useMemo` reports the SAME react-hooks/refs
count, so that reasoning was simply wrong. The real reason not to memoise is
that `simNodes` is a ref's array mutated in place — its identity would not
change when its contents do, which makes it an unsound dependency. Comment
now says that instead.
part of #111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#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
CollaboratorAuthor

⚠️ This overlaps PR #490 almost entirely — do not merge either without a decision

I did not know #490 existed when I opened this; my 5-agent review found it. @Jose-Gael-Cruz-Lopez opened #490 at 02:38Z against issue #111, ~50 minutes before this one, from the same base (432c207). The two PRs touch nine of the same files, including all three hot spots. Whichever merges first leaves the other with a large, error-prone conflict across page.tsx, AtmosphericBackdrop.tsx and KnowledgeGraph2D.tsx.

Posting an honest comparison rather than arguing for mine.

#490#492 (this)
hero link passaxis-reject + squared distance — still O(N²), cheaper constantspatial binning, sub-quadratic, extracted + equivalence-tested
KnowledgeGraph2D ticksdirect DOM writes, zero React work per tickrAF-coalesced forceRerender — React still reconciles once per frame
backdropsprites + 30fps + pauses on visibilitychangesprites + 30fps, no visibility pause
HowItWorksnext/dynamicssr: false + placeholdernext/dynamic, SSR kept
Study.tsxmotion subtree extracted to StudyMotion.tsxleft alone deliberately
suppressions baselinepruned 13 → 12grown 13 → 14
local E2E lanecould not run (machine unprovisioned)ran green, repeatedly

Where #490 is better: the direct-DOM-write graph is the deeper fix and is what the issue's own proposal recommended; the visibilitychange pause is a real win this PR lacks; extracting StudyMotion is more than I did; and pruning the suppressions baseline beats growing it.

Where this PR is better: the link pass is asymptotically better, not just cheaper per pair, and its equivalence to the naive version is proved by test rather than asserted. ssr: false on HowItWorks removes a landing-page section from the server-rendered HTML — a crawler regression on the one page that needs SEO. And this branch has actually been through the local E2E lane.

Reading #490 also caught a real bug in this PR that my own review missed: my 30fps cap advanced the orb physics once per painted frame, so drift ran at half speed — a visual change, in a PR claiming none. #490 handled that explicitly ("drift speed preserved via elapsed-frame scaling"). Fixed in 463facd.

My suggestion, for whoever decides: take #490 as the base — it is the more thorough pass and the graph fix is better — and port two things onto it from here: the binned lib/linkPairs.ts with its equivalence suite, and dropping ssr: false from the HowItWorks dynamic import. But that is a call for @AndresL230, not for me.

Jose-Gael-Cruz-Lopez added a commit that referenced this pull request Jul 31, 2026
…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>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
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 added a commit that referenced this pull request Jul 31, 2026
* perf(frontend): #111 runtime animations + lazy image loading
- 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>
* fix(perf): #490 review round — sprite memory cap, un-gated Study panes, 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>
* fix(perf): #490 round 2 — revive backdrop on un-reduce, SSR the HowItWorks 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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: AndresL230 <190146319+AndresL230@users.noreply.github.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Superseded by #490, which is merged.

@AndresL230's call was to take Jose's PR as the base, port anything worthwhile from here, verify it, and merge — which is what happened. Recording what carried over and what didn't:

Carried over: the ssr: false → SSR-preserving HowItWorks import (Jose made that change himself in 23b773f, crediting this PR's review), and the intermittent CI fix from this branch's investigation (FlashcardImportModal.test.tsx never unmounting — the reason this very PR failed CI with 416/416 tests passing).

Deliberately not carried over: the spatially-binned lib/linkPairs.ts. I benchmarked it against #490's axis-reject loop rather than assuming, and #490's is faster at the real workload — 0.068ms vs 0.113ms per frame at the actual 226-node cloud. Binning only wins past ~N=2000; below that, Map allocation and per-point sorting cost more than it saves. Identical pairs at every N. The asymptotically better algorithm was the slower one here, so it stayed out.

Also not carried over: the rAF-coalesced tick render. #490 writes simulation positions directly to the DOM, which is strictly better — and is what issue #111's own proposal recommended.

The one thing this PR did that #490 hadn't: run the full local E2E cycle. That has now been run against the merged head — 35/35 journeys, oracles clean.

Opening this without checking for an existing open PR on the same issue was my mistake.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

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

perf(frontend): cut per-frame work in the hero canvas, backdrop and graph (#111) - #492

Closed
AndresL230 wants to merge 3 commits into
mainfrom
feat/111-performance
Closed

perf(frontend): cut per-frame work in the hero canvas, backdrop and graph (#111)#492
AndresL230 wants to merge 3 commits into
mainfrom
feat/111-performance

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Four hot spots, no visual change. Half of #111 was already fixed on main and is untouched here — the landing canvas already respects reduced-motion, per-node shadowBlur is gone (0 hits repo-wide), the floating-cards RAF already resolves its NodeList once, and the spotlight rect is already cached. Those claims in the issue body are stale; this PR is only the genuinely remaining work.

1. Hero canvas link pass — measured 0.353ms → 0.097ms per frame (3.6×)

An uncapped double loop measured all 25,425 pairs of the 226 projected nodes every frame, to draw the ~213 that actually qualify. 99.2% of the distance checks were wasted.

A node can only link within 70 * sc px, so binning into a grid whose cell is the largest such radius means every possible partner sits in the 3×3 block around it and nothing else is ever measured.

Extracted to lib/linkPairs.ts so "identical output" is provable rather than asserted: linkPairs.test.ts compares it to the all-pairs reference across 40 random clouds plus dense, sparse, negative-coordinate, asymmetric and degenerate cases — and checks the emission order matches, because these are translucent overlapping strokes and order decides the composited image. One test also guards against a future "simplification" quietly restoring the double loop.

2. AtmosphericBackdrop — 14 gradients per frame → 14 blits, at half the rate

It rebuilt 14 full-viewport radial gradients every frame at 60fps, app-wide, mounted twice by ShellFrame. Everything a gradient depends on — colour, radius, depth-derived alpha — is fixed when the orb is created; only x/y move.

Each orb is baked once into an offscreen canvas and blitted; the loop is capped at ~30fps (ambient drift moves well under a pixel per frame). Re-bakes only when the device pixel ratio actually changes.

The reduced-motion still-frame path is preserved exactly, including the existing behaviour that switching reduced-motion on mid-session stops the loop.

3. KnowledgeGraph2D — three problems

  • Tick storm..on("tick", forceRerender) reconciled the whole SVG per physics tick. Ticks now coalesce into at most one render per animation frame. This does not touch the deterministic settle: simulation.tick() does not dispatch tick events (only the internal timer does), so the reduced-motion/test-mode path still renders once, synchronously, and stays deterministic.
  • Tooltip churn.setTooltipPos was the unconditional first statement of onPointerMove — moving the mouse over empty canvas re-rendered the entire graph to update a value nothing reads unless hovered is set. Now gated, with the position seeded on pointer-enter so the first frame still lands correctly.
  • O(E·N) lookups. Edge rendering used .find() per endpoint per edge per frame; the link filter did two .some() scans per edge. Both now use a Map/Set.

The offscreen IntersectionObserver pause and unmount cleanup are preserved, and the queued tick render is cancelled on unmount so it can't fire against a dead tree.

4. Images and framer-motion

Intrinsic width/height on the 19 brand-icon sites (a fixed-size asset whose box was only pinned in inline CSS, so nothing reserved it before styles resolved), and decoding="async" on the lazily-loaded ones. next/image deliberately not adopted, per your call.

HowItWorks (~54 motion.*, the landing page's only framer-motion consumer) is now next/dynamic, so the library leaves the critical path for a section below the fold. Notssr: false — the markup still server-renders.

Study.tsx and Calendar.tsx are deliberately left alone. The brief listed them, but both wrap their screen's primary content in the motion element — deferring it would defer the content itself, which is worse than the bundle cost it saves. Calendar.test.tsx's framer-motion mock stays green either way.

One suppression count moves

KnowledgeGraph2Dreact-hooks/refs 13 → 14. Building the id→node Map is one more use of a ref-derived value during render, in a file where that pattern is already baselined 13 times. I verified in isolation that the Map is the sole cause (restoring .find() returns it to 13), and tried three formulations — all cost the same. The trade is one more instance of an existing suppressed pattern in exchange for deleting a per-frame O(E·N) scan.

Gates

  • tsc --noEmit clean · npm run lint 0 errors (35 warnings, one fewer than before) · npx vitest run 59 files, 415 tests
  • Full local e2e cycle — recorded in a comment below

part of #111

Summary by CodeRabbit

  • Performance Improvements

    • Improved responsiveness of the interactive knowledge graph and animated background effects.
    • Optimized homepage canvas link rendering for smoother visual performance.
    • Improved image loading and decoding across chat, reports, settings, and other screens.
    • Added image dimensions to logos to reduce layout shifts during page loading.
  • Tests

    • Added comprehensive coverage for visual link-generation behavior and edge cases.

…raph (#111)
Four hot spots, no visual change. Half of #111 was already fixed on main
(landing canvas reduced-motion, per-node shadowBlur, the floating-cards
NodeList, the cached spotlight rect) and is untouched here.
**Hero canvas link pass — 0.353ms -> 0.097ms per frame (3.6x).**
An uncapped double loop measured all 25,425 pairs of the 226 projected nodes
every frame to draw the ~213 that qualify — 99.2% of the distance checks were
wasted. A node can only link within 70*sc px, so binning into a grid whose
cell is the largest such radius means every possible partner sits in the 3x3
block around it. Extracted to lib/linkPairs.ts so the fast version can be
PROVED equivalent to the obvious one rather than believed to be: the test
compares them across 40 random clouds plus dense, sparse, negative-coordinate
and degenerate cases, and asserts the emission ORDER matches too — these are
translucent overlapping strokes, so order decides the composited image.
**AtmosphericBackdrop — 14 gradients per frame -> 14 blits, at half the rate.**
It rebuilt 14 full-viewport radial gradients every frame at 60fps, app-wide,
mounted twice by ShellFrame. Everything a gradient depends on (colour, radius,
depth-derived alpha) is fixed when the orb is created; only x/y move. Each orb
is now baked once into an offscreen canvas and blitted, and the loop is capped
at ~30fps — ambient drift moves well under a pixel per frame. Re-bakes only if
the device pixel ratio changes. The reduced-motion still-frame path is
preserved exactly, including the behaviour that turning reduced-motion on
mid-session stops the loop.
**KnowledgeGraph2D — three separate problems.**
Ticks now coalesce into at most one React render per animation frame; d3's
timer can fire more than once per frame and each render reconciled the entire
SVG. This does not touch the deterministic settle: simulation.tick() does not
dispatch tick events, so the reduced-motion/test-mode path still renders once,
synchronously. setTooltipPos was the unconditional first statement of
onPointerMove, so moving the mouse across empty canvas re-rendered the whole
graph to update a value nothing reads unless a node is hovered; it is now
gated on `hovered`, with the position seeded on pointer-enter so the first
frame still lands in the right place. Edge rendering and the link filter both
did O(E x N) linear scans; both now use a Map/Set.
**Images.** Intrinsic width/height on the 19 brand-icon sites (a fixed-size
asset whose box was only pinned in inline CSS, so nothing reserved it before
styles resolved), and decoding="async" on the lazily-loaded ones. next/image
is deliberately not adopted — zero usages repo-wide and it needs a Cloudflare
Workers loader of its own.
**framer-motion.** HowItWorks (~54 motion.* elements, the landing page's only
consumer) is now next/dynamic, so the library leaves the critical path for a
section below the fold. NOT `ssr: false` — the markup still server-renders.
Study.tsx and Calendar.tsx are deliberately left alone: both wrap their
screen's PRIMARY content in the motion element, so deferring it would defer
the content itself, which is worse than the bundle cost it saves.
One lint-suppression count moves (KnowledgeGraph2D react-hooks/refs 13 -> 14):
building the id->node Map is one more use of a ref-derived value during render,
in a file where that pattern is already baselined 13 times. Verified in
isolation that the map is the only cause. The trade is one more instance of an
existing suppressed pattern for removing a per-frame O(E x N) scan.
part of #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

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6b16f5bf-7585-4348-9020-48b0593f57a0

📥 Commits

Reviewing files that changed from the base of the PR and between abf1c04 and 463facd.

📒 Files selected for processing (1)
  • frontend/src/components/AtmosphericBackdrop.tsx
📝 Walkthrough

Walkthrough

The PR adds spatial link-pair generation, cached canvas sprites, coalesced knowledge graph renders, dynamic loading for HowItWorks, intrinsic image dimensions, and asynchronous image decoding across frontend surfaces.

Changes

Frontend rendering performance

Layer / File(s)Summary
Spatial link generation and page integration
frontend/src/lib/linkPairs.ts, frontend/src/lib/linkPairs.test.ts, frontend/src/app/(public)/page.tsx
Adds naive and spatially binned link generation. Tests cover equivalence, ordering, edge cases, duplicate prevention, and distance-count reduction. The public page uses the optimized function and dynamically loads HowItWorks.
Cached atmospheric backdrop rendering
frontend/src/components/AtmosphericBackdrop.tsx
Caches orb gradients as offscreen sprites, rebuilds them when orb count or DPR changes, and throttles painting to approximately 30 FPS.
Knowledge graph render scheduling and lookup
frontend/src/components/KnowledgeGraph2D.tsx, frontend/eslint-suppressions.json
Coalesces simulation updates with requestAnimationFrame, cancels pending renders on unmount, replaces repeated scans with Set/Map lookups, and limits tooltip updates to active hovers.
Image dimensions and asynchronous decoding
frontend/src/app/(public)/*, frontend/src/components/{SideNav,SignInModal,TopNav,MarkdownChat,ReportIssueFlow}.tsx, frontend/src/components/screens/*
Adds intrinsic dimensions to Sapling logos and enables asynchronous decoding for content, preview, cosmetic, and chat images.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

Sequence Diagram(s)

sequenceDiagram
participant KnowledgeGraph2D
participant Simulation
participant requestAnimationFrame
participant Canvas
Simulation->>KnowledgeGraph2D: emit simulation tick
KnowledgeGraph2D->>requestAnimationFrame: queue one render
requestAnimationFrame->>KnowledgeGraph2D: execute render callback
KnowledgeGraph2D->>Canvas: draw resolved nodes and edges
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 38.89% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the primary performance changes to the hero canvas, backdrop, and graph.
Description check✅ PassedThe description provides a detailed change summary, rationale, testing results, issue reference, and reviewer notes, despite not using the template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/111-performance

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

❤️ Share

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented 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-staging463facdCommit Preview URL

Branch Preview URL
Jul 31 2026, 03:47 AM

AndresL230and others added 2 commits July 30, 2026 20:43
Two review findings.
The spatial grid packed (gx, gy) into one integer, `((gx + 1024) << 12) |
(gy + 1024)`, on the assumption both stay inside a bound. linkPairs is an
exported general-purpose helper with no such precondition: a small `reach`
makes cells small, the grid coordinates blow past the bound, two different
cells hash together, the same bucket is visited twice inside one 3x3 scan —
and the pair is emitted TWICE. Reproduced in review:
points = [(0,0,sc1), (0,5000,sc1), (0.5,5000.4,sc1)], reach 1
naive -> 1 pair; binned -> the same pair, duplicated
Not reachable from the hero canvas (its coordinates and scales keep the grid
tiny), but the file claims equivalence for all inputs, and "not reachable
today" is not a property an exported helper should rely on. Now a
Map-of-columns, which has no bound to blow. Same speed: 0.356ms -> 0.100ms,
3.5x, unchanged from the packed version. The repro is now a regression test.
The nodeById comment claimed memoising would trade one lint violation for
another. Measured during review: `useMemo` reports the SAME react-hooks/refs
count, so that reasoning was simply wrong. The real reason not to memoise is
that `simNodes` is a ref's array mutated in place — its identity would not
change when its contents do, which makes it an unsound dependency. Comment
now says that instead.
part of #111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#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
CollaboratorAuthor

⚠️ This overlaps PR #490 almost entirely — do not merge either without a decision

I did not know #490 existed when I opened this; my 5-agent review found it. @Jose-Gael-Cruz-Lopez opened #490 at 02:38Z against issue #111, ~50 minutes before this one, from the same base (432c207). The two PRs touch nine of the same files, including all three hot spots. Whichever merges first leaves the other with a large, error-prone conflict across page.tsx, AtmosphericBackdrop.tsx and KnowledgeGraph2D.tsx.

Posting an honest comparison rather than arguing for mine.

#490#492 (this)
hero link passaxis-reject + squared distance — still O(N²), cheaper constantspatial binning, sub-quadratic, extracted + equivalence-tested
KnowledgeGraph2D ticksdirect DOM writes, zero React work per tickrAF-coalesced forceRerender — React still reconciles once per frame
backdropsprites + 30fps + pauses on visibilitychangesprites + 30fps, no visibility pause
HowItWorksnext/dynamicssr: false + placeholdernext/dynamic, SSR kept
Study.tsxmotion subtree extracted to StudyMotion.tsxleft alone deliberately
suppressions baselinepruned 13 → 12grown 13 → 14
local E2E lanecould not run (machine unprovisioned)ran green, repeatedly

Where #490 is better: the direct-DOM-write graph is the deeper fix and is what the issue's own proposal recommended; the visibilitychange pause is a real win this PR lacks; extracting StudyMotion is more than I did; and pruning the suppressions baseline beats growing it.

Where this PR is better: the link pass is asymptotically better, not just cheaper per pair, and its equivalence to the naive version is proved by test rather than asserted. ssr: false on HowItWorks removes a landing-page section from the server-rendered HTML — a crawler regression on the one page that needs SEO. And this branch has actually been through the local E2E lane.

Reading #490 also caught a real bug in this PR that my own review missed: my 30fps cap advanced the orb physics once per painted frame, so drift ran at half speed — a visual change, in a PR claiming none. #490 handled that explicitly ("drift speed preserved via elapsed-frame scaling"). Fixed in 463facd.

My suggestion, for whoever decides: take #490 as the base — it is the more thorough pass and the graph fix is better — and port two things onto it from here: the binned lib/linkPairs.ts with its equivalence suite, and dropping ssr: false from the HowItWorks dynamic import. But that is a call for @AndresL230, not for me.

Jose-Gael-Cruz-Lopez added a commit that referenced this pull request Jul 31, 2026
…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>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
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 added a commit that referenced this pull request Jul 31, 2026
* perf(frontend): #111 runtime animations + lazy image loading
- 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>
* fix(perf): #490 review round — sprite memory cap, un-gated Study panes, 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>
* fix(perf): #490 round 2 — revive backdrop on un-reduce, SSR the HowItWorks 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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: AndresL230 <190146319+AndresL230@users.noreply.github.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Superseded by #490, which is merged.

@AndresL230's call was to take Jose's PR as the base, port anything worthwhile from here, verify it, and merge — which is what happened. Recording what carried over and what didn't:

Carried over: the ssr: false → SSR-preserving HowItWorks import (Jose made that change himself in 23b773f, crediting this PR's review), and the intermittent CI fix from this branch's investigation (FlashcardImportModal.test.tsx never unmounting — the reason this very PR failed CI with 416/416 tests passing).

Deliberately not carried over: the spatially-binned lib/linkPairs.ts. I benchmarked it against #490's axis-reject loop rather than assuming, and #490's is faster at the real workload — 0.068ms vs 0.113ms per frame at the actual 226-node cloud. Binning only wins past ~N=2000; below that, Map allocation and per-point sorting cost more than it saves. Identical pairs at every N. The asymptotically better algorithm was the slower one here, so it stayed out.

Also not carried over: the rAF-coalesced tick render. #490 writes simulation positions directly to the DOM, which is strictly better — and is what issue #111's own proposal recommended.

The one thing this PR did that #490 hadn't: run the full local E2E cycle. That has now been run against the merged head — 35/35 journeys, oracles clean.

Opening this without checking for an existing open PR on the same issue was my mistake.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

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

perf(frontend): cut per-frame work in the hero canvas, backdrop and graph (#111) - #492

Closed
AndresL230 wants to merge 3 commits into
mainfrom
feat/111-performance
Closed

perf(frontend): cut per-frame work in the hero canvas, backdrop and graph (#111)#492
AndresL230 wants to merge 3 commits into
mainfrom
feat/111-performance

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Four hot spots, no visual change. Half of #111 was already fixed on main and is untouched here — the landing canvas already respects reduced-motion, per-node shadowBlur is gone (0 hits repo-wide), the floating-cards RAF already resolves its NodeList once, and the spotlight rect is already cached. Those claims in the issue body are stale; this PR is only the genuinely remaining work.

1. Hero canvas link pass — measured 0.353ms → 0.097ms per frame (3.6×)

An uncapped double loop measured all 25,425 pairs of the 226 projected nodes every frame, to draw the ~213 that actually qualify. 99.2% of the distance checks were wasted.

A node can only link within 70 * sc px, so binning into a grid whose cell is the largest such radius means every possible partner sits in the 3×3 block around it and nothing else is ever measured.

Extracted to lib/linkPairs.ts so "identical output" is provable rather than asserted: linkPairs.test.ts compares it to the all-pairs reference across 40 random clouds plus dense, sparse, negative-coordinate, asymmetric and degenerate cases — and checks the emission order matches, because these are translucent overlapping strokes and order decides the composited image. One test also guards against a future "simplification" quietly restoring the double loop.

2. AtmosphericBackdrop — 14 gradients per frame → 14 blits, at half the rate

It rebuilt 14 full-viewport radial gradients every frame at 60fps, app-wide, mounted twice by ShellFrame. Everything a gradient depends on — colour, radius, depth-derived alpha — is fixed when the orb is created; only x/y move.

Each orb is baked once into an offscreen canvas and blitted; the loop is capped at ~30fps (ambient drift moves well under a pixel per frame). Re-bakes only when the device pixel ratio actually changes.

The reduced-motion still-frame path is preserved exactly, including the existing behaviour that switching reduced-motion on mid-session stops the loop.

3. KnowledgeGraph2D — three problems

  • Tick storm..on("tick", forceRerender) reconciled the whole SVG per physics tick. Ticks now coalesce into at most one render per animation frame. This does not touch the deterministic settle: simulation.tick() does not dispatch tick events (only the internal timer does), so the reduced-motion/test-mode path still renders once, synchronously, and stays deterministic.
  • Tooltip churn.setTooltipPos was the unconditional first statement of onPointerMove — moving the mouse over empty canvas re-rendered the entire graph to update a value nothing reads unless hovered is set. Now gated, with the position seeded on pointer-enter so the first frame still lands correctly.
  • O(E·N) lookups. Edge rendering used .find() per endpoint per edge per frame; the link filter did two .some() scans per edge. Both now use a Map/Set.

The offscreen IntersectionObserver pause and unmount cleanup are preserved, and the queued tick render is cancelled on unmount so it can't fire against a dead tree.

4. Images and framer-motion

Intrinsic width/height on the 19 brand-icon sites (a fixed-size asset whose box was only pinned in inline CSS, so nothing reserved it before styles resolved), and decoding="async" on the lazily-loaded ones. next/image deliberately not adopted, per your call.

HowItWorks (~54 motion.*, the landing page's only framer-motion consumer) is now next/dynamic, so the library leaves the critical path for a section below the fold. Notssr: false — the markup still server-renders.

Study.tsx and Calendar.tsx are deliberately left alone. The brief listed them, but both wrap their screen's primary content in the motion element — deferring it would defer the content itself, which is worse than the bundle cost it saves. Calendar.test.tsx's framer-motion mock stays green either way.

One suppression count moves

KnowledgeGraph2Dreact-hooks/refs 13 → 14. Building the id→node Map is one more use of a ref-derived value during render, in a file where that pattern is already baselined 13 times. I verified in isolation that the Map is the sole cause (restoring .find() returns it to 13), and tried three formulations — all cost the same. The trade is one more instance of an existing suppressed pattern in exchange for deleting a per-frame O(E·N) scan.

Gates

  • tsc --noEmit clean · npm run lint 0 errors (35 warnings, one fewer than before) · npx vitest run 59 files, 415 tests
  • Full local e2e cycle — recorded in a comment below

part of #111

Summary by CodeRabbit

  • Performance Improvements

    • Improved responsiveness of the interactive knowledge graph and animated background effects.
    • Optimized homepage canvas link rendering for smoother visual performance.
    • Improved image loading and decoding across chat, reports, settings, and other screens.
    • Added image dimensions to logos to reduce layout shifts during page loading.
  • Tests

    • Added comprehensive coverage for visual link-generation behavior and edge cases.

…raph (#111)
Four hot spots, no visual change. Half of #111 was already fixed on main
(landing canvas reduced-motion, per-node shadowBlur, the floating-cards
NodeList, the cached spotlight rect) and is untouched here.
**Hero canvas link pass — 0.353ms -> 0.097ms per frame (3.6x).**
An uncapped double loop measured all 25,425 pairs of the 226 projected nodes
every frame to draw the ~213 that qualify — 99.2% of the distance checks were
wasted. A node can only link within 70*sc px, so binning into a grid whose
cell is the largest such radius means every possible partner sits in the 3x3
block around it. Extracted to lib/linkPairs.ts so the fast version can be
PROVED equivalent to the obvious one rather than believed to be: the test
compares them across 40 random clouds plus dense, sparse, negative-coordinate
and degenerate cases, and asserts the emission ORDER matches too — these are
translucent overlapping strokes, so order decides the composited image.
**AtmosphericBackdrop — 14 gradients per frame -> 14 blits, at half the rate.**
It rebuilt 14 full-viewport radial gradients every frame at 60fps, app-wide,
mounted twice by ShellFrame. Everything a gradient depends on (colour, radius,
depth-derived alpha) is fixed when the orb is created; only x/y move. Each orb
is now baked once into an offscreen canvas and blitted, and the loop is capped
at ~30fps — ambient drift moves well under a pixel per frame. Re-bakes only if
the device pixel ratio changes. The reduced-motion still-frame path is
preserved exactly, including the behaviour that turning reduced-motion on
mid-session stops the loop.
**KnowledgeGraph2D — three separate problems.**
Ticks now coalesce into at most one React render per animation frame; d3's
timer can fire more than once per frame and each render reconciled the entire
SVG. This does not touch the deterministic settle: simulation.tick() does not
dispatch tick events, so the reduced-motion/test-mode path still renders once,
synchronously. setTooltipPos was the unconditional first statement of
onPointerMove, so moving the mouse across empty canvas re-rendered the whole
graph to update a value nothing reads unless a node is hovered; it is now
gated on `hovered`, with the position seeded on pointer-enter so the first
frame still lands in the right place. Edge rendering and the link filter both
did O(E x N) linear scans; both now use a Map/Set.
**Images.** Intrinsic width/height on the 19 brand-icon sites (a fixed-size
asset whose box was only pinned in inline CSS, so nothing reserved it before
styles resolved), and decoding="async" on the lazily-loaded ones. next/image
is deliberately not adopted — zero usages repo-wide and it needs a Cloudflare
Workers loader of its own.
**framer-motion.** HowItWorks (~54 motion.* elements, the landing page's only
consumer) is now next/dynamic, so the library leaves the critical path for a
section below the fold. NOT `ssr: false` — the markup still server-renders.
Study.tsx and Calendar.tsx are deliberately left alone: both wrap their
screen's PRIMARY content in the motion element, so deferring it would defer
the content itself, which is worse than the bundle cost it saves.
One lint-suppression count moves (KnowledgeGraph2D react-hooks/refs 13 -> 14):
building the id->node Map is one more use of a ref-derived value during render,
in a file where that pattern is already baselined 13 times. Verified in
isolation that the map is the only cause. The trade is one more instance of an
existing suppressed pattern for removing a per-frame O(E x N) scan.
part of #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

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6b16f5bf-7585-4348-9020-48b0593f57a0

📥 Commits

Reviewing files that changed from the base of the PR and between abf1c04 and 463facd.

📒 Files selected for processing (1)
  • frontend/src/components/AtmosphericBackdrop.tsx
📝 Walkthrough

Walkthrough

The PR adds spatial link-pair generation, cached canvas sprites, coalesced knowledge graph renders, dynamic loading for HowItWorks, intrinsic image dimensions, and asynchronous image decoding across frontend surfaces.

Changes

Frontend rendering performance

Layer / File(s)Summary
Spatial link generation and page integration
frontend/src/lib/linkPairs.ts, frontend/src/lib/linkPairs.test.ts, frontend/src/app/(public)/page.tsx
Adds naive and spatially binned link generation. Tests cover equivalence, ordering, edge cases, duplicate prevention, and distance-count reduction. The public page uses the optimized function and dynamically loads HowItWorks.
Cached atmospheric backdrop rendering
frontend/src/components/AtmosphericBackdrop.tsx
Caches orb gradients as offscreen sprites, rebuilds them when orb count or DPR changes, and throttles painting to approximately 30 FPS.
Knowledge graph render scheduling and lookup
frontend/src/components/KnowledgeGraph2D.tsx, frontend/eslint-suppressions.json
Coalesces simulation updates with requestAnimationFrame, cancels pending renders on unmount, replaces repeated scans with Set/Map lookups, and limits tooltip updates to active hovers.
Image dimensions and asynchronous decoding
frontend/src/app/(public)/*, frontend/src/components/{SideNav,SignInModal,TopNav,MarkdownChat,ReportIssueFlow}.tsx, frontend/src/components/screens/*
Adds intrinsic dimensions to Sapling logos and enables asynchronous decoding for content, preview, cosmetic, and chat images.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

Sequence Diagram(s)

sequenceDiagram
participant KnowledgeGraph2D
participant Simulation
participant requestAnimationFrame
participant Canvas
Simulation->>KnowledgeGraph2D: emit simulation tick
KnowledgeGraph2D->>requestAnimationFrame: queue one render
requestAnimationFrame->>KnowledgeGraph2D: execute render callback
KnowledgeGraph2D->>Canvas: draw resolved nodes and edges
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 38.89% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the primary performance changes to the hero canvas, backdrop, and graph.
Description check✅ PassedThe description provides a detailed change summary, rationale, testing results, issue reference, and reviewer notes, despite not using the template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/111-performance

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

❤️ Share

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented 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-staging463facdCommit Preview URL

Branch Preview URL
Jul 31 2026, 03:47 AM

AndresL230and others added 2 commits July 30, 2026 20:43
Two review findings.
The spatial grid packed (gx, gy) into one integer, `((gx + 1024) << 12) |
(gy + 1024)`, on the assumption both stay inside a bound. linkPairs is an
exported general-purpose helper with no such precondition: a small `reach`
makes cells small, the grid coordinates blow past the bound, two different
cells hash together, the same bucket is visited twice inside one 3x3 scan —
and the pair is emitted TWICE. Reproduced in review:
points = [(0,0,sc1), (0,5000,sc1), (0.5,5000.4,sc1)], reach 1
naive -> 1 pair; binned -> the same pair, duplicated
Not reachable from the hero canvas (its coordinates and scales keep the grid
tiny), but the file claims equivalence for all inputs, and "not reachable
today" is not a property an exported helper should rely on. Now a
Map-of-columns, which has no bound to blow. Same speed: 0.356ms -> 0.100ms,
3.5x, unchanged from the packed version. The repro is now a regression test.
The nodeById comment claimed memoising would trade one lint violation for
another. Measured during review: `useMemo` reports the SAME react-hooks/refs
count, so that reasoning was simply wrong. The real reason not to memoise is
that `simNodes` is a ref's array mutated in place — its identity would not
change when its contents do, which makes it an unsound dependency. Comment
now says that instead.
part of #111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#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
CollaboratorAuthor

⚠️ This overlaps PR #490 almost entirely — do not merge either without a decision

I did not know #490 existed when I opened this; my 5-agent review found it. @Jose-Gael-Cruz-Lopez opened #490 at 02:38Z against issue #111, ~50 minutes before this one, from the same base (432c207). The two PRs touch nine of the same files, including all three hot spots. Whichever merges first leaves the other with a large, error-prone conflict across page.tsx, AtmosphericBackdrop.tsx and KnowledgeGraph2D.tsx.

Posting an honest comparison rather than arguing for mine.

#490#492 (this)
hero link passaxis-reject + squared distance — still O(N²), cheaper constantspatial binning, sub-quadratic, extracted + equivalence-tested
KnowledgeGraph2D ticksdirect DOM writes, zero React work per tickrAF-coalesced forceRerender — React still reconciles once per frame
backdropsprites + 30fps + pauses on visibilitychangesprites + 30fps, no visibility pause
HowItWorksnext/dynamicssr: false + placeholdernext/dynamic, SSR kept
Study.tsxmotion subtree extracted to StudyMotion.tsxleft alone deliberately
suppressions baselinepruned 13 → 12grown 13 → 14
local E2E lanecould not run (machine unprovisioned)ran green, repeatedly

Where #490 is better: the direct-DOM-write graph is the deeper fix and is what the issue's own proposal recommended; the visibilitychange pause is a real win this PR lacks; extracting StudyMotion is more than I did; and pruning the suppressions baseline beats growing it.

Where this PR is better: the link pass is asymptotically better, not just cheaper per pair, and its equivalence to the naive version is proved by test rather than asserted. ssr: false on HowItWorks removes a landing-page section from the server-rendered HTML — a crawler regression on the one page that needs SEO. And this branch has actually been through the local E2E lane.

Reading #490 also caught a real bug in this PR that my own review missed: my 30fps cap advanced the orb physics once per painted frame, so drift ran at half speed — a visual change, in a PR claiming none. #490 handled that explicitly ("drift speed preserved via elapsed-frame scaling"). Fixed in 463facd.

My suggestion, for whoever decides: take #490 as the base — it is the more thorough pass and the graph fix is better — and port two things onto it from here: the binned lib/linkPairs.ts with its equivalence suite, and dropping ssr: false from the HowItWorks dynamic import. But that is a call for @AndresL230, not for me.

Jose-Gael-Cruz-Lopez added a commit that referenced this pull request Jul 31, 2026
…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>
AndresL230 added a commit that referenced this pull request Jul 31, 2026
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 added a commit that referenced this pull request Jul 31, 2026
* perf(frontend): #111 runtime animations + lazy image loading
- 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>
* fix(perf): #490 review round — sprite memory cap, un-gated Study panes, 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>
* fix(perf): #490 round 2 — revive backdrop on un-reduce, SSR the HowItWorks 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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: AndresL230 <190146319+AndresL230@users.noreply.github.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Superseded by #490, which is merged.

@AndresL230's call was to take Jose's PR as the base, port anything worthwhile from here, verify it, and merge — which is what happened. Recording what carried over and what didn't:

Carried over: the ssr: false → SSR-preserving HowItWorks import (Jose made that change himself in 23b773f, crediting this PR's review), and the intermittent CI fix from this branch's investigation (FlashcardImportModal.test.tsx never unmounting — the reason this very PR failed CI with 416/416 tests passing).

Deliberately not carried over: the spatially-binned lib/linkPairs.ts. I benchmarked it against #490's axis-reject loop rather than assuming, and #490's is faster at the real workload — 0.068ms vs 0.113ms per frame at the actual 226-node cloud. Binning only wins past ~N=2000; below that, Map allocation and per-point sorting cost more than it saves. Identical pairs at every N. The asymptotically better algorithm was the slower one here, so it stayed out.

Also not carried over: the rAF-coalesced tick render. #490 writes simulation positions directly to the DOM, which is strictly better — and is what issue #111's own proposal recommended.

The one thing this PR did that #490 hadn't: run the full local E2E cycle. That has now been run against the merged head — 35/35 journeys, oracles clean.

Opening this without checking for an existing open PR on the same issue was my mistake.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230