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 — Tailwind v4 @theme block defining the 23 semantic token slots (colors, shapes, shadows, typography)
  • 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 23 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:

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

Process with Tailwind CLI before deploying:

npx tailwindcss -i themes/myapp/tokens.css -o public/themes/myapp.css

Token Reference

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

Surface Tokens (6)

TokenDefault (light)Purpose
--color-backgroundoklch(100% 0 0)Page background
--color-surfaceoklch(97% 0 0)Section/panel background
--color-cardoklch(95% 0 0)Card component background
--color-borderoklch(90% 0 0)Borders and dividers
--color-textoklch(15% 0 0)Primary text
--color-text-mutedoklch(50% 0 0)Secondary/placeholder text

Role Tokens (8)

TokenDefault (light)Purpose
--color-primaryoklch(55% 0.2 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(65% 0.15 200)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-family-sansui-sans-serif, system-ui, sans-serifBody and UI text
--font-family-monoui-monospace, monospaceCode, IDs, technical values

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) {
  @theme {
    --color-background: oklch(12% 0 0);
    --color-surface: oklch(17% 0 0);
    --color-text: oklch(95% 0 0);
    /* ... 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(12% 0 0);
  --color-surface: oklch(17% 0 0);
  --color-text: oklch(95% 0 0);
}

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

Authoring format vs. deployed format:

The tokens.css file uses the Tailwind v4 @theme authoring syntax, which is processed by the Tailwind CLI. Do not serve the raw tokens.css directly — run it through Tailwind first:

npx tailwindcss -i themes/mytheme/tokens.css -o public/themes/mytheme.css

Add this to your build pipeline or package.json scripts.

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.