cards: one neutral surface, and the generated fill deleted with it
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m15s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m59s

A card's fill stops being a function of the note. One neutral per theme on
all three surfaces — white in light, neutral-900 in dark, which is what the
palette's `default` always was and what both editors already used, so this
is a collapse onto a surface everything already had rather than a colour
anybody has to like.

Measured, against "not the same color as their background but close to it":
card vs board 1.04 light / 1.10 dark, edge vs card 1.98 / 1.73, body text
17.93 / 17.17, muted 10.37 / 14.23. The fill is deliberately the weakest
number on the card — the edge and the shadow separate it from the board, so
a fill that separated on its own would make it a panel.

Deleted, since the card was their only consumer: `derivedFill` / `hslHex`
and the level tables in colors.ts, `derivedFillArgb` / `hslToArgb` in
DerivedTint.kt, `NOTE_CARD_CLASSES_STRONG`, `chosenNoteColor`,
`noteCardClasses`, `noteTintVars`, the `.note-tint` rule in style.css,
`chosenBackground` / `tintable` / `noteTintFor` / `noteCardColor` /
`noteIsStrong` / `firstLabelColor` in NoteTint.kt, and
`resolvedNoteColor` / `noteColorIsChosen`.

`tintHash` and `derivedTint` STAY, against the plan: a label with no colour
of its own still derives one from its name, and that path was never the one
that failed. The mirrored pair and its fixture survive intact.

The editor follows the card, and its Done button takes the brand — the
board's compose FAB is the app's existing statement of "affirmative action
here", where Material's default secondaryContainer is a baseline colour this
theme never sets.

The colour picker is left in place, doing nothing, for exactly one step:
removing it here would leave `note.color` written by nothing and read by
nothing, which is a worse intermediate than a control that visibly does
nothing. #3041 takes the field and the picker together.

Step 1 of M315. #3148

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 10:55:51 -04:00
co-authored by Claude Opus 5
parent f50204a98b
commit b91091caca
9 changed files with 309 additions and 872 deletions
+86 -238
View File
@@ -18,8 +18,8 @@ export const NOTE_COLOR_KEYS = [
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. */
* 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> = {
@@ -59,14 +59,20 @@ export const LABEL_CHIP_CLASSES: Record<NoteColor, string> = {
// 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.
// ONE Tailwind step deeper than LABEL_CHIP_CLASSES' text. 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. When the card could be any of twenty fills that was a
// hard constraint — on a gray-tagged card at `neutral-200` the chip's `-700` measured
// 3.98 (green), 4.11 (orange) and 4.34 (teal), all under the 4.5 body text needs,
// where `-800` put every hue between 5.63 and 12.01 in light and `-300` gave 7.20 to
// 10.84 in dark, across every fill. One step for all ten beat three per-hue exceptions.
//
// THE CONSTRAINT IS GONE as of M315: there is one card fill per theme now
// (NOTE_CARD_SURFACE), so this table is solving for two backgrounds instead of twenty.
// Left at these values here deliberately — they still clear 4.5 on white and on
// `neutral-900`, so nothing is broken, and re-measuring the tag ink against one known
// background (including whether the chip and inline tables can now collapse into one)
// is its own step: #3149.
//
// Mirrored in NoteTint.kt as `lightTagInk` / `darkTagInk`.
export const TAG_TEXT_CLASSES: Record<NoteColor, string> = {
@@ -123,31 +129,27 @@ export const NOTE_COLOR_LABELS: Record<NoteColor, string> = {
};
// ---------------------------------------------------------------------------
// Derived tints — the colour a note has when nothing chose one for it.
// Derived colour — the hue a LABEL wears when nobody picked 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.
// 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.
//
// "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 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/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.
// 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.
/** 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",
);
@@ -175,128 +177,26 @@ export function tintHash(id: string): number {
return hash >>> 0;
}
/** The tint a note with no colour of its own wears. Stable for the life of the note. */
/** 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 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.
* 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 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.
* 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 the hash the notes already use. The cost is
* 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
@@ -313,119 +213,67 @@ export function resolveLabelColor(label: { name: string; color?: string | null }
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:
// 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 for labels, which hash the lowercased NAME rather than an id:
// And the live path — a label, hashing its lowercased NAME:
//
// 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.
// 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 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.
// ---------------------------------------------------------------------------
// THE CARD SURFACE — one neutral, no hue, no note involved (M315).
//
// 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.
// 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.
//
// 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.
// 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.
//
// 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),
};
}
// 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";