// 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]; // The SUBDUED ramp — what a note wears when nothing chose a colour for it. // // NO BORDER, here or in the strong ramp below. A note card used to carry a 1px // `border-{hue}-900`, and measured against its own fill that line was a 1.56–2.09 // contrast while the fill managed only 1.03–1.05 against the board. The loudest // thing on every card was therefore an identical line in an identical place, and a // board of them read as a grid of outlined rectangles no matter what colour was // inside — "too samey even with the color differences". The card's boundary now // comes from its fill in dark mode and from `shadow` in light (NoteCard.vue), both // of which vary with the card instead of framing it. // // The dark values are COMPOSITED HEX rather than a Tailwind step, and that is the // whole idea. `dark:bg-red-950/25` laid a hue over the near-black BOARD, which put // the card at the board's own lightness (1.03) — invisible without the border it // has just lost. These lay the same hue over the CARD SURFACE (`neutral-900`, // #171717) at 18%, so an untagged card sits exactly where the default white card // always sat (1.11–1.14 vs the board, against `bg-neutral-900`'s 1.10) while // carrying LESS colour than before: chroma 7–17 where the old ramp had 10–23. // // Subtler and more visible at once, which is only a contradiction if you assume // subtlety has to come from lightness. Here it comes from chroma, and lightness is // left to say "this is a card". // // Recipe, so these can be regenerated rather than guessed at: sRGB alpha // compositing of `{hue}-950` at 0.18 over #171717 — `round(fg*0.18 + 23*0.82)` per // channel. `gray` uses `neutral-800` as its 950, matching the strong ramp. export const NOTE_CARD_CLASSES: Record = { default: "bg-white dark:bg-neutral-900", red: "bg-red-50 dark:bg-[#1f1515]", orange: "bg-orange-50 dark:bg-[#1f1614]", yellow: "bg-amber-50 dark:bg-[#1f1813]", green: "bg-green-50 dark:bg-[#141b17]", teal: "bg-teal-50 dark:bg-[#141b1b]", blue: "bg-blue-50 dark:bg-[#171a22]", purple: "bg-purple-50 dark:bg-[#1d1425]", pink: "bg-pink-50 dark:bg-[#211419]", gray: "bg-neutral-100 dark:bg-[#1a1a1a]", }; 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. // // 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. // // WHAT SEPARATES THE TWO RAMPS IS NOW CHROMA, NOT LIGHTNESS. Since the subdued ramp // moved onto the card surface, the two sit at almost the same lightness in dark mode // (red: 1.11 vs 1.12 against the board) and differ threefold in colour (chroma 10 vs // 41). That is the better axis anyway: lightness is what says "this is a card", and // spending it on emphasis is what left untagged cards flat against the board. // // Light mode was already built this way and needed no change — `-50` and `-100` are // both essentially white plus a different amount of hue. 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 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; }