Skip to content

feat: the descent becomes a deck — three explainer slides, /experience + /writing, and the fonts finally render - #46

Merged
IngTian merged 96 commits into
mainfrom
claude/quant-pod
Aug 12, 2026
Merged

feat: the descent becomes a deck — three explainer slides, /experience + /writing, and the fonts finally render#46
IngTian merged 96 commits into
mainfrom
claude/quant-pod

Conversation

@IngTian

@IngTian IngTian commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Replaces the résumé-shaped homepage with a scroll-snapped deck that argues the
field before it lists the work, adds three new routes, deletes the site's only
React island, and fixes a set of bugs found by an adversarial audit — including one
that had never rendered a single webfont, live included.

89 commits. Worth reviewing as the sections below rather than commit by commit.

The homepage is now a deck

Heights → Interlude → Choice → Rules → Solve → Story → Work → Signature, driven
by lib/deck.ts (pure, unit-tested) rather than CSS scroll-snap — snap was tried
first and measured: every slide gap is 1.8x–7.3x a single wheel gesture, so one
gesture proposes a destination the browser snaps back from. The numbers are kept
in global.css so nobody "simplifies" it back.

The three explainer slides answer what the problem is before what I've done,
on the owner's observation that the old page answered his trajectory but never his
field:

  • Choice — what the decision is, and how much of it there is.
  • Rules — what you may not do, as the feasible set collapsing. Real convex
    geometry: constraints are half-spaces, so the polytope is clipped and measured.
  • Solve"Still an open problem." A real finite-horizon dynamic program
    solved by backward induction, with lib/bellman.ts asserting the Bellman
    optimality condition at every node and that the traced route beats all 29
    enumerated candidate policies.

Slide copy went through several honesty passes at the owner's insistence — an
earlier version wrote out a formulation as if the problem were solved. It names
methodologies now, and every invented number is labelled as illustrative on-slide.

New routes

/experience, /writing, /research (rebuilt with expandable paper cards).
The scripted terminal — the site's only React island — is deleted, so the site now
ships no client framework at all.

The fixes an adversarial audit found

Each was reproduced with measurements before being touched, and re-measured after.

  • No webfont had ever rendered, on this branch or on the live site. astro:fonts
    was pointed at the same custom properties the stylesheet declares as type-role
    tokens, so our sheet overwrote its hashed family name with a bare 'Fraunces'
    that matches no @font-face. Measured before: 0 of 24 faces loaded, Georgia /
    .SF NS / Menlo painting everywhere. Each family owns its own --ff-* now.
  • One in-site navigation destroyed two slides. define-js/complex-js were
    added at module scope; Astro's ClientRouter swaps <html>. Round-tripping to
    /research grew #choice 900px → 1585px and made its beat buttons no-ops.
  • The dark-theme "Download CV" button was 1.64:1. Now 8.61:1, via the
    --on-accent token that already existed for exactly this.
  • The research fold read "like a ppt." A leftover keyframe animation with
    fill-mode: both pinned opacity to 1, so the fade could never start — a hard
    rectangular wipe. Deep links had also stopped opening their card.
  • Four phone defects, including a corner nav that lost its first destination
    off-screen on every inner route, and body text overlapping by up to 31.8px.
  • A horizontal scrollbar on the phone homepage (1fr tracks can't shrink
    below min-content).

One trade-off to decide before merging

Making the fonts render costs real time, A/B'd under identical conditions:

perf FCP LCP bytes
before 94 1585ms 2858ms 276KB
after 91–92 1957ms 3010ms 307KB

The +31KB is exactly the mono woff2, never fetched before because nothing matched
it. The FCP cost is font-display: swap's block period, now that text has a font
to wait for. Dropping Inter's preload to claw it back measured worse (FCP 2483ms).
Note the ≥99 bar was not being met on this machine before the change either, so a
CI reading is the real gate.

Also worth knowing: nobody has ever seen this site in its intended typography,
so every judgement about spacing and hierarchy on the branch was made against
Georgia and system-ui. The design deserves a fresh look.

Gates

798 tests green (vitest), npm run build clean, Lighthouse a11y / best-practices /
SEO all 100. Verified across 7 routes x 6 widths (320 → 1920): no horizontal
overflow, and every deck slide fits its own panel. Both themes screenshotted.

Still open, deliberately not in this PR: the deck's final advance at desktop sizes,
an ArrowDown that both steps a beat and advances a slide, a 55–60ms theme-toggle
block, and a code-cleanliness list (duplicate beat steppers, dead .terminal-chrome
CSS, unreachable QuantPod code, React/three.js still in dependencies, and
CLAUDE.md/README describing the deleted terminal).

🤖 Generated with Claude Code

IngTian and others added 30 commits August 8, 2026 02:48
terrain.project bakes a look-down camera (YAW 32deg/TILT 53deg); fed a desk it
rotates the bench diagonally (front-left y=769 vs front-right y=372), which reads
as a tilted table rather than a desk you sit at. podProject removes yaw and uses an
18deg downward tilt, keeping terrain's maths shape so it is a sibling not a fork.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Objects as pod-space data with depth sorting, plus screenRects() returning
PERCENTAGE boxes so the DOM overlay links need no JS on resize.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parsePeriod handles every irregular period string in profile.ts — two-digit end
years, open-ended ranges, a leading 'Fall'. Tested against the real data so a
future entry with a new format fails loudly instead of silently vanishing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All generators are pure and seeded (never Math.random) so the static first frame
matches the animated one and screenshots reproduce. SCREEN_CONTENT is the single
source of truth binding each slot to its picture, label and destination, so a
screen and its link cannot disagree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixed three undefined identifiers that would be noticed immediately by quant readers:
- `c` (turnover cost) now defined inline as `12e-4` (12 bps) with comment
- `within` and `dispersion` added to the `from risk import` line

The snippet is now self-contained: every identifier is either defined, imported,
or belongs to an external namespace (np.).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The only module touching canvas APIs; geometry and content come from the tested
pure modules. Implements the 'dots' style (pointillist cyber register, matching
the terrain hero's established aesthetic): the wall/bench/objects render as
sparse-to-dense dot fields with per-dot alpha variation; screens stay crisp text
and lines (NOT dots, so they remain legible as lit monitors). Dark surroundings
are deliberately near-black because that is the lighting design that makes lit
screens read as lit.

The 'dots' style is shipped first: lowest-risk (it is already the house
aesthetic), honest to the site's register, and cheap (coarse spacing = ~1k dots
across the full frame at 2× DPR). Three other styles (pixel, constr, real) will
be added in Task 9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FINDING 1 — Math.random() for per-dot alpha made the pod shimmer on hover/theme
change/resize, and screenshots never reproduced. Replaced all three sites (wall,
bench, desk objects) with a deterministic hash of the dot's lattice position via
dotJitter(x, y), matching the seeded() pattern already used in podScreens.ts.

FINDING 2 — paintGantt divided by (hi - lo), which NaNs out when every bar has
the same start and end year. Added a guard: `span = Math.max(1, hi - lo)` so
the divide is always safe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The canvas is aria-hidden decoration; every destination is a real focusable
element positioned by PERCENTAGE over its monitor (<a> to navigate, <button> to
announce), so keyboard, screen readers and JS-off all work with no second code
path. No React island; vanilla script re-inits on astro:page-load with teardown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ROOT CAUSE: Three compounding failures: (1) hardcoded RGB values ignored the
palette function, painting dark-theme colors in light theme; (2) alpha values
were far too low (wall 0.056-0.08, bench 0.084-0.34) to read against the page;
(3) off-canvas dots weren't clipped, wasting paint; (4) screen content overflow
wasn't constrained.

CHANGES:
- Wall/bench/desk objects now parse palette colors and use higher alpha (wall
  0.126-0.18, bench 0.168-0.56, objects 0.154-0.42), denser spacing (14/8/6px),
  larger radius (1.1/1.2/1.0 DPR) so they're clearly visible.
- Clamp loops to [0,W) x [0,H) so off-canvas dots aren't painted.
- Bloomberg columns tightened (0.36/0.64 not 0.42/0.74) to fit.
- LaTeX headings scale down if too wide.
- Monitor bezels get a lighter top edge (benchEdge color) for depth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four geometry/paint changes:
1. Bench and wall now SOLID filled polygons at alpha 1.0, not sparse
   dot fields. Added lit front lip (3px band, benchLip color) and
   screen-spill gradient on bench top. Subtle dot texture on top.
2. Pixel-art treatment: imageSmoothingEnabled=false for geometry,
   snap projected coords to 3px grid, monitor bezels 3px with lit
   top/dark bottom edges, desk objects flat blocks with lighter
   1px top face. Screen content stays smoothed (text readable).
3. Pod paints opaque background (roomBg) first; canvas CSS mask
   dissolves top/bottom 8% into page (hard left/right edges).
4. Geometry adjusted: HORIZON 0.62→0.68, bench front -1.05→-1.35
   so bench fills frame bottom, no empty 26% band.

Two content bugs fixed:
5. ganttBars: no duplicate labels (keep longer-running), PhD FIRST
   (quant-first hierarchy). Tests lock both properties.
6. Bloomberg panel: shortened last values (5,410 not 5,412.18) so
   columns fit inside bezel, reduced font 3.5→3.2, moved column
   x-fractions, verify with measureText.

Measured: alpha census now 100% painted across all bands (before:
bench 10-14%, bottom 26% empty). Visual: deliberate pixel-art pod,
bench + wall solid and recognizable in both themes.

