File size: 8,980 Bytes
5351cc8 016c754 5351cc8 016c754 5351cc8 016c754 5351cc8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | 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))}`;
}
|