@nurix/etna 0.18.19 → 0.18.20
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/data/harness-index.json +1 -1
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/skills/design/SKILL.md +65 -0
- package/data/harnesses/code/skills/design/data/catalog/rules.csv +132 -0
- package/data/harnesses/code/skills/design/data/design-md/SCHEMA.md +317 -0
- package/data/harnesses/code/skills/design/data/design-md/library/airbnb.md +235 -0
- package/data/harnesses/code/skills/design/data/design-md/library/airtable.md +232 -0
- package/data/harnesses/code/skills/design/data/design-md/library/anthropic-claude.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/apple.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/binance.md +223 -0
- package/data/harnesses/code/skills/design/data/design-md/library/bmw.md +219 -0
- package/data/harnesses/code/skills/design/data/design-md/library/cal.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/clay.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/clickhouse.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/cohere.md +227 -0
- package/data/harnesses/code/skills/design/data/design-md/library/coinbase.md +237 -0
- package/data/harnesses/code/skills/design/data/design-md/library/composio.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/cursor.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/elevenlabs.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/expo.md +228 -0
- package/data/harnesses/code/skills/design/data/design-md/library/figma.md +221 -0
- package/data/harnesses/code/skills/design/data/design-md/library/framer.md +236 -0
- package/data/harnesses/code/skills/design/data/design-md/library/hashicorp.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/ibm.md +234 -0
- package/data/harnesses/code/skills/design/data/design-md/library/intercom.md +240 -0
- package/data/harnesses/code/skills/design/data/design-md/library/kraken.md +236 -0
- package/data/harnesses/code/skills/design/data/design-md/library/linear-app.md +237 -0
- package/data/harnesses/code/skills/design/data/design-md/library/lovable.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/meta.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/minimax.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/mintlify.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/miro.md +233 -0
- package/data/harnesses/code/skills/design/data/design-md/library/mistral-ai.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/mongodb.md +236 -0
- package/data/harnesses/code/skills/design/data/design-md/library/notion.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/nvidia.md +222 -0
- package/data/harnesses/code/skills/design/data/design-md/library/ollama.md +226 -0
- package/data/harnesses/code/skills/design/data/design-md/library/opencode-ai.md +223 -0
- package/data/harnesses/code/skills/design/data/design-md/library/posthog.md +234 -0
- package/data/harnesses/code/skills/design/data/design-md/library/raycast.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/replicate.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/resend.md +285 -0
- package/data/harnesses/code/skills/design/data/design-md/library/revolut.md +225 -0
- package/data/harnesses/code/skills/design/data/design-md/library/runwayml.md +219 -0
- package/data/harnesses/code/skills/design/data/design-md/library/sanity.md +251 -0
- package/data/harnesses/code/skills/design/data/design-md/library/sentry.md +244 -0
- package/data/harnesses/code/skills/design/data/design-md/library/shopify.md +227 -0
- package/data/harnesses/code/skills/design/data/design-md/library/slack.md +240 -0
- package/data/harnesses/code/skills/design/data/design-md/library/spacex.md +252 -0
- package/data/harnesses/code/skills/design/data/design-md/library/spotify.md +248 -0
- package/data/harnesses/code/skills/design/data/design-md/library/stripe.md +241 -0
- package/data/harnesses/code/skills/design/data/design-md/library/supabase.md +255 -0
- package/data/harnesses/code/skills/design/data/design-md/library/superhuman.md +226 -0
- package/data/harnesses/code/skills/design/data/design-md/library/tesla.md +233 -0
- package/data/harnesses/code/skills/design/data/design-md/library/theverge.md +248 -0
- package/data/harnesses/code/skills/design/data/design-md/library/together-ai.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/uber.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/vercel.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/voltagent.md +245 -0
- package/data/harnesses/code/skills/design/data/design-md/library/warp.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/webflow.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/wired.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/wise.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/x-ai.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/zapier.md +238 -0
- package/data/harnesses/code/skills/design/data/design-md/nurix.md +217 -0
- package/data/harnesses/code/skills/design/data/operator-pack/exemplars/command-center.tsx +249 -0
- package/data/harnesses/code/skills/design/data/operator-pack/exemplars/create-edit.tsx +249 -0
- package/data/harnesses/code/skills/design/data/operator-pack/exemplars/dashboard.tsx +248 -0
- package/data/harnesses/code/skills/design/data/operator-pack/exemplars/entity-detail.tsx +245 -0
- package/data/harnesses/code/skills/design/data/operator-pack/exemplars/list-detail.tsx +223 -0
- package/data/harnesses/code/skills/design/data/operator-pack/exemplars/log-stream.tsx +249 -0
- package/data/harnesses/code/skills/design/data/operator-pack/fixtures.md +88 -0
- package/data/harnesses/code/skills/design/data/operator-pack/surface-catalog.md +132 -0
- package/data/harnesses/code/skills/design/data/operator-pack/trust-rubric.md +76 -0
- package/data/harnesses/code/skills/design/references/create.md +126 -0
- package/data/harnesses/code/skills/design/references/diagnosis.md +108 -0
- package/data/harnesses/code/skills/design/references/hard-rules.md +155 -0
- package/data/harnesses/code/skills/design/references/improve.md +85 -0
- package/data/harnesses/code/skills/design/references/interaction.md +77 -0
- package/data/harnesses/code/skills/design/references/introspect.md +100 -0
- package/data/harnesses/code/skills/design/references/lifecycle.md +73 -0
- package/data/harnesses/code/skills/design/references/personality.md +504 -0
- package/data/harnesses/code/skills/design/references/structure.md +138 -0
- package/data/harnesses/code/skills/design/scripts/derive-personality.mjs +706 -0
- package/data/harnesses/code/skills/design/scripts/detector.mjs +898 -0
- package/data/harnesses/code/skills/design/scripts/generate-fixtures.mjs +551 -0
- package/data/harnesses/code/skills/design/scripts/hook-config.json +11 -0
- package/data/harnesses/code/skills/design/scripts/phase-gate.mjs +386 -0
- package/data/harnesses/code/skills/design/scripts/preship-sweep.mjs +280 -0
- package/data/harnesses/code/skills/design/scripts/render-rung.mjs +690 -0
- package/data/harnesses/code/skills/design/scripts/scaffold.mjs +552 -0
- package/data/harnesses/code/skills/design/scripts/self-integrity.mjs +432 -0
- package/package.json +1 -1
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
// skills/design/scripts/render-rung.mjs — verification-ladder rung 2: deterministic render checks + pixel instruments (D32)
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { inflateSync } from 'node:zlib';
|
|
5
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
|
+
|
|
8
|
+
// The four viewports the ladder sweeps; width is the breakpoint, height is a fixed tall canvas.
|
|
9
|
+
const VIEWPORTS = [375, 768, 1024, 1440];
|
|
10
|
+
const VIEWPORT_HEIGHT = 900;
|
|
11
|
+
|
|
12
|
+
// Axe rule ids the render rung gates on: the accessible-name family and the ARIA-validity family (int-17 / int-18).
|
|
13
|
+
const AXE_ACCESSIBLE_NAME = ['button-name', 'select-name', 'image-alt', 'link-name'];
|
|
14
|
+
const AXE_ARIA_VALIDITY = ['aria-allowed-attr', 'aria-required-attr', 'aria-required-children', 'aria-valid-attr-value'];
|
|
15
|
+
|
|
16
|
+
// Common local dev-server ports probed when no --url is given.
|
|
17
|
+
const DEV_PORTS = [5173, 4173, 3000, 8080];
|
|
18
|
+
|
|
19
|
+
// Severity rank for the delivered (severity-ranked) list; auto-critical is never minted by machine render rules.
|
|
20
|
+
const SEVERITY_RANK = { 'auto-critical': 0, critical: 1, major: 2, minor: 3 };
|
|
21
|
+
|
|
22
|
+
const NO_BROWSER_LINE = 'render rung: no headless browser available in this project; rung skipped (rungs 0-1 still hold)';
|
|
23
|
+
|
|
24
|
+
// ----------------------------------------------------------------------------
|
|
25
|
+
// Pure helpers (exported for unit testing — no browser required)
|
|
26
|
+
// ----------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
// Decode an 8-bit non-interlaced RGB/RGBA PNG into { width, height, channels, data } using only node:zlib.
|
|
29
|
+
export function decodePng(buffer) {
|
|
30
|
+
const sig = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
31
|
+
for (let i = 0; i < 8; i++) if (buffer[i] !== sig[i]) throw new Error('not a PNG (bad signature)');
|
|
32
|
+
let pos = 8;
|
|
33
|
+
let width = 0, height = 0, bitDepth = 0, colorType = 0, interlace = 0;
|
|
34
|
+
const idat = [];
|
|
35
|
+
while (pos < buffer.length) {
|
|
36
|
+
const len = buffer.readUInt32BE(pos); pos += 4;
|
|
37
|
+
const type = buffer.toString('ascii', pos, pos + 4); pos += 4;
|
|
38
|
+
const chunk = buffer.subarray(pos, pos + len); pos += len;
|
|
39
|
+
pos += 4; // skip CRC
|
|
40
|
+
if (type === 'IHDR') {
|
|
41
|
+
width = chunk.readUInt32BE(0);
|
|
42
|
+
height = chunk.readUInt32BE(4);
|
|
43
|
+
bitDepth = chunk[8];
|
|
44
|
+
colorType = chunk[9];
|
|
45
|
+
interlace = chunk[12];
|
|
46
|
+
} else if (type === 'IDAT') {
|
|
47
|
+
idat.push(chunk);
|
|
48
|
+
} else if (type === 'IEND') {
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (bitDepth !== 8) throw new Error(`unsupported bit depth ${bitDepth} (need 8)`);
|
|
53
|
+
if (interlace !== 0) throw new Error('interlaced PNG unsupported');
|
|
54
|
+
const channels = colorType === 2 ? 3 : colorType === 6 ? 4 : 0;
|
|
55
|
+
if (!channels) throw new Error(`unsupported color type ${colorType} (need 2 or 6)`);
|
|
56
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
57
|
+
const stride = width * channels;
|
|
58
|
+
const out = new Uint8Array(height * stride);
|
|
59
|
+
let rp = 0;
|
|
60
|
+
for (let y = 0; y < height; y++) {
|
|
61
|
+
const filter = raw[rp++];
|
|
62
|
+
const rowStart = y * stride;
|
|
63
|
+
for (let i = 0; i < stride; i++) {
|
|
64
|
+
const x = raw[rp++];
|
|
65
|
+
const a = i >= channels ? out[rowStart + i - channels] : 0;
|
|
66
|
+
const b = y > 0 ? out[rowStart - stride + i] : 0;
|
|
67
|
+
const c = y > 0 && i >= channels ? out[rowStart - stride + i - channels] : 0;
|
|
68
|
+
let recon;
|
|
69
|
+
switch (filter) {
|
|
70
|
+
case 0: recon = x; break;
|
|
71
|
+
case 1: recon = x + a; break;
|
|
72
|
+
case 2: recon = x + b; break;
|
|
73
|
+
case 3: recon = x + ((a + b) >> 1); break;
|
|
74
|
+
case 4: recon = x + paethPredictor(a, b, c); break;
|
|
75
|
+
default: throw new Error(`unknown PNG filter ${filter}`);
|
|
76
|
+
}
|
|
77
|
+
out[rowStart + i] = recon & 0xff;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { width, height, channels, data: out };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The PNG Paeth predictor: pick the neighbour (left/up/upper-left) closest to the initial estimate.
|
|
84
|
+
function paethPredictor(a, b, c) {
|
|
85
|
+
const p = a + b - c;
|
|
86
|
+
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
87
|
+
if (pa <= pb && pa <= pc) return a;
|
|
88
|
+
if (pb <= pc) return b;
|
|
89
|
+
return c;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// inst-01: Sobel gradient-magnitude edge density — edge pixels over interior pixels (evidence, not a verdict).
|
|
93
|
+
export function edgeDensity(img, threshold = 110) {
|
|
94
|
+
const { width, height, channels, data } = img;
|
|
95
|
+
if (width < 3 || height < 3) return 0;
|
|
96
|
+
const gray = new Float64Array(width * height);
|
|
97
|
+
for (let i = 0, p = 0; i < gray.length; i++, p += channels) {
|
|
98
|
+
gray[i] = 0.299 * data[p] + 0.587 * data[p + 1] + 0.114 * data[p + 2];
|
|
99
|
+
}
|
|
100
|
+
let edges = 0;
|
|
101
|
+
let interior = 0;
|
|
102
|
+
for (let y = 1; y < height - 1; y++) {
|
|
103
|
+
for (let x = 1; x < width - 1; x++) {
|
|
104
|
+
const i = y * width + x;
|
|
105
|
+
const tl = gray[i - width - 1], t = gray[i - width], tr = gray[i - width + 1];
|
|
106
|
+
const l = gray[i - 1], r = gray[i + 1];
|
|
107
|
+
const bl = gray[i + width - 1], bb = gray[i + width], br = gray[i + width + 1];
|
|
108
|
+
const gx = (tr + 2 * r + br) - (tl + 2 * l + bl);
|
|
109
|
+
const gy = (bl + 2 * bb + br) - (tl + 2 * t + tr);
|
|
110
|
+
const mag = Math.sqrt(gx * gx + gy * gy);
|
|
111
|
+
if (mag >= threshold) edges++;
|
|
112
|
+
interior++;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return interior === 0 ? 0 : edges / interior;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// inst-05: white-space ratio — fraction of pixels within a small Euclidean distance of the page background colour.
|
|
119
|
+
export function whiteSpaceRatio(img, bg = [255, 255, 255], distance = 20) {
|
|
120
|
+
const { width, height, channels, data } = img;
|
|
121
|
+
const total = width * height;
|
|
122
|
+
if (total === 0) return 0;
|
|
123
|
+
const d2 = distance * distance;
|
|
124
|
+
let near = 0;
|
|
125
|
+
for (let p = 0; p < total * channels; p += channels) {
|
|
126
|
+
const dr = data[p] - bg[0], dg = data[p + 1] - bg[1], db = data[p + 2] - bg[2];
|
|
127
|
+
if (dr * dr + dg * dg + db * db <= d2) near++;
|
|
128
|
+
}
|
|
129
|
+
return near / total;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// inst-10: distinct-RGB count after quantizing each channel to its top 5 bits.
|
|
133
|
+
export function distinctRgbCount(img) {
|
|
134
|
+
const { width, height, channels, data } = img;
|
|
135
|
+
const total = width * height;
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
for (let p = 0; p < total * channels; p += channels) {
|
|
138
|
+
const key = ((data[p] >> 3) << 10) | ((data[p + 1] >> 3) << 5) | (data[p + 2] >> 3);
|
|
139
|
+
seen.add(key);
|
|
140
|
+
}
|
|
141
|
+
return seen.size;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// inst-09: Hasler-Susstrunk colourfulness of the surface.
|
|
145
|
+
export function colorfulness(img) {
|
|
146
|
+
const { width, height, channels, data } = img;
|
|
147
|
+
const total = width * height;
|
|
148
|
+
if (total === 0) return 0;
|
|
149
|
+
let sumRg = 0, sumYb = 0, sumRg2 = 0, sumYb2 = 0;
|
|
150
|
+
for (let p = 0; p < total * channels; p += channels) {
|
|
151
|
+
const rg = data[p] - data[p + 1];
|
|
152
|
+
const yb = 0.5 * (data[p] + data[p + 1]) - data[p + 2];
|
|
153
|
+
sumRg += rg; sumYb += yb; sumRg2 += rg * rg; sumYb2 += yb * yb;
|
|
154
|
+
}
|
|
155
|
+
const meanRg = sumRg / total, meanYb = sumYb / total;
|
|
156
|
+
const stdRg = Math.sqrt(Math.max(0, sumRg2 / total - meanRg * meanRg));
|
|
157
|
+
const stdYb = Math.sqrt(Math.max(0, sumYb2 / total - meanYb * meanYb));
|
|
158
|
+
return Math.sqrt(stdRg * stdRg + stdYb * stdYb) + 0.3 * Math.sqrt(meanRg * meanRg + meanYb * meanYb);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Parse an "rgb(...)" / "rgba(...)" string into [r, g, b, a]; returns null if not parseable.
|
|
162
|
+
export function parseRgb(str) {
|
|
163
|
+
if (!str) return null;
|
|
164
|
+
const m = String(str).match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,/\s]+([\d.]+))?/i);
|
|
165
|
+
if (!m) return null;
|
|
166
|
+
return [Math.round(+m[1]), Math.round(+m[2]), Math.round(+m[3]), m[4] === undefined ? 1 : +m[4]];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// sRGB relative luminance per WCAG 2.x.
|
|
170
|
+
export function relativeLuminance([r, g, b]) {
|
|
171
|
+
const lin = (v) => {
|
|
172
|
+
const s = v / 255;
|
|
173
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
174
|
+
};
|
|
175
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// WCAG contrast ratio between two [r,g,b] colours.
|
|
179
|
+
export function wcagContrast(rgb1, rgb2) {
|
|
180
|
+
const l1 = relativeLuminance(rgb1), l2 = relativeLuminance(rgb2);
|
|
181
|
+
const hi = Math.max(l1, l2), lo = Math.min(l1, l2);
|
|
182
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Extract colour-valued custom-property values from a personality.css text (skips var()-referencing values).
|
|
186
|
+
export function extractCssColorValues(cssText) {
|
|
187
|
+
const values = [];
|
|
188
|
+
const re = /--[\w-]+\s*:\s*([^;{}]+);/g;
|
|
189
|
+
let m;
|
|
190
|
+
const colorish = /(#[0-9a-f]{3,8}\b)|\b(rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\s*\(/i;
|
|
191
|
+
while ((m = re.exec(cssText)) !== null) {
|
|
192
|
+
const value = m[1].trim();
|
|
193
|
+
if (value.includes('var(')) continue;
|
|
194
|
+
if (colorish.test(value)) values.push(value);
|
|
195
|
+
}
|
|
196
|
+
return [...new Set(values)];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Normalize an [r,g,b] triple to the "r,g,b" set key.
|
|
200
|
+
function rgbKey(rgb) {
|
|
201
|
+
return `${rgb[0]},${rgb[1]},${rgb[2]}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ----------------------------------------------------------------------------
|
|
205
|
+
// CLI plumbing
|
|
206
|
+
// ----------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
// Parse argv into { project, url }.
|
|
209
|
+
function parseArgs(argv) {
|
|
210
|
+
const out = { project: process.cwd(), url: null };
|
|
211
|
+
for (let i = 0; i < argv.length; i++) {
|
|
212
|
+
const a = argv[i];
|
|
213
|
+
if (a === '--project') out.project = resolve(argv[++i] ?? '.');
|
|
214
|
+
else if (a.startsWith('--project=')) out.project = resolve(a.slice('--project='.length));
|
|
215
|
+
else if (a === '--url') out.url = argv[++i] ?? null;
|
|
216
|
+
else if (a.startsWith('--url=')) out.url = a.slice('--url='.length);
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Try to import playwright, playwright-core, then puppeteer from the target project; return a driver adapter or null.
|
|
222
|
+
async function loadBrowserDriver(projectDir) {
|
|
223
|
+
const require = createRequire(join(resolve(projectDir), 'package.json'));
|
|
224
|
+
for (const name of ['playwright', 'playwright-core', 'puppeteer']) {
|
|
225
|
+
let resolved;
|
|
226
|
+
try { resolved = require.resolve(name); } catch { continue; }
|
|
227
|
+
let mod;
|
|
228
|
+
try { mod = await import(pathToFileURL(resolved).href); } catch { continue; }
|
|
229
|
+
const ns = mod && mod.default && (mod.default.chromium || typeof mod.default.launch === 'function') ? mod.default : mod;
|
|
230
|
+
if (ns.chromium) return { kind: 'playwright', name, launcher: ns.chromium };
|
|
231
|
+
if (typeof ns.launch === 'function') return { kind: 'puppeteer', name, launcher: ns };
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Install a self-contained init script (runs before page scripts on every navigation) across the playwright / puppeteer split.
|
|
237
|
+
async function addInitScript(driver, page, fn) {
|
|
238
|
+
if (driver.kind === "playwright") await page.addInitScript(fn);
|
|
239
|
+
else await page.evaluateOnNewDocument(fn);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Set the page viewport across the playwright / puppeteer API split.
|
|
243
|
+
async function setViewport(driver, page, width) {
|
|
244
|
+
if (driver.kind === 'playwright') await page.setViewportSize({ width, height: VIEWPORT_HEIGHT });
|
|
245
|
+
else await page.setViewport({ width, height: VIEWPORT_HEIGHT });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Probe common localhost dev ports; return the first reachable URL or null.
|
|
249
|
+
async function detectDevServer() {
|
|
250
|
+
for (const port of DEV_PORTS) {
|
|
251
|
+
const url = `http://localhost:${port}`;
|
|
252
|
+
const controller = new AbortController();
|
|
253
|
+
const timer = setTimeout(() => controller.abort(), 400);
|
|
254
|
+
try {
|
|
255
|
+
await fetch(url, { signal: controller.signal, redirect: 'manual' });
|
|
256
|
+
clearTimeout(timer);
|
|
257
|
+
return url;
|
|
258
|
+
} catch {
|
|
259
|
+
clearTimeout(timer);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ----------------------------------------------------------------------------
|
|
266
|
+
// In-page collectors (serialized into the browser; must be self-contained)
|
|
267
|
+
// ----------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
// Return true if the page scrolls horizontally at the current viewport.
|
|
270
|
+
function inPageOverflow() {
|
|
271
|
+
const el = document.scrollingElement || document.documentElement;
|
|
272
|
+
return { scrollWidth: el.scrollWidth, innerWidth: window.innerWidth, overflow: el.scrollWidth > window.innerWidth };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Walk visible text nodes, compute effective contrast against the nearest opaque ancestor background, return offenders.
|
|
276
|
+
function inPageTextContrast(cap) {
|
|
277
|
+
const parseRgb = (s) => {
|
|
278
|
+
const m = String(s).match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,/\s]+([\d.]+))?/i);
|
|
279
|
+
return m ? [Math.round(+m[1]), Math.round(+m[2]), Math.round(+m[3]), m[4] === undefined ? 1 : +m[4]] : null;
|
|
280
|
+
};
|
|
281
|
+
const lum = ([r, g, b]) => {
|
|
282
|
+
const f = (v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); };
|
|
283
|
+
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
|
284
|
+
};
|
|
285
|
+
const ratio = (a, b) => { const l1 = lum(a), l2 = lum(b); const hi = Math.max(l1, l2), lo = Math.min(l1, l2); return (hi + 0.05) / (lo + 0.05); };
|
|
286
|
+
const path = (el) => {
|
|
287
|
+
let s = el.tagName.toLowerCase();
|
|
288
|
+
if (el.id) s += '#' + el.id;
|
|
289
|
+
else if (el.classList.length) s += '.' + el.classList[0];
|
|
290
|
+
return s;
|
|
291
|
+
};
|
|
292
|
+
const effectiveBg = (el) => {
|
|
293
|
+
let node = el;
|
|
294
|
+
while (node && node.nodeType === 1) {
|
|
295
|
+
const bg = parseRgb(getComputedStyle(node).backgroundColor);
|
|
296
|
+
if (bg && bg[3] > 0) return [bg[0], bg[1], bg[2]];
|
|
297
|
+
node = node.parentElement;
|
|
298
|
+
}
|
|
299
|
+
return [255, 255, 255];
|
|
300
|
+
};
|
|
301
|
+
const offenders = [];
|
|
302
|
+
const seen = new Set();
|
|
303
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
304
|
+
let n;
|
|
305
|
+
while ((n = walker.nextNode())) {
|
|
306
|
+
if (!n.nodeValue || !n.nodeValue.trim()) continue;
|
|
307
|
+
const el = n.parentElement;
|
|
308
|
+
if (!el) continue;
|
|
309
|
+
const cs = getComputedStyle(el);
|
|
310
|
+
if (cs.visibility === 'hidden' || cs.display === 'none' || +cs.opacity === 0) continue;
|
|
311
|
+
const rect = el.getBoundingClientRect();
|
|
312
|
+
if (rect.width < 1 || rect.height < 1) continue;
|
|
313
|
+
const fg = parseRgb(cs.color);
|
|
314
|
+
if (!fg || fg[3] === 0) continue;
|
|
315
|
+
const bg = effectiveBg(el);
|
|
316
|
+
const size = parseFloat(cs.fontSize) || 16;
|
|
317
|
+
const weight = parseInt(cs.fontWeight, 10) || 400;
|
|
318
|
+
const large = size >= 24 || (size >= 18.66 && weight >= 700);
|
|
319
|
+
const threshold = large ? 3 : 4.5;
|
|
320
|
+
const r = ratio([fg[0], fg[1], fg[2]], bg);
|
|
321
|
+
if (r < threshold) {
|
|
322
|
+
const key = path(el) + '|' + cs.color;
|
|
323
|
+
if (seen.has(key)) continue;
|
|
324
|
+
seen.add(key);
|
|
325
|
+
offenders.push({ selector: path(el), color: cs.color, bg: `rgb(${bg.join(', ')})`, ratio: +r.toFixed(2), threshold, large });
|
|
326
|
+
if (offenders.length >= cap) break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return offenders;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Return the geometry of every visible interactive element for target-size analysis.
|
|
333
|
+
function inPageTargets() {
|
|
334
|
+
const sel = 'a[href], button, input:not([type=hidden]), select, textarea, [role=button], [role=link], [role=menuitem], [role=tab], [role=checkbox], [role=switch], [tabindex]:not([tabindex="-1"])';
|
|
335
|
+
const path = (el) => {
|
|
336
|
+
let s = el.tagName.toLowerCase();
|
|
337
|
+
if (el.id) s += '#' + el.id;
|
|
338
|
+
else if (el.classList.length) s += '.' + el.classList[0];
|
|
339
|
+
return s;
|
|
340
|
+
};
|
|
341
|
+
const out = [];
|
|
342
|
+
for (const el of document.querySelectorAll(sel)) {
|
|
343
|
+
const cs = getComputedStyle(el);
|
|
344
|
+
if (cs.visibility === 'hidden' || cs.display === 'none' || +cs.opacity === 0) continue;
|
|
345
|
+
if (el.disabled) continue;
|
|
346
|
+
const r = el.getBoundingClientRect();
|
|
347
|
+
if (r.width < 1 || r.height < 1) continue;
|
|
348
|
+
out.push({ selector: path(el), x: r.x, y: r.y, w: r.width, h: r.height });
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Resolve a list of CSS colour strings to canonical "rgb(...)" via a probe element.
|
|
354
|
+
function inPageResolveColors(colorStrings) {
|
|
355
|
+
const probe = document.createElement('span');
|
|
356
|
+
probe.style.display = 'none';
|
|
357
|
+
document.body.appendChild(probe);
|
|
358
|
+
const out = [];
|
|
359
|
+
for (const s of colorStrings) {
|
|
360
|
+
probe.style.color = '';
|
|
361
|
+
probe.style.color = s;
|
|
362
|
+
out.push(getComputedStyle(probe).color);
|
|
363
|
+
}
|
|
364
|
+
probe.remove();
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Sample computed foreground / background / border colours across visible elements for drift analysis.
|
|
369
|
+
function inPageComputedColors(cap) {
|
|
370
|
+
const path = (el) => {
|
|
371
|
+
let s = el.tagName.toLowerCase();
|
|
372
|
+
if (el.id) s += '#' + el.id;
|
|
373
|
+
else if (el.classList.length) s += '.' + el.classList[0];
|
|
374
|
+
return s;
|
|
375
|
+
};
|
|
376
|
+
const out = [];
|
|
377
|
+
const all = document.querySelectorAll('body *');
|
|
378
|
+
const step = Math.max(1, Math.floor(all.length / cap));
|
|
379
|
+
for (let i = 0; i < all.length; i += step) {
|
|
380
|
+
const el = all[i];
|
|
381
|
+
const cs = getComputedStyle(el);
|
|
382
|
+
if (cs.visibility === 'hidden' || cs.display === 'none') continue;
|
|
383
|
+
const rect = el.getBoundingClientRect();
|
|
384
|
+
if (rect.width < 1 || rect.height < 1) continue;
|
|
385
|
+
const entry = { selector: path(el), colors: [] };
|
|
386
|
+
if (el.textContent && el.textContent.trim()) entry.colors.push(['color', cs.color]);
|
|
387
|
+
if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') entry.colors.push(['background', cs.backgroundColor]);
|
|
388
|
+
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
|
|
389
|
+
if (parseFloat(cs['border' + side + 'Width']) > 0) entry.colors.push(['border', cs['border' + side + 'Color']]);
|
|
390
|
+
}
|
|
391
|
+
if (entry.colors.length) out.push(entry);
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Install PerformanceObservers (cod-12) before any page script runs, accumulating CLS and the latest LCP on window.
|
|
397
|
+
function inPagePerfInit() {
|
|
398
|
+
window.__perf = { cls: 0, lcp: 0 };
|
|
399
|
+
try {
|
|
400
|
+
new PerformanceObserver((list) => {
|
|
401
|
+
for (const e of list.getEntries()) if (!e.hadRecentInput) window.__perf.cls += e.value;
|
|
402
|
+
}).observe({ type: "layout-shift", buffered: true });
|
|
403
|
+
} catch (e) { /* layout-shift not supported in this build */ }
|
|
404
|
+
try {
|
|
405
|
+
new PerformanceObserver((list) => {
|
|
406
|
+
const entries = list.getEntries();
|
|
407
|
+
const last = entries[entries.length - 1];
|
|
408
|
+
if (last) window.__perf.lcp = last.renderTime || last.loadTime || last.startTime || 0;
|
|
409
|
+
}).observe({ type: "largest-contentful-paint", buffered: true });
|
|
410
|
+
} catch (e) { /* largest-contentful-paint not supported in this build */ }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Read the accumulated perf metrics installed by inPagePerfInit.
|
|
414
|
+
function inPagePerfRead() {
|
|
415
|
+
return window.__perf || { cls: 0, lcp: 0 };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Read the page's effective background colour for the white-space instrument.
|
|
419
|
+
function inPageBackground() {
|
|
420
|
+
const parse = (s) => {
|
|
421
|
+
const m = String(s).match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,/\s]+([\d.]+))?/i);
|
|
422
|
+
return m ? [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : +m[4]] : null;
|
|
423
|
+
};
|
|
424
|
+
for (const el of [document.body, document.documentElement]) {
|
|
425
|
+
const bg = parse(getComputedStyle(el).backgroundColor);
|
|
426
|
+
if (bg && bg[3] > 0) return [Math.round(bg[0]), Math.round(bg[1]), Math.round(bg[2])];
|
|
427
|
+
}
|
|
428
|
+
return [255, 255, 255];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ----------------------------------------------------------------------------
|
|
432
|
+
// Finding accumulation + output
|
|
433
|
+
// ----------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
// Register a machine-rule finding, deduped by anchor+evidence, tracking which viewports saw it.
|
|
436
|
+
function addFinding(findings, viewport, finding) {
|
|
437
|
+
const key = `${finding.anchor}|${finding.evidenceKey}`;
|
|
438
|
+
const existing = findings.get(key);
|
|
439
|
+
if (existing) existing.viewports.add(viewport);
|
|
440
|
+
else findings.set(key, { ...finding, viewports: new Set([viewport]) });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Print one finding in the detector's fix-or-classify format.
|
|
444
|
+
function printFinding(f) {
|
|
445
|
+
const vp = [...f.viewports].sort((a, b) => a - b).join(', ');
|
|
446
|
+
console.log(`[${f.severity}] ${f.anchor} ${f.dimension} — ${f.statement}`);
|
|
447
|
+
console.log(` viewport ${vp} · ${f.where}`);
|
|
448
|
+
console.log(` before -> after · ${f.before} -> ${f.after}`);
|
|
449
|
+
console.log(` consequence · ${f.consequence}`);
|
|
450
|
+
console.log(' fix it now, or classify why the rule does not apply and record that classification in docs/memory/design-decay.md — never ignore silently.');
|
|
451
|
+
console.log('');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ----------------------------------------------------------------------------
|
|
455
|
+
// Main
|
|
456
|
+
// ----------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
// Run the render rung: load a browser, sweep viewports for machine findings, compute pixel instruments.
|
|
459
|
+
async function main() {
|
|
460
|
+
const { project, url: urlArg } = parseArgs(process.argv.slice(2));
|
|
461
|
+
|
|
462
|
+
const driver = await loadBrowserDriver(project);
|
|
463
|
+
if (!driver) {
|
|
464
|
+
console.log(NO_BROWSER_LINE);
|
|
465
|
+
return 0;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
let url = urlArg;
|
|
469
|
+
if (!url) {
|
|
470
|
+
url = await detectDevServer();
|
|
471
|
+
if (url) console.log(`render rung: no --url given; using detected dev server ${url}`);
|
|
472
|
+
}
|
|
473
|
+
if (!url) {
|
|
474
|
+
console.log('render rung: no --url given and no dev server detected on common ports; pass --url <dev-server-url>. rung skipped.');
|
|
475
|
+
return 0;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Resolve axe-core from the project if present; gate only the accessible-name + ARIA-validity families.
|
|
479
|
+
let axeSource = null;
|
|
480
|
+
try {
|
|
481
|
+
const require = createRequire(join(resolve(project), 'package.json'));
|
|
482
|
+
axeSource = readFileSync(require.resolve('axe-core'), 'utf8');
|
|
483
|
+
} catch {
|
|
484
|
+
console.log('render rung: axe-core not installed in this project; the accessible-name and ARIA-validity checks (int-17/int-18) are skipped.');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Parse the project's personality.css token colours for the drift check (cod-02).
|
|
488
|
+
let tokenColorStrings = null;
|
|
489
|
+
const cssPath = join(resolve(project), 'src', 'styles', 'personality.css');
|
|
490
|
+
if (existsSync(cssPath)) {
|
|
491
|
+
tokenColorStrings = extractCssColorValues(readFileSync(cssPath, 'utf8'));
|
|
492
|
+
} else {
|
|
493
|
+
console.log('render rung: no src/styles/personality.css found; the rendered token-drift check (cod-02) is skipped.');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const findings = new Map();
|
|
497
|
+
const instruments = {};
|
|
498
|
+
const axeRules = [...(axeSource ? AXE_ACCESSIBLE_NAME.concat(AXE_ARIA_VALIDITY) : [])];
|
|
499
|
+
let axeSeen = new Set();
|
|
500
|
+
|
|
501
|
+
const browser = await driver.launcher.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] });
|
|
502
|
+
try {
|
|
503
|
+
const page = await browser.newPage();
|
|
504
|
+
// cod-12: register the perf observers before any navigation so LCP/CLS are captured from first paint.
|
|
505
|
+
try { await addInitScript(driver, page, inPagePerfInit); } catch (err) { /* init-script API unavailable; cod-12 perf skipped */ }
|
|
506
|
+
for (const width of VIEWPORTS) {
|
|
507
|
+
await setViewport(driver, page, width);
|
|
508
|
+
try {
|
|
509
|
+
await page.goto(url, { waitUntil: 'load', timeout: 30000 });
|
|
510
|
+
} catch (err) {
|
|
511
|
+
console.log(`render rung: could not load ${url} at ${width}px (${err.message}); is the dev server running? rung skipped.`);
|
|
512
|
+
await browser.close();
|
|
513
|
+
return 0;
|
|
514
|
+
}
|
|
515
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
516
|
+
|
|
517
|
+
// (1) spc-10 — horizontal overflow.
|
|
518
|
+
const ov = await page.evaluate(inPageOverflow);
|
|
519
|
+
if (ov.overflow) {
|
|
520
|
+
addFinding(findings, width, {
|
|
521
|
+
anchor: 'spc-10', dimension: 'P3 Spatial', statement: 'horizontal scroll at this viewport means the design is broken',
|
|
522
|
+
evidenceKey: 'overflow', where: `document scrollWidth ${ov.scrollWidth}px > innerWidth ${ov.innerWidth}px`,
|
|
523
|
+
before: `${ov.scrollWidth}px content`, after: `<= ${ov.innerWidth}px (no horizontal scroll)`,
|
|
524
|
+
consequence: 'the operator scrolls sideways to reach controls; layout reads as broken', severity: 'critical',
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// (2) typ-01 — effective text contrast.
|
|
529
|
+
const contrast = await page.evaluate(inPageTextContrast, 60);
|
|
530
|
+
for (const o of contrast) {
|
|
531
|
+
addFinding(findings, width, {
|
|
532
|
+
anchor: 'typ-01', dimension: 'P1 Typography', statement: 'text contrast below the readable floor',
|
|
533
|
+
evidenceKey: `${o.selector}|${o.color}`, where: `${o.selector} · ${o.color} on ${o.bg}`,
|
|
534
|
+
before: `${o.ratio}:1`, after: `>= ${o.threshold}:1 (${o.large ? 'large' : 'body'})`,
|
|
535
|
+
consequence: 'the operator cannot read the text; the surface reads as broken', severity: 'critical',
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// (3) spc-03 — target geometry with the int-11 >=8px spacing exception.
|
|
540
|
+
const targets = await page.evaluate(inPageTargets);
|
|
541
|
+
for (const t of targets) {
|
|
542
|
+
if (t.w >= 24 && t.h >= 24) continue;
|
|
543
|
+
if (isSpacingExcepted(t, targets)) continue;
|
|
544
|
+
addFinding(findings, width, {
|
|
545
|
+
anchor: 'spc-03', dimension: 'P3 Spatial', statement: 'interactive target below the 24x24 CSS-px minimum (WCAG 2.5.8) and not covered by the >=8px spacing exception',
|
|
546
|
+
evidenceKey: t.selector, where: `${t.selector} · ${Math.round(t.w)}x${Math.round(t.h)}px`,
|
|
547
|
+
before: `${Math.round(t.w)}x${Math.round(t.h)}px`, after: '>= 24x24px, or >= 8px clearance from every neighbour',
|
|
548
|
+
consequence: 'the target is hard to hit; mis-clicks cost the operator time and trust', severity: 'major',
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// (4) cod-02 — rendered token drift.
|
|
553
|
+
if (tokenColorStrings && tokenColorStrings.length) {
|
|
554
|
+
const resolved = await page.evaluate(inPageResolveColors, tokenColorStrings);
|
|
555
|
+
const allowed = new Set();
|
|
556
|
+
for (const s of resolved) { const rgb = parseRgb(s); if (rgb) allowed.add(rgbKey(rgb)); }
|
|
557
|
+
const sampled = await page.evaluate(inPageComputedColors, 400);
|
|
558
|
+
for (const el of sampled) {
|
|
559
|
+
for (const [prop, colStr] of el.colors) {
|
|
560
|
+
const rgb = parseRgb(colStr);
|
|
561
|
+
if (!rgb || rgb[3] === 0) continue;
|
|
562
|
+
if (allowed.has(rgbKey(rgb))) continue;
|
|
563
|
+
addFinding(findings, width, {
|
|
564
|
+
anchor: 'cod-02', dimension: 'S3 Design Tokens', statement: 'rendered colour resolves outside the personality.css token vocabulary (drift)',
|
|
565
|
+
evidenceKey: `${prop}|${rgbKey(rgb)}`, where: `${el.selector} · ${prop} ${colStr} not among the emitted tokens`,
|
|
566
|
+
before: colStr, after: 'a value from the emitted token surface',
|
|
567
|
+
consequence: 'an off-manifest colour erodes token discipline; the look drifts from the resolved personality', severity: 'minor',
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// (5) int-17 / int-18 — axe accessible-name + ARIA-validity families.
|
|
574
|
+
if (axeSource) {
|
|
575
|
+
try {
|
|
576
|
+
await page.addScriptTag({ content: axeSource });
|
|
577
|
+
const violations = await page.evaluate(async (rules) => {
|
|
578
|
+
const res = await window.axe.run(document, { runOnly: { type: 'rule', values: rules }, resultTypes: ['violations'] });
|
|
579
|
+
return res.violations.map((v) => ({ id: v.id, impact: v.impact, count: v.nodes.length, target: v.nodes[0] ? String(v.nodes[0].target[0]) : '' }));
|
|
580
|
+
}, axeRules);
|
|
581
|
+
for (const v of violations) {
|
|
582
|
+
if (axeSeen.has(v.id)) continue;
|
|
583
|
+
axeSeen.add(v.id);
|
|
584
|
+
const isName = AXE_ACCESSIBLE_NAME.includes(v.id);
|
|
585
|
+
addFinding(findings, width, {
|
|
586
|
+
anchor: isName ? 'int-17' : 'int-18', dimension: 'S1 Semantic Markup',
|
|
587
|
+
statement: isName ? `axe ${v.id}: element lacks a discernible accessible name` : `axe ${v.id}: invalid ARIA usage`,
|
|
588
|
+
evidenceKey: v.id, where: `${v.count} node(s), e.g. ${v.target || '(unnamed)'}`,
|
|
589
|
+
before: `axe ${v.id} violation (${v.impact || 'serious'})`, after: 'zero violations in this rule',
|
|
590
|
+
consequence: isName ? 'assistive tech announces nothing actionable; keyboard and screen-reader operators are blocked' : 'invalid ARIA misleads assistive tech about state and role', severity: 'critical',
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
} catch (err) {
|
|
594
|
+
console.log(`render rung: axe run failed at ${width}px (${err.message}); continuing without the accessible-name/ARIA checks.`);
|
|
595
|
+
axeSource = null;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// (6) cod-12 — runtime perf budgets measurable in a passive headless load: LCP and CLS.
|
|
600
|
+
// INP and frame time require real interaction and belong to the interactive rung (3), not this one.
|
|
601
|
+
try {
|
|
602
|
+
const perf = await page.evaluate(inPagePerfRead);
|
|
603
|
+
if (perf && typeof perf.lcp === 'number' && perf.lcp > 2500) {
|
|
604
|
+
addFinding(findings, width, {
|
|
605
|
+
anchor: 'cod-12', dimension: 'S4 Performance', statement: 'largest-contentful-paint over the 2.5s budget',
|
|
606
|
+
evidenceKey: 'lcp', where: `LCP ${Math.round(perf.lcp)}ms at load`,
|
|
607
|
+
before: `${Math.round(perf.lcp)}ms`, after: '<= 2500ms',
|
|
608
|
+
consequence: 'the operator waits on first meaningful paint; the surface feels slow to arrive', severity: 'major',
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
if (perf && typeof perf.cls === 'number' && perf.cls > 0.1) {
|
|
612
|
+
addFinding(findings, width, {
|
|
613
|
+
anchor: 'cod-12', dimension: 'S4 Performance', statement: 'cumulative layout shift over the 0.1 budget',
|
|
614
|
+
evidenceKey: 'cls', where: `CLS ${perf.cls.toFixed(3)} across load`,
|
|
615
|
+
before: perf.cls.toFixed(3), after: '<= 0.1',
|
|
616
|
+
consequence: 'content jumps as it loads; the operator mis-clicks and loses their place', severity: 'major',
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
} catch (err) { /* PerformanceObserver entries unsupported; cod-12 skipped, rungs 0-1 still hold */ }
|
|
620
|
+
|
|
621
|
+
// Instruments — pixel evidence, never a verdict.
|
|
622
|
+
try {
|
|
623
|
+
const shot = Buffer.from(await page.screenshot({ type: 'png' }));
|
|
624
|
+
const bg = await page.evaluate(inPageBackground);
|
|
625
|
+
const img = decodePng(shot);
|
|
626
|
+
instruments[width] = {
|
|
627
|
+
edgeDensity: edgeDensity(img),
|
|
628
|
+
whiteSpace: whiteSpaceRatio(img, bg),
|
|
629
|
+
colorfulness: colorfulness(img),
|
|
630
|
+
distinctRgb: distinctRgbCount(img),
|
|
631
|
+
};
|
|
632
|
+
} catch (err) {
|
|
633
|
+
instruments[width] = { error: err.message };
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
} finally {
|
|
637
|
+
await browser.close();
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return report(findings, instruments);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Return true when an undersized target is covered by the int-11 spacing exception (>=8px clearance from every neighbour).
|
|
644
|
+
export function isSpacingExcepted(t, all) {
|
|
645
|
+
const GAP = 8;
|
|
646
|
+
for (const o of all) {
|
|
647
|
+
if (o === t) continue;
|
|
648
|
+
const overlapX = t.x < o.x + o.w && o.x < t.x + t.w;
|
|
649
|
+
const overlapY = t.y < o.y + o.h && o.y < t.y + t.h;
|
|
650
|
+
if (overlapX && overlapY) return false; // overlapping targets — not excepted
|
|
651
|
+
const gapX = Math.max(o.x - (t.x + t.w), t.x - (o.x + o.w));
|
|
652
|
+
const gapY = Math.max(o.y - (t.y + t.h), t.y - (o.y + o.h));
|
|
653
|
+
if (overlapY && gapX < GAP) return false; // same row band, too close horizontally
|
|
654
|
+
if (overlapX && gapY < GAP) return false; // same column band, too close vertically
|
|
655
|
+
}
|
|
656
|
+
return true;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Print the severity-ranked findings and the instruments block; return the process exit code.
|
|
660
|
+
function report(findings, instruments) {
|
|
661
|
+
const list = [...findings.values()].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
|
|
662
|
+
if (list.length === 0) {
|
|
663
|
+
console.log('render rung: no machine-rule findings across 375/768/1024/1440.');
|
|
664
|
+
} else {
|
|
665
|
+
console.log(`render rung: ${list.length} machine-rule finding(s) — fix or classify each before ship.`);
|
|
666
|
+
console.log('');
|
|
667
|
+
for (const f of list) printFinding(f);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
console.log('instruments (evidence, not verdicts — dense is not bad)');
|
|
671
|
+
console.log(' these numbers are evidence for a judged finding, never a verdict; a high reading is a prompt to look, not proof of a problem.');
|
|
672
|
+
for (const width of VIEWPORTS) {
|
|
673
|
+
const m = instruments[width];
|
|
674
|
+
if (!m) { console.log(` ${width}px: not computed`); continue; }
|
|
675
|
+
if (m.error) { console.log(` ${width}px: not computed (${m.error})`); continue; }
|
|
676
|
+
console.log(` ${width}px: inst-01 edge-density ${m.edgeDensity.toFixed(3)} · inst-05 white-space ${m.whiteSpace.toFixed(3)} · inst-09 colorfulness ${m.colorfulness.toFixed(1)} · inst-10 distinct-rgb ${m.distinctRgb}`);
|
|
677
|
+
}
|
|
678
|
+
console.log(' not computed: inst-02 feature-congestion, inst-03 subband-entropy, inst-04 grid-quality, inst-06 symmetry, inst-07 figure-ground, inst-08 contour-congestion, inst-11 klm-operator-cost');
|
|
679
|
+
console.log(' cod-12: LCP and CLS measured on the passive load; INP and 16ms frame time need real interaction — the interactive rung (3) covers those.');
|
|
680
|
+
|
|
681
|
+
return list.length > 0 ? 1 : 0;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const isMain = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
|
|
685
|
+
if (isMain) {
|
|
686
|
+
main().then((code) => process.exit(code)).catch((err) => {
|
|
687
|
+
console.error(`render rung: unexpected error — ${err.stack || err.message}`);
|
|
688
|
+
process.exit(1);
|
|
689
|
+
});
|
|
690
|
+
}
|