RFC-0023: Design tokens v2 and surface styles
- Status: Accepted; implemented (23a–23e shipped)
- Author: Halil (Madraka), drafted with Claude · Date: 2026-10-16
- Related:
- RFC-0017 (theme settings,
set_theme_settings,x-contrast), RFC-0018 (theme kit, CSS layers, 18d section styles), RFC-0021 (DESIGN.md,render_page, the design judge), RFC-0022 (surface tools); docs/design/agent-built-sites-gap-analysis.mdG3 and G4 (step 2 of its suggested order).
- RFC-0017 (theme settings,
- References (checked 2026-10-16):
- CSS Color 4
oklch()and CSS Color 5light-dark(): https://www.w3.org/TR/css-color-5/#light-dark - W3C Design Tokens Community Group format 2025.10: https://www.designtokens.org/tr/drafts/format/
- Utopia fluid type scales: https://utopia.fyi/type/calculator/
- WCAG 2.2 contrast: 1.4.3, 1.4.6, 1.4.11.
- CSS Color 4
Summary
Sites built from the same structure look alike, because a theme exposes four colors, four font stacks and three corner styles, and its CSS hard-codes about a hundred greys. This RFC replaces hand-picked colors with tokens agents choose as enums:
- a seed color, a palette scheme, a contrast level and a light, dark or automatic mode;
- a curated font pairing and a fluid type scale;
- corner radius and density.
Code expands them into OKLCH color roles and CSS variables, checks WCAG at expansion time, and gives every site dark mode. Bands and cards gain surface styles (accent, gradient, mesh, dot and grid patterns) built from the same tokens. Agents never write a color, CSS or an image to get them.
Motivation
- Sameness. In the RFC-0021 design evals the judge’s most common note was “lacks variation in tone and rhythm”. The catalog has the structure (bands, grids, cards); the theme has too few distinct looks to choose from.
- Agents pick hex codes badly.
set_theme_settingstakes four free colors checked only for contrast. Models pick near-identical greys or loud pairs; nothing guides them towards a coherent palette. - No dark mode. The blog theme’s CSS uses fixed Tailwind greys (
zinc-*, about 100 uses), so a dark scheme is impossible without rewriting it. RFC-0018 names moving them to variables as a prerequisite. - Precedent. Tools whose AI picks presets and tokens (Framer, Webflow, Wix Harmony, Figma Sites, Relume) produce the most consistently polished sites, as the gap analysis found. Material 3’s dynamic color builds a full scheme from one seed with guaranteed contrast.
Proposal
1. Color: one seed, roles derived
New theme settings, all scalars (the RFC-0017 schema subset):
| Setting | Values | Default |
|---|---|---|
seed |
#rrggbb |
the theme’s brand color |
scheme |
tonal (seed hue, soft) · vibrant (seed hue, saturated) · neutral (near-grey with a seed tint) · mono (greys, seed only for the brand) |
tonal |
contrast |
standard (AA) · high (AAA for text) |
standard |
mode |
light · dark · auto (follows the reader) |
light |
Expansion (@cloudhal/core-ts/tokens, pure, Worker-safe):
- Tonal palettes. The seed is converted to OKLCH. The scheme fixes chroma; tonal palettes (primary, neutral, neutral-variant) are generated at fixed lightness steps.
- Roles are taken from those steps for light and for dark:
type ColorRole = | "surface" | "surface-muted" | "surface-raised" | "surface-inverse" | "text" | "text-muted" | "text-inverse" | "border" | "brand" | "on-brand" | "accent" | "on-accent" | "link" | "focus";- Contrast is guaranteed by construction. Every role pair that carries text or UI is checked (WCAG 2.2: 4.5:1 for text, 7:1 for text at
high, 3:1 for UI parts). A failing pair moves its foreground’s lightness one step at a time until it passes; the expansion never emits a failing pair. The steps taken are returned as notes for Studio and agents. - Output.
themeVariablesemits--ch-color-<role>for each role:lightanddarkemit one value per role;autoemitslight-dark(<light>, <dark>)withcolor-scheme: light darkon:root.
- Fallback. Browsers without
oklch()orlight-dark()get the light value through a preceding#rrggbbdeclaration. - Old settings still work. A theme that declares only the old settings (
brand,link,surface,text) keeps them. A site that sets them keeps its colors until it setsseed, which wins.get_theme_settingsshows both and says which applies.
2. Type: a pairing and a fluid scale
| Setting | Values | Default |
|---|---|---|
typePairing |
a curated id: editorial (serif display, sans text), modern (geometric sans), humanist, technical (sans and mono), classic (serif), system |
system |
typeScale |
compact (1.2) · balanced (1.25) · dramatic (1.333) |
balanced |
- Pairings map to font families shipped by the theme as self-hosted variable WOFF2 files (OFL-licensed: e.g. Fraunces, Source Serif 4, Inter, Space Grotesk, IBM Plex Mono), preloaded only for the families a page uses.
systemloads nothing. - The scale becomes fluid steps
--ch-step--1 … --ch-step-5withclamp()between 390 and 1440 px (Utopia’s method), so headings shrink on phones without breakpoints. - Headings get
text-wrap: balanceand body texttext-wrap: prettywhere supported.
3. Shape and density
| Setting | Values | Default |
|---|---|---|
radius |
none · small · medium · large · full |
small |
density |
compact · cozy · airy |
cozy |
They become --ch-radius-{s,m,l} and --ch-space-{xs…xl}, which the theme’s CSS uses for buttons, cards, fields, bands and gaps. The existing radius setting keeps its three values as a subset.
4. Surface styles for bands and cards
Band and Card gain a surface prop:
surface |
Built from |
|---|---|
plain · muted · raised · inverse |
the matching color role |
accent |
accent with on-accent text |
gradient |
a two-stop linear gradient between brand and accent roles |
mesh |
three layered radial-gradients from brand, accent and surface roles |
dots · grid |
an SVG pattern (a data URI the renderer writes from the border role) over surface-muted |
noise |
a fixed, tiny SVG turbulence texture over surface-muted, at low opacity |
- The renderer writes these, never the agent. Text on each surface uses the role the expansion paired with it, so contrast holds on every surface; patterned surfaces keep their pattern at no more than 8% contrast so text stays readable.
tonestays. The existingBand.tonemaps tosurface(surface→plain,muted,brand→accent,inverse).Bandalso gainsanchor(an id for#links,[a-z][a-z0-9-]{0,40}), so a menu can link to a section.- No JSON Schema change: these are catalog props (RFC-0020 props are free-form and checked against the catalog).
5. Agents, Studio and the eval
get_designreturns the token settings with their allowed values, the expanded roles (light and dark) with their contrast ratios, and the notes.set_theme_settingstakes the new settings (enums only), andrender_pagetakes an optionalscheme: light | darkto look at both.- DESIGN guidance point 1 becomes “pick a seed, a scheme and a pairing that fit the brand, and keep to them”.
- The site-design skill gains a palette and pairing recipe per kind of site (editorial, SaaS, portfolio, local business).
- Studio’s Appearance form shows the seed, the enums, a live swatch grid of the roles for light and dark, and the contrast notes. The old free color pickers stay for themes that use them.
- The design judge rubric gains
color: “Is the palette coherent, with one dominant color and accents used with purpose?”. render_pagegains acontrastcheck in dark mode, run when the site’s mode isdarkorauto.- Export.
cloudhal tokens export [--format dtcg]and aget_design_tokensMCP tool write the expanded tokens as W3C DTCG 2025.10 JSON for Figma and Style Dictionary.
Invariants
- Agents and people choose enums and one seed color. CSS, colors per role and images never come from them.
- The expansion is pure and deterministic: the same settings give the same variables on the server, in previews and in exports.
- No emitted text/background pair fails WCAG 2.2 at the chosen contrast level, in light or dark.
- A page renders without JavaScript, and without
oklch()orlight-dark()support it falls back to the light values. - Old settings keep working until a site opts into
seed.
Alternatives considered
| Option | Pros | Cons | Why not |
|---|---|---|---|
| Keep free colors, add more of them | No new model | More ways to pick badly; no dark mode; contrast checks only after the fact | Choices should be coherent by construction |
| A fixed list of named palettes | Simple, curated | Few distinct looks; brands cannot match their color | A seed plus scheme gives both brand fit and curation |
| Agents write CSS variables directly | Unlimited | Unchecked contrast, CSS injection surface, no dark pairing | Breaks “data, not code” |
| HSL instead of OKLCH | Wider old-browser support | Uneven perceived lightness; contrast drifts across hues | OKLCH keeps steps perceptually even; a #rrggbb fallback covers old browsers |
| Google Fonts links | Huge choice | Third-party requests, privacy and CSP changes | Self-hosted, curated pairings; licences checked once |
Compatibility & migration
- No core contract change.
Site.theme.configis a map checked against the theme’s schema; the new settings are theme schema keys. The theme kit (RFC-0018) documents the role variable names,--ch-color-<role>, as the convention. - The blog theme moves its fixed greys to role variables (23a). Its default settings reproduce today’s light look within a step, and screenshots before and after are compared in
render_page. - Live sites keep their colors until someone sets
seed. - Rollback: remove the new keys from the theme schema; stored values fall back to defaults (
themeVariablesalready drops values that no longer fit).
Security & policy impact
- No new scopes.
set_theme_settingsstayssite:settingswith approval for agents, as in RFC-0017. - Nothing free-form reaches CSS: colors are
#rrggbbchecked by pattern, every other value is an enum, and SVG patterns are generated by the renderer from role colors. - Fonts are static files shipped with the theme. No third-party requests and no CSP change.
Rollout
| Phase | Scope |
|---|---|
| 23a | @cloudhal/core-ts/tokens (OKLCH palettes, roles, contrast repair, notes); seed, scheme, contrast, mode in the blog theme; its greys moved to role variables; dark and auto modes; get_design and Appearance swatches; render_page --scheme dark |
| 23b | Type pairings (self-hosted variable fonts), the fluid scale, text-wrap |
| 23c | Radius and density tokens |
| 23d | Surface styles for Band and Card, Band.anchor, the tone mapping |
| 23e | DTCG export (CLI and MCP), the color rubric criterion, skill recipes, an eval and judge-check run recorded here |
Open questions
- Font payload. Ship every pairing’s files with the theme (about 1–2 MB in the deploy, only the used ones downloaded), or let a site pick one pairing at deploy time to keep the bundle small? (23b: all ship, 0.48 MB; only the used ones download.)
- Per-page mode. Should a page or a band force dark (for a landing hero) regardless of the site mode, or is
surface: inverseenough? - Accent derivation. Derive the accent as the seed’s complementary hue, a fixed rotation, or let the person pick a second seed? (23e: 60°, or 150° when 60° lands on the yellows and olives; a second seed stays possible later.)
- Gradient taste. Keep gradients and meshes off by default in the site-design guidance (easy to overuse) and allow them only for heroes and calls to action? (23d: once a page, hero or call to action.)
Status
- 23a shipped.
- Expansion.
@cloudhal/core-ts/tokens(expandColorTokens) builds 16 roles for light and dark from the seed in OKLCH. That is the §1 list plustext-subtleandborder-strong, which the blog theme’s greys needed. The light brand is the seed itself; the accent hue is the seed’s turned by 150°, as a fixed rotation (open question 3). - Contrast. Eleven pairs are checked (text 4.5:1 or 7:1 at
high, UI parts 3:1), plus text on brand and on accent. A failing foreground moves one lightness step at a time; backgrounds never move. If neither white nor near-black text reads on a fill, the fill is moved instead. Every move is returned as arepairsentry and as a note. A test runs 9 seeds × 4 schemes × 2 levels × light and dark without one failing pair. - Which colors apply.
siteColorTokensdecides for the renderer,get_designand Studio alike. The seed applies when the theme declaresseedand the site set one or chose a dark or automatic mode; otherwise the site’s old four colors apply, with the former greys (legacyColorRoles), so live sites look the same. - The blog theme. It gains
seed,scheme,contrastandmode. Its CSS reads--ch-color-*, and Tailwind’szinc-*andwhiteare remapped to the roles with@theme inline, so no class had to change for dark mode. Light renders matched the old look; dark and seeded renders passed everyrender_pagecheck (calculator, pricing, chart pages). - Agents.
get_designreturnscolors: source, settings, both role sets, every pair’s ratio and the repairs. Its text says which settings to change and to render dark when the mode is dark or auto.render_pagetakesscheme: light | dark, and DESIGN guidance points 1 and 8 say so. - Studio. The Appearance form shows
ColorRolePreviewunder the fields: a sample and swatches for light, and for dark once a seed is set. The repairs appear in the person’s language, and it works out live as the fields change. - Moved to later phases. The dark-mode
contrastcheck run automatically by the site’s mode, and the site-design skill recipes, move to 23e.
- Expansion.
- 23b shipped.
- Pairings. The blog theme gains
typePairing(defaultsystem, which keeps the olderfontsetting):editorial: Fraunces and Inter;modern: Space Grotesk and Inter;humanist: Source Sans 3;technical: JetBrains Mono headings and code, Inter text;classic: Source Serif 4.
- Font files. They are self-hosted in the theme’s
public/fonts: Fontsource 5.3 variable files, upright, weight axis only, latin and latin-ext (Turkish), with their OFL-1.1 licences. That is 12 files, 0.48 MB in the deploy.fonts.cssdeclares them with unicode ranges, so a page downloads only its pairing’s families and the subsets its text needs.Base.astropreloads the pairing’s latin files (src/fonts.ts).- This answers open question 1: every pairing ships with the theme, and readers download only what is used.
- Scale.
typeScale(compact,balanced,dramatic; no default) writes fluid steps--ch-step--1 … --ch-step-6with Utopia’s method.- Body text runs from 16 px at 390 px wide to 17 px at 1440 px. The ratio is gentler on phones (1.125, 1.15 or 1.2) than on desktops (1.2, 1.25 or 1.333).
- A step 6 was added for the theme’s largest hero size.
- Tailwind’s
text-basetotext-5xlread the steps, with their fixed sizes as fallbacks. Until a site chooses a scale nothing is written, so live sites keep their sizes.
- Wrapping. Headings get
text-wrap: balance; paragraphs, list items and quotespretty. - Agents.
get_designreturnstype(pairing, scale) and says how to change them. DESIGN guidance point 1 and the theme’s DESIGN.md say which pairing fits which kind of site. - Checked. Every pairing rendered with
render_pageat 1440 and 390 px passes every check, including dramatic headings on phones.
- Pairings. The blog theme gains
- 23c shipped.
- Settings. The blog theme’s
radiusgrows tonone,small,medium,largeandfull(its three old values keep their names), anddensity(compact,cozy,airy) is new. - Variables.
shapeCss(@cloudhal/core-ts/tokens) writes:--ch-radius-control, for buttons and single-line fields; it is a pill atfull, while cards and bands keep the smaller radii;--ch-radius-s,-mand-l;--ch-space-unit, which is 0.875×, 1× or 1.2× Tailwind’s 0.25rem;--ch-space-xsto-xl, for themes and custom CSS.
- The theme. It maps Tailwind’s
rounded*sizes and its spacing unit to these variables, so every padding, gap and band follows the density without class changes. Controls take the control radius; this replaces the old corner rules for buttons, CTAs and callouts. - Defaults.
smallandcozyare Tailwind’s own values, so existing sites keep their look. One exception: the header search field takes the control radius, 4 px instead of 6 px. - Agents.
get_designreturnsshape, and the theme’s DESIGN.md says which corners and spacing fit which site. - Checked. Pill and airy, and square and compact, both rendered with
render_pageat 1440 and 390 px and passed every check, the tap targets on compact included.
- Settings. The blog theme’s
- 23d shipped.
- Surfaces.
BandandCardtakesurface(SURFACE_STYLES):plain,muted,raised,inverse,brand,accent,gradient,mesh,dots,gridandnoise.brandwas added to the §4 list so that every old tone has a surface with the same look.tonemaps throughTONE_SURFACE. Itsbrandmaps tobrand, notaccentas §4 proposed, because pages with a brand band would otherwise change color.
- Rendering. The renderer writes
data-surface; an oldtonealso stays asdata-tonefor themes that style it. The theme draws each surface from the color roles only:gradientruns from the brand to a newgradientrole: the brand turned 40° towards the accent, then moved until text on brand still reads on it. The expansion checks that pair, so text on a gradient keeps its contrast; a test runs every seed, scheme and level.meshis three soft radial lights from the brand and accent over the muted surface.dotsandgridare CSS gradients from the border roles, not SVG data URIs, so they follow light and dark throughlight-dark().noiseis a fixed grey SVG grain at 18% opacity.- Without
color-mix()or gradient interpolation, browsers fall back to the plain fill.
- Anchors.
Band.anchor(^[a-z][a-z0-9-]{0,40}$) becomes the band’sid, so links can jump to it. Anchors starting withch-are dropped so they cannot clash with the renderer’s own ids. A band with an anchor keeps a small scroll margin. - Text on loud surfaces. Buttons, links and secondary text on loud surfaces (inverse, brand, accent, gradient) follow the surface’s own text color, as the loud tones did.
- Guidance. DESIGN guidance point 2 and the theme’s DESIGN.md say when to use which surface: gradient and mesh at most once a page, for the hero or the call to action (open question 4).
- Checked. A page with every surface passed the contrast check in light, dark and auto, at 1440 and 390 px.
- Follow-up. The accent derived from a violet seed (hue + 150°) comes out a dark mustard once repaired for white text. Open question 3 (how to derive the accent) is worth revisiting in 23e.
- Surfaces.
- 23e shipped.
-
Export.
designTokensDtcg(@cloudhal/core-ts/tokens) writes a W3C DTCG 2025.10 file:color.lightandcolor.dark, every role as{colorSpace: "srgb", components, hex};radiusandspaceas dimensions;- once a scale is chosen,
type-step: the desktop size as the value, with the minimum and theclamp()in$extensions["dev.cloudhal"]; - the settings, mode, source and repairs in the root
$extensions.
The spec text read when this was written leaves modes out, so light and dark are sibling groups. It is available as
cloudhal tokens export [--format dtcg] [--out <file>]and as the read-only MCP toolget_design_tokens(site:read). -
Dark contrast in
render_page. A site whose mode isdarkrenders dark by default. When the mode isdarkorauto, text contrast is also checked in the other scheme, at the first width, and those examples are folded into thecontrastcheck, markeddark @1440:. A page can no longer pass in light and fail in dark. -
The judge. The rubric gains
color: “Is the palette coherent, with one dominant color and accents used with purpose?”, with anchors. -
The site-design skill.
references/tokens.mdhas a look recipe for each kind of site (editorial, SaaS, developer tool, portfolio, local business, institution) and the surface rules.- A new step 2 proposes a look and sets it with
set_theme_settings, so the skill now declaressite:settingsand the approvals forset_designandset_theme_settings. references/guidance.mdnow matchesDESIGN_GUIDANCEagain, and the page recipes use surfaces.
-
Accent (open question 3). The accent sits 60° on from the seed, or 150° on when 60° lands among the yellows and olives (hue 50–115), which turn to mustard once darkened for white text. The gradient end avoids the same band by turning the other way. Checked across violet, blue, teal, red, orange, green, pink and yellow seeds.
-
Theme fixes the eval found.
- A band’s direct children now have vertical room between them (scaled by density). It was missing before 23, so headings touched their text and cards.
- Large lead text keeps a 48ch measure.
-
Eval (2026-10-16). Following the project’s eval practice, this was run through the tools by the agent itself, not by a Workers AI judge.
-
Method. Three briefs were built twice on a local site with identical content and the same theme CSS:
- before: the old free settings and
tone; - after: the recipe from
references/tokens.md, set throughget_theme_settings→set_theme_settings→get_design→get_design_tokens, andsurface.
Each was rendered with
render_page’s browser and checks at 1440 and 390 px, light and (for dark or auto) dark, then scored on the seven rubric criteria. - before: the old free settings and
-
Checks. Every render passed every check. The token expansion reported 0 failing pairs and 0 repairs for all three recipes.
Brief Before After Change SaaS landing (Ledgerly: violet, vibrant, modern, dramatic, pill buttons, airy, auto) 3.29 4.00 color 2→4, rhythm, hierarchy and spacing 3→4 City magazine (Harbour Review: deep blue, neutral, editorial, balanced, square, cozy, auto) 3.29 4.00 color 2→4, hierarchy (serif display), rhythm and spacing 3→4 Bakery (Mavi Fırın: terracotta, tonal, humanist, balanced, rounded, cozy, light) 3.29 3.86 color 2→3 (gradient hero and teal accent compete), rhythm, hierarchy and spacing 3→4 Mean 3.29 3.95 +0.66; color +1.67 - What it does not show. Fidelity, readability and mobile did not change: the content and layout were the same. The gain is the look the tokens and surfaces add, which is what the judge’s most common note (“lacks variation in tone and rhythm”) asked for.
- Limits. There was one judge, the agent itself, so the numbers are a direction, not a benchmark. The vision-model judge (
cloudhal ai judge-check) was not run for this phase.
-
-