Files
improvise/context/adr/004-faces-and-themes.md
T
Edward Langley 85c273952f docs(adr): keep the Rgb arm — the user ruled on the one choice left open
"Keep full color support in themes" (review on 6a4c7dc). The draft left
dropping the Rgb arm as a live choice, because clause 4's protection against
losing a user's terminal theme is a property of the SHIPPED THEME rather than
of the TYPE — weaker than the unconstructible-bad-state shape this repo
usually reaches for. The answer settles it: the arm stays.

Recorded as a REJECTED ALTERNATIVE with its reasoning rather than deleted,
and as the sixth entry in "What the user decided". The substance of the
change is small; what it fixes is that the weakness is now a CHOSEN cost
rather than an unexamined one, which is the difference between a trade-off
and an oversight.

One distinction stated explicitly because it is the easy misreading: "full
colour support in themes" is about what the FORMAT MAY EXPRESS; the
shipped-theme rule is about what IMPROVISE'S OWN DEFAULT SAYS. They are
independent and clause 4 keeps both. If the intent was also that the shipped
default use absolute colour, that is a different change and would reopen the
regression clause 4 exists to prevent — so it is flagged rather than
inferred, cheap to correct either way.

Sibling sweep, since this touched a clause, an alternative and a deferral:
the status line, the intro and the section intro all claimed five answers
and now say five plus the one they left standing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 13:13:03 -07:00

51 KiB
Raw Blame History

ADR 004 — Faces: appearance as UI-agnostic data

Status: accepted (2026-09-22) · Decided by: the user, who answered all five open questions on PR #122 and then ruled on the one choice they left · Direction from: the user (improvise-nn4s) · Governed by: context/design-principles.md §5

"I think we want to iterate towards having a way to theme the app like emacs does: faces, etc. described in a UI-agnostic format that the different frontends can apply (there's probably a swift version down the road and maybe Qt, etc.)"

Iterate towards. This ADR fixes a vocabulary and a set of constraints so that incremental work cannot paint the repo into a corner. It deliberately does not specify a finished theming system, and what is still deferred is named at the end.

It was written with five questions left open on the grounds that they were the user's rather than the ADR's, and the user answered all five on PR #122 — then ruled on the one choice those answers left standing. Their answers are folded into the clauses below and recorded, with the reasoning, in What the user decided. Two of them changed the decision rather than merely confirming it: reverse is in the portable set, and the file format is no longer deferred.

Context

What the tree actually contains

Measured at f0687b1, and stated as counts because the ticket's figures were re-derived rather than repeated:

124 lines carrying 125 ratatui Color:: literals, across exactly 10 files, all under src/. Not one lives in a shared crate — improvise-core, improvise-protocol, improvise-command and improvise-wasm-client name no ratatui type. So forbidden(improvise_core, ui) is holding, and the problem this ADR addresses is not a layering breach. It is that the 125 literals are the only place a large part of the app's appearance is decided, and that place is one front end.

The distribution is itself informative. DarkGray is 33 of the 125 — a quarter of the app's colouring is one role, "recede". After it: Yellow 19, Cyan 18, Green 15, Black 12, Blue 11, Magenta 7, White 3, Gray 3, Red 2, Indexed 2. Nine named ANSI colours, two indexed greys, and nothing else. The whole TUI is painted from a palette of eleven, and only the two Indexed values (235 for the minibuffer strip, 237 for the cursor row) are not a name the user's terminal gets to define.

Attributes are smaller still: BOLD 25, UNDERLINED 3, ITALIC 1. Ratatui offers nine modifiers and the app uses three.

The pattern already works, three times, and is wrong once

context/code/tui.md already carries a section called "Semantic-to-terminal colour, and the one that is wrong", and invariant(semantic_palette_at_the_core_boundary) already states the rule: a shared type that needs a colour names a semantic enum, and the render layer owns the one mapping.

where shape verdict
AxisColor → draw.rs::axis_fg 5 arms named for roles (Row, Column, Page, Filter, Inactive) the pattern working (improvise-rml)
WizardFieldKind → import_wizard_ui.rs::field_kind_fg 4 arms, the enum is a domain type the pattern working
PanelContent::active_color() 3 arms (Formula=Yellow, Category=Cyan, View=Blue) the pattern working, but private to the TUI — never lifted
AppMode → draw.rs::mode_style matches the mode enum directly; there is no ModeColor the gap improvise-zx8t left open on purpose
MinibufColor → draw.rs::minibuf_fg 3 arms named Green, Cyan, Yellow; the map is the identity the one that is wrong

MinibufColor's doc comment says "Semantic palette only". The type makes that false: there is nothing to theme, because the name is the colour.

The cost of that is already paid, in the browser, in hex

web/app.js sets el.minibuffer.className = mb.color.toLowerCase(), and web/style.css answers with six hand-written hex values — #6aa84f/#4f7f3c, #3f9ba3/#2f7980, #b8952f/#8a6f21, plus three more under prefers-color-scheme: dark. Its comment says they are "resolved to a terminal-descended colour". Every other colour in that file — 77 uses — goes through a var(--…) custom property. The minibuffer's are outside the token system for exactly one reason: the wire handed the page a colour, and a page cannot ask its own palette for "cyan".

That is not a hypothetical. It is the regression this ADR exists to stop, already measurable in one file.

The fork

