Files
thoughtsync/frontend/src/notes/colors.ts
T
bvandeusenandClaude Opus 5 13a88179b8
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 5s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m31s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m1s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 8m45s
tags: one ink, chip and inline, and the chip edge solved for 3:1
Step 1 left one card surface per theme, so the tag ink is no longer
choosing a value that has to clear twenty backgrounds. Re-measured against
the one it actually lands on, the two tables collapse into one.

`-800` in light, `-300` in dark, for a `#tag` in the prose AND for a chip's
text. Dark needed no decision at all — the two tables already held the same
value for all ten hues, which is most of the argument on its own. Light
collapses onto the INLINE column deliberately: since M311 a tag whose text
is in the body is drawn where it was typed and not repeated as a chip, so
inline is the common case and this leaves what is seen most exactly as it
was. The chip is strictly better for the move:

                    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)

The chip edge goes 0.60 -> 0.65, and this is the first time that number
could be solved rather than judged. 0.60 was picked against a chip sitting
on a card of its own colour, a case that no longer exists; against a known
fill the smallest alpha clearing the 3:1 of WCAG 1.4.11 for all ten hues is
arithmetic. 0.60 gives 2.75-3.82 and misses for six of them, 0.65 gives
3.03-4.36 and misses for none. Dark runs 4.52-5.76.

That edge is doing more work than it looks: a chip's fill measures 1.02-1.26
against the card in light and 1.02-1.73 in dark, and dark red at 1.02 is
invisible. The ring is the pill; the fill only tints it.

`LABEL_CHIP_CLASSES` becomes `LABEL_CHIP_SHELL` — fill and edge, no ink —
and `labelChipClasses(label)` composes shell and ink in one place. The board
and the editor each had their own copy of that composition, with a comment
on one of them asking the other to stay in step. Now it is one call.

Fixed on the way past: the web drew `default`'s chip ring at `black/10`
(1.36 against its own fill) where Compose derived it from the ink (3.21) —
the same chip, visibly different pills. Both are the ink at 65% now.

`chipForeground` stays, narrowed to what it always actually was: the
REMINDER pill's ink, transcribed from NoteCard.vue's literal red-700 /
neutral-600. It is not a tag and must not move with one.

Also gone: `NOTE_NODE_FILL`, a per-hue table of solid hexes for graph nodes
with no consumer anywhere in the repo.

Step 2 of M315. #3149

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:13:43 -04:00

302 lines
16 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. 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<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",
};
// 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<NoteColor, string> = {
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<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()] })];
}
/**
* 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<NoteColor, string> = {
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";