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

Merged
bvandeusen merged 73 commits from dev into main 2026-08-29 13:39:45 -04:00
9 changed files with 309 additions and 872 deletions
Showing only changes of commit b91091caca - Show all commits
@@ -1,21 +1,19 @@
package com.fabledsword.thoughtsync.ui package com.fabledsword.thoughtsync.ui
import kotlin.math.abs // The colour a LABEL wears when nobody picked one for it.
// 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 // Every `#tag` is born colourless, so without this a board of tags is a board of
// telling one from the next. Every note now carries some tint; this is where an // identical grey chips. Hashing the tag's NAME is deterministic, identical on every
// untagged one gets it. // 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 // THIS WAS THE CARD'S COLOUR TOO, ONCE. It is not any more (M315): a note's fill is
// rolled at render time would differ between the phone and the browser and change on // one neutral and only its tags carry hue. The hash survived that removal because the
// every reload. Hashing the note's id is deterministic, identical on every surface, // job it still does — give a name a stable colour — was never the job that failed.
// costs no column and no migration, and a note keeps its colour for life — which is // What failed was asking a colour that means "which tag" to also mean nothing at all
// what "random" actually meant here. // 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 // 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 // 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 // three implementations, and the same reason: a value that disagrees across surfaces
// is a bug you cannot unsee and cannot explain. // 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. // mirror gets — see the fixture comment in colors.ts.
/** /**
* The tints a derived colour can land on: `NOTE_TINTS`' keys minus `default`, which * The colours a derived hue can land on: `NOTE_TINTS`' keys minus `default`, which is
* is the white this exists to eliminate. `gray` stays — `bg-neutral-100` reads as a * the ABSENCE of a colour — a tag that derived it would be indistinguishable from one
* deliberate card against the board's `bg-neutral-50`, not as an absence. * 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 * Order is load-bearing and matches `DERIVED_TINT_KEYS` in colors.ts. Reordering this
* this list silently recolours every untagged note on one surface only. * list silently recolours every tag on one surface only.
*/ */
val DERIVED_TINT_KEYS: List<String> = val DERIVED_TINT_KEYS: List<String> =
listOf("red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray") listOf("red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray")
@@ -65,7 +63,8 @@ fun tintHash(id: String): Int {
return hash 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 { fun derivedTint(id: String): String {
// Through Long to read the hash as unsigned. A signed remainder would be negative // 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. // 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 * 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 * `default`: the server mints one as `Label(owner_id=…, name=name)` with no colour,
* colour, so tag-driven note colour against that would leave the board exactly as * so without deriving, a board of tags would be a board of identical grey chips.
* grey as it was.
* *
* DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing #2965's plan. That plan wanted * 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 * 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" name.isEmpty() -> "default"
else -> derivedTint(name.lowercase()) 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( fun EditorTopBar(
note: Note, note: Note,
readOnly: Boolean, 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, onClose: () -> Unit,
onStartChecklist: () -> Unit, onStartChecklist: () -> Unit,
onPicker: (Picker) -> Unit, onPicker: (Picker) -> Unit,
@@ -99,16 +101,16 @@ fun EditorTopBar(
}, },
actions = { actions = {
if (!readOnly) { if (!readOnly) {
// A dot in the note's CURRENT colour rather than a palette icon: it // A dot in the picker's CURRENT colour rather than a palette icon: it
// shows what the colour is as well as what the button does. // shows what the setting is as well as what the button does.
IconButton(onClick = { onPicker(Picker.COLOR) }) { IconButton(onClick = { onPicker(Picker.COLOR) }) {
Box( Box(
modifier = modifier =
Modifier Modifier
.size(SWATCH_DOT) .size(SWATCH_DOT)
.clip(CircleShape) .clip(CircleShape)
.background(tint.chipBackground(dark)) .background(picked.chipBackground(dark))
.border(1.dp, tint.border(dark), CircleShape), .border(1.dp, picked.border(dark), CircleShape),
) )
} }
IconButton(onClick = { onPicker(Picker.REMINDER) }) { IconButton(onClick = { onPicker(Picker.REMINDER) }) {
@@ -139,16 +141,18 @@ fun EditorTopBar(
// EXPLICIT, and not optional — the same lesson the old bottom bar learned. // EXPLICIT, and not optional — the same lesson the old bottom bar learned.
// Material derives a bar's content colour from its container via // Material derives a bar's content colour from its container via
// contentColorFor(), which maps a colour-SCHEME ROLE to its `on-` pair and // 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 // returns Unspecified for anything else. The card surface is a plain constant
// icons drew with no colour filter: black vectors on a near-black bar, a // and not a role, so the icons drew with no colour filter: black vectors on a
// toolbar that rendered the whole time and was invisible in dark mode. // 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 // onSurface for the actions too, not the default onSurfaceVariant: the bar has
// on a tinted bar rather than a scheme surface, and the muted variant does // to read against the card rather than the board, and the muted variant does
// not have the contrast to spare. // not have the contrast to spare.
colors = colors =
TopAppBarDefaults.topAppBarColors( TopAppBarDefaults.topAppBarColors(
containerColor = tint.background(dark), containerColor = noteCardSurface(dark),
navigationIconContentColor = MaterialTheme.colorScheme.onSurface, navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
titleContentColor = MaterialTheme.colorScheme.onSurface, titleContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface, actionIconContentColor = MaterialTheme.colorScheme.onSurface,
@@ -194,11 +198,9 @@ fun EditorTopBar(
fun EditorFooter( fun EditorFooter(
updatedAt: String?, updatedAt: String?,
saving: Boolean, saving: Boolean,
tint: NoteTint,
onClose: () -> Unit, onClose: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val dark = isSystemInDarkTheme()
Row( Row(
modifier = modifier =
modifier modifier
@@ -221,12 +223,20 @@ fun EditorFooter(
) )
FilledTonalIconButton( FilledTonalIconButton(
onClick = onClose, onClick = onClose,
// The note's own colour rather than the scheme's secondaryContainer, // The BRAND, matching the board's compose FAB — the app's one existing
// which would be the one element on a tinted card ignoring the tint. // 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 = colors =
IconButtonDefaults.filledTonalIconButtonColors( IconButtonDefaults.filledTonalIconButtonColors(
containerColor = tint.chipBackground(dark), containerColor = MaterialTheme.colorScheme.primary,
contentColor = tint.chipForeground(dark), contentColor = MaterialTheme.colorScheme.onPrimary,
), ),
) { ) {
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.editor_done)) 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. // and BEFORE padding, so the padded edge is still a tap target.
.clip(RoundedCornerShape(CARD_RADIUS)) .clip(RoundedCornerShape(CARD_RADIUS))
.then(opening) .then(opening)
.background(noteCardColor(note, dark)) // ONE surface and ONE edge on every card, both neutral, neither
// ONE grey edge on every card, regardless of its colour — the tint is // asking the note anything. See noteCardSurface and CARD_EDGE_DARK.
// deliberately not consulted here. See CARD_EDGE_DARK. .background(noteCardSurface(dark))
.border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS)) .border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS))
.padding(12.dp), .padding(12.dp),
) { ) {
@@ -426,29 +426,84 @@ private const val MAX_LABEL_CHIPS = 3
private val CARD_RADIUS = 12.dp private val CARD_RADIUS = 12.dp
private val CARD_ELEVATION = 1.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 // WHAT A CARD IS: one surface and one edge, neither of which asks the note anything.
// it; uniformity is the feature.
// //
// The version of this that came from the palette was a `{hue}-900` border, and it // Both are constants HERE rather than columns in NoteTint precisely so the palette
// failed twice over: the line measured 1.56-2.09 against its own fill while the fill // CANNOT vary them; uniformity is the feature. The editor reads the surface from here
// managed only 1.03-1.05 against the board, so it was the loudest thing on the card // too, so a note opened is the same object as the note on the board.
// 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. * 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 // It was already neutral before the fill was. The version that came from the palette
// the card it edges (light 1.57-1.98 across all twenty fills, dark 1.58-1.73), so the // was a `{hue}-900` border and failed twice over: the line measured 1.56-2.09 against
// edge reads with the same authority in either theme. Dark is `neutral-700`, which is // its own fill while the fill managed only 1.03-1.05 against the board, so it was the
// what the `default` card's border always was — one entry's value promoted to the rule // loudest thing on the card — and it carried the same information the fill did, so a
// for all of them. Light sits between `neutral-300` and `neutral-400`, neither of which // field of cards read as a grid of outlines however different the colours inside were.
// lands in range: 300 fades to 1.18 on a gray-tagged card, 400 jumps to 2.52 and reads // A neutral line carries no information at all, which is exactly what lets it be
// as a wireframe. // 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 // 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 // measured and rejected: a border composites over what is under it, so `White` at 20%
// 20% comes out #56396D on a purple card and #A3C9C1 on a teal one. Hue-coded edges are // came out #56396D on a purple card and #A3C9C1 on a teal one. With one fill that
// the thing being removed. // 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_LIGHT = Color(0xFFB8B8B8)
private val CARD_EDGE_DARK = Color(0xFF404040) private val CARD_EDGE_DARK = Color(0xFF404040)
private val CHIP_RADIUS = 6.dp 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 * 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. * 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 * The card's surface paints the WHOLE sheet rather than a panel inside it, so opening
* opening a note reads as the same object growing to fill the display. * 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 * 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 * what already happened would be a lie with a tap attached; [EditorFooter] in the
@@ -58,7 +59,10 @@ fun NoteEditorScreen(
onAction: (EditorAction) -> Unit, onAction: (EditorAction) -> Unit,
) { ) {
val dark = isSystemInDarkTheme() 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 // 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 // needs a key — but a draft's id changes the moment it is first saved, and
@@ -150,24 +154,24 @@ fun NoteEditorScreen(
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
shape = RoundedCornerShape(topStart = SHEET_CORNER, topEnd = SHEET_CORNER), 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 // Both content colours are spelled out for the reason the toolbar had to
// be: Surface and Scaffold each default theirs to contentColorFor(their // be: Surface and Scaffold each default theirs to contentColorFor(their
// container), which returns Unspecified for anything that is not a // 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 // Unspecified as LocalContentColor and everything inside that does not
// set its own colour draws black — which is how the last toolbar became // set its own colour draws black — which is how the last toolbar became
// invisible in dark mode. // invisible in dark mode.
contentColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.onSurface,
) { ) {
Scaffold( Scaffold(
containerColor = noteCardColor(note, dark), containerColor = noteCardSurface(dark),
contentColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.onSurface,
topBar = { topBar = {
EditorTopBar( EditorTopBar(
note = note, note = note,
readOnly = readOnly, readOnly = readOnly,
tint = tint, picked = picked,
onClose = leave, onClose = leave,
onStartChecklist = { onStartChecklist = {
val (next, id) = blocks.plusTask() val (next, id) = blocks.plusTask()
@@ -187,7 +191,6 @@ fun NoteEditorScreen(
EditorFooter( EditorFooter(
updatedAt = note.updatedAt, updatedAt = note.updatedAt,
saving = saving, saving = saving,
tint = tint,
onClose = leave, onClose = leave,
) )
}, },
@@ -3,23 +3,25 @@ package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color 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 * A colour is stored by the core as a key ("red", "teal", …) and every surface
* surface resolves it to its own tints. The web app resolves through Tailwind * resolves it to its own tints. The web app resolves through Tailwind classes; this
* classes; this table is those same Tailwind colours as literals, so a note that * table is those same Tailwind colours as literals, so a tag that is amber on the
* is amber on the desktop is the same amber on the phone rather than a near-miss. * desktop is the same amber on the phone rather than a near-miss. Generated from
* Generated from tailwindcss 3.4's palette rather than transcribed by eye. * 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 * Dark tints keep the web's ALPHA instead of a precomputed blend — Compose composites
* blend — Compose composites a translucent colour over what's beneath exactly as CSS * a translucent colour over what's beneath exactly as CSS does, so a panel sits on the
* does, so the card sits on the background the same way in both. * background the same way in both.
* *
* This table is the palette of MEANINGFUL colours: a tag's. The fill an untagged note * This table is the palette of MEANINGFUL colours: a tag's. A NOTE no longer has one
* wears is generated rather than looked up, and lives in DerivedTint.kt. * 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 * `yellow` maps to Tailwind's *amber*, matching colors.ts; plain yellow is too
* acid against the neutral surfaces. * 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 * 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 * 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 * 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 * 12.01 in light and 7.20 and 10.84 in dark, across every fill that existed then,
* every generated fill, so it is one rule rather than three exceptions. * 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 lightTagInk: Color,
val darkTagInk: 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 pale fill of a PANEL, a banner, an update card or a picker swatch — the
* the picker. A note with no tag does not come through here at all; see * places that borrow a hue to say what they are. Not a note's: since M315 a card
* [noteCardColor]. * has one neutral surface and does not come through this table at all.
*
* 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].
*/ */
fun chosenBackground(dark: Boolean): Color = fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
when {
!tintable -> background(dark)
dark -> darkBackground.copy(alpha = STRONG_DARK_ALPHA)
else -> lightChipBackground
}
fun border(dark: Boolean): Color = if (dark) darkBorder else lightBorder 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. // it does not carry the meaning. Raise to 0.80 if that judgment is ever overruled.
private const val CHIP_EDGE_ALPHA = 0.60f 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. */ /** Keyed by the core's colour vocabulary. Order matches the web's picker. */
val NOTE_TINTS: Map<String, NoteTint> = val NOTE_TINTS: Map<String, NoteTint> =
mapOf( mapOf(
@@ -154,7 +122,6 @@ val NOTE_TINTS: Map<String, NoteTint> =
darkChipForeground = Color(0xFFD4D4D4), darkChipForeground = Color(0xFFD4D4D4),
lightTagInk = Color(0xFF404040), lightTagInk = Color(0xFF404040),
darkTagInk = Color(0xFFD4D4D4), darkTagInk = Color(0xFFD4D4D4),
tintable = false,
), ),
"red" to "red" to
NoteTint( NoteTint(
@@ -295,64 +262,6 @@ val NOTE_TINTS: Map<String, NoteTint> =
@ReadOnlyComposable @ReadOnlyComposable
fun noteTint(key: String): NoteTint = NOTE_TINTS[key] ?: NOTE_TINTS.getValue("default") 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. * 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 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 * 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 * 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 * --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 * say so and a tag will simply be a different colour on the phone than in the browser.
* browser. The same four ids and hashes are written into colors.ts as a comment; * The same names and hashes are written into colors.ts as a comment; changing either
* changing either side means changing both and re-checking here. * 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 { class DerivedTintTest {
@Test @Test
@@ -27,7 +33,7 @@ class DerivedTintTest {
} }
@Test @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("purple", derivedTint("00000000-0000-0000-0000-000000000000"))
assertEquals("blue", derivedTint("11111111-1111-1111-1111-111111111111")) assertEquals("blue", derivedTint("11111111-1111-1111-1111-111111111111"))
assertEquals("orange", derivedTint("6ba7b810-9dad-11d1-80b4-00c04fd430c8")) 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 /** 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 * 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 @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) { for (n in 0 until 2000) {
val tint = derivedTint("note-$n") assertEquals(true, derivedTint("tag-$n") in DERIVED_TINT_KEYS)
assertEquals(true, tint in DERIVED_TINT_KEYS)
} }
} }
@@ -51,8 +56,8 @@ class DerivedTintTest {
assertEquals(9, DERIVED_TINT_KEYS.size) assertEquals(9, DERIVED_TINT_KEYS.size)
} }
/** The order IS the mapping — reordering silently recolours every untagged note /** The order IS the mapping — reordering silently recolours every tag on one
* on one surface only. Written out longhand so a reorder fails here loudly. */ * surface only. Written out longhand so a reorder fails here loudly. */
@Test @Test
fun `key order matches colors ts`() { fun `key order matches colors ts`() {
assertEquals( 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 /** 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 @Test
fun `a label with no colour derives one from its name`() { fun `a label with no colour derives one from its name`() {
val known = DERIVED_TINT_KEYS.toSet() + "default" val known = DERIVED_TINT_KEYS.toSet() + "default"
@@ -159,13 +96,29 @@ class DerivedTintTest {
assertEquals("teal", resolvedLabelColor("todo", "teal", known)) 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. * 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 * 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 * 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 * both green. Colour is a hint that two chips are distinct, never a claim that two
* carry the same tag; the chip's TEXT is what says which tag it is. * of one colour are the same tag; the chip's TEXT is what says which tag it is.
*/ */
@Test @Test
fun `different tag names spread across the palette`() { fun `different tag names spread across the palette`() {
@@ -175,144 +128,10 @@ class DerivedTintTest {
assertEquals(true, colours.toSet().size >= 5) 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 @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() val seen = (0 until 500).map { derivedTint("spread-$it") }.toSet()
assertEquals(DERIVED_TINT_KEYS.size, seen.size) 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 { useNotesStore } from "../stores/notes";
import { import {
LABEL_CHIP_CLASSES, LABEL_CHIP_CLASSES,
NOTE_CARD_SURFACE,
NOTE_COLOR_KEYS, NOTE_COLOR_KEYS,
NOTE_COLOR_LABELS, NOTE_COLOR_LABELS,
NOTE_SWATCH_CLASSES, NOTE_SWATCH_CLASSES,
noteCardClasses,
noteTintVars,
resolveLabelColor, resolveLabelColor,
type NoteColor, type NoteColor,
} from "../notes/colors"; } from "../notes/colors";
@@ -242,37 +241,43 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
ref="root" 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="group relative mb-4 break-inside-avoid rounded-xl border border-[#b8b8b8] p-3 shadow-sm transition hover:shadow-md dark:border-[#404040]"
:class="[ :class="[
noteCardClasses(note), NOTE_CARD_SURFACE,
dragging ? 'opacity-40' : '', dragging ? 'opacity-40' : '',
dragOver dragOver
? 'scale-[1.02] shadow-lg ring-2 ring-brand ring-offset-2 ring-offset-white dark:ring-offset-neutral-950' ? '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' : '', active ? 'ring-2 ring-brand' : '',
]" ]"
:style="noteTintVars(note)"
:data-note-id="note.id" :data-note-id="note.id"
> >
<!-- THE EDGE LIVES HERE, NOT IN THE PALETTE, and that is the whole point of it. <!-- THE EDGE IS THE CARD'S BOUNDARY, and since M315 it is the ONLY thing that
Every card gets the same grey hairline whatever colour it is; the fill is the varies from the board: the fill is one neutral (NOTE_CARD_SURFACE) and no
only thing that varies. The version of this that was a `{hue}-900` border longer says anything about the note. That makes this line load-bearing rather
failed because the line was both the loudest element on the card (1.56-2.09 than decorative — it is what a card IS.
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 two values are MATCHED, not picked by eye: each measures ~1.6-1.7 against It was already neutral before the fill was. The version that came from the
the card it edges (light 1.57-1.98, dark 1.58-1.73), so the edge has the same palette was a `{hue}-900` border and failed twice over: the line was the
authority in either theme. #404040 is `neutral-700`, which is what the default loudest element on the card (1.56-2.09 against its own fill, where the fill
card's border always was — promoted from one entry in the palette to the rule managed 1.03-1.05 against the board) AND carried the same information the fill
for all of them. #b8b8b8 sits between `neutral-300` and `neutral-400`, neither did, so a board of them read as a grid of outlines. A neutral line carries no
of which lands in range: 300 fades out at 1.18 on a gray-tagged card, 400 information at all, which is exactly what lets it be structure. The fill is
jumps to 2.52 and reads as a wireframe. 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 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, and was measured and rejected: a border composites over what is under it, so
so `border-white/20` comes out #56396d on a purple card and #a3c9c1 on a teal `border-white/20` came out #56396d on a purple card and #a3c9c1 on a teal one.
one. Hue-coded edges are the thing being removed. 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-sm`, back down from `shadow`: the border is the boundary again, so the
shadow is only depth. --> 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]; export type NoteColor = (typeof NOTE_COLOR_KEYS)[number];
/** Membership test for a colour key arriving from the server, which may be newer /** 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 * than this client. Once a lookup in a card-fill table; since M315 there is no such
* deleted — an untagged note's fill is generated now, not chosen from a palette. */ * table, and this guards a LABEL's stored colour on its way into the palette. */
const KNOWN_COLORS = new Set<string>(NOTE_COLOR_KEYS); const KNOWN_COLORS = new Set<string>(NOTE_COLOR_KEYS);
export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = { 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. // 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 // ONE Tailwind step deeper than LABEL_CHIP_CLASSES' text. A chip carries its own
// stylistic one. A chip carries its own `-100` fill, so its text has exactly one // `-100` fill, so its text has exactly one background to read against; inline text
// background to read against. Inline text sits on whatever the CARD is — which // sits on whatever the CARD is. When the card could be any of twenty fills that was a
// includes a gray-tagged card at `neutral-200`, where the chip's `-700` measured 3.98 // hard constraint — on a gray-tagged card at `neutral-200` the chip's `-700` measured
// (green), 4.11 (orange) and 4.34 (teal), all under the 4.5 body text needs. At `-800` // 3.98 (green), 4.11 (orange) and 4.34 (teal), all under the 4.5 body text needs,
// every hue lands between 5.63 and 12.01 in light, and `-300` gives 7.20 to 10.84 in // where `-800` put every hue between 5.63 and 12.01 in light and `-300` gave 7.20 to
// dark, measured against every fill in NOTE_CARD_CLASSES_STRONG and every generated // 10.84 in dark, across every fill. One step for all ten beat three per-hue exceptions.
// fill. One step for all ten beats 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`. // Mirrored in NoteTint.kt as `lightTagInk` / `darkTagInk`.
export const TAG_TEXT_CLASSES: Record<NoteColor, string> = { 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 // Every `#tag` is born colourless, so without this a board of tags is a board of
// help telling one from the next. Every note now carries some tint; this is where // identical grey chips. Hashing the tag's NAME is deterministic, identical on every
// an untagged one gets it. // 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 // THIS WAS THE CARD'S COLOUR TOO, ONCE. It is not any more (M315): a note's fill is
// tint rolled at render time would differ between the phone and the browser and // one neutral and only its tags carry hue. The hash survived that removal because the
// change on every reload. Hashing the note's id is deterministic, identical on // job it still does — give a name a stable colour — was never the job that failed.
// every surface, costs no column and no migration, and a note keeps its colour // What failed was asking a colour that means "which tag" to also mean nothing at all
// for life — which is what "random" actually meant here. // 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 // 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 note is one // hash over the same key order, and the two must agree exactly or a tag is one colour
// colour on the phone and another in the browser. Same discipline as the // on the phone and another in the browser. Same discipline as the checklist grammar's
// checklist grammar's three implementations, and the same reason: a value that // three implementations, and the same reason: a value that disagrees across surfaces
// disagrees across surfaces is a bug you cannot unsee and cannot explain. // is a bug you cannot unsee and cannot explain.
// //
// The Kotlin side has a unit test pinning the fixture below. THIS SIDE HAS NO // 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`. // MECHANICAL GUARD — the frontend has no test runner, only `vue-tsc --noEmit`.
// If you change anything here, check it against the fixture by hand. // 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( export const DERIVED_TINT_KEYS: readonly NoteColor[] = NOTE_COLOR_KEYS.filter(
(key) => key !== "default", (key) => key !== "default",
); );
@@ -175,128 +177,26 @@ export function tintHash(id: string): number {
return hash >>> 0; 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 { export function derivedTint(id: string): NoteColor {
return DERIVED_TINT_KEYS[tintHash(id) % DERIVED_TINT_KEYS.length]; 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. * The colour to paint a LABEL — its chip, and its `#tag` where it sits in the prose.
*
* 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.
* *
* Derived from the tag's NAME, not stored, when nobody has picked one. Every `#tag` * 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 * ever typed is `default`: `notes/tags.py` mints one as `Label(owner_id=…, name=name)`
* `Label(owner_id=…, name=name)` with no colour, so it takes the column default. * with no colour, so it takes the column default. Without deriving, a board of tags
* Tag-driven note colour against that would leave the board exactly as grey as it * would be a board of identical grey chips.
* was.
* *
* DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing the original plan in #2965. * 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 * 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 * 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 * 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 * 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. * 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 * 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()); return derivedTint(label.name.toLowerCase());
} }
// Fixture — the same ids and expected keys the Kotlin test asserts. Kept here as // 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 // prose because there is nowhere on this side to assert it. If you change the hash or
// or the key order, these four must still hold on BOTH surfaces: // 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 // 00000000-0000-0000-0000-000000000000 0xbe478ed1 purple
// 11111111-1111-1111-1111-111111111111 0x3d75cc01 blue // 11111111-1111-1111-1111-111111111111 0x3d75cc01 blue
// 6ba7b810-9dad-11d1-80b4-00c04fd430c8 0xf108e530 orange // 6ba7b810-9dad-11d1-80b4-00c04fd430c8 0xf108e530 orange
// f47ac10b-58cc-4372-a567-0e02b2c3d479 0x5b651540 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 // todo -> pink grocery -> blue work -> green home -> gray
// ideas -> green reading -> gray urgent -> red // 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 // 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 // and it is not a bug: colour hints that two tags are distinct, it never claims two
// carry the same tag. The chip's text is what says which tag it is. // 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 // THE CARD SURFACE — one neutral, no hue, no note involved (M315).
// heavier fill in dark.
// //
// UNCHANGED by the border pass, deliberately. The operator's complaint was the edge // This used to be a function of the note: a palette class for a tagged one, a fill
// line and the loudness of the DERIVED tint; the chosen colours were "pretty well" // generated from the id for the rest. Both are gone. The operator's verdict after
// where they landed, and a ramp somebody has already signed off on is not something // four passes at it — "my coloring attempt has failed and nothing looks right… we've
// to redo while fixing something else. // 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 NOW LIVES ONLY ON THE TAG — the chip and the inline `#tag`, both of which sit
// colour; a note without one gets a generated fill instead (`derivedFill`), because // on this one known background from here on. That is a smaller job done properly
// nine palette keys could never carry both jobs. The palette says WHICH TAG. The // instead of a larger one done four times.
// generator says nothing at all, and only has to keep the board from repeating.
// //
// So these values do not need to be subtle and never did — a tagged note is making a // The codebase had already made this argument about the card's EDGE, one level down:
// statement, and the quiet end of the board is now handled somewhere else entirely. // the hue-coded border came out as "a neutral line carries no information at all,
export const NOTE_CARD_CLASSES_STRONG: Record<NoteColor, string> = { // which is exactly what lets it be structure instead of content". The fill is the same
default: "bg-white dark:bg-neutral-900", // argument at the next size up.
red: "bg-red-100 dark:bg-red-950/70", //
orange: "bg-orange-100 dark:bg-orange-950/70", // THE VALUES. `bg-white dark:bg-neutral-900` — which is exactly what `default` always
yellow: "bg-amber-100 dark:bg-amber-950/70", // was, and exactly what the note EDITOR panel has always been (NoteEditor.vue), so
green: "bg-green-100 dark:bg-green-950/70", // this is a collapse onto a surface both other surfaces already used rather than a
teal: "bg-teal-100 dark:bg-teal-950/70", // new colour anybody has to like.
blue: "bg-blue-100 dark:bg-blue-950/70", //
purple: "bg-purple-100 dark:bg-purple-950/70", // Measured against the operator's constraint, "not the same color as their background
pink: "bg-pink-100 dark:bg-pink-950/70", // but close to it":
gray: "bg-neutral-200 dark:bg-neutral-800/70", //
}; // card vs board light #ffffff on #fafafa 1.04
// dark #171717 on #0a0a0a 1.10
/** // edge vs card light #b8b8b8 on #ffffff 1.98
* The palette key a note was GIVEN, or null when nothing gave it one. // dark #404040 on #171717 1.73
* // body vs card light #171717 on #ffffff 17.93 (needs 4.5)
* Null is the interesting answer: it means the fill has to be generated, because the // dark #fafafa on #171717 17.17
* note carries no statement about what it is. Everything downstream branches here. // muted vs card light #404040 on #ffffff 10.37
* // dark #e5e5e5 on #171717 14.23
* 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 — // The fill is deliberately the WEAKEST of those numbers. A card is not separated from
* it is the one the person controls by typing, where alphabetical or most-used would // the board by its fill and never was — the edge and the shadow do that, which is why
* move a note's colour when an unrelated tag was added somewhere else. // 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.
* Manual labels count the same as `#tags`. Someone looking at a chip cannot tell which export const NOTE_CARD_SURFACE = "bg-white dark:bg-neutral-900";
* 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),
};
}
-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 /* 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 * us something about vestibular comfort or attention, and the answer is to arrive
* instantly rather than to animate faster. * instantly rather than to animate faster.