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 */