DESIGN SYSTEM

THE REGISTRY

Every component, rendered from ds/registry.json v3 (2026-07-10). Registry-first: consult before building, extend variants over forking, one-offs are findings.

Swaps CSS vars on the previews only - the app stays lab

TOKENS

Raw values + semantic roles - the only home for literals.04 ENTRIES

accent-api

draftds/tokens/semantic.ts

One accent map (text/border/borderSoft/bg/glow classes + theme-aware accentHex) replacing 12 drifting maps

voltmagentacyangoldbloodfogbone
textbgbordersoftglow
voltAa
magentaAa
cyanAa
goldAa
bloodAa
fogAa
boneAa

a11y · brand theme: accent text <32px must use BRAND_ACCENT.text #00D0DE, never #00F0FF (OLED bloom rule); same split principle applies to lab volt when text-heavy

rule · class strings must be complete literals (Tailwind v4 JIT scanning)

rule · canvas/gradient call sites use accentHex(theme, accent), never a local hex map

rule · domain narrowing helpers (chromeAccent, vip bone) stay in their domains and consume this map

rule · MIGRATION refit item 7 (app accent sweep) LANDED 2026-06-13 (Wave 2b): the 4 app-local hex maps are gone — vip-accents deleted, landing/identity-theme trimmed to HERO_CTA, identity-accents trimmed to the chromeAccent/archetypeGlyph helpers; every accent consumer now reads accentHex(lab,·)/ACCENT_CLASSES from here (pinned to lab for byte-parity). No NEW app code may add another local accent map

rule · brand-skin cyan slot is deep azure #0EA5E9 (shifted from lab #29E6FF, which collides with the brand volt-slot #00F0FF ladder — Tim-ratified 2026-06-13)

used by · ds-internal: 23+ files across primitives/motion/patterns/igaming (SectionTag, MagneticButton, ContourField, GlowWash, NudgeShell, LedgerRow, OddsButton, MicroMarketCard, TierRung, …) · app-direct (Wave 2b accent sweep — local hex/class maps deleted, consumers now import accentHex(lab,·) + ACCENT_CLASSES straight from this api): identity (IdentityShiftScene), you (SkillCard, IdentityHeader, WarArts, VipBand, QuickStats, QuickNavCards), vip (VipHero, TierUpMoment), chrome (PlayerMenu, MobileMenu, MobileMenuFooter), join (SceneYours, SceneArrival), community (CrewCard, RaceBoard), ds/igaming (EngagementDock, OrbitalBar)

layout-tokens

draftds/tokens/semantic.ts

PAGE_CONTAINER / PAGE_MAX_W / PAGE_GUTTER_X — the ONE page-content gutter, byte-aligned to the header nav row (max-w-[1500px] px-5 md:px-8) so the SWOOBZ logo's left edge lines up with page content at every breakpoint. Replaces the drifted vertical/leaf containers (max-w-[1400px] px-5 md:px-10, ~58px narrower).

PAGE_CONTAINER

mx-auto w-full max-w-[1500px] px-5 md:px-8 · header-aligned

rule · class strings are complete literals (Tailwind v4 JIT scanning); compose page-specific spacing via cn(PAGE_CONTAINER, '...')

rule · PAGE_CONTAINER carries mx-auto w-full max-w-[1500px] px-5 md:px-8 — do not re-add mx-auto/w-full at the call site

rule · rails whose gutter lives on inner bleed-scroll children (LaneRail) use PAGE_MAX_W + md:px-8 on the container and keep px-5 md:px-0 on the children

rule · the VerticalHeroScene containerClassName default IS PAGE_CONTAINER; only pass an override for a deliberately non-standard gutter

used by · ds: VerticalHeroScene (default containerClassName) · app: app/sportsbook/page.tsx, components/sportsbook/{SportsbookHeader,LaneRail,LeagueChips,LiveRail,detail/MatchHero,detail/MatchDetail}, components/predictions/PredictionsHeader

motion-tokens

draftds/tokens/motion.ts

EASE, SPRING families (cursor/snap/standard/sheet/slowFill/pop/burst), DURATION, DWELL_MS, STAGGER, PULL_CAP, Z ladder

EASE (out-expo)
SPRING.cursor
SPRING.cursorWrap
SPRING.cursorFollow
SPRING.snap
SPRING.standard
SPRING.sheet
SPRING.slowFill
SPRING.pop
SPRING.burst

rule · components import EASE/SPRING from here — no local ease arrays or spring literals after refit

rule · animate transform + opacity only (verified motion discipline)

rule · z-[NN] literals must match the Z ladder; MobileSlip scrim moves 64 → Z.sheet (74) in refit

used by · all motion call sites after refit

density-tokens

draftds/tokens/density.ts

Admin density scale (comfortable/compact/ultra-compact) + densityToCssVars; flat --density-* namespace driving the [data-density] cascade. comfortable mirrors today's player geometry (SSR-safe default).

comfortablecompactultra-compact
comfortable
compact
ultra-compact

rule · drift-tested against globals.css via density-css.test.ts — every default + both override blocks + both @custom-variant lines must match densityToCssVars()

