fix(web): profit and loss were the same brightness, and inputs had no visible border - #543
Conversation
…ntrol-border contrast Fixes #532. Three failures in the palette, measured against `keel/web/render.py:41-49`: 1. `--good`/`--bad` were `#1f5f4f`/`#96322a`, luminances 0.0904/0.0893 -- a 1.01:1 ratio, so profit and loss were told apart by hue alone (WCAG 1.4.1 Use of Color). `--good` moves to `#237e38` (luminance 0.1556); `--bad` stays `#96322a`. Delta is now 0.0663 light (was 0.0011). Dark mode's `--bad` moves `#e07a6a` -> `#d85f4c` (luminance 0.3081 -> 0.2331); `--good` (`#6fbf9f`) was unchanged -- it already sat at the ceiling the new `--bad` moves toward. Delta is now 0.1984 dark (was 0.1234). 2. `--accent` was byte-identical to `--good` in both themes (`#1f5f4f` light, `#6fbf9f` dark), so a link and a gain rendered the same colour. `--accent` gets its own value: `#1d5f8a` light, `#7aa8e0` dark. 3. `.field input, .field select` sat its background on `--bg` (the page background), so the `--line` border at 1.27:1 light / 1.33:1 dark was the only thing marking a control's boundary -- below WCAG 1.4.11's 3:1 floor for non-text UI components. Raising `--line` itself was rejected: it also draws table rules, the footer border and card edges, which SC 1.4.11 explicitly exempts as decorative -- widening it would cost nothing accessibility-wise and change the look of everything it touches. A new `--control-line` token (`#84817c` light, 3.72:1 on `--bg`; `#706d66` dark, 3.54:1) is scoped to interactive controls only. `fg`/`bg` -- the strongest pair on the page -- is untouched and still measures 16.50:1 light, 15.06:1 dark, both comfortably AAA. Not in this change: the glyph/sign rendering for P&L (`▲ +2.4%` / `▼ −2.4%`) the issue also describes. That lands with the new client's `state` field (#533/#536); this PR is the palette and the CI gate only. tests/web/test_palette_contrast.py adds the WCAG contrast gate: it re-parses the actual `--token: #hex` declarations out of `render.py`'s `_STYLE` string (never a second hardcoded copy of the palette) and asserts every foreground/background pair reaches 4.5:1, the new control-border token reaches 3:1, and good/bad's luminance delta clears a per-theme floor (0.05 light, 0.15 dark -- picked with margin under each theme's new measured delta, and both low enough that the pre-fix values, 0.0011 and 0.1234, fail them). Confirmed test-first: it fails against the unmodified palette (missing `--control-line`, `accent == good`, delta below floor) before this diff, and passes after it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6
Review caught a directional mistake in the previous commit's good/bad separation: it darkened
light `--good` (7.17:1 AAA -> 4.89:1 AA) and lightened dark `--bad` (kept at 6.24:1 AA but the
earlier attempt moved it toward the background too), spending three AAA grades it didn't need
to spend. All of it still passed every ratio-floor test, because "clears 4.5:1" and "did not
lose a grade it already had" are different properties, and only ratio floors were being tested.
The rule: on a given background, moving a colour toward it loses contrast, moving away gains
it. When two colours need separating, move whichever one has headroom to spend AWAY from the
background -- separation and contrast improve together, instead of trading one for the other.
Light (background light, colours dark -> darken the one with headroom):
--bad #96322a -> #4d1711 0.0893 -> 0.0223 lum 7.22:1 -> 13.92:1 (AAA, was AAA)
--good #1f5f4f -> #1f5f4f unchanged 7.17:1 (AAA, unchanged)
delta(good, bad): 0.0011 -> 0.0681
Dark (background dark, colours light -> lighten the one with headroom):
--good #6fbf9f -> #83d3b2 0.4314 -> 0.5463 lum 8.39:1 -> 10.39:1 (AAA, was AAA)
--bad #e07a6a -> #e07a6a unchanged 6.24:1 (AA, unchanged)
delta(good, bad): 0.1234 -> 0.2382
`--accent` (split from `--good`, unchanged rationale from the previous commit) is darkened in
light mode to also clear AAA now that it no longer has to equal `--good`:
light --accent #1d5f8a -> #1a5578 6.59:1 -> 7.70:1 on --bg (AAA, was AA)
dark --accent #7aa8e0 unchanged 7.41:1 on --bg (AAA), 6.92:1 on --card (AA) -- the one
grade this split still spends; recorded in
`_GRADE_FLOOR["dark"]["card"]["accent"]` and in the
dark :root block's comment, with the rejected
alternative (darkening --accent further into --bad's
hue territory) named explicitly.
No token's WCAG grade is now lower than it is after this commit, checked against every prior
state including the original pre-#532 palette -- enforced by the new
`test_no_text_pair_grade_drops_below_its_pinned_floor` in tests/web/test_palette_contrast.py,
which pins the AAA/AA grade (not just the ratio) for every foreground/surface pair and exists
specifically because the previous commit's ratio-only tests could not have caught this mistake.
`_MIN_GOOD_BAD_LUMINANCE_DELTA` floors also tighten (0.05/0.15 -> 0.06/0.2) now that the
corrected values clear them with more margin.
`--control-line`, `--line`, `fg`, `muted`, `warn` and the `.field` border rule are untouched by
this commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6
…last grade drop
Review caught a second instance of the same directional error: dark `--accent` was left at
`#7aa8e0` (7.41:1 AAA on `--bg`, 6.92:1 AA on `--card`) with a comment claiming the AAA-on-card
alternative required darkening the colour toward `--bad`'s red-brown hue. That reasoning was
wrong twice over -- darkening a blue does not shift its hue, and in dark mode darkening moves a
colour TOWARD the background, which loses contrast, not gains it. The same headroom rule that
moved light `--bad` and dark `--good` applies to `--accent` too: it had contrast to spend, and
spending it by lightening (not darkening) gains contrast on both surfaces at once.
dark --accent #7aa8e0 -> #86b1e5 lum 0.3752 -> 0.4217
on --bg: 7.41:1 AAA -> 8.22:1 AAA
on --card: 6.92:1 AA -> 7.68:1 AAA
Zero grade drops now remain in either theme on either surface. `--accent`/`--good` are
separated by hue (blue vs. green), not luminance -- their luminance delta narrows to 0.125,
which is fine and does not need a floor: only `--good`/`--bad` need a luminance floor, because
that is the pair a red-green colour-deficient reader cannot otherwise tell apart, and no such
floor was added for `--accent`/`--good`.
tests/web/test_palette_contrast.py: `_GRADE_FLOOR["dark"]["card"]["accent"]` moves from "AA" to
"AAA" and the exception comment documenting it is removed -- there is no longer an exception.
The regression test's docstring is updated to describe the mistake generally (darkening toward
a dark background loses contrast for any token, not just `--good`/`--bad`) rather than pointing
at a specific accepted trade-off that no longer exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6
…the CSS parser Review found three remaining gaps against #532's acceptance criteria. 1. Light `--bad` (`#4d1711`, from the previous commit) traded one photometric collision for another: it cleared AAA against `--bg`/`--card` and a 0.0681 delta from `--good`, but sat almost on top of `--fg` (`#1c1b19`) -- 1.19:1, down from the original `#96322a`'s 2.28:1. Every unhighlighted table cell renders in `--fg`, so a loss became indistinguishable from a neutral row in greyscale, on e-ink, or for a red-green colour-deficient reader -- the same shape of bug #532 exists to fix, just moved to a different pair of tokens. `--bg` (luminance 0.9566) and `--fg` (0.0110) sit at opposite ends of the 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 such value that is also 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, clear of the 1.19:1 collision), and a 0.0319 delta from `--good` -- smaller than the previous `#4d1711`'s 0.0681 but 29x the original 0.0011, and no second collision. Dark mode has a milder version of the same trade (`--good` on `--fg` drops from 1.80:1 to 1.45:1 as a side effect of the previous commit's fix) -- left as-is rather than re-picked, since 1.45:1 is real separation, nothing like light mode's near-collision, and is now pinned as a floor. tests/web/test_palette_contrast.py adds `_MIN_SIGNAL_FG_RATIO` and `test_signal_tokens_stay_distinguishable_from_fg`: every signal token (`good`, `bad`, `warn`) against `--fg`, in both themes, floored at 1.7 light / 1.4 dark -- informed by the pre-#532 numbers but not equal to them, since matching the original `--bad`'s 2.28:1 exactly is mathematically incompatible with AAA-against-`--bg` and separation from `--good` at once (the comment on `_MIN_SIGNAL_FG_RATIO` and the light `:root` block work through why). `_MIN_GOOD_BAD_LUMINANCE_DELTA["light"]` moves from 0.06 to 0.025 to match. 2. Landed the P&L glyph #532's acceptance criteria call for and the previous commits left out: "P&L renders with a glyph and sign; removing all colour leaves the meaning intact." Added `pnl_cell()` in render.py, used at the journal's P&L column (previously inline at render_insights): `▲`/`▼` ahead of the already-signed `money()` output, inside the existing `.good`/`.bad` span. Colour is now reinforcement, not the only carrier of meaning -- strip every colour from the page and `▲ 120.00` / `▼ -45.00` still read as gain and loss. 3. `_theme_palette` now strips `/* ... */` comments from the stylesheet before running `_VAR_RE` over it. The comments this issue's own commits added document rejected hex values in prose, sometimes in exactly the `` `--bad: #hex` `` shape a real declaration takes; before this fix, a comment written that way would have silently poisoned the parsed palette (a dict comprehension keeps whichever match comes last) instead of raising. Added `test_comment_text_does_not_poison_the_parsed_palette` to prove the fix against a synthetic stylesheet built for exactly that shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6
Review fixes appliedThe glyph landed — this is what actually satisfies 1.4.1New It also makes the honest point that With the glyph in, colour is a secondary signal, which is what makes the palette trade below acceptable.
|
light --bad |
vs --bg |
vs --fg |
delta from good |
|---|---|---|---|
original #96322a |
7.22 AAA | 2.28 | 0.0011 ← the bug |
round 2 #4d1711 |
13.92 AAA | 1.19 ← new collision | 0.0681 |
final #7b2915 |
9.28 AAA | 1.78 | 0.0319 |
The delta is smaller than round 2's, and that is forced rather than chosen. Working the constraints: --bad must clear the --fg floor, which puts its luminance at ≥ 0.0537; --good sits at 0.0904 with an AAA ceiling of 0.0938, so almost no headroom to move the other way. The maximum achievable good/bad delta is 0.0367. This pick reaches 0.0319 — 87% of the theoretical best, and 29× the original collision, with no AAA lost anywhere.
New floor, so the collision cannot recur
test_signal_tokens_stay_distinguishable_from_fg pins _MIN_SIGNAL_FG_RATIO = {"light": 1.7, "dark": 1.4} across good/bad/warn in both themes. It rejects round 2's #4d1711 at 1.19:1 and passes the corrected palette.
That closes the failure mode this PR twice walked into: fixing one photometric collision by opening another. The test now knows about both axes.
Comment-stripping
_theme_palette strips /* */ before parsing. The new test builds a stylesheet whose comment is shaped exactly like a real rejected-value note — /* REJECTED: --bad: #000000 ... */ — and asserts the live declaration still wins. Worth having, since this PR's own comments now document rejected hex values in close to that form.
Gates
ruff clean · mypy clean, 354 files · 4564 passed, 3 skipped · test_palette_contrast.py now 11 tests (was 9 — the PR body's "8" was wrong from the start)
Closes #532.
The finding
--goodand--badwere photometrically identical in light mode:Profit and loss were separated by hue alone —
render.py:82carried nothing else. That fails WCAG 1.4.1 Use of Color in an application whose central signal is gain versus loss. Roughly one in twelve men has red-green colour deficiency; greyscale, e-ink and direct sunlight collapse the distinction for everyone else.--accentwas also byte-identical to--goodin both themes, so a link and a gain rendered alike.The rule this change encodes
Move the colour that has room to move away from the background. Toward the background loses contrast; away gains it. When two colours need luminance separation, moving the one with headroom gains separation and contrast instead of trading one for the other.
bad.good.A first draft did the opposite in both themes and cost two AAA grades to buy separation. That alternative is now recorded in the source comment as rejected, with the reason.
Result — zero grade drops, four tokens improved
Light
badaccentgoodDark
goodaccentoncardbadgood/bad luminance delta: 0.0011 → 0.0681 light, 0.1234 → 0.2382 dark.
fg,mutedandwarnare untouched on every surface. Every entry that was AAA before is still AAA.Form inputs
.field inputusedbackground: var(--bg)— the page background — so a--lineborder at 1.27:1 was the only thing marking the control. Fails WCAG 1.4.11 (3:1 for component boundaries).Raising
--lineglobally was rejected and the rejection is recorded:--linealso draws table rules, the footer border and card edges, which 1.4.11 explicitly exempts as decorative. A new--control-lineis scoped to interactive boundaries only — 3.72:1 light, 3.54:1 dark.--lineis unchanged, and a test pins that it stays unchanged.Tests
tests/web/test_palette_contrast.py, 8 tests, pure Python, no new dependency — the WCAG formulas are about twenty lines.Two properties make it a real guard rather than a restatement:
render._STYLErather than holding a copy, so a reverted or fat-fingered value fails for real.Confirmed red first: run against the unmodified palette it fails for the right reasons — missing
--control-line,accent == good, luminance delta below floor.Not in this PR
The
▲/▼glyph and sign that make P&L survive without colour at all. That needs thestatefield from the API payload and arrives with the client (#533, #536). This PR is the palette and the guard.Gates
ruff check keel tests packages— All checks passedmypy— no issues in 354 source filespytest -q— 4562 passed, 3 skipped🤖 Generated with Claude Code
https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6