Theme

One JSON holds every design decision; the build, the runtime and the audits read the same file.

The families

A theme is a partial override of the base theme that ships in the package (uxdsl/theme/base.json): write only what you change. These are every family the schema knows, with the keys the base theme defines.

FamilyShapeIn the base themeWhat it isRead by
breakpointsname → width in px (a number)xs 0, sm 480, md 768, lg 1024, xl 1280The viewport widths, in px, where responsive values step. The base (xs) must be 0.xs() … xl() and every responsive value in the theme
spacingkey → value0–16The spacing scale: a key per step, one value each, the same at every width.space(k); densities, radii and typography reference it
palettefamily → variant → valueprimary, secondary, surface, tertiary, success, info, warning, error, dark, … (14)Semantic roles (primary, surface, text, …), each a family of variants (main, light, dark, contrast, …).palette(family.variant); every role and directive
fontsfamilies: name → stack; google: [family spec]google, familiesfamilies: the font stacks typography roles name; google: the Google Fonts families to @import ([] opts out).--uxdsl__font__name; typography_details
colorsfamily → shade → color, or name → colorwhite, black, gray, purple, pink, red, amber, green, skyThe color collection: families of shades (gray.50 … gray.950) or single named colors.color(family.shade); palette values reference it
typography_detailsrole → fontFamily, fontSize, lineHeight, fontWeight, letterSpacing, textTransform, textDecoration, fontStyle, marginBlockStart, marginBlockEnddefault, tag, h1, h2, h3, h4, h5, h6, p, … (17)Text roles (default, h1 … h6, p, caption, …): each field a CSS value or a responsive expression; default fills the fields a role omits.@ds-typo(role)
densitieskey → value0–15Spacing steps that move at breakpoints: each value is usually a responsive expression over space().density(k); surface sizes
inputsrole → surface, base (padding, radius, bg, color, border, shadow, opacity, outline, outline-offset, transform, cursor, font-weight, caret, placeholder, underline), states (hover, focus, focusvisible, readonly, invalid, disabled)contained, outlined, underlineText-field roles: a surface, base overrides (caret, placeholder, underline …) and per-state fields.@ds-input(role)
buttonsrole → surface, base (padding, radius, bg, color, border, shadow, opacity, outline, outline-offset, transform, cursor, font-weight), states (hover, active, focus, focusvisible, disabled, selected)contained, outlined, flatAction roles: a surface, base overrides and per-state fields.@ds-button(role)
surfacesrole → padding, radius, bg, color, border, shadowcontained, outlined, flatContainer roles composed from the families above: padding, radius, bg, color, border, shadow.@ds-surface(role); buttons and inputs build on a surface
shadowskey → value0–5box-shadow presets, static or responsive, layers allowed. shadow(0) is none.shadow(k); surfaces
borderskey → value0–5Whole-edge presets (width, style, color), static or responsive. border(0) is none.border(k); surfaces
radiikey → value0–5Corner presets, static or responsive. radius(0) is square; pill and circle are built in.radius(k); surfaces and sizes
modesdark → palette → (as palette)darkDark mode: modes.dark.palette redefines palette values, and only those; nothing else has a dark variant.the same palette() references, under prefers-color-scheme and data-theme

Where the theme comes from

uxdsl.theme.json next to uxdsl.config.cjs (or in the working directory) — or uxdsl.theme.js / .cjs exporting the theme object when it is computed. The CLI, the PostCSS plugin, the Vite plugin and the Webpack loader discover the same file; a theme option passed to one of them wins. Point $schema at the packaged schema and an editor validates and completes it:

{
  "$schema": "./node_modules/uxdsl/schema/theme.schema.json",
  "breakpoints": { "md": 800 },
  "palette": { "primary": { "main": "color(green.700)" } }
}

How an override merges

Objects merge key by key; a string — a responsive expression included — replaces the whole value. This override and the result deepMergeTheme gave when this page was built:

{
  "palette": {
    "primary": {
      "main": "color(green.700)"
    }
  },
  "spacing": {
    "4": "0.875rem"
  }
}
palette.primary → {"main":"color(green.700)","light":"color(purple.500)","dark":"color(purple.900)","contrast":"color(white)"}
spacing["4"]   → "0.875rem"   spacing["5"] → "1rem"

uxdsl theme prints the effective theme and uxdsl theme --diff labels every leaf project or default (Tooling).

Values

Every leaf is a nonempty string (a breakpoint width is a number), written in one grammar on every path:

  • a literal CSS value: "0.75rem", "0 1px 2px rgba(0, 0, 0, 0.1)";
  • a token function: space(4), color(gray.300), palette(surface.light), radius(2), border(1), shadow(2);
  • a responsive expression over the theme’s breakpoints, xs(space(4)) md(space(5)) — not in spacing, colors, palette or fonts.families;
  • var(--…) as the escape hatch.

validateTheme (uxdsl/theme) is the one validator: the plugin, the CLI, generateThemeCss and applyTheme all call it, and nothing is coerced. A number where a string belongs, as this page’s build found:

UXD_THEME_INVALID breakpoints.md: UXD_THEME_INVALID: Expected a finite number of pixels, got a string (at breakpoints.md).
UXD_THEME_INVALID spacing.4: UXD_THEME_INVALID: Expected a nonempty string, got number 12 (at spacing.4).

Dark mode

modes.dark.palette redefines palette values for dark mode, and nothing else has a dark variant. The compiled CSS applies them under prefers-color-scheme: dark unless <html data-theme="light">, and always under data-theme="dark". Components keep naming palette(…); a switch only sets the attribute:

document.documentElement.setAttribute('data-theme', 'dark')   // or 'light'; remove it to follow the OS

The compiled ladder is on Colors & palette; a dark key you omit keeps its light value.

Fonts

fonts.google lists Google Fonts families; the compiled stylesheet and generateThemeCss lead with their @import, before every other rule. The base theme’s, encoded by googleFontsImportUrls:

https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap

{ "fonts": { "google": [] } } opts out: no import, and no request to Google. fonts.families names the stacks (--uxdsl__font__ui, --uxdsl__font__code).

resolveTheme(override) and DEFAULT_THEME

Effective palette.primary (14 families in the resolved theme)
VariantresolveThemeFromDEFAULT_THEME
main#0f766eyour overridecolor(purple.700)
lightcolor(purple.500)basecolor(purple.500)
darkcolor(purple.900)basecolor(purple.900)
contrastcolor(white)basecolor(white)

structuralChanges(themeStructure(…)): values only, so applyTheme would apply it without a rebuild

Override only main and dark/contrast come from the base: the same merge uxdsl theme --diff labels leaf by leaf (see the CLI page).

tokenValueToCss · validateResponsiveExpression · analyzeResponsiveValue

tokenValueToCss → color-mix(in srgb, var(--uxdsl__palette__primary-main) 50%, transparent)

Groups of breakpoint functions (inside a larger value)
BreakpointsBase!important
xs mdyesno

googleFontsImportUrls · encodeGoogleFontFamily

The active theme's fonts.google, as the @import URLs the compiler and generateThemeCss both emit:

  • https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap

encodeGoogleFontFamily → Noto+Sans+JP:wght@400;700

The active theme, as JSON

The theme this site is showing. Edit it and the page follows through applyTheme; a change of structure is refused. Nothing is saved to a file.

Runtime Config JSON
Edit JSON to update UI live. UI changes also sync back here.
Waiting for valid JSON...