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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user