@1agh/maude 0.24.0 → 0.26.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/cli/commands/design.mjs +5 -0
- package/cli/lib/design-link.mjs +13 -6
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/gitignore-block.test.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +30 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +481 -78
- package/plugins/design/dev-server/annotations-layer.tsx +817 -170
- package/plugins/design/dev-server/api.ts +15 -1
- package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
- package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
- package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
- package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_svg-optimize.mjs +35 -0
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
- package/plugins/design/dev-server/bin/draw-build.sh +48 -0
- package/plugins/design/dev-server/bin/draw-proof.sh +129 -0
- package/plugins/design/dev-server/bin/smoke.sh +114 -12
- package/plugins/design/dev-server/bin/svg-optimize.sh +24 -0
- package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
- package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
- package/plugins/design/dev-server/canvas-cursors.ts +130 -82
- package/plugins/design/dev-server/canvas-icons.tsx +169 -0
- package/plugins/design/dev-server/canvas-lib.tsx +110 -0
- package/plugins/design/dev-server/canvas-shell.tsx +113 -89
- package/plugins/design/dev-server/client/app.jsx +1084 -417
- package/plugins/design/dev-server/config.schema.json +10 -0
- package/plugins/design/dev-server/context.ts +9 -0
- package/plugins/design/dev-server/dist/client.bundle.js +242 -20
- package/plugins/design/dev-server/dist/comment-mount.js +40 -62
- package/plugins/design/dev-server/draw/brush.ts +639 -0
- package/plugins/design/dev-server/draw/composition.ts +229 -0
- package/plugins/design/dev-server/draw/geometry.ts +578 -0
- package/plugins/design/dev-server/draw/index.ts +28 -0
- package/plugins/design/dev-server/draw/layout.ts +260 -0
- package/plugins/design/dev-server/draw/optimize.ts +65 -0
- package/plugins/design/dev-server/draw/palette.ts +417 -0
- package/plugins/design/dev-server/draw/primitives.ts +643 -0
- package/plugins/design/dev-server/draw/serialize.ts +458 -0
- package/plugins/design/dev-server/draw/test/brush.test.ts +213 -0
- package/plugins/design/dev-server/draw/test/composition.test.ts +141 -0
- package/plugins/design/dev-server/draw/test/geometry.test.ts +199 -0
- package/plugins/design/dev-server/draw/test/gradient.test.ts +167 -0
- package/plugins/design/dev-server/draw/test/layout.test.ts +120 -0
- package/plugins/design/dev-server/draw/test/optimize.test.ts +40 -0
- package/plugins/design/dev-server/draw/test/palette.test.ts +123 -0
- package/plugins/design/dev-server/draw/test/primitives.test.ts +129 -0
- package/plugins/design/dev-server/draw/test/serialize.test.ts +105 -0
- package/plugins/design/dev-server/export-dialog.tsx +189 -1
- package/plugins/design/dev-server/exporters/html.ts +4 -1
- package/plugins/design/dev-server/exporters/index.ts +4 -1
- package/plugins/design/dev-server/exporters/pdf.ts +7 -1
- package/plugins/design/dev-server/exporters/png.ts +21 -2
- package/plugins/design/dev-server/exporters/pptx.ts +330 -201
- package/plugins/design/dev-server/exporters/scope.ts +107 -11
- package/plugins/design/dev-server/exporters/svg.ts +4 -1
- package/plugins/design/dev-server/http.ts +40 -9
- package/plugins/design/dev-server/input-router.tsx +9 -2
- package/plugins/design/dev-server/sync/index.ts +73 -17
- package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +83 -3
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +243 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +95 -6
- package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
- package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
- package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
- package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
- package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
- package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +11 -4
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +32 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +52 -0
- package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +9 -11
- package/plugins/design/dev-server/tool-palette.tsx +140 -11
- package/plugins/design/dev-server/tsconfig.json +8 -1
- package/plugins/design/dev-server/use-annotation-resize.tsx +208 -61
- package/plugins/design/dev-server/use-tool-mode.tsx +55 -9
- package/plugins/design/templates/_shell.html +36 -9
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +15 -0
- package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +1 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file draw/palette.ts — Phase 25 geometry-engine color layer
|
|
3
|
+
* @scope plugins/design/dev-server/draw/palette.ts
|
|
4
|
+
* @purpose Color correctness the draw-agent verifies from SOURCE, never from
|
|
5
|
+
* the vision model (VLMs misread color confidently):
|
|
6
|
+
* • WCAG 2.1 relative-luminance + contrast ratio (4.5 / 3 / 7);
|
|
7
|
+
* • OKLCH → sRGB conversion (Björn Ottosson constants) so ramps
|
|
8
|
+
* are generated in a perceptually uniform space;
|
|
9
|
+
* • evenly-spaced lightness ramps (rubric check 16);
|
|
10
|
+
* • 60-30-10 area-distribution check (accent ≤ ~15%).
|
|
11
|
+
* Pure + deterministic; React-free (DDR-067).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const CURRENT_COLOR = 'currentColor';
|
|
15
|
+
|
|
16
|
+
export interface Rgb {
|
|
17
|
+
/** 0–255 */
|
|
18
|
+
r: number;
|
|
19
|
+
g: number;
|
|
20
|
+
b: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Oklch {
|
|
24
|
+
/** Lightness 0–1. */
|
|
25
|
+
l: number;
|
|
26
|
+
/** Chroma (≈0–0.4). */
|
|
27
|
+
c: number;
|
|
28
|
+
/** Hue in degrees. */
|
|
29
|
+
h: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
// Parsing
|
|
34
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse a hex (`#rgb`, `#rrggbb`, `#rrggbbaa`) or `rgb()/rgba()` string to
|
|
38
|
+
* 0–255 channels. Throws on anything else (use {@link oklchToRgb} for OKLCH).
|
|
39
|
+
*/
|
|
40
|
+
export function parseColor(input: string): Rgb {
|
|
41
|
+
const s = input.trim();
|
|
42
|
+
if (s.startsWith('#')) {
|
|
43
|
+
let hex = s.slice(1);
|
|
44
|
+
if (hex.length === 3 || hex.length === 4) {
|
|
45
|
+
hex = hex
|
|
46
|
+
.split('')
|
|
47
|
+
.map((ch) => ch + ch)
|
|
48
|
+
.join('');
|
|
49
|
+
}
|
|
50
|
+
if (hex.length !== 6 && hex.length !== 8) throw new Error(`parseColor: bad hex "${input}"`);
|
|
51
|
+
const r = Number.parseInt(hex.slice(0, 2), 16);
|
|
52
|
+
const g = Number.parseInt(hex.slice(2, 4), 16);
|
|
53
|
+
const b = Number.parseInt(hex.slice(4, 6), 16);
|
|
54
|
+
if ([r, g, b].some((n) => Number.isNaN(n))) throw new Error(`parseColor: bad hex "${input}"`);
|
|
55
|
+
return { r, g, b };
|
|
56
|
+
}
|
|
57
|
+
const m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(s);
|
|
58
|
+
if (m) return { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]) };
|
|
59
|
+
throw new Error(`parseColor: unsupported color "${input}" (hex or rgb() only)`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Parse an `oklch(L C H)` string (L as `%` or 0–1) into an {@link Oklch}. */
|
|
63
|
+
export function parseOklch(input: string): Oklch {
|
|
64
|
+
const m = /^oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)/i.exec(input.trim());
|
|
65
|
+
if (!m) throw new Error(`parseOklch: bad oklch "${input}"`);
|
|
66
|
+
const lraw = m[1];
|
|
67
|
+
const l = lraw.endsWith('%') ? Number(lraw.slice(0, -1)) / 100 : Number(lraw);
|
|
68
|
+
return { l, c: Number(m[2]), h: Number(m[3]) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function toHex({ r, g, b }: Rgb): string {
|
|
72
|
+
const h = (n: number) =>
|
|
73
|
+
Math.max(0, Math.min(255, Math.round(n)))
|
|
74
|
+
.toString(16)
|
|
75
|
+
.padStart(2, '0');
|
|
76
|
+
return `#${h(r)}${h(g)}${h(b)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
// WCAG contrast
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
function srgbToLinear(c8: number): number {
|
|
84
|
+
const c = c8 / 255;
|
|
85
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** WCAG 2.1 relative luminance (0 = black, 1 = white). */
|
|
89
|
+
export function relativeLuminance(color: Rgb | string): number {
|
|
90
|
+
const { r, g, b } = typeof color === 'string' ? parseColor(color) : color;
|
|
91
|
+
return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** WCAG contrast ratio between two colors (1–21). Order-independent. */
|
|
95
|
+
export function contrastRatio(a: Rgb | string, b: Rgb | string): number {
|
|
96
|
+
const la = relativeLuminance(a);
|
|
97
|
+
const lb = relativeLuminance(b);
|
|
98
|
+
const hi = Math.max(la, lb);
|
|
99
|
+
const lo = Math.min(la, lb);
|
|
100
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type WcagLevel = 'AA' | 'AAA';
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Does `ratio` clear the WCAG bar? `large` = ≥18.66px bold / ≥24px regular
|
|
107
|
+
* (3:1 at AA). `nonText` = UI components / graphical objects (always 3:1).
|
|
108
|
+
*/
|
|
109
|
+
export function meetsWcag(
|
|
110
|
+
ratio: number,
|
|
111
|
+
opts: { level?: WcagLevel; large?: boolean; nonText?: boolean } = {}
|
|
112
|
+
): boolean {
|
|
113
|
+
const { level = 'AA', large = false, nonText = false } = opts;
|
|
114
|
+
if (nonText) return ratio >= 3;
|
|
115
|
+
if (level === 'AAA') return ratio >= (large ? 4.5 : 7);
|
|
116
|
+
return ratio >= (large ? 3 : 4.5);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
120
|
+
// OKLCH → sRGB (Björn Ottosson)
|
|
121
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
function linearToSrgb8(c: number): number {
|
|
124
|
+
const v = c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
|
|
125
|
+
return Math.max(0, Math.min(255, Math.round(v * 255)));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Convert OKLCH (L 0–1, C, H°) to clamped sRGB 0–255. */
|
|
129
|
+
export function oklchToRgb({ l, c, h }: Oklch): Rgb {
|
|
130
|
+
const hr = (h * Math.PI) / 180;
|
|
131
|
+
const a = c * Math.cos(hr);
|
|
132
|
+
const b = c * Math.sin(hr);
|
|
133
|
+
|
|
134
|
+
const l_ = l + 0.3963377774 * a + 0.2158037573 * b;
|
|
135
|
+
const m_ = l - 0.1055613458 * a - 0.0638541728 * b;
|
|
136
|
+
const s_ = l - 0.0894841775 * a - 1.291485548 * b;
|
|
137
|
+
|
|
138
|
+
const lc = l_ * l_ * l_;
|
|
139
|
+
const mc = m_ * m_ * m_;
|
|
140
|
+
const sc = s_ * s_ * s_;
|
|
141
|
+
|
|
142
|
+
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
|
|
143
|
+
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
|
|
144
|
+
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.707614701 * sc;
|
|
145
|
+
|
|
146
|
+
return { r: linearToSrgb8(rLin), g: linearToSrgb8(gLin), b: linearToSrgb8(bLin) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function oklchToHex(o: Oklch): string {
|
|
150
|
+
return toHex(oklchToRgb(o));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
154
|
+
// Ramp generation
|
|
155
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
export interface RampOpts {
|
|
158
|
+
hue: number;
|
|
159
|
+
chroma: number;
|
|
160
|
+
/** Number of tiers (default 5). */
|
|
161
|
+
count?: number;
|
|
162
|
+
/** Lightest / darkest tier lightness (default 0.95 / 0.2). */
|
|
163
|
+
lMax?: number;
|
|
164
|
+
lMin?: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* An OKLCH tonal ramp with evenly spaced lightness — perceptually uniform by
|
|
169
|
+
* construction (rubric check 16). Each tier holds chroma + hue constant; only
|
|
170
|
+
* lightness steps, lightest first. Returns OKLCH tiers (convert with
|
|
171
|
+
* {@link oklchToHex} as needed).
|
|
172
|
+
*/
|
|
173
|
+
export function oklchRamp(opts: RampOpts): Oklch[] {
|
|
174
|
+
const { hue, chroma, count = 5, lMax = 0.95, lMin = 0.2 } = opts;
|
|
175
|
+
if (count < 1) return [];
|
|
176
|
+
if (count === 1) return [{ l: (lMax + lMin) / 2, c: chroma, h: hue }];
|
|
177
|
+
const step = (lMax - lMin) / (count - 1);
|
|
178
|
+
const out: Oklch[] = [];
|
|
179
|
+
for (let i = 0; i < count; i++) {
|
|
180
|
+
out.push({ l: lMax - step * i, c: chroma, h: hue });
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Are the lightness values of a ramp evenly spaced (within `tol`)? The
|
|
187
|
+
* machine-checkable form of "perceptually even ramp". Needs ≥3 tiers.
|
|
188
|
+
*/
|
|
189
|
+
export function isPerceptuallyEven(ramp: Oklch[], tol = 0.01): boolean {
|
|
190
|
+
if (ramp.length < 3) return true;
|
|
191
|
+
const deltas: number[] = [];
|
|
192
|
+
for (let i = 1; i < ramp.length; i++) deltas.push(ramp[i].l - ramp[i - 1].l);
|
|
193
|
+
const mean = deltas.reduce((a, b) => a + b, 0) / deltas.length;
|
|
194
|
+
return deltas.every((d) => Math.abs(d - mean) <= tol);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
198
|
+
// 60-30-10 distribution
|
|
199
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
export interface AreaShare {
|
|
202
|
+
role: string;
|
|
203
|
+
area: number;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface DistributionResult {
|
|
207
|
+
byRole: Record<string, number>;
|
|
208
|
+
accentRatio: number;
|
|
209
|
+
dominantRole: string | null;
|
|
210
|
+
/** True when an accent role exists and stays ≤ 15% of total area. */
|
|
211
|
+
ok: boolean;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Check a composition's color area distribution against the 60-30-10 guideline.
|
|
216
|
+
* Roles whose name contains `accent` are summed as the accent budget; the check
|
|
217
|
+
* passes when that budget is ≤ 15% of the total painted area (rubric check 14).
|
|
218
|
+
*/
|
|
219
|
+
export function colorDistribution(shares: AreaShare[]): DistributionResult {
|
|
220
|
+
const total = shares.reduce((a, s) => a + Math.max(0, s.area), 0);
|
|
221
|
+
const byRole: Record<string, number> = {};
|
|
222
|
+
let accent = 0;
|
|
223
|
+
let dominantRole: string | null = null;
|
|
224
|
+
let dominantArea = -1;
|
|
225
|
+
for (const s of shares) {
|
|
226
|
+
const ratio = total > 0 ? s.area / total : 0;
|
|
227
|
+
byRole[s.role] = (byRole[s.role] ?? 0) + ratio;
|
|
228
|
+
if (/accent/i.test(s.role)) accent += ratio;
|
|
229
|
+
if (s.area > dominantArea) {
|
|
230
|
+
dominantArea = s.area;
|
|
231
|
+
dominantRole = s.role;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { byRole, accentRatio: accent, dominantRole, ok: accent > 0 && accent <= 0.15 };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
238
|
+
// Color harmony — Cohen-Or et al. eight hue-wheel templates (SIGGRAPH 2006)
|
|
239
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
/** One wedge of a harmonic template: a sector centered at `center`° (relative to
|
|
242
|
+
* the template's rotation) spanning `arc`° of the hue wheel. */
|
|
243
|
+
export interface HarmonyWedge {
|
|
244
|
+
center: number;
|
|
245
|
+
arc: number;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The eight Cohen-Or harmonic templates (`i V L I T Y X` + achromatic `N`),
|
|
250
|
+
* expressed as hue-wheel wedges relative to a rotation. These encode the classic
|
|
251
|
+
* schemes: `i` = monochromatic, `I` = complementary, `V`/`Y` = analogous /
|
|
252
|
+
* split, `X` = double-complementary, `T` = a 180° span. Used BOTH to generate a
|
|
253
|
+
* harmonious palette (snap hues into the wedges) AND to score harmony (distance
|
|
254
|
+
* of a palette's hues to the nearest template).
|
|
255
|
+
*/
|
|
256
|
+
export const HARMONY_TEMPLATES: Record<string, HarmonyWedge[]> = {
|
|
257
|
+
i: [{ center: 0, arc: 18 }],
|
|
258
|
+
V: [{ center: 0, arc: 93.6 }],
|
|
259
|
+
L: [
|
|
260
|
+
{ center: 0, arc: 18 },
|
|
261
|
+
{ center: 90, arc: 79.2 },
|
|
262
|
+
],
|
|
263
|
+
I: [
|
|
264
|
+
{ center: 0, arc: 18 },
|
|
265
|
+
{ center: 180, arc: 18 },
|
|
266
|
+
],
|
|
267
|
+
T: [{ center: 0, arc: 180 }],
|
|
268
|
+
Y: [
|
|
269
|
+
{ center: 0, arc: 93.6 },
|
|
270
|
+
{ center: 180, arc: 18 },
|
|
271
|
+
],
|
|
272
|
+
X: [
|
|
273
|
+
{ center: 0, arc: 93.6 },
|
|
274
|
+
{ center: 180, arc: 93.6 },
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/** Smallest absolute angular difference between two hues (degrees, 0–180). */
|
|
279
|
+
export function hueDelta(a: number, b: number): number {
|
|
280
|
+
const d = Math.abs(((a - b) % 360) + 360) % 360;
|
|
281
|
+
return d > 180 ? 360 - d : d;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Angular distance from hue `h` to the nearest edge of a wedge (0 if inside). */
|
|
285
|
+
function distToWedge(h: number, w: HarmonyWedge, rotation: number): number {
|
|
286
|
+
const d = hueDelta(h, w.center + rotation);
|
|
287
|
+
return Math.max(0, d - w.arc / 2);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function minDistToTemplate(h: number, tmpl: HarmonyWedge[], rotation: number): number {
|
|
291
|
+
let best = Number.POSITIVE_INFINITY;
|
|
292
|
+
for (const w of tmpl) best = Math.min(best, distToWedge(h, w, rotation));
|
|
293
|
+
return best;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Saturation-weighted distance of a hue set to a harmonic template at a given
|
|
298
|
+
* rotation — the Cohen-Or harmony metric. 0 = every hue inside a wedge
|
|
299
|
+
* (harmonious); larger = more disharmony. `sats` (0–1, default 1) weight vivid
|
|
300
|
+
* colors more (a gray hue barely matters).
|
|
301
|
+
*/
|
|
302
|
+
export function harmonyDistance(
|
|
303
|
+
hues: number[],
|
|
304
|
+
template: keyof typeof HARMONY_TEMPLATES,
|
|
305
|
+
rotation: number,
|
|
306
|
+
sats?: number[]
|
|
307
|
+
): number {
|
|
308
|
+
const tmpl = HARMONY_TEMPLATES[template];
|
|
309
|
+
let sum = 0;
|
|
310
|
+
for (let i = 0; i < hues.length; i++) {
|
|
311
|
+
const s = sats?.[i] ?? 1;
|
|
312
|
+
sum += s * minDistToTemplate(hues[i], tmpl, rotation);
|
|
313
|
+
}
|
|
314
|
+
return sum;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export interface HarmonyFit {
|
|
318
|
+
template: keyof typeof HARMONY_TEMPLATES;
|
|
319
|
+
rotation: number;
|
|
320
|
+
distance: number;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Find the best-fitting harmonic template + rotation for a hue set (brute search
|
|
325
|
+
* over templates × rotations at 2° steps). The returned `distance` is the
|
|
326
|
+
* discriminating critic metric: ≈0 = harmonious, large = "fighting colors".
|
|
327
|
+
*/
|
|
328
|
+
export function bestHarmony(hues: number[], sats?: number[]): HarmonyFit {
|
|
329
|
+
let best: HarmonyFit = { template: 'i', rotation: 0, distance: Number.POSITIVE_INFINITY };
|
|
330
|
+
for (const template of Object.keys(HARMONY_TEMPLATES) as (keyof typeof HARMONY_TEMPLATES)[]) {
|
|
331
|
+
for (let rotation = 0; rotation < 360; rotation += 2) {
|
|
332
|
+
const distance = harmonyDistance(hues, template, rotation, sats);
|
|
333
|
+
if (distance < best.distance) best = { template, rotation, distance };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return best;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Snap each hue into the nearest wedge of a template (rotated) — generation-side
|
|
341
|
+
* harmonization. Hues already inside a wedge are unchanged; outliers clamp to the
|
|
342
|
+
* nearest wedge edge. Returns the harmonized hues (use these to build the palette).
|
|
343
|
+
*/
|
|
344
|
+
export function harmonize(
|
|
345
|
+
hues: number[],
|
|
346
|
+
template: keyof typeof HARMONY_TEMPLATES,
|
|
347
|
+
rotation = 0
|
|
348
|
+
): number[] {
|
|
349
|
+
const tmpl = HARMONY_TEMPLATES[template];
|
|
350
|
+
return hues.map((h) => {
|
|
351
|
+
let best = h;
|
|
352
|
+
let bestD = Number.POSITIVE_INFINITY;
|
|
353
|
+
for (const w of tmpl) {
|
|
354
|
+
const lo = w.center + rotation - w.arc / 2;
|
|
355
|
+
const hi = w.center + rotation + w.arc / 2;
|
|
356
|
+
const inside = distToWedge(h, w, rotation) === 0;
|
|
357
|
+
if (inside) return ((h % 360) + 360) % 360;
|
|
358
|
+
// distance to each edge; clamp to the closer one
|
|
359
|
+
const dLo = hueDelta(h, lo);
|
|
360
|
+
const dHi = hueDelta(h, hi);
|
|
361
|
+
const edge = dLo < dHi ? lo : hi;
|
|
362
|
+
const d = Math.min(dLo, dHi);
|
|
363
|
+
if (d < bestD) {
|
|
364
|
+
bestD = d;
|
|
365
|
+
best = edge;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return ((best % 360) + 360) % 360;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
373
|
+
// Value / contrast — value does the work, color gets the credit
|
|
374
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* The WCAG-luminance value range (max − min) across a color set, 0–1. The
|
|
378
|
+
* "muddy / washed-out" detector: a composition whose colors differ in hue but
|
|
379
|
+
* cluster in a narrow value band reads as flat soup (exactly the failure that
|
|
380
|
+
* sank the pastel + the neon-overlap backgrounds). A premium composition needs a
|
|
381
|
+
* value range that actually spans (rule of thumb: ≥ ~0.35 for a scene with depth).
|
|
382
|
+
*/
|
|
383
|
+
export function valueRange(colors: Array<Rgb | string>): number {
|
|
384
|
+
if (colors.length === 0) return 0;
|
|
385
|
+
const ls = colors.map((c) => relativeLuminance(c));
|
|
386
|
+
return Math.max(...ls) - Math.min(...ls);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function apcaLum(color: Rgb | string): number {
|
|
390
|
+
const { r, g, b } = typeof color === 'string' ? parseColor(color) : color;
|
|
391
|
+
// APCA uses a straight 2.4 power (not the WCAG piecewise linearization).
|
|
392
|
+
const y = 0.2126 * (r / 255) ** 2.4 + 0.7152 * (g / 255) ** 2.4 + 0.0722 * (b / 255) ** 2.4;
|
|
393
|
+
// biome-ignore lint/suspicious/noApproximativeNumericConstant: APCA-0.98G blkClmp exponent (1.414) for the 0.022 black-soft-clamp — a defined spec constant, NOT √2.
|
|
394
|
+
return y < 0.022 ? y + (0.022 - y) ** 1.414 : y;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* APCA lightness contrast (Lc) between text and background — the perceptual
|
|
399
|
+
* contrast metric WCAG 3 is built on (value does the perceptual work). Returns
|
|
400
|
+
* |Lc| 0–~108. Thresholds (text): Lc 90 body-preferred, 75 body-min, 60 content,
|
|
401
|
+
* 45 large/heavy; Lc 15 is the floor for ANY visible element. Implements the
|
|
402
|
+
* APCA-0.98G constants; pin the version before treating a number as standard.
|
|
403
|
+
*/
|
|
404
|
+
export function apcaLc(text: Rgb | string, bg: Rgb | string): number {
|
|
405
|
+
const ytxt = apcaLum(text);
|
|
406
|
+
const ybg = apcaLum(bg);
|
|
407
|
+
let sapc: number;
|
|
408
|
+
let out: number;
|
|
409
|
+
if (ybg > ytxt) {
|
|
410
|
+
sapc = (ybg ** 0.56 - ytxt ** 0.57) * 1.14;
|
|
411
|
+
out = sapc < 0.1 ? 0 : (sapc - 0.027) * 100;
|
|
412
|
+
} else {
|
|
413
|
+
sapc = (ybg ** 0.65 - ytxt ** 0.62) * 1.14;
|
|
414
|
+
out = sapc > -0.1 ? 0 : (sapc + 0.027) * 100;
|
|
415
|
+
}
|
|
416
|
+
return Math.abs(out);
|
|
417
|
+
}
|