UI Consistency & Optimization Audit — 2026-08-01¶
A single pass over the site tier answering four questions the owner asked together: is everything actually tagged against the design contract; are the pointer effects worth what they cost; can popovers be read; and why do fonts look like they belong to different products.
Companions:
design-language.md(the system),page-consistency.md(the per-page checklist),performance-audit-2026-08-01.md(the measured pass this one follows — its findings still hold and are not revisited).Finding IDs are stable:
UIC-xconsistency,UIP-xperformance,UIL-xlegibility,UIF-xfonts.
Scope¶
Site tier only — components/** and app/routes/** minus the full-screen
games and the --app-* apps. That exemption is the design’s, not a shortcut:
games with a bespoke visual identity keep their own scoped variable groups
(design-language.md §12), and forcing Temple of Joy onto glass pills would make
it less coherent, not more. 546 files scanned of 1,154.
rmhladder, rmhcalculator and homes are in scope — they are
usesSiteTheme apps.
The shape of what was wrong¶
Nearly every violation found here has the same underlying story, and it is worth stating once because it changes what “fixing” means:
The contract had holes, and the call sites routed around them.
There was no --site-success-fg, so 1 caller wrote text-white and it was
invisible in half the themes. There was no duration token, so 55 call sites
wrote duration-200. There was no way to say “this chip is on a photograph”, so
~120 call sites wrote bg-black/60 text-white. font-mono was wired to a
webfont instead of the theme’s mono token, so 74 call sites silently ignored the
theme. The casino tables had no vocabulary for “a playing card is white”, so
~210 raw palette classes.
Almost none of that was carelessness. It was people reaching for something that did not exist. So the fixes below are mostly system extensions first, call-site migrations second — and three new CI rules so the holes stay closed.
1. Consistency¶
UIC-1 [P1 · fixed] The casino tables were raw Tailwind palette, top to bottom¶
components/rmhcoins/* renders inside PageLayout at /predictions — full site
tier, fully themed — and carried ~210 raw palette classes: bg-red-600
pockets, bg-white / text-gray-900 card faces, bg-gray-800 felt,
bg-blue-500 seats, text-yellow-500 coins. In every theme it was the same
pixels, and high-contrast had no answer for any of it.
The colours are not all the same kind of thing, and that is the fix:
A playing card is white with black pips and red hearts. A roulette wheel has red, black and green pockets. Those are not the theme’s to choose — recolouring them does not make the page coherent, it makes the game wrong. They are now a scoped group,
--casino-*, in the same spirit as--temple-*and--slice-*, with a.style-high-contrastoverride for the pieces whose job is to be read.Everything around the objects — buttons, balances, win/lose messaging, panel chrome — is the site’s, and now takes
--site-*.
Also fixed there: 25 rounded-md/lg/xl, 4 shadow-lg/2xl, and a
bg-site-success text-black result banner that was near-black on dark green in
the default theme.
UIC-2 [P1 · fixed] --site-success-fg did not exist¶
bg-site-accent and bg-site-danger each have a paired foreground and a
zero-specificity :where() rule that supplies it. bg-site-success had
neither — and --site-success swings from a dark forest green (default,
#167d3a) to a bright mint (nocturne, #62f6b8) across the theme set, so
there is no single ink that works. Callers wrote text-white.
All 12 theme blocks now author the pair, each chosen for ≥4.5:1 against its own
green (computed, not eyeballed — the lowest is 4.85:1). :where(.bg-site-success)
and :where(.bg-site-warning) joined the safety net, so a background-only
element is correct without saying anything.
UIC-3 [P2 · fixed] There was no way to say “this is on a photograph”¶
~120 call sites wrote bg-black/60 text-white — lightbox controls, image-grid
chips, listing badges, upload tiles. The ink is right: a chip over a user’s
photo has no idea whether that photo is a snowfield or a night sky, so it must
carry its own dark backing in every theme. But written as literals it is a
design decision the system cannot see, tune, or degrade — high-contrast in
particular had no reach into it, and a 60%-alpha scrim is not a high-contrast
surface.
Now a contract: --site-media-scrim / -strong / -hover / --site-media-veil
/ --site-media-ink, with a high-contrast override that makes the scrims solid.
88 call sites migrated. One rule with it: a scrim is a background-colour, never
a backdrop-filter — these land on repeated list items, where the §5.1 blur
budget is zero. (One card grid was violating exactly that; fixed.)
UIC-4 [P2 · fixed] Status and podium colours¶
text-amber-400 on star ratings, text-emerald-400 on money, text-red-400 on
errors, text-sky-400 on a route line, text-yellow-500 on 10 coin icons — 36
call sites across rideshare, creator-studio, tournaments and the wager card, all
of which already had a correct token (text-site-warning, -success,
-danger, -accent). Migrated.
The leaderboard’s top three were text-yellow-400 / text-slate-300 /
text-amber-600. A podium is a fixed cultural signal — a “silver” that is the
accent colour stops reading as second place — but it deserves tokens so a theme
can tune it for legibility on its own surfaces. Added --site-podium-* with a
high-contrast override.
UIC-5 [P2 · fixed] Durations had no token to reach for¶
Tailwind v4 has theme namespaces for colour, radius, shadow and easing — but
not for duration. So design-language.md §0.1 asked for tokenised durations
and there was nothing token-shaped to write; 76 site-tier call sites had drifted
onto bare duration-150 / -200 / -300 / -700.
Added three @utility classes bound to the theme’s own
--site-transition-speed: duration-site-fast (0.75×), duration-site (1×),
duration-site-slow (1.5×) — 150 / 200 / 300ms at the default, and a theme that
wants to feel quick (ultra, 150ms) now gets a quick whole site. 55 call sites
migrated.
Not migrated (deliberate): animate-in … duration-700 on news.$slug.
Those set animation-duration, which a transition utility does not touch — and
at 700ms they also break §7’s “nothing slower than 0.3s”. Flagged below as
UIP-6, not silently retimed: rewriting a page’s entrance choreography is a
design call, not a mechanical one.
UIC-6 [P3 · fixed] Assorted radii¶
rounded-xl/-2xl/-md in the two embed routes, the login brand mark, the
news source card and the replay grid — 12 sites, all now rounded-site*.
UIC-7 [P3 · not a defect] login.tsx brand hexes¶
text-[#5865F2] and text-[#ea4335] are Discord blurple and Google red. A
provider’s mark is its own brand asset and is not ours to theme. Allowlisted by
name in the CI rule rather than left to be re-flagged every pass.
2. Legibility¶
UIL-1 [P0 · fixed] Popovers were built on the tier that has no blur¶
The reported symptom — “a lot of popups are too transparent to read anything” — has one main cause and one aggravating one.
The cause. Nine floating surfaces were built on L1 .glass-fill or on a raw
bg-site-surface recipe. L1 has no backdrop blur by design — it is the tier
for repeated cards, where the §5.1 budget is zero blurs — and --site-surface is
deliberately a translucent tint, not paper. So a menu built on either sat
straight over whatever it had opened on top of:
Surface |
Was |
|---|---|
|
L1 |
|
raw surface |
|
L1 |
|
L1 |
|
L1 |
|
L1 |
|
L1 |
|
raw surface |
|
L1 |
All now .glass-overlay. (Radix Select, Dialog, Sheet, the command
palette, QuickPanel, NotificationsPopover and ReactionMenu were already
correct.)
The aggravator. L4’s tint was 80% × --glass-user-tint — a plain multiply by
the Glass clarity slider. A visitor who picked “Airy” got
80% × 0.7 = 56% on every dropdown on the site and lost them against a busy
page. Opacity on this tier is not purely taste: a popover is the one surface you
must read the instant it appears, and nobody scrolls one into a comfortable
position. It is now clamp(78%, 90% × factor, 100%) — the slider still moves it,
78% is the floor no preference can cross, and the clamp upper bound also stops
“Calm” minting an invalid percentage.
UIL-2 [P1 · fixed] No fallback where the engine cannot blur¶
Every tier above L1 leans on backdrop-filter to separate itself from its
backdrop; there was no @supports fallback, so where the engine cannot blur
(older WebKit, a blocked filter, print) the tint was doing the whole job alone
and the read surfaces lost. Added one rule, at the tier layer rather than
per-component: no blur → --site-surface-opaque. .glass-fill is excluded on
purpose, since it never depended on a blur.
UIL-3 [withdrawn — measured, and it does not hold]¶
I flagged this in the first pass: nocturne sets --site-glass-tint to 9% alpha
and ultra to 6.5%, so a .glass-pane in those themes is ~93% see-through with
only an 18px blur holding body text off the backdrop — and since themeCssVars()
derives user themes from the same shape (TINT_ALPHA_MIN is 0.04), a purchased
theme can reach the same range.
Measured before changing anything, and the concern is wrong. Text-on-pane contrast, per theme, ink against the tint composited over its own page ground:
Theme |
tint |
text on pane |
|---|---|---|
|
78% |
21.00:1 |
|
55% |
15.01:1 |
|
100% |
21.00:1 |
|
62% |
17.28:1 |
|
60% |
11.77:1 |
|
9% |
13.58:1 |
|
6.5% |
17.04:1 |
Every one clears AA comfortably, and the two low-tint themes clear it by a wide margin — because they are dark themes with light ink. A transparent pane there means the text sits on the dark canvas, which is a better backdrop than the pane, not a worse one. What a 6.5% tint actually costs is surface definition — you can see the text fine, you just cannot see where the panel ends — and that is the deliberate aesthetic of a theme called “ultra”, with the rim glint and the hairline border there to carry the edge.
Left unchanged. The one place transparency genuinely threatened legibility was L4, and that has a floor now (UIL-1).
3. Fonts¶
UIF-1 [P1 · fixed] The font utilities were wired to the wrong type system¶
The site had two parallel type systems and the Tailwind utilities pointed at the one without the theme in it:
--font-sans: var(--font-inter); /* not --site-font-body */
--font-mono: var(--font-jetbrains-mono); /* not --site-font-mono */
--font-display: var(--font-inter); /* not --site-font-display */
Three consequences, and they explain the reported symptom exactly:
font-mono— 74 call sites across 48 files — bypassed--site-font-mono. A theme that sets its own mono face (ultradoes) changed nothing, anywhere. It had also lost its fallback chain:var(--font-inter)with nosans-serifbehind it means a failed webfont drops to the browser default rather than a system face, which is precisely how type ends up looking “unique” on one machine and not another.font-displayresolved to Inter — the opposite of the display font. The two names meant contradictory things, which is why 31 files wrote the mouthfulfont-(family-name:--site-font-display): the obvious utility was already taken by the wrong face.font-sansskipped--site-font-body, so the theme’s body face applied to<body>by inheritance and was then overridden by every element that saidfont-sansto mean “not mono”.
All three now resolve through the contract. Every existing call site is fixed
without touching one of them. font-serif and font-comic stay literal —
they are decorative faces a page opts into deliberately, not roles a theme
fills — but both gained a generic fallback.
Cleanups on top: the 43 font-(family-name:--site-font-display) spellings
collapse to font-display, and 14 inline
style={{ fontFamily: 'var(--site-font-display)' }} duplicates fold into the
class (inline styles can’t be overridden by a theme rule, so they were a second,
weaker way of saying the same thing).
UIF-2 [P3 · noted] font-nunito is no longer the body default¶
The doc said body is font-nunito; <body> actually carries font-body, which
resolves to --site-font-body (SF Pro → Inter → system-ui). font-nunito still
exists as a one-face utility. Documentation corrected rather than the code —
the code is right.
4. Performance¶
The measured pass earlier the same day
(performance-audit-2026-08-01.md) found
that custom-property writes on <html> were restyling the whole document every
pointer frame, and fixed the writes. This pass asks the next question: should
those effects be running at all? The owner’s answer — the design does not need
to react to the cursor — makes most of the remaining cost simply removable.
UIP-1 [P0 · fixed] Cursor reactivity, removed¶
Removed |
What it was costing |
|---|---|
|
A 220px radial gradient whose centre tracked the cursor. Gradient position is not a compositable property — there is no transform that slides it — so each frame re-rasterised the element’s whole box. |
|
A document |
|
Woke a rAF and re-composited two viewport-sized aurora layers on every frame the mouse moved. On a fine pointer the hook now attaches no listener at all. |
|
A |
|
A per-card cursor-tracked radial sheen. |
|
Mounted per card in the arcade hub: N |
|
Dead file — zero call sites. |
Hover is still announced, by the two cues that cost nothing per frame: the tint
raise (background-color) and --glass-glint-hover lifting L1’s ambient rim
glint to full (a registered custom property, so it interpolates without JS).
Kept: device tilt (opt-in, touch-only, and the one input a phone has) and
useDeviceAttitude (an explicit “inspect this object” control with a
drag/keyboard path beside it). Neither is a cursor.
Net effect on an idle desktop page: zero pointermove listeners and zero rAF
loops in the site shell. Before, moving the mouse across the window woke three
independent listeners, two rAF loops, and a full-element repaint on whatever
card was under the cursor.
UIP-2 [P1 · fixed] Inert plumbing and orphaned CSS¶
33 data-glass-light attributes across 15 files, plus three conditional
data-glass-light={interactive ? '' : undefined} props in the shared
primitives — the selector that read them is gone.
A dead-code sweep over the same layer turned up more of the same shape:
Removed |
Why |
|---|---|
|
Zero call sites, and it carried a standing |
The Coming Soon Gate (CSS) |
182 lines and 15 classes for a full-screen gate with no component, no route, and no history of one. |
|
A MutationObserver + ResizeObserver minting a per-size |
|
Zero call sites. |
The scan that found these also produced ~30 false positives — a documented
escape hatch with no current user (.glass-opaque), unadopted halves of a
deliberate scale (.site-display-1/-4), and the shared vocabulary the
2026-07-30 Apple audit extracted for pages to adopt later (.site-section-head,
.site-rows, .site-inverse-block). Those are provided API, not orphans, and
were left alone. “No call sites” is the start of the question, not the answer.
UIP-3 [P2 · fixed] A backdrop-filter on a repeated card¶
BuildCard’s “curated” badge used bg-black/60 backdrop-blur-sm — one
backdrop-blur region per card in the grid, against a §5.1 budget of zero on
repeated list items. Now a media scrim. The card itself also moved from the
box-model lookalike (bg-site-surface + border + radius, plus the cursor sheen)
onto .glass-fill .glass-interactive, which is the same read with none of the
per-frame work.
UIP-4 [P1 · fixed] The GL liquid layer was never initialised — removed¶
lib/liquid-gl/ was a complete WebGPU/WebGL2 renderer — orchestrator, both
backends, a scene builder, a trust/watchdog module, tier detection — and
initLiquidGL() had no caller anywhere in the repo. index.ts’s own
docblock said “dynamic-imported from Providers after first paint”; Providers did
not. So setLiquidActive(true) never ran once, isLiquidActive() was
permanently false, html.liquid-gl was never set, and everything downstream was
dead in a way that was invisible from any single file:
the scene light
useGlassLightpublished on every pointer frame had no reader (this is why UIP-1’s removal cost nothing);setAuroraOffset’s numeric mirror had no reader either;useLiquidBodyalways reported inactive, soliquid-morphandliquid-popeach carried a fully-written shader branch that never executed — and the CSS/SVG path they “fell back” to was, in fact, the only renderer;the
/liquid-glassdesign lab showed a tier badge reading “CSS / SVG fallback” permanently, next to a “force CSS fallback” switch that toggled between the fallback and itself.
Removed, ~2,240 lines: index.ts, detect.ts, scene.ts, both renderers,
webgpu.d.ts, scene-light.ts, active.ts, registry.ts,
hooks/useLiquidBody.ts, two test files, and the html.liquid-gl CSS block.
The shader branches in liquid-morph / liquid-pop are constant-folded to the
path that always ran, and the lab’s section now documents the SVG metaball
underlay it actually demonstrates.
Kept, because they are the CSS/SVG tier’s own maths and have nothing to do
with GL: droplet.ts (the morph’s trailing droplet geometry) and
pop-motion.ts (the pop’s timing curves). They moved to lib/liquid/ —
a folder called liquid-gl with no GL in it would mislead the next reader.
trust.ts went with the tier; its one surviving export, a twelve-line
isWebKit UA test, moved into lib/view-transition.ts, its only caller.
This was flagged rather than fixed in the first pass of this audit, on the grounds that wiring it up or deleting it is a product call. The owner made it: delete.
UIP-5 [P1 · fixed] .glass-liquid’s sheen repainted the host every frame, forever¶
animation: glass-sheen 9s ease-in-out infinite animated background-position.
There is no transform that slides a gradient, so background position is a paint
property: every frame of the sweep re-rasterised the host’s entire box — and the
animation is infinite, on surfaces that are by definition among the largest on
the page (a login card, a pricing slab). It was the last standing per-frame paint
on the site, and on a 120Hz panel it is 120 full-element repaints a second for an
ambient decoration.
The band is now its own layer on ::after, moved with translate3d. Transform is
a compositor property: the layer rasterises once and the GPU moves it. ::after
was available because the pointer hotspot that used to own it is retired (UIP-1) —
the fix for one finding paid for the other.
Measured, headless Chromium at 4× CPU throttle, three panes, 3s sampled inside the active sweep (the keyframe’s long dwells mean the naive average would flatter both):
Renderer work over 3s |
v2 |
v3 |
|---|---|---|
|
720 |
0 |
|
2,700 (1,300ms) |
12 (8.9ms) |
|
180 (27ms) |
0 |
|
180 (13ms) |
0 |
Area repainted |
737 Mpx |
0 Mpx |
Total render time |
1,453ms |
8.9ms |
163× less renderer work, and the paint count is zero — the twelve raster tasks are the layer being rasterised once at startup. Verified visually too: rendered side by side at three points in the cycle, the two are pixel-matched.
Three details are load-bearing, and one of them cost a wrong first attempt:
The host clips (
overflow: clip). A moving layer has to leave the pane and nothing else can stop it showing outside — aclip-pathormaskon the layer is applied before its transform, so it travels with the band.cliprather thanhiddenbecause it creates no scroll container and no containing block for absolute descendants.A host with a deliberately overflowing child opts into a bleed via
--glass-sheen-bleed. Exactly one does: the membership card’s “Most popular” ribbon straddles the top edge, and a hard clip would cut it in half.The geometry is v2’s, converted rather than redesigned. The first attempt used a narrower band with rounder gradient stops and showed hard vertical seams — at 115deg a gradient’s stops run along the tilted axis, so
transparent 0%is a corner, not an edge, and the band’s own box edges cut through mid-gradient colour. Keeping the 260% width and the original stops puts those seams outside the clip for the whole travel. The translate values are the algebra of the old keyframe: a background-position ofP%on a layer wider than its box resolves toP × (box − image), so 150% → −92.3% and −60% → +36.9% of the band’s own width.
UIP-6 [P0 · fixed] 103 animation classes that compile to nothing¶
The first pass flagged news.$slug’s five staggered animate-in … duration-700
entrances for breaking §7’s “nothing slower than 0.3s”. Going to retime them
turned up the actual problem: they never ran.
animate-in, fade-in-0, zoom-in-95, slide-in-from-*, fill-mode-* and
animate-out come from tailwindcss-animate, a Tailwind v3 plugin this
project does not have. It went in the v3 → v4 migration and nothing replaced
it. Grepping the built CSS:
utility |
rules in the built CSS |
|---|---|
|
0 |
|
0 |
|
0 |
|
0 |
|
0 |
|
2 |
|
3 |
|
1 |
103 of them, across 18 files, including surfaces nobody would guess were
affected: the command palette, ComposeBox and ComposeModal, the
profile hover card, the keyboard-shortcuts sheet, and every Radix
data-[state=open]:animate-in data-[state=closed]:animate-out pair. All of it
pops in and out with no transition, and has for as long as the project has been
on v4.
Removed — provably a no-op on the rendered page, since they produce no CSS. The
orphaned duration-700 / delay-450 that were there to time them went too
(there is no transition on those elements either, so they were equally inert);
the one element with a real transition-colors keeps a duration, on the token
scale.
The motion was then put back, on the documented system (the owner picked
option 1 over adding tw-animate-css). Two halves, one clock:
framer-motion +
lib/motion.tswherever React owns the mounting: the ComposeBox/ComposeModal alt-text and audience panels (modalContent— they also moved from L1 to L4, which is a legibility fix the tier scan missed because theirrelativepanel sits inside afixedparent), the news article’s five-step entrance (staggerContainer(0.06)+staggerItem, so the last item lands at ~400ms instead of the 1,150ms the dead version described), and eleven game surfaces (scaleIn/fade/fadeRise/popIn).A new
data-motionattribute for Radix surfaces, whose unmount is Radix’s to schedule — animating those with framer needsforceMount+AnimatePresencethreaded through every consumer, so the exit rides Radix’s owndata-stateinstead.fade/pop/rise, mapping tooverlay/scaleIn/modalContent. Applied to the command palette, the keyboard-shortcuts sheet, the profile hover card,Select(which had no animation at all) and Slice It’s modal.
dialog.css and sheet.css had already hand-rolled this pattern, each with a
comment explaining that the plugin was missing — but they were the only two, and
they hardcoded three different timings between them (0.3s ease-out, 0.32s
cubic-bezier(0.22,1,0.36,1), 0.3s cubic-bezier(0.16,1,0.3,1)). Both are retimed
onto the shared --motion-* tokens, which mirror DURATION.slow/fast and
EASE.emphasized. Their bespoke shapes stay — a sheet slides from an edge and
none of the three generic shapes covers that; only the clock is shared.
Verified in a real browser rather than by reading the CSS: the built stylesheet
loaded into Chromium, each attribute combination mounted, and getAnimations()
asked what actually started — fade/pop/rise each report exactly one running
animation with the expected name, data-state="closed" reports the -out
variant, an element with no data-motion reports none, and under
reducedMotion: 'reduce' all five report zero.
Rule 9 in the design gate fails on the dead vocabulary, so it cannot come back by accident.
UIP-7 [P1 · fixed] transition-all, and the four places it was hiding a layout animation¶
transition: all makes the engine watch every animatable property on the
element — including width, height, padding, gap, top. A class change
that happens to touch one of those then animates a reflow nobody asked for, and
because all is a blanket, nothing in the source says which property was ever
meant to move.
228 call sites, converted to name what actually changes. The mapping is mechanical and derived per element from what its own state variants and ternary branches touch:
Change set |
Becomes |
|---|---|
colours only |
|
transform only |
|
both, or ring / shadow / opacity / filter |
|
a state touches a LAYOUT property |
left alone, 47 — see below |
Games and the --app-* apps are included here, unlike the consistency rules:
they are exempt from the palette, not from the frame budget, and a game needs
it more.
The interesting four. Some elements had transition-all precisely because
they animate a layout property, and narrowing the property list would have
silently killed the animation. Those are the ones worth having found:
Surface |
Was |
Now |
|---|---|---|
|
animated |
full-width fill, |
|
animated |
|
|
animated |
|
|
|
|
The first three relayout the element and its siblings on every frame of every
reveal; as transforms they are composited. The dots keep width — four 6px pills
on a one-shot step change, and scaleX would flatten a rounded-full cap into
an ellipse — but the property is now named rather than blanketed.
The 47 the first sweep skipped got that same judgement, one at a time, by reading
each element’s inline style object instead of its class list:
20 were progress / HP / XP / timer bars animating an inline
width: X%— the same defect as the four above, scattered across the games. All converted to a full-width fill withorigin-left+scaleX. These matter more than the site ones did: a boss health bar and a round timer update during play, so each was relayouting its track every frame of every tick. Their ownrounded-fullcame off with the conversion — the track already clips, and a scaled radius squashes the cap into a point.21 set only paint properties inline (
backgroundColor,filter, …) →transition-colors/transition-transform/transition.The last 10 were then done by hand, and four of them were hiding the same layout animation as the rest:
two toggle knobs (
SongDetailsPanel,dream-rift/SettingsScreen) slid onleft, relayouting the switch on every flip → parked at rest, translated;cursed-logic/MinigameOverlay’s timing-bar playhead moved onleft: calc(…%)every frame of the minigame → a full-width carrier translates by the percentage instead (a percentage translate resolves against the element’s own width, so the marker rides the carrier and offsets itself by half its width to stay centred);lights-out’s grid cells animated their inlinewidth/height, so every viewport resize animated the whole board’s geometry →transition.
The rest name what their branches change.
void-breaker’s three boss-phase pips keepwidth— three tiny spans, changed once per phase — but say so.transition-allnow appears nowhere in the codebase, so rule 8 was widened from the site tier to the whole tree.
One of the conversions had a bug worth recording: rmhbox/InstructionsScreen
gates its transition on paused, and the naive rewrite put w-full origin-left
inside the same ternary — so a paused timer bar would have had no width at all.
Geometry is unconditional; only the transition pauses.
CI-enforced for site UI from here (§13 rule 8).
120Hz readiness¶
Checked and clean. The shell’s remaining rAF loops (RadialWheel,
LiquidGlobe, useFluidPress, useFluidDrag) all normalise on measured delta
time, so they settle at the same perceived rate on a 60Hz and a 144Hz panel.
The only 1000/60 constants in the tree are inside game simulations, where a
fixed timestep is correct. The spring vocabulary is parameterised by perceptual
duration + bounce (lib/fluid.ts / APPLE_SPRING), which is refresh-rate
independent by construction.
Site-tier backdrop-filter count is within budget: eleven call sites, none on a
repeated list item, all singular chrome or overlays.
5. What is now CI-enforced¶
lib/__tests__/design-consistency.test.ts grew five rules — 5–8 site-tier only,
rule 9 across the whole tree —
all with the exemption list justified in the file:
Rule |
Fails on |
|---|---|
5 |
A raw Tailwind palette colour ( |
6 |
A hardcoded radius ( |
7 |
A floating surface below L4 — positioned + stacked + edge-anchored, carrying |
8 |
|
9 |
A |
They read className= attributes specifically rather than string literals
generally: a class-shaped word in prose (“the old bg-black/60 skin”) is a
comment, not a call site — the same lesson the game-viewport gate records. The
suite also asserts the site-tier filter kept >200 files and that a known site
file is in it and a known game file is not, so an over-broad exemption list
cannot make the new rules pass vacuously.
6. Verification¶
pnpm exec tsc --noEmit— clean.pnpm build— clean (Vite + 6 esbuild server bundles; the CSS compiles, which is what validates the new@theme inlinebindings and@utilityclasses).pnpm exec vitest run— 273 files, 5,013 tests, all passing, including the extended design gate and the rAF allowlist.
Not done, and it matters: nobody has looked at this in a browser. §0.9 of the
design language asks for three themes × two widths × reduced motion by eye, and
that is still owed — particularly for UIL-1 (the nine surfaces that changed
tier), UIC-1 (the casino group in high-contrast) and UIF-1 (the font rewire,
which changes the resolved face of font-mono on 74 call sites).