@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,1308 @@
1
+ /**
2
+ * `agent-config ui:render <file> --profile asset` — the engine witness for one
3
+ * hand-authored SVG. Step 2.1 and step 2.6 of `road-to-svg-with-a-witness`.
4
+ *
5
+ * The PAGE profile is untouched. Its `VIEWPORTS` contract, its capture loop and
6
+ * its manifest keys are the same bytes they were before this file existed —
7
+ * step 2.2 pins that, and the pin is the reason the asset code lives here
8
+ * rather than inside `uiRender.ts`'s loop.
9
+ *
10
+ * CLASS A, inherited from `uiRender.ts` and not weakened: the browser this
11
+ * module is handed is closed by its caller in a `finally`, nothing is cached
12
+ * between invocations, and no listener or watch mode exists.
13
+ *
14
+ * WHAT THIS READS, and why each reading is here rather than in the static
15
+ * linter (`src/scripts/lint_svg_asset.ts`):
16
+ *
17
+ * - fill ratio, per asset class — a global threshold read a 24-grid
18
+ * hamburger's designed padding as a loose viewBox (R12). The threshold
19
+ * comes from the fixture manifest's `fill_ratio_thresholds` when one is
20
+ * supplied, from the per-class defaults below otherwise. Never global.
21
+ * - the resolved font family — a `<text>` with no `font-family` resolves to
22
+ * `Times` in Chromium (R1). Only a renderer knows that.
23
+ * - draw-on dead time — `getTotalLength()` against the dash. Deliberately
24
+ * NOT a static rule: the prototype's regex fired on an intentional spinner
25
+ * (R9, R12), so the dash is measured against the geometry it actually runs
26
+ * on or it is not measured at all.
27
+ * - frames at a declared time — `pauseAnimations()` + `setCurrentTime()`.
28
+ * `screenshot({ animations: 'disabled' })` does NOT freeze SMIL: three
29
+ * captures 300 ms apart read `cx` 21.3, 46.3, 70.9, while the pause-and-seek
30
+ * pair held 100 and 100 (R6).
31
+ * - a near-white region enclosed by paint where the source shows a hole (S5).
32
+ * - `img` / CSS-background delivery: the emulated preference does not reach an
33
+ * image document (R5), so the reading is `not_witnessable` and the HOST is
34
+ * checked for a `<picture>` route instead.
35
+ *
36
+ * NO NETWORK, NO SCRIPT, NO FILE OUTSIDE THE PROJECT — `uiRenderAssetIsolation.ts`.
37
+ */
38
+ import * as crypto from 'node:crypto';
39
+ import * as fs from 'node:fs';
40
+ import * as path from 'node:path';
41
+ import { elementExtent, parseElements, readViewBox, vectorVerdict } from '../../scripts/lint_svg_asset.js';
42
+ import { hasCssAnimation, hasSmilMarkup } from '../../scripts/_lib/svg_motion.js';
43
+ import { compareGeometry, sampleSource } from './uiRenderAssetCompare.js';
44
+ import { readGrounds } from './uiRenderAssetGrounds.js';
45
+ import { gradeIconGrid } from './uiRenderAssetGrid.js';
46
+ import { confinePage, fileRootsFor, injectStyle } from './uiRenderAssetIsolation.js';
47
+ export * from './uiRenderAssetGrid.js';
48
+ import { ASSET_CLASSES, BRAND_TOKEN_PATHS, DEFAULT_FILL_RATIO_THRESHOLDS, DEFAULT_GEOMETRY_TOLERANCES, DRAW_ON_DEAD_TIME_THRESHOLD, FALLBACK_BACKGROUNDS, BLEED_SHARE_THRESHOLD, FOREIGN_CONTENT_SHARE_THRESHOLD, HOLE_ATTRIBUTE, HOST_BASENAME, ICON_SIZES, LOW_CONTRAST_SHARE_THRESHOLD, MARK_SMALL_SIZES, NEAR_WHITE_CHANNEL, } from './uiRenderAssetTypes.js';
49
+ export * from './uiRenderAssetTypes.js';
50
+ export * from './uiRenderAssetCompare.js';
51
+ // ---------------------------------------------------------------------------
52
+ // Pure readings — no browser
53
+ // ---------------------------------------------------------------------------
54
+ /**
55
+ * The cost reading — step 3.2 of `road-to-svg-from-a-live-session`.
56
+ *
57
+ * Counted from the bytes, so it needs no renderer and is the same number on
58
+ * every machine. `coordinates` counts the numbers inside `d` and `points`,
59
+ * which is the axis that makes a traced file hostile to edit: one accepted
60
+ * mark went 5.3 KB to 49.5 KB in a conversion and nothing measured it.
61
+ */
62
+ export function costReading(content, elements) {
63
+ let coordinates = 0;
64
+ for (const el of elements) {
65
+ if (el.closing)
66
+ continue;
67
+ for (const key of ['d', 'points']) {
68
+ const v = el.attrs[key];
69
+ if (!v)
70
+ continue;
71
+ coordinates += (v.match(/-?\d*\.?\d+(?:[eE][-+]?\d+)?/g) ?? []).length;
72
+ }
73
+ }
74
+ const open = elements.filter((el) => !el.closing);
75
+ return {
76
+ bytes: Buffer.byteLength(content, 'utf8'),
77
+ elements: open.length,
78
+ paths: open.filter((el) => el.name === 'path').length,
79
+ gradients: open.filter((el) => el.name === 'lineargradient' || el.name === 'radialgradient').length,
80
+ coordinates,
81
+ };
82
+ }
83
+ export function sha256(buf) {
84
+ return crypto.createHash('sha256').update(buf).digest('hex');
85
+ }
86
+ /**
87
+ * The class when `--class` was not given. Recorded as `asset_class_inferred`
88
+ * so a reader never mistakes a guess for a declaration.
89
+ */
90
+ export function inferAssetClass(content) {
91
+ if (hasSmilMarkup(content) || hasCssAnimation(content))
92
+ return 'animated';
93
+ const elements = parseElements(content);
94
+ const root = elements.find((el) => el.name === 'svg' && !el.closing);
95
+ const vb = readViewBox(root);
96
+ const texts = elements.filter((el) => el.name === 'text' && !el.closing).length;
97
+ const markers = elements.filter((el) => el.name === 'marker' && !el.closing).length;
98
+ if (vb && vb.w === vb.h && Math.max(vb.w, vb.h) <= 64)
99
+ return 'icon';
100
+ if (markers > 0 || texts >= 2)
101
+ return 'diagram';
102
+ if (texts >= 1)
103
+ return 'illustration';
104
+ return 'mark';
105
+ }
106
+ /** The declared intrinsic size: `width`/`height` first, the viewBox otherwise. */
107
+ export function intrinsicSize(root, vb) {
108
+ const num = (raw) => {
109
+ if (!raw)
110
+ return null;
111
+ const m = /^\s*(-?[\d.]+)\s*(px)?\s*$/i.exec(raw);
112
+ const v = m ? Number(m[1]) : Number.NaN;
113
+ return Number.isFinite(v) && v > 0 ? v : null;
114
+ };
115
+ const w = num(root?.attrs['width']) ?? vb?.w ?? 48;
116
+ const h = num(root?.attrs['height']) ?? vb?.h ?? 48;
117
+ return { width: w, height: h };
118
+ }
119
+ /**
120
+ * Icons at 16/24/48; a MARK additionally at 16 and 32; every other class at 1x
121
+ * and 2x its intrinsic size.
122
+ *
123
+ * Step 4.3 of `road-to-svg-from-a-live-session`. A review sent an accepted
124
+ * mark back because its face turned to mush at 32 px, and the only way anybody
125
+ * could know that was to look — there was no capture at a small size to look
126
+ * AT. Legibility stays a look; the capture is the evidence that the look
127
+ * happened, which is the same split the rest of this profile keeps.
128
+ *
129
+ * The small sizes come FIRST so a reader scanning the manifest meets them
130
+ * before the 1x the file was designed at.
131
+ */
132
+ /**
133
+ * The LARGEST capture, which is not the last one.
134
+ *
135
+ * Finding 6 of the third completion review. `assetSizes` PREPENDS
136
+ * `MARK_SMALL_SIZES` for a mark — so a reader taking `sizes[sizes.length - 1]`
137
+ * gets the 2x scaled size, which is smaller than 32 whenever the intrinsic
138
+ * width is under 16. Reproduced: intrinsic width 10 gives a last entry of 20
139
+ * against a maximum of 32, so the geometry pass read the asset at a size it
140
+ * was not biggest at and the grounds reading used a capture that was not the
141
+ * largest taken.
142
+ *
143
+ * A named function rather than an inline `reduce`, because the defect was
144
+ * exactly that the selection looked obvious enough not to be read — and an
145
+ * inline expression is not something a test can hold.
146
+ */
147
+ export function biggestSize(sizes) {
148
+ let best = null;
149
+ for (const s of sizes)
150
+ if (best === null || s.width > best.width)
151
+ best = s;
152
+ return best ?? { label: '1x', width: 64, height: 64 };
153
+ }
154
+ export function assetSizes(cls, intrinsic) {
155
+ if (cls === 'icon') {
156
+ return ICON_SIZES.map((px) => ({ label: `${px}`, width: px, height: px }));
157
+ }
158
+ const ratio = intrinsic.height / intrinsic.width;
159
+ const scaled = [1, 2].map((k) => ({
160
+ label: `${k}x`,
161
+ width: Math.round(intrinsic.width * k),
162
+ height: Math.round(intrinsic.width * k * ratio),
163
+ }));
164
+ if (cls !== 'mark')
165
+ return scaled;
166
+ const small = MARK_SMALL_SIZES.map((px) => ({
167
+ label: `${px}`,
168
+ width: px,
169
+ height: Math.max(1, Math.round(px * ratio)),
170
+ }));
171
+ return [...small, ...scaled];
172
+ }
173
+ /**
174
+ * Thresholds from a fixture manifest when one is supplied, the per-class
175
+ * defaults otherwise. The SOURCE travels with the number: a threshold a reader
176
+ * cannot trace is a threshold they cannot argue with.
177
+ */
178
+ export function readThresholds(file) {
179
+ const out = {
180
+ fillRatio: DEFAULT_FILL_RATIO_THRESHOLDS,
181
+ fillRatioSource: 'default',
182
+ geometry: DEFAULT_GEOMETRY_TOLERANCES,
183
+ geometrySource: 'default',
184
+ };
185
+ if (!file || !fs.existsSync(file))
186
+ return out;
187
+ let parsed;
188
+ try {
189
+ parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
190
+ }
191
+ catch {
192
+ return out;
193
+ }
194
+ const obj = parsed;
195
+ const fr = obj.fill_ratio_thresholds;
196
+ if (fr && ASSET_CLASSES.every((c) => typeof fr[c] === 'number')) {
197
+ out.fillRatio = { ...DEFAULT_FILL_RATIO_THRESHOLDS, ...fr };
198
+ out.fillRatioSource = 'manifest';
199
+ }
200
+ const gt = obj.geometry_tolerances;
201
+ // EVERY SUPPLIED FIELD IS A NUMBER, not just the one the guard used to
202
+ // name. Finding 5 of the review of this branch: the merge closed the
203
+ // missing-key hole and left the wrong-TYPE one, so
204
+ // `max_boundary_distance_units: "lots"` was accepted as manifest-sourced
205
+ // and returned verbatim — and `0 <= "lots"` is false, which is the same
206
+ // fail-closed comparison the missing key produced. A threshold that is not
207
+ // a number is not a threshold.
208
+ const wellTyped = (t) => t !== undefined &&
209
+ typeof t.silhouette_iou_min === 'number' &&
210
+ Object.values(t).every((v) => typeof v === 'number' && Number.isFinite(v));
211
+ if (gt && ASSET_CLASSES.every((c) => wellTyped(gt[c]))) {
212
+ // MERGED PER CLASS, not spread over the class. Finding 3 of the sixth
213
+ // completion review: the validation checks one field per class and the
214
+ // spread then replaced the whole class object, so a manifest naming
215
+ // every class but supplying only `silhouette_iou_min` was accepted as
216
+ // `geometrySource: manifest` with `max_boundary_distance_units`
217
+ // UNDEFINED.
218
+ //
219
+ // THE DIRECTION, corrected — the first version of this comment had it
220
+ // backwards and the review of this branch caught it. `undefined` does
221
+ // not make the boundary check vanish: `maxDistance <= undefined` is
222
+ // FALSE, so every comparison failed it, and a pixel-perfect render
223
+ // (iou 1.0, boundary distance 0.0) scored `fail`. It was fail-CLOSED,
224
+ // not silently absent. That is the better of the two directions and
225
+ // still wrong: a manifest the reader was told to trust refused every
226
+ // file it was applied to.
227
+ const merged = {};
228
+ for (const c of ASSET_CLASSES) {
229
+ merged[c] = { ...DEFAULT_GEOMETRY_TOLERANCES[c], ...gt[c] };
230
+ }
231
+ out.geometry = merged;
232
+ out.geometrySource = 'manifest';
233
+ }
234
+ return out;
235
+ }
236
+ /**
237
+ * The light and dark background from the project's tokens, or the stated
238
+ * fallback. Never a silent fallback — the caller records which it was.
239
+ */
240
+ export function readBackgroundTokens(projectRoot) {
241
+ for (const rel of BRAND_TOKEN_PATHS) {
242
+ const abs = path.join(projectRoot, rel);
243
+ if (!fs.existsSync(abs))
244
+ continue;
245
+ let doc;
246
+ try {
247
+ doc = JSON.parse(fs.readFileSync(abs, 'utf8'));
248
+ }
249
+ catch {
250
+ continue;
251
+ }
252
+ const light = findToken(doc, ['background', 'bg', 'surface'], ['light', 'default', 'base']);
253
+ const dark = findToken(doc, ['background', 'bg', 'surface'], ['dark', 'inverse', 'inverted']);
254
+ if (light || dark) {
255
+ return {
256
+ light: light ?? FALLBACK_BACKGROUNDS.light,
257
+ dark: dark ?? FALLBACK_BACKGROUNDS.dark,
258
+ source: 'tokens',
259
+ tokens_file: rel,
260
+ };
261
+ }
262
+ }
263
+ return { ...FALLBACK_BACKGROUNDS, source: 'fallback', tokens_file: null };
264
+ }
265
+ /** A depth-first walk for a DTCG `$value` whose path carries both hints. */
266
+ function findToken(doc, nameHints, modeHints) {
267
+ const stack = [{ node: doc, keys: [] }];
268
+ let best = null;
269
+ while (stack.length > 0) {
270
+ const top = stack.pop();
271
+ if (!top || typeof top.node !== 'object' || top.node === null)
272
+ continue;
273
+ const rec = top.node;
274
+ const value = rec['$value'] ?? rec['value'];
275
+ if (typeof value === 'string' && /^#[0-9a-f]{3,8}$/i.test(value)) {
276
+ const joined = top.keys.join('.').toLowerCase();
277
+ if (nameHints.some((h) => joined.includes(h)) && modeHints.some((h) => joined.includes(h))) {
278
+ best = value;
279
+ }
280
+ }
281
+ for (const [k, v] of Object.entries(rec)) {
282
+ if (k.startsWith('$'))
283
+ continue;
284
+ stack.push({ node: v, keys: [...top.keys, k] });
285
+ }
286
+ }
287
+ return best;
288
+ }
289
+ /**
290
+ * The fraction of a draw-on animation's duration during which the drawn length
291
+ * does not change — the R8 defect, stated as a number.
292
+ *
293
+ * With a dash of `D` real units on a path of real length `L`, a dash offset of
294
+ * `o` paints `clamp(D - o, 0, L)`. When `D > L` the paint reaches `L` while `o`
295
+ * is still above zero, and every further step of the animation draws nothing.
296
+ */
297
+ export function drawOnDeadFraction(dashUnits, from, to, totalLength) {
298
+ const span = Math.abs(from - to);
299
+ if (span <= 0 || totalLength <= 0 || dashUnits <= 0)
300
+ return 0;
301
+ const drawn = (o) => Math.min(Math.max(dashUnits - o, 0), totalLength);
302
+ const lo = Math.min(from, to);
303
+ const hi = Math.max(from, to);
304
+ const STEPS = 400;
305
+ let live = 0;
306
+ let prev = drawn(hi);
307
+ for (let i = 1; i <= STEPS; i++) {
308
+ const o = hi - ((hi - lo) * i) / STEPS;
309
+ const now = drawn(o);
310
+ if (Math.abs(now - prev) > 1e-6)
311
+ live += 1;
312
+ prev = now;
313
+ }
314
+ return 1 - live / STEPS;
315
+ }
316
+ /**
317
+ * One screenshot, with exactly one retry on a protocol error.
318
+ *
319
+ * Measured on a GitHub-hosted ubuntu runner with `chromium_headless_shell`:
320
+ * `page.screenshot` failed with `Protocol error (Page.captureScreenshot):
321
+ * Unable to capture screenshot` on one asset while the same asset captured
322
+ * cleanly in another test of the same run — a compositor surface that was not
323
+ * ready, not a property of the file. ONE retry, deliberately: a genuine failure
324
+ * fails twice and still surfaces, while a loop would turn a real break into a
325
+ * slow one. A `path` write also creates its parent directories, so a missing
326
+ * `outDir` is not what this is for.
327
+ */
328
+ async function captureWithOneRetry(page, shot) {
329
+ try {
330
+ await page.screenshot({ path: shot });
331
+ }
332
+ catch (first) {
333
+ if (!/Unable to capture screenshot|Protocol error/i.test(String(first)))
334
+ throw first;
335
+ await page.waitForTimeout(250);
336
+ await page.screenshot({ path: shot });
337
+ }
338
+ }
339
+ /** Does this element declare itself less than fully opaque? */
340
+ export function isDeclaredTranslucent(el) {
341
+ const style = el.attrs['style'] ?? '';
342
+ const read = (key) => {
343
+ const fromStyle = new RegExp(`(?:^|;)\\s*${key}\\s*:\\s*([^;]+)`).exec(style)?.[1];
344
+ const raw = (fromStyle ?? el.attrs[key] ?? '').trim();
345
+ if (raw === '')
346
+ return null;
347
+ // A PERCENTAGE IS LEGAL CSS AND `Number()` CANNOT READ IT.
348
+ //
349
+ // Finding 8 of the third completion review: `Number('50%')` is NaN, so
350
+ // `fill-opacity="50%"` read as "not declared translucent" and every
351
+ // pixel of that half-transparent shape was counted as background
352
+ // bleeding through the author's paint — the exact false warning the
353
+ // comment above this function says the declaration prevents.
354
+ const pct = /^([+-]?(?:\d*\.\d+|\d+\.?))\s*%$/.exec(raw);
355
+ const v = pct ? Number(pct[1]) / 100 : Number(raw);
356
+ return Number.isFinite(v) ? v : null;
357
+ };
358
+ // `stroke-opacity` belongs here for the same reason the other two do: a
359
+ // stroke the author made translucent is a declaration, not a seam. It was
360
+ // missing from the list, which finding 8 also names.
361
+ for (const key of ['opacity', 'fill-opacity', 'stroke-opacity']) {
362
+ const v = read(key);
363
+ if (v !== null && v < 0.99)
364
+ return true;
365
+ }
366
+ return false;
367
+ }
368
+ /** Boxes, in user units, of every element matching `pick`. */
369
+ function extentsOf(elements, pick) {
370
+ const out = [];
371
+ for (const el of elements) {
372
+ if (el.closing || !pick(el))
373
+ continue;
374
+ const b = elementExtent(el);
375
+ if (b)
376
+ out.push({ x: b.minX, y: b.minY, w: b.maxX - b.minX, h: b.maxY - b.minY });
377
+ }
378
+ return out;
379
+ }
380
+ /** A reading pass over one asset. The caller owns the browser's lifetime. */
381
+ export async function runAssetRender(browser, opts) {
382
+ const bytes = fs.readFileSync(opts.file);
383
+ const content = bytes.toString('utf8');
384
+ const isHost = /\.html?$/i.test(opts.file);
385
+ const dir = path.dirname(opts.file);
386
+ const elements = isHost ? [] : parseElements(content);
387
+ const root = elements.find((el) => el.name === 'svg' && !el.closing);
388
+ const vb = isHost ? null : readViewBox(root);
389
+ const intrinsic = intrinsicSize(root, vb);
390
+ const declaredClass = opts.assetClass;
391
+ const assetClass = declaredClass ?? inferAssetClass(content);
392
+ const delivery = opts.delivery ?? 'inline';
393
+ const thresholds = readThresholds(opts.thresholdsFile);
394
+ const backgrounds = readBackgroundTokens(opts.projectRoot);
395
+ const blocked = [];
396
+ const reading = {
397
+ profile: 'asset',
398
+ file: path.relative(opts.projectRoot, opts.file).split(path.sep).join('/'),
399
+ digest: sha256(bytes),
400
+ asset_class: assetClass,
401
+ asset_class_inferred: declaredClass === undefined,
402
+ delivery,
403
+ view_box: vb,
404
+ intrinsic,
405
+ backgrounds,
406
+ sizes: [],
407
+ readings_at: null,
408
+ true_vector: isHost ? false : vectorVerdict(elements).trueVector,
409
+ painted_box: null,
410
+ fill_ratio: null,
411
+ fill_ratio_threshold: thresholds.fillRatio[assetClass],
412
+ fill_ratio_threshold_source: thresholds.fillRatioSource,
413
+ outside_view_box: [],
414
+ texts: [],
415
+ accessible_names: [],
416
+ duplicate_accessible_names: [],
417
+ frames: [],
418
+ animation_duration_s: 0,
419
+ distinct_frames: 0,
420
+ draw_on: [],
421
+ icon_grid: null,
422
+ near_white_regions: [],
423
+ cost: costReading(content, elements),
424
+ grounds: [],
425
+ network: { aborted: 0, blocked_urls: blocked },
426
+ findings: [],
427
+ reduced_motion: { witnessable: false, frames_equal: null, reason: 'not read' },
428
+ };
429
+ const hostHtml = isHost ? null : inlineHost(content);
430
+ const targetUrl = isHost ? `file://${opts.file}` : `file://${path.join(dir, HOST_BASENAME)}`;
431
+ const fileRoots = fileRootsFor(opts.projectRoot, opts.file);
432
+ const context = await browser.newContext({ javaScriptEnabled: false });
433
+ // A transpiler shim, and it belongs here rather than in a test helper.
434
+ // Running this file through `tsx` — which is how `./scripts-run` and every
435
+ // consumer script reach it — emits `__name(...)` wrappers around the
436
+ // functions handed to `page.evaluate`. The browser has no such global, so
437
+ // the first evaluate dies with `ReferenceError: __name is not defined`
438
+ // while the same code passes under vitest, whose transform does not add it.
439
+ // Defining an identity shim before any page script runs makes both paths
440
+ // behave the same; it is a no-op where the wrapper was never emitted.
441
+ await context.addInitScript(() => {
442
+ const g = globalThis;
443
+ if (typeof g.__name !== 'function')
444
+ g.__name = (fn) => fn;
445
+ });
446
+ try {
447
+ // Sizes x backgrounds. Icons get 16/24/48; everything else 1x and 2x.
448
+ const sizes = isHost
449
+ ? [{ label: 'host', width: 800, height: 600 }]
450
+ : assetSizes(assetClass, intrinsic);
451
+ for (const size of sizes) {
452
+ for (const mode of ['light', 'dark']) {
453
+ const colour = backgrounds[mode];
454
+ const page = await openPage(context, {
455
+ url: targetUrl,
456
+ hostHtml,
457
+ hostBasename: HOST_BASENAME,
458
+ width: Math.max(size.width, 64),
459
+ height: Math.max(size.height, 64),
460
+ background: colour,
461
+ cssWidth: size.width,
462
+ blocked,
463
+ fileRoots,
464
+ });
465
+ const shot = path.join(opts.outDir, `asset-${size.label}-${mode}.png`);
466
+ await captureWithOneRetry(page, shot);
467
+ reading.sizes.push({
468
+ label: size.label,
469
+ css_width: size.width,
470
+ css_height: size.height,
471
+ background: mode,
472
+ background_color: colour,
473
+ screenshot: path.relative(opts.projectRoot, shot).split(path.sep).join('/'),
474
+ });
475
+ await page.close();
476
+ }
477
+ }
478
+ reading.network.aborted = blocked.length;
479
+ // One geometry pass. Every reading below is in viewBox units, which do
480
+ // not move with the render size — so reading them once and SAYING where
481
+ // beats re-reading them six times and implying they might differ.
482
+ const biggest = biggestSize(sizes);
483
+ // The moment the geometry was read, named rather than implied: an
484
+ // animated asset's painted box is a property of a time, and t = 0 is a
485
+ // declared one.
486
+ reading.readings_at = { css_width: biggest.width, css_height: biggest.height, background: 'light', t: 0 };
487
+ const page = await openPage(context, {
488
+ url: targetUrl,
489
+ hostHtml,
490
+ hostBasename: HOST_BASENAME,
491
+ width: Math.max(biggest.width, 64),
492
+ height: Math.max(biggest.height, 64),
493
+ background: backgrounds.light,
494
+ cssWidth: biggest.width,
495
+ blocked,
496
+ fileRoots,
497
+ });
498
+ if (!isHost) {
499
+ const raw = await page.evaluate(readAssetInPage, { nearWhite: NEAR_WHITE_CHANNEL });
500
+ applyGeometry(reading, raw, vb);
501
+ }
502
+ // Accessible names across every graphic on the page (R7).
503
+ const names = await page.evaluate(readAccessibleNamesInPage);
504
+ const counts = new Map();
505
+ for (const n of names)
506
+ counts.set(n, (counts.get(n) ?? 0) + 1);
507
+ reading.accessible_names = [...counts].map(([name, count]) => ({ name, count }));
508
+ reading.duplicate_accessible_names = [...counts].filter(([, c]) => c > 1).map(([n]) => n);
509
+ // Frames at t = 0, mid, end. SMIL is paused and sought; CSS/WAAPI
510
+ // animations are paused and their currentTime is set (R6).
511
+ const duration = await page.evaluate(readAnimationEndInPage);
512
+ reading.animation_duration_s = duration;
513
+ if (duration > 0) {
514
+ const points = [
515
+ ['start', 0],
516
+ ['mid', duration / 2],
517
+ ['end', duration],
518
+ ];
519
+ for (const [label, t] of points) {
520
+ await page.evaluate(seekInPage, t);
521
+ const buf = await page.screenshot();
522
+ reading.frames.push({ label, t, digest: sha256(buf) });
523
+ }
524
+ reading.distinct_frames = new Set(reading.frames.map((f) => f.digest)).size;
525
+ }
526
+ await page.close();
527
+ // Reduced motion. Inline is witnessable; an image document is not (R5).
528
+ reading.reduced_motion = await readReducedMotion(context, {
529
+ url: targetUrl,
530
+ hostHtml,
531
+ width: Math.max(biggest.width, 64),
532
+ height: Math.max(biggest.height, 64),
533
+ background: backgrounds.light,
534
+ cssWidth: biggest.width,
535
+ blocked,
536
+ delivery,
537
+ duration,
538
+ isHost,
539
+ hostDir: path.dirname(path.resolve(opts.file)),
540
+ fileRoots,
541
+ });
542
+ if (opts.compareTo) {
543
+ reading.comparison = await compareGeometry(context, {
544
+ baseline: opts.file,
545
+ candidate: opts.compareTo,
546
+ assetClass,
547
+ tolerance: thresholds.geometry[assetClass],
548
+ toleranceSource: thresholds.geometrySource,
549
+ ...(opts.registration ? { registration: opts.registration } : {}),
550
+ blocked,
551
+ });
552
+ // Step 1.2: ask the SOURCE what is under each near-white region.
553
+ // Only reachable with a registration, which is the whole condition
554
+ // — without one the regions keep `no-source` and produce no
555
+ // finding, because the rule's claim is about the source and there
556
+ // is no source to make it against.
557
+ if (opts.registration && vb && reading.near_white_regions.length > 0) {
558
+ const points = reading.near_white_regions.map((n) => n.centre);
559
+ const samples = await sampleSource(context, opts.compareTo, vb, opts.registration, points, blocked);
560
+ if (samples !== null) {
561
+ reading.near_white_regions = reading.near_white_regions.map((n, i) => ({
562
+ ...n,
563
+ source_verdict: (samples[i]?.isBackground ?? false) ? 'hole' : 'paint',
564
+ }));
565
+ }
566
+ }
567
+ }
568
+ // Steps 4.1 and 4.2: the per-ground readings, taken at the biggest
569
+ // size so the counts are not dominated by anti-aliasing. Not for a
570
+ // host document — the silhouette of a page is the page.
571
+ if (!isHost) {
572
+ const confinedOpen = (ctx, spec) => openPage(ctx, { ...spec, fileRoots });
573
+ reading.grounds = await readGrounds(context, confinedOpen, {
574
+ url: targetUrl,
575
+ hostHtml,
576
+ width: Math.max(biggest.width, 64),
577
+ height: Math.max(biggest.height, 64),
578
+ cssWidth: biggest.width,
579
+ blocked,
580
+ backgrounds,
581
+ holeBoxes: extentsOf(elements, (el) => (el.attrs[HOLE_ATTRIBUTE] ?? '') !== ''),
582
+ // Finding 7: a shape the author made translucent is not a seam.
583
+ softBoxes: extentsOf(elements, isDeclaredTranslucent),
584
+ viewBox: vb,
585
+ });
586
+ }
587
+ }
588
+ finally {
589
+ await context.close();
590
+ }
591
+ if (!isHost && opts.iconSet && assetClass === 'icon') {
592
+ reading.icon_grid = gradeIconGrid(opts.file, vb, elements, opts.iconSet, reading.painted_box);
593
+ }
594
+ reading.network.aborted = blocked.length;
595
+ reading.findings = deriveFindings(reading);
596
+ return { reading, verification: 'verified' };
597
+ }
598
+ /** The host document for an inline delivery: one background, one sized graphic. */
599
+ function inlineHost(svg) {
600
+ const body = svg.replace(/<\?xml[^>]*\?>\s*/i, '').replace(/<!DOCTYPE[^>]*>\s*/i, '');
601
+ return `<!doctype html><html><head><meta charset="utf-8"><title>asset</title></head><body>${body}</body></html>`;
602
+ }
603
+ /**
604
+ * One page with the network closed. The synthetic host is fulfilled at a URL
605
+ * inside the asset's own directory, so the asset's relative references resolve
606
+ * from where it actually lives.
607
+ */
608
+ async function openPage(context, spec) {
609
+ const page = await context.newPage();
610
+ await page.setViewportSize({ width: Math.ceil(spec.width), height: Math.ceil(spec.height) });
611
+ if (spec.reducedMotion)
612
+ await page.emulateMedia({ reducedMotion: 'reduce' });
613
+ await confinePage(page, spec.blocked, spec.fileRoots, async (route) => {
614
+ if (!spec.hostHtml || !route.request().url().endsWith(`/${spec.hostBasename}`))
615
+ return false;
616
+ await route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: spec.hostHtml });
617
+ return true;
618
+ });
619
+ await page.goto(spec.url, { waitUntil: 'load', timeout: 30_000 });
620
+ await injectStyle(page, `html,body{margin:0;padding:0;background:${spec.background}}` +
621
+ `body>svg{display:block;width:${spec.cssWidth}px;height:auto}`);
622
+ return page;
623
+ }
624
+ function readAssetInPage(args) {
625
+ const svg = document.querySelector('svg');
626
+ if (!svg)
627
+ return { viewBox: null, elements: [], texts: [] };
628
+ const vbAttr = (svg.getAttribute('viewBox') ?? '').trim().split(/[\s,]+/).map(Number);
629
+ const viewBox = vbAttr.length === 4 && vbAttr.every((n) => Number.isFinite(n))
630
+ ? [vbAttr[0], vbAttr[1], vbAttr[2], vbAttr[3]]
631
+ : null;
632
+ const ctm = svg.getScreenCTM();
633
+ const inv = ctm ? ctm.inverse() : null;
634
+ const userBox = (el) => {
635
+ const r = el.getBoundingClientRect();
636
+ if (!inv)
637
+ return null;
638
+ const pts = [
639
+ [r.left, r.top],
640
+ [r.right, r.top],
641
+ [r.left, r.bottom],
642
+ [r.right, r.bottom],
643
+ ].map(([x, y]) => new DOMPoint(x, y).matrixTransform(inv));
644
+ const xs = pts.map((p) => p.x);
645
+ const ys = pts.map((p) => p.y);
646
+ const minX = Math.min(...xs);
647
+ const minY = Math.min(...ys);
648
+ return { x: minX, y: minY, w: Math.max(...xs) - minX, h: Math.max(...ys) - minY };
649
+ };
650
+ const SKIP = ['defs', 'title', 'desc', 'style', 'metadata', 'clippath', 'mask', 'marker', 'pattern', 'symbol'];
651
+ const inSkipped = (el) => {
652
+ let p = el.parentElement;
653
+ while (p && p !== svg) {
654
+ if (SKIP.includes(p.tagName.toLowerCase()))
655
+ return true;
656
+ p = p.parentElement;
657
+ }
658
+ return false;
659
+ };
660
+ const GENERIC = [
661
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
662
+ 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', 'math', 'emoji', 'fangsong',
663
+ ];
664
+ const ctx = document.createElement('canvas').getContext('2d');
665
+ const resolves = (first, sample) => {
666
+ if (!first)
667
+ return false;
668
+ if (GENERIC.includes(first.toLowerCase()))
669
+ return true;
670
+ if (!ctx)
671
+ return true;
672
+ ctx.font = `32px "${first}", monospace`;
673
+ const a = ctx.measureText(sample).width;
674
+ ctx.font = '32px "__agent_config_absent_family__", monospace';
675
+ const b = ctx.measureText(sample).width;
676
+ return Math.abs(a - b) > 0.01;
677
+ };
678
+ // The document's own default family. A <text> that declares nothing lands
679
+ // on exactly this — which is how "no family was declared" is told apart
680
+ // from "a family was declared and did not resolve" (R1).
681
+ const defaultFamily = window.getComputedStyle(document.documentElement).fontFamily;
682
+ const all = Array.from(svg.querySelectorAll('*'));
683
+ const elements = [];
684
+ const texts = [];
685
+ for (let i = 0; i < all.length; i++) {
686
+ const el = all[i];
687
+ const tag = el.tagName.toLowerCase();
688
+ if (SKIP.includes(tag) || inSkipped(el))
689
+ continue;
690
+ if (!('getBBox' in el) || typeof el.getBBox !== 'function')
691
+ continue;
692
+ const g = el;
693
+ const cs = window.getComputedStyle(g);
694
+ if (cs.display === 'none' || cs.visibility === 'hidden')
695
+ continue;
696
+ let geom = null;
697
+ try {
698
+ const bb = g.getBBox();
699
+ geom = { x: bb.x, y: bb.y, w: bb.width, h: bb.height };
700
+ }
701
+ catch {
702
+ geom = null;
703
+ }
704
+ if (!geom || (geom.w === 0 && geom.h === 0))
705
+ continue;
706
+ let totalLength = null;
707
+ const geometry = g;
708
+ if (typeof geometry.getTotalLength === 'function') {
709
+ try {
710
+ totalLength = geometry.getTotalLength();
711
+ }
712
+ catch {
713
+ totalLength = null;
714
+ }
715
+ }
716
+ const pathLengthRaw = Number(el.getAttribute('pathLength'));
717
+ let from = null;
718
+ let to = null;
719
+ for (const anim of Array.from(el.children)) {
720
+ if (anim.tagName.toLowerCase() !== 'animate')
721
+ continue;
722
+ if ((anim.getAttribute('attributeName') ?? '') !== 'stroke-dashoffset')
723
+ continue;
724
+ const values = (anim.getAttribute('values') ?? '').split(';').map(Number).filter((n) => Number.isFinite(n));
725
+ if (values.length >= 2) {
726
+ from = values[0];
727
+ to = values[values.length - 1];
728
+ }
729
+ else {
730
+ const f = Number(anim.getAttribute('from'));
731
+ const t = Number(anim.getAttribute('to'));
732
+ if (Number.isFinite(f))
733
+ from = f;
734
+ if (Number.isFinite(t))
735
+ to = t;
736
+ }
737
+ }
738
+ const fillOpacity = Number(cs.fillOpacity || '1');
739
+ const opacity = Number(cs.opacity || '1');
740
+ elements.push({
741
+ index: i,
742
+ id: el.getAttribute('id') ?? '',
743
+ tag,
744
+ box: userBox(el),
745
+ geom,
746
+ fill: cs.fill,
747
+ opaque: fillOpacity >= 0.99 && opacity >= 0.99 && cs.fill !== 'none',
748
+ strokeWidth: Number.parseFloat(cs.strokeWidth || '0') || 0,
749
+ dash: cs.strokeDasharray,
750
+ totalLength,
751
+ pathLengthAttr: Number.isFinite(pathLengthRaw) && pathLengthRaw > 0 ? pathLengthRaw : null,
752
+ containedBy: [],
753
+ dashOffsetFrom: from,
754
+ dashOffsetTo: to,
755
+ });
756
+ if (tag === 'text') {
757
+ const attrFamily = el.getAttribute('font-family');
758
+ const cssFamily = cs.fontFamily;
759
+ const hasDeclaration = attrFamily !== null || cssFamily !== defaultFamily;
760
+ const declared = hasDeclaration ? (attrFamily ?? cssFamily) : '';
761
+ const list = (declared || cssFamily).split(',');
762
+ const first = (list[0] ?? '').trim().replace(/^["']|["']$/g, '');
763
+ const boxRefId = el.getAttribute('data-box');
764
+ const clip = /url\(['"]?#([^)'"]+)/.exec(el.getAttribute('clip-path') ?? '');
765
+ const refId = boxRefId ?? (clip ? clip[1] : null);
766
+ let declaredBox = null;
767
+ if (refId) {
768
+ const ref = svg.querySelector(`#${CSS.escape(refId)}`);
769
+ if (ref && typeof ref.getBBox === 'function') {
770
+ const rb = ref.getBBox();
771
+ declaredBox = { x: rb.x, y: rb.y, w: rb.width, h: rb.height };
772
+ }
773
+ }
774
+ const sample = el.textContent ?? '';
775
+ texts.push({
776
+ index: i,
777
+ id: el.getAttribute('id') ?? '',
778
+ text: sample,
779
+ box: geom,
780
+ declaredBoxId: refId,
781
+ declaredBox,
782
+ declaredFamily: declared,
783
+ firstFamily: first,
784
+ resolvedFamily: cssFamily,
785
+ firstFamilyResolved: hasDeclaration && resolves(first, sample || 'Hamburgefonstiv'),
786
+ });
787
+ }
788
+ }
789
+ // Containment by SAMPLED FILL (step 1.2). A second pass rather than a
790
+ // branch in the loop above, because it needs every element to exist first.
791
+ // `isPointInFill` is what the renderer itself answers with, so a near-white
792
+ // shape beside a ring is no longer "inside" it for having a smaller box.
793
+ // Finding 9: the OUTER loop is restricted to the only consumer. The pass
794
+ // used to call `isPointInFill` for every ordered pair of geometry-bearing
795
+ // elements and `applyGeometry` then read `containedBy` for opaque
796
+ // near-white elements alone — on the 195 KB, 355-gradient trace this change
797
+ // cites as its motivation that is 10^5-10^6 cross-process calls whose
798
+ // results are discarded. The reading is identical; only the pairs nobody
799
+ // asks about are gone.
800
+ //
801
+ // The channel floor is PASSED, not spelled twice. Finding 10 of this
802
+ // change's second completion review: a literal `237` on this side of the
803
+ // page boundary and `NEAR_WHITE_CHANNEL` on the other, bound only by a
804
+ // trailing comment, is the same shape as the dead constants this branch
805
+ // fixes elsewhere — one of them changes and nothing says so. A
806
+ // `page.evaluate` body cannot close over module scope, so it arrives as an
807
+ // argument, exactly as `minContrast` does two files away.
808
+ const NEAR_WHITE = args.nearWhite;
809
+ const candidates = elements.filter((el) => {
810
+ if (el.geom === null || !el.opaque)
811
+ return false;
812
+ const m = /rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/.exec(el.fill);
813
+ return m !== null && [m[1], m[2], m[3]].every((c) => Number(c) >= NEAR_WHITE);
814
+ });
815
+ for (const inner of candidates) {
816
+ if (!inner.geom)
817
+ continue;
818
+ const cx = inner.geom.x + inner.geom.w / 2;
819
+ const cy = inner.geom.y + inner.geom.h / 2;
820
+ for (const outer of elements) {
821
+ if (outer.index === inner.index)
822
+ continue;
823
+ const node = all[outer.index];
824
+ if (typeof node.isPointInFill !== 'function')
825
+ continue;
826
+ try {
827
+ if (node.isPointInFill({ x: cx, y: cy }))
828
+ inner.containedBy.push(outer.index);
829
+ }
830
+ catch {
831
+ // A node that refuses the question answers "not contained",
832
+ // which is the safe direction: it produces no finding.
833
+ }
834
+ }
835
+ }
836
+ return { viewBox, elements, texts };
837
+ }
838
+ function readAccessibleNamesInPage() {
839
+ const out = [];
840
+ for (const svg of Array.from(document.querySelectorAll('svg'))) {
841
+ const role = svg.getAttribute('role') ?? '';
842
+ if (svg.getAttribute('aria-hidden') === 'true')
843
+ continue;
844
+ if (!/^(img|graphics-document|graphics-symbol)$/.test(role))
845
+ continue;
846
+ const label = svg.getAttribute('aria-label');
847
+ if (label) {
848
+ out.push(label.trim());
849
+ continue;
850
+ }
851
+ const by = svg.getAttribute('aria-labelledby');
852
+ if (by) {
853
+ // EVERY id, joined by a space. Finding 9 of the fifth completion
854
+ // review: this read the first id only, while the static rule in
855
+ // `svg_rules.ts` resolves the whole list — so for
856
+ // `aria-labelledby="a b"` the two halves of one change disagreed
857
+ // about what the accessible name IS, and the rendered reading
858
+ // reported a shorter name than the file actually exposes. The
859
+ // accname spec concatenates the referenced elements in order.
860
+ const parts = by
861
+ .split(/\s+/)
862
+ .filter((id) => id !== '')
863
+ .map((id) => (document.getElementById(id)?.textContent ?? '').trim())
864
+ .filter((t) => t !== '');
865
+ out.push(parts.join(' '));
866
+ continue;
867
+ }
868
+ const title = svg.querySelector(':scope > title');
869
+ if (title)
870
+ out.push((title.textContent ?? '').trim());
871
+ }
872
+ for (const el of Array.from(document.querySelectorAll('[role="img"]:not(svg)'))) {
873
+ const label = el.getAttribute('aria-label');
874
+ if (label)
875
+ out.push(label.trim());
876
+ }
877
+ return out.filter((n) => n !== '');
878
+ }
879
+ /** Seconds to the end of one iteration of the longest animation; 0 when static. */
880
+ function readAnimationEndInPage() {
881
+ let end = 0;
882
+ const seconds = (raw) => {
883
+ if (!raw)
884
+ return 0;
885
+ const m = /^\s*([\d.]+)\s*(ms|s)?\s*$/.exec(raw);
886
+ if (!m)
887
+ return 0;
888
+ const v = Number(m[1]);
889
+ return m[2] === 'ms' ? v / 1000 : v;
890
+ };
891
+ for (const el of Array.from(document.querySelectorAll('animate,animateTransform,animateMotion,animateColor,set'))) {
892
+ const dur = seconds(el.getAttribute('dur'));
893
+ const rc = el.getAttribute('repeatCount');
894
+ const reps = rc && rc !== 'indefinite' && Number.isFinite(Number(rc)) ? Number(rc) : 1;
895
+ end = Math.max(end, dur * reps);
896
+ }
897
+ for (const anim of document.getAnimations()) {
898
+ const timing = anim.effect?.getComputedTiming();
899
+ const d = typeof timing?.duration === 'number' ? timing.duration / 1000 : 0;
900
+ end = Math.max(end, d);
901
+ }
902
+ return end;
903
+ }
904
+ /**
905
+ * A declared time, held. `screenshot({ animations: 'disabled' })` does NOT
906
+ * freeze SMIL (R6) — `pauseAnimations()` plus `setCurrentTime()` does.
907
+ */
908
+ function seekInPage(t) {
909
+ for (const svg of Array.from(document.querySelectorAll('svg'))) {
910
+ const s = svg;
911
+ if (typeof s.pauseAnimations === 'function') {
912
+ s.pauseAnimations();
913
+ s.setCurrentTime(t);
914
+ }
915
+ }
916
+ for (const anim of document.getAnimations()) {
917
+ anim.pause();
918
+ anim.currentTime = t * 1000;
919
+ }
920
+ }
921
+ // ---------------------------------------------------------------------------
922
+ // Assembling the reading
923
+ // ---------------------------------------------------------------------------
924
+ function applyGeometry(reading, raw, vb) {
925
+ const EPS = 1e-6;
926
+ let box = null;
927
+ for (const el of raw.elements) {
928
+ const b = el.box ?? el.geom;
929
+ if (!b)
930
+ continue;
931
+ box = box
932
+ ? {
933
+ x: Math.min(box.x, b.x),
934
+ y: Math.min(box.y, b.y),
935
+ w: Math.max(box.x + box.w, b.x + b.w) - Math.min(box.x, b.x),
936
+ h: Math.max(box.y + box.h, b.y + b.h) - Math.min(box.y, b.y),
937
+ }
938
+ : { ...b };
939
+ if (vb) {
940
+ const outLeft = b.x < vb.x - EPS;
941
+ const outTop = b.y < vb.y - EPS;
942
+ const outRight = b.x + b.w > vb.x + vb.w + EPS;
943
+ const outBottom = b.y + b.h > vb.y + vb.h + EPS;
944
+ if (outLeft || outTop || outRight || outBottom) {
945
+ reading.outside_view_box.push({
946
+ index: el.index,
947
+ id: el.id,
948
+ tag: el.tag,
949
+ box: b,
950
+ wholly_outside: b.x + b.w <= vb.x || b.y + b.h <= vb.y || b.x >= vb.x + vb.w || b.y >= vb.y + vb.h,
951
+ });
952
+ }
953
+ }
954
+ }
955
+ reading.painted_box = box;
956
+ if (box && vb && vb.w > 0 && vb.h > 0) {
957
+ const clipped = {
958
+ x0: Math.max(box.x, vb.x),
959
+ y0: Math.max(box.y, vb.y),
960
+ x1: Math.min(box.x + box.w, vb.x + vb.w),
961
+ y1: Math.min(box.y + box.h, vb.y + vb.h),
962
+ };
963
+ const area = Math.max(0, clipped.x1 - clipped.x0) * Math.max(0, clipped.y1 - clipped.y0);
964
+ reading.fill_ratio = area / (vb.w * vb.h);
965
+ }
966
+ for (const t of raw.texts) {
967
+ const declaredBox = t.declaredBox ?? (vb ? { x: vb.x, y: vb.y, w: vb.w, h: vb.h } : t.box);
968
+ reading.texts.push({
969
+ index: t.index,
970
+ id: t.id,
971
+ text: t.text,
972
+ box: t.box,
973
+ declared_box_id: t.declaredBoxId,
974
+ declared_box: declaredBox,
975
+ fits: t.box.x >= declaredBox.x - 0.5 &&
976
+ t.box.y >= declaredBox.y - 0.5 &&
977
+ t.box.x + t.box.w <= declaredBox.x + declaredBox.w + 0.5 &&
978
+ t.box.y + t.box.h <= declaredBox.y + declaredBox.h + 0.5,
979
+ declared_family: t.declaredFamily,
980
+ first_family: t.firstFamily,
981
+ resolved_family: t.resolvedFamily,
982
+ first_family_resolved: t.firstFamilyResolved,
983
+ });
984
+ }
985
+ for (const el of raw.elements) {
986
+ if (el.dashOffsetFrom === null || el.dashOffsetTo === null)
987
+ continue;
988
+ if (!el.totalLength || el.totalLength <= 0)
989
+ continue;
990
+ const dashFirst = Number.parseFloat(el.dash.split(/[\s,]+/)[0] ?? '');
991
+ if (!Number.isFinite(dashFirst) || dashFirst <= 0)
992
+ continue;
993
+ const scale = el.pathLengthAttr ? el.totalLength / el.pathLengthAttr : 1;
994
+ reading.draw_on.push({
995
+ index: el.index,
996
+ id: el.id,
997
+ total_length: el.totalLength,
998
+ path_length_attr: el.pathLengthAttr,
999
+ dash_units: dashFirst * scale,
1000
+ offset_from: el.dashOffsetFrom * scale,
1001
+ offset_to: el.dashOffsetTo * scale,
1002
+ dead_fraction: drawOnDeadFraction(dashFirst * scale, el.dashOffsetFrom * scale, el.dashOffsetTo * scale, el.totalLength),
1003
+ });
1004
+ }
1005
+ // Near-white paint where the source shows a hole (S5). Geometry, not pixels:
1006
+ // an opaque near-white shape wholly inside a non-near-white painted shape is
1007
+ // a filled hole, on any background — which is why no render is needed to
1008
+ // decide it and why the dark capture above is confirmation rather than input.
1009
+ const isNearWhite = (fill) => {
1010
+ const m = /rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/.exec(fill);
1011
+ if (!m)
1012
+ return false;
1013
+ return [m[1], m[2], m[3]].every((c) => Number(c) >= NEAR_WHITE_CHANNEL);
1014
+ };
1015
+ for (const inner of raw.elements) {
1016
+ if (!inner.opaque || !isNearWhite(inner.fill))
1017
+ continue;
1018
+ if (!inner.geom)
1019
+ continue;
1020
+ // Step 1.2: containment is the SAMPLED fill, computed in-page by
1021
+ // `isPointInFill`, not a bounding-box comparison. A box test calls a
1022
+ // near-white shape "inside" a ring, an L or a crescent it never
1023
+ // touches, which is how this rule came to fire six times on paint an
1024
+ // owner had deliberately chosen.
1025
+ const host = raw.elements.find((o) => inner.containedBy.includes(o.index) && o.opaque && !isNearWhite(o.fill));
1026
+ if (host) {
1027
+ reading.near_white_regions.push({
1028
+ index: inner.index,
1029
+ id: inner.id,
1030
+ fill: inner.fill,
1031
+ enclosed_by: host.id || `${host.tag}@${host.index}`,
1032
+ centre: { x: inner.geom.x + inner.geom.w / 2, y: inner.geom.y + inner.geom.h / 2 },
1033
+ // Filled in by the source sampling when a registered raster is
1034
+ // supplied. Absent evidence stays absent.
1035
+ source_verdict: 'no-source',
1036
+ });
1037
+ }
1038
+ }
1039
+ }
1040
+ function deriveFindings(r) {
1041
+ const out = [];
1042
+ if (r.fill_ratio !== null && r.fill_ratio < r.fill_ratio_threshold) {
1043
+ out.push({
1044
+ rule: 'structure/loose-viewbox',
1045
+ severity: 'warning',
1046
+ detail: `fill ratio ${r.fill_ratio.toFixed(3)} is below the ${r.asset_class} threshold ` +
1047
+ `${r.fill_ratio_threshold} (${r.fill_ratio_threshold_source}) — the painted box leaves the rest of the viewBox empty`,
1048
+ });
1049
+ }
1050
+ for (const t of r.texts) {
1051
+ if (!t.fits) {
1052
+ out.push({
1053
+ rule: 'geometry/text-overflows-box',
1054
+ severity: 'warning',
1055
+ detail: `<text>${t.id ? ` #${t.id}` : ''} renders ${t.box.w.toFixed(1)} x ${t.box.h.toFixed(1)} units at ` +
1056
+ `(${t.box.x.toFixed(1)}, ${t.box.y.toFixed(1)}), outside its declared box ` +
1057
+ `${t.declared_box_id ? `#${t.declared_box_id} ` : '(the viewBox) '}` +
1058
+ `${t.declared_box.w.toFixed(1)} x ${t.declared_box.h.toFixed(1)}`,
1059
+ });
1060
+ }
1061
+ if (!t.first_family_resolved) {
1062
+ out.push({
1063
+ rule: 'geometry/default-font-fallback',
1064
+ severity: 'warning',
1065
+ detail: t.declared_family
1066
+ ? `declared family "${t.first_family}" did not resolve; the renderer used "${t.resolved_family}"`
1067
+ : `no font-family is declared, so the label resolved to the renderer default "${t.resolved_family}"`,
1068
+ });
1069
+ }
1070
+ }
1071
+ for (const d of r.draw_on) {
1072
+ if (d.dead_fraction >= DRAW_ON_DEAD_TIME_THRESHOLD) {
1073
+ out.push({
1074
+ rule: 'motion/draw-on-dead-time',
1075
+ severity: 'warning',
1076
+ detail: `${(d.dead_fraction * 100).toFixed(1)}% of the draw-on duration paints nothing new: a ` +
1077
+ `${d.dash_units.toFixed(1)}-unit dash on a ${d.total_length.toFixed(2)}-unit path` +
1078
+ `${d.path_length_attr === null ? ' with no pathLength declared' : ''}`,
1079
+ });
1080
+ }
1081
+ }
1082
+ // Steps 4.1 and 4.2. Both name the GROUND they were read on, because
1083
+ // "the mark is hard to see" is not a property of the mark.
1084
+ for (const g of r.grounds) {
1085
+ if (g.background === 'dark' && g.silhouette_px > 0 && g.bleed_share > BLEED_SHARE_THRESHOLD) {
1086
+ out.push({
1087
+ rule: 'geometry/background-bleed-inside-silhouette',
1088
+ severity: 'warning',
1089
+ detail: `${g.bleed_px} of ${g.silhouette_px} pixels inside the silhouette read as the ${g.background} ` +
1090
+ `ground (${g.background_color}) — background showing THROUGH the graphic, which is what a ` +
1091
+ 'hairline seam between two abutting shapes looks like. Let the earlier surface reach about a ' +
1092
+ `pixel under its neighbour, or declare the gap with ${HOLE_ATTRIBUTE} if it is meant to be there`,
1093
+ });
1094
+ }
1095
+ if (g.silhouette_px > 0 && g.low_contrast_share > LOW_CONTRAST_SHARE_THRESHOLD) {
1096
+ out.push({
1097
+ rule: 'accessibility/low-contrast-on-background',
1098
+ severity: 'warning',
1099
+ detail: `${(g.low_contrast_share * 100).toFixed(0)} % of the painted pixels fall below a 3:1 contrast ` +
1100
+ `ratio against the ${g.background} ground (${g.background_color}) — build a variant for that ` +
1101
+ 'ground, or name it as missing',
1102
+ });
1103
+ }
1104
+ }
1105
+ // Step 2.1: the two readings the subject-only comparison separates out.
1106
+ // Neither is folded into IoU, and neither is silent.
1107
+ const raster = r.comparison?.raster;
1108
+ if (raster) {
1109
+ if (raster.frame_edge_contact.length > 0) {
1110
+ out.push({
1111
+ rule: 'geometry/source-cut-by-frame',
1112
+ severity: 'warning',
1113
+ detail: `the registered source runs into its frame on the ${raster.frame_edge_contact.join(', ')} ` +
1114
+ 'edge(s) — content cut by a crop agrees with a trace of that crop exactly, so the silhouette ' +
1115
+ 'reading cannot see the cut. Widen the crop and re-register before trusting the numbers',
1116
+ });
1117
+ }
1118
+ if (raster.foreign_content_share > FOREIGN_CONTENT_SHARE_THRESHOLD) {
1119
+ out.push({
1120
+ rule: 'geometry/foreign-content-in-frame',
1121
+ severity: 'warning',
1122
+ detail: `${(raster.foreign_content_share * 100).toFixed(1)} % of the registered source's painted pixels ` +
1123
+ 'lie outside the subject — a neighbouring mark, a glow or a shadow inside the registration ' +
1124
+ 'frame. It is reported here and kept out of the silhouette and colour readings',
1125
+ });
1126
+ }
1127
+ }
1128
+ for (const n of r.near_white_regions) {
1129
+ // Step 1.2: a finding needs EVIDENCE of a hole. Without a registered
1130
+ // source the region is recorded in the manifest and nothing is pushed —
1131
+ // the rule's message was always conditional on what the source shows,
1132
+ // and nothing was reading it.
1133
+ if (n.source_verdict !== 'hole')
1134
+ continue;
1135
+ out.push({
1136
+ rule: 'geometry/near-white-hole',
1137
+ severity: 'warning',
1138
+ detail: `${n.fill} is painted opaquely inside ${n.enclosed_by}, and the registered source shows its own ` +
1139
+ 'background there — a hole must be cut out, not painted near-white, or it reads white on a dark ' +
1140
+ 'background (S5)',
1141
+ });
1142
+ }
1143
+ if (r.icon_grid?.status === 'read') {
1144
+ const g = r.icon_grid;
1145
+ const strokeOff = g.stroke_width !== null &&
1146
+ g.reference_stroke_widths.length > 0 &&
1147
+ !g.reference_stroke_widths.some((s) => Math.abs(s - g.stroke_width) < 0.01);
1148
+ const minRefPadding = Math.min(...g.reference_padding);
1149
+ const paddingOff = g.padding !== null && g.padding < minRefPadding - 0.01;
1150
+ if (strokeOff || paddingOff) {
1151
+ out.push({
1152
+ rule: 'geometry/icon-off-grid',
1153
+ severity: 'warning',
1154
+ detail: `stroke ${g.stroke_width ?? 'none'} / padding ${g.padding?.toFixed(2) ?? 'none'} against the incumbent set's ` +
1155
+ `stroke ${g.reference_stroke_widths.join('/')} and padding ${g.reference_padding.join('/')} ` +
1156
+ `(read from ${g.reference_files.join(', ')})`,
1157
+ });
1158
+ }
1159
+ }
1160
+ // Measured, and previously derived nothing: an inline asset whose route was
1161
+ // read in a real browser and did NOT stop the motion produced an empty
1162
+ // findings array, so the one layer that can actually see the failure stayed
1163
+ // silent about it.
1164
+ if (r.reduced_motion.witnessable && r.reduced_motion.frames_equal === false) {
1165
+ out.push({
1166
+ rule: 'motion/reduced-motion-route-does-not-stop-it',
1167
+ severity: 'error',
1168
+ detail: 'under an emulated prefers-reduced-motion: reduce the frames still differ — the route this ' +
1169
+ 'file declares does not stop its animation, which is the R4 failure measured rather than inferred',
1170
+ });
1171
+ }
1172
+ // Step 1.1: gated on the duration, not only on the host's route. A host
1173
+ // with no `<picture>` route is correct for a file that does not move, and
1174
+ // this used to fire on all six files of a real conversion session — none
1175
+ // of which carries `<animate`, `@keyframes` or `animation`. A warning that
1176
+ // appears on every file is a warning an operator learns to scroll past,
1177
+ // which is Risk 1 of the parent roadmap arriving by a different door.
1178
+ const delivered = r.reduced_motion.delivered_animates;
1179
+ const unread = r.reduced_motion.delivered_unreadable ?? [];
1180
+ // Finding 13. An unreadable delivered file is UNKNOWN, and unknown is
1181
+ // reported rather than passed: the page-duration fallback reads 0 for a
1182
+ // spinner and a static logo alike, so falling back to it here would be the
1183
+ // absent-is-not-clean failure the rest of this change argues against.
1184
+ const moves = unread.length > 0
1185
+ ? true
1186
+ : delivered === null || delivered === undefined
1187
+ ? r.animation_duration_s > 0
1188
+ : delivered;
1189
+ if (r.reduced_motion.host_picture_route === false && moves) {
1190
+ out.push({
1191
+ rule: 'motion/img-delivery-no-static-route',
1192
+ severity: 'warning',
1193
+ detail: (unread.length > 0
1194
+ ? `${unread.join(', ')} could not be read, so whether it animates is unknown — reported ` +
1195
+ 'rather than passed. '
1196
+ : '') +
1197
+ 'an animated SVG is delivered through <img> or a CSS background and the host carries no ' +
1198
+ '<picture><source media="(prefers-reduced-motion: reduce)"> route — the emulated preference ' +
1199
+ 'does not reach an image document (R5), so this is the only place the route can live',
1200
+ });
1201
+ }
1202
+ for (const name of r.duplicate_accessible_names) {
1203
+ out.push({
1204
+ rule: 'accessibility/duplicate-accessible-name',
1205
+ severity: 'warning',
1206
+ detail: `"${name}" names more than one graphic on this page (R7)`,
1207
+ });
1208
+ }
1209
+ return out;
1210
+ }
1211
+ async function readReducedMotion(context, spec) {
1212
+ const R5 = 'an image document is unreachable by media emulation; the host\'s <picture> route is the thing to check';
1213
+ const page = await openPage(context, {
1214
+ url: spec.url,
1215
+ hostHtml: spec.hostHtml,
1216
+ hostBasename: HOST_BASENAME,
1217
+ width: spec.width,
1218
+ height: spec.height,
1219
+ background: spec.background,
1220
+ cssWidth: spec.cssWidth,
1221
+ blocked: spec.blocked,
1222
+ fileRoots: spec.fileRoots,
1223
+ reducedMotion: true,
1224
+ });
1225
+ try {
1226
+ if (spec.delivery === 'img' || spec.delivery === 'css-bg') {
1227
+ const seen = await page.evaluate(() => {
1228
+ const sources = Array.from(document.querySelectorAll('picture > source'));
1229
+ const route = sources.some((s) => /prefers-reduced-motion\s*:\s*reduce/i.test(s.getAttribute('media') ?? ''));
1230
+ // Every file this host DELIVERS, by its own reference — the
1231
+ // `<img>` src, each `<source srcset>`, and any CSS
1232
+ // background-image url. Names only; they are resolved on disk.
1233
+ const refs = new Set();
1234
+ for (const img of Array.from(document.querySelectorAll('img'))) {
1235
+ const src = img.getAttribute('src');
1236
+ if (src)
1237
+ refs.add(src);
1238
+ }
1239
+ for (const s of sources) {
1240
+ for (const part of (s.getAttribute('srcset') ?? '').split(',')) {
1241
+ const url = part.trim().split(/\s+/)[0];
1242
+ if (url)
1243
+ refs.add(url);
1244
+ }
1245
+ }
1246
+ for (const el of Array.from(document.querySelectorAll('*'))) {
1247
+ const bg = window.getComputedStyle(el).backgroundImage;
1248
+ const m = /url\((['"]?)([^)'"]+)\1\)/.exec(bg);
1249
+ if (m?.[2])
1250
+ refs.add(m[2]);
1251
+ }
1252
+ return { route, refs: [...refs] };
1253
+ });
1254
+ // Step 1.1: the question is about the DELIVERED file, and the host
1255
+ // page cannot answer it — its own duration is 0 for a spinner and
1256
+ // for a static logo alike.
1257
+ let animates = null;
1258
+ const unreadable = [];
1259
+ for (const ref of seen.refs) {
1260
+ if (/^(?:https?:|data:)/i.test(ref))
1261
+ continue;
1262
+ const abs = path.resolve(spec.hostDir, ref.replace(/^file:\/\//, ''));
1263
+ if (!/\.svg$/i.test(abs))
1264
+ continue;
1265
+ let body;
1266
+ try {
1267
+ body = fs.readFileSync(abs, 'utf8');
1268
+ }
1269
+ catch {
1270
+ // Finding 13: an `.svg` this host DELIVERS that could not
1271
+ // be read is absent evidence, and absent is not clean. It
1272
+ // used to `continue` into the page-duration fallback, which
1273
+ // that duration's own doc calls useless for an image
1274
+ // delivery — so an animating file behind an unreadable path
1275
+ // produced no warning at all.
1276
+ unreadable.push(ref);
1277
+ continue;
1278
+ }
1279
+ const moves = hasSmilMarkup(body) || hasCssAnimation(body);
1280
+ animates = (animates ?? false) || moves;
1281
+ }
1282
+ return {
1283
+ witnessable: false,
1284
+ frames_equal: null,
1285
+ reason: R5,
1286
+ host_picture_route: seen.route,
1287
+ delivered_animates: animates,
1288
+ ...(unreadable.length > 0 ? { delivered_unreadable: unreadable } : {}),
1289
+ };
1290
+ }
1291
+ if (spec.duration <= 0) {
1292
+ return { witnessable: true, frames_equal: true, reason: 'nothing animates in this file' };
1293
+ }
1294
+ await page.evaluate(seekInPage, 0);
1295
+ const a = sha256(await page.screenshot());
1296
+ await page.evaluate(seekInPage, spec.duration / 2);
1297
+ const b = sha256(await page.screenshot());
1298
+ return {
1299
+ witnessable: true,
1300
+ frames_equal: a === b,
1301
+ reason: 'inline delivery: the emulated preference reaches this document',
1302
+ };
1303
+ }
1304
+ finally {
1305
+ await page.close();
1306
+ }
1307
+ }
1308
+ //# sourceMappingURL=uiRenderAsset.js.map