@penpair/core 0.2.0 → 0.3.1

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 (125) hide show
  1. package/dist/adapters.js +58 -11
  2. package/dist/approvals.js +143 -29
  3. package/dist/atomic-output.js +9 -4
  4. package/dist/audit-rules.js +345 -79
  5. package/dist/backup-note.js +29 -8
  6. package/dist/baseline-coverage.js +169 -35
  7. package/dist/baselines-config.js +119 -29
  8. package/dist/baselines.js +280 -57
  9. package/dist/cli-arguments.js +18 -6
  10. package/dist/cli-main.js +1645 -392
  11. package/dist/client.d.ts +5 -2
  12. package/dist/client.js +127 -28
  13. package/dist/command-result.js +341 -52
  14. package/dist/coordination.js +91 -29
  15. package/dist/credentials.js +57 -13
  16. package/dist/deep-check.js +56 -12
  17. package/dist/design-spec.js +189 -49
  18. package/dist/endpoint.js +102 -34
  19. package/dist/export.js +86 -18
  20. package/dist/file-archive.js +33 -7
  21. package/dist/file-guard.js +29 -11
  22. package/dist/formats.js +215 -14
  23. package/dist/index.d.ts +8 -7
  24. package/dist/index.js +8 -7
  25. package/dist/inspection.d.ts +1 -1
  26. package/dist/inspection.js +378 -65
  27. package/dist/instance/config-schema.js +400 -41
  28. package/dist/instance/config.js +101 -30
  29. package/dist/instance/connect.js +151 -44
  30. package/dist/instance/connection.d.ts +26 -5
  31. package/dist/instance/connection.js +194 -21
  32. package/dist/instance/gitignore.js +5 -2
  33. package/dist/instance/init.js +32 -7
  34. package/dist/instance/runner.js +32 -10
  35. package/dist/instance/setup.js +46 -13
  36. package/dist/instance/tty.js +26 -8
  37. package/dist/journal.d.ts +10 -0
  38. package/dist/journal.js +212 -49
  39. package/dist/kit-smoke.js +82 -20
  40. package/dist/kit-source.js +10 -40
  41. package/dist/kit.js +102 -34
  42. package/dist/layout-policy.d.ts +2 -0
  43. package/dist/layout-policy.js +86 -0
  44. package/dist/layout.d.ts +7 -1
  45. package/dist/layout.js +27 -70
  46. package/dist/live-smoke.js +13 -4
  47. package/dist/local-profile.js +2 -1
  48. package/dist/mcp-server.js +360 -47
  49. package/dist/native-api.js +47 -11
  50. package/dist/onboard.js +33 -7
  51. package/dist/operation.d.ts +2 -2
  52. package/dist/operation.js +279 -69
  53. package/dist/page-session.d.ts +23 -0
  54. package/dist/page-session.js +142 -0
  55. package/dist/png.js +18 -3
  56. package/dist/portable-tokens.js +254 -57
  57. package/dist/probe.d.ts +1 -1
  58. package/dist/probe.js +48 -11
  59. package/dist/project-input.js +24 -10
  60. package/dist/project-snippets.js +18 -6
  61. package/dist/project-status.js +133 -24
  62. package/dist/registry.js +217 -60
  63. package/dist/run-file.js +129 -30
  64. package/dist/shots.js +108 -26
  65. package/dist/skill-check-entry.d.ts +17 -0
  66. package/dist/skill-check-entry.js +89 -0
  67. package/dist/skill-check-instructions.d.ts +7 -0
  68. package/dist/skill-check-instructions.js +39 -0
  69. package/dist/skill-check.d.ts +15 -0
  70. package/dist/skill-check.js +36 -0
  71. package/dist/skill-file.d.ts +2 -0
  72. package/dist/skill-file.js +31 -0
  73. package/dist/skill-instructions.d.ts +4 -0
  74. package/dist/skill-instructions.js +97 -22
  75. package/dist/skill-metadata.d.ts +8 -0
  76. package/dist/skill-metadata.js +52 -0
  77. package/dist/skill-routing.d.ts +2 -0
  78. package/dist/skill-routing.js +36 -0
  79. package/dist/skills.js +131 -41
  80. package/dist/snippet.js +27 -7
  81. package/dist/snippets/read.js +8 -2
  82. package/dist/structure-snapshot.d.ts +12 -0
  83. package/dist/structure-snapshot.js +439 -85
  84. package/dist/token-edit.js +219 -64
  85. package/dist/token-projection.d.ts +56 -0
  86. package/dist/token-projection.js +286 -0
  87. package/dist/tokens-diff.js +125 -32
  88. package/dist/version.d.ts +1 -1
  89. package/dist/version.js +1 -1
  90. package/package.json +3 -2
  91. package/skills/design-judgment/SKILL.md +1 -1
  92. package/skills/design-judgment/agents/openai.yaml +6 -0
  93. package/skills/penpair-design/SKILL.md +14 -1
  94. package/skills/penpair-design/agents/openai.yaml +6 -0
  95. package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
  96. package/skills/penpair-design/references/project-facts.md +2 -0
  97. package/skills/penpair-handoff/SKILL.md +14 -1
  98. package/skills/penpair-handoff/agents/openai.yaml +6 -0
  99. package/skills/penpair-handoff/references/comparison-companion.md +16 -0
  100. package/skills/penpair-handoff/references/handoff-flow.md +2 -0
  101. package/skills/penpair-recipes/SKILL.md +13 -1
  102. package/skills/penpair-recipes/agents/openai.yaml +6 -0
  103. package/skills/penpair-recipes/assets/visual-checkpoint.md +48 -0
  104. package/skills/penpair-recipes/references/visual-checkpoint.md +37 -0
  105. package/skills/penpair-setup/SKILL.md +9 -3
  106. package/skills/penpair-setup/agents/openai.yaml +6 -0
  107. package/skills/penpair-verify/SKILL.md +49 -0
  108. package/skills/penpair-verify/agents/openai.yaml +6 -0
  109. package/skills/penpair-verify/references/browser-evidence.md +33 -0
  110. package/dist/cli.d.ts +0 -2
  111. package/dist/cli.js +0 -3
  112. package/dist/docker/lifecycle.d.ts +0 -96
  113. package/dist/docker/lifecycle.js +0 -273
  114. package/dist/docker/secrets.d.ts +0 -11
  115. package/dist/docker/secrets.js +0 -38
  116. package/dist/docker/template.d.ts +0 -8
  117. package/dist/docker/template.js +0 -158
  118. package/dist/docker-profile.d.ts +0 -114
  119. package/dist/docker-profile.js +0 -18
  120. package/dist/instance/lifecycle.d.ts +0 -129
  121. package/dist/instance/lifecycle.js +0 -336
  122. package/dist/instance/secrets.d.ts +0 -11
  123. package/dist/instance/secrets.js +0 -38
  124. package/dist/instance/template.d.ts +0 -8
  125. package/dist/instance/template.js +0 -158
