HSL lightness is not luminance — the dark floor was too low

The unit test I added with the generated fills failed on its first run, on
exactly the claim it was written to check, so it earned its keep immediately.

The floor was 0.090 — `neutral-900`'s own HSL lightness — reasoning that a
ramp starting at the card surface and climbing could not end up below it.
That confuses HSL lightness with luminance. At one fixed lightness the eye
sees very different brightnesses by hue, because green carries 71% of the
luminance formula and blue only 7%: at L=0.090 a yellow measures 0.0118 and a
blue 0.0061. Every blue-ish untagged note was 1.41x DARKER than the card it
was supposed to match, which on the board reads as a hole rather than as
variety — the opposite of what the whole change is for.

Solved rather than nudged: 0.113 is the lowest floor at which EVERY hue
clears the card surface. The range now measures 1.11-1.71 against the board
against the old 1.06-1.54, so the floor is back where the shipped ramp had it
and the ceiling is higher. Body text 7.8 against the 4.5 it needs, meta 4.6
against 3.0. 338 distinct dark fills.

Two things about the test are worth keeping.

It asserts on LUMINANCE rather than on the lightness that was put in — a test
of the input would have agreed with the bug and passed.

And it now sweeps 40,000 ids rather than 500. The worst case is a HUE, not an
id, and 500 ids reach only 459 of the 2160 hue/level combinations — it caught
this one by luck. 40,000 covers all 2160.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 17:17:32 -04:00
co-authored by Claude Opus 5
parent 86f1e4a08f
commit 3d490bb6f3
3 changed files with 66 additions and 24 deletions
+20 -10
View File
@@ -154,7 +154,7 @@ export function derivedTint(id: string): NoteColor {
// at most a 1.03 contrast ratio, which is to say not at all. Nine tints that look
// like three is exactly the wall the tint was added to break up.
//
// So this hashes to a colour directly rather than to a key. 324 distinct fills in
// So this hashes to a colour directly rather than to a key. 338 distinct fills in
// dark, 193 in light, against nine.
//
// TWO AXES, AND THE SECOND ONE IS THE FIX. The old ramp varied hue while pinning
@@ -181,13 +181,23 @@ const TINT_LEVELS = 6;
const DARK_SATURATION = 0.25;
const LIGHT_SATURATION = 0.6;
// Dark starts a hair under `neutral-900`, the plain card surface, and climbs — so
// nothing is ever darker than an untinted card and no note recedes into the board.
// The top of the range measures 1.54 against the board where the old single level
// HSL LIGHTNESS IS NOT LUMINANCE, and the dark floor is set by the difference.
//
// The obvious floor is `neutral-900`'s own lightness, 0.090 — start at the plain card
// surface and climb, so no note ever recedes into the board. That was the first
// attempt and it was wrong: at a FIXED HSL lightness the eye sees wildly different
// brightnesses by hue, because green carries 71% of the luminance formula and blue
// only 7%. At L=0.090 a yellow measures 0.0118 and a blue 0.0061 — the blue landing
// 1.41x DARKER than the card it was meant to match, so a sixth of the board would
// have been holes rather than variety.
//
// 0.113 is the lowest floor at which every hue clears the card surface, solved for
// rather than guessed: 1.11-1.71 against the board, where the old single level
// managed 1.14. Light runs the other way, from white down past the `neutral-50`
// board; a card slightly darker than the board still reads as one because the edge
// says so.
const DARK_LIGHTNESS = [0.09, 0.104, 0.118, 0.132, 0.146, 0.16];
// says so, and near white the hue barely moves luminance at all so it needs no
// equivalent correction.
const DARK_LIGHTNESS = [0.113, 0.127, 0.141, 0.155, 0.169, 0.183];
const LIGHT_LIGHTNESS = [1.0, 0.99, 0.98, 0.97, 0.96, 0.95];
/**
@@ -282,10 +292,10 @@ export function resolveLabelColor(label: { name: string; color?: string | null }
//
// And for derivedFill, which uses the same hash on two axes (dark / light):
//
// 00000000-0000-0000-0000-000000000000 hue 177 lev 3 #192a29 #f3fcfb
// 11111111-1111-1111-1111-111111111111 hue 113 lev 1 #152114 #fbfefb
// 6ba7b810-9dad-11d1-80b4-00c04fd430c8 hue 136 lev 0 #111d14 #ffffff
// f47ac10b-58cc-4372-a567-0e02b2c3d479 hue 352 lev 3 #2a191b #fcf3f4
// 00000000-0000-0000-0000-000000000000 hue 177 lev 3 #1e3130 #f3fcfb
// 11111111-1111-1111-1111-111111111111 hue 113 lev 1 #1a2818 #fbfefb
// 6ba7b810-9dad-11d1-80b4-00c04fd430c8 hue 136 lev 0 #162419 #ffffff
// f47ac10b-58cc-4372-a567-0e02b2c3d479 hue 352 lev 3 #311e20 #fcf3f4
//
// Note `work`/`ideas` and `home`/`reading` collide. Nine keys makes that unavoidable
// and it is not a bug: colour hints that two notes are related, it never claims they