// Note color palette. Keys match the backend's NOTE_COLORS; the actual tints live // here (frontend concern). Class strings are full literals so Tailwind's content // scanner (src/**/*.ts) keeps them in the build. export const NOTE_COLOR_KEYS = [ "default", "red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray", ] as const; export type NoteColor = (typeof NOTE_COLOR_KEYS)[number]; /** Membership test for a colour key arriving from the server, which may be newer * than this client. Was a lookup in the subdued card table until that table was * deleted — an untagged note's fill is generated now, not chosen from a palette. */ const KNOWN_COLORS = new Set(NOTE_COLOR_KEYS); export const NOTE_SWATCH_CLASSES: Record = { default: "bg-white dark:bg-neutral-600", red: "bg-red-300 dark:bg-red-700", orange: "bg-orange-300 dark:bg-orange-700", yellow: "bg-amber-300 dark:bg-amber-700", green: "bg-green-300 dark:bg-green-700", teal: "bg-teal-300 dark:bg-teal-700", blue: "bg-blue-300 dark:bg-blue-700", purple: "bg-purple-300 dark:bg-purple-700", pink: "bg-pink-300 dark:bg-pink-700", gray: "bg-neutral-400 dark:bg-neutral-500", }; // Label chip tints (bg + readable text + a hairline edge), keyed by the same color // vocabulary. // // The RING is not decoration. A tagged note takes its first tag's colour and is drawn // at that hue's `-100` — exactly what the chip uses as its fill — so in light mode the // chip measured a contrast ratio of 1.00 against the card it had itself coloured. // Perfectly invisible; the tag name read as loose text. An edge holds the pill's shape // against ANY background, where shifting the fill only moves which card it collides // with. export const LABEL_CHIP_CLASSES: Record = { default: "bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300 ring-1 ring-inset ring-black/10 dark:ring-white/15", red: "bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300 ring-1 ring-inset ring-red-700/60 dark:ring-red-300/60", orange: "bg-orange-100 text-orange-700 dark:bg-orange-950/50 dark:text-orange-300 ring-1 ring-inset ring-orange-700/60 dark:ring-orange-300/60", yellow: "bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300 ring-1 ring-inset ring-amber-700/60 dark:ring-amber-300/60", green: "bg-green-100 text-green-700 dark:bg-green-950/50 dark:text-green-300 ring-1 ring-inset ring-green-700/60 dark:ring-green-300/60", teal: "bg-teal-100 text-teal-700 dark:bg-teal-950/50 dark:text-teal-300 ring-1 ring-inset ring-teal-700/60 dark:ring-teal-300/60", blue: "bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300 ring-1 ring-inset ring-blue-700/60 dark:ring-blue-300/60", purple: "bg-purple-100 text-purple-700 dark:bg-purple-950/50 dark:text-purple-300 ring-1 ring-inset ring-purple-700/60 dark:ring-purple-300/60", pink: "bg-pink-100 text-pink-700 dark:bg-pink-950/50 dark:text-pink-300 ring-1 ring-inset ring-pink-700/60 dark:ring-pink-300/60", gray: "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200 ring-1 ring-inset ring-neutral-700/60 dark:ring-neutral-200/60", }; // The ink for a `#tag` drawn where it was typed, rather than repeated as a chip. // // ONE Tailwind step deeper than LABEL_CHIP_CLASSES' text, and the difference is not a // stylistic one. A chip carries its own `-100` fill, so its text has exactly one // background to read against. Inline text sits on whatever the CARD is — which // includes a gray-tagged card at `neutral-200`, where the chip's `-700` measured 3.98 // (green), 4.11 (orange) and 4.34 (teal), all under the 4.5 body text needs. At `-800` // every hue lands between 5.63 and 12.01 in light, and `-300` gives 7.20 to 10.84 in // dark, measured against every fill in NOTE_CARD_CLASSES_STRONG and every generated // fill. One step for all ten beats three per-hue exceptions. // // Mirrored in NoteTint.kt as `lightTagInk` / `darkTagInk`. export const TAG_TEXT_CLASSES: Record = { default: "text-neutral-700 dark:text-neutral-300", red: "text-red-800 dark:text-red-300", orange: "text-orange-800 dark:text-orange-300", yellow: "text-amber-800 dark:text-amber-300", green: "text-green-800 dark:text-green-300", teal: "text-teal-800 dark:text-teal-300", blue: "text-blue-800 dark:text-blue-300", purple: "text-purple-800 dark:text-purple-300", pink: "text-pink-800 dark:text-pink-300", gray: "text-neutral-800 dark:text-neutral-200", }; /** * The classes for one `#tag` in a note's own words. * * `picked` maps a lowercased tag name to the colour stored on that label, so a tag the * operator has recoloured reads the same inline as it does on a chip. A tag the note * does not carry as a label yet — just typed, not yet derived — is not in the map, and * `resolveLabelColor` derives one from the name exactly as the chip would have. */ export function tagTextClasses(name: string, picked?: Record): string { return TAG_TEXT_CLASSES[resolveLabelColor({ name, color: picked?.[name.toLowerCase()] })]; } // Solid fills for graph nodes (SVG needs concrete colors, not Tailwind bg classes). // Mid-tone hues read on both the light and dark graph background. export const NOTE_NODE_FILL: Record = { default: "#9ca3af", red: "#ef4444", orange: "#f97316", yellow: "#f59e0b", green: "#22c55e", teal: "#14b8a6", blue: "#3b82f6", purple: "#a855f7", pink: "#ec4899", gray: "#6b7280", }; export const NOTE_COLOR_LABELS: Record = { default: "Default", red: "Red", orange: "Orange", yellow: "Yellow", green: "Green", teal: "Teal", blue: "Blue", purple: "Purple", 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 fill for an UNTAGGED note, which is a different job from the palette above. // // The palette has nine keys and they MEAN something: a tag's colour. An untagged // note's fill means nothing at all — it exists so a board is not a monolithic wall. // Tying the second job to the first was the mistake. Nine keys is far too few for a // board of any size, and once the nine were subdued enough not to shout they became // indistinguishable from each other: measured, the nine dark fills were separated by // at most a 1.03 contrast ratio, which is to say not at all. Nine tints that look // like three is exactly the wall the tint was added to break up. // // So this hashes to a colour directly rather than to a key. 338 distinct fills in // dark, 193 in light, against nine. // // TWO AXES, AND THE SECOND ONE IS THE FIX. The old ramp varied hue while pinning // every fill to the same lightness — deliberately, so each would read as a card // against the board. But the eye separates by lightness first, so nine hues at one // lightness read as one card repeated. Varying lightness too is what makes the // difference; hue alone never could at this darkness. // // It is only SAFE to vary lightness because the card now has a grey edge of its own // (NoteCard.vue). While the fill was the only boundary the card had, it could not // afford to drift toward the board. The edge bought that freedom. // // MIRRORED in `android/.../ui/DerivedTint.kt`, which has the unit test. Same hash, // same levels, same rounding — see the fixture below. /** Lightness steps a generated fill can land on. Six rather than three because the * levels are what carry the variety, and rather than twelve because past a point * they stop being distinguishable and only cost contrast headroom. */ const TINT_LEVELS = 6; // Saturation is FIXED, and that is what keeps this subtle whichever hue it lands on. // Variety comes from hue and lightness; loudness would come from saturation, so // saturation is the one dial the hash never touches. const DARK_SATURATION = 0.25; const LIGHT_SATURATION = 0.6; // HSL LIGHTNESS IS NOT LUMINANCE, and the dark floor is set by the difference. // // The obvious floor is `neutral-900`'s own lightness, 0.090 — start at the plain card // surface and climb, so no note ever recedes into the board. That was the first // attempt and it was wrong: at a FIXED HSL lightness the eye sees wildly different // brightnesses by hue, because green carries 71% of the luminance formula and blue // only 7%. At L=0.090 a yellow measures 0.0118 and a blue 0.0061 — the blue landing // 1.41x DARKER than the card it was meant to match, so a sixth of the board would // have been holes rather than variety. // // 0.113 is the lowest floor at which every hue clears the card surface, solved for // rather than guessed: 1.11-1.71 against the board, where the old single level // managed 1.14. Light runs the other way, from white down past the `neutral-50` // board; a card slightly darker than the board still reads as one because the edge // says so, and near white the hue barely moves luminance at all so it needs no // equivalent correction. const DARK_LIGHTNESS = [0.113, 0.127, 0.141, 0.155, 0.169, 0.183]; const LIGHT_LIGHTNESS = [1.0, 0.99, 0.98, 0.97, 0.96, 0.95]; /** * The opaque fill an untagged note wears, as `#rrggbb`. Stable for the note's life. * * Hue and level are read from DIFFERENT parts of the hash so a note's shade is not a * function of its hue — two notes of nearly the same hue should still be able to * differ in weight, which is half of where the variety comes from. */ export function derivedFill(id: string, dark: boolean): string { const hash = tintHash(id); const level = (hash >>> 16) % TINT_LEVELS; return hslHex( hash % 360, dark ? DARK_SATURATION : LIGHT_SATURATION, dark ? DARK_LIGHTNESS[level] : LIGHT_LIGHTNESS[level], ); } /** * Textbook HSL to RGB, written out rather than pulled from a library because the * Kotlin side has to compute the same bytes and there is no library both can share. * Rounding is `floor(v + 0.5)` on both sides rather than the language's `round`: * Kotlin rounds half away from zero and JS rounds half up, which agree here, but * stating the rule leaves nothing for a future reader to have to check. */ function hslHex(hue: number, saturation: number, lightness: number): string { const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; const sector = hue / 60; const second = chroma * (1 - Math.abs((sector % 2) - 1)); const match = lightness - chroma / 2; const ramps: [number, number, number][] = [ [chroma, second, 0], [second, chroma, 0], [0, chroma, second], [0, second, chroma], [second, 0, chroma], [chroma, 0, second], ]; const [red, green, blue] = ramps[Math.floor(sector)]; const byte = (v: number) => Math.min(255, Math.max(0, Math.floor((v + match) * 255 + 0.5))) .toString(16) .padStart(2, "0"); return `#${byte(red)}${byte(green)}${byte(blue)}`; } /** * The colour to paint a LABEL — its chip, and (step 3) every note carrying it. * * Derived from the tag's NAME, not stored, when nobody has picked one. Every `#tag` * ever typed is currently `default`: `notes/tags.py` mints one as * `Label(owner_id=…, name=name)` with no colour, so it takes the column default. * Tag-driven note colour against that would leave the board exactly as grey as it * was. * * DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing the original plan in #2965. * That plan wanted a hashed colour written at each of the four places a label can be * born — and named the risk itself: `find_or_create_label` is "easy to miss, and it * is the common one", because most tags are born from typing `#grocery`, not from a * management screen. Deriving has no mint points to miss, needs no backfill for the * tags that already exist, and reuses the hash the notes already use. The cost is * that renaming a tag recolours it, which is defensible: the name IS the tag. * * An explicitly-picked colour is still stored and still wins, so tag colours stay * editable exactly as asked. * * Lowercased because tags dedupe case-insensitively — `#Todo` renamed to `#todo` is * the same tag and should not change colour. Both `toLowerCase` here and Kotlin's * `lowercase()` are locale-independent, so the mirror holds. */ export function resolveLabelColor(label: { name: string; color?: string | null }): NoteColor { const picked = label.color as NoteColor | undefined | null; if (picked && picked !== "default" && KNOWN_COLORS.has(picked)) return picked; if (!label.name) return "default"; return derivedTint(label.name.toLowerCase()); } // 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 // // And for labels, which hash the lowercased NAME rather than an id: // // todo -> pink grocery -> blue work -> green home -> gray // ideas -> green reading -> gray urgent -> red // // And for derivedFill, which uses the same hash on two axes (dark / light): // // 00000000-0000-0000-0000-000000000000 hue 177 lev 3 #1e3130 #f3fcfb // 11111111-1111-1111-1111-111111111111 hue 113 lev 1 #1a2818 #fbfefb // 6ba7b810-9dad-11d1-80b4-00c04fd430c8 hue 136 lev 0 #162419 #ffffff // f47ac10b-58cc-4372-a567-0e02b2c3d479 hue 352 lev 3 #311e20 #fcf3f4 // // 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. // // UNCHANGED by the border pass, deliberately. The operator's complaint was the edge // line and the loudness of the DERIVED tint; the chosen colours were "pretty well" // where they landed, and a ramp somebody has already signed off on is not something // to redo while fixing something else. // // THERE IS NO SECOND RAMP ANY MORE. This one is reached only by a note that HAS a // colour; a note without one gets a generated fill instead (`derivedFill`), because // nine palette keys could never carry both jobs. The palette says WHICH TAG. The // generator says nothing at all, and only has to keep the board from repeating. // // So these values do not need to be subtle and never did — a tagged note is making a // statement, and the quiet end of the board is now handled somewhere else entirely. export const NOTE_CARD_CLASSES_STRONG: Record = { default: "bg-white dark:bg-neutral-900", red: "bg-red-100 dark:bg-red-950/70", orange: "bg-orange-100 dark:bg-orange-950/70", yellow: "bg-amber-100 dark:bg-amber-950/70", green: "bg-green-100 dark:bg-green-950/70", teal: "bg-teal-100 dark:bg-teal-950/70", blue: "bg-blue-100 dark:bg-blue-950/70", purple: "bg-purple-100 dark:bg-purple-950/70", pink: "bg-pink-100 dark:bg-pink-950/70", gray: "bg-neutral-200 dark:bg-neutral-800/70", }; /** * The palette key a note was GIVEN, or null when nothing gave it one. * * Null is the interesting answer: it means the fill has to be generated, because the * note carries no statement about what it is. Everything downstream branches here. * * 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 chosenNoteColor(note: { color?: string | null; labels?: { name: string; color: string }[]; }): NoteColor | null { const picked = note.color as NoteColor | undefined | null; if (picked && picked !== "default" && KNOWN_COLORS.has(picked)) return picked; const first = note.labels?.[0]; if (first) return resolveLabelColor(first); return null; } /** * The class list for a note card. * * A tagged note gets a palette class. An untagged one gets `note-tint`, whose fill * arrives through the custom properties in `noteTintVars` — see the rule in * style.css, which exists because an inline style cannot answer a media query and the * light and dark fills are two different generated colours. */ export function noteCardClasses(note: { id: string; color?: string | null; labels?: { name: string; color: string }[]; }): string { const chosen = chosenNoteColor(note); return chosen ? NOTE_CARD_CLASSES_STRONG[chosen] : "note-tint"; } /** * The generated fill for an untagged note, as the two custom properties `note-tint` * reads — or undefined when the note has a colour of its own, or is a draft. * * A draft carries no id, so there is nothing to derive from; `note-tint`'s fallbacks * catch that and paint the plain card surface. Hashing the empty string instead would * give every draft the same fill and then change it at save time anyway. */ export function noteTintVars(note: { id: string; color?: string | null; labels?: { name: string; color: string }[]; }): Record | undefined { if (chosenNoteColor(note) || !note.id) return undefined; return { "--tint-light": derivedFill(note.id, false), "--tint-dark": derivedFill(note.id, true), }; }