@event4u/agent-config 16.4.0 → 16.5.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 (77) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +40 -191
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/design.md +1 -1
  7. package/dist/agent-src/guidelines/design-antipatterns.md +1 -1
  8. package/dist/agent-src/rules/token-budget-discipline.md +2 -2
  9. package/dist/agent-src/skills/accessibility-auditor/data/aria-patterns.csv +2 -0
  10. package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
  11. package/dist/agent-src/skills/canvas-design/SKILL.md +4 -2
  12. package/dist/agent-src/skills/design-intelligence/references/integration-mapping.md +13 -10
  13. package/dist/agent-src/skills/fe-design/references/design-patterns.md +49 -2
  14. package/dist/agent-src/skills/frontend-render-security/SKILL.md +9 -0
  15. package/dist/agent-src/skills/iconography/SKILL.md +4 -2
  16. package/dist/agent-src/skills/image-provider-routing/SKILL.md +16 -5
  17. package/dist/agent-src/skills/logo-generation/SKILL.md +12 -8
  18. package/dist/agent-src/skills/svg-authoring/SKILL.md +318 -0
  19. package/dist/agent-src/skills/wireframe/SKILL.md +5 -2
  20. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/dist/cli/commands/uiRender.js +201 -4
  22. package/dist/cli/commands/uiRenderAsset.js +1308 -0
  23. package/dist/cli/commands/uiRenderAssetCompare.js +752 -0
  24. package/dist/cli/commands/uiRenderAssetGrid.js +265 -0
  25. package/dist/cli/commands/uiRenderAssetGrounds.js +323 -0
  26. package/dist/cli/commands/uiRenderAssetIsolation.js +85 -0
  27. package/dist/cli/commands/uiRenderAssetTypes.js +145 -0
  28. package/dist/cli/main.js +47 -1
  29. package/dist/cli/registry.js +1 -1
  30. package/dist/cli-delegate/{chunk-MSUL5NYP.js → chunk-4DBG5K7M.js} +1 -1
  31. package/dist/cli-delegate/{chunk-AED2VQGM.js → chunk-TMWYUOK4.js} +10 -1
  32. package/dist/cli-delegate/cmd_conformance.js +395 -214
  33. package/dist/cli-delegate/cmd_handoff.js +2 -2
  34. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  35. package/dist/discovery/deprecation-report.md +1 -1
  36. package/dist/discovery/discovery-manifest.json +47 -22
  37. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  38. package/dist/discovery/discovery-manifest.summary.md +6 -6
  39. package/dist/discovery/dormancy-report.md +1 -1
  40. package/dist/discovery/orphan-report.md +1 -1
  41. package/dist/discovery/packs.json +19 -15
  42. package/dist/discovery/trust-report.md +2 -2
  43. package/dist/discovery/workspaces.json +13 -9
  44. package/dist/hooks/dispatch.js +1073 -808
  45. package/dist/hooks/dispatch.sha256 +1 -1
  46. package/dist/hooks/svg_rules.chunk.cjs +1626 -0
  47. package/dist/hooks/svg_rules.chunk.cjs.sha256 +1 -0
  48. package/dist/mcp/registry-manifest.json +3 -3
  49. package/dist/mcp/server.json +2 -2
  50. package/dist/mcp/server.mjs +931 -735
  51. package/dist/scripts/_lib/scan_scope.js +105 -0
  52. package/dist/scripts/_lib/svg_dom.js +284 -0
  53. package/dist/scripts/_lib/svg_geometry.js +477 -0
  54. package/dist/scripts/_lib/svg_motion.js +179 -0
  55. package/dist/scripts/_lib/svg_rules.js +1337 -0
  56. package/dist/scripts/_lib/svg_urls.js +67 -0
  57. package/dist/scripts/lint_svg_asset.js +324 -0
  58. package/docs/guidelines/design-antipatterns.md +1 -1
  59. package/package.json +4 -3
  60. package/src/config/gate-violation-baselines.json +3 -3
  61. package/src/config/routing-coverage-seed.json +9 -2
  62. package/src/scripts/_lib/svg_dom.ts +318 -0
  63. package/src/scripts/_lib/svg_geometry.ts +480 -0
  64. package/src/scripts/_lib/svg_motion.ts +208 -0
  65. package/src/scripts/_lib/svg_rules.ts +1453 -0
  66. package/src/scripts/_lib/svg_urls.ts +72 -0
  67. package/src/scripts/_lib/verification_command.ts +59 -0
  68. package/src/scripts/_lib/verify_clause.ts +27 -17
  69. package/src/scripts/hooks/block_speaking_inbox_dir.ts +16 -1
  70. package/src/scripts/hooks/design_pass_hook.ts +157 -3
  71. package/src/scripts/hooks/svg_obligation.ts +497 -0
  72. package/src/scripts/hooks/svg_rules_loader.ts +133 -0
  73. package/src/scripts/hooks/turn_end_gate_hook.ts +5 -3
  74. package/src/scripts/lint_consolidation_lineage.ts +6 -3
  75. package/src/scripts/lint_design_quality.ts +60 -12
  76. package/src/scripts/lint_motion_authority_drift.ts +27 -0
  77. package/src/scripts/lint_svg_asset.ts +365 -0
