Checklists in the body, colour from tags, and commit-derived CalVer #4
@@ -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`() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user