loopable / web /src /customer-grid /choiceColors.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
016c754 verified
Raw
History Blame Contribute Delete
8.98 kB
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 `<input type="color">`, 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-<hue>` 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; `<hue>-<strength>`. */
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))}`;
}