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-x consistency, UIP-x performance, UIL-x legibility, UIF-x fonts.


Scope

Site tier onlycomponents/** 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-contrast override 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

components/ui/Tooltip.tsx — every tooltip site-wide

L1

components/site/LanguageSwitcher.tsx — the locale listbox

raw surface

components/feed/ComposeBox.tsx — post options menu

L1

components/feed/ComposeModal.tsx — alt-text dialog

L1

components/feed/ConversationView.tsx — attach menu

L1

components/feed/GroupChatView.tsx — attach menu

L1

components/feed/MentionTextarea.tsx — mention autocomplete

L1

components/homes/WatchButton.tsx — watch dialog

raw surface

components/ui/horizontal-scroller.tsx — scroll buttons ×2

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

default

78%

21.00:1

light

55%

15.01:1

high-contrast

100%

21.00:1

graphite

62%

17.28:1

sepia

60%

11.77:1

nocturne

9%

13.58:1

ultra

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:

  1. font-mono — 74 call sites across 48 files — bypassed --site-font-mono. A theme that sets its own mono face (ultra does) changed nothing, anywhere. It had also lost its fallback chain: var(--font-inter) with no sans-serif behind 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.

  2. font-display resolved to Inter — the opposite of the display font. The two names meant contradictory things, which is why 31 files wrote the mouthful font-(family-name:--site-font-display): the obvious utility was already taken by the wrong face.

  3. font-sans skipped --site-font-body, so the theme’s body face applied to <body> by inheritance and was then overridden by every element that said font-sans to 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

.glass-interactive::after hotspot

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.

hooks/useGlassLight (deleted)

A document pointermove listener doing a closest() ancestor walk, a cached getBoundingClientRect() and two custom-property writes per frame — feeding a scene light with no live consumer (see UIP-4).

useLiquidBackground’s fine-pointer branch

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.

RadialShell’s ring parallax

A pointermove listener plus a rAF lerp, mounted on every route in the shell. The lerp kept scheduling frames ~⅓s after the pointer stopped.

hooks/useCardSheen (deleted)

A per-card cursor-tracked radial sheen.

hooks/usePointerParallax (deleted)

Mounted per card in the arcade hub: N pointermove/deviceorientation listeners, N spring sets and N preserve-3d composited layers, to lean a card three degrees toward the cursor.

hooks/useParallax (deleted)

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

.hover-lift (CSS)

Zero call sites, and it carried a standing will-change: transform — a promoted GPU layer per element for an effect nobody uses.

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.

initGlassLens() (132 lines)

A MutationObserver + ResizeObserver minting a per-size <filter> pair for every [data-glass-lens] element, under an 8-pair LRU — with no caller. The displacement lens is parked, so no CSS rule reads the variable it wrote. lensMapDataURI (the static filter’s map) stays; the rest of the file went with the generator.

hooks/useParallax.ts

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 useGlassLight published on every pointer frame had no reader (this is why UIP-1’s removal cost nothing);

  • setAuroraOffset’s numeric mirror had no reader either;

  • useLiquidBody always reported inactive, so liquid-morph and liquid-pop each 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-glass design 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 background-position

v3 translate3d

Paint events

720

0

RasterTask events

2,700 (1,300ms)

12 (8.9ms)

UpdateLayoutTree

180 (27ms)

0

PrePaint

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:

  1. The host clips (overflow: clip). A moving layer has to leave the pane and nothing else can stop it showing outside — a clip-path or mask on the layer is applied before its transform, so it travels with the band. clip rather than hidden because it creates no scroll container and no containing block for absolute descendants.

  2. 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.

  3. 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 of P% on a layer wider than its box resolves to P × (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

.animate-in

0

.fade-in

0

.zoom-in-95

0

.slide-in-from-bottom

0

.fill-mode-both

0

.animate-pulse (control)

2

.animate-spin (control)

3

.transition-colors (control)

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.ts wherever 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 their relative panel sits inside a fixed parent), 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-motion attribute for Radix surfaces, whose unmount is Radix’s to schedule — animating those with framer needs forceMount + AnimatePresence threaded through every consumer, so the exit rides Radix’s own data-state instead. fade / pop / rise, mapping to overlay / 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

transition-colors (95)

transform only

transition-transform (24)

both, or ring / shadow / opacity / filter

transition (49) — every visual property, no layout ones

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

onboarding/FirstWeekCard progress

animated width: X%

full-width fill, origin-left + scaleX(p)

feed/PollDisplay result bar

animated width: X% behind text

origin-left + scaleX(p)

feed/InsightsModal trend columns

animated height: X%

origin-bottom + scaleY(p)

feed/WelcomeModal step dots

w-1.5w-5

transition-[width,background-color]

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 with origin-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 own rounded-full came 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 on left, relayouting the switch on every flip → parked at rest, translated;

    • cursed-logic/MinigameOverlay’s timing-bar playhead moved on left: 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 inline width/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 keep width — three tiny spans, changed once per phase — but say so. transition-all now 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 (bg-red-600, text-zinc-500, …) in site UI. Allowlist: app/routes/login.tsx (brand marks).

6

A hardcoded radius (rounded-sm/md/lg/xl/2xl/3xl). rounded-full / rounded-none are shapes, not radii, and pass.

7

A floating surface below L4 — positioned + stacked + edge-anchored, carrying .glass-fill / .glass-pane / bg-site-surface.

8

transition-all. Name the properties, or use plain transition (visual only, no layout).

9

A tailwindcss-animate class (animate-in, zoom-in-95, …). The plugin is not installed, so these compile to nothing. Whole tree, not site-only — a class that produces no CSS is dead everywhere.

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 inline bindings and @utility classes).

  • pnpm exec vitest run273 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).