diff --git a/src/components/CornerNav.astro b/src/components/CornerNav.astro index 5d17529..ee8d900 100644 --- a/src/components/CornerNav.astro +++ b/src/components/CornerNav.astro @@ -386,13 +386,32 @@ const items: Item[] = [ // The actual flip: swap the attribute, persist, sync a11y, and repaint the // canvas-based / page-load-driven pieces (terrain) for the new palette. + // THE ATTRIBUTE FLIP ONLY. Everything here must be cheap, because this runs as a View Transition's DOM-update + // callback and the browser holds the page frozen until it returns. + // + // It used to also dispatch astro:page-load from inside here, which re-initialises EVERY vanilla script on the + // page — the deck's full slide re-measure, three beat steppers, the Toc, the cow, and the terrain canvas + // repaint — all inside that frozen window. Measured at 6x CPU throttle: 4 long tasks with the worst at 383ms, + // by far the largest single stall anywhere on the site. That is the "slowish" the owner felt. function flipTheme() { const dark = document.documentElement.dataset.theme === 'dark'; if (dark) { delete document.documentElement.dataset.theme; } else { document.documentElement.dataset.theme = 'dark'; } try { localStorage.setItem('theme', dark ? 'light' : 'dark'); } catch (e) {} syncToggle(); - document.dispatchEvent(new Event('astro:page-load')); + } + + // The expensive half, deliberately OUTSIDE the transition. The JS-painted surfaces (terrain, descent graph, + // fluid sky) read their palette off data-theme once per init, so they still need a re-init to repaint — it + // just must not happen while the page is held. The trade is that a canvas settles into its new palette a beat + // after the crossfade rather than during it, which is far cheaper than a third of a second of frozen page. + function repaintForTheme() { + // A DEDICATED EVENT, not astro:page-load. Fifteen scripts listen for page-load and only three paint from JS + // and read the palette (TerrainHero, DescentPath, FluidSky) — the rest re-measure the deck's seven slides, + // rebuild the Toc's observers, restart three beat steppers and re-wire the cow, none of which a colour change + // affects. Isolated by measurement: 357ms of long task on the homepage against 55ms on /404, and unchanged + // when the View Transition was disabled, so the cost was always this re-init and never the transition. + document.dispatchEvent(new Event('theme:change')); } function onToggle() { @@ -405,9 +424,15 @@ const items: Item[] = [ if (startVT && !prefersReducedMotion()) { document.documentElement.classList.add('theme-vt'); const t = startVT(flipTheme); - t.finished.finally(() => document.documentElement.classList.remove('theme-vt')); + t.finished.finally(() => { + document.documentElement.classList.remove('theme-vt'); + // After the crossfade, never inside it. requestAnimationFrame so the repaint starts on a fresh frame + // rather than tacked onto the one that just finished animating. + requestAnimationFrame(repaintForTheme); + }); } else { flipTheme(); + requestAnimationFrame(repaintForTheme); } } toggle?.addEventListener('click', onToggle); diff --git a/src/components/DescentPath.astro b/src/components/DescentPath.astro index 6c3b78d..1db50a4 100644 --- a/src/components/DescentPath.astro +++ b/src/components/DescentPath.astro @@ -741,4 +741,6 @@ import { WAYPOINTS } from '../lib/trajectory'; } document.addEventListener('astro:page-load', initAll); + // Repaint on a theme flip without re-initialising the rest of the page — see TerrainHero for the measurement. + document.addEventListener('theme:change', initAll); diff --git a/src/components/TerrainHero.astro b/src/components/TerrainHero.astro index 3f9ee3b..9b1ed79 100644 --- a/src/components/TerrainHero.astro +++ b/src/components/TerrainHero.astro @@ -222,4 +222,10 @@ const bubbles = JSON.stringify([ // Run on first load and after every View Transition swap. astro:page-load // fires in both cases; if ClientRouter isn't present it still fires once. document.addEventListener('astro:page-load', initTerrain); + // A THEME FLIP REPAINTS THIS CANVAS, but must not re-init the whole page. The toggle used to dispatch + // astro:page-load, which re-ran every vanilla script — the deck's 7-slide re-measure, the Toc, three beat + // steppers, the cow — for a palette change none of them care about. Measured at 6x throttle: a 357ms long task + // on the homepage against 55ms on /404, and identical with the View Transition disabled, so the transition was + // never the cost. This canvas is one of only three surfaces that paint from JS and read the palette. + document.addEventListener('theme:change', initTerrain); diff --git a/src/components/proto/FluidSky.astro b/src/components/proto/FluidSky.astro index 34dfb4a..c5f99eb 100644 --- a/src/components/proto/FluidSky.astro +++ b/src/components/proto/FluidSky.astro @@ -316,4 +316,7 @@ const { strength = 4, mode = 'background', variant = 'descent', yStart = 0 } = A } document.addEventListener('astro:page-load', initAll); + // Repaint on a theme flip: this one recompiles its shader, which is exactly why it should happen for THIS event + // and not for every re-init the page might trigger. See TerrainHero for the measurement. + document.addEventListener('theme:change', initAll); diff --git a/src/pages/404.astro b/src/pages/404.astro index ce7005d..4fe151b 100644 --- a/src/pages/404.astro +++ b/src/pages/404.astro @@ -1,6 +1,5 @@ --- import BaseLayout from '../layouts/BaseLayout.astro'; -import { PAGES } from '../data/nav'; import PlushCow from '../components/PlushCow.astro'; import { COW_LINES_404 } from '../data/cowGlyph'; import FluidSky from '../components/proto/FluidSky.astro'; @@ -30,15 +29,16 @@ import SkyWash from '../components/SkyWash.astro'; label="A cow, keeping you company on a page that does not exist. Press it." /> + {/* ONE LINK, NOT SIX. There used to be a second row listing every page, driven from data/nav.ts — and the + owner spotted that it was both unreadable and unnecessary: "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 are right. It was rendering the SAME `PAGES` array the corner nav renders, so it was exactly + duplicate links a few hundred pixels below a nav that is always on screen. And it was illegible by + construction: --ink-3 is a dark ink, while /404 rides the descent gradient into its dark half, so the row + sat dark-on-dark in the light theme. + What stays is the one link the corner nav does NOT offer — the way home. */} @@ -56,13 +56,12 @@ import SkyWash from '../components/SkyWash.astro'; better than one row that wraps at an accidental place, and it no longer changes shape as pages are added. */ .nf-cow { margin: clamp(18px, 3vh, 36px) 0 clamp(4px, 1vh, 12px); } - .nf-links { margin-top: 40px; display: flex; flex-direction: column; gap: 20px; align-items: center; } - .nf-pages { display: flex; gap: 16px; justify-content: center; align-items: baseline; flex-wrap: wrap; } + /* One row now, so no column stack and no wrap handling — the .nf-pages and .nf-link rules that lived here are + deleted with the markup rather than left behind as unreachable CSS. */ + .nf-links { margin-top: 40px; display: flex; justify-content: center; } .nf-primary { font-family: var(--font-mono); font-size: 13px; letter-spacing: 0.04em; text-decoration: none; color: var(--ink-1); border: 1px solid var(--ink-4); border-radius: 999px; padding: 9px 20px; transition: border-color 0.3s, color 0.3s; } .nf-primary:hover { border-color: var(--seal); color: var(--seal); } - .nf-link { font-family: var(--font-mono); font-size: 12.5px; letter-spacing: 0.04em; color: var(--ink-3); text-decoration: none; } - .nf-link:hover { color: var(--seal); } @media (prefers-reduced-motion: no-preference) { .nf-inner { animation: nf-in 0.8s ease both; } diff --git a/src/pages/research.astro b/src/pages/research.astro index e456f54..fe7ba6d 100644 --- a/src/pages/research.astro +++ b/src/pages/research.astro @@ -112,7 +112,9 @@ const stops = researchStops(publications, researchInterests);

The idea

{p.idea}

- {p.takeaway &&

{p.takeaway}

} + {/* The takeaway is NOT repeated here any more. It renders once, on the card, and stays there + whether the card is open or shut — see .card-takeaway. This copy existed only because the + card's was hidden on expand, and hiding it was the thing making the fold feel wrong. */}
)} @@ -333,11 +335,15 @@ const stops = researchStops(publications, researchInterests); } .byline { display: block; font-family: var(--font-mono); font-size: 11px; color: var(--ink-4); letter-spacing: 0.02em; margin-top: 14px; } - /* The collapsed card's summary — the one-line thesis and one number, so the card is a real - preview rather than a title you must open to evaluate. */ + /* The card's summary — the one-line thesis and one number, so the card is a real preview rather than a title + you must open to evaluate. It is no longer "the COLLAPSED card's summary": it is present in both states now, + which is what keeps the card from changing size when a paper opens. + It takes the ACCENT face, inheriting that from the in-panel copy this replaces. That copy was the only use of + Fraunces on /research, and the page preloads Fraunces — so moving the accent here keeps the preload earning + its bytes instead of downloading a face nothing on the page uses. */ .card-takeaway { display: block; - font-family: var(--font-display); font-style: italic; + font-family: var(--font-accent); font-style: italic; font-size: clamp(16px, 1.5vw, 19px); line-height: 1.55; color: var(--ink-2); margin-top: 20px; max-width: 54ch; } @@ -362,8 +368,26 @@ const stops = researchStops(publications, researchInterests); transition: transform .32s cubic-bezier(.2,.7,.3,1); } .paper-card[aria-expanded='true'] .card-chevron { transform: rotate(225deg) translateY(-1px); } - /* Once open, the cue reads as the reverse action and the summary is redundant with the detail. */ - .paper-card[aria-expanded='true'] .card-takeaway { display: none; } + /* Once open, the cue reads as the reverse action and the summary is redundant with the detail — but it has to + LEAVE smoothly, and `display: none` is what made the fold feel wrong however well the panel itself animated. + The owner: "the collapse/expand animation is not smooth for the research card." Measured frame by frame on + open: the document went 1656 -> 1636 -> 1882 -> 2173 -> … so the page jerked 20px BACKWARDS on the first + frame and then grew. That dip is exactly this line vanishing in one frame while the panel below it was still + at 56px — two movements in opposite directions at the same moment, which reads as a stutter at the start of + every expansion. + Collapsing it over the same duration and easing as the panel makes the motion monotonic: one thing shrinks + while a much larger thing grows, and the net travel is always downward. */ + /* NOTHING HIDES IT ANY MORE — the owner's call, and it is the simplification the fold needed: + "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." + Right on both counts. The card now keeps exactly its own size whether open or shut, so opening a paper moves + ONE box — the panel below it — and nothing above the panel shifts at all. It also removes a second animating + element from every frame of the fold. + Two earlier versions of this were worse. `display: none` dropped the line in a single frame, which measured as + a 20px jerk BACKWARDS in the document height at the exact moment the panel started growing. Collapsing it + smoothly fixed the jerk but still meant two boxes animating in opposite directions for 400ms. + 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 that is visible in both states. */ /* two columns: LEFT (idea + formal math) is wider so equations show in full; RIGHT (results) is the narrower numeric column. */ @@ -405,6 +429,14 @@ const stops = researchStops(publications, researchInterests); .paper-body { --fold-ease: cubic-bezier(0.4, 0.02, 0.2, 1); overflow: hidden; + /* contain tells the browser this panel's insides cannot affect layout or paint outside its box, so growing it + need not re-examine or repaint its ancestors. It is a free win on an accordion — the panel is already an + overflow: hidden clipper, so `contain` promises nothing that was not already true. + Honest limit: this is NOT a measured fix for the choppiness the owner reports. Three instruments failed to + see that at all (rAF cadence reports vsync; a screencast probe reported ~95fps even at 20x CPU throttle, + which is impossible), and the main-thread cost measures cheap: 36ms of style and 25ms of layout across the + whole 400ms animation at 6x throttle. So this is applied because it is correct, not because it is the cure. */ + contain: layout paint; max-height: 0; margin-top: 0; } @@ -454,8 +486,8 @@ const stops = researchStops(publications, researchInterests); color: var(--ink-4); margin-bottom: 14px; } .block > p { font-size: 16px; line-height: 1.72; color: var(--ink-2); } - .takeaway { font-family: var(--font-accent); font-style: italic; font-size: clamp(19px, 2.4vw, 24px); - line-height: 1.4; color: var(--ink-1); margin-top: 4vh; padding-left: 20px; border-left: 2px solid var(--ochre); } + /* (.takeaway is gone with the in-panel duplicate it styled — the thesis renders once, on the card. Deleted + rather than left as unreachable CSS.) */ /* formal-method equations — full size to match the body type, never folded */ .math .eq { margin: 0 0 30px; } diff --git a/src/scripts/artGallery.ts b/src/scripts/artGallery.ts index 028dced..2206b1e 100644 --- a/src/scripts/artGallery.ts +++ b/src/scripts/artGallery.ts @@ -165,7 +165,12 @@ function updateTocZone() { if (!h) return; const r = tocEl.getBoundingClientRect(); const frac = (window.scrollY + (r.top + r.bottom) / 2) / h; - tocEl.dataset.zone = frac > DARK_FROM ? 'dark' : 'light'; + // WRITE ONLY ON CHANGE. Assigning the same value to a dataset property still counts as an attribute mutation, + // so writing it every scroll frame invalidated the rail's subtree ~60 times a second for nothing. /art measured + // 294ms of style recalc across one drag-scroll — the highest of any page, and higher than pages with far more + // on screen. The zone only changes twice in the whole page, so this is the cheapest possible guard. + const next = frac > DARK_FROM ? 'dark' : 'light'; + if (tocEl.dataset.zone !== next) tocEl.dataset.zone = next; } let ticking = false; diff --git a/src/styles/global.css b/src/styles/global.css index 9db0502..af11c32 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -207,7 +207,14 @@ body { background: var(--bg); color: var(--ink-1); font-family: var(--font-body) content: ""; position: absolute; top: 0; left: 0; right: 0; - height: 120vh; + /* min(), NOT a bare 120vh — this wash was making short pages scrollable into nothing. It is absolutely + positioned, so it still contributes to the document's scroll height, and 120vh is 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 below the page, which is precisely 120vh minus 100vh. Capping at the container's own height + leaves the homepage untouched (100% there is ~6600px, so 120vh still wins) and makes a one-screen page + exactly one screen. */ + height: min(120vh, 100%); pointer-events: none; background: /* sun-glow, upper-right — lifted so the Monet light actually registers */