Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/eslint-suppressions.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,7 @@
"count": 1
},
"react-hooks/refs": {
"count": 13
"count": 12
}
},
"src/components/KnowledgeGraph3D.test.tsx": {
Expand Down
34 changes: 29 additions & 5 deletions frontend/src/app/(public)/page.tsx
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
'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 { HeroCard } from '@/components/HeroCard';
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. 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'), {
loading: () => <section id="how-it-works" className="landing-section relative" style={{ height: '340vh' }} />,
});

const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5000';

const SCRAMBLE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!<>-_\\/[]{}=+*^?#_";
Expand DownExpand Up@@ -219,12 +229,26 @@

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();
Expand DownExpand Up@@ -510,7 +534,7 @@
>
<div className="max-w-[88%] mx-auto flex items-center justify-between w-full">
<button type="button" aria-label="Sapling — scroll to top" className="flex items-center cursor-pointer group" style={{ gap: '4px' }} onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}>
<img src="/sapling-icon.svg" alt="" style={{ width: '26px', height: '26px', flexShrink: 0, position: 'relative', top: '-2px' }} />

Check warning on line 537 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span style={{ fontFamily: "var(--font-spectral), 'Spectral', Georgia, serif", fontWeight: 700, fontSize: '20px', color: 'var(--brand-forest)', letterSpacing: '-0.02em', lineHeight: 1.1 }}>Sapling</span>
</button>
<div className="flex items-center">
Expand DownExpand Up@@ -726,7 +750,7 @@
<footer className="landing-section border-t border-white/35 py-12 px-8 relative z-10">
<div className="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-2">
<img src="/sapling-icon.svg" alt="Sapling" style={{ width: '20px', height: '20px' }} />

Check warning on line 753 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span className="text-sm font-light tracking-wide text-[var(--text-dim)]">Sapling · © 2026</span>
</div>
<div className="flex flex-wrap justify-center gap-6">
Expand DownExpand Up@@ -809,7 +833,7 @@
display: 'flex', flexDirection: 'column', gap: 22,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<img src="/sapling-icon.svg" alt="Sapling" style={{ width: 22, height: 22 }} />

Check warning on line 836 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span style={{ fontFamily: "var(--font-spectral), 'Spectral', Georgia, serif", fontWeight: 700, fontSize: 17, color: 'var(--brand-forest)', letterSpacing: '-0.02em' }}>Sapling</span>
</div>
<div>
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/app/globals.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,6 +261,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); }