@@ -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 = ["contrast", "library", "touchTargets", "names", "blankText", "interactiveIds", "allowBlank", "nameSegments", "suppressions"];
8
- if (Object.keys(v).some(k => !keys.includes(k)))
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
- for (const key of ["contrast", "library", "touchTargets", "names", "blankText"])
11
- if (v[key] !== undefined && typeof v[key] !== "boolean")
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
- for (const entries of [v.interactiveIds, v.allowBlank])
14
- if (entries !== undefined && (!Array.isArray(entries) || entries.length > 1000 || entries.some(s => typeof s !== "string" || !s || s.length > 200)))
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
- if (v.nameSegments !== undefined && (!Number.isInteger(v.nameSegments) || v.nameSegments < 1 || v.nameSegments > 10))
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
- if (v.suppressions !== undefined && (!Array.isArray(v.suppressions) || v.suppressions.length > 1000 || v.suppressions.some(s => !s || !["contrast", "library", "touch-target", "names", "blank-text"].includes(s.rule) || typeof s.nodeId !== "string" || !s.nodeId || typeof s.reason !== "string" || !s.reason.trim() || s.reason.length > 1000)))
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
- return [0, 2, 4].map(i => parseInt(match[1].slice(i, i + 2), 16) / 255);
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.map(c => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4).reduce((n, c, i) => n + c * [0.2126, 0.7152, 0.0722][i], 0);
33
- const x = luminance(a), y = luminance(b);
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 || tokens.schemaVersion !== 1 || tokens.kind !== "token-inspection" || tokens.complete !== true || tokens.redacted === true || tokens.fileId !== shapes.fileId || tokens.revision !== shapes.revision || !Array.isArray(tokens.sets))
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
- for (const set of [...tokens.sets].sort((a, b) => Number(a.order) - Number(b.order))) {
41
- if (set.active !== true || !Array.isArray(set.tokens))
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" && (!Array.isArray(token.unknowns) || !token.unknowns.some(k => k === "resolvedValue" || k === "type"))) {
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), rules = validateAuditRules(rawRules);
58
- if (!Number.isInteger(limit) || limit < 1 || limit > 10000)
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, suppressed = 0, contrastMeasured = 0, contrastUnknown = 0, copies = 0, knownComponents = 0, unknownComponents = 0;
62
- const rows = new Map(source.nodes.map(n => [String(n.id), n])), colors = resolvedColors(tokenInspection, source);
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
- if (findings.length < limit)
69
- findings.push({ ...finding, ...(suppression ? { suppression: suppression.reason } : {}) });
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, bindings = node.tokens;
73
- if (!style || style.opacity !== 1 && style.opacity !== undefined || style.blendMode !== "normal" && style.blendMode !== undefined)
74
- return { reason: "Unknown/transparent/blended node style" };
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 { reason: "Fill observation unavailable" };
78
- if (!fills.length)
79
- return { absent: true };
80
- if (fills.length !== 1 || !fills[0] || typeof fills[0] !== "object")
81
- return { reason: "Multiple or unsupported paints" };
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 || fill.fillColorGradient || fill.fillImage || fill.fillType && fill.fillType !== "solid")
84
- return { reason: "Gradient/image/alpha paint" };
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 { reason: "No independently resolved opaque bound color" };
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 { reason: "Observed paint differs from resolved bound color" };
91
- return { rgb: colors.get(name) };
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 = { reason: "No opaque ancestor background" };
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 = { reason: "No opaque ancestor background" };
111
- for (const id of visited)
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 && n.geometry?.hidden !== true);
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), geometry = node.geometry;
253
+ const id = String(node.id);
254
+ const geometry = node.geometry;
120
255
  if (rules.names) {
121
- if (["Board", "Rectangle", "Group"].includes(String(node.name)))
122
- add({ rule: "names", nodeId: id, severity: "info", state: "finding", detail: "Default layer name" });
123
- if (node.type === "board" && node.parentId === rootId && (String(node.name).split("/").length !== (rules.nameSegments ?? 3) || String(node.name).split("/").some(s => !s.trim())))
124
- add({ rule: "names", nodeId: id, severity: "info", state: "finding", detail: "Frame name differs from declared slash-separated page/state/device convention" });
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({ rule: "blank-text", nodeId: id, severity: "info", state: "unknown", detail: "Text not observed" });
130
- else if (!text.trim() || /^(?:lorem ipsum|placeholder|todo)(?:\b|$)/i.test(text.trim()))
131
- add({ rule: "blank-text", nodeId: id, severity: "info", state: "finding", detail: "Blank or placeholder text" });
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
- else if (["board", "group"].includes(String(node.type)) && typeof geometry?.width === "number" && typeof geometry?.height === "number")
142
- largest.push({ id, area: geometry.width * geometry.height });
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 = { reason: "Mixed text range styles" };
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({ rule: "contrast", nodeId: id, severity: "info", state: "unknown", detail: foreground.reason ?? background.reason ?? "Opaque bound colors unavailable" });
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), weight = Number(typography?.fontWeight), threshold = Number.isFinite(size) && (size >= 24 || size >= 56 / 3 && weight >= 700) ? 3 : 4.5;
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({ rule: "contrast", nodeId: id, severity: "warning", state: "finding", detail: "Observed opaque text/background contrast below the review threshold", evidence: { ratio, threshold, assumption: "Design units treated as CSS px; exceptions and implementation rendering unassessed" } });
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), g = node?.geometry;
168
- if (!node || typeof g?.width !== "number" || typeof g.height !== "number" || !Number.isFinite(g.width) || !Number.isFinite(g.height) || g.width < 0 || g.height < 0)
169
- add({ rule: "touch-target", nodeId: id, severity: "info", state: "unknown", detail: "Explicit interactive target dimensions unavailable" });
170
- else if (g.width < 44 || g.height < 44)
171
- add({ rule: "touch-target", nodeId: id, severity: g.width < 24 || g.height < 24 ? "warning" : "info", state: "finding", detail: g.width < 24 || g.height < 24 ? "Below 24-unit size signal" : "Below 44-unit preference", evidence: { width: g.width, height: g.height, assessment: "size-signal-only; spacing, exceptions, input semantics and CSS scale unassessed" } });
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 { schemaVersion: 1, kind: "design-audit", source: { fileId: source.fileId, pageId: source.pageId, revision: source.revision, hash: observationHash(source.nodes) }, advisory: true, accessibilityCompliance: "not-assessed", complete: totalFindings <= limit, totalFindings, suppressed, findings,
175
- contrast: { measured: contrastMeasured, unknown: contrastUnknown }, library: rules.library ? { instances: copies, knownNodes: knownComponents, unknownNodes: unknownComponents, shareOfKnown: knownComponents ? copies / knownComponents : null, largestPrimitiveGroups: largest.slice(0, 10) } : null,
176
- assumptions: ["Complete captured subtree only; hidden ancestors and overlapping/composited paints need independent review.", "Interactive IDs are explicit declarations, not inferred clickability or WCAG compliance.", "Unavailable resolved colors and mixed styles remain unknown; rules never mutate or block by default."] };
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
  }
@@ -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
- const warn = (detail) => ({ check: "backup note", ok: true, warning: true, detail, remedy: "Export a current archive, restore it into scratch, then update the backup note; see docs/backup.md." });
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 || note.schemaVersion !== 1 || typeof note.fileId !== "string" || typeof note.recordedAt !== "string" || typeof note.archiveSha256 !== "string" || !/^[a-f0-9]{64}$/i.test(note.archiveSha256))
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
- if (!config.file || note.fileId !== config.file.id)
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
- if (age >= config.backup.remindAfterDays * 86_400_000)
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
- if (note.restoreVerified !== true)
40
+ }
41
+ if (note.restoreVerified !== true) {
26
42
  return warn("Archive recorded recently, but its restore is not recorded as verified");
27
- return { check: "backup note", ok: true, detail: "Recent owner-maintained backup/restore note matches the pinned file (a note is not independent restore proof)" };
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
  }