Skip to content

Themes

Every prototype has its own look: a theme, a color mode and a motion preference, resolved per persona and applied before first paint. A theme can come from six hand-tuned presets, a library of product-category themes, a palette generated from one brand color, the DevTools Theme Studio, the protoyard theme CLI, or a blueprint brand block. API reference: packages/themes/README.md.

Identity vs mode

Two separate choices:

Theme (identity)Mode
What it isA named Theme: colors for both modes, typography, shape, density, style profileWhich half of the palette is used
ValuesA built-in preset (violet, ocean, emerald, rose, amber, monochrome), a prototype theme or a saved custom themelight, dark, system
Defined in@protoyard/themes, definePrototype({ themes }), the Theme Studioprotoyard.json, a scenario, DevTools, ?mode=

Every theme defines a full palette for both modes, so any theme works in light and dark. system follows prefers-color-scheme live.

The style model

A theme is a Theme (packages/core/src/theme.ts, validated by ThemeSchema). This is everything a theme can set:

FieldType and rangeDefault
idlowercase kebab-case (^[a-z][a-z0-9-]*$)required
name1–40 charactersrequired
descriptionstring''
light, darka ThemePalette each: 33 color tokens (table below)required
fontFamilyCSS font stack'Inter Variable', Inter, ui-sans-serif, system-ui, sans-serif
fontFamilyHeadingCSS font stackfalls back to fontFamily
fontFamilyMonoCSS font stack'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace
radiusCSS length, the base corner radius0.625rem
densitynumber 0.75–1.25, multiplies all spacing1
styleoptional style profile (below)DEFAULT_THEME_STYLE

defineTheme(input) parses with zod and fills these defaults.

Color tokens → CSS variables → Tailwind utilities

applyAppearance() writes each palette token of the resolved mode as a --py-* custom property (tokenToCssVar('primaryForeground') → --py-primary-foreground). @protoyard/themes/base.css maps them into the Tailwind 4 theme, so you style with ordinary utilities:

Palette tokenCSS variableTailwind utilities
background / foreground--py-background / --py-foregroundbg-background, text-foreground
surface / surfaceForeground--py-surface / --py-surface-foregroundbg-surface, text-surface-foreground
elevated--py-elevatedbg-elevated
muted / mutedForeground--py-muted / --py-muted-foregroundbg-muted, text-muted-foreground
border, input, ring--py-border, --py-input, --py-ringborder-border, border-input, ring-ring
primary / primaryForeground--py-primary / --py-primary-foregroundbg-primary, text-primary-foreground
secondary / secondaryForeground--py-secondary / …bg-secondary, text-secondary-foreground
accent / accentForeground--py-accent / …bg-accent, text-accent-foreground
destructive, success, warning, info (+ …Foreground)--py-destructive …bg-destructive, text-success, bg-warning text-warning-foreground…
sidebar, sidebarForeground, sidebarBorder--py-sidebar …bg-sidebar, text-sidebar-foreground, border-sidebar-border
chart1 … chart5--py-chart1 … --py-chart5bg-chart-1 … bg-chart-5, text-chart-1…
Chart palette (optional theme.charts)--py-chart6 … --py-chart8, --py-seq-1 … --py-seq-9, --py-div-1 … --py-div-9, --py-chart-grid, --py-chart-axisbg-chart-6 … bg-chart-8 (fall back to series 1–3), stroke-chart-grid, text-chart-axis
shadow--py-shadowused inside shadow-soft, shadow-float
(computed)--py-primary-hover, --py-destructive-hoverbg-primary-hover, bg-destructive-hover

The two hover fills are computed per theme and mode by hoverFill(): a visible step darker or lighter that keeps the button text at 4.5:1 or more.

Typography, shape, density and motion variables

VariableFromUsed for
--py-font-sans, --py-font-heading, --py-font-monofontFamily, fontFamilyHeading, fontFamilyMonofont-sans, font-heading, font-mono; h1–h4 use the heading font
--py-font-size-xs … --py-font-size-9xlstyle.typography.scaleRatiotext-xs … text-9xl. null keeps Tailwind's sizes; a ratio gives ratio^n rem with base = 1rem
--py-font-weight-body, -medium, -heading, -boldstyle.typography.weightsfont-normal, font-medium, font-semibold, font-bold; body and heading weights
--py-letter-spacing-body, -headingstyle.typography.trackingbody and h1–h4 letter spacing; tracking-body, tracking-heading
--py-radiusradiusrounded-lg; rounded-xs … rounded-2xl are derived from it
--py-radius-step-6, -4, -2style.shape.radiusScaleThe offsets between radius steps (6px, 4px, 2px × scale)
--py-border-widthstyle.shape.borderWidthTailwind's default border width
--py-shadow-soft, --py-shadow-floatstyle.elevationshadow-soft, shadow-float
--py-densitydensity (or a density override)Multiplies Tailwind's --spacing, so every p-4, gap-2 scales
--py-motion-fast, -base, -slow, --py-motion-easingstyle.motionduration-fast, duration-base, duration-slow, default transition duration and easing; 0ms / linear under reduced motion
--py-focus-ring-width, --py-focus-ring-offsetstyle.focusRing@protoyard/ui focus-ring profiles
--py-motionreduced motion0 when reduced, 1 otherwise

applyAppearance() also sets data-py-theme, data-py-mode, data-py-motion (reduce or full), data-py-surface (solid or glass), the dark or light class and color-scheme on the element. The dark: variant is class-based.

Style profile

theme.style holds the dimensions beyond color. Every field is optional and defaults to Protoyard's standard look, so a theme without style (every built-in preset) parses to DEFAULT_THEME_STYLE. Values are validated strictly (lengths in px/rem/em, easing keywords or cubic-bezier(…)) because they become CSS. resolveThemeStyle(partial) fills a partial profile with the defaults.

