@compilr-dev/sdk 0.18.10 → 0.19.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.
Files changed (34) hide show
  1. package/dist/canvas/assets.d.ts +99 -0
  2. package/dist/canvas/assets.js +111 -0
  3. package/dist/canvas/color.d.ts +85 -0
  4. package/dist/canvas/color.js +216 -0
  5. package/dist/canvas/index.d.ts +8 -0
  6. package/dist/canvas/index.js +16 -0
  7. package/dist/canvas/quality-checks.d.ts +75 -0
  8. package/dist/canvas/quality-checks.js +217 -0
  9. package/dist/canvas/ramp.d.ts +45 -0
  10. package/dist/canvas/ramp.js +98 -0
  11. package/dist/capabilities/packs.js +16 -2
  12. package/dist/platform/context.d.ts +16 -2
  13. package/dist/platform/context.js +13 -1
  14. package/dist/platform/repositories.d.ts +4 -0
  15. package/dist/platform/sqlite/canvas-repository.d.ts +9 -0
  16. package/dist/platform/sqlite/canvas-repository.js +52 -0
  17. package/dist/platform/sqlite/db.js +29 -0
  18. package/dist/platform/sqlite/schema.d.ts +1 -1
  19. package/dist/platform/sqlite/schema.js +1 -1
  20. package/dist/platform/tools/canvas-tools.d.ts +12 -2
  21. package/dist/platform/tools/canvas-tools.js +316 -6
  22. package/dist/platform/tools/image-tools.d.ts +9 -2
  23. package/dist/platform/tools/image-tools.js +4 -2
  24. package/dist/platform/tools/index.js +10 -2
  25. package/dist/platform/tools/project-tools.js +3 -2
  26. package/dist/skills/canvas-exemplars.d.ts +8 -0
  27. package/dist/skills/canvas-exemplars.js +36 -0
  28. package/dist/skills/canvas-icons.d.ts +6 -0
  29. package/dist/skills/canvas-icons.js +84 -0
  30. package/dist/skills/canvas-skills.js +207 -20
  31. package/dist/skills/platform-skills.d.ts +2 -0
  32. package/dist/skills/platform-skills.js +8 -0
  33. package/dist/team/tool-config.js +4 -0
  34. package/package.json +1 -1
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Canvas assets — images stored beside a canvas, not inside it.
3
+ *
4
+ * ⚠️ WHY NOT JUST A data: URI IN THE HTML. The sandbox CSP is `img-src data: blob:`, so a
5
+ * data URI is the ONLY thing that renders — but the canvas document is stored in sqlite
6
+ * and edited with `canvas_edit`'s str_replace. Four product photos inline is a few hundred
7
+ * KB of base64 sitting in the middle of the document the agent has to read an outline of
8
+ * and patch by hand. The editing discipline that makes canvases reliable stops working.
9
+ *
10
+ * So the markup carries a REFERENCE and the bytes live in a side table:
11
+ *
12
+ * <img src="asset:hero" alt="…">
13
+ *
14
+ * and the host substitutes real data URIs at the two moments the canvas becomes pixels:
15
+ * rendering into the iframe, and exporting to PDF/HTML/PNG. `resolveCanvasAssets` below is
16
+ * that substitution, shared by both so they cannot disagree — the same reason the token
17
+ * block had to be extracted.
18
+ *
19
+ * ⚠️ `asset:` IS NOT A REAL SCHEME. If a host forgets to substitute, the CSP blocks it and
20
+ * the image silently does not render. That is the failure mode to design against, which is
21
+ * why `canvas_validate` errors on a reference with no matching asset, and why the renderer
22
+ * and exporter share one function rather than each implementing the regex.
23
+ */
24
+ /** An image stored against a canvas. */
25
+ export interface CanvasAsset {
26
+ id: number;
27
+ canvasId: number;
28
+ /** The handle the markup references as `asset:<ref>`. */
29
+ ref: string;
30
+ mediaType: string;
31
+ /** base64, no data: prefix. */
32
+ data: string;
33
+ width?: number;
34
+ height?: number;
35
+ /** Encoded size in bytes — what the canvas actually costs to render. */
36
+ bytes: number;
37
+ /** Where it came from, for provenance when someone asks "which file is this?". */
38
+ sourcePath?: string;
39
+ createdAt: Date;
40
+ }
41
+ export interface CreateCanvasAssetInput {
42
+ canvasId: number;
43
+ ref: string;
44
+ mediaType: string;
45
+ data: string;
46
+ width?: number;
47
+ height?: number;
48
+ bytes: number;
49
+ sourcePath?: string;
50
+ }
51
+ /**
52
+ * A ref is a short handle an agent types into markup, so it has to be safe to embed in an
53
+ * attribute and easy to get right: lowercase word characters and dashes.
54
+ */
55
+ export declare const ASSET_REF: RegExp;
56
+ export declare const isValidAssetRef: (ref: string) => boolean;
57
+ /** Every asset ref the markup uses, deduplicated, in document order. */
58
+ export declare function referencedAssets(html: string): string[];
59
+ /**
60
+ * Replace every `asset:<ref>` with a real data URI.
61
+ *
62
+ * A reference with no matching asset is LEFT ALONE rather than blanked: the CSP will drop
63
+ * it either way, and leaving it makes the cause visible in devtools instead of presenting
64
+ * an empty `src` that looks like an authoring mistake.
65
+ */
66
+ export declare function resolveCanvasAssets(html: string, assets: readonly Pick<CanvasAsset, 'ref' | 'mediaType' | 'data'>[]): string;
67
+ /**
68
+ * Refs used by the markup that no asset satisfies.
69
+ *
70
+ * ⚠️ This is the check that makes the indirection safe. Without it a typo produces a
71
+ * canvas that renders with a hole in it and no error anywhere.
72
+ */
73
+ export declare function missingAssetRefs(html: string, assets: readonly Pick<CanvasAsset, 'ref'>[]): string[];
74
+ /**
75
+ * The total encoded weight of the assets a canvas actually uses.
76
+ * Unreferenced assets are excluded — they cost storage, not render time.
77
+ */
78
+ export declare function assetPayloadBytes(html: string, assets: readonly Pick<CanvasAsset, 'ref' | 'bytes'>[]): number;
79
+ /**
80
+ * ⚠️ A BUDGET, because every byte here is paid twice — once in the stored canvas and once
81
+ * in every export. Four photos at card size land around 200KB; a single un-resized phone
82
+ * photo is 4MB on its own. The tool refuses above this and says what to do.
83
+ */
84
+ export declare const ASSET_BUDGET_BYTES = 2000000;
85
+ /** Display width an image is resized to before storing, unless the caller says otherwise. */
86
+ export declare const DEFAULT_ASSET_WIDTH = 640;
87
+ /**
88
+ * The inverse of `resolveCanvasAssets`: turn embedded data URIs back into `asset:<ref>`.
89
+ *
90
+ * ⚠️ THIS IS NOT OPTIONAL, it is what keeps the indirection from unravelling. The host
91
+ * bridge serializes the live iframe's DOM back to be persisted after a visual edit — and
92
+ * that DOM holds the RESOLVED data URIs. Without this pass, one visual edit writes every
93
+ * image into the stored document as base64: exactly the state the asset table exists to
94
+ * prevent, arrived at by the back door, and silently.
95
+ *
96
+ * Matching is on the base64 payload rather than the whole URI, so a media type rewritten
97
+ * by the browser still maps home.
98
+ */
99
+ export declare function dereferenceCanvasAssets(html: string, assets: readonly Pick<CanvasAsset, 'ref' | 'data'>[]): string;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Canvas assets — images stored beside a canvas, not inside it.
3
+ *
4
+ * ⚠️ WHY NOT JUST A data: URI IN THE HTML. The sandbox CSP is `img-src data: blob:`, so a
5
+ * data URI is the ONLY thing that renders — but the canvas document is stored in sqlite
6
+ * and edited with `canvas_edit`'s str_replace. Four product photos inline is a few hundred
7
+ * KB of base64 sitting in the middle of the document the agent has to read an outline of
8
+ * and patch by hand. The editing discipline that makes canvases reliable stops working.
9
+ *
10
+ * So the markup carries a REFERENCE and the bytes live in a side table:
11
+ *
12
+ * <img src="asset:hero" alt="…">
13
+ *
14
+ * and the host substitutes real data URIs at the two moments the canvas becomes pixels:
15
+ * rendering into the iframe, and exporting to PDF/HTML/PNG. `resolveCanvasAssets` below is
16
+ * that substitution, shared by both so they cannot disagree — the same reason the token
17
+ * block had to be extracted.
18
+ *
19
+ * ⚠️ `asset:` IS NOT A REAL SCHEME. If a host forgets to substitute, the CSP blocks it and
20
+ * the image silently does not render. That is the failure mode to design against, which is
21
+ * why `canvas_validate` errors on a reference with no matching asset, and why the renderer
22
+ * and exporter share one function rather than each implementing the regex.
23
+ */
24
+ /**
25
+ * A ref is a short handle an agent types into markup, so it has to be safe to embed in an
26
+ * attribute and easy to get right: lowercase word characters and dashes.
27
+ */
28
+ export const ASSET_REF = /^[a-z0-9][a-z0-9-]{0,39}$/;
29
+ export const isValidAssetRef = (ref) => ASSET_REF.test(ref);
30
+ /**
31
+ * ⚠️ Matches `asset:<ref>` ONLY inside an attribute value, not anywhere in the document.
32
+ * A canvas that mentions `asset:hero` in a code sample or a caption must not have its text
33
+ * rewritten into a 200KB base64 blob.
34
+ */
35
+ const REFERENCE = /(["'])asset:([a-z0-9][a-z0-9-]{0,39})\1/gi;
36
+ /** Every asset ref the markup uses, deduplicated, in document order. */
37
+ export function referencedAssets(html) {
38
+ const seen = new Set();
39
+ for (const m of html.matchAll(REFERENCE))
40
+ seen.add(m[2].toLowerCase());
41
+ return [...seen];
42
+ }
43
+ /**
44
+ * Replace every `asset:<ref>` with a real data URI.
45
+ *
46
+ * A reference with no matching asset is LEFT ALONE rather than blanked: the CSP will drop
47
+ * it either way, and leaving it makes the cause visible in devtools instead of presenting
48
+ * an empty `src` that looks like an authoring mistake.
49
+ */
50
+ export function resolveCanvasAssets(html, assets) {
51
+ if (assets.length === 0)
52
+ return html;
53
+ const byRef = new Map(assets.map((a) => [a.ref.toLowerCase(), a]));
54
+ return html.replace(REFERENCE, (whole, quote, ref) => {
55
+ const asset = byRef.get(ref.toLowerCase());
56
+ if (!asset)
57
+ return whole;
58
+ return `${quote}data:${asset.mediaType};base64,${asset.data}${quote}`;
59
+ });
60
+ }
61
+ /**
62
+ * Refs used by the markup that no asset satisfies.
63
+ *
64
+ * ⚠️ This is the check that makes the indirection safe. Without it a typo produces a
65
+ * canvas that renders with a hole in it and no error anywhere.
66
+ */
67
+ export function missingAssetRefs(html, assets) {
68
+ const have = new Set(assets.map((a) => a.ref.toLowerCase()));
69
+ return referencedAssets(html).filter((ref) => !have.has(ref));
70
+ }
71
+ /**
72
+ * The total encoded weight of the assets a canvas actually uses.
73
+ * Unreferenced assets are excluded — they cost storage, not render time.
74
+ */
75
+ export function assetPayloadBytes(html, assets) {
76
+ const used = new Set(referencedAssets(html));
77
+ return assets
78
+ .filter((a) => used.has(a.ref.toLowerCase()))
79
+ .reduce((total, a) => total + a.bytes, 0);
80
+ }
81
+ /**
82
+ * ⚠️ A BUDGET, because every byte here is paid twice — once in the stored canvas and once
83
+ * in every export. Four photos at card size land around 200KB; a single un-resized phone
84
+ * photo is 4MB on its own. The tool refuses above this and says what to do.
85
+ */
86
+ export const ASSET_BUDGET_BYTES = 2_000_000;
87
+ /** Display width an image is resized to before storing, unless the caller says otherwise. */
88
+ export const DEFAULT_ASSET_WIDTH = 640;
89
+ /**
90
+ * The inverse of `resolveCanvasAssets`: turn embedded data URIs back into `asset:<ref>`.
91
+ *
92
+ * ⚠️ THIS IS NOT OPTIONAL, it is what keeps the indirection from unravelling. The host
93
+ * bridge serializes the live iframe's DOM back to be persisted after a visual edit — and
94
+ * that DOM holds the RESOLVED data URIs. Without this pass, one visual edit writes every
95
+ * image into the stored document as base64: exactly the state the asset table exists to
96
+ * prevent, arrived at by the back door, and silently.
97
+ *
98
+ * Matching is on the base64 payload rather than the whole URI, so a media type rewritten
99
+ * by the browser still maps home.
100
+ */
101
+ export function dereferenceCanvasAssets(html, assets) {
102
+ let out = html;
103
+ for (const asset of assets) {
104
+ if (!asset.data)
105
+ continue;
106
+ // `data:<anything>;base64,<payload>` → asset:<ref>, in any attribute quoting.
107
+ const pattern = new RegExp(`data:[^"'\\s]*?base64,${asset.data.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g');
108
+ out = out.replace(pattern, `asset:${asset.ref}`);
109
+ }
110
+ return out;
111
+ }
@@ -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;
@@ -4,3 +4,11 @@
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';
13
+ export { resolveCanvasAssets, dereferenceCanvasAssets, referencedAssets, missingAssetRefs, assetPayloadBytes, isValidAssetRef, ASSET_BUDGET_BYTES, DEFAULT_ASSET_WIDTH, } from './assets.js';
14
+ export type { CanvasAsset, CreateCanvasAssetInput } from './assets.js';
@@ -4,3 +4,19 @@
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';
17
+ /*
18
+ Assets — images stored beside a canvas. `resolveCanvasAssets` is renderer-safe and is
19
+ shared by the host's live iframe AND its export path; they must not each implement the
20
+ substitution, for the same reason the theme token block had to be extracted.
21
+ */
22
+ export { resolveCanvasAssets, dereferenceCanvasAssets, referencedAssets, missingAssetRefs, assetPayloadBytes, isValidAssetRef, ASSET_BUDGET_BYTES, DEFAULT_ASSET_WIDTH, } from './assets.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[];