diff --git a/keel/web/render.py b/keel/web/render.py
index 5a68238..fbd8419 100644
--- a/keel/web/render.py
+++ b/keel/web/render.py
@@ -39,14 +39,107 @@
_STYLE = """
:root {
+ /* #532: `--good` and `--bad` were `#1f5f4f`/`#96322a`, luminances 0.0904/0.0893 -- a 1.01:1
+ ratio, i.e. profit and loss were told apart by hue alone (WCAG 1.4.1). The direction of
+ the fix matters: on a light background, moving a colour DOWN in luminance moves it AWAY
+ from the background (more contrast) while moving it UP moves it toward the background
+ (less contrast) -- so separating two dark colours by moving one of them lighter buys
+ separation by SPENDING contrast, while moving one of them darker buys separation and
+ contrast in the same move. `--bad` had contrast to spend (7.22:1 on `--bg`, comfortably
+ past the 7:1 AAA line); `--good` did not (7.17:1, already barely AAA), so `--bad` is the
+ one that moves. `--good` stays `#1f5f4f`, untouched, still 7.17:1 / 7.48:1 AAA.
+
+ REJECTED: lightening `--good` toward `--bg` instead of darkening `--bad` away from it. An
+ earlier draft of this fix did exactly that (`--good` -> `#237e38`) and reached a 0.0663
+ separation, but paid for it by moving `--good` DOWN to 4.89:1 -- AA, not AAA, a grade this
+ palette did not need to spend since `--bad` had the same separation available for free.
+ Caught by review, not by the contrast-ratio tests below: every ratio in the rejected draft
+ still cleared its WCAG floor, because "still passes AA" and "did not lose a grade it
+ already had" are different properties, and only the latter is what this repo's
+ documentation standard would call a decision made in the wrong direction. See
+ `test_no_text_pair_grade_drops_below_its_pinned_floor` in
+ tests/web/test_palette_contrast.py, added specifically because ratio-floor tests alone
+ could not have caught this mistake.
+
+ REJECTED, second time: darkening `--bad` all the way to `#4d1711` (luminance 0.0223).
+ That cleared AAA against `--bg`/`--card` (13.92:1 / 14.52:1) and a 0.0681 delta from
+ `--good`, but traded one photometric collision for another: `#4d1711` sits almost on top
+ of `--fg` (`#1c1b19`, luminance 0.0110) -- 1.19:1 against it, down from the original
+ `#96322a`'s 2.28:1. Every unhighlighted number in the same table renders in `--fg`, so in
+ greyscale, on e-ink, or for a red-green colour-deficient reader, that draft made a LOSS
+ indistinguishable from a neutral cell, which is the same shape of bug #532 exists to fix,
+ just moved to a different pair of tokens. `--bg` (0.9566) and `--fg` (0.0110) sit at
+ opposite ends of the luminance scale, and `--good` (0.0904) already occupies nearly the
+ only luminance band that is simultaneously AAA-against-`--bg` and clearly separated from
+ `--fg` -- there is no second, equally dark value that fits both properties AND stays far
+ from `--good`. `--bad` settles at `#7b2915` (luminance 0.0585): AAA on both surfaces
+ (9.28:1 `--bg` / 9.68:1 `--card`), 1.78:1 against `--fg` (real separation, clearly past
+ the 1.19:1 collision, short of `#96322a`'s coincidental 2.28:1 -- which is coincidental
+ precisely because `#96322a` sat almost on top of `--good`, the bug this whole fix exists
+ to remove), and a 0.0319 delta from `--good` -- smaller than `#4d1711`'s 0.0681 but 29x
+ the original 0.0011, and no longer a second collision. tests/web/test_palette_contrast.py
+ pins a floor against BOTH regressions now: `_MIN_GOOD_BAD_LUMINANCE_DELTA` for good/bad,
+ `_MIN_SIGNAL_FG_RATIO` for every signal token against `--fg`.
+
+ `--accent` was also byte-identical to `--good` in both themes, so a link and a gain
+ rendered the same colour; it gets its own blue, `#1a5578`, dark enough to clear AAA too
+ (7.70:1 `--bg` / 8.03:1 `--card`) rather than settle for AA now that it no longer has to
+ equal `--good`. */
--bg: #fbfaf8; --fg: #1c1b19; --muted: #6b6862; --line: #e3dfd8;
- --card: #ffffff; --accent: #1f5f4f; --warn: #8a5a00; --bad: #96322a; --good: #1f5f4f;
+ --card: #ffffff; --accent: #1a5578; --warn: #8a5a00; --bad: #7b2915; --good: #1f5f4f;
+ /* #532: `.field input, .field select` puts the control's background on `--bg` (the page
+ background), so `--line` at 1.27:1 was the only thing marking a form control's boundary --
+ below WCAG 1.4.11's 3:1 floor for non-text UI components. Raising `--line` itself was
+ rejected: `--line` also draws table rules, the footer border and card edges, which SC
+ 1.4.11 explicitly exempts as decorative, and raising it would have widened all of those
+ for no accessibility gain. `--control-line`, `#84817c` here (3.72:1 on `--bg`), is scoped
+ to interactive control boundaries only. */
+ --control-line: #84817c;
}
:root:not([data-theme="light"]) { color-scheme: light dark; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
+ /* Mirror image of the light-mode fix, and the direction flips with the background: dark
+ mode's background is dark, so moving a colour UP in luminance is the move away from it.
+ `--good` (`#6fbf9f`) had contrast to spend, 8.39:1 on `--bg`, past AAA with room; `--bad`
+ (`#e07a6a`) did not -- 6.24:1, already only AA -- so `--good` is the one that moves this
+ time: `#6fbf9f` -> `#83d3b2`, luminance 0.4314 -> 0.5463, now 10.39:1 on `--bg` / 9.70:1
+ on `--card` (AAA, up from AAA). Delta from `--bad` is 0.2382 (was 0.1234). `--bad` stays
+ `#e07a6a`, untouched, still 6.24:1 / 5.83:1 AA.
+
+ REJECTED: darkening `--bad` toward `--bg` instead -- the dark-mode mirror of the light
+ draft rejected above, for the same reason: `--bad` is already sitting on the AA floor it
+ cannot afford to spend, while `--good` has AAA headroom to give.
+
+ Lightening `--good` has one side effect, milder than light mode's `--bad`/`--fg` mistake
+ above but the same shape: `--good` is now closer to `--fg` (`#ecead5`), so `--good` on
+ `--fg` drops from 1.80:1 (at `#6fbf9f`) to 1.45:1 (at `#83d3b2`). Left as-is rather than
+ re-picked, because 1.45:1 is real, visible separation -- nothing like light mode's
+ 1.19:1, which was nearly a collision -- and `_MIN_SIGNAL_FG_RATIO["dark"]` in
+ tests/web/test_palette_contrast.py pins 1.4 (a small margin under the measured 1.45) as
+ the floor going forward, so a future change that pushes it lower fails the build instead
+ of drifting.
+
+ `--accent` gets its own blue, distinct from `--good` (green) and `--bad` (salmon) as
+ in light mode -- separated from `--good` by HUE, not luminance (their luminance delta is
+ 0.125, and that is fine: only `--good`/`--bad` need a luminance floor, because that pair
+ is what a red-green colour-deficient reader cannot otherwise tell apart; blue-against-
+ green carries no such risk, so no luminance floor is pinned between `--accent` and
+ `--good`). `#7aa8e0` was tried first and clears AAA on `--bg` (7.41:1) but only AA on
+ `--card` (6.92:1) -- the pairing that actually renders as button text
+ (`color: var(--card)` on `background: var(--accent)`).
+
+ REJECTED: darkening `#7aa8e0` further to try to reach AAA on `--card`. This repeats the
+ exact mistake the `--good`/`--bad` fix above exists to avoid: dark mode's background is
+ dark, so darkening a colour moves it TOWARD the background and loses contrast, not
+ toward some other hue -- darkening a blue keeps it blue, it just gets less readable.
+ `--accent` has headroom to spend the same way `--good` did: LIGHTENING it moves away
+ from `--bg` and gains contrast on both surfaces at once. `#86b1e5` -- lighter, still
+ unmistakably blue -- reaches 8.22:1 on `--bg` and 7.68:1 on `--card`, AAA on both, with
+ zero grades spent anywhere in either theme. */
--bg: #16150f; --fg: #ecead5; --muted: #9a968a; --line: #2f2d25;
- --card: #1d1c15; --accent: #6fbf9f; --warn: #d9a441; --bad: #e07a6a; --good: #6fbf9f;
+ --card: #1d1c15; --accent: #86b1e5; --warn: #d9a441; --bad: #e07a6a; --good: #83d3b2;
+ --control-line: #706d66;
}
}
* { box-sizing: border-box; }
@@ -94,7 +187,7 @@
.field { display: flex; flex-direction: column; gap: 0.2rem; margin: 0.6rem 0; max-width: 26rem; }
.field span { font-size: 0.8rem; color: var(--muted); }
.field input, .field select { font: inherit; padding: 0.4rem 0.6rem; border-radius: 7px;
- border: 1px solid var(--line); background: var(--bg); color: var(--fg); }
+ border: 1px solid var(--control-line); background: var(--bg); color: var(--fg); }
.field em { font-style: normal; font-size: 0.78rem; color: var(--muted); }
button { font: inherit; font-weight: 550; padding: 0.35rem 0.9rem; border-radius: 7px;
border: 1px solid var(--accent); background: var(--accent); color: var(--card);
@@ -153,6 +246,28 @@ def pct(value: Decimal | float | None, *, places: int = 2) -> str:
return f"{value:.{places}f}%"
+def pnl_cell(value: Decimal | None) -> str:
+ """The journal's P&L cell: a glyph, the signed amount, and a colour class -- in that order
+ of how much of the meaning each one carries alone.
+
+ #532: colour alone failed WCAG 1.4.1 -- `--good`/`--bad` were once photometrically
+ identical in light mode (1.01:1), and even corrected they are separated by luminance a
+ red-green colour-deficient reader may still not resolve reliably by hue. `money()` already
+ prints the sign (`-12.34`, never `12.34` with an implied minus), so the SIGN survived colour
+ removal already; the GLYPH is what this function adds, because a sign is one character a
+ skimmed table row can miss where `▲`/`▼` at the start of the cell cannot. Strip every
+ colour from the page (greyscale, e-ink, `prefers-contrast`) and `▲ 120.00` / `▼ 45.00`
+ still read as gain and loss; strip the glyph instead and only the minus sign is left to
+ carry it, which is exactly the "distinguished by one easily-missed detail" state #532 was
+ filed to fix for colour."""
+ if value is None:
+ # "--", never "0.00": a trade with no recorded net is not a break-even trade.
+ return "--"
+ tone = "good" if value >= 0 else "bad"
+ glyph = "▲" if tone == "good" else "▼"
+ return f'{glyph} {esc(money(value))}'
+
+
def kv(key: str, value: str, *, tone: str = "") -> str:
cls = f' class="v {tone}"' if tone else ' class="v"'
return f'
{esc(key)}{value}
'
@@ -500,12 +615,6 @@ def render_insights(report: Any, journal: Any) -> str:
)
journal_rows = []
for entry in journal.entries:
- if entry.pnl_net is None:
- # "--", never "0.00": a trade with no recorded net is not a break-even trade.
- pnl_cell = "--"
- else:
- tone = "good" if entry.pnl_net >= 0 else "bad"
- pnl_cell = f'{esc(money(entry.pnl_net))}'
journal_rows.append(
(
esc(utc(entry.closed_at, fmt="%Y-%m-%d %H:%M")),
@@ -514,7 +623,7 @@ def render_insights(report: Any, journal: Any) -> str:
esc(money(entry.qty, places=8)),
esc(money(entry.entry_fill)),
esc(money(entry.exit_fill)),
- pnl_cell,
+ pnl_cell(entry.pnl_net),
esc(money(entry.fees, places=4)),
esc(entry.outcome),
)
diff --git a/tests/web/test_palette_contrast.py b/tests/web/test_palette_contrast.py
new file mode 100644
index 0000000..eac4e76
--- /dev/null
+++ b/tests/web/test_palette_contrast.py
@@ -0,0 +1,394 @@
+"""WCAG contrast gate for the web UI palette (#532).
+
+Everything here is measured from `keel/web/render.py:_STYLE` itself -- the light `:root { }`
+block and the dark `@media (prefers-color-scheme: dark)` block are parsed with a regex, not
+copied into this file as a second source of truth. That is deliberate: a hardcoded expected
+hex string only proves this file agrees with itself, while re-deriving the ratios from the
+actual stylesheet is what makes a reverted or fat-fingered palette value fail the build instead
+of drifting unnoticed, which is the whole point of #532's acceptance criterion "a CI test
+asserts every pair's ratio; changing a palette value to something failing makes it fail."
+
+The luminance and contrast-ratio formulas are WCAG 2.x's own (relative luminance:
+https://www.w3.org/TR/WCAG21/#dfn-relative-luminance; contrast ratio:
+https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio) -- twenty lines of arithmetic, no dependency.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+from keel.web import render
+
+# `render.RENDER_PY` doesn't exist -- reading `render.__file__` keeps this test tied to
+# whatever module actually shipped the stylesheet, rather than a path guessed from this
+# test file's own location (which breaks the moment either file moves).
+_RENDER_PY = Path(render.__file__)
+
+#: WCAG 2.x AA, normal-size text (SC 1.4.3): fg/muted/accent/warn/bad/good all render body-
+#: or label-sized text somewhere in render.py (table cells, `.kv .v`, `.pill`), never large text.
+_AA_TEXT_MIN = 4.5
+
+#: WCAG 2.x AA, non-text UI component boundaries (SC 1.4.11): the form-input border this issue
+#: adds a token for. Decorative dividers (table rules, footer border, card edges) are explicitly
+#: exempt from this minimum and are asserted UNCHANGED instead, below.
+_AA_UI_BOUNDARY_MIN = 3.0
+
+#: Minimum acceptable `|luminance(good) - luminance(bad)|`, pinned per theme rather than
+#: globally, because a single shared floor cannot do both jobs at once: the light theme's fixed
+#: delta (measured 0.0319) is much smaller than the dark theme's (measured 0.2382), since light
+#: mode keeps both colours near the dark end of the scale to hold AAA against a near-white
+#: background, while dark mode has the whole upper half of the scale to spread them across.
+#: A shared floor high enough to catch a regressed DARK pair would reject a compliant LIGHT
+#: pair; a shared floor low enough to admit the light pair would not catch a reverted dark pair
+#: (its original delta, 0.1234, would still pass a lenient shared floor). The values below sit
+#: with headroom under each theme's actual measured delta and, checked against the ORIGINAL
+#: palette this issue reports (light delta 0.0011, dark delta 0.1234), both floors reject it --
+#: which is the property this pin exists to guarantee: reverting #532 fails this test. Note
+#: what this floor does NOT guarantee: it only measures separation, not direction, which is why
+#: `test_no_text_pair_grade_drops_below_its_pinned_floor` exists separately below -- an earlier
+#: draft of this fix hit 0.0663/0.1984, clearing tighter floors than these, while moving `good`
+#: and `bad` the wrong way and losing three AAA grades in the process.
+#:
+#: The light floor drops from 0.06 to 0.025 in a later revision, because 0.06 was reached by
+#: `--bad: #4d1711` (luminance 0.0223), which is AAA against `--bg`/`--card` and far from
+#: `--good` but sits almost on top of `--fg` (`#1c1b19`, luminance 0.0110) -- see
+#: `_MIN_SIGNAL_FG_RATIO` below, added for exactly that regression. `--good` (0.0904) already
+#: occupies nearly the only luminance band that is both AAA-against-`--bg` and clearly separated
+#: from `--fg`; there is no second such value far enough from `--good` to also hit a 0.06 delta.
+#: `--bad` settles at `#7b2915` (luminance 0.0585, delta 0.0319 from `--good`) as the best
+#: available balance of the three constraints at once (AAA, good/bad separation, fg
+#: separation) -- still 29x the original 0.0011 collision, no longer a second collision of its
+#: own.
+_MIN_GOOD_BAD_LUMINANCE_DELTA = {"light": 0.025, "dark": 0.2}
+
+#: Minimum acceptable contrast between a signal token (`good`, `bad`, `warn`) and `--fg`,
+#: pinned per theme. This is a DIFFERENT collision from the one `_MIN_GOOD_BAD_LUMINANCE_DELTA`
+#: guards: that one is good-vs-bad; this one is either-of-them-vs-the-body-text-colour every
+#: unhighlighted table cell renders in. A signal token that drifts too close to `--fg` is
+#: indistinguishable from a neutral cell in greyscale, on e-ink, or for a red-green
+#: colour-deficient reader -- discovered when a draft of the good/bad fix picked
+#: `--bad: #4d1711` (light), which cleared AAA against `--bg`/`--card` and a healthy delta from
+#: `--good`, but landed at 1.19:1 against `--fg` (`#1c1b19`), down from `#96322a`'s original
+#: 2.28:1. The floors below are NOT the pre-#532 values themselves -- `--good` (0.0904) already
+#: sits at nearly the only luminance simultaneously AAA-against-`--bg` and far from `--fg`
+#: (0.0110), which is why the original `--bad` (0.0893) read as 2.28:1 against `--fg` in the
+#: first place: it was almost exactly as close to `--good` as it is far from `--fg`, i.e. the
+#: same coincidence that caused the bug this issue fixes. There is no light `--bad` that is
+#: simultaneously AAA, well-separated from `--good`, AND as far from `--fg` as the original
+#: was. The floors instead sit with margin under what THIS palette actually reaches -- light
+#: 1.7 (measured minimum 1.78, at `--bad`), dark 1.4 (measured minimum 1.45, at `--good`,
+#: see render.py's dark `:root` comment) -- high enough to reject the 1.19:1 regression that
+#: prompted this test, low enough to admit the corrected palette.
+_MIN_SIGNAL_FG_RATIO = {"light": 1.7, "dark": 1.4}
+
+_SIGNAL_TOKENS = ("good", "bad", "warn")
+
+_HEX_RE = re.compile(r"#[0-9a-fA-F]{6}")
+_VAR_RE = re.compile(r"--([a-z-]+):\s*(#[0-9a-fA-F]{6})")
+
+#: `_STYLE`'s own comments document rejected hex values in exactly the form a real declaration
+#: takes -- `` `--bad: #96322a` `` inside a `/* ... */` block, prose around it notwithstanding
+#: -- so `_VAR_RE` must never see comment text, only real declarations. It parses correctly
+#: today only because none of the current comments happen to spell a rejected value with a
+#: colon directly after the token name; that is luck, not a guarantee, and `dict.__setitem__`
+#: via a dict comprehension keeps whichever match comes LAST, so a future comment written this
+#: way would poison the parse silently rather than raising.
+_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
+
+#: Every token render.py's stylesheet declares text or UI-boundary colour with, in both themes.
+_EXPECTED_TOKENS = {
+ "bg", "fg", "muted", "line", "card", "accent", "warn", "bad", "good", "control-line",
+}
+
+#: Tokens used to colour readable text somewhere in the page (table cells, `.kv .v`, pills,
+#: nav labels, the `.field em` hint) -- excludes `bg`, `card` and `line`, which are surfaces and
+#: a decorative divider, not foregrounds.
+_TEXT_FOREGROUND_TOKENS = ("fg", "muted", "accent", "warn", "bad", "good")
+
+
+def _relative_luminance(hex_color: str) -> float:
+ """WCAG relative luminance of a `#rrggbb` colour, in [0, 1]."""
+ value = hex_color.lstrip("#")
+ r, g, b = (int(value[i : i + 2], 16) / 255.0 for i in (0, 2, 4))
+
+ def linearize(channel: float) -> float:
+ return channel / 12.92 if channel <= 0.03928 else ((channel + 0.055) / 1.055) ** 2.4
+
+ r_lin, g_lin, b_lin = linearize(r), linearize(g), linearize(b)
+ return 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin
+
+
+def _contrast_ratio(hex_a: str, hex_b: str) -> float:
+ """WCAG contrast ratio between two colours: (L_lighter + 0.05) / (L_darker + 0.05)."""
+ lum_a, lum_b = _relative_luminance(hex_a), _relative_luminance(hex_b)
+ lighter, darker = max(lum_a, lum_b), min(lum_a, lum_b)
+ return (lighter + 0.05) / (darker + 0.05)
+
+
+def _theme_palette(css: str, *, dark: bool) -> dict[str, str]:
+ """The `--token: #hex;` declarations for one theme, keyed by token name (no `--`).
+
+ `/* ... */` comments are stripped FIRST, over the whole stylesheet, before either block is
+ located or `_VAR_RE` runs over it -- `_STYLE`'s comments live inside the `:root` braces and
+ document rejected hex values in prose, so leaving them in place risks `_VAR_RE` matching a
+ rejected value quoted in a comment instead of the real declaration (see `_COMMENT_RE`'s own
+ note above).
+
+ Light mode is the first bare `:root { ... }` block -- matching `:root\\s*{` skips the
+ adjacent `:root:not([data-theme="light"]) { color-scheme: light dark; }` rule, which has
+ a `:not(...)` between `:root` and `{` and so never matches. Dark mode is the `:root:not(
+ ...) { ... }` block nested inside `@media (prefers-color-scheme: dark)`; searching for
+ that selector specifically (rather than "the second `:root` block") is what keeps this
+ parser from silently reading the wrong block if a rule is inserted between them later.
+ """
+ css = _COMMENT_RE.sub("", css)
+ if dark:
+ dark_css = css[css.index("@media") :]
+ block = re.search(r':root:not\(\[data-theme="light"\]\)\s*\{([^}]*)\}', dark_css)
+ else:
+ block = re.search(r":root\s*\{([^}]*)\}", css)
+ assert block is not None, f"could not find the {'dark' if dark else 'light'} :root block"
+ return {name: hex_value for name, hex_value in _VAR_RE.findall(block.group(1))}
+
+
+def _load_themes() -> tuple[dict[str, str], dict[str, str]]:
+ css = render._STYLE
+ return _theme_palette(css, dark=False), _theme_palette(css, dark=True)
+
+
+def test_every_token_is_declared_in_both_themes() -> None:
+ """Guards the parser and the palette together: a token dropped from either `:root` block
+ -- by a typo, or by only half-applying a change -- fails here before it fails obscurely in
+ a KeyError three tests down."""
+ light, dark = _load_themes()
+ assert _EXPECTED_TOKENS <= light.keys(), light.keys()
+ assert _EXPECTED_TOKENS <= dark.keys(), dark.keys()
+
+
+def test_text_foregrounds_meet_aa_against_bg_and_card_in_both_themes() -> None:
+ """SC 1.4.3: every colour render.py uses for text (`.good`, `.warn`, `.bad`, `.muted`,
+ nav labels, and `--accent` as button text on its own background) must reach 4.5:1 against
+ both surfaces text can sit on -- the page (`--bg`) and a card (`--card`)."""
+ for theme_name, palette in zip(("light", "dark"), _load_themes()):
+ for token in _TEXT_FOREGROUND_TOKENS:
+ for surface in ("bg", "card"):
+ ratio = _contrast_ratio(palette[token], palette[surface])
+ assert ratio >= _AA_TEXT_MIN, (
+ f"{theme_name} --{token} on --{surface} is {ratio:.2f}:1, "
+ f"below the {_AA_TEXT_MIN}:1 AA floor for normal text"
+ )
+
+
+def test_fg_on_bg_contrast_has_not_regressed() -> None:
+ """`fg`/`bg` was untouched by #532 and is the strongest pair on the page (AAA in both
+ themes already). Pinned to the exact numbers #532 measured and reported --
+ 16.50:1 light, 15.06:1 dark -- so any future edit to `--fg` or `--bg` for an unrelated
+ reason still has to notice it moved this number."""
+ light, dark = _load_themes()
+ assert _contrast_ratio(light["fg"], light["bg"]) == _approx(16.50)
+ assert _contrast_ratio(dark["fg"], dark["bg"]) == _approx(15.06)
+
+
+def _approx(expected: float, tol: float = 0.01) -> object:
+ class _Approx:
+ def __eq__(self, other: object) -> bool:
+ return isinstance(other, int | float) and abs(other - expected) <= tol
+
+ def __repr__(self) -> str:
+ return f"~{expected}"
+
+ return _Approx()
+
+
+def test_good_and_bad_differ_in_luminance_not_only_hue() -> None:
+ """The core of #532: light mode's `#1f5f4f` (good) and `#96322a` (bad) had luminances
+ 0.0904 and 0.0893 -- a delta of 0.0011, a 1.01:1 ratio -- so profit and loss were
+ distinguished by hue alone (WCAG 1.4.1). Reject any pair whose luminance separation falls
+ back under the measured-and-margined floor in `_MIN_GOOD_BAD_LUMINANCE_DELTA`. This test
+ only checks separation, not which colour moved to create it -- see
+ `test_no_text_pair_grade_drops_below_its_pinned_floor` for the direction check that
+ separation alone cannot express."""
+ for theme_name, palette in zip(("light", "dark"), _load_themes()):
+ delta = abs(_relative_luminance(palette["good"]) - _relative_luminance(palette["bad"]))
+ floor = _MIN_GOOD_BAD_LUMINANCE_DELTA[theme_name]
+ assert delta >= floor, (
+ f"{theme_name} good/bad luminance delta is {delta:.4f}, below the {floor} floor "
+ "-- profit and loss are distinguishable by hue alone again"
+ )
+
+
+def test_signal_tokens_stay_distinguishable_from_fg() -> None:
+ """A second, DIFFERENT collision from the good/bad one above: `good`, `bad` and `warn` are
+ the only colours rendered over `--fg` (every unhighlighted table cell), so a signal token
+ that drifts too close to `--fg` reads the same as ordinary text -- a loss that looks like
+ a neutral row -- once colour is removed (greyscale, e-ink, a red-green colour-deficient
+ reader). Caught in review: a draft of the good/bad separation fix picked light
+ `--bad: #4d1711`, which passed every OTHER test in this file (AAA against `--bg`/`--card`,
+ a healthy delta from `--good`) while landing at 1.19:1 against `--fg` -- effectively a
+ second version of the exact bug #532 exists to fix, just against a different token. See
+ `_MIN_SIGNAL_FG_RATIO` for why its floors are informed by, but not equal to, the pre-#532
+ ratios."""
+ for theme_name, palette in zip(("light", "dark"), _load_themes()):
+ floor = _MIN_SIGNAL_FG_RATIO[theme_name]
+ for token in _SIGNAL_TOKENS:
+ ratio = _contrast_ratio(palette[token], palette["fg"])
+ assert ratio >= floor, (
+ f"{theme_name} --{token} on --fg is {ratio:.2f}:1, below the {floor}:1 floor "
+ "-- a signal colour is becoming indistinguishable from ordinary body text"
+ )
+
+
+def test_comment_text_does_not_poison_the_parsed_palette() -> None:
+ """Proves `_theme_palette`'s comment-stripping fix (`_COMMENT_RE`) actually works: a
+ synthetic stylesheet with a comment that quotes a rejected value in exactly the
+ `` `--bad: #hex` `` shape a real rejected-alternative note would use must not leak that
+ value into the parsed palette -- the real declaration on the line below must win."""
+ css = """
+ :root {
+ /* REJECTED: --bad: #000000 would be the wrong choice here. */
+ --bg: #fbfaf8; --fg: #1c1b19; --bad: #7b2915; --good: #1f5f4f;
+ }
+ """
+ palette = _theme_palette(css, dark=False)
+ assert palette["bad"] == "#7b2915"
+
+
+def test_accent_is_not_good() -> None:
+ """`--accent` and `--good` were byte-identical in both themes before #532
+ (`#1f5f4f` light, `#6fbf9f` dark), so a hyperlink and a gain rendered as the same colour.
+ They must stay two different declarations."""
+ light, dark = _load_themes()
+ assert light["accent"] != light["good"]
+ assert dark["accent"] != dark["good"]
+
+
+def test_control_border_meets_the_ui_boundary_minimum_in_both_themes() -> None:
+ """SC 1.4.11: `.field input, .field select` sit on `--bg` (their own background is the
+ page background, per render.py), so `--control-line` -- the border token this issue adds --
+ must reach 3:1 against `--bg` on its own. Before #532 the only boundary was `--line` at
+ 1.27:1 light / 1.33:1 dark, which is why this is a distinct, higher-contrast token rather
+ than a change to `--line` itself."""
+ for theme_name, palette in zip(("light", "dark"), _load_themes()):
+ ratio = _contrast_ratio(palette["control-line"], palette["bg"])
+ assert ratio >= _AA_UI_BOUNDARY_MIN, (
+ f"{theme_name} --control-line on --bg is {ratio:.2f}:1, "
+ f"below the {_AA_UI_BOUNDARY_MIN}:1 non-text UI boundary floor"
+ )
+
+
+def test_decorative_dividers_stay_exempt_and_unchanged() -> None:
+ """`--line` draws table rules, the footer border and card edges -- decorative dividers
+ that SC 1.4.11 explicitly exempts, listing "purely decorative" boundaries alongside
+ "essentially unaltered" browser-default controls. #532 must not raise `--line` globally to
+ manufacture 3:1 for the one place (form inputs) that actually needed it; that is what the
+ separate `--control-line` token above is for. Pinned to the exact hex values in place before
+ this issue, so a well-intentioned "just raise --line too" edit fails here instead of
+ quietly widening every rule and border on the page."""
+ light, dark = _load_themes()
+ assert light["line"] == "#e3dfd8"
+ assert dark["line"] == "#2f2d25"
+ # Still comfortably under the 3:1 boundary floor -- confirms the exemption is real, not
+ # accidental compliance.
+ assert _contrast_ratio(light["line"], light["bg"]) < _AA_UI_BOUNDARY_MIN
+ assert _contrast_ratio(dark["line"], dark["bg"]) < _AA_UI_BOUNDARY_MIN
+
+
+def test_field_input_border_uses_the_control_line_token_not_line() -> None:
+ """Belt-and-suspenders on the CSS itself, not just the token's contrast value: this fails
+ if `.field input, .field select` is ever pointed back at `var(--line)`, even if `--line`'s
+ own hex value happened to reach 3:1 some day by coincidence."""
+ css = render._STYLE
+ field_rule = re.search(r"\.field input,\s*\.field select\s*\{[^}]*\}", css)
+ assert field_rule is not None
+ assert "var(--control-line)" in field_rule.group(0)
+ assert "var(--line)" not in field_rule.group(0)
+
+
+#: WCAG 2.x AAA, normal-size text (SC 1.4.6): 7:1. AA (`_AA_TEXT_MIN`, 4.5:1) is the WCAG floor
+#: this whole page must clear; AAA is this palette's actual working standard in practice --
+#: every text pair reached it before #532 except `--muted` and `--warn`, both pre-existing AA
+#: design choices this issue never touched. The distinction matters because "still >= 4.5:1"
+#: and "did not lose a grade it already had" are different properties: an early draft of this
+#: fix's good/bad separation passed every ratio test above while quietly dropping `--good` and
+#: `--accent` from AAA to AA in light mode and `--bad` further into AA in dark mode. Grades,
+#: not just ratios, are what `_GRADE_FLOOR` below pins.
+_AAA_TEXT_MIN = 7.0
+
+_GRADE_RANK = {"FAIL": 0, "AA": 1, "AAA": 2}
+
+
+def _grade(ratio: float) -> str:
+ if ratio >= _AAA_TEXT_MIN:
+ return "AAA"
+ if ratio >= _AA_TEXT_MIN:
+ return "AA"
+ return "FAIL"
+
+
+#: The WCAG grade every text-foreground/surface pair reaches as of this commit -- the floor a
+#: future edit may raise but must not lower without a stated reason and an updated entry here,
+#: the same standard CONTRIBUTING.md's documentation section asks of a comment that overturns a
+#: prior decision. `muted` and `warn` are pinned at AA because that is what they were before
+#: #532 and #532 does not touch them -- this table is not a claim that AA is good enough for
+#: the palette in general, only a record of what each pair actually reaches today.
+#:
+#: Every entry is AAA except `muted` and `warn` (pre-existing AA, untouched by #532). An earlier
+#: draft pinned dark `--accent`/`--card` at AA (`#7aa8e0`, 6.92:1), on the reasoning that
+#: reaching AAA meant darkening `--accent` toward `--bad`'s hue -- which repeated the same
+#: directional error the `good`/`bad` fix exists to correct: in dark mode, darkening moves
+#: TOWARD the background and only loses contrast, it does not shift hue. `--accent` had the
+#: same headroom `--good` did; LIGHTENING it to `#86b1e5` reaches AAA on both surfaces (8.22:1
+#: `--bg`, 7.68:1 `--card`) with the blue hue intact, so there is no exception left to record
+#: here.
+_GRADE_FLOOR: dict[str, dict[str, dict[str, str]]] = {
+ "light": {
+ "bg": {
+ "fg": "AAA", "muted": "AA", "accent": "AAA", "warn": "AA", "bad": "AAA", "good": "AAA",
+ },
+ "card": {
+ "fg": "AAA", "muted": "AA", "accent": "AAA", "warn": "AA", "bad": "AAA", "good": "AAA",
+ },
+ },
+ "dark": {
+ "bg": {
+ "fg": "AAA", "muted": "AA", "accent": "AAA", "warn": "AAA", "bad": "AA", "good": "AAA",
+ },
+ "card": {
+ "fg": "AAA", "muted": "AA", "accent": "AAA", "warn": "AAA", "bad": "AA", "good": "AAA",
+ },
+ },
+}
+
+
+def test_no_text_pair_grade_drops_below_its_pinned_floor() -> None:
+ """Regression guard for the mistake an earlier draft of #532 made: it fixed the good/bad
+ luminance collision by DARKENING light `--good` and LIGHTENING dark `--bad` -- moving both
+ colours TOWARD their own background instead of away from it. That passed every ratio test
+ in this file, because all of the thresholds above are floors, not exact pins, and the draft
+ cleared every one of them -- while quietly dropping three AAA grades to AA (light `--good`
+ 7.17:1 -> 4.89:1, light `--accent` 7.17:1 -> 6.59:1, dark `--bad` 6.24:1 -> 4.93:1) that a
+ "does it still clear 4.5:1" check cannot see, because 4.89 and 7.17 both clear it.
+
+ The rule the accepted fix follows instead: on any background, moving a colour toward the
+ background loses contrast while moving it away gains contrast, so when two colours need
+ separating, move whichever one has AAA headroom to spend -- separation and contrast both
+ improve in the same move, instead of trading one for the other.
+
+ This test pins the GRADE, not just the ratio, for every text pair in `_GRADE_FLOOR` and
+ fails if a future edit -- including a well-intentioned separation fix like #532's own first
+ draft, or a later one that repeats the same directional mistake for a third token
+ (`--accent` was darkened toward dark mode's background before it was correctly lightened
+ away from it) -- lowers a grade without updating the floor and stating why. Every entry is
+ pinned at AAA except `muted` and `warn`, pre-existing AA choices this issue does not touch.
+ """
+ light, dark = _load_themes()
+ for theme_name, palette in (("light", light), ("dark", dark)):
+ for surface in ("bg", "card"):
+ for token, floor in _GRADE_FLOOR[theme_name][surface].items():
+ ratio = _contrast_ratio(palette[token], palette[surface])
+ actual = _grade(ratio)
+ assert _GRADE_RANK[actual] >= _GRADE_RANK[floor], (
+ f"{theme_name} --{token} on --{surface} is {ratio:.2f}:1 ({actual}), "
+ f"below its pinned floor of {floor}"
+ )