rule · comfortable is the SSR-safe default (mirrors today's player geometry); admin never reskins the player

rule · Exposure-Red is the ONLY sanctioned [data-domain][data-density] compound selector — in ultra-compact admin --color-hero swaps volt→blood so KPI/exposure surfaces consuming var(--color-hero) inherit it for free; no other compound data-attr selector may be added

rule · --color-hero is a NEW semantic alias added to BOTH lab+brand themes (defaults to the primary accent so it exists outside admin)

used by · ds/admin/DensityBoundary.tsx · app/globals.css ([data-density] blocks)

PRIMITIVES

Buttons, inputs, meters - the atoms every surface consumes.20 ENTRIES

glyph

stableds/primitives/Glyph.tsx

THE icon system — 29 stroke glyphs (added logout for the admin top-bar sign-out control; activity/beaker/layers/ticket/users/link/sliders/chart/arrow landed for the admin scaffold + cockpit wave), currentColor, weight prop. Glyph-only icons platform-wide

crownboltflamesnowskullcompassorbbladesfootballbasketballtennisesportsoctagondiamondnodesactivitybeakerlayersticketuserslinksliderschartarrow
proptypedefault
idGlyphId
weightnumber1.7
classNamestring

a11y · decorative by default (aria-hidden); meaning must come from adjacent text

rule · no other icon source is permitted — extend GlyphId instead

rule · bell/search/check added by MAKER-NOTIFS (Wave 3) — single-owner edit; MAKER-SEARCH/CHROME consume id strings without re-editing this file

used by · 60+ files

magnetic-button

stableds/primitives/MagneticButton.tsx

Primary CTA — magnetic lean capped at PULL_CAP px, glow on hover

solidghostbloodidlehoverdisabled
proptypedefault
childrenReact.ReactNode
variant"solid" | "ghost" | "blood""solid"
onClick() => void
disabledbooleanfalse
classNamestring

motion · capped magnetic pull (6/4px) · SPRING.snap return · glow shadow on hover

a11y · focus-visible: 2px accent outline, offset 3px; forced-colors: 3px Highlight outline

rule · honest affordance: no lean/hover answer when disabled

rule · pull is capped in px, never proportional (failed-attempts 2026-06-11)

rule · label renders CSS-uppercased — tests match case-insensitively

used by · 30 files — every CTA

section-tag

stableds/primitives/SectionTag.tsx

Mono editorial micro-label with accent dash

voltmagentacyangoldbloodfog
voltmagentacyangoldbloodfog
proptypedefault
childrenReact.ReactNode
accentSectionTagAccent (Exclude<AccentName, "bone">)"volt"
classNamestring

used by · 57 files

count-up

stableds/primitives/CountUp.tsx

Animated number with prefix/suffix/decimals; fail-safe static value

124,800
proptypedefault
valuenumber
prefixstring""
suffixstring""
decimalsnumber0
durationnumberDURATION.countUp
classNamestring

a11y · tabular-nums + ligatures off on ALL financial data (old-DS §1.2/§4.5)

used by · 26 files

otp-boxes

stableds/primitives/OtpBoxes.tsx

The canonical input ceremony — 6-digit code boxes with paste support, shake/clear keys

idlefilledshakedisabled

Previewed disabled - the live ceremony auto-focuses on mount (/join).

proptypedefault
clearKeynumber
shakeKeynumber
disabledbooleanfalse
onComplete(code: string) => void

a11y · each box labeled "Code digit N"; auto-focus on mount

used by · SceneKey

two-tap-button

stableds/primitives/TwoTapButton.tsx

Destructive-action button: first tap arms, second confirms, DWELL_MS.twoTapDisarm auto-disarm

idlearmedconfirming
proptypedefault
labelstring
armedLabelstring
variant"solid" | "ghost" | "blood""ghost"
classNamestring
onConfirm() => void

motion · armed state goes blood; DWELL_MS.twoTapDisarm (4s) silence auto-disarms

rule · refit landed: arm/disarm state extracted to use-two-tap; the PlayerMenu sign-out row consumes the hook (no more re-implementation)

used by · settings · PlayerMenu SignOutRow (via use-two-tap)

use-two-tap

draftds/primitives/use-two-tap.ts

Destructive-action arm/disarm hook: first tap arms, second confirms, DWELL_MS.twoTapDisarm silence auto-disarms

Arm / disarm hook powering TwoTapButton - first tap arms, second confirms, 4s silence auto-disarms. See two-tap-button above for the live ceremony.

proptypedefault
onConfirm() => void

rule · TwoTapButton is the canonical presentation; menu rows consume the hook so the timing contract never drifts

rule · draft until design-qa on the refit build — behavior is the shipped settings/PlayerMenu logic verbatim

used by · TwoTapButton · PlayerMenuRows (SignOutRow)

progress-ring

draftds/primitives/ProgressRing.tsx

SVG progress ring: value 0..1, size, accent, line-token track, optional center slot

proptypedefault
valuenumber (0..1, clamped)
sizenumber64
strokenumber4
accentAccentName"volt"
labelstring (aria-label)
decorativebooleanfalse
classNamestring
childrenReact.ReactNode (center slot)

motion · DURATION.ring fill tween with EASE

a11y · role=progressbar with aria-valuenow unless purely decorative (countdown rings are aria-hidden, result announced via text)

rule · geometry lives in progress-geometry.ts (tested); track is var(--color-line), fill is theme-reactive accentVar

progress-bar

draftds/primitives/ProgressBar.tsx

Horizontal meter: value 0..1, accent, height, optional shimmer sweep skin

proptypedefault
valuenumber (0..1, clamped)
accentAccentName"volt"
heightnumber (px)6
shimmerbooleanfalse
labelstring (aria-label)
decorativebooleanfalse
classNamestring

motion · SPRING.slowFill fill; always set explicit initial width (never animate from auto — failed-attempts)

a11y · role=progressbar with aria-valuenow unless decorative

rule · shimmer skin consumes the SHIMMER_SWEEP token recipe; fill always animates from explicit initial width 0

chip-selector

draftds/primitives/ChipSelector.tsx

Single-select chip row (stake amounts etc.): options, value, onChange, accent

proptypedefault
optionsreadonly ChipOption[] ({ value, label })
valuestring | number | null
onChange(value) => void
accentAccentName"volt"
labelstring (radiogroup aria-label, required)
disabledbooleanfalse
classNamestring

a11y · radiogroup semantics; selection carried by solid accent fill + aria-checked, never color alone; arrow keys step with wrap and move focus (roving tabindex); FOCUS_RING token

rule · step/wrap logic lives in chip-selection.ts (tested)

rule · chip rows keep individual chips <= 256px (cursor WRAP_MAX_W) so the wrap-ring treatment is deterministic at every viewport

pill

draftds/primitives/Pill.tsx

Non-magnetic pill control/badge family (filters, jump chips, room pills, carousel arrows, +N MORE)

solidghostbadgeidleactivedisabled
+4 MORE
proptypedefault
childrenReact.ReactNode
variant"solid" | "ghost" | "badge""ghost"
accentAccentName"volt"
activeboolean (ghost accent light-up)false
disabledbooleanfalse
onClick() => void
classNamestring

a11y · FOCUS_RING token on interactive pills; interactive pills are buttons, badges are spans (never focusable); type from MICRO_LABEL.label

rule · CTAs stay magnetic-button; TradeButtons stays its own igaming pair

particle-burst

draftds/primitives/ParticleBurst.tsx

Radial particle burst with palette prop

proptypedefault
palettereadonly string[]BURST_PALETTES.gold
countnumber22
reachnumber (min scatter px; band to reach*2.4)90
originClassNamestring (complete literal)"left-1/2 top-1/2"
classNamestring

motion · SPRING.burst scatter + DURATION.burst opacity fade; cleans up on parent unmount

a11y · aria-hidden; purely decorative

rule · LOSS states never burst — celebration is for wins above wager only (anti-LDW)

rule · scatter math lives in burst-geometry.ts (tested, injectable rand)

rule · refit landed: founder/GoldBurst deleted and LevelUpOverlay's local Particles removed — both consume this

used by · ClaimSuccess · LevelUpOverlay

tab-strip

stableds/primitives/TabStrip.tsx

The single sliding-pill segmented control: one hairline track with a volt layoutId pill behind labeled segments. Active = solid fill + aria-selected/checked + label (never color alone); arrows step with wrap via chip-selection and move focus via roving tabindex; segment|strip variants; tablist|radiogroup semantics. The trailing count (TabOption.count) renders as a SUBORDINATE de-tracked tabular tally (dimmer than the label, no caps tracking) so a counted strip reads 'label first, count second' — not the glued 'SLOTS 164' token. strip wrap defaults true; wrap={false} keeps one line for an overflow-x-auto scroll parent (a flex-nowrap className cannot override the base flex-wrap — cn is clsx, not twMerge).

segmentstripactiveinactive
proptypedefault
idstring
optionsreadonly TabOption[] {value; label; leading?; count?}
valuestring
onChange(value: string) => void
accentAccentName"volt"
size"sm" | "md""sm"
variant"segment" | "strip""segment"
wrapbooleantrue
asTabRole ("tablist" | "radiogroup")"radiogroup"
ariaLabelstring
classNamestring

motion · layoutId pill slides SPRING.snap; entrance is opacity/travel-free — the pill must NOT fly from a scaling ancestor (verified-facts no-scaling-ancestor rule)

a11y · tablist|radiogroup + roving tabindex; selection carried by solid fill + aria-selected/checked + label, never color alone; arrows step+wrap via chip-selection

rule · id is the layoutId scope — must be unique per mounted strip so pills never share an origin

rule · no scaling ancestor may sit above the strip (layoutId measurement — verified-facts)

rule · player-facing immersive register (motion-forward sliding pill) — do NOT fold the denser/quieter admin Tabs kit into this; separate register

rule · pure logic (role mapping + roving tabindex) lives in tab-selection.ts; keyboard step reuses chip-selection.ts

rule · draft→stable 2026-06-13 (Wave 2b consolidation): shipped across /history, predictions, casino + community segmented rows and design-qa'd for byte-parity; the gated sportsbook ScopeSegment + LaneRail stay hand-rolled (filmed no-scroll-jump filter gate + layoutId measurement) and are deferred, NOT migrated

used by · components/history/HistoryFilters.tsx · components/predictions/CategoryFilter.tsx · components/community/Boards.tsx · components/casino/filter/LobbyFilterBar.tsx · components/casino/filter/parts/FilterPanel.tsx · components/casino/filter/parts/ThemeStrip.tsx

status-dot

draftds/primitives/StatusDot.tsx

Pulsing status/live dot. Decorative by default (aria-hidden); pass label when the dot signals LIVE/active so the meaning reaches assistive tech via sr-only text — status is never color/animation-only (triple-channel rule). Shared across player + admin registers.

LivebloodvoltmagentagoldSTATIC XS
proptypedefault
accentAccentName"blood"
size"xs" | "sm""sm"
pulsebooleantrue
labelstring
classNamestring

a11y · decorative => aria-hidden; meaningful (LIVE) => role=status + sr-only label so status is never color/animation-only (triple-channel rule)

rule · purely presentational — no admin variant needed; consumed by both player + admin registers

rule · meaning must come from the label (or an adjacent label) — the dot alone never carries status

tooltip

draftds/primitives/Tooltip.tsx

Hover + focus-visible tooltip. The trigger owns focus and gets aria-describedby; the bubble is role=tooltip, non-focusable, mounted/unmounted via AnimatePresence (no persistent aria-hidden toggle), Escape dismisses. Rides Z.tooltip (above modal/menu/playerMenu) so a trigger inside any overlay still surfaces. bg-coal/90 glass surface + ACCENT_CLASSES.borderSoft hairline + MICRO_LABEL.label type. Shows on hover AND focus (keyboard parity — never hover-only); over-limit triggers use aria-disabled (not native disabled) so they stay in the tab order. Supplementary text only — never the sole channel (triple-channel rule).

hiddenopen
proptypedefault
childrenReactNode (the focusable trigger)
contentReactNode
placement"top" | "bottom""top"
accentAccentName"bone"
classNamestring

motion · AnimatePresence opacity + 4px travel via DURATION.crossfade (0.2) + EASE; reduced-motion → opacity only

a11y · trigger gets aria-describedby when open; bubble is role=tooltip + non-focusable, AnimatePresence mount/unmount (no aria-hidden toggle); Escape dismisses; supplementary text only (never the sole channel)

rule · supplementary text only — never the sole channel (triple-channel)

rule · shows on hover AND focus-visible (keyboard parity, never hover-only)

rule · a disabled trigger must use aria-disabled + stay focusable (not native `disabled`, which leaves the tab order and makes the bubble hover-only) — the over-limit deposit presets follow this

rule · bubble rides Z.tooltip (82, above modal/menu/playerMenu) via style={{ zIndex }} — never a bare z-50 literal off the ladder

used by · components/join/SceneFuel.tsx · components/wallet/DepositTab.tsx

avatar

draftds/primitives/Avatar.tsx

Identity disc — initials fallback or a fading image, ringed in the accent. Sizes xs(h-6)/sm(h-8, default)/md(h-11); accent drives the border ring + initials tint (default volt); optional corner status reuses ds/primitives/StatusDot (composition). Image path uses alt={name}; initials path is role=img + aria-label with the initials span aria-hidden; status carries a StatusDot label so online/live is never color-only. Image opacity crossfade on load (DURATION.crossfade), onError → initials.

initialsimage-loadingimage-loadedimage-errored
ALAda LovelaceTRGHOnlineJPJIN_PARK
proptypedefault
namestring (drives initials + accessible name)
srcstring (falls back to initials on absence/error)
size"xs" | "sm" | "md""sm"
accentAccentName"volt"
status{ accent: AccentName, pulse?: boolean, label: string }
classNamestring

motion · image opacity crossfade on load via DURATION.crossfade

a11y · image path alt={name}; initials path role=img + aria-label (initials span aria-hidden); corner status reuses StatusDot label so online/live is never color-only (triple-channel)

rule · greenfield foundational primitive — chat ChatMessageRow author + community rows adopt it in a later consumer pass (no dedupe owed this wave)

rule · corner status is composition over ds/primitives/StatusDot, never a re-rolled dot

rule · first app consumer landed Wave 4: components/community/cosweat seat rail (archetype-accented, sm)

used by · components/community/cosweat/CoSweatSeat.tsx (Wave 4 — first real app consumer; seat faces)

swoobz-wordmark

draftds/primitives/SwoobzWordmark.tsx

The real SWOOBZ wordmark as inlined SVG paths — replaces the plain-text "SWOOBZ" that was standing in for the logo in the admin top bar. Single currentColor fill (the source asset's warm #fd4e07 gradient is DROPPED — cyan-only accent brand rule forbids injecting a second hue into the mark), so it inherits the header text color and has zero external-asset/CSP dependency.

LIVE PREVIEW NEEDS APP CONTEXT · SEE ds: AdminTopBar (default brand slot, replacing the plain-text SWOOBZ span)

proptypedefault
classNamestring
titlestring (flips to role=img + <title>; omit for decorative aria-hidden)

a11y · decorative by default (aria-hidden, no role); pass title to make it an accessible role=img landmark (the admin top bar passes title="SWOOBZ")

rule · no path may carry its own fill/class — every path renders on the single currentColor fill so no warm hue can smuggle back in from the source asset

rule · aspect ratio is fixed by the source viewBox — scale via height only (h-* + w-auto), never stretch

used by · ds: AdminTopBar (default brand slot, replacing the plain-text SWOOBZ span)

switch

draftds/primitives/Switch.tsx

Player-register toggle — a real <button role=switch aria-checked>; h-7 w-12 rounded-full track, h-[18px] knob, transition-all duration-300 (the player pace), data-cursor=hover (the player cursor ring), accent glow when on (SELECTED_GLOW.volt for volt else ACCENT_CLASSES.glow, gated off when disabled). FOCUS_RING; Space/Enter toggle natively; disabled dims to opacity-40 and drops the cursor ring. Triple-channel: knob POSITION + accent fill + label all signal state, never color alone.

offondisabled
proptypedefault
checkedboolean
onChange(next: boolean) => void
labelReactNode
accentAccentName"volt"
disabledbooleanfalse
ariaLabelstring (required when no visible label)
classNamestring

motion · knob + track transition-all duration-300 (the player pace); reduced-motion collapses harmlessly via the OS

a11y · role=switch + aria-checked; Space/Enter toggle natively; FOCUS_RING; ariaLabel required when no visible label; triple-channel (knob POSITION + accent fill + label)

rule · PLAYER REGISTER — carries the cursor ring + 300ms pace + accent glow; the calm ds/admin Switch (100ms peer-checkbox, no ring) stays separate, mirroring the StatusDot player/admin split

rule · accent glow is gated off when disabled

used by · components/settings/PreferencesSection.tsx

skeleton

draftds/primitives/Skeleton.tsx

Shimmer placeholder bar carrying the house animate-shimmer sweep (SHIMMER_SWEEP recipe, WHITE_ALPHA.sweep midpoint) — no new gradient literal. width/height are Tailwind literals from the caller (defaults w-full/h-3). aria-hidden — the loading announcement belongs to Spinner / the table role. Register-neutral placeholder shared by player + admin loading surfaces.

proptypedefault
widthstring (Tailwind literal)"w-full"
heightstring (Tailwind literal)"h-3"
classNamestring

a11y · aria-hidden (loading announcement belongs to Spinner/table role)

rule · reuses the house animate-shimmer utility + SHIMMER_SWEEP recipe (WHITE_ALPHA.sweep midpoint) — NO new gradient literal

rule · register-neutral — a separate player Skeleton would be a byte-identical fork (forbidden duplication); player + admin loading surfaces share this primitive

used by · data-table (loading rows)

mini-trend

draftds/primitives/MiniTrend.tsx

Compact STATIC KPI sparkline — the admin-register twin of the igaming Sparkline (no motion, no gradient id, so it renders in a server component). Min..max-scaled polyline, volt rising / blood falling via var(--color-*), fog tone for skeleton ghosts. Under two points draws an honest dashed resting baseline, never a fabricated flat line. Path math is the node-tested mini-trend-path helper.

Rising
Falling
Resting
proptypedefault
seriesreadonly number[]
tone"auto" | "fog""auto"
classNamestring

a11y · aria-hidden — direction MUST also be carried by an adjacent signed-delta string, never the tint alone

rule · static (no motion) — the admin calm budget

rule · no gradient id / no hooks — renders inside a server component

rule · < 2 points => honest dashed resting baseline, never a fake flat line

rule · tints via accentVar (var(--color-*)) — theme-reactive, no raw hex

used by · scaffold-preview · dashboard-card trend slot

MOTION

Entrances, rails, overlays - immersion as the standard.16 ENTRIES

reveal

stableds/motion/Reveal.tsx

Scroll entrance wrapper — rise + fade + unblur

RISES + FADES + UNBLURS INTO VIEW
proptypedefault
delaynumber0
ynumber44
oncebooleantrue

motion · DURATION.reveal with EASE

used by · 44 files

scramble-text

stableds/motion/ScrambleText.tsx

Decode-in headline text

EVERY ACTION IS A MARKET
proptypedefault
textstring
speednumber28
delaynumber0

a11y · aria-label keeps the real text during scramble

used by · 7 headers

marquee

stableds/motion/Marquee.tsx

Seamless infinite strip — dual-render loop

VOLTMAGENTACYANGOLDBLOOD
proptypedefault
durationnumberMARQUEE_S.base
reversebooleanfalse
pauseOnHoverbooleanfalse

used by · GamesFilmstrip · LiveTicker

tilt-card

stableds/motion/TiltCard.tsx

3D pointer tilt + glare wrapper

HOVER ME
proptypedefault
maxTiltnumber10
glarebooleantrue

used by · 7 card surfaces

transition-overlay

stableds/motion/transitions/TransitionOverlay.tsx

Page-transition overlay: expanding → dissolving phases with hard lifetime ceiling

idleexpandingdissolving

Page-transition scrim: expanding → dissolving; mounted once at app root. RUNNING AT /casino.

motion · DURATION.expand → DWELL_MS.transitionSettle hold → DURATION.dissolve; DWELL_MS.transitionMax failsafe

rule · scrim color from theme (ink), not literal #07080C

used by · app/layout

custom-cursor

stableds/motion/CustomCursor.tsx

Dot + arrow pointer layer with SPRING.cursor ring that wraps ≤200×64 targets in their accent

COLORED WRAP
IDLE DOT
The dot is the pointer - opt in with data-cursor; the live layer is already active on this page.

motion · SPRING.cursor ring chase; Z.cursorRing/Z.cursorDot

rule · data-cursor / data-cursor-color is the opt-in contract

rule · the dot IS the pointer — it never disappears (failed-attempts 2026-06-12)

rule · volt + ink stroke come from theme tokens, not literals

rule · WRAP_MAX_W=256 / WRAP_MAX_H=64 gate the wrap-ring; wider targets get the free ring + visible dot — size demo containers so interactive targets stay under the gate

used by · app/layout

noise-overlay

stableds/motion/NoiseOverlay.tsx

Fixed grain layer at Z.noise

GRAIN @ 0.05 OPACITY

used by · app/layout

contour-field

stableds/motion/ContourField.tsx

Animated topographic canvas — DPR≤2, IO pause, reduced-motion static frame

proptypedefault
accentAccentName"volt"
ringsnumber14
opacitynumber0.16

rule · semantic accents only — strokes resolve via accentHex(lab) + hexAlpha (all 8 hex call sites migrated 2026-06-12)

used by · 8 heroes · app/not-found

sheet

draftds/motion/Sheet.tsx

Overlay sheet shell: side bottom|right, scrim tier, drag-dismiss grab zone, ESC, scroll lock

proptypedefault
openboolean
onClose() => void
side"bottom" | "right""bottom"
scrim"sheet" | "modal" | "takeover""sheet"
zZLayer"sheet"
ariaLabelstring

motion · SPRING.sheet travel; entrance is opacity + travel ONLY — a scaling ancestor breaks layoutId children (verified-facts)

a11y · role=dialog aria-modal; initial focus into panel + Tab trap + focus restore via use-overlay; ESC closes; scroll locked

rule · drawer = side:"right" variant — no separate Drawer component

use-overlay

draftds/motion/use-overlay.ts

Hook: body scroll lock + overlay count registration + ESC handling + focus trap (panelRef) for any overlay surface

Scroll-lock + overlay-count + ESC + focus-trap for any overlay. Powers sheet / modal / nudge - open those previews above to see it.

proptypedefault
openboolean
onClose() => void
panelRefRefObject<HTMLElement | null> (optional — enables focus ownership)
closeOnEscbooleantrue

rule · every ds overlay (sheet, modal, celebration) registers through this so FounderNudge's overlay-hold gate keeps working

rule · pure decisions (drag-dismiss thresholds, focus wrap) live in ds/motion/overlay-logic.ts with node tests

use-pointer-parallax

stableds/motion/use-pointer-parallax.ts

Pointer parallax springs (±14px) for hero focal layers

±14px pointer parallax springs for hero focal layers. Active on vertical-hero-scene's focal slot.

used by · VerticalHeroScene · BossHero

use-breakpoint

stableds/motion/use-breakpoint.ts

Media-query hook for desktop/mobile branching

(min-width:640px) → MOBILELive value on this viewport - re-renders on resize.

used by · MicroMarketDock · GamesFilmstrip · TriggerLab · WalletSurface

command-palette

draftcomponents/search/CommandPalette.tsx

Cmd/Ctrl+K global command palette — fuzzy subsequence search across nav/games/markets/fixtures/actions; top-anchored command bar with arrow+Enter+Esc, role=combobox/listbox + aria-activedescendant, focus-trap via use-overlay, Z.modal scrim. Enter navigates (router.push) or fires the wallet-open action; opacity/travel entrance (no scale — verified-facts). Presentation-only: navigation + the deposit action, never money.

closedopenno-results

LIVE PREVIEW NEEDS APP CONTEXT · SEE components/chrome/TopNav.tsx (CHROME-MAKER mounts SearchTrigger) · app/layout.tsx (CHROME-MAKER mounts CommandPalette + CommandKListener)

proptypedefault
(CommandPalette)no props — reads useCommand open state
(SearchTrigger){ className?: string } — the TopNav opener button
(CommandKListener)no props — global ⌘K keydown, null render

motion · AnimatePresence opacity scrim (DURATION.crossfade) + panel opacity/travel (DURATION.expand, EASE) — never scale on the ancestor (verified-facts layoutId rule)

a11y · input role=combobox aria-expanded aria-controls aria-activedescendant; results role=listbox > role=option aria-selected; ↑/↓ move active, Enter activates, Esc closes; focus trapped + restored via use-overlay

rule · no useSearchParams (SSG preserved)

rule · fuzzy matcher is pure + unit-tested, no new deps (subsequence scorer in fuzzy.ts)

rule · Glyph-only icons (id=search leading mark)

rule · opacity/travel entrance — no scale

rule · presentation-only — navigation + openWallet action; NEVER bus.emit / useEngagement

used by · components/chrome/TopNav.tsx (CHROME-MAKER mounts SearchTrigger) · app/layout.tsx (CHROME-MAKER mounts CommandPalette + CommandKListener)

PATTERNS

Panels, stat grids, ledgers, shells - composed surfaces.17 ENTRIES

panel

draftds/patterns/Panel.tsx

Surface panel: surface glass|coal|well

glasscoalwell
glass
coal
well
proptypedefault
surface"glass" | "coal" | "well""coal"
rounded"none" | "xl" | "2xl""2xl"

rule · padding stays with the consumer; dashed empty-state wells add border-dashed via className

rule · PANEL_SURFACE class map is exported so sibling patterns (StatTile) wear a surface without nesting

used by · StatTile

spotlight-coach

draftds/patterns/SpotlightCoach.tsx

Guided first-run coachmark: a dimming scrim with a transparent cutout ring around a live target (data-guide-anchor selector) + an anchored card (eyebrow / title / body / Back / Next / Skip + step dots). NON-BLOCKING (portal pointer-events:none except the card; the cutout is a shadow, so the highlighted element stays tappable), resumable (controlled stepIndex or defaultStepIndex), dismissible (Skip / Escape / scrim), graceful-degrade to a centered card when no target resolves. Has NO round-awareness: the consumer gates open on the settle beat (useNudgeBeatReady) so a beat never paints over a live round. Portals to document.body. Pure geometry + state hooks in spotlight-coach-geometry.ts (banned-hook split).

voltmagentacyangoldbloodfogbone

LIVE PREVIEW NEEDS APP CONTEXT · SEE app: SurfaceGuideCoach (components/onboarding)

proptypedefault
stepsReadonlyArray<SpotlightCoachStep>
stepIndexnumber (controlled resume cursor)
openboolean (consumer gates first-run + the settle beat)true
accentAccentName"volt"
onStepChange | onComplete | onSkip | onStepPrimarycallbacks (onStepPrimary returns true = handled, do not advance)

rule · the coach is NOT its own gate: the consumer MUST gate open on useNudgeBeatReady so a beat never paints over a live/unsettled round (round-guard law)

rule · target anchors are data-guide-anchor selectors on the real UI; a missing anchor degrades to a centered card (never a dead-end)

rule · content lives as DATA in a per-surface registry (app: lib/onboarding/surface-guides.ts), never scattered JSX

rule · one voice at a time: while a coach is active the AmbientNudgeSlot + GuideToast stand down (activeSurface signal)

used by · app: SurfaceGuideCoach (components/onboarding)

pagination

draftds/patterns/Pagination.tsx

Player Pagination — discrete page Pills (solid=active, ghost=others+arrows) with first/last + current ± siblings and ellipsis gaps; window math in pagination-range.ts (tested). nav[aria-label]; active page aria-current=page; prev/next real buttons with sr-only labels + disabled at bounds; ellipsis aria-hidden. No layoutId (discrete buttons, not a sliding segment — TabStrip owns that register). The caret is a decorative CSS chevron (rotated border edges, aria-hidden — the sanctioned ds/admin/Select pattern), NOT a Glyph. No money math — page math only.

first-pagelast-pageper-page-active
proptypedefault
pagenumber (1-based current page)
pageCountnumber (>= 1)
onChange(page: number) => void
accentAccentName"volt"
siblingCountnumber (pages either side of current)1
ariaLabelstring"Pagination"
classNamestring

a11y · nav[aria-label]; active page aria-current=page; prev/next real buttons with sr-only labels + disabled at bounds; ellipsis aria-hidden

rule · player register — the OPERATOR/admin pager is a separate ds/admin entry (long-tail, task #32); do not fold them

rule · no money math — page math only; window math lives in pagination-range.ts (tested)

rule · no layoutId (discrete buttons, not a sliding segment — TabStrip owns that register)

rule · caret is a decorative CSS chevron (ds/admin/Select pattern), not a Glyph; if a chevron Glyph is later added it can adopt it

accordion

draftds/patterns/Accordion.tsx

Click-to-disclose content accordion (greenfield for completeness). Each item is a coal Panel; the header is button[aria-expanded][aria-controls]; the panel is role=region[aria-labelledby] mounted via AnimatePresence height-expand (DURATION.expand/EASE) with a chevron rotating 180°, reduced-motion → instant. Single-open default + allowMultiple; roving Arrow Up/Down + Home/End move focus between triggers; FOCUS_RING on triggers. The chevron is a decorative CSS caret (ds/admin/Select pattern), not a Glyph.

singlemultiplecollapsedexpanded
HOW PAYOUTS WORK
Settled plays credit your balance the moment a market resolves. Server-formatted display strings only - the floor never does money math client-side.
Set a per-session ceiling in settings. Presets above your limit dim out and explain why on hover - the limit is honored before any intent is built.
Reduced-motion follows your OS. Self-exclusion and cool-off tools live in settings and apply instantly across every surface.
proptypedefault
itemsreadonly AccordionItem[] {id, trigger, content, accent?}
allowMultipleboolean (false = single-open, collapses siblings)false
defaultOpenIdstring
accentAccentName"volt"
classNamestring

motion · AnimatePresence height-expand (DURATION.expand/EASE) + chevron rotates 180°; reduced-motion → instant (opacity-only exit, no height tween)

a11y · header button[aria-expanded][aria-controls]; panel role=region[aria-labelledby]; roving Arrow Up/Down + Home/End move focus between triggers; FOCUS_RING on triggers

rule · greenfield for completeness — this is the click-to-disclose content pattern for future FAQ/detail-meta surfaces

rule · the FILED disclosures (FilmstripCard hover-expand + nav toggles) are NOT accordions and stay out of scope

rule · chevron is a decorative CSS caret (ds/admin/Select pattern), not a Glyph

rule · roving-focus + open/close math live in accordion-nav.ts (tested)

used by · app/faq/FaqAccordion.tsx · app/help/HelpAccordion.tsx

page-crumb

draftds/patterns/PageCrumb.tsx

Placement shell over the Breadcrumb primitive — the ONE source of truth for where a hub-page breadcrumb sits. Wraps Breadcrumb in the shared rail gutter `mx-auto max-w-7xl px-5 sm:px-8` + `pt-24` (clears the fixed h-16 nav) + `mb-6` before content, and passes `next/link` as Link so trail links are client-side + prefetched. Trail is HOME / parent / current; the personal hubs route their parent through /you (the command center). Hard Link to the parent (never router.back()) — deterministic on the way back regardless of entry path, matching the shipped detail back-links.

proptypedefault
itemsreadonly BreadcrumbItem[] {label, href?}
accentAccentNameBreadcrumb default ("fog")
classNamestring

a11y · inherits Breadcrumb: nav[aria-label=Breadcrumb] > ol > li; last item aria-current=page rendered as text (not a link); separators aria-hidden; links wear accent text + FOCUS_RING. Crumb links come first in DOM (under nav, before the page title) = correct tab/reading order

rule · thin wrapper — placement only; never re-implement the trail markup (that is Breadcrumb's contract). PageCrumb owns the gutter/top-pad/Link-binding, nothing else

rule · next/link is the only Link binding — client-side + prefetch on hub trails

rule · hero pages mount PageCrumb as the FIRST child before the hero (hero keeps its own top padding); no-hero pages REMOVE their root pt-24 (PageCrumb supplies it)

rule · presentation only — no money math, no money invariant surface touched

used by · app/bonuses/page.tsx · app/history/page.tsx · app/vip/page.tsx · app/settings/page.tsx · app/you/page.tsx · app/community/page.tsx · app/season/page.tsx

content-page

draftds/patterns/ContentPage.tsx

The shared shell for all static content/legal/SEO pages. PageCrumb (nav clearance) + an Anton hero with mono eyebrow + lede + a pure anchor-link TOC (no JS), then an anchored <main> column of ContentSections. Server component — markup only, zero store reads, no money math (SSG by construction). Interactive children (the FAQ Accordion) are client islands passed into a section body. content-prose.ts is the single source of prose class literals + the pure tested sectionAnchor slug helper; ContentSection renders one anchored <section id> with scroll-mt-24 nav clearance.

EXAMPLE

CONTENT PAGE

The shared shell for every static content / legal page - Anton hero, mono eyebrow, anchored prose.

■ 01 / OVERVIEW

WHAT THIS IS

One server component the content / legal pages compose - pure presentation, no store reads, no money. Real OP stakes, no cash wagering yet.

■ 02 / STRUCTURE

HOW IT COMPOSES

  • PageCrumb for nav clearance + wayfinding.
  • An anchored column of ContentSections.
  • Interactive bodies (Accordion, DefinitionList) passed in.

LAST UPDATED · JUNE 2026

proptypedefault
crumbsreadonly BreadcrumbItem[] {label, href?}
eyebrowstring (mono kicker)
titleReactNode (Anton title; one word may wear text-stroke-<accent>)
ledeReactNode (one-line summary; mirrors meta description)
accentAccentName (legal set = fog; trust set = vertical accents)"fog"
sectionsreadonly ContentSectionModel[] {id, eyebrow, heading, body}
updatedstring (optional LAST UPDATED stamp, legal pages)

a11y · PageCrumb landmark first in DOM (correct reading order); TOC is nav[aria-label='On this page'] of pure #anchor links; each ContentSection is <section aria-labelledby> with scroll-mt-24 nav clearance; FOCUS_RING on TOC links

rule · server component — no "use client", no store reads, no bus.emit, no money math (SSG + money invariant by construction)

rule · all prose styling flows from content-prose.ts (single source of complete Tailwind v4 literals) — never inline ad-hoc prose classes

rule · honest-claims law: pages composing this shell state demo + play-money + no-real-money where relevant; fairness never claims a real RNG cert / licence / real money

rule · DefinitionList + Accordion are the reusable bodies — a <dl> or disclosure block is a ds element, never inline app markup (no-inline-UI rule)

used by · app/legal/terms/page.tsx · app/legal/privacy/page.tsx · app/legal/cookies/page.tsx · app/responsible-play/page.tsx · app/how-it-works/page.tsx · app/fairness/page.tsx · app/about/page.tsx · app/faq/page.tsx · app/glossary/page.tsx

definition-list

draftds/patterns/DefinitionList.tsx

The glossary <dl> term/definition primitive. Each pair is <dt> (Anton, text-bone) + <dd> (content-prose body); an accent tints an optional editorial leading rule reusing the shipped gold-stripe idiom (border-<accent>/30 pl-4). Server component, pure presentation — no store reads, no money.

FLUID iGAMING
Every action can spawn its own behavior micro-market.
Ownership Points - real stakes in the floor's economy. Not cash, not withdrawable yet.
DETERMINISTIC SIM
The same seed produces the same outcome sequence.
proptypedefault
itemsreadonly Definition[] {term, def}
accentAccentName (tints the optional leading rule)
classNamestring

rule · server component — pure presentation, no store reads, no money

rule · the <dl> is a ds element, never inline app markup (no-inline-UI rule)

rule · accent rule literals are complete per-accent strings (Tailwind v4 JIT), no interpolation

used by · app/glossary/page.tsx

account-rail

stableds/patterns/AccountRail.tsx

Persistent YOU-group side menu (StyleguideNav lineage). Desktop lg+: a sticky left rail listing the 5 account destinations (YOU/BONUSES/VIP/SETTINGS/HISTORY) with an active highlight (usePathname + the pure accountRailActiveHref helper); collapses to a sticky horizontal chip row on mobile (mask edge-fade + active chip centered on mount). Wraps {children} as the 2-col grid content column so account pages share one wayfinding spine.

a11y · nav landmark (aria-label='Account sections'); aria-current=page on the active destination; native next/link keyboard nav + FOCUS_RING.

rule · single source of truth for the YOU-group link set lives in ds/patterns/account-rail-links.ts (ACCOUNT_RAIL_LINKS + the pure tested accountRailActiveHref)

rule · do NOT wrap a page that already has its own in-page nav rail (e.g. /you's command-center zone-jumper) — stacking both is a double-menu regression (failed-attempts 2026-06-13)

rule · mobile chip row sits at z-40, under the chrome ladder (60+); client island, host pages stay server components / SSG

used by · app/bonuses · app/vip · app/settings · app/history

stat-tile

draftds/patterns/StatTile.tsx

Label/value/foot stat tile on the MICRO_LABEL scale; adds admin KPI delta?/isLive? variants (pre-formatted sign-in-text delta with accent by direction up→volt/down→blood/flat→fog, never caller-set; isLive pulses an inlined cyan dot — the patterns layer takes no upward import from ds/admin)

kpi-deltakpi-live
HANDLE
€84.2k
+12.4%
HOLD
6.1%
−0.3pp
LIVE BETS
318
proptypedefault
labelReactNode
valueReactNode (pass CountUp for animated figures)
footReactNode
accentAccentName"bone"
footAccentAccentName"fog"
surfacePanelSurface"well"
deltaStatTileDelta {value: ReactNode; direction: "up"|"down"|"flat"}
isLivebooleanfalse

a11y · values use tabular-nums; label/value association via dl semantics (renders a dt/dd group — must live inside StatGrid's dl)

rule · non-interactive by design — wrap the grid cell, not the tile, for click targets

rule · delta accent derives from direction via DELTA_ACCENT (up→volt, down→blood, flat→fog) — never caller-set

rule · isLive is the ONLY cyan entry in the admin KPI register (the cyan budget gate); the live dot is INLINED so patterns takes no upward import from ds/admin/StatusDot

rule · delta.value carries its own sign — no money math in the DS

stat-grid

draftds/patterns/StatGrid.tsx

Responsive grid of StatTiles, hairline-gap variant included (composition contract — tiles arrive as children, not imports)

gaphairline
LEVEL
7
XP
2,340
QUESTS
3
STREAK
W2
proptypedefault
columns2 | 3 | 4 | 52
variant"gap" | "hairline""gap"

a11y · renders the <dl> that StatTile's dt/dd groups require

rule · hairline tiles need an opaque surface — the 1px line grid shows through gap-px seams only

ledger-row

stableds/patterns/LedgerRow.tsx

Mono transaction/position row: label, amount, timestamp, status accent

  • SLIP WON · ARSENAL TO WIN+120 ◈
  • QUICK PLAY · NEON DICE−50 ◈
  • DEPOSIT+500 ◈PENDING
proptypedefault
labelReactNode
leadingReactNode (direction badge / live dot / glyph)
amountstring — PRE-FORMATTED, sign included
amountAccentAccentName"bone"
status{ label, accent: AccentName, pulse? }
timestampstring (hidden under sm)

a11y · amounts tabular-nums; gains/losses carried by sign + text, never color alone (triple-channel)

rule · amounts arrive pre-formatted with an explicit sign — this row never does money math

rule · renders an <li>; the consumer owns the <ul> (divide-y divide-line for the classic ledger)

rule · draft→stable 2026-06-13 (Wave 2b registry hygiene): graduated — shipped across history/wallet/you/notifications/admin (6 real surfaces) + styleguide preview; the empty usedBy was stale, now populated with the actual import edges

used by · components/history/HistoryList.tsx · components/history/history-rows.ts · components/wallet/WalletHistory.tsx · components/you/TransactionList.tsx · components/notifications/NotificationRow.tsx · ds/admin/DataTable.tsx · components/styleguide/previews-patterns.tsx

nudge-shell

draftds/patterns/NudgeShell.tsx

Toast/nudge shell with the cap + snooze + overlay-hold gates built in

topbottominlineheld (overlay up)visiblepaused (hover/focus)
proptypedefault
openboolean
onClose(reason: "auto" | "dismiss") => void
accentAccentName"volt"
dwellMsnumber (Infinity = sticky)DWELL_MS.guideToast
placement"top" | "bottom" | "inline""top"
zIndexnumberZ.guideToast (top/inline) / Z.founderNudge (bottom)
iconReactNode (Glyph chip)
ctaReactNode (a REAL button/link)

motion · DURATION.toast entrance with EASE; travel -28 top / +56 bottom / +10 inline

a11y · role=status (polite); CTA is a real button; auto-dismiss pauses on hover/focus

rule · gates (session cap, snooze, overlayCount hold) are part of the shell, not the consumer

rule · pure cap/snooze/cooldown gate lives in ds/patterns/nudge-gate (canShowNudge, nextNudgeDelayMs) — tested

rule · overlay hold reads lib/ui overlayCount directly; a held open re-attempts DWELL_MS.overlayRetry after the last overlay closes

celebration-shell

draftds/patterns/CelebrationShell.tsx

Non-blocking celebration overlay shell: celebration scrim, display headline slot, ParticleBurst slot, dwell auto-dismiss

proptypedefault
openboolean
onDismiss() => void
dwellMsnumber — DWELL_MS per moment
zIndexnumberZ.levelUp
kickerReactNode
headlineReactNode (display type slot)
subReactNode
particlesReactNode (ParticleBurst slot)
backdropReactNode (ambient rays/tint layer)

motion · frame fade DURATION.dissolve; content opacity+travel DURATION.toast staggered on STAGGER.base — never scale (scaling ancestors break layoutId)

a11y · non-blocking — page stays interactive; announced via role=status; reduced-motion: static frame, no particles

rule · LOSS = zero animation, zero sound, zero celebration; payout < wager is a LOSS (anti-LDW, old-DS §7.2/§8.4)

rule · celebration tiers gate on multiplier — sportsbook explosion threshold is 25x, not 10x (§7.3)

rule · particles/backdrop are SLOTS (pass ds ParticleBurst) — both suppressed under reduced motion

vertical-hero-scene

stableds/patterns/VerticalHeroScene.tsx

Vertical hero scaffold (frozen spec D0): layout split/interleave, moduleAlign, focal/ambient/glow slots

CASINO

THE FLOOR

MOMENT MODULE SLOT
Frozen D0 scaffold - runs full-bleed on the three vertical headers.
proptypedefault
accentHeroAccent ("magenta" | "volt" | "cyan")
editorialReactNode
moduleReactNode (zero-CLS: reserve explicit min-h)
introReactNode (interleave row 2)
chipsReactNode
focalReactNode (desktop-only, ±10px parallax)
ambientReactNodeaccent ContourField rings=16 opacity=0.12
glowReactNode | nullaccent GlowWash size=560
layout"split" | "interleave""split"
moduleAlign"start" | "center""start"
classNamestring (vertical padding)
containerClassNamestring"max-w-7xl px-5 md:px-10"

rule · frozen spec D0 — layout changes need a new ruling

rule · explicit grid placement on BOTH overlapping items; geometry asserted by scripts/hero-geometry-check.mjs, not eyeballs

used by · CasinoHeader · PredictionsHeader · SportsbookHeader

glow-wash

stableds/patterns/GlowWash.tsx

Accent glow wash layer for hero scenes

proptypedefault
accentGlowAccent ("magenta" | "volt" | "cyan" | "blood")
sizenumber (px diameter)480
classNamestring (positioning utilities only)

a11y · aria-hidden — purely decorative

rule · static forever: never animates, never tracks the pointer (one glow layer per hero)

used by · VerticalHeroScene

empty-state

draftds/patterns/EmptyState.tsx

Empty/error state block, two density registers: `page` (giant 404/not-found headline) and `compact` (disc-framed glyph + mono kicker + foot, for in-chrome empties like the notification feed). The compact register replaces the inline empty block that used to live in NotificationFeed.

pagecompact

NO POSITIONS YET

AN EMPTY LEDGER

Your settled plays land here.

NO SIGNAL YET

WINS · BONUSES · LEVEL-UPS LAND HERE

proptypedefault
kickerReactNode (mono top line)
headlineReactNode (display type — page register only)
explainerReactNode (mono sub line)
actionReactNode (CTA slot — MagneticButton/Link)
accentAccentName (kicker / compact-disc tone)"fog"
ambientReactNode (ContourField slot — page register only)
as"h1" | "h2""h1"
density"page" | "compact""page"
glyphGlyphId (disc-framed mark — compact register only)

rule · CTA and ambient are SLOTS — the shell imports no stores, canvas or buttons (error boundaries stay dependency-light)

rule · compact density drops the giant headline + ambient — disc glyph + kicker + foot only, so it fits a popover or right Sheet without blowing the frame

rule · consumers own vertical centering via className/wrapper — cn() is plain clsx, so never pass a conflicting display utility onto the register root

used by · app/not-found · app/error · notification feed (compact)

IGAMING

Odds, markets, HUD, celebrations - the vertical recipes.33 ENTRIES

odds-value

stableds/igaming/OddsValue.tsx

Odds number that flashes on change — up volt / down blood for DWELL_MS.oddsFlash

steadyflash-upflash-down
2.45Flashes volt/blood + signed delta on live change
proptypedefault
valuenumber
classNamestring

a11y · triple-channel (WCAG 1.4.1): direction carried by signed delta text + flash, NEVER color alone; tabular-nums

used by · QuickBetControls · OddsButton · SlipBody

odds-button

stableds/igaming/OddsButton.tsx

Canonical odds control wired to the slip store; selected = volt fill + glow. `selection` (OddsSelectionButton) is the derived-market variant: slot-id per market group + responsive shortLabel.

smlgselectionidleselected
Taps write the slip store - presentation only, no money.
proptypedefault
matchId/home/away/pick/pickLabel/oddsselection identity
size"sm" | "lg""sm"

motion · SPRING.snap select

a11y · selected carried by fill + aria-pressed + label, never color alone; focus-visible ring

rule · slip mutations via slip-store only; money via bus events only

rule · selected glow comes from SELECTED_GLOW (semantic.ts) — no inline rgba

rule · refit landed: OddsSelectionButton absorbed DetailOddsButton (file deleted) — shared OddsShell presentation, aria-pressed everywhere

used by · LiveMatchCard · MatchBoard · detail/MarketsBoard

trade-buttons

stableds/igaming/TradeButtons.tsx

YES/NO trade pair — cyan/magenta, price-aware

proptypedefault
yesPricenumber
activeSide"yes" | "no" | null
onPick(side: "yes" | "no") => void
disabledbooleanfalse
size"sm" | "lg""sm"

a11y · side carried by YES/NO text + price, never color alone (triple-channel)

used by · MarketCard · TradeDesk · PredictionsHeroModule

probability-bar

stableds/igaming/ProbabilityBar.tsx

Cyan spring probability fill (0..1) — becomes a ProgressBar skin during refit (design-qa first: spring physics + cyan glow must not silently change)

62%

used by · MarketCard · TapeStats

context-strip

draftds/igaming/ContextStrip.tsx

DISPLAY-ONLY model context strip: model 1X2 lead + form + h2h home strength as compact labeled cyan mini-bars. Fail-closed null → renders nothing. NEVER a settlement/money input.

MODEL48%ARSENAL
FORM65%ARSENAL
H2H60%ARSENAL
FORM60%CHELSEA
proptypedefault
contextWireMarketContext | null (display ints 0..100)
homeTeamstring
awayTeamstring
classNamestring

a11y · decorative model suggestion; the percent + side label carry meaning, never the fill tint alone

rule · DISPLAY-ONLY - never a settlement or OP input; the market settles on the final score

rule · fail-closed: a null context or a context with no surviving reading renders nothing (no empty strip)

rule · the cyan fill is bg-cyan; small accent percents use text-cyan/90 (OLED halation rule)

used by · RichMarketCardShell

sparkline

stableds/igaming/Sparkline.tsx

Price sparkline — rising volt / falling blood tint, spring dot. `detail` is the hero size: fixed 0..100¢ scale, gridlines, DURATION.drawFast.

defaultdetail
0 real ticks - awaiting first move, not a fabricated flat line
Same resting state at the detail (hero) size
proptypedefault
seriesnumber[] (detail expects 0..1 prices)
variant"default" | "detail""default"
classNamestring

motion · DURATION.draw path draw

a11y · direction also carried by signed change text near the chart, never tint alone

rule · refit landed: detail variant absorbed PriceChart (file deleted); hex tints resolve via accentHex

used by · MarketCard · PredictionsHeroModule · MarketDetail

micro-market-card

stableds/igaming/MicroMarketCard.tsx

The micro-market unit: countdown ring, stake chips, spawn sweep

defaultcompactopenopen+stakedresolved-wonresolved-lostclosed

spawned by your win streak

DEMO

HOT HAND DETECTED

You're on a 3-win run. Does the next round hit ≥2×?

spawned by your win streak

DEMO

HOT HAND DETECTED

You're on a 3-win run. Does the next round hit ≥2×?

proptypedefault
marketMicroMarket
nownumber
compactbooleanfalse

motion · spawn sweep · countdown ring · popLayout exit

a11y · timer ring aria-hidden; result announced via text; resolved-lost is NEUTRAL — zero color, no icon, no celebration (triple-channel + anti-LDW)

rule · never mutates balance — bus events only

rule · M4 landed: local ACCENT map → accent-api (ACCENT_CLASSES.text + accentHex/hexAlpha wash)

rule · refit still owes: hand-rolled ring → progress-ring, stake row → chip-selector

rule · STATUS HONESTY: resolved-lost still renders the shipped blood treatment — flipping to the NEUTRAL loss contract (a11y note) is a refit design-qa change, not a silent M4 edit

rule · compact stake chips intentionally NOT refit to chip-selector (compact geometry differs) — revisit only with a design-qa pass

used by · MicroMarketDock · MicroMarketSheet

micro-market-dock

stableds/igaming/MicroMarketDock.tsx

Desktop micro-market stack (max 4 + "+N MORE") at Z.microMarkets; steps left of the open chat dock

spawned by your win streak

DEMO

HOT HAND DETECTED

You're on a 3-win run. Does the next round hit ≥2×?

Desktop stacks ≤4 of these + "+N MORE"; runs fixed in chrome at every route.

used by · app/layout

micro-market-sheet

stableds/igaming/MicroMarketSheet.tsx

Mobile pill → sheet at Z.sheet; one auto-expand per session; suppressed while chat sheet is open

Mobile pill ⇄ bottom sheet; one auto-expand per session - the fixed pill lives in chrome on mobile. RUNNING AT /casino.

rule · M4 split: MicroMarketSheet (auto-expand gates + overlay lifecycle) + MicroMarketPill + MicroMarketSheetPanel — all <150 lines

rule · panel re-base onto the Sheet primitive stays refit (ds/motion/Sheet was draft-unbuilt when M4 landed); springs snapped to SPRING.sheet/standard/pop

used by · MicroMarketDock

slip-dock

draftds/igaming/SlipDock.tsx

Persistent global betslip dock (mounted once in app/layout beside MicroMarketDock so it follows the navigation-surviving slip). HYBRID: desktop floating SlipDockTab (bottom-4 right-4 z-[70]=Z.microMarkets, below the mm stack, chatDockOpen step-aside) appears only when the in-flow SlipColumn scrolls out of view (IntersectionObserver → useSlipDockUi), opening SlipBody verbatim in a right-side Sheet; mobile = the existing MobileSlip re-homed

desktop-tabdesktop-expanded-sheetmobile-rehomedsuppressed (count===0 && status===idle → renders null)column-in-view (tab hidden, in-flow SlipColumn serves the slip)tab-idle (SLIP (n) + @combined chip)tab-pending (SWEATING n/total, blood pulse)tab-won (CASHED ✓, volt)tab-lost (SLIP DIED, blood)expanded (SlipBody in side=right Sheet, z=sheet 74)

BET SLIP

OP

Tap any odds to build a slip.
Stack legs for a parlay.

Real OP wager · results settle from the real match · track it in ACTIVE BETS.

Expanded surface the dock opens in a right-side Sheet. The floating tab appears bottom-right only when this column scrolls out of view + BUMPS on every leg add - pure presentation, no money moves here.
proptypedefault
onReview() => void (SlipDockTab — delegates expand; SlipDock itself takes no props)

motion · count + combined-odds BUMP key={count} scale [1,1.08,1] (0.3s) + SPRING.pop pulse-dot ping on every leg add; tab enter/exit y24 SPRING.standard via AnimatePresence; right→ step-aside transition-[right] 500ms on chatDockOpen

a11y · tab is a button aria-label="Open bet slip"; pulse-dot + REVIEW → arrow are aria-hidden; Sheet carries role=dialog aria-modal + focus trap + ESC via use-overlay (ariaLabel="Bet slip"); status/leg copy announced as text, never color-only

rule · PURE PRESENTATION — reads useSlip selectors (selections/status/legResults) + opens a Sheet; NO bus.emit, NO useEngagement, NO second bet:placed emitter; placement still flows through slip-engine.placeSlip via SlipBody

rule · money invariant untouched: slip-store/slip-engine/SlipBody/MobileSlip are CONSUMED ONLY, never modified (the module-level navigation-surviving settleTimer + SWEAT-THE-SLIP bus spawn stay intact)

rule · z-[70] is the spec value mapped to Z.microMarkets — sits spatially BELOW the mm desktop stack (bottom-44 up) so they never overlap; adopts the mm chatDockOpen lg:right-[376px] step-aside

rule · self-suppresses entirely (returns null) when count===0 && status===idle via dockHasContent — no chrome while the slip is EMPTY; a LIVE slip (any leg, or a pending/won/lost flight) follows across surfaces by design (spec dd 2 "follow the live slip across board↔detail↔beyond" + the HERO opt-in surfaces a draft in the dock from the landing page), so the dock is intentionally NOT route-gated; the "tap any odds" empty copy stays in SlipBody's empty state (single home, no second empty surface)

rule · no fly-to / layoutId across containers — the bump is an in-place key={count} pulse (avoids the cross-container layoutId trap, docs/MEMORY-TRAPS.md)

rule · pure derivation lives in slip-dock-logic.ts (node-testable: dockHasContent / dockTabVisible / dockTabLabel / dockOddsChip / dockTint); the shell stays thin

rule · useSlipDockUi declared create-if-missing, EXACTLY {columnInView, setColumnInView} — defaults columnInView:true so the tab stays quiet until a SlipColumn mounts; carries one boolean, zero money/ledger field

rule · draft until the refit lands it in the live flow (M-B wires app/layout + swaps both sticky SlipBody columns to SlipColumn + removes the double page-local MobileSlip) + a design-qa pass

used by · app/layout

engagement-dock

stableds/igaming/EngagementDock.tsx

XP HUD: quests strip + XP bar + level diamond + "+XP" chip; hidden on /you

XP bar + level diamond + quests chip, docked bottom-right (hidden <sm, null on /you). Reads the ledger XP - presentation HUD. RUNNING AT /casino.

motion · SPRING.slowFill bar; shimmer sweep from token recipe

rule · refit: bar → progress-bar(shimmer) still owed; sweep gradient → WHITE_ALPHA.sweep LANDED (registry-lint 2026-06-12)

used by · app/layout

quest-drawer

stableds/igaming/QuestDrawer.tsx

Right-side quest drawer with claim flow at Z.menu

Right-side quest + boss-HP drawer - reads the ledger read-only (zero-state: 0 claimable, full boss HP). The drawer's ✕ closes it.

rule · refit: re-base on Sheet(side: right); bars → progress-bar

used by · app/layout

level-up-overlay

stableds/igaming/LevelUpOverlay.tsx

Level-up celebration at Z.levelUp — join-grace sequencing, DWELL_MS.levelUp dwell

Fires on level-up (ledger-driven) - the same burst composition is live in celebration-shell above. RUNNING AT /casino.

rule · refit: particle-burst re-base LANDED; celebration-shell re-base still owed

rule · wins only — never fires on a loss (anti-LDW)

used by · app/layout

orbital-nav

stableds/igaming/orbital/OrbitalNav.tsx

Mobile thumb nav system: XP top-edge line (OrbitalBar), FAB + arc (OrbitalArc) at Z.orbital*

keyedguestbadgedarc-open

Mobile thumb nav: XP top-edge line + FAB arc (fixed, <sm only). RUNNING AT /casino on mobile.

a11y · arc buttons carry aria-label from their ORBITAL_ACTIONS entry (failed-attempts 2026-06-12 gap CLOSED in M4 — keep labels when extending actions); FAB carries aria-expanded

rule · XP line animates from explicit initial width, never auto

rule · FAB fill is opaque — translucent FAB over the progress line reads broken

used by · app/layout

tier-presence

stableds/igaming/TierPresence.tsx

Current VIP tier presence block with progress line

rule · M4 landed: vip-accents consumption → accent-api (accentHex + ACCENT_CLASSES)

rule · refit: progress line → progress-bar

used by · vip · you

tier-ladder

stableds/igaming/TierLadder.tsx

VIP tier ladder (rungs included); rings consolidate onto progress-ring during refit

THE SEVEN RUNGS

THE CLIMB, IN FULL VIEW

Every threshold is published. Every benefit is listed. Nothing unlocks behind a curtain - when your wagered total crosses a line, the rung is yours, automatically.

  1. FOUNDER

    TIER 6
    +3 QUEST SLOTS+12% CREW DAMAGEEARLY ACCESS8% WEEKLY CASHBACK · CAP 600 ◈

    20,000 ◈ WAGERED

    BOOK: INVITE + €2,000,000 / MONTH

  2. GENESIS

    TIER 5
    +2 QUEST SLOTS+10% CREW DAMAGEEARLY ACCESS6% WEEKLY CASHBACK · CAP 400 ◈

    10,000 ◈ WAGERED

    BOOK: €1,000,000 / MONTH

  3. SOVEREIGN

    TIER 4
    +2 QUEST SLOTS+8% CREW DAMAGEEARLY ACCESS4% WEEKLY CASHBACK · CAP 250 ◈

    4,000 ◈ WAGERED

    BOOK: €400,000 / MONTH

  4. ARCHITECT

    TIER 3
    +1 QUEST SLOT+6% CREW DAMAGEEARLY ACCESS

    1,000 ◈ WAGERED

    BOOK: €100,000 / MONTH

  5. BUILDER

    TIER 2
    +1 QUEST SLOT+4% CREW DAMAGE

    250 ◈ WAGERED

    BOOK: €25,000 / MONTH

  6. HOLDER

    TIER 1
    +2% CREW DAMAGE

    50 ◈ WAGERED

    BOOK: €5,000 / MONTH

  7. ENTRANT

    TIER 0YOU ARE HERE
    FLOOR ACCESS

    OPEN TO EVERYONE

    BOOK: €0 / MONTH

■ QUALIFICATION = LIFETIME WAGERED DEMO CREDITS · ONLY CLIMBS · NEVER RESETS, NEVER DEMOTES

rule · M4 landed: rungs consume accent-api (borderSoft = the vip /50 border); TierRung lives at ds/igaming/TierRung.tsx

rule · refit: rings consolidate onto progress-ring

used by · vip

game-art

stableds/igaming/art/game-art.ts

Casino card art generator (gameArt/tagClasses) — sanctioned raw-value home; feeds the TransitionLink continuity contract

cyan
volt
magenta
bone

rule · raw gradients/hex are SANCTIONED here and only here (plus match-art/market-art)

used by · 12 files

match-art

stableds/igaming/art/match-art.ts

Sportsbook match art generator (sport hue) — sanctioned raw-value home

football
basketball
tennis
esports
mma
hockey

used by · 7 files

market-art

stableds/igaming/art/market-art.ts

Predictions market art generator (category tint) — sanctioned raw-value home

sports
crypto
culture
swoobz

used by · 5 files

tape-field

stableds/igaming/TapeField.tsx

Price-tape hero canvas — tested geometry (tape-geometry.test.ts moves with it)

proptypedefault
seriesnumber[]
accentAccentName (M4: replaced the hex color prop)"cyan"
bandStart/bandCenter/amplitudegeometry

used by · PredictionsHeader

live-ticker

stableds/igaming/LiveTicker.tsx

Live feed marquee strip (real feed + ambient seed)

rule · M4 landed: local accent class map → ACCENT_CLASSES[accent].text (accent-api)

used by · 5 pages

deposit-ticker

stableds/igaming/DepositTicker.tsx

Deposit confirmation ticker — 0→32 confs ring, server-formatted strings ONLY (UI never does money math)

0/32

CONFIRMING 0/32

USDC 25.00 · fee USDC 0.10 · lands as USDC 24.90

intent_dsdemo

a11y · confirmation count announced as text; ring decorative

rule · display strings come from the record — never compute money client-side

rule · already the GOOD var(--color-*) consumption pattern; ring → progress-ring during refit

used by · SceneFuel · DepositTab · WithdrawTab

account-ribbon

draftds/igaming/AccountRibbon.tsx

Account-modulated hero link row — zero-CLS fixed height, DURATION.crossfade swap, DEMO fallback state

guestkeyeddemo-fallback
DEMO ◈ ONLY
proptypedefault
classNamestring
justify"start" | "end""end"
idleReactNode | null — idle-state slot (null = empty reserved row; default DEMO label)DEMO ◈ ONLY

motion · DURATION.crossfade opacity swap inside a fixed h-4 row (zero CLS)

rule · a LINK, never a modal, never gates play (frozen engagement spec §0)

rule · refit landed: all three hero modules re-pointed here (ModuleRibbon ×2 + sportsbook AccountRibbon deleted); casino passes idle=null, predictions passes its season-intel link

rule · flip to stable after design-qa on the refit build (status honesty)

used by · CasinoMomentModule · PredictionsHeroModule · SportsbookMoment

slip-status-banner

stableds/igaming/SlipStatusBanner.tsx

Slip placed/won/lost banner — re-bases on nudge-shell during refit

Presentation slip status - no money settles here.

a11y · loss carried by the SLIP DIED text — no icon, no celebration; the full NEUTRAL loss contract (zero color, no shake) is the refit target (triple-channel loss rule)

rule · STATUS HONESTY: lost still renders the shipped blood treatment + shake — flipping to the NEUTRAL loss contract (a11y note) is a refit design-qa change, not a silent edit (same rule as micro-market-card)

used by · SlipBody

lane-rail

draftcomponents/sportsbook/LaneRail.tsx

Sticky sport-lane filter bar: ALL + 6 sport chips (volt layoutId pill, pulsing blood live dot, padded fixture count, behavior-ranked order from lane-prefs), scope segment right / second row on mobile, contextual league chips fading in underneath — never a league axis at lane=ALL.

activeinactivelive-dotfloated

LIVE PREVIEW NEEDS APP CONTEXT · RUNNING AT /sportsbook

motion · layoutId sportsbook-lane-pill slides SPRING.snap; league strip fades DURATION.crossfade; no scaling ancestor may sit above the bar (layoutId measurement — verified-facts)

a11y · radiogroup + roving tabindex, arrows step via ds chip-selection; active carried by solid volt fill + aria-checked, never color alone; floated chips add sr-only 'ranked by your bets'

rule · sticky top-16 z-50 — deliberately below Z.nav (60), NOT a new ladder rung

rule · no filter drawers/sheets — inline pills only (a drawer hides state)

rule · URL sync reads window.location.search ONCE on mount via history.replaceState — never useSearchParams (keeps /sportsbook SSG)

rule · filter state lives in lane-store (§3.1) — MatchBoard and LiveRail consume the same store, so the rail filters with the board

rule · app-side recipe (store-coupled) — registered for registry-first discovery; a ds/ fold is a later-wave call

rule · league chips render INSIDE the sticky bar — picking a lane mid-scroll reveals the league axis pinned under the rail, never off-screen at the board top

rule · /ds preview = context-route fallback (RUNNING AT /sportsbook) — mounting the rail on /ds would run use-lane-url-sync and rewrite the /ds URL; ratified convention for full-bleed store-coupled chrome (verified-facts)

used by · app/sportsbook/page.tsx

scope-segment

draftcomponents/sportsbook/ScopeSegment.tsx

ALL / TODAY / LIVE segmented control — three segments in one hairline capsule, the active segment carried by the sliding volt pill.

activeinactive
Live lane-store control - /sportsbook reseeds scope from its URL on mount

motion · layoutId sportsbook-scope-pill slides SPRING.snap

a11y · radiogroup + roving tabindex, arrows step via ds chip-selection; active carried by solid volt fill + aria-checked, never color alone

rule · scope definitions live in lane-store (§3.1): live = !!match.live; today = live OR startsInMin <= 1440; all = everything

used by · LaneRail

board-section

draftcomponents/sportsbook/BoardSection.tsx

Grouped-board section header — the SAME anatomy whether the group is a sport (lane=ALL) or a league (lane=sport): Glyph + name + count + live dot, mono 10px, hairline underline, scroll-mt-20 anchor.

STANLEY CUP FINAL· 0201 LIVE

proptypedefault
anchorstring (scroll-anchor id)
sportSport (Glyph via SPORT_ICONS)
labelstring
countnumber (padStart(2, "0"))
liveCountnumber (blood dot + NN LIVE when > 0)

rule · rendered as a popLayout SIBLING of its rows, never their parent — fixture row keys stay stable across grouping changes (the stable-container/no-remount gate)

used by · MatchBoard

shared-slip-card

draftcomponents/slip/SharedSlip.tsx

Shared-slip view: matchArt hero painted from the first leg's fixture (continuity with the book), STAGGER.base leg cascade, honest repricing — current odds always shown, shared snapshot struck through + ODDS MOVED chip (volt up / blood down) when |Δ| ≥ 0.05 — and the single LOAD INTO YOUR SLIP CTA.

okdead-linklegs-expiredbusy
  • ARSENAL TO WIN

    Arsenal v Liverpool · PICK 1

    ODDS MOVED

    2.45

    2.51

  • UNDER 5.5 · PANTHERS V OILERS

    Panthers v Oilers · PICK 2

    1.84

Leg anatomy - struck shared price + ODDS MOVED is the only sanctioned strike-through; full card runs at /slip/[code]

motion · hero + legs enter SPRING.standard, legs cascade STAGGER.base y 14→0

a11y · odds movement carried by the ODDS MOVED chip text + struck price, never color alone; DEMO label on the hero

rule · loading NEVER places — LOAD only writes useSlip when status === 'idle' (else FINISH YOUR SWEAT FIRST); placement always flows through placeSlip() with the balance check; no deposit prompt

rule · always repriced to current data-derived odds — the struck shared price is the sportsbook's ONLY sanctioned strike-through (anti-dark-pattern by construction)

rule · fail-closed: any codec rejection renders the empty-state error, never a crash; unresolvable legs drop with a count notice

used by · app/slip/[code]/page.tsx

slip-share-button

draftcomponents/sportsbook/SlipShareButton.tsx

Share entry point — ghost SHARE SLIP while building, gold SHARE THE CASH on a settled win; the share chain prefers the richest channel: canvas card file share > URL share > clipboard copy (+ card download), confirming inline with LINK COPIED.

idle-ghostwon-goldpreparingcopiedfailedhidden
Renders null on an empty slip - never a dead share affordance

rule · renders null with an empty slip — never a dead share affordance

rule · the 1200×630 canvas card + share chain live in lib/slip-share/share-card.ts; the code comes from lib/slip-share/codec.ts (labels never encoded — rebuilt from static data)

rule · share is presentation only — zero bus events, zero store writes

rule · click flips to disabled PREPARING… synchronously BEFORE the canvas render (fonts.ready + 1200×630 draw + toBlob take seconds) — the CTA must never read dead or accept double-clicks

rule · inline confirm on the button (AccountSection precedent) is the ratified deviation from §5.3's guide-toast wording — verified-facts

used by · SlipBody

notification-feed

draftcomponents/notifications/NotificationFeed.tsx

Bell + activity feed — a presentation-only zustand store aggregates bus events into read/unread notifications (ring buffer 30); Sheet on mobile / glass popover on desktop; NEVER emits money events or touches the ledger. Maps ONLY real emitters (bet:won/lost, market:resolved, bonus:granted/offered, quest:completed, level:up, wallet:deposited/withdrawn, founder:claimed, account:created) — no tier/season events (not in the typed bus).

emptyunreadall-read

LIVE PREVIEW NEEDS APP CONTEXT · SEE components/chrome/TopNav.tsx (CHROME-MAKER mounts NotificationBell) · app/layout.tsx (CHROME-MAKER mounts NotificationsProvider)

motion · popover opacity+y travel (EASE) · mobile Sheet side=right (SPRING.sheet) · no scale on entrance

a11y · bell aria-label is dynamic ('Notifications — N unread'); rows are buttons with mono text + relative time; popover follows the WAI-ARIA menu pattern via use-menu-popover (ESC, outside-click, arrow roving)

rule · store READS the bus only — no bus.emit, no useEngagement; money invariant by construction (store.test.ts asserts emit never called + rows carry no numeric money field)

rule · ring buffer cap 30 (NOTIFICATIONS_CAP) — newest first

rule · maps ONLY events the typed bus actually emits (noise events drop to null in map.ts)

rule · mark-read on CLOSE, not open — the unread highlight persists while the feed is up

rule · Bell owns the Feed state internally; chrome mounts only <NotificationBell/> + <NotificationsProvider/>

used by · components/chrome/TopNav.tsx (CHROME-MAKER mounts NotificationBell) · app/layout.tsx (CHROME-MAKER mounts NotificationsProvider)

cosweat-tables

draftcomponents/community/cosweat/CoSweatTables.tsx

Named co-sweat 'play together' shells — each card is a TransitionLink into a real detail page (same art string both sides). The seat rail shows REAL sim members (memberFor) through ds/primitives/Avatar accented by archetype, with honest OPEN chairs (dashed nodes Glyph) so the shell reads as joinable, never an always-full social-proof wall. Header + a per-card SIMULATED Pill state it plainly: simulated co-presence on demo credits, zero fake social proof, no real-multiplayer claim. CHALLENGE lifts the lead seated member to the parent (M-WIRE), which drops a callout draft into the chat composer — the EXISTING challenge seam, unforked. Presentation-only: NEVER bus.emit / useEngagement / money.

ssr-empty (pre-mount, empty chairs)seated (filled + open chairs)challenge-armed

PULL UP A SEAT

NAMED SHELLS · SIMULATED CO-PRESENCE · OWNERSHIP POINTS
Open THE CRASH PIT
SWOOBZ ORIGINALSSIMULATED

THE CRASH PIT

Sweat the multiplier together

pulling up seats…
SEATED
Open THE MINES SHELL
SWOOBZ ORIGINALSSIMULATED

THE MINES SHELL

Step the grid side by side

pulling up seats…
SEATED
Open THE LIVE BOX
PREMIER LEAGUESIMULATED

THE LIVE BOX

Call the next line together

pulling up seats…
SEATED
Named co-sweat shells - real sim faces (Avatar) + honest open chairs, labelled SIMULATED. CHALLENGE is a no-op here (no parent seam) - never a fake send.

motion · Reveal cascade (per-card delay) · TiltCard maxTilt 6 · TransitionLink card-becomes-page overlay · 45s shared clock (no per-card timers)

a11y · filled seats: Avatar role=img/alt={handle} + sr-only '{handle}, {archetype}, simulated' so the simulated nature reaches assistive tech; open chairs: role=img aria-label='Open seat'; SIMULATED badge is text (never color-only); CHALLENGE is a real button; seat-count label is mono text, never a bare dot

rule · HONEST simulated co-presence — every face is a real sim member (memberFor + SIM_NAMES), seats deterministic on a 45s window (no Math.random); never invents handles

rule · always >= 1 open chair and >= 2 seated — the rail never reads as a closed crowd, never inflates occupancy (cosweat-data.test asserts the bounds)

rule · labelled SIMULATED / DEMO CREDITS — zero fake social proof, no real-multiplayer claim (keeps the TableCard honesty contract)

rule · presentation-only — reads memberFor + seeded sim; NEVER bus.emit, NEVER useEngagement; CHALLENGE reuses the chat composer-prefill seam through the parent, never moves a credit

rule · Glyph-only icons (bolt CTA, nodes open-chair), no emoji, no raw hex/EASE/z literals (ds/tokens)

rule · M-WIRE mounts <CoSweatTables onChallenge=…/> in app/community/page.tsx via the CoSweatSection client wrapper (the server page can't hold the useChatUi handler) and passes the composer-prefill handler; onChallenge is optional (honest no-op default) so the section stands alone

used by · components/community/cosweat/CoSweatSection.tsx (M-WIRE client mount wrapper — owns the requestPrefill(challengeLine(member)) seam) · app/community/page.tsx (M-WIRE mounts <CoSweatSection /> at slot 03, between LiveTables and FloorTalks)

SPORTSBOOK

Board chrome - store-coupled scan strips and lane filters.01 ENTRIES

sportsbook-sport-meta-strip

draftcomponents/sportsbook/SportMetaStrip.tsx

Per-sport scan strip surfacing live posture (N LIVE / M), league spread (N LEAGUES) and next kickoff (NEXT · home—away · 1H). Renders inside the pinned LeagueChips slot, above the league chips, only when a lane is committed (lane !== 'all'). Three pure facts from board-groups' sportMeta() selector; presentation-only, emits nothing.

livepre-matchno-fixtures

LIVE PREVIEW NEEDS APP CONTEXT · RUNNING AT /sportsbook

proptypedefault
sportSport

a11y · three facts read as mono micro-labels; live posture mirrors the lane-rail live dot convention

rule · store-coupled (reads useLaneStore lane via LeagueChips parent) — not standalone-renderable in a pure preview

rule · backed by the new pure selector sportMeta(matches, sport) in board-groups.ts (covered by board-groups.test.ts)

rule · presentation-only — emits nothing (MONEY INVARIANT safe)

rule · /ds preview = context-route fallback (RUNNING AT /sportsbook) — mirrors the lane-rail store-coupled convention (verified-facts 2026-06-13)

used by · components/sportsbook/LeagueChips.tsx

ADMIN

Operator register - density-driven tables, KPIs, forms, PII reveals.37 ENTRIES

density-boundary

draftds/admin/DensityBoundary.tsx

Client wrapper stamping data-density (+data-domain) so the var cascade re-skins descendants; publishes density-context for portals that escape the DOM cascade.

compact
ultra-compact
proptypedefault
densityDensity
domain"admin" | nullnull
classNamestring
childrenReactNode

a11y · presentation wrapper only — no role; geometry change is visual + var-driven

rule · SSR-safe default frozen {domain:null,density:'comfortable'}

rule · nested boundaries shadow wholesale (inner density+domain win)

rule · portals escape the DOM cascade — they read useDensity() and re-stamp data-density on their own portal root at open

rule · data-domain='admin' arms the Exposure-Red selector

used by · styleguide admin previews · admin shells (later wave)

admin-button

draftds/admin/AdminButton.tsx

Calm non-magnetic admin CTA — height var(--density-control-h) (32 compact / 24 ultra). Hover is a border/bg transition only; focus is the FOCUS_RING outline.

solidghostdangerdefaulthoverdisabledfocus-visible
proptypedefault
variant"solid" | "ghost" | "danger""ghost"
childrenReactNode
...nativeButtonHTMLAttributes

motion · border/bg color transition only (≤100ms admin motion budget) — no magnetic lean, no glow-on-hover

a11y · FOCUS_RING outline (no neon glow); disabled dims to opacity-40 + not-allowed

rule · NEVER composes MagneticButton — no magnetic lean, no cursor-ring lock

rule · no glow-on-hover (border/bg color transition only)

rule · solid→volt/ink, ghost→line/bone, danger→blood

used by · data-table · page-header · admin previews

admin-badge

draftds/admin/Badge.tsx

Admin status badge — status vocabulary → accent + label; non-focusable <span>. Status is conveyed by LABEL text + accent, never color alone, never a left-stripe. Re-exports StatusDot.

ACTIVEPENDINGBLOCKEDFLAGGEDAPPROVEDREJECTEDREVIEWLIVEEXPIREDDRAFTSUSPENDED
LIVE FEED
proptypedefault
statusBadgeStatus
dotbooleantrue
classNamestring

a11y · status conveyed by LABEL text + accent, never color alone; non-focusable span; density-compact/density-ultra size steps

rule · triple-channel — label+accent, never color alone

rule · NO left-stripe status indicator

rule · StatusDot (re-exported from Badge) is a small accent dot + optional pulse for live/pending, aria-hidden, label supplied by sibling text

used by · data-table (via caller render-prop) · admin row/cell kit

admin-input

draftds/admin/Input.tsx

Form-kit text input, height var(--density-input-h) (32 compact / 28 ultra). Well surface, mono value text, FOCUS_RING outline; invalid → blood border + aria-invalid.

defaultinvalidfocus-visibledisabled
proptypedefault
invalidbooleanfalse
...nativeInputHTMLAttributes (forwardRef)

a11y · aria-invalid mirrors invalid; FOCUS_RING outline

rule · density-driven height

rule · blood border on invalid

rule · no new glyph

used by · admin-form-field · admin-search-input

admin-form-field

draftds/admin/FormField.tsx

Label + control + hint/error wrapper; wires id (label htmlFor), aria-describedby (to hint/error), and pushes invalid into the control on error. Error message is mono + blood.

defaulterror

Hex address, no prefix

Must be a positive number

proptypedefault
labelReactNode
hintReactNode
errorstring
childrencontrol element

a11y · label htmlFor + aria-describedby to the hint/error; error message mono+blood, control border blood via its invalid prop

rule · error replaces the hint and turns it blood

rule · composes any density-driven control by slot

used by · admin forms

admin-select

draftds/admin/Select.tsx

Token-skinned native <select>, height var(--density-control-h), pure-CSS chevron (appearance-none). invalid → blood border.

defaultinvalidfocus-visibledisabled
proptypedefault
invalidbooleanfalse
children<option> elements
...nativeSelectHTMLAttributes (forwardRef)

a11y · aria-invalid mirrors invalid; FOCUS_RING outline

rule · appearance-none + CSS chevron (not a Glyph — Glyph.tsx locked)

rule · blood-on-invalid

used by · admin forms

admin-checkbox

draftds/admin/Checkbox.tsx

Token-skinned checkbox; volt fill on checked with a pure-CSS tick. Native input drives state (peer); FOCUS_RING via peer-focus-visible.

uncheckedcheckedfocus-visibledisabled
proptypedefault
labelReactNode
...nativeInputHTMLAttributes (forwardRef)

a11y · native input drives state; FOCUS_RING via peer-focus-visible

rule · native input drives state (peer)

rule · pure-CSS tick (no Glyph)

rule · ≤100ms transition

used by · admin forms

admin-switch

draftds/admin/Switch.tsx

Token-skinned toggle (native checkbox role=switch) skinned as track + knob; track fills volt on checked, knob slides via translate.

offonfocus-visibledisabled
proptypedefault
labelReactNode
...nativeInputHTMLAttributes (forwardRef)

a11y · role=switch; FOCUS_RING via peer-focus-visible

rule · track fills volt on checked, knob slides via translate

rule · no glow

used by · admin forms

admin-search-input

draftds/admin/SearchInput.tsx

Search field — Input geometry + leading pure-CSS magnifier + a real focusable clear button. Height var(--density-input-h).

proptypedefault
valuestring
onValueChange(value: string) => void
clearLabelstring"Clear search"
...nativeInputHTMLAttributes

a11y · clear is a real focusable button with aria-label; FOCUS_RING

rule · leading magnifier is a pure-CSS mark (NOT a Glyph — Glyph.tsx locked, no search glyph)

rule · clear is a real focusable button

rule · no glow

used by · admin tables/toolbars

admin-modal-parts

draftds/admin/ModalParts.tsx

Compound Modal (root + .Header / .Body / .Footer) over ds/motion/Modal — composes the existing overlay (opacity+travel entrance, scrim tiers, useOverlay ESC/scroll-lock/focus); does NOT re-implement it. Close control is a CSS '×'.

closedopen
proptypedefault
openboolean
onClose() => void
ariaLabelstring
scrim"sheet" | "modal" | "takeover""modal"
classNamestring
childrenReactNode

motion · inherits ds/motion/Modal (opacity + travel entrance, scrim tiers)

a11y · role=dialog aria-modal via ds/motion/Modal; ESC + scroll-lock + focus preserved

rule · composes the EXISTING ds/motion/Modal — does NOT re-implement the overlay

rule · close control is a CSS '×' (Glyph locked)

used by · reveal-with-reason · admin dialogs

reveal-with-reason

draftds/admin/RevealWithReason.tsx

Masked-by-default value with a reason-required reveal → onReveal(reason). Owns only modal-open + reason-input state; the revealed value comes from the parent after onReveal resolves (never derived client-side).

maskedreason-modal-openrevealedreveal-error
•••.•••.•••.••
proptypedefault
maskedDisplayReactNode
revealedDisplayReactNode
isRevealedboolean
reasonRequiredbooleantrue
reasonMinLennumber8
reasonPlaceholderstring
onReveal(reason: string) => Promise<void>
onError(err: unknown) => void
onRemask() => void
eventTypeDisplaystring
fieldLabelDisplaystring
size"sm" | "md"
classNamestring
data-testidstring

a11y · reason input is a native <textarea> (Input has no multiline variant); confirm gated on reason length

rule · owns ONLY modal-open + reason-input state

rule · revealed value comes from the parent after onReveal resolves — NEVER derived client-side

rule · confirm gated on reason.trim().length >= reasonMinLen

rule · rejection keeps modal open, surfaced via onError

rule · MONEY INVARIANT — onReveal is caller-owned, this component NEVER emits a bus event

used by · masked-pii-cell

masked-pii-cell

draftds/admin/MaskedPiiCell.tsx

Masked PII table cell — kind → fieldLabel + audit-event via pii-contract; composes RevealWithReason. Both masked AND revealed forms are server-supplied props (no masking arithmetic).

+1 ••• ••• ••42
proptypedefault
kind"ip" | "phone" | "id-number" | "kyc-doc"
maskedDisplayReactNode
revealedDisplayReactNode
isRevealedboolean
reasonMinLennumber8
onReveal(reason: string) => Promise<void>
onError(err: unknown) => void
onRemask() => void
fieldLabelOverridestring
eventTypeOverridestring
size"sm" | "md"
classNamestring

a11y · inherits RevealWithReason reason ceremony

rule · kind ONLY drives field-label + audit-event default — NO masking arithmetic

rule · both masked AND revealed forms are server-supplied props

rule · pii-contract defaults: ip→'IP address'/admin.pii.ip_revealed.v1, phone→'Phone number'/admin.pii.phone_revealed.v1, id-number→'ID number'/admin.pii.id_number_revealed.v1, kyc-doc→'KYC document'/admin.pii.kyc_doc_revealed.v1

used by · admin player tables

data-table

draftds/admin/DataTable.tsx

Generic admin table: column render-props + baked loading/error/empty states with FROZEN precedence (loading > error > empty > rows). Semantic <table> + <th scope=col>; aria-sort scaffold present, sort LOGIC deferred to DataGrid. v2 (Wave 1 item 5, additive/opt-in): built-in search + filter header (client-side over loaded rows only, data-table-filter.ts), a mobile card-stack below `sm` with an always-reachable actions footer (`stackOnMobile` / `renderCard` / `renderRowActions`), and per-column truncate (native `title`, not a custom tooltip widget).

loadingerroremptyrows
ACCOUNTSTATUSBALANCE
0x91F2A0ACTIVE+€1,240.00
0x4B7E11REVIEW+€88.50
0xC0FFEEBLOCKED−€12.00
0x7A3D90FLAGGED+€5,310.75
proptypedefault
columnsDataTableColumn<Row>[] {key, header, cell, align?, mono?, nowrap?, sortable?, truncate?}
searchableboolean (requires getSearchText at the type level)
getSearchText(row: Row) => string
searchPlaceholderstring"SEARCH"
filtersDataTableFilter<Row>[] {id, label, options, predicate} (data-table-filter.ts)
stackOnMobileboolean — renders a card per row below `sm`, actions always reachablefalse
renderCard(row: Row) => ReactNode — custom mobile card body; omit for the auto-built label:value card
renderRowActions(row: Row) => ReactNode — pinned to the card footer; falls back to the `actions`-keyed column
rowsreadonly Row[]
rowKey(row: Row) => string
loadingboolean
errorstring | null
onRetry() => void
empty{headline, body?, ctaLabel?, onCta?}
sortKeystring
sortDir"asc" | "desc""asc"
data-testidstring

a11y · semantic <table> + <th scope=col>; aria-sort scaffold on sortable headers

rule · precedence FROZEN loading>error>empty>rows (resolveDataTableState, own test)

rule · numeric cols use align:end + mono (tabular-nums)

rule · cells return PRE-FORMATTED ReactNode (parent formats amounts w/ explicit sign, no money math)

rule · row height rides var(--density-row-h)

rule · Badge is passed by caller via the cell render-prop, not imported by DataTable

rule · search/filter narrow ONLY the rows already loaded — never a server call (data-table-filter.ts)

rule · truncate uses the native `title` attribute (not the DS Tooltip primitive) — a non-interactive span carrying tabIndex fails lint/a11y/noNoninteractiveTabindex, and CSS truncation never removes the full text from the a11y tree anyway

rule · truncate is a no-op for non-string cell content — never puts arbitrary JSX in a title attribute

used by · admin lists/triage

admin-page-scaffold

draftds/admin/AdminPageScaffold.tsx

The one page-shell composite: Breadcrumb (only when a non-empty trail is given) → PageHeader (eyebrow/title/subtitle/actions/stats, forwarded unchanged) → children, all inside `flex flex-col gap-6 p-6` — formalizes the wrapper ~50 admin page.tsx/*Client.tsx files already hand-copy (e.g. partner-applications/page.tsx:25, cashier/CashierHubClient.tsx:86). Purely additive; touches neither Breadcrumb nor PageHeader, zero existing PageHeader consumers regressed.

LIVE PREVIEW NEEDS APP CONTEXT · SEE ds/admin/AdminPageScaffold.tsx

proptypedefault
breadcrumbsreadonly BreadcrumbItem[] (optional — omit or pass [] on a group's top-level landing page, §5.2)
eyebrowReactNode (forwarded to PageHeader)
titleReactNode (forwarded to PageHeader, required)
purposeReactNode (forwarded to PageHeader — one-line job-language description, above subtitle)
subtitleReactNode (forwarded to PageHeader)
actionsReactNode (forwarded to PageHeader)
statsReactNode (forwarded to PageHeader — KpiStrip / DashboardCard row)
relatedPageHeaderRelatedLink[] (forwarded to PageHeader — capped at 3)
relatedLinkComponentType<{href, className?, children}> (forwarded to PageHeader's Link prop)plain <a>
childrenReactNode (page content)
classNamestring

a11y · inherits Breadcrumb (nav[aria-label=Breadcrumb] > ol > li, last item aria-current=page) and PageHeader (title is the page heading) unchanged

rule · composes the EXISTING canonical Breadcrumb + PageHeader only — never re-implements either's markup

rule · breadcrumbs omitted or empty → no Breadcrumb rendered (top-level group-landing pages per §5.2)

rule · the flex flex-col gap-6 p-6 wrapper is the ONE source of the page-content gutter convention — pages adopting this composite drop their own hand-copied wrapper div

gated-surface-notice

draftds/admin/GatedSurfaceNotice.tsx

The one 'this surface is off' register every ALLOW_*-gated admin route renders on a 503/gate-disabled response, instead of each page inventing its own failure copy — or worse, mislabeling a deploy-gate response as a different failure (the content/registration-form/[id] 'FORM NOT FOUND' bug this fixes). Composes InlineState only; kind=empty — a gated surface is neutral, not an error to retry.

LIVE PREVIEW NEEDS APP CONTEXT · SEE ds/admin/GatedSurfaceNotice.tsx

proptypedefault
envVarstring (the ALLOW_* deploy-gate env var, rendered verbatim so the operator knows exactly which flag to flip)
surfacestring (optional — what's disabled, e.g. "Announcements"; falls back to a generic phrase)
classNamestring

a11y · inherits InlineState unchanged

rule · kind is always empty, never error — a gate-off surface is not a failure state

rule · render this INSTEAD OF the page's normal data/error UI when the API response is 503 gate-disabled, not alongside it

rule · never used for genuine 404/500 responses — those keep their own accurate InlineState kicker

dashboard-card

draftds/admin/DashboardCard.tsx

Admin KPI card: mono eyebrow + big mono tabular numeric + delta row; exposure tone routes the value through text-hero (var(--color-hero)) so the one sanctioned Exposure-Red selector swaps it volt→blood in ultra-compact admin for free. isLive adds the cyan StatusDot pulse. Wave 1 item 7 — the KPI provenance contract: `scope` is now REQUIRED so a client-side page-filtered count can never present visually identical to a real server total (`loaded-page` renders a small "PAGE" tag beside the eyebrow).

defaultexposuredefaultkpi-deltakpi-trendkpi-live
DEPOSITS / 24H€312,880+8.2%
WITHDRAWALS€97,1400.0%
EXPOSURE€1.24M+€44k
proptypedefault
eyebrowReactNode
valueReactNode (pre-formatted string)
scope"server-aggregate" | "loaded-page" (REQUIRED — verify the true provenance at the call site, never assume)
deltaStatTileDelta {value; direction}
trendReactNode (pass a MiniTrend)
freshnessReactNode (pre-formatted "as-of" string, e.g. "2m ago" — combines with foot on one caption line)
tone"default" | "exposure""default"
isLivebooleanfalse
hrefstring — click-through into the surface this KPI summarizes
LinkComponentType<{href, className?, children}> — client-routed nav, defaults to a plain <a>plain <a>
classNamestring

a11y · numeric is mono/tabular; isLive dot is aria-hidden, meaning carried by the eyebrow

rule · numeric is mono/tabular (admin doubles down on mono, §5)

rule · tone=exposure routes the value through text-hero so the ONE sanctioned Exposure-Red compound selector swaps it volt→blood in ultra-compact admin for free (never a raw accent)

rule · isLive adds the cyan StatusDot pulse (cyan budget gate)

rule · density-padded via var(--density-pad-card); no money math (pre-formatted strings)

rule · scope is required — do not default to server-aggregate without verifying the source query is a true COUNT(*)/aggregate, not a capped/paginated client count

used by · kpi-strip · page-header stats slot

kpi-strip

draftds/admin/KpiStrip.tsx

Horizontal KPI row: density-gap responsive grid composing DashboardCards / StatTiles. Pure layout — rides var(--density-gap) so spacing tightens with density.

GGR€212k+4.0%
NGR€188k+2.7%
MARGIN6.4%−0.2pp
LIVE TABLESPAGE24
proptypedefault
childrenReactNode (DashboardCard/StatTile cells)
columns2 | 3 | 4 (mobile always 2-up)4
classNamestring

rule · pure layout — rides var(--density-gap) so spacing tightens with density

rule · the cyan / Exposure-Red rules live on the cards, the strip only arranges them

used by · page-header stats slot · admin dashboards

inline-state

draftds/admin/InlineState.tsx

Inline empty/error register: compact, table-frame-preserving (distinct from the page-register ds/patterns/EmptyState giant-404 headline). error tints kicker blood + hosts retry; empty keeps it fog; action by slot.

emptyerror

NO RESULTS

No accounts match the current filters.

COULD NOT LOAD

Upstream timed out (504).

proptypedefault
kind"empty" | "error""empty"
kickerReactNode
bodyReactNode
actionReactNode
classNamestring

a11y · kicker carries the state label as text; action is a slotted control

rule · COMPACT register distinct from the page-register ds/patterns/EmptyState (giant 404 headline)

rule · error tints kicker blood + hosts retry, empty keeps it fog

rule · action by slot (imports no button — stays dependency-light inside tables)

rule · vertical padding from the consumer

used by · data-table (error/empty rows)

admin-spinner

draftds/admin/Spinner.tsx

Token-stroked rotating loading ring (calm admin register, no glow). currentColor drives the stroke — tint via text-*; role=status + sr-only label.

LoadingLoadingLoading
proptypedefault
sizenumber (px)16
classNamestring
labelstring (sr-only; '' silences)"Loading"

a11y · role=status + sr-only label; aria-hidden geometry; '' silences the announcement

rule · animate-spin at admin pace (≤ house durations)

rule · currentColor drives stroke — tint via text-*

rule · no accent shout, no neon glow

used by · admin loading states

admin-shell

draftds/admin/AdminShell/AdminShell.tsx

Compound operator shell on ds tokens: sticky top bar (brand + section context + Cmd-K trigger + operator identity), collapsible section sidebar with active-rail, command palette (Cmd/Ctrl+K, arrow-key nav over the flattened nav), and mobile chrome (left drawer + bottom quick-nav). Presentational + slot-driven — the app owns nav config, resolved operator identity, and the detail-rail / audit-bar contents.

sidebar-expandedsidebar-collapseddrawer-open (mobile)palette-open

LIVE PREVIEW NEEDS APP CONTEXT · SEE app/admin (AdminConsoleShell — the whole console chrome)

proptypedefault
sectionsAdminNavSection[]
activePathstring (app passes usePathname())
operatorAdminOperator | null
detailRail / detailRailCollapsed / footerReactNode / boolean / ReactNode (slots)

a11y · mobile-first hard gate — sidebar folds to a drawer + bottom nav under md, every nav target >=44px, no horizontal overflow at 390px; skip-link first in DOM; active route carries aria-current; palette is a labelled aria-modal dialog; drawer closes on Escape/scrim

rule · presentational — no claims/session logic; the app claim-filters sections BEFORE passing them

rule · cyan economy: active-nav rail + Cmd-K key hint + highlighted palette row only

rule · navigation is plain <a href> (dependency-light, matches the console pattern) — no next/navigation coupling

rule · detail-rail + audit-bar are slots the app fills (keeps the legacy rail contract working during migration)

used by · app/admin (AdminConsoleShell — the whole console chrome)

section-card

draftds/admin/SectionCard.tsx

The titled dashboard-section container — a coal panel with a hairline-separated header (eyebrow + title + optional live cyan dot + right action slot) over a body. Turns loose stacked panels into a scannable cockpit. flush hands the body edge-to-edge for a DataTable / divided ActivityRow feed.

RECENT ACTIVITYSettlements
Market 0x91F2 settledoracle · 4 outcomes
12:04
House pool rebalancedrevenue-router
11:58
Withdrawal held for reviewcashier · manual
11:41
proptypedefault
eyebrowReactNode
titleReactNode
actionReactNode (link / Badge / AdminButton by slot)
isLivebooleanfalse
flushbooleanfalse
classNamestring
childrenReactNode

a11y · <section> landmark; isLive dot is aria-hidden, meaning carried by the title

rule · header composes AdminButton / Badge by SLOT — imports none of them

rule · isLive adds the cyan StatusDot pulse (cyan budget gate)

rule · flush drops body padding so a table / feed seats against the border

used by · seam scaffolds · cockpit landings

activity-row

draftds/admin/ActivityRow.tsx

One dense row of an activity / audit feed — a leading accent StatusDot, a two-line body (event + mono meta), and a trailing mono cell (time or value). The dot is decorative; the meaning rides the text, so the row survives forced-colors and never leans on colour alone. Stack inside a SectionCard flush body with divide-y for the feed rhythm.

KYC approved0x4B7E11 · support
+2m
Bet placedlive · €120
now
Session ended0xC0FFEE
18m
proptypedefault
accentAccentName"fog"
pulsebooleanfalse
titleReactNode
metaReactNode
trailingReactNode (mono time / value)
classNamestring

a11y · leading dot aria-hidden; the event + meta text carry the meaning

rule · meaning rides the text, never colour alone (forced-colors safe)

rule · trailing cell is mono/tabular

rule · stack in a SectionCard flush body with divide-y divide-line/60

used by · cockpit recent-activity feeds

quick-action

draftds/admin/QuickAction.tsx

A navigable quick-action tile — a glyph disc, a label + hint, and a trailing chevron that leans forward on hover (transform-only, the admin motion budget). Renders a real anchor (keyboard-reachable, works without JS). primary is the single volt-emphasised affordance a surface leads with; default the calm siblings. The suggested-next affordance the ScaffoldState seams and cockpit landings compose.

defaultprimary
proptypedefault
hrefstring
glyphGlyphId
labelReactNode
hintReactNode
tone"default" | "primary""default"
externalbooleanfalse
classNamestring

a11y · real <a> anchor — keyboard-reachable, FOCUS_RING outline; glyphs aria-hidden

rule · hover leans the chevron transform-only (admin motion budget) + border/bg colour transition

rule · primary is the ONE volt-emphasised affordance; default is calm line

rule · external adds rel=noreferrer

used by · scaffold-state suggested actions · cockpit quick-action grids

scaffold-preview

draftds/admin/ScaffoldPreview.tsx

The dimmed ghost of the interface a scaffolded surface WILL render once its data producer is wired — a skeleton table / KPI cards / activity feed / chart / form. Exists so an operator sees the STRUCTURE that is coming, not a blank void. Every part is aria-hidden (the real state is carried by the ScaffoldState copy layered over it) and fully static / deterministic (no Math.random).

tablecardslistchartform
proptypedefault
kind"table" | "cards" | "list" | "chart" | "form"

a11y · entirely aria-hidden — announces nothing; the ScaffoldState copy carries state

rule · static + deterministic ghost (no Math.random — Domain C fixture contract)

rule · skeleton bars via the Skeleton primitive; card/chart ghosts host a fog MiniTrend

used by · scaffold-state

scaffold-state

draftds/admin/ScaffoldState.tsx

THE designed 'not wired yet' surface — the replacement for the bare bordered void box the honest-seam pages shipped. NEVER an empty frame: a glyph disc, a pulsing cyan status pill, a human headline, a plain-language explanation, an honest technical seam kicker, a suggested next action, AND a dimmed ScaffoldPreview of the interface this surface will grow into. Server-safe (no hooks / no motion): the seam pages render it directly. The ghost sits inset-0 under a radial + bottom scrim (tokenised via var(--color-coal)); the preview is aria-hidden, the copy is what announces.

WIRED · AWAITING DATA

Churn cohorts land when the detector persists

packages/churn-detector runs in-memory only today, with no persisted cohort table, so the API returns a stub rather than real cohort counts.

SHIPS WHEN churn-detector persists cohorts

proptypedefault
glyphGlyphId
statusReactNode (mono pill copy)
titleReactNode
bodyReactNode
kickerReactNode (honest seam)
actionReactNode (QuickAction / anchor)
previewScaffoldPreviewKind"table"
accentAccentName"cyan"
data-testidstring
classNamestring

a11y · the copy (headline/status/body/kicker) is the announced content; the ghost preview is aria-hidden

rule · NEVER a bare void — always a glyph, headline, explanation, next action AND a skeleton preview of the coming UI

rule · server-safe (no hooks / no motion) — honest-seam pages render it directly

rule · scrims tokenised via var(--color-coal); status pill is the ONLY cyan (cyan budget gate)

rule · forwards data-testid so the pages' placeholder-* selectors survive

used by · app/admin honest-seam pages (churn, decision-engine, experiments, observability, cms, lotteries, operators, seo, settings, stats, tournaments)

api-error-state

draftds/admin/ApiErrorState.tsx

Surfaces the backend human-readable message body VERBATIM (never a bare "HTTP 503") and classifies the failure into operator language via kind: permission (names the missing claim, gold), disabled (deploy switch off, calm fog register matching GatedSurfaceNotice), error (a genuine failure, alarming blood register + retry). data-kind carries the classification as a real DOM attribute. Optional collapsed-by-default detail expander (native <details>, no JS state) for the raw payload.

LIVE PREVIEW NEEDS APP CONTEXT · SEE app/admin bridge hub + stuck + reorg-alerts (gated/error banners) · app/admin /audit-log (error + CORE_DATABASE_URL-not-configured states)

proptypedefault
kind"permission" | "disabled" | "error"
messagestring (the backend raw message body, surfaced verbatim)
claimstring (kind="permission" — the missing claim/scope)
envVarstring (kind="disabled" — the ALLOW_* deploy-gate env var, rendered verbatim)
surfacestring (what is affected, e.g. "Bridge operations")
onRetry() => void
detailunknown (raw payload for the collapsed detail expander)
classNamestring
data-testidstring

a11y · kicker + message are the announced content; the detail expander is a native <details>/<summary> (keyboard + screen-reader native disclosure)

rule · ALWAYS surfaces the backend message body verbatim — never collapses to a bare HTTP status

rule · kind="disabled" never renders the alarming blood accent — a gated surface is not a failure

rule · kind="error" is the only alarming (blood) register and the one that expects onRetry

rule · server-safe (no hooks) — the detail expander is a native <details>, collapsed by default

rule · this is the general contract every page-level data-fetch failure should render through instead of hand-rolling InlineState branching

used by · app/admin bridge hub + stuck + reorg-alerts (gated/error banners) · app/admin /audit-log (error + CORE_DATABASE_URL-not-configured states)

seam-state

draftds/admin/SeamState.tsx

Every zero/blank admin surface declares WHICH kind of nothing it is: verified-empty (real data source, zero rows — says what "none" means + the next step), awaiting-data (feature real, producer not shipped — delegates to ScaffoldState), disabled-by-flag (deploy switch off — delegates to ApiErrorState kind="disabled", VISIBLY DISTINCT from verified-empty), error (delegates to ApiErrorState). data-seam-kind carries the classification as a real DOM attribute. Does NOT replace app/admin SeamScaffold (kept working unchanged) — this is the newer general contract for pages that must tell all four kinds of nothing apart, closing the Wave-0 bug where bridge-reorg's gated-off state rendered identically to "no alerts".

LIVE PREVIEW NEEDS APP CONTEXT · SEE app/admin bridge stuck + reorg-alerts (mobile fallback state) · app/admin /kyc queue (verified-empty copy fix)

proptypedefault
kind"verified-empty" | "awaiting-data" | "disabled-by-flag" | "error"
headline / body / actionReactNode (kind="verified-empty")
glyph / status / title / body / kicker / action / preview / accentScaffoldState props (kind="awaiting-data")
envVar / surface / messageApiErrorState disabled props (kind="disabled-by-flag")
errorKind / message / claim / surface / onRetry / detailApiErrorState error props (kind="error")
classNamestring
data-testidstring

a11y · inherits InlineState / ScaffoldState / ApiErrorState per branch, unchanged

rule · a discriminated union on kind — each branch only accepts that branch's props

rule · disabled-by-flag MUST stay visually + copy distinct from verified-empty (the Wave-0 bridge-reorg identical-empty bug)

rule · does not replace app/admin/SeamScaffold.tsx — that stays working unchanged for existing honest-seam pages

rule · data-seam-kind is a mechanical, real DOM attribute — tests assert on it, not on styled text

used by · app/admin bridge stuck + reorg-alerts (mobile fallback state) · app/admin /kyc queue (verified-empty copy fix)

donut-chart

draftds/admin/DonutChart.tsx

Ring breakdown of flat single-accent arcs (never a two-accent blend on one arc) + a label/value/share legend. Segments carry pre-resolved basis points (Domain A: buildDonutGeometry does only arc-length arithmetic on already-server-computed bps, no client float division of a financial value). Honest empty state — an all-zero/absent segment set renders a dim resting ring + InlineState instead of a fabricated full circle.

populatedemptydefaultempty

LIVE PREVIEW NEEDS APP CONTEXT · SEE app/admin GlassBoxDashboard (revenue-router bucket breakdown)

proptypedefault
segmentsReadonlyArray<{id; label; valueBps; valueDisplay; accent}>
centerLabelReactNode
centerValueReactNode
emptyKickerReactNode"NO DATA"
emptyBodyReactNode
classNamestring

a11y · the ring SVG is aria-hidden; the legend's visible text (label + share % + value) carries the meaning, never the arc color alone

rule · each arc is ONE flat ACCENT_CLASSES color — never two accents blended on the same arc/segment

rule · valueBps is basis points already resolved server-side (floor division); this component performs no financial math, only arc-length geometry via donut-geometry.ts

rule · empty/zero segments never draw — buildDonutGeometry drops <=0 bps and returns [] when the whole set is zero, which renders the honest InlineState register instead of a full ring

used by · app/admin GlassBoxDashboard (revenue-router bucket breakdown)

bar-chart

draftds/admin/BarChart.tsx

Horizontal bar list — mono label + pre-formatted value row over a track/fill bar, scaled against the largest bar (or an explicit max). Native title attribute carries the value on hover (the visible valueDisplay text is the real a11y channel, the tooltip is a convenience). Honest empty state — an empty or all-zero bar set renders InlineState instead of a fabricated full bar.

populatedemptydefaultempty

LIVE PREVIEW NEEDS APP CONTEXT · SEE app/admin GlassBoxDashboard (markets-by-status posture)

proptypedefault
barsReadonlyArray<{id; label; value; valueDisplay; accent?}>
maxnumber (defaults to the largest bar's value)
emptyKickerReactNode"NO DATA"
emptyBodyReactNode
classNamestring

a11y · each row's visible label + valueDisplay text carries the meaning; bar width is decorative reinforcement, never the only signal

rule · value is a plain count/magnitude used ONLY to size the bar — financial figures still arrive as pre-formatted valueDisplay strings (no money math in this component)

rule · an empty bars array or an all-zero value set renders the InlineState empty register, never a fabricated full-width bar

rule · transition is transform/width only, capped at 300ms (admin motion budget)

used by · app/admin GlassBoxDashboard (markets-by-status posture)

admin-profile-block

draftds/admin/AdminShell/AdminProfileBlock.tsx

Operator identity block pinned to the foot of the console sidebar (and composable into the mobile drawer): an Avatar disc (initials/photo), display name + role in the mono register, and a trailing sign-out affordance. Collapsed rail shows the disc alone (title carries the name).

expandedcollapseddefaultno-operator

LIVE PREVIEW NEEDS APP CONTEXT · SEE ds/admin/AdminShell/AdminSidebar (desktop foot block)

proptypedefault
operatorAdminOperator | null
collapsedbooleanfalse
onSignOut() => void
classNamestring

a11y · the sign-out control carries aria-label + title "Sign out"; the Avatar's initials fallback is role=img + aria-label per the Avatar contract

rule · composes the Avatar primitive — never a bespoke identity disc

rule · display-only: no session/auth logic lives here, the shell supplies the resolved identity + sign-out handler

used by · ds/admin/AdminShell/AdminSidebar (desktop foot block)

admin-icon-action

draftds/admin/AdminShell/AdminIconAction.tsx

A square glyph icon-link for the top-bar action cluster. Anchor-based (a real navigation, never a fake notification bell) — the label is the accessible name (sr-only span + native title). No unread badge; this affordance only ever carries an honest, claim-gated destination the caller resolves.

defaultdefaulthoverfocus-visible

LIVE PREVIEW NEEDS APP CONTEXT · SEE app/admin AdminChrome (settings, audit-log top-bar shortcuts)

proptypedefault
hrefstring (a real destination)
glyphGlyphId
labelstring (accessible name)
classNamestring

a11y · sr-only label span + native title; FOCUS_RING outline

rule · NEVER a fabricated unread/notification badge — this is a plain navigation affordance

rule · the composing app claim-gates which actions render (mirrors the nav item's own gate) so an icon never outlives its authorization

used by · app/admin AdminChrome (settings, audit-log top-bar shortcuts)

trend-chart

draftds/admin/TrendChart.tsx

Single-series area+line HERO chart with a minimal Y-axis (3 gridline ticks) and a bounded, evenly-spaced set of X-axis labels (never one per point). The row-spanning chart a dashboard leads with — distinct from MiniTrend (a KPI-card sparkline, no axes). Honest empty state: fewer than two points renders InlineState instead of a fabricated line, matching the BarChart/DonutChart/MiniTrend convention across the admin chart family.

populatedemptydefaultempty

LIVE PREVIEW NEEDS APP CONTEXT · SEE app/admin GlassBoxDashboard (signups-over-time main chart)

proptypedefault
series{id; label; points: {label; value}[]; accent?: 'volt'|'cyan'}
formatTick(value: number) => stringMath.round(value).toLocaleString('en-US')
emptyKickerReactNode"NOT ENOUGH HISTORY YET"
emptyBodyReactNode
classNamestring

a11y · the SVG carries role="img" + aria-label=series.label; x-axis tick labels are aria-hidden decoration since the same values are already legible text, not the sole channel

rule · accent is 'volt' by default; pass 'cyan' ONLY for the one designated hero metric on a page (the admin cyan budget) — never two cyan hero charts on one screen

rule · y-axis ticks are ALWAYS exactly 3 (min/mid/max), x-axis ticks capped at 6 regardless of series length via trend-chart-path.ts (pure, node-tested geometry)

rule · fewer than two points renders the InlineState empty register, never a fabricated flat/interpolated line

rule · formatTick is the caller's formatter — no money math or locale logic lives in this component itself

used by · app/admin GlassBoxDashboard (signups-over-time main chart)

activity-rail

draftds/admin/ActivityRail.tsx

Compact activity feed list — leading mark (StatusDot by default, or a custom leading slot for a Glyph/avatar) · primary text · muted meta line · optional trailing pre-formatted value, one row per item, hairline-divided. The dashboard-sidebar composite (newest signups, pending approvals, latest XP awards) — thin and scannable, no table chrome. Honest empty state matches the admin chart family.

populatedemptydefaultempty

LIVE PREVIEW NEEDS APP CONTEXT · SEE app/admin GlassBoxDashboard (activity rail: newest signups, pending approvals, latest XP)

proptypedefault
itemsReadonlyArray<{id; accent?; leading?; primary; meta?; trailing?}>
emptyKickerReactNode"NOTHING YET"
emptyBodyReactNode
classNamestring

a11y · the leading StatusDot is aria-hidden; the primary text is the real signal, meta/trailing are supplementary — never color-only meaning

rule · the rail is a thin list, not a table — no column alignment, no headers; each row stays scannable at a glance

rule · an empty items array renders the InlineState empty register, never a fabricated placeholder row

rule · trailing/meta are pre-formatted strings from the caller — no money math or date formatting in this component

used by · app/admin GlassBoxDashboard (activity rail: newest signups, pending approvals, latest XP)

confirm-danger-modal

draftds/admin/ConfirmDangerModal.tsx

The uniform confirm contract for every irreversible/money-mutating admin action: an action-summary sentence, a labeled who/amount/destination restatement (arbitrary rows), and a confirm tier — 'confirm' (a deliberate click) or 'typed' (the operator must type an exact phrase before the confirm button enables). Cancel (ESC, backdrop, header ×, Cancel button) is always the safe default and never fires the action; onConfirm fires ONLY from the confirm button. Owns busy state and surfaces a rejected onConfirm inline without closing.

confirmtypeddefaultbusyerror

LIVE PREVIEW NEEDS APP CONTEXT · SEE app/admin cashier/adjustments AdjustmentsClient (tier='typed', phrase 'ADJUST') · app/admin token-ops BuybackConsole (pause/unpause tier='confirm', create TWAP tier='typed') · app/admin token-ops RevenueRouterMonitor (report fixed costs tier='confirm')

proptypedefault
openboolean
onClose() => void
onConfirm() => void | Promise<void>
actionSummaryReactNode
rowsReadonlyArray<{ label: ReactNode; value: ReactNode }>
tier'confirm' | 'typed''confirm'
typedPhrasestring
typedLabelReactNode

a11y · the underlying Modal owns ESC/scroll-lock/focus-trap via useOverlay; the error line is role="alert"

rule · onConfirm is wired ONLY to the confirm button — no other interaction (cancel, backdrop, ESC) may call it

rule · tier='typed' throws a dev-time error if typedPhrase is missing or empty — fail-closed on misconfiguration, not a silent no-gate

rule · this component never re-derives or previews the action's own money math — the caller supplies pre-computed `rows` (render amounts via MoneyDisplay)

rule · built on the existing ds/admin/ModalParts (Modal.Header/Body/Footer) — does not fork the overlay

used by · app/admin cashier/adjustments AdjustmentsClient (tier='typed', phrase 'ADJUST') · app/admin token-ops BuybackConsole (pause/unpause tier='confirm', create TWAP tier='typed') · app/admin token-ops RevenueRouterMonitor (report fixed costs tier='confirm')

money-display

draftds/admin/MoneyDisplay.tsx

Renders a monetary wire value (bigint/string USDC micro-units, or an integer BPS string) through the house lexical formatter — the ONE place a raw wire integer becomes operator-facing text. unit="usdc" renders grouped truncated dollars ("$1,234.56"); unit="bps" renders a multiplier ("1.5x"). Never prints the bare integer. Pure display (Domain C) — performs no financial math, only string formatting via ds/admin/money-conversion.

usdcbps

LIVE PREVIEW NEEDS APP CONTEXT · SEE app/admin bonus-codes BonusCodesClient + CampaignCard (replaces local formatUsdDisplay/formatBpsDisplay) · app/admin cashier/adjustments AdjustmentsClient (applied amount + new-balance restatement) · app/admin token-ops RevenueRouterMonitor + BuybackConsole (ConfirmDangerModal restatement rows)

proptypedefault
valuebigint | string
unit'usdc' | 'bps''usdc'
classNamestring

a11y · tabular-nums text; carries no color-only meaning

rule · never render a raw micro-unit/BPS integer directly — always through this component or its money-conversion.ts formatters

rule · unparseable input falls back to the raw string rather than throwing (house convention, matches eye-format.ts)

used by · app/admin bonus-codes BonusCodesClient + CampaignCard (replaces local formatUsdDisplay/formatBpsDisplay) · app/admin cashier/adjustments AdjustmentsClient (applied amount + new-balance restatement) · app/admin token-ops RevenueRouterMonitor + BuybackConsole (ConfirmDangerModal restatement rows)

money-input

draftds/admin/MoneyInput.tsx

The operator-facing money input — the ONE place a dollar (mode="usdc") or percent (mode="bps", 1 bps = 0.01%) string is typed. Conversion to the wire format (bigint micro-units / integer BPS as a STRING) happens at this display boundary via pure string math (ds/admin/money-conversion.ts) — never float multiplication, never BigInt in the browser. onChange emits both the raw typed decimal AND the parsed wire value (null while unparseable — fail-closed; caller treats null as not-ready). Preserves an in-progress keystroke (e.g. a trailing ".") across a controlled round trip by only re-seeding its text when the incoming value differs from what it itself last emitted.

usdcbpsdefaultinvalid

LIVE PREVIEW NEEDS APP CONTEXT · SEE app/admin bonus-codes BonusCodesClient create-campaign form (usdMasterAmountLamports, depositRequirementLamports, wageringMultiplierBps, rewardValueBps) · app/admin cashier/adjustments AdjustmentsClient (amount field, allowNegative) · app/admin token-ops BuybackConsole (TWAP total USDC) + RevenueRouterMonitor (fixed costs)

proptypedefault
mode'usdc' | 'bps''usdc'
valuestring
onChange(v: { decimal: string; wire: string | null }) => void
allowNegativebooleanfalse
invalidboolean

a11y · invalid state sets aria-invalid via the underlying ds/admin/Input; the $/% affordance glyphs are aria-hidden

rule · never computes/emits a value via Number()/parseFloat/BigInt — string slicing/padding only (ds/admin/money-conversion.ts)

rule · rejects (wire: null, invalid styling) more than the mode's max decimals, a disallowed negative sign, or non-numeric junk — fail-closed, never a best-effort guess

rule · the `value` prop is the wire format (micro-units for usdc, integer bps for bps) — a call site whose own API expects decimal dollars (e.g. cashier manual-adjust) converts locally with the exported decimalToMicroUnits/microUnitsToDecimalInput helpers rather than storing wire-format state

used by · app/admin bonus-codes BonusCodesClient create-campaign form (usdMasterAmountLamports, depositRequirementLamports, wageringMultiplierBps, rewardValueBps) · app/admin cashier/adjustments AdjustmentsClient (amount field, allowNegative) · app/admin token-ops BuybackConsole (TWAP total USDC) + RevenueRouterMonitor (fixed costs)