@compilr-dev/sdk 0.18.9 → 0.18.11
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/dist/canvas/color.d.ts +85 -0
- package/dist/canvas/color.js +216 -0
- package/dist/canvas/index.d.ts +6 -0
- package/dist/canvas/index.js +10 -0
- package/dist/canvas/quality-checks.d.ts +75 -0
- package/dist/canvas/quality-checks.js +217 -0
- package/dist/canvas/ramp.d.ts +45 -0
- package/dist/canvas/ramp.js +98 -0
- package/dist/capabilities/packs.js +10 -2
- package/dist/config.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.js +2 -0
- package/dist/models/model-registry.js +1 -0
- package/dist/models/ollama-discovery.d.ts +63 -0
- package/dist/models/ollama-discovery.js +172 -0
- package/dist/models/providers.js +10 -0
- package/dist/platform/tools/canvas-tools.d.ts +3 -1
- package/dist/platform/tools/canvas-tools.js +108 -4
- package/dist/provider.js +16 -0
- package/dist/skills/canvas-exemplars.d.ts +8 -0
- package/dist/skills/canvas-exemplars.js +36 -0
- package/dist/skills/canvas-icons.d.ts +6 -0
- package/dist/skills/canvas-icons.js +84 -0
- package/dist/skills/canvas-skills.js +160 -20
- package/dist/skills/platform-skills.d.ts +2 -0
- package/dist/skills/platform-skills.js +8 -0
- package/dist/team/tool-config.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour arithmetic for canvas checks and for the host's surface ramp.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ WHY THIS EXISTS. Three real terminal palettes, as the host injects them today:
|
|
5
|
+
* Tokyo Night puts `--canvas-card` 2.1% from `--canvas-bg`, Catppuccin Latte 0.0%,
|
|
6
|
+
* Gruvbox Dark 1.8%. A card that shares its ground's value is a rectangle of text, and
|
|
7
|
+
* no prompt has ever prevented it — the values simply are not far apart enough. The
|
|
8
|
+
* agent is not doing anything wrong.
|
|
9
|
+
*
|
|
10
|
+
* So the numbers have to be computed somewhere. Here, as pure functions with no I/O, so
|
|
11
|
+
* the same maths serves the validator (does this canvas fail?) and the host (what ramp
|
|
12
|
+
* should I inject?) without either owning it.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ OKLab lightness, not HSL's. HSL calls #FFFF00 and #0000FF both "50% lightness",
|
|
15
|
+
* which would rate yellow-on-blue as a zero-contrast pair. OKLab's L tracks perceived
|
|
16
|
+
* lightness, which is what "5–7% apart" has to mean to be worth asserting.
|
|
17
|
+
*
|
|
18
|
+
* WCAG contrast is separate and deliberately NOT OKLab — the 4.5:1 threshold is defined
|
|
19
|
+
* against sRGB relative luminance, so using anything else would be asserting a different
|
|
20
|
+
* rule than the one being cited.
|
|
21
|
+
*/
|
|
22
|
+
export interface Rgb {
|
|
23
|
+
r: number;
|
|
24
|
+
g: number;
|
|
25
|
+
b: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A CSS colour literal → rgb in 0..1, or null when it is not one.
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ Returns null for `var(--x)` ON PURPOSE, and callers depend on it: a custom property
|
|
31
|
+
* has no value until the host resolves it, and guessing one would produce confident
|
|
32
|
+
* arithmetic about a colour nobody has chosen yet.
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseColor(input: string): Rgb | null;
|
|
35
|
+
/** sRGB relative luminance, per WCAG 2.x. */
|
|
36
|
+
export declare function relativeLuminance({ r, g, b }: Rgb): number;
|
|
37
|
+
/** WCAG contrast ratio, 1..21. Order-independent. */
|
|
38
|
+
export declare function contrastRatio(a: Rgb, b: Rgb): number;
|
|
39
|
+
/**
|
|
40
|
+
* OKLab lightness, 0..1 — perceived lightness, which is what the 5–7% target means.
|
|
41
|
+
* Coefficients from Björn Ottosson's OKLab definition.
|
|
42
|
+
*/
|
|
43
|
+
export declare function oklabLightness({ r, g, b }: Rgb): number;
|
|
44
|
+
/** Perceived lightness distance between two colours, in percentage points. */
|
|
45
|
+
export declare function lightnessDeltaPct(a: Rgb, b: Rgb): number;
|
|
46
|
+
/**
|
|
47
|
+
* Is this colour a neutral (a grey), rather than a hue?
|
|
48
|
+
*
|
|
49
|
+
* Used to count "non-neutral hues": greys are the ground the composition sits on and
|
|
50
|
+
* never count against the accent budget, however many of them there are.
|
|
51
|
+
*/
|
|
52
|
+
export declare function isNeutral({ r, g, b }: Rgb, tolerance?: number): boolean;
|
|
53
|
+
/** Hue angle in degrees, 0..360. Meaningless for a neutral — check `isNeutral` first. */
|
|
54
|
+
export declare function hueAngle({ r, g, b }: Rgb): number;
|
|
55
|
+
/** The floor for text, per WCAG AA at normal size. */
|
|
56
|
+
export declare const AA_TEXT = 4.5;
|
|
57
|
+
/**
|
|
58
|
+
* The target separation between adjacent surfaces, in OKLab percentage points.
|
|
59
|
+
*
|
|
60
|
+
* Below MIN it disappears; above MAX the card reads as a separate page rather than a
|
|
61
|
+
* raised surface. Exported so the host's ramp and the validator cannot disagree.
|
|
62
|
+
*
|
|
63
|
+
* ⚠️ MIN IS CALIBRATED TO THE HANDOFF'S SPECIMENS, NOT TO ITS STATED NUMBER, and the
|
|
64
|
+
* difference matters. Section 01 says "below 3%" and reports its three palettes at 2.1%,
|
|
65
|
+
* 0.0% and 1.8% — but those figures reproduce under no standard metric: Tokyo Night's
|
|
66
|
+
* pair measures 3.5 in OKLab, 4.0 in CIE L*, 3.9 in HSL. The formula behind them is not
|
|
67
|
+
* stated, so adopting the number would be asserting arithmetic nobody can check.
|
|
68
|
+
*
|
|
69
|
+
* The SPECIMENS are sound, though — they are real palettes a designer looked at and
|
|
70
|
+
* judged muddy, and the same section gives a corrected ramp for each. So MIN is set where
|
|
71
|
+
* it separates the two groups in the metric actually used here:
|
|
72
|
+
*
|
|
73
|
+
* as injected (must fail) 3.5 · 0.0 · 3.4
|
|
74
|
+
* derived ramp (must pass) 5.6 · 7.5 · 6.1
|
|
75
|
+
*
|
|
76
|
+
* 4 sits between them with room on both sides. `canvas-color.test.ts` asserts that
|
|
77
|
+
* separation directly rather than any single figure, so a future metric change has to
|
|
78
|
+
* keep sorting the real palettes correctly instead of matching a constant.
|
|
79
|
+
*/
|
|
80
|
+
export declare const SURFACE_DELTA_MIN = 4;
|
|
81
|
+
export declare const SURFACE_DELTA_TARGET = 6;
|
|
82
|
+
export declare const SURFACE_DELTA_MAX = 12;
|
|
83
|
+
/** A hairline sits this far toward the foreground — never more. */
|
|
84
|
+
export declare const HAIRLINE_MIN_PCT = 10;
|
|
85
|
+
export declare const HAIRLINE_MAX_PCT = 15;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour arithmetic for canvas checks and for the host's surface ramp.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ WHY THIS EXISTS. Three real terminal palettes, as the host injects them today:
|
|
5
|
+
* Tokyo Night puts `--canvas-card` 2.1% from `--canvas-bg`, Catppuccin Latte 0.0%,
|
|
6
|
+
* Gruvbox Dark 1.8%. A card that shares its ground's value is a rectangle of text, and
|
|
7
|
+
* no prompt has ever prevented it — the values simply are not far apart enough. The
|
|
8
|
+
* agent is not doing anything wrong.
|
|
9
|
+
*
|
|
10
|
+
* So the numbers have to be computed somewhere. Here, as pure functions with no I/O, so
|
|
11
|
+
* the same maths serves the validator (does this canvas fail?) and the host (what ramp
|
|
12
|
+
* should I inject?) without either owning it.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ OKLab lightness, not HSL's. HSL calls #FFFF00 and #0000FF both "50% lightness",
|
|
15
|
+
* which would rate yellow-on-blue as a zero-contrast pair. OKLab's L tracks perceived
|
|
16
|
+
* lightness, which is what "5–7% apart" has to mean to be worth asserting.
|
|
17
|
+
*
|
|
18
|
+
* WCAG contrast is separate and deliberately NOT OKLab — the 4.5:1 threshold is defined
|
|
19
|
+
* against sRGB relative luminance, so using anything else would be asserting a different
|
|
20
|
+
* rule than the one being cited.
|
|
21
|
+
*/
|
|
22
|
+
const clamp01 = (n) => (n < 0 ? 0 : n > 1 ? 1 : n);
|
|
23
|
+
const HEX = /^#?([0-9a-f]{3,8})$/i;
|
|
24
|
+
/** `#abc` · `#aabbcc` · `#aabbccdd` → rgb. Alpha is parsed and discarded, not rejected. */
|
|
25
|
+
function parseHex(input) {
|
|
26
|
+
const m = HEX.exec(input.trim());
|
|
27
|
+
if (!m)
|
|
28
|
+
return null;
|
|
29
|
+
let hex = m[1];
|
|
30
|
+
// 3 and 4 digit forms double each nibble; 4 and 8 carry alpha we drop.
|
|
31
|
+
if (hex.length === 3 || hex.length === 4) {
|
|
32
|
+
hex = hex
|
|
33
|
+
.split('')
|
|
34
|
+
.map((c) => c + c)
|
|
35
|
+
.join('');
|
|
36
|
+
}
|
|
37
|
+
if (hex.length !== 6 && hex.length !== 8)
|
|
38
|
+
return null;
|
|
39
|
+
return {
|
|
40
|
+
r: parseInt(hex.slice(0, 2), 16) / 255,
|
|
41
|
+
g: parseInt(hex.slice(2, 4), 16) / 255,
|
|
42
|
+
b: parseInt(hex.slice(4, 6), 16) / 255,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** One `rgb()`/`rgba()` channel: `200`, `78%`, or `.5` for alpha (which we drop). */
|
|
46
|
+
function channel(raw) {
|
|
47
|
+
const t = raw.trim();
|
|
48
|
+
if (!t)
|
|
49
|
+
return null;
|
|
50
|
+
const pct = t.endsWith('%');
|
|
51
|
+
const n = Number.parseFloat(pct ? t.slice(0, -1) : t);
|
|
52
|
+
if (!Number.isFinite(n))
|
|
53
|
+
return null;
|
|
54
|
+
return clamp01(pct ? n / 100 : n / 255);
|
|
55
|
+
}
|
|
56
|
+
const FUNC = /^(rgba?|hsla?)\s*\(([^)]*)\)$/i;
|
|
57
|
+
/**
|
|
58
|
+
* ⚠️ Splits on commas AND whitespace, because both spellings are legal and in the wild:
|
|
59
|
+
* `rgb(10, 20, 30)` and `rgb(10 20 30 / 50%)`. The slash-separated alpha is dropped with
|
|
60
|
+
* the rest — every caller here asks about hue and lightness, never transparency.
|
|
61
|
+
*/
|
|
62
|
+
const parts = (body) => body
|
|
63
|
+
.replace(/\//g, ' ')
|
|
64
|
+
.split(/[,\s]+/)
|
|
65
|
+
.map((p) => p.trim())
|
|
66
|
+
.filter(Boolean);
|
|
67
|
+
function hueToRgb(p, q, t) {
|
|
68
|
+
let x = t;
|
|
69
|
+
if (x < 0)
|
|
70
|
+
x += 1;
|
|
71
|
+
if (x > 1)
|
|
72
|
+
x -= 1;
|
|
73
|
+
if (x < 1 / 6)
|
|
74
|
+
return p + (q - p) * 6 * x;
|
|
75
|
+
if (x < 1 / 2)
|
|
76
|
+
return q;
|
|
77
|
+
if (x < 2 / 3)
|
|
78
|
+
return p + (q - p) * (2 / 3 - x) * 6;
|
|
79
|
+
return p;
|
|
80
|
+
}
|
|
81
|
+
function parseHsl(body) {
|
|
82
|
+
const p = parts(body);
|
|
83
|
+
if (p.length < 3)
|
|
84
|
+
return null;
|
|
85
|
+
const h = Number.parseFloat(p[0]);
|
|
86
|
+
const s = Number.parseFloat(p[1]) / 100;
|
|
87
|
+
const l = Number.parseFloat(p[2]) / 100;
|
|
88
|
+
if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l))
|
|
89
|
+
return null;
|
|
90
|
+
const hn = (((h % 360) + 360) % 360) / 360;
|
|
91
|
+
const sn = clamp01(s);
|
|
92
|
+
const ln = clamp01(l);
|
|
93
|
+
if (sn === 0)
|
|
94
|
+
return { r: ln, g: ln, b: ln };
|
|
95
|
+
const q = ln < 0.5 ? ln * (1 + sn) : ln + sn - ln * sn;
|
|
96
|
+
const pp = 2 * ln - q;
|
|
97
|
+
return {
|
|
98
|
+
r: hueToRgb(pp, q, hn + 1 / 3),
|
|
99
|
+
g: hueToRgb(pp, q, hn),
|
|
100
|
+
b: hueToRgb(pp, q, hn - 1 / 3),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* A CSS colour literal → rgb in 0..1, or null when it is not one.
|
|
105
|
+
*
|
|
106
|
+
* ⚠️ Returns null for `var(--x)` ON PURPOSE, and callers depend on it: a custom property
|
|
107
|
+
* has no value until the host resolves it, and guessing one would produce confident
|
|
108
|
+
* arithmetic about a colour nobody has chosen yet.
|
|
109
|
+
*/
|
|
110
|
+
export function parseColor(input) {
|
|
111
|
+
const t = input.trim();
|
|
112
|
+
if (!t)
|
|
113
|
+
return null;
|
|
114
|
+
const hex = parseHex(t);
|
|
115
|
+
if (hex)
|
|
116
|
+
return hex;
|
|
117
|
+
const m = FUNC.exec(t);
|
|
118
|
+
if (!m)
|
|
119
|
+
return null;
|
|
120
|
+
const fn = m[1].toLowerCase();
|
|
121
|
+
if (fn === 'hsl' || fn === 'hsla')
|
|
122
|
+
return parseHsl(m[2]);
|
|
123
|
+
const p = parts(m[2]);
|
|
124
|
+
if (p.length < 3)
|
|
125
|
+
return null;
|
|
126
|
+
const [r, g, b] = [channel(p[0]), channel(p[1]), channel(p[2])];
|
|
127
|
+
if (r === null || g === null || b === null)
|
|
128
|
+
return null;
|
|
129
|
+
return { r, g, b };
|
|
130
|
+
}
|
|
131
|
+
const linear = (c) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
132
|
+
/** sRGB relative luminance, per WCAG 2.x. */
|
|
133
|
+
export function relativeLuminance({ r, g, b }) {
|
|
134
|
+
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
|
135
|
+
}
|
|
136
|
+
/** WCAG contrast ratio, 1..21. Order-independent. */
|
|
137
|
+
export function contrastRatio(a, b) {
|
|
138
|
+
const la = relativeLuminance(a);
|
|
139
|
+
const lb = relativeLuminance(b);
|
|
140
|
+
const [hi, lo] = la >= lb ? [la, lb] : [lb, la];
|
|
141
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* OKLab lightness, 0..1 — perceived lightness, which is what the 5–7% target means.
|
|
145
|
+
* Coefficients from Björn Ottosson's OKLab definition.
|
|
146
|
+
*/
|
|
147
|
+
export function oklabLightness({ r, g, b }) {
|
|
148
|
+
const lr = linear(r);
|
|
149
|
+
const lg = linear(g);
|
|
150
|
+
const lb = linear(b);
|
|
151
|
+
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
|
|
152
|
+
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
|
|
153
|
+
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
|
|
154
|
+
return clamp01(0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s);
|
|
155
|
+
}
|
|
156
|
+
/** Perceived lightness distance between two colours, in percentage points. */
|
|
157
|
+
export function lightnessDeltaPct(a, b) {
|
|
158
|
+
return Math.abs(oklabLightness(a) - oklabLightness(b)) * 100;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Is this colour a neutral (a grey), rather than a hue?
|
|
162
|
+
*
|
|
163
|
+
* Used to count "non-neutral hues": greys are the ground the composition sits on and
|
|
164
|
+
* never count against the accent budget, however many of them there are.
|
|
165
|
+
*/
|
|
166
|
+
export function isNeutral({ r, g, b }, tolerance = 0.06) {
|
|
167
|
+
return Math.max(r, g, b) - Math.min(r, g, b) <= tolerance;
|
|
168
|
+
}
|
|
169
|
+
/** Hue angle in degrees, 0..360. Meaningless for a neutral — check `isNeutral` first. */
|
|
170
|
+
export function hueAngle({ r, g, b }) {
|
|
171
|
+
const max = Math.max(r, g, b);
|
|
172
|
+
const min = Math.min(r, g, b);
|
|
173
|
+
const d = max - min;
|
|
174
|
+
if (d === 0)
|
|
175
|
+
return 0;
|
|
176
|
+
let h;
|
|
177
|
+
if (max === r)
|
|
178
|
+
h = ((g - b) / d) % 6;
|
|
179
|
+
else if (max === g)
|
|
180
|
+
h = (b - r) / d + 2;
|
|
181
|
+
else
|
|
182
|
+
h = (r - g) / d + 4;
|
|
183
|
+
h *= 60;
|
|
184
|
+
return h < 0 ? h + 360 : h;
|
|
185
|
+
}
|
|
186
|
+
/** The floor for text, per WCAG AA at normal size. */
|
|
187
|
+
export const AA_TEXT = 4.5;
|
|
188
|
+
/**
|
|
189
|
+
* The target separation between adjacent surfaces, in OKLab percentage points.
|
|
190
|
+
*
|
|
191
|
+
* Below MIN it disappears; above MAX the card reads as a separate page rather than a
|
|
192
|
+
* raised surface. Exported so the host's ramp and the validator cannot disagree.
|
|
193
|
+
*
|
|
194
|
+
* ⚠️ MIN IS CALIBRATED TO THE HANDOFF'S SPECIMENS, NOT TO ITS STATED NUMBER, and the
|
|
195
|
+
* difference matters. Section 01 says "below 3%" and reports its three palettes at 2.1%,
|
|
196
|
+
* 0.0% and 1.8% — but those figures reproduce under no standard metric: Tokyo Night's
|
|
197
|
+
* pair measures 3.5 in OKLab, 4.0 in CIE L*, 3.9 in HSL. The formula behind them is not
|
|
198
|
+
* stated, so adopting the number would be asserting arithmetic nobody can check.
|
|
199
|
+
*
|
|
200
|
+
* The SPECIMENS are sound, though — they are real palettes a designer looked at and
|
|
201
|
+
* judged muddy, and the same section gives a corrected ramp for each. So MIN is set where
|
|
202
|
+
* it separates the two groups in the metric actually used here:
|
|
203
|
+
*
|
|
204
|
+
* as injected (must fail) 3.5 · 0.0 · 3.4
|
|
205
|
+
* derived ramp (must pass) 5.6 · 7.5 · 6.1
|
|
206
|
+
*
|
|
207
|
+
* 4 sits between them with room on both sides. `canvas-color.test.ts` asserts that
|
|
208
|
+
* separation directly rather than any single figure, so a future metric change has to
|
|
209
|
+
* keep sorting the real palettes correctly instead of matching a constant.
|
|
210
|
+
*/
|
|
211
|
+
export const SURFACE_DELTA_MIN = 4;
|
|
212
|
+
export const SURFACE_DELTA_TARGET = 6;
|
|
213
|
+
export const SURFACE_DELTA_MAX = 12;
|
|
214
|
+
/** A hairline sits this far toward the foreground — never more. */
|
|
215
|
+
export const HAIRLINE_MIN_PCT = 10;
|
|
216
|
+
export const HAIRLINE_MAX_PCT = 15;
|
package/dist/canvas/index.d.ts
CHANGED
|
@@ -4,3 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export * from './types.js';
|
|
6
6
|
export * from './validate.js';
|
|
7
|
+
export { parseColor, relativeLuminance, contrastRatio, oklabLightness, lightnessDeltaPct, isNeutral, hueAngle, AA_TEXT, SURFACE_DELTA_MIN, SURFACE_DELTA_TARGET, SURFACE_DELTA_MAX, HAIRLINE_MIN_PCT, HAIRLINE_MAX_PCT, } from './color.js';
|
|
8
|
+
export type { Rgb } from './color.js';
|
|
9
|
+
export { runQualityChecks, checkSurfaceTokens, checkTextContrast, checkHueCount, checkDeadTweaks, primaryVarNames, } from './quality-checks.js';
|
|
10
|
+
export type { QualityIssue } from './quality-checks.js';
|
|
11
|
+
export { deriveSurfaceRamp, withLightness } from './ramp.js';
|
|
12
|
+
export type { SurfaceRamp } from './ramp.js';
|
package/dist/canvas/index.js
CHANGED
|
@@ -4,3 +4,13 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export * from './types.js';
|
|
6
6
|
export * from './validate.js';
|
|
7
|
+
/*
|
|
8
|
+
⚠️ Renderer-safe on purpose. Both modules are pure arithmetic with no imports beyond
|
|
9
|
+
each other, so Desktop's renderer can use them for the surface ramp without pulling the
|
|
10
|
+
SDK main entry (which reaches node:util and breaks a browser bundle). The host and the
|
|
11
|
+
validator must agree on the ramp's targets, so they share the constants rather than
|
|
12
|
+
each keeping a copy.
|
|
13
|
+
*/
|
|
14
|
+
export { parseColor, relativeLuminance, contrastRatio, oklabLightness, lightnessDeltaPct, isNeutral, hueAngle, AA_TEXT, SURFACE_DELTA_MIN, SURFACE_DELTA_TARGET, SURFACE_DELTA_MAX, HAIRLINE_MIN_PCT, HAIRLINE_MAX_PCT, } from './color.js';
|
|
15
|
+
export { runQualityChecks, checkSurfaceTokens, checkTextContrast, checkHueCount, checkDeadTweaks, primaryVarNames, } from './quality-checks.js';
|
|
16
|
+
export { deriveSurfaceRamp, withLightness } from './ramp.js';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deterministic half of canvas quality.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ WHY THESE ARE CODE AND NOT PROMPT. Each one is arithmetic on values the canvas
|
|
5
|
+
* declares, and each has already failed to hold as prose. The surface check in
|
|
6
|
+
* particular is the defect no prompt has ever prevented, because the agent is not doing
|
|
7
|
+
* anything wrong — it uses the token it was given, and on a terminal-derived palette
|
|
8
|
+
* `--canvas-card` lands within a few percent of `--canvas-bg`.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ WHAT IS DELIBERATELY MISSING: contrast between two *tokens*. The SDK never sees the
|
|
11
|
+
* theme palette — `canvas_validate` takes html and type, and nothing in the platform
|
|
12
|
+
* context carries colours — so `var(--canvas-muted)` on `var(--canvas-surface-2)` cannot
|
|
13
|
+
* be resolved here. Rather than plumb a palette through on spec, the checks below cover
|
|
14
|
+
* the two cases that need no palette: token PAIRINGS that are wrong whatever they
|
|
15
|
+
* resolve to, and literal colours, whose values are right there in the markup.
|
|
16
|
+
*/
|
|
17
|
+
import type { ControlManifest } from './types.js';
|
|
18
|
+
export interface QualityIssue {
|
|
19
|
+
level: 'error' | 'warn';
|
|
20
|
+
message: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The custom properties a value actually USES — primaries only, never fallbacks.
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ THIS EXISTS BECAUSE A SUBSTRING TEST GETS IT BACKWARDS. The handoff tells authors to
|
|
26
|
+
* write `var(--canvas-surface-1, var(--canvas-card))` so a canvas degrades on a host
|
|
27
|
+
* without the ramp, and to KEEP that fallback afterwards. A plain
|
|
28
|
+
* `/var\(--canvas-card/` test matches the fallback and fires the surface error on the
|
|
29
|
+
* exact corrected form the error message asks for — a validator that punishes compliance
|
|
30
|
+
* teaches an agent to ignore it. Caught by the negative test, not by reading the regex.
|
|
31
|
+
*
|
|
32
|
+
* So: walk to each `var(`, take the name up to the first comma, then skip the whole
|
|
33
|
+
* fallback by tracking paren depth.
|
|
34
|
+
*/
|
|
35
|
+
export declare function primaryVarNames(value: string): string[];
|
|
36
|
+
/**
|
|
37
|
+
* A card that shares its ground's value is a rectangle of text.
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ This flags the TOKEN PAIRING, not a measured delta, and that is the whole point:
|
|
40
|
+
* whether `--canvas-card` is 0.0% or 2.1% from `--canvas-bg` depends on the user's
|
|
41
|
+
* theme, and it is under 4% on every palette anyone measured. The fix is the same in
|
|
42
|
+
* all of them — use the ramp — so the check needs no palette to be right.
|
|
43
|
+
*/
|
|
44
|
+
export declare function checkSurfaceTokens(html: string): QualityIssue[];
|
|
45
|
+
/**
|
|
46
|
+
* Text that cannot be read on the surface it actually sits on.
|
|
47
|
+
*
|
|
48
|
+
* Two halves, both palette-free:
|
|
49
|
+
* - a literal colour on a literal background, measured exactly;
|
|
50
|
+
* - white on `var(--canvas-accent)`, which is a pairing rather than a measurement —
|
|
51
|
+
* the accent is a mid-tone in every theme we ship (#FF5722 gives 3.2:1), so the pair
|
|
52
|
+
* is wrong whatever it resolves to.
|
|
53
|
+
*/
|
|
54
|
+
export declare function checkTextContrast(html: string): QualityIssue[];
|
|
55
|
+
/**
|
|
56
|
+
* More hues than reasons.
|
|
57
|
+
*
|
|
58
|
+
* ⚠️ Greys never count. They are the ground a composition sits on, and a palette of six
|
|
59
|
+
* greys is a perfectly good canvas — what makes one look machine-made is four competing
|
|
60
|
+
* *hues* with nothing to distinguish. Warn, never error: multiple hues are legitimate
|
|
61
|
+
* when they encode categories a reader must tell apart.
|
|
62
|
+
*/
|
|
63
|
+
export declare function checkHueCount(html: string): QualityIssue[];
|
|
64
|
+
/**
|
|
65
|
+
* A Tweak the user can drag that changes nothing.
|
|
66
|
+
*
|
|
67
|
+
* ⚠️ SKIPPED ENTIRELY WHEN THE CANVAS DEFINES `applyParams`. That is the host bridge's
|
|
68
|
+
* fourth binding path — a canvas can take the whole params object in JS and do what it
|
|
69
|
+
* likes with it, in which case no static reference to the param name need exist. Without
|
|
70
|
+
* this guard the check would report confident errors about a legitimate pattern, which is
|
|
71
|
+
* worse than not checking at all.
|
|
72
|
+
*/
|
|
73
|
+
export declare function checkDeadTweaks(html: string, manifest?: ControlManifest): QualityIssue[];
|
|
74
|
+
/** Every quality check, in the order their messages should be read. */
|
|
75
|
+
export declare function runQualityChecks(html: string, manifest?: ControlManifest): QualityIssue[];
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { parseColor, contrastRatio, isNeutral, hueAngle, AA_TEXT } from './color.js';
|
|
2
|
+
/** Every `style="…"` attribute body and every `{…}` CSS rule body, as separate scopes. */
|
|
3
|
+
function declarationScopes(html) {
|
|
4
|
+
const scopes = [];
|
|
5
|
+
for (const m of html.matchAll(/style\s*=\s*"([^"]*)"/gi))
|
|
6
|
+
scopes.push(m[1]);
|
|
7
|
+
for (const m of html.matchAll(/style\s*=\s*'([^']*)'/gi))
|
|
8
|
+
scopes.push(m[1]);
|
|
9
|
+
for (const m of html.matchAll(/\{([^{}]*)\}/g))
|
|
10
|
+
scopes.push(m[1]);
|
|
11
|
+
return scopes;
|
|
12
|
+
}
|
|
13
|
+
const declaration = (scope, property) => {
|
|
14
|
+
const m = property.exec(scope);
|
|
15
|
+
return m ? m[1].trim() : null;
|
|
16
|
+
};
|
|
17
|
+
const TEXT_COLOR = /(?:^|[;{\s])color\s*:\s*([^;}]+)/i;
|
|
18
|
+
const BACKGROUND = /(?:^|[;{\s])background(?:-color)?\s*:\s*([^;}]+)/i;
|
|
19
|
+
/**
|
|
20
|
+
* The custom properties a value actually USES — primaries only, never fallbacks.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ THIS EXISTS BECAUSE A SUBSTRING TEST GETS IT BACKWARDS. The handoff tells authors to
|
|
23
|
+
* write `var(--canvas-surface-1, var(--canvas-card))` so a canvas degrades on a host
|
|
24
|
+
* without the ramp, and to KEEP that fallback afterwards. A plain
|
|
25
|
+
* `/var\(--canvas-card/` test matches the fallback and fires the surface error on the
|
|
26
|
+
* exact corrected form the error message asks for — a validator that punishes compliance
|
|
27
|
+
* teaches an agent to ignore it. Caught by the negative test, not by reading the regex.
|
|
28
|
+
*
|
|
29
|
+
* So: walk to each `var(`, take the name up to the first comma, then skip the whole
|
|
30
|
+
* fallback by tracking paren depth.
|
|
31
|
+
*/
|
|
32
|
+
export function primaryVarNames(value) {
|
|
33
|
+
const names = [];
|
|
34
|
+
for (let i = value.indexOf('var('); i !== -1; i = value.indexOf('var(', i)) {
|
|
35
|
+
let j = i + 4;
|
|
36
|
+
while (j < value.length && /\s/.test(value[j]))
|
|
37
|
+
j++;
|
|
38
|
+
let name = '';
|
|
39
|
+
while (j < value.length && /[-\w]/.test(value[j]))
|
|
40
|
+
name += value[j++];
|
|
41
|
+
if (name.startsWith('--'))
|
|
42
|
+
names.push(name);
|
|
43
|
+
// Skip to the matching close paren — the fallback is deliberately not scanned.
|
|
44
|
+
let depth = 1;
|
|
45
|
+
while (j < value.length && depth > 0) {
|
|
46
|
+
if (value[j] === '(')
|
|
47
|
+
depth++;
|
|
48
|
+
else if (value[j] === ')')
|
|
49
|
+
depth--;
|
|
50
|
+
j++;
|
|
51
|
+
}
|
|
52
|
+
i = j;
|
|
53
|
+
}
|
|
54
|
+
return names;
|
|
55
|
+
}
|
|
56
|
+
const usesVar = (value, name) => primaryVarNames(value).some((n) => n.toLowerCase() === name);
|
|
57
|
+
/**
|
|
58
|
+
* A card that shares its ground's value is a rectangle of text.
|
|
59
|
+
*
|
|
60
|
+
* ⚠️ This flags the TOKEN PAIRING, not a measured delta, and that is the whole point:
|
|
61
|
+
* whether `--canvas-card` is 0.0% or 2.1% from `--canvas-bg` depends on the user's
|
|
62
|
+
* theme, and it is under 4% on every palette anyone measured. The fix is the same in
|
|
63
|
+
* all of them — use the ramp — so the check needs no palette to be right.
|
|
64
|
+
*/
|
|
65
|
+
export function checkSurfaceTokens(html) {
|
|
66
|
+
const usesCardAsSurface = declarationScopes(html).some((scope) => {
|
|
67
|
+
const bg = declaration(scope, BACKGROUND);
|
|
68
|
+
return bg !== null && usesVar(bg, '--canvas-card');
|
|
69
|
+
});
|
|
70
|
+
if (!usesCardAsSurface)
|
|
71
|
+
return [];
|
|
72
|
+
return [
|
|
73
|
+
{
|
|
74
|
+
level: 'error',
|
|
75
|
+
message: 'A card is filled with var(--canvas-card) over a var(--canvas-bg) ground. On a ' +
|
|
76
|
+
'theme-derived palette those two sit within a few percent of each other, so the ' +
|
|
77
|
+
'card disappears and only its border holds the layout together. Use the elevation ' +
|
|
78
|
+
'ramp instead: background: var(--canvas-surface-1, var(--canvas-card)) for a raised ' +
|
|
79
|
+
'card, var(--canvas-surface-2, var(--canvas-card)) for an inset area (tables, code), ' +
|
|
80
|
+
'and border-color: var(--canvas-hairline, var(--canvas-border)). Keep the fallbacks ' +
|
|
81
|
+
'so the canvas still renders on a host that has not shipped the ramp.',
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
/** Near-white, for the "white on the accent" trap. */
|
|
86
|
+
function isNearWhite(value) {
|
|
87
|
+
if (/^\s*white\s*$/i.test(value))
|
|
88
|
+
return true;
|
|
89
|
+
const c = parseColor(value);
|
|
90
|
+
return c !== null && c.r > 0.85 && c.g > 0.85 && c.b > 0.85;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Text that cannot be read on the surface it actually sits on.
|
|
94
|
+
*
|
|
95
|
+
* Two halves, both palette-free:
|
|
96
|
+
* - a literal colour on a literal background, measured exactly;
|
|
97
|
+
* - white on `var(--canvas-accent)`, which is a pairing rather than a measurement —
|
|
98
|
+
* the accent is a mid-tone in every theme we ship (#FF5722 gives 3.2:1), so the pair
|
|
99
|
+
* is wrong whatever it resolves to.
|
|
100
|
+
*/
|
|
101
|
+
export function checkTextContrast(html) {
|
|
102
|
+
const issues = [];
|
|
103
|
+
let whiteOnAccent = false;
|
|
104
|
+
const failures = [];
|
|
105
|
+
for (const scope of declarationScopes(html)) {
|
|
106
|
+
const fg = declaration(scope, TEXT_COLOR);
|
|
107
|
+
const bg = declaration(scope, BACKGROUND);
|
|
108
|
+
if (fg === null || bg === null)
|
|
109
|
+
continue;
|
|
110
|
+
if (usesVar(bg, '--canvas-accent') && isNearWhite(fg)) {
|
|
111
|
+
whiteOnAccent = true;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const fgc = parseColor(fg);
|
|
115
|
+
const bgc = parseColor(bg);
|
|
116
|
+
// ⚠️ A null here means a token or a keyword, NOT a failure — skip rather than guess.
|
|
117
|
+
if (!fgc || !bgc)
|
|
118
|
+
continue;
|
|
119
|
+
const ratio = contrastRatio(fgc, bgc);
|
|
120
|
+
if (ratio < AA_TEXT) {
|
|
121
|
+
failures.push(`${fg.trim()} on ${bg.trim()} — ${ratio.toFixed(1)}:1`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (whiteOnAccent) {
|
|
125
|
+
issues.push({
|
|
126
|
+
level: 'error',
|
|
127
|
+
message: 'White text on var(--canvas-accent). The accent is a mid-tone in every theme — ' +
|
|
128
|
+
'on the default it is 3.2:1, below the 4.5:1 floor. Either put a dark ink label on ' +
|
|
129
|
+
'the accent fill, or drop the fill and use the accent as the text colour on the ' +
|
|
130
|
+
'page ground.',
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (failures.length) {
|
|
134
|
+
issues.push({
|
|
135
|
+
level: 'error',
|
|
136
|
+
message: `Text below the 4.5:1 floor against the surface it sits on: ${failures.slice(0, 4).join('; ')}` +
|
|
137
|
+
(failures.length > 4 ? ` (+${String(failures.length - 4)} more)` : '') +
|
|
138
|
+
'. Check a muted grey against the INSET surface, not the page — one that passes on ' +
|
|
139
|
+
'white will fail on surface-2.',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return issues;
|
|
143
|
+
}
|
|
144
|
+
/** Hues within this many degrees are the same hue wearing two tints. */
|
|
145
|
+
const HUE_BUCKET = 30;
|
|
146
|
+
/**
|
|
147
|
+
* More hues than reasons.
|
|
148
|
+
*
|
|
149
|
+
* ⚠️ Greys never count. They are the ground a composition sits on, and a palette of six
|
|
150
|
+
* greys is a perfectly good canvas — what makes one look machine-made is four competing
|
|
151
|
+
* *hues* with nothing to distinguish. Warn, never error: multiple hues are legitimate
|
|
152
|
+
* when they encode categories a reader must tell apart.
|
|
153
|
+
*/
|
|
154
|
+
export function checkHueCount(html) {
|
|
155
|
+
const buckets = new Set();
|
|
156
|
+
for (const m of html.matchAll(/#[0-9a-f]{3,8}\b|\b(?:rgb|hsl)a?\s*\([^)]*\)/gi)) {
|
|
157
|
+
const c = parseColor(m[0]);
|
|
158
|
+
if (!c || isNeutral(c))
|
|
159
|
+
continue;
|
|
160
|
+
buckets.add(Math.round(hueAngle(c) / HUE_BUCKET));
|
|
161
|
+
}
|
|
162
|
+
if (buckets.size <= 2)
|
|
163
|
+
return [];
|
|
164
|
+
return [
|
|
165
|
+
{
|
|
166
|
+
level: 'warn',
|
|
167
|
+
message: `${String(buckets.size)} distinct hues are hardcoded in this canvas. Count the hues, then ` +
|
|
168
|
+
'count the reasons — if there are more hues than things a reader must tell apart, ' +
|
|
169
|
+
'colour is decoration. One accent marking the finding, and greys for everything else.',
|
|
170
|
+
},
|
|
171
|
+
];
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* A Tweak the user can drag that changes nothing.
|
|
175
|
+
*
|
|
176
|
+
* ⚠️ SKIPPED ENTIRELY WHEN THE CANVAS DEFINES `applyParams`. That is the host bridge's
|
|
177
|
+
* fourth binding path — a canvas can take the whole params object in JS and do what it
|
|
178
|
+
* likes with it, in which case no static reference to the param name need exist. Without
|
|
179
|
+
* this guard the check would report confident errors about a legitimate pattern, which is
|
|
180
|
+
* worse than not checking at all.
|
|
181
|
+
*/
|
|
182
|
+
export function checkDeadTweaks(html, manifest) {
|
|
183
|
+
const controls = manifest?.controls ?? [];
|
|
184
|
+
if (controls.length === 0)
|
|
185
|
+
return [];
|
|
186
|
+
if (/\bapplyParams\s*[=:(]/.test(html))
|
|
187
|
+
return [];
|
|
188
|
+
const dead = controls
|
|
189
|
+
.map((c) => c.param)
|
|
190
|
+
.filter((param) => {
|
|
191
|
+
if (!param)
|
|
192
|
+
return false;
|
|
193
|
+
const safe = param.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
194
|
+
const bound = new RegExp(`var\\(\\s*--${safe}\\b|data-bind\\s*=\\s*["']${safe}["']|data-show\\s*=\\s*["']${safe}["']`, 'i');
|
|
195
|
+
return !bound.test(html);
|
|
196
|
+
});
|
|
197
|
+
if (dead.length === 0)
|
|
198
|
+
return [];
|
|
199
|
+
return [
|
|
200
|
+
{
|
|
201
|
+
level: 'error',
|
|
202
|
+
message: `Tweak${dead.length > 1 ? 's' : ''} declared but bound to nothing: ${dead.join(', ')}. ` +
|
|
203
|
+
'A control the user can drag that changes nothing reads as a broken canvas. Either ' +
|
|
204
|
+
'reference it — var(--param) in CSS, data-bind="param" for text, data-show="param" ' +
|
|
205
|
+
'to toggle — or remove it from the manifest.',
|
|
206
|
+
},
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
/** Every quality check, in the order their messages should be read. */
|
|
210
|
+
export function runQualityChecks(html, manifest) {
|
|
211
|
+
return [
|
|
212
|
+
...checkSurfaceTokens(html),
|
|
213
|
+
...checkTextContrast(html),
|
|
214
|
+
...checkHueCount(html),
|
|
215
|
+
...checkDeadTweaks(html, manifest),
|
|
216
|
+
];
|
|
217
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The surface ramp the host injects.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ WHY THE HOST AND NOT CSS. A single `color-mix` cannot serve both polarities: a light
|
|
5
|
+
* theme's ground has no headroom above it (Catppuccin Latte sits at ~95% lightness, so a
|
|
6
|
+
* card 6% "above" it would be past white), while a dark theme's has plenty. CSS cannot
|
|
7
|
+
* branch on a palette's polarity. Whatever computes this has to know the luminance, so it
|
|
8
|
+
* lives here — pure, testable, and shared with the validator so the two cannot disagree
|
|
9
|
+
* about what "5–7% apart" means.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ THE GROUND MOVES ONLY WHEN IT MUST. The handoff's worked examples darken the ground
|
|
12
|
+
* in every case; this does it only when there is no room for the raised surface, so a
|
|
13
|
+
* dark theme's canvas keeps exactly the background its theme specifies and nothing about
|
|
14
|
+
* an existing canvas shifts. Latte has no room, so it darkens — there is no alternative
|
|
15
|
+
* that also keeps the card raised, which is the point of the ramp.
|
|
16
|
+
*/
|
|
17
|
+
import { type Rgb } from './color.js';
|
|
18
|
+
export interface SurfaceRamp {
|
|
19
|
+
/** The page ground. Equal to the input unless there was no room for the ramp. */
|
|
20
|
+
bg: string;
|
|
21
|
+
/** Raised — cards. */
|
|
22
|
+
surface1: string;
|
|
23
|
+
/** Inset — tables, code, an app screen's work area. */
|
|
24
|
+
surface2: string;
|
|
25
|
+
/** 1px borders. */
|
|
26
|
+
hairline: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The same colour at a different perceived lightness.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ Binary search over a mix toward white or black rather than an OKLab inverse. It
|
|
32
|
+
* keeps the hue of the ground (a tinted terminal ground stays tinted, which is most of
|
|
33
|
+
* why these palettes look like themselves) and cannot produce an out-of-gamut value,
|
|
34
|
+
* which an inverse transform can.
|
|
35
|
+
*/
|
|
36
|
+
export declare function withLightness(color: Rgb, targetL: number): Rgb;
|
|
37
|
+
/**
|
|
38
|
+
* Derive the three ramp tokens from a theme's ground and foreground.
|
|
39
|
+
*
|
|
40
|
+
* Raised is always lighter and inset always darker, in both polarities — a card that
|
|
41
|
+
* recedes from its page does not read as a card. When the ground cannot support both
|
|
42
|
+
* steps, the ground itself is moved just far enough to make room, and the returned `bg`
|
|
43
|
+
* says so.
|
|
44
|
+
*/
|
|
45
|
+
export declare function deriveSurfaceRamp(bgInput: string, fgInput: string): SurfaceRamp | null;
|