Page Consistency Guide — building a page that looks native¶
Audience: coding agents and contributors adding or editing pages. Companion to
docs/design-language.md(tokens, themes, primitives) andapp/CLAUDE.md(routing mechanics).
The site’s design language is Radial Avant-Garde Glass (see
design-language.md): a radial shell — fixed ring
backdrop, drifting aurora, slim top bar, and a central RMH hub that blooms
into the liquid globe you turn to navigate — wrapping content rendered in
the Liquid Glass material, in a strict high-contrast monochrome palette.
The glass is shipped, not aspirational: surfaces are translucent tints over the
shared aurora, every tier carries a rim glint, and the tier class is what the
degradation switches (high contrast, reduced transparency, perf-lite) act on.
Pages look consistent because they share four things: the _site radial
shell, the PageLayout column system, the --site-* token contract, and the
same route-level conventions (head/SEO, i18n, auth, loading and error states).
This guide is the recipe.
1. Decide the page type first¶
Type |
Where the file goes |
Gets |
|---|---|---|
Standard site page (feed, wallet, settings, admin, …) |
|
Radial shell (ring backdrop, top bar, central hub nav), skip link, page entrance |
Full-screen experience (game, |
|
Nothing — you own the whole viewport |
This split is deliberate. Games, login, secret/*, the legal pages
(terms, privacy, cookies, copyright, security) and discord/* are
intentionally top-level — do not “fix” them into _site/.
2. Canonical standard page¶
// app/routes/_site/example.tsx
import { createFileRoute } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { PageLayout } from '@/components/feed/PageLayout';
export const Route = createFileRoute('/_site/example')({
head: () => ({ meta: [{ title: 'Example | RMH Studios' }] }),
component: ExamplePage,
});
function ExamplePage() {
const { t } = useTranslation('site');
return (
<PageLayout title={t('example-title', { defaultValue: 'Example' })}>
<div className="px-4 pt-4 pb-12 max-w-2xl mx-auto">{/* content */}</div>
</PageLayout>
);
}
PageLayout (components/feed/PageLayout.tsx) supplies a flat, transparent
big-type header — its h1 in the theme display font, sitting directly on the
radial ring backdrop (the radial content layer strips the old bordered header
capsule) — plus an optional back arrow (backTo/backLabel), optional
breadcrumbs, and the width-constrained center column. There is no border-r
app-frame edge and no in-page sidebar — the shell owns navigation (the radial
hub everywhere, plus a persistent nav rail ≥1120px). The center column carries
pb-dock to clear the mobile safe area.
rightSidebar is not an in-page column: it is portalled into the shell’s
live rail, which exists only ≥1440px. Treat it as supplementary — anything
load-bearing belongs in children.
Props: title, children, rightSidebar?, headerRight?,
wide?, backTo?, backLabel?, breadcrumbs?.
The header is not sticky. The radial content layer unpins page-level sticky
chrome — headers, tab strips, search bars — so every _site route flows on the
document’s own scroll like the feed. That is what lets mobile Safari collapse its
toolbars. Don’t re-pin one.
Feed-column variant¶
Pages that render a raw column (achievements, bookmarks) skip PageLayout and
use AnimatedMain directly:
import { AnimatedMain } from '@/components/feed/AnimatedMain';
import { WIDE_NO_RIGHT_SIDEBAR_WIDTH } from '@/lib/layout-width';
<AnimatedMain className="w-full min-w-0 pb-dock" targetWidth={WIDE_NO_RIGHT_SIDEBAR_WIDTH}>
{/* column content */}
</AnimatedMain>;
(No trailing spacer div is needed any more — the shell’s frame is a grid and owns the rail track.)
Widths come from lib/layout-width.ts: DEFAULT_WIDTH 648, WIDE_WIDTH 800,
WIDE_NO_RIGHT_SIDEBAR_WIDTH 952. Don’t invent new column widths — they are the
page’s preference (--main-target), and the shell raises the floor on wide
screens (--rad-measure) so the column uses the window it is given. Pass
wide (or wide on PageLayout) when the page should take the whole content
track instead.
3. The consistency checklist¶
Work through this for every new or edited page:
Structure¶
File in the right place (
_site/vs top level) — see §1.Wrapped in
PageLayout(or theAnimatedMaincolumn pattern).Inner content padded
px-4 pt-4 pb-12(usually withmax-w-2xl mx-auto).No sidebars/nav re-implemented — the shell owns them.
Visual tokens (see design-language.md §1)¶
Colors/borders/text only via
site-*utilities; radii viarounded-site/rounded-site-sm; shadows viashadow-site.Ink tracks its surface. On a filled surface, use that surface’s paired foreground —
bg-site-accent→text-site-accent-fg,bg-site-danger→text-site-danger-fg.globals.csssupplies these by default (zero specificity), so a background-only element is already correct; what you must not do is hardcodetext-white/text-blackon a themed surface — it survives today’s accent and breaks against a user’s. Translucent tints (bg-site-accent/15+text-site-accent) are a separate, correct pattern and are untouched.Surfaces via
Card(L1.glass-fillby default;panefor L2,interactivefor the hover tint-raise + hover-raised rim glint + press flex) or the glass elevation classes (design-language.md §5.1) — repeated rows/tiles use.glass-fill(no blur), singular panels.glass-pane, floating UI.glass-overlay. Rawbg-site-surfacestill works (degrades to a translucent L1 tint).Buttons via
<Button variant size>; pills via<Badge>; icons fromlucide-react.Tab strips via
<LiquidTabs>(components/ui/liquid-tabs.tsx) — the active capsule flows between tabs. Each strip rides its own glass sheet (sheetprop, default on) and sits below the hero / page-title capsule in the content flow, separated by the standard gutter — never inside the sticky header (§5.45). A strip that must stay sticky keeps atopoffset clearing the floating header but remains its own sheet; on narrow screens wrap it intab-sheet-scrollso it scrolls horizontally instead of clipping. Exception: tab bars that are really route links (RMHLadder) or need richer ARIA (aria-controls) keep their own markup and add thelayoutIdcapsule + sheet wrapper directly. Every such custom capsule still carries the §5.47 morph underlay (useLiquidMorph+ the two-layer outer-layoutId/inner-material span split) — a strip is either fully liquid or it isn’t shipped (§15.1).Spacing rhythm (§15.4): use
--site-section-gap(12–16px) between sibling glass elements in a column; responsiveSiteShellgutters between columns;PageLayout/.site-sticky-chromeowns the gap from a page header to the first content below it. Internal padding at the primitive’s canonical value, never a cramped per-page override: text inputs/wellspx-3 py-2(with 44px targets restored for coarse pointers), card contentpx-4 py-3+, menu/list rows ≥12px inline padding (text never touches the glass edge).Dialogs and editors: standard dialogs use
DialogContentand remain horizontally centered within the visual viewport. Complex or wide forms passmobileFullscreen; large sheet editors do the same withSheetContent. Keep sibling controls atgap-2or greater, section groups atgap-3/space-y-3or greater, and leave close-control clearance in the shared header instead of adding one-off offsets.Sticky stacking (§15.5): a column has one sticky group. Either merge related co-stickies (tabs + search) into a single sticky glass container, or cascade independent stickies with cumulative
topoffsets using.site-sticky-secondary(which accounts for the condensed header, viewport edge, and section gutter). Primary column chrome uses.site-sticky-chrome; editor-internal sticky bars use.site-sticky-contained. Never pin two stickies to the sametop— they overlap and hide each other while scrolled.Liquid Glass optics are live, inside the radial shell included: the tier you pick paints real material (tint, blur where the tier has it, rim glint on panes/overlays/chrome, hover glint on fills) and every degradation is central, so never branch per-component. Never rely on an optic for legibility — text has to hold on
--site-surface-opaquetoo, because high-contrast, reduced transparency and perf-lite all collapse to it. Opt into the rationed extras only when a page’s spec says so:.glass-refract(≤2/page, hero/chrome only — a frosted edge bevel today; the displacement lens is parked, see design-language.md),.glass-refract--prism(≤1/page, static chromatic rim),.glass-liquidambient sheen (≤3/page),.glass-sheen-hover(primary CTAs).Nothing on the page reacts to pointer position. No
pointermovelistener, no gradient or background position driven by a cursor, no per-card sheen or tilt hook — all of that was retired site-wide (design-language.md §5.1.1), because moving a gradient repaints the whole element at pointer rate. Hover is a state (:hover, a class), never a coordinate; if you want light on a surface, the tier class already paints it.Nothing writes a custom property to
<html>per frame. Root custom properties are inherited by the entire document and have no invalidation set, so one such write per frame is a whole-document restyle per frame — it was the single biggest cost the site ever shipped (performance-audit-2026-08-01.md). Write to the element that reads the value.
States¶
Loading:
<Skeleton>blocks (addshimmerfor hero placeholders) or<Spinner>(router-level pending is already handled byRoutePending). Buttons that trigger async work use<Button loading={…}>for in-flight feedback — never a hand-rolleddisabled+<Loader2>.Empty:
<EmptyState icon title description action?>.Errors: rely on route
errorComponentinheritance (components/errors/RouteErrorFallback); thrownotFound()in loaders for 404s (renderscomponents/errors/NotFound).Feedback:
toastfromsonner— never custom toast UI.Destructive confirmations:
await useConfirm()({ title, danger })— never nativewindow.confirm(it ignores the theme system, i18n, and focus-trapping).Copy-to-clipboard:
<CopyButton value={…} label={…} />— don’t re-rollnavigator.clipboard.writeText+ a localcopiedstate.Nested pages (2+ levels): give orientation via
PageLayout’sbreadcrumbs/backToprops (or the<Breadcrumbs>primitive).Signed-out (if auth-gated): either redirect in
beforeLoad(throw redirect({ to: "/login", search: { callbackURL } })) or render a centered sign-in prompt with<Button variant="accent">.
Head / SEO (see app/CLAUDE.md for details)¶
head()returns at minimummeta: [{ title: "X | RMH Studios" }].Public/marketing pages: use
buildMeta()+buildCanonical()from@/lib/seo; content pages add JSON-LD viajsonLdScript(...)from@/lib/schema.
i18n¶
Every user-facing string through
t("key", { defaultValue: "…" }), namespacesitefor standard pages (see design-language.md §10 for the namespace map).pnpm i18n:extractrun after adding strings.
Motion & accessibility¶
framer-motion for JS animation (MotionConfig already gates reduced motion); gate imperative animation with
useReducedMotion(). Prefer the shared tokens/variants inlib/motion.tsover ad-hocduration/ease.Card→detail navigations (posts, images, books, blog/news) open with the liquid morph:
runViewTransition(el, { liquid: true })+liquidVTName()(lib/view-transition.ts, §5.48), then stagger the detail’s secondary content — don’t hard-cut into a detail view.Icon-only controls have
aria-labelorsr-onlytext; decorative iconsaria-hidden.Keyboard path works (focus-visible rings are global; don’t suppress outlines).
Focus ring stays visible against the surface it lands on — the global ring is
2px solid var(--site-accent)(offset 2px), so any theme whose--site-accentapproaches its--site-surfacemust be checked. Tab to the skip link (_site.tsx, the first focus stop) under.style-high-contrastspecifically: the ring vs. surface must clear WCAG 1.4.11 (≥3:1).Check the two other shipped themes —
.style-graphite(Midnight, the dark twin) and.style-high-contrast(glass off entirely) — plus reduced-transparency, not just the Daylight default.Colour is not the only carrier of meaning. Status must survive the colour-vision modes (design-language.md §2.1), which retint
--site-success/--site-danger/--site-warning— so pair the colour with a glyph, a label or a position.<Badge>already does; use it rather than a bare tinted dot.The page still reads at 125% text size and in
compactdensity (Settings → Appearance) — both are token-level and neither should need a per-page override.Mobile: bottom padding clears the floating dock (
pb-[calc(env(safe-area-inset-bottom,0px)+92px)] md:pb-0on the column — PageLayout does this); tap targets comfortable at 480px (xsbreakpoint).
Before committing¶
pnpm check:consistency— the commit gate. It scans the added lines for the rules CI fails on, runs the executable gates inlib/__tests__/(design-language.md §13), lints the changed files, typechecks and checks the generated docs, then prints what it cannot check. It runs itself ongit commitvia.githooks/pre-commit(afterpnpm hooks:install) and, in agent sessions,.claude/hooks/commit-gate.sh. Fix what it reports — don’t reach for--no-verify.pnpm exec tsc --noEmitandpnpm lintintroduce no new warnings.pnpm test— the full suite, before you push.Dev server run once so
app/routeTree.gen.tsregenerates (never edit it by hand).Walk design-language.md §0 (definition of done) once more against the finished diff. A green gate means you did not regress an enforced rule; it does not mean the page looks right.
4. Full-screen games/apps¶
Apps (RMHbox, RMHType, RMHStudy, RMHTube, RMHMusic) are not freeform: they
share the --app-* token contract and chrome in components/shared/
(app-theme.css, AppShell, AppHeader, AppToaster + lib/shared/app-toast,
ConnectionStatus). A new app writes a palette class and nothing else —
mount AppShell with it, use AppHeader for the app bar. See
design-language.md §12; writing a sixth shell, header
or toast store is the exact drift that tier exists to prevent.
Games own their viewport and their look, but still share:
components/shared/GameLoadingFallback.tsxandcomponents/shared/GameErrorBoundary.tsx(strings in thesharednamespace).Auth via the same session (
useSession()from@/components/Providers, or abeforeLoadredirect).Reduced-motion respect where feasible (canvas/WebGL excluded).
The theme class is suppressed on game routes (
THEME_EXCLUDED_ROUTESincomponents/Providers.tsx) — game UIs use their own palettes, often defined as dedicated variable groups inglobals.css(e.g.--temple-*,--slice-*,--neon-*).Realtime games connect through
lib/<game>/socket.tssingletons — seeserver/CLAUDE.mdfor ports, paths, and event naming.
5. Common drift patterns to avoid¶
These are the mistakes that make a page feel “off” — reviewers will flag them:
Hardcoded colors (
bg-zinc-900,text-white, hex values) instead ofsite-*tokens — breaks every theme at once. CI-enforced for site-tier files (design-language.md §13 rule 5). Two legitimate escapes, both tokenised: a chip that sits on a photograph uses the--site-media-*contract (theme-tracking ink over someone’s snapshot is the wrong answer), and a domain-fixed palette — a playing card, a roulette pocket — gets a scoped variable group like--casino-*.rounded-lg/rounded-2xlinstead ofrounded-site*— hardcodes a radius that ignores each theme’s--site-radius/--site-radius-sm. CI-enforced (§13 rule 6).rounded-fullandrounded-noneare shapes, not radii, and stay fine. 2b. A dropdown / popover / menu / tooltip on.glass-fillinstead of.glass-overlay. L1 has no backdrop blur — it is the tier for repeated cards — so a menu built on it is transparent over whatever it opened on top of. CI-enforced (§13 rule 7). 2b2.transition-all. It makes the engine watch every animatable property including the layout ones, so a state change that toucheswidthorgapanimates a reflow. Name what moves (transition-colors,transition-transform,transition-[a,b], or plaintransition). CI-enforced (§13 rule 8). And if a layout property IS what you want to animate, reach for a transform first — a progress bar isscaleXon a full-width fill, not an animatedwidth. 2c. A bareduration-200/duration-300instead ofduration-site/duration-site-fast/duration-site-slow, which follow the theme’s--site-transition-speed. Anything the user drags is a spring (APPLE_SPRING), not a duration at all.A custom page header instead of
PageLayout’s — or re-pinning one. The shell’s header is a flat, transparent big-type block floating on the ring backdrop, and the radial content layer deliberately unpins page-level sticky chrome so every route flows on the document’s own scroll (which is the only way mobile Safari collapses its toolbars).Arbitrary column widths instead of
lib/layout-width.tsconstants.Hand-rolled modals/spinners/empty states/copy-buttons instead of the
components/ui/primitives, or nativewindow.confirminstead ofuseConfirm.Untranslated strings (missing
t()), ort()withoutdefaultValue.Forgetting the bottom padding (
pb-dock) on custom columns → content hidden behind the floating mobile dock..pb-dockclears the dock, the home indicator, and iOS Safari’s floating tab bar (via--safe-bottom) on mobile and collapses to 0 at md+. Also: putting a backdrop tier (.glass-pane/chrome/overlay) on repeated list items (blur cost) or on an ancestor of aposition:fixedelement (containing block) — see design-language.md §5.1.Adding
react-icons, new font imports, or one-off animation systems. 8b. Re-introducing cursor tracking — apointermovelistener, a hover hotspot, a card tilt, a “follow the mouse” glow. The whole class of effect was removed in one pass (design-language.md §5.1.1). Same for a reveal-on-scroll that defaults toopacity: 0: useuseReveal, whose hidden state is opt-in and therefore cannot strand content when the JS does not arrive.Re-adding app-frame edges:
border-r border-site-borderon page columns, full-bleedsticky top-0 border-bheaders, or a re-implemented sidebar — the radial shell owns the chrome (ring backdrop, top bar, central hub nav), and PageLayout’s header is a flat big-type block floating on the backdrop, not a bordered capsule.Hand-rolling a
layoutIdtab capsule instead ofLiquidTabs, or stacking.glass-refractonto a.glass-chrome--asideelement (its::beforeis the blur carrier, so the lens band has nowhere to live — see design-language.md §5.1). The aside’s glint rides its own border-box background layer now (§4.35), not a pseudo.