Skip to main content
Clawboo’s design system is a single CSS file plus a thin runtime: every color, shadow, font, motion curve, and type step is a CSS custom property declared in apps/web/src/app/globals.css, and switching the entire visual theme is one class flip on <html>. There is no component library to learn; the tokens are the contract, and a small set of reusable primitives (Skeleton, Spinner, StatusPill, EmptyState, FormattedAlert, Select) bundle the right tokens for the patterns that recur. This page explains the theme architecture (Tailwind 4 @theme inline indirection over :root light / .dark overrides), the four-tier surface elevation model, the motion tokens, the type scale, the brand palette and generative team color collections, and the shared primitives. It is for people working on Clawboo’s UI: the goal is that you reach for a token before a raw hex, and a primitive before re-implementing a pattern.
The source of truth for every actual token value is globals.css, which is what this page cites.

The model

The indirection is the whole trick. A Tailwind utility like bg-surface resolves to --color-surface, which the @theme inline block maps to var(--surface), which :root defines as #ffffff and .dark overrides to #111827. Flip one class and every bg-surface element re-paints with no rebuild and no flash. Component code never names a hex; it names a semantic token, and the token carries the theme.

Theme architecture

@theme inline indirection

The top of globals.css is a Tailwind 4 @theme inline block whose entries are pure aliases: --color-surface: var(--surface), --color-mint: var(--mint), and so on. This is what makes the theme runtime-switchable. Tailwind generates utilities from the --color-* names, but each one points at a second variable (--surface, --mint) that is redefined per theme. Without the inline keyword Tailwind would bake the value at build time; with it, the value is read live from the cascade. The same block registers the @custom-variant dark (&:where(.dark, .dark *)) so dark: utilities key off the .dark class rather than a media query; the app controls the theme explicitly, including a “system” mode that resolves to one or the other.

:root light, .dark overrides

