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
7 changed files with 217 additions and 50 deletions
Showing only changes of commit 988e1d3f00 - Show all commits
@@ -114,10 +114,14 @@ fun resolvedLabelColor(
fun resolvedNoteColor(
id: String,
color: String,
labelColor: String,
known: Set<String>,
): String =
when {
color.isNotEmpty() && color != "default" && color in known -> color
// The note's FIRST tag. Passed in already resolved, so this does not need to
// know that a colourless tag derives one from its name.
labelColor.isNotEmpty() -> labelColor
// A draft carries DRAFT_ID (""), so there is no identity to derive from yet.
// Staying white until the note exists costs one colour change at save time;
// hashing the empty string instead would give EVERY draft the same tint and
@@ -125,3 +129,18 @@ fun resolvedNoteColor(
id.isEmpty() -> "default"
else -> derivedTint(id)
}
/**
* Whether a note's colour was CHOSEN rather than derived — which is what decides
* between the two weights the card is drawn at.
*
* A tag (or, until step 5, the picker) means somebody said what this note is. A
* derived tint only means the board should not be a wall of white. Drawing both at
* the same weight is what prompted the operator's "the tints look the same as the
* chosen colors" — the tint was never meant to shout as loudly as a decision.
*/
fun noteColorIsChosen(
color: String,
labelColor: String,
known: Set<String>,
): Boolean = labelColor.isNotEmpty() || (color.isNotEmpty() && color != "default" && color in known)
@@ -37,7 +37,8 @@ fun NoteCard(
onToggleItem: (Int, Boolean) -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTintFor(note.id, note.color)
val tint = noteTintFor(note)
val strong = noteIsStrong(note)
Column(
modifier =
@@ -47,7 +48,7 @@ fun NoteCard(
// rounded corners instead of a rectangle overhanging them.
.clip(RoundedCornerShape(CARD_RADIUS))
.clickable(onClickLabel = stringResource(R.string.board_open_note), onClick = onOpen)
.background(tint.background(dark))
.background(tint.cardBackground(dark, strong))
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS))
.padding(12.dp),
) {
@@ -63,7 +63,8 @@ fun NoteEditorScreen(
onAction: (EditorAction) -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTintFor(note.id, note.color)
val tint = noteTintFor(note)
val strong = noteIsStrong(note)
// Keyed by the SESSION, not by note.id: the editor is reused across notes, so it
// needs a key — but a draft's id changes the moment it is first saved, and
@@ -155,7 +156,7 @@ fun NoteEditorScreen(
Surface(
modifier = Modifier.fillMaxSize(),
shape = RoundedCornerShape(topStart = SHEET_CORNER, topEnd = SHEET_CORNER),
color = tint.background(dark),
color = tint.cardBackground(dark, strong),
// Both content colours are spelled out for the reason the toolbar had to
// be: Surface and Scaffold each default theirs to contentColorFor(their
// container), which returns Unspecified for anything that is not a
@@ -166,7 +167,7 @@ fun NoteEditorScreen(
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Scaffold(
containerColor = tint.background(dark),
containerColor = tint.cardBackground(dark, strong),
contentColor = MaterialTheme.colorScheme.onSurface,
topBar = {
EditorTopBar(
@@ -3,6 +3,7 @@ package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import com.fabledsword.thoughtsync.core.Note
/**
* The note colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE.
@@ -30,9 +31,44 @@ data class NoteTint(
val lightChipForeground: Color,
val darkChipBackground: Color,
val darkChipForeground: Color,
/**
* False only for `default`, which is the ABSENCE of a colour rather than one of
* them. Everything else has two weights (see [cardBackground]); `default` has one,
* because there is no such thing as an emphatic lack of colour — and re-alphaing
* its opaque neutral fill would make a draft card translucent.
*/
val tintable: Boolean = true,
) {
fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
/**
* A NOTE card's fill, at one of two weights.
*
* `strong` means the colour was CHOSEN — by a tag, or (until step 5) by the
* picker. Subdued means it was derived from the note's id purely so the board is
* not a wall of white. Drawing those at the same weight is what prompted the
* operator's "the tints look the same as the chosen colors".
*
* The two weights move in OPPOSITE directions per theme, because that is where
* each has headroom. Light cannot go lighter than `-50` without being white again,
* so the gap opens by deepening the chosen end to `-100` — which is exactly
* [lightChipBackground], already in this table, so no new hex is transcribed.
* Dark CAN go lighter, so the derived end drops to a quarter opacity: closer to
* the board, which gives the light body text MORE contrast, not less.
*
* Borders are untouched. The fill is the signal; moving both muddies the edge.
*/
fun cardBackground(
dark: Boolean,
strong: Boolean,
): Color =
when {
!tintable -> background(dark)
dark -> darkBackground.copy(alpha = if (strong) STRONG_DARK_ALPHA else SUBDUED_DARK_ALPHA)
strong -> lightChipBackground
else -> lightBackground
}
fun border(dark: Boolean): Color = if (dark) darkBorder else lightBorder
fun chipBackground(dark: Boolean): Color = if (dark) darkChipBackground else lightChipBackground
@@ -40,6 +76,12 @@ data class NoteTint(
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
}
// The two dark-theme weights, as fractions. Mirror `dark:bg-{hue}-950/25` and `/70`
// in colors.ts — these are the only two numbers that have to agree by hand, since the
// light weights are both already columns of the table below.
private const val SUBDUED_DARK_ALPHA = 0.25f
private const val STRONG_DARK_ALPHA = 0.70f
/** Keyed by the core's colour vocabulary. Order matches the web's picker. */
val NOTE_TINTS: Map<String, NoteTint> =
mapOf(
@@ -54,6 +96,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF525252),
darkChipBackground = Color(0x1AFFFFFF),
darkChipForeground = Color(0xFFD4D4D4),
tintable = false,
),
"red" to
NoteTint(
@@ -185,10 +228,31 @@ fun noteTint(key: String): NoteTint = NOTE_TINTS[key] ?: NOTE_TINTS.getValue("de
*/
@Composable
@ReadOnlyComposable
fun noteTintFor(
id: String,
color: String,
): NoteTint = noteTint(resolvedNoteColor(id, color, NOTE_TINTS.keys))
fun noteTintFor(note: Note): NoteTint =
noteTint(resolvedNoteColor(note.id, note.color, firstLabelColor(note), NOTE_TINTS.keys))
/**
* Whether this note's card is drawn at the CHOSEN weight — see [NoteTint.cardBackground].
*
* Not `@Composable`: the card needs it alongside `isSystemInDarkTheme()`, and keeping
* it an ordinary function means it can be read anywhere the note is.
*/
fun noteIsStrong(note: Note): Boolean =
noteColorIsChosen(note.color, firstLabelColor(note), NOTE_TINTS.keys)
/**
* The colour of the note's FIRST label, already resolved, or "" when it has none.
*
* First rather than any other: it is the one the person controls by typing, where
* alphabetical or most-used would move a note's colour when an unrelated tag was
* added somewhere else. Manual labels count the same as `#tags` — someone looking at
* a chip cannot tell which kind they made, and two identically-tagged notes in
* different colours for an invisible reason is worse than the rule being loose.
*/
private fun firstLabelColor(note: Note): String =
note.labels.firstOrNull()
?.let { resolvedLabelColor(it.name, it.color, NOTE_TINTS.keys) }
?: ""
/**
* The tint for a LABEL, derived from its name when nobody has picked one.
@@ -64,14 +64,14 @@ class DerivedTintTest {
@Test
fun `an explicitly picked colour still wins`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertEquals("teal", resolvedNoteColor("any-id", "teal", known))
assertEquals("teal", resolvedNoteColor("any-id", "teal", "", known))
}
@Test
fun `an unknown colour key falls back to the derived tint`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val id = "00000000-0000-0000-0000-000000000000"
assertEquals("purple", resolvedNoteColor(id, "chartreuse", known))
assertEquals("purple", resolvedNoteColor(id, "chartreuse", "", known))
}
/** `default` is not a choice, it is the absence of one — so a note stored as
@@ -81,16 +81,52 @@ class DerivedTintTest {
fun `a default colour is treated as no colour`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val id = "11111111-1111-1111-1111-111111111111"
assertEquals("blue", resolvedNoteColor(id, "default", known))
assertNotEquals("default", resolvedNoteColor(id, "default", known))
assertEquals("blue", resolvedNoteColor(id, "default", "", known))
assertNotEquals("default", resolvedNoteColor(id, "default", "", known))
}
/** A draft has no id yet. It must not be hashed — see the comment in DerivedTint. */
@Test
fun `a draft stays default until it has an id`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertEquals("default", resolvedNoteColor("", "", known))
assertEquals("default", resolvedNoteColor("", "default", known))
assertEquals("default", resolvedNoteColor("", "", "", known))
assertEquals("default", resolvedNoteColor("", "default", "", known))
}
/** The point of the whole milestone: notes sharing a tag share a colour, however
* different their ids. Four `#todo` notes in four colours is what started this. */
@Test
fun `notes sharing a tag share a colour whatever their ids`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val todo = resolvedLabelColor("todo", "default", known)
val a = resolvedNoteColor("11111111-1111-1111-1111-111111111111", "default", todo, known)
val b = resolvedNoteColor("6ba7b810-9dad-11d1-80b4-00c04fd430c8", "default", todo, known)
assertEquals(todo, a)
assertEquals(a, b)
// ...and it is NOT what either id would have derived on its own.
assertNotEquals(derivedTint("11111111-1111-1111-1111-111111111111"), a)
}
/** Until step 5 removes the picker, a hand-picked colour outranks the tag. */
@Test
fun `an explicit note colour still beats its tag`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val todo = resolvedLabelColor("todo", "default", known)
assertNotEquals("teal", todo)
assertEquals("teal", resolvedNoteColor("any-id", "teal", todo, known))
}
/** Strength is not a second decision — it IS whether the colour was chosen. */
@Test
fun `only a chosen colour is drawn strongly`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val todo = resolvedLabelColor("todo", "default", known)
assertEquals(true, noteColorIsChosen("default", todo, known))
assertEquals(true, noteColorIsChosen("teal", "", known))
assertEquals(false, noteColorIsChosen("default", "", known))
assertEquals(false, noteColorIsChosen("", "", known))
// An unrecognised key is not a choice — it is data we could not read.
assertEquals(false, noteColorIsChosen("chartreuse", "", known))
}
/** A tag with no colour of its own derives one from its NAME, which is what makes
+2 -9
View File
@@ -3,12 +3,11 @@ import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import {
LABEL_CHIP_CLASSES,
NOTE_CARD_CLASSES,
NOTE_COLOR_KEYS,
NOTE_COLOR_LABELS,
NOTE_SWATCH_CLASSES,
noteCardClasses,
resolveLabelColor,
resolveNoteColor,
type NoteColor,
} from "../notes/colors";
import type { Note } from "../stores/notes";
@@ -191,12 +190,6 @@ async function snoozeReminder(minutes: number): Promise<void> {
emit("reminder-changed");
}
// Takes the note, not its colour: a note with no colour of its own gets one
// derived from its id, so the card can no longer be painted from a single field.
function cardClass(note: Note): string {
return NOTE_CARD_CLASSES[resolveNoteColor(note)] ?? NOTE_CARD_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 {
@@ -231,7 +224,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
ref="root"
class="group relative mb-4 break-inside-avoid rounded-xl border p-3 shadow-sm transition hover:shadow-md"
:class="[
cardClass(note),
noteCardClasses(note),
dragging ? 'opacity-40' : '',
dragOver
? 'scale-[1.02] shadow-lg ring-2 ring-brand ring-offset-2 ring-offset-white dark:ring-offset-neutral-950'
+79 -26
View File
@@ -19,15 +19,15 @@ export type NoteColor = (typeof NOTE_COLOR_KEYS)[number];
export const NOTE_CARD_CLASSES: Record<NoteColor, string> = {
default: "bg-white border-neutral-200 dark:bg-neutral-900 dark:border-neutral-700",
red: "bg-red-50 border-red-200 dark:bg-red-950/40 dark:border-red-900",
orange: "bg-orange-50 border-orange-200 dark:bg-orange-950/40 dark:border-orange-900",
yellow: "bg-amber-50 border-amber-200 dark:bg-amber-950/40 dark:border-amber-900",
green: "bg-green-50 border-green-200 dark:bg-green-950/40 dark:border-green-900",
teal: "bg-teal-50 border-teal-200 dark:bg-teal-950/40 dark:border-teal-900",
blue: "bg-blue-50 border-blue-200 dark:bg-blue-950/40 dark:border-blue-900",
purple: "bg-purple-50 border-purple-200 dark:bg-purple-950/40 dark:border-purple-900",
pink: "bg-pink-50 border-pink-200 dark:bg-pink-950/40 dark:border-pink-900",
gray: "bg-neutral-100 border-neutral-300 dark:bg-neutral-800 dark:border-neutral-700",
red: "bg-red-50 border-red-200 dark:bg-red-950/25 dark:border-red-900",
orange: "bg-orange-50 border-orange-200 dark:bg-orange-950/25 dark:border-orange-900",
yellow: "bg-amber-50 border-amber-200 dark:bg-amber-950/25 dark:border-amber-900",
green: "bg-green-50 border-green-200 dark:bg-green-950/25 dark:border-green-900",
teal: "bg-teal-50 border-teal-200 dark:bg-teal-950/25 dark:border-teal-900",
blue: "bg-blue-50 border-blue-200 dark:bg-blue-950/25 dark:border-blue-900",
purple: "bg-purple-50 border-purple-200 dark:bg-purple-950/25 dark:border-purple-900",
pink: "bg-pink-50 border-pink-200 dark:bg-pink-950/25 dark:border-pink-900",
gray: "bg-neutral-100 border-neutral-300 dark:bg-neutral-800/25 dark:border-neutral-700",
};
export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = {
@@ -143,23 +143,6 @@ export function derivedTint(id: string): NoteColor {
return DERIVED_TINT_KEYS[tintHash(id) % DERIVED_TINT_KEYS.length];
}
/**
* The colour to actually paint a note.
*
* An explicitly-picked colour still wins — the picker is on its way out
* (milestone 309 step 5) but it has not gone yet, and a note the operator
* coloured by hand changing under them would read as data loss.
*/
export function resolveNoteColor(note: { id: string; color?: string | null }): NoteColor {
const picked = note.color as NoteColor | undefined | null;
if (picked && picked !== "default" && picked in NOTE_CARD_CLASSES) return picked;
// No id yet means an unsaved draft: nothing to derive from. Staying white until the
// note exists costs one colour change at save time; hashing the empty string would
// give every draft the same tint and then change it anyway.
if (!note.id) return "default";
return derivedTint(note.id);
}
/**
* The colour to paint a LABEL — its chip, and (step 3) every note carrying it.
*
@@ -208,3 +191,73 @@ export function resolveLabelColor(label: { name: string; color?: string | null }
// 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.
// The FULL-strength ramp: what a note wears when its colour was CHOSEN — by a tag, or
// (until step 5) by the picker. One Tailwind step deeper in light mode, and a much
// heavier fill in dark.
//
// The two ramps move in opposite directions per theme, because that is where each has
// headroom. Light mode cannot go lighter than `-50` without being white again, so the
// gap is opened by deepening the tagged end to `-100`. Dark mode CAN go lighter, so
// the untagged end drops to `/25` — closer to the board, which also gives the light
// body text MORE contrast rather than less.
//
// Borders stay put. The fill is the signal; moving both just muddies the edge.
export const NOTE_CARD_CLASSES_STRONG: Record<NoteColor, string> = {
default: "bg-white border-neutral-200 dark:bg-neutral-900 dark:border-neutral-700",
red: "bg-red-100 border-red-200 dark:bg-red-950/70 dark:border-red-900",
orange: "bg-orange-100 border-orange-200 dark:bg-orange-950/70 dark:border-orange-900",
yellow: "bg-amber-100 border-amber-200 dark:bg-amber-950/70 dark:border-amber-900",
green: "bg-green-100 border-green-200 dark:bg-green-950/70 dark:border-green-900",
teal: "bg-teal-100 border-teal-200 dark:bg-teal-950/70 dark:border-teal-900",
blue: "bg-blue-100 border-blue-200 dark:bg-blue-950/70 dark:border-blue-900",
purple: "bg-purple-100 border-purple-200 dark:bg-purple-950/70 dark:border-purple-900",
pink: "bg-pink-100 border-pink-200 dark:bg-pink-950/70 dark:border-pink-900",
gray: "bg-neutral-200 border-neutral-300 dark:bg-neutral-800/70 dark:border-neutral-700",
};
/**
* The colour a note wears AND how strongly, in one answer.
*
* `strong` is not a second decision — it IS whether the colour was chosen. A tag (or,
* until step 5, the picker) means somebody said what this note is; a derived tint only
* means the board should not be a wall of white. Rendering those two at the same
* weight is what made the operator ask "the tints look the same as the chosen colors".
*
* Resolution order, and why: an explicit pick beats a tag because it is the more
* specific statement and the picker still exists. The FIRST label wins among tags —
* it is the one the person controls by typing, where alphabetical or most-used would
* move a note's colour when an unrelated tag was added somewhere else.
*
* Manual labels count the same as `#tags`. Someone looking at a chip cannot tell which
* kind they made, and two identically-tagged notes in different colours for an
* invisible reason is worse than the rule being slightly loose.
*/
export function resolveNoteTint(note: {
id: string;
color?: string | null;
labels?: { name: string; color: string }[];
}): { color: NoteColor; strong: boolean } {
const picked = note.color as NoteColor | undefined | null;
if (picked && picked !== "default" && picked in NOTE_CARD_CLASSES) {
return { color: picked, strong: true };
}
const first = note.labels?.[0];
if (first) return { color: resolveLabelColor(first), strong: true };
// No id yet means an unsaved draft: nothing to derive from. Staying white until the
// note exists costs one colour change at save time; hashing the empty string would
// give every draft the same tint and then change it anyway.
if (!note.id) return { color: "default", strong: false };
return { color: derivedTint(note.id), strong: false };
}
/** The card classes for a note — both ramps behind one call. */
export function noteCardClasses(note: {
id: string;
color?: string | null;
labels?: { name: string; color: string }[];
}): string {
const { color, strong } = resolveNoteTint(note);
const ramp = strong ? NOTE_CARD_CLASSES_STRONG : NOTE_CARD_CLASSES;
return ramp[color] ?? NOTE_CARD_CLASSES.default;
}