Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions src/components/CornerNav.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/components/DescentPath.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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);
</script>
6 changes: 6 additions & 0 deletions src/components/TerrainHero.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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);
</script>
3 changes: 3 additions & 0 deletions src/components/proto/FluidSky.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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);
</script>
23 changes: 11 additions & 12 deletions src/pages/404.astro
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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."
/>
</div>
{/* 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. */}
<nav class="nf-links">
<a class="nf-primary" href="/">← back to the descent</a>
{/* DRIVEN BY data/nav.ts, not hand-listed. This block had gone stale twice over: it was missing
/experience (added months ago) and /writing (added with it), so the one page whose entire job is to
offer a way out was offering an out-of-date subset of the site. A list that has to be edited in two
places gets edited in one. */}
<span class="nf-pages">
{PAGES.map((pg) => <a class="nf-link" href={pg.href}>{pg.label}</a>)}
</span>
</nav>
</section>
</main>
Expand All @@ -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; }
Expand Down
48 changes: 40 additions & 8 deletions src/pages/research.astro
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ const stops = researchStops(publications, researchInterests);
<section class="block" id={paperSectionId(i, 'idea')}>
<h3>The idea</h3>
<p>{p.idea}</p>
{p.takeaway && <p class="takeaway">{p.takeaway}</p>}
{/* 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. */}
</section>
)}

Expand Down Expand Up @@ -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;
}
Expand All @@ -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. */
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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; }
Expand Down
7 changes: 6 additions & 1 deletion src/scripts/artGallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 8 additions & 1 deletion src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading