Themes & tokens
A surface's look is driven by design tokens — CSS custom properties, all named --cal-*. A theme is a plugin that declares those tokens (plus configurable settings) and publishes CSS that consumes them. This page covers the token cascade, how a theme declares tokens in theme.json, how the resolver produces a workspace's effective values, and how your surface CSS consumes them.
Grounded in src/Core/Application/Extensions/WorkspacePublicThemeResolver.cs, WorkspacePublicTheme.cs, the theme endpoints (src/Administration/Api/ThemeEndpoints.cs, src/Workspace/Api/WorkspacePublicEndpoints.cs), the runtime baseline tokens (src/Surface.Rendering/Resources/app/surface/src/styles/tokens.scss), and the theme.json sync service.
What you'll learn
- The
--cal-*token convention and the runtime's baseline tokens - The token cascade (distribution → tenant → workspace → surface) and how it's resolved
- How a theme plugin declares tokens and settings in
theme.json - How a workspace's resolved values are exposed at
/workspace/public/theme - How surface CSS (Vue components and
.njktemplates) consumes tokens
The --cal-* token convention
Every surface design token is a CSS custom property prefixed --cal-. The runtime ships a small neutral baseline so components render sanely before any theme is assigned (src/Surface.Rendering/Resources/app/surface/src/styles/tokens.scss):
:root {
--cal-space-4: 1rem;
--cal-font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--cal-color-fg: #1a1a1a;
--cal-color-bg: #ffffff;
--cal-color-muted: #6b7280;
}The baseline is deliberately unopinionated — no branding, no colour scheme. Themes layer their values on top.
The cascade
Tokens resolve along four axes, each overriding the one before it:
distribution → tenant → workspace → surface
(baseline) (brand) (per-site) (per-channel)
weakest ─────────────────────────────▶ strongest- Distribution — the runtime's baseline
--cal-*defaults above. - Tenant / workspace — a theme plugin is assigned to a workspace, and the workspace can override the theme's default setting values.
- Surface — the most specific override, when a workspace exposes several surfaces.
Today the resolved layer that a surface actually consumes is the workspace effective theme: the assigned theme plugin's definition defaults, overridden by that workspace's stored values.
Status: The resolver implemented today (
WorkspacePublicThemeResolver) merges theme-definition defaults with per-workspace overrides. The tenant- and surface-level override layers are part of the intended cascade but are not yet distinct resolution steps in this resolver — treat them as the direction of travel, not shipped behaviour.
How resolution works
WorkspacePublicThemeResolver.ResolveAsync(workspaceKey) (src/Core/Application/Extensions/WorkspacePublicThemeResolver.cs):
- Loads the workspace; returns
nullif it's inactive, its tenant is inactive, or it has no assigned theme plugin/version. - Lists the theme's setting definitions (with their defaults) and the workspace's override values.
- For each active, non-secret setting, takes the workspace override if present, else the definition default, and normalises the JSON to a plain string.
- Returns a
WorkspacePublicTheme—{ themePluginId, themeVersion, valuesByKey }.
Secrets never leak
The public theme is served anonymously, so secret-typed settings are filtered out during resolution. Only presentable values reach the surface.
Declaring tokens in theme.json
A theme plugin ships a theme.json next to its assembly. On install the host reads it via ThemeJsonWorkspaceTemplateSyncService and syncs two things: template definitions and setting fields (config.fields). Each setting field becomes a token value a workspace can override.
{
"surface": "surface",
"definitions": [
{ "templateKey": "custom-dashboard", "displayName": "Custom Dashboard", "priority": 50 }
],
"config": {
"fields": {
"primaryColor": {
"label": "Primary Color",
"type": "color",
"value": "#007bff",
"required": true,
"order": 10
},
"spaceUnit": {
"label": "Base Spacing",
"type": "text",
"value": "1rem",
"group": "Layout",
"order": 20
}
}
}
}Each key under config.fields (primaryColor, spaceUnit) is a setting key — the key you'll read back from the theme API. A field carries label, type (default text, e.g. color/select), value/defaultValue, required, group/tab, options, and order. The full schema and its accepted aliases are in Extension manifests.
theme.json keys are setting keys, not CSS variable names
theme.json defines the settings a workspace configures (primaryColor → #007bff). It does not itself name a --cal-* variable. You bind a setting value to a --cal-* property yourself — see Consuming tokens below.
Section layouts
A section layout is the grid a section uses — single, two-2-1, sidebar-left — and the regions inside it are where blocks go. The editor offers exactly what the theme allows, so nobody can compose a grid nothing can style.
What the base surface brings
The runtime ships a base set (single, two-1-1, two-2-1, sidebar-left, three-1-1-1), styled in the same tokens.scss that carries the neutral --cal-* values. They are the base, not the canon: a fresh installation has something to compose with, and a theme replaces or extends them.
Without them the composition renderer would emit data-cal-layout that nothing listens to, and the editor would offer no choice — which looks like a bug rather than a missing theme.
Declaring your own
A theme declares which layouts it can render, and which regions exist inside them:
{
"sectionLayouts": [
{ "key": "single", "label": "One column", "regions": ["main"] },
{
"key": "two-2-1",
"label": "Two columns (2:1)",
"regions": [
{ "key": "main", "label": "Content" },
{ "key": "aside", "label": "Sidebar" }
]
}
]
}A region may be written as a plain string or as { key, label }. The declared order is the reading order — sorting them would put a sidebar before the content it sits next to.
The layout key is what the composition renderer writes into data-cal-layout, and the region key into data-cal-region. Your CSS selects on those:
.cal-section[data-cal-layout='two-2-1'] {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--cal-space-4);
}Inheritance
Your layouts are added to the base ones, and one with the same key replaces the base version entirely (never merges with it — a two-2-1 carrying the base's regions while your CSS expects two others would put blocks in regions that do not exist).
Adding is the safe direction: the base stylesheet is always loaded, so the base layouts work under your theme too, and a theme that only wants to contribute sidebar-right need not repeat the whole palette.
To stand alone with your own grid system:
{ "inheritSectionLayouts": false, "sectionLayouts": [ … ] }Then everything your layouts need is yours to provide. A theme that declares no layouts of its own inherits regardless — there would otherwise be nothing left to compose with.
Layouts live in the theme, not in the core
This is what keeps the token axis the design authority: nobody can compose a grid the theme cannot style, and no layout names end up in Callora's contracts. Bring a layout nobody anticipated and the editor offers it without a change to the platform.
Dropping a layout does not break a page
If a theme stops declaring a layout — usually because somebody switched themes — sections still naming it fall back to single when rendered. The blocks all stay; the section just becomes one readable column instead of a grid nothing styles. The editor names the affected sections so the change is visible rather than mysterious.
A theme that declares no layouts at all says nothing about any of them, and nothing falls back. That is deliberately different from a theme that dropped one.
Reading a workspace's tokens
The resolved values are exposed anonymously at GET /workspace/public/theme?workspaceKey=<key> (src/Workspace/Api/WorkspacePublicEndpoints.cs):
{
"workspaceKey": "acme",
"themePluginId": "acme.brand-theme",
"themeVersion": "1.0.0",
"valuesByKey": {
"primaryColor": "#e4002b",
"spaceUnit": "1.25rem"
}
}valuesByKey is the workspace's effective tokens (defaults + overrides). Operators inspect and edit them through the authenticated theme API (/api/themes/workspaces/{workspaceKey}/settings and .../effective in ThemeEndpoints.cs); the surface just reads the public endpoint.
Consuming tokens in surface CSS
Your surface CSS — whether a Vue component's <style> or a .njk template's stylesheet — consumes tokens with var(--cal-*), always with a fallback so it renders before a theme loads:
<style scoped>
.card {
color: var(--cal-color-fg, #1a1a1a);
background: var(--cal-color-bg, #fff);
padding: var(--cal-space-4, 1rem);
border-color: var(--cal-color-primary, #007bff);
}
</style>To turn a theme.json setting into a live --cal-* property, bind the resolved value onto :root (or a surface wrapper). A common pattern: fetch the public theme and set the variables, or have your SSR template write them into a <style> from the tokens context:
<!-- in a .njk template: map resolved setting values onto --cal-* properties -->
<style>
:root {
--cal-color-primary: {{ tokens.primaryColor }};
--cal-space-4: {{ tokens.spaceUnit }};
}
</style>Status: The wiring that maps a workspace's
valuesByKeyonto--cal-*custom properties is a surface-side convention, not an automatic host injection. The host resolves and serves the values (/workspace/public/theme) and passestokensinto SSR templates; binding them to specific--cal-*names is done by the theme's own CSS/JS. Confirm the exacttokenskeys your host passes for your surface before relying on specific names.
Name tokens by role, not by value
Prefer --cal-color-primary over --cal-color-blue. Role-based tokens let a workspace re-brand by changing one setting value, without every component needing to know the new colour.
Next steps
- Consume tokens from an SSR page: SSR Templates
- Consume tokens from a Vue view: Building a surface plugin
- The full
theme.jsonschema: Extension manifests - The theme endpoints: REST API