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