@penpair/core 0.2.0 → 0.3.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/dist/adapters.js +58 -11
- package/dist/approvals.js +143 -29
- package/dist/atomic-output.js +9 -4
- package/dist/audit-rules.js +345 -79
- package/dist/backup-note.js +29 -8
- package/dist/baseline-coverage.js +169 -35
- package/dist/baselines-config.js +119 -29
- package/dist/baselines.js +280 -57
- package/dist/cli-arguments.js +18 -6
- package/dist/cli-main.js +1621 -392
- package/dist/client.d.ts +5 -2
- package/dist/client.js +127 -28
- package/dist/command-result.js +341 -52
- package/dist/coordination.js +91 -29
- package/dist/credentials.js +57 -13
- package/dist/deep-check.js +56 -12
- package/dist/design-spec.js +189 -49
- package/dist/endpoint.js +102 -34
- package/dist/export.js +86 -18
- package/dist/file-archive.js +33 -7
- package/dist/file-guard.js +29 -11
- package/dist/formats.js +215 -14
- package/dist/index.d.ts +8 -7
- package/dist/index.js +8 -7
- package/dist/inspection.d.ts +1 -1
- package/dist/inspection.js +378 -65
- package/dist/instance/config-schema.js +400 -41
- package/dist/instance/config.js +101 -30
- package/dist/instance/connect.js +151 -44
- package/dist/instance/connection.d.ts +26 -5
- package/dist/instance/connection.js +194 -21
- package/dist/instance/gitignore.js +5 -2
- package/dist/instance/init.js +32 -7
- package/dist/instance/runner.js +32 -10
- package/dist/instance/setup.js +46 -13
- package/dist/instance/tty.js +26 -8
- package/dist/journal.d.ts +10 -0
- package/dist/journal.js +212 -49
- package/dist/kit-smoke.js +82 -20
- package/dist/kit-source.js +10 -40
- package/dist/kit.js +102 -34
- package/dist/layout-policy.d.ts +2 -0
- package/dist/layout-policy.js +86 -0
- package/dist/layout.d.ts +7 -1
- package/dist/layout.js +27 -70
- package/dist/live-smoke.js +13 -4
- package/dist/local-profile.js +2 -1
- package/dist/mcp-server.js +360 -47
- package/dist/native-api.js +47 -11
- package/dist/onboard.js +33 -7
- package/dist/operation.d.ts +2 -2
- package/dist/operation.js +279 -69
- package/dist/page-session.d.ts +23 -0
- package/dist/page-session.js +142 -0
- package/dist/png.js +18 -3
- package/dist/portable-tokens.js +254 -57
- package/dist/probe.d.ts +1 -1
- package/dist/probe.js +48 -11
- package/dist/project-input.js +24 -10
- package/dist/project-snippets.js +18 -6
- package/dist/project-status.js +133 -24
- package/dist/registry.js +217 -60
- package/dist/run-file.js +129 -30
- package/dist/shots.js +108 -26
- package/dist/skill-instructions.js +91 -22
- package/dist/skills.js +129 -39
- package/dist/snippet.js +27 -7
- package/dist/snippets/read.js +8 -2
- package/dist/structure-snapshot.d.ts +12 -0
- package/dist/structure-snapshot.js +439 -85
- package/dist/token-edit.js +219 -64
- package/dist/token-projection.d.ts +56 -0
- package/dist/token-projection.js +286 -0
- package/dist/tokens-diff.js +125 -32
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/penpair-design/SKILL.md +3 -0
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
- package/skills/penpair-design/references/project-facts.md +2 -0
- package/skills/penpair-handoff/SKILL.md +2 -0
- package/skills/penpair-handoff/references/comparison-companion.md +13 -0
- package/skills/penpair-handoff/references/handoff-flow.md +2 -0
- package/skills/penpair-recipes/SKILL.md +5 -0
- package/skills/penpair-setup/SKILL.md +3 -1
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -3
- package/dist/docker/lifecycle.d.ts +0 -96
- package/dist/docker/lifecycle.js +0 -273
- package/dist/docker/secrets.d.ts +0 -11
- package/dist/docker/secrets.js +0 -38
- package/dist/docker/template.d.ts +0 -8
- package/dist/docker/template.js +0 -158
- package/dist/docker-profile.d.ts +0 -114
- package/dist/docker-profile.js +0 -18
- package/dist/instance/lifecycle.d.ts +0 -129
- package/dist/instance/lifecycle.js +0 -336
- package/dist/instance/secrets.d.ts +0 -11
- package/dist/instance/secrets.js +0 -38
- package/dist/instance/template.d.ts +0 -8
- package/dist/instance/template.js +0 -158
package/dist/audit-rules.js
CHANGED
|
@@ -1,99 +1,227 @@
|
|
|
1
1
|
import { validateDesignInspection } from "./design-spec.js";
|
|
2
2
|
import { observationHash } from "./journal.js";
|
|
3
3
|
export function validateAuditRules(raw) {
|
|
4
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
4
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
5
5
|
throw new Error("audit must be an object");
|
|
6
|
+
}
|
|
6
7
|
const v = raw;
|
|
7
|
-
const keys = [
|
|
8
|
-
|
|
8
|
+
const keys = [
|
|
9
|
+
"contrast",
|
|
10
|
+
"library",
|
|
11
|
+
"touchTargets",
|
|
12
|
+
"names",
|
|
13
|
+
"blankText",
|
|
14
|
+
"interactiveIds",
|
|
15
|
+
"allowBlank",
|
|
16
|
+
"nameSegments",
|
|
17
|
+
"suppressions",
|
|
18
|
+
];
|
|
19
|
+
if (Object.keys(v).some((k) => !keys.includes(k))) {
|
|
9
20
|
throw new Error("Unknown audit setting");
|
|
10
|
-
|
|
11
|
-
|
|
21
|
+
}
|
|
22
|
+
for (const key of [
|
|
23
|
+
"contrast",
|
|
24
|
+
"library",
|
|
25
|
+
"touchTargets",
|
|
26
|
+
"names",
|
|
27
|
+
"blankText",
|
|
28
|
+
]) {
|
|
29
|
+
if (v[key] !== undefined && typeof v[key] !== "boolean") {
|
|
12
30
|
throw new Error("Audit rule toggles must be booleans");
|
|
13
|
-
|
|
14
|
-
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
for (const entries of [
|
|
34
|
+
v.interactiveIds,
|
|
35
|
+
v.allowBlank,
|
|
36
|
+
]) {
|
|
37
|
+
if (entries !== undefined &&
|
|
38
|
+
(!Array.isArray(entries) ||
|
|
39
|
+
entries.length > 1000 ||
|
|
40
|
+
entries.some((s) => typeof s !== "string" || !s || s.length > 200))) {
|
|
15
41
|
throw new Error("Audit ID lists must have at most 1000 explicit IDs");
|
|
16
|
-
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (v.nameSegments !== undefined &&
|
|
45
|
+
(!Number.isInteger(v.nameSegments) || v.nameSegments < 1 || v.nameSegments > 10)) {
|
|
17
46
|
throw new Error("Audit nameSegments must be 1–10");
|
|
18
|
-
|
|
47
|
+
}
|
|
48
|
+
if (v.suppressions !== undefined &&
|
|
49
|
+
(!Array.isArray(v.suppressions) ||
|
|
50
|
+
v.suppressions.length > 1000 ||
|
|
51
|
+
v.suppressions.some((s) => !s ||
|
|
52
|
+
![
|
|
53
|
+
"contrast",
|
|
54
|
+
"library",
|
|
55
|
+
"touch-target",
|
|
56
|
+
"names",
|
|
57
|
+
"blank-text",
|
|
58
|
+
].includes(s.rule) ||
|
|
59
|
+
typeof s.nodeId !== "string" ||
|
|
60
|
+
!s.nodeId ||
|
|
61
|
+
typeof s.reason !== "string" ||
|
|
62
|
+
!s.reason.trim() ||
|
|
63
|
+
s.reason.length > 1000))) {
|
|
19
64
|
throw new Error("Audit suppressions require exact rule/node ID and reason");
|
|
65
|
+
}
|
|
20
66
|
return structuredClone(v);
|
|
21
67
|
}
|
|
22
68
|
/** Opaque sRGB subset only. Alpha/multiple paints/images require a richer compositing model. */
|
|
23
69
|
export function opaqueRgb(raw) {
|
|
24
|
-
if (typeof raw !== "string")
|
|
70
|
+
if (typeof raw !== "string") {
|
|
25
71
|
return;
|
|
72
|
+
}
|
|
26
73
|
const match = /^#([a-f0-9]{6})(ff)?$/i.exec(raw);
|
|
27
|
-
if (!match)
|
|
74
|
+
if (!match) {
|
|
28
75
|
return;
|
|
29
|
-
|
|
76
|
+
}
|
|
77
|
+
return [
|
|
78
|
+
0,
|
|
79
|
+
2,
|
|
80
|
+
4,
|
|
81
|
+
].map((i) => parseInt(match[1].slice(i, i + 2), 16) / 255);
|
|
30
82
|
}
|
|
31
83
|
export function contrastRatio(a, b) {
|
|
32
|
-
const luminance = (rgb) => rgb
|
|
33
|
-
|
|
84
|
+
const luminance = (rgb) => rgb
|
|
85
|
+
.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
|
|
86
|
+
.reduce((n, c, i) => n +
|
|
87
|
+
c *
|
|
88
|
+
[
|
|
89
|
+
0.2126,
|
|
90
|
+
0.7152,
|
|
91
|
+
0.0722,
|
|
92
|
+
][i], 0);
|
|
93
|
+
const x = luminance(a);
|
|
94
|
+
const y = luminance(b);
|
|
34
95
|
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
|
|
35
96
|
}
|
|
36
97
|
function resolvedColors(tokens, shapes) {
|
|
37
98
|
const result = new Map();
|
|
38
|
-
if (!tokens ||
|
|
99
|
+
if (!tokens ||
|
|
100
|
+
tokens.schemaVersion !== 1 ||
|
|
101
|
+
tokens.kind !== "token-inspection" ||
|
|
102
|
+
tokens.complete !== true ||
|
|
103
|
+
tokens.redacted === true ||
|
|
104
|
+
tokens.fileId !== shapes.fileId ||
|
|
105
|
+
tokens.revision !== shapes.revision ||
|
|
106
|
+
!Array.isArray(tokens.sets)) {
|
|
39
107
|
return result;
|
|
40
|
-
|
|
41
|
-
|
|
108
|
+
}
|
|
109
|
+
for (const set of [
|
|
110
|
+
...tokens.sets,
|
|
111
|
+
].sort((a, b) => Number(a.order) - Number(b.order))) {
|
|
112
|
+
if (set.active !== true || !Array.isArray(set.tokens)) {
|
|
42
113
|
continue;
|
|
114
|
+
}
|
|
43
115
|
for (const token of set.tokens) {
|
|
44
|
-
if (typeof token.name !== "string")
|
|
116
|
+
if (typeof token.name !== "string") {
|
|
45
117
|
continue;
|
|
118
|
+
}
|
|
46
119
|
result.delete(token.name);
|
|
47
|
-
if (token.type === "color" &&
|
|
120
|
+
if (token.type === "color" &&
|
|
121
|
+
(!Array.isArray(token.unknowns) ||
|
|
122
|
+
!token.unknowns.some((k) => k === "resolvedValue" || k === "type"))) {
|
|
48
123
|
const rgb = opaqueRgb(token.resolvedValue);
|
|
49
|
-
if (rgb)
|
|
124
|
+
if (rgb) {
|
|
50
125
|
result.set(token.name, rgb);
|
|
126
|
+
}
|
|
51
127
|
}
|
|
52
128
|
}
|
|
53
129
|
}
|
|
54
130
|
return result;
|
|
55
131
|
}
|
|
56
132
|
export function auditDesignRules(raw, rawRules, tokenInspection, limit = 1000) {
|
|
57
|
-
const source = validateDesignInspection(raw)
|
|
58
|
-
|
|
133
|
+
const source = validateDesignInspection(raw);
|
|
134
|
+
const rules = validateAuditRules(rawRules);
|
|
135
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 10000) {
|
|
59
136
|
throw new Error("Audit finding limit must be 1–10000");
|
|
137
|
+
}
|
|
60
138
|
const findings = [];
|
|
61
|
-
let totalFindings = 0
|
|
62
|
-
|
|
139
|
+
let totalFindings = 0;
|
|
140
|
+
let suppressed = 0;
|
|
141
|
+
let contrastMeasured = 0;
|
|
142
|
+
let contrastUnknown = 0;
|
|
143
|
+
let copies = 0;
|
|
144
|
+
let knownComponents = 0;
|
|
145
|
+
let unknownComponents = 0;
|
|
146
|
+
const rows = new Map(source.nodes.map((n) => [
|
|
147
|
+
String(n.id),
|
|
148
|
+
n,
|
|
149
|
+
]));
|
|
150
|
+
const colors = resolvedColors(tokenInspection, source);
|
|
63
151
|
const add = (finding) => {
|
|
64
152
|
totalFindings++;
|
|
65
|
-
const suppression = rules.suppressions?.find(s => s.rule === finding.rule && s.nodeId === finding.nodeId);
|
|
66
|
-
if (suppression)
|
|
153
|
+
const suppression = rules.suppressions?.find((s) => s.rule === finding.rule && s.nodeId === finding.nodeId);
|
|
154
|
+
if (suppression) {
|
|
67
155
|
suppressed++;
|
|
68
|
-
|
|
69
|
-
|
|
156
|
+
}
|
|
157
|
+
if (findings.length < limit) {
|
|
158
|
+
findings.push({
|
|
159
|
+
...finding,
|
|
160
|
+
...(suppression
|
|
161
|
+
? {
|
|
162
|
+
suppression: suppression.reason,
|
|
163
|
+
}
|
|
164
|
+
: {}),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
70
167
|
};
|
|
71
168
|
const paint = (node) => {
|
|
72
|
-
const style = node.style
|
|
73
|
-
|
|
74
|
-
|
|
169
|
+
const style = node.style;
|
|
170
|
+
const bindings = node.tokens;
|
|
171
|
+
if (!style ||
|
|
172
|
+
(style.opacity !== 1 && style.opacity !== undefined) ||
|
|
173
|
+
(style.blendMode !== "normal" && style.blendMode !== undefined)) {
|
|
174
|
+
return {
|
|
175
|
+
reason: "Unknown/transparent/blended node style",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
75
178
|
const fills = style.fills;
|
|
76
|
-
if (!Array.isArray(fills))
|
|
77
|
-
return {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
179
|
+
if (!Array.isArray(fills)) {
|
|
180
|
+
return {
|
|
181
|
+
reason: "Fill observation unavailable",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (!fills.length) {
|
|
185
|
+
return {
|
|
186
|
+
absent: true,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (fills.length !== 1 || !fills[0] || typeof fills[0] !== "object") {
|
|
190
|
+
return {
|
|
191
|
+
reason: "Multiple or unsupported paints",
|
|
192
|
+
};
|
|
193
|
+
}
|
|
82
194
|
const fill = fills[0];
|
|
83
|
-
if (fill.fillOpacity !== undefined && fill.fillOpacity !== 1 ||
|
|
84
|
-
|
|
195
|
+
if ((fill.fillOpacity !== undefined && fill.fillOpacity !== 1) ||
|
|
196
|
+
fill.fillColorGradient ||
|
|
197
|
+
fill.fillImage ||
|
|
198
|
+
(fill.fillType && fill.fillType !== "solid")) {
|
|
199
|
+
return {
|
|
200
|
+
reason: "Gradient/image/alpha paint",
|
|
201
|
+
};
|
|
202
|
+
}
|
|
85
203
|
const name = bindings?.fill;
|
|
86
|
-
if (typeof name !== "string" || !colors.has(name))
|
|
87
|
-
return {
|
|
204
|
+
if (typeof name !== "string" || !colors.has(name)) {
|
|
205
|
+
return {
|
|
206
|
+
reason: "No independently resolved opaque bound color",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
88
209
|
const actual = opaqueRgb(fill.fillColor);
|
|
89
|
-
if (!actual || observationHash(actual) !== observationHash(colors.get(name)))
|
|
90
|
-
return {
|
|
91
|
-
|
|
210
|
+
if (!actual || observationHash(actual) !== observationHash(colors.get(name))) {
|
|
211
|
+
return {
|
|
212
|
+
reason: "Observed paint differs from resolved bound color",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
rgb: colors.get(name),
|
|
217
|
+
};
|
|
92
218
|
};
|
|
93
219
|
const backgroundCache = new Map();
|
|
94
220
|
const backgroundFor = (parent) => {
|
|
95
221
|
const visited = [];
|
|
96
|
-
let result = {
|
|
222
|
+
let result = {
|
|
223
|
+
reason: "No opaque ancestor background",
|
|
224
|
+
};
|
|
97
225
|
while (typeof parent === "string") {
|
|
98
226
|
if (backgroundCache.has(parent)) {
|
|
99
227
|
result = backgroundCache.get(parent);
|
|
@@ -102,76 +230,214 @@ export function auditDesignRules(raw, rawRules, tokenInspection, limit = 1000) {
|
|
|
102
230
|
const ancestor = rows.get(parent);
|
|
103
231
|
result = paint(ancestor);
|
|
104
232
|
visited.push(parent);
|
|
105
|
-
if (!result.absent)
|
|
233
|
+
if (!result.absent) {
|
|
106
234
|
break;
|
|
235
|
+
}
|
|
107
236
|
parent = ancestor.parentId;
|
|
108
237
|
}
|
|
109
|
-
if (result.absent)
|
|
110
|
-
result = {
|
|
111
|
-
|
|
238
|
+
if (result.absent) {
|
|
239
|
+
result = {
|
|
240
|
+
reason: "No opaque ancestor background",
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
for (const id of visited) {
|
|
112
244
|
backgroundCache.set(id, result);
|
|
245
|
+
}
|
|
113
246
|
return result;
|
|
114
247
|
};
|
|
115
|
-
const rootId = source.nodes.find(n => n.parentId === null)?.id;
|
|
248
|
+
const rootId = source.nodes.find((n) => n.parentId === null)?.id;
|
|
116
249
|
const largest = [];
|
|
117
|
-
const considered = source.nodes.filter(n => n.parentId !== null &&
|
|
250
|
+
const considered = source.nodes.filter((n) => n.parentId !== null &&
|
|
251
|
+
n.geometry?.hidden !== true);
|
|
118
252
|
for (const node of considered) {
|
|
119
|
-
const id = String(node.id)
|
|
253
|
+
const id = String(node.id);
|
|
254
|
+
const geometry = node.geometry;
|
|
120
255
|
if (rules.names) {
|
|
121
|
-
if ([
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
256
|
+
if ([
|
|
257
|
+
"Board",
|
|
258
|
+
"Rectangle",
|
|
259
|
+
"Group",
|
|
260
|
+
].includes(String(node.name))) {
|
|
261
|
+
add({
|
|
262
|
+
rule: "names",
|
|
263
|
+
nodeId: id,
|
|
264
|
+
severity: "info",
|
|
265
|
+
state: "finding",
|
|
266
|
+
detail: "Default layer name",
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
if (node.type === "board" &&
|
|
270
|
+
node.parentId === rootId &&
|
|
271
|
+
(String(node.name).split("/").length !== (rules.nameSegments ?? 3) ||
|
|
272
|
+
String(node.name)
|
|
273
|
+
.split("/")
|
|
274
|
+
.some((s) => !s.trim()))) {
|
|
275
|
+
add({
|
|
276
|
+
rule: "names",
|
|
277
|
+
nodeId: id,
|
|
278
|
+
severity: "info",
|
|
279
|
+
state: "finding",
|
|
280
|
+
detail: "Frame name differs from declared slash-separated page/state/device convention",
|
|
281
|
+
});
|
|
282
|
+
}
|
|
125
283
|
}
|
|
126
284
|
if (rules.blankText && node.type === "text" && !rules.allowBlank?.includes(id)) {
|
|
127
285
|
const text = node.text?.characters;
|
|
128
|
-
if (typeof text !== "string")
|
|
129
|
-
add({
|
|
130
|
-
|
|
131
|
-
|
|
286
|
+
if (typeof text !== "string") {
|
|
287
|
+
add({
|
|
288
|
+
rule: "blank-text",
|
|
289
|
+
nodeId: id,
|
|
290
|
+
severity: "info",
|
|
291
|
+
state: "unknown",
|
|
292
|
+
detail: "Text not observed",
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
else if (!text.trim() || /^(?:lorem ipsum|placeholder|todo)(?:\b|$)/i.test(text.trim())) {
|
|
296
|
+
add({
|
|
297
|
+
rule: "blank-text",
|
|
298
|
+
nodeId: id,
|
|
299
|
+
severity: "info",
|
|
300
|
+
state: "finding",
|
|
301
|
+
detail: "Blank or placeholder text",
|
|
302
|
+
});
|
|
303
|
+
}
|
|
132
304
|
}
|
|
133
305
|
if (rules.library) {
|
|
134
306
|
const component = node.component;
|
|
135
|
-
if (typeof component?.isComponentCopyInstance !== "boolean")
|
|
307
|
+
if (typeof component?.isComponentCopyInstance !== "boolean") {
|
|
136
308
|
unknownComponents++;
|
|
309
|
+
}
|
|
137
310
|
else {
|
|
138
311
|
knownComponents++;
|
|
139
|
-
if (component.isComponentCopyInstance)
|
|
312
|
+
if (component.isComponentCopyInstance) {
|
|
140
313
|
copies++;
|
|
141
|
-
|
|
142
|
-
|
|
314
|
+
}
|
|
315
|
+
else if ([
|
|
316
|
+
"board",
|
|
317
|
+
"group",
|
|
318
|
+
].includes(String(node.type)) &&
|
|
319
|
+
typeof geometry?.width === "number" &&
|
|
320
|
+
typeof geometry?.height === "number") {
|
|
321
|
+
largest.push({
|
|
322
|
+
id,
|
|
323
|
+
area: geometry.width * geometry.height,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
143
326
|
}
|
|
144
327
|
}
|
|
145
328
|
if (rules.contrast && node.type === "text") {
|
|
146
329
|
let foreground = paint(node);
|
|
147
330
|
const background = backgroundFor(node.parentId);
|
|
148
331
|
const unknowns = node.unknowns;
|
|
149
|
-
if (unknowns?.includes("text.mixedRangeStyles"))
|
|
150
|
-
foreground = {
|
|
332
|
+
if (unknowns?.includes("text.mixedRangeStyles")) {
|
|
333
|
+
foreground = {
|
|
334
|
+
reason: "Mixed text range styles",
|
|
335
|
+
};
|
|
336
|
+
}
|
|
151
337
|
if (!foreground.rgb || !background.rgb) {
|
|
152
338
|
contrastUnknown++;
|
|
153
|
-
add({
|
|
339
|
+
add({
|
|
340
|
+
rule: "contrast",
|
|
341
|
+
nodeId: id,
|
|
342
|
+
severity: "info",
|
|
343
|
+
state: "unknown",
|
|
344
|
+
detail: foreground.reason ?? background.reason ?? "Opaque bound colors unavailable",
|
|
345
|
+
});
|
|
154
346
|
}
|
|
155
347
|
else {
|
|
156
348
|
contrastMeasured++;
|
|
157
349
|
const typography = node.text?.typography;
|
|
158
|
-
const size = Number(typography?.fontSize)
|
|
350
|
+
const size = Number(typography?.fontSize);
|
|
351
|
+
const weight = Number(typography?.fontWeight);
|
|
352
|
+
const threshold = Number.isFinite(size) && (size >= 24 || (size >= 56 / 3 && weight >= 700)) ? 3 : 4.5;
|
|
159
353
|
const ratio = contrastRatio(foreground.rgb, background.rgb);
|
|
160
|
-
if (ratio < threshold)
|
|
161
|
-
add({
|
|
354
|
+
if (ratio < threshold) {
|
|
355
|
+
add({
|
|
356
|
+
rule: "contrast",
|
|
357
|
+
nodeId: id,
|
|
358
|
+
severity: "warning",
|
|
359
|
+
state: "finding",
|
|
360
|
+
detail: "Observed opaque text/background contrast below the review threshold",
|
|
361
|
+
evidence: {
|
|
362
|
+
ratio,
|
|
363
|
+
threshold,
|
|
364
|
+
assumption: "Design units treated as CSS px; exceptions and implementation rendering unassessed",
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
}
|
|
162
368
|
}
|
|
163
369
|
}
|
|
164
370
|
}
|
|
165
|
-
if (rules.touchTargets)
|
|
371
|
+
if (rules.touchTargets) {
|
|
166
372
|
for (const id of rules.interactiveIds ?? []) {
|
|
167
|
-
const node = rows.get(id)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
373
|
+
const node = rows.get(id);
|
|
374
|
+
const g = node?.geometry;
|
|
375
|
+
if (!node ||
|
|
376
|
+
typeof g?.width !== "number" ||
|
|
377
|
+
typeof g.height !== "number" ||
|
|
378
|
+
!Number.isFinite(g.width) ||
|
|
379
|
+
!Number.isFinite(g.height) ||
|
|
380
|
+
g.width < 0 ||
|
|
381
|
+
g.height < 0) {
|
|
382
|
+
add({
|
|
383
|
+
rule: "touch-target",
|
|
384
|
+
nodeId: id,
|
|
385
|
+
severity: "info",
|
|
386
|
+
state: "unknown",
|
|
387
|
+
detail: "Explicit interactive target dimensions unavailable",
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
else if (g.width < 44 || g.height < 44) {
|
|
391
|
+
add({
|
|
392
|
+
rule: "touch-target",
|
|
393
|
+
nodeId: id,
|
|
394
|
+
severity: g.width < 24 || g.height < 24 ? "warning" : "info",
|
|
395
|
+
state: "finding",
|
|
396
|
+
detail: g.width < 24 || g.height < 24
|
|
397
|
+
? "Below 24-unit size signal"
|
|
398
|
+
: "Below 44-unit preference",
|
|
399
|
+
evidence: {
|
|
400
|
+
width: g.width,
|
|
401
|
+
height: g.height,
|
|
402
|
+
assessment: "size-signal-only; spacing, exceptions, input semantics and CSS scale unassessed",
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
}
|
|
172
406
|
}
|
|
407
|
+
}
|
|
173
408
|
largest.sort((a, b) => b.area - a.area || a.id.localeCompare(b.id));
|
|
174
|
-
return {
|
|
175
|
-
|
|
176
|
-
|
|
409
|
+
return {
|
|
410
|
+
schemaVersion: 1,
|
|
411
|
+
kind: "design-audit",
|
|
412
|
+
source: {
|
|
413
|
+
fileId: source.fileId,
|
|
414
|
+
pageId: source.pageId,
|
|
415
|
+
revision: source.revision,
|
|
416
|
+
hash: observationHash(source.nodes),
|
|
417
|
+
},
|
|
418
|
+
advisory: true,
|
|
419
|
+
accessibilityCompliance: "not-assessed",
|
|
420
|
+
complete: totalFindings <= limit,
|
|
421
|
+
totalFindings,
|
|
422
|
+
suppressed,
|
|
423
|
+
findings,
|
|
424
|
+
contrast: {
|
|
425
|
+
measured: contrastMeasured,
|
|
426
|
+
unknown: contrastUnknown,
|
|
427
|
+
},
|
|
428
|
+
library: rules.library
|
|
429
|
+
? {
|
|
430
|
+
instances: copies,
|
|
431
|
+
knownNodes: knownComponents,
|
|
432
|
+
unknownNodes: unknownComponents,
|
|
433
|
+
shareOfKnown: knownComponents ? copies / knownComponents : null,
|
|
434
|
+
largestPrimitiveGroups: largest.slice(0, 10),
|
|
435
|
+
}
|
|
436
|
+
: null,
|
|
437
|
+
assumptions: [
|
|
438
|
+
"Complete captured subtree only; hidden ancestors and overlapping/composited paints need independent review.",
|
|
439
|
+
"Interactive IDs are explicit declarations, not inferred clickability or WCAG compliance.",
|
|
440
|
+
"Unavailable resolved colors and mixed styles remain unknown; rules never mutate or block by default.",
|
|
441
|
+
],
|
|
442
|
+
};
|
|
177
443
|
}
|
package/dist/backup-note.js
CHANGED
|
@@ -2,9 +2,16 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
/** An optional advisory record, not proof that an archive can be restored. Disabled unless configured. */
|
|
4
4
|
export async function backupNoteCheck(root, config, now = new Date()) {
|
|
5
|
-
if (!config.backup)
|
|
5
|
+
if (!config.backup) {
|
|
6
6
|
return undefined;
|
|
7
|
-
|
|
7
|
+
}
|
|
8
|
+
const warn = (detail) => ({
|
|
9
|
+
check: "backup note",
|
|
10
|
+
ok: true,
|
|
11
|
+
warning: true,
|
|
12
|
+
detail,
|
|
13
|
+
remedy: "Export a current archive, restore it into scratch, then update the backup note; see docs/backup.md.",
|
|
14
|
+
});
|
|
8
15
|
let note;
|
|
9
16
|
try {
|
|
10
17
|
note = JSON.parse(await readFile(path.join(root, config.backup.noteFile), "utf8"));
|
|
@@ -12,17 +19,31 @@ export async function backupNoteCheck(root, config, now = new Date()) {
|
|
|
12
19
|
catch {
|
|
13
20
|
return warn("No readable backup note; archive/restore status is unknown");
|
|
14
21
|
}
|
|
15
|
-
if (!note ||
|
|
22
|
+
if (!note ||
|
|
23
|
+
note.schemaVersion !== 1 ||
|
|
24
|
+
typeof note.fileId !== "string" ||
|
|
25
|
+
typeof note.recordedAt !== "string" ||
|
|
26
|
+
typeof note.archiveSha256 !== "string" ||
|
|
27
|
+
!/^[a-f0-9]{64}$/i.test(note.archiveSha256)) {
|
|
16
28
|
return warn("Backup note is incomplete or invalid; archive/restore status is unknown");
|
|
17
|
-
|
|
29
|
+
}
|
|
30
|
+
if (!config.file || note.fileId !== config.file.id) {
|
|
18
31
|
return warn("Backup note does not match a pinned project file");
|
|
32
|
+
}
|
|
19
33
|
const timestamp = Date.parse(note.recordedAt);
|
|
20
34
|
const age = now.getTime() - timestamp;
|
|
21
|
-
if (!Number.isFinite(timestamp) || age < 0)
|
|
35
|
+
if (!Number.isFinite(timestamp) || age < 0) {
|
|
22
36
|
return warn("Backup note has an invalid or future date");
|
|
23
|
-
|
|
37
|
+
}
|
|
38
|
+
if (age >= config.backup.remindAfterDays * 86_400_000) {
|
|
24
39
|
return warn(`Backup note is ${Math.floor(age / 86_400_000)} days old; refresh the archive and restore evidence`);
|
|
25
|
-
|
|
40
|
+
}
|
|
41
|
+
if (note.restoreVerified !== true) {
|
|
26
42
|
return warn("Archive recorded recently, but its restore is not recorded as verified");
|
|
27
|
-
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
check: "backup note",
|
|
46
|
+
ok: true,
|
|
47
|
+
detail: "Recent owner-maintained backup/restore note matches the pinned file (a note is not independent restore proof)",
|
|
48
|
+
};
|
|
28
49
|
}
|