PR #120 gave the browser a real palette: --bg, --surface, --raised, --fg, --dim, --line, --line-strong, --accent, --accent-soft, --accent-ink, --selected, --error, in light and dark. That palette is deliberately the landing page's, so the site and the app read as one product.

Note what those names are: a surface/ink/accent token set, not a list of app roles. It decomposes along a different axis from the TUI's 125 literals, and nothing relates them. So the two front ends are at a fork: either appearance becomes shared vocabulary that each end maps to its own palette, or PR #120's tokens and the TUI's literals drift permanently and one document looks like two unrelated products. improvise-pcje is already open on the browser dropping every AxisColor.

Decision

Appearance is named by faces — roles, not colours. A face carries a small portable attribute set. A theme binds faces to values, resolves once in shared code, and is client-local. A front end translates resolved attributes to its own framework and ignores what it cannot render.

Nine clauses. Each is a place this gets decided by accident otherwise.

1. A face names a role. A colour is never a role.

AxisColor::Row is a face name. MinibufColor::Green is not. The test is whether the name survives a theme that re-colours everything: "the row axis" does, "green" does not.

This is the same move improvise-k099 already made for text — AppMode::label and hint_text left the wire enum and became Hint { keys, command, caption }, a semantic value a localized client resolves. invariant(a_ui_string_is_keyed_by_a_command_never_by_a_key) is that rule for strings; this ADR is the same rule for pixels.

2. Faces are named by derivation where a domain enum exists.

Four of the five families above are indexed by an enum the app already has. A face per arm means adding an Axis variant adds a face by construction rather than by remembering to edit a list — the same discipline mode_style's exhaustive match already buys (improvise-zx8t), and the reason the vocabulary does not become a 124-entry flat file.

The face set is therefore mostly generated, and only the cross-cutting base faces are written out by hand.

3. Faces inherit, and inheritance resolves exactly once, in shared code.

Emacs makes a face system tractable by deriving most faces from a few; without it this is 124 independent decisions. A face names a parent and overrides some attributes; Theme::resolve(Face) -> ResolvedFace walks the chain and returns a struct in which every attribute is concrete.

A front end never walks the chain. Three front ends resolving inheritance independently is three derivations of one answer — improvise-edp is the worked example (the category tree, flattened twice, two different ways) and improvise-anr9 is the second (the grid frame). design-principles §5 states the general rule and this is a direct application of it: the decision is shared, and only the last translation is local.

The cheapest thing this buys is visible in the call sites today. Twelve sites spell fg(Color::Black).bg(<accent>) by hand — a hand-rolled reverse video whose ink is hardcoded Black. Under inheritance that is one rule — a selection base face carrying reverse (clause 5) over whatever background it inherits — expressed as a derived face per surface; as twelve literals it is twelve chances to get a light-background terminal wrong.

resolve takes a Face and nothing else, so "the surface's accent" is a parent and never a parameter. This is the clause's sharpest edge and it was found by review. The grid's selected cell, the formula panel's and the view panel's are three different backgrounds, so they are three faces — grid.cursor-cell, panel.formula.selected, panel.view.selected — each naming selection as its parent and overriding background from its own surface's accent. They are not one Face::Selection resolved with context. A resolution that took a context argument would be a second input the front ends would each have to supply correctly, which is the three-derivations hazard reappearing one level down; and a context-free resolve is what lets a front end cache a resolved theme as a flat table. Clause 2 already generates faces from domain enums, so "one face per (surface, state) pair" is the mechanism this repo already has, not a new one.

reverse is not a counter-example to any of that, though it looks like one: it is applied by the renderer after resolve returns, against colours the front end already holds, so it adds no input to resolution. Clause 5 works it through.

4. A colour is palette-relative or absolute, and the shipped theme is never absolute.

This is the hard question and the clause most expensive to get wrong.

Today Color::Green means whatever the user's terminal calls green. Their terminal theme applies to improvise for free, and 123 of the 125 literals are named colours, so this is nearly the whole TUI. A theme format carrying only RGB silently takes that away — a regression, not a gap, and one that would be found late.

So a colour attribute is:

Colour ::= Default                  -- inherit the surface; state nothing
         | Named(AnsiSlot)          -- one of the 16 slots, meaning "whatever
                                    --   THIS DISPLAY calls that slot"
         | Indexed(u8)              -- one of the 256, same meaning, weaker claim
         | Rgb(r, g, b)             -- an absolute colour, deliberately

The line that matters is not where the arms are, it is between the first three and the last: Default, Named and Indexed are palette-relative, and Rgb is absolute. A palette-relative value says which of the viewer's colours; an absolute one says which colour. So the shipped-theme rule is stated over that line — the shipped default theme contains no Rgb — and it is the only rule that actually protects the terminal theme. An earlier draft wrote the rule as "only Default and Named" while also promising the two existing Indexed greys would keep rendering exactly, and left the fourth arm as "a detail". Those three cannot all hold: folding an index into Rgb changes what it means and breaks the rule in the same stroke. Review caught it; the fourth arm is the fix.

Which spelling does what to a terminal user, stated as a table because the difference is invisible in a theme file and the wrong one is a thing to stumble into rather than choose:

