Themes

ferro-theme provides a semantic token system that gives JSON-UI applications consistent, customizable visual identities. A theme is a CSS file plus an optional JSON file — no Rust code is needed to create or modify one.

Overview

A theme consists of two files:

  • tokens.css — plain CSS :root { ... } block declaring the 30 semantic token slots (colors, shapes, shadows, typography, density, motion, focus ring, display font)
  • theme.json — partial JSON object overriding intent template layouts (optional; empty {} uses defaults)

Themes live in the themes/ directory at the project root:

themes/
  myapp/
    tokens.css
    theme.json

Quick Start

Scaffold a new theme:

ferro make:theme myapp

This creates themes/myapp/tokens.css with all 30 token slots pre-filled with defaults, and themes/myapp/theme.json as an empty object.

Activate the theme by setting it as the default in your middleware setup:

#![allow(unused)]
fn main() {
use ferro::{ThemeMiddleware, Theme};

let middleware = ThemeMiddleware::new()
    .default_theme(Theme::from_path("./themes/myapp").expect("theme directory not found"));
}

Edit themes/myapp/tokens.css to customize the visual identity:

:root {
  --color-primary: oklch(55% 0.22 160);  /* green brand color */
  --color-accent: oklch(65% 0.18 300);   /* purple accent */
}

No build step is required. ThemeMiddleware injects tokens.css verbatim into an inline <style> tag, so the file must be standard CSS. Tailwind at-rules such as @theme are not processed at runtime — browsers ignore unknown at-rules wholesale, leaving every token in the block undefined.

Token Reference

All 30 semantic token slots. Components use these class names — themes control the values.

Every valid v1 theme (23 slots) remains a valid v2 theme without changes — the 7 new slots ship with defaults in the base CSS.

Surface Tokens (6)

TokenDefault (light)Purpose
--color-backgroundoklch(99% 0.004 250)Page background
--color-surfaceoklch(100% 0 0)Section/panel background
--color-cardoklch(100% 0 0)Card component background
--color-borderoklch(90% 0.012 250)Borders and dividers
--color-textoklch(20% 0.02 250)Primary text
--color-text-mutedoklch(50% 0.016 250)Secondary/placeholder text

Role Tokens (8)

TokenDefault (light)Purpose
--color-primaryoklch(20% 0.02 250)Primary actions, links
--color-primary-foregroundoklch(100% 0 0)Text on primary backgrounds
--color-secondaryoklch(70% 0.05 250)Secondary actions
--color-secondary-foregroundoklch(15% 0 0)Text on secondary backgrounds
--color-accentoklch(70% 0.13 250)Highlights, badges
--color-destructiveoklch(55% 0.22 25)Delete, error states
--color-successoklch(55% 0.18 145)Success states
--color-warningoklch(70% 0.18 80)Warning states

Shape Tokens (4)

TokenDefaultPurpose
--radius-sm0.25remSmall elements (badges, tags)
--radius-md0.375remMedium elements (inputs, buttons)
--radius-lg0.5remLarge elements (cards, modals)
--radius-full9999pxPill-shaped elements

Shadow Tokens (3)

TokenPurpose
--shadow-smSubtle elevation (inputs, dropdowns)
--shadow-mdCard elevation
--shadow-lgModal/overlay elevation

Typography Tokens (2)

TokenDefaultPurpose
--font-sansui-sans-serif, system-ui, sans-serifBody and UI text
--font-monoui-monospace, monospaceCode, IDs, technical values

Density Token (1)

TokenDefaultPurpose
--spacing0.25remBase spacing unit; all spacing utilities scale as calc(var(--spacing) * N) — one knob rescales all padding/margin/gap

Motion Tokens (4)

TokenDefaultPurpose
--motion-duration-fast120msMicro-interactions: hover, toggles, controls (anything repeated)
--motion-duration-base220msStandard transitions: dropdowns, modals, toasts
--motion-duration-slow320msLarge surfaces: drawers, page-level reveals
--motion-easecubic-bezier(0.2, 0, 0.38, 0.9)Standard easing curve — calm, settled, no bounce

Base CSS collapses all three durations to 0.01ms under prefers-reduced-motion: reduce.

Focus Ring Token (1)

TokenDefault (light)Purpose
--color-ringaccent-family oklch (oklch(55% 0.2 250))Uniform focus-visible ring for interactive components — accessibility as a token

Display Font Token (1)

TokenDefaultPurpose
--font-displayvar(--font-sans)Display/heading font family

Type Scaling

The token vocabulary deliberately omits per-size type tokens. To scale all text, set the root font-size in your theme's tokens.css — Tailwind's text sizes are rem-based, so they all shift relative to the browser default:

:root {
  font-size: 14px;   /* compact — all rem-based sizes scale down */
}

Spacing scales independently via the --spacing density token; root font-size controls the type rem anchor. Font weight is likewise not tokenized — there is no demonstrated theming need.

Dark Mode

Themes include automatic dark mode support via @media (prefers-color-scheme: dark).

The scaffolded tokens.css includes a dark mode block:

@media (prefers-color-scheme: dark) {
  :root {
    --color-background: oklch(15% 0.014 250);
    --color-surface: oklch(19% 0.016 250);
    --color-text: oklch(95% 0.006 250);
    /* ... remaining dark overrides */
  }
}

To add manual dark mode toggle (e.g., user preference stored in a cookie), apply a data-theme="dark" attribute on the <html> element and scope the overrides with [data-theme="dark"]:

[data-theme="dark"] {
  --color-background: oklch(15% 0.014 250);
  --color-surface: oklch(19% 0.016 250);
  --color-text: oklch(95% 0.006 250);
}

Intent Templates

theme.json controls how JSON-UI intent layouts render. Leave it as {} to use the built-in layouts, or override specific intents.

Supported intent keys: browse, focus, collect, process, summarize, analyze, track.

Supported slot keys within each intent: title, body, fields, actions, relationships, pagination, metadata, stats.

Each intent has two modes: display (reading data) and input (forms/editing). Each mode specifies an ordered list of slots and an optional layout component.

Example — override Browse display to show title, fields, and pagination in a Table layout:

{
  "browse": {
    "display": {
      "slots": ["title", "fields", "pagination"],
      "layout": "Table"
    }
  }
}

Example — override Collect input to show fields and actions in a Form layout:

{
  "collect": {
    "input": {
      "slots": ["fields", "actions"],
      "layout": "Form"
    }
  }
}

Unspecified intents use the built-in renderer. Only override what you want to change.

ThemeMiddleware Setup

ThemeMiddleware resolves the active theme per request using a resolver chain. Multiple resolvers are tried in order; the first Some result wins.

#![allow(unused)]
fn main() {
use ferro::{ThemeMiddleware, HeaderThemeResolver};

let middleware = ThemeMiddleware::new()
    .resolver(HeaderThemeResolver::new("./themes"));
}

The middleware stores the resolved theme in task-local context. JSON-UI responses automatically include the theme CSS as an inline <style> tag in the HTML <head>. When no resolver matches, ThemeMiddleware falls back to the built-in default theme — it never returns an error.

Resolver Types

HeaderThemeResolver — selects theme from the X-Theme request header:

#![allow(unused)]
fn main() {
use ferro::HeaderThemeResolver;

HeaderThemeResolver::new("./themes")
// Request with `X-Theme: pro` header loads themes/pro/
}

TenantThemeResolver — selects theme based on TenantContext.plan:

#![allow(unused)]
fn main() {
use ferro::TenantThemeResolver;

TenantThemeResolver::new("./themes")
// Tenant with plan "enterprise" loads themes/enterprise/
}

DefaultResolver — always returns a specific theme:

#![allow(unused)]
fn main() {
use ferro::{DefaultResolver, Theme};

DefaultResolver::new(Theme::from_path("./themes/corporate").expect("theme directory not found"))
}

When no resolver matches, ThemeMiddleware falls back to the built-in default theme automatically.

Setting a Custom Default

Use .default_theme() to change the fallback theme (used when no resolver matches):

#![allow(unused)]
fn main() {
use ferro::{ThemeMiddleware, TenantThemeResolver, Theme};

let middleware = ThemeMiddleware::new()
    .resolver(TenantThemeResolver::new("./themes"))
    .default_theme(Theme::from_path("./themes/corporate").expect("theme directory not found"));
}

Multi-Tenant Themes

In multi-tenant applications, each tenant can have a distinct visual identity. TenantThemeResolver reads TenantContext.plan and uses it as the theme directory name.

TenantMiddleware must run before ThemeMiddleware so TenantContext is populated when the theme is resolved.

#![allow(unused)]
fn main() {
use ferro::{TenantMiddleware, ThemeMiddleware, TenantThemeResolver};

// Register TenantMiddleware first
let tenant_mw = TenantMiddleware::new().resolver(/* ... */);

// Then ThemeMiddleware — reads tenant.plan as theme name
let theme_mw = ThemeMiddleware::new()
    .resolver(TenantThemeResolver::new("./themes"));
}

A tenant with plan: "enterprise" loads themes/enterprise/. Tenants without a plan (or with a plan that doesn't match a theme directory) fall through to the next resolver or the default theme.

For Theme Creators

No build step:

tokens.css is plain CSS. ThemeMiddleware reads it and injects it verbatim into an inline <style> tag on every JSON-UI response. Utility classes are pre-built into the framework's base CSS and resolve tokens through var() at render time, so a theme only declares variable values. Do not use Tailwind at-rules (@theme, @import "tailwindcss") — browsers ignore unknown at-rules wholesale, leaving every token in the block undefined.

Partial overrides in theme.json:

theme.json only needs to specify the intents and modes you want to override. An empty {} is valid and means "use all built-in layouts." Intents not specified in theme.json use the framework's built-in renderer unchanged.

Publishing a theme:

A theme is just two static files. Publish them as an npm package, a GitHub repo, or any file distribution mechanism. Users add them to their themes/ directory and point ThemeMiddleware to the theme name.