Tests: all 146 pass (2 new ganttBars tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The isometric desk read as an abstract diagram, not a place. Three things
were wrong architecturally, so this replaces rather than patches it:

- It was a slab in a void. Now it is a ROOM: city through a mullioned
  window, shelf with books/mugs/plant/plate, framed prints, wall vent,
  pendant lamp, floor with board seams, and your chair back in the
  foreground so you are the one sitting here.
- It faked pixel art at 1180px by snapping to a 3px grid. Real pixel art
  is authored small and scaled up, so the room paints a fixed 480x280
  buffer that CSS blows up with image-rendering: pixelated. Paint cost is
  now independent of viewport size, and there is no DPR handling at all.
- It baked prose into 140px-wide screens, which is what produced every
  clipping bug (measured: a 47-char line needing 453px in a 126px box, a
  3.6x overflow). Screens now show ICONIC content — token-length bars, the
  equity curve, career spans, a paper's shape, market rows — and the real
  words are HTML labels under each monitor, so they stay selectable and
  screen-reader-native.

Full-bleed, and the page ends inside the room: the descent's sky stops
here and the links sit on the floor. That also dissolves the seam problem
rather than tuning it — there is no edge to blend when there is no edge.

Adds outlined objects and ordered (Bayer) dithering, which is what makes
depth read as value bands without gradients. Palette is theme-driven from
the site's tokens (ochre light / emerald dark, seal red in both) and stays
a NIGHT room in both themes, since --bg never flips bright.

podGeometry.ts, podPaint.ts and their test are superseded and removed;
nothing else referenced them. podRoom.test.ts locks the screen layout
(shared by the painter AND the DOM overlay, so drift is silent), the
buffer size, and the palette's value ramp.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three notes from the owner, all addressed:

"add a roof top so it blends with the descent" — the room now has a
ceiling: recessed troffers in housings, a cable tray, and seams converging
on the vanishing axis. Its dark upper band is where the descent's sky
lands, so there is no hard top edge.

"make the monitor, the desk, the paper etc 3D" — everything is extruded.
box() draws a front face, a slanted side face and a lit top face, and which
side a box shows depends on which side of the room's vanishing axis it sits
on. That is one-point perspective, and it is what the flat-rectangle pass
was missing. The desk gains an apron, a dark base shadow and legs.

"in a quant shop monitors are stacked and each bigger than this" — the row
became a RIG: three big primaries at eye level on a mounting bar, four
smaller stacked above on a cross-brace. Two of the upper four are ambient
(a correlation heatmap, a ticker tape) with no link, so the wall reads as a
working desk rather than exactly-as-many-monitors-as-there-are-links.

"higher res would be great, I'm not saying keep extra low res" — the buffer
goes 480x280 -> 800x440. Still genuine pixel art (each buffer pixel paints
as a crisp 2x2 block at a 1600px viewport) but with room for the detail a
trading floor needs.

Also a trading-floor vocabulary the bedroom version lacked: three world
clocks, a server rack cropped by the frame edge, a workstation tower under
the desk, cable grommets. The window moved to a tall left column so the
city is visible instead of a sliver behind the rig, and the coffee is a
tapered mug with a rim and a ring handle rather than a grey brick.

Splits the module in two: podRoom.ts is pure layout + palette (unit-tested,
28 tests), podRoomPaint.ts is the only canvas-touching half. rigFrame()
derives the posts from the monitor rows, so moving the rig can never leave
its posts behind — the previous hardcoded posts did exactly that.

One test earns its keep already: it caught a composition pass shrinking the
monitors to make room for the window, which is the opposite of what was
asked. The fix was to narrow the window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five notes from the owner, all addressed:

"keep going higher res (way higher)" + "the monitor needs to be way higher
res, I mean the contents" — buffer goes 800x440 -> 1600x880. The contents
were the real constraint: a primary screen is now ~330x190 buffer px, which
is enough for an editor with a gutter, 20 token-run lines and a minimap; a
backtest with tick-marked axes, a drawdown underwater panel and a legend; a
Gantt with a year axis and a "now" marker; a two-column paper with a figure;
a market panel with sparklines and up/down arrows. The pixel-art look now
comes from flat shading, hard 1px edges and ordered dithering rather than
from upscaling.

"more 3D feel. the monitor can be tilted by a few degrees" — nothing is
straight-on any more. Each monitor YAWS inward toward the viewer (13 deg at
the ends of the rig, less toward the vanishing axis) and TILTS back 6 deg,
so it is a QUAD, not a rectangle, and its exposed top face is drawn as a
separate lit strip. That top strip plus the two vertical edges differing in
height is the whole 3D read.

"usually you have an arm support behind that monitor" — every monitor now
hangs off a VESA arm: a drop from the rig bar, an elbow joint, and a mount
plate visible either side of the yawed panel.

"the hover effect is not good enough. too loud" — the outline ring and glow
bloom are gone. Hover is now a 2px accent underline along the monitor's chin
plus a barely-there dither on the glass, and the chin LED lights.

Screen content renders rectangular into a cached offscreen buffer, then
blits into the tilted quad column by column with per-column vertical
scaling — perspective without an affine transform, and content code stays
rectangular and readable. Dithering switched to cached 4x4 canvas patterns
because a per-pixel loop at this size costs hundreds of thousands of calls
per repaint.

Tests cover the new camera maths: yaw direction per side, monotonic yaw with
distance from the axis, tilt always dropping the top edge, quads inside the
buffer, and the glass inheriting the yaw. One caught a real wrong assumption
of mine — I asserted the middle monitor is square-on, but the rig is
deliberately shifted right to clear the window, so it is yawed too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two compounding causes, both mine:

- The source buffer was sized to the quad's BOUNDING BOX height. Every
  column of a trapezoid is shorter than its bounding box, so each column got
  squashed by a different amount and the content sagged toward the middle.
  It is now sized to the trapezoid's unwrapped height — the mean of its two
  vertical edges.
- blitQuad rounded each column's HEIGHT independently, accumulating a
  per-column rounding error in the same direction. It now rounds the top and
  bottom edges and derives the height from them, so the error cannot
  accumulate.

Visible in the heatmap's rows and the equity curve, both of which now read
level.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…el one

The owner asked to try retrofuturism instead of pixel art, and to keep the
pixel work rather than discard it. So: a SECOND painter over the SAME
geometry module, and a live switcher (press 1 / 2, or the pill at the top of
the room) because small-detail screenshots are unjudgeable.

The pixel work is preserved on branch claude/quant-pod-pixel, pushed.

Why a separate file rather than a flag: the two styles have OPPOSITE
rendering rules. Pixel art needs integer rects, hard edges and
imageSmoothingEnabled = false; retrofuturism needs curved CRT glass, chrome
gradients, phosphor bloom, rounded bevels and real type. One painter with
both would mean a conditional at every draw call. podRoom.ts stays the
single source of geometry, so the DOM overlay's click targets are identical
in either style — that was the whole reason layout and paint were split.

The style's vocabulary: CRTs recessed into one continuous console housing
(so the gaps between screens read as thick bezel, as on a real console),
glass warped by a per-row barrel distortion with scanlines and a baked
phosphor bloom, a specular arc for curvature, needle gauges, a VU meter,
seven-segment readouts with ghosted unlit segments, toggle banks with
indicator lamps, a reel-to-reel mainframe, domed ceiling lamps, fanfold
tractor-feed printout, and a moulded chrome-framed chair.

ENGRAVED TYPE is the biggest departure: this style uses real text (the
site's JetBrains Mono, hand-tracked, dark shadow under a light face) because
retro consoles are covered in labels. The pixel style deliberately has no
glyphs at all. The CRT resolution also finally allows the editor to show
actual readable code instead of token-length bars.

Amber phosphor in light theme, green in dark — both authentic CRT colours,
and both already the site's accent tokens.

Two bugs found by looking and fixed: destination captions were painted in
canvas AND rendered as HTML, so every primary monitor had a doubled label
(the canvas ones are gone; the HTML must stay, it is the accessible path);
and the cow's patches were drawn unclipped over a mis-ordered body, so it
had no silhouette — now drawn back-to-front with the patches clipped to the
body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The owner said "some objects are plane 2D and some have a little 3D, that's
why it all feels off". Measured rather than assumed: extrapolating each
drawing system's receding edges to the centre axis gave FIVE mutually
contradictory vanishing points, where a coherent perspective demands one.

    declared horizon in code ....  y = 300
    floor seams .................  y = 501
    ceiling seams ...............  y = -303
    desk objects (box()) ........  y = 825 .. 1326, DIFFERENT PER OBJECT
    wall fixtures ...............  y = infinity  (flat elevation)

Root cause was architectural: the old box() took a per-object `depth`
argument and derived its side face from the object's x position, so depth was
never a property of the room — it was a per-object fudge factor.

This adds a real camera. The room is defined as volumes in world space
(metres, y up, z into the scene) and EVERY corner of every object goes
through ONE perspective transform with one focal length and one pitch.
Objects can no longer disagree about depth because none of them chooses its
own. ~200 lines, no 3D library.

Face visibility is decided by the sign of the projected area (shoelace), not
by an ad-hoc "is this left or right of centre" test — so a box left of the
axis shows its right face and vice versa, with no special cases.

Tests assert the COHERENCE property directly: they intersect projected
world-parallel lines and require floor, ceiling and desk families to converge
on the same point to within a pixel, and every desk object to agree with the
camera's own vanishing point. Writing them first caught three real bugs
before a single pixel was drawn:

  - vpy was derived by projecting a 1e6 coordinate; precision loss put it
    207px off. Now closed-form: H/2 - f*tan(pitch).
  - the FACE_IDX winding table was hand-written inconsistently, so a box
    could report BOTH its front and its back visible at once.
  - the shoelace sign was inverted (screen y grows downward, which mirrors
    the image), so every box showed its far faces and hid its near ones.

Also corrects a test I had written too strictly: "at most three visible
faces" is an ORTHOGRAPHIC invariant. Under perspective a box straddling the
centre line can legitimately show four. The real invariant, now asserted, is
that a face and its opposite are never visible together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e camera

The owner's reference image corrected my reading of "retrofuturism": not
1960s mission control (chrome, gauges, rivets, CRT scanlines, dark and
cluttered) but FLAT MODERNIST ARCHITECTURE — bold saturated planes, clean
edges, hard sun and shadow, silhouette figures. Barragan / Bauhaus by way of
vaporwave. Their words: "clean lines, clean colors, bold structures".

This style and the new camera want the same thing, which is why they landed
together: the look is large flat planes meeting at clean angles, and
projectBox() returns exactly that — one flat fill per visible face, lit face
light, shadow face dark, no texture, no gradient inside a face.

The scene is an open rooftop terrace at sunset rather than an enclosed room,
because "dark and dull" was the original complaint about this whole section:
sky gradient with a hard sun disc, a cream colonnade, stepped coral/teal/
mint/mustard masses, a pool, fan palms, and a seated silhouette.

PALETTE — worth stating because CLAUDE.md says the seal is the only saturated
colour in light theme. Every hue here descends from an existing token:
    seal   #b23a2e -> coral/terracotta      ochre  #c8a36a -> mustard/sand
    indigo #6d7689 -> teal/turquoise        paper  #efe9dd -> the cream masses
So the change is chroma, not hue, and it is scoped to the pod — already a
human-approved standalone piece — not to the site.

Also fixes the camera's framing: FOCAL 1.5 -> 0.62 and the camera pulled back
to z = -3.4. At the old lens one monitor filled the entire canvas, because
1.5 is right for standing INSIDE a room and this scene is VIEWED. Pulling the
camera back rather than shrinking the world keeps every dimension in honest
metres.

Ambient life per the owner's choice of "subtle only": the sun drifts, the
pool's highlight bars slide, the birds shift. Gated three ways — style must be
'scene', section must be on screen, and prefers-reduced-motion must be unset.
The static first frame is the finished picture and the no-JS state.

The style lab now offers all three (1 modernist / 2 console / 3 pixel art) and
the DOM overlay re-positions its links per style, since the modernist scene
lays its screens out in world space and the other two do not — without that
the click targets would silently drift off the painted screens.

Test-threshold lesson recorded in the test itself: I twice "fixed" a failing
yaw-skew assertion by adjusting the number instead of measuring. Measuring
showed skew scales with panel height (h=0.58 gives 6.1px at cx=1.6; h=0.36
gives 2.4px) — the test's stand-in panel was the problem, not the camera.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by looking at the render, not by reasoning:

- The seated figure was a black slab across the centre of the composition,
  masking the middle monitor. Moved off-axis to x=1.62 (the reference's
  figures always sit to one side of the architecture), given a neck, rounded
  shoulders and a chair in SHADE rather than ink, so it reads as a person in
  furniture instead of a hole in the picture.
- Its arm reached from the off-centre seat to the centred keyboard, drawing a
  long black bar straight across the desk that read as a girder. It now rests
  a forearm on the desk in front of itself.
- The colonnade's arches were POINTED — a quadratic curve to an apex is a
  gothic arch, the wrong century for this style. Now true semicircular heads
  springing from the jambs, and fewer/wider openings.
- The desk floated: its plinth was too shallow to meet the ground plane
  convincingly. The plinth is now drawn first (so the slab overhangs it and
  casts the shadow line that seats it), runs nearly the full desk depth, and
  gains two side returns.

Also moved DESK_Z_NEAR 0.62 -> 1.35 so the pool becomes a real foreground
rather than a thin strip at the bottom edge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pod panel has been linking to /experience since the pod shipped, and the
page did not exist. Independent of the unresolved 3D-showpiece question, so
worth clearing now.

Content comes entirely from data/profile.ts (timeline + awards) — editing the
site's facts stays a data edit. Built on the established reading-page pattern:
--reading-grad, FluidSky variant="reading", SkyWash, SideRail, same 34% left
margin and 860px measure as /projects, so the three reading pages read as one
system rather than three guesses.

EDUCATION LEADS, deliberately. The site's identity hierarchy is load-bearing
(quant/mathematician first, engineering second), so the incoming PhD is the
first group on the page and the first stop on the rail rather than sitting
under nine engineering roles.

experienceStops() groups the timeline while preserving each entry's ORIGINAL
index, which is the /research rail bug in a new costume: group-then-renumber
would point rail stops at ids the page never emitted. A test locks it —
education at source index 1 must target x-1, not x-0.

railLabel() takes the institution rather than the job title, because a thin
margin cannot hold "Senior Software Engineer · TikTok".

10 new tests (206 total, green): group order, nesting, original-index
targeting, empty cases, target uniqueness against the real timeline, and
railLabel's separator/fallback/truncation behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The owner asked to keep the dotted seam on the left. /experience had replaced
the homepage's descent spine with plain border rules, which lost the site's
one recurring timeline mark.

Ported it, but NOT copy-pasted: the homepage version is tuned for the dark
descent (paper at 0.22 alpha over --bg), while /experience is a reading page
with dark ink on a luminous ground. A literal copy would have drawn a
near-white thread on cream. The thread now uses rgba(var(--hairline-rgb), …),
which inverts per theme, and the dot fill uses a locally declared --dot-fill
sampled from --reading-grad's mid stop.

Corrected three tokens I invented while writing this: --rule, --rule-strong
and --reading-bg do not exist in tokens.css. Checked instead of assuming;
--hairline-rgb is the real one.

The incoming PhD keeps the flagship indigo hollow ring at the homepage's
measured 10px/2px proportions — at 9px a 1.5px border rounds down to 1px and
the ring stops registering as colour. The seal stays reserved for the brand
mark. Verified in both themes: indigo ring in light, cyan in dark.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The owner corrected the concept, and the correction is structural rather than
aesthetic: seriation reorders an n×n matrix over n COMPARABLE assets, but this
site is ONE asset. Clustering it against itself is a category error — the
object would assert a shape the data cannot have, and a quant reader would
catch it.

The right dimensionality for one dependent variable explained by many signals
is a factor model:

    r_TIAN = alpha + sum_k beta_k f_k + epsilon

Their framing ("we are an asset; the portfolio items are signals that may
explain our quant abilities") maps onto it exactly. Six panels also stop being
an arbitrary k=6 cut and become six FACTORS, which is the conventional form —
Fama-French uses five — so six needs no invented justification.

This is stage one of their two-stage plan: the expression, then the expression
EXPANDS into a 3D exposure fan. fanBeams() already lays the terms out in
space, so each beta_k f_k term becomes one beam — the maths stays visible as
the thing the picture came from. No renderer yet, on purpose: the numbers are
claims about a real record, so they get locked and looked at before anything
is drawn.

WEIGHTING BUG, found by printing the betas rather than trusting them: an
earlier version weighted timeline entries by DURATION while everything else
counted as one artefact, putting two units in one denominator. Nine roles
summing 16 job-years swamped six research items summing 6, and the model
reported Experience 0.59 / Research 0.22 — inverting the site's load-bearing
quant-first hierarchy. Not a fact about the record, an artefact of mixing years
with counts. Now one declared artefact = one unit (owner's call):

    Experience 0.450  Research 0.300  Craft 0.150  Projects 0.100
    Writing 0.000  Market reports 0.000     sum = 1.000000

The two zeros are deliberate and load-bearing: those pages do not exist, so
the model shows a labelled stub at zero rather than inventing a loading for
work that has not been done. A test asserts they are exactly zero.

Two layout bugs the tests caught before a pixel was drawn:
- the centre-out slot assignment used `mid ± k` with a half-integer midpoint,
  producing slot indices outside 0..n-1, which pushed a beam past the declared
  spread and swung another behind the viewer where its label could never be
  read. Now a real permutation of 0..n-1.
- spreadDeg was 232, which is geometrically impossible for a forward-facing
  fan: z ∝ cos(azimuth), so |azimuth| must stay under 90°. Measured the sweep
  (120° → min z 0.449; 160° → 0.156; 170° → edge-on; 232° → −0.394) and set
  150°, which leaves a real margin. The measurement is in the comment.

39 tests, written as honesty guards rather than shape checks: betas sum to 1,
every beta is reproducible from profile.ts by the arithmetic the on-screen
caption will state, the unbuilt factors are exactly zero, no two beams overlap
in azimuth, every tip is in front of the viewer, and every href points at a
route that exists.

Also lands the throwaway kill-gate route /proto-showpiece with the three
candidate concepts as static SVG, which is how seriation and the simplex got
judged cheaply instead of after five sessions of building.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The owner asked for signal strengths to be re-evaluated automatically by an
LLM whenever the content changes. Built, with two guards the naive version
lacks.

DETERMINISM. An LLM call at build time would make two builds of identical
content produce different betas — breaking the site's determinism rule,
drifting numbers between deploys for no content reason, and putting an API key
in the deploy path. So the scoring is a COMMITTED ARTEFACT
(src/data/signalWeights.ts) reviewed like any other change, and the build only
reads it. A PR then shows the score diff, so a moved beta is visible before it
ships.

HONESTY. Once an LLM assigns strengths, beta stops being verifiable arithmetic
and becomes a judgement wearing a hard number's clothing — and "an LLM scored
it" is a worse answer to "what is this computed from?" than counting is. So
the split is: the LLM scores evidence strength against a FIXED, PUBLISHED
rubric; the arithmetic stays mechanical; and RUBRIC_PROMPT ships on the page so
the reader can see the rule the number came from. Every score also carries the
clause that justified it, which makes a bad score arguable rather than
mysterious.

Three independent raters (sceptical / literal / quant-hiring), none seeing the
others, median taken. Inter-rater agreement is what makes the numbers usable:
20 items, 14 exact agreement, 20 within one point, 0 disputes. A rubric that
scattered would be a number generator, not a measurement.

WHAT THE SCORES SAY, and it is uncomfortable: verifiable quant-research
evidence sits in TWO items — the RL-BHRP paper and the freelance role that
produced it, both 4. Senior SWE at TikTok scored 2, all three raters noting
that $200M and 50k QPS measure serving throughput rather than modelling
quality. The four research interests scored 1 unanimously: declared interests,
not artefacts. Nothing scored 5, because arXiv is verifiable, not peer-reviewed
— a test asserts no 5 exists without external validation.

The staleness guard is the part that makes re-running safe: scores store the
text they described, so an edited item is DETECTED rather than silently keeping
its old score. Any missing, changed or orphaned score fails a test, and
evidenceWeights() degrades the whole model to counting rather than shipping a
half-scored number nobody could reproduce.

Loadings now carry `basis: 'count' | 'evidence'` so the caption cannot lie
about which one is live.

38 new tests (284 total, green).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Answering "is it rendering? where is it": it was NOT. The factor model was four
files of pure logic and 284 passing tests imported by zero pages. That was my
error — I said "the math first", then kept going deeper into scoring instead of
putting a pixel on screen.

Now rendered as a build-time SVG at /proto-showpiece, above the three earlier
candidates: the expression across the top, the six terms expanded into beams
below it, beam area proportional to beta, the seal-red asset mark at the origin
every beam loads onto, ground rules and cast shadows so the fan sits on a plane
rather than floating. Beside the art: the live loadings as a bar list, the
rubric in a disclosure, and the honest finding stated in words.

The owner confirmed the scoring makes sense, so evidence-of-quant-research
stays the scored axis and the betas ship as computed.

Two geometry bugs found by LOOKING at the first render, not by reasoning:
- the camera (z -4.6, focal 1.02, pitch 0.10) squashed the whole fan into the
  middle third of the frame. Pulled in to -2.5, lifted to 1.15, focal 1.25,
  pitch 0.30.
- liftDeg 26 left the six beams nearly coplanar, so they overlapped and their
  tip labels stacked. Raised to 52: elevation is the only axis available to
  separate tips once azimuth is spent on the spread.

Still wrong and not yet fixed: the two zero-loading stubs (Writing, Market
reports) are visually swallowed by their neighbours, and their labels still
collide with Craft and Projects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two complaints, both correct. The LaTeX one was a real bug, not taste.

THE MATHS. I hand-built the equation from SVG <tspan> elements, so it rendered
as monospace text pretending to be an equation — wrong subscript sizing, no
italic/upright distinction, no operator spacing, a fake summation. This site
already has a KaTeX -> MathML build step (src/lib/equations.ts, output:'mathml',
zero client runtime) and I bypassed it. The equations now go through it:

  - general form with a real \sum and its limits
  - the fitted form with each loading substituted, zero terms in \textcolor grey
    so an honest absence reads as deliberate rather than as a typo
  - beta's definition as a real \frac

The fitted terms read their betas from the same loadings() the fan draws, so
the equation and the picture cannot disagree.

THE CLUSTERING. Labels were placed at the projected beam tips, and six tips
inside a 150 degree arc are simply not far enough apart — two lines of text at
each made it worse. Replaced with LEADER LINES to a vertical ladder in the
margins, which is the standard technical-illustration fix because it decouples
where a label POINTS from where it SITS. No collisions now, and the beta values
are legible.

A CSS bug found by grepping the emitted stylesheet rather than by staring at the
render: Astro TREE-SHOOK the entire .frame-fan rule, because the class is only
ever referenced from inside a set:html string. Every var(--fan-*) then resolved
to nothing, so the beams and all six labels were invisible while the SVG itself
was perfectly well-formed — and my first instinct, that the geometry was
off-canvas, was wrong: printing the projected tips showed all six comfortably in
frame. Fixed by setting the palette inline on the wrapper and moving the text
rules inside the <svg>, neither of which can be shaken.

Also: the ladder's margin is now computed from the longest label's length
instead of a fixed 150px, which is what clipped "MARKET REPORTS" at the frame
edge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All four notes.

(1) INTERACTIVE 3D. Real three.js, drag to rotate, with a gentle idle spin that
stops the moment the visitor takes control. Hover or focus a beam and it
isolates that factor and lists the signals loading on it with their evidence
scores. Cost is contained: the scene module is reached only through a dynamic
import inside an IntersectionObserver, so Vite emits three.js as its own 483KB
chunk that is fetched when the section scrolls into view — never on first paint,
never during a Lighthouse audit (it does not scroll), and never at all under
prefers-reduced-motion or below 721px, where the baked SVG still frame stays as
the finished picture. A failed chunk restores the still rather than leaving a
blank box.

(2) ONE EQUATION, BIG. The fitted expansion and the beta definition were a
second and third line competing with the object for attention, and the fan
already shows every loading. Only the general form remains, at clamp(30px,
4.4vw, 62px) — display scale, because it is the thesis of the section.

(3) THE INTERLEAVING WAS A REAL FLAW, not a design choice. Three coupled
variables guaranteed crossings: beta drove length AND width, while elevation
rose with azimuth. Horizontal reach goes as cos(elevation), so a short,
steeply-lifted outer beam reached less far than a long, flat inner one and got
drawn INSIDE it. Measured at the time:
    craft   az -45deg -> screen x 515
    writing az -75deg -> screen x 595   <- outermost beam, drawn inside craft
    exp     az -15deg -> screen x 624
Now decoupled: azimuth alone sets angular position, elevation is one shared
constant, beta drives length and width only. Beams also carry a unit `dir`, and
a test asserts that ordering by dir.x always matches ordering by azimuth — the
invariant, rather than today's numbers. Even azimuth spacing is asserted too, so
it reads as a fan rather than two wedges with stragglers.

(4) PIXEL ART, as renderer settings rather than artwork: the scene renders into
a 582x320 buffer that CSS scales up with image-rendering: pixelated, materials
are MeshToonMaterial against a 4-step gradient ramp so lighting quantises into
flat bands, and the asset marker is a 0-subdivision icosahedron. No taste calls
about where to put a shape — which matters, because hand-authored illustration is
what failed three times here.

Two bugs caught by looking at the render: the equation was set in --paper, which
IS the background on a light reading page, so it was cream-on-cream and nearly
invisible (now --ink-1, which inverts per theme); and the labels sat on top of
the beams, unreadable over lit ochre. They now anchor 1.18x past each tip with a
tinted backing plate, and the static and live placements share that factor so
they cannot disagree.

287 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…onse

(3) ROTATION WAS DEAD, and it was a real bug, not a tuning issue. The .fan-hits
overlay is position:absolute inset:0 — it covered the entire 1320x726 stage with
default pointer-events, so every pointerdown landed on the <ul> and the canvas
never received one. Diagnosed with elementFromPoint at five points across the
stage: all five returned .fan-hits, never the canvas. The container and its
wrappers are now pointer-events:none and only the labels themselves are targets.

(2) THE 180-DEGREE SPRAY became a full 360 dial. The old 150 degree spread was
chosen when the fan was a flat SVG still and every tip had to face one fixed
camera to be labelled — that constraint died with the still frame. A beam
pointing away is now the REASON to rotate rather than a defect. Slot pitch also
divides by n instead of (n-1) so the last beam does not land on the first.

(1) PIXEL ART DROPPED. It read as a retro-game artefact against a Swiss-minimal
page. The low-res buffer and image-rendering:pixelated are gone; rendering is
full resolution and antialiased, and the toon ramp went from 4 hard steps to 6
soft ones — still flat-shaded, no longer cel-shaded.

(4)(6) THE EMPTINESS. Added a measuring frame derived entirely from the model:
concentric rings at beta = 0.1 to 0.5 so a beam's length is readable as a VALUE
rather than a relative size, one radial spoke per factor running the full radius
so all six angular slots are visible even where a beam is a stub, tick marks at
each ring, and a vertical axis through the origin. No invented furniture.

(5) HOVER RESPONSE, eased per frame rather than switched: the hovered beam lifts
off the ground plane and scales out, brightens toward paper while the others hold
their base colour, the zero stubs fade up from 0.42, and the whole gauge
brightens so the rings are legible while a value is being read.

Two tests updated, and worth stating why they are not weakened: "every tip has
positive z" was correct for a fixed camera and is wrong for a dial, so it is now
"beams genuinely surround the origin" — some pointing toward, some away. And
sorting by dir.x no longer tracks azimuth order once the fan wraps past 90
degrees (sin is not monotone over a full turn), so the anti-interleaving
invariant is now stated directly: distinct, evenly pitched azimuths.

288 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The owner caught a real dishonesty: "it's not like predicting me against a PnL,
this is not right... I don't want to overly materialize myself as an asset
either, that seems weird."

Both objections land. r_TIAN is written as a RETURN, and a return implies a P&L
that does not exist — the metaphor was writing the maths, which is backwards and
is exactly what this site refuses to do. And pricing a person like a security is
off on its own terms; it also flattens the one genuinely distinguishing thing,
which is BREADTH.

So: keep every piece of real machinery — declared sources, mined signals,
evidence scores against a published rubric, reproducible arithmetic — and drop
the pricing. The output is not a predicted return. It is a measured profile: how
much evidence supports each dimension, and how concentrated or broad that
support is.

Two standard statistics, both computed from the scores already published, neither
needing a return series, a covariance, or a regression:

    HHI (sum of squared shares)      0.3397   vs 0.1667 if perfectly even
    effective dimensions (1/HHI)     2.94 of 6
    entropy                          1.736 of 2.585 bits
    breadth (entropy / log2 n)       0.672
    dimensions covered               4 of 6

"Effective dimensions 2.94 of 6" is the most honest number available here,
because it refuses to flatter: six dimensions exist, the evidence supports about
three.

dimensionDepth() then separates the two axes a single fan conflated:

    dimension     share   n   mean  peak
    Experience    0.459   9   1.89   4     <- volume, not depth
    Research      0.324   6   2.00   4     <- depth, fewer items
    Projects      0.108   2   2.00   2
    Craft         0.108   3   1.33   2     <- 3 awards standing in for 44 artworks
    Writing       0.000   0   0.00   0
    Market rep.   0.000   0   0.00   0

Share and mean score are different things, and a picture showing only share
implied they were the same.

Zero-weight dimensions are counted in the total and excluded from the entropy sum
(0·log0 = 0). Deliberate: an empty dimension is information, and hiding it would
inflate breadth — the flattering direction, therefore the wrong one. A test locks
that.

16 tests, checked against closed forms (HHI = 1/n and breadth = 1 when even, HHI
= 1 and breadth = 0 when all on one, scale invariance) rather than against
whatever the code happens to return. 305 total, green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flat-modernist terrace was still mounted between Mountains and Ground —
the one the owner called "horribly wrong". Leaving a rejected piece live while
the replacement converges is worse than an honest gap, so it comes out now.

The section slot is deliberately empty until the showpiece is settled. The
component and all three earlier styles stay in the repo (and pixel art stays on
branch claude/quant-pod-pixel) so nothing is lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… not asserted

The owner's own framing, and the strongest yet because nobody else can borrow it:
"I've spent a very large portion of my career as a fullstack SDE. Most who choose
that path stay on it. I'm almost running a gradient descent on my own while
finding that we are at a LOCAL MIN in the SDE — that's why we adapted and intended
to find the GLOBAL MIN." Then the part that turns metaphor into thesis: "as we
wander we also learn more about OUR SENSITIVITY TO THE WORLD — what we really like
vs. what we don't."

THREE REASONS THIS IS LITERAL RATHER THAN DECORATIVE:

1. The hero's field ALREADY contains the story. Measured against lib/terrain.ts's
   own BUMPS, with no retuning:
       distinct minima              2
       global min   (-1.46, -0.64)  depth -0.9019
       local basin  ( 1.76,  0.79)  depth -0.4774
       gap                          0.4245
       barrier on the direct path  +0.2646 at t=0.44
       climb required to escape     0.7421
       plain descent from the local basin STAYS STUCK   (verified)
   A real local minimum, a real deeper one, a real barrier, provable entrapment:
   gradient descent cannot cross it, so escaping means going uphill. That is the
   story, and it is honest about the maths.

2. It is the paper's own thesis. profile.ts describes RL-BHRP as "learning how to
   allocate, rather than assuming". The career is the same structure — not
   descending a known landscape but learning the objective while descending.

3. It explains the SDE-to-quant move with no self-congratulation: the field looked
   flat in one direction until he learned he was sensitive to it. A claim about
   preference discovery, not about being better than other engineers.

MEASUREMENT BUG, recorded because it nearly killed the idea: the first run
reported "2nd basin = global min, gap 0.0000". Two of terrain.ts's three declared
valley centres descend into the SAME basin, and the script took found[0] and
found[1] off a sorted list without deduping. distinctBasins() now dedupes by
position, and a test asserts exactly two.

Every waypoint position is DECLARED and the module says so — a stated reading of
his path laid on a real field, not a fitted trajectory, because there is no career
loss function to fit. Claiming otherwise is the kind of lie a quant reader catches.
Each stop carries what it taught about his own sensitivity rather than a duties
list, and a test enforces that.

14 tests as an honesty gate: two distinct basins, a meaningful gap, a real
barrier, provable entrapment, a vanishing gradient at the minimum, every waypoint
period matched to a real timeline entry (so the path cannot invent a job), and the
engineering years landing in the shallow basin with the PhD in the deep one — the
shape of the claim itself. 319 total, green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The owner's call: "we don't need to rush, we have plenty of time to think about
this." Correct — all five rejected attempts failed because they were built before
the thinking converged.

So the reasoning gets committed instead of another visual. It was living in
.superpowers/sdd/, which is gitignored, and would not have survived a git clean —
and right now that reasoning is the most valuable artefact in this effort.

Contents: the brief in the owner's own words, the five rejections WITH their
reasons (the reason matters more than the list), the measured 173-item corpus
census, the finding that 42 photographs carry 42 written notes averaging 82
characters, the career-as-descent framing with its measured basin/barrier numbers,
five genuinely open questions, and the one process lesson — a still frame first,
three.js only after.

States plainly what is decided (the maths, all tested) and what is not (the visual
form). The homepage slot stays intentionally empty rather than carrying a rejected
piece.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IngTian and others added 27 commits August 10, 2026 20:11
The owner: "our main emphasis is our approach to solve the problem, with RL, math,
stochastic process, and bellman. i dont know how to merge all these into one slide,
you might want to think about it. i think we can reuse the same graph we have right
now."

THE MERGE: those four are not four subjects. They are the PARTS OF ONE EQUATION —
the same Bellman recursion the difficulty slide now ends on. Read it again:

  V_t(x) = max_{u in U(x)} { r(x,u) + E[ V_{t+1}(x') ] }
                              ^^^^^^      ^^^^^^^^
    E[.] cannot be taken without a model of how the world moves  -> STOCHASTIC PROCESS
    V_{t+1} is the table with 10^3000 entries, so you fit it     -> REINFORCEMENT LEARNING
    r(x,u) is what you actually maximise: risk-adjusted, hierarchical -> THE MATHS
    and the equation itself is the frame demanding all three     -> BELLMAN

So the slide is one equation read four ways, not four topics crammed together. Each
move annotates a different term and says what the approach supplies there — ending
on our own objective, which is where RL-BHRP belongs.

THE SELECTOR IS THE EQUATION'S OWN TERMS. Same affordance and same position as slide
two's band, but what you click is a piece of mathematics rather than a numbered
beat — which is also how this slide avoids being a third copy of the same format,
the objection raised when slide two repeated slide one.

The graph is reused as asked, and it turned out the old one-shot timeline already ran
in exactly four phases (mesh backward, candidates, route forward, settled), so the
moves map onto it 1:1. What changed is that a reader can now drive it: the phases
became states, with auto-advance, click-to-freeze and arrow keys copied from the two
slides before it, dwell times included.

FOUR THINGS MEASUREMENT DECIDED, not taste:
- \htmlClass does not survive MathML output (class names land only in the
  <annotation> LaTeX echo), so parts of one baked equation cannot be lit by CSS.
- \underbrace with a text label made the LABEL set the term's width, so the equation
  reflowed between moves. \mathclap fixed the geometry (width held at 487px, left
  edge pinned at 169, across all four) but a zero-width label then overlapped its
  neighbours — "a model of how the world moves" ran through the brackets beside it.
  So the brace carries NO text: the selector and the prose already name each part.
- The brace is accented via `munder > mo`, which hits the brace and nothing else —
  \max builds a munder too, but its second child is an mrow of limits, not an
  operator. Colour-free LaTeX means it themes for free (ochre in light, emerald in
  dark), which a baked \textcolor hex never could.
- overflow-x:auto made the equation box a scroll container, clipping the brace by
  9-14px at every size; the padding is the brace's room.

AND THE SLIDE NOW FITS NARROW SCREENS, which it never did. Measured: the committed
version was 1539px against a 1050px viewport at 1024 wide. It is 1050 now — all ten
sizes clean, where the baseline failed four of them. Below 1100px the chart takes the
cut, because a stacked lattice is decoration at that width while the equation and the
prose are the argument. (A first attempt put those overrides in the existing
max-width block, which sits BEFORE the base .so-chart rule — equal specificity, so
source order silently won. Same trap as the Toc's nested tier.)

PERFORMANCE, HONESTLY: Lighthouse goes 96 -> 95, and I could not recover it. The
cause is a slide's worth of new content under Lighthouse's 4x CPU throttle — LCP
2559ms -> 2709ms, consistent across three runs each way, +8.5KB of HTML. Two attempts
failed and are recorded rather than hidden: taking the inactive equations out of
layout did nothing (html.solve-js is added after the largest paint, so a rule gated
on it cannot move LCP), and dropping four MathML trees did nothing either. The
selector chips are plain HTML anyway now, which is the right call at 12px regardless.
Unthrottled the real LCP element is span.page-label at 284ms. The four display
equations are 5.6KB of the 8.5KB and they ARE the slide, so the honest trade is to
keep them and report the point.

Verified: 768 tests green (17 new — the moves and equations stay in lockstep, the
brace appears in exactly the three moves that annotate a term and never on the whole
equation, and the copy keeps the incoming-PhD rule and does not overclaim the lattice
as the published model), build clean, a11y/best-practices/SEO 100, all ten viewport
sizes fit with zero clipping on all three explainer slides, all four moves
screenshotted in both themes, and both degraded states checked: no-JS shows one
equation and one text panel rather than a blank box, reduced motion rests on the last
move with the lattice solved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The owner, on the version shipped one commit ago:

  "well, now you are being too precise. we dont know how to solve this problem yet.
   it's an open problem. so dont write out that equation as if it's known. it's not.
   i only have some ideas how to attack this, from RL, with math, or, stochastic
   process, probability, as the backbone."

He is right, and the error was structural rather than a matter of wording. Typesetting
the Bellman recursion as an equality, on a slide titled "the method", walked over in
four annotated variants, asserts a formulation AND a solution. Neither is in hand. It
was redundant too: the difficulty slide already shows that recursion as the thing that
blows up, where it is true. One slide earlier it earns its place; one slide later it
overclaims.

So the slide now says what is the case. Heading: "Large pieces of this are solved. The
whole of it, at this size, is not." Kicker "the method" -> "what is open"; the rail's
'Method' -> 'Open', because a rail must not promise what the section withdraws.

Four directions, one per word the owner used, each carrying its own limit ON the slide
rather than in a footnote — that is the mechanism that keeps it honest:
  the backbone   stochastic process, probability — the process together with the
                 objective is what decides whether a closed form exists at all
  the certificate  convex optimisation, duality — one date is convex and certifiable
  learning, kept small  RL, with its real home named (execution and hedging, minutes
                 and millions of decisions) and monthly allocation excluded from it
  one attempt    RL-BHRP, located as one attempt on one piece, and a preprint

Four things the grounding caught that I had wrong or was about to get wrong:
- My planned framing for the lattice — "the largest version anyone can solve exactly"
  — is FALSE, and false on this slide's own terms, since Gârleanu-Pedersen 2013 is
  closed-form at thousands of names. The figure is now described as what it is: a world
  handed over complete, with the five things it was given, each one a thing the real
  problem withholds.
- "Solved. The optimum is read off the surface" was not in the caption where I thought
  it was. It was a move's read-out, written into an aria-live region — so a screen
  reader heard this slide terminate on the word "Solved." Deleted, and a test now
  forbids that word in any read-out.
- arXiv:2508.11856 is a PREPRINT. My previous commit message called it published.
  profile.ts was already right; the slide and the tests now say so too, and a test
  forbids "published".
- TOOLKIT in desk.ts was dead code whose Reinforcement learning entry read "how
  exposures adapt as conditions shift" — the exact overclaim, describing RL as a
  settled capability. Deleted, and a test forbids that phrasing.

Every number on the figure is checked against lib/bellman.ts rather than asserted:
13 dates x 9 states = 117 values with the horizon layer zero by construction, so 108
decisions, 972 comparisons, 9^12 = 282,429,536,481 paths never visited, and 29 rivals.

Two layout findings, both from measurement and both counter-intuitive:
- Hoisting the heading and thesis to full width made the slide TALLER, not shorter
  (928px -> 1177px). The figure column is 636px against the moves' ~400, so the left
  column has slack and pays for the heading for free; above the split it adds instead.
  Reverted.
- Once the chart came down on short screens it stopped being the binding side — the
  left column took over, and cutting more chart did nothing at all (774px stayed
  774px). The text had to give.

Deliberately no replacement notation. A suboptimality bound presumes a well-defined
true optimum, which is exactly what the backbone move says is not handed to you; a
conditional expectation would fail to describe the figure beneath it, whose transition
is deterministic and takes no expectation anywhere.

Verified: 774 tests green. tests/methodMoves.test.ts is DELETED rather than amended —
it pinned the retracted thesis, so it would have failed the honest version and passed
the overclaiming one. tests/attackMoves.test.ts replaces it and asserts the openness in
both directions: no claim of a solution, and equally no hedging into vagueness, since
every direction must cite something dated and checkable. Build clean, a11y 100,
best-practices 100, SEO 100, all ten viewport sizes fit with zero clipping on all three
explainer slides, all four moves screenshotted in both themes, and both degraded states
checked. Lighthouse performance stays 95: removing four MathML equations did NOT
recover the point I lost last commit, which settles it — the cost is this slide's text
volume under a 4x CPU throttle, not the typesetting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing called solved

The owner, on the version from the previous commit:

  "i wouldnt say this as solved. probably at first we can only solve a small chunk of
   it. in the future maybe we can do more. so my idea is that we only name a few
   methodologies only general ones to begin with. thats more truthful. so the entire
   headline should be something like: Still an open problem. in the tabs, we list a few
   viable methodologies. that's it. we dont need to go that deep. you can also drop the
   first one attempt. that's what work is for."

Three corrections, all applied.

NOTHING IS CALLED SOLVED. The heading was "Large pieces of this are solved. The whole
of it, at this size, is not." Accurate, and still the wrong thing to lead with. It is
now the sentence he asked for: "Still an open problem." A test forbids the word
"solved" in the heading and the thesis, so the previous framing cannot creep back.

THE AMBITION IS MODEST, and stated: no one has a method for the whole of it at this
size, so the realistic aim is a chunk — solve a small part properly, then widen.

THE TABS ARE GENERAL. They were four directions carrying a dated literature — Mossin
1968, Gârleanu-Pedersen 2013, Michaud 1989, Boyd et al. 2017 and more — each with a
labelled "what it does not give you" block. Every claim was true and the whole thing
still read as a survey of a field the slide had just called open. Now: three
methodologies, the three families he named himself, two or three plain sentences each,
with the catch in the last sentence rather than in a labelled block.

  the backbone     probability, stochastic processes
  the mathematics  convex optimisation
  the adaptation   reinforcement learning

A test now FORBIDS dated citations and "et al" in the tab copy, which is the tell that
this has drifted back into a reading list.

THE PAPER IS OFF THIS SLIDE. "that's what work is for" — right, and naming a preprint
on a slide about an open problem quietly implied the attempt had closed part of it. A
test asserts no arXiv id, no RL-BHRP, no co-author name here, and separately that the
publication is still intact in profile.ts, so nothing is lost. The closing line points
where it went: "Directions, not results. What has actually been built is further down,
under the work."

Consequences carried through rather than left dangling: four tabs became three, so the
band is three-across and the lattice's settled state moved from stage 3 to stage 2 (a
rule left on stage 3 would have been dead CSS and the final state would never have
shown its emphasis); the labelled-limit CSS is deleted; and the kicker went from "what
is open" to "where to start", because with this heading it was saying the same thing
twice.

ON THE LIGHTHOUSE POINT, which I chased and did not win. It is 95, and I can now say
exactly why rather than guessing: removing the whole #solve section scores 96 with LCP
2558ms, the ORIGINAL version of the section scored 96 at 2559ms, and this version
scores 95 at 2709ms — a discrete 150ms step, which is Lighthouse's simulated RTT.
Four experiments failed to move it: deleting the four MathML equations, removing four
more MathML chips, taking the inactive panels out of layout, and removing the centring
grid. Rounding the SVG to whole pixels took the page from 192,410 to 189,740 bytes and
did not cross back either — the boundary sits within about 700 bytes of the original,
which is too fragile a cliff to design around. I kept the rounding because it is a real
saving that costs nothing visible (the viewBox is 760x380, scaled up), and stopped
there rather than cutting the 29 candidate policies, which would trade a true statement
about the figure for a rounding boundary. Unthrottled, the real LCP element is
span.page-label at 284ms.

Verified: 773 tests green. tests/attackMoves.test.ts is deleted and replaced by
tests/methodologies.test.ts — the third version of this file, because the slide has now
been wrong in both directions and what needs pinning is the narrow band between them:
no claim of a solution, nothing called solved, no drift back into a survey, and equally
no hedging into vagueness (each direction must still be three sentences of real
content). Build clean, a11y 100, best-practices 100, SEO 100, all ten viewport sizes
fit with zero clipping on all three explainer slides, all three tabs screenshotted in
both themes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… stops breathing mid-scroll

Three things the owner raised.

1. "The realistic ambition is a chunk": do you have evidence for that? maybe just drop it

No, there is none. It was my editorialising dressed as a finding — a confident sentence
about what is achievable, on the one slide whose whole purpose is not doing that.
Dropped. The test that REQUIRED that phrasing is inverted: it now forbids the class of
claim, including "will eventually be solved" and "in future we can".

2. "probability + convex optimization + rl might just be tools ... there are more right?
   i dont think only these 3 actually"

Right, and there were two separate faults. The list was short of things that genuinely
matter, and it was FRAMED as complete, which is the worse of the two.

  the backbone     probability, stochastic processes
  the inputs       statistics, estimation            <- new
  the mathematics  convex AND integer optimisation   <- integer side added
  the sequence     stochastic control, learning

Estimation is where most of the real damage happens and it was missing entirely. The
numbers in it are computed, not asserted: a full covariance over three thousand names
carries N(N+1)/2 = 4,501,500 free parameters and needs more observations than names
before it can be inverted at all, which at 252 trading days is about twelve years of
daily data. A test recomputes both so the sentence cannot drift.

Reinforcement learning now sits inside "stochastic control" rather than standing alone,
which is more truthful: for this problem RL is approximate dynamic programming, not a
separate idea.

And the framing is fixed, which matters more than the fourth tab: "not a plan and not a
full toolbox: a few of the general families any attempt would be assembled from", with
the close line ending "and not a complete list". Robust optimisation, simulation,
filtering, econometrics and multiple-testing correction are all left out on purpose and
that is now honest rather than a claim of completeness. A test asserts the disclaimer is
present.

3. "scroll down and up the slides is not smooth enough"

Measured, and the cause was not what I would have guessed. During a deck advance the
page rendered a median frame of 33.7ms with 82% of frames dropped and a worst frame of
142ms, against 16.7ms for 60fps. Bisecting the layers cleared the corner-nav
backdrop-filter, the descent washes, the grain and the hero canvas — every one made no
measurable difference. It is SkyWash: `inset: 0` inside main.descent means the whole
DOCUMENT, so each of its three elements measures 1685 x 7812 = 13.2 megapixels, each
with mix-blend-mode, all animating without pause. Roughly 40 megapixels re-blended
against its backdrop every frame.

Four candidate fixes, all measured over the same four-advance walk:

  will-change removed          32.5ms   no help
  blend modes off              24.6ms   helps, and costs the Monet look
  wash fixed to the viewport   25.0ms   helps, and the sky stops descending with you
  ANIMATION PAUSED              8.4ms   equal to deleting the wash outright

So the animation is the whole cost, and pausing it only WHILE SCROLLING costs nothing:
the sky still breathes whenever the reader is still, which is the only time anyone can
see it breathe. `animation-play-state: paused` freezes mid-cycle and resumes from the
same point, so there is no jump. Result: median 16.6-25ms from 25-50ms, and about 50
frames per advance where there were 30.

It lives in SkyWash rather than in the deck because the wash is on eight pages and the
deck is on one. Safe direction: the class is only ever ADDED by script, so with no JS the
animation just runs, which is today's behaviour. Reduced motion never attaches the
listener at all — verified, along with the sky resuming after a scroll settles.

Two things I did NOT do, and why. I did not also relax will-change while paused: measured
neutral-to-worse, because un-promoting a 13-megapixel layer forces a re-raster at exactly
the wrong moment. And I did not shrink or un-blend the wash, because this Chrome reports
gpu_compositing = disabled_software — it has no GPU, so blend costs here are far worse
than on real hardware, and redesigning the sky on software-rasterised numbers would be
the wrong call. The animation pause is justified regardless of hardware: it removes work
that cannot be perceived on any machine.

Verified: 775 tests green, build clean, a11y 100, best-practices 100, SEO 100,
performance unchanged at 95, all ten viewport sizes fit with zero clipping on all three
explainer slides, all four tabs screenshotted in both themes, and the wash confirmed
breathing when still, paused during scroll, and resuming after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The owner: "for the work, for the writing. let's leave an easter egg there. have a
pixel art plush cow says something."

The cow was already a motif — the terrain hero's rare pill can read "Moo!" instead of
the KKT condition, and the original quant-pod brief put a plush cow on the desk. This
gives it a body and a view.

A 15x13 face compiled from a character matrix in data/cowGlyph.ts, same discipline as
the ticker and rule marks: it is data, so every pixel is inspectable and tested. A face
rather than a whole animal because at 2px per cell a full cow is a grey smudge. Rendered
at 30x26, sitting at 22% opacity until hovered or focused — missable while reading the
page, findable while looking at it.

Five lines, one per press, in order and wrapping:
  Moo. / I am not a source of alpha. / Past performance is not indicative of future
  grass. / I hold no view on rates. / Still an open problem. Moo.

The last is a callback to the method slide, which is the only place the site admits to
not knowing something. The cow agrees with it.

It is a real <button> with an accessible name, keyboard-reachable, and what it says
lands in a polite live region — an easter egg a screen-reader user cannot find is not
restraint, it is an omission. Deterministic order, because this project bans
Math.random() at paint time and an egg that says the same thing in the same sequence is
also testable.

Three faults found and fixed while building it:
- My first pass joined the lines with a separator and wrote a literal NUL byte into the
  source. The lines now travel as JSON on the wrapper, which cannot be ambiguous about
  where a line ends and cannot smuggle a control character.
- Placed as a sibling of the padded inner column on both pages, the cow rendered in the
  left gutter and collided with the fixed Toc rail. Inside .wk-inner and .writing-block
  it clears the rail by 40px on the homepage and 419px on /writing — measured.
- The nudge animation would not replay on a rapid second press without removing the
  class, forcing a reflow read, and re-adding it.

Tests pin the things a 30px mark hides: the eyes are two holes symmetric about the
centre (or the cow squints), the ears do not touch across the top (or it reads as a
bear), the snout is narrower than the head, coverage sits in the plush-solid band
without losing its holes, and the copy stays in the site's dry register — no
exclamation marks, no emoji, no shouting, and nothing that could be misread as a real
performance claim on a quant's site.

Verified: 787 tests green (12 new), build clean, Lighthouse a11y 100,
best-practices 100, SEO 100, performance unchanged at 95, and the cow confirmed cycling
all five lines and wrapping on both pages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things the owner asked for, and the third took four attempts.

1. THE BIG COW REPLACES THE SCAFFOLDING ON /writing. His words: "instead of scaffolding
something that doesn't exist yet, let's just put that big cow there." Right — three
sections each promising what will one day fill them is three apologies and no content,
and it made the page look abandoned rather than early. The page now reads: heading,
lede, "Nothing published yet.", and a 182x112 cow holding the space.
   The structure is not thrown away. The moment data/writing.ts holds one entry the kind
sections render exactly as before and the cow steps aside — one condition, no rework.
And the rail is now conditional too: with no kind sections on the page, rendering it
would have left a column of anchors pointing at ids that do not exist.

2. A COW ON THE 404. A lost reader is exactly who could use the company. It sits above
the links, so the way out is still the last thing read. Both pages get their own lines,
because a cow saying "I am not a source of alpha" on a 404 is a non-sequitur:
   404:     "This page does not exist. I checked twice." / "Nothing here. Not even grass."
   writing: "Nothing written yet. I am holding the space." / "Writing is slower than
            backtesting." / "Ask me again after the thesis."

3. IT NOW LOOKS LIKE A COW. "your pixel art cow doesn't quite look like a cow btw. looks
like a dragon." He was right, and it took three more passes:
   TAKE 1  single-tone front-facing head → a DRAGON. The pointed shapes on top read as
           horns or wings, the outline tapered to a snout, and one flat tone cannot carry
           patches, which are the strongest "this is a cow" signal there is.
   TAKE 2  same view with sideways ears, a patch and an outlined muzzle → a ROBOT. Every
           feature was a rectangle, so the muzzle became a grille and the ears bolts.
   TAKE 3  the SIDE silhouette — long body, four legs, head low at the shoulder, tail.
           Finally an animal, but the TONES came out inverted: currentColor is LIGHT on
           the dark pages, so the layer named "dark markings" rendered brightest and the
           patches read as lit windows on a hull. The robot again, smaller.
   TAKE 4  tones named by ROLE instead of by darkness. COW_BODY is the animal and draws
           at full ink; COW_PATCH is only the markings and draws at 34% so they RECEDE
           into it. That comes out right in both themes with no second colour, which the
           palette rules would not allow: a light cow with dark patches on the dark
           pages, a dark cow with softer ones on light. The eye is a HOLE, so the page
           shows through it.

Sizes: `small` is furniture at the foot of the work section (52x32, 22% opacity until you
look at it). `large` is the subject of its page, so it is simply there at 90% — on
/writing and /404 the cow is not hiding, it IS the content, and dimming it would just
look broken. Under 700px wide the large drawing caps at 210px rather than setting the
page width.

The tests are shape assertions rather than a pixel snapshot, because a snapshot breaks on
every touch-up and proves nothing about whether it still looks like an animal: wider than
tall, four separate legs, head low and left, exactly one eye and it is a hole, a tail at
the far end, patches irregular row-to-row (uniform rows are rectangles, which is what
made take two a robot), and body ink outnumbering patch ink two to one.
Two of those tests were wrong first and are fixed: a patch also leaves a gap in the body
layer, so the eye test counted two eyes; and the irregularity test measured the body
layer, proving nothing about the markings.

Verified: 792 tests green (17 for the cow), build clean, a11y 100 and best-practices 100
on /404 and a11y 100 on /writing, all ten viewport sizes still clean on all three
explainer slides, no dead rail anchors on /writing, and the cow confirmed cycling its
lines on all three pages in both themes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ight

1. THE COW MOVES TO THE LOWER RIGHT OF THE FRONT PAGE. "in the front page, you can put
the moo at the lower right corner." It was at the foot of the work section, sitting
awkwardly close to the Toc rail. The footer is flex/justify-between with wrapping, so the
links take the first line and the seal and the cow spread across the second — seal left,
cow right, which is the gap he drew a box around. It lands 112px from the footer's right
edge, measured.
   Two bugs on the way: the wrapper came out 1361px wide around 56px of drawing, so the
cow sat at the far LEFT of its own box. `flex: 0 0 auto` did NOT fix it (measured: still
1361px), so the width is stated outright with `width: max-content` plus `margin-left:
auto`. And at 22% opacity the cow vanished rather than hid against the near-black footer;
it is 50% there now.

2. IT IS BUILT LIKE THE REFERENCE NOW. The owner sent a classic 8-bit Holstein and said
"your cow doesn't read like a cow tbh". Comparing mine against it, the missing piece was
structural: the reference is a light FILL inside a dark OUTLINE, and mine was a solid
silhouette. That is most of why his reads as a drawing of a cow and mine read as a blob
with legs. Also added, from the reference: a muzzle at the front of the head and an udder.
   THE OUTLINE IS DERIVED, NOT DRAWN. Two hand-drawn attempts produced diagonal wedges
and a shape worse than no outline at all, so it is computed — every transparent cell
orthogonally adjacent to the animal becomes ink. Correct by construction, it cannot drift
when the silhouette is edited, and it is testable. Diagonals are excluded on purpose:
including them thickens every corner into a blob. The grid is padded one cell on every
side, because without it the legs had no bottom edge, the ear no top and the muzzle ran
off the left.
   FOUR LAYERS, AND PALETTE TOKENS RATHER THAN currentColor — because the drawing needs
two tones with guaranteed contrast and currentColor only gives one. Fill is --paper, the
outline and patches are --ink-1, and the muzzle is the vermilion --seal at 55%, the one
saturated colour the palette allows. --paper and --ink-1 flip together with the theme, so
the cow is pale-with-dark-ink on light pages and inverts coherently on dark; the fill and
outline can never collapse into each other.

3. THE READING SKIES START LOWER. "what i liked is actually lower. the original background
position on that card when we didnt delete anything." The fluid is sampled from scroll
position (uYOffset = scrollY / vw), so deleting content above a card moved the card to a
smaller scrollY and showed a HIGHER part of the field — which explains exactly what he
described. FluidSky takes a `yStart` prop (in viewport widths) that gives a page a
constant head start into the pattern; /research and /projects now use yStart 1.15 and
strength 4.
   I COULD NOT VERIFY THIS VISUALLY and am not pretending otherwise: this headless Chrome
reports webglAvailable:false, so the fluid canvas never renders here at all. yStart is one
number in each page's FluidSky tag and the effect is continuous, so it is cheap to tune by
eye on the real machine. I deliberately did NOT switch those pages to the `descent` ramp
even though that is the look he pointed at — the `reading` variant exists because those
pages carry dark ink on pale paper, and swapping the ramp risks the contrast that
lib/skyLegibility.ts is there to protect, which I also cannot check without a GPU.

Verified: 795 tests green (20 for the cow), build clean, a11y 100 on the homepage,
performance 95 with LCP unchanged at 2710ms — an intermediate reading of 89 was machine
noise from a concurrently running audit (load average 9.8), confirmed by re-measuring.
New tests assert the derivation rather than a drawing: every outline cell touches the
animal and none overlaps it, nothing runs off the grid on any side, the eye is ONE hole
(counted as runs, not cells — counting cells reported two eyes for one two-cell eye), and
the muzzle sits at the front of the head.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The owner: "now you bumped my IT logo to nowhere."

My fault, and the mechanism is worth recording. The seal and the cow were siblings of the
links nav inside one flex/wrap row, so where they landed was decided by the WRAP POINT
rather than by any rule. When I gave the cow `width: max-content` to stop it stretching,
its width changed — the wrap point moved with it, and the seal went with the wrap.

They now share one explicit row of their own: seal left, cow right, `justify-between`.
Deterministic whatever the widths do. Measured: seal 112px from the left edge, cow 112px
from the right, both below the nav and on the same baseline.

Verified: 795 tests green, build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The owner supplied a pixel grid after five of my attempts failed: "looks like a dragon",
then a robot, then "doesn't read like a cow", then "horribly wrong ... they are monsters
not cows". He was right every time, and the grid made both underlying errors obvious.

1. THE POSE. Every version I drew was a full profile — one eye, muzzle pointing sideways.
   The reference is a CHIBI cow FACING THE VIEWER: it looks straight at you, with a large
   muzzle in the middle of the face. That silhouette is most of what makes it read as a
   cow instead of as some quadruped. Hand-drawing in a text file and judging in a terminal
   is what let me miss it five times.

2. THE PALETTE. I had two tones. The reference needs four — black markings, white body,
   PINK muzzle, TAN horns — and the horns and the muzzle are exactly the features that say
   "cow" rather than "dog". They were the two things I never had.

So this is a transcription, not a design: horns at the top corners, black patches over the
ears and around the eyes, two eyes, a wide pink muzzle with two nostrils, a white body
with a patch each side, a tail with a dark tuft, and hooves. Four colour layers plus the
derived outline, with the horns on --ochre and the muzzle on --seal.

One bug found by screenshot: the muzzle came out brick red, because the seal was painting
at 42% straight onto the page with no white beneath it. The body layer now covers the
muzzle cells so the red mixes with paper and reads as pink. A test asserts it.

The tests are rewritten around what actually distinguishes this from my failures: taller
than wide (a profile cow is wider than tall, so this fails if anyone reverts), a muzzle
centred within 2.5 cells and spanning over half the face, exactly TWO eye runs above the
muzzle, exactly two horn runs and both in the top half, two hooves, white under every
muzzle cell, and the derived outline hugging the cow without overlapping it.

Verified: 795 tests green, build clean, and the cow screenshotted at 4x on /404.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The owner: "i want the research card to be folded by default. when going from fold to
collapse and vice versa i want animations."

FOLDED BY DEFAULT. The script used to open the first featured paper on load, on the
reasoning that a wall of shut cards makes a reader click before they can read anything.
But the collapsed card already carries the title, subject, takeaway and headline numbers —
enough to decide with — so opening one just picked a winner on the reader's behalf. A deep
link still opens its own card.

THE FOLD ANIMATES, which it never did: it toggled the `hidden` attribute, so the panel
appeared and vanished with no motion at all. That is the thing he noticed, and it was on
the audit list too.

Two attempts, and the first one silently did nothing:
  * max-height with a generous cap (400vh) looked right in the code and DID NOT ANIMATE.
    Measured: against 795px of content the panel reached full height inside 140ms of a
    460ms transition, because max-height interpolates over the CAP, not over the content.
  * So the script measures the panel and animates to its own scrollHeight, then clears the
    cap on transitionend so a later resize or font swap cannot clip it. Closing needs the
    concrete height written back first, or there is nothing to animate FROM.
Now measured properly: 0 -> 395 -> 736 -> 784 -> 795 opening, 795 -> 112 -> 0 closing.

max-height rather than grid-template-rows 0fr->1fr because .paper-body is ALREADY a
two-column grid whose own rows are load-bearing. This is a deliberate exception to the
transform/opacity-only rule — an accordion cannot be built without animating size — and it
is one element, not a full-page layer.

Two things that came free with it: the closed panel now leaves the accessibility tree and
the tab order, because a zero-height panel whose links are still focusable is a keyboard
trap on a page that looks empty; and html:not(.paper-js) keeps the panel fully open, so
with JS off the papers stay readable rather than collapsing to a sliver.

Verified: 791 tests green, build clean, /research a11y 100, and the open/close interpolation
sampled frame by frame rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It shipped as "the one dark slide" — a deliberate register break marking the
arrival of the explainer, dark in both themes. The owner rejected it outright:
"in light theme, the Still an open problem slide, the background is dark."

He is right, and the reason is worth keeping. A light-theme reader has no way to
read one dark slab as a *register* choice; they read it as a bug, or as a panel
that half-loaded. A theme is a promise about the whole page, and one surface
opting out breaks it.

Every colour on that slide already routed through six variables, including the
Bellman lattice, so this is a six-value swap — now byte-identical to Choice,
Rules and Work, which is also what stops the four paper slides drifting apart.
Nothing needed redrawing, and the lattice reads better on the lighter ground:
the value surface is a dark wireframe instead of a faint one, and the ochre
optimum carries more against tan than it did against near-black.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
THE BUBBLE. "why not have a dialogue bubble to wrap those words? also, the font
can be more pixel like, let the texts be bigger. way bigger." So the words moved
inside a pixel bubble: square corners, a one-cell ink border, a paper interior,
and a stepped tail. He picked 10px cells from the two sizes mocked up, and that
one number drives the border, the type and the tail — which is what makes it
read as pixel art rather than a rounded chat bubble with a blocky font. MOO! is
drawn type from a matrix, 190x50 against the 11.5px mono line it replaces.

The tail is two layers, because one colour cannot work on both grounds: solid
ink was invisible on the homepage's near-black footer, and solid paper would
vanish on the light /404 and /writing grounds. Its top row is painted over the
bubble's border, and that overlap is what opens the mouth.

THE SEAL. Reported twice as "drifting", and both of my earlier fixes edited the
markup — which could not have worked, because the markup was never the problem.
global.css forced `display: block` on the footer inside the deck, and an ID
selector outranks Tailwind's `flex justify-between items-end`. So on the
homepage, and only the homepage, the footer laid out as stacked blocks and the
seal rendered at the far LEFT. The slide rule now excludes the signature
outright, so its own utilities simply apply and there is nothing to override.

Also fixes the cow itself under the dark theme, which a screenshot caught: it
was drawn from --paper/--ink-1/--ochre, and in dark --paper (#dfe3df) and
--ink-1 (#dce1dc) differ by three values per channel, so the body and outline
collapsed and the animal lost every patch, hoof and eye — while --ochre becomes
the terminal phosphor green, so it grew emerald horns. A Holstein is a drawing,
not a surface. Its four colours are pinned; only the bubble follows the theme.

Verified: both themes, /404 and /writing, and 7 routes x 6 widths with no
horizontal overflow. A11y 100 on /, /research and /404. 798 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"the research card collapsing/folding animation is not smooth at all, almost
like a ppt." Three separate faults, none of them a timing problem, so all three
are fixed rather than re-tuned:

1. THE FIRST OPEN DID NOT ANIMATE AT ALL. The panel was collapsed with `hidden`,
   i.e. display:none, and the script removed that attribute and set the target
   height in the same frame — which gives the browser no previous computed value
   to interpolate from. So the first open of every card snapped, and only repeat
   opens animated. The pre-paint collapse is now pure max-height (the CSS was
   already doing it via html.paper-js, so the attribute was redundant), and the
   panel is always laid out.
2. THERE WAS NO FADE, only a rectangular wipe — which is exactly the "ppt" read.
   A leftover `detail-in` keyframe from the previous implementation ran with
   fill-mode: both and pinned opacity to 1 forever, and an animation outranks a
   transition, so the fold's own opacity transition could never be seen. Deleted.
3. THE CONTENT DID NOT MOVE. The two columns now fade and lift on their own
   timeline — transform and opacity, per the project's motion rule — arriving
   just behind the box on the way open and leaving first on the way closed.

Two more bugs found while in here. A stale transitionend listener: closing a card
before its open finished left the open's handler attached, the close's own event
fired it, and it set max-height back to `none` — so the panel sprang open again
as an empty gap, reachable by double-clicking. And revealFromHash still tested
for the `hidden` attribute the fold no longer uses, so the guard was always true
and deep links had silently stopped opening their card.

Closed panels are `inert` rather than display:none, so a zero-height panel is
still out of the tab order and the accessibility tree.

Measured: 48 distinct box heights across ~400ms on first open, close, and second
open alike. Closed state verified inert, aria-hidden, zero height, links
unfocusable. A11y 100.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolving the difference between the shipped homepage and this branch's. Prod
carries the featured paper as a lucid glass card with two headline stats; this
branch replaced it with a three-column "The work" that points at all three
routes. The structure is the better information design for a deck — it names
where everything is instead of promoting one item — but it spent every number on
the page to get there, which on a quant's site is the wrong trade. The Sharpe
against its two baselines is the most checkable claim here and the one thing a
reader cannot infer from a title.

So the structure stays and the numbers return: ~120% compounded out of sample,
and 0.90 Sharpe against 0.85 / 0.76. Derived exactly as the shipped card derives
them — results[0] and the Sharpe row out of metrics — so profile.ts stays the
single source and the figures cannot drift from the paper's own table. Both
baselines are named, because a number with nothing to compare it to is
decoration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found while checking the new footer cow across widths, and not caused by it: the
Choice slide's two PnL legends sat in a `grid-template-columns: 1fr 1fr`, and a
bare `1fr` is `minmax(auto, 1fr)`, so a track cannot shrink below its content's
min-content width. Each legend measures 197px (three fixed 5ch numeric columns),
which at 390px wide put two 197px tracks in a 342px box and pushed the document
to 438px against a 390px viewport.

Letting the tracks shrink is worse, not better — measured: the name column
collapsed to 0 and "Buys once, never looks" wrapped into 72px of one-character
lines. These labels are short sentences, so the only width that works for them is
the full column, and below 700px the two legends stack.

Stacking costs about 70px, and because the panels are absolutely positioned to
fill a fixed-height stage that cost overflows the box rather than growing it —
the last legend row landed on the caption (legend bottom 733 against a panel
bottom of 747 now, 775 before). The chart gives the room back, which is this
slide's own rule at every other narrow breakpoint: at phone width the curves say
"these five diverge" while the legend carries the numbers.

Verified 7 routes x 6 widths from 320 to 1920: scrollWidth equals the viewport
everywhere, and the labels are back to single lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit claimed the first open of every card snapped because the
panel was coming out of display:none in the same task as the height change. That
claim was reasoning from the code, not a measurement, and it is wrong: I built a
reduction of the exact old pattern and it starts a transition, identically to a
forced-reflow control. The parallel audit measured the same thing on the shipped
build — roughly 36 frames from 0 to 769px. The box was always interpolating.

The fix stands, and so do the other faults, which I did verify:

- The leftover `detail-in` animation with fill-mode: both was the real cause of
  the "ppt" read. Measured on a reduction: a filled animation overrides the
  element's own declared value, so `opacity: 0` never applied while closed
  (computed opacity 1) and asking for the fade started ZERO transitions. No fade,
  no content movement — a rectangular wipe, which is exactly what he described.
- Deep links opened nothing, verified fixed: /research#r-p0-math now lands with
  the panel open, uncapped, 824px, aria-expanded true and the anchor visible.
- The stale transitionend listener could still re-expand a closed panel.

Removing `hidden` remains right, but for a duller reason than I gave: the CSS
collapse gated on html.paper-js already did that job, so it was a second
mechanism to keep in sync — and it was the one that broke deep links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
From the adversarial audit, reproduced before fixing. Choice and Rules each added
their enhancement class at module scope. Astro's ClientRouter replaces <html> on
every in-site swap, taking its class list with it, and a bundled module only ever
executes once — so the classes lasted exactly until the reader's first navigation.

Measured, home -> /research -> home at 1440x900: html went "define-js complex-js
solve-js" to just "solve-js"; #choice grew 900px -> 1585px; every .ch-panel fell
back to position: relative so all four stacked and rendered at once; and the beat
buttons became silent no-ops, since is-on landed on a panel whose opacity stays 0.

solve-js was the survivor precisely because Solve.astro already adds its class
inside its init, which re-runs on astro:page-load. Both now match it, and the add
moved AFTER each guard clause — so the "nothing to sequence" path returns before
the class exists instead of adding it and removing it again.

After: html is "define-js complex-js solve-js" and #choice is 900px with panels
absolute, identical to a fresh load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
From the adversarial audit, measured: the footer's primary action painted
near-white #dce1dc on the phosphor-emerald accent #66c28c — 1.64:1 against a
4.5:1 requirement, on the site's main call to action.

The cause is that it set `text-ink-1`, which rides the ink ramp and so inverts
with the theme, while the accent is a LIGHT colour in both themes. tokens.css has
carried --on-accent all along for exactly this and names the CV button as the
reason it exists; the footer simply never used it. It is now exposed through
@theme as --color-on-accent, so the fix is a utility swap and any future accent
chip can reach it the same way.

Dark 1.64:1 -> 8.61:1. Light is byte-identical: --on-accent and --ink-1 hold the
same value there, so the shipped light theme is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The adversarial audit's top finding, verified independently before and after:
none of Fraunces, Inter or JetBrains Mono had EVER rendered — not on this branch
and not on the live site. Every page was Georgia / system-ui / Menlo.

The cause was a name collision. astro:fonts was pointed at --font-display,
--font-body and --font-mono, which are also the site's type ROLE tokens. It
writes its own hashed family into those properties from an inline <style> in the
head; tokens.css and global.css each re-declared the same properties further down
with a bare 'Fraunces', which matches no @font-face rule anywhere, and won on
source order. Measured before: 0 of 24 FontFace entries loaded, and
getPlatformFontsForNode reported Georgia on the h1, .SF NS on body copy and Menlo
on the mono — all isCustomFont: false — while every woff2 still downloaded.

Each family now owns its own --ff-* variable, so astro:fonts and the stylesheet
cannot clobber each other, and the role tokens are built from them in exactly ONE
place. The duplicate declarations in tokens.css are deleted rather than corrected:
two copies of the same stack is what hid this for so long. Per-family `fallbacks`
replace the default sans-serif, which was the wrong shape for both the display
serif and the mono.

Measured after: Fraunces, Inter and JetBrains Mono all paint as isCustomFont:
true, 12 of 24 faces loaded (the other 12 are the metric-matching fallback faces,
which are not files).

Payload is unchanged — the same four woff2 were already being preloaded and
wasted, so this spends no new bytes. font-display: swap on all 24 faces and
metric-matched fallbacks keep CLS at 0. Re-checked the whole deck after the metric
change: every slide still fits its panel at 390/768/1024/1280/1440/1920, and 7
routes x 6 widths have no horizontal overflow. 798 tests green, a11y 100.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
From the adversarial audit. Each claim reproduced with numbers first, then fixed,
then re-measured.

1. THE CORNER NAV LOST ITS FIRST DESTINATION on every inner route. The capsule was
   one non-wrapping flex row with justify-content: flex-end, so once its contents
   outgrew the phone the overflow escaped LEFT, off screen: on /research at 390px
   "Research" sat at x -32..41 and read as "earch"; at 320px it was gone entirely
   (-102..-29) and "Writing" was clipped too. It wraps now, with a radius that
   suits two rows, and the orphan separator — left behind because the icon links
   are already hidden from 1199px down — is hidden with it.
   After: nothing clipped at 320, 390 or 1024.

2. .ru-note PRINTED OVER .ru-cap, and not only on phones. The beat panels are
   absolutely positioned to fill a fixed-height stage, so a panel taller than its
   box does not push the caption down — it paints on top of it. Measured overlap:
   31.8px at 320x700, 8.3px at 390x844, 7.6px at 1024x768. The stage floor took
   two measured passes (256px still left 13.8px, 272px still left 5.8px) because
   the note wraps to more lines the narrower it gets; 288px clears the worst of
   the four notes. After: -2.2 / -17.5 / -10.4, all clear.

3. THE RULES BEAT BAND WAS FOUR COLUMNS ON A PHONE — 84.75px each at 390px and
   67.25px at 320px, about ten characters per line for a body that has to be read
   before it can be chosen. Two columns below 700px: 170.5px and 135.5px.

4. THE STORY FIGURE KEPT position: sticky BELOW THE STACKING BREAKPOINT. Sticky
   puts the graph beside its explanation, which only means anything while there is
   a column beside it; stacked, the figure is above the prose and pinning it made
   a 744px figure ride down over the paragraphs at two of three phone deck stops.
   My first attempt at this silently did nothing — I put the override inside the
   max-width block that sits BEFORE the base declaration, so source order won at
   equal specificity. It is now after it, and measured static at 320/390/1024.

Gates: 798 tests green, a11y / best-practices / SEO 100, every slide still fits
its panel at six widths, and no horizontal overflow across 7 routes x 6 widths.

Known and left alone: the Rules and Story slides are still taller than a phone
viewport (1359px at 320x700). lib/deck.ts pages through slides that are
legitimately taller than a screen, so this is the deck working as designed rather
than a clipping bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"maybe move the cow to the middle in the bottom." It was pushed to the right,
which sat it directly under the IT seal and made the two read as one crowded
corner while the entire left half of the strip stayed empty. Centred, the footer
balances: links left, seal right, cow between and below them.

`margin-left: auto` comes off .cow-wrap--small in the same change. It used to pin
the cow to the right of whatever row it landed in, and an auto left margin absorbs
all the free space on that side — so leaving it in would have silently cancelled
the centring and put the cow straight back against the right edge. Placement now
belongs to the container, which is the only thing that knows what the row is for.

Measured at 1440x900: the cow's centre is 720px against a viewport centre of
720px, with the nav at x=101 and the seal at x=1152.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by a scripted adaptability sweep, not by eye — which is the point, because
the defect only appeared on the ACTIVE row. `.rule` animates from 0 to 20px when a
stop activates and takes that width out of the label beside it, so the longest
label in the data went from fitting to needing 151px against a 148px box, and the
rail's own ellipsis cut it to "UNIVERSITY OF TORON…". A reader saw it only after
scrolling to that stop.

That string is the PhD institution, which the site's identity hierarchy treats as
load-bearing, so an ellipsis there is not an acceptable degradation.

The rail goes 190px -> 200px. There is room: measured at 1180px, the narrowest
width where the rail renders at all, its right edge is at x=218 and the nearest
content starts at x=401 — 183px of clearance. Widening beats tightening the
label's letter-spacing, which would have changed the rail's typography everywhere
to fix one string. After: no label on /experience or /research truncates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by two passes that disagreed with each other, which is why both were worth
running: a scripted geometry sweep, and a fan-out of agents reading screenshots at
viewports nobody had ever looked at.

THE WORST ONE, and the script could not have found it: at 320x568 the hero h1 sat
at y=32..90 while the wrapped two-row nav capsule occupies y=12..80 — so 48 of the
name's 58px were behind it and "Ing Tian" was legible only from the g's descender.
The site's own name, unreadable, in both themes. #heights is a flex column with
justify-content: flex-end, so when its content is taller than min-height the
overflow leaves through the TOP, straight under the nav. It now carries 96px of
top padding, which fixes both cases: the block still parks at the foot of the
screen when it fits, and starts below the capsule when it does not.

THE SAME CLASS, ON EVERY DECK SLIDE. The deck rests with a slide's top at the
viewport top and a paper panel puts its kicker and headline in the first ~90px, so
at rest the capsule sat across each explainer's headline. Measured at 390x844,
covered widths: "What is multi-period portfolio optimization?" 228px · "Now imagine
it is not your money." 341px · "Still an open problem." 212px · "The work" 123px,
plus every kicker. Not transient — those are the positions the deck stops at.

The panels take 78px of top padding below 1100px wide or 620px tall. NOT
--deck-lead, which was my first attempt: a lead lifts the resting position above
the slide, so a full-bleed opaque panel gets a strip of sky above it and reads as a
slipped card — Story.astro had already measured that (60px of sky) and names the
panel's own padding-top as the right home for this air. The first attempt also did
nothing at all, because `.is-slides > section` (0,1,1) loses to Astro's scoped
`.ch[data-astro-cid-…]` (0,2,0); the panel selectors are (0,2,1) and win.

The five `--deck-lead: 0px` declarations go with it. They only restated the
default — Deck.astro falls back to 0 when the property is absent — so they were
five copies of nothing.

ALSO:
- The footer strip takes the same clearance. Here an interactive element was at
  stake: resting on #signature at 320px, the capsule covered 95px of "Download CV ↓"
  and the Research pill, so a tap in that band would have hit the nav.
- Both nav separators now hide with the icon links, not just the first. Hiding the
  icons from 1199px down while keeping a rule on each side left a stray double
  divider with a 14px empty slot between them at 768px, reading as a missing button.

Verified after: no headline, kicker, hero name or interactive element is under the
nav at any deck rest position across 10 viewports; the geometry sweep is still 0
findings over 196 combinations x 3 scroll positions; 798 tests green; a11y 100.

Known and left: on free-scrolling reading pages whatever sits at the top of the
viewport is under the nav, which is how every fixed header behaves and is reachable
by scrolling up; and at the final deck stop the tail of #work shows above the footer
behind the capsule, which is clear at #work's own stop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oter

Two things the owner asked for in one pass, since they touch the same files.

── THE TYPE. "i think prod's font is better. the current font is kind of weird. but
maybe you can use it in some sub headlines to add some varieties."

Worth recording what was actually being compared, because it is not obvious: the
live site has never loaded a webfont — that was the bug fixed a few commits back —
so "prod's font" IS Georgia, and every judgement made about this design over months
was made in Georgia. Fraunces only started rendering when the collision was fixed.
It is a high-contrast display serif with deliberately wonky curves, and at headline
size against a calm workhorse serif it reads as mannered. Choosing the face he has
actually been reading is the right basis for the call.

So --font-display is Georgia (a system serif, nothing downloaded for it), and
Fraunces moves to a new --font-accent used where a characterful serif is an asset:
the Work slide's sub-headlines (RL-BHRP / witness / manifold), its italic writing
lede, and the /research pull-quote. Structure Georgia, voice Fraunces.

PERF, and one wrong turn measured and reversed. Making Fraunces an accent looked
like it should free its preload — nothing above the fold waits for it now. But the
homepage still USES it, so the file downloads either way, and unannounced it lands
late in the critical chain: three runs on a production build gave FCP 2333ms against
1956ms with the preload, for identical bytes. Preload restored. Net state is
92 / FCP 1956ms / 307KB, unchanged from the previous commit — so this is
perf-neutral, and the standing cost of having real webfonts at all remains 2 points
against prod's accidental no-webfont 94 / 1585ms / 276KB.

(A first reading of 50 with FCP 11854ms was me measuring the DEV server, which ships
unbundled modules and source maps at 2147KB. Discarded, not reported.)

── THE COW. "the IT logo still conflicts with the cow. im thinking if it's better to
move the cow to above." Right on both counts. Bottom-right, then centred, were the
second and third positions tried inside the footer, and the position was never the
problem: a 19-word strip cannot hold two marks without them competing. The seal is
the brand; the cow is an aside.

It now sits at the foot of the Work slide, centred, which is where he pointed — and
that slide had 480px of empty panel doing nothing (422px of content in a 900px
slide). The footer is left with one mark, and the cow is still the last thing before
it.

Gates: 798 tests green, a11y 100, every slide fits at six widths, and the geometry
sweep is still 0 findings over 196 combinations x 3 scroll positions, re-run against
a production build. Both themes checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
── POSITION. The owner picked E from six real renders of the live page. Centred was
the version he called "off … there by mistake", and the reason is structural rather
than a matter of taste: a three-column grid has no centre line, so a centred mark
lands in the gutter between the first two columns and aligns to nothing, while
every other element on that panel sits on the grid. Flush right puts it on the same
edge the grid ends on, at a new `mark` size — small enough to read as a margin note
rather than a fourth column.

── THE BUBBLE, which was a bug I introduced. "try to let the bubble grow
horizontally instead of vertically, still two lines. as such, we dont trigger a
vertical rerender. the current rendering has no animation and thus it abruptly
pushes the content up and down."

I had reserved two lines of height at one font size, but the messages run 24 to 51
characters, so at the smaller size the long one wrapped to four lines: the box grew,
and every press shoved the panel. Reserving MORE height would only have meant a
taller bubble full of air.

The fix is to stop letting the browser choose where to wrap. lib/cowSpeech.ts splits
each message at the word boundary that most nearly halves it, both halves render
`white-space: nowrap`, and the box is sized from the copy. So the height is exactly
two lines for every message BY CONSTRUCTION, and the width follows the wider half.
Balanced rather than greedy, because the bubble is as wide as its wider half — an
unbalanced split would be nearly as wide as the whole sentence and buy nothing.

Because the pair is right-aligned, the bubble grows LEFTWARD into empty panel while
the cow stays anchored, so nothing else can move. Width transitions over 300ms;
animating width is a deliberate exception to the transform/opacity rule for the same
reason the research fold animates max-height — you cannot grow a box on the
compositor, and scaleX would distort the pixel border and the type.

Measured across a full cycle of messages at 1440x900: bubble height 87px, bubble top
637, panel height 900, document height 6812 and the cow's right edge 1405 are all
CONSTANT, while the width moves 132 → 206 → 119px. Two follow-ups came out of
measuring rather than reasoning: the box first shipped sized for the longest message
and shrank on the first press (311px against the 132px that message needs), so it is
now sized for the first message, which is also the only right answer with JS off; and
the `ch` approximation left the wider half 0.4px past the content box at 620px, so
there is one character of slack.

Verified: halves never overflow their box at 320/360/390/430/620/768/1440 on both the
mark and the large cow, heights constant at every one of those widths, 808 tests
green (10 new for the split), a11y 100, and the geometry sweep still 0 findings over
196 combinations x 3 scroll positions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"i noticed that when i scroll down to the work, it seems like you aligned the bottom
with the footer so that leaves a huge space below the cow. why not merge the footer
and the last slide? name it appendix."

He read the symptom exactly right. They were two top-level sections, so the deck gave
each its own stop: resting on the work left 161px of empty tan below the cow with the
footer entirely below the fold, and the final stop was a 193px strip. One section,
one stop — the page now ends where it stops.

The footer is NESTED inside #appendix rather than merged into its markup. lib/deck.ts
counts only the direct <section> children of <main>, so nesting is the whole
mechanism: no change to the deck, one fewer stop. It keeps its own dark ground
because it still sits on the descent gradient below the tan panel, which is where the
sky reaches near-black — the page continues to END in the dark, which is the point of
the descent. Merging it INTO the panel would have made the footer tan and thrown that
away.

`Appendix` is his word and it is the better one now: with the links, the seal and the
copyright inside it, this is no longer only "the work" but the reference matter at the
end of a paper — the register the rest of the site is already written in. Flag for
him: this changes the visible heading from "The work", which was itself a deliberate
choice ("just say plainly, my publications, my writings, my projects"). The column
labels still say Publications / Writing / Projects, so the content is self-describing,
but the word is easy to change back.

Measured at 1440x900, before -> after: empty panel below the cow 161px -> 68px;
document height 6812 -> 6625; top-level sections 8 -> 7; rail stops "The work" +
"Links" -> "Appendix". The footer is now visible at the same rest position as the
work instead of below the fold.

This also fixes a confirmed finding from the adversarial audit as a side effect: the
deck's final advance was wrong at every desktop size because the footer strip
generated a stop that swallowed the work's own. There is now one final stop and it
lands 6px from the page bottom.

Two pieces of dead CSS go with it — the `:not(#signature)` exemption on the slide rule
and the footer's own nav-clearance padding. Neither can match a nested footer, and a
:not() guarding an element that can never appear is a false clue about the layout.

tests/pageStops.test.ts caught the rename before the browser did: its RENDERED list is
the only thing tying the rail's targets to the page's real section ids. Updated, and
808 tests green.

CI: the failure on the previous commit was transient, not this branch's. astro:fonts
got a 404 from fonts.gstatic.com for a JetBrains Mono file while computing fallback
metrics — Google had rotated the file hashes mid-run (the requested URL ended
BYaTNPxDcwgknk-4, the live one ends BYRbKPx3cwgknk-6nFg). Reproduced against a
genuinely cold cache locally — moving node_modules/.astro/fonts aside, not just
.astro/fonts, which is a copy — and the build downloaded all five files and passed.
Re-ran the job: green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A 20-agent review of 100 screenshots at four never-examined viewports, each finding
re-checked by an independent refuter: 14 claimed, 6 refuted, 8 confirmed. Five were
real and are fixed here. The two most serious were invisible to my own scripted
sweeps, which is the argument for having run both.

1. THE CAREER GRAPH WAS BLANK ON EVERY PHONE. Below 720px the canvas gives way to its
   waypoint list, which is supposed to stop being visually hidden and become the
   content. The rule reset `clip` — the LEGACY property — but Tailwind v4's `sr-only`
   hides with `clip-path: inset(50%)`, which was never reset. So the list was laid out
   AND unpaintable at the same time: measured at 320x568, .dp-stage display none,
   .dp-stops display grid with 8 items, visibility visible, opacity 1, height 573px,
   clip-path inset(50%). The site's showpiece was 573px of blank panel on every phone,
   in both themes. `clip-path: none` is the whole fix. Undoing a visually-hidden helper
   means undoing every property it sets, and the two clip properties are not one.

2. THE CODA PRINTED ON TOP OF THE GRAPH between 721px and 1000px. Two breakpoints that
   mean the same thing were different numbers: the canvas hides at 720px, but the coda
   returned to normal flow at 1000px — inside the stage — so in that band the drawing
   was still drawing underneath it. Measured at 844x390: stage y 256..474, coda static
   at 267..311, with the italic line and the node label "B.Eng · McGill"
   interpenetrating at x≈420-476. Now both are 720px.

3. THE RECOGNITION LIST DID NOT COLLAPSE. Identical `8.5rem 1fr` grid to the timeline
   nodes beside it, which do collapse — it was simply missed. At 320px the year column
   held 136px of mostly empty space while the award wrapped into 115px at about
   fourteen characters a line.

4. THE RULES BEATS WERE STILL TOO NARROW at the smallest sizes. Two columns fixed the
   four-across case; at 320px each is 135.5px, about seventeen characters a line. One
   column below 430px.

5. THE /art RAIL WENT INVISIBLE HALFWAY DOWN. The page rides the whole descent while
   the rail is fixed at the middle of the screen, so dark ink stops working: measured
   at 50% scroll, the ACTIVE label was rgb(22,20,15) on rgb(22,20,15) — ratio 1.0, the
   section you are in rendered invisible. It now flips to paper ink off the descent
   fraction behind it, computed inside the gallery's existing scroll handler rather
   than a second listener. 1.0 -> 15.22.

Also: the hero's mono identity line was being eaten by the terrain dots at 320px. The
legibility wash is switched off on phones because its halos are placed for the desktop
composition, which left the terrain as the only thing between the dots and the type.
Lowering the canvas alone was not enough — still broken up at 0.32 — so phones get a
vertical band of paper across the middle where all the hero text sits, fading out top
and bottom so the terrain still reads.

THREE of these fixes silently did nothing on the first attempt, all to the same cause:
an override written into a media block that sits ABOVE the rule it overrides loses on
source order. The awards grid measured "136px 115.219px" unchanged; the Rules band
stayed two columns. Both are now placed after what they override, and said so in the
comments. This codebase interleaves its media queries, so that trap is permanent.

Refuted and deliberately NOT changed: the rail label "Ericsson AI Lab (GAIA)" was
reported truncated at ultrawide by a reviewer and by two verifiers. It is not —
measured scrollWidth 158 against clientWidth 158, the closing bracket is there, and no
rail label on any route truncates when forced into its active state. The ultrawide
screenshots were captured at half raster (a 3440x1440 PNG with a live WebGL sky times
out), so rail type renders about five pixels tall and a ")" reads as an ellipsis. My
capture choice created that finding, not the site.

Gates: 808 tests green, a11y 100 on /, /research, /experience and /art, geometry sweep
0 findings over 196 combinations x 3 scroll positions, no nav-over-text collision at
any deck rest position across 10 viewports, bubble halves fit at 7 widths, and no
horizontal overflow on 7 routes x 6 widths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@IngTian
IngTian merged commit 3bd29bc into main Aug 12, 2026
1 check passed
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.

1 participant