From 426be67c10d1f188659fb2720165b762d71d0f63 Mon Sep 17 00:00:00 2001 From: Jose Cruz Date: Thu, 30 Jul 2026 19:38:06 -0700 Subject: [PATCH 1/3] 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 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 --- frontend/eslint-suppressions.json | 2 +- frontend/src/app/(public)/page.tsx | 34 ++++- .../src/components/AtmosphericBackdrop.tsx | 120 +++++++++++++---- .../KnowledgeGraph2D.testmode.test.tsx | 17 ++- frontend/src/components/KnowledgeGraph2D.tsx | 124 ++++++++++++++---- frontend/src/components/SideNav.tsx | 4 + frontend/src/components/TopNav.tsx | 4 + frontend/src/components/screens/Admin.tsx | 4 +- frontend/src/components/screens/Settings.tsx | 5 +- frontend/src/components/screens/Social.tsx | 2 +- frontend/src/components/screens/Study.tsx | 85 ++++++------ .../src/components/screens/StudyMotion.tsx | 57 ++++++++ 12 files changed, 350 insertions(+), 108 deletions(-) create mode 100644 frontend/src/components/screens/StudyMotion.tsx diff --git a/frontend/eslint-suppressions.json b/frontend/eslint-suppressions.json index 839888bf..a4db8d29 100644 --- a/frontend/eslint-suppressions.json +++ b/frontend/eslint-suppressions.json @@ -55,7 +55,7 @@ "count": 1 }, "react-hooks/refs": { - "count": 13 + "count": 12 } }, "src/components/KnowledgeGraph3D.test.tsx": { diff --git a/frontend/src/app/(public)/page.tsx b/frontend/src/app/(public)/page.tsx index 0c9eb491..eda0a965 100644 --- a/frontend/src/app/(public)/page.tsx +++ b/frontend/src/app/(public)/page.tsx @@ -1,16 +1,26 @@ 'use client'; import { useEffect, useState, useRef, useCallback } from 'react'; +import dynamic from 'next/dynamic'; import { useRouter } from 'next/navigation'; import { useUser } from '@/context/UserContext'; import { useScrollLock } from '@/lib/useScrollLock'; import { Network, Sparkles, FilePlus2, Brain, CalendarClock, Users, PenSquare } from 'lucide-react'; -import HowItWorks from '@/components/HowItWorks'; import SignInModal from '@/components/SignInModal'; import { BRAND_FOREST } from '@/lib/brand'; import { Button } from "@/components/ui"; import { IS_TEST_MODE, random, now } from '@/lib/testMode'; +// HowItWorks statically imports framer-motion, which a plain import would ride +// into the landing chunk. Lazy-load it ssr:false (same split as ChatPanel's +// MarkdownChat) so the motion stack ships as its own client chunk. The +// placeholder mirrors the section's own 340vh scroll height so the layout +// below doesn't shift while the chunk loads. +const HowItWorks = dynamic(() => import('@/components/HowItWorks'), { + ssr: false, + loading: () =>
, +}); + const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5000'; const SCRAMBLE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!<>-_\\/[]{}=+*^?#_"; @@ -218,12 +228,26 @@ export default function LandingPage() { ctx.globalCompositeOperation = 'source-over'; ctx.lineWidth = 0.5; + // The pair walk itself is unavoidable (links depend on projected + // positions, which change every frame), but almost every pair fails the + // distance test — so make failing cheap: hoist the per-node threshold + // out of the inner loop, reject on |dx|/|dy| before multiplying, and + // compare squared distances so the sqrt only runs for pairs that + // actually draw a link. for (let i = 0; i < proj.length; i++) { + const p1 = proj[i]; + const maxD = 70 * p1.sc; + const maxD2 = maxD * maxD; + const aScale = 0.15 * Math.min(1, p1.sc); for (let j = i + 1; j < proj.length; j++) { - const p1 = proj[i], p2 = proj[j]; - const d = Math.hypot(p1.x - p2.x, p1.y - p2.y); - if (d < 70 * p1.sc) { - const a = (1 - d / (70 * p1.sc)) * 0.15 * Math.min(1, p1.sc); + const p2 = proj[j]; + const dx = p1.x - p2.x; + if (dx > maxD || dx < -maxD) continue; + const dy = p1.y - p2.y; + if (dy > maxD || dy < -maxD) continue; + const d2 = dx * dx + dy * dy; + if (d2 < maxD2) { + const a = (1 - Math.sqrt(d2) / maxD) * aScale; if (a > 0.002) { ctx.strokeStyle = `rgba(156,163,175,${a})`; ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke(); diff --git a/frontend/src/components/AtmosphericBackdrop.tsx b/frontend/src/components/AtmosphericBackdrop.tsx index f59d4387..51f22694 100644 --- a/frontend/src/components/AtmosphericBackdrop.tsx +++ b/frontend/src/components/AtmosphericBackdrop.tsx @@ -13,6 +13,10 @@ * - DPR-aware; redraws on window resize. * - 14 orbs, each with a slow 2D drift + pseudo-depth via scale + opacity. * - Opacity capped at 0.10; you feel them more than you see them. + * - Each orb's radial gradient is pre-baked to an offscreen sprite (#111); + * frames composite bitmaps via drawImage. Re-baked only on DPR change. + * - The RAF loop is throttled to ~30fps (imperceptible on this slow drift) + * and pauses entirely while the tab is hidden. * - Honors prefers-reduced-motion: paints one still frame, never animates. * - Palette is intentionally independent of the UI green so the brand's * accent keeps its exclusive semantic meaning (growth/mastery). @@ -27,8 +31,13 @@ type Orb = { r: number; color: string; phase: number; + // Pre-baked gradient sprite; re-baked only when the DPR changes (#111). + sprite?: HTMLCanvasElement; }; +// ~30fps frame budget — plenty for a slow ambient drift (#111). +const FRAME_MS = 1000 / 30; + // Warm, atmospheric palette — blues, purples, ambers, teals. Green is // reserved for UI branding, so it's intentionally absent here. const PALETTE = [ @@ -58,12 +67,38 @@ function makeOrbs(width: number, height: number): Orb[] { return orbs; } +// Pre-bake one orb's radial gradient to an offscreen canvas so the animation +// loop composites bitmaps with drawImage instead of re-creating full-viewport +// gradients every frame (#111). Radius/alpha are fixed at orb creation, so +// only a DPR change invalidates a sprite. +function bakeSprite(orb: Orb, dpr: number): HTMLCanvasElement | undefined { + const rad = orb.r * (0.7 + orb.z * 0.6); + // Peak opacity 0.10 on the closest orbs; falls off with depth. + const alpha = 0.035 + orb.z * 0.07; + const size = Math.max(1, Math.ceil(rad * 2 * dpr)); + const sprite = document.createElement("canvas"); + sprite.width = size; + sprite.height = size; + const sctx = sprite.getContext("2d"); + if (!sctx) return undefined; + const c = size / 2; + const grad = sctx.createRadialGradient(c, c, 0, c, c, rad * dpr); + grad.addColorStop(0, hexToRgba(orb.color, alpha)); + grad.addColorStop(0.55, hexToRgba(orb.color, alpha * 0.45)); + grad.addColorStop(1, hexToRgba(orb.color, 0)); + sctx.fillStyle = grad; + sctx.fillRect(0, 0, size, size); + return sprite; +} + export function AtmosphericBackdrop() { const canvasRef = React.useRef(null); const rafRef = React.useRef(null); const orbsRef = React.useRef([]); const sizeRef = React.useRef<{ w: number; h: number; dpr: number }>({ w: 0, h: 0, dpr: 1 }); const reducedMotionRef = React.useRef(false); + const bakedDprRef = React.useRef(null); + const lastFrameRef = React.useRef(null); React.useEffect(() => { const canvas = canvasRef.current; @@ -89,62 +124,95 @@ export function AtmosphericBackdrop() { canvas.style.height = `${h}px`; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); if (orbsRef.current.length === 0) orbsRef.current = makeOrbs(w, h); + // Orb sizes are fixed at creation, so a DPR change is the only thing + // that invalidates the pre-baked sprites. + if (bakedDprRef.current !== dpr) { + for (const orb of orbsRef.current) orb.sprite = bakeSprite(orb, dpr); + bakedDprRef.current = dpr; + } }; resize(); window.addEventListener("resize", resize); - const paint = (t: number) => { + const drawFrame = () => { const { w, h } = sizeRef.current; ctx.clearRect(0, 0, w, h); // The pale-green background tint preserved — the backdrop must NOT - // paint a big opaque fill; the page's own --bg shows through. + // paint a big opaque fill; the page's own --bg shows through. Each + // orb's gradient is pre-baked (see bakeSprite), so a frame is pure + // bitmap compositing. for (const orb of orbsRef.current) { - const x = orb.x; - const y = orb.y; + if (!orb.sprite) continue; const rad = orb.r * (0.7 + orb.z * 0.6); - // Peak opacity 0.10 on the closest orbs; falls off with depth. - const alpha = 0.035 + orb.z * 0.07; - - const grad = ctx.createRadialGradient(x, y, 0, x, y, rad); - grad.addColorStop(0, hexToRgba(orb.color, alpha)); - grad.addColorStop(0.55, hexToRgba(orb.color, alpha * 0.45)); - grad.addColorStop(1, hexToRgba(orb.color, 0)); - - ctx.fillStyle = grad; - ctx.beginPath(); - ctx.arc(x, y, rad, 0, Math.PI * 2); - ctx.fill(); + ctx.drawImage(orb.sprite, orb.x - rad, orb.y - rad, rad * 2, rad * 2); } + }; - if (reducedMotionRef.current) return; - - // Slow drift. Wrap orbs that float past the edges (with a soft margin - // so they don't pop in — the gradient tail handles the fade). + // Slow drift, scaled by elapsed 60fps-equivalent frames so the ~30fps + // throttle keeps the same drift speed as the old per-frame step. Wrap + // orbs that float past the edges (with a soft margin so they don't pop + // in — the gradient tail handles the fade). + const step = (dtFrames: number) => { + const { w, h } = sizeRef.current; for (const orb of orbsRef.current) { - orb.phase += 0.0008; - orb.x += orb.vx + Math.cos(orb.phase) * 0.04; - orb.y += orb.vy + Math.sin(orb.phase * 0.8) * 0.03; + orb.phase += 0.0008 * dtFrames; + orb.x += (orb.vx + Math.cos(orb.phase) * 0.04) * dtFrames; + orb.y += (orb.vy + Math.sin(orb.phase * 0.8) * 0.03) * dtFrames; const m = orb.r; if (orb.x < -m) orb.x = w + m; else if (orb.x > w + m) orb.x = -m; if (orb.y < -m) orb.y = h + m; else if (orb.y > h + m) orb.y = -m; } + }; + + const tick = (t: number) => { + if (reducedMotionRef.current) { + // Paint one still frame only. + drawFrame(); + rafRef.current = null; + return; + } + rafRef.current = requestAnimationFrame(tick); + const last = lastFrameRef.current; + // Throttle to ~30fps; the 1ms epsilon keeps 60Hz displays from + // slipping to every third frame on timestamp jitter. + if (last != null && t - last < FRAME_MS - 1) return; + // Clamp catch-up so a long RAF gap can't teleport the orbs. + const dtFrames = last == null ? 1 : Math.min((t - last) / (1000 / 60), 4); + lastFrameRef.current = t; + drawFrame(); + step(dtFrames); + }; - rafRef.current = requestAnimationFrame(paint); + // Repainting a hidden tab is pure waste — pause the loop entirely and + // resume (without integrating the hidden gap into the drift) when the + // tab becomes visible again. + const onVisibility = () => { + if (document.hidden) { + if (rafRef.current != null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + } else if (!reducedMotionRef.current && rafRef.current == null) { + lastFrameRef.current = null; + rafRef.current = requestAnimationFrame(tick); + } }; + document.addEventListener("visibilitychange", onVisibility); if (reducedMotionRef.current) { // Paint one still frame only. - paint(0); + drawFrame(); } else { - rafRef.current = requestAnimationFrame(paint); + rafRef.current = requestAnimationFrame(tick); } return () => { if (rafRef.current != null) cancelAnimationFrame(rafRef.current); window.removeEventListener("resize", resize); + document.removeEventListener("visibilitychange", onVisibility); mql?.removeEventListener?.("change", onReducedChange); }; }, []); diff --git a/frontend/src/components/KnowledgeGraph2D.testmode.test.tsx b/frontend/src/components/KnowledgeGraph2D.testmode.test.tsx index 081a17ef..5c47c7fb 100644 --- a/frontend/src/components/KnowledgeGraph2D.testmode.test.tsx +++ b/frontend/src/components/KnowledgeGraph2D.testmode.test.tsx @@ -51,17 +51,25 @@ const EDGES: GraphEdge[] = [ { source: "a", target: "d", strength: 0.7 }, ]; -/** Every rendered circle + edge coordinate, as attribute strings. */ +/** + * Every rendered node position + edge coordinate, as attribute strings. + * Node positions live on the group's `transform` (#111 moved the per-tick + * writes off React: children sit at relative cx/cy 0 and the group carries + * the translate); circle radii are kept so size regressions still surface. + */ function snapshot(container: HTMLElement): string[] { const svg = container.querySelector("svg"); expect(svg).not.toBeNull(); + const groups = Array.from(svg!.querySelectorAll('[data-testid="graph-node"]')).map( + (g) => `n:${g.getAttribute("transform")}`, + ); const circles = Array.from(svg!.querySelectorAll("circle")).map( (c) => `c:${c.getAttribute("cx")},${c.getAttribute("cy")},${c.getAttribute("r")}`, ); const lines = Array.from(svg!.querySelectorAll("line")).map( (l) => `l:${l.getAttribute("x1")},${l.getAttribute("y1")},${l.getAttribute("x2")},${l.getAttribute("y2")}`, ); - return [...circles, ...lines]; + return [...groups, ...circles, ...lines]; } describe("KnowledgeGraph2D — test-mode determinism", () => { @@ -72,6 +80,7 @@ describe("KnowledgeGraph2D — test-mode determinism", () => { const snap1 = snapshot(first.container); // The simulation must have actually laid out and rendered content. expect(snap1.length).toBeGreaterThan(0); + expect(snap1.some((s) => s.startsWith("n:"))).toBe(true); expect(snap1.some((s) => s.startsWith("l:"))).toBe(true); for (const s of snap1) { expect(s).not.toMatch(/NaN|null|undefined/); @@ -92,8 +101,8 @@ describe("KnowledgeGraph2D — test-mode determinism", () => { , ); const centers = new Set( - Array.from(container.querySelectorAll("circle")).map( - (c) => `${c.getAttribute("cx")},${c.getAttribute("cy")}`, + Array.from(container.querySelectorAll('[data-testid="graph-node"]')).map( + (g) => g.getAttribute("transform"), ), ); // 5 nodes must occupy at least 5 distinct positions once settled. diff --git a/frontend/src/components/KnowledgeGraph2D.tsx b/frontend/src/components/KnowledgeGraph2D.tsx index f0ef41a3..d330508c 100644 --- a/frontend/src/components/KnowledgeGraph2D.tsx +++ b/frontend/src/components/KnowledgeGraph2D.tsx @@ -138,11 +138,52 @@ function KnowledgeGraph2DImpl({ const [, forceRerender] = React.useReducer((x) => x + 1, 0); const [hovered, setHovered] = React.useState(null); - const [tooltipPos, setTooltipPos] = React.useState({ x: 0, y: 0 }); const [view, setView] = React.useState({ tx: 0, ty: 0, scale: 1 }); const dragRef = React.useRef(null); const movedRef = React.useRef(false); + // Per-tick position updates bypass React entirely (#111): the tick handler + // writes node-group transforms and edge endpoints straight onto these DOM + // elements, so a simulation tick never re-renders the tree. React renders + // stay reserved for structural changes (nodes/edges added or removed, + // hover, selection, pan/zoom). Same idea for the tooltip: state only seeds + // its position when it (re)opens — while it's showing, pointer moves + // reposition it via direct style writes, so a bare pointer-move never + // calls setState. + const nodeElsRef = React.useRef(new Map()); + const edgeElsRef = React.useRef(new Map()); + const tooltipRef = React.useRef(null); + const [tooltipPos, setTooltipPos] = React.useState({ x: 0, y: 0 }); + + // d3 mutates node x/y in place, so JSX renders (which read the same + // objects) and these direct writes always agree on the latest positions. + const applyPositions = React.useCallback(() => { + // Lazy id→node map: the link force replaces string endpoints with node + // objects on init, so this fallback almost never materialises. + let byId: Map | null = null; + const resolve = (end: string | SimNode): SimNode | undefined => { + if (typeof end === "object") return end; + if (!byId) byId = new Map(simNodesRef.current.map((n) => [n.id, n])); + return byId.get(end); + }; + for (const n of simNodesRef.current) { + if (n.x == null || n.y == null) continue; + const el = nodeElsRef.current.get(n.id); + if (el) el.setAttribute("transform", `translate(${n.x}, ${n.y})`); + } + simLinksRef.current.forEach((l, i) => { + const el = edgeElsRef.current.get(i); + if (!el) return; + const s = resolve(l.source); + const t = resolve(l.target); + if (!s || !t || s.x == null || s.y == null || t.x == null || t.y == null) return; + el.setAttribute("x1", String(s.x)); + el.setAttribute("y1", String(s.y)); + el.setAttribute("x2", String(t.x)); + el.setAttribute("y2", String(t.y)); + }); + }, []); + // Rebuild the simulation whenever the node/edge set fundamentally changes. // We diff by id so stable nodes keep their current x/y/vx/vy. const dataKey = React.useMemo( @@ -173,17 +214,16 @@ function KnowledgeGraph2DImpl({ } as SimNode; }); + const nextIds = new Set(nextNodes.map((n) => n.id)); const nextLinks: SimLink[] = edges - .filter((e) => nextNodes.some((n) => n.id === e.source) && nextNodes.some((n) => n.id === e.target)) + .filter((e) => nextIds.has(e.source) && nextIds.has(e.target)) .map((e) => ({ source: e.source, target: e.target, strength: e.strength })); simNodesRef.current = nextNodes; simLinksRef.current = nextLinks; if (!simRef.current) { - simRef.current = forceSimulation(nextNodes).on("tick", () => { - forceRerender(); - }); + simRef.current = forceSimulation(nextNodes).on("tick", applyPositions); } else { simRef.current.nodes(nextNodes); } @@ -214,10 +254,12 @@ function KnowledgeGraph2DImpl({ && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches); if (reducedMotion) { sim.alpha(1).tick(200).alpha(0).stop(); - forceRerender(); } else { sim.alpha(0.9).restart(); } + // One structural render mounts the node/edge elements at their current + // coordinates; from here on ticks write positions straight to the DOM. + forceRerender(); return () => { // Simulation is kept across renders; only stop on full unmount below. @@ -325,6 +367,9 @@ function KnowledgeGraph2DImpl({ simRef.current?.alphaTarget(0.3).restart(); n.fx = n.x; n.fy = n.y; + // dragRef/fx are refs — render once so the tooltip hides and the pinned + // stroke shows (ticks no longer re-render on our behalf). + forceRerender(); }; const onSvgPointerDown = (e: React.PointerEvent) => { @@ -341,10 +386,17 @@ function KnowledgeGraph2DImpl({ originTy: view.ty, }; movedRef.current = false; + svg.style.cursor = "grabbing"; // direct write; restored on pointer-up }; const onPointerMove = (e: React.PointerEvent) => { - setTooltipPos({ x: e.clientX, y: e.clientY }); + // Reposition the tooltip only while it's actually mounted, and via a + // direct style write — no setState on a bare pointer-move. + const tip = tooltipRef.current; + if (tip) { + tip.style.left = `${e.clientX + 14}px`; + tip.style.top = `${e.clientY + 14}px`; + } const drag = dragRef.current; if (!drag || e.pointerId !== drag.pointerId) return; movedRef.current = true; @@ -384,6 +436,12 @@ function KnowledgeGraph2DImpl({ } simRef.current?.alphaTarget(0); } + if (svgRef.current) svgRef.current.style.cursor = "grab"; + // Restore tooltip/stroke now that dragRef cleared; reseed the tooltip + // position so it remounts under the release point, not where the hover + // started. + setTooltipPos({ x: e.clientX, y: e.clientY }); + forceRerender(); }; const onWheel = (e: React.WheelEvent) => { @@ -410,6 +468,11 @@ function KnowledgeGraph2DImpl({ const simNodes = simNodesRef.current; const simLinks = simLinksRef.current; + // O(1) endpoint resolution for edges whose source/target is still an id + // string (before the link force swaps in node objects) — replaces the old + // per-edge .find() over all nodes. + const nodeById = new Map(); + for (const n of simNodes) nodeById.set(n.id, n); return (
@@ -421,7 +484,7 @@ function KnowledgeGraph2DImpl({ aria-label="Knowledge graph" style={{ display: "block", - cursor: dragRef.current?.kind === "pan" ? "grabbing" : "grab", + cursor: "grab", touchAction: "none", }} onPointerDown={onSvgPointerDown} @@ -440,13 +503,17 @@ function KnowledgeGraph2DImpl({ {/* Edges */} {simLinks.map((l, i) => { - const s = typeof l.source === "object" ? (l.source as SimNode) : simNodes.find((n) => n.id === l.source); - const t = typeof l.target === "object" ? (l.target as SimNode) : simNodes.find((n) => n.id === l.target); + const s = typeof l.source === "object" ? (l.source as SimNode) : nodeById.get(String(l.source)); + const t = typeof l.target === "object" ? (l.target as SimNode) : nodeById.get(String(l.target)); if (!s || !t || s.x == null || t.x == null) return null; const op = variant === "constellation" ? 0.35 : 0.2; return ( { + if (el) edgeElsRef.current.set(i, el); + else edgeElsRef.current.delete(i); + }} data-testid="graph-edge" x1={s.x} y1={s.y} @@ -474,11 +541,21 @@ function KnowledgeGraph2DImpl({ return ( { + if (el) nodeElsRef.current.set(n.id, el); + else nodeElsRef.current.delete(n.id); + }} data-testid="graph-node" data-node-id={n.id} + transform={`translate(${n.x}, ${n.y})`} style={{ cursor: "grab" }} onPointerDown={(ev) => onNodePointerDown(ev, n)} - onPointerEnter={() => setHovered(n)} + onPointerEnter={(ev) => { + // Seed the tooltip position as it mounts so it opens + // under the cursor, not at a stale spot. + setTooltipPos({ x: ev.clientX, y: ev.clientY }); + setHovered(n); + }} onPointerLeave={() => setHovered((h) => (h?.id === n.id ? null : h))} onClick={(ev) => { ev.stopPropagation(); @@ -487,10 +564,10 @@ function KnowledgeGraph2DImpl({ }} > {variant === "organism" && ( - + )} {isHl && ( - + - - + + ) : (
{form.asset_url && ( - + )} )} @@ -983,7 +983,7 @@ function CosmeticsTab() { + ) : c.css_value ? ( sample diff --git a/frontend/src/components/screens/Settings.tsx b/frontend/src/components/screens/Settings.tsx index c0b83e85..985daf6d 100644 --- a/frontend/src/components/screens/Settings.tsx +++ b/frontend/src/components/screens/Settings.tsx @@ -890,7 +890,10 @@ function CosmeticPreview({ )} @@ -900,7 +903,7 @@ function CosmeticPreview({ if (cosmetic.type === "banner" && cosmetic.asset_url) { return (
- +
); } diff --git a/frontend/src/components/screens/Social.tsx b/frontend/src/components/screens/Social.tsx index f9641502..1b1fa952 100644 --- a/frontend/src/components/screens/Social.tsx +++ b/frontend/src/components/screens/Social.tsx @@ -624,7 +624,7 @@ function RoomChat({ roomId, members }: { roomId: string; members: { user_id: str }} > {m.image_url && ( - attachment + attachment )} {renderText(m.text)} {m.edited_at && (edited)} diff --git a/frontend/src/components/screens/Study.tsx b/frontend/src/components/screens/Study.tsx index 327de96f..7c110483 100644 --- a/frontend/src/components/screens/Study.tsx +++ b/frontend/src/components/screens/Study.tsx @@ -2,13 +2,6 @@ import React from "react"; import dynamic from "next/dynamic"; import { useSearchParams } from "next/navigation"; -import { AnimatePresence, motion, MotionGlobalConfig } from "framer-motion"; -import { IS_TEST_MODE } from "@/lib/testMode"; - -// Deterministic DOM for browser tests (#383): framer-motion's own test -// seam jumps every animation straight to its final keyframe. No-op in -// production builds (flag inlined to false at build time). -if (IS_TEST_MODE) MotionGlobalConfig.skipAnimations = true; import { TopBar } from "../TopBar"; import { AIDisclaimerChip } from "../AIDisclaimerChip"; import { Icon } from "../Icon"; @@ -21,6 +14,28 @@ const MarkdownChat = dynamic( () => import("../MarkdownChat").then((m) => m.MarkdownChat), { ssr: false, loading: () => null }, ); + +// Lazy-load the framer-motion subtree the same way (#111) — the library is +// only needed for the toggle highlight + mode crossfade, so it stays out of +// Study's initial bundle. Fallbacks keep the resting visuals identical while +// the chunk loads: an identically-styled static pill, and the same flex +// wrapper minus the crossfade. +const StudyToggleHighlight = dynamic( + () => import("./StudyMotion").then((m) => m.StudyToggleHighlight), + { + ssr: false, + loading: () => ( + + ), + }, +); +const StudyModePanel = dynamic( + () => import("./StudyMotion").then((m) => m.StudyModePanel), + { + ssr: false, + loading: () =>
, + }, +); import { StudyGuideSkeleton, FlashcardsSkeleton } from "../Skeleton"; import { useToast } from "../ToastProvider"; import { useIsMobile } from "@/lib/useIsMobile"; @@ -125,18 +140,7 @@ export function Study() { zIndex: 1, }} > - {mode === v && ( - - )} + {mode === v && } {label} ))} @@ -151,32 +155,23 @@ export function Study() { subtitle={mode === "guide" ? undefined : "Spaced review with ratings and a 3D flip"} actions={actions} /> - - - {mode === "guide" ? ( - - ) : ( - - )} - - + + {mode === "guide" ? ( + + ) : ( + + )} +
); } diff --git a/frontend/src/components/screens/StudyMotion.tsx b/frontend/src/components/screens/StudyMotion.tsx new file mode 100644 index 00000000..de86699e --- /dev/null +++ b/frontend/src/components/screens/StudyMotion.tsx @@ -0,0 +1,57 @@ +"use client"; +import React from "react"; +import { AnimatePresence, motion, MotionGlobalConfig } from "framer-motion"; +import { IS_TEST_MODE } from "@/lib/testMode"; + +// Deterministic DOM for browser tests (#383): framer-motion's own test +// seam jumps every animation straight to its final keyframe. No-op in +// production builds (flag inlined to false at build time). +if (IS_TEST_MODE) MotionGlobalConfig.skipAnimations = true; + +/** + * The Study screen's framer-motion subtree, split out so the library stays + * out of Study's initial bundle (#111) — Study.tsx loads both components + * via next/dynamic (the MarkdownChat pattern). + */ + +/** Sliding highlight behind the active Study Guide / Flashcards toggle + * button (spring layout animation between the two buttons). */ +export function StudyToggleHighlight() { + return ( + + ); +} + +/** Cross-fades the guide/cards panes when the mode toggles — old pane exits + * up, new pane enters from below; mode="wait" so they never overlap. */ +export function StudyModePanel({ + mode, + children, +}: { + mode: string; + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} From f92e0ce882faad3f62aae3658ee838f68162dcb3 Mon Sep 17 00:00:00 2001 From: Jose Cruz Date: Thu, 30 Jul 2026 23:06:42 -0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(perf):=20#490=20review=20round=20?= =?UTF-8?q?=E2=80=94=20sprite=20memory=20cap,=20un-gated=20Study=20panes,?= =?UTF-8?q?=20eager=20nav=20logos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/app/(public)/page.tsx | 4 +- frontend/src/app/globals.css | 4 ++ .../src/components/AtmosphericBackdrop.tsx | 42 +++++++++++-------- frontend/src/components/SideNav.tsx | 1 - frontend/src/components/TopNav.tsx | 1 - frontend/src/components/screens/Study.tsx | 27 ++++++++---- .../src/components/screens/StudyMotion.tsx | 32 ++------------ 7 files changed, 56 insertions(+), 55 deletions(-) diff --git a/frontend/src/app/(public)/page.tsx b/frontend/src/app/(public)/page.tsx index eda0a965..daa55d49 100644 --- a/frontend/src/app/(public)/page.tsx +++ b/frontend/src/app/(public)/page.tsx @@ -15,7 +15,9 @@ import { IS_TEST_MODE, random, now } from '@/lib/testMode'; // into the landing chunk. Lazy-load it ssr:false (same split as ChatPanel's // MarkdownChat) so the motion stack ships as its own client chunk. The // placeholder mirrors the section's own 340vh scroll height so the layout -// below doesn't shift while the chunk loads. +// below doesn't shift while the chunk loads. Tradeoff: ssr:false drops this +// section's marketing copy from the server HTML (JS-rendering crawlers still +// see it); if that ever matters, hoist the headings into static markup. const HowItWorks = dynamic(() => import('@/components/HowItWorks'), { ssr: false, loading: () =>
, diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 98da2abb..86e5e3c0 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -224,6 +224,10 @@ body { font-size: 14px; line-height: 1.5; } .fade-in { animation: fade-in var(--dur) var(--ease); } .slide-up { animation: slide-up var(--dur-slow) var(--ease); } .fade-up { animation: fade-in 0.5s var(--ease); animation-fill-mode: both; } +/* Study mode-switch enter fade (#111/#490): CSS stand-in for the old + framer-motion crossfade so pane content never waits on the motion chunk. */ +@keyframes study-mode-enter { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } +.study-mode-enter { animation: study-mode-enter 0.22s cubic-bezier(0.2, 0.85, 0.35, 1); } .anim-d0 { animation-delay: 0ms; } .anim-d1 { animation-delay: 80ms; } .anim-d2 { animation-delay: 160ms; } diff --git a/frontend/src/components/AtmosphericBackdrop.tsx b/frontend/src/components/AtmosphericBackdrop.tsx index 51f22694..c688569d 100644 --- a/frontend/src/components/AtmosphericBackdrop.tsx +++ b/frontend/src/components/AtmosphericBackdrop.tsx @@ -37,6 +37,7 @@ type Orb = { // ~30fps frame budget — plenty for a slow ambient drift (#111). const FRAME_MS = 1000 / 30; +const SPRITE_MAX_PX = 512; // Warm, atmospheric palette — blues, purples, ambers, teals. Green is // reserved for UI branding, so it's intentionally absent here. @@ -75,14 +76,18 @@ function bakeSprite(orb: Orb, dpr: number): HTMLCanvasElement | undefined { const rad = orb.r * (0.7 + orb.z * 0.6); // Peak opacity 0.10 on the closest orbs; falls off with depth. const alpha = 0.035 + orb.z * 0.07; - const size = Math.max(1, Math.ceil(rad * 2 * dpr)); + // Cap the sprite's backing resolution: at full device resolution the 14 + // sprites cost ~100 MB of canvas memory at DPR 2 (#490 review). The + // gradients are soft, so drawImage upscaling from a capped sprite is + // visually indistinguishable at a fraction of the memory. + const size = Math.max(1, Math.min(Math.ceil(rad * 2 * dpr), SPRITE_MAX_PX)); const sprite = document.createElement("canvas"); sprite.width = size; sprite.height = size; const sctx = sprite.getContext("2d"); if (!sctx) return undefined; const c = size / 2; - const grad = sctx.createRadialGradient(c, c, 0, c, c, rad * dpr); + const grad = sctx.createRadialGradient(c, c, 0, c, c, c); grad.addColorStop(0, hexToRgba(orb.color, alpha)); grad.addColorStop(0.55, hexToRgba(orb.color, alpha * 0.45)); grad.addColorStop(1, hexToRgba(orb.color, 0)); @@ -113,6 +118,21 @@ export function AtmosphericBackdrop() { const onReducedChange = () => { reducedMotionRef.current = IS_TEST_MODE || !!mql?.matches; }; mql?.addEventListener?.("change", onReducedChange); + const drawFrame = () => { + const { w, h } = sizeRef.current; + ctx.clearRect(0, 0, w, h); + + // The pale-green background tint preserved — the backdrop must NOT + // paint a big opaque fill; the page's own --bg shows through. Each + // orb's gradient is pre-baked (see bakeSprite), so a frame is pure + // bitmap compositing. + for (const orb of orbsRef.current) { + if (!orb.sprite) continue; + const rad = orb.r * (0.7 + orb.z * 0.6); + ctx.drawImage(orb.sprite, orb.x - rad, orb.y - rad, rad * 2, rad * 2); + } + }; + const resize = () => { const dpr = Math.min(window.devicePixelRatio || 1, 2); const w = window.innerWidth; @@ -130,25 +150,13 @@ export function AtmosphericBackdrop() { for (const orb of orbsRef.current) orb.sprite = bakeSprite(orb, dpr); bakedDprRef.current = dpr; } + // Setting canvas.width cleared the backing store; when the loop is + // parked (reduced motion) nothing else repaints, so do it here. + if (reducedMotionRef.current) drawFrame(); }; resize(); window.addEventListener("resize", resize); - const drawFrame = () => { - const { w, h } = sizeRef.current; - ctx.clearRect(0, 0, w, h); - - // The pale-green background tint preserved — the backdrop must NOT - // paint a big opaque fill; the page's own --bg shows through. Each - // orb's gradient is pre-baked (see bakeSprite), so a frame is pure - // bitmap compositing. - for (const orb of orbsRef.current) { - if (!orb.sprite) continue; - const rad = orb.r * (0.7 + orb.z * 0.6); - ctx.drawImage(orb.sprite, orb.x - rad, orb.y - rad, rad * 2, rad * 2); - } - }; - // Slow drift, scaled by elapsed 60fps-equivalent frames so the ~30fps // throttle keeps the same drift speed as the old per-frame step. Wrap // orbs that float past the edges (with a soft margin so they don't pop diff --git a/frontend/src/components/SideNav.tsx b/frontend/src/components/SideNav.tsx index 490f5acf..0c58f236 100644 --- a/frontend/src/components/SideNav.tsx +++ b/frontend/src/components/SideNav.tsx @@ -113,7 +113,6 @@ export function SideNav() { alt="Sapling" width={32} height={32} - loading="lazy" decoding="async" style={{ width: 32, diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 43a8c94e..662e98d2 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -171,7 +171,6 @@ export function TopNav() { alt="Sapling" width={32} height={32} - loading="lazy" decoding="async" style={{ width: "32px", diff --git a/frontend/src/components/screens/Study.tsx b/frontend/src/components/screens/Study.tsx index 7c110483..c858f1e0 100644 --- a/frontend/src/components/screens/Study.tsx +++ b/frontend/src/components/screens/Study.tsx @@ -29,13 +29,26 @@ const StudyToggleHighlight = dynamic( ), }, ); -const StudyModePanel = dynamic( - () => import("./StudyMotion").then((m) => m.StudyModePanel), - { - ssr: false, - loading: () =>
, - }, -); +// The mode transition wraps the pane CONTENT, so it must not depend on the +// motion chunk (#490 review): a lazy wrapper either gates the panes behind +// the chunk fetch (next/dynamic's fallback can't carry children) or remounts +// them mid-session when the chunk lands, wiping state. A keyed CSS +// enter-fade needs no chunk at all; the global reduced-motion reset covers +// it. The render-phase state derivation mirrors the old AnimatePresence +// initial={false} — no animation on first mount, only on mode switches. +function StudyModePanel({ mode, children }: { mode: string; children: React.ReactNode }) { + const [seen, setSeen] = React.useState({ mode, animate: false }); + if (seen.mode !== mode) setSeen({ mode, animate: true }); + return ( +
+ {children} +
+ ); +} import { StudyGuideSkeleton, FlashcardsSkeleton } from "../Skeleton"; import { useToast } from "../ToastProvider"; import { useIsMobile } from "@/lib/useIsMobile"; diff --git a/frontend/src/components/screens/StudyMotion.tsx b/frontend/src/components/screens/StudyMotion.tsx index de86699e..4d129465 100644 --- a/frontend/src/components/screens/StudyMotion.tsx +++ b/frontend/src/components/screens/StudyMotion.tsx @@ -1,6 +1,6 @@ "use client"; import React from "react"; -import { AnimatePresence, motion, MotionGlobalConfig } from "framer-motion"; +import { motion, MotionGlobalConfig } from "framer-motion"; import { IS_TEST_MODE } from "@/lib/testMode"; // Deterministic DOM for browser tests (#383): framer-motion's own test @@ -10,8 +10,9 @@ if (IS_TEST_MODE) MotionGlobalConfig.skipAnimations = true; /** * The Study screen's framer-motion subtree, split out so the library stays - * out of Study's initial bundle (#111) — Study.tsx loads both components - * via next/dynamic (the MarkdownChat pattern). + * out of Study's initial bundle (#111) — Study.tsx loads it + * via next/dynamic (the MarkdownChat pattern); the mode-switch fade is + * plain CSS (`study-mode-enter`) so pane content never waits on this chunk. */ /** Sliding highlight behind the active Study Guide / Flashcards toggle @@ -30,28 +31,3 @@ export function StudyToggleHighlight() { /> ); } - -/** Cross-fades the guide/cards panes when the mode toggles — old pane exits - * up, new pane enters from below; mode="wait" so they never overlap. */ -export function StudyModePanel({ - mode, - children, -}: { - mode: string; - children: React.ReactNode; -}) { - return ( - - - {children} - - - ); -} From 23b773f815913d4004cd47178b9384b07992aaa3 Mon Sep 17 00:00:00 2001 From: Jose Cruz Date: Thu, 30 Jul 2026 23:31:16 -0700 Subject: [PATCH 3/3] =?UTF-8?q?fix(perf):=20#490=20round=202=20=E2=80=94?= =?UTF-8?q?=20revive=20backdrop=20on=20un-reduce,=20SSR=20the=20HowItWorks?= =?UTF-8?q?=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/app/(public)/page.tsx | 12 +++++------- frontend/src/components/AtmosphericBackdrop.tsx | 11 ++++++++++- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/(public)/page.tsx b/frontend/src/app/(public)/page.tsx index daa55d49..846a97cb 100644 --- a/frontend/src/app/(public)/page.tsx +++ b/frontend/src/app/(public)/page.tsx @@ -12,14 +12,12 @@ import { Button } from "@/components/ui"; import { IS_TEST_MODE, random, now } from '@/lib/testMode'; // HowItWorks statically imports framer-motion, which a plain import would ride -// into the landing chunk. Lazy-load it ssr:false (same split as ChatPanel's -// MarkdownChat) so the motion stack ships as its own client chunk. The -// placeholder mirrors the section's own 340vh scroll height so the layout -// below doesn't shift while the chunk loads. Tradeoff: ssr:false drops this -// section's marketing copy from the server HTML (JS-rendering crawlers still -// see it); if that ever matters, hoist the headings into static markup. +// into the landing chunk. next/dynamic splits it into its own client chunk +// while still server-rendering the section's marketing copy (#492 review: +// ssr:false would drop it from the HTML crawlers see — the one page where +// that matters). The placeholder mirrors the section's own 340vh scroll +// height so layout below doesn't shift during client-side chunk loads. const HowItWorks = dynamic(() => import('@/components/HowItWorks'), { - ssr: false, loading: () =>
, }); diff --git a/frontend/src/components/AtmosphericBackdrop.tsx b/frontend/src/components/AtmosphericBackdrop.tsx index c688569d..9b9e2a5b 100644 --- a/frontend/src/components/AtmosphericBackdrop.tsx +++ b/frontend/src/components/AtmosphericBackdrop.tsx @@ -115,7 +115,16 @@ export function AtmosphericBackdrop() { // Test mode paints one still frame with seeded orbs — same path as // prefers-reduced-motion. reducedMotionRef.current = IS_TEST_MODE || !!mql?.matches; - const onReducedChange = () => { reducedMotionRef.current = IS_TEST_MODE || !!mql?.matches; }; + const onReducedChange = () => { + reducedMotionRef.current = IS_TEST_MODE || !!mql?.matches; + // Un-reducing must revive the loop explicitly: tick() parks itself on a + // still frame when reduced (the old always-running loop resumed + // implicitly, so without this the backdrop would stay frozen). + if (!reducedMotionRef.current && rafRef.current == null && !document.hidden) { + lastFrameRef.current = null; + rafRef.current = requestAnimationFrame(tick); + } + }; mql?.addEventListener?.("change", onReducedChange); const drawFrame = () => {