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 b1bb032..fd3afba 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 @@ -1,5 +1,7 @@ package com.fabledsword.thoughtsync.ui +import kotlin.math.abs + // The colour a note has when nothing chose one for it. // // A board of `default` notes is a wall of white rectangles and the eye gets no help @@ -144,3 +146,135 @@ fun noteColorIsChosen( labelColor: String, known: Set, ): Boolean = labelColor.isNotEmpty() || (color.isNotEmpty() && color != "default" && color in known) + + +// --------------------------------------------------------------------------- +// The fill for an UNTAGGED note, which is a different job from the palette above. +// +// The palette has nine keys and they MEAN something: a tag's colour. An untagged +// note's fill means nothing at all — it exists so a board is not a monolithic wall. +// Tying the second job to the first was the mistake. Nine keys is far too few for a +// board of any size, and once the nine were subdued enough not to shout they became +// indistinguishable from each other: measured, the nine dark fills were separated by +// 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 +// dark, 193 in light, against nine. +// +// TWO AXES, AND THE SECOND ONE IS THE FIX. The old ramp varied hue while pinning +// every fill to the same lightness — deliberately, so each would read as a card +// against the board. But the eye separates by lightness first, so nine hues at one +// lightness read as one card repeated. Varying lightness too is what makes the +// difference; the hue alone never could at this darkness. +// +// It is only SAFE to vary lightness because the card now has a grey edge of its own +// (see NoteCard.CARD_EDGE_DARK). While the fill was the only boundary the card had, +// it could not afford to drift toward the board. The edge bought that freedom. + +/** Lightness steps a derived fill can land on. Six rather than three because the + * levels are what carry the variety, and rather than twelve because past a point + * they stop being distinguishable and only cost contrast headroom. */ +private const val TINT_LEVELS = 6 + +/** + * Saturation is FIXED, and that is what keeps this subtle no matter which hue it + * lands on. Variety comes from hue and lightness; loudness would come from + * saturation, so saturation is the one dial the hash never touches. + */ +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. + */ +private val DARK_LIGHTNESS = doubleArrayOf(0.090, 0.104, 0.118, 0.132, 0.146, 0.160) + +/** + * Light runs the other way, from white down toward the `neutral-50` board and just + * past it. A card slightly darker than the board still reads as a card because the + * edge says so — the same freedom the edge bought in dark, spent in the other + * direction. + */ +private val LIGHT_LIGHTNESS = doubleArrayOf(1.000, 0.990, 0.980, 0.970, 0.960, 0.950) + +private const val HUE_DEGREES = 360L +private const val LEVEL_BIT_SHIFT = 16 +private const val HUE_SECTOR_DEGREES = 60.0 +private const val TWO = 2.0 +private const val CHANNEL_MAX = 255.0 +private const val ROUND_HALF = 0.5 +private const val CHANNEL_CEILING = 255 +private const val ALPHA_OPAQUE = 0xFF +private const val ALPHA_BIT_SHIFT = 24 +private const val RED_BIT_SHIFT = 16 +private const val GREEN_BIT_SHIFT = 8 + +/** + * The opaque ARGB fill an untagged note wears, stable for the life of the note. + * + * Returns an Int rather than a Compose `Color` on purpose: this file stays free of + * `androidx.compose` so `DerivedTintTest` can run on the host JVM, and that test is + * the only mechanical guard the mirror with colors.ts has. + * + * Hue and level are read from DIFFERENT parts of the hash so a note's shade is not a + * function of its hue — two notes of nearly the same hue should still be able to + * differ in weight, which is half of where the variety comes from. + */ +fun derivedFillArgb( + id: String, + dark: Boolean, +): Int { + val hash = tintHash(id).toLong() and UNSIGNED_MASK + val level = ((hash shr LEVEL_BIT_SHIFT) % TINT_LEVELS).toInt() + return hslToArgb( + hue = (hash % HUE_DEGREES).toDouble(), + saturation = if (dark) DARK_SATURATION else LIGHT_SATURATION, + lightness = if (dark) DARK_LIGHTNESS[level] else LIGHT_LIGHTNESS[level], + ) +} + +/** + * Textbook HSL to RGB, written out rather than pulled from a library because the + * TypeScript side has to compute the same bytes and there is no library both can + * share. + * + * DOUBLE, NOT FLOAT, and that is not a style choice. JavaScript has one number type + * and it is IEEE-754 binary64; a Kotlin `Float` is binary32, so the two would round + * differently near a channel boundary and a note would be one byte off between the + * phone and the browser. Nobody would ever see that as a bug — they would see two + * colours that are "sort of the same" and never work out why. Doubles on both sides + * make it the same arithmetic rather than nearly the same. + * + * Rounding is `floor(v + 0.5)` on both sides, NOT the language's `round`: Kotlin + * rounds half away from zero and JavaScript rounds half up, which agree for the + * non-negative values here, but stating the rule leaves nothing to have to check. + */ +private fun hslToArgb( + hue: Double, + saturation: Double, + lightness: Double, +): Int { + val chroma = (1.0 - abs(TWO * lightness - 1.0)) * saturation + val sector = hue / HUE_SECTOR_DEGREES + val second = chroma * (1.0 - abs(sector % TWO - 1.0)) + val match = lightness - chroma / TWO + val (red, green, blue) = + when (sector.toInt()) { + 0 -> Triple(chroma, second, 0.0) + 1 -> Triple(second, chroma, 0.0) + 2 -> Triple(0.0, chroma, second) + 3 -> Triple(0.0, second, chroma) + 4 -> Triple(second, 0.0, chroma) + else -> Triple(chroma, 0.0, second) + } + return (ALPHA_OPAQUE shl ALPHA_BIT_SHIFT) or + (channelByte(red + match) shl RED_BIT_SHIFT) or + (channelByte(green + match) shl GREEN_BIT_SHIFT) or + channelByte(blue + match) +} + +private fun channelByte(value: Double): Int = (value * CHANNEL_MAX + ROUND_HALF).toInt().coerceIn(0, CHANNEL_CEILING) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt index 6f4e467..019fcb5 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt @@ -39,8 +39,6 @@ fun NoteCard( onToggleItem: (Int, Boolean) -> Unit, ) { val dark = isSystemInDarkTheme() - val tint = noteTintFor(note) - val strong = noteIsStrong(note) Column( modifier = @@ -54,7 +52,7 @@ fun NoteCard( // rounded corners instead of a rectangle overhanging them. .clip(RoundedCornerShape(CARD_RADIUS)) .clickable(onClickLabel = stringResource(R.string.board_open_note), onClick = onOpen) - .background(tint.cardBackground(dark, strong)) + .background(noteCardColor(note, dark)) // ONE grey edge on every card, regardless of its colour — the tint is // deliberately not consulted here. See CARD_EDGE_DARK. .border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS)) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt index e1877d8..0772176 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt @@ -64,7 +64,6 @@ fun NoteEditorScreen( ) { val dark = isSystemInDarkTheme() val tint = noteTintFor(note) - val strong = noteIsStrong(note) // Keyed by the SESSION, not by note.id: the editor is reused across notes, so it // needs a key — but a draft's id changes the moment it is first saved, and @@ -156,7 +155,7 @@ fun NoteEditorScreen( Surface( modifier = Modifier.fillMaxSize(), shape = RoundedCornerShape(topStart = SHEET_CORNER, topEnd = SHEET_CORNER), - color = tint.cardBackground(dark, strong), + color = noteCardColor(note, dark), // Both content colours are spelled out for the reason the toolbar had to // be: Surface and Scaffold each default theirs to contentColorFor(their // container), which returns Unspecified for anything that is not a @@ -167,7 +166,7 @@ fun NoteEditorScreen( contentColor = MaterialTheme.colorScheme.onSurface, ) { Scaffold( - containerColor = tint.cardBackground(dark, strong), + containerColor = noteCardColor(note, dark), contentColor = MaterialTheme.colorScheme.onSurface, topBar = { EditorTopBar( diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt index eb2b0a5..82c23e4 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt @@ -14,10 +14,12 @@ import com.fabledsword.thoughtsync.core.Note * is amber on the desktop is the same amber on the phone rather than a near-miss. * Generated from tailwindcss 3.4's palette rather than transcribed by eye. * - * Most dark tints keep the web's ALPHA (`dark:bg-red-950/70`) instead of a - * precomputed blend — Compose composites a translucent colour over what's beneath - * exactly as CSS does, so the card sits on the background the same way in both. The - * one exception is [NoteTint.darkCardSubdued], which is baked; see its own note. + * Dark tints keep the web's ALPHA (`dark:bg-red-950/70`) instead of a precomputed + * blend — Compose composites a translucent colour over what's beneath exactly as CSS + * does, so the card sits on the background the same way in both. + * + * This table is the palette of MEANINGFUL colours: a tag's. The fill an untagged note + * wears is generated rather than looked up, and lives in DerivedTint.kt. * * `yellow` maps to Tailwind's *amber*, matching colors.ts; plain yellow is too * acid against the neutral surfaces. @@ -32,74 +34,45 @@ data class NoteTint( val lightChipForeground: Color, val darkChipBackground: Color, val darkChipForeground: Color, - /** - * The DARK subdued card fill, opaque, as a colour rather than an alpha. - * - * Every other dark value in this table is `{hue}-950` at an alpha, composited by - * whoever draws it over the near-black BOARD — which put an untagged card at the - * board's own lightness (a 1.03 contrast) and left the border doing all the work - * of saying "card". The border is gone (see [cardBackground]), so these are - * `{hue}-950` composited at 0.18 over the CARD SURFACE, #171717, and baked: the - * card now sits where the plain white card always sat, 1.11–1.14 against the - * board, while carrying LESS hue than the old ramp did — chroma 7–17 against - * 10–23. - * - * Opaque rather than another alpha because the backdrop is no longer the board - * alone: the editor draws the same fill on a sheet. A colour that means one thing - * on the board and another in the editor is the bug this whole milestone exists - * to stop. - * - * Baked rather than lerped at draw time: Compose's `lerp(Color, Color, Float)` - * interpolates in Oklab, CSS alpha-composites in sRGB, and the mirror in - * colors.ts is only worth having if both sides land on the same byte. - */ - val darkCardSubdued: Color, /** * False only for `default`, which is the ABSENCE of a colour rather than one of - * them. Everything else has two weights (see [cardBackground]); `default` has one, - * because there is no such thing as an emphatic lack of colour — and re-alphaing - * its opaque neutral fill would make a draft card translucent. + * them. A tint can be drawn at the chosen weight (see [chosenBackground]); + * `default` cannot, because there is no such thing as an emphatic lack of colour — + * and re-alphaing its opaque neutral fill would make a draft card translucent. */ val tintable: Boolean = true, ) { fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground /** - * A NOTE card's fill, at one of two weights, and the card's ONLY boundary. + * The fill for a note whose colour was CHOSEN — by a tag, or (until step 5) by + * the picker. A note with no tag does not come through here at all; see + * [noteCardColor]. * - * `strong` means the colour was CHOSEN — by a tag, or (until step 5) by the - * picker. Subdued means it was derived from the note's id purely so the board is - * not a wall of white. Drawing those at the same weight is what prompted the - * operator's "the tints look the same as the chosen colors". + * That split IS the design. The palette's nine keys mean something: which tag. + * An untagged note's fill means nothing, and tying the two together is what left + * the board monolithic — nine keys is far too few for a board of any size, and + * subdued enough not to shout they became indistinguishable from one another + * (measured: the nine dark fills sat within a 1.03 contrast of each other). + * Meaning gets a palette; texture gets a generator. * - * THE CARD'S EDGE IS NO LONGER A TINT. It used to carry a 1px `{hue}-900` border, - * and measured against its own fill that line was a 1.56–2.09 contrast where the - * fill managed 1.03–1.05 against the board — so the loudest thing on every card - * was a line saying exactly what the fill already said, and a field of them read - * as a grid of outlines whatever colour was inside. The card still has an edge; - * it is one grey for all ten keys and it lives in `NoteCard.kt` as a constant, so - * the palette cannot vary it. [border] is untouched and still serves panels, - * banners, the update card and the pickers — single elements, not a field. + * THE CARD'S EDGE IS NOT A TINT and never comes from here. It used to be a 1px + * `{hue}-900` border measuring 1.56–2.09 against its own fill while the fill + * managed 1.03–1.05 against the board — the loudest thing on every card saying + * exactly what the fill already said, so a field of them read as a grid of + * outlines. The edge is now one grey for all ten keys, held as a constant in + * `NoteCard.kt` where the palette cannot reach it. [border] is untouched and + * still serves panels, banners, the update card and the pickers. * - * WHAT SEPARATES THE TWO WEIGHTS IS CHROMA, NOT LIGHTNESS. In dark they now sit - * within a hair of each other (red: 1.11 against the board versus 1.12) and - * differ threefold in colour (chroma 10 versus 41). Lightness is what says "this - * is a card"; spending it on emphasis is what left untagged cards flat. - * - * Light was already built this way and is untouched: `-50` and `-100` are both - * white plus a different amount of hue, and `-100` is exactly - * [lightChipBackground], already in this table, so no new hex is transcribed. + * Light is one Tailwind step deeper (`-100`, which is exactly + * [lightChipBackground] — already in this table, so no new hex is transcribed); + * dark is the `-950` at [STRONG_DARK_ALPHA]. */ - fun cardBackground( - dark: Boolean, - strong: Boolean, - ): Color = + fun chosenBackground(dark: Boolean): Color = when { !tintable -> background(dark) - dark && strong -> darkBackground.copy(alpha = STRONG_DARK_ALPHA) - dark -> darkCardSubdued - strong -> lightChipBackground - else -> lightBackground + dark -> darkBackground.copy(alpha = STRONG_DARK_ALPHA) + else -> lightChipBackground } fun border(dark: Boolean): Color = if (dark) darkBorder else lightBorder @@ -137,8 +110,8 @@ data class NoteTint( private const val CHIP_EDGE_ALPHA = 0.60f // The chosen weight in dark, as a fraction. Mirrors `dark:bg-{hue}-950/70` in -// colors.ts. Its counterpart is no longer an alpha at all — the subdued weight is a -// baked colour, [NoteTint.darkCardSubdued], for the reasons recorded there. +// colors.ts. There is no counterpart any more: an untagged note's fill is generated +// rather than drawn from this table at a second weight. See DerivedTint.kt. private const val STRONG_DARK_ALPHA = 0.70f /** Keyed by the core's colour vocabulary. Order matches the web's picker. */ @@ -155,7 +128,6 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF525252), darkChipBackground = Color(0x1AFFFFFF), darkChipForeground = Color(0xFFD4D4D4), - darkCardSubdued = Color(0xFF171717), tintable = false, ), "red" to @@ -169,7 +141,6 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFFB91C1C), darkChipBackground = Color(0x80450A0A), darkChipForeground = Color(0xFFFCA5A5), - darkCardSubdued = Color(0xFF1F1515), ), "orange" to NoteTint( @@ -182,7 +153,6 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFFC2410C), darkChipBackground = Color(0x80431407), darkChipForeground = Color(0xFFFDBA74), - darkCardSubdued = Color(0xFF1F1614), ), "yellow" to NoteTint( @@ -195,7 +165,6 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF92400E), darkChipBackground = Color(0x80451A03), darkChipForeground = Color(0xFFFCD34D), - darkCardSubdued = Color(0xFF1F1813), ), "green" to NoteTint( @@ -208,7 +177,6 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF15803D), darkChipBackground = Color(0x80052E16), darkChipForeground = Color(0xFF86EFAC), - darkCardSubdued = Color(0xFF141B17), ), "teal" to NoteTint( @@ -221,7 +189,6 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF0F766E), darkChipBackground = Color(0x80042F2E), darkChipForeground = Color(0xFF5EEAD4), - darkCardSubdued = Color(0xFF141B1B), ), "blue" to NoteTint( @@ -234,7 +201,6 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF1D4ED8), darkChipBackground = Color(0x80172554), darkChipForeground = Color(0xFF93C5FD), - darkCardSubdued = Color(0xFF171A22), ), "purple" to NoteTint( @@ -247,7 +213,6 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF7E22CE), darkChipBackground = Color(0x803B0764), darkChipForeground = Color(0xFFD8B4FE), - darkCardSubdued = Color(0xFF1D1425), ), "pink" to NoteTint( @@ -260,7 +225,6 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFFBE185D), darkChipBackground = Color(0x80500724), darkChipForeground = Color(0xFFF9A8D4), - darkCardSubdued = Color(0xFF211419), ), "gray" to NoteTint( @@ -273,7 +237,6 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF404040), darkChipBackground = Color(0xFF404040), darkChipForeground = Color(0xFFE5E5E5), - darkCardSubdued = Color(0xFF1A1A1A), ), ) @@ -301,12 +264,36 @@ fun noteTintFor(note: Note): NoteTint = noteTint(resolvedNoteColor(note.id, note.color, firstLabelColor(note), NOTE_TINTS.keys)) /** - * Whether this note's card is drawn at the CHOSEN weight — see [NoteTint.cardBackground]. + * Whether this note's colour was chosen rather than generated — see [noteCardColor]. * * Not `@Composable`: the card needs it alongside `isSystemInDarkTheme()`, and keeping * it an ordinary function means it can be read anywhere the note is. */ -fun noteIsStrong(note: Note): Boolean = noteColorIsChosen(note.color, firstLabelColor(note), NOTE_TINTS.keys) +private fun noteIsStrong(note: Note): Boolean = noteColorIsChosen(note.color, firstLabelColor(note), NOTE_TINTS.keys) + +/** + * The single answer to "what colour is this card", from either of the two sources. + * + * A tagged note takes its tag's colour out of [NOTE_TINTS]; an untagged one gets a + * fill generated from its id, which is not a palette key at all. Callers ask this + * rather than choosing between them, so the two paths cannot drift apart between the + * board and the editor. + * + * A draft carries DRAFT_ID ("") and stays on the plain surface — there is no identity + * to derive from yet, and hashing the empty string would give every draft the same + * fill and then change it at save time anyway. + */ +@Composable +@ReadOnlyComposable +fun noteCardColor( + note: Note, + dark: Boolean, +): Color = + when { + noteIsStrong(note) -> noteTintFor(note).chosenBackground(dark) + note.id.isEmpty() -> NOTE_TINTS.getValue("default").background(dark) + else -> Color(derivedFillArgb(note.id, dark)) + } /** * The colour of the note's FIRST label, already resolved, or "" when it has none. 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 0e40206..76811a3 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 @@ -181,4 +181,117 @@ class DerivedTintTest { val seen = (0 until 500).map { derivedTint("spread-$it") }.toSet() assertEquals(DERIVED_TINT_KEYS.size, seen.size) } + + // --------------------------------------------------------------------- + // derivedFillArgb — the generated fill an UNTAGGED note wears. + // + // A different job from the palette above, and pinned separately. The palette's + // nine keys mean "which tag"; this means nothing at all and exists so a board is + // not a monolithic wall. It was nine keys once, and measured, those nine dark + // fills sat within a 1.03 contrast of one another — nine tints that looked like + // three. These are the tests that stop that happening again. + + /** The web has no test runner and cannot even be EXECUTED on the dev machine + * (no node), so this fixture is the only place the two implementations are ever + * 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("#f3fcfb", hex(derivedFillArgb("00000000-0000-0000-0000-000000000000", dark = false))) + assertEquals("#fbfefb", hex(derivedFillArgb("11111111-1111-1111-1111-111111111111", dark = false))) + assertEquals("#ffffff", hex(derivedFillArgb("6ba7b810-9dad-11d1-80b4-00c04fd430c8", dark = false))) + assertEquals("#fcf3f4", hex(derivedFillArgb("f47ac10b-58cc-4372-a567-0e02b2c3d479", dark = false))) + } + + /** The whole reason this replaced the nine-key ramp. Two orders of magnitude more + * fills than the palette could offer, so a board of any size stops repeating. */ + @Test + fun `the generated fill spreads far wider than the palette ever could`() { + val dark = (0 until 500).map { derivedFillArgb("note-$it", dark = true) }.toSet() + val light = (0 until 500).map { derivedFillArgb("note-$it", dark = false) }.toSet() + assertEquals(true, dark.size > 200) + assertEquals(true, light.size > 100) + // ...and far more than the nine it replaced, which is the actual claim. + assertEquals(true, dark.size > DERIVED_TINT_KEYS.size * 20) + } + + /** + * Lightness is the axis that carries the variety, so it is the one worth pinning. + * + * The bug this catches is the one that shipped: a ramp that varies hue while + * holding lightness fixed reads as one card repeated, because the eye separates + * by lightness first. If someone collapses these levels again, the fills will + * still all be "different colours" and the board will still be a wall. + */ + @Test + fun `generated fills vary in lightness, not only in hue`() { + val levels = (0 until 500).map { luminance(derivedFillArgb("note-$it", dark = true)) }.toSet() + assertEquals(true, levels.size >= 6) + 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. */ + @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) + } + } + + /** Body text is drawn on these. AA wants 4.5:1 and the meta row 3:1; the margin + * here is enormous, and the test is what keeps it that way if the levels move. */ + @Test + fun `every generated fill keeps body text well clear of AA`() { + for (n in 0 until 500) { + assertEquals(true, contrast(0xFFD4D4D4.toInt(), derivedFillArgb("note-$n", dark = true)) >= 4.5) + assertEquals(true, contrast(0xFF404040.toInt(), derivedFillArgb("note-$n", dark = false)) >= 4.5) + } + } + + /** Same id, same fill, forever — a note that changed colour on reload would read + * as corruption. The point of hashing rather than rolling a die. */ + @Test + fun `the generated fill is stable for an id`() { + val id = "f47ac10b-58cc-4372-a567-0e02b2c3d479" + assertEquals(derivedFillArgb(id, dark = true), derivedFillArgb(id, dark = true)) + assertNotEquals(derivedFillArgb(id, dark = true), derivedFillArgb(id, dark = false)) + } + + /** Every fill is fully opaque. A translucent one would composite over whatever is + * behind it, and the editor sheet is not the board — the same note would be two + * colours depending on where you were looking at it. */ + @Test + fun `generated fills are opaque`() { + for (n in 0 until 100) { + assertEquals(0xFF, (derivedFillArgb("note-$n", dark = true) ushr 24) and 0xFF) + } + } + + private fun hex(argb: Int): String = "#%06x".format(argb and 0xFFFFFF) + + private fun channel(argb: Int, shift: Int): Double { + val c = ((argb ushr shift) and 0xFF) / 255.0 + return if (c <= 0.03928) c / 12.92 else Math.pow((c + 0.055) / 1.055, 2.4) + } + + /** WCAG relative luminance, so the assertions above are about what an eye sees + * rather than about the bytes. */ + private fun luminance(argb: Int): Double = + 0.2126 * channel(argb, 16) + 0.7152 * channel(argb, 8) + 0.0722 * channel(argb, 0) + + private fun contrast( + a: Int, + b: Int, + ): Double { + val la = luminance(a) + val lb = luminance(b) + return (maxOf(la, lb) + 0.05) / (minOf(la, lb) + 0.05) + } } diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue index 23aa0a5..46f5cfe 100644 --- a/frontend/src/components/NoteCard.vue +++ b/frontend/src/components/NoteCard.vue @@ -7,6 +7,7 @@ import { NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, noteCardClasses, + noteTintVars, resolveLabelColor, type NoteColor, } from "../notes/colors"; @@ -231,6 +232,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown)) : '', active ? 'ring-2 ring-brand' : '', ]" + :style="noteTintVars(note)" :data-note-id="note.id" >