The single source of truth is app/globals.css. This is
the human-readable mirror. The Figma “Portfolio” library mirrors these too —
keep all three in sync (see .claude/skills/sync-tokens-to-figma).
The site has four time-of-day moods, selected by [data-theme] on <html>:
morning, noon, evening, night. There is no separate light/dark — the
moods are the theme. Every colour utility resolves through CSS variables that
[data-theme] re-points, so one attribute flip re-themes everything.
“Auto” (the system preference) resolves the mood from the visitor’s local
sunrise/sunset (geolocation) and falls back to clock buckets
(6–11 morning · 11–16 noon · 16–20 evening · else night).
Token (--color-*) |
morning | noon | evening | night |
|---|---|---|---|---|
fg (text) |
#2F2F2F |
#2F2F2F |
#F7F5F4 |
#F7F5F4 |
base (page bg) |
#F2EFEA |
#EDECEB |
#373330 |
#292623 |
btn / btn-fg |
dark rgba(47,47,47,.85) / #F7F5F4 |
same | light rgba(247,245,244,.85) / #2F2F2F |
same |
glass |
rgba(255,255,255,.75) |
same | rgba(41,38,35,.75) |
same |
glass-hover |
rgba(255,255,255,.9) |
same | rgba(41,38,35,.9) |
same |
tile-media |
#F7F6F7 |
same | #5B5855 |
same |
hairline |
rgba(47,47,47,.12) |
same | rgba(247,245,244,.14) |
same |
Glass surfaces sit at 75% opacity; clickable glass goes to 90% on hover
(bg-glass-hover) — mirrors the Figma tile behaviour.
Stale: the table above still uses the four time-of-day moods.
globals.csshas been plain light/dark for a while — readlightfrom thenooncolumn anddarkfrom thenightcolumn. Needs a proper rewrite.
surface-hover (bg-surface-hover) — hover fill for transparent icon
chrome, i.e. the header buttons. Figma component 81:277 state=hover shows a
flat #f7f6f7, an unbound raw hex in the file; bind it when syncing back.
| light | dark | |
|---|---|---|
--surface-hover |
rgba(255,255,255,.5) |
rgba(247,245,244,.09) |
over flat --bg-base |
#f6f6f5 — within 2/255 of the comp |
— (no comp) |
| over the real gradient behind the header | ~#ebeaee, +20/255 |
~#3b3835, +19/255 |
Deliberately translucent rather than the literal hex: the header floats over the
WebGL model, and an opaque chip would punch a rectangle out of it on hover.
Light is set for fidelity to the comp; dark has no comp, so it is matched on
delta instead — both lift ~+19/255 off what is actually behind them, which
is the gradient backdrop, not flat --bg-base. --glass-hover cannot stand in:
at 0.42 over a dark ground it is the background, i.e. invisible.
tile-media (bg-tile-media) — the empty-tile fill in the bottom rail
(Figma node 79:253), and later the well behind each tile’s image or video.
The comp measures a flat #f7f6f7 — the same raw hex as --surface-hover
above, but here it is used literally. This is the one card surface on the
site, and it is opaque.
| light | dark | |
|---|---|---|
--tile-media |
#F7F6F7 — the comp’s hex, exactly |
#5B5855 |
| the ground actually behind the rail | ~#d6d5db |
~#3d3a37 |
| lift off that ground | +33/255 | +30/255 |
Light is the comp value with nothing composited on top. Dark has no comp, so it
is matched on delta: what is behind the tiles is the bottom of the
gradient backdrop, not flat --bg-base, and #5B5855 steps off it by the same
amount #F7F6F7 steps off the light ground. #5B5855 also holds the palette’s
warm ramp — R/G/B stepping +3/+3, the same as --bg-base (#292623) and the
gradient’s bottom stop.
Opacity was traded for card presence, deliberately: the rail floats over the
WebGL model, so the three tiles now occlude it. Note this is exactly the
argument --surface-hover still runs the other way — the header chip stays
translucent so hover doesn’t punch a rectangle out of the model. The two tokens
used to share one rationale and no longer do; that divergence is intended, not
drift.
This token was previously
rgba(235,235,235,.25)/rgba(61,58,55,.25)and unused by any component — it resolved to roughly#ecebeb, a wash faint enough that the tiles were nearly invisible against the backdrop. It then ran asrgba(255,255,255,.5)/rgba(247,245,244,.09), which approximated the comp to within 2/255 in light (#f6f6f5) but still read as a wash rather than a surface.
panel (bg-panel) — the case-study reading surface (Figma node 97:313,
a wireframe: the values below are the system’s, not measured). The .article-panel
chrome in globals.css §4c reads it directly; bg-panel exists for anything else
that needs the surface.
| light | dark | |
|---|---|---|
--panel |
#F7F6F7 — the same hex as --tile-media |
#33302D |
Light reuses the site’s one card hex on purpose — the panel is a card
surface at page scale — but stays its own token because the two jobs can
legitimately diverge (a media well vs. a page of prose). Dark does not
reuse --tile-media’s #5B5855: that value is matched on delta for a small
well and a full reading surface that light would glow. Instead it is
--bg-base (#292623) lifted +10/255 on the palette’s warm R−G=3 / G−B=3
ramp — one card step off the page.
There is deliberately no --panel-fg: --text reads on this surface in
both themes (#2f2f2f on near-white, #f7f5f4 on the warm dark). Muted ink
inside the panel is color-mix(in srgb, var(--text) 60%, transparent), so it
tracks the theme by construction.
The panel is opaque and must stay free of backdrop-filter descendants: it
carries .cloud-hide, and an ancestor below opacity 1 forms a backdrop root
(see the glass gotchas below).
tile-scrim (tile-scrim utility, bg-tile-scrim) — the wash that frosts a
tile’s media on hover and focus, Figma node 88:307 state2. Paired with
--tile-scrim-glow, the halo behind the label (text-shadow-tile-label).
| light | dark | |
|---|---|---|
--tile-scrim |
rgba(255,255,255,.65) — the comp’s value, exactly |
rgba(41,38,35,.65) |
--tile-scrim-glow |
#FFF |
rgba(0,0,0,.55) |
The alpha carries over from light to dark unchanged, which is the one place this
token departs from how --tile-media and --surface-hover are derived. Those
two are matched on delta because they sit over a gradient whose value
differs per theme; the scrim sits over photography, which has no theme, and
0.65 is what makes an image legible-but-recessive either way. Only the base hue
follows the palette (the same warm dark --glass uses).
The glow inverts rather than carrying over: it exists because 0.65 white over an image is not a uniform ground, and a glyph landing on a light patch of photo needs its own separation. Light text on the dark scrim needs the same help from the opposite direction, so the sign flips with the fill.
Unlike --surface-hover, the scrim can afford to be this heavy — what it
obscures is the tile’s own image, and obscuring it is the point.
Gradient backdrops (radial over linear, non-interpolable → crossfaded by two layers):
| mood | radial | linear |
|---|---|---|
| morning | #ffffff → #F4E9D8 |
#F0D8B8 → #D7BFA6 → #9A8C7A |
| noon | #ffffff → #D6D5DB |
#E8E8EA → #D9D7D5 → #B8B5B2 |
| evening | #FFD8C2 → #B28B7C |
#4A3F38 → #3C3530 → #2F2A26 |
| night | #4A4A52 → #2E2C2A |
#222324 → #292623 → #3D3A37 |
Families — Outfit (Google, variable 100–900) for everything: font-heading,
font-body, font-sans all point at it. Roboto Mono (font-mono) for code and
eyebrow labels.
One scale, named semantically. Every size is a clamp() interpolating between
the viewport bounds 390px → 1440px — the grid-page mobile boundary and
--container-max, so type stops growing exactly where the container does.
Outside that range it pins to the endpoints. There are no type breakpoints.
| Utility | 390px | 1440px | line-height | Used by |
|---|---|---|---|---|
text-caption |
11 | 12 | 1.4 | eyebrows (404 404 label) |
text-label |
13 | 14 | 1.2 | h6, Button, .skip-link |
text-body |
14 | 16 | 1.55 | p, li, h5, Button size="lg" |
text-lead |
16 | 18 | 1.5 | h4 |
text-heading |
18 | 20 | 1.3 | h3 |
text-title |
20 | 24 | 1.25 | h2 |
text-display |
24 | 32 | 1.25 | h1, the hero tagline |
text-display-lg |
32 | 48 | 1.1 | 404 headline |
The endpoints are the values the old stepped scale already used, so 390px and
1440px render identically to the previous mobile/desktop steps — only the middle
is new. text-display’s 1.25 is the comp’s 30/24 exactly, and being unitless it
holds at every step of the clamp.
Three rules behind the implementation:
--text-*: initial clears Tailwind’s built-in ramp. text-xs … text-9xl
no longer exist — every size must come from the table above. The reset is a
startsWith("--text") prefix match against an allowlist, so text-shadow-*,
text-indent, text-decoration-* and text-underline-offset survive, and
--color-* is untouched (text-fg is a colour utility and still works). It
must be the first declaration in its block — clearing runs in declaration
order. The palette’s own --text is safe only because it sits in a plain
:root rule; never move it into a @theme block, the prefix would eat it.@theme, not the @theme inline block.
inline freezes the value into the utility, so no var() is emitted and the
:root declaration becomes contingent on a reference surviving elsewhere.
@layer base reads these with var(), so they need a guaranteed :root.rem + vw, never bare vw. A bare-vw size is
completely inert to the reader’s browser font-size setting — a hard WCAG
1.4.4 failure. With the rem term the whole scale tracks it: at root 20px
the hero reads 30 → 37px instead of 24 → 32px. Be accurate about the limit,
though — a 200% text-only resize grows a token by ~2x at narrow viewports
(where the clamp sits on its rem min) but only ~1.66x at 1440px, because
the vw component does not scale. Every token still lands at or above twice
its own minimum. That gap is inherent to fluid type; the alternative is
all-rem bounds with no fluidity at all.@layer base maps h1–h6, p and li onto the scale (h4–h6 previously
had no size at all and fell through to the UA defaults). It restates both
font-size and line-height per element: Tailwind’s paired
--text-x--line-height modifier is applied by the text-x utility only, so a
bare var(--text-x) in plain CSS pulls the size alone.
Weight is deliberately not paired into the tokens. The same size legitimately
appears at two weights — base h1 is 700, the hero tagline is 400 — so a
--text-display--font-weight would contradict one of them. Weight stays
orthogonal, via font-normal / font-bold.
Tracking — tracking-eyebrow (0.3em) for mono uppercase labels. Tailwind’s
widest default is 0.1em, nowhere near enough.
Measures scale with the type. The hero uses max-w-[15.833em] — at 24px
that is 380px exactly (the comp), and being em it grows with the clamp, so the
tagline holds its line count instead of reflowing from three lines to four.
grid-page utility: 4 cols (≤390px) / 8 (391–768) / 12 (≥769);
padding 16 / 24 / 96; gap 16 / 24 / 32; max-width: 1440px, centered.--page-inset / page-inset utility: 16 / 24 / 32px on
the same breakpoints. No longer the header’s placement — it is the floor
under the content column below, the distance from the top the header still
uses (pt-[var(--page-inset)], per Figma node 81:303), and the corner the
article overlay pins to. Deliberately not grid-page: that centres a
1440px container and pads 96px inside it, landing content 240px from the edge
on a 1728 viewport — but the two narrow steps match, where an 8px
disagreement would be obvious.Content column — --content-max (1200px) / --content-inset /
page-measure utility, on <header> and <main>. Figma node 79:253 puts
four things on one centred measure, x=264 → 1464 on the 1728 frame: the logo
(81:252), the tagline (81:306), the button cluster’s right edge (81:287)
and the tile row (87:241). So --tile-rail-w is --content-max — one
token, two consumers, and moving the column moves both by construction.
padding-inline: max(--page-inset, (100% − --content-max) / 2). The max()
does the responsive work with no media query: the column can never sit tighter
than the chrome inset, so it falls back to 16 / 24 / 32 on its own once the
viewport narrows past 1264 (= 1200 + 2 × 32). Below that the rail keeps
its own 8px gutter and the two edges drift by up to 24px — chrome floors
first, deliberately. The 100% is safe inside a custom property: properties
substitute as tokens and resolve percentages at the using declaration.
With a study open, :root[data-article] re-points --content-inset at
--page-inset and the header slides back to the corner over 600ms — the
overlay is full-bleed (node 97:274), and chrome on a 1200 column above it
would read as two grids. prefers-reduced-motion drops the transition.
Tile cluster — --tile-rail-gutter / --tile-rail-w / --tile-rail-fit /
--tile-rail-h / --tile-rail-lead / --tile-rail-ceiling /
--tile-rail-sink / --tile-rail-drop and the tile-rail utility: the three
tiles under the WebGL model, Figma node 87:241. A 1200px centred row —
the content column above, not full-bleed and not grid-page — which is what
puts the row’s two edges under the logo glyph and the button cluster.
Width is min(--content-max, --tile-rail-fit, 100% − 2 × gutter) — three
caps, smallest wins — and not max-width plus padding, which would eat
into the 1200 and leave the tiles 389.33 instead of
(1200 − 16) / 3 = 394.67 (the comp’s 394.6666). So the box is 1200 when
there is room and 8px off each edge when there is not; the gutter is a minimum
margin, in play only below ~1216px.
Height falls out of width: each tile is aspect-ratio: 1. That is what makes
the composition safe on the vertical axis for free — the cluster caps at 1200,
so the tiles cap at 394.67 and cannot grow to swallow a tall screen. No dvh
height token.
--tile-rail-fit is the same trick against a short screen, and it is
what lets the centring below be exact rather than best-effort: the widest row
whose square still fits centred with its top edge clear of
--tile-rail-ceiling. Invert (100dvh − tile) / 2 ≥ ceiling for the tile,
then re-inflate to a row of three plus two gutters —
3 × (100dvh − 2 × ceiling) + 2 × gutter. A no-op while stacked and a no-op
above ~843px tall (448 + 394.67), where the 1200 cap binds first and the
tiles are full size; under that the row narrows instead of the placement
drifting (a 720px-tall window gets a 832 row and 272 tiles, dead centre, where
a full-size tile would have sat low). The max(240px, …) floor stops the row
collapsing on an absurdly short window — below ~523px tall it is the centring
that yields.
The header does not follow this cap: it stays on the full column, so the chrome never slides sideways when the window is resized vertically. Below ~843px tall the row is therefore narrower than the row of chrome above it.
Vertical placement is the page’s job: app/page.tsx wraps header + main +
cluster in a min-h-dvh flex column, the cluster sits at mt-auto, and the
drop lifts it back off the bottom edge. From 769px wide — no height gate —
the drop is (100dvh − --tile-rail-h) / 2, half the leftover, which puts the
row’s midline on the viewport’s: the comp’s rule (361 + 394.667 / 2 = 558.3
in an 1117 frame whose centre is 558.5), holding at every height it
geometrically can.
--tile-rail-h restates the tile edge in vw purely for that halving — a
custom property cannot see the container the way the utility’s 100% can. The
two agree exactly from 1216px up, where the cap binds, and differ by at most a
scrollbar width below it, under 3px once halved.
--tile-rail-lead (16px) is spent as padding on the section, not a
margin — a top margin would compete with the mt-auto push. Padding sits
inside the box, and because the row’s top edge is measured up from the
column’s bottom, padding above it does not move it: the lead is inert while
the rail is centred and only appears once the auto margin has collapsed to 0,
i.e. stacked or on a window too short to centre in.
--tile-rail-sink rides the row below that midline on windows 800px
tall or less — 5dvh, i.e. 40px at 800 and 36px at 720 — where the fit cap
has already traded away some tile size and the composition wants the extra air
under the tagline. Zero above 800, so the full-size row stays dead centre.
Sinking is unconditionally safe: it moves the row away from the tagline and
shortens the column, so it can neither collide nor raise a scrollbar.
The drop wraps that halving in a max(gutter, min(…)). With --tile-rail-fit
already guaranteeing the row fits centred, these are pure backstops for the
range below its 240px floor, not modes — everywhere above it the centring
term wins and the midlines coincide exactly:
min() against --tile-rail-ceiling (208px + lead). Chrome + tagline
occupy the top ~224px at the top of the type clamp (32 inset + 48 row + 8 +
a three-line 120px <h1>), and they overlap the row horizontally — they
share its left edge now, and a centred 1200 row starts at x=120 on a
1440 — so the row must not ride up into them. Once the fit cap has hit its
floor and can no longer shrink the row out of the way, this pins the row
under the tagline instead: still as high as it can go, and with the column
still fitting, where centring anyway would have grown it past the fold and
scrolled over the empty space beneath the rail.max() against the gutter. Shorter still, even the pinned position
overflows and the second term goes negative; this stops the margin
inverting and dragging the row past the column’s bottom edge.It has to be arithmetic rather than a shrinkable flex spacer — the column is
min-h-dvh, so it grows to fit its content and never develops the negative
free space that would make a flex item yield.
Stacked, the drop is the plain 8px gutter: centring is meaningless when the three squares alone are ~1093px on a 390 and the page scrolls regardless.
The drop is consumed on the section, the flex item, not on the tile-rail
box inside it: margin-top: auto on a block box computes to 0 and pushes
nothing.
min-h, never h: when the tiles cannot fit, the column grows and the page
scrolls rather than crushing them. The height must stay on that wrapper, not
on <main> — <main> sits below the 80px header, so the two would sum to
100dvh + 80 and produce a scrollbar over nothing.
Below 769px — the site’s own desktop step, not Tailwind’s 640 — the three stack to one column at the same tile ratio and the page scrolls. The other step, 391, would leave three tiles 119px wide.
--radius-xs 4 · sm 6 · md 8 · lg 12 · xl 16px
(rounded-xs/sm/md/lg/xl). Cards, the 48px header buttons and the 64px icon
tiles all use rounded-md.Everything on the site is a hand-rolled CSS transition except one entrance, because a transition has no “before” state to leave from on mount.
.reveal — opacity + an 8px lift, 600ms cubic-bezier(.22,1,.36,1).
600ms matches .bg-layer’s crossfade and the hero canvas fade, so the page
settles on one clock. Stagger with [--reveal-delay:80ms], travel further with
[--reveal-from:12px]. Header uses 0 / 80 / 140ms; the <h1> 220ms.
Three rules, all load-bearing:
animation-fill-mode: backwards, never both. both retains the final
keyframe indefinitely, and animation declarations outrank inline styles —
so a retained transform: none would permanently override any transform set
later, from JS or otherwise. backwards covers the delay window and then
lets go.opacity: 0 statically. Reduced motion, no animation
support, or a typo’d delay then degrade to already visible rather than to
a blank header.--reveal-delay / --reveal-from in .reveal. That
rule is unlayered and would beat the layered Tailwind utility meant to set
them. Read them through var() fallbacks only.Reduced motion is handled in the plain-CSS @media (prefers-reduced-motion)
block, not via motion-reduce: utilities — a utility ties with .reveal on
specificity and would win or lose on source order.
The curve is ease-out-quint (--ease-out-quint), the utility form of the
cubic-bezier(.22,1,.36,1) written out above. Rules inside globals.css still
spell it literally — they are animation / transition shorthands, where a
var() is more fragile than it is worth. Use the utility in components.
The 600ms clock covers entrances and mode changes, not hover. Interaction feedback runs at 300ms — the tile scrim and label, matching Button. Hover held for 600ms reads as lag rather than as one clock.
A hover state that only crossfades opacity gets no prefers-reduced-motion
branch. That block exists to remove motion; a fade with no travel is feedback,
and taking it away leaves the state change unannounced.
Article mode moves on the View Transitions API (globals.css §4c) — the one
place a layout change is animated, because no transition can tween between two
grid templates. Same clock and curve: every ::view-transition-* group runs
600ms ease-out-quint. The rules that make it behave:
:root { view-transition-name: none } — the root is deliberately
uncaptured, so everything unnamed (the WebGL hero, the tagline’s
.article-hide fade) keeps rendering live instead of freezing into a
snapshot for 600ms.::view-transition { pointer-events: none } — the pseudo-tree overlays the
viewport in the top layer and would otherwise swallow every pointer event
for the duration.view-transition-names
(article-tile-<id>, article-panel). Duplicate names silently abort the
whole transition — never reuse them.ArticleProvider just flips the state (instant, still
correct); reduced motion is honoured in JS by skipping the API call and in
the reduced-motion block (animations set to none, so an OS-level flip
mid-session also resolves instantly)..article-hide is .cloud-hide keyed to [data-article] — same 600ms
opacity + discretely-interpolated visibility pair, same inert-in-the-same-tick
handling in the provider. The selected tile’s 50% dim is interaction feedback,
so it runs on the 300ms clock with no reduced-motion branch.
The glass utility is a port of the Figma GLASS effect on node 68:528
(“intro”). Figma exposes only the frost radius over MCP, so the rest was
recovered by decoding the 1:1 render and sampling it — the node sits on a flat
canvas, which isolates the light and bevel component exactly.
| Figma param | Value | Measured on the render | CSS |
|---|---|---|---|
| Frost | 43 | — (flat backdrop) | blur(var(--glass-blur, 43px)) |
| Light | −45° @ 80% | 1px rim, white-over-fill α 0.69 → 0.88 top-left → bottom-right (mean 0.78) | two 1px inset shadows, --glass-rim-dim top-left + --glass-rim-lit bottom-right |
| Depth | 20 | inner band ~15px deep | --glass-depth: 11px (offset == −spread ⇒ the band runs to ~15px) |
| Splay | 100 | fully soft falloff, no step | blur == 2 × depth |
| Refraction | 80 | — | no CSS equivalent. The bevel stands in for it |
| Dispersion | 50 | ±1 LSB, inside 8-bit noise | dropped |
| — | — | top+left −4/255, bottom+right +4/255 | the two inset bevel shadows |
| Fill | rgba(255,255,255,.4) |
interior dead flat, no tint | --glass → --color-glass |
| Drop shadow | none | none | none — do not add one |
Tokens: --glass / --glass-hover (fill) · --glass-rim-dim /
--glass-rim-lit (rim) · --glass-bevel-shadow / --glass-bevel-light
(bevel) · --glass-blur (frost radius). Only the fills are aliased into
@theme; the rest are read inside the utility, never applied as bg-*.
The surface no longer matches node 68:528 in three respects. These are
decided, not drift — do not “correct” them back toward Figma:
0.26 / 0.36 white; dark flips to rgba(0,0,0,.40) / rgba(0,0,0,.24). The
alphas look inverted against the token names on purpose — dim is the
top-left, away from the −45° light, so on a dark edge it is the stronger
one. The bevel is untouched and still carries the −45° read.Dark-theme rim and bevel values are tuned by eye — there is no dark Figma node to measure.
glass-sm composes, it does not replace: use glass glass-sm. It only
re-points --glass-depth to 5px, for surfaces small enough that the 11px
bevel would swallow the content (the 64px icon tiles, tooltips, code).--glass-blur on a glass element. It is registered via
@property (inheriting, initial 43px) so the tile cluster can ramp every
descendant at once; declaring it locally shadows that.backdrop-filter lines matters. Lightning CSS
collapses the prefixed pair to whichever comes last, and Firefox only knows
the unprefixed one — so -webkit- must stay first.opacity < 1 forms a backdrop root. Any ancestor mid-fade leaves its
glass descendants with no backdrop to sample: the frost renders at zero and
then snaps to full the instant opacity reaches 1. Ramping --glass-blur
alone does not help — while the backdrop root exists the declared radius is
ignored; the two have to be sequenced. Read this before animating opacity
over glass. The escape hatch is the next section.tile-scrim — frost inside a cardA second, smaller frosted surface: blur(--tile-scrim-blur) over
--tile-scrim, with no rim and no bevel. glass is a slab floating over the
page; this is a wash inside a card, and an inner bevel on something already
hard-clipped to 8px reads as a second border. Used only by the tile rail’s
hover state.
--tile-scrim-blur is 15px and deliberately not --glass-blur. That token
is registered and inheriting so a cluster can be ramped from an ancestor;
declaring it here would shadow the ramp for everything inside a tile — the
gotcha above, from the other side. 15px is sized to the media it frosts and has
no relationship to glass’s 43.
Always pair it with tile-media-fill on the media underneath. A blur has to
gather pixels from beyond each output pixel, and where the backdrop simply ends
backdrop-filter resolves back toward the unfiltered source. With the media
flush to the tile the result is a sharp, readable band right around the
perimeter, roughly 2× the blur radius wide, framing a properly frosted middle.
tile-media-fill bleeds the media out by that same 2× so there are real pixels
under the scrim’s edges, and the tile’s overflow-hidden clips the excess. The
cost is a permanent crop — the media covers a box ~18% larger than the tile — so
frame accordingly, and if you raise --tile-scrim-blur the bleed follows it
automatically.
This is also the site’s one backdrop-filter under an opacity fade. Both
.reveal and .cloud-hide fade ancestors of the scrim, which is exactly the
gotcha above. It survives, for a specific reason: the scrim samples its own
sibling <img> — content inside the fading subtree, which is still there at
any opacity. The trap catches surfaces sampling the page outside the fading
element, and that content really is gone.
So it does not generalise: glass proper still fails under a fade, and the
gotcha above applies verbatim everywhere else on the site.
Measured, not reasoned. Hovering a tile with the rail’s
<section>pinned at opacity0.99and at0.6renders the frost in full, and the render is byte-identical withisolation: isolateremoved from the tile — so the isolate on the<a>is not what saves it and should not be credited with the job. It is there for stacking, nothing more. Chromium only; re-check if Safari ever matters.
text-fg · bg-base · bg-btn / text-btn-fg / bg-btn-hover ·
bg-glass / bg-glass-hover · bg-surface-hover · bg-tile-media ·
bg-panel · border-hairline · font-heading / font-body / font-mono ·
text-caption / text-label / text-body / text-lead / text-heading /
text-title / text-display / text-display-lg · tracking-eyebrow ·
text-shadow-tile-label · ease-out-quint ·
glass / glass-sm · tile-scrim / tile-media-fill ·
grid-page / page-inset / page-measure / tile-rail · reveal.
Opacity modifiers work as usual (text-fg/70).
Plain classes from §4c–d (not Tailwind utilities): article-rail /
article-hide / article-panel / article-close-row / article-item ·
article-prose and its slots article-lead / article-meta /
article-quote / article-grid-2 / article-stats.
Tailwind’s default font-size ramp (
text-xs…text-9xl) has been removed — see Typography above.text-sm,text-2xletc. are no longer valid and will silently emit nothing.