diff --git a/CLAUDE.md b/CLAUDE.md index ab1d816..5b3c63e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,8 +25,19 @@ pill, rendered as build-time MathML. When in doubt, remove rather than add. - **Astro 6** (static output) + **React 19** (exactly ONE island — the terminal) + **Tailwind v4** (`@tailwindcss/vite`; tokens via `@theme` in `src/styles/global.css`) + TypeScript (strict). -- `astro:fonts` self-hosts **Fraunces** (display, uses the SOFT axis), **Inter** - (body), **JetBrains Mono** (mono/terminal). No CJK fonts. +- **Type: four roles, three faces.** `--font-display` is **Georgia** (a system + serif, no download); `--font-body` is **Inter**; `--font-mono` is **JetBrains + Mono**; `--font-accent` is **Fraunces**, used only on sub-headlines and italic + editorial lines. No CJK fonts. + - Georgia is deliberate, not a leftover. Fraunces was the display face and the + owner rejected it on sight — "prod's font is better, the current font is kind + of weird" — after a long-standing bug meant no webfont had EVER rendered and + the whole design had been judged in Georgia. Keep Georgia for structure. + - `astro:fonts` self-hosts the three webfonts under its own `--ff-*` variables. + Never point `cssVariable` at `--font-display`/`-body`/`-mono`: those are the + site's role tokens, and the collision is what stopped every webfont from + loading. All three are preloaded — removing either Fraunces' or Inter's + preload measured a worse FCP (see BaseLayout's note). - `astro:assets` optimizes gallery images (responsive `widths`, webp/avif). - `site: 'https://ingtian.github.io'`, no `base` (user site at root). diff --git a/astro.config.mjs b/astro.config.mjs index 7389e69..449675c 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -2,7 +2,6 @@ import { defineConfig, fontProviders } from 'astro/config'; import tailwindcss from '@tailwindcss/vite'; -import react from '@astrojs/react'; import sitemap from '@astrojs/sitemap'; // https://astro.build/config @@ -13,17 +12,51 @@ export default defineConfig({ // codebase) — fewer @font-face faces, and dropping mono italic removes a whole // woff2. Fraunces: 400/500/600 normal+italic. Inter: 400/500 normal+italic // (italic = venue/timeline lines). Mono: 400/500 normal only (never italic). + // + // ── EACH FAMILY OWNS ITS OWN VARIABLE (--ff-*), AND NOT --font-display/-body/-mono. + // Those three names are the site's TYPE ROLE tokens, declared in the stylesheet, and pointing + // astro:fonts at the same names was a silent, total failure: astro:fonts writes + // `--font-display: "Fraunces-2d723b2dd3e1ca4b", …` into an inline + + diff --git a/src/components/FactorFan.astro b/src/components/FactorFan.astro new file mode 100644 index 0000000..4f55bb4 --- /dev/null +++ b/src/components/FactorFan.astro @@ -0,0 +1,349 @@ +--- +// src/components/FactorFan.astro +// The factor exposure fan, interactive and in real 3D — drag to rotate. +// +// ONE ASSET, MANY SIGNALS. The site is a single ticker; the portfolio items are signals that +// load onto it. So the object is a factor model, r = α + Σ β_k f_k + ε, with each term +// expanded into one beam whose size IS that term's loading. The maths is not decoration on top +// of the picture — the picture is the maths laid out in space. +// +// WHY three.js, and why it costs nothing measurable: +// the module is dynamically imported from inside an IntersectionObserver, so the chunk is +// fetched only when the section actually scrolls into view. Lighthouse does not scroll, so it +// never loads during an audit and the Perf ≥99 bar is untouched. Under +// prefers-reduced-motion the import never fires at all and the baked SVG still frame — which +// is also the no-JS state and the LCP paint — stays on screen. +// +// RENDERED AS AN INSTRUMENT, not as pixel art. A first pass rendered into a low-resolution +// buffer with image-rendering: pixelated; it read as a retro-game artefact against a +// Swiss-minimal page and was dropped. Full resolution, flat metal-free shading, hairline +// structure: the object should look measured, because it is showing a measurement. +import { fanFrame } from '../lib/fanFrame'; +import { loadings, fanBeams, FACTORS } from '../lib/factorModel'; +import { SIGNAL_WEIGHTS } from '../data/signalWeights'; +import { FACTOR_EQUATIONS } from '../lib/equations'; + +const ls = loadings(SIGNAL_WEIGHTS.signals); +const beams = fanBeams(); +const stillSvg = fanFrame(SIGNAL_WEIGHTS.signals); + +// Serialised for the client so the runtime does no model work: geometry and loadings are +// computed once at BUILD time and handed over as data. +const payload = { + beams: beams.map((b) => ({ + key: b.factor.key, + label: b.factor.label, + href: b.factor.href, + announce: b.factor.announce ?? null, + gloss: b.factor.gloss, + beta: ls.find((l) => l.factor.key === b.factor.key)!.beta, + azimuth: b.azimuth, + elevation: b.elevation, + length: b.length, + halfWidth: b.halfWidth, + tip: b.tip, + })), +}; + +// Signals per factor, for the hover panel — what actually loads on each beam. +const signalsByFactor = Object.fromEntries( + FACTORS.map((f) => [ + f.key, + SIGNAL_WEIGHTS.signals.filter((s) => s.factor === f.key).map((s) => ({ label: s.label, score: s.score })), + ]), +); +--- +
+

