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, slim top bar, and a central RMH hub that blooms the navigation —
wrapping content that ships as a flat high-contrast monochrome baseline
today and references Apple-style Liquid Glass optics as its direction. 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?.
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 pointer light) 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).
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
.style-light(Glass Light) and.style-high-contrast(glass off), plus reduced-transparency, not just the default monochrome baseline.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 pushing¶
pnpm exec tsc --noEmitandpnpm lintintroduce no new warnings.pnpm exec vitest run— the suite carries the UI consistency gate (lib/__tests__/design-consistency.test.ts, design-language.md §13), which fails on hand-rolled tab strips and active-underline markers.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.
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.rounded-lg/rounded-2xlinstead ofrounded-site*— hardcodes a radius that ignores each theme’s--site-radius(16px) /--site-radius-sm(10px).Custom headers instead of
PageLayout’s sticky header.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.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.