Checklists in the body, colour from tags, and commit-derived CalVer #4

Merged
bvandeusen merged 73 commits from dev into main 2026-08-29 13:39:45 -04:00
8 changed files with 479 additions and 146 deletions
Showing only changes of commit 1d3cc7bcd4 - Show all commits
@@ -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<String>,
): 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)
@@ -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))
@@ -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(
@@ -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.111.14 against the
* board, while carrying LESS hue than the old ramp did — chroma 717 against
* 1023.
*
* 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.562.09 contrast where the
* fill managed 1.031.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.562.09 against its own fill while the fill
* managed 1.031.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<String, NoteTint> =
lightChipForeground = Color(0xFF525252),
darkChipBackground = Color(0x1AFFFFFF),
darkChipForeground = Color(0xFFD4D4D4),
darkCardSubdued = Color(0xFF171717),
tintable = false,
),
"red" to
@@ -169,7 +141,6 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFFB91C1C),
darkChipBackground = Color(0x80450A0A),
darkChipForeground = Color(0xFFFCA5A5),
darkCardSubdued = Color(0xFF1F1515),
),
"orange" to
NoteTint(
@@ -182,7 +153,6 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFFC2410C),
darkChipBackground = Color(0x80431407),
darkChipForeground = Color(0xFFFDBA74),
darkCardSubdued = Color(0xFF1F1614),
),
"yellow" to
NoteTint(
@@ -195,7 +165,6 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF92400E),
darkChipBackground = Color(0x80451A03),
darkChipForeground = Color(0xFFFCD34D),
darkCardSubdued = Color(0xFF1F1813),
),
"green" to
NoteTint(
@@ -208,7 +177,6 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF15803D),
darkChipBackground = Color(0x80052E16),
darkChipForeground = Color(0xFF86EFAC),
darkCardSubdued = Color(0xFF141B17),
),
"teal" to
NoteTint(
@@ -221,7 +189,6 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF0F766E),
darkChipBackground = Color(0x80042F2E),
darkChipForeground = Color(0xFF5EEAD4),
darkCardSubdued = Color(0xFF141B1B),
),
"blue" to
NoteTint(
@@ -234,7 +201,6 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF1D4ED8),
darkChipBackground = Color(0x80172554),
darkChipForeground = Color(0xFF93C5FD),
darkCardSubdued = Color(0xFF171A22),
),
"purple" to
NoteTint(
@@ -247,7 +213,6 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF7E22CE),
darkChipBackground = Color(0x803B0764),
darkChipForeground = Color(0xFFD8B4FE),
darkCardSubdued = Color(0xFF1D1425),
),
"pink" to
NoteTint(
@@ -260,7 +225,6 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFFBE185D),
darkChipBackground = Color(0x80500724),
darkChipForeground = Color(0xFFF9A8D4),
darkCardSubdued = Color(0xFF211419),
),
"gray" to
NoteTint(
@@ -273,7 +237,6 @@ val NOTE_TINTS: Map<String, NoteTint> =
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.
@@ -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)
}
}
+2
View File
@@ -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"
>
<!-- THE EDGE LIVES HERE, NOT IN THE PALETTE, and that is the whole point of it.
+148 -68
View File
@@ -17,45 +17,10 @@ export const NOTE_COLOR_KEYS = [
export type NoteColor = (typeof NOTE_COLOR_KEYS)[number];
// The SUBDUED ramp — what a note wears when nothing chose a colour for it.
//
// NO BORDER IN EITHER RAMP, here or below — the note card still HAS an edge, but it
// is one grey for every card and it lives in NoteCard.vue, not in the palette. That
// separation is the fix. The border used to be `border-{hue}-900` and measured
// 1.562.09 against its own fill while the fill managed only 1.031.05 against the
// board, so the loudest thing on every card was a line that said exactly what the
// fill already said — and a field of them read as a grid of outlines however
// different the colours inside were. A line that varies by colour is content; a line
// that never varies is structure. Only one of those competes with the fill.
//
// The dark values are COMPOSITED HEX rather than a Tailwind step, and that is the
// whole idea. `dark:bg-red-950/25` laid a hue over the near-black BOARD, which put
// the card at the board's own lightness (1.03), which is why the old hue border had
// to shout to be seen at all. These lay the same hue over the CARD SURFACE
// (`neutral-900`,
// #171717) at 18%, so an untagged card sits exactly where the default white card
// always sat (1.111.14 vs the board, against `bg-neutral-900`'s 1.10) while
// carrying LESS colour than before: chroma 717 where the old ramp had 1023.
//
// Subtler and more visible at once, which is only a contradiction if you assume
// subtlety has to come from lightness. Here it comes from chroma, and lightness is
// left to say "this is a card".
//
// Recipe, so these can be regenerated rather than guessed at: sRGB alpha
// compositing of `{hue}-950` at 0.18 over #171717 — `round(fg*0.18 + 23*0.82)` per
// channel. `gray` uses `neutral-800` as its 950, matching the strong ramp.
export const NOTE_CARD_CLASSES: Record<NoteColor, string> = {
default: "bg-white dark:bg-neutral-900",
red: "bg-red-50 dark:bg-[#1f1515]",
orange: "bg-orange-50 dark:bg-[#1f1614]",
yellow: "bg-amber-50 dark:bg-[#1f1813]",
green: "bg-green-50 dark:bg-[#141b17]",
teal: "bg-teal-50 dark:bg-[#141b1b]",
blue: "bg-blue-50 dark:bg-[#171a22]",
purple: "bg-purple-50 dark:bg-[#1d1425]",
pink: "bg-pink-50 dark:bg-[#211419]",
gray: "bg-neutral-100 dark:bg-[#1a1a1a]",
};
/** Membership test for a colour key arriving from the server, which may be newer
* than this client. Was a lookup in the subdued card table until that table was
* deleted — an untagged note's fill is generated now, not chosen from a palette. */
const KNOWN_COLORS = new Set<string>(NOTE_COLOR_KEYS);
export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = {
default: "bg-white dark:bg-neutral-600",
@@ -178,6 +143,98 @@ export function derivedTint(id: string): NoteColor {
return DERIVED_TINT_KEYS[tintHash(id) % DERIVED_TINT_KEYS.length];
}
// ---------------------------------------------------------------------------
// 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; 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
// (NoteCard.vue). While the fill was the only boundary the card had, it could not
// afford to drift toward the board. The edge bought that freedom.
//
// MIRRORED in `android/.../ui/DerivedTint.kt`, which has the unit test. Same hash,
// same levels, same rounding — see the fixture below.
/** Lightness steps a generated 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. */
const TINT_LEVELS = 6;
// Saturation is FIXED, and that is what keeps this subtle whichever 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.
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
// 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];
const LIGHT_LIGHTNESS = [1.0, 0.99, 0.98, 0.97, 0.96, 0.95];
/**
* The opaque fill an untagged note wears, as `#rrggbb`. Stable for the note's life.
*
* 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.
*/
export function derivedFill(id: string, dark: boolean): string {
const hash = tintHash(id);
const level = (hash >>> 16) % TINT_LEVELS;
return hslHex(
hash % 360,
dark ? DARK_SATURATION : LIGHT_SATURATION,
dark ? DARK_LIGHTNESS[level] : LIGHT_LIGHTNESS[level],
);
}
/**
* Textbook HSL to RGB, written out rather than pulled from a library because the
* Kotlin side has to compute the same bytes and there is no library both can share.
* Rounding is `floor(v + 0.5)` on both sides rather than the language's `round`:
* Kotlin rounds half away from zero and JS rounds half up, which agree here, but
* stating the rule leaves nothing for a future reader to have to check.
*/
function hslHex(hue: number, saturation: number, lightness: number): string {
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
const sector = hue / 60;
const second = chroma * (1 - Math.abs((sector % 2) - 1));
const match = lightness - chroma / 2;
const ramps: [number, number, number][] = [
[chroma, second, 0],
[second, chroma, 0],
[0, chroma, second],
[0, second, chroma],
[second, 0, chroma],
[chroma, 0, second],
];
const [red, green, blue] = ramps[Math.floor(sector)];
const byte = (v: number) =>
Math.min(255, Math.max(0, Math.floor((v + match) * 255 + 0.5)))
.toString(16)
.padStart(2, "0");
return `#${byte(red)}${byte(green)}${byte(blue)}`;
}
/**
* The colour to paint a LABEL — its chip, and (step 3) every note carrying it.
*
@@ -204,7 +261,7 @@ export function derivedTint(id: string): NoteColor {
*/
export function resolveLabelColor(label: { name: string; color?: string | null }): NoteColor {
const picked = label.color as NoteColor | undefined | null;
if (picked && picked !== "default" && picked in NOTE_CARD_CLASSES) return picked;
if (picked && picked !== "default" && KNOWN_COLORS.has(picked)) return picked;
if (!label.name) return "default";
return derivedTint(label.name.toLowerCase());
}
@@ -223,6 +280,13 @@ export function resolveLabelColor(label: { name: string; color?: string | null }
// todo -> pink grocery -> blue work -> green home -> gray
// ideas -> green reading -> gray urgent -> red
//
// 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
//
// 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
// carry the same tag. The chip's text is what says which tag it is.
@@ -236,14 +300,13 @@ export function resolveLabelColor(label: { name: string; color?: string | null }
// where they landed, and a ramp somebody has already signed off on is not something
// to redo while fixing something else.
//
// WHAT SEPARATES THE TWO RAMPS IS NOW CHROMA, NOT LIGHTNESS. Since the subdued ramp
// moved onto the card surface, the two sit at almost the same lightness in dark mode
// (red: 1.11 vs 1.12 against the board) and differ threefold in colour (chroma 10 vs
// 41). That is the better axis anyway: lightness is what says "this is a card", and
// spending it on emphasis is what left untagged cards flat against the board.
// THERE IS NO SECOND RAMP ANY MORE. This one is reached only by a note that HAS a
// colour; a note without one gets a generated fill instead (`derivedFill`), because
// nine palette keys could never carry both jobs. The palette says WHICH TAG. The
// generator says nothing at all, and only has to keep the board from repeating.
//
// Light mode was already built this way and needed no change — `-50` and `-100` are
// both essentially white plus a different amount of hue.
// So these values do not need to be subtle and never did — a tagged note is making a
// statement, and the quiet end of the board is now handled somewhere else entirely.
export const NOTE_CARD_CLASSES_STRONG: Record<NoteColor, string> = {
default: "bg-white dark:bg-neutral-900",
red: "bg-red-100 dark:bg-red-950/70",
@@ -258,12 +321,10 @@ export const NOTE_CARD_CLASSES_STRONG: Record<NoteColor, string> = {
};
/**
* The colour a note wears AND how strongly, in one answer.
* The palette key a note was GIVEN, or null when nothing gave it one.
*
* `strong` is not a second decision — it IS whether the colour was chosen. A tag (or,
* until step 5, the picker) means somebody said what this note is; a derived tint only
* means the board should not be a wall of white. Rendering those two at the same
* weight is what made the operator ask "the tints look the same as the chosen colors".
* Null is the interesting answer: it means the fill has to be generated, because the
* note carries no statement about what it is. Everything downstream branches here.
*
* Resolution order, and why: an explicit pick beats a tag because it is the more
* specific statement and the picker still exists. The FIRST label wins among tags —
@@ -274,31 +335,50 @@ export const NOTE_CARD_CLASSES_STRONG: Record<NoteColor, string> = {
* kind they made, and two identically-tagged notes in different colours for an
* invisible reason is worse than the rule being slightly loose.
*/
export function resolveNoteTint(note: {
id: string;
export function chosenNoteColor(note: {
color?: string | null;
labels?: { name: string; color: string }[];
}): { color: NoteColor; strong: boolean } {
}): NoteColor | null {
const picked = note.color as NoteColor | undefined | null;
if (picked && picked !== "default" && picked in NOTE_CARD_CLASSES) {
return { color: picked, strong: true };
}
if (picked && picked !== "default" && KNOWN_COLORS.has(picked)) return picked;
const first = note.labels?.[0];
if (first) return { color: resolveLabelColor(first), strong: true };
// No id yet means an unsaved draft: nothing to derive from. Staying white until the
// note exists costs one colour change at save time; hashing the empty string would
// give every draft the same tint and then change it anyway.
if (!note.id) return { color: "default", strong: false };
return { color: derivedTint(note.id), strong: false };
if (first) return resolveLabelColor(first);
return null;
}
/** The card classes for a note — both ramps behind one call. */
/**
* The class list for a note card.
*
* A tagged note gets a palette class. An untagged one gets `note-tint`, whose fill
* arrives through the custom properties in `noteTintVars` — see the rule in
* style.css, which exists because an inline style cannot answer a media query and the
* light and dark fills are two different generated colours.
*/
export function noteCardClasses(note: {
id: string;
color?: string | null;
labels?: { name: string; color: string }[];
}): string {
const { color, strong } = resolveNoteTint(note);
const ramp = strong ? NOTE_CARD_CLASSES_STRONG : NOTE_CARD_CLASSES;
return ramp[color] ?? NOTE_CARD_CLASSES.default;
const chosen = chosenNoteColor(note);
return chosen ? NOTE_CARD_CLASSES_STRONG[chosen] : "note-tint";
}
/**
* The generated fill for an untagged note, as the two custom properties `note-tint`
* reads — or undefined when the note has a colour of its own, or is a draft.
*
* A draft carries no id, so there is nothing to derive from; `note-tint`'s fallbacks
* catch that and paint the plain card surface. Hashing the empty string instead would
* give every draft the same fill and then change it at save time anyway.
*/
export function noteTintVars(note: {
id: string;
color?: string | null;
labels?: { name: string; color: string }[];
}): Record<string, string> | undefined {
if (chosenNoteColor(note) || !note.id) return undefined;
return {
"--tint-light": derivedFill(note.id, false),
"--tint-dark": derivedFill(note.id, true),
};
}
+20
View File
@@ -47,6 +47,26 @@ body {
}
}
/* An untagged note's fill is GENERATED from its id, not chosen from the palette —
* see `derivedFill` in notes/colors.ts for why nine keys was never going to be
* enough. That means it cannot be a Tailwind class, and it cannot be a plain inline
* style either: light and dark are two different computed colours and an inline
* style has no way to answer a media query. So the card publishes both as custom
* properties and this rule picks between them.
*
* The fallbacks are the draft case. A note with no id yet has nothing to hash, so it
* publishes no properties and lands on the plain card surface — one colour change at
* save time, rather than every draft sharing a fill and then changing anyway. */
.note-tint {
background-color: var(--tint-light, #ffffff);
}
@media (prefers-color-scheme: dark) {
.note-tint {
background-color: var(--tint-dark, #171717);
}
}
/* Motion is a feature, not a given. Anyone whose OS says "reduce motion" has told
* us something about vestibular comfort or attention, and the answer is to arrive
* instantly rather than to animate faster.