Factor exposures — where to find my work

+ + +
+

one asset · six signals · drag the fan to rotate

+ +
+ +
+ + + + +
+ + + + + diff --git a/src/components/PlushCow.astro b/src/components/PlushCow.astro new file mode 100644 index 0000000..d9c2140 --- /dev/null +++ b/src/components/PlushCow.astro @@ -0,0 +1,446 @@ +--- +// src/components/PlushCow.astro +// THE COW, AND WHAT IT SAYS. The owner loves cows, so it gets to be a real drawing rather than a decoration. +// +// He asked for a big one on /writing "instead of scaffolding something that doesn't exist yet", one on the 404, +// one at the homepage's lower-right corner, and for it to actually look like a cow — the first five attempts did +// not. The art is a transcription of a grid he supplied; see data/cowGlyph.ts for why that was necessary. +// +// THE BUBBLE. "why not have a dialogue bubble to wrap those words? also, the font can be more pixel like, let +// the texts be bigger. way bigger." So the words moved inside a pixel speech bubble: a one-cell ink border with +// no radius (pixel art has no curves), a paper interior, and a stepped tail pointing at the cow. He chose 10px +// cells from the two sizes I mocked up, and that one number drives the whole bubble — border thickness, the MOO +// type, and the tail's steps all measure exactly one cell, which is what makes it read as pixel art rather than +// as a rounded chat bubble with a blocky font. +// +// MOO! IS TYPE, NOT DECORATION — drawn from a matrix in data/cowGlyph.ts, 190x50 on screen against the 11.5px +// mono line it replaced. The rotating sentence sits under it, also enlarged. Both live inside the bubble. +import { + COW_BODY, COW_PATCH, COW_MUZZLE, COW_HORN, COW_OUTLINE, COW_MOO, COW_W, COW_H, COW_LINES, + COW_TAIL_FILL, COW_TAIL_INK, COW_TAIL_W, COW_TAIL_H, +} from '../data/cowGlyph'; +import { splitAll, widestHalf } from '../lib/cowSpeech'; + +interface Props { + /** 'mark' is the quiet corner mark on the Work slide; 'small' is a footer-scale cow; 'large' is the page's subject. */ + size?: 'mark' | 'small' | 'large'; + /** Override what it says. Defaults to the homepage lines. */ + lines?: readonly string[]; + /** Accessible name — worth tailoring, since on /404 and /writing the cow is the main thing on the page. */ + label?: string; +} + +const { size = 'small', lines = COW_LINES, label = 'A cow. Press it.' } = Astro.props; + +// The cow grew with the bubble. At 2px cells (44x52) it was a speck beside a 270px bubble — the pair has to +// read as one object. 'mark' is the smallest: the owner picked it for the Work slide so the pair reads as a +// margin note rather than a fourth panel. +const CELL = size === 'large' ? 8 : size === 'mark' ? 3 : 5; +// One cell, shared by the border, the MOO type and the tail. 10px was the owner's pick at footer scale; the +// mark halves it, which keeps the pixel grid intact because every dimension is a multiple of the cell. +const MOO_CELL = size === 'mark' ? 5 : 10; + +// EVERY MESSAGE PRE-BROKEN INTO TWO LINES, at build time. See lib/cowSpeech.ts for why: the bubble has to keep +// one height and vary its WIDTH, or pressing the cow reflows the page. Shipping the pairs means the client never +// measures text or decides a break. +const spoken = splitAll(lines); +// THE FIRST MESSAGE'S wider half, not the widest of all of them. Sizing the box from the copy at build time is +// what avoids a resize on first paint — but sizing it to the LONGEST message means the bubble ships too wide for +// the message actually in it and then shrinks on the first press. Measured: 311px on load against the 132px that +// message needs. With no JS the message never changes, so the first one is the only size that can be right. +// Plus one character of slack. `ch` is the advance of "0", which for a monospace face is the advance of every +// glyph — but only to within the browser's rounding, and measured at 620px the wider half came out 0.4px past +// the content box. Nothing clips (the bubble does not hide overflow) but the type can touch the border, and on a +// machine whose mono metrics differ it could be worse than 0.4px. +const measure = widestHalf(lines.slice(0, 1)) + 1; +--- + +
+ +
+ + {COW_MOO.cells.map((c) => ( + + ))} + + + +

+ {spoken[0]?.[0]} + {spoken[0]?.[1]} +

+ {size === 'large' && ( + + + )} + + +
+ + +
+ + + + diff --git a/src/components/QuantPod.astro b/src/components/QuantPod.astro new file mode 100644 index 0000000..2abcfe9 --- /dev/null +++ b/src/components/QuantPod.astro @@ -0,0 +1,355 @@ +--- +// src/components/QuantPod.astro +// The pod as a full-bleed pixel-art ROOM that the page ends inside. +// +// WHY FULL-BLEED: framed in a 1180px box with its edges masked, the room read as a +// picture hanging in the sky rather than a place you had arrived at. Taking the full +// width makes it the environment, and it dissolves the seam problem entirely — there +// is no edge to blend when there is no edge. The descent's sky ends here on purpose; +// the links and the seal sit INSIDE the room. Documented as a deliberate exception to +// "one continuous sky" in CLAUDE.md. +// +// WHY LOW-RES: real pixel art is authored in a small buffer and scaled up. The room +// paints into a fixed 480x280 canvas which CSS blows up with image-rendering: +// pixelated. That is why every edge is crisp and every detail is deliberate — and it +// makes the paint cost independent of viewport size. +// +// THE ACCESSIBILITY CONTRACT (unchanged): +// - ONE canvas, aria-hidden, pure decoration. +// - Every destination is a REAL focusable element positioned by PERCENTAGE over its +// monitor: to navigate, + + +
+ + + +
+
+ + + + diff --git a/src/components/SideRail.astro b/src/components/SideRail.astro index bca43d0..549b66b 100644 --- a/src/components/SideRail.astro +++ b/src/components/SideRail.astro @@ -66,7 +66,14 @@ const { stops, id, label = 'On this page' } = Astro.props; top: 50%; transform: translateY(-50%); z-index: 30; - max-width: 190px; + /* 200px, not 190px, and the 10px is load-bearing: at 190px the longest label in the data — "University of + Toronto", the PhD institution — needed 151px against a 148px box and rendered as "UNIVERSITY OF TORON…". + Only the ACTIVE row truncated, because .rule animates from 0 to 20px on activation and takes that width + out of the label beside it, so the defect appeared only once a reader had scrolled to that stop. + There is room: measured at 1180px, the narrowest width where the rail is shown at all, its right edge sits + at x=218 and the nearest content starts at x=401 — 183px of clearance. Widening is preferable to tightening + the label's tracking, which would have changed the rail's type everywhere to fix one string. */ + max-width: 200px; } .side-rail ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 16px; } /* Nested lists breathe less than top-level ones, so a group reads as a group. */ @@ -87,7 +94,11 @@ const { stops, id, label = 'On this page' } = Astro.props; bought hierarchy that was already there at the cost of readability. */ .side-rail .sub a { font-size: 10px; letter-spacing: 0.12em; opacity: 0.45; } .side-rail .rule { width: 0; height: 1px; background: currentColor; opacity: 0.55; transition: width 0.4s ease; flex: none; } - .side-rail .label { min-width: 0; } + /* ONE LINE PER ROW. Measured with a real long title in the data: a truncated 22-char label still wrapped to + two lines in a 190px rail, which broke the even vertical rhythm and pushed the neighbouring rows + off-centre. The ellipsis is the rail's own, so a label that still cannot fit degrades to a clean cut + rather than to a second line. */ + .side-rail .label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* ── STATE beats TIER, and state beats hover. ── These are written with :where() on the tier part so the STATE selectors win @@ -187,7 +198,21 @@ const { stops, id, label = 'On this page' } = Astro.props; const vh = window.innerHeight; const bandTop = vh * 0.10; const bandBottom = vh * 0.62; - const measured = stops.map((s, treeIndex) => { + + // COLLAPSED CONTENT: a stop whose target is not rendered must not appear in the rail. + // /research collapses each paper behind a card, so "The idea / Method / Results" point at + // elements inside a `hidden` panel. getBoundingClientRect() on those returns an all-zero box, + // which is not merely inert — it sorts to the top and can win the fallback rule, lighting a + // section the reader cannot see. Hiding the row is also the honest UI: the rail should offer + // what the page is currently showing. + for (const s of stops) { + const live = s.el.getClientRects().length > 0; + s.a.closest('li')?.toggleAttribute('hidden', !live); + } + const visible = stops.filter((s) => s.el.getClientRects().length > 0); + if (!visible.length) return; + + const measured = visible.map((s, treeIndex) => { const r = s.el.getBoundingClientRect(); return { ...s, treeIndex, top: r.top, diff --git a/src/components/SkyWash.astro b/src/components/SkyWash.astro index aa9628c..172bea6 100644 --- a/src/components/SkyWash.astro +++ b/src/components/SkyWash.astro @@ -93,5 +93,66 @@ from { transform: scale(1); opacity: 0.10; } to { transform: scale(1.08); opacity: 0.16; } } + + /* ── THE BREATHE HOLDS ITS BREATH WHILE YOU SCROLL, and this is the fix for the deck feeling rough. + The owner: "i found that scroll down and up the slides is not smooth enough." + MEASURED, because the obvious suspects were all wrong. During a deck advance the page was rendering a + median frame of 33.7ms — 82% of frames dropped, worst 142ms — where 16.7ms is 60fps. Bisecting the layers + cleared the corner-nav backdrop-filter, the descent washes, the grain and the hero canvas (all made no + difference), and landed here: each of these three elements is 1685 x 7812 = 13.2 MEGAPIXELS, because + `inset: 0` inside main.descent means the whole DOCUMENT, not one viewport. Three of them, each with + mix-blend-mode, animating without pause. Every frame re-blends about 40 megapixels against its backdrop. + Fix candidates, all measured against a four-advance walk: + will-change removed 32.5ms (no help) + blend modes off 24.6ms (helps, and costs the Monet look) + wash fixed to the viewport 25.0ms (helps, and the sky stops descending with you) + ANIMATION PAUSED 8.4ms (as good as deleting the wash outright, which also gave 8.4ms) + So the animation is the whole cost, and pausing it only WHILE SCROLLING costs nothing at all: the sky + still breathes whenever the reader is still, which is when they can actually see it breathe. + `animation-play-state: paused` freezes mid-cycle and resumes from the same point, so there is no jump. + Safe direction: the class is only ever ADDED by script, so with no JS the animation simply runs — the + current behaviour, and a finished state. */ + :global(html.wash-still) .sky-warm, + :global(html.wash-still) .sky-cool, + :global(html.wash-still) .sky-sun { + animation-play-state: paused; + } + /* NOT also relaxing will-change here, though it looks like free savings: measured, dropping the promotion + on scroll start was neutral-to-worse (medians 24.9/24.3/24.9 against 16.6/16.7/25.0 with it kept), + because un-promoting forces a re-raster of a 13-megapixel layer at exactly the wrong moment. */ } + + diff --git a/src/components/Toc.astro b/src/components/Toc.astro index 5473742..47127c5 100644 --- a/src/components/Toc.astro +++ b/src/components/Toc.astro @@ -6,22 +6,63 @@ // // Each stop carries a `zone` (light|dark) so the label color adapts to the // gradient sky beneath it — dark ink on the pale top, paper on the dark bottom. -const stops: { label: string; href: string; zone: 'light' | 'dark' }[] = [ - { label: 'About', href: '#heights', zone: 'light' }, - { label: 'Work', href: '#mountains', zone: 'light' }, - { label: 'Ask', href: '#ground', zone: 'dark' }, - { label: 'Links', href: '#signature', zone: 'dark' }, -]; +// +// The rail is FIXED, so a per-stop zone is only half the story: when the opaque story panel fills the +// viewport, EVERY visible label sits over paper, not just the story's own. The script below therefore +// also sets an .over-paper class on the rail by testing the panel's box against the rail's own box, +// which flips all the labels to dark ink for as long as the panel is behind them. Without it the +// three dark-sky labels rendered light-on-light and effectively vanished. +// 'Work' pointed at #mountains, which no longer exists on the homepage — the résumé section was +// deleted (see index.astro). A rail stop whose target is missing is dead: the scrollspy silently drops +// it, but it still renders as a clickable link that goes nowhere. Removed rather than repointed. +// 'Ask' pointed at #ground — the terminal — which is deleted (see index.astro), and 'Work' before it +// pointed at #mountains, also deleted. Both are removed rather than repointed: a rail stop whose target +// is missing still renders as a clickable link that goes nowhere. +// ORDER FOLLOWS THE PAGE, and the page now answers "what do I do" before "how I got here" — so Story moved +// below the explainer. A rail whose order disagrees with the document is worse than no rail: the scrollspy +// sorts by measured position, so a mismatched list would light stops out of sequence. +// +// NESTED, and the labels are the sections' own subjects. The owner: "the ToC still says choices. i think this +// is more a 'the problem' section. similarly that An OR Quant slide deserves a ToC Tag as well… for the +// problem, the constraint, stuff, you can maybe learn from research's nested ToC. this way it's clearer on the +// top level." +// +// The tree lives in lib/pageStops (homeStops), the same module the /research, /projects and /experience rails +// read — one place that knows what a rail carries, and unit-tested. Zones stay HERE on the top-level stops +// because they are a property of the sky behind a stop, not of the page's structure. +import { homeStops, flattenStops } from '../lib/pageStops'; + +const stops = homeStops(); +// Children inherit their parent's zone: they are slides inside the same stretch of sky, and a child with a +// mismatched zone would render light-on-light for exactly as long as its parent read correctly. +const zoneOf = new Map(); +for (const s of stops) { + zoneOf.set(s.target, s.zone); + for (const c of s.children ?? []) zoneOf.set(c.target, s.zone); +} --- + diff --git a/src/sections/Solve.astro b/src/sections/Solve.astro new file mode 100644 index 0000000..976ebd5 --- /dev/null +++ b/src/sections/Solve.astro @@ -0,0 +1,585 @@ +--- +// src/sections/Solve.astro +// SLIDE 3 OF 3 — THE METHOD, as the algorithm actually running. +// +// The owner's brief: "the last slide animate a 3D route in a Bellman propagation of many that we highlight as +// our goal." +// +// So this is the Bellman lattice: states of the book across time, values propagating BACKWARD (which is +// literally what dynamic programming does — you cannot know today's best move until you know what tomorrow is +// worth), then the optimal route lighting FORWARD through the candidates it beat. +// +// WHY THIS IS NOT THE SIXTH REJECTED SHOWPIECE. Every one of the five failures was a drawing that looked like +// it meant something without meaning anything, and three of them were hand-drawn props. This is neither: the +// surface is a real finite-horizon dynamic program solved by backward induction, and lib/bellman.ts's tests +// assert the Bellman optimality condition at EVERY node, that the traced route achieves exactly the value the +// DP promises, and that it beats all 29 enumerated candidate policies. If the maths is real, the animation +// teaches the method rather than decorating it. +// +// Hand-rolled oblique projection rather than three.js — ships no bytes, and a parallel projection keeps +// heights comparable across the lattice, which matters when the height IS the value being compared. +import { + LEVELS, PERIODS, WORLD, solve, route, candidates, + project3, valueBounds, +} from '../lib/bellman'; +import { METHODOLOGIES, OPEN_HEADING, OPEN_STATEMENT, OPEN_CLOSE } from '../data/desk'; + +const sol = solve(); +const optimal = route(sol, 0); +const cands = candidates(); +const bounds = valueBounds(sol); + +const VW = 760, VH = 380; +const view = { w: VW, h: VH, shear: 0.30, lift: 0.46, pad: { l: 30, r: 30, t: 18, b: 46 } }; +const norm = (v: number) => (bounds.hi - bounds.lo > 0 ? (v - bounds.lo) / (bounds.hi - bounds.lo) : 0); +const at = (t: number, level: number, v: number) => project3(t, level, norm(v), PERIODS, LEVELS, view); + +// The lattice as lines of constant state (running through time) and lines of constant time (across states). +// Both are needed for the surface to read as a surface rather than as scattered dots. +const stateLines = Array.from({ length: LEVELS }, (_, s) => + Array.from({ length: PERIODS + 1 }, (_, t) => at(t, s, sol.value[t][s])), +); +const timeLines = Array.from({ length: PERIODS + 1 }, (_, t) => + Array.from({ length: LEVELS }, (_, s) => at(t, s, sol.value[t][s])), +); + +// Candidate routes, drawn at the value the DP assigns to the state each one occupies. +const candLines = cands.map((c) => + c.map((level, t) => at(t, level, sol.value[t][level])), +); +const optLine = optimal.map((s) => at(s.t, s.level, sol.value[s.t][s.level])); +// WHOLE PIXELS, not tenths. The lattice emits 29 candidate polylines plus 13 ribs and 9 spines; at one decimal +// place that is several KB of HTML for precision nobody can see, since the 760x380 viewBox is scaled up on +// screen. It is also load-bearing for performance: the extra bytes pushed the document across a congestion-window +// boundary and cost one simulated round-trip (~150ms) in Lighthouse's throttled LCP. +const path = (pt: [number, number][]) => pt.map(([x, y]) => `${Math.round(x)},${Math.round(y)}`).join(' '); + +// Which periods the model expects to punish risk — the reason the route moves at all. +const badPeriods = WORLD.tilt.map((t, i) => ({ t, i })).filter(({ t }) => t < 0).map(({ i }) => i); +--- +
+
+
+

3 / 3 · where to start

+ +
+ +
+

{OPEN_HEADING}

+ +

{OPEN_STATEMENT}

+ +
+ {METHODOLOGIES.map((m, i) => ( +
+

{m.lede}

+

{m.detail}

+
+ ))} +
+
+ +
+ + + + {timeLines.map((line, t) => ( + + ))} + {stateLines.map((line, s) => ( + + ))} + + + + + {candLines.map((line) => )} + + + + + {optLine.map(([x, y], i) => ( + + ))} + + +
+ value surface + {cands.length} candidate policies + the optimum +
+ +

A world small enough to solve exactly…

+ + +
+ {LEVELS} exposure levels at {PERIODS} dates — {PERIODS * LEVELS} decisions, {PERIODS * LEVELS * LEVELS} comparisons, + and {LEVELS}{PERIODS} = 2.8×1011 exposure paths the recursion never visits. It is + solvable exactly because the world was handed over complete: the tilt is written down in advance + (risk is cut at {badPeriods.map((i) => `t${i}`).join(', ')}), the transition is deterministic so no + expectation is taken anywhere, the objective is additive by construction, there is one number of risk + instead of three thousand, and there is no rule book. The real problem withholds all five. +
+
+
+ + +
    + {METHODOLOGIES.map((m, i) => ( +
  1. + +
  2. + ))} +
+ +

{OPEN_CLOSE}

+
+
+
+ + + + diff --git a/src/sections/Story.astro b/src/sections/Story.astro new file mode 100644 index 0000000..0726f21 --- /dev/null +++ b/src/sections/Story.astro @@ -0,0 +1,279 @@ +--- +// src/sections/Story.astro +// THE EDITORIAL that explains the descent graph, in two columns on a paper ground. +// +// The owner's brief: "after the descent line, a section explaining ourselves, incl. why we transition +// into quants… the section explains the graph literally. You maybe even can get creative, try 2 +// columns etc." And the register: the earlier "forty-two sentences" sketch read like a good journal +// spread, but its CONTENT was photograph notes — "after all we are not an artist". So this keeps that +// typographic register and puts the actual subject in it. +// +// WHY A PAPER GROUND. The rest of the homepage is a sky you descend through. This section is the one +// place that stops and talks, so it gets a different surface: an editorial paper panel, full-bleed, +// with the sky visible above and below it. That is also the elegant thing the owner picked out about +// the light theme, and the panel colour is chosen per theme rather than shared. +// +// Every number here is COMPUTED at build time from the same field the graph draws, so the prose and +// the picture cannot drift apart. Nothing is hand-typed. +// +// THE PANEL NOW WRAPS THE GRAPH TOO. The owner: "in the descent one climb section, you might want to +// completely turn off the fluid background and use the paper editorial background." That is one +// slide — the picture and the words that read it — so it is one surface, and DescentPath renders as +// a inside this panel rather than as a sibling floating on the sky. Turning the fluid off +// behind it is done by the panel being opaque, not by touching the fluid layer: the sky keeps +// running above and below, and this slide simply sits on paper. +// The figure row is deleted (see below), so the measurement imports go with it — curvature(), +// discoveryOf() and trajectoryFacts() were only ever called here to fill those three numbers. The +// modules stay: lib/sensitivity and lib/knowledge are what the GRAPH is drawn from, and they keep their +// own specs. +import { STORY } from '../data/story'; +--- +
+
+ +
+ +
+ +
+

Why the climb

+ +
+ {STORY.map((col) => ( +
+

{col.kicker}

+

{col.lead}

+ {col.body.map((para) =>

{para}

)} +
+ ))} +
+ + +
+
+
+
+ + diff --git a/src/sections/Work.astro b/src/sections/Work.astro new file mode 100644 index 0000000..61f569b --- /dev/null +++ b/src/sections/Work.astro @@ -0,0 +1,266 @@ +--- +// src/sections/Work.astro +// SLIDE 3 — "just say plainly, my publications, my writings, my projects." +// +// Plainly is the instruction and the whole design: after two slides that teach and argue, this one simply +// says where the work is. Three columns, one line of what each holds, a real link each. No display-size +// headline competing with the two slides before it, no counts, no "selected X" headings. +// +// WHY THIS IS NOT A RESUME even though it is a list. A résumé lists credentials — title, employer, dates, +// reverse-chronological — and claims authorization. This lists ARTEFACTS and where to read them. The +// distinction is that every row here is a thing a reader can go and check. +import { publications, projects } from '../data/profile'; +import { WRITINGS_PROMISE, WRITINGS_TOPICS } from '../data/making'; +import PlushCow from '../components/PlushCow.astro'; +import Signature from './Signature.astro'; + +const featured = publications.find((p) => p.featured); +const others = publications.filter((p) => !p.featured); +const shownProjects = projects.slice(0, 2); + +// THE TWO HEADLINE NUMBERS, recovered from the shipped homepage. This slide replaced a "Selected writing" glass +// card that carried them, and dropping them cost the homepage every number on it — which on a quant's site is the +// wrong thing to spend for breadth: the Sharpe against its two baselines is the most checkable claim here, and it +// is the one thing a reader cannot get from a title. Derived exactly as the shipped card derived them, so +// profile.ts stays the single source and the figures cannot drift from the paper's own table. +const compounded = featured?.results?.[0]; +const sharpe = featured?.metrics?.rows.find((r) => /sharpe/i.test(r.metric)); +--- + +
+
+
+

Appendix

+ +
+ +
+

Publications

+ {featured && ( +
+

{featured.shortTitle ?? 'RL-BHRP'}

+

+ {featured.subject} · {featured.venue} + {featured.arxivId && <> · arXiv:{featured.arxivId}} +

+ +
+ {compounded && ( +
+

{compounded.value}

+

wealth compounded · OOS ’20–’25

+
+ )} + {sharpe && ( +
+

{sharpe.values[0]}

+

Sharpe · vs {sharpe.values[1]} / {sharpe.values[2]}

+
+ )} +
+
+ )} + {others.map((p) => ( +
+

{p.title}

+

{p.venue} · {p.year}

+
+ ))} + Read the research +
+ + +
+
+

Writing

+ coming +
+

{WRITINGS_PROMISE}

+
    + {WRITINGS_TOPICS.slice(0, 3).map((t) =>
  • {t}
  • )} +
+
+ + +
+

Projects

+ {shownProjects.map((p) => ( +
+

{p.name}

+

{p.tagline}

+
+ ))} + All projects +
+
+ + +
+ +
+ +
+
+ + + + + diff --git a/src/styles/global.css b/src/styles/global.css index f34c277..b7975f8 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -15,9 +15,36 @@ --color-ochre: var(--ochre); --color-indigo: var(--indigo); --color-seal: var(--seal); - --font-display: 'Fraunces', Georgia, serif; - --font-body: 'Inter', system-ui, sans-serif; - --font-mono: 'JetBrains Mono', ui-monospace, monospace; + /* Text sitting ON the accent, as a utility. tokens.css has defined --on-accent all along and names the CV + button as the reason it exists, but the footer never used it: it set text-ink-1, which rides the ink ramp, + so in dark theme the button painted near-white #dce1dc on phosphor green #66c28c — measured at 1.64:1, + against a 4.5:1 requirement. Exposing it here means the fix is a utility swap rather than a hand-written + override, and any future accent chip can reach it the same way. */ + --color-on-accent: var(--on-accent); + /* THE TYPE ROLES, built from astro:fonts' own --ff-* variables and declared in exactly ONE place. + They used to be literal stacks written out here AND again in tokens.css, and that duplication is what hid + a total failure: whichever copy came last won, and both named "Fraunces" — a family with no @font-face rule + anywhere, because astro:fonts registers its faces under a hashed name. Nothing self-hosted rendered. + No tail after the var(): astro:fonts already builds the whole stack, ending in the generic family, from the + per-family `fallbacks` in astro.config.mjs. Appending our own repeated it — the computed value came out as + "…, serif, Georgia, 'Times New Roman', serif" — so the var()'s DEFAULT carries the safety instead, and it + only applies if astro:fonts stops emitting the variable at all. */ + /* ── DISPLAY IS GEORGIA, NOT FRAUNCES, on the owner's call after seeing Fraunces render for the first time: + "i think prod's font is better. the current font is kind of weird." + Worth recording precisely what he was comparing, because it is not what it looks like. The live site has + never loaded a webfont — that was the bug fixed a few commits ago — so "prod's font" IS Georgia, and the + months he spent judging this design were all in Georgia. Fraunces is a high-contrast display serif with + deliberately wonky curves; against a calm workhorse serif it reads as mannered at headline size. He is + choosing the one he has actually been reading, which is the right basis for the choice. + Consequence worth having: no display webfont in the critical path, so Fraunces stops being preloaded and + the FCP cost of the font fix largely goes back. */ + --font-display: Georgia, 'Times New Roman', 'Nimbus Roman', serif; + --font-body: var(--ff-inter, system-ui, sans-serif); + --font-mono: var(--ff-jetbrains, ui-monospace, monospace); + /* FRAUNCES KEPT, AS AN ACCENT — "but maybe you can use it in some sub headlines to add some varieties." So it + stops being the site's voice and becomes a second one, used on sub-headlines and the italic editorial lines + where a characterful serif is an asset rather than a distraction. Structure stays Georgia. */ + --font-accent: var(--ff-fraunces, Georgia, serif); } /* overflow-x:hidden guards against the .descent::before/::after atmosphere @@ -45,6 +72,130 @@ body { background: var(--bg); color: var(--ink-1); font-family: var(--font-body) background: var(--descent-grad); } +/* ============================================================ + THE SCROLLABLE DECK — "like a PPT, one scroll guides you to the + next slide; it's not a free scroll." + + The descent stays one continuous sky; what changes is that the + scroll now RESTS at slide boundaries instead of anywhere. + + CSS scroll-snap was tried FIRST and MEASURED, because it is the + cheap answer with no JS. It does not work at this page's + proportions, and the numbers are worth keeping so nobody + "simplifies" the deck back into it: + + slide gaps at 1700x1050: 1134px, 819px, 2279px, 2346px, 581px + one wheel gesture: ~320px + + Every gap is 1.8x-7.3x a single scroll, so with `y mandatory` one + gesture proposes a destination ~320px away and the nearest snap + position to it is the slide it just left — the browser snaps back + and the page does not move. Measured: stuck at y=84 through eight + gestures, and through a 12-event burst shaped like a trackpad + flick. `proximity`, `scroll-snap-stop: always`, and shrinking the + hero to exactly 100vh all produced identical stuck walks; only + gestures of 640px+ ever advanced. + + The deck is therefore driven explicitly — see lib/deck.ts (pure, + unit-tested: where the stops are) and Deck.astro (one gesture = + one stop). + + BREATHING AIR above each stop is a --deck-lead custom property on + the slide, read by the controller. It was `scroll-margin-top` + here, which was WRONG and silently did nothing: scroll-margin is + only honoured by scrollIntoView(), and the deck moves with + window.scrollTo(). The rule read correctly, applied never, and + the paper panel's title came to rest hard against the browser + chrome. A CSS property that only some scroll APIs respect is a + trap; the offset now lives where the scrolling happens. + ============================================================ */ + +/* ============================================================ + EVERY SLIDE OWNS THE SCREEN. + + Measured at 1700x1050 before this rule, the shortfall per slide + was: choice 448px, work 477px, rules 378px, interlude 231px, + solve 147px. That shortfall is not empty space — it is exactly + how much of the NEXT slide is visible while the deck rests on + this one, which is why the Solve headline appeared at the foot + of the Rules slide. A deck whose slides each show a strip of the + following slide is not a deck; it is a scroll that pauses. + + min-height rather than height: two slides (the hero at 1134px + and the career story at 1282px) are legitimately TALLER than a + screen, and lib/deck.ts already pages through those. Forcing a + fixed height would either crop them or reintroduce the overflow + this fixes. + + The flex centring is what makes it look deliberate rather than + padded: a 600px slide in a 1050px frame sits in the middle of + its own screen instead of clinging to the top with 450px of + dead air under it. The paper panels stay full-bleed because + they are the flex ITEM, so the ground still reaches both edges. + ============================================================ */ +/* EXCLUDES THE SIGNATURE rather than overriding it afterwards, and that distinction was a real bug. The footer + is not a slide — it is a strip — and it used to be exempted below with `display: block`, which silently beat + its own Tailwind `flex justify-between items-end` (an ID selector outranks a utility class). So on the + homepage, and ONLY on the homepage, the footer laid out as three stacked blocks and the IT seal rendered at + the far LEFT instead of the bottom right. The owner reported it twice as the seal "drifting"; both of my + earlier attempts edited the markup, which could not have worked, because the markup was never the problem. + Excluding it here means the utilities in Signature.astro simply apply, and there is nothing left to override. + THE EXCLUSION IS GONE because the footer is no longer a top-level section: it is nested inside #appendix (see + sections/Work.astro), so this selector cannot reach it and there is nothing to exempt. Keeping a :not() for an + element that can never match would be a false clue about the layout. */ +.is-slides > section { + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; +} +/* ── THE NAV MUST NOT LAND ON A HEADLINE, which on phones it did — on every slide. + The corner nav is a fixed capsule at the top; the deck rests with a slide's top at the viewport top; and a + paper panel puts its kicker and headline in the first ~90px. So at rest, on a phone, the capsule sat across + the headline of each explainer slide. Measured at 390x844 resting on each stop, covered widths: + "What is multi-period portfolio optimization?" 228px · "Now imagine it is not your money." 341px · + "Still an open problem." 212px · "The work" 123px — plus every kicker ("1 / 3", "2 / 3 · the difficulty"). + Not transient: these are the positions the deck comes to REST at, so the headline was permanently obscured. + + THE FIX IS THE PANEL'S OWN PADDING, NOT --deck-lead. A lead was the obvious lever and it is the wrong one: + it lifts the deck's resting position ABOVE the slide, so a full-bleed opaque panel gets a strip of sky above + it and reads as a card that has slipped down — Story.astro measured exactly that (paper at 1953, deck resting + at 1893, 60px of sky) and documents why it carries no lead, naming the panel's padding-top as the right home + for this air. Padding keeps the paper flush to the top of the screen and moves only the content inside it. + + 78px clears the wrapped two-row capsule on phones (12px offset + ~54px tall) and the single-row capsule in + landscape (17px + ~44px). These selectors are (0,2,1) against Astro's scoped `.ch-paper[data-astro-cid-…]` at + (0,2,0), so the longhand wins over each panel's own `padding` shorthand — which is also why the first attempt + at this, a plain `.is-slides > section` rule, silently lost and changed nothing. */ +@media (max-width: 1100px), (max-height: 620px) { + .is-slides > section > .story-paper, + .is-slides > section > .ch-paper, + .is-slides > section > .ru-paper, + .is-slides > section > .so-paper, + .is-slides > section > .wk-paper { padding-top: 78px; } + /* The footer no longer needs its own clearance rule. It used to be a stop of its own, so the deck rested with + its first row at the top of the screen and the capsule sat across "Download CV ↓" — 95px of it at 320px, a + band where a tap would have hit the nav instead of the link. Nested inside #appendix it is never at the top + of the viewport at rest, and the appendix's own panel padding above covers the case. */ +} + +/* The panel inside a slide must be able to fill it, or the centring above just + moves a short panel down the screen and leaves sky above and below it. */ +.is-slides > section > .story-paper, +.is-slides > section > .ch-paper, +.is-slides > section > .ru-paper, +.is-slides > section > .so-paper, +.is-slides > section > .wk-paper { + flex: 1 0 auto; + display: flex; + flex-direction: column; + justify-content: center; +} +/* The nested footer must NOT stretch. It is the last flex item in #appendix, and a stretched footer would put its + links in the middle of a tall band instead of at the foot of the page. `flex: none` keeps it at its own height + and lets the tan panel above it take the slack. */ +.is-slides > section > #signature { flex: none; } + /* ── Atmosphere, upper sky (heights → interlude). Normal blend: these are warm LIGHT washes + a soft horizon haze that read on the pale paper. Localized to the top, they scroll away naturally. ── */ diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 64f26ba..1ea115d 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -57,10 +57,13 @@ #dcd5cf 100% ); - /* fonts (families resolved via astro:fonts) */ - --font-display: 'Fraunces', Georgia, serif; - --font-body: 'Inter', system-ui, sans-serif; - --font-mono: 'JetBrains Mono', ui-monospace, monospace; + /* FONTS ARE NOT DECLARED HERE ANY MORE, and that is the fix for a site-wide bug rather than a move for + tidiness. These three lines used to read `--font-display: 'Fraunces', Georgia, serif` and so on, with a + comment claiming the families were "resolved via astro:fonts" — they were the very thing preventing that. + astro:fonts writes its own hashed family name into the same custom property from an inline