cards: one neutral surface, and the generated fill deleted with it
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m15s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m59s

A card's fill stops being a function of the note. One neutral per theme on
all three surfaces — white in light, neutral-900 in dark, which is what the
palette's `default` always was and what both editors already used, so this
is a collapse onto a surface everything already had rather than a colour
anybody has to like.

Measured, against "not the same color as their background but close to it":
card vs board 1.04 light / 1.10 dark, edge vs card 1.98 / 1.73, body text
17.93 / 17.17, muted 10.37 / 14.23. The fill is deliberately the weakest
number on the card — the edge and the shadow separate it from the board, so
a fill that separated on its own would make it a panel.

Deleted, since the card was their only consumer: `derivedFill` / `hslHex`
and the level tables in colors.ts, `derivedFillArgb` / `hslToArgb` in
DerivedTint.kt, `NOTE_CARD_CLASSES_STRONG`, `chosenNoteColor`,
`noteCardClasses`, `noteTintVars`, the `.note-tint` rule in style.css,
`chosenBackground` / `tintable` / `noteTintFor` / `noteCardColor` /
`noteIsStrong` / `firstLabelColor` in NoteTint.kt, and
`resolvedNoteColor` / `noteColorIsChosen`.

`tintHash` and `derivedTint` STAY, against the plan: a label with no colour
of its own still derives one from its name, and that path was never the one
that failed. The mirrored pair and its fixture survive intact.

The editor follows the card, and its Done button takes the brand — the
board's compose FAB is the app's existing statement of "affirmative action
here", where Material's default secondaryContainer is a baseline colour this
theme never sets.

The colour picker is left in place, doing nothing, for exactly one step:
removing it here would leave `note.color` written by nothing and read by
nothing, which is a worse intermediate than a control that visibly does
nothing. #3041 takes the field and the picker together.

