// 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. Once a lookup in a card-fill table; since M315 there is no such * table, and this guards a LABEL's stored colour on its way into the 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", }; // A chip's SHELL: its fill and its hairline edge, keyed by the colour vocabulary. The // INK is not here — see TAG_TEXT_CLASSES, which one table now serves both a chip's text // and a `#tag` left in the prose. Compose the two with `labelChipClasses`. // // THE RING IS NOT DECORATION. A chip's fill measures 1.02-1.26 against the card in // light and 1.02-1.73 in dark — that is to say, very nearly nothing. The pill's shape // is the edge; the fill only tints it. (Dark red is the extreme at 1.02, which is // invisible: without the ring that chip would be loose text.) An edge holds the shape // against any background, where shifting the fill only moves which card it collides // with. // // AT 65%, NOT 60%, AND SOLVED FOR RATHER THAN GUESSED. 0.60 was chosen against a worst // case that no longer exists — a chip sitting on a card of its own colour, back when a // note took its first tag's fill. Against the one card surface (M315) the ring is the // ink at alpha over a known fill, so the alpha that clears the 3:1 of WCAG 1.4.11 for // all ten hues can simply be solved: 0.60 gives 2.75-3.82 in light and misses for six // of them, 0.65 gives 3.03-4.36 and misses for none. Dark is 4.52-5.76. 0.80 was the // number the old comment named as the fallback and it is more than is needed — it // draws a hard outline where a hairline does the job. // // `default`'s ring was `black/10 dark:white/15` here and the ink at alpha on the phone — // 1.36 against its own fill in light against Compose's 3.21, so the two surfaces were // drawing visibly different pills for the same chip. It is the ink at 65% on both now, // like every other key: one rule for ten hues, not nine and an exception. // // Mirrored in NoteTint.kt as `chipBackground` and `chipBorder` / CHIP_EDGE_ALPHA. export const LABEL_CHIP_SHELL: Record = { default: "bg-black/5 dark:bg-white/10 ring-1 ring-inset ring-neutral-700/65 dark:ring-neutral-300/65", red: "bg-red-100 dark:bg-red-950/50 ring-1 ring-inset ring-red-800/65 dark:ring-red-300/65", orange: "bg-orange-100 dark:bg-orange-950/50 ring-1 ring-inset ring-orange-800/65 dark:ring-orange-300/65", yellow: "bg-amber-100 dark:bg-amber-950/50 ring-1 ring-inset ring-amber-800/65 dark:ring-amber-300/65", green: "bg-green-100 dark:bg-green-950/50 ring-1 ring-inset ring-green-800/65 dark:ring-green-300/65", teal: "bg-teal-100 dark:bg-teal-950/50 ring-1 ring-inset ring-teal-800/65 dark:ring-teal-300/65", blue: "bg-blue-100 dark:bg-blue-950/50 ring-1 ring-inset ring-blue-800/65 dark:ring-blue-300/65", purple: "bg-purple-100 dark:bg-purple-950/50 ring-1 ring-inset ring-purple-800/65 dark:ring-purple-300/65", pink: "bg-pink-100 dark:bg-pink-950/50 ring-1 ring-inset ring-pink-800/65 dark:ring-pink-300/65", gray: "bg-neutral-200 dark:bg-neutral-700 ring-1 ring-inset ring-neutral-800/65 dark:ring-neutral-200/65", }; // THE INK A TAG IS DRAWN IN — one table, for a `#tag` left in the prose AND for a // chip's text. It was two, and the split was real while it lasted: a chip carried its // own `-100` fill and could afford `-700`, while inline text sat on whatever the card // was, which included a gray-tagged card at `neutral-200` where `-700` measured 3.98 // (green), 4.11 (orange) and 4.34 (teal) — all under the 4.5 body text needs. One step // deeper cleared every fill at once, so inline got `-800` and the chip kept `-700`. // // M315 removed the twenty card fills the split was solving for, and this is the payoff: // against ONE card surface both jobs can take the same value. `-800`/`-300` is the one // they take, and the direction is deliberate — the inline token is the common case // (since M311 a tag whose text is in the body is drawn where it was typed and NOT // repeated as a chip), so collapsing onto the inline column leaves what is seen most // exactly as it was, and moves only the chip. The chip is strictly better for it: // // inline, on the card as a chip, on its own fill // light `-800` 7.09 - 15.13 6.37 - 12.01 (was 4.52 - 8.23) // dark `-300` 9.45 - 14.23 8.23 - 11.88 (unchanged) // // Dark needed no decision at all: the two tables were already the same value for all // ten hues, which is on its own most of the argument that one table was always enough. // // 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()] })]; } /** * The whole class list for a label CHIP: the shell and the ink, composed. * * One function rather than the composition written out at each call site, because the * board's chip and the editor's chip have to be the same pill — the same tag changing * colour on opening a note would be worse than both being grey. That was a comment * asking two files to stay in step; it is one call now. * * Takes the LABEL, not its colour: a tag nobody has coloured derives one from its name, * so chips carry the tag's identity rather than all being the same grey. */ export function labelChipClasses(label: { name: string; color?: string | null }): string { const color = resolveLabelColor(label); return `${LABEL_CHIP_SHELL[color]} ${TAG_TEXT_CLASSES[color]}`; } 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 colour — the hue a LABEL wears when nobody picked one for it. // // Every `#tag` is born colourless, so without this a board of tags is a board of // identical grey chips. Hashing the tag's NAME is deterministic, identical on every // surface, costs no column and no migration, and a tag keeps its colour for life. // // THIS WAS THE CARD'S COLOUR TOO, ONCE. It is not any more (M315): a note's fill is // one neutral and only its tags carry hue. The hash survived that removal because the // job it still does — give a name a stable colour — was never the job that failed. // What failed was asking a colour that means "which tag" to also mean nothing at all // on an untagged note, at which point the board had two vocabularies and neither read. // // THIS IS HALF A MIRRORED PAIR. `android/.../ui/DerivedTint.kt` computes the same // hash over the same key order, and the two must agree exactly or a tag 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. 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 colour a name maps to, stable for as long as the name is. Called with a * label's lowercased name; `id` is the parameter's history, not its meaning. */ 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 its `#tag` where it sits in the prose. * * Derived from the tag's NAME, not stored, when nobody has picked one. Every `#tag` * ever typed is `default`: `notes/tags.py` mints one as `Label(owner_id=…, name=name)` * with no colour, so it takes the column default. Without deriving, a board of tags * would be a board of identical grey chips. * * 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 a hash that is already written twice. 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 names 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 must still hold on BOTH surfaces. // // The raw hash, over four UUIDs — ids no longer pick a colour, but they are what the // hash itself is pinned by and the Kotlin test still asserts them: // // 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 the live path — a label, hashing its lowercased NAME: // // 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 tags are distinct, it never claims two // chips of one colour are the same tag. The chip's text is what says which tag it is. // --------------------------------------------------------------------------- // THE CARD SURFACE — one neutral, no hue, no note involved (M315). // // This used to be a function of the note: a palette class for a tagged one, a fill // generated from the id for the rest. Both are gone. The operator's verdict after // four passes at it — "my coloring attempt has failed and nothing looks right… we've // tried a lot to make the color work and somehow it never seems to land" — and the // diagnosis underneath it is that a card's fill was being asked to carry meaning it // could not carry. Nine keys is too few to identify anything on a board of any size, // and a generated fill identifies nothing by construction, so a coloured board taught // the eye to read hue as significant and then handed it noise. // // COLOUR NOW LIVES ONLY ON THE TAG — the chip and the inline `#tag`, both of which sit // on this one known background from here on. That is a smaller job done properly // instead of a larger one done four times. // // The codebase had already made this argument about the card's EDGE, one level down: // the hue-coded border came out as "a neutral line carries no information at all, // which is exactly what lets it be structure instead of content". The fill is the same // argument at the next size up. // // THE VALUES. `bg-white dark:bg-neutral-900` — which is exactly what `default` always // was, and exactly what the note EDITOR panel has always been (NoteEditor.vue), so // this is a collapse onto a surface both other surfaces already used rather than a // new colour anybody has to like. // // Measured against the operator's constraint, "not the same color as their background // but close to it": // // card vs board light #ffffff on #fafafa 1.04 // dark #171717 on #0a0a0a 1.10 // edge vs card light #b8b8b8 on #ffffff 1.98 // dark #404040 on #171717 1.73 // body vs card light #171717 on #ffffff 17.93 (needs 4.5) // dark #fafafa on #171717 17.17 // muted vs card light #404040 on #ffffff 10.37 // dark #e5e5e5 on #171717 14.23 // // The fill is deliberately the WEAKEST of those numbers. A card is not separated from // the board by its fill and never was — the edge and the shadow do that, which is why // 1.04 is enough and why it has to stay near 1: a fill that separated on its own would // be a panel, and a board of panels is the wall this whole line of work started from. export const NOTE_CARD_SURFACE = "bg-white dark:bg-neutral-900";