Checklists in the body, colour from tags, and commit-derived CalVer #4
@@ -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