| import type { Field } from "./types"; |
|
|
| export interface ChoiceTint { |
| bg: string; |
| fg: string; |
| } |
|
|
| |
| |
| |
| |
| 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(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| const INK_DARK = "#182230"; |
|
|
| export type SwatchStrength = "tint" | "pastel"; |
|
|
| export interface BrandSwatch extends ChoiceTint { |
| |
| id: string; |
| hue: "blue" | "green" | "yellow" | "red" | "purple"; |
| strength: SwatchStrength; |
| |
| 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" }, |
| ]; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| 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]; |
| } |
|
|
| |
| 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)) |
| ); |
| } |
|
|
| |
| 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"; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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); |
| } |
|
|
| |
| export function defaultOptionColor(value: string): string { |
| return pickTint(value).bg; |
| } |
|
|
| |
| 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))}`; |
| } |
|
|