cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Accessibility: a high-contrast theme, and every state readable without color

Dami committed 126ed86
theme = high-contrast draws High Contrast Dark or Light by the
terminal's background (each also nameable), with every role held to
WCAG AAA: 7:1 for all text, hints included, 4.5:1 for lines and
unavailable items.

The audit: the pointer, the selection and their headers, search matches,
traced cells and the highlighted menu item are drawn in reverse video
with their colors stored swapped, so they look the same in color and
still stand out without it; spilled values are italic. The other states
already had text, a glyph or an underline. theme.Monochrome draws every
role in one gray, and monochrome_test.go checks each state's cue in the
cell's text or attributes. Drawable undoes the reverse video where
lipgloss would draw underlined spaces without it.

Golden screens for the high-contrast theme, dark, light and a menu.
commit
126ed86872d1ac6f0a7997250a6347a589e8d8e9
parent
f0d0fafc45d0c87e1a7f2b205226b612df59a653
author
Dami <dami.etoile@gmail.com>
26 files changed, +809 -73
Mcmd/012/configcmd.go2+
Mdocs/contributing/ux.md32+++-
Mdocs/reference/config.md2+-
Mdocs/terminal/themes.md27++-
Me2e/screenlist_test.go17++
Me2e/screensetup_test.go8+
Me2e/testdata/screens/rules-panel.html2+-
Me2e/testdata/screens/spill-light.html6+-
Me2e/testdata/screens/spill.html6+-
Ae2e/testdata/screens/theme-high-contrast-light.html30+++
Ae2e/testdata/screens/theme-high-contrast-menu.html30+++
Ae2e/testdata/screens/theme-high-contrast.html30+++
Me2e/testdata/screens/theme-picker.html2+-
Minternal/config/registry.go3+-
Minternal/ui/links.go2+-
Ainternal/ui/monochrome_test.go304++++++++++++++++++++++++++++++
Minternal/ui/theme/contrast.go1+
Ainternal/ui/theme/highcontrast.go92+++++++++
Ainternal/ui/theme/monochrome.go32+++
Minternal/ui/theme/palette.go15+-
Minternal/ui/theme/scheme.go57++++-
Minternal/ui/theme/scheme_test.go99+++++++---
Ainternal/ui/theme/standout_test.go39++++
Minternal/ui/theme/theme.go40++++-
Minternal/ui/themepicker/themepicker.go2+
Minternal/ui/view.go2+-
M cmd/012/configcmd.go +2 -0
M docs/contributing/ux.md +30 -2
@@ -58,8 +58,8 @@ follow-up task.
5858 at odd widths and under overlays; draw them with `theme.Fill`.5959 - Text on a colored background needs about 4.5:1 contrast against both6060 reference palettes (`e2e/palette_test.go`). Check it in the gallery.61-- Never rely on color alone: state also shows as text (mode indicator,62- `modified`, `#DIV/0!`, `Circular reference`).61+- Never rely on color alone: every state also shows as text, a glyph or62+ an attribute, listed in [Reading without color](#reading-without-color).6363 - Numbers right-aligned, text left, booleans and errors centered, one6464 column of padding, text overflowing into empty neighbors: as Sheets.6565 Headers centered. Menu bar titles separated by two spaces. Keys shown
@@ -77,6 +77,34 @@ follow-up task.
7777 - Render only what's visible. A keystroke on a 10,000-cell sheet must feel7878 instant.7979 80+## Reading without color81+82+Each state has a cue that survives a terminal without color, or a reader83+who can't tell the colors apart. `internal/ui/monochrome_test.go` draws84+each with `theme.Monochrome` (every role in one gray) and checks the cue85+is in the cell's text or attributes; a new state gets a cue and a case86+there.87+88+| State | Cue without color |89+|---|---|90+| The pointer (active cell), the selection, their row and column headers | Reverse video; the active cell's headers are also bold, and the name box names the cell or range |91+| Errors | The error's text (`#DIV/0!`) with a curly underline, explained on the context line |92+| Warnings | Text on the context line or status line (`Invalid: ...`, `Circular reference`) |93+| Entries failing validation | A dotted underline, and `Invalid:` with the rule on the context line |94+| Values an array spilled | Italic, and `Spilled from B2` on the context line |95+| Pivot table results | Their headings (`SUM of Units`, `Grand Total`), and a note in words when an edit is refused |96+| Protected ranges | A question in words before an edit (`A1:B2 is protected.`) |97+| Notes | A `▝` in the cell's top-right corner |98+| Macro recording | `REC` beside the mode indicator |99+| Mode | The mode indicator's word (`READY`, `ENTER`, `POINT`, `MENU`) |100+| Search matches, traced cells | Reverse video, with a count or the list on the context line |101+| The copied range | A dashed underline |102+| Dropdowns, checkboxes, active filters | `▾`; `[ ]` and `[✓]`; `▼` instead of `▾` |103+104+The reverse-video roles (`Theme.standouts`) keep their look in color:105+their colors are stored swapped, so SGR 7 swaps them back. Conditional106+formats and color scales are colors the user chose, and stay colors.107+80108 ## Required with every user-facing change81109 82110 - [ ] Registered command(s) with title and description; key binding in
M docs/reference/config.md +1 -1
M docs/terminal/themes.md +23 -4
@@ -25,6 +25,9 @@ theme = light:Catppuccin Latte,dark:Catppuccin Mocha # follow the terminal
2525 background, and draws the menu bar, the column headers and the status2626 line as solid bands, in the manner of 1-2-3 and classic terminal2727 programs. The terminal's own palette is never changed.28+- **`high-contrast`** is white on black on a dark terminal and black on29+ white on a light one, switching when the terminal does, with every30+ role at WCAG AAA contrast (see [High contrast](#high-contrast)).2831 - **`light:A,dark:B`** picks A on a light terminal and B on a dark one,2932 and switches when the terminal does (for example with the system's3033 appearance, in terminals that report it).
@@ -49,6 +52,21 @@ takes, plus **1-2-3 Classic**: CGA colors on a blue screen. Names match
4952 without regard to case, and then without regard to spaces, hyphens and5053 underscores, so `tokyo-night` finds `TokyoNight`.5154 55+## High contrast56+57+`theme = high-contrast` draws with two built-in schemes, **High Contrast58+Dark** and **High Contrast Light**, picked by the terminal's background;59+name one of them (`theme = high-contrast-light`) to keep it. They hold60+every role to WCAG AAA rather than AA: 7:1 for all text, hints and61+muted text included, and 4.5:1 for lines, borders, chart axes and62+unavailable menu items. The pointer is black on cyan, the selection63+white on blue, errors a light red on black (a dark red on white).64+65+States never rest on color alone in any theme: the pointer and the66+selection are in reverse video, errors have a curly underline, invalid67+entries a dotted one, spilled values are italic, and so on68+([Reading without color](../contributing/ux.md#reading-without-color)).69+5270 ## Your own schemes5371 5472 Put a file in the `themes` directory next to the config file
@@ -91,13 +109,14 @@ well:
91109 text color; the column header row is filled with the header color;92110 - a background role too close to the screen's background is moved apart;93111 - text below its contrast minimum against its background (WCAG 4.5:1 for94- text, 3:1 for hints, lines and chart axes, 2:1 for unavailable items)95- is moved toward the scheme's text color, or toward black or white, just96- far enough to reach it.112+ text, 3:1 for hints, lines and chart axes, 2:1 for unavailable items;113+ the high-contrast schemes' AAA minimums above) is moved toward the114+ scheme's text color, or toward black or white, just far enough to115+ reach it.97116 98117 A test runs every built-in scheme through this and checks those minimums99118 for cell text, text on the bars, the selection, the headers and every100-other role (`TestEveryThemeReadable`). Charts, as text and as kitty119+other role (`TestEveryThemeReadable`, and `TestHighContrast` for AAA). Charts, as text and as kitty101120 images, use the scheme's colors too.102121 103122 Conditional formats ([Conditional formatting and data validation](../sheets/rules.md#conditional-formatting)) name
M e2e/screenlist_test.go +17 -0
M e2e/screensetup_test.go +8 -0
M e2e/testdata/screens/rules-panel.html +1 -1
@@ -5,7 +5,7 @@
55 <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Task</span><span style="background:var(--vt-palette-6)"> </span> <span style="font-weight:bold">Owner</span> <span style="font-weight:bold">Hours</span> <span style="font-weight:bold">Done</span> <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Conditional</span> <span style="font-weight:bold">format</span> <span style="font-weight:bold">rules</span> <span style="color:var(--vt-palette-8)">───────────────────┐</span>66 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-2);text-decoration:line-through">Write</span><span style="text-decoration:line-through"> </span><span style="color:var(--vt-palette-2);text-decoration:line-through">spec</span> Ann <span style="color:var(--vt-palette-8)">▾</span><span style="background:#f0c674"> </span><span style="color:#1d1f21;background:#f0c674">12</span><span style="background:#f0c674"> </span> [✓] <span style="color:var(--vt-palette-8)">│</span> + Add rule <span style="color:var(--vt-palette-8)">│</span>77 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Build UI Bo <span style="color:var(--vt-palette-8)">▾</span><span style="background:#cc6666"> </span><span style="color:#000000;background:#cc6666">40</span><span style="background:#cc6666"> </span> [ ] <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-2);text-decoration:line-through">123</span> A2:A6 Custom formula is =$D2 <span style="color:var(--vt-palette-8)">│</span>8-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-2);text-decoration:line-through">Tests</span> Cy <span style="color:var(--vt-palette-8)">▾</span><span style="background:#de966d"> </span><span style="color:#1d1f21;background:#de966d">26</span><span style="background:#de966d"> </span> [✓] <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="background:#b5bd68"> </span><span style="background:#dcc370"> </span><span style="background:#e4a56f"> </span><span style="background:#cc6666"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:#000000;background:var(--vt-palette-6)">C2:C6</span><span style="background:var(--vt-palette-6)"> </span><span style="color:#000000;background:var(--vt-palette-6)">Color</span><span style="background:var(--vt-palette-6)"> </span><span style="color:#000000;background:var(--vt-palette-6)">scale</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-2);text-decoration:line-through">Tests</span> Cy <span style="color:var(--vt-palette-8)">▾</span><span style="background:#de966d"> </span><span style="color:#1d1f21;background:#de966d">26</span><span style="background:#de966d"> </span> [✓] <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="background:#b5bd68"> </span><span style="background:#dcc370"> </span><span style="background:#e4a56f"> </span><span style="background:#cc6666"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">C2:C6</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Color</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">scale</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>99 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Docs <span style="text-decoration:underline dotted var(--vt-palette-3)">Dee</span> <span style="color:var(--vt-palette-8)">▾</span><span style="background:#c9c06c"> </span><span style="color:#1d1f21;background:#c9c06c">6</span><span style="background:#c9c06c"> </span> [ ] <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-5);font-weight:bold">123</span><span style="background:var(--vt-palette-5)"> </span> C7 Greater than 80 <span style="color:var(--vt-palette-8)">│</span>1010 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Release Ann <span style="color:var(--vt-palette-8)">▾</span><span style="background:#b5bd68"> </span><span style="color:#1d1f21;background:#b5bd68">3</span><span style="background:#b5bd68"> </span> [ ] <span style="color:var(--vt-palette-8)">└────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">3</span> <span style="color:var(--vt-palette-8)">rules</span> <span style="color:var(--vt-palette-8)">─┘</span>1111 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span> Total <span style="background:var(--vt-palette-5)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-5);font-weight:bold">87</span><span style="background:var(--vt-palette-5)"> </span>
M e2e/testdata/screens/spill-light.html +3 -3
@@ -3,9 +3,9 @@
33 <span style="color:var(--vt-palette-0)">Spilled</span> <span style="color:var(--vt-palette-0)">from</span> <span style="font-weight:bold">D2</span><span style="color:var(--vt-palette-0)">;</span> <span style="color:var(--vt-palette-0)">edit</span> <span style="color:var(--vt-palette-0)">the</span> <span style="color:var(--vt-palette-0)">formula</span> <span style="color:var(--vt-palette-0)">there</span>44 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">E</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>55 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span> Household budget 20266-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span> Rent <span style="color:var(--vt-palette-4)">1450</span>7-<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">3</span><span style="background:var(--vt-palette-6)"> </span> Rent 1450 <span style="color:var(--vt-palette-4)">Groceries</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">612.4</span><span style="background:var(--vt-palette-6)"> </span>8-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> Groceries 612.4 <span style="color:var(--vt-palette-4)">Transit</span> <span style="color:var(--vt-palette-4)">96</span>6+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span> Rent <span style="color:var(--vt-palette-4);font-style:italic">1450</span>7+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">3</span><span style="background:var(--vt-palette-6)"> </span> Rent 1450 <span style="color:var(--vt-palette-4);font-style:italic">Groceries</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">612.4</span><span style="background:var(--vt-palette-6)"> </span>8+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> Groceries 612.4 <span style="color:var(--vt-palette-4);font-style:italic">Transit</span> <span style="color:var(--vt-palette-4);font-style:italic">96</span>99 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span> Transit 961010 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span>1111 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span> Total 2158.4
M e2e/testdata/screens/spill.html +3 -3
@@ -3,9 +3,9 @@
33 <span style="color:var(--vt-palette-8)">Spilled</span> <span style="color:var(--vt-palette-8)">from</span> <span style="font-weight:bold">D2</span><span style="color:var(--vt-palette-8)">;</span> <span style="color:var(--vt-palette-8)">edit</span> <span style="color:var(--vt-palette-8)">the</span> <span style="color:var(--vt-palette-8)">formula</span> <span style="color:var(--vt-palette-8)">there</span>44 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">E</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>55 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> Household budget 20266-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> Rent <span style="color:var(--vt-palette-6)">1450</span>7-<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">3</span><span style="background:var(--vt-palette-6)"> </span> Rent 1450 <span style="color:var(--vt-palette-6)">Groceries</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">612.4</span><span style="background:var(--vt-palette-6)"> </span>8-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Groceries 612.4 <span style="color:var(--vt-palette-6)">Transit</span> <span style="color:var(--vt-palette-6)">96</span>6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> Rent <span style="color:var(--vt-palette-6);font-style:italic">1450</span>7+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">3</span><span style="background:var(--vt-palette-6)"> </span> Rent 1450 <span style="color:var(--vt-palette-6);font-style:italic">Groceries</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">612.4</span><span style="background:var(--vt-palette-6)"> </span>8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Groceries 612.4 <span style="color:var(--vt-palette-6);font-style:italic">Transit</span> <span style="color:var(--vt-palette-6);font-style:italic">96</span>99 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Transit 961010 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span>1111 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span> Total 2158.4
A e2e/testdata/screens/theme-high-contrast-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0;text-decoration:underline">F</span><span style="color:#000000;background:#e0e0e0">ile</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0;text-decoration:underline">E</span><span style="color:#000000;background:#e0e0e0">dit</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0;text-decoration:underline">V</span><span style="color:#000000;background:#e0e0e0">iew</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0;text-decoration:underline">I</span><span style="color:#000000;background:#e0e0e0">nsert</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">F</span><span style="color:#000000;background:#e0e0e0;text-decoration:underline">o</span><span style="color:#000000;background:#e0e0e0">rmat</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0;text-decoration:underline">D</span><span style="color:#000000;background:#e0e0e0">ata</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0;text-decoration:underline">H</span><span style="color:#000000;background:#e0e0e0">elp</span><span style="background:#e0e0e0"> </span><span style="background:#a0e8ff"> </span><span style="color:#000000;background:#a0e8ff;font-weight:bold">READY</span><span style="background:#a0e8ff"> </span>2+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">B3:B5</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">1450</span><span style="background:#ffffff"> </span>3+<span style="background:#ffffff"> </span>4+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">A</span><span style="background:#d0d0d0"> </span><span style="background:#a0e8ff"> </span><span style="color:#000000;background:#a0e8ff;font-weight:bold">B</span><span style="background:#a0e8ff"> </span><span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">C</span><span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">D</span><span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">E</span><span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">F</span><span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">G</span><span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">H</span><span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">I</span><span style="background:#d0d0d0"> </span>5+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">1</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">Household</span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">budget</span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">2026</span><span style="background:#ffffff"> </span>6+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">2</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>7+<span style="background:#a0e8ff"> </span><span style="color:#000000;background:#a0e8ff;font-weight:bold">3</span><span style="background:#a0e8ff"> </span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">Rent</span><span style="background:#ffffff"> </span><span style="background:#a0e8ff"> </span><span style="color:#000000;background:#a0e8ff">1450</span><span style="background:#a0e8ff"> </span><span style="background:#ffffff"> </span>8+<span style="background:#0033cc"> </span><span style="color:#ffffff;background:#0033cc">4</span><span style="background:#0033cc"> </span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">Groceries</span><span style="background:#0033cc"> </span><span style="color:#ffffff;background:#0033cc">612.4</span><span style="background:#0033cc"> </span><span style="background:#ffffff"> </span>9+<span style="background:#0033cc"> </span><span style="color:#ffffff;background:#0033cc">5</span><span style="background:#0033cc"> </span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">Transit</span><span style="background:#ffffff"> </span><span style="background:#0033cc"> </span><span style="color:#ffffff;background:#0033cc">96</span><span style="background:#0033cc"> </span><span style="background:#ffffff"> </span>10+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">6</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">Savings</span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">r</span><span style="background:#ffffff"> </span><span style="color:#a00000;background:#ffffff;text-decoration:underline wavy #a00000">#DIV/0!</span><span style="background:#ffffff"> </span>11+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">7</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">Total</span><span style="background:#ffffff"> </span><span style="color:#000000;background:#ffffff">2158.4</span><span style="background:#ffffff"> </span>12+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">8</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>13+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">9</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>14+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">10</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>15+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">11</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>16+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">12</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>17+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">13</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>18+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">14</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>19+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">15</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>20+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">16</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>21+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">17</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>22+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">18</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>23+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">19</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>24+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">20</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>25+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">21</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>26+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">22</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>27+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">23</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>28+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">24</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>29+<span style="background:#d0d0d0"> </span><span style="color:#000000;background:#d0d0d0">25</span><span style="background:#d0d0d0"> </span><span style="background:#ffffff"> </span>30+<span style="background:#a0e8ff"> </span><span style="color:#000000;background:#a0e8ff;font-weight:bold">Sheet1</span><span style="background:#a0e8ff"> </span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">+</span><span style="background:#e0e0e0"> </span><span style="color:#6a6a6a;background:#e0e0e0">│</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">untitled</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">modified</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0;font-weight:bold">B3:B5</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">Sum</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">2158.4</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">Avg</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">719.47</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">Count</span><span style="background:#e0e0e0"> </span><span style="color:#000000;background:#e0e0e0">3</span></pre>\ No newline at end of file
A e2e/testdata/screens/theme-high-contrast-menu.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;text-decoration:underline">F</span><span style="color:#000000;background:#40e0ff">ile</span><span style="background:#40e0ff"> </span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">E</span><span style="color:#ffffff;background:#1f1f1f">dit</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">V</span><span style="color:#ffffff;background:#1f1f1f">iew</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">I</span><span style="color:#ffffff;background:#1f1f1f">nsert</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">F</span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">o</span><span style="color:#ffffff;background:#1f1f1f">rmat</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">D</span><span style="color:#ffffff;background:#1f1f1f">ata</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">H</span><span style="color:#ffffff;background:#1f1f1f">elp</span><span style="background:#1f1f1f"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">MENU</span><span style="background:#40e0ff"> </span>2+<span style="color:#757575;background:#000000">┌─────────────────────┐</span><span style="background:#000000"> </span>3+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">New</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span>4+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Open</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+O</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#40e0ff"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">C</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">D</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">E</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">G</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">H</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">I</span><span style="background:#3a3a3a"> </span>5+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Import</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">2026</span><span style="background:#000000"> </span>6+<span style="color:#757575;background:#000000">├─────────────────────┤</span><span style="background:#000000"> </span>7+<span style="color:#757575;background:#000000">│</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">Save</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">Ctrl+S</span><span style="background:#40e0ff"> </span><span style="color:#757575;background:#000000">│</span><span style="color:#ffffff;background:#000000">50</span><span style="background:#000000"> </span>8+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Save</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">as</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="color:#ffffff;background:#000000">.4</span><span style="background:#000000"> </span>9+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Download</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">›</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="color:#ffffff;background:#000000">96</span><span style="background:#000000"> </span>10+<span style="color:#757575;background:#000000">├─────────────────────┤</span><span style="color:#ff8080;background:#000000;text-decoration:underline wavy #ff8080">!</span><span style="background:#000000"> </span>11+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Settings</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">›</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="color:#000000;background:#40e0ff">.4</span><span style="background:#40e0ff"> </span><span style="background:#000000"> </span>12+<span style="color:#757575;background:#000000">├─────────────────────┤</span><span style="background:#000000"> </span>13+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Quit</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+Q</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span>14+<span style="color:#757575;background:#000000">└─────────────────────┘</span><span style="background:#000000"> </span>15+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">11</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>16+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">12</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>17+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">13</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>18+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">14</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>19+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">15</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>20+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">16</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>21+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">17</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>22+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">18</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>23+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">19</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>24+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">20</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>25+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">21</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>26+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">22</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>27+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">23</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>28+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">24</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>29+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">25</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>30+<span style="color:#ffffff;background:#1f1f1f">Save</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">the</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">sheet</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Arrows</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">move</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Enter</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">choose</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Esc</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">close</span></pre>\ No newline at end of file
A e2e/testdata/screens/theme-high-contrast.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">F</span><span style="color:#ffffff;background:#1f1f1f">ile</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">E</span><span style="color:#ffffff;background:#1f1f1f">dit</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">V</span><span style="color:#ffffff;background:#1f1f1f">iew</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">I</span><span style="color:#ffffff;background:#1f1f1f">nsert</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">F</span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">o</span><span style="color:#ffffff;background:#1f1f1f">rmat</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">D</span><span style="color:#ffffff;background:#1f1f1f">ata</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">H</span><span style="color:#ffffff;background:#1f1f1f">elp</span><span style="background:#1f1f1f"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">READY</span><span style="background:#40e0ff"> </span>2+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">B3:B5</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">1450</span><span style="background:#000000"> </span>3+<span style="background:#000000"> </span>4+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">A</span><span style="background:#3a3a3a"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">B</span><span style="background:#40e0ff"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">C</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">D</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">E</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">G</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">H</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">I</span><span style="background:#3a3a3a"> </span>5+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">1</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Household</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">budget</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">2026</span><span style="background:#000000"> </span>6+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">2</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>7+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">3</span><span style="background:#40e0ff"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Rent</span><span style="background:#000000"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">1450</span><span style="background:#40e0ff"> </span><span style="background:#000000"> </span>8+<span style="background:#0033cc"> </span><span style="color:#ffffff;background:#0033cc">4</span><span style="background:#0033cc"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Groceries</span><span style="background:#0033cc"> </span><span style="color:#ffffff;background:#0033cc">612.4</span><span style="background:#0033cc"> </span><span style="background:#000000"> </span>9+<span style="background:#0033cc"> </span><span style="color:#ffffff;background:#0033cc">5</span><span style="background:#0033cc"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Transit</span><span style="background:#000000"> </span><span style="background:#0033cc"> </span><span style="color:#ffffff;background:#0033cc">96</span><span style="background:#0033cc"> </span><span style="background:#000000"> </span>10+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">6</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Savings</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">r</span><span style="background:#000000"> </span><span style="color:#ff8080;background:#000000;text-decoration:underline wavy #ff8080">#DIV/0!</span><span style="background:#000000"> </span>11+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">7</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Total</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">2158.4</span><span style="background:#000000"> </span>12+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">8</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>13+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">9</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>14+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">10</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>15+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">11</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>16+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">12</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>17+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">13</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>18+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">14</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>19+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">15</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>20+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">16</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>21+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">17</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>22+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">18</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>23+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">19</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>24+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">20</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>25+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">21</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>26+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">22</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>27+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">23</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>28+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">24</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>29+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">25</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>30+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">Sheet1</span><span style="background:#40e0ff"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">+</span><span style="background:#1f1f1f"> </span><span style="color:#757575;background:#1f1f1f">│</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">untitled</span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">modified</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;font-weight:bold">B3:B5</span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">Sum</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">2158.4</span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">Avg</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">719.47</span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">Count</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">3</span></pre>\ No newline at end of file
M e2e/testdata/screens/theme-picker.html +1 -1
@@ -5,7 +5,7 @@
55 <span style="background:#928374"> </span><span style="color:#211e19;background:#928374">1</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Household</span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">b</span><span style="color:#928374;background:#282828">│</span><span style="background:#689d6a"> </span><span style="color:#282828;background:#689d6a;font-weight:bold;text-decoration:underline">Gruvbox</span><span style="color:#282828;background:#689d6a">Dark</span><span style="background:#689d6a"> </span><span style="color:#282828;background:#689d6a">dark</span><span style="background:#689d6a"> </span><span style="color:#928374;background:#282828">│</span><span style="background:#282828"> </span>66 <span style="background:#928374"> </span><span style="color:#211e19;background:#928374">2</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">│</span><span style="background:#282828"> </span><span style="color:#d79921;background:#282828;font-weight:bold">Gruvbox</span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Light</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">light</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">│</span><span style="background:#282828"> </span>77 <span style="background:#928374"> </span><span style="color:#211e19;background:#928374">3</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Rent</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">│</span><span style="background:#282828"> </span><span style="color:#d79921;background:#282828;font-weight:bold">Gruvbox</span><span style="color:#ebdbb2;background:#282828">DarkHard</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">dark</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">│</span><span style="background:#282828"> </span>8-<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">4</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Groceries</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">└───────────────────────────────────────────────────</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">3</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">of</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">350</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">─┘</span><span style="background:#282828"> </span>8+<span style="background:#928374"> </span><span style="color:#211e19;background:#928374">4</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Groceries</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">└───────────────────────────────────────────────────</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">3</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">of</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">353</span><span style="background:#282828"> </span><span style="color:#928374;background:#282828">─┘</span><span style="background:#282828"> </span>99 <span style="background:#928374"> </span><span style="color:#211e19;background:#928374">5</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Transit</span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">96</span><span style="background:#282828"> </span>1010 <span style="background:#928374"> </span><span style="color:#211e19;background:#928374">6</span><span style="background:#928374"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Savings</span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">r</span><span style="background:#282828"> </span><span style="color:#d9705a;background:#282828;text-decoration:underline wavy #cc241d">#DIV/0!</span><span style="background:#282828"> </span>1111 <span style="background:#689d6a"> </span><span style="color:#282828;background:#689d6a;font-weight:bold">7</span><span style="background:#689d6a"> </span><span style="background:#282828"> </span><span style="color:#ebdbb2;background:#282828">Total</span><span style="background:#282828"> </span><span style="background:#689d6a"> </span><span style="color:#282828;background:#689d6a">2158.4</span><span style="background:#689d6a"> </span><span style="background:#282828"> </span>
M internal/config/registry.go +2 -1
M internal/ui/links.go +1 -1
A internal/ui/monochrome_test.go +304 -0
@@ -0,0 +1,304 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+ "github.com/FelineStateMachine/012/internal/ui/theme"9+)10+11+// Every state reads without color (docs/contributing/ux.md#visual-rules):12+// drawn with a monochrome theme, where every role has the same color,13+// each state still differs from a plain cell in its text or attributes.14+15+// attrs are the attributes a terminal cell is drawn with, colors aside.16+type attrs struct {17+ bold, faint, italic, reverse, strike bool18+ underline string // "" none, or the SGR 4 sub-parameter: 1 single, 3 curly, 4 dotted, 5 dashed19+}20+21+// monoCell is one terminal cell: its text and attributes.22+type monoCell struct {23+ text string24+ attrs25+}26+27+// monoCells splits a rendered line into cells, reading SGR attributes28+// and skipping colors and other escape sequences.29+func monoCells(line string) []monoCell {30+ var out []monoCell31+ var a attrs32+ for i := 0; i < len(line); {33+ switch {34+ case strings.HasPrefix(line[i:], "\x1b["):35+ j := i + 236+ for j < len(line) && (line[j] < 0x40 || line[j] > 0x7e) {37+ j++38+ }39+ if j < len(line) && line[j] == 'm' {40+ sgr(line[i+2:j], &a)41+ }42+ i = j + 143+ case strings.HasPrefix(line[i:], "\x1b]"): // OSC, e.g. a hyperlink, up to ST or BEL44+ end := strings.Index(line[i:], "\x1b\\")45+ bel := strings.IndexByte(line[i:], '\a')46+ switch {47+ case bel >= 0 && (end < 0 || bel < end):48+ i += bel + 149+ case end >= 0:50+ i += end + 251+ default:52+ i = len(line)53+ }54+ default:55+ r := []rune(line[i:])[0]56+ out = append(out, monoCell{text: string(r), attrs: a})57+ i += len(string(r))58+ }59+ }60+ return out61+}62+63+// sgr applies one SGR sequence's parameters to a.64+func sgr(params string, a *attrs) {65+ fields := strings.Split(params, ";")66+ for i := 0; i < len(fields); i++ {67+ p, sub, _ := strings.Cut(fields[i], ":")68+ switch p {69+ case "", "0":70+ *a = attrs{}71+ case "1":72+ a.bold = true73+ case "2":74+ a.faint = true75+ case "3":76+ a.italic = true77+ case "4":78+ a.underline = sub79+ if sub == "" {80+ a.underline = "1"81+ } else if sub == "0" {82+ a.underline = ""83+ }84+ case "7":85+ a.reverse = true86+ case "9":87+ a.strike = true88+ case "22":89+ a.bold, a.faint = false, false90+ case "23":91+ a.italic = false92+ case "24":93+ a.underline = ""94+ case "27":95+ a.reverse = false96+ case "29":97+ a.strike = false98+ case "38", "48", "58":99+ if sub == "" && i+1 < len(fields) {100+ switch fields[i+1] {101+ case "5":102+ i += 2103+ case "2":104+ i += 4105+ }106+ }107+ }108+ }109+}110+111+// monoLine is screen line y drawn in monochrome, as cells.112+func monoLine(m *Model, y int) []monoCell {113+ m.th = theme.Monochrome(m.th)114+ return monoCells(strings.Split(m.View().Content, "\n")[y])115+}116+117+// cellAt is the terminal cells of sheet cell a (in the first screen of118+// an unscrolled sheet).119+func cellAt(m *Model, a sheet.Addr) []monoCell {120+ x := m.hdrW() + a.Col*sheet.DefaultWidth121+ cells := monoLine(m, gridTop+a.Row)122+ return cells[x : x+sheet.DefaultWidth]123+}124+125+func cellText(cs []monoCell) string {126+ var b strings.Builder127+ for _, c := range cs {128+ b.WriteString(c.text)129+ }130+ return b.String()131+}132+133+// every reports whether f holds for every cell of cs; with text, only for134+// those that aren't blank.135+func every(cs []monoCell, text bool, f func(monoCell) bool) bool {136+ for _, c := range cs {137+ if text && strings.TrimSpace(c.text) == "" {138+ continue139+ }140+ if !f(c) {141+ return false142+ }143+ }144+ return true145+}146+147+func TestMonochromeSheetStates(t *testing.T) {148+ isReverse := func(c monoCell) bool { return c.reverse }149+ plain := func(c monoCell) bool { return c.attrs == attrs{} }150+151+ t.Run("pointer and plain cell", func(t *testing.T) {152+ m := newModel()153+ m.sheet.Set(addr("B1"), "text")154+ if p := cellAt(m, addr("A1")); !every(p, false, isReverse) {155+ t.Errorf("the pointer isn't in reverse video: %+v", p)156+ }157+ if c := cellAt(m, addr("B1")); !every(c, false, plain) {158+ t.Errorf("a plain cell has attributes: %+v", c)159+ }160+ })161+162+ t.Run("selection and headers", func(t *testing.T) {163+ m := newModel()164+ press(t, m, "<shift+right>", "<shift+down>")165+ for _, a := range []string{"A1", "B1", "A2", "B2"} {166+ if c := cellAt(m, addr(a)); !every(c, false, isReverse) {167+ t.Errorf("%s in the selection isn't in reverse video", a)168+ }169+ }170+ if c := cellAt(m, addr("C3")); !every(c, false, plain) {171+ t.Error("a cell outside the selection has attributes")172+ }173+ // Column headers: the active cell's (the selection's anchor, A1)174+ // bold and reversed, another selected one reversed, the rest plain.175+ hdr := monoLine(m, headerLine)176+ x := m.hdrW()177+ col := func(c int) []monoCell { return hdr[x+c*sheet.DefaultWidth : x+(c+1)*sheet.DefaultWidth] }178+ if !every(col(0), true, func(c monoCell) bool { return c.reverse && c.bold }) {179+ t.Errorf("the active column's header: %+v", col(0))180+ }181+ if !every(col(1), true, func(c monoCell) bool { return c.reverse && !c.bold }) {182+ t.Errorf("a selected column's header: %+v", col(1))183+ }184+ if every(col(2), true, isReverse) {185+ t.Error("an unselected column's header is reversed")186+ }187+ if !strings.Contains(cellText(monoLine(m, formulaLine)), "A1:B2") {188+ t.Error("the name box doesn't name the selection")189+ }190+ })191+192+ t.Run("error", func(t *testing.T) {193+ m := newModel()194+ m.sheet.Set(addr("B1"), "=1/0")195+ c := cellAt(m, addr("B1"))196+ if !strings.Contains(cellText(c), "#DIV/0!") || !every(c, true, func(c monoCell) bool { return c.underline == "3" }) {197+ t.Errorf("an error cell: %q %+v", cellText(c), c)198+ }199+ })200+201+ t.Run("invalid entry and its warning", func(t *testing.T) {202+ m := newModel()203+ mustValidate(t, m, `{"ranges":"B1:B9","criteria":"number","condition":"between","values":["1","10"]}`)204+ m.sheet.Set(addr("B1"), "42")205+ c := cellAt(m, addr("B1"))206+ if !every(c, true, func(c monoCell) bool { return c.underline == "4" }) {207+ t.Errorf("an invalid cell isn't dotted: %+v", c)208+ }209+ m.cur = addr("B1")210+ if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "Invalid:") {211+ t.Errorf("no warning in words: %q", l)212+ }213+ })214+215+ t.Run("circular reference", func(t *testing.T) {216+ m := newModel()217+ m.sheet.Set(addr("A1"), "=A1+1")218+ if l := cellText(monoLine(m, m.height-1)); !strings.Contains(l, "Circular reference") {219+ t.Errorf("status line: %q", l)220+ }221+ })222+223+ t.Run("spill", func(t *testing.T) {224+ m := newModel()225+ m.sheet.Set(addr("B1"), "=SEQUENCE(3)")226+ m.cur = addr("D1")227+ c := cellAt(m, addr("B2"))228+ if strings.TrimSpace(cellText(c)) != "2" || !every(c, true, func(c monoCell) bool { return c.italic }) {229+ t.Errorf("a spilled value isn't italic: %+v", c)230+ }231+ m.cur = addr("B2")232+ if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "Spilled from B1") {233+ t.Errorf("context line: %q", l)234+ }235+ })236+237+ t.Run("note", func(t *testing.T) {238+ m := newModel()239+ m.sheet.Set(addr("B1"), "x")240+ m.sheet.SetNote(addr("B1"), "Checked")241+ if c := cellAt(m, addr("B1")); c[len(c)-1].text != "▝" {242+ t.Errorf("no note mark: %q", cellText(c))243+ }244+ })245+246+ t.Run("found and traced", func(t *testing.T) {247+ m := traceModel(t)248+ m.cur = addr("B1")249+ press(t, m, "<alt+,>")250+ if c := cellAt(m, addr("A2")); !every(c, false, isReverse) {251+ t.Errorf("a traced cell isn't reversed: %+v", c)252+ }253+ m = newModel()254+ m.sheet.Set(addr("B2"), "Rent")255+ press(t, m, "<ctrl+f>", "Rent")256+ if c := cellAt(m, addr("B2")); !every(c, false, isReverse) {257+ t.Errorf("a match isn't reversed: %+v", c)258+ }259+ })260+261+ t.Run("copied", func(t *testing.T) {262+ m := newModel()263+ m.sheet.Set(addr("A1"), "1")264+ press(t, m, "<ctrl+c>", "<right>")265+ c := cellAt(m, addr("A1"))266+ if !every(c, false, func(c monoCell) bool { return c.underline != "" }) || !every(c, true, func(c monoCell) bool { return c.underline == "5" }) {267+ t.Errorf("the copied range isn't underlined, dashed under its text: %+v", c)268+ }269+ })270+}271+272+// States told in words: the mode indicator, recording, protected ranges273+// and pivot tables.274+func TestMonochromeStatesInWords(t *testing.T) {275+ m := newModel()276+ if l := cellText(monoLine(m, menuLine)); !strings.Contains(l, "READY") {277+ t.Errorf("indicator: %q", l)278+ }279+ press(t, m, "1")280+ if l := cellText(monoLine(m, menuLine)); !strings.Contains(l, "ENTER") {281+ t.Errorf("indicator while typing: %q", l)282+ }283+ press(t, m, "<esc>")284+ run(m, m.runCommand("macro.record"))285+ if l := cellText(monoLine(m, menuLine)); !strings.Contains(l, "REC") {286+ t.Errorf("recording: %q", l)287+ }288+289+ m = newModel()290+ press(t, m, "<shift+down>")291+ m.runCommand("data.protect_range")292+ press(t, m, "<enter>", "<esc>", "7")293+ if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "is protected") {294+ t.Errorf("protected: %q", l)295+ }296+297+ m = wideModel()298+ salesTable(t, m)299+ m.runCommand("data.pivot")300+ press(t, m, "<space>", "reg", "<enter>", "<enter>", "<ctrl+home>", "x")301+ if l := cellText(monoLine(m, contextLine)); !strings.Contains(l, "Pivot table results can't be edited") {302+ t.Errorf("pivot: %q", l)303+ }304+}
M internal/ui/theme/contrast.go +1 -0
A internal/ui/theme/highcontrast.go +92 -0
@@ -0,0 +1,92 @@
1+package theme2+3+import "strings"4+5+// HighContrast is the theme name for the high-contrast schemes: the dark6+// one on a dark terminal, the light one on a light terminal, switching7+// when the terminal does. HighContrastDark and HighContrastLight name8+// one of them.9+const (10+ HighContrast = "high-contrast"11+ HighContrastDark = "High Contrast Dark"12+ HighContrastLight = "High Contrast Light"13+)14+15+// The high-contrast schemes: white on black and black on white, with16+// ANSI colors picked so the roles defined on them (black text on the17+// pointer's cyan, the found cells' yellow, the traced cells' green) meet18+// WCAG AAA before correction, and a selection in saturated blue with19+// white text. FromPalette then holds every role to AAA (aaa below).20+const highContrastDark = `# High Contrast Dark: WCAG AAA (7:1) text.21+background = #00000022+foreground = #ffffff23+selection-background = #0033cc24+palette = 0=#00000025+palette = 1=#ff808026+palette = 2=#40e04027+palette = 3=#ffe04028+palette = 4=#80a8ff29+palette = 5=#ff80ff30+palette = 6=#40e0ff31+palette = 7=#e0e0e032+palette = 8=#3a3a3a33+palette = 9=#ffa0a034+palette = 10=#80ff8035+palette = 11=#ffff8036+palette = 12=#a0c0ff37+palette = 13=#ffa0ff38+palette = 14=#a0f0ff39+palette = 15=#ffffff40+`41+42+const highContrastLight = `# High Contrast Light: WCAG AAA (7:1) text.43+background = #ffffff44+foreground = #00000045+selection-background = #0033cc46+palette = 0=#00000047+palette = 1=#a0000048+palette = 2=#b0f0b049+palette = 3=#ffe07050+palette = 4=#0033cc51+palette = 5=#80008052+palette = 6=#a0e8ff53+palette = 7=#d0d0d054+palette = 8=#6a6a6a55+palette = 9=#a0000056+palette = 10=#005a0057+palette = 11=#6a4a0058+palette = 12=#0033cc59+palette = 13=#80008060+palette = 14=#005a6a61+palette = 15=#ffffff62+`63+64+// highContrastPalettes are the two schemes, dark first.65+func highContrastPalettes() []Palette {66+ var out []Palette67+ for _, s := range []struct{ name, text string }{{HighContrastDark, highContrastDark}, {HighContrastLight, highContrastLight}} {68+ p, _, err := parseText(s.text)69+ if err != nil {70+ panic("theme: " + s.name + ": " + err.Error())71+ }72+ p.Name, p.HighContrast = s.name, true73+ out = append(out, p)74+ }75+ return out76+}77+78+// isHighContrast reports whether name is HighContrast, loosely matched79+// as Lookup matches names.80+func isHighContrast(name string) bool {81+ return squash(strings.TrimSpace(name)) == squash(HighContrast)82+}83+84+// highContrastFor is the high-contrast scheme for a dark or light85+// terminal.86+func highContrastFor(dark bool) Palette {87+ ps := highContrastPalettes()88+ if dark {89+ return ps[0]90+ }91+ return ps[1]92+}
A internal/ui/theme/monochrome.go +32 -0
M internal/ui/theme/palette.go +9 -6
@@ -27,6 +27,9 @@ type Palette struct {
2727 Selection color.Color // may be nil2828 Dark bool2929 File string // the file it came from; empty for built-ins30+ // HighContrast holds every role to WCAG AAA (7:1 for text) instead31+ // of AA: the built-in high-contrast schemes.32+ HighContrast bool3033 }3134 3235 // Terminal is the theme name that keeps the terminal's own palette.
@@ -74,7 +77,7 @@ var builtins = sync.OnceValue(func() []Palette {
7477 panic("theme: classic: " + err.Error())7578 }7679 c.Name = ClassicName77- return append([]Palette{c}, ps...)80+ return append(append([]Palette{c}, highContrastPalettes()...), ps...)7881 })7982 8083 // Builtins returns the built-in schemes.
@@ -87,12 +90,12 @@ type Entry struct {
8790 User bool // a file in the themes directory8891 }8992 90-// List returns the themes available: terminal first, then the files in91-// dir (the user's themes directory, may be ""), then the built-ins, each92-// group sorted by name case-insensitively. A file with a built-in's name93-// hides the built-in.93+// List returns the themes available: terminal and high-contrast first,94+// then the files in dir (the user's themes directory, may be ""), then95+// the built-ins, each group sorted by name case-insensitively. A file96+// with a built-in's name hides the built-in.9497 func List(dir string) []Entry {95- out := []Entry{{Name: Terminal, Dark: true}}98+ out := []Entry{{Name: Terminal, Dark: true}, {Name: HighContrast, Dark: true}}9699 seen := map[string]bool{}97100 var user []Entry98101 for _, p := range userPalettes(dir) {
M internal/ui/theme/scheme.go +42 -15
@@ -27,9 +27,12 @@ const barBlend = 0.12
2727 // background is moved toward the scheme's text color just far enough2828 // to meet it, or toward black or white.2929 func FromPalette(p Palette) Theme {30- t := New(p.Dark)30+ t := roles(p.Dark)3131 t.Name, t.Palette = p.Name, &p32- text := readable(p.Foreground, p.Background, nil, minText)32+ if p.HighContrast {33+ t.levels = aaa34+ }35+ text := readable(p.Foreground, p.Background, nil, t.levels.text)3336 t.Screen = lipgloss.NewStyle().Background(p.Background).Foreground(text)3437 eachRole(&t, func(_ string, s *lipgloss.Style) { *s = mapStyle(*s, &p) })3538 if p.Selection != nil && contrast(p.Selection, p.Background) >= minDistinct {
@@ -42,8 +45,9 @@ func FromPalette(p Palette) Theme {
4245 }4346 bar := lipgloss.NewStyle().Background(blend(p.Background, text, barBlend)).Foreground(text)4447 t.MenuBarRow, t.StatusBarRow = bar, bar45- eachRole(&t, func(name string, s *lipgloss.Style) { *s = fixContrast(name, *s, text, &p) })48+ eachRole(&t, func(name string, s *lipgloss.Style) { *s = fixContrast(name, *s, text, &p, t.levels) })4649 t.ColumnHeaderRow = lipgloss.NewStyle().Background(t.Header.GetBackground()).Foreground(t.Header.GetForeground())50+ t.standouts()4751 return t4852 }4953
@@ -92,21 +96,38 @@ func slot(c color.Color, p *Palette) (color.Color, bool) {
9296 return nil, false9397 }9498 99+// levels are the contrast minimums a theme's roles are held to.100+type levels struct {101+ text float64 // cell text, bars, menus, chips: every other role102+ secondary float64 // hints, muted text, marks and chart labels103+ lines float64 // borders, rules, chart axes and swatches, progress104+ disabled float64 // unavailable items105+}106+107+var (108+ // aa is WCAG AA, every scheme's floor.109+ aa = levels{text: minText, secondary: minSecondary, lines: minSecondary, disabled: minDisabled}110+ // aaa is WCAG AAA, the high-contrast schemes': 7:1 for all text,111+ // hints included, and 4.5:1 for lines and unavailable items.112+ aaa = levels{text: minTextAAA, secondary: minTextAAA, lines: minText, disabled: minText}113+)114+95115 // minContrast is the contrast minimum for a role's text.96-func minContrast(role string) float64 {116+func minContrast(role string, lv levels) float64 {97117 switch role {98118 case "Disabled":99- return minDisabled100- case "Hint", "Muted", "Border", "FrozenLine", "ChartFrame", "ChartAxis", "ChartLabel",101- "Progress", "ProgressTodo", "Copied", "Series", "Match", "Dropdown", "NoteMark":102- return minSecondary119+ return lv.disabled120+ case "Hint", "Muted", "ChartLabel", "Match", "Dropdown", "NoteMark":121+ return lv.secondary122+ case "Border", "FrozenLine", "ChartFrame", "ChartAxis", "Progress", "ProgressTodo", "Copied", "Series":123+ return lv.lines103124 }104- return minText125+ return lv.text105126 }106127 107128 // fixContrast keeps a role's background apart from the screen and its108129 // text readable on it.109-func fixContrast(role string, s lipgloss.Style, text color.Color, p *Palette) lipgloss.Style {130+func fixContrast(role string, s lipgloss.Style, text color.Color, p *Palette, lv levels) lipgloss.Style {110131 if role == "Screen" {111132 return s112133 }
@@ -119,11 +140,11 @@ func fixContrast(role string, s lipgloss.Style, text color.Color, p *Palette) li
119140 }120141 }121142 if c := s.GetForeground(); !isNoColor(c) && role != "SeriesBg" {122- s = s.Foreground(readable(c, bg, text, minContrast(role)))143+ s = s.Foreground(readable(c, bg, text, minContrast(role, lv)))123144 } else if !isNoColor(s.GetBackground()) && role != "SeriesBg" {124145 // Text on a background role with no color of its own is drawn in125146 // the screen's text color.126- s = s.Foreground(readable(text, bg, nil, minContrast(role)))147+ s = s.Foreground(readable(text, bg, nil, minContrast(role, lv)))127148 }128149 return s129150 }
@@ -133,13 +154,19 @@ func isNoColor(c color.Color) bool {
133154 return c == nil || none134155 }135156 136-// Resolve returns the theme called name: the terminal theme's variant137-// for a dark or light terminal, or a scheme found by Lookup in dir. An138-// unknown scheme gives the terminal theme and the error.157+// Resolve returns the theme called name: the terminal theme's or the158+// high-contrast scheme's variant for a dark or light terminal, or a159+// scheme found by Lookup in dir. An unknown scheme gives the terminal160+// theme and the error.139161 func Resolve(name string, dark bool, dir string) (Theme, error) {140162 if name == "" || name == Terminal {141163 return New(dark), nil142164 }165+ if isHighContrast(name) {166+ t := FromPalette(highContrastFor(dark))167+ t.Name = HighContrast168+ return t, nil169+ }143170 p, err := Lookup(name, dir)144171 if err != nil {145172 return New(dark), err
M internal/ui/theme/scheme_test.go +66 -33
@@ -48,43 +48,75 @@ func requireContrast(t *testing.T, theme, what string, fg, bg color.Color, min f
4848 4949 // Every built-in scheme, run through the role derivation, keeps text5050 // readable: on cells, on the bars, in the selection and the headers, and51-// every other role at its minimum.51+// every other role at its minimum. The high-contrast schemes are held to52+// WCAG AAA.5253 func TestEveryThemeReadable(t *testing.T) {5354 for _, p := range Builtins() {54- th := FromPalette(p)55- screenBg, text := th.Screen.GetBackground(), th.Screen.GetForeground()56- requireContrast(t, p.Name, "cell text", text, screenBg, minText)57- for _, bar := range []struct {58- name string59- s lipgloss.Style60- }{{"menu bar", th.MenuBarRow}, {"status bar", th.StatusBarRow}, {"column header row", th.ColumnHeaderRow}} {61- requireContrast(t, p.Name, bar.name+" text", bar.s.GetForeground(), bar.s.GetBackground(), minText)62- if c := contrast(bar.s.GetBackground(), screenBg); c < minDistinct-0.01 {63- t.Errorf("%s: %s doesn't stand out from the screen (%.2f)", p.Name, bar.name, c)64- }55+ requireReadable(t, p.Name, FromPalette(p))56+ }57+}58+59+// requireReadable checks a scheme's roles against its minimums.60+func requireReadable(t *testing.T, name string, th Theme) {61+ t.Helper()62+ lv := th.levels63+ screenBg, text := th.Screen.GetBackground(), th.Screen.GetForeground()64+ requireContrast(t, name, "cell text", text, screenBg, lv.text)65+ for _, bar := range []struct {66+ name string67+ s lipgloss.Style68+ }{{"menu bar", th.MenuBarRow}, {"status bar", th.StatusBarRow}, {"column header row", th.ColumnHeaderRow}} {69+ requireContrast(t, name, bar.name+" text", bar.s.GetForeground(), bar.s.GetBackground(), lv.text)70+ if c := contrast(bar.s.GetBackground(), screenBg); c < minDistinct-0.01 {71+ t.Errorf("%s: %s doesn't stand out from the screen (%.2f)", name, bar.name, c)6572 }66- for _, r := range []struct {67- name string68- s lipgloss.Style69- }{70- {"selection", th.Selection}, {"header", th.Header}, {"row header", th.RowHeader},71- {"active header", th.HeaderActive}, {"selected header", th.HeaderSel}, {"pointer", th.Pointer},72- {"key chip", th.KeyChip}, {"menu selection", th.MenuSelected}, {"indicator", th.Indicator},73- } {74- requireContrast(t, p.Name, r.name, r.s.GetForeground(), r.s.GetBackground(), minText)73+ }74+ for _, r := range []struct {75+ name string76+ s lipgloss.Style77+ }{78+ {"selection", th.Selection}, {"header", th.Header}, {"row header", th.RowHeader},79+ {"active header", th.HeaderActive}, {"selected header", th.HeaderSel}, {"pointer", th.Pointer},80+ {"key chip", th.KeyChip}, {"menu selection", th.MenuSelected}, {"indicator", th.Indicator},81+ } {82+ requireContrast(t, name, r.name, r.s.GetForeground(), r.s.GetBackground(), lv.text)83+ }84+ // Muted text and hints on the bars, where the status line shows them.85+ requireContrast(t, name, "muted text", th.Muted.GetForeground(), screenBg, lv.secondary)86+ eachRole(&th, func(role string, s *lipgloss.Style) {87+ fg, bg := s.GetForeground(), s.GetBackground()88+ if role == "SeriesBg" || isNoColor(fg) {89+ return7590 }76- // Muted text and hints on the bars, where the status line shows them.77- requireContrast(t, p.Name, "muted text", th.Muted.GetForeground(), screenBg, minSecondary)78- eachRole(&th, func(role string, s *lipgloss.Style) {79- fg, bg := s.GetForeground(), s.GetBackground()80- if role == "SeriesBg" || isNoColor(fg) {81- return82- }83- if isNoColor(bg) {84- bg = screenBg91+ if isNoColor(bg) {92+ bg = screenBg93+ }94+ requireContrast(t, name, role, fg, bg, minContrast(role, lv))95+ })96+}97+98+// The high-contrast theme follows the terminal's background, and its99+// schemes are AAA: 7:1 for every text role, hints included.100+func TestHighContrast(t *testing.T) {101+ for _, dark := range []bool{true, false} {102+ th, err := Resolve("high-contrast", dark, "")103+ if err != nil || th.Name != HighContrast || th.Palette == nil || th.Palette.Dark != dark || th.levels != aaa {104+ t.Fatalf("dark %v: %q %v", dark, th.Name, err)105+ }106+ requireReadable(t, th.Palette.Name, th)107+ for _, role := range []string{"Hint", "Muted", "Warning", "Error", "ErrorCell", "Link", "Spilled", "NoteMark"} {108+ if minContrast(role, th.levels) < 7 {109+ t.Errorf("%s held to %.1f", role, minContrast(role, th.levels))85110 }86- requireContrast(t, p.Name, role, fg, bg, minContrast(role))87- })111+ }112+ }113+ for _, name := range []string{"High Contrast Dark", "high-contrast-light", "HIGH-CONTRAST"} {114+ if _, err := Resolve(name, true, ""); err != nil {115+ t.Error(err)116+ }117+ }118+ if l := List(""); l[1].Name != HighContrast {119+ t.Errorf("list: %v", l[:3])88120 }89121 }90122
@@ -103,7 +135,8 @@ func TestSchemeUsesPaletteColors(t *testing.T) {
103135 }104136 }105137 })106- if bg := th.Selection.GetBackground(); fmt.Sprint(bg) != fmt.Sprint(p.Selection) {138+ // The selection is in reverse video: its background is its foreground.139+ if bg := th.Selection.GetForeground(); fmt.Sprint(bg) != fmt.Sprint(p.Selection) || !th.Selection.GetReverse() {107140 t.Errorf("selection %v, scheme's %v", bg, p.Selection)108141 }109142 // The terminal theme keeps ANSI colors and no bands.
A internal/ui/theme/standout_test.go +39 -0
M internal/ui/theme/theme.go +39 -1
@@ -131,6 +131,10 @@ type Theme struct {
131131 Series [chart.Colors]lipgloss.Style132132 SeriesBg [chart.Colors]lipgloss.Style133133 SeriesANSI [chart.Colors]int134+135+ // levels are the contrast minimums the roles meet: WCAG AA, or AAA136+ // for a high-contrast scheme.137+ levels levels134138 }135139 136140 // ImageID is the style of an image's Unicode placeholders: the
@@ -161,6 +165,39 @@ func (t *Theme) Text(base lipgloss.Style, st sheet.Style) lipgloss.Style {
161165 162166 // New returns the terminal theme's dark or light variant.163167 func New(dark bool) Theme {168+ t := roles(dark)169+ t.standouts()170+ return t171+}172+173+// standouts draws the roles that mark where the user is (the pointer,174+// the selection, their headers, search matches, traced cells and the175+// highlighted menu item) in reverse video: the colors swapped and SGR 7176+// on, so they look the same in color and still stand out on a terminal177+// without it. Call it last: the roles' colors read reversed afterwards.178+func (t *Theme) standouts() {179+ for _, s := range []*lipgloss.Style{&t.Pointer, &t.Selection, &t.HeaderActive, &t.HeaderSel,180+ &t.Found, &t.Traced, &t.MenuSelected, &t.MenuAccelSelected} {181+ fg, bg := s.GetForeground(), s.GetBackground()182+ *s = s.Foreground(bg).Background(fg).Reverse(true)183+ }184+}185+186+// Drawable returns s as it can be drawn: a standout role with an187+// underline or strikethrough layered on it is drawn in its own colors188+// without reverse video, because lipgloss draws the spaces of underlined189+// or struck-out text without it, which would show their colors swapped.190+// The underline or strikethrough is then that text's cue without color.191+func Drawable(s lipgloss.Style) lipgloss.Style {192+ if !s.GetReverse() || !s.GetUnderline() && !s.GetStrikethrough() {193+ return s194+ }195+ fg, bg := s.GetForeground(), s.GetBackground()196+ return s.Foreground(bg).Background(fg).Reverse(false)197+}198+199+// roles are the terminal theme's roles, before standouts.200+func roles(dark bool) Theme {164201 // Contrast was checked against the reference palettes in e2e: text on165202 // colored backgrounds stays at or above roughly 4.5:1.166203 headerBg, headerFg := lipgloss.BrightBlack, lipgloss.BrightWhite
@@ -205,7 +242,7 @@ func New(dark bool) Theme {
205242 ErrorCell: lipgloss.NewStyle().Foreground(lipgloss.Red),206243 ErrorMark: lipgloss.NewStyle().UnderlineStyle(lipgloss.UnderlineCurly).UnderlineColor(lipgloss.Red),207244 Link: lipgloss.NewStyle().Foreground(link).Underline(true),208- Spilled: lipgloss.NewStyle().Foreground(bar),245+ Spilled: lipgloss.NewStyle().Foreground(bar).Italic(true),209246 Found: lipgloss.NewStyle().Background(lipgloss.Yellow).Foreground(lipgloss.Black),210247 Traced: lipgloss.NewStyle().Background(lipgloss.Green).Foreground(lipgloss.Black),211248 Argument: lipgloss.NewStyle().Bold(true).Underline(true),
@@ -243,6 +280,7 @@ func New(dark bool) Theme {
243280 Dropdown: lipgloss.NewStyle().Foreground(muted),244281 scales: map[scaleKey]Shade{},245282 shades: map[sheet.RuleStyle]Shade{},283+ levels: aa,246284 }247285 ruleRoles(&t, dark)248286 for i, c := range series {
M internal/ui/themepicker/themepicker.go +2 -0
M internal/ui/view.go +1 -1