CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
Android / Kotlin + Rust (APK) (push) Failing after 4m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m13s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Measured, the nine dark subdued fills were separated from each other by at most a 1.03 contrast ratio. That is not "subtle", it is identical, and it is why a board of them reads as one card repeated: "I only see 3 colors ... it looks like a monolithic wall." Two causes, and the second one is mine. NINE IS TOO FEW. The palette exists to say WHICH TAG. An untagged note's fill says nothing at all — it only has to keep the board from repeating. Those are different jobs and tying them together capped the second at nine values for a board that will hold hundreds. ONE AXIS IS TOO FEW. The subdued 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 are one card nine times. Hue alone was never going to carry it at that darkness. So an untagged note's fill is now generated from its id rather than looked up: hue anywhere on the circle, one of six lightness levels, saturation fixed. 324 distinct fills in dark and 193 in light, against nine. Separation between fills goes from a 1.03 ceiling to 1.42. Varying lightness is only SAFE because the card has its own grey edge now. While the fill was the card's only boundary it could not afford to drift toward the board; the edge bought that freedom, one commit before it was needed. Saturation is the one dial the hash never touches — variety comes from hue and lightness, loudness would come from saturation. Dark starts a hair under `neutral-900` and climbs, so no note is ever darker than a plain card. Light runs from white down past the board. Body text measures 8.7 at worst against the 4.5 it needs; the meta row 5.1 against 3.0. DOUBLE, NOT FLOAT, on the Kotlin side. JavaScript has one number type and it is binary64; a Kotlin Float is binary32, so the two would round differently near a channel boundary and a note would be one byte off between the phone and the browser. Nobody would ever file that — they would see two colours that are "sort of the same" and never work out why. The web half cannot be executed here at all (no node on this machine), so the Kotlin fixture test is the only place the two implementations are ever compared. It now pins eight generated values as well as the hash, plus the properties that actually matter: that lightness varies, that nothing sinks below the card surface, and that body text stays clear of AA. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
385 lines
18 KiB
TypeScript
385 lines
18 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",
|
|
};
|
|
|
|
// 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. 324 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;
|
|
|
|
// Dark starts a hair under `neutral-900`, the plain card surface, and climbs — so
|
|
// nothing is ever darker than an untinted card and no note recedes into the board.
|
|
// The top of the range measures 1.54 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.
|
|
const DARK_LIGHTNESS = [0.09, 0.104, 0.118, 0.132, 0.146, 0.16];
|
|
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 #192a29 #f3fcfb
|
|
// 11111111-1111-1111-1111-111111111111 hue 113 lev 1 #152114 #fbfefb
|
|
// 6ba7b810-9dad-11d1-80b4-00c04fd430c8 hue 136 lev 0 #111d14 #ffffff
|
|
// f47ac10b-58cc-4372-a567-0e02b2c3d479 hue 352 lev 3 #2a191b #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),
|
|
};
|
|
}
|