FieldValuesDefault
typography.scaleRatio1–1.75, or nullnull (Tailwind's built-in steps)
typography.weightsbody, medium, heading, bold: 100–900400, 500, 600, 700
typography.trackingbody, heading: 0, normal or an em value0, 0
shape.radiusScale0–4, multiplies the steps around radius1
shape.borderWidtha length1px
elevationflat (no shadow), soft, crisp, glow (primary-tinted), layered (stacked contact, key and ambient shadows)soft
surfacesolid, glasssolid
motion.durationsfast, base, slow: 0–2000 ms150, 200, 300
motion.easingCSS keyword or cubic-bezier(a, b, c, d)cubic-bezier(0.4, 0, 0.2, 1)
focusRingstyle: ring, outline, glow, inset; width, offset: lengthsring, 2px, 2px

The profile is applied, not just stored. Besides the variables above, themeComponentProfile(theme) sets @protoyard/ui component attributes, but only where the theme departs from the default look:

Theme settingAttribute on the root
a non-default focusRingdata-py-focus-ring-style
surface: 'glass'data-py-card-style="glass"
elevation: 'flat' (and not glass)data-py-card-style="bordered"
radius of 0data-py-button-style="square"
radius of 999px or moredata-py-button-style="pill"

glass is progressive enhancement: bg-surface and bg-elevated become translucent with a backdrop blur only in browsers that support backdrop-filter; others keep solid fills.

Style presets

packages/themes/src/style-presets.ts names the common choices so the Theme Studio, DevTools, the CLI and blueprints share one vocabulary. Each preset is plain data (id, label, description and its values):

DimensionExportPresets
DensityDENSITY_PRESETScompact 0.875, default 1, comfortable 1.125, spacious 1.25
Corner radiusRADIUS_PRESETSsquare (0, radius scale 0, so every step is square), sharp (0.25rem, 0.5), default (0.625rem, 1), round (1rem, 1.25), pill (9999px, 1)
ElevationELEVATION_PRESETSflat, soft, crisp, glow, layered (one per ELEVATION_STYLES value)
Font pairingFONT_PRESETSThe FONT_CATALOG ids
MotionMOTION_PRESETSnone (0 ms, linear), subtle (100/150/200 ms, ease-out), standard (150/200/300 ms, the default curve), expressive (200/300/500 ms, slight overshoot)

stylePresetInput(selection) turns a selection such as { radius: 'round', motion: 'subtle' } into theme fields (radius, density, the font stacks) plus a ThemeStyleInput. applyStylePresets(theme, selection) applies it on top of a theme, keeps every style field the selection does not touch, and validates the result with ThemeSchema. An unknown id throws a TypeError that lists the known ids. The default presets (default, default, soft, standard) reproduce DEFAULT_THEME_STYLE exactly.

Live in DevTools. DevTools → Look has a control per dimension: density presets plus a fine slider, corner radius, elevation and motion as segmented choices, and the font pairing as a list. Each choice is a temporary override (theme.setOverride({ radius: 'round' }) and so on) applied on top of the active theme, with a source badge. Theme clears that one override, and Clear appearance overrides clears them all; it is enabled as soon as any style override is set.

import { applyStylePresets, violetTheme } from '@protoyard/themes';

const roomyViolet = applyStylePresets(violetTheme, {
  density: 'comfortable',
  radius: 'round',
  elevation: 'layered',
  motion: 'subtle',
});

Live style overrides

The radius, elevation, font and motion presets also work as live overrides, like density: a reviewer can try a prototype "more rounded", "flatter" or "editorial" without creating a theme.

await runtime.theme.setOverride({ radius: 'round', elevation: 'flat', font: 'editorial' });
runtime.theme.appearance().radius; // { value: 'round', source: 'override' }
await runtime.theme.clearOverride(); // back to the scenario, then the theme
  • Ids only. OverridesSchema and a scenario's appearance accept the

preset ids (RADIUS_PRESET_IDS, ELEVATION_STYLES, FONT_PAIRING_IDS, MOTION_PRESET_IDS in @protoyard/core), never free CSS. Anything else is rejected.

  • Resolution. Each dimension resolves override › scenario appearance ›

the effective theme's own value, which the runtime reports as { value: null, source: 'default' }.

  • Application. bindAppearance (@protoyard/renderer-svelte) merges

them on top of the theme (the same result as applyStylePresets) before applyAppearance, so the page updates live and component profiles follow (pill gives pill buttons, flat bordered cards). The preset data loads as a small lazy chunk the first time a preset is active; until it arrives the page keeps what it shows. The density override and reduced motion still win. The no-flash cache replays the merged --py-* variables, so a reload paints the overridden look first.

  • Storage. Overrides live in the prototype's control DB only, never the

data DB (ADR-005), and theme.clearOverride() or DevTools' "Clear overrides" removes them.

  • Links. ?radius=round&elevation=flat&font=editorial&motion=none

applies the same presets as clearable overrides (source override, persisted like a DevTools choice). Values outside the preset ids are ignored. DevTools' share link (reproducibleLink) carries any active style override together with the theme, mode, shell and layout overrides, so a reviewer can send the exact look.

Fonts

FONT_CATALOG (packages/themes/src/fonts.ts) has eight system-first pairings: system, humanist, geometric, grotesk, editorial (serif headings), rounded, condensed and mono. Each stack is var(--py-font-stack-<id>-…, <local fallback>), so choosing one never downloads a font. @protoyard/themes/fonts.css is optional and only declares those --py-font-stack-* variables so a prototype can override them. loadVariableFont({ family, src }) loads a self-hosted, same-origin .woff2 face only when called; remote URLs and other formats are refused.

Wiring it into a prototype

A prototype's src/app.css gets all of this with one import (templates/blank/src/app.css):

@import "@protoyard/renderer-svelte/app.css";

/* Let Tailwind see this prototype's own markup. */
@source "./";

That file imports Tailwind, @protoyard/themes/base.css, @protoyard/ui/styles.css and @protoyard/devtools/styles.css in the right order.

How Tailwind finds package classes. Tailwind resolves an @source path relative to the stylesheet that declares it. So each kit's stylesheet (@protoyard/ui/styles.css, @protoyard/devtools/styles.css, @protoyard/charts-ui/styles.css, @protoyard/ai-ui/styles.css, …) registers its own folder with @source "./". A stylesheet that needs another package's classes imports that package's stylesheet by name (@import "@protoyard/charts-ui/styles.css";), never with a path into its folder. The same CSS then works from this repository, from packed packages and from a consumer's node_modules. packages/cli/tests/package-boundaries.test.ts rejects any cross-package @source or @import path in packages/*/src.

Use tokens, not raw colors. bg-primary text-primary-foreground follows every theme and both modes; bg-violet-600 does not.

The palette engine

packages/themes/src/palette.ts turns one brand color into a complete, accessible light and dark theme. It is pure and deterministic: the same input always gives the same hex values.

FunctionWhat it does
generatePaletteScale(seed)An 11-step scale (PALETTE_STEPS: 50, 100, 200 … 900, 950) from a #rgb or #rrggbb seed. Keeps the seed's OKLCH hue, caps chroma at 0.24, and places each step at a fixed OKLCH lightness (0.98 for 50 down to 0.12 for 950). Throws a TypeError for anything that is not hex.
createThemeFromSeed({ id, name, seed, secondary?, neutral?, style? })A full Theme validated by defineTheme. The optional secondary seed drives the secondary fill and chart2; without it the seed is reused. neutral picks a neutral family; without it the original neutrals are used. style is passed through. The description becomes Generated from <seed>…. An unknown family throws a TypeError.

OKLCH and gamut mapping

The engine works in OKLCH (packages/themes/src/oklch.ts), a perceptual color space where equal lightness steps look equal across hues. That is what lets one lightness table produce consistent scales for any seed. rgbToOklch converts sRGB to OKLCH; oklchToHex converts back and, when a color falls outside the sRGB gamut, keeps lightness and hue and reduces chroma by binary search until it fits. Every output is a plain #rrggbb.

How contrast is guaranteed

Neutrals (background, foreground, surface, muted, borders, sidebar) are fixed values per mode, or come from a neutral family. The colored tokens are searched, not guessed:

TokenHow it is chosen
primary, ringThe seed's hue at chroma 0.2, stepping OKLCH lightness from 0.52 down (light) or 0.48 up (dark) until it reaches 4.5:1 against background, surface and muted (light) or elevated (dark). Falls back to black or white at the extreme.
destructive, success, warning, infoSame search at fixed hues (25, 145, 90, 245).
secondaryA light (or dark) tint of the secondary hue, searched until the foreground reaches 4.5:1.
accentStep 100 (light) or 900 (dark) of the seed scale.
secondary, accent repairIf either still fails 4.5:1 with its foreground, it is replaced with a neutral.
chart1 … chart5Step 600 of the seed and secondary scales plus three fixed hues, then repaired by the chart palette rules.

Neutral families

Neutrals carry most of a theme's mood, so createThemeFromSeed can derive them from a family instead of the fixed values (NEUTRAL_FAMILIES, type NeutralFamily):

FamilyOKLCH hue / chromaFeel
zinc286 / 0.006Nearly pure gray
slate257 / 0.02Blue-gray
stone56 / 0.008Faintly warm gray
gray265 / 0.012Cool gray
warm70 / 0.016Sand and paper
cool225 / 0.016Ice and steel
tintedthe seed's hue / 0.016A whisper of the brand color

Each family uses one fixed OKLCH lightness per token and mode (light background 0.985, surface 1, muted 0.965; dark background 0.145, surface 0.21, elevated and muted 0.27). Dark borders and inputs stay translucent white, so they follow whichever neutral is underneath. The text tokens (foreground, mutedForeground, sidebarForeground) start from a table lightness and are searched toward black or white until they reach 4.5:1 on every background they sit on. When a secondary or accent fill misses 4.5:1 with its text, the repair falls back to the family's own neutral fill.

Omitting neutral keeps the original neutrals bit-for-bit. A library definition can set neutral too (LibraryThemeDefinition.neutral); the 64-theme library uses families across its product-category and curated collections.

import { createThemeFromSeed } from '@protoyard/themes';

export const paperTheme = createThemeFromSeed({
  id: 'paper-ledger',
  name: 'Paper Ledger',
  seed: '#0f766e',
  neutral: 'warm',
});

The result is then held to the same WCAG pairs as the presets (see Contrast checks). packages/themes/tests/palette.test.ts checks every text and UI pair in both modes for 500 deterministic seeds, with the original neutrals and with every neutral family.

import { createThemeFromSeed } from '@protoyard/themes';

export const harborTheme = createThemeFromSeed({
  id: 'harbor',
  name: 'Harbor',
  seed: '#0f766e',
  secondary: '#1d4ed8',
  style: { elevation: 'crisp', shape: { radiusScale: 0.8 } },
});

Chart palettes

Chart colors belong to the theme. packages/themes/src/charts.ts derives, per mode, the colors a chart needs from chart1 … chart5 and checks them with the same rules as the charts kit (validateChartPalette in packages/charts/src/palette):

RuleMinimum
Every series color and both ramp ends against surface and background (WCAG 1.4.11)3 : 1
Any two series colors, OKLab distance0.08
Any two series colors under simulated protanopia and deuteranopia (Machado 2009)0.04
Any two series colors under simulated tritanopia (stricter than the charts kit)0.04
Sequential ramp; each half of the diverging ramplightness changes monotonically toward the data end

A color that fails moves in lightness only (the hue stays recognizable); the three derived series colors may also shift hue a little. deriveChartTokens(palette, mode) returns the block below, and validateChartTokens(tokens, palette, mode) lists any rule a block breaks.

FieldContentCSS variables
categorical8 series colors; the first five are chart1 … chart5--py-chart1 … --py-chart8
sequential9 steps, low → high values--py-seq-1 … --py-seq-9
diverging9 steps, negative end → neutral midpoint (step 5) → positive end--py-div-1 … --py-div-9
grid, axisThe palette's border and mutedForeground--py-chart-grid, --py-chart-axis

Where the colors come from:

  • Generated themes (createThemeFromSeed, so every library theme and

every blueprint brand seed) carry a full charts block for both modes, and their chart1 … chart5 are the repaired series.

  • Built-in presets have no charts block, to keep the always-loaded

presets small; their chart1 … chart5 already pass the rules (a few scale steps were replaced by hex values for that). The charts kit derives series 6–8 and the ramps from them at run time.

  • Your own themes may set charts (validated by ThemeSchema: hex colors,

8 / 9 / 9 entries); without it they behave like the presets, and the charts kit repairs any chart1 … chart5 that fail.

applyAppearance always writes --py-chart-grid and --py-chart-axis, and writes the series 1–8 and the ramps when the theme has a charts block. It removes series 6–8 and the ramps again when the next theme has none. packages/themes/tests/charts.test.ts checks every preset and library theme in both modes, 120 generated themes, and that the charts kit has nothing left to repair.

Built-in presets and the theme library

Built-in presets (always available)

From packages/themes/src/presets.ts. They are hand-assembled from Tailwind-compatible scales (scales.ts), with light and dark designed separately rather than inverted. BUILTIN_THEMES is always in every prototype's theme list, and violet (DEFAULT_THEME_ID) is the fallback.

IdNameDescriptionRadius
violetVioletThe Protoyard default: calm zinc neutrals with a violet accent.0.625rem
oceanOceanCool slate surfaces and a confident blue — classic SaaS.0.625rem
emeraldEmeraldFresh green on soft gray — finance, health and sustainability.0.625rem
roseRoseWarm stone with a rose accent — consumer and lifestyle products.0.875rem
amberAmberSunny amber with dark text — energetic dashboards and marketplaces.0.625rem
monochromeMonochromePure grayscale, crisp corners — wireframe-grade focus on behavior.0.375rem

The theme library (loaded on demand)

The library holds 64 themes: the 32 product-category themes of packages/themes/src/library/definitions.ts (first table) and 32 more in definitions-extra.ts (second table). Each entry is data only: id, name, description, category, tags, collection, mood, a seed and secondary color, a neutral family, a font pairing id, radius, density, an optional style profile and, for colorblind-safe themes, five chart seeds. Tokens are not stored; they are built by the palette engine when a theme is loaded. All names are fictional.

IdNameCategorySeed / secondaryFontRadiusDensityElevation
fintech-ledgerFintech Ledgerfintech#0f766e / #1d4ed8grotesk0.5rem0.95soft
health-clinicHealth Clinichealth#059669 / #0284c7humanist0.75rem1.05soft
devtools-nightDevtools Nightdevtools#6366f1 / #22d3eemono0.375rem0.9crisp
enterprise-neutralEnterprise Neutralenterprise#1e3a5f / #475569system0.5rem1flat
editorial-luxuryEditorial Luxuryeditorial#6b21a8 / #9a3412editorial0.25rem1.05soft
playful-consumerPlayful Consumerconsumer#e11d48 / #0ea5e9rounded1rem1.05soft
kids-playgroundKids Playgroundkids#ea580c / #2563ebrounded1.25rem1.1soft
education-campusEducation Campuseducation#1d4ed8 / #0f766ehumanist0.75rem1soft
gaming-neonGaming Neongaming#a855f7 / #22d3eegeometric0.5rem0.95glow
legal-serifLegal Seriflegal#1e3a8a / #44403ceditorial0.25rem1flat
ecommerce-boldE-commerce Boldecommerce#c2410c / #0f172ageometric0.625rem1crisp
marketplace-fairMarketplace Fairmarketplace#4338ca / #b45309system0.625rem1soft
travel-horizonTravel Horizontravel#0284c7 / #d97706humanist0.75rem1.05soft
food-bistroFood Bistrofood#b91c1c / #a16207rounded0.875rem1.05soft
real-estate-loftReal Estate Loftreal-estate#3f6212 / #78716ceditorial0.5rem1soft
hr-peopleHR Peoplehr#7c3aed / #0d9488humanist0.75rem1.05soft
analytics-insightAnalytics Insightanalytics#0891b2 / #4f46e5condensed0.375rem0.9flat
security-vaultSecurity Vaultsecurity#0f172a / #b91c1cgrotesk0.25rem0.95crisp
ai-labAI Labai#4f46e5 / #db2777geometric0.75rem1glow
productivity-minimalProductivity Minimalproductivity#18181b / #3b82f6system0.375rem0.875flat
brutalist-blockBrutalist Blockbrutalist#000000 / #f59e0bgrotesk0px1flat
retro-terminalRetro Terminalretro#16a34a / #a3e635mono0.125rem0.95flat
glass-frostGlass Frostglass#2563eb / #7c3aedgeometric1rem1soft, glass surface
accessible-high-contrastAccessible High Contrastaccessible#0000ee / #008000system0.5rem1.1flat
accessible-dark-contrastAccessible Dark Contrastaccessible#66b3ff / #ffdd57system0.5rem1.1flat
saas-startupSaaS Startupsaas#7c3aed / #06b6d4geometric0.625rem1soft
media-studioMedia Studiomedia#db2777 / #6366f1condensed0.5rem0.95crisp
nonprofit-hopeNonprofit Hopenonprofit#c2410c / #0369a1humanist0.75rem1.05soft
iot-controlIoT Controliot#0e7490 / #854d0econdensed0.375rem0.9crisp
sports-arenaSports Arenasports#dc2626 / #1d4ed8geometric0.5rem1crisp
climate-earthClimate Earthclimate#15803d / #0f766ehumanist0.625rem1soft
crypto-ledgerCrypto Ledgercrypto#1e3a8a / #ca8a04mono0.5rem0.95glow

Elevation soft in this table is the schema default for entries that do not set one. Several entries also set typography, radius scale, border width, motion or focus-ring values; see definitions.ts for the full profile.

Twenty of the first 32 now use a neutral family, so no two themes in one family have nearly the same primary (the table above does not list the neutral; see definitions.ts). accessible-high-contrast and accessible-dark-contrast are AAA themes in the high-contrast collection, and brutalist-block and productivity-minimal are in mono: their black and near-black seeds now keep a gray primary (seeds below OKLCH chroma 0.04 stay achromatic) instead of a saturated one.

The second set:

IdNameCollectionSeed / secondaryNeutralFontRadiusElevation
harbor-tealHarbor Tealindustry#0e7490 / #f59e0bstonegrotesk0.5remcrisp
midnight-opsMidnight Opsindustry, dark-first#0891b2 / #22c55ecoolmono0.375remglow
paper-ledgerPaper Ledgerindustry#374151 / #b45309warmeditorial0.25remflat
citrus-marketCitrus Marketindustry#ea580c / #65a30dstonerounded1remsoft
aurora-analyticsAurora Analyticsindustry#7c3aed / #06b6d4graycondensed0.5remlayered
clinic-calmClinic Calmindustry#0d9488 / #6366f1zinchumanist0.875remsoft
campus-ivyCampus Ivyindustry#166534 / #a16207warmeditorial0.625remcrisp
fieldworkFieldworkindustry#4d7c0f / #92400estonehumanist0.5remflat
studio-reelStudio Reelindustry#be185d / #0ea5e9zincgeometric0.75remglow
transit-lineTransit Lineindustry#0369a1 / #dc2626graygrotesk0.375remlayered
hearth-homeHearth Homeindustry#b45309 / #0f766ewarmrounded0.875remlayered
civic-hallCivic Hallindustry#1e40af / #b91c1ccoolsystem0.25remflat
nimbus-cloudNimbus Cloudbrand-like#0ea5e9 / #8b5cf6tintedgeometric0.75remsoft
ember-payEmber Paybrand-like#dc2626 / #f59e0bstonegrotesk0.625remcrisp
lumen-notesLumen Notesbrand-like#ca8a04 / #475569stoneeditorial0.5remflat
orbit-chatOrbit Chatbrand-like#4f46e5 / #ec4899tintedrounded1.25remsoft
quartz-peopleQuartz Peoplebrand-like#db2777 / #0d9488grayhumanist0.75remlayered
summit-crmSummit CRMbrand-like#0f766e / #1d4ed8coolsystem0.5remcrisp
pixel-forgePixel Forgebrand-like, dark-first#a855f7 / #f97316warmmono0.25remglow
velvet-shopVelvet Shopbrand-like#9d174d / #a16207warmeditorial0soft
contrast-paperContrast Paperhigh-contrast (AAA)#1d4ed8 / #15803dgraysystem0.5remflat
contrast-inkContrast Inkhigh-contrast (AAA)#6d28d9 / #b91c1czinchumanist0.375remcrisp
contrast-nightContrast Nighthigh-contrast (AAA), dark-first#38bdf8 / #facc15slatesystem0.5remcrisp
contrast-amberContrast Amberhigh-contrast (AAA), dark-first#f59e0b / #22d3eewarmgrotesk0.25remflat
okabe-blueOkabe Bluecolorblind-safe#0072b2 / #e69f00zincsystem0.5remsoft
okabe-orangeOkabe Orangecolorblind-safe#d55e00 / #56b4e9slategeometric0.75remlayered
safe-signalSafe Signalcolorblind-safe, dark-first#56b4e9 / #e69f00slatecondensed0.375remcrisp
clear-sightClear Sightcolorblind-safe#009e73 / #cc79a7grayhumanist0.875remflat
winter-frostWinter Frostseasonal#0284c7 / #6366f1coolgeometric1remsoft
autumn-harvestAutumn Harvestseasonal#c2410c / #854d0ewarmeditorial0.625remsoft
graphiteGraphitemono#3f3f46 / #71717aslategrotesk0.25remflat
sepia-printSepia Printmono#57534e / #a8a29estoneeditorial0.125remflat
CollectionThemesWhat it promises (checked by tests/library.test.ts)
industry40Product-category moods
brand-like8Fictional brand looks
high-contrast6Every text pair at 7:1 (WCAG AAA) in both modes; light-first and dark-first
colorblind-safe4Okabe-Ito chart seeds, repaired to the chart rules with their hues kept, distinct under protan, deutan and tritan simulation
seasonal2Seasonal looks
mono4Gray or near-gray primaries

Every theme has its own neutral family, font, radius and elevation combination, and no two themes that share a neutral family have primaries closer than ΔE_OK 0.04 in both modes. createThemeFromSeed({ contrast: 'AAA' }) searches every text pair to 7:1 and needs a neutral family; chartSeeds (five hex colors) replaces the derived chart series before the chart repair.

Each definition can also set catalog metadata (packages/themes/src/library/types.ts):

FieldValuesDefault
collectionindustry, brand-like, high-contrast, colorblind-safe, seasonal, mono (THEME_COLLECTIONS)industry
moodany of calm, bold, playful, corporate, editorial, technical, warm, fresh, luxurious, minimal (THEME_MOODS)none
darkFirsttrue when the theme is meant to be shown in dark mode firstfalse
contrastLevelAA (text 4.5:1) or AAA (text 7:1, enforced by tests/library.test.ts)AA
neutrala neutral familythe original neutrals

The first 32 definitions carry product categories and tags; optional metadata uses the defaults above when a definition omits it. The additional 32 entries make the collections explicit. tests/library.test.ts checks every definition: unique kebab-case ids, #rrggbb seeds, known fonts, neutrals, collections, moods and contrast levels, density range, and the style profile through core's ThemeStyleSchema.

The registry API (packages/themes/src/library/registry.ts):

ExportWhat it does
THEME_LIBRARY_METAEager metadata only (id, name, description, category, tags, collection, mood, darkFirst, contrastLevel, seed, secondary, neutral), no palette tokens. Optional definition fields arrive with their defaults.
listThemeLibrary({ category?, tag?, collection?, mood? })Filters the metadata
searchThemeLibrary(query)Case-insensitive search over name, id, tags, mood, category, collection and description. Every word must match; results rank name and id matches first (3), then tags, mood, category and collection (2), then the description (1), with ties in library order. An empty query returns the whole library.
listCollections()Every collection in a fixed order with its theme count
getLibraryThemeMeta(id), getLibraryThemeDefinition(id)Lookups
loadLibraryTheme(id)Async. Dynamically imports the builder (import('./build.ts')), runs createThemeFromSeed, then applies the font pairing, radius and density. Results are cached in memory. Unknown ids reject with the list of known ids.
loadAllLibraryThemes()Loads every library theme (tests, galleries)
clearLibraryThemeCache()Clears the cache (tests)

The dynamic import keeps the palette engine out of the eager metadata chunk in bundlers that split on import().

Using a library theme in any prototype. Every prototype can use every library theme with no code of its own. mountPrototype gives the runtime a resolver (resolveLibraryTheme in packages/renderer-svelte/src/mount.ts) that loads @protoyard/themes/library the first time an unknown id is asked for: a ?theme=fintech-ledger link, a scenario's appearance.themeId, a persona preference or a DevTools override. The resolved theme is validated, registered once and applied like any other theme. The no-flash cache then replays it before the next boot, and a stored override keeps it across reloads. An id the library does not know falls back visibly to the manifest default.

The same ids work declaratively: "defaultTheme": "fintech-ledger" in protoyard.json, appearance: { themeId: 'health-clinic' } in a scenario, or "brand": { "libraryTheme": "fintech-ledger" } in a blueprint (below). The manifest schema accepts any kebab-case theme id (ThemeIdRefSchema in packages/core/src/manifest.ts), and pnpm protoyard validate <slug> runs a Theme ids check: defaultTheme and every scenario appearance.themeId must be a built-in preset, one of the prototype's own themes or a library id. An unknown id is a warning that names the closest known ids (defaultTheme: unknown theme "fintech-ledgr"; closest: fintech-ledger, …), because the runtime falls back instead of failing.

The gallery's Themes page (apps/gallery/src/pages/themes.astro), the UI Kit prototype's Theme Lab and the CLI list them too.

The gallery Themes page renders 70 cards (64 library themes and six built-ins) as static HTML with precomputed light and dark CSS variables, then hydrates a small filter island. Search, collection and mood filters stay in ?q=, ?collection= and ?mood=; the page mode toggle changes every card's palette and the ?theme=<id> link for the selected prototype. Chart series previews use the same mode-specific variables as each card, so the palette engine does not ship to the gallery page.

DevTools → Look → Theme is a theme browser (packages/devtools/src/sections/ThemeBrowser.svelte). It groups the prototype's own themes, the built-in presets and saved custom themes, with the whole library collapsed behind Browse N more themes:

  • Search matches name, id, description, tags, mood, category and

collection across every group (searchThemeLibrary for the library). A status line announces how many themes match. Searching or filtering shows library matches without expanding it.

  • Collection chips (industry, brand-like, high-contrast,

colorblind-safe, seasonal, mono, with counts) and a tag filter narrow the library.

  • Library cards show the seed and secondary colors until the theme is

loaded, then its primary, accent, surface and foreground. Choosing one loads it through the runtime's resolver; the card shows a spinner until it applies.

  • The choice is a temporary override or the persona's preference, per "Apply

changes as" above. Each group is a radio group: arrow keys, Home and End move within it, and Tab moves between groups.

Custom themes per prototype

Pass themes to definePrototype({ themes }). They are added to the built-in presets (a theme with a preset's id replaces that preset in this prototype) and appear in DevTools → Look.

Generate one from a seed, or start from a preset's palettes and change a few tokens. Either way the prototype needs "@protoyard/themes": "workspace:*" in its package.json dependencies (then pnpm install). For example, in src/theme.ts:

import { defineTheme } from '@protoyard/core';
import { oceanTheme } from '@protoyard/themes';

export const harborTheme = defineTheme({
  id: 'harbor',
  name: 'Harbor',
  description: 'Ocean surfaces with a deep teal accent.',
  radius: '0.5rem',
  light: { ...oceanTheme.light, primary: '#0f766e', primaryForeground: '#ffffff', ring: '#0d9488' },
  dark: { ...oceanTheme.dark, primary: '#2dd4bf', primaryForeground: '#042f2e', ring: '#2dd4bf' },
  style: { elevation: 'crisp' },
});
// src/prototype.ts
export const definition = definePrototype({
  manifest: parseManifest(manifestJson),
  // …
  themes: [harborTheme],
});

To make it the default, set "defaultTheme": "harbor" in protoyard.json. A scenario can also pick it: appearance: { themeId: 'harbor', mode: 'dark' }.

Every palette needs all 33 tokens; spreading a preset palette or using createThemeFromSeed is how you avoid writing them all. Without the extra dependency, write both palettes in full with defineTheme() from @protoyard/core. pnpm protoyard theme create (below) writes a complete src/theme.ts for you.

The blueprint brand block

A blueprint for pnpm protoyard generate can carry an optional brand (packages/blueprint/src/schema.ts, resolved by resolveBlueprintBrand in packages/blueprint/src/brand.ts):

FieldValues
seed#rgb / #rrggbb; required unless preset is set
secondaryoptional second hex color
presetviolet, ocean, emerald, rose, amber, monochrome; the full palette when seed is absent, ignored when seed is present
styleProfilesoft, crisp, flat, rounded, editorial, mono: radius, density, optional font and style knobs
fonta FONT_CATALOG id (overrides the profile's font)
radius, densitya CSS length; 0.75–1.25 (override the profile's values)
id, nametheme id (kebab-case, no leading, trailing or doubled hyphens) and display name; both default from the brand or blueprint name (Acme Teal → acme-teal, 3D Print Shop → brand-3d-print-shop)
libraryThemea theme library id (fintech-ledger, …), used as-is; cannot be combined with id, seed, secondary, preset, styleProfile, font, radius or density
{
  "formatVersion": 1,
  "name": "Acme Desk",
  "brand": { "seed": "#0f766e", "name": "Acme Teal", "styleProfile": "soft", "font": "grotesk" },
  "entities": ["…"],
  "roles": ["…"]
}

generate builds the theme (palette engine for a seed, or a copy of the preset), applies the profile, font, radius and density, and checks every text and UI pair in both modes. A failing brand stops generate with a validation error listing the pairs. On success it writes src/generated/brand-theme.ts, registers the theme in src/prototype.ts (themes: [acmeTealTheme]) and sets defaultTheme in protoyard.json. A dry run of the blueprint above lists, among the other files:

$ pnpm protoyard generate acme-desk --blueprint ./acme-desk.blueprint.json --dry-run
- Dry run: would write prototypes/acme-desk (39 files, nothing written)
  …
  generated src/generated/brand-theme.ts
  …
  emitted   src/prototype.ts
  …

With "brand": { "libraryTheme": "fintech-ledger" }, generate writes no theme module and keeps the template's src/prototype.ts: it only sets "defaultTheme": "fintech-ledger", and the renderer loads the library theme on demand (its contrast is covered by the library's own tests). The id is checked against THEME_LIBRARY_META by generate and pnpm protoyard blueprint validate; an unknown id fails with exit 4 and the closest ids. The check lives in brandLibraryThemeId (packages/blueprint/src/brand.ts), not the zod schema, because parseBlueprint runs inside generated prototypes and the library stays out of their initial chunk.

Without brand, the template's default theme is kept.

Theme Studio

DevTools → Look has a Theme Studio section (packages/devtools/src/sections/ThemeStudioSection.svelte). It is its own lazy chunk: its code loads only when you press Open studio.

AreaWhat you can do
BuildName the theme; pick a seed color (color picker or hex field) and, optionally, a secondary color; choose a font pairing from FONT_CATALOG, a corner radius and motion from the style presets (Square, Sharp, Default, Round, Pill; None, Subtle, Standard, Expressive), elevation (flat, soft, crisp, glow, layered), surface (Solid, Glass), focus ring (ring, outline, glow, inset) and density (0.75–1.25 in 0.125 steps). The draft is built with createThemeFromSeed.
HarmonyChoose a neutral family and a contrast-safe secondary-color suggestion (complementary, analogous, triadic, split-complementary or monochrome). Suggestions use deterministic OKLCH harmonies and are filtered for valid light and dark palettes.
PreviewA sample card, buttons, badge and input themed by the draft only (scoped with applyAppearance on the preview box, cleared on close), with its own Light / Dark toggle.
ContrastLive WCAG warnings for every text pair (4.5:1) and focus pair (3:1) in both modes, or a confirmation that all pass. An invalid name or seed shows an inline error and nothing can be saved.
SaveSave stores the theme; Save and preview also applies it as a temporary override. The id is a slug of the name, and saving again with the same name replaces it.
Custom themesLists saved themes. Each can be exported or deleted; deleting the active theme falls back to a known theme. Saved themes appear in the theme grid above the Studio.
ExportShows the theme as JSON and offers a <id>.theme.json download.
ImportPaste JSON or choose a file. It is validated before saving; errors are shown inline per field.
Share draftCopy draft share link encodes the Studio inputs into a compact themeDef URL value (at most 1,024 encoded characters). Opening it loads the draft in the Studio; the recipient explicitly saves it into their own browser. The link contains the seed and style choices, not generated palette tokens or saved domain data.

Storage. Custom themes are kept in this prototype's control database (protoyard.<namespace>.control, key customThemes), never in the data database, so they survive reloads and scenario loads but do not travel to other prototypes or browsers. The runtime (packages/runtime/src/theme.ts) enforces the rules:

RuleValue
ValidationThemeSchema on save, on import and again on every boot; invalid stored entries are skipped and reported, never fatal
Id clashesA custom theme cannot reuse a built-in or prototype theme id
LimitMAX_CUSTOM_THEMES = 24
Import sizeMAX_THEME_JSON_BYTES = 64 KB; parsed with JSON.parse only
Import formatsBare Theme JSON or { "format": "protoyard-theme/v1", "theme": { … } }

The same API is on the runtime's theme controller for scripts and tests: customThemes(), isCustom(id), saveCustom(input), removeCustom(id), exportTheme(id) and importTheme(json).

The Studio's download is a bare theme object, ready for protoyard theme import or theme create --from. The runtime's exportTheme(id) API returns the versioned wrapper; the Studio unwraps that response for its JSON download. The versioned wrapper is also accepted by Studio import.

The CLI

pnpm protoyard theme <command> (packages/cli/src/commands/theme.ts). Every command also takes --json.

CommandOptionsWrites files
theme list—no
theme export [id]--from <file> (instead of an id), `--format css\tailwind\
theme create <slug>--seed <hex> with --id <id> (required), --name, --secondary <hex>; or --from <file>; --force, --dry-runprototypes/<slug>/src/theme.ts
theme import <slug> <file>--force, --dry-runprototypes/<slug>/src/theme.ts

create and import validate the theme with ThemeSchema and the WCAG pairs in both modes before writing, refuse to overwrite an existing src/theme.ts without --force, and print the file instead of writing it with --dry-run. They write the file only; register the exported theme in definePrototype({ themes }) yourself.

List the six presets and the 64 library themes:

$ pnpm protoyard theme list
violet	Violet	builtin
ocean	Ocean	builtin
emerald	Emerald	builtin
rose	Rose	builtin
amber	Amber	builtin
monochrome	Monochrome	builtin
fintech-ledger	Fintech Ledger	library
health-clinic	Health Clinic	library
…
climate-earth	Climate Earth	library
crypto-ledger	Crypto Ledger	library

Export a built-in or library theme (library themes are built on the fly with loadLibraryTheme). css prints both palettes as --py-* variables under :root and .dark:

$ pnpm protoyard theme export fintech-ledger --format css
:root {
  --py-background: #fafafa;
  --py-foreground: #111827;
  …
  --py-primary: #007a71;
  --py-primary-foreground: #ffffff;
  …
  --py-radius: 0.5rem;
  --py-density: 0.95;
  --py-font-family: var(--py-font-stack-grotesk-sans, "Helvetica Neue", Helvetica, Arial, sans-serif);
}

.dark {
  …
  --py-primary: #00a296;
  --py-primary-foreground: #09090b;
  …
}

tailwind prints an @theme block that maps the variables to color utilities; tokens prints W3C design-tokens JSON (colors per mode, radius, font family, density):

$ pnpm protoyard theme export ocean --format tailwind
@theme {
  --color-background: var(--py-background);
  --color-foreground: var(--py-foreground);
  …
  --color-shadow: var(--py-shadow);
  --radius-theme: var(--py-radius);
}

$ pnpm protoyard theme export violet --format tokens
{
  "$schema": "https://design-tokens.github.io/community-group/format/",
  "name": "Violet",
  "color": {
    "light": {
      "background": {
        "$type": "color",
        "$value": "#fafafa"
      },
  …

The exports cover the palettes, radius, density and font family only, not the style profile. Two naming details differ from base.css: the css format names the font --py-font-family (the runtime uses --py-font-sans), and the tailwind format names chart colors --color-chart1 (the base stylesheet uses --color-chart-1). An unknown id exits with code 3 and lists the known ids.

Create a theme file from a seed (shown as a dry run, nothing written):

$ pnpm protoyard theme create crm-demo --id harbor --name Harbor --seed '#0f766e' --secondary '#1d4ed8' --dry-run
Would write prototypes/crm-demo/src/theme.ts
import { defineTheme } from '@protoyard/core';

export const harborTheme = defineTheme({
  "id": "harbor",
  "name": "Harbor",
  "description": "Generated from #0f766e and #1d4ed8.",
  …
  "light": {
    …
    "primary": "#007a71",
    "primaryForeground": "#ffffff",
  …
});

Import a bare theme JSON file into a prototype (or use theme create <slug> --from <file>, which does the same):

$ pnpm protoyard theme import crm-demo ./harbor.theme.json --dry-run
Would write prototypes/crm-demo/src/theme.ts
import { defineTheme } from '@protoyard/core';

export const harborTheme = defineTheme({
…

Mode, reduced motion and density

SettingValuesResolution
Modelight, dark, systemsystem uses prefers-color-scheme and updates live when the OS changes
Reduced motiontrue / falseFalls back to the OS prefers-reduced-motion. When reduced, the motion variables become 0ms / linear, and base.css disables transitions and animations when either the OS or data-py-motion="reduce" asks for it.
Densitytheme.density, 0.75–1.25 (default 1)Part of the theme. DevTools → Look has density presets (compact, default, comfortable, spacious) and a fine slider (0.75–1.25 in 0.025 steps); both are always a temporary override, and Theme returns to the theme's own density. `applyAppearance(el, { density: 'compact' \
Viewportdesktop, tablet, iphone, android, customNot a theme setting, but resolved the same way. Narrows the app column (data-py-viewport); media queries still follow the real window.
Shell, device, orientationauto, none, phone, tablet, desktop-window, browser, widget-host, extension-popup, side-panel; a catalog device; portrait / landscapeNot theme settings, but resolved the same way (override › scenario › manifest preview). The frame drawn around the app follows the theme and mode. See SHELLS.md.

Precedence

The effective theme, mode and reduced-motion value each come from the first layer that sets them:

OrderSourceSet by
1overrideDevTools → Look with "Temporary override" selected, Theme Studio's Save and preview, a link's ?theme= / ?mode= / ?radius= / ?elevation= / ?font= / ?motion=, theme.setOverride()
2preferenceThe active persona's saved choice: DevTools → Look with "Persona preference" selected, theme.setPreference(), or persona.preferences from the generator
3scenarioThe loaded scenario's appearance
4defaultprotoyard.json: defaultTheme (default violet) and defaultMode (default system); for reduced motion, the OS setting

DevTools shows the source next to each value. Switching persona can change the look, because preferences are stored per persona. An unknown theme id (a deleted custom theme, a bad link) falls back visibly to the manifest default with source default.

Theme and persona changes never write to the data database (ADR-005 in DECISIONS.md).

No flash on load

The runtime boots asynchronously, so without help the page would paint with the default colors and then switch. Two things prevent that:

  1. base.css ships Violet defaults for :root and .dark, so the first

paint is already themed.

  1. defineProtoyardConfig() injects noFlashScript(namespace) as the first

<head> script. It replays the last applied appearance from localStorage (protoyard.<namespace>.appearance) before first paint: the --py-* variables, mode class, theme id, motion, data-py-surface and the allowlisted component profile attributes. Without a cache it still honors prefers-color-scheme.

The renderer refreshes that cache every time the appearance changes. The script only replays --py-* names with string values and known profile values, and every storage access is wrapped in try, so private mode or a tampered cache cannot break the page or inject markup.

Per-prototype isolation

WhatScope
CSS variablesWritten on the <html> of the page running that prototype. Each prototype is its own app and page.
PreferencesStored per persona in that prototype's control database
Custom themes (Theme Studio)That prototype's control database, key customThemes
Prototype themesOnly exist in the prototype that declares them
No-flash cachelocalStorage key per namespace: protoyard.<namespace>.appearance

applyAppearance() also works on any element, not only <html>. That scopes a theme to a subtree, which is how the Theme Studio preview, the gallery's Themes page and the UI Kit's Theme Lab show themes side by side. clearAppearance(el) removes everything it wrote.

Contrast checks

Presets, library themes and generated themes are tested against WCAG 2.x contrast (TEXT_CONTRAST_PAIRS and UI_CONTRAST_PAIRS in packages/themes/src/contrast.ts):

PairsMinimum
Text on its background: foreground/background, surfaceForeground/surface, foreground/elevated, mutedForeground on background, surface and muted, every xForeground/x pair, sidebarForeground/sidebar4.5 : 1
Semantic colors used as text: primary on background, surface and muted; destructive on surface and muted; success and info on surface4.5 : 1
Focus ring: ring on background and surface3 : 1
Where the pairs are enforcedHow
packages/themes/tests/contrast.test.tsEvery built-in preset and every library theme, both modes; built-in hover fills stay visible and keep text at AA
packages/themes/tests/library.test.tsEvery library theme, both modes
packages/themes/tests/palette.test.tscreateThemeFromSeed across 500 deterministic seeds, both modes, with the original neutrals and every neutral family
Theme StudioLive warnings before saving
theme create / theme importRefuses to write a failing theme
generate with a brandRefuses to generate a failing brand theme

In the presets, warning is a fill: use bg-warning text-warning-foreground, not text-warning.

The helpers are exported, so a prototype test can check its own theme:

import { contrastRatio, TEXT_CONTRAST_PAIRS, UI_CONTRAST_PAIRS } from '@protoyard/themes';
import { harborTheme } from '../src/theme.ts';

it('harbor meets contrast in both modes', () => {
  for (const mode of ['light', 'dark'] as const) {
    const palette = harborTheme[mode];
    for (const [fg, bg] of TEXT_CONTRAST_PAIRS) {
      if (!palette[fg].startsWith('#') || !palette[bg].startsWith('#')) continue; // helpers read hex only
      expect(contrastRatio(palette[fg], palette[bg])).toBeGreaterThanOrEqual(4.5);
    }
    for (const [fg, bg] of UI_CONTRAST_PAIRS) {
      expect(contrastRatio(palette[fg], palette[bg])).toBeGreaterThanOrEqual(3);
    }
  }
});

contrastRatio() accepts #rgb and #rrggbb only. Translucent values such as the dark-mode borders (rgb(255 255 255 / 0.1)) cannot be checked this way, which is why the loop skips non-hex values.

Performance

Library theme tokens are built only when a theme is loaded, and the Theme Studio is a separate DevTools chunk. For measured bundle and timing impact of the library, the palette engine and the Theme Studio, see PERFORMANCE.md.