A tag with no colour of its own derives one from its name
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m10s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Android / Kotlin + Rust (APK) (push) Successful in 7m43s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m10s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Android / Kotlin + Rust (APK) (push) Successful in 7m43s
Every #tag ever typed is `default`. `notes/tags.py` mints one as `Label(owner_id=…, name=name)` with no colour, so it takes the column default — which means tag-driven note colour, built on top, would have left the board exactly as grey as it was. Four #todo notes in the operator's screenshot, four different colours, because the tint is per-note-id and ignores tags entirely. DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing the plan in 2965. That plan wanted a hashed colour written wherever a label is born, and named the risk in its own body: `find_or_create_label` is "easy to miss, and it is the common one", because most tags are born from typing `#grocery`, not from a management screen. Deriving has no mint points to miss, needs no backfill for the tags that already exist, and reuses the hash and the fixture the notes already have. The cost is that renaming a tag recolours it. That is defensible — the name IS the tag — and an explicitly picked colour is still stored and still wins, so tag colours stay editable exactly as asked. Lowercased before hashing: tags dedupe case-insensitively, so #Todo and #todo are one tag and must not be two colours. All five places a label's colour is drawn now resolve the same way — the card chip, the editor chip, the drawer's tag list, and the management modal's dot and swatch ring. The modal's ring follows the resolved colour rather than the stored one, so opening the picker highlights what you can already see instead of nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,36 @@ fun derivedTint(id: String): String {
|
||||
return DERIVED_TINT_KEYS[index.toInt()]
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour key for a LABEL — its chip, and (step 3) every note carrying it.
|
||||
*
|
||||
* Derived from the tag's NAME when nobody has picked one. Every `#tag` ever typed is
|
||||
* currently `default`: the server mints one as `Label(owner_id=…, name=name)` with no
|
||||
* colour, so tag-driven note colour against that would leave the board exactly as
|
||||
* grey as it was.
|
||||
*
|
||||
* 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
|
||||
* the risk itself: `find_or_create_label` is "easy to miss, and it is the common one",
|
||||
* since most tags are born from typing `#grocery`, not from a management screen.
|
||||
* Deriving has no mint points to miss and no backfill for the tags already out there.
|
||||
* The cost is that renaming a tag recolours it, which is fair: the name IS the tag.
|
||||
*
|
||||
* Lowercased because tags dedupe case-insensitively — `#Todo` renamed to `#todo` is
|
||||
* the same tag and should not change colour. Kotlin's `lowercase()` and the web's
|
||||
* `toLowerCase()` are both locale-independent, so the mirror holds.
|
||||
*/
|
||||
fun resolvedLabelColor(
|
||||
name: String,
|
||||
color: String,
|
||||
known: Set<String>,
|
||||
): String =
|
||||
when {
|
||||
color.isNotEmpty() && color != "default" && color in known -> color
|
||||
name.isEmpty() -> "default"
|
||||
else -> derivedTint(name.lowercase())
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour key to actually paint a note with.
|
||||
*
|
||||
|
||||
@@ -330,7 +330,7 @@ fun EditorLabelRow(
|
||||
val dark = isSystemInDarkTheme()
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
note.labels.forEach { label ->
|
||||
val tint = noteTint(label.color)
|
||||
val tint = labelTintFor(label.name, label.color)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
|
||||
@@ -182,7 +182,7 @@ private fun LabelChips(labels: List<NoteLabel>) {
|
||||
// not grow taller than its content. The editor shows the full set.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
labels.take(MAX_LABEL_CHIPS).forEach { label ->
|
||||
val tint = noteTint(label.color)
|
||||
val tint = labelTintFor(label.name, label.color)
|
||||
Text(
|
||||
text = label.name,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
|
||||
@@ -189,3 +189,17 @@ fun noteTintFor(
|
||||
id: String,
|
||||
color: String,
|
||||
): NoteTint = noteTint(resolvedNoteColor(id, color, NOTE_TINTS.keys))
|
||||
|
||||
/**
|
||||
* The tint for a LABEL, derived from its name when nobody has picked one.
|
||||
*
|
||||
* Every `#tag` is born colourless, so without this a board of tags is a board of
|
||||
* identical grey chips. See `DerivedTint.kt` for why this derives rather than
|
||||
* persisting a colour when the tag is minted.
|
||||
*/
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
fun labelTintFor(
|
||||
name: String,
|
||||
color: String,
|
||||
): NoteTint = noteTint(resolvedLabelColor(name, color, NOTE_TINTS.keys))
|
||||
|
||||
@@ -93,6 +93,52 @@ class DerivedTintTest {
|
||||
assertEquals("default", resolvedNoteColor("", "default", known))
|
||||
}
|
||||
|
||||
/** A tag with no colour of its own derives one from its NAME, which is what makes
|
||||
* every `#todo` note the same colour rather than nine different ones. */
|
||||
@Test
|
||||
fun `a label with no colour derives one from its name`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
val todo = resolvedLabelColor("todo", "default", known)
|
||||
assertEquals(derivedTint("todo"), todo)
|
||||
assertNotEquals("default", todo)
|
||||
}
|
||||
|
||||
/** Tags dedupe case-insensitively, so `#Todo` and `#todo` are one tag and must not
|
||||
* be two colours. This is the whole reason the name is lowercased first. */
|
||||
@Test
|
||||
fun `label colour ignores case`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
assertEquals(
|
||||
resolvedLabelColor("todo", "default", known),
|
||||
resolvedLabelColor("ToDo", "default", known),
|
||||
)
|
||||
}
|
||||
|
||||
/** `teal` deliberately, NOT the colour "todo" derives to (pink) — asserting the
|
||||
* derived value here would pass even with the explicit branch deleted. */
|
||||
@Test
|
||||
fun `an explicitly picked label colour still wins`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
assertNotEquals("teal", derivedTint("todo"))
|
||||
assertEquals("teal", resolvedLabelColor("todo", "teal", known))
|
||||
}
|
||||
|
||||
/**
|
||||
* The spread is real, and collisions are real too.
|
||||
*
|
||||
* Nine keys means two tags sharing a colour is not a bug and cannot be designed
|
||||
* out — in this very sample `home`/`reading` are both gray and `work`/`ideas` are
|
||||
* both green. Colour is a hint that two notes are related, never a claim that they
|
||||
* carry the same tag; the chip's TEXT is what says which tag it is.
|
||||
*/
|
||||
@Test
|
||||
fun `different tag names spread across the palette`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
val names = listOf("todo", "grocery", "work", "home", "ideas", "reading", "urgent")
|
||||
val colours = names.map { resolvedLabelColor(it, "default", known) }
|
||||
assertEquals(true, colours.toSet().size >= 5)
|
||||
}
|
||||
|
||||
/** The reason the feature exists: two adjacent notes should not look identical. */
|
||||
@Test
|
||||
fun `the tint spreads across the palette`() {
|
||||
|
||||
Reference in New Issue
Block a user