From 6fbee27f9c400c0cc4f1d9899d98f0d2ad503b88 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 26 Aug 2026 12:57:59 -0400 Subject: [PATCH] A tag with no colour of its own derives one from its name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../fabledsword/thoughtsync/ui/DerivedTint.kt | 30 ++++++++++++ .../thoughtsync/ui/EditorChrome.kt | 2 +- .../fabledsword/thoughtsync/ui/NoteCard.kt | 2 +- .../fabledsword/thoughtsync/ui/NoteTint.kt | 14 ++++++ .../thoughtsync/ui/DerivedTintTest.kt | 46 +++++++++++++++++++ frontend/src/components/AppShell.vue | 10 ++-- frontend/src/components/LabelsModal.vue | 24 +++++++--- frontend/src/components/NoteCard.vue | 9 ++-- frontend/src/components/NoteEditor.vue | 11 +++-- frontend/src/notes/colors.ts | 40 ++++++++++++++++ 10 files changed, 168 insertions(+), 20 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/DerivedTint.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/DerivedTint.kt index 78e9b33..cc576ca 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/DerivedTint.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/DerivedTint.kt @@ -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 = + 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. * diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt index 0f49629..efff405 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt @@ -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), diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt index b6286b7..27b73ab 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt @@ -182,7 +182,7 @@ private fun LabelChips(labels: List) { // 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, diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt index 971103f..a2cf631 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt @@ -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)) diff --git a/android/app/src/test/java/com/fabledsword/thoughtsync/ui/DerivedTintTest.kt b/android/app/src/test/java/com/fabledsword/thoughtsync/ui/DerivedTintTest.kt index 8a34fe5..0aaee31 100644 --- a/android/app/src/test/java/com/fabledsword/thoughtsync/ui/DerivedTintTest.kt +++ b/android/app/src/test/java/com/fabledsword/thoughtsync/ui/DerivedTintTest.kt @@ -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`() { diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 9db9c1b..1d601ab 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -14,7 +14,7 @@ import ImportNotes from "./ImportNotes.vue"; import LabelsModal from "./LabelsModal.vue"; import { isDesktop } from "../desktop/bridge"; import { facetsToQuery } from "../notes/facets"; -import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors"; +import { NOTE_SWATCH_CLASSES, resolveLabelColor } from "../notes/colors"; const route = useRoute(); const router = useRouter(); @@ -173,8 +173,10 @@ onBeforeUnmount(() => { const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null)); -function labelDot(color: string): string { - return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default; +// The drawer's tag list. Same resolution as every other chip and dot — a tag that is +// green on a card must be green here, or the sidebar stops being a way to find it. +function labelDot(label: { name: string; color: string }): string { + return NOTE_SWATCH_CLASSES[resolveLabelColor(label)] ?? NOTE_SWATCH_CLASSES.default; } // The board lenses — the routes a search can happen *within*. Searching while looking @@ -443,7 +445,7 @@ async function signOut() { > {{ lb.name }} diff --git a/frontend/src/components/LabelsModal.vue b/frontend/src/components/LabelsModal.vue index f0a8a4b..7509af0 100644 --- a/frontend/src/components/LabelsModal.vue +++ b/frontend/src/components/LabelsModal.vue @@ -1,7 +1,13 @@