Replace the /agents hero background with a layered signal field - #1595
Conversation
Five switchable canvas concepts behind the real hero copy, with a restraint slider (whisper → ambient), light/dark toggle, and copy/veil toggles so we can judge legibility per concept. Standalone file — not wired into the Next.js app. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
Presets (baseline, clocked, reserve, delivery, etch, wall) vary trail style, motion quantization, accent ratio, destinations, residue, and pointer contract. Live controls for pitch/speed/trail/accent/substrate/ pointer so the axes can be dialed independently of the presets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
Working notes only — strip this file before opening a PR (see the "Before opening a PR" section). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
…trols Any preset can now be crossed with any other. Toggling mid-run is handled: turning targets off clears in-flight destinations, and cycling etch resets the residue buffer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
Adds the mechanic rounds 1 and 2 lacked: fork and merge. Every runner carries a mass and a plane, and the two events move both in opposite directions — forks halve mass and push a child back a plane, merges sum mass and pull the survivor forward. Trace weight, color, speed, and softness all derive from those two numbers, so accent restraint becomes structural rather than a probability roll. Destinations return as four deterministically placed front-plane pads, gated on mass, which fixes round 2's random lopsided placement and makes arrival something a signal has to converge to earn. Canvas 2D. WebGL is deferred with the three things it would actually buy written down, so the decision can be revisited on evidence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
Ten tasks. The pure law arithmetic is extracted into law.js so it can be tested with node:test before anything is drawn; the rest grows layers.html out of runners.html one system at a time, with planes:1 held as a parity control against round 2 throughout. Path2D trail batching lands before depth planes rather than after, since tripling the per-segment gradient cost across three planes is the actual perf cliff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
…d parallax Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
…untime Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
Collision detection only ever fires between two runners already on the same plane, so Math.min(za, zb) was always a no-op and the pull-forward merge path was structurally dead code. mergePlane now floors at 0 and steps one plane toward the front of wherever the meeting happened.
… runners Replace round 2's random target placement with HeroLaw.padAnchors (deterministic, derived from the hero copy's bounding box). Gate arrival on HeroLaw.canClaim so only a front-plane runner whose mass has genuinely converged past claimMass can claim a pad; anything lighter passes straight through. Claimed pads hold a lit state that decays over ~8s. Raised claimMass from 0.9 to 1.05: measured that at 0.9, most claims came from untouched, never-forked spawns (base mass is 1), not from actual convergence. 1.05 sits just past the base-spawn floor so only a genuine merge can clear it.
The boolean front-plane guard restricted mud but also restricted the effect the design wanted: a full-field accumulated circuit. Multiply each runner's residue write by its own plane dim factor (the same falloff already used for trails and heads) so all three planes write, back planes faintly and the front strongly. At planes:1 the dim factor is exactly 1, so this is byte-for-byte the original single-plane write.
Ribbon batch alpha drifted ~33% brighter than round 2 (measured, not estimated): bucket representatives used the upper edge, the taper exponent had moved 1.6 -> 2, and the 0.85 constant was gone. Restored all three; the flat preset now measures within 0.2% of runners.html. Etch residue and collision sparks were written at raw lattice coordinates while their trails render inside the plane's scale-about-centre transform, so a back-plane runner laid residue up to ~26px off its own trail. Both now mirror that transform; burst() short-circuits at scale 1 so planes:1 stays bit-exact. Also: mass now drives trail width in the segments style (it was a constant, so the circuit preset showed none of the law's payoff); law.test.js pins the mass-1/tier-3 width anchor and the planes:1 identity collapse; stale "front-plane etch" copy corrected in four places; dead CONTROLS array deleted; round 3 identity in the title and brand; assorted comment and copy fixes. The five parity defaults are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
Four items the final review triaged as acceptable, preserved because two of them bear on the eventual port into AnimatedDotGrid.tsx and would otherwise have lived only in a gitignored workspace. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
The two control rows sat between the tabs and the stage, which pushed the canvas down and split related parameters across a wrap. They are now one column beside the stage: label left, value right, control full-width beneath. Under 900px the column stacks back into rows so it cannot crush the canvas. Verified at 1440/900/390: all 24 controls present and wired, live values respond through the real DOM elements, presets switch, no console errors, no horizontal overflow. Parity defaults and law tests untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
- The parameter set moved to a right-hand column; the doc still said "second control row". - The npm install line was the exact command that escaped into the real repo's package.json during round 3. Replaced with the existing scratch path and a warning. - window.env being undefined cost two agents significant time and was documented nowhere. Now shown with the working page.evaluate form. - Seven round-3 rulings that overrode the plan mid-build are now recorded with their reasons. They read as arbitrary constants in the code; each one is load-bearing and at least two are silently re-breakable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
The density/time-scale intro still opened at full brightness: few, slow runners, each arriving with crisp trails and maximum bloom. Layers, sparks, and bloom now multiply by an ink envelope (0.3 -> 1 on the same cubic-out intro curve), so the first runners emerge dim and the shine develops as the field populates. The CSS canvas fade stretches from 1s ease to 2.5s ease-out. Reduced-motion scenes skip the intro, so their still stays at full brightness; the opacity fade (not motion) applies and the field is verified static once it completes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
There was a problem hiding this comment.
Risk HIGH: Replaces the /agents hero background by rewriting AnimatedDotGrid.tsx with a new layered signal-field canvas animation (~1,400 lines added).
Reasons
- Modifies a
.tsxfile undercomponents/— a HIGH-risk trigger per classification rules. - Large component rewrite (1,407 additions / 265 deletions) with new simulation, rendering, and interaction logic.
- Affects a shared UI component used on
/agents; regressions could impact page performance, theme behavior, or accessibility. - Introduces runtime canvas work (runner simulation, pointer bias, pad completion cascades) that warrants manual visual and performance review.
- Not a content-only or OpenAPI spec change — scope is front-end animation infrastructure.
Notes
- Run
yarn devand review/agentsin dark/light mode at desktop and mobile widths; the PR author notes entry animation, pointer response, and pad completions are best verified live. - Confirm reduced-motion behavior renders a static frame and does not animate.
- Watch for console errors, frame-rate drops offscreen (IntersectionObserver pause), and theme palette integration via
useTheme(). - Pad placement is random per load — spot-check several hard refreshes for layout balance.
- Author
ksorathiais not on the repository contributor list; no auto-approval applied.
Sent by Cursor Automation: Docs PR classifier
Restraint 0.69 -> 0.6: ~22 runners instead of ~25, fork cap 65 -> 58, slightly less ink overall. The lower cap also clamps the early fork froth a fresh field opens with — the "starts hot, cools down" read. Veil gradient strengthened a step (82/58% surface mix -> 88/65%, feather 72% -> 75%) so hero copy sits on a calmer ground without losing the field behind it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
Both interactions existed in the prototype and were dropped in the port. Parallax: back planes shift away from the pointer at composite time, magnitude camera-derived (c * (1 - scale)) so it stays coherent with the perspective cues; the front plane stays pinned under the copy. Sparks and bloom halos ride their plane's offset so glow never slides off its stroke. New here: a smoothed camera chases the pointer and fades activity in/out, so entering or leaving the hero eases rather than snaps. Click: pointerdown seeds four boosted runners (always accent, brighter, longer trail, shorter ttl) at the nearest node plus a burst. Listener is window-level since the canvas wrapper passes pointer events through to the copy. Reduced-motion scenes attach neither interaction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
Dying runners never reach trimTrail, so the maxTrail shrink on death was a dead store (inherited from the prototype, which had the same one). The shipped look has always been fade-in-place; drop the store and say so. Click-to-spawn now listens for "click" instead of "pointerdown" — the browser suppresses click for touch scroll gestures, so scrolling through the hero no longer bursts — and requires the event target to live inside the hero section, so clicks on chrome overlaying the hero rect (the sticky masthead after scroll) pass through without spawning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
Boosted spawns rolled a random plane like any other; a back-plane spawn renders scaled about the stage center, so it materialized offset from the click it was answering while the burst landed at the cursor. Front- plane-only also restores the accent-lives-at-the-front law, removing the documented boosted exception. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
A runner mid-dive draws into two layers composited at different parallax offsets, so a moving pointer can briefly double-image the trail. The offset is geometry we accept (composite-time parallax, masked by the defocus); the exposure window is tunable. Half a second per plane still reads as travel rather than a pop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
The hero had three independent onsets: the copy animated from first paint, the canvas faded in on window.load, and the scene's intro envelope ran on its own 3.5s clock. On a warm load they overlapped; on a cold one the headline finished before the field started. Give them a shared t=0. AnimatedDotGrid reports its first drawn frame via onReady, and the page opens a gate on that or a 300ms fallback, whichever lands first — a slow or failed canvas can't hold the copy back. CSS pauses every stage animation until the gate opens; a noscript rule releases it when nothing is around to open it. Sequence the copy ahead of the field: the text lands, then the lattice blooms up behind it on a 450ms delay. Put the canvas fade on the same curve as .hero-rise and shorten the intro envelope so it co-terminates with the opacity fade instead of stacking a second dissolve behind it. Animate the veil in alongside the headline so the field reads as pushed back by the arriving copy rather than merely co-timed. Measurement moves off window.load, which is what made the field's start time depend on network speed. This also runs the synchronous presim earlier in page startup — worth watching for an INP regression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
The copy button swapped its icon and label with a visibility toggle, so the one moment the click is about had no feedback at all. Both glyphs and both labels now stay mounted and stacked in the same grid cell — the width still can't shift, but each pair can cross-fade with a short blur-off on the hero's curve, the check scales up as it arrives, and the label trails the icon by 40ms so the pair reads as one gesture. Press gets a scale(0.97), and the success state resets at 1.6s instead of 2s. Both labels are in the tree now, so an aria-live wrapper around them would announce both. The announcement moves to its own sr-only region and the visual labels go aria-hidden. Also lands the in-progress coding tool launch bar: the bare wordmark row becomes one bordered control with an "Open in" label and a ghost button per tool, and the hero heading drops its weight override. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
This branch rewrote AnimatedDotGrid in place, which put a 1,824-line file in the path of every other branch touching it. The new hero renderer moves to its own component and AnimatedDotGrid returns to its main version untouched, so both can live side by side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
…design # Conflicts: # pages/agents.tsx
Cleanups only — no intended change to what renders. - Fold hasIntroducedRef/hasReportedReadyRef into one hasBuiltRef; they were set together on the same first pass and could not diverge. - Drop sceneRef (write-only), isInitialized (a slower spelling of dimensions.width > 0), and the now-orphaned Scene type. Make the rAF and timestamp refs effect-locals, since nothing outside reads them. - Use lib/debounce instead of a third local copy; the shared helper gains the .cancel the local copies existed for. - Hoist grade() to a GRADE const — it derives from SETTINGS alone, so it was recomputed per spawn and per frame for a fixed result. - Stop allocating in the per-frame path: mutate r.head instead of replacing it, walk trail -> head by index instead of concat()ing a joined array per runner, and count live runners with a loop instead of allocating a filtered array. - PLANES now derives from LAYER_RES.length, which is what actually bounds it; the SETTINGS.planes knob only looked adjustable. - CSS: drop will-change (it can't retire itself, so it pinned a layer on every hero element for the life of the page), drop the veil's animation-delay (it is a sibling of the staggered copy, so the variable never resolved), and split the copy-CTA glyph/label transforms that were being overwritten a rule later. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
The entrance read as the background switching on. The cause was presim: at 60px/s a 4s pre-simulation is exactly one full 240px trail, so the first painted frame already had every runner streaked across the field, and the 1.8s opacity ramp then cross-faded a finished picture. A dissolve of a composed image reads as appearing, not generating. The opacity transition now only lifts the canvas off zero (0.9s); the arrival belongs to the intro envelope, which runs longer (2.4s -> 4.2s) from lower floors (density 0.35 -> 0.2, ink 0.3 -> 0.12) at a speed where traces visibly extend (timeScale 0.2 -> 0.45). presim is intro-aware: a first visit presims 0.5s so the traces draw on screen, while rebuilds from resize, theme switch, or scroll-return keep the full 4s — there is no entrance to watch on those, so they should still land composed. Reduced motion likewise keeps the full presim, since its single still has to be the complete field. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zQf5Vjbw9wBBTCCVywwep
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 568c195. Configure here.
| burst(px(ni), py(nj), 12, 1, r.accent, r.z); | ||
| r.dying = true; | ||
| if (other && !other.dying) other.dying = true; | ||
| return; |
There was a problem hiding this comment.
Stale occupancy allows cross-plane merges
Low Severity
Merge resolution looks up a cell owner by id but never checks that the owner still shares the incoming runner’s plane. After a survivor surfaces via setPlane, its old-plane scent can still trigger merges for about mergeWindow, so mass can jump across depths despite the same-plane-only collision rule.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 568c195. Configure here.
There was a problem hiding this comment.
Correct — occOwner is keyed per plane, but setPlane doesn't clear the mover's old-plane stamps, so a stale id can survive a merge for mergeWindow and pull mass across depths. Accepting it for this PR: it needs a stale-owner-means-empty-cell path in advanceCell (the naive same-plane guard is worse — it falls through to the annihilation branch and would kill a live runner on a phantom stamp), and the visible result is a rare, sub-second depth hop on a background animation. Tracking it as follow-up rather than reopening simulation logic on an approved PR.
The cross-fade needs both glyph and label stacks mounted, so both are aria-hidden — which left the button unnamed until a copy succeeded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LSdrJ7cMVDRBs9U6FkfL4




What
Rebuilds the
/agentshero: a new canvas background, and a pass over the copy that sits on top of it.The background (
AnimatedAgentRuns, a new component) replaces the node-cloud dot grid with a layered signal field. Runners travel a lattice across three depth planes, fork (mass halves, the child sinks a plane), and merge (mass sums, the survivor surfaces). Only a runner heavy enough to have merged can claim one of four endpoint pads — arrival is earned.Depth renders as one camera's worth of cues: perspective scale, resample defocus, atmospheric fog, and color cooling on the back planes, with bloom reserved for the front. All tuning lives in a documented
SETTINGSconstant at the top of the component.AnimatedDotGridstays where it is —KnockAiBannerstill uses it. This PR adds a component and points/agentsat it rather than rewriting the old one.Details
The field
useTheme(),IntersectionObserverpause offscreen, debouncedResizeObserver, one-time fade-in. The 55% bottom mask moved onto the canvas itself, so the page no longer wraps it in a maskingBox. Fog resolves--tgph-surface-1at runtime so back planes fade toward the real page surface.The copy on top
onReady, which opens a gate the heading, veil, and CTA animate off. A 300ms timer is the ceiling, so a slow or failed canvas can't hold the copy hostage;<noscript>releases the animations outright.aria-hidden, so the button carries anaria-labeland the confirmation lives in its own polite live region.lib/debounce.ts: debounced functions now expose.cancel(), so the resize handler can drop a pending call on unmount.Verification
yarn type-checkandyarn format.checkpass; CI green.Known issue
AnimatedAgentRunskeys cell occupancy per plane, butsetPlanedoesn't clear a mover's old-plane stamps — so for up tomergeWindow, a stale id can pull mass across depths despite the same-plane-only collision rule. Real but low severity: a rare, sub-second depth hop in a background animation. Shipping as-is; the thread records why the obvious same-plane guard is the wrong fix.Review notes
Best reviewed running (
yarn dev,/agents) — the entry, pointer response, and pad completions are the point. Hard-refresh replays the entry; pad placement varies per load.