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.
| Token | Hex | Role |
|---|---|---|
--color-paper | #f5f1e6 | Canvas / cream |
--color-paper-2 | #ece6d4 | Alt surface |
--color-ink | #0e1d15 | Body & type; also the dark panel surface |
--color-ink-2 | #2a3a31 | — |
--color-ink-3 | #5d6a62 | Muted ink |
--color-forest | #1f5d3a | Primary |
--color-forest-deep | #143f2c | — |
--color-gold | #e8c46a | Accent |
--color-gold-deep | #c79f3f | — |
--color-rule | rgb(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:
| Constant | Value | Source |
|---|---|---|
| Content max width | 1240px (--container-content) | @theme in global.css |
| Section vertical padding | 112px (96px below lg) | per-component py-28 / lg:py-28 |
| Card padding | 24–32px | per-component |
| Button padding | 14×22, weight 700 | Button.astro (px-5.5 py-3.5) |
| Grid cell | 96px (--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
| Concern | File |
|---|---|
@theme tokens, grid backdrop, cursor-glow layers, .section-panel, light: variant, --prose-* vars | website/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 memo | website/src/sanity/queries/globalStyles.ts |
Fetch + :root override injection | website/src/layouts/Layout.astro |
| Color-default bootstrap script | studio/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.