/* Approval gate (#290) — the "you're in" beat.
The sapling draws itself once and the message steps in behind it; the
Expand Down
153 changes: 119 additions & 34 deletions frontend/src/components/AtmosphericBackdrop.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Expand All@@ -27,8 +31,14 @@ 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;
const SPRITE_MAX_PX = 512;

// Warm, atmospheric palette — blues, purples, ambers, teals. Green is
// reserved for UI branding, so it's intentionally absent here.
const PALETTE = [
Expand DownExpand Up@@ -58,12 +68,42 @@ 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;
// 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, 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));
sctx.fillStyle = grad;
sctx.fillRect(0, 0, size, size);
return sprite;
}

export function AtmosphericBackdrop() {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const rafRef = React.useRef<number | null>(null);
const orbsRef = React.useRef<Orb[]>([]);
const sizeRef = React.useRef<{ w: number; h: number; dpr: number }>({ w: 0, h: 0, dpr: 1 });
const reducedMotionRef = React.useRef<boolean>(false);
const bakedDprRef = React.useRef<number | null>(null);
const lastFrameRef = React.useRef<number | null>(null);

React.useEffect(() => {
const canvas = canvasRef.current;
Expand All@@ -75,9 +115,33 @@ 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 = () => {
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;
Expand All@@ -89,62 +153,83 @@ 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;
}
// 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 paint = (t: number) => {
// 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;
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.
for (const orb of orbsRef.current) {
const x = orb.x;
const y = orb.y;
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();
}

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).
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;
}
};

rafRef.current = requestAnimationFrame(paint);
const tick = (t: number) => {
if (reducedMotionRef.current) {
// Paint one still frame only.
drawFrame();
rafRef.current = null;
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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);
};

// 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);
};
}, []);
Expand Down
17 changes: 13 additions & 4 deletions frontend/src/components/KnowledgeGraph2D.testmode.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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", () => {
Expand All@@ -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/);
Expand All@@ -92,8 +101,8 @@ describe("KnowledgeGraph2D — test-mode determinism", () => {
<KnowledgeGraph2D nodes={NODES} edges={EDGES} width={600} height={480} />,
);
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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/eslint-suppressions.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,7 @@
"count": 1
},
"react-hooks/refs": {
"count": 13
"count": 12
}
},
"src/components/KnowledgeGraph3D.test.tsx": {
Expand Down
34 changes: 29 additions & 5 deletions frontend/src/app/(public)/page.tsx
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
'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 { HeroCard } from '@/components/HeroCard';
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. 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'), {
loading: () => <section id="how-it-works" className="landing-section relative" style={{ height: '340vh' }} />,
});

const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5000';

const SCRAMBLE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!<>-_\\/[]{}=+*^?#_";
Expand DownExpand Up@@ -219,12 +229,26 @@

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();
Expand DownExpand Up@@ -510,7 +534,7 @@
>
<div className="max-w-[88%] mx-auto flex items-center justify-between w-full">
<button type="button" aria-label="Sapling — scroll to top" className="flex items-center cursor-pointer group" style={{ gap: '4px' }} onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}>
<img src="/sapling-icon.svg" alt="" style={{ width: '26px', height: '26px', flexShrink: 0, position: 'relative', top: '-2px' }} />

Check warning on line 537 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span style={{ fontFamily: "var(--font-spectral), 'Spectral', Georgia, serif", fontWeight: 700, fontSize: '20px', color: 'var(--brand-forest)', letterSpacing: '-0.02em', lineHeight: 1.1 }}>Sapling</span>
</button>
<div className="flex items-center">
Expand DownExpand Up@@ -726,7 +750,7 @@
<footer className="landing-section border-t border-white/35 py-12 px-8 relative z-10">
<div className="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-2">
<img src="/sapling-icon.svg" alt="Sapling" style={{ width: '20px', height: '20px' }} />

Check warning on line 753 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span className="text-sm font-light tracking-wide text-[var(--text-dim)]">Sapling · © 2026</span>
</div>
<div className="flex flex-wrap justify-center gap-6">
Expand DownExpand Up@@ -809,7 +833,7 @@
display: 'flex', flexDirection: 'column', gap: 22,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<img src="/sapling-icon.svg" alt="Sapling" style={{ width: 22, height: 22 }} />

Check warning on line 836 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span style={{ fontFamily: "var(--font-spectral), 'Spectral', Georgia, serif", fontWeight: 700, fontSize: 17, color: 'var(--brand-forest)', letterSpacing: '-0.02em' }}>Sapling</span>
</div>
<div>
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/app/globals.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,6 +261,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); }

/* Approval gate (#290) — the "you're in" beat.
The sapling draws itself once and the message steps in behind it; the
Expand Down
153 changes: 119 additions & 34 deletions frontend/src/components/AtmosphericBackdrop.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Expand All@@ -27,8 +31,14 @@ 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;
const SPRITE_MAX_PX = 512;

// Warm, atmospheric palette — blues, purples, ambers, teals. Green is
// reserved for UI branding, so it's intentionally absent here.
const PALETTE = [
Expand DownExpand Up@@ -58,12 +68,42 @@ 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;
// 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, 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));
sctx.fillStyle = grad;
sctx.fillRect(0, 0, size, size);
return sprite;
}

export function AtmosphericBackdrop() {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const rafRef = React.useRef<number | null>(null);
const orbsRef = React.useRef<Orb[]>([]);
const sizeRef = React.useRef<{ w: number; h: number; dpr: number }>({ w: 0, h: 0, dpr: 1 });
const reducedMotionRef = React.useRef<boolean>(false);
const bakedDprRef = React.useRef<number | null>(null);
const lastFrameRef = React.useRef<number | null>(null);

React.useEffect(() => {
const canvas = canvasRef.current;
Expand All@@ -75,9 +115,33 @@ 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 = () => {
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;
Expand All@@ -89,62 +153,83 @@ 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;
}
// 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 paint = (t: number) => {
// 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;
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.
for (const orb of orbsRef.current) {
const x = orb.x;
const y = orb.y;
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();
}

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).
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;
}
};

rafRef.current = requestAnimationFrame(paint);
const tick = (t: number) => {
if (reducedMotionRef.current) {
// Paint one still frame only.
drawFrame();
rafRef.current = null;
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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);
};

// 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);
};
}, []);
Expand Down
17 changes: 13 additions & 4 deletions frontend/src/components/KnowledgeGraph2D.testmode.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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", () => {
Expand All@@ -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/);
Expand All@@ -92,8 +101,8 @@ describe("KnowledgeGraph2D — test-mode determinism", () => {
<KnowledgeGraph2D nodes={NODES} edges={EDGES} width={600} height={480} />,
);
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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/eslint-suppressions.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,7 @@
"count": 1
},
"react-hooks/refs": {
"count": 13
"count": 12
}
},
"src/components/KnowledgeGraph3D.test.tsx": {
Expand Down
34 changes: 29 additions & 5 deletions frontend/src/app/(public)/page.tsx
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
'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 { HeroCard } from '@/components/HeroCard';
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. 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'), {
loading: () => <section id="how-it-works" className="landing-section relative" style={{ height: '340vh' }} />,
});

const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5000';

const SCRAMBLE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!<>-_\\/[]{}=+*^?#_";
Expand DownExpand Up@@ -219,12 +229,26 @@

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();
Expand DownExpand Up@@ -510,7 +534,7 @@
>
<div className="max-w-[88%] mx-auto flex items-center justify-between w-full">
<button type="button" aria-label="Sapling — scroll to top" className="flex items-center cursor-pointer group" style={{ gap: '4px' }} onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}>
<img src="/sapling-icon.svg" alt="" style={{ width: '26px', height: '26px', flexShrink: 0, position: 'relative', top: '-2px' }} />

Check warning on line 537 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span style={{ fontFamily: "var(--font-spectral), 'Spectral', Georgia, serif", fontWeight: 700, fontSize: '20px', color: 'var(--brand-forest)', letterSpacing: '-0.02em', lineHeight: 1.1 }}>Sapling</span>
</button>
<div className="flex items-center">
Expand DownExpand Up@@ -726,7 +750,7 @@
<footer className="landing-section border-t border-white/35 py-12 px-8 relative z-10">
<div className="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-2">
<img src="/sapling-icon.svg" alt="Sapling" style={{ width: '20px', height: '20px' }} />

Check warning on line 753 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span className="text-sm font-light tracking-wide text-[var(--text-dim)]">Sapling · © 2026</span>
</div>
<div className="flex flex-wrap justify-center gap-6">
Expand DownExpand Up@@ -809,7 +833,7 @@
display: 'flex', flexDirection: 'column', gap: 22,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<img src="/sapling-icon.svg" alt="Sapling" style={{ width: 22, height: 22 }} />

Check warning on line 836 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span style={{ fontFamily: "var(--font-spectral), 'Spectral', Georgia, serif", fontWeight: 700, fontSize: 17, color: 'var(--brand-forest)', letterSpacing: '-0.02em' }}>Sapling</span>
</div>
<div>
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/app/globals.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,6 +261,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); }

/* Approval gate (#290) — the "you're in" beat.
The sapling draws itself once and the message steps in behind it; the
Expand Down
153 changes: 119 additions & 34 deletions frontend/src/components/AtmosphericBackdrop.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Expand All@@ -27,8 +31,14 @@ 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;
const SPRITE_MAX_PX = 512;

// Warm, atmospheric palette — blues, purples, ambers, teals. Green is
// reserved for UI branding, so it's intentionally absent here.
const PALETTE = [
Expand DownExpand Up@@ -58,12 +68,42 @@ 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;
// 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, 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));
sctx.fillStyle = grad;
sctx.fillRect(0, 0, size, size);
return sprite;
}

export function AtmosphericBackdrop() {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const rafRef = React.useRef<number | null>(null);
const orbsRef = React.useRef<Orb[]>([]);
const sizeRef = React.useRef<{ w: number; h: number; dpr: number }>({ w: 0, h: 0, dpr: 1 });
const reducedMotionRef = React.useRef<boolean>(false);
const bakedDprRef = React.useRef<number | null>(null);
const lastFrameRef = React.useRef<number | null>(null);

React.useEffect(() => {
const canvas = canvasRef.current;
Expand All@@ -75,9 +115,33 @@ 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 = () => {
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;
Expand All@@ -89,62 +153,83 @@ 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;
}
// 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 paint = (t: number) => {
// 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;
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.
for (const orb of orbsRef.current) {
const x = orb.x;
const y = orb.y;
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();
}

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).
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;
}
};

rafRef.current = requestAnimationFrame(paint);
const tick = (t: number) => {
if (reducedMotionRef.current) {
// Paint one still frame only.
drawFrame();
rafRef.current = null;
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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);
};

// 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);
};
}, []);
Expand Down
17 changes: 13 additions & 4 deletions frontend/src/components/KnowledgeGraph2D.testmode.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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", () => {
Expand All@@ -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/);
Expand All@@ -92,8 +101,8 @@ describe("KnowledgeGraph2D — test-mode determinism", () => {
<KnowledgeGraph2D nodes={NODES} edges={EDGES} width={600} height={480} />,
);
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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/eslint-suppressions.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,7 @@
"count": 1
},
"react-hooks/refs": {
"count": 13
"count": 12
}
},
"src/components/KnowledgeGraph3D.test.tsx": {
Expand Down
34 changes: 29 additions & 5 deletions frontend/src/app/(public)/page.tsx
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
'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 { HeroCard } from '@/components/HeroCard';
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. 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'), {
loading: () => <section id="how-it-works" className="landing-section relative" style={{ height: '340vh' }} />,
});

const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5000';

const SCRAMBLE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!<>-_\\/[]{}=+*^?#_";
Expand DownExpand Up@@ -219,12 +229,26 @@

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();
Expand DownExpand Up@@ -510,7 +534,7 @@
>
<div className="max-w-[88%] mx-auto flex items-center justify-between w-full">
<button type="button" aria-label="Sapling — scroll to top" className="flex items-center cursor-pointer group" style={{ gap: '4px' }} onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}>
<img src="/sapling-icon.svg" alt="" style={{ width: '26px', height: '26px', flexShrink: 0, position: 'relative', top: '-2px' }} />

Check warning on line 537 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span style={{ fontFamily: "var(--font-spectral), 'Spectral', Georgia, serif", fontWeight: 700, fontSize: '20px', color: 'var(--brand-forest)', letterSpacing: '-0.02em', lineHeight: 1.1 }}>Sapling</span>
</button>
<div className="flex items-center">
Expand DownExpand Up@@ -726,7 +750,7 @@
<footer className="landing-section border-t border-white/35 py-12 px-8 relative z-10">
<div className="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-2">
<img src="/sapling-icon.svg" alt="Sapling" style={{ width: '20px', height: '20px' }} />

Check warning on line 753 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span className="text-sm font-light tracking-wide text-[var(--text-dim)]">Sapling · © 2026</span>
</div>
<div className="flex flex-wrap justify-center gap-6">
Expand DownExpand Up@@ -809,7 +833,7 @@
display: 'flex', flexDirection: 'column', gap: 22,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<img src="/sapling-icon.svg" alt="Sapling" style={{ width: 22, height: 22 }} />

Check warning on line 836 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span style={{ fontFamily: "var(--font-spectral), 'Spectral', Georgia, serif", fontWeight: 700, fontSize: 17, color: 'var(--brand-forest)', letterSpacing: '-0.02em' }}>Sapling</span>
</div>
<div>
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/app/globals.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,6 +261,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); }

/* Approval gate (#290) — the "you're in" beat.
The sapling draws itself once and the message steps in behind it; the
Expand Down
153 changes: 119 additions & 34 deletions frontend/src/components/AtmosphericBackdrop.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Expand All@@ -27,8 +31,14 @@ 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;
const SPRITE_MAX_PX = 512;

// Warm, atmospheric palette — blues, purples, ambers, teals. Green is
// reserved for UI branding, so it's intentionally absent here.
const PALETTE = [
Expand DownExpand Up@@ -58,12 +68,42 @@ 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;
// 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, 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));
sctx.fillStyle = grad;
sctx.fillRect(0, 0, size, size);
return sprite;
}

export function AtmosphericBackdrop() {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const rafRef = React.useRef<number | null>(null);
const orbsRef = React.useRef<Orb[]>([]);
const sizeRef = React.useRef<{ w: number; h: number; dpr: number }>({ w: 0, h: 0, dpr: 1 });
const reducedMotionRef = React.useRef<boolean>(false);
const bakedDprRef = React.useRef<number | null>(null);
const lastFrameRef = React.useRef<number | null>(null);

React.useEffect(() => {
const canvas = canvasRef.current;
Expand All@@ -75,9 +115,33 @@ 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 = () => {
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;
Expand All@@ -89,62 +153,83 @@ 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;
}
// 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 paint = (t: number) => {
// 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;
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.
for (const orb of orbsRef.current) {
const x = orb.x;
const y = orb.y;
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();
}

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).
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;
}
};

rafRef.current = requestAnimationFrame(paint);
const tick = (t: number) => {
if (reducedMotionRef.current) {
// Paint one still frame only.
drawFrame();
rafRef.current = null;
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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);
};

// 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);
};
}, []);
Expand Down
17 changes: 13 additions & 4 deletions frontend/src/components/KnowledgeGraph2D.testmode.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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", () => {
Expand All@@ -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/);
Expand All@@ -92,8 +101,8 @@ describe("KnowledgeGraph2D — test-mode determinism", () => {
<KnowledgeGraph2D nodes={NODES} edges={EDGES} width={600} height={480} />,
);
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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/eslint-suppressions.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,7 @@
"count": 1
},
"react-hooks/refs": {
"count": 13
"count": 12
}
},
"src/components/KnowledgeGraph3D.test.tsx": {
Expand Down
34 changes: 29 additions & 5 deletions frontend/src/app/(public)/page.tsx
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
'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 { HeroCard } from '@/components/HeroCard';
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. 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'), {
loading: () => <section id="how-it-works" className="landing-section relative" style={{ height: '340vh' }} />,
});

const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5000';

const SCRAMBLE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!<>-_\\/[]{}=+*^?#_";
Expand DownExpand Up@@ -219,12 +229,26 @@

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();
Expand DownExpand Up@@ -510,7 +534,7 @@
>
<div className="max-w-[88%] mx-auto flex items-center justify-between w-full">
<button type="button" aria-label="Sapling — scroll to top" className="flex items-center cursor-pointer group" style={{ gap: '4px' }} onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}>
<img src="/sapling-icon.svg" alt="" style={{ width: '26px', height: '26px', flexShrink: 0, position: 'relative', top: '-2px' }} />

Check warning on line 537 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span style={{ fontFamily: "var(--font-spectral), 'Spectral', Georgia, serif", fontWeight: 700, fontSize: '20px', color: 'var(--brand-forest)', letterSpacing: '-0.02em', lineHeight: 1.1 }}>Sapling</span>
</button>
<div className="flex items-center">
Expand DownExpand Up@@ -726,7 +750,7 @@
<footer className="landing-section border-t border-white/35 py-12 px-8 relative z-10">
<div className="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-2">
<img src="/sapling-icon.svg" alt="Sapling" style={{ width: '20px', height: '20px' }} />

Check warning on line 753 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span className="text-sm font-light tracking-wide text-[var(--text-dim)]">Sapling · © 2026</span>
</div>
<div className="flex flex-wrap justify-center gap-6">
Expand DownExpand Up@@ -809,7 +833,7 @@
display: 'flex', flexDirection: 'column', gap: 22,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<img src="/sapling-icon.svg" alt="Sapling" style={{ width: 22, height: 22 }} />

Check warning on line 836 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span style={{ fontFamily: "var(--font-spectral), 'Spectral', Georgia, serif", fontWeight: 700, fontSize: 17, color: 'var(--brand-forest)', letterSpacing: '-0.02em' }}>Sapling</span>
</div>
<div>
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/app/globals.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,6 +261,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); }

/* Approval gate (#290) — the "you're in" beat.
The sapling draws itself once and the message steps in behind it; the
Expand Down
153 changes: 119 additions & 34 deletions frontend/src/components/AtmosphericBackdrop.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Expand All@@ -27,8 +31,14 @@ 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;
const SPRITE_MAX_PX = 512;

// Warm, atmospheric palette — blues, purples, ambers, teals. Green is
// reserved for UI branding, so it's intentionally absent here.
const PALETTE = [
Expand DownExpand Up@@ -58,12 +68,42 @@ 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;
// 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, 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));
sctx.fillStyle = grad;
sctx.fillRect(0, 0, size, size);
return sprite;
}

export function AtmosphericBackdrop() {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const rafRef = React.useRef<number | null>(null);
const orbsRef = React.useRef<Orb[]>([]);
const sizeRef = React.useRef<{ w: number; h: number; dpr: number }>({ w: 0, h: 0, dpr: 1 });
const reducedMotionRef = React.useRef<boolean>(false);
const bakedDprRef = React.useRef<number | null>(null);
const lastFrameRef = React.useRef<number | null>(null);

React.useEffect(() => {
const canvas = canvasRef.current;
Expand All@@ -75,9 +115,33 @@ 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 = () => {
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;
Expand All@@ -89,62 +153,83 @@ 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;
}
// 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 paint = (t: number) => {
// 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;
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.
for (const orb of orbsRef.current) {
const x = orb.x;
const y = orb.y;
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();
}

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).
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;
}
};

rafRef.current = requestAnimationFrame(paint);
const tick = (t: number) => {
if (reducedMotionRef.current) {
// Paint one still frame only.
drawFrame();
rafRef.current = null;
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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);
};

// 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);
};
}, []);
Expand Down
17 changes: 13 additions & 4 deletions frontend/src/components/KnowledgeGraph2D.testmode.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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", () => {
Expand All@@ -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/);
Expand All@@ -92,8 +101,8 @@ describe("KnowledgeGraph2D — test-mode determinism", () => {
<KnowledgeGraph2D nodes={NODES} edges={EDGES} width={600} height={480} />,
);
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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/eslint-suppressions.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,7 @@
"count": 1
},
"react-hooks/refs": {
"count": 13
"count": 12
}
},
"src/components/KnowledgeGraph3D.test.tsx": {
Expand Down
34 changes: 29 additions & 5 deletions frontend/src/app/(public)/page.tsx
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
'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 { HeroCard } from '@/components/HeroCard';
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. 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'), {
loading: () => <section id="how-it-works" className="landing-section relative" style={{ height: '340vh' }} />,
});

const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5000';

const SCRAMBLE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!<>-_\\/[]{}=+*^?#_";
Expand DownExpand Up@@ -219,12 +229,26 @@

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();
Expand DownExpand Up@@ -510,7 +534,7 @@
>
<div className="max-w-[88%] mx-auto flex items-center justify-between w-full">
<button type="button" aria-label="Sapling — scroll to top" className="flex items-center cursor-pointer group" style={{ gap: '4px' }} onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}>
<img src="/sapling-icon.svg" alt="" style={{ width: '26px', height: '26px', flexShrink: 0, position: 'relative', top: '-2px' }} />

Check warning on line 537 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span style={{ fontFamily: "var(--font-spectral), 'Spectral', Georgia, serif", fontWeight: 700, fontSize: '20px', color: 'var(--brand-forest)', letterSpacing: '-0.02em', lineHeight: 1.1 }}>Sapling</span>
</button>
<div className="flex items-center">
Expand DownExpand Up@@ -726,7 +750,7 @@
<footer className="landing-section border-t border-white/35 py-12 px-8 relative z-10">
<div className="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-2">
<img src="/sapling-icon.svg" alt="Sapling" style={{ width: '20px', height: '20px' }} />

Check warning on line 753 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span className="text-sm font-light tracking-wide text-[var(--text-dim)]">Sapling · © 2026</span>
</div>
<div className="flex flex-wrap justify-center gap-6">
Expand DownExpand Up@@ -809,7 +833,7 @@
display: 'flex', flexDirection: 'column', gap: 22,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<img src="/sapling-icon.svg" alt="Sapling" style={{ width: 22, height: 22 }} />

Check warning on line 836 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span style={{ fontFamily: "var(--font-spectral), 'Spectral', Georgia, serif", fontWeight: 700, fontSize: 17, color: 'var(--brand-forest)', letterSpacing: '-0.02em' }}>Sapling</span>
</div>
<div>
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/app/globals.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,6 +261,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); }

/* Approval gate (#290) — the "you're in" beat.
The sapling draws itself once and the message steps in behind it; the
Expand Down
153 changes: 119 additions & 34 deletions frontend/src/components/AtmosphericBackdrop.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Expand All@@ -27,8 +31,14 @@ 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;
const SPRITE_MAX_PX = 512;

// Warm, atmospheric palette — blues, purples, ambers, teals. Green is
// reserved for UI branding, so it's intentionally absent here.
const PALETTE = [
Expand DownExpand Up@@ -58,12 +68,42 @@ 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;
// 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, 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));
sctx.fillStyle = grad;
sctx.fillRect(0, 0, size, size);
return sprite;
}

export function AtmosphericBackdrop() {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const rafRef = React.useRef<number | null>(null);
const orbsRef = React.useRef<Orb[]>([]);
const sizeRef = React.useRef<{ w: number; h: number; dpr: number }>({ w: 0, h: 0, dpr: 1 });
const reducedMotionRef = React.useRef<boolean>(false);
const bakedDprRef = React.useRef<number | null>(null);
const lastFrameRef = React.useRef<number | null>(null);

React.useEffect(() => {
const canvas = canvasRef.current;
Expand All@@ -75,9 +115,33 @@ 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 = () => {
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;
Expand All@@ -89,62 +153,83 @@ 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;
}
// 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 paint = (t: number) => {
// 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;
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.
for (const orb of orbsRef.current) {
const x = orb.x;
const y = orb.y;
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();
}

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).
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;
}
};

rafRef.current = requestAnimationFrame(paint);
const tick = (t: number) => {
if (reducedMotionRef.current) {
// Paint one still frame only.
drawFrame();
rafRef.current = null;
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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);
};

// 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);
};
}, []);
Expand Down
17 changes: 13 additions & 4 deletions frontend/src/components/KnowledgeGraph2D.testmode.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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", () => {
Expand All@@ -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/);
Expand All@@ -92,8 +101,8 @@ describe("KnowledgeGraph2D — test-mode determinism", () => {
<KnowledgeGraph2D nodes={NODES} edges={EDGES} width={600} height={480} />,
);
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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/eslint-suppressions.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,7 @@
"count": 1
},
"react-hooks/refs": {
"count": 13
"count": 12
}
},
"src/components/KnowledgeGraph3D.test.tsx": {
Expand Down
34 changes: 29 additions & 5 deletions frontend/src/app/(public)/page.tsx
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
'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 { HeroCard } from '@/components/HeroCard';
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. 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'), {
loading: () => <section id="how-it-works" className="landing-section relative" style={{ height: '340vh' }} />,
});

const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5000';

const SCRAMBLE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!<>-_\\/[]{}=+*^?#_";
Expand DownExpand Up@@ -219,12 +229,26 @@

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();
Expand DownExpand Up@@ -510,7 +534,7 @@
>
<div className="max-w-[88%] mx-auto flex items-center justify-between w-full">
<button type="button" aria-label="Sapling — scroll to top" className="flex items-center cursor-pointer group" style={{ gap: '4px' }} onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}>
<img src="/sapling-icon.svg" alt="" style={{ width: '26px', height: '26px', flexShrink: 0, position: 'relative', top: '-2px' }} />

Check warning on line 537 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span style={{ fontFamily: "var(--font-spectral), 'Spectral', Georgia, serif", fontWeight: 700, fontSize: '20px', color: 'var(--brand-forest)', letterSpacing: '-0.02em', lineHeight: 1.1 }}>Sapling</span>
</button>
<div className="flex items-center">
Expand DownExpand Up@@ -726,7 +750,7 @@
<footer className="landing-section border-t border-white/35 py-12 px-8 relative z-10">
<div className="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-2">
<img src="/sapling-icon.svg" alt="Sapling" style={{ width: '20px', height: '20px' }} />

Check warning on line 753 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span className="text-sm font-light tracking-wide text-[var(--text-dim)]">Sapling · © 2026</span>
</div>
<div className="flex flex-wrap justify-center gap-6">
Expand DownExpand Up@@ -809,7 +833,7 @@
display: 'flex', flexDirection: 'column', gap: 22,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<img src="/sapling-icon.svg" alt="Sapling" style={{ width: 22, height: 22 }} />

Check warning on line 836 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span style={{ fontFamily: "var(--font-spectral), 'Spectral', Georgia, serif", fontWeight: 700, fontSize: 17, color: 'var(--brand-forest)', letterSpacing: '-0.02em' }}>Sapling</span>
</div>
<div>
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/app/globals.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,6 +261,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); }

/* Approval gate (#290) — the "you're in" beat.
The sapling draws itself once and the message steps in behind it; the
Expand Down
153 changes: 119 additions & 34 deletions frontend/src/components/AtmosphericBackdrop.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Expand All@@ -27,8 +31,14 @@ 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;
const SPRITE_MAX_PX = 512;

// Warm, atmospheric palette — blues, purples, ambers, teals. Green is
// reserved for UI branding, so it's intentionally absent here.
const PALETTE = [
Expand DownExpand Up@@ -58,12 +68,42 @@ 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;
// 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, 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));
sctx.fillStyle = grad;
sctx.fillRect(0, 0, size, size);
return sprite;
}

export function AtmosphericBackdrop() {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const rafRef = React.useRef<number | null>(null);
const orbsRef = React.useRef<Orb[]>([]);
const sizeRef = React.useRef<{ w: number; h: number; dpr: number }>({ w: 0, h: 0, dpr: 1 });
const reducedMotionRef = React.useRef<boolean>(false);
const bakedDprRef = React.useRef<number | null>(null);
const lastFrameRef = React.useRef<number | null>(null);

React.useEffect(() => {
const canvas = canvasRef.current;
Expand All@@ -75,9 +115,33 @@ 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 = () => {
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;
Expand All@@ -89,62 +153,83 @@ 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;
}
// 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 paint = (t: number) => {
// 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;
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.
for (const orb of orbsRef.current) {
const x = orb.x;
const y = orb.y;
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();
}

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).
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;
}
};

rafRef.current = requestAnimationFrame(paint);
const tick = (t: number) => {
if (reducedMotionRef.current) {
// Paint one still frame only.
drawFrame();
rafRef.current = null;
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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);
};

// 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);
};
}, []);
Expand Down
17 changes: 13 additions & 4 deletions frontend/src/components/KnowledgeGraph2D.testmode.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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", () => {
Expand All@@ -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/);
Expand All@@ -92,8 +101,8 @@ describe("KnowledgeGraph2D — test-mode determinism", () => {
<KnowledgeGraph2D nodes={NODES} edges={EDGES} width={600} height={480} />,
);
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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/eslint-suppressions.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,7 @@
"count": 1
},
"react-hooks/refs": {
"count": 13
"count": 12
}
},
"src/components/KnowledgeGraph3D.test.tsx": {
Expand Down
34 changes: 29 additions & 5 deletions frontend/src/app/(public)/page.tsx
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
'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 { HeroCard } from '@/components/HeroCard';
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. 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'), {
loading: () => <section id="how-it-works" className="landing-section relative" style={{ height: '340vh' }} />,
});

const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5000';

const SCRAMBLE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!<>-_\\/[]{}=+*^?#_";
Expand DownExpand Up@@ -219,12 +229,26 @@

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();
Expand DownExpand Up@@ -510,7 +534,7 @@
>
<div className="max-w-[88%] mx-auto flex items-center justify-between w-full">
<button type="button" aria-label="Sapling — scroll to top" className="flex items-center cursor-pointer group" style={{ gap: '4px' }} onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}>
<img src="/sapling-icon.svg" alt="" style={{ width: '26px', height: '26px', flexShrink: 0, position: 'relative', top: '-2px' }} />

Check warning on line 537 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span style={{ fontFamily: "var(--font-spectral), 'Spectral', Georgia, serif", fontWeight: 700, fontSize: '20px', color: 'var(--brand-forest)', letterSpacing: '-0.02em', lineHeight: 1.1 }}>Sapling</span>
</button>
<div className="flex items-center">
Expand DownExpand Up@@ -726,7 +750,7 @@
<footer className="landing-section border-t border-white/35 py-12 px-8 relative z-10">
<div className="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-2">
<img src="/sapling-icon.svg" alt="Sapling" style={{ width: '20px', height: '20px' }} />

Check warning on line 753 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span className="text-sm font-light tracking-wide text-[var(--text-dim)]">Sapling · © 2026</span>
</div>
<div className="flex flex-wrap justify-center gap-6">
Expand DownExpand Up@@ -809,7 +833,7 @@
display: 'flex', flexDirection: 'column', gap: 22,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<img src="/sapling-icon.svg" alt="Sapling" style={{ width: 22, height: 22 }} />

Check warning on line 836 in frontend/src/app/(public)/page.tsx

View workflow job for this annotation

GitHub Actions/ Frontend (lint + tsc + vitest)

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
<span style={{ fontFamily: "var(--font-spectral), 'Spectral', Georgia, serif", fontWeight: 700, fontSize: 17, color: 'var(--brand-forest)', letterSpacing: '-0.02em' }}>Sapling</span>
</div>
<div>
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/app/globals.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -261,6 +261,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); }

/* Approval gate (#290) — the "you're in" beat.
The sapling draws itself once and the message steps in behind it; the
Expand Down
153 changes: 119 additions & 34 deletions frontend/src/components/AtmosphericBackdrop.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Expand All@@ -27,8 +31,14 @@ 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;
const SPRITE_MAX_PX = 512;

// Warm, atmospheric palette — blues, purples, ambers, teals. Green is
// reserved for UI branding, so it's intentionally absent here.
const PALETTE = [
Expand DownExpand Up@@ -58,12 +68,42 @@ 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;
// 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, 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));
sctx.fillStyle = grad;
sctx.fillRect(0, 0, size, size);
return sprite;
}

export function AtmosphericBackdrop() {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const rafRef = React.useRef<number | null>(null);
const orbsRef = React.useRef<Orb[]>([]);
const sizeRef = React.useRef<{ w: number; h: number; dpr: number }>({ w: 0, h: 0, dpr: 1 });
const reducedMotionRef = React.useRef<boolean>(false);
const bakedDprRef = React.useRef<number | null>(null);
const lastFrameRef = React.useRef<number | null>(null);

React.useEffect(() => {
const canvas = canvasRef.current;
Expand All@@ -75,9 +115,33 @@ 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 = () => {
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;
Expand All@@ -89,62 +153,83 @@ 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;
}
// 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 paint = (t: number) => {
// 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;
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.
for (const orb of orbsRef.current) {
const x = orb.x;
const y = orb.y;
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();
}

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).
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;
}
};

rafRef.current = requestAnimationFrame(paint);
const tick = (t: number) => {
if (reducedMotionRef.current) {
// Paint one still frame only.
drawFrame();
rafRef.current = null;
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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);
};

// 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);
};
}, []);
Expand Down
17 changes: 13 additions & 4 deletions frontend/src/components/KnowledgeGraph2D.testmode.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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", () => {
Expand All@@ -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/);
Expand All@@ -92,8 +101,8 @@ describe("KnowledgeGraph2D — test-mode determinism", () => {
<KnowledgeGraph2D nodes={NODES} edges={EDGES} width={600} height={480} />,
);
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.
Expand Down
Loading
Loading