docs(adr): ADR 004 accepted — the user answered all five open questions

Status proposed -> accepted. Each answer is folded into the clauses and
recorded verbatim in a new "What the user decided" section, with what it
settled and where it now lives. The questions are kept: a decision whose
alternatives have been deleted reads as though it was never in doubt.

Two answers changed the decision rather than confirming it.

REVERSE IS IN, AND MY OBJECTION TO IT WAS WRONG (Q3). The draft excluded it
because "CSS, Swift and Qt would have to compute it, which makes resolution
stateful". The user asked whether a class overriding custom properties could
do it. It can, and working it through shows the objection was mistaken: the
swap happens in the RENDERER, after resolution, reading the face's own two
custom properties crossed — a re-reference, not a computation — so `resolve`
stays context-free and clause 3 is untouched. The `Default` case, which is
the one that would have broken it, is covered by the var() fallback. No
reversed palette is needed, and neither is `--accent-ink`: a hardcoded ink
token makes the same assertion about the surface that `fg(Color::Black)`
does, one layer up. Swift and Qt swap two values they already hold. The
portable set is six, and the twelve hand-rolled `fg(Black).bg(accent)` sites
stop asserting the ink is black — a latent light-terminal bug.

THE FORMAT IS JSON, AND THE DEFERRAL DISSOLVED RATHER THAN BEING MET (Q5).
It was deferred on the condition that improvise grows a configuration story.
Download-edit-upload is not a config file: no search path, no precedence, no
absent-file policy — it is an export/import pair, the shape export_csv
already has. So the larger question stays unmade. JSON costs no new
dependency (serde_json is already in every crate but improvise-protocol,
where it is optional); the types derive serde in the protocol and the
serde_json call happens at the front ends, keeping the bundle footprint
unchanged. "Editable by hand" constrains the shape: face names as keys,
stable ordering, no surrogate keys. And a published face name is a
compatibility surface — stated as a cost, not only a benefit.

The other three. Q1 (the item-add/category-add split is an accident) is the
one flagged as expensive and went the cheap way: no face name encodes it,
and the default theme makes them agree — which is clause 8's ONE DECLARED
EXCEPTION to the reproduces-today-exactly property, written down because the
migration's whole safety argument is that it changes nothing. Q2 decomposes
the thirteen DarkGray modes into five faces derived from distinctions
AppMode already draws (minibuffer/panel/is_editing), with the three splits
that could not be justified named rather than guessed; the user's "probably"
is respected, since thirteen faces would have been a transcription.
Q4 makes the second slot table optional, and a terminal mostly ignores a
two-table theme because it HAS the user's palette — the tables are the
browser's substitute for one. Detection declined: verified that crossterm
0.29 has OSC 52 but no OSC 11 background query.

Kept deliberately: every rejected alternative; the palette-relative-vs-
absolute clause Codex improved; and the honest statement that the protection
is a property of the shipped theme rather than of the type. The user has not
ruled on dropping `Rgb`, so it is now listed as a live open choice.

