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 299 additions and 5 deletions
Showing only changes of commit 6f173b166b - Show all commits
@@ -0,0 +1,93 @@
package com.fabledsword.thoughtsync.ui
// The colour a note has when nothing chose one for it.
//
// A board of `default` notes is a wall of white rectangles and the eye gets no help
// telling one from the next. Every note now carries some tint; this is where an
// untagged one gets it.
//
// "RANDOM" MEANS DERIVED. The operator asked for "random subdued colors", but a tint
// rolled at render time would differ between the phone and the browser and change on
// every reload. Hashing the note's id is deterministic, identical on every surface,
// costs no column and no migration, and a note keeps its colour for life — which is
// what "random" actually meant here.
//
// THIS IS HALF A MIRRORED PAIR. `frontend/src/notes/colors.ts` computes the same hash
// over the same key order, and the two must agree exactly or a note is one colour on
// the phone and another in the browser. Same discipline as the checklist grammar's
// three implementations, and the same reason: a value that disagrees across surfaces
// is a bug you cannot unsee and cannot explain.
//
// NO COMPOSE IN THIS FILE, deliberately. It is the half of the pair that CAN be
// pinned by a host-JVM test, and staying free of `androidx.compose` is what keeps
// `DerivedTintTest` runnable in the Unit tests step rather than on an emulator. The
// web side has no test runner at all, so this test is the only mechanical guard the
// mirror gets — see the fixture comment in colors.ts.
/**
* The tints a derived colour can land on: `NOTE_TINTS`' keys minus `default`, which
* is the white this exists to eliminate. `gray` stays — `bg-neutral-100` reads as a
* deliberate card against the board's `bg-neutral-50`, not as an absence.
*
* Order is load-bearing and matches `DERIVED_TINT_KEYS` in colors.ts. Reordering
* this list silently recolours every untagged note on one surface only.
*/
val DERIVED_TINT_KEYS: List<String> =
listOf("red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray")
private const val FNV_OFFSET_BASIS = -0x7ee3623b // 0x811c9dc5 as a signed Int
private const val FNV_PRIME = 0x01000193
private const val BYTE_MASK = 0xFF
private const val UNSIGNED_MASK = 0xFFFFFFFFL
/**
* FNV-1a over the id's bytes, 32-bit.
*
* Chosen because both languages compute it identically in ten lines with no library.
* Explicitly NOT `String.hashCode()`: Kotlin's is specified but JS has no equivalent,
* and reimplementing Java's from memory in TypeScript is exactly how a mirror drifts.
*
* `and BYTE_MASK` is a no-op for the ASCII of a UUID, and is kept because it states
* the intent — this hashes BYTES, so the TypeScript side reading `charCodeAt(i) &
* 0xff` is the same function rather than a coincidence.
*
* Overflow is the point: Kotlin's `Int` wraps on multiply, which is what the web's
* `Math.imul` exists to reproduce.
*/
fun tintHash(id: String): Int {
var hash = FNV_OFFSET_BASIS
for (ch in id) {
hash = hash xor (ch.code and BYTE_MASK)
hash *= FNV_PRIME
}
return hash
}
/** The tint a note with no colour of its own wears. Stable for the life of the note. */
fun derivedTint(id: String): String {
// Through Long to read the hash as unsigned. A signed remainder would be negative
// for half of all ids and index out of the list.
val index = (tintHash(id).toLong() and UNSIGNED_MASK) % DERIVED_TINT_KEYS.size
return DERIVED_TINT_KEYS[index.toInt()]
}
/**
* The colour key to actually paint a note with.
*
* 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.
*
* `known` is passed in rather than read from `NOTE_TINTS` so this file stays free of
* Compose and therefore testable; `noteTintFor` supplies the real set.
*/
fun resolvedNoteColor(id: String, color: String, known: Set<String>): String =
when {
color.isNotEmpty() && color != "default" && color in known -> color
// 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
// then change it anyway, which is two surprises where one will do.
id.isEmpty() -> "default"
else -> derivedTint(id)
}
@@ -37,7 +37,7 @@ fun NoteCard(
onToggleItem: (Int, Boolean) -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint(note.color)
val tint = noteTintFor(note.id, note.color)
Column(
modifier =
@@ -63,7 +63,7 @@ fun NoteEditorScreen(
onAction: (EditorAction) -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint(note.color)
val tint = noteTintFor(note.id, note.color)
// 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
@@ -175,3 +175,15 @@ val NOTE_TINTS: Map<String, NoteTint> =
@Composable
@ReadOnlyComposable
fun noteTint(key: String): NoteTint = NOTE_TINTS[key] ?: NOTE_TINTS.getValue("default")
/**
* The tint for a NOTE, which is not the same thing as looking up its stored key: a
* note that has no colour of its own gets one derived from its id, so that a board
* of untagged notes reads as individual items rather than a wall of white.
*
* See `DerivedTint.kt` — the rule lives there, free of Compose, so it can be tested.
*/
@Composable
@ReadOnlyComposable
fun noteTintFor(id: String, color: String): NoteTint =
noteTint(resolvedNoteColor(id, color, NOTE_TINTS.keys))
@@ -0,0 +1,102 @@
package com.fabledsword.thoughtsync.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
/**
* Pins the derived-tint rule against `frontend/src/notes/colors.ts`.
*
* These are not tests of Kotlin — they are the ONE mechanical guard the mirrored pair
* has. The web side is TypeScript with no test runner (its CI lane is `vue-tsc
* --noEmit` and nothing else), so if these values drift, nothing on that surface will
* say so and a note will simply be a different colour on the phone than in the
* browser. The same four ids and hashes are written into colors.ts as a comment;
* changing either side means changing both and re-checking here.
*/
class DerivedTintTest {
@Test
fun `hashes match the fixture shared with the web`() {
// Kotlin's Int is signed, so the two hashes above 0x7FFFFFFF are written as
// their negative literal. The unsigned value in the comment is what colors.ts
// records and what an implementation of FNV-1a will actually produce.
assertEquals(-0x41B8712F, tintHash("00000000-0000-0000-0000-000000000000")) // 0xbe478ed1
assertEquals(0x3D75CC01, tintHash("11111111-1111-1111-1111-111111111111"))
assertEquals(-0x0EF71AD0, tintHash("6ba7b810-9dad-11d1-80b4-00c04fd430c8")) // 0xf108e530
assertEquals(0x5B651540, tintHash("f47ac10b-58cc-4372-a567-0e02b2c3d479"))
}
@Test
fun `tints match the fixture shared with the web`() {
assertEquals("purple", derivedTint("00000000-0000-0000-0000-000000000000"))
assertEquals("blue", derivedTint("11111111-1111-1111-1111-111111111111"))
assertEquals("orange", derivedTint("6ba7b810-9dad-11d1-80b4-00c04fd430c8"))
assertEquals("orange", derivedTint("f47ac10b-58cc-4372-a567-0e02b2c3d479"))
}
/** Half of all 32-bit hashes are negative as Kotlin Ints; a signed remainder would
* index out of the list for those. The bug this catches is a crash, not a wrong
* colour, so it is worth more than one id's worth of coverage. */
@Test
fun `every tint is a real palette key, over many ids`() {
for (n in 0 until 2000) {
val tint = derivedTint("note-$n")
assertEquals(true, tint in DERIVED_TINT_KEYS)
}
}
@Test
fun `the derived palette excludes default`() {
assertEquals(false, "default" in DERIVED_TINT_KEYS)
assertEquals(9, DERIVED_TINT_KEYS.size)
}
/** The order IS the mapping — reordering silently recolours every untagged note
* on one surface only. Written out longhand so a reorder fails here loudly. */
@Test
fun `key order matches colors ts`() {
assertEquals(
listOf("red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"),
DERIVED_TINT_KEYS,
)
}
@Test
fun `an explicitly picked colour still wins`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
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))
}
/** `default` is not a choice, it is the absence of one — so a note stored as
* `default` gets a derived tint rather than staying white. That is the whole
* point of the change. */
@Test
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))
}
/** 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))
}
/** The reason the feature exists: two adjacent notes should not look identical. */
@Test
fun `the tint spreads across the palette`() {
val seen = (0 until 500).map { derivedTint("spread-$it") }.toSet()
assertEquals(DERIVED_TINT_KEYS.size, seen.size)
}
}
+6 -3
View File
@@ -7,6 +7,7 @@ import {
NOTE_COLOR_KEYS,
NOTE_COLOR_LABELS,
NOTE_SWATCH_CLASSES,
resolveNoteColor,
type NoteColor,
} from "../notes/colors";
import type { Note } from "../stores/notes";
@@ -189,8 +190,10 @@ async function snoozeReminder(minutes: number): Promise<void> {
emit("reminder-changed");
}
function cardClass(color: NoteColor): string {
return NOTE_CARD_CLASSES[color] ?? NOTE_CARD_CLASSES.default;
// 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;
}
function labelChip(color: string): string {
@@ -225,7 +228,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.color),
cardClass(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'
+84
View File
@@ -84,3 +84,87 @@ export const NOTE_COLOR_LABELS: Record<NoteColor, string> = {
pink: "Pink",
gray: "Gray",
};
// ---------------------------------------------------------------------------
// Derived tints — the colour a note has when nothing chose one for it.
//
// A board of `default` notes is a wall of white rectangles and the eye gets no
// help telling one from the next. Every note now carries some tint; this is where
// an untagged one gets it.
//
// "RANDOM" MEANS DERIVED. The operator asked for "random subdued colors", but a
// tint rolled at render time would differ between the phone and the browser and
// change on every reload. Hashing the note's id is deterministic, identical on
// every surface, costs no column and no migration, and a note keeps its colour
// for life — which is what "random" actually meant here.
//
// THIS IS HALF A MIRRORED PAIR. `android/.../ui/NoteTint.kt` computes the same
// hash over the same key order, and the two must agree exactly or a note is one
// colour on the phone and another in the browser. Same discipline as the
// checklist grammar's three implementations, and the same reason: a value that
// disagrees across surfaces is a bug you cannot unsee and cannot explain.
//
// The Kotlin side has a unit test pinning the fixture below. THIS SIDE HAS NO
// MECHANICAL GUARD — the frontend has no test runner, only `vue-tsc --noEmit`.
// If you change anything here, check it against the fixture by hand.
/** The tints a derived colour can land on: the palette minus `default`, which is
* the white this exists to eliminate. `gray` stays — `bg-neutral-100` reads as a
* deliberate card against the board's `bg-neutral-50`, not as an absence. */
export const DERIVED_TINT_KEYS: readonly NoteColor[] = NOTE_COLOR_KEYS.filter(
(key) => key !== "default",
);
/**
* FNV-1a over the id's bytes, 32-bit.
*
* Chosen because both languages compute it identically in ten lines with no
* library. Explicitly NOT `String.hashCode()`: Kotlin's is specified but JS has
* no equivalent, and reimplementing Java's from memory in TypeScript is exactly
* how a mirror drifts.
*
* `& 0xff` is a no-op for the ASCII of a UUID, and is kept because it states the
* intent — this hashes BYTES, so the Kotlin side reading `id[i].code and 0xFF`
* is the same function rather than a coincidence.
*/
export function tintHash(id: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < id.length; i++) {
hash ^= id.charCodeAt(i) & 0xff;
// Math.imul, not `*`: JS numbers are doubles and a 32-bit overflow would be
// silently kept as precision instead of wrapping the way Kotlin's Int does.
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash >>> 0;
}
/** The tint a note with no colour of its own wears. Stable for the life of the note. */
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);
}
// 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:
//
// 00000000-0000-0000-0000-000000000000 0xbe478ed1 purple
// 11111111-1111-1111-1111-111111111111 0x3d75cc01 blue
// 6ba7b810-9dad-11d1-80b4-00c04fd430c8 0xf108e530 orange
// f47ac10b-58cc-4372-a567-0e02b2c3d479 0x5b651540 orange