abstracttui 0.5.0

A reactive, compositor-grade terminal UI engine: fine-grained signals, layered rendering with damage tracking, images (kitty/iTerm2/sixel/mosaic), software-rasterized 3D (GLB), themes and animation.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
# 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`](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:

| 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`](captures/themes-table.md).

## Switching themes at runtime

There is exactly one app-level theme signal. Reads are reactive, writes
restyle the whole application:

```rust
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:

```rust
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](api.md#appthemeswitcher--the-theme-menu-button);
`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 `Violation`s (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:

```rust
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:

```rust
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.

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:

```rust
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
};

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:

```rust
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`](../examples/README.md).