Light is the default. :root declares the full token set with light values; the .dark selector re-declares the same token names with dark values. The light theme is paper-white (--background: #f8fafc, --surface: #ffffff, slate-900 text); the dark theme is the original production palette (--background: #0a0e1a, --surface: #111827, #e8e8e8 text). Brand colors are deepened in light mode for AA contrast on white; OpenClaw Red is #e94762 in dark but #d82947 in light, mint is #34d399#059669, amber is #fbbf24#d97706. A handful of tokens are deliberately theme-invariant, declared only in :root and inherited everywhere:
  • Terminal surfaces: --terminal-bg: #0d1117 / --terminal-fg: #e8e8e8. Onboarding install/gateway log windows always read as a terminal regardless of app theme.
  • Motion tokens: the easing curves and durations (see Motion tokens) don’t change between themes.

The one-class flip

Two pieces apply the .dark class, and they must agree:
  1. ThemeProvider (apps/web/src/features/theme/ThemeProvider.tsx), a React context holding theme: 'system' | 'light' | 'dark' and a derived resolvedTheme: 'light' | 'dark'. It reads localStorage['clawboo.theme'] on mount, defaulting to 'light' when nothing is stored (so onboarding happens in light mode). It subscribes to matchMedia('(prefers-color-scheme: dark)') only while in system mode, and adds/removes .dark on document.documentElement whenever resolvedTheme changes. useTheme() exposes { theme, resolvedTheme, setTheme } and throws if called outside the provider. ThemeToggle cycles light → dark → system.
  2. An inline script in apps/web/index.html, runs synchronously before React mounts to prevent a flash of the wrong theme (FOUC). It mirrors the provider’s resolution logic exactly: read localStorage['clawboo.theme'], default to 'light', resolve 'system' via matchMedia, and set .dark on <html>.
The inline index.html script and ThemeProvider’s resolver are duplicated logic by necessity; the script must run before any JS bundle loads. The default ('light'), the storage key (clawboo.theme), and the 'system' resolution must stay identical in both. Change one, change the other.
ThemeProvider wraps the app tree in apps/web/src/app/providers.tsx. Components that render their own pixel-themed surfaces (CodeMirror, Recharts) read resolvedTheme and pick a matching theme object; for example the editor exports both clawbooEditorThemeDark and clawbooEditorThemeLight.

Surface elevation (four tiers)

The visual hierarchy is a four-tier elevation model. Tier 1 is the base --surface; tiers 2–4 each bundle a background, border, shadow, and (for the glass tiers) a backdrop blur into a single class so a card, popover, or modal opts into the right elevation with one className. Each tier resolves its own token trio. The raised tier uses --surface-raised (a slight lift: #fdfdfe light, #141b2e dark), --border, and --shadow-raised. The floating and overlay tiers use translucent surfaces (--surface-floating, --surface-overlay), highlight borders that read on glass (--border-floating, --border-overlay), and progressively heavier multi-layer shadows (--shadow-floating, --shadow-overlay). Shadows are theme-aware: light mode is soft and slate-tinted, dark mode keeps a deep-black drop. The tier class brings only background, border, shadow, and blur. Component-specific properties, border-radius, padding, an accent-colored border, positioning, stay inline on the element. A glass tier is always paired with a scrim behind it: --overlay-scrim (modal strength) and --overlay-scrim-soft (sheet strength) replace hardcoded rgb(0 0 0 / X) backdrops with theme-aware values. --code-block-bg is the companion token for code/tool-call rows and command previews.
The elevation shadows are also exposed as Tailwind utilities, shadow-tier-raised, shadow-tier-floating, shadow-tier-overlay, generated from the --shadow-tier-* aliases in the @theme inline block, for cases where you want the shadow without the rest of the tier treatment.

Motion tokens

Motion is theme-invariant and lives in :root. Five tokens cover the app’s transition vocabulary: The first three are full <duration> <easing> shorthands you drop straight into a transition property (transition: border-color var(--motion-fast)); the last two are easing curves only, for when you need a custom duration. Framer Motion springs are the runtime side of this, used for state-change choreography that CSS transitions can’t express. Accessibility is built in. A global @media (prefers-reduced-motion: reduce) rule neutralizes CSS animation-duration / transition-duration to 0.001ms across *, freezes the skeleton shimmer to a static tint, and forces scroll-behavior: auto. Framer Motion is opted in on top of that by a single <MotionConfig reducedMotion="user"> at the app root (app/providers.tsx) — it does not honour the preference on its own, since MotionConfigContext defaults to reducedMotion: "never". That CSS rule cannot reach motion written straight to the DOM from a requestAnimationFrame loop, so the graph’s two raw-RAF loops (useFloatingMotion’s idle float and graphPhysics’ relaxation pass) read the preference themselves through lib/prefersReducedMotion.ts and stay parked; they also subscribe to the media query, so toggling the OS setting takes effect without a reload. A global :focus-visible rule paints a 2px accent ring (outline: 2px solid var(--primary)) on every keyboard-focused interactive element. It is suppressed inside CodeMirror, which owns its focus visuals, and inside React Flow’s pane and handles — but not on .react-flow__node, which really is a Tab stop (React Flow makes nodes focusable by default), so graph navigation stays visible. Boo nodes paint the ring on their inner .group because the node wrapper is a 280px transparent envelope around a much smaller shape. Muted body text uses --muted-foreground, which is held at ≥4.5:1 against every surface token in both themes and is the token to reach for instead of a low-opacity text-foreground/N. apps/web/src/app/__tests__/tokenContrast.test.ts parses globals.css and enforces it: every text token clears 4.5:1 against every surface it is painted on, every status colour clears the 3:1 non-text floor, and both button-label-on-fill pairs clear 4.5:1. Its KNOWN_DEBT ratchet is empty, and an addition to it means deliberately shipping text below AA. Dark mode resolves a conflict worth knowing about, and it is why the brand red is two tokens. As text, the red has to be light enough to clear the dark surfaces; under a white button label it has to be dark, because white on that lighter red is only 3.83:1 — which was every primary CTA in the app. Lightening fixes the first and worsens the second, so the roles are split: --destructive / --destructive-solid follow the same pattern. Light mode has no conflict, so the -solid tokens alias their base there. Reach for bg-primary-solid / bg-destructive-solid whenever a filled button carries text-primary-foreground / text-destructive-foreground; use plain bg-primary for tints, rings and borders. The guard asserts the label pairs against the -solid fills, so wiring a label onto the wrong one fails the build.

Type scale

Two typefaces across three tokens: --font-display and --font-body both resolve to Inter (loaded from Google Fonts), distinguished by weight and letter-spacing rather than by family, and --font-mono (Geist Mono, a local @font-face from a woff2) is used for code and data. The @theme inline block exposes them as the font-display, font-body, and font-mono Tailwind families. The type scale fills the gaps Tailwind’s default leaves. Tailwind already ships text-xs (12), text-sm (14), text-base (16), text-lg (18), text-2xl (24); the custom tokens add the steps Clawboo needs: A typography rule of thumb runs through the codebase: titles ≥ 18px use --font-display (Inter, bold) with negative letter-spacing; body and small headings use --font-body (Inter, regular); code, tool-call lines, timestamps, and metric columns stay Geist Mono. A .font-data utility class bundles mono + tabular-nums so numeric columns (cost, token counts, ports, durations) align and don’t jitter as values update.

Brand palette and team color collections

The brand colors

The brand identity is three accents, OpenClaw Red (primary), mint, and amber, plus a category palette. Each is a token with a per-theme value and an rgb triplet companion (--mint-rgb, --amber-rgb, --primary-rgb, --foreground-rgb) so component code can compose translucent overlays via rgb(var(--mint-rgb) / 0.2) rather than reaching for a fixed alpha hex. A separate category palette (--category-data, --category-comm, --category-code, --category-file, --category-web, --category-other) gives a stable color per classification slot for skill / cron / source badges. comm aliases mint and file aliases amber so the visual language stays consistent; the rest are their own hues, deepened one step in light mode for contrast. The dark values are intentionally byte-identical to the hardcoded hex they replaced, so the dark UI looked unchanged after the token migration. Consumers compose opacity with color-mix(in srgb, var(--category-X) N%, transparent).
@clawboo/ui exports a tokens constant with the dark brand hex values for any non-CSS context that needs literal colors (it predates the full theme system). For themed UI, prefer the CSS tokens; the tokens constant does not switch with the theme.

Team color collections

A team doesn’t pick individual Boo colors; it picks a collection, and each of its Boos gets a distinct color generated from that collection. The recipes live in apps/web/src/lib/teamPalettes.ts. There are eight collections, vivid-pop, dusty-pastel-pro, coastal-mist, executive-jewel, sharp-saas, soft-neutral-editorial, monochrome-accent, and classic, and the team’s choice is stored in a color_collection_id column on the teams table. generateTeamColors(collectionId, count, theme, hueRotation) is the generator. For a generative collection it divides the OKLCH hue wheel into count even steps from the recipe’s hueOffset, staggers lightness alternately (an Okabe-Ito separation so the team reads as distinct even in grayscale or under color-vision deficiency), picks the lightness band by theme, nudges any wheel-adjacent pair that collapses below a minimum perceptual distance, and gamut-maps each result to sRGB via culori’s clampChroma (reduce chroma, preserve hue and lightness, the CSS Color 4 strategy). A per-team hueRotation, derived from the team id via hueRotationFromSeed (FNV-1a → 0–359°), shifts which hues appear so two teams on the same collection at the same size don’t look identical, while leaving chroma and lightness, the collection’s “feel”, untouched. classic is the default and is legacy-faithful: it carries a fixedColors list (the original tint set minus the Boo-Zero-reserved red) and the OKLCH recipe fields are ignored. For classic, pickBooColor short-circuits to the original per-agent hash assignment (resolveBooTint(agentId)), so out of the box every Boo keeps the exact color it had before collections existed. The reserved tint list itself, TINTS in @clawboo/boo-avatar, re-exported through @clawboo/ui, pins index 0 (#ff4d4d, OpenClaw Red) to Boo Zero; every other agent hashes into TINTS[1..9]. The full deployment-matches-preview discipline (a team’s create-time preview palette equalling its deployed palette, via a client-minted team id) is covered in Teams.

Reusable primitives

The pattern primitives live in apps/web/src/features/shared/. Each one bundles the right tokens so a recurring UI pattern reads consistently and stays theme-correct, accessible, and reduced-motion-safe by construction. Two principles run through all of them. They are token-driven, every color is a CSS variable (rgb(var(--mint-rgb) / 0.2), var(--primary)), so a primitive recolors itself when the theme flips with no per-component logic. They are accessibility-first; reduced-motion fallbacks, aria-* and role attributes, and native-element semantics are baked in, so callers get correct behavior for free.

Dismissal: one stack, one owner

Overlays do not each listen for Escape. useDismissableLayer.ts keeps a single keydown and a single mousedown listener on document and hands the gesture to the topmost open layer onlyModal, Select’s popover, ConfirmDialog, the context menus and the model pickers all register there while open. Two rules make the ordering explicit rather than emergent:
  • A popover always outranks a dialog, so a dropdown opened inside a modal is dismissed on its own and the modal (and its half-filled form) survives to take the next Escape.
  • The listener bubbles, so anything nearer the event target — a React onKeyDown, CodeMirror’s keymap — runs first and can veto the stack by calling preventDefault(). That is the contract for element-scoped Escape handlers.
Before this, each overlay bound its own listener and picked a phase to win or lose the race; two listeners on document in the same phase both fired, because stopPropagation() does not suppress a same-target, same-phase sibling. An ESLint rule (no-restricted-syntax in eslint.config.mjs) now rejects new capture-phase key listeners anywhere in apps/web/src. ResizeHandle.tsx and LazyBoundary.tsx live in the same directory but are layout / structural primitives (the split-panel drag seam, and the ErrorBoundary + Suspense + retry seam every lazily-loaded surface renders through) rather than token-bundling design primitives.

Design rationale and trade-offs

One file, one flip. Putting every value in globals.css and switching themes with a single class buys two things: zero-rebuild theming (a contributor can re-theme the entire app by editing variable values) and a flash-free first paint (the inline script applies the class before React mounts). The cost is the duplicated resolver in index.html, which must be kept in lockstep with ThemeProvider. Tokens over hex. The semantic indirection (bg-surface--color-surface--surface) means component code states intent, not appearance. A new theme is a new set of variable values, not a sweep through component files. The discipline this requires; never writing a raw hex in a component, is the price. Tier classes over bespoke elevation. Bundling background + border + shadow + blur into .surface-raised-tier / -floating-tier / -overlay-tier keeps elevation consistent and theme-correct everywhere a card, popover, or modal appears, instead of each one re-deriving its own shadow stack. Generative team palettes over fixed tints. OKLCH generation with CVD-aware lightness staggering and per-team hue rotation scales to any team size and any number of teams while keeping each team distinguishable, something a fixed ten-color tint list can’t do. classic is retained as the pixel-exact default so nothing changed for existing installs.

Boundaries and non-goals

  • Not a published component library. The primitives are app-internal (apps/web/src/features/shared/), and @clawboo/ui is a private: true package; like every @clawboo/* package, it ships only inside the CLI bundle, not to npm. There is no externally consumable Clawboo UI kit.
  • Not a full shadcn surface. @clawboo/ui provides cn, a cva re-export, the tokens constant, and the BooAvatar component. shadcn/ui primitives are initialized per-app, not vendored into the package.
  • globals.css is the single source of truth. Every token value comes from globals.css; this page documents what ships.
These docs describe Clawboo v0.3.1, the current release.

See also

Last modified on August 8, 2026