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))}`;
}