Nine tints that looked like three — generate the fill instead
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
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>
This commit is contained in:
+148
-68
@@ -17,45 +17,10 @@ export const NOTE_COLOR_KEYS = [
|
||||
|
||||
export type NoteColor = (typeof NOTE_COLOR_KEYS)[number];
|
||||
|
||||
// The SUBDUED ramp — what a note wears when nothing chose a colour for it.
|
||||
//
|
||||
// NO BORDER IN EITHER RAMP, here or below — the note card still HAS an edge, but it
|
||||
// is one grey for every card and it lives in NoteCard.vue, not in the palette. That
|
||||
// separation is the fix. The border used to be `border-{hue}-900` and measured
|
||||
// 1.56–2.09 against its own fill while the fill managed only 1.03–1.05 against the
|
||||
// board, so the loudest thing on every card was a line that said exactly what the
|
||||
// fill already said — and a field of them read as a grid of outlines however
|
||||
// different the colours inside were. A line that varies by colour is content; a line
|
||||
// that never varies is structure. Only one of those competes with the fill.
|
||||
//
|
||||
// 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), which is why the old hue border had
|
||||
// to shout to be seen at all. 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]",
|
||||
};
|
||||
/** 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",
|
||||
@@ -178,6 +143,98 @@ 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.
|
||||
*
|
||||
@@ -204,7 +261,7 @@ export function derivedTint(id: string): NoteColor {
|
||||
*/
|
||||
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 (picked && picked !== "default" && KNOWN_COLORS.has(picked)) return picked;
|
||||
if (!label.name) return "default";
|
||||
return derivedTint(label.name.toLowerCase());
|
||||
}
|
||||
@@ -223,6 +280,13 @@ export function resolveLabelColor(label: { name: string; color?: string | null }
|
||||
// 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.
|
||||
@@ -236,14 +300,13 @@ export function resolveLabelColor(label: { name: string; color?: string | null }
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// Light mode was already built this way and needed no change — `-50` and `-100` are
|
||||
// both essentially white plus a different amount of hue.
|
||||
// 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",
|
||||
@@ -258,12 +321,10 @@ export const NOTE_CARD_CLASSES_STRONG: Record<NoteColor, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The colour a note wears AND how strongly, in one answer.
|
||||
* The palette key a note was GIVEN, or null when nothing gave it one.
|
||||
*
|
||||
* `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".
|
||||
* 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 —
|
||||
@@ -274,31 +335,50 @@ export const NOTE_CARD_CLASSES_STRONG: Record<NoteColor, string> = {
|
||||
* 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;
|
||||
export function chosenNoteColor(note: {
|
||||
color?: string | null;
|
||||
labels?: { name: string; color: string }[];
|
||||
}): { color: NoteColor; strong: boolean } {
|
||||
}): NoteColor | null {
|
||||
const picked = note.color as NoteColor | undefined | null;
|
||||
if (picked && picked !== "default" && picked in NOTE_CARD_CLASSES) {
|
||||
return { color: picked, strong: true };
|
||||
}
|
||||
if (picked && picked !== "default" && KNOWN_COLORS.has(picked)) return picked;
|
||||
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 };
|
||||
if (first) return resolveLabelColor(first);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The card classes for a note — both ramps behind one call. */
|
||||
/**
|
||||
* 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 { color, strong } = resolveNoteTint(note);
|
||||
const ramp = strong ? NOTE_CARD_CLASSES_STRONG : NOTE_CARD_CLASSES;
|
||||
return ramp[color] ?? NOTE_CARD_CLASSES.default;
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user