|
| 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