Files
thoughtsync/frontend/src/notes/colors.ts
T
bvandeusenandClaude Opus 5 d9e5753dc2
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 3m25s
board: a tag in the prose is coloured where it sits, not printed twice
A tagged note was showing its tag twice — once where it was typed, once as a
chip — and the duplicate was the loud copy. Now the chip row carries only what
the body cannot say (a tag lifted off its own line, a label from the picker),
and a `#tag` left mid-sentence is tinted in place.

Which characters are a tag is asked of the CORE, the way the card already asks
it which lines are checklist items: `extract_tag_spans` keeps the spans
`extract_tags` throws away, and `body_tags` hands them to Kotlin. Offsets are
UTF-16 code units, because `AnnotatedString` and JS both index that way and a
char index lands mid-token the first time somebody writes an emoji. The web
keeps its own matcher in markdown.ts, mirroring `line_tags` case for case.

The inline ink is its own table, one Tailwind step deeper than the chip's. A
chip brings its own -100 fill and reads against that alone; inline text sits on
whatever the card is, including a gray-tagged card at neutral-200 — where the
chip's -700 measured 3.98 (green), 4.11 (orange) and 4.34 (teal), under the 4.5
body text needs. At -800/-300 every hue lands 5.63-12.01 light and 7.20-10.84
dark across every palette and generated fill.

Chips now carry the `#` on every surface. The via_tag branch that used to
decide it is gone from the card, and Android's row said no hash at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:20:17 -04:00

432 lines
20 KiB
TypeScript

// 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<string>(NOTE_COLOR_KEYS);
export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = {
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<NoteColor, string> = {
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<NoteColor, string> = {
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, string>): 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<NoteColor, string> = {
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<NoteColor, string> = {
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<NoteColor, string> = {
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<string, string> | undefined {
if (chosenNoteColor(note) || !note.id) return undefined;
return {
"--tint-light": derivedFill(note.id, false),
"--tint-dark": derivedFill(note.id, true),
};
}