Design System

Brand tokens & theme

White Tree Digital is dark by default: the page sits on a near-black forest-green ground, most sections float as rounded cards over a faint fixed grid, and a small palette of editable brand colors drives everything through Tailwind v4 @theme tokens that double as runtime CSS variables.

The whole visual system rests on a single file, website/src/styles/global.css, which declares the design tokens in an @theme block, defines the dark grid backdrop, and registers the light: surface variant that components use to recolor themselves on cream islands. The brand colors in that block are fallbacks — the live values come from a Sanity singleton (globalStyles) that the layout fetches on every request and injects as a :root override. This page covers the palette, the approved pairings, the dark grid theme, the surface system, the radius/layout constants, and the Sanity override flow. Fonts live on Typography; the Tailwind v4 conventions live on Tailwind v4.

The brand palette

Ten editable brand tokens are declared in the @theme block of global.css. These are the colors an editor can change in Studio; everything visual references them.

TokenHexRole
--color-paper#f5f1e6Canvas / cream
--color-paper-2#ece6d4Alt surface
--color-ink#0e1d15Body & type; also the dark panel surface
--color-ink-2#2a3a31
--color-ink-3#5d6a62Muted ink
--color-forest#1f5d3aPrimary
--color-forest-deep#143f2c
--color-gold#e8c46aAccent
--color-gold-deep#c79f3f
--color-rulergb(14 29 21 / 0.14)Borders & dividers (ink at 14% alpha)

Tailwind v4 turns each --color-* into a utility automatically, so bg-forest, text-gold, border-rule, and opacity modifiers like text-paper/70 all work without any config. Translucency in the dark theme is done with opacity modifiers on these existing tokens (text-paper/70, border-paper/15) rather than new colors.

Gold is precious — use it where you want eyes to land, not as a general surface.

Code-fixed structural surfaces

Two more color tokens live in the same @theme block but are not editable brand tokens — they were added for the dark redesign and are fixed in code:

--color-ground: #0a140e;     /* body + grid backdrop + footer fill */
--color-card: #16271c;       /* nested cards inside panels */

