Nine tints that looked like three — generate the fill instead
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
Android / Kotlin + Rust (APK) (push) Failing after 4m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m13s
Desktop (Tauri) / Update manifest (push) Successful in 5s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
Android / Kotlin + Rust (APK) (push) Failing after 4m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m13s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Measured, the nine dark subdued fills were separated from each other by at most a 1.03 contrast ratio. That is not "subtle", it is identical, and it is why a board of them reads as one card repeated: "I only see 3 colors ... it looks like a monolithic wall." Two causes, and the second one is mine. NINE IS TOO FEW. The palette exists to say WHICH TAG. An untagged note's fill says nothing at all — it only has to keep the board from repeating. Those are different jobs and tying them together capped the second at nine values for a board that will hold hundreds. ONE AXIS IS TOO FEW. The subdued 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 are one card nine times. Hue alone was never going to carry it at that darkness. So an untagged note's fill is now generated from its id rather than looked up: hue anywhere on the circle, one of six lightness levels, saturation fixed. 324 distinct fills in dark and 193 in light, against nine. Separation between fills goes from a 1.03 ceiling to 1.42. Varying lightness is only SAFE because the card has its own grey edge now. While the fill was the card's only boundary it could not afford to drift toward the board; the edge bought that freedom, one commit before it was needed. Saturation is the one dial the hash never touches — variety comes from hue and lightness, loudness would come from saturation. Dark starts a hair under `neutral-900` and climbs, so no note is ever darker than a plain card. Light runs from white down past the board. Body text measures 8.7 at worst against the 4.5 it needs; the meta row 5.1 against 3.0. DOUBLE, NOT FLOAT, on the Kotlin side. JavaScript has one number type and it is 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 file that — they would see two colours that are "sort of the same" and never work out why. The web half cannot be executed here at all (no node on this machine), so the Kotlin fixture test is the only place the two implementations are ever compared. It now pins eight generated values as well as the hash, plus the properties that actually matter: that lightness varies, that nothing sinks below the card surface, and that body text stays clear of AA. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.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<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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.56–2.09 against its own fill while the fill managed only 1.03–1.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.11–1.14 vs the board, against `bg-neutral-900`'s 1.10) while
|
||||
// carrying LESS colour than before: chroma 7–17 where the old ramp had 10–23.
|
||||
//
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user