Checklists in the body, colour from tags, and commit-derived CalVer #4

Merged
bvandeusen merged 73 commits from dev into main 2026-08-29 13:39:45 -04:00
10 changed files with 168 additions and 20 deletions
Showing only changes of commit 6fbee27f9c - Show all commits
@@ -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`() {
+6 -4
View File
@@ -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() {
>
<span
class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
:class="labelDot(lb.color)"
:class="labelDot(lb)"
></span>
<span class="truncate">{{ lb.name }}</span>
</RouterLink>
+17 -7
View File
@@ -1,7 +1,13 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useLabelsStore, type Label } from "../stores/labels";
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
import {
NOTE_COLOR_KEYS,
NOTE_COLOR_LABELS,
NOTE_SWATCH_CLASSES,
resolveLabelColor,
type NoteColor,
} from "../notes/colors";
import BaseModal from "./BaseModal.vue";
import Icon from "./Icon.vue";
@@ -25,8 +31,12 @@ async function rename(id: string, value: string) {
if (name) await labels.rename(id, name);
}
function labelDot(color: string): string {
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
// Shows the colour the tag ACTUALLY wears, derived from its name when nobody has
// picked one — so this screen agrees with the chips everywhere else. The ring in the
// swatch grid below follows the same resolution, so opening the picker highlights
// what you can already see rather than nothing at all.
function labelDot(label: { name: string; color: string }): string {
return NOTE_SWATCH_CLASSES[resolveLabelColor(label)] ?? NOTE_SWATCH_CLASSES.default;
}
function openColor(id: string) {
@@ -75,8 +85,8 @@ async function doMerge(sourceId: string, targetId: string) {
<button
type="button"
class="h-4 w-4 shrink-0 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-white/15"
:class="labelDot(lb.color)"
:title="`Color: ${NOTE_COLOR_LABELS[(lb.color as NoteColor)] ?? lb.color}`"
:class="labelDot(lb)"
:title="`Color: ${NOTE_COLOR_LABELS[resolveLabelColor(lb)]}`"
aria-label="Change label color"
@click="openColor(lb.id)"
/>
@@ -120,7 +130,7 @@ async function doMerge(sourceId: string, targetId: string) {
:title="NOTE_COLOR_LABELS[key]"
:aria-label="NOTE_COLOR_LABELS[key]"
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
:class="[NOTE_SWATCH_CLASSES[key], lb.color === key ? 'ring-2 ring-brand' : '']"
:class="[NOTE_SWATCH_CLASSES[key], resolveLabelColor(lb) === key ? 'ring-2 ring-brand' : '']"
@click="pickColor(lb.id, key)"
/>
</div>
@@ -140,7 +150,7 @@ async function doMerge(sourceId: string, targetId: string) {
>
<span
class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
:class="labelDot(t.color)"
:class="labelDot(t)"
></span>
<span class="truncate">{{ t.name }}</span>
</button>
+6 -3
View File
@@ -7,6 +7,7 @@ import {
NOTE_COLOR_KEYS,
NOTE_COLOR_LABELS,
NOTE_SWATCH_CLASSES,
resolveLabelColor,
resolveNoteColor,
type NoteColor,
} from "../notes/colors";
@@ -196,8 +197,10 @@ function cardClass(note: Note): string {
return NOTE_CARD_CLASSES[resolveNoteColor(note)] ?? NOTE_CARD_CLASSES.default;
}
function labelChip(color: string): string {
return LABEL_CHIP_CLASSES[color as NoteColor] ?? LABEL_CHIP_CLASSES.default;
// Takes the label, not its colour: a tag nobody has coloured derives one from its
// name, so chips carry the tag's identity rather than all being the same grey.
function labelChip(label: { name: string; color: string }): string {
return LABEL_CHIP_CLASSES[resolveLabelColor(label)] ?? LABEL_CHIP_CLASSES.default;
}
// Per-card color popover (recolor without opening the editor).
@@ -300,7 +303,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
v-for="lb in note.labels"
:key="lb.id"
class="rounded-full px-2 py-0.5 text-xs"
:class="labelChip(lb.color)"
:class="labelChip(lb)"
>{{ lb.via_tag ? "#" + lb.name : lb.name }}</span
>
</div>
+7 -4
View File
@@ -9,7 +9,7 @@ import { fromLocalInput, toLocalInput } from "../notes/datetime";
import { takeMorphOrigin } from "../composables/useEditorMorph";
import { prefersReducedMotion } from "../composables/useReducedMotion";
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
import { LABEL_CHIP_CLASSES, resolveLabelColor, type NoteColor } from "../notes/colors";
import {
afterEnter,
type EditorBlock,
@@ -358,8 +358,11 @@ async function onLabelsChange(next: NoteLabel[]) {
async function removeLabel(id: string) {
await onLabelsChange(labelList.value.filter((lb) => lb.id !== id));
}
function labelChip(c: string): string {
return LABEL_CHIP_CLASSES[c as NoteColor] ?? LABEL_CHIP_CLASSES.default;
// Takes the label, not its colour — a tag nobody has coloured derives one from its
// name. Must match NoteCard's chip exactly: the same tag either side of opening a
// note changing colour would be worse than both being grey.
function labelChip(label: { name: string; color: string }): string {
return LABEL_CHIP_CLASSES[resolveLabelColor(label)] ?? LABEL_CHIP_CLASSES.default;
}
// ---- add a checklist ----
@@ -608,7 +611,7 @@ function revPreview(rev: NoteRevision): string {
v-for="lb in labelList"
:key="lb.id"
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class="labelChip(lb.color)"
:class="labelChip(lb)"
>
{{ lb.via_tag ? "#" + lb.name : lb.name }}
<button
+40
View File
@@ -160,6 +160,37 @@ export function resolveNoteColor(note: { id: string; color?: string | null }): N
return derivedTint(note.id);
}
/**
* 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`
* ever typed is currently `default`: `notes/tags.py` mints one as
* `Label(owner_id=…, name=name)` with no colour, so it takes the column default.
* Tag-driven note colour against that would leave the board exactly as grey as it
* was.
*
* DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing the original plan in #2965.
* That plan wanted a hashed colour written at each of the four places a label can be
* born — and named the risk itself: `find_or_create_label` is "easy to miss, and it
* is the common one", because most tags are born from typing `#grocery`, not from a
* management screen. Deriving has no mint points to miss, needs no backfill for the
* tags that already exist, and reuses the hash the notes already use. The cost is
* 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
* editable exactly as asked.
*
* Lowercased because tags dedupe case-insensitively — `#Todo` renamed to `#todo` is
* the same tag and should not change colour. Both `toLowerCase` here and Kotlin's
* `lowercase()` are locale-independent, so the mirror holds.
*/
export function resolveLabelColor(label: { name: string; color?: string | null }): NoteColor {
const picked = label.color as NoteColor | undefined | null;
if (picked && picked !== "default" && picked in NOTE_CARD_CLASSES) return picked;
if (!label.name) return "default";
return derivedTint(label.name.toLowerCase());
}
// Fixture — the same ids and expected keys the Kotlin test asserts. Kept here as
// prose because there is nowhere on this side to assert it. If you change the hash
// or the key order, these four must still hold on BOTH surfaces:
@@ -168,3 +199,12 @@ export function resolveNoteColor(note: { id: string; color?: string | null }): N
// 11111111-1111-1111-1111-111111111111 0x3d75cc01 blue
// 6ba7b810-9dad-11d1-80b4-00c04fd430c8 0xf108e530 orange
// f47ac10b-58cc-4372-a567-0e02b2c3d479 0x5b651540 orange
//
// And for labels, which hash the lowercased NAME rather than an id:
//
// todo -> pink grocery -> blue work -> green home -> gray
// ideas -> green reading -> gray urgent -> red
//
// Note `work`/`ideas` and `home`/`reading` collide. Nine keys makes that unavoidable
// and it is not a bug: colour hints that two notes are related, it never claims they
// carry the same tag. The chip's text is what says which tag it is.