Step 1 of M315. #3148

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 10:55:51 -04:00
co-authored by Claude Opus 5
parent f50204a98b
commit b91091caca
9 changed files with 309 additions and 872 deletions
@@ -1,21 +1,19 @@
package com.fabledsword.thoughtsync.ui
import kotlin.math.abs
// The colour a note has when nothing chose one for it.
// The colour a LABEL wears when nobody picked one for it.
//
// A board of `default` notes is a wall of white rectangles and the eye gets no help
// telling one from the next. Every note now carries some tint; this is where an
// untagged one gets it.
// Every `#tag` is born colourless, so without this a board of tags is a board of
// identical grey chips. Hashing the tag's NAME is deterministic, identical on every
// surface, costs no column and no migration, and a tag keeps its colour for life.
//
// "RANDOM" MEANS DERIVED. The operator asked for "random subdued colors", but a tint
// rolled at render time would differ between the phone and the browser and change on
// every reload. Hashing the note's id is deterministic, identical on every surface,
// costs no column and no migration, and a note keeps its colour for life — which is
// what "random" actually meant here.
// THIS WAS THE CARD'S COLOUR TOO, ONCE. It is not any more (M315): a note's fill is
// one neutral and only its tags carry hue. The hash survived that removal because the
// job it still does — give a name a stable colour — was never the job that failed.
// What failed was asking a colour that means "which tag" to also mean nothing at all
// on an untagged note, at which point the board had two vocabularies and neither read.
//
// THIS IS HALF A MIRRORED PAIR. `frontend/src/notes/colors.ts` computes the same hash
// over the same key order, and the two must agree exactly or a note is one colour on
// over the same key order, and the two must agree exactly or a tag is one colour on
// the phone and another in the browser. Same discipline as the checklist grammar's
// three implementations, and the same reason: a value that disagrees across surfaces
// is a bug you cannot unsee and cannot explain.
@@ -27,12 +25,12 @@ import kotlin.math.abs
// mirror gets — see the fixture comment in colors.ts.
/**
* The tints a derived colour can land on: `NOTE_TINTS`' keys minus `default`, which
* is the white this exists to eliminate. `gray` stays — `bg-neutral-100` reads as a
* deliberate card against the board's `bg-neutral-50`, not as an absence.
* The colours a derived hue can land on: `NOTE_TINTS`' keys minus `default`, which is
* the ABSENCE of a colour — a tag that derived it would be indistinguishable from one
* nobody has tagged. `gray` stays: as a chip it reads as a deliberate choice.
*
* Order is load-bearing and matches `DERIVED_TINT_KEYS` in colors.ts. Reordering
* this list silently recolours every untagged note on one surface only.
* Order is load-bearing and matches `DERIVED_TINT_KEYS` in colors.ts. Reordering this
* list silently recolours every tag on one surface only.
*/
val DERIVED_TINT_KEYS: List<String> =
listOf("red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray")
@@ -65,7 +63,8 @@ fun tintHash(id: String): Int {
return hash
}
/** The tint a note with no colour of its own wears. Stable for the life of the note. */
/** The colour a name maps to, stable for as long as the name is. Called with a
* label's lowercased name; `id` is the parameter's history, not its meaning. */
fun derivedTint(id: String): String {
// Through Long to read the hash as unsigned. A signed remainder would be negative
// for half of all ids and index out of the list.
@@ -74,12 +73,11 @@ fun derivedTint(id: String): String {
}
/**
* The colour key for a LABEL — its chip, and (step 3) every note carrying it.
* The colour key for a LABEL — its chip, and its `#tag` where it sits in the prose.
*
* Derived from the tag's NAME when nobody has picked one. Every `#tag` ever typed is
* currently `default`: the server mints one as `Label(owner_id=…, name=name)` with no
* colour, so tag-driven note colour against that would leave the board exactly as
* grey as it was.
* `default`: the server mints one as `Label(owner_id=…, name=name)` with no colour,
* so without deriving, a board of tags would be a board of identical grey chips.
*
* DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing #2965's plan. That plan wanted
* a hashed colour written at each of the four places a label can be born — and named
@@ -102,193 +100,3 @@ fun resolvedLabelColor(
name.isEmpty() -> "default"
else -> derivedTint(name.lowercase())
}
/**
* The colour key to actually paint a note with.
*
* An explicitly-picked colour still wins — the picker is on its way out (milestone
* 309 step 5) but it has not gone yet, and a note the operator coloured by hand
* changing under them would read as data loss.
*
* `known` is passed in rather than read from `NOTE_TINTS` so this file stays free of
* Compose and therefore testable; `noteTintFor` supplies the real set.
*/
fun resolvedNoteColor(
id: String,
color: String,
labelColor: String,
known: Set<String>,
): String =
when {
color.isNotEmpty() && color != "default" && color in known -> color
// The note's FIRST tag. Passed in already resolved, so this does not need to
// know that a colourless tag derives one from its name.
labelColor.isNotEmpty() -> labelColor
// A draft carries DRAFT_ID (""), so there is no identity to derive from yet.
// Staying white until the note exists costs one colour change at save time;
// hashing the empty string instead would give EVERY draft the same tint and
// then change it anyway, which is two surprises where one will do.
id.isEmpty() -> "default"
else -> derivedTint(id)
}
/**
* Whether a note's colour was CHOSEN rather than derived — which is what decides
* between the two weights the card is drawn at.
*
* 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. Drawing both at
* the same weight is what prompted the operator's "the tints look the same as the
* chosen colors" — the tint was never meant to shout as loudly as a decision.
*/
fun noteColorIsChosen(
color: String,
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. 338 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
/**
* HSL LIGHTNESS IS NOT LUMINANCE, and the floor here is set by the difference.
*
* The obvious floor is `neutral-900`'s own lightness, 0.090 — "start at the plain card
* surface and climb, so no note ever recedes into the board". That was the first
* attempt and it was wrong, because at a FIXED HSL lightness the eye sees wildly
* different brightnesses depending on hue: green carries 71% of the luminance formula
* and blue only 7%, so at L=0.090 a yellow measures 0.0118 and a blue 0.0061 — the
* blue landing 1.41x DARKER than the card it was supposed to match. A sixth of the
* board would have been holes rather than variety.
*
* 0.113 is the lowest floor at which EVERY hue clears the card surface, solved for
* rather than guessed. The range then measures 1.111.71 against the board where the
* old single level managed 1.14, so the floor is unchanged and the ceiling is much
* higher. `no generated fill sinks below the card surface` in DerivedTintTest is what
* caught the original mistake and what holds this.
*/
private val DARK_LIGHTNESS = doubleArrayOf(0.113, 0.127, 0.141, 0.155, 0.169, 0.183)
/**
* Light runs the other way, from white down toward the `neutral-50` board and just
* 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
// The six hue sectors, as a table rather than a `when` — which is also the form
// colors.ts uses, so the two read as the same function rather than as two people's
// idea of it. `sector` is in [0, 6) because the hue it came from is in [0, 360).
val ramps =
listOf(
Triple(chroma, second, 0.0),
Triple(second, chroma, 0.0),
Triple(0.0, chroma, second),
Triple(0.0, second, chroma),
Triple(second, 0.0, chroma),
Triple(chroma, 0.0, second),
)
val (red, green, blue) = ramps[sector.toInt()]
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)
@@ -76,7 +76,9 @@ import com.fabledsword.thoughtsync.core.Note
fun EditorTopBar(
note: Note,
readOnly: Boolean,
tint: NoteTint,
/** What the colour PICKER is set to — the swatch dot's fill, and nothing else.
* Since M315 no surface paints with a note's colour; see [noteCardSurface]. */
picked: NoteTint,
onClose: () -> Unit,
onStartChecklist: () -> Unit,
onPicker: (Picker) -> Unit,
@@ -99,16 +101,16 @@ fun EditorTopBar(
},
actions = {
if (!readOnly) {
// A dot in the note's CURRENT colour rather than a palette icon: it
// shows what the colour is as well as what the button does.
// A dot in the picker's CURRENT colour rather than a palette icon: it
// shows what the setting is as well as what the button does.
IconButton(onClick = { onPicker(Picker.COLOR) }) {
Box(
modifier =
Modifier
.size(SWATCH_DOT)
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.border(dark), CircleShape),
.background(picked.chipBackground(dark))
.border(1.dp, picked.border(dark), CircleShape),
)
}
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
@@ -139,16 +141,18 @@ fun EditorTopBar(
// EXPLICIT, and not optional — the same lesson the old bottom bar learned.
// Material derives a bar's content colour from its container via
// contentColorFor(), which maps a colour-SCHEME ROLE to its `on-` pair and
// returns Unspecified for anything else. A note tint is never a role, so the
// icons drew with no colour filter: black vectors on a near-black bar, a
// toolbar that rendered the whole time and was invisible in dark mode.
// returns Unspecified for anything else. The card surface is a plain constant
// and not a role, so the icons drew with no colour filter: black vectors on a
// near-black bar, a toolbar that rendered the whole time and was invisible in
// dark mode. STILL TRUE with one neutral surface — it is the same kind of
// value, so this stays exactly as it is.
//
// onSurface for the actions too, not the default onSurfaceVariant: these sit
// on a tinted bar rather than a scheme surface, and the muted variant does
// onSurface for the actions too, not the default onSurfaceVariant: the bar has
// to read against the card rather than the board, and the muted variant does
// not have the contrast to spare.
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = tint.background(dark),
containerColor = noteCardSurface(dark),
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
@@ -194,11 +198,9 @@ fun EditorTopBar(
fun EditorFooter(
updatedAt: String?,
saving: Boolean,
tint: NoteTint,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
val dark = isSystemInDarkTheme()
Row(
modifier =
modifier
@@ -221,12 +223,20 @@ fun EditorFooter(
)
FilledTonalIconButton(
onClick = onClose,
// The note's own colour rather than the scheme's secondaryContainer,
// which would be the one element on a tinted card ignoring the tint.
// The BRAND, matching the board's compose FAB — the app's one existing
// statement of "this is the affirmative action here", now reused rather
// than a second one invented.
//
// This wore the note's own tint until M315, on the argument that it would
// otherwise be the one element on a tinted card ignoring the tint. There is
// no tint to ignore any more, and the alternative — Material's default
// secondaryContainer — is a baseline M3 colour this theme never sets, so
// taking the default would put an off-brand lilac in the corner of the
// editor.
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = tint.chipBackground(dark),
contentColor = tint.chipForeground(dark),
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) {
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.editor_done))
@@ -95,9 +95,9 @@ fun NoteCard(
// and BEFORE padding, so the padded edge is still a tap target.
.clip(RoundedCornerShape(CARD_RADIUS))
.then(opening)
.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.
// ONE surface and ONE edge on every card, both neutral, neither
// asking the note anything. See noteCardSurface and CARD_EDGE_DARK.
.background(noteCardSurface(dark))
.border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS))
.padding(12.dp),
) {
@@ -426,29 +426,84 @@ private const val MAX_LABEL_CHIPS = 3
private val CARD_RADIUS = 12.dp
private val CARD_ELEVATION = 1.dp
// THE CARD'S EDGE — one grey, every card, both weights, all ten colour keys. It is a
// constant here rather than a column in NoteTint precisely so the palette CANNOT vary
// it; uniformity is the feature.
// ---------------------------------------------------------------------------
// WHAT A CARD IS: one surface and one edge, neither of which asks the note anything.
//
// The version of this that came from the palette was a `{hue}-900` border, and it
// failed twice over: the line measured 1.56-2.09 against its own fill while the fill
// managed only 1.03-1.05 against the board, so it was the loudest thing on the card
// and it carried the same information the fill did. A field of cards read as a grid of
// outlines however different the colours inside were. A neutral line carries no
// information at all, which is exactly what lets it be structure instead of content.
// Both are constants HERE rather than columns in NoteTint precisely so the palette
// CANNOT vary them; uniformity is the feature. The editor reads the surface from here
// too, so a note opened is the same object as the note on the board.
/**
* THE CARD SURFACE — one neutral per theme (M315).
*
* This used to be a function of the note: a palette fill for a tagged one, a colour
* generated from the id for the rest. Both are gone. The operator's verdict after four
* passes — "my coloring attempt has failed and nothing looks right… we've tried a lot
* to make the color work and somehow it never seems to land" — and the diagnosis under
* it is that a card's fill was being asked to carry meaning it could not carry. Nine
* keys is too few to identify anything on a board of any size, and a generated fill
* identifies nothing by construction, so a coloured board taught the eye to read hue
* as significant and then handed it noise. Colour lives on the TAG now, where the
* thing it names is right beside it.
*
* The values are `neutral-900` on dark and white on light — exactly what the palette's
* `default` always was, and exactly what the web card and both editors already use, so
* this is a collapse onto a surface every surface already had rather than a new colour
* anybody has to like. Mirrored as `NOTE_CARD_SURFACE` in `frontend/src/notes/colors.ts`
* (`bg-white dark:bg-neutral-900`).
*
* NOT a colour-scheme role: `surface` is the BOARD in this theme (neutral-50 / -950),
* and Material's `surfaceContainer` roles are unset here so they would resolve to
* baseline M3 greys rather than to the web's neutrals. Two hexes matching the web beats
* a role that nearly does.
*
* Measured, against the operator's "not the same color as their background but close
* to it" — the card fill is deliberately the WEAKEST number on the card:
*
* card vs board light #FFFFFF on #FAFAFA 1.04
* dark #171717 on #0A0A0A 1.10
* edge vs card light #B8B8B8 on #FFFFFF 1.98
* dark #404040 on #171717 1.73
* body vs card light #171717 on #FFFFFF 17.93 (needs 4.5)
* dark #FAFAFA on #171717 17.17
* muted vs card light #404040 on #FFFFFF 10.37
* dark #E5E5E5 on #171717 14.23
*
* A card is not separated from the board by its fill and never was — the edge and the
* shadow do that, which is why 1.04 is enough and why it has to stay near 1. A fill
* that separated on its own would be a panel, and a board of panels is the wall this
* whole line of work started from.
*/
fun noteCardSurface(dark: Boolean): Color = if (dark) CARD_SURFACE_DARK else CARD_SURFACE_LIGHT
private val CARD_SURFACE_LIGHT = Color(0xFFFFFFFF)
private val CARD_SURFACE_DARK = Color(0xFF171717)
// THE CARD'S EDGE — one grey, every card, both themes. Since M315 it is the only thing
// that differs from the board by more than a hair, which makes it structure rather than
// decoration: it is what a card IS.
//
// The two values are MATCHED rather than chosen by eye: each measures ~1.6-1.7 against
// the card it edges (light 1.57-1.98 across all twenty fills, dark 1.58-1.73), so the
// edge reads with the same authority in either theme. Dark is `neutral-700`, which is
// what the `default` card's border always was — one entry's value promoted to the rule
// for all of them. Light sits between `neutral-300` and `neutral-400`, neither of which
// lands in range: 300 fades to 1.18 on a gray-tagged card, 400 jumps to 2.52 and reads
// as a wireframe.
// It was already neutral before the fill was. The version that came from the palette
// was a `{hue}-900` border and failed twice over: the line measured 1.56-2.09 against
// its own fill while the fill managed only 1.03-1.05 against the board, so it was the
// loudest thing on the card — and it carried the same information the fill did, so a
// field of cards read as a grid of outlines however different the colours inside were.
// A neutral line carries no information at all, which is exactly what lets it be
// structure instead of content. The fill is that same argument one size up.
//
// MEASURED AGAINST ONE FILL NOW, and deliberately left where it was. #B8B8B8 on white
// is 1.98 and #404040 on #171717 is 1.73 — both inside the ranges these values already
// shipped at across twenty fills (light 1.57-1.98, dark 1.58-1.73), but at the top of
// them rather than the ~1.6-1.7 the pair was originally matched on. Softening the light
// edge to re-match would weaken the only boundary a white card on a #FAFAFA board has,
// and the complaint that started M315 was about fill, never about edge weight. If an
// operator pass disagrees it is one constant, in two files.
//
// NOT a translucent black/white edge, which is the tidier way to write this and was
// measured and rejected: a border composites over the card's own fill, so `White` at
// 20% comes out #56396D on a purple card and #A3C9C1 on a teal one. Hue-coded edges are
// the thing being removed.
// measured and rejected: a border composites over what is under it, so `White` at 20%
// came out #56396D on a purple card and #A3C9C1 on a teal one. With one fill that
// argument no longer bites — but an opaque grey is what NoteCard.vue must also write,
// and two surfaces stating the same hex is how they stay the same card.
private val CARD_EDGE_LIGHT = Color(0xFFB8B8B8)
private val CARD_EDGE_DARK = Color(0xFF404040)
private val CHIP_RADIUS = 6.dp
@@ -39,8 +39,9 @@ import kotlinx.coroutines.delay
* negotiating with the IME for the bottom half of the display, and the swipe-down it
* buys is a gesture back already does. The shape is what was worth keeping.
*
* The note's own colour paints the WHOLE card rather than a panel inside it, so
* opening a note reads as the same object growing to fill the display.
* The card's surface paints the WHOLE sheet rather than a panel inside it, so opening
* a note reads as the same object growing to fill the display — the more literally
* true since M315, where the board and the editor became the same one neutral.
*
* No save button, deliberately. Writes are continuous, so a button offering to do
* what already happened would be a lie with a tap attached; [EditorFooter] in the
@@ -58,7 +59,10 @@ fun NoteEditorScreen(
onAction: (EditorAction) -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTintFor(note)
// The colour the PICKER is set to, which since M315 is all `note.color` still is:
// nothing paints with it any more. It feeds the swatch dot so the control shows its
// own state; both go together in #3041.
val picked = noteTint(note.color)
// 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
@@ -150,24 +154,24 @@ fun NoteEditorScreen(
Surface(
modifier = Modifier.fillMaxSize(),
shape = RoundedCornerShape(topStart = SHEET_CORNER, topEnd = SHEET_CORNER),
color = noteCardColor(note, dark),
color = noteCardSurface(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
// colour-SCHEME ROLE. A note tint never is, so the default publishes
// colour-SCHEME ROLE. The card surface is not one, so the default publishes
// Unspecified as LocalContentColor and everything inside that does not
// set its own colour draws black — which is how the last toolbar became
// invisible in dark mode.
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Scaffold(
containerColor = noteCardColor(note, dark),
containerColor = noteCardSurface(dark),
contentColor = MaterialTheme.colorScheme.onSurface,
topBar = {
EditorTopBar(
note = note,
readOnly = readOnly,
tint = tint,
picked = picked,
onClose = leave,
onStartChecklist = {
val (next, id) = blocks.plusTask()
@@ -187,7 +191,6 @@ fun NoteEditorScreen(
EditorFooter(
updatedAt = note.updatedAt,
saving = saving,
tint = tint,
onClose = leave,
)
},
@@ -3,23 +3,25 @@ package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import com.fabledsword.thoughtsync.core.Note
/**
* The note colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE.
* The colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE.
*
* A note's colour is stored by the core as a key ("red", "teal", …) and every
* surface resolves it to its own tints. The web app resolves through Tailwind
* classes; this table is those same Tailwind colours as literals, so a note that
* 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.
* A colour is stored by the core as a key ("red", "teal", …) and every surface
* resolves it to its own tints. The web app resolves through Tailwind classes; this
* table is those same Tailwind colours as literals, so a tag that 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.
*
* 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.
* Dark tints keep the web's ALPHA instead of a precomputed blend — Compose composites
* a translucent colour over what's beneath exactly as CSS does, so a panel 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.
* This table is the palette of MEANINGFUL colours: a tag's. A NOTE no longer has one
* at all (M315) — the card is one neutral per theme, held in NoteCard.kt beside the
* edge, where the palette cannot reach either of them. What is left here is the chip,
* the inline `#tag`, and the panels and banners that borrow a hue to say what they
* are.
*
* `yellow` maps to Tailwind's *amber*, matching colors.ts; plain yellow is too
* acid against the neutral surfaces.
@@ -43,51 +45,22 @@ data class NoteTint(
* happens to be, which includes the gray-tagged card at `neutral-200` — and there
* the chip's `-700` measured 3.98 (green), 4.11 (orange) and 4.34 (teal), all
* under the 4.5 body text needs. One step deeper puts every hue between 5.63 and
* 12.01 in light and 7.20 and 10.84 in dark, across every fill in the palette and
* every generated fill, so it is one rule rather than three exceptions.
* 12.01 in light and 7.20 and 10.84 in dark, across every fill that existed then,
* so it was one rule rather than three exceptions.
*
* THE CONSTRAINT IS GONE as of M315 — one card fill per theme means this is solving
* for two backgrounds instead of twenty. Left here unchanged because it still
* clears 4.5 on both; re-measuring against one known background is #3149.
*/
val lightTagInk: Color,
val darkTagInk: Color,
/**
* False only for `default`, which is the ABSENCE of a colour rather than one of
* 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
/**
* 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].
*
* 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 NOT A TINT and never comes from here. It used to be a 1px
* `{hue}-900` border measuring 1.562.09 against its own fill while the fill
* managed 1.031.05 against the board — the loudest thing on every card saying
* exactly what the fill already said, so a field of them read as a grid of
* outlines. The edge is now one grey for all ten keys, held as a constant in
* `NoteCard.kt` where the palette cannot reach it. [border] is untouched and
* still serves panels, banners, the update card and the pickers.
*
* 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].
* The pale fill of a PANEL, a banner, an update card or a picker swatch — the
* places that borrow a hue to say what they are. Not a note's: since M315 a card
* has one neutral surface and does not come through this table at all.
*/
fun chosenBackground(dark: Boolean): Color =
when {
!tintable -> background(dark)
dark -> darkBackground.copy(alpha = STRONG_DARK_ALPHA)
else -> lightChipBackground
}
fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
fun border(dark: Boolean): Color = if (dark) darkBorder else lightBorder
@@ -133,11 +106,6 @@ data class NoteTint(
// it does not carry the meaning. Raise to 0.80 if that judgment is ever overruled.
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. 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. */
val NOTE_TINTS: Map<String, NoteTint> =
mapOf(
@@ -154,7 +122,6 @@ val NOTE_TINTS: Map<String, NoteTint> =
darkChipForeground = Color(0xFFD4D4D4),
lightTagInk = Color(0xFF404040),
darkTagInk = Color(0xFFD4D4D4),
tintable = false,
),
"red" to
NoteTint(
@@ -295,64 +262,6 @@ val NOTE_TINTS: Map<String, NoteTint> =
@ReadOnlyComposable
fun noteTint(key: String): NoteTint = NOTE_TINTS[key] ?: NOTE_TINTS.getValue("default")
/**
* The tint for a NOTE, which is not the same thing as looking up its stored key: a
* note that has no colour of its own gets one derived from its id, so that a board
* of untagged notes reads as individual items rather than a wall of white.
*
* See `DerivedTint.kt` — the rule lives there, free of Compose, so it can be tested.
*/
@Composable
@ReadOnlyComposable
fun noteTintFor(note: Note): NoteTint =
noteTint(resolvedNoteColor(note.id, note.color, firstLabelColor(note), NOTE_TINTS.keys))
/**
* 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.
*/
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.
*
* First rather than any other: it is the one the person controls by typing, where
* alphabetical or most-used would move a note's colour when an unrelated tag was
* added somewhere else. Manual labels count the same as `#tags` — someone looking at
* a chip cannot tell which kind they made, and two identically-tagged notes in
* different colours for an invisible reason is worse than the rule being loose.
*/
private fun firstLabelColor(note: Note): String {
val first = note.labels.firstOrNull() ?: return ""
return resolvedLabelColor(first.name, first.color, NOTE_TINTS.keys)
}
/**
* The tint for a LABEL, derived from its name when nobody has picked one.
*
@@ -5,14 +5,20 @@ import org.junit.Assert.assertNotEquals
import org.junit.Test
/**
* Pins the derived-tint rule against `frontend/src/notes/colors.ts`.
* Pins the derived-colour rule against `frontend/src/notes/colors.ts`.
*
* These are not tests of Kotlin — they are the ONE mechanical guard the mirrored pair
* has. The web side is TypeScript with no test runner (its CI lane is `vue-tsc
* --noEmit` and nothing else), so if these values drift, nothing on that surface will
* say so and a note will simply be a different colour on the phone than in the
* browser. The same four ids and hashes are written into colors.ts as a comment;
* changing either side means changing both and re-checking here.
* say so and a tag will simply be a different colour on the phone than in the browser.
* The same names and hashes are written into colors.ts as a comment; changing either
* side means changing both and re-checking here.
*
* SMALLER SINCE M315. Half of what this file used to pin — the generated card fill, and
* the resolution order that chose between a picked colour, a tag's and a generated one
* — went with the code it guarded when the card became one neutral. The four UUID
* hashes stay because they are what the hash ITSELF is pinned by; nothing derives a
* colour from an id any more, only from a tag's name.
*/
class DerivedTintTest {
@Test
@@ -27,7 +33,7 @@ class DerivedTintTest {
}
@Test
fun `tints match the fixture shared with the web`() {
fun `colours match the fixture shared with the web`() {
assertEquals("purple", derivedTint("00000000-0000-0000-0000-000000000000"))
assertEquals("blue", derivedTint("11111111-1111-1111-1111-111111111111"))
assertEquals("orange", derivedTint("6ba7b810-9dad-11d1-80b4-00c04fd430c8"))
@@ -36,12 +42,11 @@ class DerivedTintTest {
/** Half of all 32-bit hashes are negative as Kotlin Ints; a signed remainder would
* index out of the list for those. The bug this catches is a crash, not a wrong
* colour, so it is worth more than one id's worth of coverage. */
* colour, so it is worth more than one name's worth of coverage. */
@Test
fun `every tint is a real palette key, over many ids`() {
fun `every derived colour is a real palette key, over many names`() {
for (n in 0 until 2000) {
val tint = derivedTint("note-$n")
assertEquals(true, tint in DERIVED_TINT_KEYS)
assertEquals(true, derivedTint("tag-$n") in DERIVED_TINT_KEYS)
}
}
@@ -51,8 +56,8 @@ class DerivedTintTest {
assertEquals(9, DERIVED_TINT_KEYS.size)
}
/** The order IS the mapping — reordering silently recolours every untagged note
* on one surface only. Written out longhand so a reorder fails here loudly. */
/** The order IS the mapping — reordering silently recolours every tag on one
* surface only. Written out longhand so a reorder fails here loudly. */
@Test
fun `key order matches colors ts`() {
assertEquals(
@@ -61,76 +66,8 @@ class DerivedTintTest {
)
}
@Test
fun `an explicitly picked colour still wins`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertEquals("teal", resolvedNoteColor("any-id", "teal", "", known))
}
@Test
fun `an unknown colour key falls back to the derived tint`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val id = "00000000-0000-0000-0000-000000000000"
assertEquals("purple", resolvedNoteColor(id, "chartreuse", "", known))
}
/** `default` is not a choice, it is the absence of one — so a note stored as
* `default` gets a derived tint rather than staying white. That is the whole
* point of the change. */
@Test
fun `a default colour is treated as no colour`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val id = "11111111-1111-1111-1111-111111111111"
assertEquals("blue", resolvedNoteColor(id, "default", "", known))
assertNotEquals("default", resolvedNoteColor(id, "default", "", known))
}
/** A draft has no id yet. It must not be hashed — see the comment in DerivedTint. */
@Test
fun `a draft stays default until it has an id`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertEquals("default", resolvedNoteColor("", "", "", known))
assertEquals("default", resolvedNoteColor("", "default", "", known))
}
/** The point of the whole milestone: notes sharing a tag share a colour, however
* different their ids. Four `#todo` notes in four colours is what started this. */
@Test
fun `notes sharing a tag share a colour whatever their ids`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val todo = resolvedLabelColor("todo", "default", known)
val a = resolvedNoteColor("11111111-1111-1111-1111-111111111111", "default", todo, known)
val b = resolvedNoteColor("6ba7b810-9dad-11d1-80b4-00c04fd430c8", "default", todo, known)
assertEquals(todo, a)
assertEquals(a, b)
// ...and it is NOT what either id would have derived on its own.
assertNotEquals(derivedTint("11111111-1111-1111-1111-111111111111"), a)
}
/** Until step 5 removes the picker, a hand-picked colour outranks the tag. */
@Test
fun `an explicit note colour still beats its tag`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val todo = resolvedLabelColor("todo", "default", known)
assertNotEquals("teal", todo)
assertEquals("teal", resolvedNoteColor("any-id", "teal", todo, known))
}
/** Strength is not a second decision — it IS whether the colour was chosen. */
@Test
fun `only a chosen colour is drawn strongly`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val todo = resolvedLabelColor("todo", "default", known)
assertEquals(true, noteColorIsChosen("default", todo, known))
assertEquals(true, noteColorIsChosen("teal", "", known))
assertEquals(false, noteColorIsChosen("default", "", known))
assertEquals(false, noteColorIsChosen("", "", known))
// An unrecognised key is not a choice — it is data we could not read.
assertEquals(false, noteColorIsChosen("chartreuse", "", known))
}
/** A tag with no colour of its own derives one from its NAME, which is what makes
* every `#todo` note the same colour rather than nine different ones. */
* every `#todo` chip the same colour rather than nine different ones. */
@Test
fun `a label with no colour derives one from its name`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
@@ -159,13 +96,29 @@ class DerivedTintTest {
assertEquals("teal", resolvedLabelColor("todo", "teal", known))
}
/** An unreadable key is not a choice — it is data from a server newer than this
* client, and the tag should still be drawn as something. */
@Test
fun `an unknown colour key falls back to the derived colour`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertEquals(derivedTint("todo"), resolvedLabelColor("todo", "chartreuse", known))
}
/** A label with no name at all has nothing to hash. Neutral, not a random hue. */
@Test
fun `a nameless label stays default`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertEquals("default", resolvedLabelColor("", "", known))
assertEquals("default", resolvedLabelColor("", "default", known))
}
/**
* The spread is real, and collisions are real too.
*
* Nine keys means two tags sharing a colour is not a bug and cannot be designed
* out — in this very sample `home`/`reading` are both gray and `work`/`ideas` are
* both green. Colour is a hint that two notes are related, never a claim that they
* carry the same tag; the chip's TEXT is what says which tag it is.
* both green. Colour is a hint that two chips are distinct, never a claim that two
* of one colour are the same tag; the chip's TEXT is what says which tag it is.
*/
@Test
fun `different tag names spread across the palette`() {
@@ -175,144 +128,10 @@ class DerivedTintTest {
assertEquals(true, colours.toSet().size >= 5)
}
/** The reason the feature exists: two adjacent notes should not look identical. */
/** The reason the feature exists: two tags on one board should not look identical. */
@Test
fun `the tint spreads across the palette`() {
fun `the derived colour spreads across the palette`() {
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("#1e3130", hex(derivedFillArgb("00000000-0000-0000-0000-000000000000", dark = true)))
assertEquals("#1a2818", hex(derivedFillArgb("11111111-1111-1111-1111-111111111111", dark = true)))
assertEquals("#162419", hex(derivedFillArgb("6ba7b810-9dad-11d1-80b4-00c04fd430c8", dark = true)))
assertEquals("#311e20", hex(derivedFillArgb("f47ac10b-58cc-4372-a567-0e02b2c3d479", dark = true)))
assertEquals("#f3fcfb", hex(derivedFillArgb("00000000-0000-0000-0000-000000000000", dark = false)))
assertEquals("#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 reads as a hole in the board rather than a card on it.
*
* THIS TEST HAS ALREADY EARNED ITS KEEP. The first floor was 0.090 — `neutral-900`'s
* own HSL lightness — on the reasoning that starting at the card surface and
* climbing could not possibly go below it. That reasoning confuses HSL lightness
* with luminance. At one fixed lightness the eye sees very different brightnesses
* by hue, because green carries 71% of the luminance formula and blue 7%: at 0.090
* a yellow measures 0.0118 and a blue 0.0061, so every blue-ish untagged note was
* 1.41x darker than the card it was supposed to match. Solved for, the floor is
* 0.113.
*
* Which is why this asserts on LUMINANCE and not on the input lightness — testing
* the number that was put in would have agreed with the bug.
*/
@Test
fun `no generated fill sinks below the card surface`() {
val surface = luminance(0xFF171717.toInt())
for (n in 0 until 500) {
assertEquals(true, luminance(derivedFillArgb("note-$n", dark = true)) >= surface)
}
// The worst case is a HUE — blue, around 240 — and 500 ids are not enough to
// be sure of having visited it at the darkest level. There are 360 x 6 = 2160
// reachable combinations; by the coupon-collector bound ~16,600 draws covers
// them, so 40,000 makes a miss vanishingly unlikely. Cheap on the JVM, and the
// alternative is a test that would have PASSED against the original bug.
for (n in 0 until 40_000) {
assertEquals(true, luminance(derivedFillArgb("hue-sweep-$n", dark = true)) >= surface)
}
}
/** 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)
}
}
+27 -22
View File
@@ -3,11 +3,10 @@ import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import {
LABEL_CHIP_CLASSES,
NOTE_CARD_SURFACE,
NOTE_COLOR_KEYS,
NOTE_COLOR_LABELS,
NOTE_SWATCH_CLASSES,
noteCardClasses,
noteTintVars,
resolveLabelColor,
type NoteColor,
} from "../notes/colors";
@@ -242,37 +241,43 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
ref="root"
class="group relative mb-4 break-inside-avoid rounded-xl border border-[#b8b8b8] p-3 shadow-sm transition hover:shadow-md dark:border-[#404040]"
:class="[
noteCardClasses(note),
NOTE_CARD_SURFACE,
dragging ? 'opacity-40' : '',
dragOver
? 'scale-[1.02] shadow-lg ring-2 ring-brand ring-offset-2 ring-offset-white dark:ring-offset-neutral-950'
: '',
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.
Every card gets the same grey hairline whatever colour it is; the fill is the
only thing that varies. The version of this that was a `{hue}-900` border
failed because the line was both the loudest element on the card (1.56-2.09
against its own fill, where the fill managed 1.03-1.05 against the board) AND
carried the same information the fill did, so a board of them read as a grid
of outlines. A neutral line carries no information at all, which is exactly
what lets it be structure.
<!-- THE EDGE IS THE CARD'S BOUNDARY, and since M315 it is the ONLY thing that
varies from the board: the fill is one neutral (NOTE_CARD_SURFACE) and no
longer says anything about the note. That makes this line load-bearing rather
than decorative — it is what a card IS.
The two values are MATCHED, not picked by eye: each measures ~1.6-1.7 against
the card it edges (light 1.57-1.98, dark 1.58-1.73), so the edge has the same
authority in either theme. #404040 is `neutral-700`, which is what the default
card's border always was — promoted from one entry in the palette to the rule
for all of them. #b8b8b8 sits between `neutral-300` and `neutral-400`, neither
of which lands in range: 300 fades out at 1.18 on a gray-tagged card, 400
jumps to 2.52 and reads as a wireframe.
It was already neutral before the fill was. The version that came from the
palette was a `{hue}-900` border and failed twice over: the line was the
loudest element on the card (1.56-2.09 against its own fill, where the fill
managed 1.03-1.05 against the board) AND carried the same information the fill
did, so a board of them read as a grid of outlines. A neutral line carries no
information at all, which is exactly what lets it be structure. The fill is
the same argument one size up, made two milestones later.
MEASURED AGAINST ONE FILL NOW, and deliberately left where it was. #b8b8b8 on
white is 1.98 and #404040 on #171717 is 1.73 — both inside the ranges these
values already shipped at across twenty fills (light 1.57-1.98, dark
1.58-1.73), but at the top of them rather than the ~1.6-1.7 the pair was
originally matched on. Softening the light edge to re-match would weaken the
only boundary a white card on a #fafafa board has, and the complaint that
started M315 was about fill, never about edge weight. If an operator pass
disagrees it is one constant, in two files.
NOT a translucent black/white edge, which is the tidier-looking way to do this
and was measured and rejected: a border composites over the card's own fill,
so `border-white/20` comes out #56396d on a purple card and #a3c9c1 on a teal
one. Hue-coded edges are the thing being removed.
and was measured and rejected: a border composites over what is under it, so
`border-white/20` came out #56396d on a purple card and #a3c9c1 on a teal one.
With one fill that argument no longer bites — but an opaque grey is what the
Android side must also write, and two surfaces stating the same hex is how
they stay the same card.
`shadow-sm`, back down from `shadow`: the border is the boundary again, so the
shadow is only depth. -->
+86 -238
View File
@@ -18,8 +18,8 @@ export const NOTE_COLOR_KEYS = [
export type NoteColor = (typeof NOTE_COLOR_KEYS)[number];
/** 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. */
* than this client. Once a lookup in a card-fill table; since M315 there is no such
* table, and this guards a LABEL's stored colour on its way into the palette. */
const KNOWN_COLORS = new Set<string>(NOTE_COLOR_KEYS);
export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = {
@@ -59,14 +59,20 @@ export const LABEL_CHIP_CLASSES: Record<NoteColor, string> = {
// The ink for a `#tag` drawn where it was typed, rather than repeated as a chip.
//
// ONE Tailwind step deeper than LABEL_CHIP_CLASSES' text, and the difference is not a
// stylistic one. A chip carries its own `-100` fill, so its text has exactly one
// background to read against. Inline text sits on whatever the CARD is — which
// includes a gray-tagged card at `neutral-200`, where the chip's `-700` measured 3.98
// (green), 4.11 (orange) and 4.34 (teal), all under the 4.5 body text needs. At `-800`
// every hue lands between 5.63 and 12.01 in light, and `-300` gives 7.20 to 10.84 in
// dark, measured against every fill in NOTE_CARD_CLASSES_STRONG and every generated
// fill. One step for all ten beats three per-hue exceptions.
// ONE Tailwind step deeper than LABEL_CHIP_CLASSES' text. A chip carries its own
// `-100` fill, so its text has exactly one background to read against; inline text
// sits on whatever the CARD is. When the card could be any of twenty fills that was a
// hard constraint — on a gray-tagged card at `neutral-200` the chip's `-700` measured
// 3.98 (green), 4.11 (orange) and 4.34 (teal), all under the 4.5 body text needs,
// where `-800` put every hue between 5.63 and 12.01 in light and `-300` gave 7.20 to
// 10.84 in dark, across every fill. One step for all ten beat three per-hue exceptions.
//
// THE CONSTRAINT IS GONE as of M315: there is one card fill per theme now
// (NOTE_CARD_SURFACE), so this table is solving for two backgrounds instead of twenty.
// Left at these values here deliberately — they still clear 4.5 on white and on
// `neutral-900`, so nothing is broken, and re-measuring the tag ink against one known
// background (including whether the chip and inline tables can now collapse into one)
// is its own step: #3149.
//
// Mirrored in NoteTint.kt as `lightTagInk` / `darkTagInk`.
export const TAG_TEXT_CLASSES: Record<NoteColor, string> = {
@@ -123,31 +129,27 @@ export const NOTE_COLOR_LABELS: Record<NoteColor, string> = {
};
// ---------------------------------------------------------------------------
// Derived tints — the colour a note has when nothing chose one for it.
// Derived colour — the hue a LABEL wears when nobody picked one for it.
//
// A board of `default` notes is a wall of white rectangles and the eye gets no
// help telling one from the next. Every note now carries some tint; this is where
// an untagged one gets it.
// Every `#tag` is born colourless, so without this a board of tags is a board of
// identical grey chips. Hashing the tag's NAME is deterministic, identical on every
// surface, costs no column and no migration, and a tag keeps its colour for life.
//
// "RANDOM" MEANS DERIVED. The operator asked for "random subdued colors", but a
// tint rolled at render time would differ between the phone and the browser and
// change on every reload. Hashing the note's id is deterministic, identical on
// every surface, costs no column and no migration, and a note keeps its colour
// for life — which is what "random" actually meant here.
// THIS WAS THE CARD'S COLOUR TOO, ONCE. It is not any more (M315): a note's fill is
// one neutral and only its tags carry hue. The hash survived that removal because the
// job it still does — give a name a stable colour — was never the job that failed.
// What failed was asking a colour that means "which tag" to also mean nothing at all
// on an untagged note, at which point the board had two vocabularies and neither read.
//
// THIS IS HALF A MIRRORED PAIR. `android/.../ui/NoteTint.kt` computes the same
// hash over the same key order, and the two must agree exactly or a note is one
// colour on the phone and another in the browser. Same discipline as the
// checklist grammar's three implementations, and the same reason: a value that
// disagrees across surfaces is a bug you cannot unsee and cannot explain.
// THIS IS HALF A MIRRORED PAIR. `android/.../ui/DerivedTint.kt` computes the same
// hash over the same key order, and the two must agree exactly or a tag is one colour
// on the phone and another in the browser. Same discipline as the checklist grammar's
// three implementations, and the same reason: a value that disagrees across surfaces
// is a bug you cannot unsee and cannot explain.
//
// The Kotlin side has a unit test pinning the fixture below. THIS SIDE HAS NO
// MECHANICAL GUARD — the frontend has no test runner, only `vue-tsc --noEmit`.
// If you change anything here, check it against the fixture by hand.
/** The tints a derived colour can land on: the palette minus `default`, which is
* the white this exists to eliminate. `gray` stays — `bg-neutral-100` reads as a
* deliberate card against the board's `bg-neutral-50`, not as an absence. */
export const DERIVED_TINT_KEYS: readonly NoteColor[] = NOTE_COLOR_KEYS.filter(
(key) => key !== "default",
);
@@ -175,128 +177,26 @@ export function tintHash(id: string): number {
return hash >>> 0;
}
/** The tint a note with no colour of its own wears. Stable for the life of the note. */
/** The colour a name maps to, stable for as long as the name is. Called with a
* label's lowercased name; `id` is the parameter's history, not its meaning. */
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. 338 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;
// HSL LIGHTNESS IS NOT LUMINANCE, and the dark floor is set by the difference.
//
// The obvious floor is `neutral-900`'s own lightness, 0.090 — start at the plain card
// surface and climb, so no note ever recedes into the board. That was the first
// attempt and it was wrong: at a FIXED HSL lightness the eye sees wildly different
// brightnesses by hue, because green carries 71% of the luminance formula and blue
// only 7%. At L=0.090 a yellow measures 0.0118 and a blue 0.0061 — the blue landing
// 1.41x DARKER than the card it was meant to match, so a sixth of the board would
// have been holes rather than variety.
//
// 0.113 is the lowest floor at which every hue clears the card surface, solved for
// rather than guessed: 1.11-1.71 against the board, where the old single level
// managed 1.14. Light runs the other way, from white down past the `neutral-50`
// board; a card slightly darker than the board still reads as one because the edge
// says so, and near white the hue barely moves luminance at all so it needs no
// equivalent correction.
const DARK_LIGHTNESS = [0.113, 0.127, 0.141, 0.155, 0.169, 0.183];
const LIGHT_LIGHTNESS = [1.0, 0.99, 0.98, 0.97, 0.96, 0.95];
/**
* 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.
* The colour to paint a LABEL — its chip, and its `#tag` where it sits in the prose.
*
* Derived from the tag's NAME, not stored, when nobody has picked one. Every `#tag`
* ever typed is currently `default`: `notes/tags.py` mints one as
* `Label(owner_id=…, name=name)` with no colour, so it takes the column default.
* Tag-driven note colour against that would leave the board exactly as grey as it
* was.
* ever typed is `default`: `notes/tags.py` mints one as `Label(owner_id=…, name=name)`
* with no colour, so it takes the column default. Without deriving, a board of tags
* would be a board of identical grey chips.
*
* DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing the original plan in #2965.
* That plan wanted a hashed colour written at each of the four places a label can be
* born — and named the risk itself: `find_or_create_label` is "easy to miss, and it
* is the common one", because most tags are born from typing `#grocery`, not from a
* management screen. Deriving has no mint points to miss, needs no backfill for the
* tags that already exist, and reuses the hash the notes already use. The cost is
* tags that already exist, and reuses a hash that is already written twice. The cost is
* that renaming a tag recolours it, which is defensible: the name IS the tag.
*
* An explicitly-picked colour is still stored and still wins, so tag colours stay
@@ -313,119 +213,67 @@ export function resolveLabelColor(label: { name: string; color?: string | null }
return derivedTint(label.name.toLowerCase());
}
// Fixture — the same ids and expected keys the Kotlin test asserts. Kept here as
// prose because there is nowhere on this side to assert it. If you change the hash
// or the key order, these four must still hold on BOTH surfaces:
// Fixture — the same names and expected keys the Kotlin test asserts. Kept here as
// prose because there is nowhere on this side to assert it. If you change the hash or
// the key order, these must still hold on BOTH surfaces.
//
// The raw hash, over four UUIDs — ids no longer pick a colour, but they are what the
// hash itself is pinned by and the Kotlin test still asserts them:
//
// 00000000-0000-0000-0000-000000000000 0xbe478ed1 purple
// 11111111-1111-1111-1111-111111111111 0x3d75cc01 blue
// 6ba7b810-9dad-11d1-80b4-00c04fd430c8 0xf108e530 orange
// f47ac10b-58cc-4372-a567-0e02b2c3d479 0x5b651540 orange
//
// And for labels, which hash the lowercased NAME rather than an id:
// And the live path — a label, hashing its lowercased NAME:
//
// 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 #1e3130 #f3fcfb
// 11111111-1111-1111-1111-111111111111 hue 113 lev 1 #1a2818 #fbfefb
// 6ba7b810-9dad-11d1-80b4-00c04fd430c8 hue 136 lev 0 #162419 #ffffff
// f47ac10b-58cc-4372-a567-0e02b2c3d479 hue 352 lev 3 #311e20 #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.
// and it is not a bug: colour hints that two tags are distinct, it never claims two
// chips of one colour are the same tag. The chip's text is what says which tag it is.
// The FULL-strength ramp: what a note wears when its colour was CHOSEN — by a tag, or
// (until step 5) by the picker. One Tailwind step deeper in light mode, and a much
// heavier fill in dark.
// ---------------------------------------------------------------------------
// THE CARD SURFACE — one neutral, no hue, no note involved (M315).
//
// UNCHANGED by the border pass, deliberately. The operator's complaint was the edge
// line and the loudness of the DERIVED tint; the chosen colours were "pretty well"
// where they landed, and a ramp somebody has already signed off on is not something
// to redo while fixing something else.
// This used to be a function of the note: a palette class for a tagged one, a fill
// generated from the id for the rest. Both are gone. The operator's verdict after
// four passes at it — "my coloring attempt has failed and nothing looks right… we've
// tried a lot to make the color work and somehow it never seems to land" — and the
// diagnosis underneath it is that a card's fill was being asked to carry meaning it
// could not carry. Nine keys is too few to identify anything on a board of any size,
// and a generated fill identifies nothing by construction, so a coloured board taught
// the eye to read hue as significant and then handed it noise.
//
// 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.
// COLOUR NOW LIVES ONLY ON THE TAG — the chip and the inline `#tag`, both of which sit
// on this one known background from here on. That is a smaller job done properly
// instead of a larger one done four times.
//
// 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",
orange: "bg-orange-100 dark:bg-orange-950/70",
yellow: "bg-amber-100 dark:bg-amber-950/70",
green: "bg-green-100 dark:bg-green-950/70",
teal: "bg-teal-100 dark:bg-teal-950/70",
blue: "bg-blue-100 dark:bg-blue-950/70",
purple: "bg-purple-100 dark:bg-purple-950/70",
pink: "bg-pink-100 dark:bg-pink-950/70",
gray: "bg-neutral-200 dark:bg-neutral-800/70",
};
/**
* The palette key a note was GIVEN, or null when nothing gave it one.
*
* 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 —
* it is the one the person controls by typing, where alphabetical or most-used would
* move a note's colour when an unrelated tag was added somewhere else.
*
* Manual labels count the same as `#tags`. Someone looking at a chip cannot tell which
* 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 chosenNoteColor(note: {
color?: string | null;
labels?: { name: string; color: string }[];
}): NoteColor | null {
const picked = note.color as NoteColor | undefined | null;
if (picked && picked !== "default" && KNOWN_COLORS.has(picked)) return picked;
const first = note.labels?.[0];
if (first) return resolveLabelColor(first);
return null;
}
/**
* 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 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),
};
}
// The codebase had already made this argument about the card's EDGE, one level down:
// the hue-coded border came out as "a neutral line carries no information at all,
// which is exactly what lets it be structure instead of content". The fill is the same
// argument at the next size up.
//
// THE VALUES. `bg-white dark:bg-neutral-900` — which is exactly what `default` always
// was, and exactly what the note EDITOR panel has always been (NoteEditor.vue), so
// this is a collapse onto a surface both other surfaces already used rather than a
// new colour anybody has to like.
//
// Measured against the operator's constraint, "not the same color as their background
// but close to it":
//
// card vs board light #ffffff on #fafafa 1.04
// dark #171717 on #0a0a0a 1.10
// edge vs card light #b8b8b8 on #ffffff 1.98
// dark #404040 on #171717 1.73
// body vs card light #171717 on #ffffff 17.93 (needs 4.5)
// dark #fafafa on #171717 17.17
// muted vs card light #404040 on #ffffff 10.37
// dark #e5e5e5 on #171717 14.23
//
// The fill is deliberately the WEAKEST of those numbers. A card is not separated from
// the board by its fill and never was — the edge and the shadow do that, which is why
// 1.04 is enough and why it has to stay near 1: a fill that separated on its own would
// be a panel, and a board of panels is the wall this whole line of work started from.
export const NOTE_CARD_SURFACE = "bg-white dark:bg-neutral-900";
-20
View File
@@ -47,26 +47,6 @@ 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.