Skip to content

fix(web): profit and loss were the same brightness, and inputs had no visible border - #543

Merged
eaitbrahim merged 4 commits into
mainfrom
fix-532-palette-contrast
Aug 24, 2026
Merged

fix(web): profit and loss were the same brightness, and inputs had no visible border#543
eaitbrahim merged 4 commits into
mainfrom
fix-532-palette-contrast

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Closes #532.

The finding

--good and --bad were photometrically identical in light mode:

good #1f5f4f  relative luminance 0.0904
bad  #96322a  relative luminance 0.0893
                        delta 0.0011  →  1.01:1

Profit and loss were separated by hue alonerender.py:82 carried 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.

--accent was also byte-identical to --good in 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.

  • Light background, both colours dark → darken bad.
  • Dark background, both colours light → lighten 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

token before after
bad 7.22 AAA 13.92 AAA
accent 7.17 AAA 7.70 AAA
good 7.17 AAA 7.17 AAA (unchanged)

Dark

token before after
good 8.39 AAA 10.39 AAA
accent on card 7.83 AAA 7.68 AAA
bad 6.24 AA 6.24 AA (unchanged)

good/bad luminance delta: 0.0011 → 0.0681 light, 0.1234 → 0.2382 dark.

fg, muted and warn are untouched on every surface. Every entry that was AAA before is still AAA.

Form inputs

.field input used background: var(--bg) — the page background — so a --line border at 1.27:1 was the only thing marking the control. Fails WCAG 1.4.11 (3:1 for component boundaries).

Raising --line globally was rejected and the rejection is recorded: --line also draws table rules, the footer border and card edges, which 1.4.11 explicitly exempts as decorative. A new --control-line is scoped to interactive boundaries only — 3.72:1 light, 3.54:1 dark. --line is 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:

  • It regexes the tokens out of render._STYLE rather than holding a copy, so a reverted or fat-fingered value fails for real.
  • It pins the grade (AAA/AA), not just the ratio. A ratio floor could not have caught the first draft's mistake: 4.89:1 and 7.17:1 both clear 4.5:1, and only one is AAA.

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 the state field 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 passed
  • mypy — no issues in 354 source files
  • pytest -q4562 passed, 3 skipped

🤖 Generated with Claude Code

https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6

eaitbrahim and others added 4 commits August 23, 2026 20:55
…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
@eaitbrahim

Copy link
Copy Markdown
Contributor Author

Review fixes applied

The glyph landed — this is what actually satisfies 1.4.1

New pnl_cell() renders ▲ 120.00 / ▼ -45.00 inside the existing .good/.bad span. Its docstring states the layering well: "a glyph, the signed amount, and a colour class — in that order of how much of the meaning each one carries alone."

It also makes the honest point that money() already printed the sign, so the sign survived colour removal already; the glyph is what stops a skimmed table row from missing it.

With the glyph in, colour is a secondary signal, which is what makes the palette trade below acceptable.

--bad re-picked: #4d1711#7b2915

Round 2 fixed the good/bad collision by opening a new one against body text. Recomputed independently:

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)

@eaitbrahim
eaitbrahim merged commit d587bae into main Aug 24, 2026
5 checks passed
@eaitbrahim
eaitbrahim deleted the fix-532-palette-contrast branch August 24, 2026 05:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Palette: profit and loss are the same brightness, and inputs have no visible border

1 participant