Files
inkwell/frontend/src/notes/colors.ts
T
bvandeusenandClaude Opus 5 47f108c9c8
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
The border was the thing making every note look the same
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>
2026-08-26 14:21:42 -04:00

304 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}