CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 3m25s
A tagged note was showing its tag twice — once where it was typed, once as a chip — and the duplicate was the loud copy. Now the chip row carries only what the body cannot say (a tag lifted off its own line, a label from the picker), and a `#tag` left mid-sentence is tinted in place. Which characters are a tag is asked of the CORE, the way the card already asks it which lines are checklist items: `extract_tag_spans` keeps the spans `extract_tags` throws away, and `body_tags` hands them to Kotlin. Offsets are UTF-16 code units, because `AnnotatedString` and JS both index that way and a char index lands mid-token the first time somebody writes an emoji. The web keeps its own matcher in markdown.ts, mirroring `line_tags` case for case. The inline ink is its own table, one Tailwind step deeper than the chip's. A chip brings its own -100 fill and reads against that alone; inline text sits on whatever the card is, including a gray-tagged card at neutral-200 — where the chip's -700 measured 3.98 (green), 4.11 (orange) and 4.34 (teal), under the 4.5 body text needs. At -800/-300 every hue lands 5.63-12.01 light and 7.20-10.84 dark across every palette and generated fill. Chips now carry the `#` on every surface. The via_tag branch that used to decide it is gone from the card, and Android's row said no hash at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
432 lines
20 KiB
TypeScript
432 lines
20 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",
|
|
};
|
|
|
|
// The ink for a `#tag` drawn where it was typed, rather than repeated as a chip.
|
|
//
|
|
// ONE Tailwind step deeper than LABEL_CHIP_CLASSES' text, and the difference is not a
|
|
// stylistic one. A chip carries its own `-100` fill, so its text has exactly one
|
|
// background to read against. Inline text sits on whatever the CARD is — which
|
|
// includes a gray-tagged card at `neutral-200`, where the chip's `-700` measured 3.98
|
|
// (green), 4.11 (orange) and 4.34 (teal), all under the 4.5 body text needs. At `-800`
|
|
// every hue lands between 5.63 and 12.01 in light, and `-300` gives 7.20 to 10.84 in
|
|
// dark, measured against every fill in NOTE_CARD_CLASSES_STRONG and every generated
|
|
// fill. One step for all ten beats three per-hue exceptions.
|
|
//
|
|
// Mirrored in NoteTint.kt as `lightTagInk` / `darkTagInk`.
|
|
export const TAG_TEXT_CLASSES: Record<NoteColor, string> = {
|
|
default: "text-neutral-700 dark:text-neutral-300",
|
|
red: "text-red-800 dark:text-red-300",
|
|
orange: "text-orange-800 dark:text-orange-300",
|
|
yellow: "text-amber-800 dark:text-amber-300",
|
|
green: "text-green-800 dark:text-green-300",
|
|
teal: "text-teal-800 dark:text-teal-300",
|
|
blue: "text-blue-800 dark:text-blue-300",
|
|
purple: "text-purple-800 dark:text-purple-300",
|
|
pink: "text-pink-800 dark:text-pink-300",
|
|
gray: "text-neutral-800 dark:text-neutral-200",
|
|
};
|
|
|
|
/**
|
|
* The classes for one `#tag` in a note's own words.
|
|
*
|
|
* `picked` maps a lowercased tag name to the colour stored on that label, so a tag the
|
|
* operator has recoloured reads the same inline as it does on a chip. A tag the note
|
|
* does not carry as a label yet — just typed, not yet derived — is not in the map, and
|
|
* `resolveLabelColor` derives one from the name exactly as the chip would have.
|
|
*/
|
|
export function tagTextClasses(name: string, picked?: Record<string, string>): string {
|
|
return TAG_TEXT_CLASSES[resolveLabelColor({ name, color: picked?.[name.toLowerCase()] })];
|
|
}
|
|
|
|
// 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. 338 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;
|
|
|
|
// HSL LIGHTNESS IS NOT LUMINANCE, and the dark floor is set by the difference.
|
|
//
|
|
// The obvious floor is `neutral-900`'s own lightness, 0.090 — start at the plain card
|
|
// surface and climb, so no note ever recedes into the board. That was the first
|
|
// attempt and it was wrong: at a FIXED HSL lightness the eye sees wildly different
|
|
// brightnesses by hue, because green carries 71% of the luminance formula and blue
|
|
// only 7%. At L=0.090 a yellow measures 0.0118 and a blue 0.0061 — the blue landing
|
|
// 1.41x DARKER than the card it was meant to match, so a sixth of the board would
|
|
// have been holes rather than variety.
|
|
//
|
|
// 0.113 is the lowest floor at which every hue clears the card surface, solved for
|
|
// rather than guessed: 1.11-1.71 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, and near white the hue barely moves luminance at all so it needs no
|
|
// equivalent correction.
|
|
const DARK_LIGHTNESS = [0.113, 0.127, 0.141, 0.155, 0.169, 0.183];
|
|
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 #1e3130 #f3fcfb
|
|
// 11111111-1111-1111-1111-111111111111 hue 113 lev 1 #1a2818 #fbfefb
|
|
// 6ba7b810-9dad-11d1-80b4-00c04fd430c8 hue 136 lev 0 #162419 #ffffff
|
|
// f47ac10b-58cc-4372-a567-0e02b2c3d479 hue 352 lev 3 #311e20 #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),
|
|
};
|
|
}
|