stitch2 0.22.8 → 0.22.9
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/audit.js +233 -35
- package/dist/cache.d.ts +2 -0
- package/dist/cache.js +37 -0
- package/dist/check.js +19 -25
- package/dist/report.js +15 -11
- package/package.json +1 -1
- package/skills/stitch2/SKILL.md +16 -0
- package/skills/stitch2-consistency/SKILL.md +2 -0
package/dist/audit.js
CHANGED
|
@@ -27,6 +27,27 @@ export async function audit(opts) {
|
|
|
27
27
|
return;
|
|
28
28
|
out.push({ severity, type, message, selector: sel(el), text: text(el) });
|
|
29
29
|
};
|
|
30
|
+
const overflowClips = (el, ancestor) => {
|
|
31
|
+
// Overflow does not clip an absolutely/fixed positioned descendant whose containing block lies
|
|
32
|
+
// outside that ancestor. This differs from clip-path and matters for overlays in zero-sized wrappers.
|
|
33
|
+
for (let e = el; e && e !== ancestor; e = e.parentElement) {
|
|
34
|
+
const position = getComputedStyle(e).position;
|
|
35
|
+
if (position !== 'absolute' && position !== 'fixed')
|
|
36
|
+
continue;
|
|
37
|
+
let block = null;
|
|
38
|
+
for (let p = e.parentElement; p; p = p.parentElement) {
|
|
39
|
+
const c = getComputedStyle(p);
|
|
40
|
+
if ((position === 'absolute' && c.position !== 'static') || c.transform !== 'none' || c.filter !== 'none' || c.perspective !== 'none' ||
|
|
41
|
+
/paint|layout|strict|content/.test(c.contain) || /transform|filter|perspective/.test(c.willChange)) {
|
|
42
|
+
block = p;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!block || !ancestor.contains(block))
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
};
|
|
30
51
|
const shown = (el) => {
|
|
31
52
|
const cs = getComputedStyle(el);
|
|
32
53
|
if (cs.display === 'none' || cs.visibility === 'hidden')
|
|
@@ -39,6 +60,23 @@ export async function audit(opts) {
|
|
|
39
60
|
// Text kept for screen readers only (Tailwind's sr-only: a 1px box, clipped away) is not drawn.
|
|
40
61
|
if (r.width <= 1 && r.height <= 1 && cs.position === 'absolute')
|
|
41
62
|
return false;
|
|
63
|
+
// Opacity, clipping and content-visibility belong to ancestors too. These nodes can have large boxes
|
|
64
|
+
// outside the page without painting anything there (a tooltip at rest or an accessible file input).
|
|
65
|
+
if (!el.checkVisibility({ opacityProperty: true, visibilityProperty: true, contentVisibilityAuto: true }))
|
|
66
|
+
return false;
|
|
67
|
+
const topLayer = el.closest(':modal, :popover-open');
|
|
68
|
+
for (let e = el; e; e = e.parentElement) {
|
|
69
|
+
const c = getComputedStyle(e);
|
|
70
|
+
if (c.clip === 'rect(0px, 0px, 0px, 0px)' || /^inset\((50|100)%\)$/.test(c.clipPath))
|
|
71
|
+
return false;
|
|
72
|
+
if (e !== el && (c.overflowX !== 'visible' || c.overflowY !== 'visible')) {
|
|
73
|
+
const q = e.getBoundingClientRect();
|
|
74
|
+
if (((c.overflowX !== 'visible' && q.width <= 0) || (c.overflowY !== 'visible' && q.height <= 0)) && overflowClips(el, e))
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (e === topLayer)
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
42
80
|
return r.width > 0 && r.height > 0;
|
|
43
81
|
};
|
|
44
82
|
const opacity = (el) => {
|
|
@@ -51,6 +89,86 @@ export async function audit(opts) {
|
|
|
51
89
|
const inSvg = (el) => !!el.closest('svg') && el.tagName.toLowerCase() !== 'svg';
|
|
52
90
|
const all = [...document.body.querySelectorAll('*')].filter((el) => shown(el) && !inSvg(el));
|
|
53
91
|
const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
92
|
+
const intersect = (a, b) => ({
|
|
93
|
+
left: Math.max(a.left, b.left), top: Math.max(a.top, b.top),
|
|
94
|
+
right: Math.min(a.right, b.right), bottom: Math.min(a.bottom, b.bottom),
|
|
95
|
+
});
|
|
96
|
+
const area = (r, minimum = 0) => r.right - r.left > minimum && r.bottom - r.top > minimum;
|
|
97
|
+
// Inline controls have one rectangle per line; their bounding rectangle includes empty corners between
|
|
98
|
+
// lines. Compare the fragments the browser draws, clipped on each axis by their scrolling ancestors.
|
|
99
|
+
const visibleRects = (el) => {
|
|
100
|
+
let rects = [...el.getClientRects()];
|
|
101
|
+
const topLayer = el.closest(':modal, :popover-open');
|
|
102
|
+
if (topLayer === el)
|
|
103
|
+
return rects.filter((r) => area(r));
|
|
104
|
+
for (let e = el.parentElement; e && e !== document.body; e = e.parentElement) {
|
|
105
|
+
const c = getComputedStyle(e);
|
|
106
|
+
if ((c.overflowX !== 'visible' || c.overflowY !== 'visible') && overflowClips(el, e)) {
|
|
107
|
+
const r = e.getBoundingClientRect();
|
|
108
|
+
rects = rects.map((q) => intersect(q, {
|
|
109
|
+
left: c.overflowX === 'visible' ? -Infinity : r.left,
|
|
110
|
+
right: c.overflowX === 'visible' ? Infinity : r.right,
|
|
111
|
+
top: c.overflowY === 'visible' ? -Infinity : r.top,
|
|
112
|
+
bottom: c.overflowY === 'visible' ? Infinity : r.bottom,
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
// Top-layer elements escape their ancestors' overflow, but their own scrolling still clips children.
|
|
116
|
+
if (e === topLayer)
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
return rects.filter((r) => area(r));
|
|
120
|
+
};
|
|
121
|
+
const proseLink = (el) => {
|
|
122
|
+
if (!el.matches('a') || getComputedStyle(el).display !== 'inline' || el.closest('nav, header, [role=navigation], [role=tablist], [role=menu]'))
|
|
123
|
+
return false;
|
|
124
|
+
const context = el.closest('p, li, blockquote, dd, figcaption');
|
|
125
|
+
if (!context)
|
|
126
|
+
return false;
|
|
127
|
+
// WCAG's inline-target exception is for links within a sentence, not a list of standalone links.
|
|
128
|
+
const walker = document.createTreeWalker(context, NodeFilter.SHOW_TEXT);
|
|
129
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode())
|
|
130
|
+
if (n.textContent.trim() && !n.parentElement?.closest('a, button'))
|
|
131
|
+
return true;
|
|
132
|
+
return false;
|
|
133
|
+
};
|
|
134
|
+
const verticalNavigationLink = (el) => {
|
|
135
|
+
if (!el.matches('a') || !el.closest('nav, [role=navigation]'))
|
|
136
|
+
return false;
|
|
137
|
+
const row = el.closest('li');
|
|
138
|
+
const list = row?.parentElement ?? el.parentElement;
|
|
139
|
+
if (!list)
|
|
140
|
+
return false;
|
|
141
|
+
const c = getComputedStyle(list);
|
|
142
|
+
// A vertical contents/menu list can contain sentence-length titles. Horizontal bars and tab rows
|
|
143
|
+
// retain their one-line label rule; wrapping them is not a substitute for responsive navigation.
|
|
144
|
+
return (c.display === 'block' && (row ? !getComputedStyle(row).display.startsWith('inline') : getComputedStyle(el).display === 'block')) ||
|
|
145
|
+
(c.display.includes('flex') && c.flexDirection.startsWith('column')) ||
|
|
146
|
+
(c.display.includes('grid') && c.gridTemplateColumns.split(/\s+/).length === 1);
|
|
147
|
+
};
|
|
148
|
+
const disclosureTextFits = (el) => {
|
|
149
|
+
if (!el.matches('button[aria-expanded]:not([aria-haspopup])'))
|
|
150
|
+
return false;
|
|
151
|
+
const c = getComputedStyle(el);
|
|
152
|
+
if (!['left', 'start'].includes(c.textAlign))
|
|
153
|
+
return false;
|
|
154
|
+
const r = el.getBoundingClientRect();
|
|
155
|
+
const top = r.top + parseFloat(c.borderTopWidth) + parseFloat(c.paddingTop);
|
|
156
|
+
const bottom = r.bottom - parseFloat(c.borderBottomWidth) - parseFloat(c.paddingBottom);
|
|
157
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
158
|
+
let found = false;
|
|
159
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
160
|
+
if (!n.textContent.trim() || !n.parentElement || !shown(n.parentElement))
|
|
161
|
+
continue;
|
|
162
|
+
const range = document.createRange();
|
|
163
|
+
range.selectNodeContents(n);
|
|
164
|
+
for (const q of range.getClientRects()) {
|
|
165
|
+
found = true;
|
|
166
|
+
if (q.top < top - 1 || q.bottom > bottom + 1)
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return found;
|
|
171
|
+
};
|
|
54
172
|
// 1. Nothing past the screen edge, except inside deliberate horizontal scrollers (chip rows).
|
|
55
173
|
if (document.documentElement.scrollWidth > W + 1)
|
|
56
174
|
push('error', 'page-overflow', document.body, `The page scrolls sideways (${document.documentElement.scrollWidth}px wide)`);
|
|
@@ -111,6 +229,10 @@ export async function audit(opts) {
|
|
|
111
229
|
for (const el of all) {
|
|
112
230
|
if (!isLabel(el) || text(el).length > 40 || el.querySelector('p, div, li, ul, br'))
|
|
113
231
|
continue;
|
|
232
|
+
// Prose links flow with their sentence; vertical contents titles and fitted disclosure headings can
|
|
233
|
+
// also wrap. Horizontal navigation, tabs and ordinary button labels retain their one-line rule.
|
|
234
|
+
if (proseLink(el) || verticalNavigationLink(el) || disclosureTextFits(el))
|
|
235
|
+
continue;
|
|
114
236
|
// A control whose text sits in two or more elements (a list row's title and its meta line) is a composite
|
|
115
237
|
// control, not a label: its lines are laid out on purpose.
|
|
116
238
|
const holders = new Set();
|
|
@@ -156,15 +278,57 @@ export async function audit(opts) {
|
|
|
156
278
|
});
|
|
157
279
|
// Behind an open modal the page is inert and covered, so only the modal's own bars count.
|
|
158
280
|
// An open modal: the element may be 0px itself when everything in it is fixed (Headless UI's Dialog).
|
|
159
|
-
const
|
|
281
|
+
const modals = [...document.querySelectorAll('dialog:modal, [aria-modal="true"]')].filter((m) => {
|
|
160
282
|
const cs = getComputedStyle(m);
|
|
161
283
|
return cs.display !== 'none' && cs.visibility !== 'hidden' && [m, ...m.querySelectorAll('*')].some(shown);
|
|
162
284
|
});
|
|
285
|
+
// Native dialogs enter the top layer in opening order, which need not match document order. Hit-testing
|
|
286
|
+
// their centres identifies the active one; focus also covers zero-sized ARIA modal wrappers.
|
|
287
|
+
const modal = modals.find((m) => {
|
|
288
|
+
const r = m.getBoundingClientRect();
|
|
289
|
+
const hit = document.elementFromPoint(Math.max(0, Math.min(W - 1, r.left + r.width / 2)), Math.max(0, Math.min(innerHeight - 1, r.top + r.height / 2)));
|
|
290
|
+
return hit?.closest('dialog:modal, [aria-modal="true"]') === m;
|
|
291
|
+
}) ?? document.activeElement?.closest('dialog:modal, [aria-modal="true"]') ?? modals.at(-1);
|
|
163
292
|
const outer = bars.filter((b) => !bars.some((o) => o !== b && o.contains(b)) && (!modal || modal.contains(b)));
|
|
164
293
|
const content = all.filter((el) => !outer.some((b) => b.contains(el)) &&
|
|
165
294
|
(!modal || modal.contains(el)) &&
|
|
166
295
|
(ownText(el) || el.matches('button, input, select, textarea, img, svg, canvas, video')) &&
|
|
167
296
|
opacity(el) > 0.5);
|
|
297
|
+
const painted = (el) => {
|
|
298
|
+
const cs = getComputedStyle(el);
|
|
299
|
+
return (ownText(el) ||
|
|
300
|
+
el.matches('svg, img, button, input') ||
|
|
301
|
+
!/rgba\(0, 0, 0, 0\)|transparent/.test(cs.backgroundColor) ||
|
|
302
|
+
Number.parseFloat(cs.borderTopWidth) > 0);
|
|
303
|
+
};
|
|
304
|
+
const paintedParts = (bar) => [bar, ...bar.querySelectorAll('*')].filter((e) => shown(e) && !inSvg(e) && painted(e));
|
|
305
|
+
const barCovers = (bar, el, rect) => {
|
|
306
|
+
// Geometry alone cannot tell which thing is on top: a menu or a modal can overlap a header while
|
|
307
|
+
// painting in front of it. Sample the actual intersection and ask the browser about paint order.
|
|
308
|
+
// Pointer-events:none must not hide a genuinely covering bar from that test.
|
|
309
|
+
const styles = [bar, el].map((e) => ({ e: e, value: e.style.getPropertyValue('pointer-events'), priority: e.style.getPropertyPriority('pointer-events') }));
|
|
310
|
+
for (const { e } of styles)
|
|
311
|
+
e.style.setProperty('pointer-events', 'auto', 'important');
|
|
312
|
+
try {
|
|
313
|
+
for (const fx of [0.1, 0.5, 0.9])
|
|
314
|
+
for (const fy of [0.1, 0.5, 0.9]) {
|
|
315
|
+
const x = rect.left + (rect.right - rect.left) * fx;
|
|
316
|
+
const y = rect.top + (rect.bottom - rect.top) * fy;
|
|
317
|
+
const hits = document.elementsFromPoint(x, y);
|
|
318
|
+
const first = hits[0];
|
|
319
|
+
if (first && bar.contains(first) && hits.some((hit) => el === hit || el.contains(hit)))
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
for (const { e, value, priority } of styles)
|
|
326
|
+
if (value)
|
|
327
|
+
e.style.setProperty('pointer-events', value, priority);
|
|
328
|
+
else
|
|
329
|
+
e.style.removeProperty('pointer-events');
|
|
330
|
+
}
|
|
331
|
+
};
|
|
168
332
|
const covered = async (edge) => {
|
|
169
333
|
window.scrollTo(0, edge === 'bottom' ? document.documentElement.scrollHeight : 0);
|
|
170
334
|
await frame();
|
|
@@ -174,28 +338,23 @@ export async function audit(opts) {
|
|
|
174
338
|
const atEdge = edge === 'bottom' ? b.bottom >= H - 4 && b.top > H / 2 : b.top <= 4 && b.bottom < H / 2;
|
|
175
339
|
if (!atEdge)
|
|
176
340
|
continue;
|
|
341
|
+
const drawn = paintedParts(bar).flatMap(visibleRects);
|
|
177
342
|
for (const el of content) {
|
|
178
343
|
if (el.contains(bar))
|
|
179
344
|
continue;
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
345
|
+
const hidden = visibleRects(el).some((r) => drawn.some((q) => {
|
|
346
|
+
const overlap = intersect(intersect(r, q), { left: 0, top: 0, right: W, bottom: H });
|
|
347
|
+
return area(overlap, 4) && barCovers(bar, el, overlap);
|
|
348
|
+
}));
|
|
349
|
+
if (hidden)
|
|
184
350
|
push('error', `under-${edge}-bar`, el, `Hidden under the fixed ${edge} bar (“${text(bar).slice(0, 30) || bar.tagName.toLowerCase()}”) when scrolled to the ${edge} (add padding)`);
|
|
185
351
|
}
|
|
186
352
|
}
|
|
187
353
|
};
|
|
188
354
|
// 3b. Fixed bars never overlap one another, including what sticks out of them (a raised tab-bar button
|
|
189
355
|
// reaching into a docked timer). Compares the painted parts: backgrounds, borders, text, icons, controls.
|
|
190
|
-
const painted = (el) => {
|
|
191
|
-
const cs = getComputedStyle(el);
|
|
192
|
-
return (ownText(el) ||
|
|
193
|
-
el.matches('svg, img, button, input') ||
|
|
194
|
-
!/rgba\(0, 0, 0, 0\)|transparent/.test(cs.backgroundColor) ||
|
|
195
|
-
Number.parseFloat(cs.borderTopWidth) > 0);
|
|
196
|
-
};
|
|
197
356
|
const fixedBars = outer.filter((b) => getComputedStyle(b).position === 'fixed' && b.getBoundingClientRect().height < window.innerHeight * 0.8);
|
|
198
|
-
const parts = (bar) =>
|
|
357
|
+
const parts = (bar) => paintedParts(bar).flatMap(visibleRects);
|
|
199
358
|
for (const [i, a] of fixedBars.entries())
|
|
200
359
|
for (const b of fixedBars.slice(i + 1)) {
|
|
201
360
|
const hit = parts(a).some((r) => parts(b).some((q) => Math.min(r.right, q.right) - Math.max(r.left, q.left) > 2 && Math.min(r.bottom, q.bottom) - Math.max(r.top, q.top) > 2));
|
|
@@ -229,7 +388,7 @@ export async function audit(opts) {
|
|
|
229
388
|
for (const el of interactive) {
|
|
230
389
|
if (interactive.some((o) => o !== el && o.contains(el)))
|
|
231
390
|
continue;
|
|
232
|
-
if (
|
|
391
|
+
if (proseLink(el))
|
|
233
392
|
continue;
|
|
234
393
|
const r = el.getBoundingClientRect();
|
|
235
394
|
const min = Math.min(r.width, r.height);
|
|
@@ -262,34 +421,73 @@ export async function audit(opts) {
|
|
|
262
421
|
const layer = layerOf;
|
|
263
422
|
// The part of a control its scrolling or clipping ancestors show: a link scrolled out of a nav strip is not drawn
|
|
264
423
|
// where its box would be.
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
424
|
+
const isEmptyOverlay = (el) => {
|
|
425
|
+
if (!['absolute', 'fixed'].includes(getComputedStyle(el).position) || text(el) || el.querySelector('svg, img, canvas, video, input'))
|
|
426
|
+
return false;
|
|
427
|
+
const c = getComputedStyle(el);
|
|
428
|
+
if (!/rgba\(0, 0, 0, 0\)|transparent/.test(c.backgroundColor) || c.backgroundImage !== 'none' || c.boxShadow !== 'none' ||
|
|
429
|
+
[c.borderTopWidth, c.borderRightWidth, c.borderBottomWidth, c.borderLeftWidth].some((v) => parseFloat(v) > 0))
|
|
430
|
+
return false;
|
|
431
|
+
for (const pseudo of ['::before', '::after']) {
|
|
432
|
+
const p = getComputedStyle(el, pseudo);
|
|
433
|
+
if (p.content !== 'none' && p.content !== 'normal' && p.display !== 'none')
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
return true;
|
|
437
|
+
};
|
|
438
|
+
const emptyOverlay = (el, other) => {
|
|
439
|
+
if (!emptyOverlays.has(el))
|
|
440
|
+
return false;
|
|
441
|
+
const r = el.getBoundingClientRect(), q = other.getBoundingClientRect();
|
|
442
|
+
// A stretched card/row action underneath its own secondary link is intentional. An empty control
|
|
443
|
+
// merely colliding with a neighbour, or a painted button covering another control, is still an error.
|
|
444
|
+
if (!(r.left <= q.left && r.right >= q.right && r.top <= q.top && r.bottom >= q.bottom && (r.width > q.width + 2 || r.height > q.height + 2)))
|
|
445
|
+
return false;
|
|
446
|
+
// Transparency alone does not prove that this pattern works: an invisible action above the link
|
|
447
|
+
// blocks it. Ask the browser whether the secondary control is actually reachable. Scroll the window
|
|
448
|
+
// only when necessary to hit-test an offscreen row; leave nested scroll positions and final scroll intact.
|
|
449
|
+
const before = { x: scrollX, y: scrollY };
|
|
450
|
+
try {
|
|
451
|
+
if (q.bottom <= 0 || q.top >= innerHeight)
|
|
452
|
+
window.scrollTo({ top: scrollY + q.top - innerHeight / 2, behavior: 'instant' });
|
|
453
|
+
return visibleRects(other).some((box) => {
|
|
454
|
+
const onScreen = intersect(box, { left: 0, top: 0, right: W, bottom: innerHeight });
|
|
455
|
+
if (!area(onScreen))
|
|
456
|
+
return false;
|
|
457
|
+
const hit = document.elementFromPoint((onScreen.left + onScreen.right) / 2, (onScreen.top + onScreen.bottom) / 2);
|
|
458
|
+
return !!hit && other.contains(hit);
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
finally {
|
|
462
|
+
if (scrollX !== before.x || scrollY !== before.y)
|
|
463
|
+
window.scrollTo({ left: before.x, top: before.y, behavior: 'instant' });
|
|
281
464
|
}
|
|
282
|
-
|
|
465
|
+
};
|
|
466
|
+
const reservedInputAction = (field, action) => {
|
|
467
|
+
if (!field.matches('input') || !action.matches('button, [role=button]') || !field.parentElement?.contains(action))
|
|
468
|
+
return false;
|
|
469
|
+
if (!['text', 'search', 'email', 'url', 'tel', 'password', 'number'].includes(field.type))
|
|
470
|
+
return false;
|
|
471
|
+
const r = field.getBoundingClientRect(), q = action.getBoundingClientRect();
|
|
472
|
+
if (q.left < r.left - 1 || q.right > r.right + 1 || q.top < r.top - 1 || q.bottom > r.bottom + 1)
|
|
473
|
+
return false;
|
|
474
|
+
const c = getComputedStyle(field);
|
|
475
|
+
const left = r.left + parseFloat(c.borderLeftWidth) + parseFloat(c.paddingLeft);
|
|
476
|
+
const right = r.right - parseFloat(c.borderRightWidth) - parseFloat(c.paddingRight);
|
|
477
|
+
// A clear/reveal/list toggle may occupy padding reserved for it, but never the editable text area.
|
|
478
|
+
return q.right <= left + 1 || q.left >= right - 1;
|
|
283
479
|
};
|
|
284
480
|
// Behind an open modal (found above) only the modal's own controls count.
|
|
285
481
|
const solid = interactive.filter((el) => opacity(el) > 0.05 && (!modal || modal.contains(el)));
|
|
482
|
+
const emptyOverlays = new Set(solid.filter(isEmptyOverlay));
|
|
483
|
+
const controlRects = new Map(solid.map((el) => [el, visibleRects(el)]));
|
|
286
484
|
for (const [i, a] of solid.entries())
|
|
287
485
|
for (const b of solid.slice(i + 1)) {
|
|
288
486
|
if (a.contains(b) || b.contains(a) || layer(a) !== layer(b))
|
|
289
487
|
continue;
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (
|
|
488
|
+
if (reservedInputAction(a, b) || reservedInputAction(b, a) || emptyOverlay(a, b) || emptyOverlay(b, a))
|
|
489
|
+
continue;
|
|
490
|
+
if (controlRects.get(a).some((r) => controlRects.get(b).some((q) => area(intersect(r, q), 2))))
|
|
293
491
|
push('error', 'control-overlap', b, `Overlaps another control (“${text(a).slice(0, 30) || a.getAttribute('aria-label') || a.tagName.toLowerCase()}”): give them room`);
|
|
294
492
|
}
|
|
295
493
|
// 4c. Text never collides with other text: labels of two controls overlapping (a long translation spilling into
|
|
@@ -508,7 +706,7 @@ export async function audit(opts) {
|
|
|
508
706
|
// Compared like overlaps (4b): behind an open modal only its own controls, and within one layer.
|
|
509
707
|
if (W < 600)
|
|
510
708
|
for (const el of solid) {
|
|
511
|
-
if (solid.some((o) => o !== el && o.contains(el)) || (el
|
|
709
|
+
if (solid.some((o) => o !== el && o.contains(el)) || proseLink(el))
|
|
512
710
|
continue;
|
|
513
711
|
const r = el.getBoundingClientRect();
|
|
514
712
|
if (r.width >= 48 && r.height >= 48)
|
package/dist/cache.d.ts
ADDED
package/dist/cache.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
/** Content, not a directory's mtime: editing an imported stylesheet must invalidate its consumers. */
|
|
5
|
+
export function fingerprintPaths(paths) {
|
|
6
|
+
const hash = createHash('sha256');
|
|
7
|
+
const visited = new Set();
|
|
8
|
+
const visit = (path) => {
|
|
9
|
+
path = resolve(path);
|
|
10
|
+
if (visited.has(path))
|
|
11
|
+
return;
|
|
12
|
+
visited.add(path);
|
|
13
|
+
hash.update(JSON.stringify(path));
|
|
14
|
+
try {
|
|
15
|
+
const stat = statSync(path);
|
|
16
|
+
if (stat.isDirectory()) {
|
|
17
|
+
hash.update('directory');
|
|
18
|
+
for (const name of readdirSync(path).sort()) {
|
|
19
|
+
if (name.startsWith('.') || name === 'node_modules')
|
|
20
|
+
continue;
|
|
21
|
+
visit(join(path, name));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
else if (stat.isFile()) {
|
|
25
|
+
hash.update('file');
|
|
26
|
+
hash.update(readFileSync(path));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error.code !== 'ENOENT')
|
|
31
|
+
throw error;
|
|
32
|
+
hash.update('missing');
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
paths.forEach(visit);
|
|
36
|
+
return hash.digest('hex');
|
|
37
|
+
}
|
package/dist/check.js
CHANGED
|
@@ -16,9 +16,11 @@
|
|
|
16
16
|
* Screens run four at a time; reruns only visit screens that changed since design/checks.json (or
|
|
17
17
|
* everything DESIGN.md, the tokens, the components or the catalog changed since).
|
|
18
18
|
*/
|
|
19
|
-
import { mkdirSync, readFileSync,
|
|
19
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
21
22
|
import { chromium } from '@playwright/test';
|
|
23
|
+
import { fingerprintPaths } from './cache.js';
|
|
22
24
|
import { colourIssues } from './colours.js';
|
|
23
25
|
import { CONFIG_FILE, checkWidths, config } from './config.js';
|
|
24
26
|
import { collectComponents, collectOptions, consistencyIssues, reuseIssues, } from './consistency.js';
|
|
@@ -61,36 +63,25 @@ const components = {};
|
|
|
61
63
|
const stateRows = {};
|
|
62
64
|
// Screens unchanged since the last green run keep their results when nothing they depend on changed
|
|
63
65
|
// (DESIGN.md, tokens, components, the catalog): reruns then only visit what actually changed.
|
|
64
|
-
const depFiles = ['DESIGN.md', 'tokens.css', 'tokens.json'];
|
|
65
|
-
const depMtime = (p) => {
|
|
66
|
-
try {
|
|
67
|
-
return statSync(join(ROOT, p)).mtimeMs;
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
return 0;
|
|
71
|
-
}
|
|
72
|
-
};
|
|
73
66
|
let previous = {};
|
|
74
67
|
try {
|
|
75
68
|
previous = JSON.parse(readFileSync(join(ROOT, 'checks.json'), 'utf8'));
|
|
76
69
|
}
|
|
77
70
|
catch { }
|
|
78
|
-
const currentDeps =
|
|
79
|
-
|
|
71
|
+
const currentDeps = fingerprintPaths([
|
|
72
|
+
...['DESIGN.md', 'tokens.css', 'tokens.json', 'components', config.catalogDir].map((p) => join(ROOT, p)),
|
|
73
|
+
...(CONFIG_FILE ? [CONFIG_FILE] : []),
|
|
74
|
+
fileURLToPath(new URL('.', import.meta.url)),
|
|
75
|
+
]);
|
|
80
76
|
// Renders are the point of --shots: every screen is visited, nothing is skipped.
|
|
81
77
|
// ...as are explicit locales: different flags check different things.
|
|
82
78
|
const runKey = JSON.stringify({ locales, shots });
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return Infinity;
|
|
90
|
-
}
|
|
91
|
-
};
|
|
92
|
-
const checkedAt = previous.checkedAt ? Date.parse(previous.checkedAt) : 0;
|
|
93
|
-
const todo = screens.filter((s) => !skipping || !previous.screens?.[s.path] || screenMtime(s.path) >= checkedAt);
|
|
79
|
+
// Each screen remembers its own inputs. A filtered run cannot bless unvisited screens after a shared edit.
|
|
80
|
+
const cacheKeys = Object.fromEntries(screens.map((s) => [
|
|
81
|
+
s.path, `${currentDeps}:${runKey}:${fingerprintPaths([join(ROOT, s.path)])}`,
|
|
82
|
+
]));
|
|
83
|
+
const todo = screens.filter((s) => shots || !previous.screens?.[s.path] || previous.cacheKeys?.[s.path] !== cacheKeys[s.path]
|
|
84
|
+
|| previous.screens[s.path].some((i) => i.type === 'check-crashed'));
|
|
94
85
|
for (const s of screens) {
|
|
95
86
|
if (!todo.includes(s)) {
|
|
96
87
|
results[s.path] = previous.screens[s.path];
|
|
@@ -272,7 +263,10 @@ async function shoot(page, s, theme, locale, at) {
|
|
|
272
263
|
}
|
|
273
264
|
const exists = new Set(listScreens().map((s) => s.path));
|
|
274
265
|
const keep = (o) => Object.fromEntries(Object.entries(o).filter(([p]) => exists.has(p)));
|
|
275
|
-
const
|
|
266
|
+
const freshPreviousComponents = Object.fromEntries(Object.entries(previous.components ?? {})
|
|
267
|
+
.filter(([path]) => previous.cacheKeys?.[path]?.startsWith(`${currentDeps}:`)
|
|
268
|
+
&& previous.cacheKeys[path].endsWith(`:${fingerprintPaths([join(ROOT, path)])}`)));
|
|
269
|
+
const allComponents = keep({ ...freshPreviousComponents, ...components });
|
|
276
270
|
// Archived versions are history: they do not vote on how a component should look.
|
|
277
271
|
const archived = new Set(listScreens()
|
|
278
272
|
.filter((s) => s.status === 'archived')
|
|
@@ -288,7 +282,7 @@ for (const s of screens) {
|
|
|
288
282
|
for (const i of issues.filter((i) => i.severity === 'error'))
|
|
289
283
|
console.log(` ${i.type}: ${i.message}${i.text ? ` — “${i.text}”` : ''}`);
|
|
290
284
|
}
|
|
291
|
-
writeFileSync(join(ROOT, 'checks.json'), `${JSON.stringify({ checkedAt: new Date().toISOString(),
|
|
285
|
+
writeFileSync(join(ROOT, 'checks.json'), `${JSON.stringify({ checkedAt: new Date().toISOString(), cacheKeys: keep({ ...previous.cacheKeys, ...cacheKeys }), screens: keep({ ...previous.screens, ...results }), components: allComponents }, null, 2)}\n`);
|
|
292
286
|
console.log(`\n${screens.length} screens checked, ${errors} errors. Results in design/checks.json (badges on the canvas).`);
|
|
293
287
|
if (strict && errors)
|
|
294
288
|
process.exit(1);
|
package/dist/report.js
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
10
11
|
import { chromium } from '@playwright/test';
|
|
11
|
-
import {
|
|
12
|
+
import { fingerprintPaths } from './cache.js';
|
|
13
|
+
import { CONFIG_FILE, config } from './config.js';
|
|
12
14
|
import { collectComponents, collectOptions, consistencyIssues, nameOf, } from './consistency.js';
|
|
13
15
|
import { filterSnippet } from './report-filter.js';
|
|
14
16
|
import { listScreens, ROOT } from './screens.js';
|
|
@@ -27,18 +29,17 @@ const KNOWN_FILE = join(out, 'known.json');
|
|
|
27
29
|
const cached = existsSync(KNOWN_FILE)
|
|
28
30
|
? JSON.parse(readFileSync(KNOWN_FILE, 'utf8'))
|
|
29
31
|
: {};
|
|
32
|
+
const deps = fingerprintPaths([
|
|
33
|
+
...['DESIGN.md', 'tokens.css', 'tokens.json', 'components', config.catalogDir].map((p) => join(ROOT, p)),
|
|
34
|
+
...(CONFIG_FILE ? [CONFIG_FILE] : []),
|
|
35
|
+
fileURLToPath(new URL('.', import.meta.url)),
|
|
36
|
+
]);
|
|
37
|
+
const sourceKeys = Object.fromEntries(screens.map((s) => [s.path, fingerprintPaths([join(ROOT, s.path)])]));
|
|
30
38
|
// A screen needs a visit when it is new, changed, or lacks crops for the requested kinds.
|
|
31
39
|
const needsVisit = (path) => {
|
|
32
40
|
const hit = cached[path];
|
|
33
|
-
if (!hit)
|
|
41
|
+
if (!hit || hit.deps !== deps || hit.sourceKey !== sourceKeys[path])
|
|
34
42
|
return true;
|
|
35
|
-
try {
|
|
36
|
-
if (statSync(join(ROOT, path)).mtimeMs !== hit.mtime)
|
|
37
|
-
return true;
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
return true;
|
|
41
|
-
}
|
|
42
43
|
if (!kinds.length)
|
|
43
44
|
return !hit.kinds.includes('all');
|
|
44
45
|
return !kinds.every((k) => hit.kinds.includes(k) || hit.kinds.includes('all'));
|
|
@@ -132,7 +133,8 @@ const runOne = async (s, worker) => {
|
|
|
132
133
|
crops[kind] ??= {};
|
|
133
134
|
crops[kind][s.path] = files;
|
|
134
135
|
}
|
|
135
|
-
|
|
136
|
+
if (!kinds.length)
|
|
137
|
+
visitedKinds[s.path].add('all');
|
|
136
138
|
}
|
|
137
139
|
catch (e) {
|
|
138
140
|
// A dead page must not kill the other workers: the screen keeps no cache entry and is retried next run.
|
|
@@ -163,7 +165,7 @@ for (const s of todo) {
|
|
|
163
165
|
if (byPath[s.path])
|
|
164
166
|
screenCrops[kind] = byPath[s.path];
|
|
165
167
|
}
|
|
166
|
-
const prev = cached[s.path];
|
|
168
|
+
const prev = cached[s.path]?.deps === deps && cached[s.path]?.sourceKey === sourceKeys[s.path] ? cached[s.path] : undefined;
|
|
167
169
|
const families = new Set([...(prev?.kinds ?? []), ...(visitedKinds[s.path] ?? [])]);
|
|
168
170
|
if (!kinds.length)
|
|
169
171
|
families.add('all');
|
|
@@ -173,6 +175,8 @@ for (const s of todo) {
|
|
|
173
175
|
try {
|
|
174
176
|
next[s.path] = {
|
|
175
177
|
mtime: statSync(join(ROOT, s.path)).mtimeMs,
|
|
178
|
+
deps,
|
|
179
|
+
sourceKey: sourceKeys[s.path],
|
|
176
180
|
components: known[s.path],
|
|
177
181
|
kinds: [...families],
|
|
178
182
|
crops: { ...(prev?.crops ?? {}), ...screenCrops },
|
package/package.json
CHANGED
package/skills/stitch2/SKILL.md
CHANGED
|
@@ -69,6 +69,22 @@ References, read before the first screen:
|
|
|
69
69
|
An issue tagged with a width ("at 1024px: …") breaks at an edge of the windows the variant covers: fix it with a
|
|
70
70
|
responsive rule in the screen, or a new variant when the layout's structure has to change (screens.md, Widths).
|
|
71
71
|
Widths include the breakpoints inside the range; `narrow-layout` is a phone column left in a wide window.
|
|
72
|
+
Overlap checks compare the drawn fragments of wrapped inline controls, not the empty corners of their
|
|
73
|
+
bounding boxes. For fixed bars, the browser's paint order decides coverage: a menu painted in front of a
|
|
74
|
+
header is not hidden by it. Native modal dialogs and ARIA modals limit coverage and control comparisons
|
|
75
|
+
to the active dialog. Transparent hints, fully clipped accessible inputs and content in zero-sized
|
|
76
|
+
overflow-clipped wrappers are not drawn controls. Positioned descendants whose containing block lies
|
|
77
|
+
outside that wrapper, and top-layer dialogs, remain checked because the wrapper does not clip them.
|
|
78
|
+
An empty, transparent stretched row/card action may sit under a secondary link; painted controls or
|
|
79
|
+
partially colliding neighbours still fail. Do not silence an overlap by hiding content or removing its
|
|
80
|
+
accessible label: inspect the visible state and fix actual collisions in the shared component.
|
|
81
|
+
Inline links within a prose sentence may wrap and receive WCAG's inline-target exception, including
|
|
82
|
+
sentences inside list items; standalone links do not. Titles in vertical contents/menu lists may wrap,
|
|
83
|
+
unlike horizontal navigation labels. An in-page disclosure (`aria-expanded`, without `aria-haspopup`)
|
|
84
|
+
may have a multiline heading when its height and padding fit the complete text. A text input's clear,
|
|
85
|
+
reveal or list-toggle button may overlap only padding reserved for it, never the editable text area.
|
|
86
|
+
Cached checks are invalidated when shared components, the catalog or the checker change. A filtered
|
|
87
|
+
rerun refreshes only those screens; it must not make unrelated screens' older checks appear current.
|
|
72
88
|
Motion warnings: `motion-token` (a transition length DESIGN.md doesn't name) and `reduced-motion` (something
|
|
73
89
|
still moves when the person asked for less); see motion.md. On the catalog, `state-*` warnings come from the
|
|
74
90
|
States sheet (components.md, States).
|
|
@@ -52,6 +52,8 @@ make the next version.
|
|
|
52
52
|
|
|
53
53
|
1. Re-run `stitch2 check` and `stitch2 consistency`: the fixed screens show "matches" in the report and no
|
|
54
54
|
`reuse`/`consistency` warnings.
|
|
55
|
+
Changes to shared components or the catalog invalidate their cached checks. A filtered check does not
|
|
56
|
+
refresh screens it did not visit; run the full check before making a whole-project consistency claim.
|
|
55
57
|
2. Tell the user, per component, which screens matched, which you fixed (and how: component used, component
|
|
56
58
|
extended, new version), and which differ on purpose (drafts you left, with the reason). Point to the report
|
|
57
59
|
on the canvas so they can see it.
|