Every note carries a tint, derived from its id
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Android / Kotlin + Rust (APK) (push) Failing after 6m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m2s
Desktop (Tauri) / Update manifest (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Android / Kotlin + Rust (APK) (push) Failing after 6m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m2s
Desktop (Tauri) / Update manifest (push) Successful in 3s
The board was a wall of white rectangles: `default` is the colour nobody picks, so it was the colour of every note except the two the operator had coloured by hand. Reported twice — 2026-08-23 as "a wall of broken up text", and again today as "all the existing notes are the same dull color". The ask was "random subdued colors", but random is the one thing it must not be. A tint rolled at render time would differ between the phone and the browser and change on every reload. FNV-1a over the note's id is deterministic, identical on every surface, needs no column and no migration, and a note keeps its colour for life — which is what "random" meant here. Two implementations, deliberately mirrored, same discipline as the checklist grammar. The Kotlin half lives in a Compose-free file so a host-JVM test can pin the fixture; the TypeScript half carries the same four ids and hashes as a comment because the frontend has no test runner at all — its whole CI lane is `vue-tsc --noEmit`. That asymmetry is worth naming rather than papering over. A draft has no id yet (DRAFT_ID is ""), so it stays white until it is saved. Hashing the empty string would give every draft one shared tint and then change it on save anyway — two surprises where one will do. 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 hand-coloured note changing under the operator would read as data loss. First of five steps toward colour coming from tags. This one stands alone: no storage change, nothing removed, and the board stops being white today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user