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