Skip to content

Replace the /agents hero background with a layered signal field - #1595

Merged
ksorathia merged 68 commits into
mainfrom
worktree-agents-hero-design
Aug 11, 2026
Merged

Replace the /agents hero background with a layered signal field#1595
ksorathia merged 68 commits into
mainfrom
worktree-agents-hero-design

Conversation

@ksorathia

@ksorathia ksorathia commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

What

Rebuilds the /agents hero: 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 SETTINGS constant at the top of the component.

AnimatedDotGrid stays where it is — KnockAiBanner still uses it. This PR adds a component and points /agents at it rather than rewriting the old one.

Details

The field

  • Entry: the field draws itself in rather than switching on. Density, simulation speed, and ink start at floors and ease (cubic out) to full over 4.2s of live time. First paint presims 0.5s, not the full four — the point of the entrance is watching the traces extend, and a fully presimmed field has already done that offscreen.
  • Pads: placement is random per load among curated slots (one pad per region, so the composition can't go lopsided). A pad completes after 2 earned claims, plays a done cascade, fades out, and a successor fades in elsewhere.
  • Pointer: runners within 150px bias toward the cursor, the planes take a parallax push, and a click in the hero spawns a boosted burst on the front plane.
  • Reduced motion: renders a single pre-simulated composed frame at full density and brightness, never animates.
  • Carried over from the old component: theme palette via useTheme(), IntersectionObserver pause offscreen, debounced ResizeObserver, one-time fade-in. The 55% bottom mask moved onto the canvas itself, so the page no longer wraps it in a masking Box. Fog resolves --tgph-surface-1 at runtime so back planes fade toward the real page surface.

The copy on top

  • One entrance timeline: the canvas announces its first drawn frame through 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.
  • Copy CTA: both glyphs and both labels stay mounted and stacked, so the button cross-fades on copy instead of cutting and never reflows. The visible stacks are aria-hidden, so the button carries an aria-label and the confirmation lives in its own polite live region.
  • Coding tools: the bare hover-dimmed wordmark row became an "Open in" launch bar — one ghost icon button per tool, each deeplinking with the setup prompt loaded.
  • lib/debounce.ts: debounced functions now expose .cancel(), so the resize handler can drop a pending call on unmount.

Verification

  • Dark/light × 1280px/390px at 2s and 25s: zero console errors, steady 120fps in the prototype harness this was ported from.
  • Motion burst frames confirm depth cross-fades settle with no doubled traces.
  • Reduced-motion screenshots are byte-identical 1.5s apart.
  • yarn type-check and yarn format.check pass; CI green.

Known issue

AnimatedAgentRuns keys cell occupancy per plane, but setPlane doesn't clear a mover's old-plane stamps — so for up to mergeWindow, 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.

ksorathia and others added 30 commits August 3, 2026 16:33
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
@ksorathia
ksorathia marked this pull request as ready for review August 7, 2026 18:49

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Risk HIGH: Replaces the /agents hero background by rewriting AnimatedDotGrid.tsx with a new layered signal-field canvas animation (~1,400 lines added).

Reasons

  1. Modifies a .tsx file under components/ — a HIGH-risk trigger per classification rules.
  2. Large component rewrite (1,407 additions / 265 deletions) with new simulation, rendering, and interaction logic.
  3. Affects a shared UI component used on /agents; regressions could impact page performance, theme behavior, or accessibility.
  4. Introduces runtime canvas work (runner simulation, pointer bias, pad completion cascades) that warrants manual visual and performance review.
  5. Not a content-only or OpenAPI spec change — scope is front-end animation infrastructure.

Notes

  • Run yarn dev and review /agents in 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 ksorathia is not on the repository contributor list; no auto-approval applied.
Open in Web View Automation 

Sent by Cursor Automation: Docs PR classifier

Comment thread components/ui/AnimatedDotGrid.tsx Outdated
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
Comment thread components/ui/AnimatedDotGrid.tsx Outdated
Comment thread components/ui/AnimatedDotGrid.tsx Outdated
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
ksorathia and others added 2 commits August 10, 2026 11:08
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
ksorathia and others added 2 commits August 10, 2026 11:56
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
Comment thread pages/agents.tsx
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
ksorathia and others added 3 commits August 10, 2026 15:33
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

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 568c195. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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
@ksorathia
ksorathia merged commit 77e58e5 into main Aug 11, 2026
5 checks passed
@ksorathia
ksorathia deleted the worktree-agents-hero-design branch August 11, 2026 17:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants