Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
97 commits
Select commit Hold shift + click to select a range
9f8f27b
feat(pod): from-the-seat isometric camera
IngTian Aug 8, 2026
49214f2
feat(pod): pod layout — wall, bench, 5 monitors, desk objects
IngTian Aug 8, 2026
ba1e377
feat(pod): Gantt screen data from the real timeline
IngTian Aug 8, 2026
c049628
feat(pod): IDE, backtest, LaTeX and Bloomberg screen content
IngTian Aug 8, 2026
a3e7723
fix(pod): resolve undefined identifiers in ideLines Python snippet
IngTian Aug 8, 2026
a7d8207
feat(pod): the painter — dots style, static frame
IngTian Aug 8, 2026
9bdbbfa
fix(pod): deterministic dot jitter + Gantt NaN guard
IngTian Aug 8, 2026
e5161a4
feat(pod): the component — canvas, overlay controls, lifecycle
IngTian Aug 8, 2026
8ca442d
fix(pod): make wall/bench/objects visible + fix overflow
IngTian Aug 8, 2026
01cbee1
fix(pod): solid pixel-art surfaces + full frame + content fixes
IngTian Aug 8, 2026
6ce2961
feat(pod): rebuild as a full-bleed pixel-art room
IngTian Aug 8, 2026
8b50f96
feat(pod): a trading floor — volume, a ceiling, a stacked rig, 800x440
IngTian Aug 8, 2026
842ba5a
feat(pod): tilt the monitors, 1600x880, arms, quieter hover
IngTian Aug 8, 2026
728a4f2
fix(pod): stop the screen contents bowing in the tilted quads
IngTian Aug 8, 2026
9855c4f
feat(pod): add a retrofuturist style, switchable live against the pix…
IngTian Aug 8, 2026
46e35d1
feat(pod): one true camera — a single vanishing point for the whole room
IngTian Aug 8, 2026
7499359
feat(pod): flat modernist scene — the actual retrofuturism, on the on…
IngTian Aug 8, 2026
b130906
fix(pod): the four visible defects in the modernist scene
IngTian Aug 8, 2026
71642fe
feat(experience): build /experience, closing a live 404
IngTian Aug 8, 2026
a39b19d
feat(experience): carry the descent spine onto the page
IngTian Aug 8, 2026
bc62912
feat(factor): one asset, many signals — the honest math first
IngTian Aug 8, 2026
bc8eb14
feat(factor): LLM-scored signal strength, as a reviewed artefact
IngTian Aug 8, 2026
1b00e00
feat(factor): render the fan — the model on screen at last
IngTian Aug 8, 2026
133c5eb
fix(factor): typeset the maths properly, and unclutter the fan
IngTian Aug 8, 2026
0cdc9d9
feat(fan): interactive 3D fan, one big equation, pixel-art shading
IngTian Aug 8, 2026
3e21f5e
fix(fan): rotation actually works, 360 dial, no pixel art, hover resp…
IngTian Aug 8, 2026
03addf2
feat(capability): measure breadth, stop predicting a return
IngTian Aug 9, 2026
3555811
chore(home): remove the rejected pod scene from the homepage
IngTian Aug 9, 2026
12b1e42
feat(trajectory): the career as a descent with one escape — measured,…
IngTian Aug 9, 2026
6ea8a91
docs(notes): record the showpiece thinking where it survives
IngTian Aug 9, 2026
4426b74
feat(sketches): a harness that makes a showpiece candidate cost one f…
IngTian Aug 9, 2026
6d5eb87
docs(notes): round-one sketch verdicts, measured
IngTian Aug 9, 2026
3bb173e
feat(descent): the geometry is measurable — curvature, feedback, and …
IngTian Aug 9, 2026
3fdfaee
feat(descent): the sensitivity function — the deep basin is discovere…
IngTian Aug 9, 2026
c858599
fix(descent): smooth the walk, drop 3D
IngTian Aug 9, 2026
e4d283d
fix(descent): a continuous curve, a continuous frontier, and no stray…
IngTian Aug 9, 2026
0093705
perf(descent): 0.6fps -> 120fps — the animation was 36M gradient step…
IngTian Aug 9, 2026
9d44f9d
feat(descent): dotted survey style, named deep side, no bottom list
IngTian Aug 9, 2026
00169a8
feat(story): the editorial that explains the descent, in two columns …
IngTian Aug 9, 2026
d994b87
feat(home): a real slide deck, the graph on paper, and the quant line…
IngTian Aug 9, 2026
8bc19d9
feat(home): delete the résumé, give the diagram the whole slide, fix …
IngTian Aug 9, 2026
2494a47
feat(research): the paper becomes a card that expands to the detailed…
IngTian Aug 9, 2026
ab7fa5a
feat(home): delete the terminal, tone the paper down, and give the de…
IngTian Aug 9, 2026
0d7c24d
feat(home): merge the descent graph and the editorial into one section
IngTian Aug 9, 2026
23d1978
content(home): strip the invented numbers out of the career section
IngTian Aug 9, 2026
7ccfae4
feat(home): present the research as a question, and demote projects t…
IngTian Aug 9, 2026
17f4fab
feat(home): teach the field in three slides, with the paper's real tr…
IngTian Aug 9, 2026
8ac1dc4
feat(home): rebuild the explainer around the search, and bake in mult…
IngTian Aug 9, 2026
bca7a02
feat(home): make the example concrete — real tickers, real news, fort…
IngTian Aug 9, 2026
d885901
feat(home): put the reader at a desk on a Monday, and draw the branching
IngTian Aug 9, 2026
6aa1d16
feat(lib): a real Bellman solver for the final slide, with the maths …
IngTian Aug 9, 2026
854834d
feat(lib): the feasible set as real linear algebra, not abstract shapes
IngTian Aug 9, 2026
1e8e801
feat(home): three slides — the choice, the rules, the solve
IngTian Aug 9, 2026
2cf8e6e
fix(home): every slide owns the whole screen
IngTian Aug 9, 2026
7027855
fix(home): let the drawing be the slide, the way the descent piece does
IngTian Aug 9, 2026
7927445
feat(home): name the field first, then explain it, then the path that…
IngTian Aug 9, 2026
e215d57
feat(home): actually define the term, with one example doing the work
IngTian Aug 9, 2026
e772b25
feat(home): a fourth beat in dollars, and the doorways move to the hero
IngTian Aug 10, 2026
3aae5a4
feat(home): a weekly year with a levered player, clickable beats, rea…
IngTian Aug 10, 2026
02b1f90
feat(home): a nested rail that names the sections, and the tagline's …
IngTian Aug 10, 2026
b0ea626
feat(site): /writing — a shelf built to grow, with designed empty states
IngTian Aug 10, 2026
7045f47
feat(home): draw the cut on the rules slide, and label the marks on w…
IngTian Aug 10, 2026
11cd647
feat(home): the limits slide gets its second act — constraints on who…
IngTian Aug 10, 2026
6b1e2b1
feat(home): the limits slide becomes the difficulty slide
IngTian Aug 10, 2026
b99e418
feat(home): readable type on both explainer slides, and a turned axis…
IngTian Aug 10, 2026
f9f06a5
fix(home): the fan reaches the right edge, flush with the band below it
IngTian Aug 10, 2026
65202bb
feat(home): pixel marks for the rules, drawing paths, and a conflict …
IngTian Aug 10, 2026
e891862
feat(home): the Bellman blowup, typeset — beat four's empty half beco…
IngTian Aug 10, 2026
ae05d54
fix(home): the circle, the type and the equation all get the room the…
IngTian Aug 10, 2026
61e2c22
feat(home): the method slide becomes one equation, walked in four moves
IngTian Aug 11, 2026
bd99cf5
fix(home): the last slide stops claiming a method it does not have
IngTian Aug 11, 2026
ac2253c
fix(home): still an open problem — three general directions, and noth…
IngTian Aug 11, 2026
18a0ac6
fix(home): a fourth family, an unevidenced claim removed, and the sky…
IngTian Aug 11, 2026
3f9e1f0
feat(work, writing): a pixel plush cow, and it has opinions
IngTian Aug 11, 2026
bcda666
feat(cow): a cow that looks like a cow, big on /writing and on the 404
IngTian Aug 11, 2026
6c5a693
feat(cow): outlined like the reference, moved to the footer's lower r…
IngTian Aug 11, 2026
521af37
fix(home): the IT seal keeps its place in the footer
IngTian Aug 11, 2026
af27bca
feat(cow): transcribed from the owner's grid — a front-facing chibi cow
IngTian Aug 11, 2026
40e4d12
feat(research): folded by default, and the fold actually animates
IngTian Aug 11, 2026
18da59f
fix(home): the open-problem slide follows the theme
IngTian Aug 11, 2026
2cb8662
feat(cow): a pixel speech bubble, and the seal stops drifting
IngTian Aug 11, 2026
2568a87
fix(research): the fold animates, including the first open
IngTian Aug 11, 2026
c4f3424
feat(home): the paper's two numbers come back to the homepage
IngTian Aug 11, 2026
04269f6
fix(home): no horizontal scrollbar on a phone
IngTian Aug 11, 2026
ee1ea73
docs(research): correct the fold diagnosis — `hidden` was not the cause
IngTian Aug 11, 2026
05b2f7b
fix(home): two slides survive an in-site navigation
IngTian Aug 11, 2026
75336a8
fix(a11y): the dark-theme CV button was 1.64:1
IngTian Aug 11, 2026
874e51e
fix(fonts): the three webfonts actually render now
IngTian Aug 11, 2026
5af7ce9
fix(mobile): four measured phone defects on the deck
IngTian Aug 11, 2026
aa71546
feat(home): the cow moves to the middle of the footer
IngTian Aug 11, 2026
9e1dd27
fix(rail): "University of Toronto" no longer truncates to "TORON…"
IngTian Aug 12, 2026
c045e84
fix(mobile): the fixed nav no longer lands on headlines
IngTian Aug 12, 2026
fd550f8
feat(home): Georgia carries the site again, and the cow leaves the fo…
IngTian Aug 12, 2026
ddc08b0
fix(cow): option E, and the bubble grows sideways instead of downward
IngTian Aug 12, 2026
8ba664c
feat(home): the work and the footer become one slide, the Appendix
IngTian Aug 12, 2026
684cba6
fix(mobile): five adaptability defects the pre-merge review found
IngTian Aug 12, 2026
f6588fc
perf(fonts): serve the two webfonts locally, and drop Inter for syste…
IngTian Aug 12, 2026
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
22 changes: 20 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,26 @@ 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, TWO downloaded faces.** `--font-display` is **Georgia** and
`--font-body` is the **system UI stack** — neither downloads anything.
`--font-mono` is **JetBrains Mono** and `--font-accent` is **Fraunces**, used
only on sub-headlines and italic editorial lines. No CJK fonts.
- Georgia and system-ui are deliberate, not leftovers. A long-standing bug meant
no webfont had EVER rendered, so the whole design was judged in Georgia + SF;
when Fraunces finally appeared the owner rejected it on sight ("prod's font is
better, the current font is kind of weird"). Both role faces are therefore what
the site has always actually looked like. Keep Georgia for structure.
- **The two webfonts are LOCAL files** in `src/assets/fonts/`, served through
`fontProviders.local()`. Do not go back to `fontProviders.google()`: fetching at
build time made every build depend on Google, and it failed a CI run with a 404
when they rotated a file hash mid-run — the same failure on a push to `main`
would silently leave the site stale. Variable fonts, one file per style, with
`weight` declared as a range. Provider config goes under `options.variants`, not
at the top level (the schema is strict).
- They live under their own `--ff-*` variables. Never point `cssVariable` at
`--font-display`/`-body`/`-mono`: those are the site's role tokens, and that
collision is what stopped every webfont from loading. Fraunces keeps its
preload — removing it measured a worse FCP (1956 → 2333ms; see BaseLayout).
- `astro:assets` optimizes gallery images (responsive `widths`, webp/avif).
- `site: 'https://ingtian.github.io'`, no `base` (user site at root).

Expand Down
67 changes: 58 additions & 9 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,77 @@
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
export default defineConfig({
site: 'https://ingtian.github.io',

// Weights/styles are pruned to exactly what the site uses (audited against the
// 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 <style> in the head, then our
// bundled sheet re-declared `--font-display: 'Fraunces', Georgia, serif` further down and won on
// source order — and the unhashed name "Fraunces" matches no @font-face rule anywhere. So the
// whole site rendered in Georgia / system-ui / Menlo while every woff2 downloaded and painted
// nothing. Measured before the fix, on the live site as well as locally: 0 of 24 FontFace entries
// loaded, and getPlatformFontsForNode reported Georgia on the h1, .SF NS on body copy and Menlo on
// the mono, all isCustomFont: false. With distinct names there is nothing to collide.
//
// `fallbacks` is per family, because the default lands every family on sans-serif — which for a
// display serif and a mono is the wrong shape to fall back to, and is what a reader sees during the
// swap and would see again if a woff2 ever failed to load.
//
// ── LOCAL FILES, NOT GOOGLE. The build used to fetch every face from fonts.gstatic.com, and that made every
// build — including the deploy — depend on a third party being consistent at that moment. It bit us: a CI run
// failed with CannotFetchFontFile / 404 because Google had rotated JetBrains Mono's file hashes mid-run (the
// request ended BYaTNPxDcwgknk-4, the live URL ended BYRbKPx3cwgknk-6nFg). A retry passed, but the same failure
// on a push to main would leave the site un-updated with nothing wrong in the repo.
//
// The files are the exact latin subsets Astro had already downloaded, committed under src/assets/fonts. They
// are VARIABLE fonts — one file per style covering the whole weight range, which is why 400/500/600 share a
// source rather than needing three. `weight` is declared as a range so Astro emits one @font-face per style
// instead of one per weight.
//
// Stored in src/, not public/: Astro copies these into the output itself, so public/ would ship them twice.
//
// INTER IS GONE, and the body face is the system UI stack again. Two reasons that agree: it is what the live
// site has always actually rendered (SF on a Mac) because no webfont ever loaded, and the owner's preference
// has consistently been for what he was already reading; and it was the most expensive face here — 100KB of
// the 222KB, for a sans-serif nearly indistinguishable from SF at body size. Vendored payload is now 111KB.
fonts: [
{ provider: fontProviders.google(), name: 'Fraunces', cssVariable: '--font-display', weights: [400, 500, 600], styles: ['normal', 'italic'] },
{ provider: fontProviders.google(), name: 'Inter', cssVariable: '--font-body', weights: [400, 500], styles: ['normal', 'italic'] },
{ provider: fontProviders.google(), name: 'JetBrains Mono', cssVariable: '--font-mono', weights: [400, 500], styles: ['normal'] },
{
provider: fontProviders.local(), name: 'Fraunces', cssVariable: '--ff-fraunces',
fallbacks: ['Georgia', 'Times New Roman', 'serif'],
// Provider-specific config goes under `options`; `variants` is not a top-level family key (the schema is
// strict and rejects it there — 'Unrecognized key: variants').
options: {
variants: [
{ weight: '400 600', style: 'normal', src: ['./src/assets/fonts/fraunces-latin-var.woff2'] },
{ weight: '400 600', style: 'italic', src: ['./src/assets/fonts/fraunces-latin-var-italic.woff2'] },
],
},
},
{
provider: fontProviders.local(), name: 'JetBrains Mono', cssVariable: '--ff-jetbrains',
fallbacks: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'],
options: {
variants: [
{ weight: '400 500', style: 'normal', src: ['./src/assets/fonts/jetbrains-mono-latin-var.woff2'] },
],
},
},
],

vite: {
plugins: [tailwindcss()]
},

integrations: [react(), sitemap({
// NO REACT. The terminal was the site's only island and it is deleted, so nothing renders a
// component: no .tsx files, no client:* directives, no react imports remain. Keeping the integration
// would ship the client runtime for nothing — the site is now entirely static HTML plus vanilla
// scripts. If an island is ever needed again, re-add @astrojs/react here (it stays in devDeps).
integrations: [sitemap({
// Prototype routes carry noindex, but an unfiltered sitemap still advertises
// them to crawlers — and their rendered bodies quote internal review notes.
filter: (page) => !page.includes('/proto-'),
Expand Down
116 changes: 116 additions & 0 deletions notes/sediment-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Sediment of Sentences — a recommendation, NOT a decision

Output of a design workflow (six independent takes, three judges: a curious stranger, an art
director, a graphics engineer; curiosity and buildability weighted double). The judges looked at
the rendered `/proto-sketches` frames and MEASURED them rather than reasoning about them, which is
why this is worth keeping.

**Nothing here is approved.** The owner has said explicitly: *"we don't need to rush, we have
plenty of time to think about this."* This file is input to that thinking.

## The two findings that matter most

**Ink coverage, measured.** The four rejected attempts sat at **1.2–2.6%** ink. The
forty-two-sentences frame measured **6.1%** — the only one that did not measure empty. That
finally makes "reads empty on a large screen" a number instead of an opinion, and it is the metric
any future candidate should be checked against (their proposed bar: ≥8%, no quadrant under 3%).

**The path sketch re-renders the hero's own field, and that is fatal.** It calls
`terrain.ts`'s `field()` — the same Gaussian mixture, the same two basins. Contours instead of dots
is a change of HATCHING, not of object, so it is the one concept already rejected outright, walked
back into. Their sharpest line, and it lands: *"'escaping costs a 1.26 climb' is self-flattery
wearing a measurement."* That is the self-branding problem in mathematical costume.

## The continuity argument worth stealing regardless of what gets built

> Same mark, different projection: the hero is a dot field in **plan**, looking down at a surface;
> this is a dot field in **section**, cut through what is under it. It reuses the visual grammar
> (dots, a value ramp, down as the axis) and reuses *none* of the mathematics.

And the enforcement, which is the good engineering idea here: **a test asserting the module never
imports `terrain.ts`**, so "not a second terrain" is a checked property rather than an intention.

## The open judgement, in their words, which only the owner can settle

> "The subject is still the photographs, which means the closing note of a quant-first page is the
> avocation, dressed in geology… The first two risks are answerable at the gate for the cost of one
> function. The third is a judgement only you can make, and it should be made before, not after,
> the component exists."

That is the real question on the table: **should the last thing on a quant-first page be the
art?** It may be exactly right (it is the human note after the mathematics) or exactly wrong (it
buries the quant identity at the moment of departure). Not a technical question.

---

# 1. THE TOP TWO

## ① SEDIMENT OF SENTENCES

**Pitch.** You scroll past the near-black ground and the descent doesn't stop — it cuts, and you're looking at a core sample whose grains are the 42 sentences he wrote about his own photographs.

**Curiosity mechanism.** The rock is legible as rock but the writing embedded in it is truncated, so the only way to finish a sentence is to touch its layer — and touching it pairs the sentence with the photograph it was written about.

**The still frame, concretely.** A vertical core occupying the middle third of the frame, 42 hard-edged bands stacked top to bottom, each band's value taken from the mean luminance of one photograph (real range 0.180 → 0.687, so the stack genuinely reads as stratigraphy, not stripes). Inside each band, hash-placed grains, count proportional to that note's character length — a long sentence is literally coarser rock. Two sentences are set at reading size in the left margin against the strata; the other 40 sit as thin truncated lines with hairline leaders to their own band. Six mono markers down the right side of the shaft, ochre, evenly spaced, each a destination. Nothing is drawn by hand; every mark is a consequence of a number on disk.

**Continuity without repetition, stated precisely.** Same mark, different projection: the hero is a dot field in **plan**, looking down at a surface; this is a dot field in **section**, cut through what's under it. It reuses the visual grammar (dots, a value ramp, down as the axis) and reuses *none* of the mathematics — no `field()`, no Gaussian mixture, no contours. I'd enforce that with a test asserting `sediment.ts` never imports `terrain.ts`, so "not a second terrain" is a checked property rather than an intention.

**Single biggest risk.** It becomes E·section again: a stack of labelled bands reads as a table with rock texture. The defence is that no band is labelled with a year, an employer, or a role — the only text in the object is his own prose — but the failure mode is one bad label away.

## ② FORTY-TWO SENTENCES

**Pitch.** The same 42 notes, no geometry: a typographic field where two sentences are large and forty are truncated invitations.

**Curiosity mechanism.** Identical — a sentence that ends in an ellipsis is an instruction to touch it — minus any object to touch it *on*.

**The still frame.** Two sentences at 34px against the dark ground, a hairline rule, then three columns of 12.5px truncated lines; 6.1% ink coverage, the only frame in the batch that didn't measure empty.

**Continuity.** Weakest part: it inherits the palette and the darkness and nothing else. It is *on* the descent rather than *of* it.

**Single biggest risk.** As the last thing on a quant-first page it makes the avocation the closing note, and it has nowhere structural for the six links to live, so they become a footer menu.

**Why this is the right #2:** it is a strict subset of #1. Delete the canvas, the strata and the markers from Sediment and this is exactly what remains. So a failure of #1 degrades to a shippable #2 instead of to nothing — which, after four write-offs, is the property worth paying for.

# 2. FIRST-SESSION BUILD SPEC (Sediment only)

**Files**
- `/Users/zetian/devpro/ing/IngTian.github.io/scripts/bake-strata.mjs` — one-shot Node script, `sharp` (already a dep, 0.34.5) reads `src/assets/art/photos/*`, emits mean relative luminance per file. Run by hand, output **committed**; sharp never runs at page build and no image is decoded at runtime.
- `/Users/zetian/devpro/ing/IngTian.github.io/src/data/photoStrata.ts` — the baked table: `{ file, lum }[]`, sorted.
- `/Users/zetian/devpro/ing/IngTian.github.io/src/lib/sediment.ts` — pure: `strata()`, `strataColor(v, ramp)`, `grainsFor(band)`, `markerDepths()`, `hash2(i, j)`.
- `/Users/zetian/devpro/ing/IngTian.github.io/src/lib/sketches/batch2.ts` — the SVG gate frame (this is the *first* thing built; see §3).
- `/Users/zetian/devpro/ing/IngTian.github.io/src/sections/Sediment.astro` — the section, vanilla `<script>`, **no** React island, **no** three.js.
- `/Users/zetian/devpro/ing/IngTian.github.io/tests/sediment.test.ts`.
- Two one-line stub routes `/writing`, `/reports` — a focusable element that goes nowhere fails both the brief and a11y.

**Geometry from real data, and the honest limits of it.** Depth ordering = photographs sorted by luminance. Band *thickness* must **not** be luminance-proportional: measured, the thickest band would take 18.6% of the core and `photo-19`/`photo-30` differ by 1.1e-5, so two sentences would occupy zero pixels. Use `t = 0.5·(1/42) + 0.5·proportional`, floored at 0.8%. Grain count per band `= 6 × note.length` (≈20,600 total, ≈1 canvas frame at DPR≤2); positions from `hash2`, never `Math.random()`.

**Marker depths are a layout decision, and I'm not going to dress it as a measurement.** The six sharpest natural contacts sit at depth fractions 0.06 / 0.25 / 0.31 / 0.37 / 0.44 / 0.96 — five bunched in the top half and one stranded at the bottom. Derived marker positions would look broken. So the six sit at `(i + 0.5)/6`, each anchored to whichever band it lands in.

**Six links in register.** A `<nav aria-label="Sections">` of six `<a>`, absolutely positioned by `top: calc(var(--core-top) + <depth> * var(--core-h))` — the same scalar the canvas uses, so registration is arithmetic, not eyeballed. The 42 sentences are an `<ol>` of `<a href="/art#p-NN">`; tab order equals descent order.

**Themes.** One function, two ramps: `STRATA_LIGHT` (ink greys warmed toward ochre) and `STRATA_DARK` (cooler, emerald-tinged). Strata render as a **42-hard-stop CSS gradient** built from the baked table at build time, one gradient per theme — which means the strata exist with JS off and with the canvas dead. Verify the darkest band against `--bg` in *both* themes; the light theme's `--bg` is #16140f and that's where this kind of thing dies.

**Reduced motion / no-JS.** No-JS = CSS strata + all 48 links + `:focus-within` reveal, i.e. finished. Reduced motion = canvas paints its one static frame at a committed depth constant chosen by looking, not 0. Motion (parallax of the grain layer, marker fade) is `transform`/`opacity` on a single `--sed-depth` var, behind `prefersReducedMotion()`.

**Lazy loading.** 42 reveal thumbnails via `astro:assets` at ~160px, `loading="lazy"`, `decoding="async"`; the canvas initialises on IntersectionObserver and re-inits on `astro:page-load` with teardown, per `TerrainHero`.

**Tests assert.** 42 bands; thicknesses sum to 1 ± 1e-9; every thickness ≥ floor; band order matches luminance order; `hash2` deterministic and in [0,1); grain counts match note lengths; `markerDepths()` strictly increasing with min separation ≥ 0.1; `strataColor` monotone in value and inside the token gamut for both ramps; and `sediment.ts` contains no import of `terrain`.

# 3. THE KILL GATE

One `draw()` added to `batch2.ts` and looked at on `/proto-sketches` at 1600×900, **in both themes**, before any component exists. It costs one function; that harness is the only gate in this project that has ever saved time.

What to look for, in order: (1) squint at it as a thumbnail — if it reads as the hero, stop; (2) does it read as **one object**, or as a list with texture (the E·section death); (3) ink coverage ≥ 8% with no quadrant under 3% — the four prior deaths all measured 1.2–2.6%; (4) is the darkest band still distinguishable from the page ground in *light* theme; (5) do the two large sentences survive the strata behind them, and do the six markers clear the 40 small lines.

# 4. WHAT TO DROP

- **Underside** — cheapest to build and closest to the one thing already rejected outright; "it's the hero inverted" is a distinction you have to explain.
- **The Core (unlabelled)** — structurally right, but unlabelled rock gives a stranger nothing specific to be curious about; it is #1 with the subject removed.
- **Constellation** — generic; every third portfolio has one and dark mode already owns stars.
- **Flow-field tracers** — a gradient field under a gradient field, with nothing to click. Screensaver.
- **Long exposure** — hue extraction imports saturated colour and breaks the palette rule; 42 hand-tuned textures is exactly the work this implementer loses at.
- **Stepwell / Simplex Cage / Seriation** — the first dies on materials (a terrace already read as a bathtub), the second is the most dated WebGL object of the last four years and retells the hero's story, the third is a category error already diagnosed.

# 5. WHAT COULD STILL GO WRONG

The honest pattern across four failures is not bad ideas, it's that I pass my own gates and then fail your eye — and the specific mechanism is that I can verify *correctness* (the numbers are real, the bands are ordered, the tests are green) but I cannot verify *taste* until you look. Sediment reduces that exposure — it has no drawn props, its density is guaranteed by 20,600 marks rather than hoped for, and its resting state is a CSS gradient that cannot fail to render — but it does not eliminate it. Three things could still kill it: the strata may read as a chart no matter what the labels say, because a stack of ranked horizontal bands *is* a chart; brightness ordering is an honest axis but a semantically arbitrary one, and if you find the mapping meaningless the whole object becomes decoration with a story attached; and the subject is still the photographs, which means the closing note of a quant-first page is the avocation, dressed in geology. The first two are answerable at the gate for the cost of one function. The third is a judgement only you can make, and it should be made before, not after, the component exists.
Loading