@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.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +40 -191
- package/README.md +1 -1
- package/dist/agent-src/commands/design.md +1 -1
- package/dist/agent-src/guidelines/design-antipatterns.md +1 -1
- package/dist/agent-src/rules/token-budget-discipline.md +2 -2
- package/dist/agent-src/skills/accessibility-auditor/data/aria-patterns.csv +2 -0
- package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
- package/dist/agent-src/skills/canvas-design/SKILL.md +4 -2
- package/dist/agent-src/skills/design-intelligence/references/integration-mapping.md +13 -10
- package/dist/agent-src/skills/fe-design/references/design-patterns.md +49 -2
- package/dist/agent-src/skills/frontend-render-security/SKILL.md +9 -0
- package/dist/agent-src/skills/iconography/SKILL.md +4 -2
- package/dist/agent-src/skills/image-provider-routing/SKILL.md +16 -5
- package/dist/agent-src/skills/logo-generation/SKILL.md +12 -8
- package/dist/agent-src/skills/svg-authoring/SKILL.md +318 -0
- package/dist/agent-src/skills/wireframe/SKILL.md +5 -2
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/commands/uiRender.js +201 -4
- package/dist/cli/commands/uiRenderAsset.js +1308 -0
- package/dist/cli/commands/uiRenderAssetCompare.js +752 -0
- package/dist/cli/commands/uiRenderAssetGrid.js +265 -0
- package/dist/cli/commands/uiRenderAssetGrounds.js +323 -0
- package/dist/cli/commands/uiRenderAssetIsolation.js +85 -0
- package/dist/cli/commands/uiRenderAssetTypes.js +145 -0
- package/dist/cli/main.js +47 -1
- package/dist/cli/registry.js +1 -1
- package/dist/cli-delegate/{chunk-MSUL5NYP.js → chunk-4DBG5K7M.js} +1 -1
- package/dist/cli-delegate/{chunk-AED2VQGM.js → chunk-TMWYUOK4.js} +10 -1
- package/dist/cli-delegate/cmd_conformance.js +395 -214
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +47 -22
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +6 -6
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +19 -15
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +13 -9
- package/dist/hooks/dispatch.js +1073 -808
- package/dist/hooks/dispatch.sha256 +1 -1
- package/dist/hooks/svg_rules.chunk.cjs +1626 -0
- package/dist/hooks/svg_rules.chunk.cjs.sha256 +1 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +931 -735
- package/dist/scripts/_lib/scan_scope.js +105 -0
- package/dist/scripts/_lib/svg_dom.js +284 -0
- package/dist/scripts/_lib/svg_geometry.js +477 -0
- package/dist/scripts/_lib/svg_motion.js +179 -0
- package/dist/scripts/_lib/svg_rules.js +1337 -0
- package/dist/scripts/_lib/svg_urls.js +67 -0
- package/dist/scripts/lint_svg_asset.js +324 -0
- package/docs/guidelines/design-antipatterns.md +1 -1
- package/package.json +4 -3
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/routing-coverage-seed.json +9 -2
- package/src/scripts/_lib/svg_dom.ts +318 -0
- package/src/scripts/_lib/svg_geometry.ts +480 -0
- package/src/scripts/_lib/svg_motion.ts +208 -0
- package/src/scripts/_lib/svg_rules.ts +1453 -0
- package/src/scripts/_lib/svg_urls.ts +72 -0
- package/src/scripts/_lib/verification_command.ts +59 -0
- package/src/scripts/_lib/verify_clause.ts +27 -17
- package/src/scripts/hooks/block_speaking_inbox_dir.ts +16 -1
- package/src/scripts/hooks/design_pass_hook.ts +157 -3
- package/src/scripts/hooks/svg_obligation.ts +497 -0
- package/src/scripts/hooks/svg_rules_loader.ts +133 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +5 -3
- package/src/scripts/lint_consolidation_lineage.ts +6 -3
- package/src/scripts/lint_design_quality.ts +60 -12
- package/src/scripts/lint_motion_authority_drift.ts +27 -0
- package/src/scripts/lint_svg_asset.ts +365 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uiRenderAssetGrid — the incumbent icon grid, read from the set's own SVGs.
|
|
3
|
+
*
|
|
4
|
+
* Step E6 of `road-to-svg-with-a-witness`, carrying findings 2.1 and 2.2 of
|
|
5
|
+
* `road-to-svg-rules-in-the-dispatch-path`. Its own module because
|
|
6
|
+
* `uiRenderAsset.ts` is over the source-size budget and this is the most
|
|
7
|
+
* self-contained reading in it: no browser, no page, no shared state — bytes
|
|
8
|
+
* in, one grading out, which is also why its two findings are exercisable
|
|
9
|
+
* without a render.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import { elementExtent, parseElements, readViewBox } from '../../scripts/lint_svg_asset.js';
|
|
14
|
+
/**
|
|
15
|
+
* Elements whose contents DEFINE something rather than paint it.
|
|
16
|
+
*
|
|
17
|
+
* A shape inside one of these never appears on the canvas on its own, so it
|
|
18
|
+
* must not contribute to the graphic's extent. `<pattern>` and `<symbol>` join
|
|
19
|
+
* the three obvious ones for the same reason.
|
|
20
|
+
*/
|
|
21
|
+
const NON_RENDERING = new Set(['defs', 'clippath', 'mask', 'symbol', 'marker', 'pattern']);
|
|
22
|
+
/**
|
|
23
|
+
* The elements that actually paint: everything outside a transformed subtree
|
|
24
|
+
* and outside a non-rendering container.
|
|
25
|
+
*
|
|
26
|
+
* Extracted by finding 9 of this change's second completion review. The extent
|
|
27
|
+
* reading did this walk and the stroke-width reading did not, so an icon whose
|
|
28
|
+
* clip path carried `stroke-width="8"` reported 8 as its stroke while its
|
|
29
|
+
* padding was measured from the 1.5 the viewer sees. Two readings of the same
|
|
30
|
+
* file disagreeing about which elements are in it is the shape a shared walk
|
|
31
|
+
* removes by construction — the previous fix widened the extent half and left
|
|
32
|
+
* the other, which is exactly how the pair came to disagree.
|
|
33
|
+
*/
|
|
34
|
+
function renderingElements(elements) {
|
|
35
|
+
const out = [];
|
|
36
|
+
const openSkips = [];
|
|
37
|
+
let skipDepth = 0;
|
|
38
|
+
for (const el of elements) {
|
|
39
|
+
if (el.closing) {
|
|
40
|
+
const was = openSkips.pop();
|
|
41
|
+
if (was === true)
|
|
42
|
+
skipDepth -= 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const carries = Boolean(el.attrs['transform'] || el.attrs['style']?.includes('transform')) ||
|
|
46
|
+
NON_RENDERING.has(el.name);
|
|
47
|
+
if (!el.selfClosing) {
|
|
48
|
+
openSkips.push(carries);
|
|
49
|
+
if (carries)
|
|
50
|
+
skipDepth += 1;
|
|
51
|
+
}
|
|
52
|
+
if (skipDepth > 0 || (el.selfClosing && carries))
|
|
53
|
+
continue;
|
|
54
|
+
out.push(el);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The centreline extent of one graphic's own geometry, in user units.
|
|
60
|
+
*
|
|
61
|
+
* Shared by the references and by the candidate — finding 2.2 is precisely
|
|
62
|
+
* that the two used to be measured by different methods, so the one thing this
|
|
63
|
+
* function must never grow is a second caller-specific branch.
|
|
64
|
+
*
|
|
65
|
+
* A TRANSFORM ANYWHERE ABOVE A SHAPE DISQUALIFIES IT, not only one on the
|
|
66
|
+
* shape. `elementExtent` refuses an element that carries its own `transform`,
|
|
67
|
+
* and a flat element list makes it easy to miss that a `<g transform="…">`
|
|
68
|
+
* moves everything inside it — measured here: a rect inside a rotated group
|
|
69
|
+
* read as if the group were not there, and the file reported a padding it does
|
|
70
|
+
* not have. The depth stack below is what carries the inheritance; without it
|
|
71
|
+
* the refusal is a half-refusal, which is worse than none because it looks
|
|
72
|
+
* like a reading.
|
|
73
|
+
*/
|
|
74
|
+
export function centrelineExtent(elements) {
|
|
75
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
76
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
77
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
78
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
79
|
+
// Two reasons to skip a subtree, tracked on one stack: an ancestor carries
|
|
80
|
+
// a transform, or an ancestor DOES NOT RENDER.
|
|
81
|
+
//
|
|
82
|
+
// The second is finding 3 of this change's completion review. Geometry
|
|
83
|
+
// inside `<defs>`, `<clipPath>`, `<mask>`, `<symbol>`, `<marker>` or
|
|
84
|
+
// `<pattern>` is a definition, not paint — and a clip path is usually a
|
|
85
|
+
// full-bleed rect, so an exported icon reported padding 0 on BOTH sides
|
|
86
|
+
// and the comparison finding 2.2 exists to make meaningful compared two
|
|
87
|
+
// zeroes. The old `d`-scraping reader had the same blind spot; reading
|
|
88
|
+
// primitives widened it, because clip and mask shapes are rects and
|
|
89
|
+
// circles.
|
|
90
|
+
for (const el of renderingElements(elements)) {
|
|
91
|
+
const box = elementExtent(el);
|
|
92
|
+
if (!box)
|
|
93
|
+
continue;
|
|
94
|
+
minX = Math.min(minX, box.minX);
|
|
95
|
+
maxX = Math.max(maxX, box.maxX);
|
|
96
|
+
minY = Math.min(minY, box.minY);
|
|
97
|
+
maxY = Math.max(maxY, box.maxY);
|
|
98
|
+
}
|
|
99
|
+
return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null;
|
|
100
|
+
}
|
|
101
|
+
/** Smallest distance from a viewBox edge to an extent. Negative when it overflows. */
|
|
102
|
+
export function paddingFromExtent(vb, ext) {
|
|
103
|
+
return Math.min(ext.minX - vb.x, ext.minY - vb.y, vb.x + vb.w - ext.maxX, vb.y + vb.h - ext.maxY);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Stroke width and padding of each reference icon, read from its own bytes.
|
|
107
|
+
* Padding is the smallest distance from a viewBox edge to the drawn geometry.
|
|
108
|
+
* Nothing is declared here and no grid table is vendored (E6, Non-goals).
|
|
109
|
+
*
|
|
110
|
+
* Finding 2.1 of `road-to-svg-rules-in-the-dispatch-path`, both halves. This
|
|
111
|
+
* used to scrape `d` with a loose number-pair regex, so:
|
|
112
|
+
*
|
|
113
|
+
* · a reference drawn with `<rect>` / `<circle>` / `<line>` yielded no
|
|
114
|
+
* extent, was dropped, and — when every reference was drawn that way —
|
|
115
|
+
* the reading then said "no icon reference set was supplied" although one
|
|
116
|
+
* was. It now reads every primitive, and a file it still cannot read is
|
|
117
|
+
* returned in `skipped` with the reason rather than vanishing.
|
|
118
|
+
* · relative commands were read as absolute, so a path drawn with `m`/`l`
|
|
119
|
+
* reported an extent anchored near the origin. `pathExtent` walks the
|
|
120
|
+
* current point.
|
|
121
|
+
*
|
|
122
|
+
* The reading degrades to `unverified` rather than to a pass, so no verdict
|
|
123
|
+
* was wrong before this — but the reason given for the degradation was.
|
|
124
|
+
*/
|
|
125
|
+
export function readIconReferencesDetailed(files) {
|
|
126
|
+
const refs = [];
|
|
127
|
+
const skipped = [];
|
|
128
|
+
for (const file of files) {
|
|
129
|
+
if (!fs.existsSync(file)) {
|
|
130
|
+
skipped.push({ file, reason: 'file does not exist' });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
134
|
+
const elements = parseElements(content);
|
|
135
|
+
const root = elements.find((el) => el.name === 'svg' && !el.closing);
|
|
136
|
+
const vb = readViewBox(root);
|
|
137
|
+
if (!vb) {
|
|
138
|
+
skipped.push({ file, reason: 'no readable viewBox' });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
// The SAME walk the candidate's stroke reading uses. Finding 5 of the
|
|
142
|
+
// third completion review: the candidate side was moved onto
|
|
143
|
+
// `renderingElements` and the reference side was not, so a reference
|
|
144
|
+
// icon's clip path contributed a stroke the viewer never sees.
|
|
145
|
+
// Reproduced: two byte-identical files reported 1.5 against [1.5, 8].
|
|
146
|
+
// The same defect as finding 9 of the previous round, in the other
|
|
147
|
+
// half of the same pair — which is what a defect-pattern sweep over
|
|
148
|
+
// the construct, rather than over the one reported site, is for.
|
|
149
|
+
const strokes = new Set();
|
|
150
|
+
for (const el of renderingElements(elements)) {
|
|
151
|
+
const sw = Number(el.attrs['stroke-width']);
|
|
152
|
+
if (Number.isFinite(sw) && sw > 0)
|
|
153
|
+
strokes.add(sw);
|
|
154
|
+
}
|
|
155
|
+
const ext = centrelineExtent(elements.filter((el) => el.name !== 'svg'));
|
|
156
|
+
if (!ext) {
|
|
157
|
+
skipped.push({ file, reason: 'no element in it carries readable geometry (every shape transformed, or none drawn)' });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
refs.push({
|
|
161
|
+
file,
|
|
162
|
+
viewBox: vb,
|
|
163
|
+
strokeWidths: [...strokes].sort((a, b) => a - b),
|
|
164
|
+
padding: paddingFromExtent(vb, ext),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return { refs, skipped };
|
|
168
|
+
}
|
|
169
|
+
/** The list-only form, kept because every existing caller wants exactly that. */
|
|
170
|
+
export function readIconReferences(files) {
|
|
171
|
+
return readIconReferencesDetailed(files).refs;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Graded against the incumbent set, both sides measured the same way (2.2).
|
|
175
|
+
*
|
|
176
|
+
* Exported so the grading can be exercised without launching a browser: the
|
|
177
|
+
* two findings it carries are a padding-method mismatch and a reference set
|
|
178
|
+
* dropped silently, and neither needs a render to reproduce.
|
|
179
|
+
*/
|
|
180
|
+
export function gradeIconGrid(file, vb, elements, iconSet,
|
|
181
|
+
/**
|
|
182
|
+
* The RENDERED painted box. Kept in the signature and deliberately unread:
|
|
183
|
+
* it is what the candidate's padding used to come from (finding 2.2), and
|
|
184
|
+
* dropping the parameter would make a caller that still passes it look
|
|
185
|
+
* correct while silently changing which argument lands where. Named with a
|
|
186
|
+
* leading underscore so the linter agrees it is not used.
|
|
187
|
+
*/
|
|
188
|
+
_paintedBox) {
|
|
189
|
+
const self = path.resolve(file);
|
|
190
|
+
const { refs, skipped } = readIconReferencesDetailed(iconSet.map((f) => path.resolve(f)).filter((f) => f !== self));
|
|
191
|
+
const comparable = vb ? refs.filter((r) => r.viewBox.w === vb.w && r.viewBox.h === vb.h) : [];
|
|
192
|
+
const strokes = new Set();
|
|
193
|
+
for (const el of renderingElements(elements)) {
|
|
194
|
+
const sw = Number(el.attrs['stroke-width']);
|
|
195
|
+
if (Number.isFinite(sw) && sw > 0)
|
|
196
|
+
strokes.add(sw);
|
|
197
|
+
}
|
|
198
|
+
const strokeWidth = strokes.size > 0 ? Math.max(...strokes) : null;
|
|
199
|
+
/**
|
|
200
|
+
* Finding 2.2: BOTH sides are centreline extents now.
|
|
201
|
+
*
|
|
202
|
+
* The candidate's padding used to come from `painted_box`, a RENDERED box
|
|
203
|
+
* that includes half the stroke and the caps, while the references came
|
|
204
|
+
* from raw coordinates. A candidate matching a reference exactly therefore
|
|
205
|
+
* measured one half-stroke less padding than it — latent rather than live
|
|
206
|
+
* only because the shipped icon pair happens to sit outside the band, so a
|
|
207
|
+
* new fixture at a different stroke width would have made it fire on a
|
|
208
|
+
* clean twin (Risk 7).
|
|
209
|
+
*
|
|
210
|
+
* Fixed by measuring, not by widening a threshold: `centrelineExtent` is
|
|
211
|
+
* the same function the references go through. `painted_box` keeps its own
|
|
212
|
+
* meaning everywhere else in the reading — it is simply not the thing to
|
|
213
|
+
* compare against a centreline.
|
|
214
|
+
*/
|
|
215
|
+
const selfExt = centrelineExtent(elements.filter((el) => el.name !== 'svg'));
|
|
216
|
+
const padding = vb && selfExt ? paddingFromExtent(vb, selfExt) : null;
|
|
217
|
+
if (comparable.length === 0) {
|
|
218
|
+
const unreadable = skipped.length
|
|
219
|
+
? ` ${String(skipped.length)} supplied file(s) could not be read: ${skipped
|
|
220
|
+
.map((s) => `${path.basename(s.file)} (${s.reason})`)
|
|
221
|
+
.join('; ')}.`
|
|
222
|
+
: '';
|
|
223
|
+
return {
|
|
224
|
+
status: 'unverified',
|
|
225
|
+
reference_files: refs.map((r) => path.basename(r.file)),
|
|
226
|
+
reference_stroke_widths: [],
|
|
227
|
+
reference_padding: [],
|
|
228
|
+
stroke_width: strokeWidth,
|
|
229
|
+
padding,
|
|
230
|
+
reason: refs.length === 0
|
|
231
|
+
? (refs.length === 0 && skipped.length === 0 && iconSet.length > 0
|
|
232
|
+
? 'every file in the supplied icon reference set is the file under test, so there is ' +
|
|
233
|
+
'nothing to compare against'
|
|
234
|
+
: iconSet.length === 0
|
|
235
|
+
? 'no icon reference set was supplied, so the incumbent grid was never read (E6)'
|
|
236
|
+
: 'an icon reference set was supplied but no file in it yielded a reading, so there ' +
|
|
237
|
+
'is no grid to compare against') + unreadable
|
|
238
|
+
: vb === null
|
|
239
|
+
? // Finding 8 of the fifth completion review: a CANDIDATE
|
|
240
|
+
// with no readable viewBox reached the same branch as a
|
|
241
|
+
// reference set that shares no size, and was told that
|
|
242
|
+
// no reference shares its viewBox — a statement about
|
|
243
|
+
// the references, when the unreadable thing is the file
|
|
244
|
+
// in front of the author. The reason has to name what
|
|
245
|
+
// is actually missing or the author fixes the wrong
|
|
246
|
+
// file.
|
|
247
|
+
'this file has no readable viewBox, so its grid cannot be compared against any ' +
|
|
248
|
+
'reference' + unreadable
|
|
249
|
+
: 'no reference icon shares this viewBox size, so there is no grid to compare against' +
|
|
250
|
+
unreadable,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const refStrokes = [...new Set(comparable.flatMap((r) => r.strokeWidths))].sort((a, b) => a - b);
|
|
254
|
+
const refPadding = [...new Set(comparable.map((r) => r.padding))].sort((a, b) => a - b);
|
|
255
|
+
return {
|
|
256
|
+
status: 'read',
|
|
257
|
+
reference_files: comparable.map((r) => path.basename(r.file)),
|
|
258
|
+
reference_stroke_widths: refStrokes,
|
|
259
|
+
reference_padding: refPadding,
|
|
260
|
+
stroke_width: strokeWidth,
|
|
261
|
+
padding,
|
|
262
|
+
reason: `read from ${comparable.length} incumbent SVG(s)`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
//# sourceMappingURL=uiRenderAssetGrid.js.map
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { HOST_BASENAME, MIN_CONTRAST_RATIO, } from './uiRenderAssetTypes.js';
|
|
2
|
+
/**
|
|
3
|
+
* The per-ground readings — steps 4.1 and 4.2 of
|
|
4
|
+
* `road-to-svg-from-a-live-session`.
|
|
5
|
+
*
|
|
6
|
+
* Three images, read in one scratch page so nothing is decoded twice:
|
|
7
|
+
*
|
|
8
|
+
* transparent the file on no ground at all, which is where its own
|
|
9
|
+
* silhouette comes from. A silhouette taken from the light
|
|
10
|
+
* capture would lose every white part of the graphic, which is
|
|
11
|
+
* precisely the paint these two readings are about.
|
|
12
|
+
* light / dark the two captures the profile already wrote.
|
|
13
|
+
*
|
|
14
|
+
* Inside that silhouette, two counts per ground. A pixel whose contrast ratio
|
|
15
|
+
* against the ground is below 3:1 is hard to see there (4.2) — a wordmark in
|
|
16
|
+
* dark violet shipped unreadable on a dark ground and the variant was never
|
|
17
|
+
* built. A pixel whose colour sits within the anti-alias band of the ground is
|
|
18
|
+
* the ground showing THROUGH the graphic (4.1) — the hairline seams between
|
|
19
|
+
* abutting traced segments, invisible on white and obvious on black.
|
|
20
|
+
*
|
|
21
|
+
* Holes are excluded by declaration, never by geometry: a ring's middle is
|
|
22
|
+
* ground on purpose, and only the author can say which gaps are intended.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* What FRACTION of the capture the graphic covers, width and height.
|
|
26
|
+
*
|
|
27
|
+
* Finding 4 of this change's second completion review. The capture is the
|
|
28
|
+
* VIEWPORT, floored at 64 px, while the page is `margin:0` with
|
|
29
|
+
* `body>svg{width:cssWidth}` — so the graphic sits in the top-left corner and
|
|
30
|
+
* covers only part of the image whenever `cssWidth` is smaller. Every icon is
|
|
31
|
+
* such a case: the sizes are 16/24/48 against a floor of 64. Mapping a
|
|
32
|
+
* user-unit box across the whole capture therefore put every declared hole and
|
|
33
|
+
* every declared-translucent shape 4/3 too far out, and the exclusions the
|
|
34
|
+
* author declared were applied to the wrong part of their own file.
|
|
35
|
+
*
|
|
36
|
+
* A FRACTION rather than a pixel count, because the image's size is known only
|
|
37
|
+
* inside the page while everything this needs is known outside it — which is
|
|
38
|
+
* also what makes the arithmetic testable without a renderer. Clamped to 1:
|
|
39
|
+
* `cssWidth` above the viewport is the graphic filling the capture, not
|
|
40
|
+
* overflowing the coordinate space.
|
|
41
|
+
*/
|
|
42
|
+
export function graphicFraction(spec) {
|
|
43
|
+
if (spec.width <= 0 || spec.height <= 0 || spec.cssWidth <= 0)
|
|
44
|
+
return { w: 1, h: 1 };
|
|
45
|
+
const w = Math.min(1, spec.cssWidth / spec.width);
|
|
46
|
+
const vb = spec.viewBox;
|
|
47
|
+
if (!vb || vb.w <= 0)
|
|
48
|
+
return { w, h: 1 };
|
|
49
|
+
// The rendered height follows the viewBox aspect from the rendered width.
|
|
50
|
+
const cssHeight = spec.cssWidth * (vb.h / vb.w);
|
|
51
|
+
return { w, h: Math.min(1, cssHeight / spec.height) };
|
|
52
|
+
}
|
|
53
|
+
export async function readGrounds(context, openPage, spec) {
|
|
54
|
+
// The transparent render: `omitBackground` leaves alpha 0 where the file
|
|
55
|
+
// paints nothing, which is the only honest source for a silhouette.
|
|
56
|
+
const clear = await openPage(context, {
|
|
57
|
+
url: spec.url,
|
|
58
|
+
hostHtml: spec.hostHtml,
|
|
59
|
+
hostBasename: HOST_BASENAME,
|
|
60
|
+
width: spec.width,
|
|
61
|
+
height: spec.height,
|
|
62
|
+
background: 'transparent',
|
|
63
|
+
cssWidth: spec.cssWidth,
|
|
64
|
+
blocked: spec.blocked,
|
|
65
|
+
});
|
|
66
|
+
let silhouette;
|
|
67
|
+
try {
|
|
68
|
+
silhouette = await clear.screenshot({ omitBackground: true });
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
await clear.close();
|
|
72
|
+
}
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const mode of ['light', 'dark']) {
|
|
75
|
+
const colour = spec.backgrounds[mode];
|
|
76
|
+
const page = await openPage(context, {
|
|
77
|
+
url: spec.url,
|
|
78
|
+
hostHtml: spec.hostHtml,
|
|
79
|
+
hostBasename: HOST_BASENAME,
|
|
80
|
+
width: spec.width,
|
|
81
|
+
height: spec.height,
|
|
82
|
+
background: colour,
|
|
83
|
+
cssWidth: spec.cssWidth,
|
|
84
|
+
blocked: spec.blocked,
|
|
85
|
+
});
|
|
86
|
+
let shot;
|
|
87
|
+
try {
|
|
88
|
+
shot = await page.screenshot();
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
await page.close();
|
|
92
|
+
}
|
|
93
|
+
const scratch = await context.newPage();
|
|
94
|
+
try {
|
|
95
|
+
await scratch.setContent('<!doctype html><body></body>');
|
|
96
|
+
const counts = await scratch.evaluate(async (args) => {
|
|
97
|
+
const load = async (b64) => {
|
|
98
|
+
const img = new Image();
|
|
99
|
+
img.src = `data:image/png;base64,${b64}`;
|
|
100
|
+
try {
|
|
101
|
+
await img.decode();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return img;
|
|
107
|
+
};
|
|
108
|
+
const a = await load(args.silhouette);
|
|
109
|
+
const b = await load(args.ground);
|
|
110
|
+
if (!a || !b)
|
|
111
|
+
return null;
|
|
112
|
+
const w = Math.min(a.naturalWidth, b.naturalWidth);
|
|
113
|
+
const h = Math.min(a.naturalHeight, b.naturalHeight);
|
|
114
|
+
if (w === 0 || h === 0)
|
|
115
|
+
return null;
|
|
116
|
+
const read = (img) => {
|
|
117
|
+
const c = document.createElement('canvas');
|
|
118
|
+
c.width = w;
|
|
119
|
+
c.height = h;
|
|
120
|
+
const cx = c.getContext('2d', { willReadFrequently: true });
|
|
121
|
+
if (!cx)
|
|
122
|
+
return null;
|
|
123
|
+
cx.clearRect(0, 0, w, h);
|
|
124
|
+
cx.drawImage(img, 0, 0);
|
|
125
|
+
return cx.getImageData(0, 0, w, h).data;
|
|
126
|
+
};
|
|
127
|
+
const alpha = read(a);
|
|
128
|
+
const px = read(b);
|
|
129
|
+
if (!alpha || !px)
|
|
130
|
+
return null;
|
|
131
|
+
const hex = args.colour.trim();
|
|
132
|
+
const parse = (v) => {
|
|
133
|
+
const m3 = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(v);
|
|
134
|
+
if (m3) {
|
|
135
|
+
return [
|
|
136
|
+
parseInt(m3[1].repeat(2), 16),
|
|
137
|
+
parseInt(m3[2].repeat(2), 16),
|
|
138
|
+
parseInt(m3[3].repeat(2), 16),
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
// 4 and 8 digits carry an ALPHA the ground does not
|
|
142
|
+
// need; 6 is the plain case. Finding 5 of the fifth
|
|
143
|
+
// completion review: this handled 3 and 6 while the
|
|
144
|
+
// token reader that produces the value accepts 3 to 8,
|
|
145
|
+
// so a `#rrggbbaa` background fell through to the
|
|
146
|
+
// white fallback and every contrast ratio on that
|
|
147
|
+
// ground was computed against a colour nobody chose —
|
|
148
|
+
// silently, because white is a plausible answer.
|
|
149
|
+
const m68 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})(?:[0-9a-f]{2})?$/i.exec(v);
|
|
150
|
+
if (m68) {
|
|
151
|
+
return [
|
|
152
|
+
parseInt(m68[1], 16),
|
|
153
|
+
parseInt(m68[2], 16),
|
|
154
|
+
parseInt(m68[3], 16),
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
const m4 = /^#([0-9a-f])([0-9a-f])([0-9a-f])[0-9a-f]$/i.exec(v);
|
|
158
|
+
if (m4) {
|
|
159
|
+
return [
|
|
160
|
+
parseInt(m4[1].repeat(2), 16),
|
|
161
|
+
parseInt(m4[2].repeat(2), 16),
|
|
162
|
+
parseInt(m4[3].repeat(2), 16),
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
const rgb = /rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/.exec(v);
|
|
166
|
+
if (rgb) {
|
|
167
|
+
return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
|
|
168
|
+
}
|
|
169
|
+
return [255, 255, 255];
|
|
170
|
+
};
|
|
171
|
+
const ground = parse(hex);
|
|
172
|
+
const lum = (c) => {
|
|
173
|
+
const f = (v) => {
|
|
174
|
+
const x = v / 255;
|
|
175
|
+
return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
|
|
176
|
+
};
|
|
177
|
+
return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]);
|
|
178
|
+
};
|
|
179
|
+
const groundLum = lum(ground);
|
|
180
|
+
const ratio = (c) => {
|
|
181
|
+
const l = lum(c);
|
|
182
|
+
const hi = Math.max(l, groundLum);
|
|
183
|
+
const lo = Math.min(l, groundLum);
|
|
184
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
185
|
+
};
|
|
186
|
+
// Declared holes and translucent shapes, mapped from user
|
|
187
|
+
// units into this image.
|
|
188
|
+
//
|
|
189
|
+
// The graphic's own extent in IMAGE pixels, which is what a
|
|
190
|
+
// user-unit coordinate maps into — not the capture's. The
|
|
191
|
+
// page is `margin:0` with `body>svg{width:cssWidth}`, so it
|
|
192
|
+
// sits at the top-left corner and the height follows the
|
|
193
|
+
// viewBox aspect. A file with no viewBox is not mapped at
|
|
194
|
+
// all (the guard below), so there is no aspect to miss.
|
|
195
|
+
const gW = w * args.graphic.w;
|
|
196
|
+
const gH = h * args.graphic.h;
|
|
197
|
+
const within = (boxes, x, y) => {
|
|
198
|
+
if (!args.vb || boxes.length === 0)
|
|
199
|
+
return false;
|
|
200
|
+
if (gW <= 0 || gH <= 0 || x >= gW || y >= gH)
|
|
201
|
+
return false;
|
|
202
|
+
const ux = args.vb.x + (x / gW) * args.vb.w;
|
|
203
|
+
const uy = args.vb.y + (y / gH) * args.vb.h;
|
|
204
|
+
return boxes.some((box) => ux >= box.x && ux <= box.x + box.w && uy >= box.y && uy <= box.y + box.h);
|
|
205
|
+
};
|
|
206
|
+
const inHole = (x, y) => within(args.holes, x, y);
|
|
207
|
+
const inSoft = (x, y) => within(args.soft, x, y);
|
|
208
|
+
// The OUTER silhouette: flood the transparent region
|
|
209
|
+
// inwards from the border. What the flood cannot reach is
|
|
210
|
+
// inside the graphic — including the hairline gaps between
|
|
211
|
+
// abutting shapes, which is the thing step 4.1 counts and
|
|
212
|
+
// which an `alpha >= 200` definition excludes by
|
|
213
|
+
// construction (measured: 0 bleed pixels on the planted
|
|
214
|
+
// fixture before this).
|
|
215
|
+
const opaque = (i) => alpha[i * 4 + 3] >= 200;
|
|
216
|
+
const outside = new Uint8Array(w * h);
|
|
217
|
+
const stack = [];
|
|
218
|
+
const push = (i) => {
|
|
219
|
+
if (outside[i] === 1 || opaque(i))
|
|
220
|
+
return;
|
|
221
|
+
outside[i] = 1;
|
|
222
|
+
stack.push(i);
|
|
223
|
+
};
|
|
224
|
+
for (let x = 0; x < w; x += 1) {
|
|
225
|
+
push(x);
|
|
226
|
+
push((h - 1) * w + x);
|
|
227
|
+
}
|
|
228
|
+
for (let y = 0; y < h; y += 1) {
|
|
229
|
+
push(y * w);
|
|
230
|
+
push(y * w + (w - 1));
|
|
231
|
+
}
|
|
232
|
+
while (stack.length > 0) {
|
|
233
|
+
const i = stack.pop();
|
|
234
|
+
const x = i % w;
|
|
235
|
+
const y = (i - x) / w;
|
|
236
|
+
if (x > 0)
|
|
237
|
+
push(i - 1);
|
|
238
|
+
if (x + 1 < w)
|
|
239
|
+
push(i + 1);
|
|
240
|
+
if (y > 0)
|
|
241
|
+
push(i - w);
|
|
242
|
+
if (y + 1 < h)
|
|
243
|
+
push(i + w);
|
|
244
|
+
}
|
|
245
|
+
let sil = 0;
|
|
246
|
+
let painted = 0;
|
|
247
|
+
let low = 0;
|
|
248
|
+
let bleed = 0;
|
|
249
|
+
for (let i = 0; i < w * h; i += 1) {
|
|
250
|
+
if (outside[i] === 1)
|
|
251
|
+
continue;
|
|
252
|
+
sil += 1;
|
|
253
|
+
const c = [
|
|
254
|
+
px[i * 4],
|
|
255
|
+
px[i * 4 + 1],
|
|
256
|
+
px[i * 4 + 2],
|
|
257
|
+
];
|
|
258
|
+
// Contrast is asked of PAINTED pixels only: the ratio
|
|
259
|
+
// of a gap against the ground showing through it is the
|
|
260
|
+
// ratio of the ground against itself.
|
|
261
|
+
if (opaque(i)) {
|
|
262
|
+
painted += 1;
|
|
263
|
+
if (ratio(c) < args.minContrast)
|
|
264
|
+
low += 1;
|
|
265
|
+
}
|
|
266
|
+
// Bleed is an INTERIOR pixel the graphic does not fully
|
|
267
|
+
// cover, so the ground shows through it.
|
|
268
|
+
//
|
|
269
|
+
// Measured, and this is the second definition: comparing
|
|
270
|
+
// the pixel's colour against the ground within a
|
|
271
|
+
// tolerance read 0 on the planted fixture, because a
|
|
272
|
+
// half-covered seam composites to a BLEND of the two
|
|
273
|
+
// greys and the ground — nowhere near the ground itself.
|
|
274
|
+
// Alpha is the thing that is actually wrong, and it is
|
|
275
|
+
// the same number whatever colour the ground is, which
|
|
276
|
+
// is why the finding fires on the dark row: that is the
|
|
277
|
+
// capture where the consequence is visible.
|
|
278
|
+
// Finding 7: a legitimately TRANSLUCENT interior is
|
|
279
|
+
// not a seam. A `fill-opacity="0.5"` shape is excluded
|
|
280
|
+
// from `painted` and would otherwise be counted as
|
|
281
|
+
// near-total bleed against a 0.002 threshold — a
|
|
282
|
+
// warning on a correct file, which is the Risk 1
|
|
283
|
+
// failure this whole concern is shaped around. The
|
|
284
|
+
// caller passes those shapes' extents the same way it
|
|
285
|
+
// passes declared holes, because "is this coverage
|
|
286
|
+
// intended" is again a question only the author
|
|
287
|
+
// answers.
|
|
288
|
+
const x = i % w;
|
|
289
|
+
const y = Math.floor(i / w);
|
|
290
|
+
if (!opaque(i) && !inHole(x, y) && !inSoft(x, y))
|
|
291
|
+
bleed += 1;
|
|
292
|
+
}
|
|
293
|
+
return { silhouette: sil, painted, low, bleed };
|
|
294
|
+
}, {
|
|
295
|
+
silhouette: silhouette.toString('base64'),
|
|
296
|
+
ground: shot.toString('base64'),
|
|
297
|
+
colour,
|
|
298
|
+
holes: spec.holeBoxes,
|
|
299
|
+
soft: spec.softBoxes,
|
|
300
|
+
vb: spec.viewBox,
|
|
301
|
+
minContrast: MIN_CONTRAST_RATIO,
|
|
302
|
+
graphic: graphicFraction(spec),
|
|
303
|
+
});
|
|
304
|
+
if (counts === null)
|
|
305
|
+
continue;
|
|
306
|
+
out.push({
|
|
307
|
+
background: mode,
|
|
308
|
+
background_color: colour,
|
|
309
|
+
silhouette_px: counts.silhouette,
|
|
310
|
+
painted_px: counts.painted,
|
|
311
|
+
low_contrast_px: counts.low,
|
|
312
|
+
low_contrast_share: counts.painted === 0 ? 0 : counts.low / counts.painted,
|
|
313
|
+
bleed_px: counts.bleed,
|
|
314
|
+
bleed_share: counts.silhouette === 0 ? 0 : counts.bleed / counts.silhouette,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
await scratch.close();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
//# sourceMappingURL=uiRenderAssetGrounds.js.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolation of the asset under test — which is untrusted input — during its
|
|
3
|
+
* own witness (16.5.0 release review).
|
|
4
|
+
*
|
|
5
|
+
* Scripts are disabled for the whole browser context, so a `<script>` or `on*`
|
|
6
|
+
* handler in the asset never runs; that is also what keeps WebSocket and
|
|
7
|
+
* WebRTC closed, since `page.route` intercepts neither. A request is allowed
|
|
8
|
+
* only on the `file:`, `data:`, `blob:` and `about:` schemes, and a `file:`
|
|
9
|
+
* request only when it resolves inside the project root or the asset's own
|
|
10
|
+
* directory, so an asset cannot pull an arbitrary local file into a capture.
|
|
11
|
+
* The comparison pages inline everything they read, so they allow no `file:`.
|
|
12
|
+
*
|
|
13
|
+
* The cost, accepted: a host document that builds its visual state with
|
|
14
|
+
* JavaScript is witnessed as its static markup, not as it would run.
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from 'node:fs';
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { ALLOWED_SCHEMES } from './uiRenderAssetTypes.js';
|
|
20
|
+
/** The directories a `file:` request may resolve into for this asset. */
|
|
21
|
+
export function fileRootsFor(projectRoot, file) {
|
|
22
|
+
return [projectRoot, path.dirname(path.resolve(file))];
|
|
23
|
+
}
|
|
24
|
+
/** True unless `url` is a `file:` URL resolving outside `root` (symlinks followed). */
|
|
25
|
+
export function fileInside(url, root) {
|
|
26
|
+
if (!url.startsWith('file:'))
|
|
27
|
+
return true;
|
|
28
|
+
const real = (p) => {
|
|
29
|
+
try {
|
|
30
|
+
return fs.realpathSync(p);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return path.resolve(p);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
let target;
|
|
37
|
+
try {
|
|
38
|
+
target = real(fileURLToPath(url));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
const base = real(root);
|
|
44
|
+
return target === base || target.startsWith(base + path.sep);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* May the witness page load `url`? A `file:` URL needs a root it resolves
|
|
48
|
+
* inside; with no roots — a page that inlines everything it reads — none does.
|
|
49
|
+
*/
|
|
50
|
+
export function requestAllowed(url, fileRoots) {
|
|
51
|
+
if (!ALLOWED_SCHEMES.some((s) => url.startsWith(s)))
|
|
52
|
+
return false;
|
|
53
|
+
return !url.startsWith('file:') || fileRoots.some((root) => fileInside(url, root));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Route every request of `page` through `requestAllowed`, recording what was
|
|
57
|
+
* refused. One opener for every witness page, so no page confines less than
|
|
58
|
+
* another (16.5.0 review: the comparison pages checked the scheme only).
|
|
59
|
+
*/
|
|
60
|
+
export async function confinePage(page, blocked, fileRoots, fulfil) {
|
|
61
|
+
await page.route('**/*', async (route) => {
|
|
62
|
+
if (fulfil && (await fulfil(route)))
|
|
63
|
+
return;
|
|
64
|
+
const url = route.request().url();
|
|
65
|
+
if (!requestAllowed(url, fileRoots)) {
|
|
66
|
+
blocked.push(url);
|
|
67
|
+
await route.abort();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
await route.continue();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Add a stylesheet to the page. Not `page.addStyleTag`: it waits on a
|
|
75
|
+
* page-script `onload`, which never fires with scripts disabled, while
|
|
76
|
+
* `evaluate` runs outside the page's own scripts.
|
|
77
|
+
*/
|
|
78
|
+
export async function injectStyle(page, css) {
|
|
79
|
+
await page.evaluate((text) => {
|
|
80
|
+
const style = document.createElement('style');
|
|
81
|
+
style.textContent = text;
|
|
82
|
+
(document.head ?? document.documentElement).appendChild(style);
|
|
83
|
+
}, css);
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=uiRenderAssetIsolation.js.map
|