# 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:
| 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:
| `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.
#### 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:
```rust
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:
```rust
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:
```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
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:
```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).