Design Language — Radial Avant-Garde Glass¶
Audience: humans and coding agents. This is the reference for how the site looks and feels, and how to build UI that is visually native to it. For the step-by-step “build a page that fits” checklist, see
docs/page-consistency.md.For the statement of the language — what the design is, why it is that way, and the laws behind these rules, without the values that move — see
design.mdat the repo root. This file is the authority whenever the two disagree.
The design language is Radial Avant-Garde Glass — a bold, experimental take on Apple’s Liquid Glass material, draped over a radial information architecture. Two ideas, one language:
Radial. The site orbits a central RMH mark; content radiates from the centre. The home feed is a vertically-scrolling wheel of cards (on the document’s own scroll) that rake onto a shallow cylinder as they cross the focus line, led by an inline compose box; navigation lives in a fixed RMH hub that, when tapped, sends the orb to the middle of the screen and swells it into a liquid globe — the destinations pinned to a glass sphere you turn, hold and let go of to travel. A soft parallax ring backdrop and a drifting blob field keep the whole surface feeling liquid and continuous. Mobile-first, with a strict high-contrast monochrome palette.
Avant-garde glass. The material is Apple’s Liquid Glass used theatrically, not literally: physically-plausible layered translucent glass with live optics — a specular rim glint that tracks the scene light, lens-model edge refraction (with an optional chromatic prism on one flagship surface), a depth-parallaxing aurora canvas, a pointer-tracked diffuse light, and travelling liquid sheens — deployed for signature radial moments (the menu is an expanding circular veil growing from the centre, not a drawn disc, with a glass sphere suspended in it). It is expressed as an elevation system of explicit CSS classes (
.glass-fill/.glass-pane/.glass-chrome/.glass-overlay/.glass-inset, plus the modifiers in §5.1) placed on components.
Everything rests on one contract: a single set of CSS custom properties
(--site-*) that every theme re-defines. Components never hardcode colors,
radii, fonts, or shadows — they consume the contract through Tailwind utilities,
so any theme (and any accent preset layered on top) restyles the entire site
without a single component change.
What ships today — read this first. Both layers are live. The radial layer (shell, hub, wheel feed) ships in
components/radial/, and the Liquid Glass material is rendered on top of it: the radial shell no longer demotes the glass classes to flat cards, the aurora canvas paints and drifts behind everything, and surfaces are translucent by token (--site-surfaceis a tint, not paper) so both the.glass-*tiers and the many pages that simply paintbg-site-surfacesample the same scene. The elevation tiers, rim glint, pointer light, frosted edge bevel and liquid sheen are all on.One optic is parked: the SVG displacement lens (
url(#glass-lens), §5.1.glass-refract) — current Chromium composites the displacement map into the bevel instead of bending the backdrop through it, so refract surfaces keep the frosted edge band (and the prism keeps its static chromatic rim) without the bend. The filters andlib/glass-lens.tsstill ship; re-enabling is restoring the@supportsupgrades inapp/globals.cssplus theinitGlassLens()call inhooks/useGlassLight.ts. Everything else in §2, §5.1 and §7 is live.
Specs: radial UI · v1 glass material · v2 optics & floating shell.
0. Definition of done for a UI commit¶
Read this before touching pixels; it is the short version of everything below,
and the thing to re-read at the end of the change. A UI commit is done when all
nine hold. (docs/page-consistency.md §3 is the same contract expanded into a
per-page checklist with code.)
Nothing is hardcoded. Every color, radius, shadow, font and duration comes from a token utility (
--site-*on the site;--app-*inside a full-screen app, §12) — no hex, norounded-lg, no bespoketransition: 200ms ease. A theme the author never opened must still look deliberate. (§1, §12)Ink tracks its surface. Filled surfaces take their paired foreground (
bg-site-accent→text-site-accent-fg). Nevertext-white/text-blackon a themed surface. (§1)It reuses a primitive. If
components/ui/has it — button, badge, dialog, tabs, empty state, skeleton, spinner, tooltip, copy button, confirm — the commit uses it rather than a local copy. A second copy of something shared is the single most common defect in this repo’s UI history, and the reason §5.2 and §12 exist at all. (§5)One tab-strip grammar. Tab strips are
<LiquidTabs>. No hand-rolledrole="tablist", nolayoutIdcapsule of your own, no active-state underline. This one is CI-enforced — see §13. (§7)Motion comes from the token set.
lib/motion.ts(DURATION,EASE,SPRING,APPLE_SPRING,transition, and the named variants) rather than ad-hoc numbers, so a global re-tune stays a one-line change. (§7)It degrades centrally, not per component. High-contrast, reduced transparency, reduced motion,
perf-liteandforced-colorsare handled by the token layer and the glass classes. A component that branches on them by hand is a bug. Legibility must never depend on an optic: text has to hold on--site-surface-opaque. (§5.1, §7, §9)Every string is translated through
t("key", { defaultValue }), withpnpm i18n:extractrun and the namespace registered inlib/i18n/config.ts. (§10)The keyboard and screen-reader path works. Icon-only controls are named, decorative icons are
aria-hidden, focus stays visible and undoubled. (§8, §9)It was actually looked at in
default,.style-lightand.style-high-contrast, at a phone width and a desktop width, and once with reduced motion on. Three themes × two widths is the floor; the audit matrix indocs/ui-audit-2026-07-28.md§1 is the extended version.
When the system does not have what you need, extend the system — add the token, add the variant, add the primitive — and say so in the commit message. Do not solve it locally with a magic number; that is how five apps ended up with five focus rings.
0.5 Fluid interfaces (the gesture layer)¶
The site’s motion answers to Apple’s Designing Fluid Interfaces (WWDC 2018
§803). That talk’s claim is that the “magical” feel of native gestures is not
decoration but a handful of mechanics, each with a formula — and those formulas
live in lib/fluid.ts, unit-tested in
lib/__tests__/fluid.test.ts, shared by every gesture surface. Do not re-derive
them locally.
Principle |
What it means here |
API |
|---|---|---|
Instantaneous response |
A surface reacts on |
|
Interruptible & redirectable |
Motion is a spring carrying position and velocity, so grabbing something mid-flight retargets it from where it is and how fast it is going. A duration tween structurally cannot do this. |
|
Momentum crosses the lift |
The velocity the finger left with is handed to the animation, not discarded. |
|
Intent is read from the projection |
“Where would this throw come to rest?”, not “is it past the line right now?” — which is why a fast flick dismisses a sheet that has barely moved. |
|
Limits push back |
Past an edge, travel is damped asymptotically instead of stopping dead. |
|
Springs are described by perceptual duration + bounce, never by hand-tuned
stiffness, so the same spring settles in the same perceived time whether it
moves 4px or 400px. The two animation tiers share one vocabulary — SPRINGS in
lib/fluid.ts (imperative/rAF) and APPLE_SPRING in lib/motion.ts
(framer-motion) — and a test asserts they cannot drift apart.
Where it ships today: every Button and interactive Card, the radial chrome
and the globe’s pins (press layer); phone-width sheet dialogs (drag-to-dismiss
with rubber-banding and flick projection); and the navigation globe (throw,
magnetism and keyboard glide all on one interruptible spring). Touch-friendly by
construction: touch-action: manipulation removes the ~300ms tap delay on
everything pressable, and hit targets are grown to 44px with insets rather than
by inflating the drawn control.
In the full-screen tier, where controls do not come from ui/button.tsx, a
container claims its subtree instead: data-fluid-press-scope makes every
button / [role=button] / summary inside press, so an app opts in once
rather than tagging thirty bespoke buttons and forgetting the thirty-first.
AppShell carries it, which reaches RMHbox, RMHType, RMHStudy and RMHTube in
one line; game shells add it themselves. Opt individual elements back out with
data-fluid-press="none" — and do opt out gestures: a joystick, a drag
handle or a fire button held for thirty seconds is not a tap, and springing it
reads as lag while promoting a compositor layer during exactly the frames a game
can least afford one.
Reduced motion stands the whole layer down — the press spring does not run, the sheet gesture is not offered, and settles snap. Direct manipulation that remains useful (turning the globe) keeps working.
1. The token contract (app/globals.css)¶
Tailwind v4 is imported at the top of app/globals.css; an @theme inline
block binds the --site-* variables to utility classes. The :root block is
the default theme — the strict-monochrome Radial Avant-Garde Glass baseline (a
light palette: white canvas, ink text and accent). There is no .style-default
class; default is the absence of any .style-* class on <html>.
Tokens every theme defines (set in :root, overridden by each .style-*
class):
Group |
Tokens |
|---|---|
Backgrounds |
|
Surfaces |
|
Glass material |
|
Borders |
|
Text |
|
Accent |
|
Status |
|
Elevation / shape |
|
Layout rhythm |
|
Typography |
|
Motion / flourish |
|
Tailwind utilities — use these, never raw hex/oklch¶
Purpose |
Utilities |
|---|---|
Backgrounds |
|
Borders |
|
Text |
|
Accent |
|
Status |
|
Radius |
|
Shadow |
|
Fonts |
|
Theme display font |
|
Extra breakpoint: xs = 480px (defined in the @theme inline block).
Foreground pairing — ink must track its surface¶
A filled surface is only readable if the ink on it comes from that surface’s
paired foreground token, not from the page’s ambient --site-text:
Surface |
Ink |
|---|---|
|
|
|
|
|
|
Forgetting the pair is the classic way to ship invisible UI: inheriting
--site-text onto a dark accent gives near-black on near-black, and onto a light
accent (bright yellow) gives white on white. globals.css now supplies each
pairing by default through zero-specificity :where() rules, so an element
that only sets a background still gets correct ink — and anything that states its
own colour still wins. Since lucide icons paint with currentColor, this covers
icons and text together.
Two things the safety net can’t do for you:
Don’t hardcode
text-white/text-blackon a themed surface. It looks fine against today’s accent and breaks against a user’s. Name the paired token instead — that’s what makes it track every theme and accent preset.Translucent tints are a different case.
bg-site-accent/15withtext-site-accentis correct and deliberate; the safety net only targets the solid fills, so tinted chips are untouched.
--site-accent-fg is contrast-checked at runtime by ensureReadableAccent()
(lib/appearance/contrast.ts), so a custom accent can’t ship an unreadable pair.
A legacy shadcn token set (--card, --primary, --muted, --border,
--ring, --radius, --chart-*, --sidebar*) also exists for a few
shadcn-derived pieces. Prefer --site-* for all new site UI. A separate
.dark class exists only for Slice It game variables — it is not the site
theme mechanism.
2. Themes + accent presets¶
Theme = a .style-<id> class on <html>; the catalog lives in
stores/themeStore.ts (SITE_STYLES, with id/label/icon/group) and the CSS for
each in app/globals.css. Visitors with no saved preference get DEFAULT_STYLE
(default), and persisted-but-unknown prefs self-heal in Providers.tsx.
The default (default = the bare :root, no class) is the Radial Liquid
Glass baseline: strict monochrome — a white aurora canvas, ink text, an ink
accent, hairline black borders — rendered as glass, not paper: its surfaces are
translucent white tints over the drifting canvas. Every other theme is the same
material in its own palette over its own --site-canvas aurora. (The old
liquid-glass theme id is retired — it became the shell.)
Note that SITE_STYLES currently ships three of these — default, graphite
and high-contrast; the other palettes below stay in globals.css as complete,
ready token sets (and as the reference every user theme derives from).
Group |
Themes |
|---|---|
Base |
|
Curated |
|
The glass primitives live in components/ui/liquid-glass.tsx (GlassPane and
GlassFilter — the v2 lens-filter host mounted globally in __root.tsx) with a
design-lab reference at /liquid-glass. A Glass clarity slider (Settings →
Appearance, §5.46) tunes how much scene shows through in five stops
(0 Opaque · 1 Calm · 2 Default · 3 Airy · 4 Clear): stop 0 is the opaque
html.reduce-transparency mechanism (the manual equivalent of the OS
prefers-reduced-transparency), and stops 1/3/4 scale the --glass-user-blur /
--glass-user-tint factors the glass classes consume (stop 2 = the shipped
default). Persisted as rmh-glass-level, applied pre-paint by the no-flash
script; the OS override and high-contrast still win.
On top of any theme a user can pick an accent preset — a curated color
(lib/appearance.ts, ACCENT_PRESETS, 14 options) that overrides just the
--site-accent* tokens as inline styles on <html>, keeping everything else
from the theme. null = the theme’s own accent.
User themes (v2, §14) extend the same principle to the marketplace: a
UserTheme’s tokens are colors + a few scalar knobs (lib/themes/tokens.ts,
THEME_TOKENS_VERSION = 2), and themeCssVars() derives the full --site-* /
--site-glass-* contract from them — so every purchased theme is a correct
glass tint and inherits future optics upgrades. Members create/publish (Theme
Studio, components/themes/); anyone buys with RMH coins. v1 token maps upcast
on read (upcastTokens), never rejected.
Themes differ only through the --site-* token contract — there are no
per-theme [data-slot] component overrides or full-page background effects on
site chrome anymore (those belonged to the retired themes). Full-page effects
(scanlines, grain, particle fields) still exist, but only inside individual
games/apps, scoped to their own variable groups. Shared primitives still set
data-slot attributes so future themes (or games) can hook them.
Theme runtime (how switching works)¶
Everything is data-driven from SITE_STYLES: the settings gallery, the runtime
class-swap, the anti-FOUC inline script, and the account-sync API validation
all derive from it — so adding a theme is just a .style-<id> CSS block plus
a SITE_STYLES entry (with its bg); nothing else needs editing.
stores/themeStore.ts— ZustanduseThemeStore { style, setStyle, preview, setPreview, accent, setAccent }.THEME_BGis derived fromSITE_STYLES.components/Providers.tsx— an effect swaps thestyle-*class on<html>(Dark/defaultneeds none — it uses:root), applies the accent override, persists tolocalStorage, and updates<meta name="theme-color">+ body background. It also self-heals any persisted-but-unknown style back toDEFAULT_STYLE. Games/app routes are excluded (THEME_EXCLUDED_ROUTES) — they own their styling; anapp-routeclass is toggled on<html>for them.No-flash SSR: an inline
themeScriptinapp/routes/__root.tsxapplies the persisted class + accent before hydration, deriving the background from theTHEME_BGmap (also fromSITE_STYLES), so there is no hand-copied theme→background map to keep in sync.
3. Typography & fonts¶
Body default is
font-nunito antialiased(set on<body>in__root.tsx).Fonts load from Google Fonts
<link>s, not @fontsource, for site chrome: Nunito + Inter are critical (loaded in__root.tsxhead); decorative theme fonts (JetBrains Mono, Playfair Display, Bangers, Bebas Neue, Orbitron, Cinzel, Pacifico, Space Grotesk, Permanent Marker, Caveat, Dancing Script, Patrick Hand) are deferred viarequestIdleCallback(deferredFontsScript).@fontsource/ibm-plex-*and@fontsource/newsreaderare used by specific games only.Recurring text patterns:
Page
<h1>:font-(family-name:--site-font-display) font-bold text-lg text-site-textBody:
text-site-text; secondary:text-sm text-site-text-muted; faint:text-site-text-dimDialog title:
text-lg font-semibold leading-none tracking-tightMono accents (counts, section labels):
font-mono text-xs uppercase tracking-widestChips/pills: use
<Badge>(orinline-flex items-center gap-1 rounded-full text-xs font-medium)
4. Iconography & emoji¶
lucide-reactis the icon library. (react-iconsappears in exactly one legacy file — don’t add more.)Buttonauto-sizes child SVGs tosize-4.Decorative icons get
aria-hidden; icon-only buttons get ansr-onlylabel oraria-label. Directional icons that must mirror in RTL take.rtl-flip.Emoji render through Twemoji (
TwemojiProviderincomponents/ui/) for cross-platform consistency.
6. Layout system & page anatomy¶
The _site layout route delegates to components/feed/SiteShell.tsx, which now
renders the radial shell (components/radial/RadialShell.tsx):
a fixed parallax ring backdrop, a slim sticky utility top bar (brand ·
search · inbox · avatar), the frame and the central RMH hub
(RadialHub). The single
<main id="main-content"> landmark lives inside the frame; pages never add
their own sidebars or page-frame (and AnimatedMain renders a <div> — the
shell’s <main> is the one landmark).
The frame is responsive by track count. Mobile is one column. From 1120px
the shell adds a persistent navigation rail, and from 1440px a live rail
(who’s online, the daily loop, friends online, trending, who to follow, plus
whatever a page portals in through PageLayout’s rightSidebar). They are grid
tracks, not overlays, so they cannot ride over the content, and each is
display: none below the width that affords it. The frame itself is capped
(--rad-frame-max, 96rem → 116rem at 1800px): filling the window means filling
it with content, not stretching one reading column across a 34-inch display. See
components/radial/README.md for the width
table and the overlap-bound rules.
The hub is a phase state machine: tapping the fixed orb sends it to the centre
of the screen, where it swells and dissolves into the liquid globe
(LiquidGlobe) while an expanding
circular veil sinks the page behind it. The globe is the navigation: every
destination is a pin on a glass sphere, and you turn it to find where you
want to go — drag to spin, let it coast, and the nearest destination is eased
into the reticle at the front. Hold it there and the reticle’s ring fills;
let go once it is full and you land on that page. Let go early, or turn away,
and the ring drains. Closing reverses the whole motion (the globe collapses, the
veil contracts, the orb re-condenses and glides home). The hub remains the
navigator on mobile and the fast full-screen switcher on desktop, where the nav
rail shows the same map without a gesture.
One hard constraint comes with it: the globe animates continuously above the
overlay, so the overlay is a painted veil rather than a backdrop-filter.
Chromium re-blurs a viewport-covering backdrop-filter in full whenever anything
above it moves — measured in this exact stack at ~10fps. The frosted material
still ships on the hub’s small, stationary foot capsule. See
components/radial/README.md for the numbers.
Every top-bar control previews before it navigates. Search drops a live
result list, the bell the latest notifications, the inbox recent threads, the
avatar a compact account menu — each with a footer link through to the full
page, so nothing is hidden behind the preview
(QuickPanel owns anchoring, the viewport
clamp, dismissal and focus).
The home (/) is a radial feed
(RadialFeed → RadialWheel): a wheel of
cards raked onto a shallow cylinder on the document’s own scroll (no inner
scroll region — that is what lets mobile Safari collapse its toolbars), led by an
inline compose box, with a floating compose button that opens the new-rmhark
modal. From 1280px it becomes a deck — the wheel keeps the primary column and
an independent second feed (Following · News · Games) runs beside it. Every other
_site route flows the same way — natural document scroll, no pinned/sticky
page chrome — inside the frame’s content track on the backdrop. The radial shell
keeps its layout opinions on content pages — it strips PageLayout’s header
card down to flat big type and unpins sticky headers/tabs/search so every page
flows like the feed — but it no longer touches the material: the glass classes
render at full strength inside the shell, over the ring backdrop and the aurora.
Two page archetypes (see docs/page-consistency.md for full code):
Standard content page — wrap in
components/feed/PageLayout.tsx:PageLayout({ title, children, rightSidebar?, headerRight?, wide?, backTo?, backLabel?, breadcrumbs? }). In the radial shell its.page-headingrenders as a flat, transparent big-type header floating directly on the ring backdrop (the radial content layer strips the old bordered header capsule), followed by the content column.rightSidebaris portalled into the shell’s live rail on wide screens and dropped on narrow ones, so it is supplementary content, never load-bearing.Feed-column / bespoke page — use
AnimatedMaindirectly with a target width fromlib/layout-width.ts, or (home) the radial wheel.
Column widths come from lib/layout-width.ts: DEFAULT_WIDTH = 648,
WIDE_WIDTH = 800, WIDE_NO_RIGHT_SIDEBAR_WIDTH = 952. Those are the page’s
preference, published as --main-target; on a wide screen the shell grants the
column a larger floor (--rad-measure) so a desktop window is not held to a
2019-era constant, and min(100%, …) keeps either from overflowing its track.
wide pages take the whole content track. Inner content is
usually px-4 pt-4 pb-12 max-w-2xl mx-auto; the column always carries
pb-dock and no border-r — there is no app-frame edge. Repeated content
floats as spaced cards (space-y-3 px-3); hairline divide-y rhythm lives
inside container cards, not between page-level sections.
Full-screen experiences (games, /login, legal pages, Discord activities) live
at the top level of app/routes/ — outside _site/ — and deliberately get
no shell (no radial chrome — they own their own look; the aurora canvas is
gated off there too, via html.app-route).
7. Motion¶
Radial motion (the shipped layer). The radial UI is CSS/rAF-driven and framer-motion-free for the shell: the feed wheel rakes each card onto a shallow cylinder on a rAF window-scroll pass with cached offsets (no layout thrash —
RadialWheel), the hub glides the orb to centre and blooms the liquid globe under an expandingclip-pathcircular veil (CSS phase machine + one mount-bounded rAF loop for the sphere’s spin and dwell), the ring backdrop parallaxes to the pointer, and page headers/heroes rise in on mount (radial-page-rise). All of it istransform/opacityonly and gated off under reduced motion; optional scroll haptics (navigator.vibrate) tick as cards cross the focus line.framer-motion is the animation library. Reach for the shared motion system in
lib/motion.tsrather than hand-typing durations/easings: it exports the timing tokens (DURATION,EASE,SPRING,APPLE_SPRING,pressable,transition) and ready-made variants (fade,fadeRise,fadeDown,scaleIn,popIn,overlay,modalContent,staggerContainer/staggerItem). Keeping enters, exits, and lists on these tokens is what makes motion feel like one system — smooth and quick (nothing here is slower than 0.3s). Inline props are still fine for one-offs, but prefertransition/ a named variant so a global re-tune stays a one-line change.Tween or spring — the rule. Tweens (
DURATION/EASE) are for state changes with no gesture behind them: cross-fades, colour shifts, reveals.APPLE_SPRINGis for anything the user acts on — presses, drags, sheet dismissals, morphing containers — because a spring is interruptible: grab a moving element mid-flight and it retargets from its current velocity instead of snapping. It is parameterised the way SwiftUI does it, by perceptualduration+bouncerather than stiffness, which is why the same preset settles in the same perceived time whether it travels 4px or 400px. Presets:smooth(no bounce, the default),snappy(controls/toggles),bouncy(reactions, celebratory pops),sheet(presentation),press. Spreadpressableonto a tappable surface for the standard press acknowledgement rather than re-deriving a scale.iOS large title:
components/ui/large-title.tsxis the collapsing title/nav-bar pair. Scroll position maps continuously onto scale/opacity/translation and onto the bar’s scroll-edge material, so the title tracks the finger 1:1 and reverses mid-gesture instead of popping between two states at a CSS threshold. Transform/opacity only — the collapse costs no layout. Reduced motion collapses it to a static title with a permanent bar.Depth from pointer/tilt:
hooks/usePointerParallax.tsreturns spring-smoothed MotionValues at three fixed depths (0 = glued to the surface, 1 = furthest back), fed by pointer position on fine-pointer hardware and bydeviceorientationon touch. Compose two or three depths in one card — a single sliding layer is just movement; layers moving at different rates read as depth. No React render runs per frame.<MotionConfig reducedMotion="user">wraps the app (Providers.tsx), so framer-motion automatically respects OS reduced-motion.CSS motion:
.page-root > *runs thepage-enteranimation (0.16s fade + 4px rise), suppressed on history-back (html.nav-pop) and during View Transitions (html.vt-active). Feed items use.feed-item-enter. Shared-element View Transitions go throughlib/view-transition.ts.Living backdrop (Liquid Glass material — v2, two layers): part of the glass layer, so it is dormant under the flat monochrome default (
--site-canvasisnone) and returns with the optics. The aurora canvas (body::before) runs an ultra-slow transform-onlyaurora-driftkeyframe, and a far-field layer (body::after, per-theme--site-aurora-far-*stops) counter-drifts at-0.6×the pointer parallax — so pointer motion produces visible depth.hooks/useLiquidBackground.ts(one rAF-throttled listener, mounted inProviders.tsxnext touseGlassLight) writes--aurora-mx/--aurora-my; both layers are gated off under reduced motion andhtml.perf-lite, and stop in high-contrast (canvas isnonethere).The scene light:
hooks/useGlassLight.tsalso writes 8px-quantized--light-x/--light-yon<html>(fine pointers only, static under reduced motion) — every glass rim’s specular glint answers it. Its per-element duty (--glass-px/--glass-pyfor the::afterdiffuse hotspot) and thelib/glass-lens.tsper-element lens-filter generator both initialize from the same single listener. On touch devicesuseLiquidBackground.tsmapsdeviceorientationto the same--light-x/--light-yunderhtml.tilt-live(opt-in on iOS via Settings → Appearance). The aurora follows that input, while coarse-pointer rim glints stay element-anchored; fixed backgrounds can otherwise composite a frame behind their scrolling parent on mobile.Looking at an object (device attitude): the tilt light above is a lean; inspecting something is a full rotation, so it works in quaternions instead.
hooks/useDeviceAttitude.ts(maths inlib/device-attitude.ts, no three.js — it ships on ordinary pages) emits a smoothed orbit per animation frame: the device’s change of heading and elevation since the viewer opened is applied to the object about its own axes, so turning to your left brings its right-hand face round to meet you and raising the phone looks down over its top. Deliberately not the object’s raw relative attitude — that is faithful but tumbles, because a phone is held pitched back and a turn about the world’s vertical then arrives as a rotation about a tilted axis. Consent is the same site-widermh-motion-ok; reduced motion removes it (the control does not render), and a drag/arrow-key path composes on top so desktops and anyone with motion off get the same object. Rendered with CSS 3D — seecomponents/library/Book3DViewer.tsx, where a library book is a real six-faced volume with a printed spine and a block of pages.Shader/DOM synchronization: liquid bodies unregister in layout-effect cleanup and re-sample before paint on route/tab commits. Scroll, nested scroll, elastic touch movement,
visualViewportchanges, page resume, and layout-shift events wake the normally-idle sampler. A liquid pop settles immediately onto its real panel when either endpoint moves mid-animation, preventing a cached GPU bud, rim, or shadow from detaching from its DOM owner.Liquid tabs: tab strips use
components/ui/liquid-tabs.tsx— each rides its own L1 glass sheet (glass-fill glass-bevel-sm rounded-fullpill,sheetprop default) placed below the hero/page-title capsule, never inside header chrome (§5.45; seepage-consistency.md). The active capsule is alayoutIdglass pill that flows between tabs onSPRING.snappyand jumps under reduced motion; on capable engines it also morphs — velocity squash/stretch plus a#glass-goometaball trail (liquid-morph.tsx, §5.47), stripped under reduced-motion / perf-lite / high-contrast. Link-based oraria-controls-rich tab bars keep their own markup and add thelayoutIdcapsule directly (creator studio, RMHLadder).Liquid opens: card→detail navigations morph the clicked glass slab into the detail hero via
runViewTransition(el, { liquid: true })+liquidVTName()(lib/view-transition.ts, §5.48) — the VT name is set at click time and cleared after; the detail’s secondary content (comments, metadata, related lists) then staggers in viastaggerContainer/fadeRise. No-VT browsers get instant nav + the stagger.hooks/useReducedMotion.ts— SSR-safe boolean for JS animations CSS can’t reach;prefersReducedMotion()for imperative checks.hooks/useCelebration.ts— confetti/fireworks; lazy-loads canvas-confetti, no-ops under reduced motion, and reads--site-accent/--site-successoff the DOM so bursts match the active theme.Two
@media (prefers-reduced-motion: reduce)blocks inglobals.csscollapse animations/transitions to 0.01ms (keeping spinner/pulse feedback) and disable theme background effects.
8. Interaction & focus states¶
Global (in globals.css):
Keyboard focus:
:focus-visible { outline: 2px solid var(--site-accent); outline-offset: 2px }covers every interactive element (links, buttons,[role],[tabindex]). Text inputs opt out (border + caret instead);Buttonopts out too (via itsdata-slot="button") and draws its own softerfocus-visible:ring-site-accent/50 ring-2 ring-offset-2. Don’t add anotherfocus-visible:ringto an element the global outline already covers — you’ll get a doubled indicator. Reach for a self-drawn ring only when the element is excluded from (or not matched by) the global rule.Selection uses the accent with its
--site-accent-fgtext; native controls getaccent-color: var(--site-accent); scrollbars are thin and themed.Scrollbars show on scroll, not always. Every scrollbar on the site — the document’s, both desktop rails’, every panel and every full-screen app tier’s — is painted transparent while its scroller is idle and fades in only while that scroller is actually being scrolled.
lib/scrollbar-reveal.ts(installed once fromProviders.tsx) stampsdata-scrollingon the scrolled element and clears it ~900ms after it stops; the§Scrollbarsblock inglobals.cssis what paints. Two things to know before touching it: the reveal is done with colour, neverscrollbar-width, so the gutter stays reserved and revealing a scrollbar can never reflow the page; and per the engine note in that block, Chromium ignores::-webkit-scrollbar*rules on any element that has a standardscrollbar-width— which* { scrollbar-width: thin }gives every element — so the standard properties are the live path there and the::-webkit-*rules are the older-WebKit fallback. An app tier sets its hover colour with--sb-thumb-hover, not a:hoverrule of its own.Cross-engine consistency: the accent outline replaces every browser’s default focus ring; Firefox’s
::-moz-focus-innerdotted border and:-moz-ui-invalidred validation glow are neutralized;::placeholderis themed atopacity: 1(Gecko dims it otherwise); autofilled fields are repainted to theme colors (WebKit/Blink); scrollbars are themed for bothscrollbar-width/scrollbar-color(Gecko) and::-webkit-scrollbar(WebKit/Blink).Tap highlight removed; active press feedback is
opacity: 0.6.Inputs hold a 16px font floor below 640px (prevents iOS zoom).
9. Accessibility¶
eslint-plugin-jsx-a11yruns at “warn” (curated rules ineslint.config.mjs) — don’t introduce new warnings.Prefer native elements and the Radix-based primitives in
components/ui/over hand-rolled widgets.Patterns to copy: skip link in
_site.tsx(sr-only focus:not-sr-only→#main-content),role="status"+ label on loaders,aria-hiddenon decorative icons,aria-labelon icon-only links/buttons.RTL locales are first-class:
<html dir>is set pre-paint fromRTL_LOCALESinlib/i18n/config.ts(todayarandur— read the constant, don’t hardcode a locale test). Use logical spacing where possible and.rtl-flipon directional icons.high-contrastis an explicit theme choice (there is no@media (prefers-contrast)hook) — test new UI against.style-high-contrastand.style-light, not just the default dark theme.
10. Strings (i18n)¶
Every user-facing string goes through t() with a defaultValue (English is
the authoritative source):
const { t } = useTranslation('site');
t('wallet-title', { defaultValue: 'Wallet' });
Namespace conventions (files under locales/en/):
Namespace |
Used for |
|---|---|
|
most |
|
core shell + feed |
|
feature areas |
|
component strings |
|
route/experience entry strings |
After adding strings run pnpm i18n:extract (populates all
locales/*/<ns>.json), and pnpm i18n:coverage to check locale coverage.
See lib/CLAUDE.md §i18n for the full pipeline (resource generation, RTL,
lazy locale chunks).
11. Do / Don’t summary¶
Do
Use
--site-*utilities (bg-site-surface,text-site-text-muted,rounded-site, …) for every color, radius, shadow, and font.Use
components/ui/primitives,PageLayout/AnimatedMain,EmptyState,Skeleton,Spinner, sonner toasts, lucide icons.Give buttons in-flight feedback with
<Button loading>(not a hand-rolleddisabled+<Loader2>), and animate with the tokens/variants inlib/motion.tsinstead of ad-hocduration/easenumbers.Add
data-slot="..."to new shared primitives so themes can restyle them.Wire every string through
t(..., { defaultValue }).Test in
default,light, andhigh-contrastthemes and under reduced motion.
Don’t
Hardcode hex/oklch colors,
rounded-lg, custom shadows, or font families in site UI.Re-add navigation/sidebars inside a page (the
_siteshell owns them).Use
react-icons, an ad-hoc standaloneLoader2where<Spinner>belongs (inlineLoader2inheriting a button’s colour is fine), or hand-rolled dialogs.Put a full-screen experience under
_site/, or a standard page outside it.Bypass Twemoji for emoji or
jsonLdScript()for JSON-LD.Re-implement something
components/shared/already provides for full-screen apps (shell, header, toaster, connection status) — see §12.
12. The full-screen app tier (--app-*)¶
The site’s --site-* contract stops at the _site shell. Full-screen apps
(RMHbox, RMHType, RMHStudy, RMHTube, RMHMusic) live at the top level of
app/routes/, are excluded from the theme system (THEME_EXCLUDED_ROUTES), and
have their own parallel contract — which is deliberately the same shape, so
what you know from §1 transfers.
components/shared/app-theme.css owns it: an --app-* token set
(--app-bg/-subtle, --app-surface/-hover/-active, --app-border(-bright),
--app-text/-muted/-dim, --app-accent/-fg/-hover/-dim,
--app-success/-danger/-warning/-info/-rare, --app-font-*,
--app-radius/-sm/-lg, --app-shadow/-sm,
--app-duration-fast/-duration/-slow + --app-ease, density and
--app-safe-* inset tokens) plus every behaviour built on it — scrollbars,
focus rings, ::placeholder, Gecko’s focus/validation quirks, tap-highlight and
double-tap-zoom suppression, the iOS sub-16px input-zoom floor, dvh heights,
and deliberate degradation for backdrop-filter,
prefers-reduced-transparency, forced-colors and print.
An app ships only its palette. Each app’s CSS is a class
(rmhbox-theme, …) that redefines the --app-* values — RMHbox’s is ~30 lines.
Components read --app-* directly; there is no per-app alias layer.
The shared pieces, all in components/shared/:
Piece |
What it owns |
|---|---|
|
Root wrapper: palette class + appearance modifier ( |
|
The app bar as a three-column grid — genuinely centred title that truncates instead of overlapping, steps aside below 640px, back label collapsing to its arrow below 480px with an |
|
One toast store and container for every app; timers are tracked, so a hand-dismissed toast doesn’t leave a second one running and a route change cancels rather than leaks. |
|
Reconnect banner + peer-wait overlay, so every socket-backed app reports an outage the same way, in the same place, with |
|
Loading/error surfaces for game routes (strings in the |
The rule: a new full-screen app writes a palette class and mounts
AppShell/AppHeader. Adding a sixth copy of the shell, the header, the toast
store or the connection banner is the defect this tier was created to end — a
fix to any of it previously had to be made five times, and in practice never
was. Games with a bespoke visual identity (Temple of Joy, Slice It, Neon
Driftway) keep their own scoped variable groups in globals.css; they are
exempt from the palette, not from the shared behaviour.
12.1 The full-screen viewport contract¶
The palette is what an app may own. The viewport is not: a game that clips
its own pause menu is broken in the same way whatever its colours are. These
four primitives live in app/globals.css (§”Full-screen app/game layout
helpers”), deliberately global rather than in app-theme.css, so a game with
its own design system does not have to adopt the app palette just to stop its
bottom bar hiding under a phone’s browser chrome.
Primitive |
Use it for |
|---|---|
|
The root of a non-scrolling app shell. |
|
The root of a full-screen screen that is a document — a landing, a lobby, a results card. Scrolls the page, not a box inside it. See rule 6. |
|
A menu / results / lobby screen: full height, centred while it fits, scrolled when it does not. |
|
A fixed-aspect playfield. Put the HUD inside |
|
A full-bleed chrome layer, inset by the device’s safe area. Geometry only — it does not touch |
|
Insets on a surface that is itself full-bleed but whose contents are not (a translucent bar, a letterbox gradient). |
|
The software keyboard’s measured height, so a fixed-height shell can end where the keyboard begins. |
Six rules, each of which was a shipped defect before it was a rule:
The world runs to the edge; the controls do not. Artwork should pass under the notch. A pause button must not. Anything pinned to a viewport edge adds
var(--safe-top|right|bottom|left)to its offset, or sits in an.app-hudlayer that already has. Picture landscape when you check — that is where the notch takes a long edge, and it is the orientation most of these games are played in. Bottom-only insets are the common half-fix.aspect-ratioderives only the axis you leftauto. Sowidth: 100%; max-height: 100%; aspect-ratio: W/His not a fit — the clamp shortens the box without narrowing it and the playfield skews. Setting both axes definite is worse: the ratio is ignored outright. Use.app-stage, which states the fit in container-query units where there is nothing left to clamp.Centring an overflowing flex container hides the top of it. Content taller than the container overflows past both edges, and scroll offsets cannot go negative, so the top is unreachable — with or without
overflow-y: auto. Useitems-center-safe/justify-center-safe(CSS:align-items: safe center) on anything that both centres and can scroll.A full-screen canvas clamps its device-pixel ratio. Fill rate scales with the square of the ratio: a 3× phone at native asks for nine times the pixels of a 1× screen, sixty times a second. 2D surfaces go through
gameSurfaceDpr()(lib/display-scale.ts, capped at 2); 3D surfaces through the tier system (lib/render/tier.ts). Whatever sizes the drawing buffer must also be what the render transform uses — clamping one and not the other scales the whole scene. And never reassigncanvas.widthinside a rAF loop: it reallocates the backing store every frame, and if it was also what cleared your surface, add the explicitclearRectwhen you stop.A shell with text entry subtracts
--kb-inset. The same shell that cannot scroll also cannot reveal a focused field when the keyboard covers the bottom 40% of the screen — anddvhdoes not help, because it tracks the browser’s toolbars, not the keyboard. So the engine moves the only thing it can, the visual viewport: it pans, and where the field still will not fit, it scales. Players report that as the app zooming when they try to type. Size such a surfacecalc(100dvh - var(--kb-inset, 0px))and mountuseKeyboardInset; the shell then ends where the keyboard begins and there is nothing left to reveal. Two companions on the field itself: don’tautoFocuson a coarse pointer (it raises the keyboard for something the player didn’t ask for), and setinputModeso a digits-only field gets the numeric pad rather than a QWERTY that covers a third more of the screen.A screen that is a document scrolls the DOCUMENT.
.app-viewportis for a surface that never scrolls; reaching for it on a landing, a lobby or a results card and then putting aflex-1 overflow-y-autobox inside costs a phone about 110px of screen, permanently. Mobile Safari collapses its address/tab bars only for document scroll — an inner scroller leaves them at full height for the whole visit, which is why the feed, which flows in the document, feels taller than an app screen on the same phone. Use.app-page:min-height: 100svh, a flex column, no inner scroller, and the safe-area gutter at the end. The rule of thumb is what the screen is, not where it lives: if the content is a column you read top to bottom, it is a document, even inside a game. RMHType’s room is both in turn —.app-pageas a lobby,.app-viewportonce the race starts — and resetswindow.scrollYacross the switch, because a fixed viewport cannot undo a scroll offset it inherits.
Rules 2–4’s checkable parts, plus the dvh fallback pair, are enforced by
lib/__tests__/game-viewport-consistency.test.ts (§13). Rules 1 and 5 are
manual — absolute top-3 right-3 is correct inside a card and wrong on the
window, and whether a shell ever hosts a text field is a question about its
subtree, not about the rule that sizes it.
13. What is enforced automatically¶
Most of this document is convention. A slice of it is executable, and runs in
the normal suite (pnpm exec vitest run, gated by web-ci.yml):
lib/__tests__/design-consistency.test.ts— one tab-strip grammar. A static source scan overcomponents/+app/routes/that fails on: (1)role="tablist"outsidecomponents/ui/liquid-tabs.tsxand its documented allowlist; (2) anaria-selectedelement that also carries aborder-b/underlinemarker; (3) an inline tab-capsulelayoutIdoutside the sanctioned renderer; (4) the conditional accent-underline bar shape (absolute+bottom-0+ hairline height +bg-site-accent). The allowlists are short and each entry is justified in the file — new tab strips get no entry. The test also asserts it scanned >200 files, so a broken walker can’t make the rules vacuously pass. Its own docstring records the one rule it can’t automate: a role-less switcher that marks its active slot some other way (an accent pill, a segmented control, aflex-1button row with an active tint) still belongs onLiquidTabs. Reviewers catch those by eye.lib/__tests__/game-viewport-consistency.test.ts— the full-screen viewport contract (§12.1). A static scan over the thirty game/app directories that fails on: (1) a scrolling flex/grid container that centres on the block axis without the-safevariant (the overflow-is-unreachable trap); (2) anaspectRatiobeside amaxWidth/maxHeightclamp, or with both axes definite, either of which defeats the ratio; (3) aheight/min-heightof100vhwith nodvh/svhline after it. Class strings are read fromclassName=specifically, not from string literals generally — an apostrophe in prose opens a literal to a naive scanner and produces confident nonsense. Like the tab gate it asserts it scanned >200 files, so a broken walker can’t make the rules pass vacuously. Its docstring records the three manual rules: safe-area insets on edge-pinned chrome, DPR clamping on full-screen canvases, and no per-frame canvas reallocation.lib/__tests__/appearance-contrast.test.ts—ensureReadableAccent()keeps a custom accent from shipping an unreadable--site-accent-fgpair.lib/__tests__/i18n-catalogs.test.ts/i18n-config.test.ts— catalog and namespace-registry integrity for §10.lib/__tests__/raf-loop-allowlist.test.ts— keeps rAF loops (the §7 radial motion layer) to the sanctioned owners instead of one per feature.pnpm lint—eslint-plugin-jsx-a11yat “warn”; the bar is no new warnings versus the base branch.
A green suite means you did not regress the enforced rules. It does not mean the change looks right — §0.9 is still a human looking at three themes.