@@ -0,0 +1,752 @@
1
+ /**
2
+ * uiRenderAssetCompare — step 2.6 of `road-to-svg-with-a-witness`.
3
+ *
4
+ * One comparison of two renders of the SAME frame: before and after an edit, or
5
+ * a source raster against its vector.
6
+ *
7
+ * Three things this deliberately does not do. It does not decide a surgical
8
+ * edit — `lint_svg_asset --baseline` does, on attributes, because anti-aliasing
9
+ * and font metrics would make a pixel verdict a per-machine verdict. It does
10
+ * not guess a registration for a raster: without a declared one the reading is
11
+ * `unverified`, never the best of several guesses. And it does not call a
12
+ * font-caused delta a failure — a delta whose only cause is a different
13
+ * resolved family reads `degraded`, with BOTH families named.
14
+ */
15
+ import * as fs from 'node:fs';
16
+ import { parseElements, readViewBox } from '../../scripts/lint_svg_asset.js';
17
+ import { confinePage } from './uiRenderAssetIsolation.js';
18
+ import { SAMPLE_RES, } from './uiRenderAssetTypes.js';
19
+ /**
20
+ * `--registration x,y,w,h[@scale]` — the source crop's own pixel frame (S9).
21
+ * An unparseable spec returns `null`, which leaves the raster reading
22
+ * `unverified` rather than letting the comparison guess an offset.
23
+ *
24
+ * Finding 2.4 of `road-to-svg-rules-in-the-dispatch-path`: `scale` was parsed
25
+ * and validated here and then read by nothing, so `@2` was a silent no-op. It
26
+ * now reaches the comparison, which multiplies the crop rect by it — the
27
+ * operator states the crop in logical units and `@2` says the raster is a 2x
28
+ * capture of them.
29
+ *
30
+ * `@0` and `@-1` still return `null`, and that is deliberate rather than
31
+ * leftover: a zero-scale crop has no pixels, so there is nothing to compare,
32
+ * and reporting the registration as absent is the same verdict a reader would
33
+ * reach from an empty crop. The caller prints the spec it could not parse, so
34
+ * "absent" is not the only thing the operator sees.
35
+ */
36
+ export function parseRegistration(spec) {
37
+ if (!spec)
38
+ return null;
39
+ const [frame, scaleRaw] = spec.split('@');
40
+ const parts = (frame ?? '').split(',').map((n) => Number(n.trim()));
41
+ if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n)))
42
+ return null;
43
+ const scale = scaleRaw === undefined ? 1 : Number(scaleRaw);
44
+ if (!Number.isFinite(scale) || scale <= 0)
45
+ return null;
46
+ // The CROP has to be a crop. Finding 7 of the fourth completion review:
47
+ // `scale <= 0` was refused with a reason — a zero or negative scale is not
48
+ // a frame — and the same reason applies to the rectangle, which was never
49
+ // checked. `0,0,0,0` and `10,10,-5,-5` were both accepted, and a crop with
50
+ // no area registers a subject that is not there.
51
+ if (parts[2] <= 0 || parts[3] <= 0)
52
+ return null;
53
+ return {
54
+ x: parts[0],
55
+ y: parts[1],
56
+ width: parts[2],
57
+ height: parts[3],
58
+ scale,
59
+ };
60
+ }
61
+ /**
62
+ * The crop rect in the raster's OWN pixels.
63
+ *
64
+ * Finding 2.4 of `road-to-svg-rules-in-the-dispatch-path`: `Registration.scale`
65
+ * was parsed by the CLI, validated, recorded in the manifest and then read by
66
+ * nothing — `@2` was a silent no-op. The operator states the region in logical
67
+ * units; `@2` says the raster is a 2x capture of them, so the rect in image
68
+ * pixels is the region multiplied through.
69
+ *
70
+ * Its own exported function rather than four expressions inside a
71
+ * `page.evaluate` argument, because the defect was exactly that those four
72
+ * expressions were unreachable from any test that does not drive a browser.
73
+ */
74
+ export function cropRect(registration) {
75
+ if (!registration)
76
+ return null;
77
+ const s = registration.scale;
78
+ return {
79
+ x: registration.x * s,
80
+ y: registration.y * s,
81
+ width: registration.width * s,
82
+ height: registration.height * s,
83
+ };
84
+ }
85
+ export async function sampleSource(context, sourceFile, vb, registration, points, blocked) {
86
+ if (points.length === 0)
87
+ return [];
88
+ let buf;
89
+ try {
90
+ buf = fs.readFileSync(sourceFile);
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ const mime = /\.png$/i.test(sourceFile)
96
+ ? 'image/png'
97
+ : /\.webp$/i.test(sourceFile)
98
+ ? 'image/webp'
99
+ : /\.svg$/i.test(sourceFile)
100
+ ? 'image/svg+xml'
101
+ : 'image/jpeg';
102
+ const crop = cropRect(registration);
103
+ if (!crop)
104
+ return null;
105
+ const page = await context.newPage();
106
+ await confinePage(page, blocked, []);
107
+ try {
108
+ await page.setContent('<!doctype html><body></body>');
109
+ return await page.evaluate(async (args) => {
110
+ const img = new Image();
111
+ img.src = args.url;
112
+ try {
113
+ await img.decode();
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ const w = Math.max(1, Math.round(args.crop.width));
119
+ const h = Math.max(1, Math.round(args.crop.height));
120
+ const canvas = document.createElement('canvas');
121
+ canvas.width = w;
122
+ canvas.height = h;
123
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
124
+ if (!ctx)
125
+ return null;
126
+ ctx.drawImage(img, args.crop.x, args.crop.y, args.crop.width, args.crop.height, 0, 0, w, h);
127
+ const data = ctx.getImageData(0, 0, w, h).data;
128
+ const at = (px, py) => {
129
+ const x = Math.min(w - 1, Math.max(0, Math.round(px)));
130
+ const y = Math.min(h - 1, Math.max(0, Math.round(py)));
131
+ const i = (y * w + x) * 4;
132
+ return [data[i], data[i + 1], data[i + 2]];
133
+ };
134
+ const background = at(0, 0);
135
+ const near = (a, b) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) <= 24;
136
+ return args.points.map((p) => {
137
+ // User units → the crop's own pixels, which is the same
138
+ // mapping the comparison stretches the vector through.
139
+ const rgb = at(((p.x - args.vb.x) / args.vb.w) * w, ((p.y - args.vb.y) / args.vb.h) * h);
140
+ return { isBackground: near(rgb, background), rgb };
141
+ });
142
+ }, { url: `data:${mime};base64,${buf.toString('base64')}`, crop, vb, points });
143
+ }
144
+ finally {
145
+ await page.close();
146
+ }
147
+ }
148
+ /**
149
+ * One comparison of two renders of the SAME frame — before and after an edit,
150
+ * or a source raster against its vector.
151
+ *
152
+ * Three things this deliberately does not do. It does not decide a surgical
153
+ * edit (`lint_svg_asset --baseline` does, on attributes, because anti-aliasing
154
+ * and font metrics would make a pixel verdict a per-machine verdict). It does
155
+ * not guess a registration for a raster — without a declared one the reading is
156
+ * `unverified`, never the best of several guesses. And it does not call a
157
+ * font-caused delta a failure: a delta whose only cause is a different resolved
158
+ * family reads `degraded`, with BOTH families named.
159
+ */
160
+ export async function compareGeometry(context, spec) {
161
+ const baselineContent = fs.readFileSync(spec.baseline, 'utf8');
162
+ const elements = parseElements(baselineContent);
163
+ const vb = readViewBox(elements.find((el) => el.name === 'svg' && !el.closing)) ?? { x: 0, y: 0, w: 100, h: 100 };
164
+ const frame = sampleFrame(vb, SAMPLE_RES);
165
+ const unitsPerSample = 1 / frame.scale;
166
+ const base = {
167
+ verdict: 'unverified',
168
+ reason: '',
169
+ asset_class: spec.assetClass,
170
+ tolerance: spec.tolerance,
171
+ tolerance_source: spec.toleranceSource,
172
+ sample_resolution: SAMPLE_RES,
173
+ anti_alias_band_units: unitsPerSample,
174
+ silhouette_iou: null,
175
+ max_boundary_distance_units: null,
176
+ sub_pixel_offset: null,
177
+ texts: [],
178
+ font_only_delta: false,
179
+ raster: null,
180
+ };
181
+ const candidateIsRaster = /\.(png|jpe?g|webp|gif|bmp|avif)$/i.test(spec.candidate);
182
+ if (candidateIsRaster && !spec.registration) {
183
+ return {
184
+ ...base,
185
+ reason: 'a raster source needs a declared registration (the source crop\'s own pixel frame, S9); ' +
186
+ 'without one the reading is unverified rather than the best of several guesses',
187
+ };
188
+ }
189
+ const page = await context.newPage();
190
+ await page.setViewportSize({ width: SAMPLE_RES, height: SAMPLE_RES });
191
+ await confinePage(page, spec.blocked, []);
192
+ try {
193
+ await page.goto('about:blank');
194
+ const baselineMask = await rasterise(page, spec.baseline, vb, null);
195
+ const candidateMask = await rasterise(page, spec.candidate, vb, spec.registration ?? null);
196
+ if (!baselineMask || !candidateMask) {
197
+ return { ...base, reason: 'one of the two inputs could not be decoded in the renderer' };
198
+ }
199
+ // Step 2.1: against a REGISTERED RASTER the comparison judges the
200
+ // SUBJECT, not everything the crop happened to contain. The subject is
201
+ // the baseline's own silhouette widened by the anti-alias band the
202
+ // comparison already computes — not by a number chosen to pass a
203
+ // fixture. Foreign content and a frame-edge cut are reported as their
204
+ // own readings below rather than folded into IoU, where they made the
205
+ // witness fail a trace its owner had accepted.
206
+ const judgeSubjectOnly = candidateIsRaster && spec.registration !== undefined;
207
+ // The subject for JUDGING is the baseline's silhouette exactly, with no
208
+ // dilation. A dilated mask would let a one-sample ring of whatever
209
+ // surrounds the subject — a glow, a shadow — into the union and cost
210
+ // the IoU about four points on a small mark, which is a tax on being
211
+ // photographed next to something rather than a reading about the
212
+ // conversion. What the restricted IoU then measures is how much of the
213
+ // subject the candidate actually paints; a candidate that misses part
214
+ // of it, or is shifted, still fails.
215
+ //
216
+ // The subject for FOREIGN CONTENT is dilated by the anti-alias band
217
+ // the comparison already computes, so the rasteriser's own edge smear
218
+ // is not reported as a neighbouring mark. Two masks, because the two
219
+ // questions have different right answers at the boundary.
220
+ const subjectForJudging = judgeSubjectOnly ? baselineMask.painted : null;
221
+ const subjectForForeign = judgeSubjectOnly
222
+ ? // ONE SAMPLE, and the units are the point. `unitsPerSample` is
223
+ // user units per sample — `dilate`'s own doc says its radius is
224
+ // in SAMPLES, so passing it read a units value as a sample count
225
+ // (finding 5). It happened to equal 1 only while
226
+ // `frame.scale >= 1`; on a coarse frame — a large viewBox against
227
+ // a small raster — it dilated by several samples and
228
+ // `foreign_content_share` under-reported exactly where the
229
+ // sampling is least precise. The anti-alias band IS one sample by
230
+ // construction, whatever that sample is worth in user units.
231
+ dilate(baselineMask.painted, frame.w, frame.h, 1)
232
+ : null;
233
+ // ALIGNMENT FIRST, ON THE UNCLIPPED CANDIDATE — finding 1 of this
234
+ // change's second completion review, and it is a proof rather than an
235
+ // observation. Clipping the candidate to the baseline's silhouette
236
+ // makes it a SUBSET of the baseline, and for a subset the IoU at zero
237
+ // shift is |candidate| / |baseline|, which no shift can beat: shifting
238
+ // can only move part of the subset off the baseline (the intersection
239
+ // shrinks) and off its own footprint (the union grows). `bestAlignment`
240
+ // replaces its (0,0) seed only on a STRICTLY greater value, so it
241
+ // returned dx = dy = 0 for every registered raster ever measured, and
242
+ // `sub_pixel_offset` was a constant reported as a reading. That is the
243
+ // defect class this branch exists to fix, reintroduced by the clip that
244
+ // fixed a different one.
245
+ //
246
+ // So the offset is read from the candidate as rendered — registration
247
+ // has already cropped the source to the subject, so this is the mark
248
+ // against the mark — and the clip is applied to the ALIGNED candidate,
249
+ // where it still does its own job of keeping a neighbour out of the IoU.
250
+ const { dx, dy } = bestAlignment(baselineMask.painted, candidateMask.painted, frame.w, frame.h);
251
+ const aligned = shift(candidateMask.painted, frame.w, frame.h, dx, dy);
252
+ const candidateForJudging = subjectForJudging === null ? aligned : intersect(aligned, subjectForJudging);
253
+ const iou = iouOf(baselineMask.painted, candidateForJudging);
254
+ const maxDistance = boundaryDistance(baselineMask.painted, candidateForJudging, frame.w, frame.h) * unitsPerSample;
255
+ const foreign = subjectForForeign === null ? null : shareOutside(candidateMask.painted, subjectForForeign);
256
+ const cutSides = judgeSubjectOnly ? touchesFrameEdge(candidateMask.painted, frame.w, frame.h) : [];
257
+ // Text is compared by box and resolved family only.
258
+ const baseTexts = await readTextBoxes(page, spec.baseline);
259
+ const candTexts = candidateIsRaster ? [] : await readTextBoxes(page, spec.candidate);
260
+ const texts = [];
261
+ for (let i = 0; i < Math.min(baseTexts.length, candTexts.length); i++) {
262
+ const a = baseTexts[i];
263
+ const b = candTexts[i];
264
+ if (!a || !b)
265
+ continue;
266
+ texts.push({
267
+ index: i,
268
+ baseline_family: a.family,
269
+ candidate_family: b.family,
270
+ baseline_box: a.box,
271
+ candidate_box: b.box,
272
+ });
273
+ }
274
+ const familyDiffers = texts.some((t) => t.baseline_family !== t.candidate_family);
275
+ // Unshifted on purpose: the alignment search above is driven by the very
276
+ // glyphs under test, so asking whether the delta is text-shaped must not
277
+ // consult an offset the delta itself chose.
278
+ const fontOnly = familyDiffers &&
279
+ disagreementIsInsideTextBoxes(baselineMask.painted, candidateMask.painted, frame, vb, texts);
280
+ let raster = null;
281
+ if (candidateIsRaster && spec.registration) {
282
+ // `whole` stays first, always — it is the reading every consumer
283
+ // already reads, and a per-group breakdown is added beside it
284
+ // rather than instead of it (step 2.2).
285
+ const derived = spec.regions ?? (await readTopLevelGroupBoxes(page, spec.baseline));
286
+ const regions = [
287
+ { id: 'whole', box: { x: vb.x, y: vb.y, w: vb.w, h: vb.h } },
288
+ ...derived.filter((r) => r.id !== 'whole'),
289
+ ];
290
+ const readings = regions.map((region) => rasterRegionReading(baselineMask, candidateMask, frame, vb, region));
291
+ const worst = readings.reduce((acc, r) => (r.mean_colour_error > acc.mean_colour_error ? r : acc), readings[0] ?? { region: 'whole', silhouette_iou: 0, mean_colour_error: 0, share_off_by_40: 0 });
292
+ raster = {
293
+ registration: spec.registration,
294
+ regions: readings,
295
+ worst_region: worst.region,
296
+ foreign_content_share: foreign?.share ?? 0,
297
+ frame_edge_contact: cutSides,
298
+ };
299
+ }
300
+ const withinIou = iou >= spec.tolerance.silhouette_iou_min;
301
+ const withinBoundary = maxDistance <= spec.tolerance.max_boundary_distance_units;
302
+ const verdict = fontOnly
303
+ ? 'degraded'
304
+ : withinIou && withinBoundary
305
+ ? 'pass'
306
+ : 'fail';
307
+ const reason = fontOnly
308
+ ? `the only geometric delta lies inside <text>, and the resolved families differ: ` +
309
+ `"${texts.map((t) => t.baseline_family).join(', ')}" against "${texts.map((t) => t.candidate_family).join(', ')}" — ` +
310
+ 'a renderer delta, never a fail'
311
+ : verdict === 'pass'
312
+ ? `silhouette IoU ${iou.toFixed(4)} and boundary ${maxDistance.toFixed(2)} units are inside the ${spec.assetClass} tolerance`
313
+ : `silhouette IoU ${iou.toFixed(4)} (min ${spec.tolerance.silhouette_iou_min}) / boundary ` +
314
+ `${maxDistance.toFixed(2)} units (max ${spec.tolerance.max_boundary_distance_units})`;
315
+ return {
316
+ ...base,
317
+ verdict,
318
+ reason,
319
+ silhouette_iou: iou,
320
+ max_boundary_distance_units: maxDistance,
321
+ sub_pixel_offset: { dx_units: dx * unitsPerSample, dy_units: dy * unitsPerSample },
322
+ texts,
323
+ font_only_delta: fontOnly,
324
+ raster,
325
+ };
326
+ }
327
+ finally {
328
+ await page.close();
329
+ }
330
+ }
331
+ /**
332
+ * The subject's own neighbourhood — a dilation of the baseline's silhouette.
333
+ *
334
+ * Step 2.1 of `road-to-svg-from-a-live-session`. When the candidate is a
335
+ * REGISTERED RASTER, everything inside the frame is in the comparison:
336
+ * neighbouring marks the crop happened to include, a soft glow around the
337
+ * subject, a drop shadow. The background mask is taken from the corner pixel,
338
+ * so all of it counts as painted and all of it lands in IoU and in the
339
+ * boundary distance. Measured consequence: the witness failed a trace the
340
+ * owner had accepted, and ranked the rejected trace above the accepted
341
+ * construction on every number.
342
+ *
343
+ * `radius` is in SAMPLES, and the caller passes the anti-alias band the
344
+ * comparison already computes — so this widens by exactly the amount the
345
+ * rasteriser can smear an edge and not by a number chosen to make a fixture
346
+ * pass.
347
+ */
348
+ function dilate(mask, w, h, radius) {
349
+ if (radius <= 0)
350
+ return mask;
351
+ const r = Math.ceil(radius);
352
+ const out = new Uint8Array(mask.length);
353
+ for (let y = 0; y < h; y += 1) {
354
+ for (let x = 0; x < w; x += 1) {
355
+ if (mask[y * w + x] !== 1)
356
+ continue;
357
+ for (let dy = -r; dy <= r; dy += 1) {
358
+ const sy = y + dy;
359
+ if (sy < 0 || sy >= h)
360
+ continue;
361
+ for (let dx = -r; dx <= r; dx += 1) {
362
+ const sx = x + dx;
363
+ if (sx < 0 || sx >= w)
364
+ continue;
365
+ out[sy * w + sx] = 1;
366
+ }
367
+ }
368
+ }
369
+ }
370
+ return out;
371
+ }
372
+ /** `a AND b`, pixel for pixel. */
373
+ export function intersect(a, b) {
374
+ const out = new Uint8Array(a.length);
375
+ for (let i = 0; i < a.length; i += 1)
376
+ out[i] = a[i] === 1 && b[i] === 1 ? 1 : 0;
377
+ return out;
378
+ }
379
+ /** Painted pixels in `mask` that `within` does not cover, as a share of `mask`. */
380
+ function shareOutside(mask, within) {
381
+ let painted = 0;
382
+ let outside = 0;
383
+ for (let i = 0; i < mask.length; i += 1) {
384
+ if (mask[i] !== 1)
385
+ continue;
386
+ painted += 1;
387
+ if (within[i] !== 1)
388
+ outside += 1;
389
+ }
390
+ return { share: painted === 0 ? 0 : outside / painted, count: outside };
391
+ }
392
+ /**
393
+ * Does painted content touch the frame's own edge?
394
+ *
395
+ * Step 2.1, and the crown-cut case it exists for: a trace whose crop started
396
+ * five pixels below the top of the mark took the crop border for a contour,
397
+ * and a comparison against that same crop cannot see the cut — the trace and
398
+ * the source agree exactly, because both are missing the same thing. The only
399
+ * signal available is that the content runs into the border, which is what
400
+ * this reports.
401
+ */
402
+ function touchesFrameEdge(mask, w, h) {
403
+ const sides = [];
404
+ const any = (idx, n) => {
405
+ for (let i = 0; i < n; i += 1)
406
+ if (mask[idx(i)] === 1)
407
+ return true;
408
+ return false;
409
+ };
410
+ if (any((i) => i, w))
411
+ sides.push('top');
412
+ if (any((i) => (h - 1) * w + i, w))
413
+ sides.push('bottom');
414
+ if (any((i) => i * w, h))
415
+ sides.push('left');
416
+ if (any((i) => i * w + (w - 1), h))
417
+ sides.push('right');
418
+ return sides;
419
+ }
420
+ /**
421
+ * The sample grid, in viewBox units.
422
+ *
423
+ * MEASURED, not assumed: `drawImage` STRETCHES an SVG into the destination
424
+ * rect — a 160x60 viewBox drawn into 256x256 put a `y = 20..44` rect at rows
425
+ * 85..187, which is 256/60 per unit on the vertical and 256/160 on the
426
+ * horizontal, with no letterbox at all. A square canvas would therefore give
427
+ * the two axes different units and make "the largest boundary distance in
428
+ * viewBox units" two different numbers depending on direction. So the canvas
429
+ * takes the asset's own aspect instead, and one scale governs both axes.
430
+ */
431
+ function sampleFrame(vb, res) {
432
+ const scale = res / Math.max(vb.w, vb.h);
433
+ return {
434
+ w: Math.max(1, Math.round(vb.w * scale)),
435
+ h: Math.max(1, Math.round(vb.h * scale)),
436
+ scale,
437
+ };
438
+ }
439
+ /**
440
+ * Painted = differs from this image's own background.
441
+ *
442
+ * Alpha alone cannot see a `<text>` sitting on an opaque background rect: every
443
+ * pixel of such a file is alpha 255, so an alpha mask reads two different
444
+ * labels as the same silhouette. The corner pixel is the background reference.
445
+ */
446
+ function paintedMask(alpha, rgb) {
447
+ const out = new Uint8Array(alpha.length);
448
+ const bgA = alpha[0];
449
+ const bgR = rgb[0];
450
+ const bgG = rgb[1];
451
+ const bgB = rgb[2];
452
+ for (let i = 0; i < alpha.length; i++) {
453
+ const dA = Math.abs(alpha[i] - bgA);
454
+ const dR = Math.abs(rgb[i * 3] - bgR);
455
+ const dG = Math.abs(rgb[i * 3 + 1] - bgG);
456
+ const dB = Math.abs(rgb[i * 3 + 2] - bgB);
457
+ const visible = alpha[i] >= 16;
458
+ out[i] = visible && (dA > 8 || dR > 8 || dG > 8 || dB > 8) ? 1 : 0;
459
+ }
460
+ return out;
461
+ }
462
+ /**
463
+ * One file sampled into an alpha mask and an RGB buffer, both at `SAMPLE_RES`
464
+ * over the baseline's viewBox. A `data:` URL plus `drawImage` keeps this inside
465
+ * the page and inside the no-network rule — nothing is fetched.
466
+ */
467
+ async function rasterise(page, file, vb, registration) {
468
+ const buf = fs.readFileSync(file);
469
+ const isSvg = /\.svg$/i.test(file);
470
+ const mime = isSvg
471
+ ? 'image/svg+xml'
472
+ : /\.png$/i.test(file)
473
+ ? 'image/png'
474
+ : /\.webp$/i.test(file)
475
+ ? 'image/webp'
476
+ : 'image/jpeg';
477
+ const dataUrl = `data:${mime};base64,${buf.toString('base64')}`;
478
+ const frame = sampleFrame(vb, SAMPLE_RES);
479
+ const out = await page.evaluate(async (args) => {
480
+ const img = new Image();
481
+ img.src = args.url;
482
+ try {
483
+ await img.decode();
484
+ }
485
+ catch {
486
+ return null;
487
+ }
488
+ const canvas = document.createElement('canvas');
489
+ canvas.width = args.w;
490
+ canvas.height = args.h;
491
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
492
+ if (!ctx)
493
+ return null;
494
+ ctx.clearRect(0, 0, args.w, args.h);
495
+ if (args.reg) {
496
+ // The SAME destination rect the vector is stretched into, which
497
+ // is what makes the two masks comparable at all.
498
+ ctx.drawImage(img, args.reg.x, args.reg.y, args.reg.width, args.reg.height, 0, 0, args.w, args.h);
499
+ }
500
+ else {
501
+ ctx.drawImage(img, 0, 0, args.w, args.h);
502
+ }
503
+ const data = ctx.getImageData(0, 0, args.w, args.h).data;
504
+ const alpha = [];
505
+ const rgb = [];
506
+ for (let i = 0; i < data.length; i += 4) {
507
+ alpha.push(data[i + 3]);
508
+ rgb.push(data[i], data[i + 1], data[i + 2]);
509
+ }
510
+ return { alpha, rgb };
511
+ }, {
512
+ url: dataUrl,
513
+ w: frame.w,
514
+ h: frame.h,
515
+ reg: cropRect(registration),
516
+ });
517
+ if (!out)
518
+ return null;
519
+ const alpha = Uint8Array.from(out.alpha);
520
+ const rgb = Uint8Array.from(out.rgb);
521
+ return { alpha, rgb, painted: paintedMask(alpha, rgb) };
522
+ }
523
+ async function readTextBoxes(page, file) {
524
+ if (!/\.svg$/i.test(file))
525
+ return [];
526
+ const svg = fs.readFileSync(file, 'utf8').replace(/<\?xml[^>]*\?>\s*/i, '');
527
+ return page.evaluate((markup) => {
528
+ const holder = document.createElement('div');
529
+ holder.innerHTML = markup;
530
+ document.body.appendChild(holder);
531
+ const out = [];
532
+ for (const t of Array.from(holder.querySelectorAll('text'))) {
533
+ const bb = t.getBBox();
534
+ out.push({
535
+ family: window.getComputedStyle(t).fontFamily,
536
+ box: { x: bb.x, y: bb.y, w: bb.width, h: bb.height },
537
+ });
538
+ }
539
+ holder.remove();
540
+ return out;
541
+ }, svg);
542
+ }
543
+ /**
544
+ * The rendered box of every TOP-LEVEL `<g id>`, in user units.
545
+ *
546
+ * Step 2.2 of `road-to-svg-from-a-live-session`. `CompareSpec.regions` existed
547
+ * and `main.ts` carried no option for it, so `worst_region` was always
548
+ * `whole` and the per-region breakdown an earlier learning asked for was
549
+ * unreachable from the CLI. A flag would have been one more input nobody
550
+ * passes; a hand-built file already names its own parts — `hat`, `head-group`,
551
+ * `arms`, `legs` — so the regions are read from the file instead.
552
+ *
553
+ * TOP-LEVEL only, and `id` required. A nested group is part of its parent's
554
+ * reading, and an anonymous group has no name to report a verdict under.
555
+ */
556
+ async function readTopLevelGroupBoxes(page, file) {
557
+ if (!/\.svg$/i.test(file))
558
+ return [];
559
+ const svg = fs.readFileSync(file, 'utf8').replace(/<\?xml[^>]*\?>\s*/i, '');
560
+ return page.evaluate((markup) => {
561
+ const holder = document.createElement('div');
562
+ holder.innerHTML = markup;
563
+ document.body.appendChild(holder);
564
+ const out = [];
565
+ const root = holder.querySelector('svg');
566
+ if (root) {
567
+ for (const child of Array.from(root.children)) {
568
+ if (child.tagName.toLowerCase() !== 'g')
569
+ continue;
570
+ const id = child.getAttribute('id');
571
+ if (!id)
572
+ continue;
573
+ try {
574
+ const bb = child.getBBox();
575
+ if (bb.width > 0 || bb.height > 0) {
576
+ out.push({ id, box: { x: bb.x, y: bb.y, w: bb.width, h: bb.height } });
577
+ }
578
+ }
579
+ catch {
580
+ // A group the renderer cannot box contributes no region,
581
+ // which leaves `whole` as its reading rather than a guess.
582
+ }
583
+ }
584
+ }
585
+ holder.remove();
586
+ return out;
587
+ }, svg);
588
+ }
589
+ function shift(mask, w, h, dx, dy) {
590
+ if (dx === 0 && dy === 0)
591
+ return mask;
592
+ const out = new Uint8Array(mask.length);
593
+ for (let y = 0; y < h; y++) {
594
+ const sy = y - dy;
595
+ if (sy < 0 || sy >= h)
596
+ continue;
597
+ for (let x = 0; x < w; x++) {
598
+ const sx = x - dx;
599
+ if (sx < 0 || sx >= w)
600
+ continue;
601
+ out[y * w + x] = mask[sy * w + sx];
602
+ }
603
+ }
604
+ return out;
605
+ }
606
+ export function iouOf(a, b) {
607
+ let inter = 0;
608
+ let union = 0;
609
+ for (let i = 0; i < a.length; i++) {
610
+ const pa = a[i] === 1;
611
+ const pb = b[i] === 1;
612
+ if (pa && pb)
613
+ inter += 1;
614
+ if (pa || pb)
615
+ union += 1;
616
+ }
617
+ return union === 0 ? 1 : inter / union;
618
+ }
619
+ /** The sub-pixel offset of the candidate against the baseline is PART of the reading (S12). */
620
+ export function bestAlignment(a, b, w, h) {
621
+ let best = { iou: iouOf(a, b), dx: 0, dy: 0 };
622
+ for (let dy = -2; dy <= 2; dy++) {
623
+ for (let dx = -2; dx <= 2; dx++) {
624
+ if (dx === 0 && dy === 0)
625
+ continue;
626
+ const v = iouOf(a, shift(b, w, h, dx, dy));
627
+ if (v > best.iou)
628
+ best = { iou: v, dx, dy };
629
+ }
630
+ }
631
+ return best;
632
+ }
633
+ /**
634
+ * The thickness of the disagreement band, in sample pixels, with a one-device-
635
+ * pixel anti-aliasing band excluded: a multi-source BFS out of the pixels where
636
+ * the two masks agree, taking the furthest disagreeing pixel.
637
+ */
638
+ function boundaryDistance(a, b, w, h) {
639
+ const dist = new Int32Array(a.length).fill(-1);
640
+ const queue = [];
641
+ for (let i = 0; i < a.length; i++) {
642
+ if ((a[i] === 1) === (b[i] === 1)) {
643
+ dist[i] = 0;
644
+ queue.push(i);
645
+ }
646
+ }
647
+ if (queue.length === 0)
648
+ return Math.max(w, h);
649
+ let head = 0;
650
+ let max = 0;
651
+ while (head < queue.length) {
652
+ const i = queue[head++];
653
+ const d = dist[i];
654
+ const x = i % w;
655
+ const y = (i - x) / w;
656
+ for (const [nx, ny] of [
657
+ [x + 1, y],
658
+ [x - 1, y],
659
+ [x, y + 1],
660
+ [x, y - 1],
661
+ ]) {
662
+ if (nx < 0 || nx >= w || ny < 0 || ny >= h)
663
+ continue;
664
+ const j = ny * w + nx;
665
+ if (dist[j] !== -1)
666
+ continue;
667
+ dist[j] = d + 1;
668
+ if (d + 1 > max)
669
+ max = d + 1;
670
+ queue.push(j);
671
+ }
672
+ }
673
+ // One sample pixel of disagreement is the anti-aliasing band, not a defect.
674
+ return Math.max(0, max - 1);
675
+ }
676
+ function disagreementIsInsideTextBoxes(a, b, frame, vb, texts) {
677
+ if (texts.length === 0)
678
+ return false;
679
+ const boxes = texts.flatMap((t) => [t.baseline_box, t.candidate_box]);
680
+ // One user unit for the glyph's own overshoot, plus one sample pixel for the
681
+ // anti-aliasing band. Wider than that and a real delta beside a label would
682
+ // be swallowed; the differing-family check is the other half of the gate.
683
+ const pad = 1 + 1 / frame.scale;
684
+ let disagreements = 0;
685
+ for (let i = 0; i < a.length; i++) {
686
+ if ((a[i] === 1) === (b[i] === 1))
687
+ continue;
688
+ disagreements += 1;
689
+ const px = i % frame.w;
690
+ const py = (i - px) / frame.w;
691
+ const ux = vb.x + px / frame.scale;
692
+ const uy = vb.y + py / frame.scale;
693
+ const inside = boxes.some((box) => ux >= box.x - pad && ux <= box.x + box.w + pad && uy >= box.y - pad && uy <= box.y + box.h + pad);
694
+ if (!inside)
695
+ return false;
696
+ }
697
+ return disagreements > 0;
698
+ }
699
+ /**
700
+ * TWO numbers per region, because S4 and S13 show either alone misleads:
701
+ * silhouette agreement over the saturated regions, and the color error over
702
+ * every non-background pixel with the share that is badly off beside it.
703
+ */
704
+ function rasterRegionReading(baseline, candidate, frame, vb, region) {
705
+ const inRegion = (i) => {
706
+ const px = i % frame.w;
707
+ const py = (i - px) / frame.w;
708
+ const ux = vb.x + px / frame.scale;
709
+ const uy = vb.y + py / frame.scale;
710
+ return ux >= region.box.x && ux <= region.box.x + region.box.w && uy >= region.box.y && uy <= region.box.y + region.box.h;
711
+ };
712
+ const chroma = (buf, i) => {
713
+ const r = buf[i * 3];
714
+ const g = buf[i * 3 + 1];
715
+ const b = buf[i * 3 + 2];
716
+ return Math.max(r, g, b) - Math.min(r, g, b);
717
+ };
718
+ let inter = 0;
719
+ let union = 0;
720
+ let errorSum = 0;
721
+ let errorCount = 0;
722
+ let off40 = 0;
723
+ for (let i = 0; i < baseline.painted.length; i++) {
724
+ if (!inRegion(i))
725
+ continue;
726
+ const aPainted = baseline.painted[i] === 1;
727
+ const bPainted = candidate.painted[i] === 1;
728
+ const aSat = aPainted && chroma(baseline.rgb, i) >= 24;
729
+ const bSat = bPainted && chroma(candidate.rgb, i) >= 24;
730
+ if (aSat && bSat)
731
+ inter += 1;
732
+ if (aSat || bSat)
733
+ union += 1;
734
+ if (!aPainted && !bPainted)
735
+ continue;
736
+ const d = (Math.abs(baseline.rgb[i * 3] - candidate.rgb[i * 3]) +
737
+ Math.abs(baseline.rgb[i * 3 + 1] - candidate.rgb[i * 3 + 1]) +
738
+ Math.abs(baseline.rgb[i * 3 + 2] - candidate.rgb[i * 3 + 2])) /
739
+ 3;
740
+ errorSum += d;
741
+ errorCount += 1;
742
+ if (d > 40)
743
+ off40 += 1;
744
+ }
745
+ return {
746
+ region: region.id,
747
+ silhouette_iou: union === 0 ? 1 : inter / union,
748
+ mean_colour_error: errorCount === 0 ? 0 : errorSum / errorCount,
749
+ share_off_by_40: errorCount === 0 ? 0 : off40 / errorCount,
750
+ };
751
+ }
752
+ //# sourceMappingURL=uiRenderAssetCompare.js.map