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 })),
+ ]),
+);
+---
+ one asset · six signals · drag the fan to rotate Factor exposures — where to find my work
+
+
+
+
+ {payload.beams.map((b) => (
+
+
+
+ {spoken[0]?.[0]} + {spoken[0]?.[1]} +
+ {size === 'large' && ( + + + )} + + +