CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m30s
Step 4 of milestone 309. All 40 combinations measured rather than eyeballed — 10 hues x 2 themes x 2 weights, dark ones composited over the board the way Compose and CSS both do, against the text actually drawn on a card (neutral-700/300 body, neutral-500/400 meta). Body text ranges 8.23:1 to 13.01:1 against a 4.5:1 requirement; meta text 4.33 to 7.11 against 3.0. Every combination passes AA with room to spare, so the two ramps step 3 introduced need no adjustment. That is the boring half. THE PASS FOUND A REAL REGRESSION. A tagged note takes its first tag's colour and is drawn at that hue's `-100` — which is exactly what the chip uses as its fill. Measured contrast between the chip and the card it had itself coloured: 1.00 in light mode. Perfectly invisible. Dark was 1.04-1.07, invisible in practice. On every tagged note the tag name had stopped reading as a chip and become loose text, and nothing about step 3 looked wrong while writing it. Fixed with an EDGE rather than a different fill. A fill can collide with any card colour and chasing that would need the chip to know what it is sitting on; a border in the chip's own foreground reads against any background and needs no plumbing. Alpha is 0.60, measured: 2.32:1 at worst, where the 0.30 I first wrote gave 1.49 and was no edge at all. It does not reach WCAG 1.4.11's 3:1, which needs 0.80 and draws a hard outline instead of a hairline. 1.4.11 governs boundaries carrying REQUIRED information, and a chip's information is its text — passing AA at 8:1 or better on every card here. The number and the reasoning are both in the source so the judgment can be overruled rather than rediscovered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
272 lines
13 KiB
TypeScript
272 lines
13 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];
|
|
|
|
export const NOTE_CARD_CLASSES: Record<NoteColor, string> = {
|
|
default: "bg-white border-neutral-200 dark:bg-neutral-900 dark:border-neutral-700",
|
|
red: "bg-red-50 border-red-200 dark:bg-red-950/25 dark:border-red-900",
|
|
orange: "bg-orange-50 border-orange-200 dark:bg-orange-950/25 dark:border-orange-900",
|
|
yellow: "bg-amber-50 border-amber-200 dark:bg-amber-950/25 dark:border-amber-900",
|
|
green: "bg-green-50 border-green-200 dark:bg-green-950/25 dark:border-green-900",
|
|
teal: "bg-teal-50 border-teal-200 dark:bg-teal-950/25 dark:border-teal-900",
|
|
blue: "bg-blue-50 border-blue-200 dark:bg-blue-950/25 dark:border-blue-900",
|
|
purple: "bg-purple-50 border-purple-200 dark:bg-purple-950/25 dark:border-purple-900",
|
|
pink: "bg-pink-50 border-pink-200 dark:bg-pink-950/25 dark:border-pink-900",
|
|
gray: "bg-neutral-100 border-neutral-300 dark:bg-neutral-800/25 dark:border-neutral-700",
|
|
};
|
|
|
|
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 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" && picked in NOTE_CARD_CLASSES) 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
|
|
//
|
|
// 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.
|
|
//
|
|
// The two ramps move in opposite directions per theme, because that is where each has
|
|
// headroom. Light mode cannot go lighter than `-50` without being white again, so the
|
|
// gap is opened by deepening the tagged end to `-100`. Dark mode CAN go lighter, so
|
|
// the untagged end drops to `/25` — closer to the board, which also gives the light
|
|
// body text MORE contrast rather than less.
|
|
//
|
|
// Borders stay put. The fill is the signal; moving both just muddies the edge.
|
|
export const NOTE_CARD_CLASSES_STRONG: Record<NoteColor, string> = {
|
|
default: "bg-white border-neutral-200 dark:bg-neutral-900 dark:border-neutral-700",
|
|
red: "bg-red-100 border-red-200 dark:bg-red-950/70 dark:border-red-900",
|
|
orange: "bg-orange-100 border-orange-200 dark:bg-orange-950/70 dark:border-orange-900",
|
|
yellow: "bg-amber-100 border-amber-200 dark:bg-amber-950/70 dark:border-amber-900",
|
|
green: "bg-green-100 border-green-200 dark:bg-green-950/70 dark:border-green-900",
|
|
teal: "bg-teal-100 border-teal-200 dark:bg-teal-950/70 dark:border-teal-900",
|
|
blue: "bg-blue-100 border-blue-200 dark:bg-blue-950/70 dark:border-blue-900",
|
|
purple: "bg-purple-100 border-purple-200 dark:bg-purple-950/70 dark:border-purple-900",
|
|
pink: "bg-pink-100 border-pink-200 dark:bg-pink-950/70 dark:border-pink-900",
|
|
gray: "bg-neutral-200 border-neutral-300 dark:bg-neutral-800/70 dark:border-neutral-700",
|
|
};
|
|
|
|
/**
|
|
* The colour a note wears AND how strongly, in one answer.
|
|
*
|
|
* `strong` is not a second decision — it IS whether the colour was chosen. A tag (or,
|
|
* until step 5, the picker) means somebody said what this note is; a derived tint only
|
|
* means the board should not be a wall of white. Rendering those two at the same
|
|
* weight is what made the operator ask "the tints look the same as the chosen colors".
|
|
*
|
|
* 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 resolveNoteTint(note: {
|
|
id: string;
|
|
color?: string | null;
|
|
labels?: { name: string; color: string }[];
|
|
}): { color: NoteColor; strong: boolean } {
|
|
const picked = note.color as NoteColor | undefined | null;
|
|
if (picked && picked !== "default" && picked in NOTE_CARD_CLASSES) {
|
|
return { color: picked, strong: true };
|
|
}
|
|
const first = note.labels?.[0];
|
|
if (first) return { color: resolveLabelColor(first), strong: true };
|
|
// No id yet means an unsaved draft: nothing to derive from. Staying white until the
|
|
// note exists costs one colour change at save time; hashing the empty string would
|
|
// give every draft the same tint and then change it anyway.
|
|
if (!note.id) return { color: "default", strong: false };
|
|
return { color: derivedTint(note.id), strong: false };
|
|
}
|
|
|
|
/** The card classes for a note — both ramps behind one call. */
|
|
export function noteCardClasses(note: {
|
|
id: string;
|
|
color?: string | null;
|
|
labels?: { name: string; color: string }[];
|
|
}): string {
|
|
const { color, strong } = resolveNoteTint(note);
|
|
const ramp = strong ? NOTE_CARD_CLASSES_STRONG : NOTE_CARD_CLASSES;
|
|
return ramp[color] ?? NOTE_CARD_CLASSES.default;
|
|
}
|