import type { Field } from "./types"; export interface ChoiceTint { bg: string; fg: string; } /** * The legacy deterministic option palette. Keeping it here gives the grid, pickers, Kanban, * and the field editor one colour vocabulary instead of four almost-matching copies. */ export const OPTION_PALETTE: readonly ChoiceTint[] = [ { bg: "#EDF3FD", fg: "#4F6079" }, { bg: "#EBF6EF", fg: "#35754E" }, { bg: "#FBF4E0", fg: "#7E6428" }, { bg: "#FCEEEC", fg: "#A3453C" }, { bg: "#F1EEFB", fg: "#6B57A8" }, { bg: "#EEF1F4", fg: "#4B5563" }, ]; export function normalizeOptionColor(value: unknown): string | undefined { if (typeof value !== "string" || !/^#[0-9a-f]{6}$/i.test(value.trim())) return undefined; return value.trim().toUpperCase(); } // --------------------------------------------------------------------------- // Wave-14 item 22 (R5) — THE BRAND SWATCHES: the 5 palette hues × 2 strengths. // // The option colour used to be an ``, i.e. sixteen million choices in a // product whose entire visual argument is five. R5 fixes the offer at ten: each hue's `-tint` // (the wash the app already uses for chips and involved columns) and its `-pastel` — the // `--lp-` FILL weight. // // ⚠ THE INK IS MEASURED, NOT ASSUMED, and the two strengths do not take the same one. // The palette law puts text on the `-deep` weight, and on a TINT that measures 4.98–5.35:1 // (AA for normal text). On a PASTEL the same inks measure 3.10–4.06:1 — every one of them // fails. So a pastel swatch takes the standard dark ink, which measures 8.25–11.58:1 there. // The law is about which weight carries text on the tint wash; a stronger fill needs a // stronger ink, and the alternative was ten swatches of which five were unreadable. // blue's tint ink is #4F6079, NOT `--lp-blue-deep` (#768FB6): that token's own comment in // index.css reserves it for "focus rings, control borders" and it measures 2.96:1 here. // The shipped OPTION_PALETTE already made this call; this keeps it. // --------------------------------------------------------------------------- /** The standard dark ink, from `optionTextColor`'s own pair. */ const INK_DARK = "#182230"; export type SwatchStrength = "tint" | "pastel"; export interface BrandSwatch extends ChoiceTint { /** Stable id for keys/tests; `-`. */ id: string; hue: "blue" | "green" | "yellow" | "red" | "purple"; strength: SwatchStrength; /** Plain words, for the swatch's title and accessible name. */ label: string; } export const BRAND_SWATCHES: readonly BrandSwatch[] = [ { id: "blue-tint", hue: "blue", strength: "tint", bg: "#EDF3FD", fg: "#4F6079", label: "Light blue" }, { id: "green-tint", hue: "green", strength: "tint", bg: "#EBF6EF", fg: "#35754E", label: "Light green" }, { id: "yellow-tint", hue: "yellow", strength: "tint", bg: "#FBF4E0", fg: "#7E6428", label: "Light yellow" }, { id: "red-tint", hue: "red", strength: "tint", bg: "#FCEEEC", fg: "#A3453C", label: "Light red" }, { id: "purple-tint", hue: "purple", strength: "tint", bg: "#F1EEFB", fg: "#6B57A8", label: "Light purple" }, { id: "blue-pastel", hue: "blue", strength: "pastel", bg: "#9DBFF2", fg: INK_DARK, label: "Blue" }, { id: "green-pastel", hue: "green", strength: "pastel", bg: "#A5D8B4", fg: INK_DARK, label: "Green" }, { id: "yellow-pastel", hue: "yellow", strength: "pastel", bg: "#F5D989", fg: INK_DARK, label: "Yellow" }, { id: "red-pastel", hue: "red", strength: "pastel", bg: "#F0A8A0", fg: INK_DARK, label: "Red" }, { id: "purple-pastel", hue: "purple", strength: "pastel", bg: "#C3B3EE", fg: INK_DARK, label: "Purple" }, ]; /** * The swatch a stored colour reads as. * * Distance is the "redmean" weighted Euclidean approximation rather than raw RGB: plain RGB * distance calls #EDF3FD closer to #FBF4E0 than to #9DBFF2, which would map a pale blue onto * the yellow family. Exact palette members land on themselves (distance 0), so a colour * already on the palette is not "nearest" to anything else. * * ⚠ DISPLAY ONLY. Nothing here rewrites what is stored — `normalizeOptionColor`, * `optionSettings` and `optionAppearanceSignature` are the PERSIST path and are untouched, so * an untouched legacy field stays byte-stable and a stored colour changes only when somebody * re-picks one. */ export function nearestSwatch(value: unknown): BrandSwatch | undefined { const hex = normalizeOptionColor(value); if (!hex) return undefined; const r = channel(hex, 1); const g = channel(hex, 3); const b = channel(hex, 5); let best = BRAND_SWATCHES[0]; let bestD = Number.POSITIVE_INFINITY; for (const swatch of BRAND_SWATCHES) { const dr = r - channel(swatch.bg, 1); const dg = g - channel(swatch.bg, 3); const db = b - channel(swatch.bg, 5); const rmean = (r + channel(swatch.bg, 1)) / 2; const d = (2 + rmean / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rmean) / 256) * db * db; if (d < bestD) { bestD = d; best = swatch; } } return best; } /** A stored colour, rendered on-brand: its nearest swatch, or (for an unparseable value) the * legacy behaviour of the colour itself under whichever standard ink reads on it. */ export function brandedTint(color: string): ChoiceTint { const swatch = nearestSwatch(color); return swatch ? { bg: swatch.bg, fg: swatch.fg } : { bg: color, fg: optionTextColor(color) }; } export function pickTint(value: string): ChoiceTint { let hash = 0; for (let i = 0; i < value.length; i += 1) hash = (hash * 31 + value.charCodeAt(i)) >>> 0; return OPTION_PALETTE[hash % OPTION_PALETTE.length]; } /** Resolve a saved colour case-insensitively while returning the canonical option's value. */ export function assignedOptionColor(field: Field, value: string): string | undefined { const colors = field.optionColors; if (!colors) return undefined; const direct = normalizeOptionColor(colors[value]); if (direct) return direct; const wanted = value.trim().toLowerCase(); for (const [label, color] of Object.entries(colors)) { if (label.trim().toLowerCase() !== wanted) continue; return normalizeOptionColor(color); } return undefined; } function channel(hex: string, offset: number): number { return Number.parseInt(hex.slice(offset, offset + 2), 16); } function luminance(hex: string): number { const linear = (n: number): number => { const s = n / 255; return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }; return ( 0.2126 * linear(channel(hex, 1)) + 0.7152 * linear(channel(hex, 3)) + 0.0722 * linear(channel(hex, 5)) ); } /** Pick whichever standard ink has the stronger WCAG contrast on an arbitrary user colour. */ export function optionTextColor(background: string): string { const bg = normalizeOptionColor(background) ?? "#FFFFFF"; const l = luminance(bg); const dark = luminance("#182230"); const light = luminance("#FFFFFF"); return (l + 0.05) / (dark + 0.05) >= (light + 0.05) / (l + 0.05) ? "#182230" : "#FFFFFF"; } /** * The tint a value is PAINTED in — grid cells, kanban cards, the cell picker, every surface. * * Item 22: a user-assigned colour now renders as its nearest BRAND SWATCH. That is the * "everywhere" half of the owner's item — a magenta chosen through the old free-RGB input * would otherwise keep painting magenta on every screen for as long as nobody re-picked it, * and the point of removing the input was that the product stops showing colours that are not * its own. The stored value is untouched (see `nearestSwatch`), so nothing is lost and the * next re-pick is what makes it permanent. * * The DEFAULT branch is deliberately NOT snapped: `pickTint` already returns members of * `OPTION_PALETTE`, five of which ARE the tint swatches, and re-hashing labels onto the wider * list would repaint every uncoloured option in every tenant AND break `optionSettings`' * byte-stability rule, which compares against `defaultOptionColor` to decide what to persist. */ export function optionTint(field: Field, value: string): ChoiceTint | undefined { if (!value || field.colorCodeOptions === false) return undefined; const custom = assignedOptionColor(field, value); return custom ? brandedTint(custom) : pickTint(value); } /** Default shown by the field editor before a user chooses a custom colour. */ export function defaultOptionColor(value: string): string { return pickTint(value).bg; } /** A darker companion for outlines/markers drawn on top of a user-selected fill. */ export function optionBorderColor(background: string): string { const hex = normalizeOptionColor(background) ?? "#D7DBE3"; const darken = (n: number): string => Math.max(0, Math.round(n * 0.68)).toString(16).padStart(2, "0").toUpperCase(); return `#${darken(channel(hex, 1))}${darken(channel(hex, 3))}${darken(channel(hex, 5))}`; }