Theming
AbstractTUI widgets never name colors — they name roles. Every drawable surface resolves a semantic token against the active theme, so an entire application restyles from a single switch, and every built-in palette is held to measured, test-enforced contrast floors.
This page covers the token model, the 26 built-in themes, runtime
switching, the contrast guarantees, registering your own themes, and the
styling conventions widget authors should follow. The complete hex value
of every token in every theme lives in the generated reference:
captures/themes-table.md.
The 36-token semantic model
A theme’s palette is a TokenSet: 36 resolved Rgba values, one per
TokenId. The tokens are grouped by the job they do, not by hue:
Grounds — the layered backgrounds an app is built on.
bg— the application field; the deepest layer, fills the terminal.surface— panel and card ground.surface_raised— raised chrome: popovers, menus, active tabs, chips, and the declared ground for code blocks.overlay— the modal scrim; deliberately carries alpha for the compositor to blend over whatever it covers.
Text tiers — three levels of copy, each with its own contrast floor.
text— body copy.text_muted— secondary copy: labels, descriptions, timestamps.text_faint— the decoration tier: placeholders, disabled glyphs, watermark art. Deliberately below the accessible-text grade; never used for information-carrying text.
Strokes
border— hairline strokes: pane separators, boxes, rules.border_focus— the focus-ring ink; must read stronger thanborder.
Voice — where the theme’s personality lives.
accent— the theme’s identity color: primary actions, active states, brand marks. One accent per screen region works best.accent_alt— a curated companion accent (gradients, secondary emphasis).link— hyperlink ink (the underline comes from the style attribute, not the color).
Semantic states
ok,warn,error,info— success, caution, failure, and informational marks.
Selection pair
selection_bg/selection_fg— always used together, never mixed with other grounds. The pair means “this is the thing keys act on”.
Cursor and shadow
cursor— the caret/block-cursor ink when the engine draws its own.shadow— a dim multiplier for cell-space drop shadows (carries alpha).shadow_ground—shadowpre-composited overbgat theme build, so it is opaque. This is whatBlock::shadowpaints: widgets never do color math themselves.
Chart ramp
chart[0..8]— eight hue-separated series colors, all legible onbg. Chart series pick a slot, never a color: slots 0–4 follow the accent/info/ok/warn/error family and slots 5–7 are curated companions, with a separation pass that keeps every series tellable-apart even in palettes where two source colors coincide.TokenSet::chart(i)clamps out-of-range indexes to the last slot, so indexing from arbitrary data can never panic.
Syntax family
syntax_keyword,syntax_string,syntax_number,syntax_type,syntax_func,syntax_punct,syntax_comment— code inks derived per theme from the audited accent/semantic family and contrast-guarded againstsurface_raised(the code ground). Comments deliberately recede at the 3:1 class; the other inks target 4.5:1.
By-id access exists for tooling (theme editors, debug overlays, config
files): TokenId::ALL (all 36, stable order), tokens.get(id),
tokens.set(id, rgba), TokenId::from_name("accent"), and
tokens.iter() for (id, color) pairs.
The 26 built-in themes
theme::themes() returns the built-in registry; theme::get(id) looks a
theme up by id (also honoring the "dark"/"light" aliases for the house
pair); theme::resolve(id) falls back to the default for unknown ids and
returns a labeled warning string alongside; theme::default_theme() is
abstract-dark. theme::list() yields (id, label, dark) for every
visible theme, built-ins first, then runtime registrations — the picker
surface.
The family:
| family | themes |
|---|---|
| Abstract originals | abstract-dark, abstract-light, abstract-aurora, abstract-paper, abstract-ember, abstract-midnight, abstract-dawn |
| Observer | observer-night |
| Catppuccin | catppuccin-mocha, catppuccin-macchiato, catppuccin-frappe, catppuccin-latte |
| Rosé Pine | rose-pine, rose-pine-moon, rose-pine-dawn |
| Tokyo Night | tokyo-night |
| Nord | nord |
| One | one-dark, one-light |
| Dracula | dracula |
| Monokai | monokai |
| Gruvbox | gruvbox |
| Solarized | solarized-dark, solarized-light |
| Everforest | everforest-dark, everforest-light |
The ported families keep every hex value their upstream palette defines, verbatim. Tokens the upstream source does not define (borders, selection tints, focus rings, the chart ramp, the syntax family) are derived by documented, contrast-guarded rules — for example, borders composite the theme’s own text ink over the ground so gruvbox gets warm cream strokes rather than clinical gray.
Every token value of every theme, generated straight from the registry:
captures/themes-table.md.
Switching themes at runtime
There is exactly one app-level theme signal. Reads are reactive, writes restyle the whole application:
#![allow(unused)]
fn main() {
use abstracttui::prelude::*;
// Inside a component: read reactively. Any dyn_view that reads the
// signal rebuilds with fresh tokens when the theme changes.
fn header(cx: Scope) -> View {
let theme = use_theme(cx);
dyn_view(LayoutStyle::line(1), move || {
let t = theme.get(); // &'static Theme: t.tokens, t.is_dark()
text(format!("{} ({})", t.label, if t.is_dark() { "dark" } else { "light" }))
})
}
// Anywhere: switch. Returns false (and changes nothing) for unknown ids.
set_theme_by_id("nord");
// Or with a handle from the registry / a runtime registration:
set_theme(abstracttui::theme::get("catppuccin-mocha").unwrap());
}
Mounting an app installs a watcher on the signal that damages the whole
tree on switch, so even static text repaints, while regions that read the
signal inside dyn_view re-render fine-grained. Theme::is_dark() is the
supported way to make polarity-conditional choices (shadow strength, image
dithering, artwork variants).
The shipped examples honor ABSTRACTTUI_THEME=<id> as a startup
convention — set_theme_by_id at boot is all it takes to adopt the same
convention in your app.
Theme modes & the switcher
Polarity is a first-class vocabulary: ThemeMode::{Dark, Light} is a
closed enum (the decisive-ground invariant leaves no room for a third
value), theme.mode() derives it from the audited dark flag — one
source, never a second luminance threshold — and
theme::themes_by_mode(mode) lists every visible theme of one mode in
the same curated order list() presents: built-ins in registry order
(the house palette of each mode first), runtime registrations trailing.
The first theme of each mode is guaranteed to be the house palette —
pickers and the toggle default rely on that order.
app::toggle_mode() flips dark ↔ light while keeping the user’s theme
choice per mode: set_theme (the one signal-write choke point)
records every switch as its mode’s last-used theme, so
nord → toggle → abstract-light → toggle → nord round-trips. A mode
never visited on this thread falls back to its house palette.
ThemeSwitcher is the drop-in control — one line in any app’s chrome:
#![allow(unused)]
fn main() {
use abstracttui::prelude::*;
// In your header / tab bar / footer row:
let menu = ThemeSwitcher::new().view(cx); // ☾/☼ button; opens the grouped menu
let flip = ThemeSwitcher::toggle().view(cx); // same chip; one click flips the mode
}
Both faces are a 5-column control: a 3x1 chip — the glyph with one cell
of padding each side, so the hit area is the visible shape — plus one cell
of margin each side, which is what keeps it off the terminal edge when it
is mounted last in a right-aligned chrome row. The chip carries a
surface_raised ground in every state including idle, so it reads as
pressable; hover and focus change the ink, not whether there is a ground.
If your chrome gives the switcher a fixed-width slot, give it 5 columns.
ThemeSwitcher::layout() replaces the geometry wholesale when you want
something else — the face fills whatever box it is given and centres the
glyph in it.
The menu face opens an owned anchored popup (modal, above the whole
live stack — it layers and anchors correctly inside a Modal or
Drawer) listing every visible theme grouped Dark then Light,
group headers as skipped rows, the active theme marked ●. It rides
the select-family machinery: Up/Down/Home/End/PageUp/PageDown move,
type-ahead jumps by label prefix (a repeated letter cycles its
matches), Enter or a click commits, Escape restores the pre-open theme,
and a press outside keeps what you previewed. Movement previews the
theme live — the Select::commit_on_move semantic, which exists
for exactly this control — and the menu re-resolves its own tokens per
step, so the list you are browsing is rendered in the theme it names.
on_change(|theme| ...) fires once per switch that sticks (commit or
outside-press with a changed theme; never on preview steps, never on
Escape) — the hook for persisting a theme preference.
The glyph: the button shows the current mode — ☾ on dark themes,
☼ on light ones. A static ◐ would spend the cell on decoration; a
mode-reflecting glyph makes the one cell double as the app’s polarity
indicator, while hover/focus affordances and the a11y label (“theme”,
value = the active theme’s label) carry the button-ness and the action.
☾ U+263E and ☼ U+263C are East-Asian-neutral (single-width in every
convention) and absent from Unicode emoji-data — unlike ☀ U+2600,
which some terminal stacks promote to a double-width emoji glyph.
Closed, the switcher is zero-idle: no layers, no timers — it re-renders
only when the theme signal or its own hover/focus state is written. The
popup’s subscriptions live on a per-open scope and die at dismissal.
The full behavior reference (popup keys, on_change semantics,
accessibility roles) is
api.md § ThemeSwitcher;
examples/themes.rs shows both faces in a toolbar and
examples/shell.rs the footer placement.
Contrast guarantees
Every registered theme must pass theme::audit(id, &tokens) — a WCAG
contrast audit that measures each documented pair with
theme::contrast_ratio(a, b) and returns structured Violations (theme,
rule, token, measured value, required floor). The built-in family passes
with zero violations as a test invariant; the floors are public in
theme::contrast::floors so your tooling audits against the same numbers:
| pair | floor |
|---|---|
text / grounds | 4.5:1 (7:1 is the target, reported not enforced) |
text_muted / bg | 3.0:1 |
text_faint / bg | 2.5:1 (the deliberate decoration tier) |
accent, accent_alt, semantics, link / bg | 3.0:1 |
selection_fg / selection_bg | 4.5:1 |
border / bg | 1.5:1 |
border_focus / bg | 2.0:1 |
cursor / bg | 3.0:1 |
syntax inks / surface_raised | 4.5:1 (comments 3.0:1) |
Syntax floors are additionally capped at what the theme’s own body text achieves on the code ground — code can never be more readable than text, which matters for deliberately soft palettes.
Beyond the pairs, grounds must be decisive: a theme’s measured ground
luminance must agree with its declared dark flag by a margin
(|L(bg) − 0.5| ≥ 0.15). A mid-gray ground makes both text polarities
marginal and breaks everything downstream that groups by polarity.
Audit exceptions are named per (theme, rule) pair, never blanket, and a
stale exception fails the test suite. Exactly one exists:
everforest-light’s text on raised chrome measures ~4.25:1 — both values
are verbatim upstream colors, the rule is stricter than the mandated
text/ground floor, and 4.25:1 still clears WCAG AA-large.
Text on a ground the theme never saw
The audit covers the theme’s own grounds. An application that paints a
ground of its own — a custom card fill, a panel colour from a client’s
brand — is outside it: across the built-in registry, body text on a
mid-dark application panel falls below the 4.5:1 floor in 8 of the 26
themes, and on a bright one in 19 (solarized-light reaches 1.01, text
the same colour as the panel beneath it).
theme::contrast::ink_on picks the theme’s most readable authored
ink for any ground and tells you what it achieved:
#![allow(unused)]
fn main() {
use abstracttui::theme::contrast::{floors, ink_on};
let ink = ink_on(&t, my_panel); // Ink { color, token, contrast }
let fg = if ink.contrast >= floors::TEXT { ink.color } else { warn_and_pick() };
}
It clears the text floor on 51 of the 52 theme/panel combinations
measured. The ratio comes back rather than being swallowed because of the
52nd: a deliberately soft palette can hold no ink dark enough for a bright
panel (everforest-light tops out at 3.49:1), and returning a bare colour
would hand you unreadable text that looks like a considered choice.
This is a door, not a default — widgets ink themselves from the theme’s
own tokens, and nothing in the paint path consults ink_on for you.
Grounds at 256 colours
The audit measures truecolor. Quantisation to the xterm-256 cube happens downstream at emit, and two grounds a theme authored a step apart can land on the same palette entry: measured across the registry, 15 of 260 ground pairs collapse, in 15 of the 26 themes — panel elevation rendering as flat.
The engine handles the theme’s own grounds for you: at Xterm256 the
driver assigns each ground its own palette entry (quantize_set_256
decides the assignment, Presenter::set_palette_assignment installs it),
re-deriving only when the theme or the colour depth changes. Truecolor
output is unchanged, and so is a 256-colour app whose grounds do not
collide.
Grounds your app mints are declared, because the separator can only keep apart what it is handed:
#![allow(unused)]
fn main() {
App::new(root).run_with(RunConfig {
extra_grounds: vec![my_panel, my_folded_panel],
..Default::default()
})
}
Driver::set_extra_grounds is the same thing for a hand-driven loop.
Two limits worth knowing. This is 256 only: at Ansi16 the collapse
still happens (98 of 260 pairs), because the 16 system registers are
user-themable and no build-time decision can know what index 4 renders as.
And separation of a foreground from its own background still wins over the
ground assignment — text reading as its own background is the worse defect.
When separating every ground is the wrong answer
Giving every ground its own entry is right for a theme whose grounds are drawn apart, and wrong for one whose grounds are not. Seven built-in ground pairs come out of the assignment more separated at 256 colours than they are at truecolor — an edge the theme author never drew. Merging “close enough” grounds does not fix it: the same pair can be one an author left indistinct and one whose elevation the plain lookup collapses, so no rule over the two colour values is right about it.
So the intent is declared, not inferred — per pair, by the theme. You state it once on the candidate and the driver does the rest:
#![allow(unused)]
fn main() {
use abstracttui::render::color::PairIntent;
use abstracttui::theme::{register, RegisterMode, ThemeCandidate, TokenId};
let candidate = ThemeCandidate {
id: "acme".into(),
label: "Acme".into(),
dark: true,
tokens,
// "These two grounds read as one surface — where the 256 palette
// has already forced them together, leave them there."
ground_intent: vec![(
TokenId::SurfaceRaised,
TokenId::SelectionBg,
PairIntent::Same,
)],
};
let theme = register(candidate, RegisterMode::Strict)?.theme;
}
That is the whole integration: Driver::sync_palette_assignment reads
Theme::ground_intent off whichever theme is live, resolves it against
TokenSet::grounds, and installs the resulting assignment. Nothing to call
per frame and no assignment to build yourself.
Pairs are named in tokens, not indices — an index would silently mean a
different pair the day the ground list reorders. Both tokens must be opaque
grounds; register refuses anything else with RegisterError::NotAGround,
in both modes, because a declaration over border protects nothing while
reading as though it does.
Each pair has three states: Same, Distinct, and undeclared — the last
being the absence of an entry, not a value you can write.
Distinct changes no bytes: it is what silence already does. What it buys
is a claim that can be checked against the artifact. Declare two grounds
Distinct and then author them at the same hex and you have contradicted
yourself — register reports it (refusing in Strict, labelling in
Labeled), where before the two would quietly share an entry and you would
go on believing the edge was protected. theme::contrast:: declaration_contradictions is the same check, callable directly.
The mirror case is deliberately not reported: Same over two colours a
mile apart asks for a merge that can never happen, because intent only ever
releases a merge at a collision. That is inert, not wrong, and you may
reasonably declare it ahead of a re-tint of your own palette.
render::color::quantize_set_256_into_with is the layer underneath, if you
are building an assignment yourself rather than going through a theme:
#![allow(unused)]
fn main() {
use abstracttui::render::color::{quantize_set_256_into_with, GroundIntent, PairIntent};
let mut idx = vec![0u8; grounds.len()];
quantize_set_256_into_with(&grounds, GroundIntent::UNDECLARED, &mut idx);
quantize_set_256_into_with(
&grounds,
GroundIntent::new(&[(0, 2, PairIntent::Same)]),
&mut idx,
);
}
Opting in cannot flip the default. A pair you did not name behaves
exactly as if you had no declaration at all, so naming one pair never moves
another. An empty declaration, and one listing nothing but Distinct, are
both byte-for-byte identical to UNDECLARED across all 26 built-in themes.
There is no way to spell “merge everything I did not mention” — a theme
should not be able to give away its elevation by omission.
Intent releases a merge; it never creates one. Same lets two grounds
share an entry when they collide. It never moves a ground that already
had an entry of its own: forcing a collapse the palette did not ask for
would invent the mirror of the defect this exists to fix.
Across the registry, declaring all ten pairs Same — the maximal opt-in —
closes three of the seven invented edges, at the cost of re-collapsing 15
pairs the assignment keeps apart (one of them, catppuccin-frappe bg/surface, above the 1.10 report floor). That is the upper bound on both
sides, and reaching it takes ten deliberate statements.
The other four invented edges are not the assignment’s: those grounds have different nearest entries already, so nothing displaced them and no declaration can reach them. They are a property of the xterm-256 lookup itself, survive with the whole set policy removed, and remain open.
Every built-in theme declares nothing, and that is a decision, not an oversight: none of the 26 authors has been asked, so elevation wins for all of them and the shipped bytes are unchanged.
extra_grounds cannot carry intent, and that is also a decision. The
declaration covers the theme’s five grounds only; consumer grounds join the
set after them and no declaration names them. Stated rather than left to be
discovered, because the mechanism above would otherwise imply it. The
reasoning: a theme has five named roles whose colours may coincide, and a
widget picks a role rather than a colour — so two roles landing on one hex
is normal and the intent question is real. An app passes raw colours. If two
of yours are the same colour they already share an entry (byte-identical
colours always do); if they are different colours you chose deliberately,
keeping them apart is what you asked for. And a panel meant to read as one
surface with surface should be t.surface, not a minted near-match.
If you have a case this reasoning misses, it is worth raising rather than
working around.
cargo run --example grounds walks the registry and shows this live — press
i on solarized-dark, one-light or abstract-midnight and the app
switches to a registered variant that declares the colliding pair, with the
two grounds arriving as one colour. On the other 23 themes it says plainly
that there is nothing to release.
To ask the question about your own theme, theme::contrast::ground_overlaps
returns a GroundOverlap for every pair of opaque grounds measuring below
the floor you pass (floors::GROUND_SEPARATION_REPORT, 1.10, is the
threshold the engine’s own measurements report at). It is a report, not a
rule: drawing two grounds alike can be deliberate. TokenSet::grounds() is
the list it walks.
Registering a custom theme
theme::register(candidate, mode) is the runtime door:
#![allow(unused)]
fn main() {
use abstracttui::theme::{register, RegisterMode, ThemeCandidate, TokenSet};
let candidate = ThemeCandidate {
id: "my-theme".into(), // kebab-case: [a-z0-9-_], non-empty
label: "My Theme".into(),
dark: true, // audited against measured luminance
tokens: my_tokens, // a full TokenSet
ground_intent: vec![], // silence — see "When separating every
// ground is the wrong answer"
};
match register(candidate, RegisterMode::Strict) {
Ok(reg) => set_theme(reg.theme),
Err(e) => eprintln!("{e}"), // structured violations, not a boolean
}
}
The audit always runs; the mode declares what happens to findings:
RegisterMode::Strict— findings refuse the registration. The error carries the structured violation list plus role-hygiene findings (RegisterError::Rejected { violations, hygiene }), so a theme file can be treated as code: fix what the audit names.RegisterMode::Labeled— the theme registers anyway, and every finding comes back onRegistration::warningsas a#FALLBACK:-prefixed line. Use this for user-supplied themes where refusing would strand the user — and surface the warnings, never swallow them.
Identity problems refuse in both modes: an empty or malformed id is
RegisterError::InvalidId, and shadowing a built-in id or one of its
aliases is RegisterError::ReservedId — a user theme silently replacing
nord would be spoofing, not customization.
Accepted registrations are &'static (leaked once, stable for the app’s
life, ~300 bytes each), visible to theme::get, theme::list, and theme
cycling. Re-registering an id replaces it for future lookups while old
handles stay valid; re-registering a byte-identical candidate returns the
existing handle without allocating.
Deriving tokens from your house colors
You rarely design 36 colors by hand, and you should not reimplement the
transform that avoids it. theme::Palette is the same seed input the
built-in table uses — twelve authored colors, as owned strings, because a
palette read from a config file at runtime is not &'static — and
Palette::derive() runs the engine’s own derivation over them:
#![allow(unused)]
fn main() {
use abstracttui::theme::{register, set_theme, Palette, RegisterMode};
let mut palette = Palette::new("acme", "Acme", /* dark */ true);
palette.bg = "#101014".into();
palette.surface = "#16161d".into();
palette.surface_raised = "#1e1e29".into();
palette.text = "#e6e6ef".into();
palette.text_muted = "#a3a3b8".into();
palette.text_faint = "#6b6b80".into();
palette.accent = "#ff6188".into();
palette.accent_alt = "#a29bfe".into();
palette.ok = "#7ee787".into();
palette.warn = "#f0c85a".into();
palette.error = "#ff6b6b".into();
palette.info = "#6ec7ff".into();
let candidate = palette.derive()?; // 12 colors -> a full TokenSet
let reg = register(candidate, RegisterMode::Strict)?; // the audit above judges it
set_theme(reg.theme);
}
Hex accepts #rgb, #rrggbb and #rrggbbaa, with or without the #.
derive is the transform and nothing else — it neither audits nor
validates the id, so register remains the single place a theme is judged
and there is no second audit to keep in step. Malformed input comes back
as a PaletteError naming every bad field, so a config with three
typos costs one round trip.
All twelve are required, deliberately: there is no “fill the rest from
bg and accent” shortcut. The colors an app is most likely to be
missing are the semantic inks — accent_alt, ok, warn, error,
info — and which green means “resolved” in a product is a decision, not
a shade.
Going through this door rather than around it is what keeps your theme in
step with the engine: the built-in table and Palette parse into the same
seed type and run the same derivation, pinned byte-for-byte across all 26
built-ins by a test, so a change to a contrast floor reaches your palette
too.
The derivation primitives
theme::derive exposes the steps that transform uses, for tooling that
needs a single value rather than a whole theme:
mix(a, b, t),lighten(c, t),darken(c, t)— sRGB-space mixes (the perceptual limits are documented at the definitions; these are for small nudges within one theme, not long decorative gradients).mix_until_contrast(base, ink, anchor, t0, step, floor)— walk a mix upward until it clears a contrast floor against its ground (how borders are derived).tint_until_readable(base, tint, fg, t0, step, t_min, floor)— walk a tint downward until the foreground stays readable on it (how selection backgrounds are derived).
Use these to compose the twelve authored colors a Palette wants — a
surface from a lighten/darken step off your ground, say — rather than
to rebuild the twelve-to-thirty-six transform Palette::derive already
runs. Building a whole TokenSet by hand is supported (register accepts
any ThemeCandidate), but a hand-rolled transform drifts from the
engine’s the next time a floor moves, and nothing will report it.
Design guidance for widget authors
Widgets built on AbstractTUI should speak tokens and nothing else — the engine’s own widget sources are lint-checked for raw hex. The conventions that keep a screen coherent:
Three focus/selection mechanisms, in priority order.
- The selection pair says “this is the thing keys act on” (list rows, table rows, selected text).
- A
border_focusstroke says “this pane owns the keyboard” (bordered widgets and panes). accentink is hover garnish.
Never render two selection pairs at different strengths — one pair, one meaning.
The state table.
- Normal: content inks on their ground.
- Hover: recolors the actionable ink to
accent— decoration only; a hover state must never carry information focus does not. - Focus:
border_focusstroke on bordered widgets; the selection pair on borderless ones. - Disabled:
text_faint, and out of the focus order entirely — a focused-disabled widget cannot exist. - Selected: persists when the pane is unfocused; the owning pane’s stroke says where keys go.
Hard rules.
- Tokens only; no color arithmetic in widgets — pre-composited tokens like
shadow_groundexist precisely so widgets never blend. - Placeholders (
text_faint) disappear on first input. - Underline-as-affordance is drawn as cells, never as a text attribute alone, so it survives 16-color terminals.
- Every widget draws inside its rect; long spans clip rather than leak.
For a live rendering of all of this, run the widgets and gallery
examples (cargo run --example gallery), and see
../examples/README.md.