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
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>
302 lines
16 KiB
TypeScript
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";
|