fix(mobile): make the site work on an iPhone — dark hero, the deck, the nav, and the descent graph - #50
Merged
Merged
Conversation
… 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>
…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>
…s 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>
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>
…pe 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>
…eadlines 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>
…oid 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>
…ext 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>
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>
…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>
…reen 1, one type scale, nothing moving" This reverts commit fd7c7a9.
…thing else" This reverts commit 2b9401f.
… their headlines on a phone" This reverts commit 6244058.
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>
…abel 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>
IngTian
marked this pull request as ready for review
August 15, 2026 02:09
IngTian
added a commit
that referenced
this pull request
Aug 15, 2026
…he zone probe made the deck sluggish (#51) The owner, on prod: "the slide animation & transition is way worse than the previous build. it's almost like sluggish." Correct, and this is the cause. I shipped it in #50 while fixing a contrast bug on the phone, and it is the one change in that PR that was NOT phone-scoped — so it slowed the desktop deck, which was never the thing under review. WHAT WAS WRONG. The nav decides its glass from what is painted behind it, and that probe ran inside the scroll handler — i.e. on every animation frame of every scroll, and a deck slide transition is a ~500ms smooth scroll, so every frame of one. Per frame it did: document.querySelectorAll('main > section > *') a fresh DOM query nav.getBoundingClientRect() x2 (the old probe() read it twice) for each candidate: getBoundingClientRect() forces layout getComputedStyle().backgroundColor forces style recalc Interleaving rect reads with style reads is the textbook layout-thrash pattern: each style read forces the layout the previous rect read just invalidated, so the cost is not N reads but N synchronous layouts. The handler this replaced did one offsetHeight and two rect reads. WHY IT CAN ALL BE HOISTED. Nothing that probe measures changes while you scroll. A panel's DOCUMENT-space top and bottom are fixed, its background colour is fixed, and the nav is position:fixed so its centre line is scroll-invariant. Only scrollY moves. So the measurement now happens once, in measureBackdrops(), and the per-frame path is a loop over a cached array of {top, bottom, luminance} bands plus one scrollY read — which does not force layout. DOM reads per frame: from 1 query + 2 + 2N to zero. STALENESS IS HANDLED WHERE IT ACTUALLY OCCURS, not by re-reading everything constantly: resize re-measures (scroll no longer shares that handler — them sharing it is how the measurement got into the scroll path), and a ResizeObserver on <main> catches the document changing height without a resize, which it does when the terrain canvas and images settle after first paint. Both fire rarely and neither fires during a scroll. The listener and the observer are hoisted to the teardown's scope so they are removed on every astro:page-load, or each View Transition would stack another observer on the same <main>. BEHAVIOUR IS UNCHANGED — same 0.35 luminance split, same descent-fraction fallback, same DARK_FROM. This is purely when the work happens. The contrast fix it was written for still stands (nav labels 3.98:1 -> 11.8-13.4:1); it just no longer costs a layout per frame to keep. NOT MEASURED, and I want that on the record: no browser pass. The owner's machine was taken down earlier by the headless fleet this branch leaned on and they asked for no more browsers, so the claim here is structural — a read count from the code, not a frame time. It wants the owner's eye on prod, or one profiling run when that is welcome again. Build clean, 813 tests green. 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.
The owner reported that the site "doesn't work well on my iphone at all — seems like all scrambled in one place." This branch fixes what was actually broken on a phone, and deliberately does not change what the phone says: after a detour through a phone-specific content design, the conclusion was to keep desktop and phone in sync and ship the fixes faithfully.
Every number below was measured in real WebKit (the engine iOS Safari uses) on iPhone 14 / iPhone SE profiles. That distinction is load-bearing: every prior "verified at 375/390/414" claim in this repo was made in headless Chrome under device emulation — an iPhone width on a different engine — and so was never evidence for any of this.
The critical one
In dark theme every hero line except the word "Ing" measured 1.0–1.5:1. The theme follows
prefers-color-scheme, so anyone whose phone is in dark mode opened the site to an effectively blank first screen. This is the most likely cause of the original report.The text colours were never wrong —
rgb(220,225,220)onrgb(22,25,29)is about 10:1. A paper wash was sitting between them, and not because a rule was missing but because one lost: the dark override ishtml[data-theme='dark'] .hero-legibilityat (0,2,0) while the phone band is#heights .hero-legibilityat (1,1,0). The ID won. Now ≥4.64:1 on every run, most 6–14:1.Worth noting how it was found, because computed styles cannot see this class of bug — the offending layer is neither the text's colour nor its parent's background. It took sampling the actual painted pixels inside each glyph's box.
The deck was the structural problem
Its contract is one gesture, one slide, every slide owns the screen. On an iPhone 14 5 of 7 slides are taller than the screen (
rules1.84,story3.07; on an SE 2.5 and 4.2). So it rested inside slides, and four confirmed bugs all follow from that single fact:{ passive: true }and therefore cannot cancel iOS's native scroll (confirmed: a passivepreventDefault()leftdefaultPreventedfalse, withtouch-action: autoonhtml,bodyandmain)The deck no longer engages below 640px. It removes all four by deleting behaviour rather than compensating for it, and phones get the same free scroll that reduced-motion readers already got.
Everything else that was broken
Two latent bugs fixed on the way: four empty
<li>wrappers were holding layout because the ≤1199px rule hid.mark(the contact link) but never its list item; and 79px of nav clearance that was sized for a capsule which no longer exists came down to 22px.Also fixed after review on the device: a stray red line on the phone's descent graph. The climb is annotated by a leader line plus the words "the climb"; the label was gated under 560px and the leader was not, so the phone drew a pointer to a caption that was never painted.
Why there are reverts in this branch
Between the fixes and now, the phone got a content design of its own — collapsed explainers, then headline-plus-one-graphic, then an index on screen 1 with a five-size type scale. That work was measured against 17 well-regarded portfolios loaded at iPhone size, and the survey was genuinely useful: it showed word count was the wrong knob (at 5.93 screens the page was already 34% below the 15-site median, and below their median words/screen), and that the real outliers were a bio on the first screen with its navigation at 93% page depth, and 13px body type among 21 distinct sizes.
The owner's call after seeing the field: "all plain and horrible tbh… fix the bugs, keep the desktop + phone in sync." That is a defensible reading rather than a retreat — every phone portfolio in the survey is a compromise, one design maintained honestly beats two, and a design its author believes in beats a genre norm he does not. So
6244058,2b9401fandfd7c7a9are reverted;PhoneCollapse.astroandPhoneExplainerCard.astrogo with them. All three reverted without a conflict — the dividend of one change per commit.Desktop
Unchanged, and measured rather than assumed: at 1440 and 1920, diffed against live prod — 1938 vs 1938 elements, zero geometry differences, the nav capsule identical to a tenth of a pixel.
Three changes are not strictly phone-scoped, named rather than buried:
<li>wrappers.max-height: 520px, so a short desktop window also gets the stacked hero — deliberate, since the diagonal composition needs vertical room rather than width.Gates
npm run buildclean; 813 tests across 45 files green.src/lib/viewport.tsis new and tested — it owns the 640px breakpoint the waylib/motion.tsowns the motion gate, with an explicit assertion on the constant so drift from the stylesheets fails loudly.One honest gap: no browser pass has run against this final reverted state. Each fix was measured when it landed, and the owner has reviewed the result on his own iPhone, but the reverts have not been re-measured together.
🤖 Generated with Claude Code