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 9s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m23s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m4s
A note card carried a 1px tint border. Measured against its own fill, that
line was a 1.56-2.09 contrast in dark mode while the fill managed only
1.03-1.05 against the board — so the loudest thing on every card was an
identical line in an identical place, and a field of them read as a grid of
outlined rectangles however different the colours inside were.
Removed from the note card on both surfaces. `border` survives for panels,
banners, the update card and the pickers: those are single elements, not a
field of them.
What replaces it differs by theme, because elevation does.
Light leans on a shadow. An untagged card is `bg-red-50` on a `neutral-50`
board — a 1.04 contrast that can only read as a card by sitting above one.
The web goes `shadow-sm` -> `shadow`; Android had no shadow at all and gets
2dp.
Dark cannot use one, black on near-black. So the subdued fills moved onto
the card surface instead: `{hue}-950` composited at 0.18 over #171717 and
baked, rather than the same hue at 0.25 over the near-black board. An
untagged card now sits where the plain white card always sat (1.11-1.14
against the board, against `bg-neutral-900`'s 1.10) while carrying LESS hue
than before — chroma 7-17 where the old ramp had 10-23.
Subtler and more visible at once, which is only a contradiction if subtlety
has to come from lightness. Here it comes from chroma, and lightness is left
to say "this is a card". Which also reframes the two weights: in dark they
now sit within a hair of each other (red: 1.11 vs 1.12) and differ threefold
in colour (chroma 10 vs 41).
The chosen ramp is untouched — the operator signed those colours off, and a
ramp somebody likes is not something to redo while fixing something else.
Light was already built this way: `-50` and `-100` are both white plus a
different amount of hue.
Body text still measures 14.3-16.4 against the 4.5 it needs, meta 6.9-7.1
against 3.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
304 lines
15 KiB
TypeScript
304 lines
15 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];
|
||
|
||
// The SUBDUED ramp — what a note wears when nothing chose a colour for it.
|
||
//
|
||
// NO BORDER, here or in the strong ramp below. A note card used to carry a 1px
|
||
// `border-{hue}-900`, and measured against its own fill that line was a 1.56–2.09
|
||
// contrast while the fill managed only 1.03–1.05 against the board. The loudest
|
||
// thing on every card was therefore an identical line in an identical place, and a
|
||
// board of them read as a grid of outlined rectangles no matter what colour was
|
||
// inside — "too samey even with the color differences". The card's boundary now
|
||
// comes from its fill in dark mode and from `shadow` in light (NoteCard.vue), both
|
||
// of which vary with the card instead of framing it.
|
||
//
|
||
// The dark values are COMPOSITED HEX rather than a Tailwind step, and that is the
|
||
// whole idea. `dark:bg-red-950/25` laid a hue over the near-black BOARD, which put
|
||
// the card at the board's own lightness (1.03) — invisible without the border it
|
||
// has just lost. These lay the same hue over the CARD SURFACE (`neutral-900`,
|
||
// #171717) at 18%, so an untagged card sits exactly where the default white card
|
||
// always sat (1.11–1.14 vs the board, against `bg-neutral-900`'s 1.10) while
|
||
// carrying LESS colour than before: chroma 7–17 where the old ramp had 10–23.
|
||
//
|
||
// Subtler and more visible at once, which is only a contradiction if you assume
|
||
// subtlety has to come from lightness. Here it comes from chroma, and lightness is
|
||
// left to say "this is a card".
|
||
//
|
||
// Recipe, so these can be regenerated rather than guessed at: sRGB alpha
|
||
// compositing of `{hue}-950` at 0.18 over #171717 — `round(fg*0.18 + 23*0.82)` per
|
||
// channel. `gray` uses `neutral-800` as its 950, matching the strong ramp.
|
||
export const NOTE_CARD_CLASSES: Record<NoteColor, string> = {
|
||
default: "bg-white dark:bg-neutral-900",
|
||
red: "bg-red-50 dark:bg-[#1f1515]",
|
||
orange: "bg-orange-50 dark:bg-[#1f1614]",
|
||
yellow: "bg-amber-50 dark:bg-[#1f1813]",
|
||
green: "bg-green-50 dark:bg-[#141b17]",
|
||
teal: "bg-teal-50 dark:bg-[#141b1b]",
|
||
blue: "bg-blue-50 dark:bg-[#171a22]",
|
||
purple: "bg-purple-50 dark:bg-[#1d1425]",
|
||
pink: "bg-pink-50 dark:bg-[#211419]",
|
||
gray: "bg-neutral-100 dark:bg-[#1a1a1a]",
|
||
};
|
||
|
||
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.
|
||
//
|
||
// 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.
|
||
//
|
||
// WHAT SEPARATES THE TWO RAMPS IS NOW CHROMA, NOT LIGHTNESS. Since the subdued ramp
|
||
// moved onto the card surface, the two sit at almost the same lightness in dark mode
|
||
// (red: 1.11 vs 1.12 against the board) and differ threefold in colour (chroma 10 vs
|
||
// 41). That is the better axis anyway: lightness is what says "this is a card", and
|
||
// spending it on emphasis is what left untagged cards flat against the board.
|
||
//
|
||
// Light mode was already built this way and needed no change — `-50` and `-100` are
|
||
// both essentially white plus a different amount of hue.
|
||
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 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;
|
||
}
|