Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 than border.

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 — shadow pre-composited over bg at theme build, so it is opaque. This is what Block::shadow paints: widgets never do color math themselves.

Chart ramp

  • chart[0..8] — eight hue-separated series colors, all legible on bg. 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 against surface_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:

familythemes
Abstract originalsabstract-dark, abstract-light, abstract-aurora, abstract-paper, abstract-ember, abstract-midnight, abstract-dawn
Observerobserver-night
Catppuccincatppuccin-mocha, catppuccin-macchiato, catppuccin-frappe, catppuccin-latte
Rosé Pinerose-pine, rose-pine-moon, rose-pine-dawn
Tokyo Nighttokyo-night
Nordnord
Oneone-dark, one-light
Draculadracula
Monokaimonokai
Gruvboxgruvbox
Solarizedsolarized-dark, solarized-light
Everforesteverforest-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:

pairfloor
text / grounds4.5:1 (7:1 is the target, reported not enforced)
text_muted / bg3.0:1
text_faint / bg2.5:1 (the deliberate decoration tier)
accent, accent_alt, semantics, link / bg3.0:1
selection_fg / selection_bg4.5:1
border / bg1.5:1
border_focus / bg2.0:1
cursor / bg3.0:1
syntax inks / surface_raised4.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 on Registration::warnings as 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.

  1. The selection pair says “this is the thing keys act on” (list rows, table rows, selected text).
  2. A border_focus stroke says “this pane owns the keyboard” (bordered widgets and panes).
  3. accent ink 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_focus stroke 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_ground exist 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.