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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user