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 @@