Gotcha 48 applied to myself, since this touched five sections: grepped every
claim before changing it, which caught "Seven clauses", the stale
`--accent-ink` alternative, and two headings I had wrapped across lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
Edward Langley
2026-09-22 13:03:25 -07:00
parent 246e8f4a63
commit 6a4c7dcd8e
+331 -55
View File
@@ -1,16 +1,23 @@
# ADR 004 — Faces: appearance as UI-agnostic data
**Status:** proposed (2026-09-21) — **this one is the user's to accept**, and it is
written to be argued with rather than adopted · **Direction from:** the user
(improvise-nn4s) · **Governed by:** `context/design-principles.md` §5
**Status:** accepted (2026-09-22) · **Decided by:** the user, who answered all five open
questions on PR #122 · **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 four constraints so that incremental
work cannot paint the repo into a corner. It deliberately does *not* specify a finished
theming system, and three things a reader will expect are explicitly deferred at the end.
**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**. Their answers are
folded into the clauses below and recorded, with the reasoning, in
*[What the user decided](#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
@@ -88,7 +95,7 @@ attribute set. A theme binds faces to values, resolves once in shared code, and
client-local. A front end translates resolved attributes to its own framework and ignores
what it cannot render.**
Seven clauses. Each is a place this gets decided by accident otherwise.
Nine clauses. Each is a place this gets decided by accident otherwise.
### 1. A face names a role. A colour is never a role.
@@ -125,9 +132,10 @@ application of it: *the decision is shared, and only the last translation is loc
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 saying
"ink on the surface's accent") expressed as a **derived face per surface**; as twelve
literals it is twelve chances to get a light-background terminal wrong.
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
@@ -141,6 +149,10 @@ level down; and a context-free `resolve` is what lets a front end cache a resolv
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.**
@@ -183,8 +195,25 @@ theme that could only say `Rgb` or `Named` would have to invent one. It is the s
distinction ADR 002 drew between an aggregate and an absence — "unstated" is a real
answer.
**What a browser does with "terminal green":** it ships a 16-slot table, one per scheme,
and resolves the slot through it. A browser has no terminal to ask, so it *is* the
**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
@@ -216,23 +245,66 @@ something the sixteen cannot express. They stay, spelled as the 256-colour slots
and a theme author choosing `Indexed` over `Named` should know they have traded the user's
palette for a guaranteed shade.
### 5. Five portable attributes. An attribute a front end cannot render is ignored.
### 5. Six portable attributes, `reverse` among them
`foreground`, `background`, `weight` (normal/bold), `slant` (normal/italic), `underline`.
The app uses `BOLD` 25 times, `UNDERLINED` 3 and `ITALIC` once, so this set is not a
guess — it is what is in use plus nothing.
An attribute a front end cannot render is ignored.
All five map onto ratatui `Modifier`, 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.
`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.
*Strike-through and reverse are deliberately not in the set*; see the open questions.
#### `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:
```css
[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.
@@ -273,7 +345,7 @@ Three negatives, all cheap now and a protocol change later:
- **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 exactly.
### 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
@@ -283,6 +355,20 @@ literal at that call site produces today, then:
- 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
@@ -304,6 +390,51 @@ eight faces, three of which happen to resolve to the same value in the default t
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 —
@@ -343,13 +474,60 @@ answer and cannot be resolved by `resolve(Face)`.
- `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
- `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.
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`:
@@ -375,8 +553,26 @@ 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 neither can
this ADR** — it is the first open question below.
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
@@ -419,13 +615,27 @@ not being unreasonable.
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.
## Open questions — these are the user's, not the ADR's
## What the user decided
These 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`. 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. Getting this wrong is the
one mistake here that is expensive, because the face names outlive the colours.
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
@@ -434,45 +644,98 @@ default, or is the DarkGray group hiding modes that want their own? improvise-zx
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](#what-the-thirteen-darkgray-modes-are-hiding-and-what-they-are-not)*
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. CSS, Swift and Qt have no such
thing and would have to compute it, which needs a resolved background and therefore makes
resolution stateful. PR #120's `--accent-ink` is the other answer to the same question.
Both work; they are different systems.
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. This
is a format decision disguised as a rendering one, and it is the main thing the deferred
format question turns on.
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.
## Deferred, with the condition that would settle each
**The file format — deferred, and the condition is a config story.** A Swift or Qt client
means a theme is data and needs a serialization, so this must be answered eventually. It
should not be answered now, because **improvise has no configuration file of any kind** —
no `~/.config` read, no dotfile, no `dirs` dependency, and no TOML anywhere in the
workspace. The first theme file would be the first config file, and that is a larger
decision than theming. The repo's existing habits point three ways and none is obviously
right: pest owns the *document* format (and a user-edited theme wants good parse errors,
which is pest's whole argument); serde/JSON owns the *wire* (and a theme crossing to a
Swift client is wire-shaped); TOML is what a config file usually is and appears nowhere
here. **Decide the format when improvise decides how it is configured at all**, and decide
open question 4 first, because per-scheme tables change the shape.
**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.
Until then a theme is a Rust value in `improvise-protocol`, which is enough for the default
theme, enough for the browser's slot table, and enough to prove the vocabulary.
**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, user-supplied theme files, and live reload — deferred.** All three follow
the vocabulary and none is the hard part: once `Theme::resolve` exists, a picker is a field
and a reload is a re-resolve. Building them first would fix the format before the vocabulary
is known, which is the wrong order.
**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.
**Dropping `Rgb` — an open choice, and the user has not ruled on it.** Clause 4's
protection against losing the user's terminal theme is a property of the *shipped theme*
(it contains no `Rgb`) rather than of the *type* (which admits one). That is weaker than
this repo usually accepts — `CellId`, `Row<T>` and `Checked` all make the bad state
unconstructible instead — and the alternatives section records why the arm was kept
anyway. It is listed here rather than left in the prose because it is a live choice
someone may want to make later, and the cost of making it then rather than now is one
enum variant and whatever themes have used it.
## Consequences
@@ -483,6 +746,12 @@ names for. `MinibufColor`'s doc comment becomes true. The nine stray hex values
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.** 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
@@ -495,7 +764,14 @@ step to a rendering path that is currently a constant. The path is per-frame and
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. And nothing here touches what is drawn — only how.
itself provide. The download/upload feature itself. And nothing here touches what is
drawn — only how.