diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/DerivedTint.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/DerivedTint.kt index fbe904d..983b1e2 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/DerivedTint.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/DerivedTint.kt @@ -158,7 +158,7 @@ fun noteColorIsChosen( // 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 @@ -185,12 +185,23 @@ private const val DARK_SATURATION = 0.25 private const val LIGHT_SATURATION = 0.60 /** - * Dark starts at 0.090 — a hair under `neutral-900`, the plain card surface — and - * climbs. Nothing is ever darker than an untinted card, so no note recedes into the - * board; they only ever rise off it. Top of the range measures 1.54 against the - * board where the old single level managed 1.14. + * HSL LIGHTNESS IS NOT LUMINANCE, and the floor here 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, because at a FIXED HSL lightness the eye sees wildly + * different brightnesses depending on hue: green carries 71% of the luminance formula + * and blue only 7%, so 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 supposed to match. 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. The range then measures 1.11–1.71 against the board where the + * old single level managed 1.14, so the floor is unchanged and the ceiling is much + * higher. `no generated fill sinks below the card surface` in DerivedTintTest is what + * caught the original mistake and what holds this. */ -private val DARK_LIGHTNESS = doubleArrayOf(0.090, 0.104, 0.118, 0.132, 0.146, 0.160) +private val DARK_LIGHTNESS = doubleArrayOf(0.113, 0.127, 0.141, 0.155, 0.169, 0.183) /** * Light runs the other way, from white down toward the `neutral-50` board and just diff --git a/android/app/src/test/java/com/fabledsword/thoughtsync/ui/DerivedTintTest.kt b/android/app/src/test/java/com/fabledsword/thoughtsync/ui/DerivedTintTest.kt index 76811a3..2ee7316 100644 --- a/android/app/src/test/java/com/fabledsword/thoughtsync/ui/DerivedTintTest.kt +++ b/android/app/src/test/java/com/fabledsword/thoughtsync/ui/DerivedTintTest.kt @@ -196,10 +196,10 @@ class DerivedTintTest { * actually compared. The same four ids and hexes are a comment in colors.ts. */ @Test fun `generated fills match the fixture shared with the web`() { - assertEquals("#192a29", hex(derivedFillArgb("00000000-0000-0000-0000-000000000000", dark = true))) - assertEquals("#152114", hex(derivedFillArgb("11111111-1111-1111-1111-111111111111", dark = true))) - assertEquals("#111d14", hex(derivedFillArgb("6ba7b810-9dad-11d1-80b4-00c04fd430c8", dark = true))) - assertEquals("#2a191b", hex(derivedFillArgb("f47ac10b-58cc-4372-a567-0e02b2c3d479", dark = true))) + assertEquals("#1e3130", hex(derivedFillArgb("00000000-0000-0000-0000-000000000000", dark = true))) + assertEquals("#1a2818", hex(derivedFillArgb("11111111-1111-1111-1111-111111111111", dark = true))) + assertEquals("#162419", hex(derivedFillArgb("6ba7b810-9dad-11d1-80b4-00c04fd430c8", dark = true))) + assertEquals("#311e20", hex(derivedFillArgb("f47ac10b-58cc-4372-a567-0e02b2c3d479", dark = true))) assertEquals("#f3fcfb", hex(derivedFillArgb("00000000-0000-0000-0000-000000000000", dark = false))) assertEquals("#fbfefb", hex(derivedFillArgb("11111111-1111-1111-1111-111111111111", dark = false))) @@ -234,14 +234,35 @@ class DerivedTintTest { assertEquals(true, levels.max() / levels.min() > 2.0) } - /** Nothing may be darker than the plain card surface (`neutral-900`, #171717) in - * dark, or the note recedes into the near-black board instead of sitting on it. */ + /** + * Nothing may be darker than the plain card surface (`neutral-900`, #171717) in + * dark, or the note reads as a hole in the board rather than a card on it. + * + * THIS TEST HAS ALREADY EARNED ITS KEEP. The first floor was 0.090 — `neutral-900`'s + * own HSL lightness — on the reasoning that starting at the card surface and + * climbing could not possibly go below it. That reasoning 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 7%: at 0.090 + * a yellow measures 0.0118 and a blue 0.0061, so every blue-ish untagged note was + * 1.41x darker than the card it was supposed to match. Solved for, the floor is + * 0.113. + * + * Which is why this asserts on LUMINANCE and not on the input lightness — testing + * the number that was put in would have agreed with the bug. + */ @Test fun `no generated fill sinks below the card surface`() { val surface = luminance(0xFF171717.toInt()) for (n in 0 until 500) { - val l = luminance(derivedFillArgb("note-$n", dark = true)) - assertEquals(true, l >= surface * 0.98) + assertEquals(true, luminance(derivedFillArgb("note-$n", dark = true)) >= surface) + } + // The worst case is a HUE — blue, around 240 — and 500 ids are not enough to + // be sure of having visited it at the darkest level. There are 360 x 6 = 2160 + // reachable combinations; by the coupon-collector bound ~16,600 draws covers + // them, so 40,000 makes a miss vanishingly unlikely. Cheap on the JVM, and the + // alternative is a test that would have PASSED against the original bug. + for (n in 0 until 40_000) { + assertEquals(true, luminance(derivedFillArgb("hue-sweep-$n", dark = true)) >= surface) } } diff --git a/frontend/src/notes/colors.ts b/frontend/src/notes/colors.ts index 17a0ec4..27f8b0e 100644 --- a/frontend/src/notes/colors.ts +++ b/frontend/src/notes/colors.ts @@ -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