fix: the 404's overflow, an illegible footer row, the fold's jolt, and a theme toggle that reset the slides - #49
Merged
Merged
Conversation
…eset the slides
Five small things in one pass, per the owner. Two of them measured clean; one perf
theory of mine was WRONG and is reported as such below.
1. THE 404 SCROLLED INTO NOTHING. The atmosphere wash is absolutely positioned at
120vh, so it still contributes to the document's scroll height while being
taller than the viewport by definition. On the homepage that never showed
because the content is thousands of pixels tall; on /404, whose content is
exactly one screen, the document measured 1080px against a 900px viewport —
180px of empty scrollable space, precisely 120vh minus 100vh. Capped at
min(120vh, 100%): the homepage is untouched (100% there is ~6600px, so 120vh
still wins) and a one-screen page is now exactly one screen. Verified across
/404, /writing and / in both themes at five viewports; every remaining overflow
is real content, and the wash now measures exactly the document height.
2. THE 404'S SECOND LINK ROW IS GONE. The owner: "in light theme, the bottom of the
404 page is illegible. i wonder if you need that at all. after all we have the
hovering nav bar." Both halves right. It rendered the SAME `PAGES` array the
corner nav renders — duplicate links a few hundred pixels under a nav that is
always on screen — and it was illegible by construction, because --ink-3 is a
dark ink while /404 rides the descent into its dark half. What stays is the one
link the nav does not offer: the way home.
3. THE FOLD'S OPENING JOLT. "the collapse/expand animation is not smooth for the
research card." The panel was animating correctly; the hitch was the collapsed
summary line vanishing via display:none in a single frame. Measured on open, the
document went 1656 -> 1636 -> 1882 -> … — a 20px jerk BACKWARDS on the first
frame, against the panel's growth, at the exact moment the expansion starts. It
now collapses over the same duration and easing, so the travel is monotonic:
1656 -> 1698 -> 1912 -> 2205 -> … -> 2446, zero backward steps.
4. /art WROTE A DATA ATTRIBUTE ON EVERY SCROLL FRAME. Assigning the same value to a
dataset property is still an attribute mutation, so the rail's subtree was being
invalidated ~60 times a second for a value that changes twice in the whole page.
Guarded. Honest measurement: style recalc across one drag-scroll went 294ms ->
274ms, which is within noise — the real cost on that page is elsewhere and is NOT
fixed here (see below).
5. THE THEME TOGGLE RESET ALL THREE EXPLAINER SLIDES, a confirmed audit finding. It
dispatched astro:page-load to force the canvases to repaint, which re-ran all
fifteen page-load listeners — including the beat steppers, which restart at beat
0. Only three surfaces paint from JS and read the palette (TerrainHero,
DescentPath, FluidSky), so they get a dedicated `theme:change` event and the
other twelve are left alone. Verified: a slide left on beat 3 is still on beat 3
after a toggle.
AND IT IS NOT A PERFORMANCE FIX, which is what I expected it to be. Measured
directly: `theme:change` costs 55-59ms and `astro:page-load` 56ms — identical,
because the cost is entirely the two field-computing canvases, which repaint
either way. I also moved the dispatch out of the View Transition callback on the
theory that it was blocking the transition; that was wrong too. Isolated by
running the toggle with prefers-reduced-motion, which skips the transition
entirely: 357ms with it, 364ms without, and /404 with the transition ON costs
zero long tasks. The transition was never the cost. The relocation is kept
because work outside a frozen page is still better than work inside one, but it
buys nothing measurable and I am not claiming it does.
STILL SLOW, deliberately not attempted here, all measured at 6x CPU throttle:
· the theme toggle's ~56ms canvas repaint. The real fix is to separate geometry
from colour — the terrain field, projection and point positions are identical
across themes, so only the ramp needs to change. That is a refactor of two
canvas components, not a one-line change.
· /art: 274ms of style recalc across one drag-scroll, the highest of any page.
· the homepage: 324 layouts and 144ms of layout across one drag-scroll.
None of these is a frame-rate collapse — an rAF probe showed no long frames
anywhere, which is exactly why that probe was useless: on a 120Hz display an empty
rAF loop reports vsync no matter what the page is doing.
808 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The owner's call, and it is the simplification the fold wanted: "i think it might be
improved if you dont hide Allocation that learns instead of assuming stuff on
expansion. so let's hold the card not move."
The card now keeps exactly its own size in both states, so opening a paper moves ONE
box — the panel below it — and nothing above the panel shifts at all. Measured across
the whole animation: the card reports a single height (387px) and a single top
(763px), and the document grows monotonically 1656 -> 2464 with zero backward steps.
Two earlier attempts at this were worse, in order:
· `display: none` dropped the line in one frame, which measured as a 20px jerk
BACKWARDS in document height at the exact moment the panel began growing.
· Collapsing it smoothly fixed the jerk but still had two boxes animating in
opposite directions for 400ms.
Holding it still removes the problem rather than timing around it.
The reason it was ever hidden was duplication — the same sentence rendered again
inside "The idea". That copy is deleted instead, so the thesis appears exactly once,
in the one place visible in both states. Its `.takeaway` rule goes with it rather
than being left as unreachable CSS.
The accent face moves to the card's line. That in-panel copy was the only use of
Fraunces on /research, and the page preloads Fraunces — so this keeps the preload
earning its bytes instead of fetching a face nothing on the page uses.
HONEST ABOUT WHAT THIS IS NOT: it is not a measured performance fix. Main-thread cost
across the fold is unchanged — 36ms of style, 26ms of layout, 51 layouts at 6x CPU
throttle, the same as before. If it reads better it is because one box moves instead
of two, not because it got cheaper. I still cannot reproduce the 20-30fps the owner
sees; three instruments failed to detect it (rAF cadence reports vsync on a 120Hz
display; a screencast counter reported ~95fps even at 20x throttle, which is
impossible), and the bisect over backdrop-filter, the descent gradient, the fluid sky
and the grain overlay separated none of them from baseline — though that bisect used
the broken instrument and so proves nothing.
`contain: layout paint` is added to the panel in the same pass: correct on its own
terms for an overflow-hidden accordion, and explicitly not claimed as the cure.
808 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IngTian
added a commit
that referenced
this pull request
Aug 15, 2026
…he nav, and the descent graph (#50) * wip(nav): three mobile nav forms behind a live switcher, to be judged on the phone TEMPORARY AND NOT FOR MERGE AS-IS. Two of the three variants and the whole switcher come out once the owner picks a form. WHY. The owner: "your nav bar exploded on iphone." It had. The merged rule pinned BOTH edges — `.cnav { right: 14px; left: 14px }` — which stops it being a corner nav at all: the capsule stretches across the screen. `justify-content: flex-end` then packed the items against the right edge of that full-width bar and `flex-wrap` pushed the overflow to a second row. Measured in real WebKit at 320px: capsule 292x68 = 91% of the screen, 73px of DEAD pill on the left, theme toggle stranded alone on row two. I introduced that, in Chrome, and signed it off because the metric I checked ("does anything overflow?") passed. I never asked whether the result looked like anything. It does not. The rule it replaced was itself a genuine fix — without `left: 14px` the contents overflowed LEFTWARD off the screen, "Research" rendering as "earch" at 390px and gone entirely at 320px — so this is not a revert. The arithmetic underneath is unforgiving: five labels plus a toggle CANNOT fit one row at 320px at any font size this design would accept. The form has to change. Three candidates: 1 hug grid, 3 auto columns → 233x68, 73% of a 320px screen, rows balanced 3+3, no dead space, still a corner affordance. Two-line change in spirit; my pick. 2 bar deliberate full-width bar, one row, space-between, 10px type → 292x36. Fits 320px with ~10px to spare, so renaming any label breaks it. That fragility is why it is not my pick. 3 menu one 40x38 mark at rest (13% of a 320px screen), expanding to a 148x209 sheet. The only form not fighting for room and the only one that survives a sixth page. A FIX FOR A LATENT BUG, INDEPENDENT OF WHICH FORM WINS: the ≤1199px rule hid `.mark` — the contact LINK — but not its `<li>` wrapper, leaving four EMPTY list items in the capsule. In a flex row that cost 8px of invisible stray gap, which is why nobody noticed. In variant 1's grid it was not invisible: the empty items claimed four cells and pushed the toggle to a third row, making the capsule 97px tall instead of 68px. The wrapper now carries `.mark-li` and hides with its link. This is the ONE change here that reaches past phones: at 641-1199px the capsule is 8px narrower (1024px: 493 -> 485). A fix, but a real difference, so it is named rather than buried. DESKTOP IS UNTOUCHED and measured, not asserted: nav geometry at 1440 and 1920 is identical to live prod (ul 663x44, left 713 / 1193, top 22, 12 li, same items). Every variant rule lives inside `@media (max-width: 640px)` and is keyed off `html[data-nav-variant]`, and the menu button is `display: none` outside variant 3, so it is absent from the tab order and the a11y tree everywhere else. The switcher is pinned bottom-centre, away from the nav it switches, and remembers the choice in localStorage so tapping through to another page keeps the form under judgement. Reachable at ?nav=1|2|3. Judged on the device because that is the standing rule here: a 68px capsule at 320px is exactly the class of detail a screenshot cannot settle. Build clean, 808 tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(nav): on a phone the nav is one mark, and the destinations are a tap away The owner picked the menu form: "of course 3." The two losing variants and the judging switcher are gone with this commit; what is left is the disclosure. WHY A DISCLOSURE, and not a smaller capsule. The constraint is arithmetic, not taste: five labels plus a theme toggle DO NOT FIT one row at 320px at any font size this design would accept. Every attempt to make them fit deformed the nav instead, and all three were measured in real WebKit: · one non-wrapping row -> contents overflowed LEFTWARD off the screen. "Research" rendered as "earch" at 390px and was entirely gone at 320px, so the first destination in the nav was unreachable. · pin both edges + wrap -> stopped being a corner nav at all: 292x68 = 91% of a 320px screen, with 73px of DEAD pill on the left from justify-content:flex-end, and the toggle stranded alone on a second row. That was the shipped state, and the owner's word for it was "exploded". Mine to own: I wrote it, verified it in Chrome against "does anything overflow?", and never asked whether the result looked like anything. · full-width bar, one row, 10px type -> fits with ~10px to spare, i.e. renaming any label breaks it. At rest the disclosure is a 40x38 mark: 13% of a 320px screen against the old 91%. It is also the only form that still works if a sixth page is ever added. The button and the list are MUTUALLY EXCLUSIVE — opening swaps one for the other — so the capsule can never grow a second row, which is the failure the other forms kept finding. TAP TARGETS MEASURED, NOT ASSUMED. These rows are now the only way to navigate on a phone, so 9px of padding (31px rows) and 14px (41px rows) both failed Apple's 44pt minimum. 16px measures 45px rows, 48px for the toggle. Verified at 320 and 390. A11Y: a real <button> with aria-expanded + aria-controls pointing at the list's id; opening moves focus to the first destination rather than leaving it on a button that just vanished; Escape closes and returns focus to the mark; an outside tap closes. All four verified in WebKit, not reasoned about. ALSO FIXES A LATENT BUG: the <=1199px rule hid `.mark` — the contact LINK — but not its <li> wrapper, so four EMPTY list items stayed in the capsule. In a flex row that was 8px of invisible stray gap, which is why it survived so long; in a grid it pushed the toggle to a third row. The wrapper now carries `.mark-li`. This is the one change that reaches past phones: at 641-1199px the capsule is 8px narrower (768px: 493 -> 485). A fix, but a real difference, so it is named rather than buried. DESKTOP UNTOUCHED, and measured against live prod rather than asserted: at 1440 and 1920 the capsule is identical — 663x44, same origin, same 10 items — and the menu button computes to display:none outside the phone breakpoint, so it is absent from the tab order and the a11y tree there. Build clean, 808 tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(hero): the dark phone hero was a light-grey fog — a paper wash was sitting between the text and the page THE WORST BUG ON THE PHONE, and the first screen a visitor sees. In dark theme every hero line except "Ing" measured 1.1-1.6:1 against the RENDERED PIXELS: "Tian" 1.12, the role line 1.18, the identity line and the whole bio around 1.5-1.6. The theme follows prefers-color-scheme, so anyone whose phone is in dark mode — most people — opened this site to an effectively blank screen. That alone may account for the owner's report that the site "doesn't work well on my iphone at all". THE TEXT COLOURS WERE NEVER WRONG. rgb(220,225,220) on rgb(22,25,29) is about 10:1. The wash was sitting between them: the phone legibility band paints `color-mix(in oklab, var(--paper) 62%, transparent)` — a LIGHT band — over the dark page and under the light text, collapsing both to one luminance. Light-grey on light-grey. THE CAUSE WAS NOT A MISSING RULE BUT A LOSING ONE, which is why it survived a 24-agent visual review and every contrast check done by eye. A dark-theme override already existed: html[data-theme='dark'] .hero-legibility -> (0,2,0) #heights .hero-legibility (the phone band) -> (1,1,0) <- ID wins so on a phone the paper band beat the dark override. The fix is the same band, darkening, written at (1,2,0) by including #heights, and placed AFTER the rule it overrides — at equal specificity source order decides, and getting that backwards has silently cost this project several fixes already. Darkens rather than brightens, matching what the desktop dark halo already does behind light text. MEASURED, from rendered pixels rather than from the cascade: every hero run now >= 4.64:1, most 6-14:1 ("Ing" 14.19, the bio 8.3-9.9, "Tian" 6.07, the role line 6.05). Light theme is untouched by construction (the new rule is gated on data-theme='dark') and re-measured to confirm. ONE PRE-EXISTING ITEM DELIBERATELY NOT TOUCHED: the "scroll" cue measures 1.52:1 in light theme, but it is decorative and faint on purpose (opacity 0.512). Raising it would change a design intent, not fix a bug, so it is reported rather than silently redesigned. The probe is worth keeping in mind for the rest of this work: computed colours cannot see this class of bug, because the offending layer is neither the text's colour nor its parent's background. Sampling the actual painted pixels in each glyph's box can. 808 tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(deck): the deck does not engage on a phone The owner: "the slides simply doesnt work well on iphone." They are right, and the reason is that the deck's contract is false at phone size rather than merely awkward. It promises "one gesture, one slide, and every slide owns the screen", and measured in real WebKit 5 of the 7 slides are TALLER than the viewport — rules 1.84 screens and story 3.07 on an iPhone 14; 2.5 and 4.2 on an SE. A deck whose slides do not fit cannot rest at slide boundaries, so it rests inside them, and all four confirmed symptoms follow from that one fact: · Body text resting under the fixed nav at 7 of 15 stops (11 of 19 on an SE). The panels' 78px of padding only clears the nav when a slide's TOP is at the viewport top — true at the 8 slide-opening stops, false at every interior one. The measured clean/dirty split is exactly that split, which is what makes this a cause rather than a correlation. · One swipe scrolled twice. The touch listeners are { passive: true } and so CANNOT preventDefault — confirmed in WebKit, where a passive preventDefault() left defaultPrevented false, with touch-action computing to `auto` on html, body and main. iOS ran its own momentum scroll while go() ran a smooth scrollTo, and the two fought. · A light flick going nowhere. Under the 44px SWIPE threshold go() is never called, so the native scroll stood and nothing re-snapped: a 30px flick rested 30px off-stop on an iPhone 14, 95px on an SE, still there four seconds later. · One swipe advancing two stops, skipping a screen of an explainer. Turning the deck off removes all four by DELETING behaviour rather than adding compensation for it. The phone gets the ordinary free scroll, which is not a degraded state: it is the same finished one reduced-motion readers already get, and the same one this file has always documented as such. VERIFIED BEHAVIOURALLY, not by reading the code. On an iPhone 14 a synthetic 200px swipe (built as a plain Event carrying the exact properties Deck.astro reads, since WebKit has no Touch constructor) moves the page 0px, and a mid-slide scroll rests exactly where it was put. At 1440 the deck is untouched: one wheel notch of 120px moves 540px and lands precisely on a slide top. NEW: src/lib/viewport.ts owns the phone breakpoint the way lib/motion.ts owns the motion gate — one place where 640px is written, and callers ask a question instead of re-typing a media query. It uses matchMedia rather than innerWidth so it agrees with CSS exactly; the two can differ by a scrollbar's width, which is precisely the disagreement that would let the deck engage on a viewport whose styles think it is a phone. Five tests, including an explicit assertion of the constant so a drift from the stylesheets fails loudly instead of shipping a phone with half a treatment. ALSO: the gate is re-decided when the breakpoint is CROSSED, not sampled once per page load. Otherwise a phone rotated to landscape (844px) would stay deck-less until it navigated, and a desktop window dragged under 640px would keep a deck driving what has become the phone layout. Rotation is one gesture, not an exotic act. A matchMedia change listener fires only on the transition that changes the answer, so there is nothing to debounce; the deck's own measure() still handles resizes that do not cross the line. 813 tests green (was 808). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(mobile): the nav zone follows what is actually behind it; landscape hero stacks; the 404 headline clears the nav Three items from the iPhone spec, and one of them taught me something worth writing down. ── ITEM 6. THE NAV'S ZONE IS DECIDED BY WHAT IS PAINTED BEHIND IT, not by how far down the sky is. The zone picked its glass from the descent FRACTION, which is a proxy for "what colour is behind the nav" — and the proxy is wrong wherever an opaque panel is drawn on top of the sky, which is most of this page. Measured in the light theme: from 57% of the document down the nav flipped to dark glass while the thing behind it was still opaque light paper, so it read as an alien dark slab and its labels fell to 3.98:1. Under AA, on the site's only navigation. It now asks whether a full-bleed opaque panel covers the nav's line, and reads that panel's own colour; the fraction remains the fallback for when the sky itself is what shows through, which is what it was always good at. Measured after — worst label contrast at 11 depths of the page: phone light 12.62:1 dark 13.44:1 desktop light 11.80:1 dark 12.77:1 TWO WRONG IMPLEMENTATIONS ON THE WAY THERE, both caught by measuring desktop as well as the phone: 1. Walking background-COLOURS only. .descent carries the sky as a background-IMAGE over a transparent colour, so the walk fell through to `body` — which is var(--bg), opaque and DARK IN BOTH THEMES by design. It reported the light theme's own hero as a dark backdrop: 2.92:1. 2. document.elementsFromPoint. The obvious tool, and the wrong one: it SKIPS elements with pointer-events: none, which is exactly what every decorative sky layer here is — the fluid-sky canvas, the atmosphere washes, the hero band. On the homepage the sky is PAINTED BY A CANVAS and .descent's own background-color is the opaque dark base, so hit-testing walked past the visible dawn sky and returned rgb(22,20,15). That flipped the light nav to dark glass over its own pale hero: 3.09:1 at 10% of scroll on DESKTOP — a regression I introduced while fixing the phone, and it would have shipped if I had only re-checked the thing I was working on. The version that works uses plain geometry — panel boxes against the nav's line — which no pointer-events value can hide from. ── ITEM 7. THE SHORT-VIEWPORT CASE IS A PHONE TOO. The hero's stack was keyed on width alone, so a landscape iPhone (844x390, 932x430) fell through it and got the DESKTOP hero: two absolutely-positioned blocks placed for a tall screen, on a viewport 390px tall. The bio printed straight across the "Ing Tian" wordmark — the one place on this site where "scrambled" was literally true rather than a frosted overlap. Keyed on height rather than `orientation: landscape`, because what breaks the diagonal composition is not being wider than tall, it is lacking the vertical room for a name above a bio; a short desktop window has the same problem and now gets the same answer. Measured after: 17 text runs, 0 collisions, at both sizes. ── ITEM 8. THE 404 HEADLINE CLEARS THE NAV. `place-items: center` centres the block, so when content is TALLER than the viewport it overflows in BOTH directions — and the top direction is unreachable, because no scroll position exists above 0. On an iPhone 14 the content wants 679px in a 664px viewport, so the first line sat above the fold with no way to bring it down; PR #49 had removed the phantom 120vh that was accidentally serving as the escape hatch, turning an ugly overlap into an unreachable line. Top padding fixes both: it clears the nav mark, and because .notfound uses min-height the box GROWS instead of centring the overflow off the top edge. TOP PADDING ONLY — a bottom pad would be scrollable space below the last line, and it measured as exactly that (11px of designed air became 39px), which is the property PR #49 established. Measured after: headline first line at y=102, no nav overlap, phantom back to 11px. 813 tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(mobile): the explainers and the career prose collapse to their headlines on a phone Items 2 and 3 of the iPhone spec. Measured on an iPhone 14, closed: choice 1.34 -> 0.32 screens rules 1.84 -> 0.34 solve 1.61 -> 0.30 story 3.07 -> 1.53 document 12.6 -> 7.20 screens (appendix, still 2.71, is item 4) NOT ONE WORD IS REMOVED: 2,666 words remain in the DOM; 484 are on screen when everything is closed. A RUNTIME TRANSFORM, NOT MARKUP, and the reason is desktop. Authoring the disclosure in the .astro files is the obvious approach and it would put three button[aria-expanded] controls into DESKTOP's tab order and accessibility tree, doing nothing — a real regression that no screenshot can show. Building it in JS below the breakpoint means the HTML Astro emits is byte-for-byte what desktop ships, so desktop is unchanged rather than merely looking unchanged. Verified: at 1440 there are 0 toggles, 0 wrappers, and the same per-section heights as before. The cost, stated plainly: a phone with JS off gets today's long scroll — the current experience, not a new failure, which makes this an enhancement layer rather than a guarantee. IT MOVES NODES AND NEVER WRITES TEXT, which is load-bearing for search rather than tidiness. Since mobile-first indexing Google reads the PHONE rendering, and its guidance is explicit: content that needs a tap to LOAD is not indexed, while content merely collapsed into an accordion is fine and is the recommended way to save space. Rendering these bodies on tap would have quietly cost the 1,606 words the explainers carry. Everything here is appendChild of existing nodes, so identity, listeners and the beat-steppers' element references all survive. THE BUTTON GOES INSIDE THE HEADING — the ARIA accordion pattern. Not a button wrapping the h2 (a button may only contain phrasing content) and not a line of text repeating the headline, which would duplicate a heading in the indexed rendering. The h2 keeps its id, its text and its place in the outline. All four toggles measure 44-73px tall, and every aria-controls id resolves. ONE IMPLEMENTATION FOR THREE DIFFERENT SHAPES. The heading sits at a different depth in each slide — Choice's h2 is a direct child of the column, Rules' is inside .ru-flow, Solve's inside .so-split > .so-say — so "the nodes after the heading" is not one sibling list. The transform walks up to the column taking each level's following siblings, which fits all three without encoding any of their structures, and aria-controls carries the resulting id LIST. TWO MISTAKES FOUND BY MEASURING RATHER THAN BY READING: · `querySelector('h2[id]')` looked correct and was not. It returns the FIRST such heading in the subtree, which on #story is DescentPath's "A descent, with one climb" nested inside the figure slot — so the transform collapsed from there and swallowed the career list along with the prose: 0.30 screens instead of 1.53, with the employer names one tap away instead of on screen. It now resolves the section's own heading through the aria-labelledby each section already declares. · Collapsing alone changed nothing at first. global.css gives every slide `min-height: 100vh` so it "owns the screen" — a rule that exists FOR THE DECK. With the deck off on phones, a collapsed explainer still reserved a full screen. The four collapsing sections drop that floor; the hero and the interlude keep theirs, because the hero is a composition that wants the screen and the tagline's whole point is landing alone in the sky. #story keeps its career stops (McGill -> Ericsson -> Amazon -> TikTok -> EA -> Toronto) ON SCREEN and collapses only the prose: those labels are what someone searching by employer matches, and on a phone DescentPath already degrades from a canvas to that text list, so nothing here hides a graph — it was already words. 813 tests green. No page errors in WebKit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(mobile): the dots on the explainer slides were the Toc, and the void above each kicker was dead nav clearance The owner: "after expansion, the 1st and 2nd slide collide and doesn't work." Reproduced on an iPhone 14 in WebKit. The section BOXES were never overlapping — measured at every state, no section overlaps any other and there are no page errors — so "collide" was two different things painting where they did not belong. ── THE DOTS WERE THE TOC. A column of grey circles ran down the left margin THROUGH the slides, which is what made it look scrambled. Not list markers: every li in the three explainers computes list-style-type: none. It was components/Toc.astro, which below 1100px becomes a column of 4-5px dots pinned at left: 12px. That worked when the thing behind it was the sky. The explainers are full-bleed OPAQUE paper panels, so on a phone the dots are painted ON the paper, inside the panel's own left margin, and read as rendering debris rather than as a control. It is also redundant there now: the dots are a section scrollspy for a deck that no longer engages on phones, and every destination they offered is in the corner nav's menu — a labelled list with 45px targets instead of five 5px dots. The Toc is decorative and aria-hidden, so removing it on phones costs no content and no landmark. Desktop is untouched: display computes to `block` at 1440 and `none` at 390, verified. ── THE VOID ABOVE EACH KICKER WAS CLEARANCE FOR A NAV THAT NO LONGER EXISTS. 79px of blank paper sat above every explainer's one-line kicker — measured on all three. On a COLLAPSED slide that band is about a third of the whole section, so it reads as something that failed to render, which is the other half of what the owner saw. It came from global.css's `padding-top: 78px`, sized for the nav as it then was: a wrapped, effectively full-width capsule 68px tall, with the deck parking each slide's top exactly at the viewport top so a headline landed right under it. Neither premise survives this branch — the phone nav is a 40x38 mark in the top RIGHT corner, these headlines are left-aligned, and the deck does not engage on phones at all. Down to 22px, which keeps the panel from starting hard against its own edge. Measured after: the gap is 23px on all three. The override is placed AFTER the rule it overrides at equal specificity, deliberately — source order is what decides, and getting that backwards has silently cost this project several fixes. WHAT I COULD NOT REPRODUCE, stated plainly: nothing about the expanded slides is broken in WebKit. Slide 1 renders its beats and the $100 split; slide 2 renders the four-size cost ladder, its caption and its beats. Both expand and collapse cleanly, and section geometry stays monotonic. So if something still looks wrong on the actual iPhone after this, it is a third thing and I would want a screenshot rather than another guess. 813 tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(descent): the graph stays the graph on a phone, and the career text list goes back to sr-only The owner: "for the descent, that graph is better than a timeline of careers." It is, and it can be — the reason it was switched off below 720px was never the drawing, only its in-frame type. WHAT CHANGED · No in-frame labels under 560px (COMPACT_MAX_W): waypoint names, the deep-side pair, and "the climb". The collision that justified hiding the canvas cannot happen if there is nothing to collide. · The right gutter existed ONLY to hold those labels — 120-230px of it — so on a phone it drops to 16px and becomes drawing area. The descent therefore renders LARGER on a phone than any labelled version could. · The stage stops being an overlay. It is built as canvas-absolute-inset-0 with the coda positioned into the drawing's empty lower-right, which presumes there IS an empty lower-right — a desktop luxury. The narrow-screen rule turned the coda static, so it flowed at the TOP of the stage and printed straight over the drawing. On a phone the stage is now a flex column: the canvas is a block with its own height (min(46vh, 88vw)) and the coda reads after it. Verified: no canvas/coda overlap. · The stops list returns to being purely a screen-reader path. That removes 228 words of visible type — the largest visible block left after the collapse work, and the owner's complaint was "still way too many texts." MEASURED: #story 3.07 -> 1.29 screens. Homepage 12.6 -> 6.63 screens closed. The drawing itself is 342x305 on an iPhone 14 and paints the full surface, both basins, the eight waypoints and the red climb segment. ONE THING WORTH KNOWING FOR ANY FUTURE JUDGEMENT OF THIS PIECE: it is an ANIMATED walk, and the first frames are nearly empty by design. Screenshotting on arrival shows "stop 1/8" and a few faint arcs, which looks like a broken render and is not one — the walk reaches 4/8 at ~3s and 8/8 at ~6s. I nearly filed my own screenshot as a bug. The names are not lost: the ordered list stays in the DOM, so the employer names remain available to a screen reader and to the phone rendering Google indexes. NOT UN-HIDING THAT LIST is also what keeps an old trap shut. Tailwind's sr-only hides with BOTH `clip` and `clip-path: inset(50%)`; a previous fallback reset only the legacy `clip`, so the list was laid out and invisible at the same time — 573px of blank panel where the showpiece belongs. 813 tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(mobile): the phone gets a headline and a picture, and nothing else The owner, after reviewing on a real iPhone: "way too many texts. way too many... probably a good iphone experience is very straight forward, heavily visual over texts. a headline + illustrative graph will do the trick." Then, a step further: "i guess you dont even need that read the full explanation." So each explainer on a phone is now kicker + headline + ONE static graphic. No beat-stepper, no stacked panels, no prose, no footnotes, and no disclosure to open. MEASURED, real WebKit, iPhone 14, everything closed: choice 1.34 -> 0.57 screens rules 1.84 -> 0.42 solve 1.61 -> 0.45 story 3.07 -> 1.20 appendix 2.71 -> 1.29 DOCUMENT 12.6 -> 5.93 screens 2,666 words remain in the DOM — the same count live prod reports, so nothing was deleted. THE THREE BUGS THE OWNER PHOTOGRAPHED ARE GONE, and structurally rather than nudged: an independent probe found 0 collisions in 10,060 measured text runs across 470 samples at 390x664 and 320x568, light and dark. In every case both parties to the old overlap are display:none, so the collision cannot recur. All three had one cause — each figure is four panels stacked in one box, revealed one at a time by a stepper, plus absolutely positioned captions. On a phone more than one painted at once. Nothing is stacked there any more. THE DISCLOSURE IS GONE, and removing it was a simplification rather than a loss. A phone visitor was never going to tap through 1,600 words of explainer on a 390px screen; the affordance existed to reassure ME that nothing had been taken away, and what it actually cost was a control, a state, a chevron and an aria contract in service of a path nobody walks. Its CSS was deleted with it — dead rules for an element that can no longer exist are a false clue about how the page works. HONEST COST, recorded once: with no way to reveal it, that prose is not RENDERED on a phone, and mobile-first indexing reads the phone rendering, so those words likely stop counting toward what the homepage ranks for. Bounded — /research, /writing and /experience carry the substantive terms on their own pages — and the owner chose the reading experience knowingly, twice. ALSO: .ru-note goes. It survived the first pass because it is not one of the stacked panels, but it is 62 words of mono about square-root price impact and log-scaled bars — precisely the prose this shape exists to remove. DESKTOP IS BYTE-IDENTICAL, proved twice by an independent agent: working tree vs a HEAD build, 1938 vs 1938 elements with zero added, removed, or geometrically changed; and the nav capsule matched live prod to a tenth of a pixel at 1440x900. Zero data-phone-* elements exist above the breakpoint. Built by five agents, one per file, so no two touched the same source; then verified by two more that had to reproduce every claim independently. Two of their findings were real and are fixed here rather than filed. 815 tests green (was 813). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(mobile): the phone homepage becomes a door — index on screen 1, one type scale, nothing moving Built from measurement rather than taste. 17 well-regarded portfolios were loaded at iPhone 14 size in real WebKit and measured the same way this site was; the survey overturned the premise of the previous four rounds. WHAT THE EVIDENCE SAID, and why the earlier rounds missed: · WORD COUNT WAS THE WRONG KNOB. At 5.93 screens this site was already 34% BELOW the 15-site median (~9.0), and at 42 words/screen below their ~56. It shipped 250 words on phone against 1,340 on desktop (19%) while all six type-led sites ship IDENTICAL counts at both widths. Four rounds of deleting text had taken it below the field on both quantity dials and the owner still said "still kind of not good". · OUTLIER A — the first screen was autobiography with nowhere to tap: 65 words, 37 of them a bio paragraph. No surveyed site puts a bio on screen 1 (karpathy's starts at screen 4.1/16.2; Boyd has none; joshwcomeau's is behind the hamburger). Only 2 of 8 links were on screen 1 and BOTH were camouflaged inside the tagline sentence; the other 6 sat at 93% page depth. Against leerob 13-of-15 links on screen 1, and Boyd's and Chimero's homepages which ARE their link lists and do not scroll. · OUTLIER B — the type read as small print: 13px body where all six type-led sites are 16-17px with ZERO variance, 21 distinct rendered sizes against Chimero 1 / Codrops 2 / Pentagram 3 / Awwwards 4, and 31% of visible words under 13px. The 64px name had no analogue in 17 sites (ceiling: brittanychiang 36, tobiasahlin 34). 64px over 13px inside 480px of screen is a 4.9x jump — a far better explanation of "too much text" than 250 words. · Structure: exactly two phone patterns work and there is no third — an INDEX, or CARDS WHERE EVERY CARD IS A LINK OUT. This page was neither: a 5.93-screen argument with its navigation at the bottom. · Animation: 0 of 17 sites animate. Bruno Simon's site IS a canvas and even he gates it behind "CLICK TO START" with a quality toggle; Codrops, whose business is WebGL demos, serves them on phones as STATIC IMAGES. This site ran two canvases at once (measured churn: hero 3.79%/5.88%, graph 2.00%/3.53%). THE SIX CHANGES 1. The index moves to screen 1: the six destinations render as a chip row under the role sublines, and the 37-word bio drops to its first clause on phone (kept whole in the DOM for desktop and for indexing). 2. One type scale, five sizes, declared once in global.css as --ph-kicker/body/section/head/name (11/16/22/28/36). Nothing visible under 13px except the mono kickers. 3. "Why the climb" is fixed — it was rendering as a headline with NOTHING under it, because #story declared no data-phone-keep while PhoneCollapse hid its body. A live defect from the previous commit, not taste. 4. The name comes down 64px -> 36px, which reclaims the ~60px the chip row needed. 5. The hero terrain paints its settled frame and stops on a phone. The descent graph stays the one moving thing — the owner's signature piece, and its geometry was already the field norm (40.7% of viewport height against Pentagram 37% / Awwwards 40% / Codrops 40%), so it is untouched. 6. The pictures become navigation: a new PhoneExplainerCard wraps the three explainer graphics and the appendix stats in one shared, tappable frame, so one repeating unit replaces six bespoke compositions — the shape colah (37 instances) and Pentagram (56) converge on. Built by five agents, one per file so no two touched the same source. VERIFICATION IS INCOMPLETE, AND I WILL NOT PRETEND OTHERWISE. The browser verification pass was cut short: the fleet of headless WebKit instances this branch has been leaning on took the owner's machine down, and they asked for no more browsers. So `npm run build` is clean and 815 tests across 46 files pass, and every change is confirmed present by inspecting the built HTML and the sources — but the NUMERIC targets the survey set (first-screen words <= 32, first-screen links >= 6, 0 words under 13px, <= 6 distinct sizes, hero churn 0%) are NOT measured for this round, and neither is the desktop diff against live prod. Earlier in this branch a phone fix silently regressed desktop and was only caught by measuring desktop too, so that gap is a real risk and not a formality. It wants one careful pass on a real device or one browser run when that is welcome again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Revert "feat(mobile): the phone homepage becomes a door — index on screen 1, one type scale, nothing moving" This reverts commit fd7c7a9. * Revert "feat(mobile): the phone gets a headline and a picture, and nothing else" This reverts commit 2b9401f. * Revert "feat(mobile): the explainers and the career prose collapse to their headlines on a phone" This reverts commit 6244058. * docs(mobile): why the phone reverted to desktop parity, and what stayed The owner, after looking at the portfolios the survey recommended: "all plain and horrible tbh. maybe just stay sync with the desktop that's it... fix the bugs, keep the desktop + phone in sync. that's it. let's just ship the stuff faithfully." A defensible reading of the research rather than a retreat from it. The survey's own finding was that every phone portfolio is a compromise — an index shipping almost nothing, or cards that are all pictures — and none of those is this site. One design maintained honestly beats two, and a design its author believes in beats a genre norm he does not. The three preceding commits revert everything that made the phone show DIFFERENT CONTENT from the desktop: 6244058 (explainers and career prose collapsing), 2b9401f (headline + one graphic, no prose), fd7c7a9 (index on screen 1, five-size type scale, 36px name, tappable cards). PhoneCollapse.astro and PhoneExplainerCard.astro go with them. All three applied without a conflict — the dividend of having kept each change in its own commit. WHAT STAYS, and why none of it is a content difference: · c0c0c1a nav: an exploded 292x68 capsule (91% of a 320px screen, 73px dead pill, toggle stranded on its own row) becomes a 40x38 mark opening a 45px-target menu. Arithmetic, not taste — five labels plus a toggle do not fit one row at 320px at any size this design would accept. · c249a67 the dark hero: every line except "Ing" at 1.0-1.5:1, because a paper wash sat between light text and a dark page and the dark override lost on specificity ((0,2,0) vs an ID's (1,1,0)). Now >= 4.64:1. Most likely the actual cause of "doesn't work well on my iphone at all". · e206523 the deck does not engage on phones, deleting four confirmed bugs: text under the nav at 7 of 15 stops, one swipe scrolled twice (passive listeners cannot cancel iOS's scroll), sub-44px flicks never snapping, one swipe advancing two stops. · ae6b864 the nav's zone follows what is painted behind it (3.98:1 -> 11.8-13.4:1); landscape stacks instead of printing the bio across the wordmark; the /404 headline clears the nav. · ae817c5 the descent graph RENDERS on a phone — labels dropped under 560px so they cannot collide, the 120-230px label gutter reclaimed as drawing area, the stage laid out as a column so the coda stops printing over the drawing. Kept BECAUSE it serves parity: desktop shows the graph, so the phone should, and this is what makes that possible. · bdd94f0 the Toc's dot column off on phones — it painted ON the now-opaque panels and was most of what the owner called "scrambled" — and 79px of clearance for a nav that no longer exists cut to 22px. Also removed: tests/descentFrame.test.ts, an untracked leftover from the reverted round asserting an aspect-ratio frame that went with fd7c7a9. It failed against the code that remains, which sizes the stage with min(46vh, 88vw). A test whose subject no longer exists should go, not stay red. Build clean, 813 tests across 45 files green. WHAT IS STILL UNVERIFIED, since it decides whether this is ready to merge: no browser pass has run against this final state. The bug fixes were each measured when they landed, but the reverts have not been re-measured together, and the phone now renders content that was last seen on a phone BEFORE the collapse work — longer than what the owner has been reviewing. Worth one look on the device before this leaves draft. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(descent): the phone's stray red line was a leader pointing at a label that was not drawn The owner: "what is that red line shooting off in the middle?" A fair question — it is a mark with no meaning, and it is mine. The climb is called out with two marks that are one annotation: a red LEADER LINE from the midpoint of the climb out to (mx-54, my-38), and the words "the climb" at (mx-58, my-44). When in-frame type was dropped under 560px so the labels could not collide, only the TEXT was gated. The leader kept drawing. So a phone got a red line striking up and to the left out of the path and stopping in empty space, pointing at a caption that was never painted. A leader with nothing at the end is not a reduced annotation, it is debris, and it is worse than either extreme — worse than showing the label, worse than drawing neither. The whole callout is now behind one condition, so the two marks live or die together. Nothing is lost on the phone: the red STRETCH of the path still shows exactly where the climb is. Only the word for it goes, and the section's own heading is "A descent, with one climb", which says it in prose two hundred pixels above the drawing. Desktop is untouched — isCompact() is false above a 560px canvas, so both marks draw exactly as before. Build clean, 813 tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four things the owner caught in one read-through of the live site, plus what measurement
turned up while chasing the fourth. Small fixes, but two of them were real bugs with
causes worth writing down.
1. The 404 page overflowed at the bottom
.descent::before— the atmosphere wash — washeight: 120vh. On the tall pages thatis harmless because the wash is shorter than the document. On /404, which is one
viewport tall, it added exactly 120vh − 100vh of scrollable nothing below the fold: a
page with nothing on it that still scrolled.
The wash keeps its 120vh reach wherever the document is taller than the viewport, and
clamps to the document on a short page. Measured: /404 scrollHeight now equals
clientHeight to the pixel; the tall routes are untouched.
2. The 404's bottom row was illegible — and redundant
A row of page links in
--ink-3, sitting where the gradient has already gone dark. Theowner's read was right on both counts: "i wonder if you need that at all. after all we
have the hovering nav bar." It listed the same
PAGESarray the corner nav renders,four rows above it. Deleted rather than recoloured — the nav is always on screen.
3. The research fold jolted open, then didn't
Two separate causes, found in order:
The jolt was a leftover
detail-inkeyframe withanimation-fill-mode: both. Fillmode pinned the panel's children at the animation's from state, so the first frame of
every open started from opacity 0 and a translate, independent of the max-height
transition — the two disagreed and the eye read it as a snap. (My first diagnosis of
this was wrong: I claimed
display: noneprevented transitions, reasoning from code. Areduction test transitioned fine and disproved it.
docs(research): correct the fold diagnosisrecords that.)The card wouldn't hold still, which is what the owner actually saw: "it's not
dragging the background up and down." The card hid its own takeaway line on expansion,
because the same sentence rendered again inside the panel. So opening a paper shrank the
card while growing the panel — two boxes animating in opposite directions, and for a
moment the document height stepped BACKWARD 20px. Their call fixed it: "let's hold the
card not move." The in-panel duplicate is deleted, the card keeps its size, and one box
moves. Measured across the animation: a single card height (387px), a single card top
(763px), document growing monotonically 1656 → 2464 with zero backward steps.
Not fixed, and I want to be straight about it: the owner measures the fold at 20-30
fps and I cannot reproduce it. Three instruments failed — rAF deltas report vsync on a
120Hz display regardless of work, and a screencast frame counter reported ~95fps even at
20× CPU throttle, which is impossible. The bisect over backdrop-filter, the descent
gradient, the fluid sky and the grain used that broken instrument, so it proves nothing.
Main-thread cost is unchanged by this PR: 36ms style / 26ms layout / 51 layouts at 6×.
If the fold reads better it is because one box moves instead of two.
4. The theme toggle reset every deck to slide 1
The toggle re-fired
astro:page-loadso the canvases would repaint for the new palette.But the deck initialisers listen to that event too, and re-initialising a deck resets it
— flip the theme on slide 3 of the fold and you were thrown back to slide 1.
Now
flipTheme()only flips the attribute, and a separatetheme:changeevent asks thethree canvas components (
TerrainHero,DescentPath,FluidSky) to repaint. The decksnever hear it. Cost is identical either way — 55-59ms vs 56ms — so this is a correctness
fix, not a perf one.
Also in the pass
Screen adaptability, all found by looking rather than by building:
DescentPath's ≤720px.sr-onlyreset didn't clear
clip-path, so the visible stops inheritedinset(50%). One line:clip-path: none.padding-top: 96pxat ≤640px.240px space. Single column at ≤640px.
ITmark and separator below 1200px and wraps its listbelow 640px, instead of overflowing.
Gates
npm run buildclean,npm test808 passing. Verified over CDP at 320/375/768/1024/1440/1920 in both themes. The 404 fix and the fold's stillness are both measured
assertions, quoted above, not eyeball impressions.