The surface ramp is ground (#0a140e) < panel (--color-ink #0e1d15) < card (#16271c). The floating panel surface reuses --color-ink exactly; only ground and card are net-new. Because they are structural (not brand), the Sanity globalStyles singleton does not expose them — there are 10 editable colors, not 12.

Approved pairings (WCAG AA)

Not every token combination is legible. The approved foreground/background pairs are:

  • Ink on Paper / Paper on Ink
  • Paper on Forest / Forest on Paper
  • Gold on Forest
  • Ink on Gold

Two hard rules:

Gold text never on paper

Gold text must never sit on a paper/cream surface — use forest there instead. (Gold fills, like a flag badge, are fine; the ban is on gold text.) On the dark theme this is enforced automatically by the surface system below: a text-gold light:text-forest utility resolves to forest on any light island, so you rarely have to think about it. Do not use Forest-on-Ink for body copy either.

The dark grid theme

The site is dark by default. <body> is painted --color-ground with --color-paper text, and rhythm comes from dark panels floating on a faint fixed grid rather than an alternating band sequence. (The old Paper/Ink/Forest band rhythm is gone — see Components and the page-builder docs for how sections compose now.)

The grid backdrop

A fixed, faint grid is painted on body::before — two linear-gradient line patterns at --grid-size (96px cells), masked by a radial vignette so it fades out toward the bottom and edges. It is the ground: positioned fixed at z-index: -1 so it sits above the ground color but behind all content, meaning no content wrapper needs its own stacking context. Opaque panels and the footer cover it; it shows through the gutters between panels and through the translucent header.

The grid line color is derived from --color-paper, not a new token:

--grid-line: rgb(from var(--color-paper) r g b / var(--grid-opacity));

The cursor glow

A second layer, body::after, is the interactive cursor glow: the same line pattern in a brighter color (--grid-glow-line, paper at 40% alpha), revealed only inside a pointer-following radial spotlight intersected with the grid vignette. Only the lines brighten; they fade back to the normal line color at the spotlight's radius edge. It's driven by GridGlow.astro (GSAP quickTo), and is hidden by default (--grid-glow-strength: 0) — no-JS, reduced-motion, and touch never show it. The whole feature is deletable: remove the body::after block, the component, and its <GridGlow /> line in the layout.

Floating section panels

Sections that float as cards use the .section-panel class, which carries only the tunable radius and drop shadow — the background is a Tailwind utility on the element itself (bg-ink / bg-paper):

.section-panel {
  position: relative;
  border-radius: var(--card-radius);
  box-shadow: var(--card-shadow);
}

The grid size, vignette spread/blur, glow radius, and panel shadow/radius are all code knobs in :root (no Studio UI) — documented in website/tunables.md. The panel-vs-full-bleed decision per section is a Studio-controlled lever, not a code knob; see Section types and the page-builder docs.

The light: surface variant

Surface tone is a custom Tailwind variant registered in global.css:

@custom-variant light (&:where([data-surface='light'], [data-surface='light'] *));

It is Tailwind's dark: inverted. The site is dark, so components write the dark value plus a light: override on any tone-sensitive utility, e.g. text-gold light:text-forest, border-paper/15 light:border-rule, bg-card light:bg-paper-2. Primary text inherits from the shell (text-paper light:text-ink). The :where() wrapper keeps the variant at specificity 0, so the override wins purely by source order.

This is the mechanism that makes "gold never on paper" automatic: any element inside a [data-surface='light'] ancestor resolves its gold accent to forest. Light islands today are the cream About panel, paper full-bleed sections, and the blog/portfolio reading surface — each stamps data-surface="light" on its shell.

UI primitives use the same pattern. For example, Button.astro's neutral variants invert by surface while solid brand buttons stay constant:

const variants = {
  primary:
    'bg-paper text-ink hover:bg-paper-2 light:bg-ink light:text-paper light:hover:bg-ink-2 px-5.5 py-3.5',
  gold: 'bg-gold text-ink hover:bg-gold-deep px-5.5 py-3.5',
  forest: 'bg-forest text-paper hover:bg-forest-deep px-5.5 py-3.5',
};

Rich-text prose flips through a parallel set of --prose-* variables (--prose-heading, --prose-quote-bar, --prose-hair, --prose-muted) that are redefined under [data-surface='light']. The quote bar is gold on dark but forest on light — the same no-gold-on-paper rule, applied to long-form prose. See Typography for the prose details.

Radius scale & layout constants

The radius scale is deliberately sparse, declared in @theme:

--radius-xs: 4px;   /* rounded-sm  — bars */
--radius-sm: 8px;   /* rounded-lg  — buttons */
--radius-md: 14px;
--radius-lg: 20px;  /* cards */

The full scale is 4 / 8 / 14 / 20 / 999. Do not use 12, 16, or 24 — with two exceptions: the round explorer/FAQ chips (999) and the floating section panel, which uses the tunable --card-radius (default 24px) as its own value.

Layout constants:

ConstantValueSource
Content max width1240px (--container-content)@theme in global.css
Section vertical padding112px (96px below lg)per-component py-28 / lg:py-28
Card padding24–32pxper-component
Button padding14×22, weight 700Button.astro (px-5.5 py-3.5)
Grid cell96px (--grid-size):root in global.css

--container-content becomes the max-w-content utility for centering page content.

Sanity-driven global styles

The @theme color values are fallbacks, not the source of truth. The 10 brand colors are editable in Sanity through the globalStyles singleton (Studio title: "Global Styles"), and editor changes take effect on the next request with no rebuild.

Schema

The singleton, studio/schemaTypes/globalStyles.ts, is a one-group document with 10 type: 'color' fields (from @sanity/color-input), each titled to match its CSS variable (color-paper, color-ink, color-forest, …):

defineField({name: 'colorRule', title: 'color-rule', description: 'Borders & dividers. Ink at 14% alpha.', type: 'color', group: 'colors'}),

The query, website/src/sanity/queries/globalStyles.ts, projects each field down to {hex, alpha} and is fetched on every SSR request, so it's wrapped in a 60-second per-isolate memo (TTL_MS):

coalesce(*[_type == "globalStyles" && _id == "globalStyles"][0]{ ... }, {})

Override injection

Layout.astro fetches the doc, maps each color field to its CSS variable via a TOKEN_MAP, and emits a single :root override <style> at the end of <head>:

{themeOverrides && <style is:global set:html={`:root { ${themeOverrides} }`} />}

Tailwind v4 utilities reference var(--color-*) at runtime, so the injected :root block simply shadows the @theme fallbacks. Translucent tokens round-trip correctly: toCssColor emits an 8-digit hex when alpha < 1 (this is how --color-rule keeps its 14% alpha).

Theme always reads the published client — never preview

Layout.astro fetches globalStyles with the published sanity client, never the stega-enabled preview client. In draft mode the preview client injects invisible zero-width characters into every string field; those characters would corrupt a CSS color value and break the :root override. The fetch is wrapped in a try/catch that falls back to the @theme defaults if Sanity is unreachable. See Visual editing for the broader stega story.

This is the only route to global theming — Layout.astro fetches globalStyles directly rather than threading it through per-route props, because the data is genuinely site-wide.

Bootstrapping the singleton

The color defaults are not set via initialValue. Two reasons: @sanity/color-input needs the full {hex, hsl, hsv, rgb} value shape to render a populated swatch (bare hex shows the empty "Create color" state), and initialValue doesn't fire reliably for plugin field types in Sanity v5. Instead, studio/scripts/init-site-styles.ts createOrReplaces the doc with the full color shape, computed from hex via studio/lib/color.ts:

colorGold: color('#e8c46a'),
colorRule: color('#0e1d15', 0.14),

Run it once on a new dataset, and re-run any time to reset colors to brand defaults:

npx sanity exec scripts/init-site-styles.ts --with-user-token

To add a new color token, add it to both the schema's fields[] and the script's doc, then re-run.

Never delete the globalStyles singleton

Do not run npx sanity documents delete globalStyles to "reset" the doc. Sanity permanently tombstones the ID, and initialValue can't recover it — createOrReplace (what the init script does) becomes the only recovery path. The script also clears drafts.globalStyles after writing so Studio shows the published values with full swatches.


Where this lives

ConcernFile
@theme tokens, grid backdrop, cursor-glow layers, .section-panel, light: variant, --prose-* varswebsite/src/styles/global.css
Cursor glow driver (GSAP)website/src/components/GridGlow.astro
Grid/shadow/glow code knobs (no Studio UI)website/tunables.md
globalStyles Sanity schema (10 color fields)studio/schemaTypes/globalStyles.ts
GROQ projection + 60s SSR memowebsite/src/sanity/queries/globalStyles.ts
Fetch + :root override injectionwebsite/src/layouts/Layout.astro
Color-default bootstrap scriptstudio/scripts/init-site-styles.ts (uses studio/lib/color.ts)
Brand reference (palette, pairings, radius, layout)website/Brand Guidelines.html, website/CLAUDE.md

Related pages: Tailwind v4 · Typography · Components · Studio & singletons · Visual editing.

Previous
Presentation Tool & draft mode