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 cc576ca..b1bb032 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 @@ -114,10 +114,14 @@ fun resolvedLabelColor( fun resolvedNoteColor( id: String, color: String, + labelColor: String, known: Set, ): 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, +): Boolean = labelColor.isNotEmpty() || (color.isNotEmpty() && color != "default" && color in known) 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 27b73ab..e0a03e4 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 @@ -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), ) { diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt index d116fb3..e1877d8 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt @@ -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( 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 a2cf631..a09dbbe 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 @@ -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 = mapOf( @@ -54,6 +96,7 @@ val NOTE_TINTS: Map = 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. 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 0aaee31..0e40206 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 @@ -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 diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue index e12e7a8..34052b5 100644 --- a/frontend/src/components/NoteCard.vue +++ b/frontend/src/components/NoteCard.vue @@ -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 { 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' diff --git a/frontend/src/notes/colors.ts b/frontend/src/notes/colors.ts index 3fef37e..87f77a4 100644 --- a/frontend/src/notes/colors.ts +++ b/frontend/src/notes/colors.ts @@ -19,15 +19,15 @@ export type NoteColor = (typeof NOTE_COLOR_KEYS)[number]; export const NOTE_CARD_CLASSES: Record = { 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 = { @@ -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 = { + 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; +}