diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/DerivedTint.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/DerivedTint.kt index 983b1e2..34abb88 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/DerivedTint.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/DerivedTint.kt @@ -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 = 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 = - 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, -): 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.11–1.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) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt index 6d760f7..5865bfe 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt @@ -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)) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt index d9319bf..e254a56 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt index 2d90551..45622e3 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt @@ -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, ) }, diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt index 5202f9f..8af03b4 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt @@ -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.56–2.09 against its own fill while the fill - * managed 1.03–1.05 against the board — the loudest thing on every card saying - * exactly what the fill already said, so a field of them read as a grid of - * outlines. The edge is now one grey for all ten keys, held as a constant in - * `NoteCard.kt` where the palette cannot reach it. [border] is untouched and - * still serves panels, banners, the update card and the pickers. - * - * 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 = mapOf( @@ -154,7 +122,6 @@ val NOTE_TINTS: Map = darkChipForeground = Color(0xFFD4D4D4), lightTagInk = Color(0xFF404040), darkTagInk = Color(0xFFD4D4D4), - tintable = false, ), "red" to NoteTint( @@ -295,64 +262,6 @@ val NOTE_TINTS: Map = @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. * diff --git a/android/app/src/test/java/com/fabledsword/thoughtsync/ui/DerivedTintTest.kt b/android/app/src/test/java/com/fabledsword/thoughtsync/ui/DerivedTintTest.kt index 2ee7316..fbf214e 100644 --- a/android/app/src/test/java/com/fabledsword/thoughtsync/ui/DerivedTintTest.kt +++ b/android/app/src/test/java/com/fabledsword/thoughtsync/ui/DerivedTintTest.kt @@ -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) - } } diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue index 6854886..0c32cee 100644 --- a/frontend/src/components/NoteCard.vue +++ b/frontend/src/components/NoteCard.vue @@ -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" > - diff --git a/frontend/src/notes/colors.ts b/frontend/src/notes/colors.ts index 876c502..041d6df 100644 --- a/frontend/src/notes/colors.ts +++ b/frontend/src/notes/colors.ts @@ -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(NOTE_COLOR_KEYS); export const NOTE_SWATCH_CLASSES: Record = { @@ -59,14 +59,20 @@ export const LABEL_CHIP_CLASSES: Record = { // 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 = { @@ -123,31 +129,27 @@ export const NOTE_COLOR_LABELS: Record = { }; // --------------------------------------------------------------------------- -// 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 = { - 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 | 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"; diff --git a/frontend/src/style.css b/frontend/src/style.css index 4081ce7..4041e69 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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.