spelling follows the user's terminal theme? what it is for
Default yes — states nothing at all ordinary text, the terminal's own background
Named(slot) yes, fully: slots 0–15 are what a terminal theme redefines almost everything; the shipped default theme is written in these
Indexed(n) in principle, in practice no for 16–255 — nothing re-themes the xterm cube a shade the sixteen cannot express, such as "just off the background" (the two greys today)
Rgb(r,g,b) no an exact colour: a brand shade, or a hue outside any palette

So a theme author reaching for Rgb gets exactly what the user ruled they should be able to have — an exact colour, which is the honest answer for a brand shade or anything the sixteen slots cannot say — and pays for it by opting that theme's users out of terminal palette tracking. Fine when chosen, bad when stumbled into, which is why the trade is written here rather than left to be discovered.

Four things follow, and the first is the point of the whole clause.

A named slot is not a colour, it is an index into the viewer's palette. A theme that says axis.row: Named(Green) has not chosen green; it has chosen the user's green. That is how the terminal theme survives, and it survives by the theme declining to state a colour rather than by a special case.

Default is a third value and not a synonym for anything. The TUI already never sets a background for ordinary grid content, so the terminal's own background shows through; a theme that could only say Rgb or Named would have to invent one. It is the same distinction ADR 002 drew between an aggregate and an absence — "unstated" is a real answer.

