// 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]; 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/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 = { 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", }; // 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 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" && picked in NOTE_CARD_CLASSES) 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 // // 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; }