prism-design 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +292 -0
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/bin/clone-architect.mjs +476 -0
- package/bin/prism.mjs +467 -0
- package/catalog/index.json +1155 -0
- package/extractions/airbnb.com/DESIGN.md +1068 -0
- package/extractions/airbnb.com/tokens.json +507 -0
- package/extractions/attio.com/DESIGN.md +1295 -0
- package/extractions/attio.com/tokens.json +438 -0
- package/extractions/auroxdashboard.com/DESIGN.md +724 -0
- package/extractions/auroxdashboard.com/tokens.json +195 -0
- package/extractions/careerexplorer.com/DESIGN.md +1178 -0
- package/extractions/careerexplorer.com/tokens.json +141 -0
- package/extractions/chance.co/DESIGN.md +1209 -0
- package/extractions/chance.co/tokens.json +160 -0
- package/extractions/choisis-ton-avenir.com/DESIGN.md +1265 -0
- package/extractions/choisis-ton-avenir.com/tokens.json +227 -0
- package/extractions/example.com/DESIGN.md +436 -0
- package/extractions/example.com/tokens.json +91 -0
- package/extractions/getdesign.md/DESIGN.md +1009 -0
- package/extractions/getdesign.md/tokens.json +219 -0
- package/extractions/github.com/DESIGN.md +1130 -0
- package/extractions/github.com/tokens.json +2092 -0
- package/extractions/hello-charly.com/DESIGN.md +1146 -0
- package/extractions/hello-charly.com/tokens.json +322 -0
- package/extractions/hyperliquid.xyz/DESIGN.md +779 -0
- package/extractions/hyperliquid.xyz/tokens.json +598 -0
- package/extractions/instagram.com/DESIGN.md +996 -0
- package/extractions/instagram.com/tokens.json +1240 -0
- package/extractions/jobirl.com/DESIGN.md +1160 -0
- package/extractions/jobirl.com/tokens.json +139 -0
- package/extractions/life360.com/DESIGN.md +1133 -0
- package/extractions/life360.com/tokens.json +491 -0
- package/extractions/lifesum.com/DESIGN.md +965 -0
- package/extractions/lifesum.com/tokens.json +170 -0
- package/extractions/linear.app/DESIGN.md +1301 -0
- package/extractions/linear.app/tokens.json +732 -0
- package/extractions/mavoie.org/DESIGN.md +1148 -0
- package/extractions/mavoie.org/tokens.json +128 -0
- package/extractions/miro.com/DESIGN.md +1237 -0
- package/extractions/miro.com/tokens.json +401 -0
- package/extractions/notion.so/DESIGN.md +1319 -0
- package/extractions/notion.so/tokens.json +906 -0
- package/extractions/onetonline.org/DESIGN.md +909 -0
- package/extractions/onetonline.org/tokens.json +280 -0
- package/extractions/posthog.com/DESIGN.md +1024 -0
- package/extractions/posthog.com/tokens.json +197 -0
- package/extractions/revolut.com/DESIGN.md +1080 -0
- package/extractions/revolut.com/tokens.json +401 -0
- package/extractions/stripe.com/DESIGN.md +1272 -0
- package/extractions/stripe.com/tokens.json +794 -0
- package/extractions/switchcollective.com/DESIGN.md +1040 -0
- package/extractions/switchcollective.com/tokens.json +98 -0
- package/extractions/truity.com/DESIGN.md +970 -0
- package/extractions/truity.com/tokens.json +166 -0
- package/extractions/uniquekicks.be/DESIGN.md +1171 -0
- package/extractions/uniquekicks.be/tokens.json +237 -0
- package/package.json +122 -0
- package/scripts/analyze.ts +281 -0
- package/scripts/bank-register.ts +379 -0
- package/scripts/bank.ts +374 -0
- package/scripts/browser-stealth.ts +189 -0
- package/scripts/clone.ts +198 -0
- package/scripts/compare-vs-gd-final.ts +273 -0
- package/scripts/compare-vs-gd.ts +269 -0
- package/scripts/compare.ts +405 -0
- package/scripts/deploy-site.ts +181 -0
- package/scripts/diff-snapshots.ts +340 -0
- package/scripts/enrich-catalog.ts +212 -0
- package/scripts/extract.ts +2038 -0
- package/scripts/extractors/advanced.ts +524 -0
- package/scripts/extractors/widgets.ts +711 -0
- package/scripts/generate-design-md.ts +5775 -0
- package/scripts/generate-final-pdf.ts +274 -0
- package/scripts/generate-og-image.ts +87 -0
- package/scripts/generate-showcase.ts +1588 -0
- package/scripts/generate-site.ts +847 -0
- package/scripts/mass-extract.sh +91 -0
- package/scripts/post-process-all.sh +55 -0
- package/scripts/regen-catalog.ts +203 -0
- package/scripts/shared/cache.ts +149 -0
- package/scripts/shared/css-helpers.ts +263 -0
- package/scripts/shared/logger.ts +57 -0
- package/scripts/shared/named-colors.ts +355 -0
- package/scripts/shared/types.ts +220 -0
- package/scripts/sync-catalog.ts +105 -0
- package/scripts/tokenize.ts +988 -0
- package/templates/layout-template.md +52 -0
- package/templates/tokens-template.json +34 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared/css-helpers.ts — Prism Phase 5 Sprint 80/20
|
|
3
|
+
* Primitives pures unifiées (parseRgb×3 → 1, luminance×3 → 1, NOISE_VALUES, etc.)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ─── Color parsing & normalization ────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
/** Parse rgb/rgba string → [r,g,b]. Returns null if invalid. */
|
|
9
|
+
export function parseRgb(color: string): [number, number, number] | null {
|
|
10
|
+
if (!color) return null;
|
|
11
|
+
const m = color.match(/rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)/);
|
|
12
|
+
if (!m) return null;
|
|
13
|
+
return [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Normalize rgb/rgba string: uniform spacing, preserve alpha when <1. */
|
|
17
|
+
export function normalizeRgb(color: string): string {
|
|
18
|
+
const m = color.match(/rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\s*\)/);
|
|
19
|
+
if (!m) return color;
|
|
20
|
+
const a = m[4];
|
|
21
|
+
if (a !== undefined && parseFloat(a) < 1) {
|
|
22
|
+
return `rgba(${m[1]}, ${m[2]}, ${m[3]}, ${a})`;
|
|
23
|
+
}
|
|
24
|
+
return `rgb(${m[1]}, ${m[2]}, ${m[3]})`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ─── Luminance (WCAG perceptual) — accepts (r,g,b) OR ([r,g,b]) ──────────────
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* WCAG relative luminance (perceptual, gamma-corrected).
|
|
31
|
+
* Used by: retheme.ts, bank-register.ts (isDark detection + fuzzy matching).
|
|
32
|
+
* Accepts both signatures for backward compat.
|
|
33
|
+
*/
|
|
34
|
+
export function luminance(r: number | [number, number, number], g?: number, b?: number): number {
|
|
35
|
+
let rr: number, gg: number, bb: number;
|
|
36
|
+
if (Array.isArray(r)) {
|
|
37
|
+
[rr, gg, bb] = r;
|
|
38
|
+
} else {
|
|
39
|
+
rr = r;
|
|
40
|
+
gg = g as number;
|
|
41
|
+
bb = b as number;
|
|
42
|
+
}
|
|
43
|
+
const s = [rr, gg, bb].map(v => {
|
|
44
|
+
const n = v / 255;
|
|
45
|
+
return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4);
|
|
46
|
+
});
|
|
47
|
+
return 0.2126 * s[0] + 0.7152 * s[1] + 0.0722 * s[2];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Simple luminance (ITU-R BT.601, not gamma-corrected).
|
|
52
|
+
* Used by: tokenize.ts classifyColors (keeping legacy behavior for compat).
|
|
53
|
+
* DO NOT change formula — changes isDark detection on 411 existing snapshots.
|
|
54
|
+
*/
|
|
55
|
+
export function luminanceSimple(rgb: [number, number, number]): number {
|
|
56
|
+
return (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Memoized luminance (Sprint 80/20 J3 perf) ───────────────────────────────
|
|
60
|
+
|
|
61
|
+
const _lumCache = new Map<string, number>();
|
|
62
|
+
|
|
63
|
+
export function luminanceMemo(r: number, g: number, b: number): number {
|
|
64
|
+
const key = `${r},${g},${b}`;
|
|
65
|
+
const cached = _lumCache.get(key);
|
|
66
|
+
if (cached !== undefined) return cached;
|
|
67
|
+
// LRU cap 10K entries
|
|
68
|
+
if (_lumCache.size >= 10000) {
|
|
69
|
+
const firstKey = _lumCache.keys().next().value;
|
|
70
|
+
if (firstKey !== undefined) _lumCache.delete(firstKey);
|
|
71
|
+
}
|
|
72
|
+
const result = luminance(r, g, b);
|
|
73
|
+
_lumCache.set(key, result);
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── Perceptual color distance (weighted euclidean) ───────────────────────────
|
|
78
|
+
|
|
79
|
+
export function colorDistance(a: [number, number, number], b: [number, number, number]): number {
|
|
80
|
+
const rMean = (a[0] + b[0]) / 2;
|
|
81
|
+
const dr = a[0] - b[0];
|
|
82
|
+
const dg = a[1] - b[1];
|
|
83
|
+
const db = a[2] - b[2];
|
|
84
|
+
return Math.sqrt(
|
|
85
|
+
(2 + rMean / 256) * dr * dr +
|
|
86
|
+
4 * dg * dg +
|
|
87
|
+
(2 + (255 - rMean) / 256) * db * db
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Pixel parsing (from tokenize.ts — most permissive) ───────────────────────
|
|
92
|
+
|
|
93
|
+
export function parsePixels(val: string): number {
|
|
94
|
+
const m = (val || '').match(/(-?\d+(?:\.\d+)?)\s*px/);
|
|
95
|
+
return m ? parseFloat(m[1]) : 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Normalize a CSS border-radius value for display.
|
|
100
|
+
* Chrome MAX_INT border-radius (3.35544e+07px) → '9999px'.
|
|
101
|
+
* Preserves valid values under 9999.
|
|
102
|
+
*/
|
|
103
|
+
export function normalizeRadius(val: string): string {
|
|
104
|
+
if (!val || val === 'none' || val === '0px') return val;
|
|
105
|
+
// Handle multi-value radius (e.g. "4px 4px 0px 0px") — normalize each part
|
|
106
|
+
const parts = val.trim().split(/\s+/);
|
|
107
|
+
const normalized = parts.map(part => {
|
|
108
|
+
const n = parseFloat(part);
|
|
109
|
+
if (!isNaN(n) && n > 9999) return '9999px';
|
|
110
|
+
return part;
|
|
111
|
+
});
|
|
112
|
+
return normalized.join(' ');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── HEX conversion ───────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
export function rgbToHex(rgb: [number, number, number]): string {
|
|
118
|
+
const [r, g, b] = rgb;
|
|
119
|
+
const toHex = (n: number) => n.toString(16).padStart(2, '0');
|
|
120
|
+
return '#' + toHex(r) + toHex(g) + toHex(b);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── Clean color value (from tokenize.ts) ─────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
export function cleanColorValue(v: string): string {
|
|
126
|
+
return (v || '').trim().replace(/\s+/g, ' ');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Dark site detection (unified) ────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
export function isDarkBackground(color: string, threshold = 0.3): boolean {
|
|
132
|
+
if (!color || color.includes('rgba(0, 0, 0, 0)') || color === 'transparent') return false;
|
|
133
|
+
const rgb = parseRgb(color);
|
|
134
|
+
if (!rgb) return false;
|
|
135
|
+
return luminance(rgb) < threshold;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── CSS NOISE filter (formerly in bank-inject.ts + renderers.ts duplicated) ──
|
|
139
|
+
|
|
140
|
+
export const NOISE_VALUES: Record<string, Set<string>> = {
|
|
141
|
+
backgroundColor: new Set(['rgba(0, 0, 0, 0)', 'transparent', 'initial', 'inherit']),
|
|
142
|
+
border: new Set(['0px none rgb(0, 0, 0)', '0px none', 'none', '0px', 'medium none']),
|
|
143
|
+
borderColor: new Set([]),
|
|
144
|
+
boxShadow: new Set(['none']),
|
|
145
|
+
opacity: new Set(['1']),
|
|
146
|
+
overflow: new Set(['visible']),
|
|
147
|
+
position: new Set(['static']),
|
|
148
|
+
flexDirection: new Set(['row']),
|
|
149
|
+
alignItems: new Set(['normal', 'stretch']),
|
|
150
|
+
justifyContent: new Set(['normal', 'flex-start']),
|
|
151
|
+
transition: new Set(['all', 'all 0s ease 0s', 'none 0s ease 0s', 'none']),
|
|
152
|
+
textTransform: new Set(['none']),
|
|
153
|
+
textDecoration: new Set(['none']),
|
|
154
|
+
letterSpacing: new Set(['normal', '0px']),
|
|
155
|
+
fontFeatureSettings: new Set(['normal']),
|
|
156
|
+
fontVariationSettings: new Set(['normal']),
|
|
157
|
+
gap: new Set(['normal', '0px']),
|
|
158
|
+
gridTemplateColumns: new Set(['none']),
|
|
159
|
+
gridTemplateRows: new Set(['none']),
|
|
160
|
+
maxWidth: new Set(['none']),
|
|
161
|
+
minHeight: new Set(['auto', '0px']),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export const ALWAYS_DROP_PROPS = new Set([
|
|
165
|
+
'borderColor',
|
|
166
|
+
'marginTop', 'marginRight', 'marginBottom', 'marginLeft',
|
|
167
|
+
'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
export const INHERITED_PROPS = new Set([
|
|
171
|
+
'color', 'fontFamily', 'fontSize', 'fontWeight', 'lineHeight',
|
|
172
|
+
'letterSpacing', 'textTransform',
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
export function isNoise(prop: string, value: string): boolean {
|
|
176
|
+
if (ALWAYS_DROP_PROPS.has(prop)) return true;
|
|
177
|
+
const noiseSet = NOISE_VALUES[prop];
|
|
178
|
+
if (!noiseSet) return false;
|
|
179
|
+
if (noiseSet.size === 0) return true; // empty = always drop
|
|
180
|
+
return noiseSet.has(value);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function isTransparent(color: string): boolean {
|
|
184
|
+
return !color || color.includes('rgba(0, 0, 0, 0)') || color === 'transparent';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Filter styles: apply NOISE + inheritance ─────────────────────────────────
|
|
188
|
+
|
|
189
|
+
export function filterStyles(
|
|
190
|
+
styles: Record<string, string>,
|
|
191
|
+
parentStyles: Record<string, string>,
|
|
192
|
+
): Record<string, string> {
|
|
193
|
+
const result: Record<string, string> = {};
|
|
194
|
+
for (const [prop, val] of Object.entries(styles)) {
|
|
195
|
+
if (typeof val !== 'string') continue;
|
|
196
|
+
if (isNoise(prop, val)) continue;
|
|
197
|
+
if (INHERITED_PROPS.has(prop) && parentStyles[prop] === val) continue;
|
|
198
|
+
result[prop] = val;
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function buildChildParentStyles(
|
|
204
|
+
ownStyles: Record<string, string>,
|
|
205
|
+
parentStyles: Record<string, string>,
|
|
206
|
+
): Record<string, string> {
|
|
207
|
+
const next = { ...parentStyles };
|
|
208
|
+
for (const [prop, val] of Object.entries(ownStyles)) {
|
|
209
|
+
if (INHERITED_PROPS.has(prop)) next[prop] = val;
|
|
210
|
+
}
|
|
211
|
+
return next;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─── HTML utilities ───────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
export function escapeHtml(str: string): string {
|
|
217
|
+
if (!str) return '';
|
|
218
|
+
return str
|
|
219
|
+
.replace(/&/g, '&')
|
|
220
|
+
.replace(/</g, '<')
|
|
221
|
+
.replace(/>/g, '>')
|
|
222
|
+
.replace(/"/g, '"')
|
|
223
|
+
.replace(/'/g, ''');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function sanitizeUrl(url: string): string {
|
|
227
|
+
if (!url) return '';
|
|
228
|
+
const lower = url.trim().toLowerCase();
|
|
229
|
+
if (lower.startsWith('javascript:') || lower.startsWith('data:')) return '#';
|
|
230
|
+
if (url.startsWith('//')) return 'https:' + url;
|
|
231
|
+
return url;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function cssKebab(prop: string): string {
|
|
235
|
+
return prop.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─── Color hue family (for tagging) ───────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
export function colorHueTag(color: string): string | null {
|
|
241
|
+
const rgb = parseRgb(color);
|
|
242
|
+
if (!rgb) return null;
|
|
243
|
+
const [r, g, b] = rgb;
|
|
244
|
+
const max = Math.max(r, g, b);
|
|
245
|
+
const min = Math.min(r, g, b);
|
|
246
|
+
const delta = max - min;
|
|
247
|
+
if (delta < 30) return null;
|
|
248
|
+
|
|
249
|
+
let hue = 0;
|
|
250
|
+
if (max === r) hue = ((g - b) / delta) % 6;
|
|
251
|
+
else if (max === g) hue = (b - r) / delta + 2;
|
|
252
|
+
else hue = (r - g) / delta + 4;
|
|
253
|
+
hue = Math.round(hue * 60);
|
|
254
|
+
if (hue < 0) hue += 360;
|
|
255
|
+
|
|
256
|
+
if (hue < 30) return 'accent-red';
|
|
257
|
+
if (hue < 60) return 'accent-orange';
|
|
258
|
+
if (hue < 150) return 'accent-green';
|
|
259
|
+
if (hue < 210) return 'accent-teal';
|
|
260
|
+
if (hue < 270) return 'accent-blue';
|
|
261
|
+
if (hue < 330) return 'accent-purple';
|
|
262
|
+
return 'accent-pink';
|
|
263
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared/logger.ts — Prism
|
|
3
|
+
* Logger léger avec niveaux + contexte + JSON mode pour CI
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
7
|
+
|
|
8
|
+
const LEVEL_WEIGHT: Record<LogLevel, number> = {
|
|
9
|
+
debug: 0, info: 1, warn: 2, error: 3,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const CURRENT_LEVEL: LogLevel = (process.env.CLONE_LOG_LEVEL as LogLevel) || 'info';
|
|
13
|
+
const JSON_MODE = process.env.CLONE_LOG_JSON === '1';
|
|
14
|
+
|
|
15
|
+
function shouldLog(level: LogLevel): boolean {
|
|
16
|
+
return LEVEL_WEIGHT[level] >= LEVEL_WEIGHT[CURRENT_LEVEL];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function format(level: LogLevel, module: string, msg: string, meta?: any): string {
|
|
20
|
+
if (JSON_MODE) {
|
|
21
|
+
return JSON.stringify({ level, module, msg, meta, ts: new Date().toISOString() });
|
|
22
|
+
}
|
|
23
|
+
const prefix = level === 'error' ? '❌' : level === 'warn' ? '⚠️ ' : level === 'debug' ? '🔍' : 'ℹ️ ';
|
|
24
|
+
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '';
|
|
25
|
+
return `${prefix} [${module}] ${msg}${metaStr}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class Logger {
|
|
29
|
+
constructor(private module: string) {}
|
|
30
|
+
|
|
31
|
+
debug(msg: string, meta?: any): void {
|
|
32
|
+
if (shouldLog('debug')) console.log(format('debug', this.module, msg, meta));
|
|
33
|
+
}
|
|
34
|
+
info(msg: string, meta?: any): void {
|
|
35
|
+
if (shouldLog('info')) console.log(format('info', this.module, msg, meta));
|
|
36
|
+
}
|
|
37
|
+
warn(msg: string, meta?: any): void {
|
|
38
|
+
if (shouldLog('warn')) console.warn(format('warn', this.module, msg, meta));
|
|
39
|
+
}
|
|
40
|
+
error(msg: string, meta?: any): void {
|
|
41
|
+
if (shouldLog('error')) console.error(format('error', this.module, msg, meta));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Wrap a promise with try/catch + warn on failure. */
|
|
45
|
+
async tryOrWarn<T>(label: string, fn: () => Promise<T>, fallback: T): Promise<T> {
|
|
46
|
+
try {
|
|
47
|
+
return await fn();
|
|
48
|
+
} catch (err) {
|
|
49
|
+
this.warn(`${label} failed`, { error: (err as Error).message });
|
|
50
|
+
return fallback;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getLogger(module: string): Logger {
|
|
56
|
+
return new Logger(module);
|
|
57
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared/named-colors.ts — Prism
|
|
3
|
+
* Palette de couleurs nommées pour nameColor/nameColorUnique.
|
|
4
|
+
* Extrait de generate-design-md.ts (RFC D).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { parseRgb, colorDistance } from './css-helpers.js';
|
|
8
|
+
|
|
9
|
+
export interface NamedColor {
|
|
10
|
+
name: string;
|
|
11
|
+
hex: string;
|
|
12
|
+
rgb: [number, number, number];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const NAMED_COLORS: NamedColor[] = [
|
|
16
|
+
{ name: 'Pure White', hex: '#ffffff', rgb: [255, 255, 255] },
|
|
17
|
+
{ name: 'Snow', hex: '#fffafa', rgb: [255, 250, 250] },
|
|
18
|
+
{ name: 'Off-White', hex: '#fafafa', rgb: [250, 250, 250] },
|
|
19
|
+
{ name: 'Ghost White', hex: '#f8f8ff', rgb: [248, 248, 255] },
|
|
20
|
+
{ name: 'Warm Cream', hex: '#f7f4ed', rgb: [247, 244, 237] },
|
|
21
|
+
{ name: 'Light Gray', hex: '#f0f0f0', rgb: [240, 240, 240] },
|
|
22
|
+
{ name: 'Silver', hex: '#c0c0c0', rgb: [192, 192, 192] },
|
|
23
|
+
{ name: 'Medium Gray', hex: '#808080', rgb: [128, 128, 128] },
|
|
24
|
+
{ name: 'Dim Gray', hex: '#696969', rgb: [105, 105, 105] },
|
|
25
|
+
{ name: 'Dark Gray', hex: '#404040', rgb: [64, 64, 64] },
|
|
26
|
+
{ name: 'Charcoal', hex: '#333333', rgb: [51, 51, 51] },
|
|
27
|
+
{ name: 'Near Black', hex: '#1c1c1c', rgb: [28, 28, 28] },
|
|
28
|
+
{ name: 'Jet Black', hex: '#111111', rgb: [17, 17, 17] },
|
|
29
|
+
{ name: 'Pure Black', hex: '#000000', rgb: [0, 0, 0] },
|
|
30
|
+
{ name: 'Deep Navy', hex: '#0a1628', rgb: [10, 22, 40] },
|
|
31
|
+
{ name: 'Navy Blue', hex: '#001f3f', rgb: [0, 31, 63] },
|
|
32
|
+
{ name: 'Royal Blue', hex: '#4169e1', rgb: [65, 105, 225] },
|
|
33
|
+
{ name: 'Cobalt Blue', hex: '#0047ab', rgb: [0, 71, 171] },
|
|
34
|
+
{ name: 'Sky Blue', hex: '#87ceeb', rgb: [135, 206, 235] },
|
|
35
|
+
{ name: 'Steel Blue', hex: '#4682b4', rgb: [70, 130, 180] },
|
|
36
|
+
{ name: 'Cornflower Blue', hex: '#6495ed', rgb: [100, 149, 237] },
|
|
37
|
+
{ name: 'Slate Blue', hex: '#6a5acd', rgb: [106, 90, 205] },
|
|
38
|
+
{ name: 'Light Blue', hex: '#add8e6', rgb: [173, 216, 230] },
|
|
39
|
+
{ name: 'Baby Blue', hex: '#89cff0', rgb: [137, 207, 240] },
|
|
40
|
+
{ name: 'Indigo', hex: '#5e6ad2', rgb: [94, 106, 210] },
|
|
41
|
+
{ name: 'Violet', hex: '#7c3aed', rgb: [124, 58, 237] },
|
|
42
|
+
{ name: 'Deep Purple', hex: '#533afd', rgb: [83, 58, 253] },
|
|
43
|
+
{ name: 'Lavender', hex: '#b9b9f9', rgb: [185, 185, 249] },
|
|
44
|
+
{ name: 'Amethyst', hex: '#9966cc', rgb: [153, 102, 204] },
|
|
45
|
+
{ name: 'Plum', hex: '#dda0dd', rgb: [221, 160, 221] },
|
|
46
|
+
{ name: 'Red', hex: '#ff0000', rgb: [255, 0, 0] },
|
|
47
|
+
{ name: 'Crimson', hex: '#dc143c', rgb: [220, 20, 60] },
|
|
48
|
+
{ name: 'Ruby', hex: '#ea2261', rgb: [234, 34, 97] },
|
|
49
|
+
{ name: 'Vermillion', hex: '#e34234', rgb: [227, 66, 52] },
|
|
50
|
+
{ name: 'Cherry', hex: '#de3163', rgb: [222, 49, 99] },
|
|
51
|
+
{ name: 'Coral', hex: '#ff7f50', rgb: [255, 127, 80] },
|
|
52
|
+
{ name: 'Rose', hex: '#ff007f', rgb: [255, 0, 127] },
|
|
53
|
+
{ name: 'Warm Rose', hex: '#ff385c', rgb: [255, 56, 92] },
|
|
54
|
+
{ name: 'Bright Orange', hex: '#f54e00', rgb: [245, 78, 0] },
|
|
55
|
+
{ name: 'Tangerine', hex: '#ff9966', rgb: [255, 153, 102] },
|
|
56
|
+
{ name: 'Amber', hex: '#ffbf00', rgb: [255, 191, 0] },
|
|
57
|
+
{ name: 'Gold', hex: '#ffd700', rgb: [255, 215, 0] },
|
|
58
|
+
{ name: 'Honey', hex: '#c08532', rgb: [192, 133, 50] },
|
|
59
|
+
{ name: 'Emerald', hex: '#10b981', rgb: [16, 185, 129] },
|
|
60
|
+
{ name: 'Forest Green', hex: '#228b22', rgb: [34, 139, 34] },
|
|
61
|
+
{ name: 'Sage', hex: '#9fbbe0', rgb: [159, 187, 224] },
|
|
62
|
+
{ name: 'Mint', hex: '#98fb98', rgb: [152, 251, 152] },
|
|
63
|
+
{ name: 'Teal', hex: '#1f8a65', rgb: [31, 138, 101] },
|
|
64
|
+
{ name: 'Success Green', hex: '#27a644', rgb: [39, 166, 68] },
|
|
65
|
+
{ name: 'Magenta', hex: '#f96bee', rgb: [249, 107, 238] },
|
|
66
|
+
{ name: 'Hot Pink', hex: '#ff69b4', rgb: [255, 105, 180] },
|
|
67
|
+
{ name: 'Pink', hex: '#ffc0cb', rgb: [255, 192, 203] },
|
|
68
|
+
{ name: 'Soft Pink', hex: '#ffd7ef', rgb: [255, 215, 239] },
|
|
69
|
+
{ name: 'Warm Brown', hex: '#26251e', rgb: [38, 37, 30] },
|
|
70
|
+
{ name: 'Chocolate', hex: '#7b3f00', rgb: [123, 63, 0] },
|
|
71
|
+
{ name: 'Beige', hex: '#f5f5dc', rgb: [245, 245, 220] },
|
|
72
|
+
{ name: 'Parchment', hex: '#f7f4ed', rgb: [247, 244, 237] },
|
|
73
|
+
// Brand-specific extensions (2026-05-25, sprint v3)
|
|
74
|
+
{ name: 'Cream', hex: '#fcfbf8', rgb: [252, 251, 248] }, // Lovable / Anthropic warm canvas
|
|
75
|
+
{ name: 'Off-Cream', hex: '#fafaf7', rgb: [250, 250, 247] },
|
|
76
|
+
{ name: 'Slate', hex: '#475569', rgb: [71, 85, 105] }, // Tailwind slate-600
|
|
77
|
+
{ name: 'Slate Gray', hex: '#64748b', rgb: [100, 116, 139] },
|
|
78
|
+
{ name: 'Cool Gray', hex: '#9ca3af', rgb: [156, 163, 175] },
|
|
79
|
+
{ name: 'Stone', hex: '#78716c', rgb: [120, 113, 108] }, // Tailwind stone-500
|
|
80
|
+
{ name: 'Terracotta', hex: '#c96442', rgb: [201, 100, 66] }, // Anthropic Claude brand
|
|
81
|
+
{ name: 'Warm Terracotta', hex: '#d97757', rgb: [217, 119, 87] }, // Claude alt
|
|
82
|
+
{ name: 'Ring Blue', hex: '#3b82f6', rgb: [59, 130, 246] }, // Tailwind focus ring (Lovable, many SaaS)
|
|
83
|
+
{ name: 'Sky Bright', hex: '#0ea5e9', rgb: [14, 165, 233] },
|
|
84
|
+
{ name: 'Brand Indigo', hex: '#6366f1', rgb: [99, 102, 241] }, // Tailwind indigo-500
|
|
85
|
+
{ name: 'M Tricolor Red', hex: '#e22718', rgb: [226, 39, 24] }, // BMW M
|
|
86
|
+
{ name: 'M Tricolor Blue', hex: '#1c69d4', rgb: [28, 105, 212] },
|
|
87
|
+
{ name: 'Vibrant Yellow', hex: '#fff8c2', rgb: [255, 248, 194] }, // Mistral cards
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Color distance threshold above which we fallback to hex (no name).
|
|
92
|
+
* RGB Euclidean distance — calibrated so that bright distinctive colors
|
|
93
|
+
* far from any named entry get a fair `#hex` label instead of a misleading match.
|
|
94
|
+
* Default: 60 (allows ~30% per-channel variation from nearest).
|
|
95
|
+
*/
|
|
96
|
+
const COLOR_NAME_DISTANCE_THRESHOLD = 60;
|
|
97
|
+
|
|
98
|
+
function rgbToHex(r: number, g: number, b: number): string {
|
|
99
|
+
return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface NamedColorResult {
|
|
103
|
+
/** Semantic color name (e.g. "Indigo", "Transparent", "Translucent White (2%)"). */
|
|
104
|
+
name: string;
|
|
105
|
+
/** Solid hex equivalent ("#000000" for transparent fallback, real hex otherwise). Use for palette indexing. */
|
|
106
|
+
hex: string;
|
|
107
|
+
/** The value to render in user-facing output. 'transparent' if alpha=0, 'rgba(...)' if 0<alpha<1, '#hex' if alpha=1. */
|
|
108
|
+
displayValue: string;
|
|
109
|
+
/** Alpha channel 0..1. Undefined if input had no alpha (treated as 1). */
|
|
110
|
+
alpha?: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Parse hex color string (#RGB, #RRGGBB, #RRGGBBAA) → [r,g,b,a]. Returns null if invalid. */
|
|
114
|
+
function parseHex(color: string): { rgb: [number, number, number]; alpha: number } | null {
|
|
115
|
+
const trimmed = color.trim();
|
|
116
|
+
// Match #RGB, #RGBA, #RRGGBB, #RRGGBBAA
|
|
117
|
+
const m = trimmed.match(/^#([0-9a-fA-F]{3,8})$/);
|
|
118
|
+
if (!m) return null;
|
|
119
|
+
const hex = m[1];
|
|
120
|
+
let r: number, g: number, b: number, a: number;
|
|
121
|
+
if (hex.length === 3) {
|
|
122
|
+
r = parseInt(hex[0] + hex[0], 16); g = parseInt(hex[1] + hex[1], 16); b = parseInt(hex[2] + hex[2], 16); a = 1;
|
|
123
|
+
} else if (hex.length === 4) {
|
|
124
|
+
r = parseInt(hex[0] + hex[0], 16); g = parseInt(hex[1] + hex[1], 16); b = parseInt(hex[2] + hex[2], 16);
|
|
125
|
+
a = parseInt(hex[3] + hex[3], 16) / 255;
|
|
126
|
+
} else if (hex.length === 6) {
|
|
127
|
+
r = parseInt(hex.slice(0, 2), 16); g = parseInt(hex.slice(2, 4), 16); b = parseInt(hex.slice(4, 6), 16); a = 1;
|
|
128
|
+
} else if (hex.length === 8) {
|
|
129
|
+
r = parseInt(hex.slice(0, 2), 16); g = parseInt(hex.slice(2, 4), 16); b = parseInt(hex.slice(4, 6), 16);
|
|
130
|
+
a = parseInt(hex.slice(6, 8), 16) / 255;
|
|
131
|
+
} else {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return { rgb: [r, g, b], alpha: a };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Resolve a color's semantic name from CSS custom properties matching the same value.
|
|
139
|
+
* If a CSS var (e.g. `--brand-primary: #533afd`) matches the input RGB, returns a human
|
|
140
|
+
* name derived from the var key ("Brand Primary"). Returns null if no semantic match.
|
|
141
|
+
*
|
|
142
|
+
* Priority order (highest → lowest):
|
|
143
|
+
* 1. Simple single-word brand var (--palette-rausch, --color-luxe, --brand-primary as single)
|
|
144
|
+
* 2. Single semantic role (--error, --success, --primary)
|
|
145
|
+
* 3. Vendor-prefixed canonical role (--hds-color-accent-border-solid → "Accent")
|
|
146
|
+
* 4. Two-segment brand+role (--brand-primary, --primary-default)
|
|
147
|
+
* 5. Composed palette tokens (--palette-bg-primary-core → "Primary")
|
|
148
|
+
*
|
|
149
|
+
* Decorative/derivative names (containing gradient/stop/subtle/quiet/hover/disabled)
|
|
150
|
+
* are de-prioritized — we prefer canonical names over variants.
|
|
151
|
+
*/
|
|
152
|
+
export function resolveSemanticBrandName(
|
|
153
|
+
rgb: [number, number, number],
|
|
154
|
+
cssVars: Record<string, string> | undefined
|
|
155
|
+
): string | null {
|
|
156
|
+
if (!cssVars || Object.keys(cssVars).length === 0) return null;
|
|
157
|
+
|
|
158
|
+
const targetHex = rgbToHex(rgb[0], rgb[1], rgb[2]).toLowerCase();
|
|
159
|
+
|
|
160
|
+
// Tokens that suggest a derivative/variant rather than canonical brand name
|
|
161
|
+
const DERIVATIVE_TOKENS = /(?:^|-)(gradient|stop|color-stop|subtle|subdued|quiet|hover|active|focus|disabled|pressed|selected|onSubdued|alternate|alt|inverse|tint|shade|opacity|alpha|translucent|core-rtl|rtl)(?:-|$)/i;
|
|
162
|
+
|
|
163
|
+
const SEMANTIC_PATTERNS: Array<{
|
|
164
|
+
regex: RegExp;
|
|
165
|
+
transform: (m: RegExpMatchArray) => string;
|
|
166
|
+
priority: number;
|
|
167
|
+
}> = [
|
|
168
|
+
// --palette-WORD or --color-WORD (single brand word: Rausch, Luxe, Hof, Babu)
|
|
169
|
+
{ regex: /^--(?:palette|color)-([a-zA-Z]{3,})$/, transform: m => titleCase(m[1]), priority: 100 },
|
|
170
|
+
// Single semantic role: --error, --warning, --success, --info, --danger, --primary
|
|
171
|
+
{ regex: /^--(error|warning|success|info|danger|alert|primary|secondary|tertiary|brand|accent)$/i,
|
|
172
|
+
transform: m => titleCase(m[1]), priority: 95 },
|
|
173
|
+
// --brand-WORD (single descriptor: --brand-primary, --brand-blue)
|
|
174
|
+
{ regex: /^--brand-([a-zA-Z]{3,})$/, transform: m => `${titleCase('Brand')} ${titleCase(m[1])}`, priority: 90 },
|
|
175
|
+
// Vendor-prefixed canonical role: --hds-color-accent[-X], --tw-color-primary, etc.
|
|
176
|
+
// Returns just the role name (e.g. "Accent") — clean and reusable.
|
|
177
|
+
{ regex: /^--(?:hds|tw|ds|ui|next|app|nx|nui|ant|aria|mui)-color-(accent|brand|primary|surface|text|border|action|link|secondary|tertiary)(?:-[a-zA-Z0-9-]+)*$/i,
|
|
178
|
+
transform: m => titleCase(m[1]), priority: 80 },
|
|
179
|
+
// --(brand|primary|accent|secondary)-WORD (single-segment descriptor only, no hyphens in group 2)
|
|
180
|
+
{ regex: /^--(brand|primary|accent|secondary|tertiary|action|link)-([a-zA-Z]{2,})$/i,
|
|
181
|
+
transform: m => `${titleCase(m[1])} ${titleCase(m[2])}`, priority: 70 },
|
|
182
|
+
// --color-(role)-WORD (single-segment descriptor)
|
|
183
|
+
{ regex: /^--color-(text|bg|background|surface|border|fill|stroke|accent|brand|action|link)-([a-zA-Z]{2,})$/i,
|
|
184
|
+
transform: m => `${titleCase(m[1])} ${titleCase(m[2])}`, priority: 60 },
|
|
185
|
+
// --palette-(role)-WORD-core
|
|
186
|
+
{ regex: /^--palette-(?:bg|surface|text|border|action|fg|fill)-([a-zA-Z]+)-?(?:core|main|primary|default)?$/i,
|
|
187
|
+
transform: m => titleCase(m[1]), priority: 50 },
|
|
188
|
+
// --palette-WORD-core
|
|
189
|
+
{ regex: /^--palette-([a-zA-Z]+)-(?:core|main|primary|default)$/i,
|
|
190
|
+
transform: m => titleCase(m[1]), priority: 45 },
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
let bestMatch: { name: string; priority: number; keyLength: number; isDerivative: boolean } | null = null;
|
|
194
|
+
|
|
195
|
+
for (const [key, value] of Object.entries(cssVars)) {
|
|
196
|
+
if (!value || typeof value !== 'string') continue;
|
|
197
|
+
|
|
198
|
+
// Try to parse the value as a color (hex or rgb/rgba)
|
|
199
|
+
let varRgb: [number, number, number] | null = null;
|
|
200
|
+
const hexParsed = parseHex(value.trim());
|
|
201
|
+
if (hexParsed && hexParsed.alpha === 1) {
|
|
202
|
+
varRgb = hexParsed.rgb;
|
|
203
|
+
} else {
|
|
204
|
+
varRgb = parseRgb(value);
|
|
205
|
+
}
|
|
206
|
+
if (!varRgb) continue;
|
|
207
|
+
|
|
208
|
+
// Exact hex match required (no fuzzy matching on semantic names)
|
|
209
|
+
const varHex = rgbToHex(varRgb[0], varRgb[1], varRgb[2]).toLowerCase();
|
|
210
|
+
if (varHex !== targetHex) continue;
|
|
211
|
+
|
|
212
|
+
const isDerivative = DERIVATIVE_TOKENS.test(key);
|
|
213
|
+
|
|
214
|
+
// Try patterns in priority order, first match wins per key
|
|
215
|
+
for (const pattern of SEMANTIC_PATTERNS) {
|
|
216
|
+
const match = key.match(pattern.regex);
|
|
217
|
+
if (match) {
|
|
218
|
+
const name = pattern.transform(match);
|
|
219
|
+
// Selection logic (in order of precedence):
|
|
220
|
+
// 1. Non-derivative beats derivative (always prefer canonical "Accent" over "Accent Hover")
|
|
221
|
+
// 2. Higher priority beats lower
|
|
222
|
+
// 3. On tie: shorter key (more canonical)
|
|
223
|
+
const beatsCurrentBest = !bestMatch
|
|
224
|
+
|| (!isDerivative && bestMatch.isDerivative)
|
|
225
|
+
|| (isDerivative === bestMatch.isDerivative && pattern.priority > bestMatch.priority)
|
|
226
|
+
|| (isDerivative === bestMatch.isDerivative && pattern.priority === bestMatch.priority && key.length < bestMatch.keyLength);
|
|
227
|
+
|
|
228
|
+
if (beatsCurrentBest) {
|
|
229
|
+
bestMatch = { name, priority: pattern.priority, keyLength: key.length, isDerivative };
|
|
230
|
+
}
|
|
231
|
+
break; // first pattern wins per key
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return bestMatch?.name || null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function titleCase(s: string): string {
|
|
240
|
+
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function titleCaseHyphenated(s: string): string {
|
|
244
|
+
return s.split(/[-_]/).filter(Boolean).map(titleCase).join(' ');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Name a color using brand-aware semantic lookup, falling back to nearest-neighbor.
|
|
249
|
+
*
|
|
250
|
+
* @param color - CSS color string (hex, rgb, rgba, "transparent")
|
|
251
|
+
* @param cssVars - Optional CSS custom properties from the same site. If provided,
|
|
252
|
+
* any var whose resolved color equals `color` will produce a semantic name
|
|
253
|
+
* (e.g. "Rausch" for --palette-rausch) instead of nearest-neighbor ("Warm Rose").
|
|
254
|
+
*/
|
|
255
|
+
export function nameColor(color: string, cssVars?: Record<string, string>): NamedColorResult {
|
|
256
|
+
if (!color) return { name: 'Unknown', hex: '#000000', displayValue: '#000000' };
|
|
257
|
+
|
|
258
|
+
const trimmed = color.trim().toLowerCase();
|
|
259
|
+
if (trimmed === 'transparent' || trimmed === 'rgba(0,0,0,0)' || trimmed === 'rgba(0, 0, 0, 0)') {
|
|
260
|
+
return { name: 'Transparent', hex: 'transparent', displayValue: 'transparent', alpha: 0 };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Detect hex input first (parseRgb only handles rgb/rgba strings)
|
|
264
|
+
let rgb: [number, number, number] | null = null;
|
|
265
|
+
let alpha = 1;
|
|
266
|
+
const hexParsed = parseHex(color);
|
|
267
|
+
if (hexParsed) {
|
|
268
|
+
rgb = hexParsed.rgb;
|
|
269
|
+
alpha = hexParsed.alpha;
|
|
270
|
+
} else {
|
|
271
|
+
const rgbaMatch = color.match(/rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\s*\)/);
|
|
272
|
+
alpha = rgbaMatch && rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1;
|
|
273
|
+
rgb = parseRgb(color);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (alpha === 0) {
|
|
277
|
+
return { name: 'Transparent', hex: 'transparent', displayValue: 'transparent', alpha: 0 };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!rgb) return { name: 'Unknown', hex: '#000000', displayValue: '#000000' };
|
|
281
|
+
|
|
282
|
+
const hex = rgbToHex(rgb[0], rgb[1], rgb[2]);
|
|
283
|
+
|
|
284
|
+
// Brand-aware semantic naming: if cssVars provided, try to match a brand/semantic CSS var
|
|
285
|
+
// before falling back to nearest-neighbor. Only applies to fully opaque colors.
|
|
286
|
+
if (cssVars && alpha === 1) {
|
|
287
|
+
const semanticName = resolveSemanticBrandName(rgb, cssVars);
|
|
288
|
+
if (semanticName) {
|
|
289
|
+
return { name: semanticName, hex, displayValue: hex };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let bestName = 'Unknown';
|
|
294
|
+
let bestDist = Infinity;
|
|
295
|
+
|
|
296
|
+
for (const nc of NAMED_COLORS) {
|
|
297
|
+
const dist = colorDistance(rgb, nc.rgb);
|
|
298
|
+
if (dist < bestDist) {
|
|
299
|
+
bestDist = dist;
|
|
300
|
+
bestName = nc.name;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// If nearest named color is too far, fall back to the hex value as name.
|
|
305
|
+
// Prevents misleading labels like "Indigo" for a totally different bright color.
|
|
306
|
+
if (bestDist > COLOR_NAME_DISTANCE_THRESHOLD) {
|
|
307
|
+
bestName = hex;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (alpha < 1) {
|
|
311
|
+
const opacityPct = Math.round(alpha * 100);
|
|
312
|
+
// For translucent colors, attempt semantic match on the opaque equivalent
|
|
313
|
+
if (cssVars) {
|
|
314
|
+
const opaqueSemanticName = resolveSemanticBrandName(rgb, cssVars);
|
|
315
|
+
if (opaqueSemanticName) {
|
|
316
|
+
return {
|
|
317
|
+
name: `Translucent ${opaqueSemanticName} (${opacityPct}%)`,
|
|
318
|
+
hex,
|
|
319
|
+
displayValue: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`,
|
|
320
|
+
alpha,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
name: `Translucent ${bestName} (${opacityPct}%)`,
|
|
326
|
+
hex,
|
|
327
|
+
displayValue: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`,
|
|
328
|
+
alpha,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return { name: bestName, hex, displayValue: hex };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Factory pour un nameColorUnique stateful (chaque générateur utilise sa propre map).
|
|
337
|
+
*
|
|
338
|
+
* @param cssVars - Optional default CSS custom properties for brand-aware semantic naming.
|
|
339
|
+
* Each call to nameColorUnique can also pass its own cssVars to override.
|
|
340
|
+
*/
|
|
341
|
+
export function createColorNamer(defaultCssVars?: Record<string, string>) {
|
|
342
|
+
const usedColorNames = new Map<string, number>();
|
|
343
|
+
return {
|
|
344
|
+
nameColorUnique(color: string, cssVars?: Record<string, string>): NamedColorResult {
|
|
345
|
+
const result = nameColor(color, cssVars ?? defaultCssVars);
|
|
346
|
+
const count = usedColorNames.get(result.name) || 0;
|
|
347
|
+
usedColorNames.set(result.name, count + 1);
|
|
348
|
+
if (count > 0) {
|
|
349
|
+
result.name = `${result.name} (${result.hex})`;
|
|
350
|
+
}
|
|
351
|
+
return result;
|
|
352
|
+
},
|
|
353
|
+
reset() { usedColorNames.clear(); },
|
|
354
|
+
};
|
|
355
|
+
}
|