Theming
Runtime themes: a small palette resolved into every semantic token, a cascade of layers from the app's default to a transient preview, theme files that hot-reload, THEME_* actions, and how the shell, architect-ui and view kinds follow one store.
Status: landed (2026-11-03) in
architect-theme(features/theme): the model (wasm-clean), the files (fs), the actions (actions) and the Dioxus store (dioxus). The shell (features/shell/src/shell/mod.rsShellTheme,AppShell), architect-ui’s root (features/ui/architect-ui/src/theme/runtime.rsThemeStoreProvider) and paint panes (features/workspace/src/paint/widget.rsPaintPane) follow it. The sandbox uses it (apps/ui/src/theme.rs). An app’s own namespaced tokens, expressions over other tokens, numbers, provenance andTHEME_INSPECTlanded with it (below). Extensions’ themes and tokens, the theme for Luau scripts,ImGuiwindows and process extensions landed after (“Extensions and themes”). Persisting the org, person and device layers and the Appearance page (#185) landed witharchitect-settings’appearancefeature (“Persistence and Appearance”).
An app built on architect is themeable at run time, all of it: the shell, architect-ui’s components, a GPU paint view, an extension’s view. One store per app holds the themes and the person’s choices, resolves the theme showing into one flat map of tokens, and tells everything that draws when that map changes. The model is herdr’s: a theme is a few colours, every other colour is derived from them, and a file saved on disk shows at once.
A theme#
A theme is a palette (a dozen colours or fewer), its appearance
(light or dark), maybe a parent it extends, and overrides by
token name (features/theme/src/model.rs ThemeFile, Palette).
| Palette colour | What it is | Tokens made from it |
|---|---|---|
background | the ground | background, desk, every surface step |
foreground | the text | foreground, ink, ink-2, ink-3, the muted ink |
accent | the brand | primary, ring, brand, brand-soft, accent (the wash), chart-1 |
surface | cards, popovers | card, popover (else a step off the background) |
border | edges | border, input, sidebar-border (else a mix) |
safe / warn / danger / info | status | the same names, signal-safe / -warn / -danger, destructive |
chart | up to five series | chart-1 … chart-5 |
Every palette colour is optional: a theme takes what it leaves out from
its parent, and then from the built-in default for its scheme
(features/theme/src/resolve.rs default_palette).
Resolving#
resolve(theme, registry) (features/theme/src/resolve.rs resolve)
walks the extends chain, lays the palettes over each other, derives
every token (derive), then applies the overrides, the parents’ first.
The derivation mixes in OKLab (features/theme/src/color.rs Rgba::mix),
branches on light and dark (a dark theme’s surfaces step toward the
text, a light theme’s toward white), and fixes contrast for the ink: ink-2,
ink-3 and the muted ink are moved toward white or black until they
reach 4.5:1 on every shell fill they sit on, the accent’s wash over a
pill included (with_contrast); text on the accent or the danger colour
picks the deep shade or white that reads better. It is deterministic: the
same theme and registry give the same map, byte for byte.
The result is a Resolved (features/theme/src/resolve.rs Resolved):
the token map, the scheme, the theme’s id, where each value came from,
and a revision. Read a token with get (an Option), value, color
or number. An unknown name answers magenta (#ff00ff), or NaN from
number, and is logged once per name, so a typo shows on screen instead
of quietly falling back. misses lists the names asked for and not
found since it was resolved, for tests and an inspector.
The token names (features/theme/src/resolve.rs names):
- architect-ui’s, the CSS variables of
fts-theme.css:background,foreground,card,popover,primary,secondary,muted,accent,destructive(each with-foreground),border,input,ring,chart-1…chart-5,sidebar-*,signal-*. - the status colours:
safe,warn,danger,info. - the shell’s:
desk,sheet,title-bar,pill,rule,active,ink,ink-2,ink-3,brand,brand-ink,brand-soft,scrim,shadow-lift,shadow-float,font. The shell’s accent isbrandhere, becauseaccentis architect-ui’s hover wash. - the rest:
font-sans,font-serif,font-mono,radius,spacing,letter-spacing,shadow-*. - the Appearance numbers:
text-scaleanddensity(factors of the designed size,1by default) andcontrast(0; at1the quieter inks move half way to the text and the edges a quarter of the way, before anything is evaluated, unless a layer set them itself:features/theme/src/resolve.rsmore_contrast). Read them withResolved::number.
An override may name any token, these or a new one, and its value may
name other tokens (mix($border, $foreground, 0.2)). That is how an app
adds its own tokens (below).
Choosing: the cascade#
What shows is chosen by layers, lowest first
(features/theme/src/store.rs LayerKind):
Builtin:FastTrackStudiolight and dark, following the system.Extension: loaded extensions’ own tokens’ defaults (below, under “Extensions and themes”). Unlike the others, it sits under the theme’s own overrides, so a theme file restyles an extension.App: the app’s default (Studio’s own pair).Org: the organization’s default.Person: the person’s, on every device.Device: this device’s.Workspace: a workspace’s or screenset’s look.Pick: an explicit pick (a picker,THEME_SET).Preview: a transient preview (THEME_PREVIEW).
Each layer may set a choice, an appearance and token overrides
(Layer). The highest layer that sets a choice wins, and the same goes
for appearance. Overrides merge, the higher over the lower. A choice is
ThemeChoice::One(id) (always that theme) or ThemeChoice::Pair { light, dark }. Appearance is System, Light or Dark. System follows the
OS’s scheme, which the host tells the store (ThemeStore::set_system).
Picking a theme (ThemeStore::pick) replaces only the half of the pair
that matches its own lightness: picking a dark theme keeps the light one.
A person’s choices live in layers, never in theme files: the files are
authored, the picks are settings. The org’s, the person’s and the
device’s layers are kept through architect-settings (below,
“Persistence and Appearance”); the others are the app’s own (App,
Workspace) or last only while it runs (Pick, Preview).
The store#
ThemeStore (features/theme/src/store.rs ThemeStore) is one per app,
Send and Sync, and its clones share it. Every write (set, update,
pick, preview, set_token, set_system, set_registry,
insert_theme, remove_theme, reload) composes the layers, resolves
again, and returns false when nothing that shows changed. On a real
change it bumps the revision and calls each subscriber once, on the
thread that made the change.
A worker or a render thread reads it without waiting on anyone:
revision is an atomic, cheap enough to poll every frame, and current
hands out the whole Resolved (an Arc), never a half-written one. Poll
the revision, and take current only when it moved.
A consumer keeps a typed slice and compares it, so it redraws only when
its own part changes (herdr’s Vt::set_theme): implement FromResolved
and hold a Projected<T>. Its update returns true only when T
differs, and its revision says which theme it was cut from (a recorded
scene stamps it).
Persistence and Appearance#
Three layers are settings (architect-settings, feature appearance;
features/settings/src/appearance.rs), kept as the
settings page’s scopes are:
| Layer | Kept in | Where |
|---|---|---|
Org | the org’s OrgSettings defaults (and locks) | the org’s settings, from its server |
Person | the Synced scope | the person’s synced settings document (DocValues, every device) |
Device | the Device scope | a device file of its own (DeviceValues::open_named(app, "appearance")) |
A layer is three kinds of key: appearance.theme (one theme’s id, or
light,dark), appearance.mode (system, light, dark), and
appearance.token.<name> for each token it overrides
(appearance.token.density). values_of and layer_of turn one into the
other. A key the org locked is read from the org in every layer.
Persist::start(store, values, delay, save) puts the three layers in the
store at start, then listens to the store’s layers
(ThemeStore::subscribe_layers, which hears a change that shows nothing
too). Once they have been still for delay, it hands each scope that
changed to save as a Saved: the scope, its appearance values, the
keys that changed. It runs on a thread of its own, and dropping it writes
what is waiting. Whoever changed a layer, save sees it: the page,
THEME_SET, THEME_TOKEN_SET, ⌘K. Persist::replace(scope, values)
takes another device’s change (the synced document’s
on_remote_change), and Persist::set_org the org’s when the window’s
org changes. The order is the cascade’s: the org’s default sits under
the person’s choice, and the device’s sits over it.
Persist::local(store, app, delay) is the whole of it for an app with no
account yet: the device’s layer in <app dir>/appearance.json, the
person’s in a synced settings document kept as <app dir>/person.loro
(the sandbox’s look, apps/ui/src/theme.rs use_lab_theme).
let persist = Persist::start(&store, &values, Duration::from_millis(250), move |s: &Saved| match s.scope {
Scope::Device => { let _ = device.save(&s.values); }
Scope::Synced => { let _ = person.save_keys(&s.values, &s.keys); }
Scope::Project => {}
});
The Appearance page. use_appearance_section() (features
appearance and ui; features/settings/src/appearance_ui.rs
AppearancePage) registers the Appearance section, under the app’s
theme store and settings provider. It reads and writes the store, so
it is the same truth THEME_* reach. From the top:
- Changes apply to: all my devices (the
Personlayer) or this device (Device). “Reset to organization default” empties both. - Mode: System, Light, Dark, live. Each card is drawn in the theme it would show.
- Theme: the
appearance.themerow, with a card per theme under it (light, then dark), drawn in its own colours (each one’sResolvedthroughThemeStore::resolve_with, the person’s overrides included). A card sets its half of the choice. - Text and spacing: text size (80 % to 140 %,
text-scale), density (compact, default, roomy:density), corners (radius), contrast (contrast), font (Inter, system, serif, monospace:fontandfont-sans).
Every row is a setting, so search finds it and a hit scrolls to its row
and pulses it, as on the standard page (setting-<key> is the row’s id).
Each row says who sets its value now (your organization, you on every
device, this device, the theme), and has a reset that takes the chosen
layer’s value away, so the one under it shows. A key the org locked is
dimmed, saying so. The page sizes its own rows by density and
text-scale. The shell’s own chrome does not read them yet.
The sandbox keeps the device’s layer in <app dir>/appearance.json and the
person’s in a synced settings document saved as <app dir>/person.loro
(it has no account to sync it with). Its made-up orgs have defaults:
Harbor & Pine’s is Nord with small corners (apps/ui/src/theme.rs
persist, org_settings).
Theme files (feature fs)#
A theme file is <id>.json in a themes folder: JSON, comments allowed,
read strictly. An unknown field, a palette value that is not a colour, a
token name that is not dot-separated lowercase-and-dashes segments, or an
expression that does not read is a Problem naming the file and the field
(themes/night.json: palette.accent: "teal-ish" is not a colour).
// themes/studio-night.json
{
"label": "Studio Night",
"appearance": "dark",
"extends": "fasttrackstudio-dark",
"palette": {
"accent": "#f59e0b",
"danger": "#ef4444",
"chart": ["#f59e0b", "#38bdf8", "#34d399", "#fbbf24", "#f472b6"]
},
"overrides": {
"radius": "0.375rem",
"arrange.item.edge": "mix($rule, $brand, 0.3)"
}
}
ThemeDirs(features/theme/src/fs.rsThemeDirs) lists the places: folders, and extensions’ files. A later place wins over an earlier one for the same id, and over a built-in theme.install_into(store)loads them and makesreload(andTHEME_RELOAD) read them again.- Last good. A file that no longer reads is reported, and the theme
it held stays as it was (
ThemeDirs::load). - Watching.
fs::watch(store, dirs)(notify) reloads after a burst of saves settles, the theme showing included. - Shipped themes.
fs::install(dir, shipped)writes an app’s own themes into the person’s folder. A missing file is written, a file that is exactly an earlier shipped version (Shipped::previous) is upgraded, and anything else is the person’s edit and is kept. - Extensions. An extension declares
[[themes]] path = "themes/x.json"(features/extensions/src/manifest.rsThemeDecl,Manifest::theme_files). The extension host puts them in the store while it is loaded when the app gives it the store (features/extensions/src/host.rsHost::with_themes; “Extensions and themes” below).ThemeDirs::extension(id, folder, files)still reads an extension’s files as one of the app’s own places, for an app with no extension host.
architect’s built-in themes are files too (features/theme/themes/,
features/theme/src/builtin.rs FILES): FastTrackStudio (the default),
and the light and dark halves of every former architect-ui preset
(Catppuccin, Nord, Dracula, Gruvbox, Solarized, Tokyo Night, Rosé Pine,
Everforest, Zen, Obsidian, tweakcn’s set). Each keeps its old look:
architect-ui’s tokens are overrides where the derivation would differ,
and the shell’s tokens are derived from its palette.
Actions (feature actions)#
actions::register(list, store) (features/theme/src/actions.rs
register) puts the store on the Action List, for ⌘K, keys, MCP and the
command line:
| id | effect | does |
|---|---|---|
THEME_LIST | read-only | every theme: id, label, appearance, source (builtin, file, memory, extension:<id>), problems |
THEME_GET | read-only | the choice, appearance, system scheme, the theme showing, revision, tokens (all, or tokens: ["brand", "ink-*"]) |
THEME_SET | mutating | the choice: one, or light / dark, and/or appearance, in a layer: the Person one (kept) when left out, device for this device only, pick for this run |
THEME_PREVIEW / THEME_PREVIEW_END | mutating | the Preview layer |
THEME_TOKEN_SET / THEME_TOKEN_RESET | mutating | an override (a literal or an expression) in a layer, the Person one when left out |
THEME_RELOAD | mutating | the files read again |
THEME_PROBLEMS | read-only | every problem, with its file (or layer) and field; an expression that does not resolve included |
THEME_INSPECT | read-only | each token, or those starting with prefix: its value, origin (derived, theme, layer), the layer and theme or layer id that set it, and its expression |
Every write answers {changed, revision}. None is destructive, since each
is undone by another, so a caller that confirms writes asks for each of
them like any other mutating action.
In a Dioxus app (feature dioxus)#
let theme = architect_theme::use_theme_store(ThemeStore::default);
// at start, after the dirs: ThemeDirs::new().folder(dir).install_into(&theme.store());
// on the OS's change (a Blitz window's WindowEvent::ThemeChanged):
theme.set_system(Scheme::Dark);
use_theme_store (features/theme/src/dioxus.rs use_theme_store)
gives the store to everything below and keeps a signal of the theme
showing. A change from any thread (an action, the watcher) arrives on the
next turn. Below it:
AppShellwith nothemeprop follows the store (ShellTheme::from_resolved).WorkspaceViewtakes the shell’s.- A pop-out window (
features/workspace/src/host/windows.rsPopoutView) is a document of its own: it shares the main window’sShellThemesignal, gets the store’sThemeHandleand the:rootsheet (RootCss) through itsBridge, and sets that sheet’s variables on its root element, so every change reaches it, not only the theme it opened with (features/workspace/tests/popout_theme.rs). ThemeStoreProvideris architect-ui’sThemeProviderwhose CSS variables are the store’s (ThemeState::from_resolved).PaintPanegives its view the whole resolved theme asThemeTokens(features/workspace/src/ctx.rsThemeTokens,From<&Resolved>) and requests a frame only when they change (Painter::set_theme). A paint view readsctx.theme.color("brand").use_themed::<T>()is any typed slice as a memo. Its readers rerun only when the slice changes.
A host without Dioxus (a REAPER extension, a process extension’s guest)
reads ThemeStore::current and subscribes.
An app’s own tokens#
architect’s tokens are semantic: ground, ink, brand, status. A domain app names its own on top of them: Studio’s arrangement items, track panel, mixer strips, MIDI notes. They live in the same resolved map, so one cascade, one inspector and one set of actions cover them, and a person or an agent can restyle any of them at run time.
Names. An app’s token is namespaced: segments of lowercase letters,
digits and -, separated by . (arrange.item.edge, mixer.meter.safe,
editor.pitch-0; features/theme/src/model.rs valid_token).
architect’s own stay one segment. Theme files, layers, Theme::from_tokens
and THEME_TOKEN_SET all take them.
Defaults that follow the palette. A value may name other tokens
(features/theme/src/expr.rs):
value := expr | literal any other CSS value is a literal, kept as is
expr := $name the final value of token `name`
| mix(arg, arg, t) two colours mixed in OKLab, t from 0 to 1
| alpha(arg, t) a colour with alpha t
arg := expr | literal a colour (#fff, rgb(…)) or a number (0.2, 20%)
A value is an expression only when the whole of it is one (it starts
with $, mix( or alpha(). Expressions are evaluated last: after
derivation and after every layer’s overrides are in, the higher over the
lower. So a later layer’s literal wins over a lower layer’s expression,
and a reference sees the final value of what it names: a person who sets
border moves every app token mixed from $border. An unknown name, a
cycle ($a → $b → $a), a value that is not a colour where one goes,
or a t outside 0 to 1 draws that token magenta and is a Problem naming
the token and the reason (the app layer’s overrides.arrange.a: $nope
names no token), in ThemeStore::problems and THEME_PROBLEMS. A token that
reads a broken one is magenta too, and says which.
An app ships its defaults as an App layer of expressions, so they follow
whatever theme the person picks, and a theme file or a person still says
the last word:
store.set(LayerKind::App, Layer::default()
.token("arrange.item.edge", "mix($border, $foreground, 0.2)")
.token("arrange.grid.bar", "alpha($ink, 0.18)")
.token("arrange.cursor.play", "$warn")
.token("arrange.item.body-alpha", "0.42"));
#[derive(PartialEq)]
struct ArrangePalette { edge: Rgba, grid_bar: Rgba, body_alpha: f32 }
impl FromResolved for ArrangePalette {
fn from_resolved(r: &Resolved) -> Self {
Self {
edge: r.color("arrange.item.edge"),
grid_bar: r.color("arrange.grid.bar"),
body_alpha: r.number("arrange.item.body-alpha"), // NaN when missing
}
}
}
let mut arrange = Projected::<ArrangePalette>::new(&store.current());
// per frame, on the render thread:
if store.revision() != arrange.revision() && arrange.update(&store.current()) {
// re-record only what reads the arrangement's slice
}
Resolved::number reads a plain number (0.42, 12), through a $ref
too. A theme’s geometry (an item’s radius, an edge’s width) is numbers in
the app’s own units.
Where a value came from. Resolved::source(name) is a TokenSource:
its origin (Derived from a palette, a Theme’s overrides, or a
Layer’s), the layer (the one that set it, or the one whose choice shows
the theme), the theme or layer id, and the expression as written when
it was one. THEME_INSPECT {prefix: "arrange."} answers the same for an
agent.
Themes built in memory. An import from another format (a REAPER
theme) or a document’s variables is Theme::from_tokens(id, scheme, tokens), checked as strictly as a file. ThemeStore::insert_theme adds
it or replaces the one of its id while the app runs, and bumps the
revision only when what shows changed. A reload of the files keeps it.
CSS. architect-ui writes only architect’s (undotted) tokens on the
root (Resolved::css_tokens), so app tokens don’t flood every document.
An app whose own CSS wants some opts in by namespace:
ThemeStoreProvider { css_namespaces: vec!["arrange".into()], … }
writes arrange.item.edge as --arrange-item-edge
(Resolved::css_namespaced, css_name). A dotted name whose dashed form
is one of architect’s (ink.2 and ink-2) is left out; architect’s wins.
Paint views read every token through ctx.theme, dotted ones included.
Keep the namespaces the app’s own (arrange., mixer., editor.), so
two apps’ tokens never collide in one map.
A view’s roles. A view that paints (an editor, an arrangement) is a
theming target like the shell: every colour it draws is a role of its own,
an app token with a default over architect’s, never a literal. The sandbox’s
Keyflow source editor is the worked example (apps/ui/src/keyflow.rs
EDITOR_TOKENS, set as the sandbox’s App layer by
apps/ui/src/theme.rs app_layer):
| Role | Default | What it colours |
|---|---|---|
editor.background | $background | the editor’s ground, edge to edge |
editor.foreground | $foreground | the text |
editor.gutter | $muted-foreground | line numbers, quiet ink |
editor.surface | $muted | inline code, raised marks |
editor.line | $border | rules and edges |
editor.caret | $brand | the caret, the IME underline |
editor.selection | alpha($brand, 0.3) | the selection |
editor.syntax.chord | $brand | roots, degrees, numerals, bass |
editor.syntax.quality | mix($brand, $foreground, 0.45) | qualities, extensions, accidentals |
editor.syntax.rhythm | $warn | durations, slashes, rests, pushes |
editor.syntax.section | $safe | sections, measure counts, separators |
editor.syntax.meta | $info | title, tempo, time and key |
editor.syntax.comment | $muted-foreground | comments |
editor.syntax.command | mix($danger, $foreground, 0.3) | commands, dynamics, repeats |
The editor reads them as a typed slice (EditorColours, use_themed)
and sets them as CSS variables on its own root: editor-view’s inputs
(--background, --foreground, --muted, …, and its --editor-caret
/ --editor-selection-bg hooks), the pane chrome’s and
--editor-syntax-*. Its stylesheet holds no colour, only those
variables: a document::Style is inserted once and never updated, so a
sheet built from the theme’s values would keep the first theme. A theme
change restyles the editor by its style alone, in a pop-out too
(apps/ui/src/keyflow.rs theme_set_restyles_the_editor). A theme
file or a person overrides any role (THEME_TOKEN_SET {name: "editor.caret", value: "$warn"}); a broken one draws magenta.
Extensions and themes#
What an owner other than the app adds is a contribution
(features/theme/src/store.rs Contribution): themes, theme files that
did not read (with their problems), and token defaults.
ThemeStore::contribute(owner, contribution) puts it in place of the
owner’s last one, as a diff; ThemeStore::withdraw(owner) takes it out.
The extension host calls both as an extension loads, reloads and unloads
(features/extensions/src/host.rs Host::with_themes,
theme_contribution); the extensions page
has the manifest’s side.
- Themes join the app’s registry:
ThemeStore::registryandThemeStore::themesinclude them,THEME_LISTsaysextension:<id>(Source::label), andpickandTHEME_SETtake them. A file that does not read keeps the owner’s last good theme of its id and is a problem. An id the app already has stays the app’s, and the clash is a problem. A reload of the app’s own files (ThemeStore::reload) leaves contributions as they are. - Tokens are the
Extensionlayer. They sit under the theme’s overrides (a theme file may restyleclicker.face) and under every other layer, and their expressions see the final palette like any other. Two owners setting one token: the first (by owner id) keeps it, and the other’s is a problem. - The revision moves only when what shows changes: a contributed
theme nobody shows changes
THEME_LIST, not the revision. A contributed theme that shows and is withdrawn falls back to the built-in default, with the problem said.
Every GUI an extension draws follows the store: a process view’s document
through :root CSS variables, a process itself through the theme sent
as data, a Luau script through theme
(features/scripting/src/theme.rs ThemeModule, a frozen snapshot per
revision), and an ImGui window through its style
(features/canvas/src/imgui.rs Style::from_theme: imgui.* tokens
first, else derived from the shell’s). The extensions page has the table.
Linked from
- The Architect tourA guided tour of every system, in the sandbox: one chapter each, over the live app, built from a guide scenario.
- ArchitectureThe patterns the architect template is showcasing.
- Extensions and scriptsAdding to an app at runtime, without rebuilding it: data-only extensions, WASM components over a versioned WIT world drawn from the Action List, Luau scripts for macros, out-of-process plugins on a desk — with capabilities, and views as frame documents.