2 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5 23fd2da91e The tag goes at the top of the card, where it gets looked at
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m58s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m21s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m6s
Label chips sat under the body, the checklist, the attachments and the link
previews. On a tall note that puts the one thing saying what a note IS below
the fold of a glance — and a board is scanned, not read. "Which of these is
about the thing I am looking for" should be the first thing the eye lands on.

Above the body rather than beside it: the body's first line is the note's
NAME (M13 steps 3 and 4), and a chip floated next to it would compete with
the thing that identifies the note. A row of its own costs one line, and only
on notes that carry tags.

Both surfaces, same order. Does not depend on tag lifting, which is a much
larger change — see the task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:17:32 -04:00
bvandeusenandClaude Opus 5 3d490bb6f3 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>
2026-08-26 17:17:32 -04:00
5 changed files with 101 additions and 42 deletions
@@ -158,7 +158,7 @@ fun noteColorIsChosen(
// at most a 1.03 contrast ratio, which is to say not at all. Nine tints that look // 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. // 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. // dark, 193 in light, against nine.
// //
// TWO AXES, AND THE SECOND ONE IS THE FIX. The old ramp varied hue while pinning // 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 private const val LIGHT_SATURATION = 0.60
/** /**
* Dark starts at 0.090 — a hair under `neutral-900`, the plain card surface — and * HSL LIGHTNESS IS NOT LUMINANCE, and the floor here is set by the difference.
* 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 * The obvious floor is `neutral-900`'s own lightness, 0.090 — "start at the plain card
* board where the old single level managed 1.14. * 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.111.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 * Light runs the other way, from white down toward the `neutral-50` board and just
@@ -58,9 +58,23 @@ fun NoteCard(
.border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS)) .border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS))
.padding(12.dp), .padding(12.dp),
) { ) {
// Body then checklist, in order — a note can carry both (M13 step 2), and // TAGS FIRST. They used to sit under everything else, which on a tall note put
// nothing above them: the first line of the body IS the note's name, at the // the one thing that says what a note IS below the fold of a glance. A board is
// same weight as the rest of it (M13 steps 3 and 4). // scanned, not read, and the answer to "which of these is about the thing I am
// looking for" should be the first thing the eye lands on rather than the last.
//
// Above the body rather than beside it, because the body's first line is the
// note's NAME (M13 steps 3 and 4) and a chip floated next to it would compete
// with the thing that identifies the note. A row of its own costs one line and
// only on notes that have tags at all.
if (note.labels.isNotEmpty()) {
LabelChips(labels = note.labels)
Spacer(Modifier.height(8.dp))
}
// Body then checklist, in order — a note can carry both (M13 step 2). The
// first line of the body IS the note's name, at the same weight as the rest of
// it (M13 steps 3 and 4).
if (note.body.isNotBlank()) { if (note.body.isNotBlank()) {
NoteBody(note = note, onToggleItem = onToggleItem) NoteBody(note = note, onToggleItem = onToggleItem)
} }
@@ -76,11 +90,6 @@ fun NoteCard(
) )
} }
if (note.labels.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
LabelChips(labels = note.labels)
}
note.remindAt?.let { at -> note.remindAt?.let { at ->
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
ReminderChip(instant = at, recurrence = note.recurrence) ReminderChip(instant = at, recurrence = note.recurrence)
@@ -196,10 +196,10 @@ class DerivedTintTest {
* actually compared. The same four ids and hexes are a comment in colors.ts. */ * actually compared. The same four ids and hexes are a comment in colors.ts. */
@Test @Test
fun `generated fills match the fixture shared with the web`() { fun `generated fills match the fixture shared with the web`() {
assertEquals("#192a29", hex(derivedFillArgb("00000000-0000-0000-0000-000000000000", dark = true))) assertEquals("#1e3130", hex(derivedFillArgb("00000000-0000-0000-0000-000000000000", dark = true)))
assertEquals("#152114", hex(derivedFillArgb("11111111-1111-1111-1111-111111111111", dark = true))) assertEquals("#1a2818", hex(derivedFillArgb("11111111-1111-1111-1111-111111111111", dark = true)))
assertEquals("#111d14", hex(derivedFillArgb("6ba7b810-9dad-11d1-80b4-00c04fd430c8", dark = true))) assertEquals("#162419", hex(derivedFillArgb("6ba7b810-9dad-11d1-80b4-00c04fd430c8", dark = true)))
assertEquals("#2a191b", hex(derivedFillArgb("f47ac10b-58cc-4372-a567-0e02b2c3d479", 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("#f3fcfb", hex(derivedFillArgb("00000000-0000-0000-0000-000000000000", dark = false)))
assertEquals("#fbfefb", hex(derivedFillArgb("11111111-1111-1111-1111-111111111111", 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) 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 @Test
fun `no generated fill sinks below the card surface`() { fun `no generated fill sinks below the card surface`() {
val surface = luminance(0xFF171717.toInt()) val surface = luminance(0xFF171717.toInt())
for (n in 0 until 500) { for (n in 0 until 500) {
val l = luminance(derivedFillArgb("note-$n", dark = true)) assertEquals(true, luminance(derivedFillArgb("note-$n", dark = true)) >= surface)
assertEquals(true, l >= surface * 0.98) }
// 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)
} }
} }
+18 -10
View File
@@ -259,6 +259,24 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
`shadow-sm`, back down from `shadow`: the border is the boundary again, so the `shadow-sm`, back down from `shadow`: the border is the boundary again, so the
shadow is only depth. --> shadow is only depth. -->
<!-- TAGS FIRST. They used to sit under everything else, which on a tall note put
the one thing that says what a note IS below the fold of a glance. A board is
scanned, not read, and the answer to "which of these is about the thing I am
looking for" should be the first thing the eye lands on rather than the last.
Above the image and the body rather than beside them, because the body's first
line is the note's NAME (M13 steps 3 and 4) and a chip floated next to it would
compete with the thing that identifies the note. -->
<div v-if="note.labels.length" class="mb-2 flex flex-wrap gap-1">
<span
v-for="lb in note.labels"
:key="lb.id"
class="rounded-full px-2 py-0.5 text-xs"
:class="labelChip(lb)"
>{{ lb.via_tag ? "#" + lb.name : lb.name }}</span
>
</div>
<img <img
v-if="firstImage" v-if="firstImage"
:src="firstImage.url" :src="firstImage.url"
@@ -317,16 +335,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
two paragraphs instead of always after them. Rendering both would have shown two paragraphs instead of always after them. Rendering both would have shown
every list twice. --> every list twice. -->
<div v-if="note.labels.length" class="mt-2 flex flex-wrap gap-1">
<span
v-for="lb in note.labels"
:key="lb.id"
class="rounded-full px-2 py-0.5 text-xs"
:class="labelChip(lb)"
>{{ lb.via_tag ? "#" + lb.name : lb.name }}</span
>
</div>
<div v-if="note.remind_at" class="mt-2 flex flex-wrap items-center gap-1.5"> <div v-if="note.remind_at" class="mt-2 flex flex-wrap items-center gap-1.5">
<span <span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs" class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
+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 // 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. // 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. // dark, 193 in light, against nine.
// //
// TWO AXES, AND THE SECOND ONE IS THE FIX. The old ramp varied hue while pinning // 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 DARK_SATURATION = 0.25;
const LIGHT_SATURATION = 0.6; const LIGHT_SATURATION = 0.6;
// Dark starts a hair under `neutral-900`, the plain card surface, and climbs — so // HSL LIGHTNESS IS NOT LUMINANCE, and the dark floor is set by the difference.
// 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 // 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` // 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 // board; a card slightly darker than the board still reads as one because the edge
// says so. // says so, and near white the hue barely moves luminance at all so it needs no
const DARK_LIGHTNESS = [0.09, 0.104, 0.118, 0.132, 0.146, 0.16]; // 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]; 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): // And for derivedFill, which uses the same hash on two axes (dark / light):
// //
// 00000000-0000-0000-0000-000000000000 hue 177 lev 3 #192a29 #f3fcfb // 00000000-0000-0000-0000-000000000000 hue 177 lev 3 #1e3130 #f3fcfb
// 11111111-1111-1111-1111-111111111111 hue 113 lev 1 #152114 #fbfefb // 11111111-1111-1111-1111-111111111111 hue 113 lev 1 #1a2818 #fbfefb
// 6ba7b810-9dad-11d1-80b4-00c04fd430c8 hue 136 lev 0 #111d14 #ffffff // 6ba7b810-9dad-11d1-80b4-00c04fd430c8 hue 136 lev 0 #162419 #ffffff
// f47ac10b-58cc-4372-a567-0e02b2c3d479 hue 352 lev 3 #2a191b #fcf3f4 // f47ac10b-58cc-4372-a567-0e02b2c3d479 hue 352 lev 3 #311e20 #fcf3f4
// //
// Note `work`/`ideas` and `home`/`reading` collide. Nine keys makes that unavoidable // 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 // and it is not a bug: colour hints that two notes are related, it never claims they