A theme MAY carry two slot tables, and a single-table theme stays legal (the user's answer to Q4: themes "should usually have the option to split out light/dark palettes"). Optional, not mandatory — so Theme { slots: SlotTable, dark: Option<SlotTable>, … } rather than a pair, because a theme with one palette should not have to say it twice.

What a terminal does with a two-table theme is: almost nothing, and that falls out of clause 4 rather than needing a rule. A terminal has the user's palette, so Named and Indexed never consult a slot table at all — the tables are the browser's substitute for a terminal palette, not a second opinion about one. They therefore bite in exactly two places: a browser resolving a slot, and an Rgb value a theme chose to state twice. For that second case a two-table theme names which table a single-scheme front end takes (default_scheme), because a terminal cannot be asked which it is in: prefers-color-scheme has no terminal equivalent, and while some terminals answer an OSC 11 background query, crossterm exposes nothing portable, so detection is not on the table and is not needed. One declared field beats a guess. Since the shipped theme is palette-relative throughout, the practical answer is that the tables are a browser concern and the TUI is unaffected.

What a browser does with "terminal green": it ships a 16-slot table per scheme and resolves the slot through it. A browser has no terminal to ask, so it is the terminal — this is precisely what a terminal emulator does, and it costs 16 custom properties. Those sixteen are where PR #120's palette meets the face system: slot 2 should resolve to the product's green (#2f6f4f light / #8ec07c dark), not to a generic ANSI green, so the app keeps its identity while the TUI keeps the user's. One table, one place, both facts served. It also retires the nine stray hex values in style.css — six under the light scheme and three under dark — which are the only colours in that file not reached through a var(--…).

What a browser does with Indexed(237): slots 0–15 go through the same 16-slot table, and 16–255 through the standard xterm formula — the 6×6×6 cube at 16 + 36r + 6g + b and the 24-step greyscale ramp at 232–255. That is one well-defined function, written once in the shared resolver rather than in each front end, and it is what makes an index portable at all.

What a terminal does with #3f7a4d: ratatui has Color::Rgb, so a truecolor terminal renders it exactly; a 256-colour terminal must approximate and a 16-colour one must approximate badly. So RGB is permitted and degrades, where a palette-relative value is exact everywhere. Hence the rule: the shipped default theme contains no Rgb. Using Rgb is a user's deliberate opt-out of their own terminal theme, which is a thing a user is allowed to want and is not a thing improvise should do on their behalf.

Indexed is palette-relative but makes the weaker claim, and that is worth saying rather than glossing. Slots 0–15 are what a user's terminal theme actually re-defines; 16–255 are the xterm cube and almost no theme touches them. So an Indexed value is portable and exact, and it is not meaningfully themeable by the user. That is precisely why Indexed(235)/(237) exist today: no ANSI slot names "very slightly lighter than whatever the background is", and those two greys are the app reaching past the sixteen for something the sixteen cannot express. They stay, spelled as the 256-colour slots they are, and a theme author choosing Indexed over Named should know they have traded the user's palette for a guaranteed shade.

5. Six portable attributes, reverse among them

An attribute a front end cannot render is ignored.

foreground, background, weight (normal/bold), slant (normal/italic), underline, reverse. The first five are what the app uses today — BOLD 25 times, UNDERLINED 3, ITALIC once — so that part of the set is not a guess.

All six map onto ratatui Modifier (REVERSED is ratatui-core's style.rs:111), onto CSS, onto NSAttributedString/SwiftUI and onto QTextCharFormat. Box, shadow, border-radius and font family do not reach a terminal and are out of the face system, not optional within it: an attribute that half the front ends structurally cannot serve belongs to the front end that can, the same way ADR 003 keeps writes out of the trait a read-only source implements. Strike-through remains out — nothing in the app strikes anything through, so including it would be the speculation this clause is shaped to avoid.

A front end ignores an attribute it cannot render, as Emacs does on a tty. Said explicitly because the alternative — refusing, or falling back — makes every future attribute a compatibility negotiation.

reverse is in, and the objection to it was wrong

An earlier draft left reverse out on the ground that "CSS, Swift and Qt have no such thing and would have to compute it, which needs a resolved background and therefore makes resolution stateful". The user asked for it anyway, and asked whether CSS could do it with a class that overrides custom properties. It can, and working that through shows the objection was simply mistaken.

The swap happens in the renderer, after resolution, against two values the element already carries — so resolve stays context-free and clause 3 is untouched:

[data-face]            { color: var(--face-fg, var(--fg));
                         background: var(--face-bg, var(--bg)); }
[data-face].reversed   { color: var(--face-bg, var(--bg));
                         background: var(--face-fg, var(--fg)); }

Three things make this the right answer rather than a trick. It is a re-reference, not a computation — both colours are already custom properties on the element, so the reversed rule just reads them crossed; nothing is derived and no JavaScript is involved. The Default case, which is the one that would have broken it, is covered by the var() fallback: a face that states no foreground falls through to the page's own --fg/--bg tokens, which is exactly what a terminal's own reverse does against the terminal's current colours. And it needs no reversed palette, which was the shape the question offered: a second palette would be a second set of values to keep in step, which is the disease this whole ADR is about. One class, two rules.

Swift and Qt are not helped by a CSS trick, and do not need to be. Both compose runs with an explicit foreground and background — NSAttributedString's .foregroundColor / .backgroundColor, QTextCharFormat's setForeground / setBackground — so the swap is two assignments against values they already hold, with the same Default substitution from their own surface. So the rule generalises and is worth stating as the rule rather than as three answers: reverse is applied by the front end after resolution, against the surface colours that front end knows. The resolver never learns what a surface is.

The TUI gets the better end of this. Modifier::REVERSED is handled by the terminal, which swaps against whatever the cell's actual colours are — so the twelve hand-rolled fg(Color::Black).bg(accent) sites become reverse over the accent and stop asserting that the ink is black, which is the light-terminal bug clause 3 describes.

6. The vocabulary lives in improvise-protocol, and improvise-core names no face.

Today AxisColor is in improvise-core and MinibufColor is in improvise-protocol. The ticket asks the ADR to resolve that rather than inherit it.

A face is UI. improvise-core is the crate that is supposed to have none, and AxisColor squeaked past forbidden(improvise_core, ui) only because it is semantic UI. Its presence there is an accident of Axis::color() being a method on a core type, not a decision. So the face vocabulary goes where Panel, AppMode and Hint already are — improvise-protocol, which depends on core and is depended on by both front ends — and Axis::color() becomes face_of(Axis) there.

The cost is real and should be weighed: gotcha 26 says "axis presentation lives on Axis", and this moves one of its five vocabularies off the type. improvise-rml's consolidation is not undone — its point was one home, not that the home be core — but a reader who knows gotcha 26 will look in the wrong place for a release or two. The alternative (put faces in core, so Axis::color() stays) keeps that pointer true and costs the core-has-no-UI rule, which is the more expensive of the two to bend.

A role may cross the wire; a theme never does. MinibufferConfig.color is already serialized into the Snapshot, and that stays correct: the server says "this prompt is an edit prompt", and the client decides what an edit prompt looks like. Faces in improvise-protocol is exactly that division.

7. A theme is client-local, and is not document data.

Three negatives, all cheap now and a protocol change later:

  • A theme is not a Command, is not in ViewState, and is not projected. Gotcha 29 and the user's own ruling on improvise-g0f: the model is shared and the UI is not. A theme reaching another client's screen is that bug exactly. ViewState membership is gated on being snapshot-safe — "a fresh client inheriting this value is correct" — and a theme fails it by definition (gotcha 30).
  • A theme is not in .improv. A document must not dictate its reader's colours. This is not merely tidiness: ADR 003 is already splitting .improv into our configuration and their data, and a theme is neither.
  • A theme is not in the Projection stream. Nothing a client themes is dirty for anybody else, so there is nothing to diff.

8. Migration: the default theme reproduces today's rendering, with one exception

The mechanism that makes a flag day unnecessary is not a plan, it is a property. If the built-in default theme resolves every face to precisely the colour and modifier the literal at that call site produces today, then:

  • migrating one call site is a no-op, verifiable by a TestBackend buffer comparison;
  • a partially migrated file is not visibly wrong, so there is no pressure to finish;
  • the 125 literals move opportunistically, when a file is touched for another reason.

The one exception is minibuffer.category-add, and it must stay an exception rather than becoming a habit. The user ruled (Q1) that the green/yellow split between item-add and category-add is not meaningful, so the default theme makes them agree and category-add changes from yellow to green. That is a deliberate one-line behaviour change riding along with a migration whose whole safety argument is that it changes nothing.

Two consequences follow and both are the point of writing it down. The TestBackend comparison for that one call site must be updated rather than expected to pass, so it is the single place a reviewer has to actually look. And any future exception needs the same treatment — named in this clause, with who decided it. A migration that quietly accumulates "while we were in there" changes loses the property that makes it safe to do piecemeal, and then the flag day comes back.

The order that foreclosed nothing, cheapest first:

  1. Face, Theme, resolve and the default theme land in improvise-protocol. Nothing else changes; no call site moves.
  2. draw.rs gains one face_style(Face) -> Style. axis_fg, minibuf_fg, field_kind_fg and mode_style become callers of it and then disappear.
  3. PanelContent::active_color() and field_kind_fg are lifted into faces — they are already the right shape and only lack a home.
  4. MinibufColor is retired in favour of a face per text-entry mode (see below).
  5. The browser's 16-slot table replaces style.css's nine stray hex values, and the -- tokens become that theme's surface faces.
  6. Everything else, as files are touched.

A note on the ticket's own step (a), which does not survive contact with the call sites. improvise-nn4s proposes renaming MinibufColor's three variants to roles as the cheapest first move. It cannot be done honestly: there is no three-way role split that fits the eight construction sites (below). The right move is a face per text-entry mode — eight faces, three of which happen to resolve to the same value in the default theme. That is barely more work and it is the difference between a true type and a second false one.

9. A theme is JSON, and a published face name is a compatibility surface.

The format was drafted as deferred, on the stated condition that improvise has no configuration story of any kind. The user's answer to Q5 dissolves that condition rather than meeting it: "They should be external and I should be able to download a JSON dump of the current theme, edit it and upload."

Download-edit-upload is not a configuration file, and that distinction is what lets this land without first deciding how improvise is configured. A config file needs a search path, a discovery order, a precedence rule between system and user and project, and a policy for what happens when it is absent or malformed. An export/import pair needs none of them: it is a value the user round-trips, which is the shape export_csv already has. So the larger decision stays unmade and this one no longer waits on it.

JSON, and the choice costs nothing. serde_json is already a dependency of every crate in the workspace except improvise-protocol, where it is optional and dev-only — so JSON adds no dependency anywhere, while TOML would have added one, and to a bundle crate if the parsing lived beside the types. The Face and Theme types therefore derive Serialize/Deserialize in improvise-protocol (serde alone, already a dependency) and the serde_json call happens at the front ends, which already have it. That keeps protocol's dependency footprint unchanged, which the wasm CI job checks per crate, and it is the same division ViewState already has: derive in the protocol, encode at the edge. A pest grammar was the other candidate and loses here — its advantage is parse errors on hand-written text, and a file the user obtained by downloading it from the app starts well-formed.

"Editable by hand" is a constraint on the shape, not a nicety. Face names are the keys, spelled as the vocabulary spells them; ordering is stable, so a re-download after an edit produces a reviewable diff rather than a reshuffle (serde_json's preserve_order, or sorted keys — indexmap is already in the tree); and there are no surrogate keys, because a numeric id in a file a human edits is a lookup the human has to perform.

A published face name is permanent. This is the real cost of Q5 and it is worth stating as plainly as the benefit: once a downloadable theme spells grid.column-header, a user's saved theme contains that string, and renaming the face breaks their file. Face names are therefore a compatibility surface on the same footing as the .improv grammar and the wire frames — which is an argument for deriving names from domain enums (clause 2), since those change rarely and visibly, and an argument against inventing decorative sub-names that a later tidy-up would want back. What a theme may safely do is omit a face it does not override; inheritance (clause 3) then supplies it, so a theme written today keeps working when a face is added tomorrow.

The download/upload path itself — a browser half and a TUI half — is follow-up work and not part of this ADR; see the deferred section.

The role vocabulary, derived from the 125 call sites

This is the part an ADR gets wrong by inventing, so it is derived rather than designed — and where the call sites disagree with themselves, that is recorded rather than smoothed over.

Base faces (everything inherits from these):

face today sites
default unset fg, unset bg the ordinary grid cell, HelpStyles::normal
dim DarkGray 33 — hints, empty cells, inactive borders, rules, secondary text
emphasis BOLD, no colour 25
error Red 2, both in the wizard

dim at 33 sites is the single largest role in the app and has no name today.

Chrome: title-bar (Black on Blue, bold), rule (the horizontal separators), popup-border, hint, banner. rule and hint both resolve to dim today and are separate faces anyway — a face system's job is to make them separable, not to assert they differ.

Grid: grid.column-header (Yellow bold), grid.group-header (Green bold), grid.total (Yellow bold), grid.cell-empty (→ dim), grid.cursor-row (Cyan on Indexed(237), bold), grid.cursor-cell (→ selection), grid.search-match (Black on Yellow), grid.edit (Green, underlined), grid.page-bar (→ axis.page).

Derived: selection is a base face, not a face anything draws with. The twelve fg(Black).bg(accent) sites are one rule and several faces — grid.cursor-cell, panel.{formula,category,view}.selected, tile-bar.selected, wizard.selected — each inheriting selection and overriding background from its own surface's accent, per clause 3. Written out because "one parameterised selection face" reads as the tidier answer and cannot be resolved by resolve(Face).

Parameterised families, one face per arm of an enum that already exists:

  • axis.{row, column, page, filter, none} — is AxisColor, unchanged.
  • panel.{formula, category, view} — is active_color(), lifted.
  • wizard.field.{category, measure, time, label} — is field_kind_fg, lifted.
  • mode.* — does not exist. mode_style matches AppMode directly, and improvise-zx8t explicitly left ModeColor open, deferring it to be decided with improvise-k099. k099 has since closed by doing exactly the analogous thing for text, so the precedent this ADR needs is already set. Its shape is the next section.
  • minibuffer.{edit, formula, command, category-add, item-add, export, save} — is MinibufColor, re-derived.

What the thirteen DarkGray modes are hiding, and what they are not

The user's answer to Q2 was that "the dark gray group is probably hiding semantic differences that should be resolved by inheritance". Probably is the right hedge, so the sub-roles below are derived from distinctions AppMode already draws — its minibuffer(), panel(), is_editing() and is_records() methods — rather than invented one per variant, and the splits that could not be justified are named as such.

mode_style colours four variants (Editing/RecordsEditing green, CommandMode yellow, TileSelect magenta) and sweeps thirteen into DarkGray. Those thirteen partition as:

face variants the fact that groups them
mode.resting Normal, RecordsNormal 2
mode.text-entry FormulaEdit, CategoryAdd, ItemAdd, ExportPrompt, SavePrompt 5 — minibuffer() is Some
mode.panel.{formula,category,view} FormulaPanel, CategoryPanel, ViewPanel 3 — is Panel::ALL, so clause 2 derives it
mode.overlay ImportWizard, Help 2
mode.transient Quit 1

The text-entry group is the one that was genuinely hiding something, and it is visible without any judgement call. All five have a MinibufferConfig, so their minibuffer is painted green, cyan or yellow while their mode indicator says nothing at all — and Editing and CommandMode, which are the same kind of mode, are the two that do get a colour. So the existing split is not "these three matter and those thirteen do not"; it is one family, partly coloured. The chain is therefore mode → mode.text-entry → mode.text-entry.<variant>, with editing and command overriding today and the other five inheriting — which is exactly the user's "resolved by inheritance", and it changes no pixel until someone decides a value.

Three splits I could not justify, recorded rather than guessed:

  • Normal vs RecordsNormal. The difference is the grid's shape, which the grid itself shows plainly; a mode indicator saying it too is redundant. One face.
  • Help vs ImportWizard. Both are full-screen overlays. They differ in whether keys mutate anything, which is real — but the mode indicator is not where a user learns it, and inventing two faces to encode it would be the ADR deciding a UI question it has no evidence for. One face; split it when someone wants them to look different.
  • Quit. Drawn for at most one frame, so whether it is its own face is unobservable. It has one only because clause 2 derives a face per arm, and that is a better reason than any aesthetic one.

Note the caution this section is under. Q2's answer contains "probably", and doctrine(an_invariant_generalised_from_a_fix_generalises_the_fix) is about exactly the move of writing down more structure than the evidence carries because the evidence is in front of you. Five faces where there were none is a decomposition; thirteen would have been a transcription.

What MinibufColor's three colours actually distinguish

Read off the eight construction sites in improvise-protocol/src/lib.rs:

face prompt today
minibuffer.edit (cell), minibuffer.edit (records) edit: Green
minibuffer.item-add add item to X: Green
minibuffer.formula formula: Cyan
minibuffer.command : Yellow
minibuffer.category-add new category: Yellow
minibuffer.export export path: Yellow
minibuffer.save save as: Yellow

Two things this contradicts, both worth stating because a plausible reading of these colours is wrong in both directions:

Cyan is the formula prompt, not search. There is no search minibuffer at all — SearchMode is Normal plus a search_mode flag (gotcha 14), drawn in draw_status under mode_style, and the search match in the grid is Black-on-Yellow. Nothing in the minibuffer is search.

Green and yellow do not split on "editing vs prompting". item-add is green and category-add is yellow, and those are the same kind of act — typing a name that becomes part of the document. Either green means "this text becomes content" (which category-add then violates) or the split is an accident. The code cannot say which, so this was put to the user as Q1, and the user's answer was "No" — it is an accident.

Three things follow, and the first is the one worth guarding.

No face name encodes the distinction. minibuffer.item-add and minibuffer.category-add both exist — clause 2 derives a face per text-entry mode, so they exist by construction — but neither is named for a difference, and both inherit mode.text-entry directly rather than being grouped under some invented parent that would re-assert it. A vocabulary that spelled the accident would have outlived the colours, which is why this was the question flagged as expensive.

The default theme makes them agree, which is the declared exception to clause 8's no-op property: category-add becomes green.

The ability to theme them apart is retained and costs nothing. Two faces that resolve alike are not a redundancy — they are the seam at which a user who does want the distinction can have it, without anyone having to add a face first. That is what naming roles buys over naming colours, and it is worth noticing that the answer "no, this is not meaningful" leaves the face vocabulary in exactly the shape clause 2 already required.

Alternatives rejected

Per-front-end palettes, kept in step by a test. The cheapest thing that looks like it works: the browser keeps style.css, the TUI keeps its literals, and something asserts they agree. Rejected by doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished) — two definitions held in step by a test are one definition waiting to be made — and by the fact that no such test can be written here: the two ends do not agree today (the browser drops every AxisColor, improvise-pcje) and a test would have to encode the disagreement.

Ship RGB only, the way a web palette naturally is. Simplest to specify, one value per attribute, no slot table anywhere. Rejected because it is the regression in clause 4: it silently ends the user's terminal theme applying to improvise, for 123 of 125 literals, and the loss would be discovered by a user rather than by a test. Emacs solved this decades ago with named colours plus tty approximation and there is no reason to relearn it.

Palette-relative only, no Rgb arm at all — proposed, and REJECTED BY THE USER. Genuinely tempting, because it makes the regression unrepresentable rather than merely defaulted-against, which is the strongest shape this repo has and the one CellId and Row<T> both took. The draft kept the arm on its own reasoning — a theme author on a truecolor terminal has a legitimate reason to want an exact colour, a browser theme is naturally written in RGB, and the arm would be reintroduced by the first person who needed it — but flagged the weakness and put the choice to the user rather than settling it.

"Keep full color support in themes" — the user, on 6a4c7dc

So Rgb stays, by decision rather than by default. The protection against losing the user's terminal theme is therefore a property of the shipped theme and not of the type, and that is now a chosen cost rather than an unexamined one. It is the honest trade: a theme may say anything a display can show, and what keeps the terminal palette alive is that the theme improvise ships declines to.

One distinction this ruling does not collapse, stated because it is the easy misreading. "Full colour support in themes" is about what the format may express; the shipped-theme rule is about what improvise's own default says. They are independent, and clause 4 keeps both: the format admits Rgb, and the default theme contains none. If the intent was also that the shipped default use absolute colour, that is a different change and would reopen the regression clause 4 exists to prevent — so it is worth saying out loud rather than inferring.

A Style struct on the wire instead of a face name. i.e. the server resolves the theme and projects concrete attributes. Rejected by clause 7: the theme would then have to be server-side, which puts one client's colours on another client's screen — improvise-g0f decided the general form of this question and the answer was the model is shared and the UI is not.

A face system in improvise-core. Keeps Axis::color() where gotcha 26 says it is. Rejected in clause 6, but narrowly, and a reader who weighs the two costs differently is not being unreasonable.

A theme section in .improv. Rejected: a document dictating its reader's colours. Also a grammar change (improv.pest is the single source of truth) for something that is not document data — precisely the cost that sank improvise-9ix's second serialization format.

What the user decided

The first five were left open on the ground that they were the user's rather than the ADR's, and were answered as inline comments on PR #122 at 246e8f4a; the sixth is the choice those answers left standing, ruled on at 6a4c7dc. Each answer is quoted verbatim, with what it settled and where it now lives — the questions are kept because a decision whose alternatives have been deleted reads as though it was never in doubt.

1. Is the item-add / category-add colour split meaningful? Green for adding an item and yellow for adding a category. If it encodes something ("green means this becomes content"), the face names must carry it and every later migration is spelled in them; if it is an accident, the default theme should quietly make them agree. Flagged as the one mistake here that is expensive, because face names outlive colours.

"No"

Settled: an accident. No face name encodes it, the default theme makes them agree, and that is clause 8's one declared exception to the reproduces-today-exactly property. The two faces still exist, because clause 2 derives one per mode, so a user who wants the distinction can still have it. Worth recording that this went the cheap way: had the answer been yes, the distinction would have had to be carried in the vocabulary permanently.

2. What is mode_style's role split? Editing=Green, CommandMode=Yellow, TileSelect=Magenta, and the remaining thirteen variants all DarkGray — counted, because context/code/tui.md says fourteen and is off by one. Is that three roles and a default, or is the DarkGray group hiding modes that want their own? improvise-zx8t declined to invent ModeColor on the grounds that the colour was the only thing at stake; under a face system that is no longer true.

"The dark gray group is probably hiding semantic differences that should be resolved by inheritance"

Settled: decompose, and inheritance is the mechanism — which makes clause 3 load-bearing rather than convenient. The thirteen become five faces, derived from distinctions AppMode already draws, and the three splits that could not be justified are named. What the thirteen DarkGray modes are hiding has the working. The "probably" was respected: five faces is a decomposition, thirteen would have been a transcription.

3. Should reverse be a portable attribute? Terminals have it natively and it is the honest spelling of the twelve fg(Black).bg(accent) sites — it inverts against whatever the surface is, so it cannot get a light terminal wrong. The draft left it out because CSS, Swift and Qt would have to compute it, which needs a resolved background and therefore looked as though it made resolution stateful.

"Yes, reverse is important can we compute it in CSS using a reversed palette or something that overrides CSS variables and a class to activate it?"

Settled: yes, and the objection was wrong. A class that reads the face's two custom properties crossed does it in pure CSS — a re-reference, not a computation — and the var() fallback covers the Default case that would otherwise have broken it. No reversed palette is needed, which is the better outcome, since a second palette is a second set of values to keep in step — and PR #120's --accent-ink, which the question offered as the alternative, is likewise not needed: a hardcoded ink token is the same assertion about the surface that fg(Color::Black) makes, one layer up. Swift and Qt are not helped by the CSS mechanism but do not need to be: the general rule is that reverse is applied by the front end after resolution, so resolve stays context-free and clause 3 is untouched. Clause 5 has the rules and the reasoning. The portable set is now six.

4. Does the browser honour prefers-color-scheme under a theme? A terminal has one scheme and cannot be asked which; a browser has two and PR #120 already serves both. Either a theme carries two slot tables, or a theme is per-scheme and the browser picks one.

"Themes should usually have the option to split out light/dark palettes and wire each into the CSS appropriately."

Settled: optional, not mandatory — "usually have the option". A single-table theme stays legal, so the second table is an Option rather than half of a pair. What a terminal does with a two-table theme is answered in clause 4 and is mostly "nothing": a terminal already has the user's palette, so the tables are the browser's substitute for one and only bite for an Rgb value stated twice, which a default_scheme field names. Detection was considered and declined — crossterm exposes nothing portable.

5. Is a face's name user-visible? Emacs faces are user-facing identifiers people put in their init file, which makes renaming one a breaking change. If improvise's faces are only ever internal, the vocabulary can be refactored freely; if they are addressable by a user theme, the first published name is permanent.

"They should be external and I should be able to download a JSON dump of the current theme, edit it and upload."

Settled, and it settled more than it was asked. Face names are external and permanent once published — a compatibility surface on the footing of the .improv grammar. And it ends the format deferral, which had been conditional on improvise growing a configuration story: download-edit-upload is an export/import pair, not a config file, so the condition is dissolved rather than met and the larger question stays unmade. The format is JSON, which costs no new dependency anywhere. Clause 9 has all of it, including what "editable by hand" constrains.

6. Should the Rgb arm be dropped? Not one of the original five: it is the choice the other answers left standing, raised because clause 4's protection against losing a user's terminal theme is a property of the shipped theme rather than of the type, which is weaker than the unconstructible-bad-state shape this repo usually reaches for.

"Keep full color support in themes"

Settled: the arm stays. A theme may express any colour a display can show, and what keeps the terminal palette alive is that improvise's own default theme declines to. The alternatives section carries it as a rejected alternative with the reasoning, rather than as an open question — and the weakness is now a chosen cost, which is the difference this answer makes. Read narrowly, as clause 4's own note says: this is about what the format may express, not about what the shipped default says.

Deferred, with the condition that would settle each

The file format is no longer here. It was deferred on the condition that improvise grows a configuration story; the user's Q5 answer dissolved that condition rather than meeting it, and the decision is clause 9. The paragraph is not merely deleted: a reader who remembers the deferral should find out what happened to it, because a deferral that silently disappears is indistinguishable from one everybody forgot.

Download and upload — follow-up work, with a browser half and a TUI half. This is a feature, not a vocabulary question, and it is the one thing Q5 asked for that this ADR does not deliver. What it will need, so that the shape is not rediscovered: the browser half is a Blob download and a file input, neither of which touches the protocol; the TUI half is a path, and that is where the configuration question the format deferral was about actually arrives — a TUI writing a theme to a path is choosing a directory. So the browser half can land first and alone, and it should, because it needs no answer to anything. The condition for the TUI half is the one clause 9 names: it waits on improvise deciding how it is configured.

A theme picker and live reload — deferred. Both follow the vocabulary and neither is the hard part: once Theme::resolve exists, a picker is a field and a reload is a re-resolve. Building them before the vocabulary lands would fix the interface around an unfinished face set, which is the wrong order.

Nothing about Rgb — the user ruled, and it is not deferred. The draft left dropping the Rgb arm as a live choice; the answer is "keep full colour support in themes", so the arm stays and the alternatives section records it as a rejected alternative with its reasoning rather than an open one. What that fixes permanently is that clause 4's protection is a property of the shipped theme rather than of the type — a chosen cost.

Consequences

Good. The browser and the TUI stop diverging, which is a fork that would otherwise widen every release. improvise-pcje becomes a matter of resolving faces the browser already has names for. MinibufColor's doc comment becomes true. The nine stray hex values in style.css rejoin the token system. context/code/tui.md's "the one that is wrong" section gets to be deleted rather than maintained. And a Swift or Qt front end starts from a vocabulary rather than from reading 125 literals.

Two more since the user's answers. The twelve hand-rolled fg(Black).bg(accent) sites become reverse, which fixes a latent light-terminal bug rather than merely tidying them — the ink stops being asserted. And a user can have their own theme without anyone building a theme system first: clause 9's JSON plus the browser's download half is a small feature, and it works because the vocabulary is the hard part and it is done.

Bad, and this one first because it is the clause most carefully argued. With Rgb in the vocabulary by decision, a user's terminal theme is protected by what improvise ships, not by what the type permits. A theme may state an absolute colour, and one that does will not track the terminal's palette — so the guarantee is "the default theme does not do this" and not "this cannot happen". That is weaker than this repo's usual bar, where CellId, Row<T> and Checked all make the bad state unconstructible, and it was said plainly before the ruling and is worth saying just as plainly after: the ruling settles what we ship, not what the type prevents, and those are different guarantees. What it buys is real — an exact brand colour, or a shade no sixteen-slot palette can express — and clause 4's table says which spelling costs what.

It is indirection where there is currently none: Color::Yellow is legible and face_style(Face::GridColumnHeader) requires a lookup to understand. That cost is real and is the honest argument for doing nothing. The answer is that the app already pays it three times (axis_fg, field_kind_fg, active_color) and nobody has objected — the indirection is only unfamiliar where it is absent.

It also moves Axis::color() out of improvise-core, making gotcha 26's "axis presentation lives on Axis" partially false until the repo map catches up, and it adds a resolution step to a rendering path that is currently a constant. The path is per-frame and the resolution is a short chain walk, so this is a nothing; it is listed because "it is obviously fast" is how a per-frame cost gets in.

And a face name becomes a compatibility surface the moment a theme is downloadable (clause 9). That is a genuine cost of accepting Q5's answer: the vocabulary stops being freely refactorable, and it stops being so before every face has been migrated, since the 125 literals move opportunistically. Names derived from domain enums (clause 2) are the mitigation, because they change rarely and visibly.

Not addressed. Fonts, spacing, and layout — a face system is colour and emphasis, and the browser's type scale and the TUI's column widths are not the same kind of fact. Accessibility contrast checking, which a theme makes possible to verify and does not itself provide. The download/upload feature itself. And nothing here touches what is drawn — only how.