Skip to content

Commit 94967fd

Browse files
Merge pull request #31 from SharpMUSH/fix/readable-colours
A legibility floor for every colour, and a trigger route that keeps the line where it was
2 parents e47a370 + fa07203 commit 94967fd

35 files changed

Lines changed: 2408 additions & 181 deletions

CLAUDE.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,73 @@ fallbacks) for inline images/maps.
372372
and never add a "probably a URL" fallback for an untagged payload. The window id is what stops a
373373
link clicked in a background pane sending to whichever character is focused.
374374

375+
- **No colour this client paints is left below a legibility floor, and the floor is applied where the
376+
colour meets the plane it lands on** (`Contrast`, Core; `ChromeInk` + `WorkspacePalette.ReadingPlane`,
377+
Tui; F7 ▸ `keep text legible`, default on). The reported defect — "Freeze being purple against a blue
378+
background" — was one cell of a grid: the bar took its accent from the theme's index 5 and painted it
379+
on the pane, and `#800080` on a `#36363d` focused pane is **1.27:1**. Measured across the grid, **six of
380+
the F2 picker's sixteen names fail 3:1 on the dark theme, nine fail on the light one, and only `grey`
381+
clears both** — which is the finding the whole design turns on: *a palette of fixed hexes cannot serve
382+
two themes*, so the resolution has to happen at the moment of painting rather than the moment of
383+
picking.
384+
- **`Contrast.Legible(fg, plane)` moves a foreground the smallest distance that clears the floor and
385+
returns anything already legible byte-identical.** Direction is the **plane's**, not the colour's — a
386+
dark plane lifts, a light plane darkens — which is what lets one function serve all three themes; the
387+
pivot is relative luminance against 0.18, because `#808080` looks like half way written down and is
388+
0.216. Hue survives while there is headroom and then **desaturates**, and it has to: pure `#0000ff`
389+
has a relative luminance of 0.0722 and tops out at 1.88:1 on a dark pane *at full blue*, so a rule
390+
holding hue absolutely would leave the commonest unreadable colour in MU\* output unreadable.
391+
- **The floor is 3.0:1 and deliberately not 4.5.** A game's own de-emphasis is spoken in exactly the
392+
colours a 4.5 floor would erase — bright black for asides, `dim` for a status line nobody is meant to
393+
read twice. Three is where a colour stops being invisible, which is the complaint; four and a half is
394+
where it stops being quiet, which is not.
395+
- **The plane handed to `MarkupFormatter` is per *theme*, not per pane.** A pane's plane depends on its
396+
character's tint and on focus, and resolving against *that* would re-format a whole buffer on every
397+
focus move — the expensive path this file reserves for one deliberate keystroke. `ReadingPlane` is the
398+
**extreme of the fourteen** a pane can wear, in the direction of travel, and that is the worst case
399+
rather than an approximation of one: once the foreground is past the background's luminance the ratio
400+
is monotone in the background, so clearing the extreme clears all fourteen.
401+
- **A span carrying a background is measured against *it*.** That plane is known exactly and a
402+
highlight's pair must be judged as a pair; a span with the default background emits none and takes
403+
the pane, so it is measured against the reading plane. `reverse` swaps first.
404+
- **`ScreenPalette` is deliberately not built this way.** Those constants sit on the settings screens'
405+
own fixed backdrop, which no theme moves; measuring them against a theme plane would be measuring
406+
them against a plane they are never painted on. `ChromeInk` is the other four — `Accent`, `Notice`,
407+
`Draft`, `Marker` — and it carries **the plane it was resolved against**, so a renderer can hold a
408+
colour it was *handed* (a world's accent) to the same floor.
409+
- **A fill is not text.** A ribbon segment's accent, a pane tint and F2's swatch are identity or
410+
sample, and lifting them would flatten the thing they exist to say; what has to clear the floor is
411+
the *ink on* them, measured against the fill. `ChromeInk.On` is that, and the header ribbon is where
412+
both rules appear side by side.
413+
- **The audit that found most of this is a test** (`FrameContrastTests`): every emitted SGR pair over
414+
24 views × 3 themes. Reading the source found four of the offenders; the paint found nine. It exempts
415+
the powerline wedges and box-drawing rules (fill boundaries and dividers), the solid blocks (a swatch
416+
is a colour sample shown as the *pane* will paint it), and the framework's `[dim]`. **The half blocks
417+
are not exempt**`` is the trigger left-rule and the focus marker, and one of them was a real
418+
defect this caught.
419+
- **One thing is outside the floor's reach and is named rather than hidden.** SharpConsoleUI resolves
420+
`[dim]` to a fixed `#808080` through no option we hold: 4.01:1 on Dark, **2.52:1** on Solarized Dark's
421+
focused pane. Reaching it means giving up `[dim]` across every renderer for an explicit floor-checked
422+
grey — a sweep, for a near miss on one theme. The exemption is a named predicate with the number in
423+
it, so whoever does that sweep can delete it and watch the test pass.
424+
- **A trigger's `route` says three things, and the third is "nothing"** (`TriggerActions.MainWindow`,
425+
Core; F2's `route` list). `SpawnTarget = null` has always meant *this rule adds no destination — the
426+
line follows whatever the other matched rules decided*, which is exactly what a highlight rule wants.
427+
F2 labelled it `main`, so it read as a destination, and "highlight it and leave it where it was" looked
428+
like something the screen could not express. It is `(none)` now, and it is what a new rule defaults to.
429+
- **`main` is a real destination** — the matching session's own window. It earns a **reserved word**
430+
rather than being spelt as the window's title because one trigger set is shared by every character
431+
that lists it and a title can only name one of them; nothing collides, since a character's session
432+
window is titled after the character and `main` is only the rail's label for it.
433+
- **Gag suppresses the *default* delivery and nothing else.** Every destination a rule asked for
434+
survives it — always true of a spawn pane (`route: Chat` + gag has always meant "only in Chat"), and
435+
now true of `main`, so `route: main` + gag keeps the line where it used to delete it.
436+
- **Destinations are deduplicated.** They were not, and `WorldSession` raises one `SpawnLine` per
437+
entry, so a highlight rule pointed at the same pane as its capture rule delivered every line twice.
438+
- **A highlight rule needs no route to reach the pane a capture rule sent the line to.** There is one
439+
line and one set of destinations, and every matched rule's highlight is on it. Do not "fix" that into
440+
a per-rule delivery.
441+
375442
## Building and testing
376443

377444
- **.NET 10 SDK**: install via `apt-get install -y dotnet-sdk-10.0` (the Microsoft CDN is often
@@ -399,6 +466,14 @@ dotnet run -c Release --project src/SharpMUTerm.Tui --no-build -- \
399466
python3 tools/ansi_frame_to_image.py frame.ansi frame.html # or .svg
400467
```
401468

469+
- **`--theme <name>` renders a frame in a built-in flavour** (`Dark` / `Light` / `Solarized Dark`). It
470+
exists because the client's chrome is derived from the theme and held to a legibility floor against it,
471+
and **every frame in the gallery renders Dark** — which is exactly how the Light theme's accent
472+
(1.42:1), draft pen (1.26:1) and notice (1.73:1) stayed unreadable without anybody ever seeing them.
473+
It sets `ThemeName` *and* `Theme`: `ResolveTheme` treats an inline theme whose name disagrees with
474+
`ThemeName` as a *customised* one and prefers it, so setting the name alone would be overruled by the
475+
Dark theme still sitting in `Theme`.
476+
402477
- **`-c Release` on the build, and it is not a formality.** A bare `dotnet build` produces *Debug*,
403478
`--no-build` runs the *Release* output, and nothing warns you: the snapshot renders happily from a
404479
binary that predates your change, so a new view comes out byte-identical to the default frame and a
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
# Legible colour, and a route that means "leave it where it is"
2+
3+
**Date:** 2026-08-11
4+
**Status:** approved
5+
6+
Two reported defects that turn out to share a cause.
7+
8+
> "we are using unreadable colors by default against our backgrounds. Such as Freeze
9+
> being purple against a blue background."
10+
>
11+
> "we failed to solve the issue of Triggers — we should be able to Highlight text and
12+
> send it to the pane where we found it. That way, players can highlight character name
13+
> text."
14+
15+
The second is mostly the first: a highlight rule *does* fire, and the colour it fires in
16+
is invisible. What is genuinely missing on the trigger side is smaller than it looked,
17+
and is stated in §5.
18+
19+
## The measurement
20+
21+
Contrast ratios (WCAG relative luminance) of each colour against the pane plane it is
22+
actually painted on — the focused pane surface, which is the brightest plane a dark theme
23+
can wear and therefore the worst case for a foreground being read on it.
24+
25+
The F2 highlight picker's palette:
26+
27+
| name | dark | light | | name | dark | light |
28+
|---|---|---|---|---|---|---|
29+
| purple | **1.27** | 8.43 | | gold | 8.55 | **1.26** |
30+
| blue | **1.40** | 7.69 | | yellow | 11.16 | **1.04** |
31+
| black | **1.75** | 18.79 | | white | 11.99 | **1.12** |
32+
| green | **2.33** | 4.60 | | cyan | 9.56 | **1.12** |
33+
| teal | **2.51** | 4.27 | | pink | 7.79 | **1.38** |
34+
| red | **3.00** | 3.58 | | lime | 8.74 | **1.23** |
35+
| magenta | 3.82 | **2.81** | | orange | 6.07 | **1.77** |
36+
| silver | 6.59 | **1.63** | | grey | 3.04 | 3.53 |
37+
38+
Six of sixteen fail on dark, nine of sixteen on light, and **only `grey` clears 3:1 on
39+
both**. That is the finding the whole design turns on: a palette of fixed hexes cannot
40+
serve two themes, so the resolution has to happen at the moment of painting, against the
41+
plane the text lands on — not at the moment of picking.
42+
43+
The client's own chrome, same measurement:
44+
45+
| what | value | dark | light |
46+
|---|---|---|---|
47+
| freeze / away / restore bar accent | `ResolveIndex(5)` = `#800080` | **1.27** | 8.43 |
48+
| rail accent, drop zones, ⌃P chip | `#00f5b7` | 11.13 | **1.42** |
49+
| draft pen | `#ffd700` | 11.30 | **1.26** |
50+
| prefix panel, client warnings | `#e5c07b` | 9.18 | **1.73** |
51+
52+
The reported Freeze defect is the first row. The rest of the table says the Light theme's
53+
chrome has never been readable.
54+
55+
And the server's own text, on the default dark theme's focused pane: ANSI 4 blue
56+
**1.34**, ANSI 1 red **1.09**, ANSI 0 black **1.75**, ANSI 5 magenta **1.27**. MU\* servers
57+
emit these constantly.
58+
59+
## 1 — One rule, applied where a colour meets its plane
60+
61+
New `SharpMUTerm.Core.Text.Contrast`. Pure, no UI dependency:
62+
63+
- `RelativeLuminance(Rgb)` and `Ratio(Rgb, Rgb)` — WCAG 2.x, the definition every number
64+
in this document was produced with.
65+
- `Legible(Rgb foreground, Rgb plane, double floor)` — the foreground blended toward white
66+
when `plane` is dark and toward black when it is light, by the **smallest** amount that
67+
clears `floor`. Returns the foreground unchanged when it already clears it.
68+
69+
Two properties this has to have, and one it cannot.
70+
71+
**Direction is the plane's, not the colour's.** One function serves all three themes
72+
because it asks the plane which way "away" is. A dark plane always lifts, a light plane
73+
always darkens; there is no theme in which the answer is ambiguous, because a pane plane is
74+
never mid-grey.
75+
76+
**Hue survives until headroom runs out, and then it desaturates.** Blending toward white
77+
raises luminance monotonically and reaches any target without clipping a channel — which
78+
scaling upward cannot do, and which is the same reasoning `WorkspacePalette.AtLuma` is
79+
already written on. It has to desaturate eventually: pure `#0000ff` has a relative
80+
luminance of 0.0722 and so tops out at 1.88:1 against a dark pane *at full blue*. A rule
81+
that preserved hue absolutely would leave that colour unreadable, which is the defect.
82+
83+
**It is not reversible.** `Legible` is a projection: two foregrounds that differ only below
84+
the floor come out closer together than they went in. That is the cost of the floor and it
85+
is accepted — the alternative is the current behaviour, where they are equally invisible.
86+
87+
**The floor is 3.0:1**, WCAG AA for large text and UI components. Deliberately not 4.5: at
88+
4.5 the server's bright-black de-emphasis and the `dim` attribute stop meaning anything —
89+
the client would flatten every deliberate act of de-emphasis a game makes.
90+
91+
## 2 — What the server sends
92+
93+
`MarkupFormatter.StyleTag` passes its resolved foreground through `Legible` before writing
94+
the hex, measured against:
95+
96+
- the span's **own background** when it has one (a highlight's background, or a `reverse`
97+
swap) — that plane is known exactly, and it is the one the text lands on;
98+
- otherwise the **pane plane**, because a span with a default background emits none and
99+
takes whatever it is drawn on.
100+
101+
**The plane handed to the formatter is per theme, not per pane.** A pane's actual plane
102+
varies with the character's tint and with focus, and re-resolving per pane would mean
103+
re-formatting a whole buffer on every focus move — the expensive path CLAUDE.md reserves for
104+
one deliberate keystroke. Instead the lift targets the **extreme of the band** in the plane's
105+
own direction: on a dark theme the brightest plane a pane can wear (untinted, focused), on a
106+
light theme the darkest. Clearing the floor there clears it on every other plane, because
107+
once the foreground is past the background's luminance the ratio is monotone in the
108+
background — so one reference plane per theme is not an approximation, it is the worst case.
109+
110+
Gated by a new F7 preference, `keep text legible`, **default on**. Off emits today's exact
111+
bytes: a user who wants their game's own palette untouched, or who has a theme where the
112+
floor fights their taste, turns it off and nothing else changes.
113+
114+
## 3 — What the client paints itself
115+
116+
The chrome literals in the table above are not rescued by §2 — they are the client's own
117+
colours and should be *derived* correctly rather than repaired at the last moment. They
118+
become named inks on `WorkspacePalette`, each resolved from the active theme and then held
119+
to the same floor against the plane it actually lands on:
120+
121+
| ink | replaces | plane it is measured against |
122+
|---|---|---|
123+
| `Marker` | `SharpMUTermApp.FrozenAccentHex()` (`ResolveIndex(5)`) | the pane band |
124+
| `Accent` | `RailRenderer.DefaultAccent`, `PaneDropRenderer.ZoneColor` | `Backdrop` |
125+
| `Notice` | `PrefixPanel` / `ClientMessageRenderer` `#e5c07b` | `Backdrop` |
126+
| `Draft` | `RailRenderer` `#ffd700` | `Backdrop` |
127+
128+
`ScreenPalette` is **not** touched. Those constants sit on the settings screens' own fixed
129+
dark backdrop, which the theme does not move — measuring them against a theme plane would be
130+
measuring them against a plane they are never painted on.
131+
132+
## 4 — The picker
133+
134+
The palette's names stay. What changes is that the F2 swatch is painted through the same
135+
lift, so the picker shows the colour the pane will show. A name is then a *hue* the theme
136+
resolves, which is what the pane-tint work already established as this codebase's way of
137+
naming a colour that has to survive a theme change.
138+
139+
## 5 — Triggers
140+
141+
Four changes. No schema change and no migration: `TriggerActions.SpawnTarget` keeps its
142+
type and its null.
143+
144+
**`route` gains an explicit `(none)`.** The rule adds no destination; the line follows
145+
whatever the other matched rules decided, and lands in the session's main window if nobody
146+
routed it. This is exactly what `SpawnTarget = null` has always meant — F2 labelled it
147+
`main`, and that label is why "highlight it and leave it where it was" looked like something
148+
the screen could not express. New rules default to it.
149+
150+
**`main` becomes a real destination** — the matching session's own main window. Reserved
151+
name; no window is titled `main` today (a character's session window is titled after the
152+
character, and `main` is only the rail's *label* for it), so nothing collides. A config that
153+
already says `SpawnTarget = "main"` currently conjures a capture pane called "main"; after
154+
this it reaches the window whoever wrote it meant.
155+
156+
**Gag suppresses the default delivery only.** Explicit destinations survive it. That is
157+
already true of spawn panes — `route: Chat` + gag has always meant "only in Chat" — and it
158+
becomes true of `main`, so `route: main` + gag keeps the line in the main window instead of
159+
deleting it. This is the answer to "gag means only where I routed it".
160+
161+
**Destinations are deduplicated.** Two rules naming one pane deliver one line. Today
162+
`TriggerEngine` appends to a bare list and `WorldSession` raises `SpawnLine` per entry, so
163+
a highlight rule pointed at the same pane as its capture rule delivers the line twice.
164+
165+
What is deliberately *not* changed: a highlight rule does not need a route to reach the pane
166+
a capture rule sent the line to. There is one line and one set of destinations, and every
167+
matched rule's highlight is on it — which is what the user's own framing asked for ("it
168+
should still follow the original route, as long as it does not change where it routes to").
169+
170+
## What the frame audit found that reading the source did not
171+
172+
The design above was implemented and then the *paint* was measured — every emitted SGR pair, over 24
173+
views × 3 themes. Five more offenders, each with a plausible-looking call site:
174+
175+
| what | measured | why the source looked fine |
176+
|---|---|---|
177+
| the trigger left-rule `` | 1.42 (Light) | `MarkupFormatter` resolved it a dozen lines above the floor it applies to every other foreground |
178+
| the header ribbon's chip | 1.53 (Light) | a fixed `#3f4859` — a *dark* chip whatever the theme, so the world accent on it was resolved for the wrong plane |
179+
| a world's own accent on the rail | 1.03 (Light) | only the *fallback* accent had been derived; a row carrying its own RGB went through raw |
180+
| the unread badge on a tab | 1.42 (Light) | `UnreadBadge.Tint` was a `const` pointing at `ScreenPalette.Accent` |
181+
| the command line's ink | 2.43 (Solarized) | the band was derived and the ink on it was not — the least readable text in the client, on the theme most often chosen for comfort |
182+
183+
`FrameContrastTests` is that audit as a test. It exempts three things, each for its own reason and not
184+
because it was failing: the powerline wedges and box-drawing rules (fill boundaries and dividers, not
185+
text), the solid blocks (F2's swatch is a colour *sample*, shown as the pane will paint it), and the
186+
framework's `[dim]`. The half blocks are deliberately **not** exempt — `` is the trigger rule and the
187+
focus marker, and one of them was a real defect this found.
188+
189+
**One thing is outside the floor's reach and is named rather than hidden.** SharpConsoleUI resolves the
190+
`[dim]` tag to a fixed `#808080` of its own, through no option we hold: 4.01:1 on the default dark theme
191+
and **2.52:1** on Solarized Dark's focused pane. Reaching it means giving up `[dim]` across every
192+
renderer in favour of an explicit floor-checked grey — a sweep, for a near miss on one theme. The
193+
exemption is a named predicate with the number in it, so whoever does that sweep can delete it and watch
194+
the test still pass.
195+
196+
## Testing
197+
198+
**Core.**
199+
- `ContrastTests` — the floor is reached; the ratio is at least the floor and not
200+
wastefully above it; hue is held while headroom lasts; both directions; already-legible
201+
colours come back byte-identical; idempotent.
202+
- `LegiblePaletteTests` — a table over all 16 picker names × ANSI 0–15 × three themes ×
203+
the whole plane band (untinted and all six tints, focused and not), asserting the floor.
204+
This is the test that would have caught the reported defect, and it is the one that keeps
205+
a future theme from reintroducing it.
206+
- Trigger routing: `(none)` adds nothing; `main` delivers to the main window; gag +
207+
explicit route keeps the line; two rules on one target deliver once.
208+
209+
**Tui.**
210+
- `WorkspacePaletteTests` — every named ink clears the floor on every theme, against the
211+
plane it is documented as landing on.
212+
- Snapshots: `freeze`, `away`, `highlight`, and a Light-theme frame, with a decoded-grid
213+
assertion on the freeze bar's painted cells rather than on the markup string — the bug
214+
was in what reached the screen.

0 commit comments

Comments
 (0)