stitch2 0.22.8 → 0.22.10

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 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 modal = [...document.querySelectorAll('[aria-modal="true"]')].find((m) => {
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 r = el.getBoundingClientRect();
181
- const overlapY = Math.min(r.bottom, b.bottom) - Math.max(r.top, b.top);
182
- const overlapX = Math.min(r.right, b.right) - Math.max(r.left, b.left);
183
- if (overlapY > 4 && overlapX > 4)
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) => [bar, ...bar.querySelectorAll('*')].filter((e) => shown(e) && !inSvg(e) && painted(e)).map((e) => e.getBoundingClientRect());
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 (el.matches('a') && el.closest('p'))
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 visibleBox = (el) => {
266
- const r = el.getBoundingClientRect();
267
- let { left, top, right, bottom } = r;
268
- for (let e = el.parentElement; e && e !== document.body; e = e.parentElement) {
269
- const cs = getComputedStyle(e);
270
- if (cs.overflowX === 'visible' && cs.overflowY === 'visible')
271
- continue;
272
- const c = e.getBoundingClientRect();
273
- if (cs.overflowX !== 'visible') {
274
- left = Math.max(left, c.left);
275
- right = Math.min(right, c.right);
276
- }
277
- if (cs.overflowY !== 'visible') {
278
- top = Math.max(top, c.top);
279
- bottom = Math.min(bottom, c.bottom);
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
- return { left, top, right, bottom };
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
- const r = visibleBox(a);
291
- const q = visibleBox(b);
292
- if (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)
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.matches('a') && el.closest('p')))
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)
@@ -0,0 +1,2 @@
1
+ /** Content, not a directory's mtime: editing an imported stylesheet must invalidate its consumers. */
2
+ export declare function fingerprintPaths(paths: string[]): string;
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, statSync, writeFileSync } from 'node:fs';
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 = Math.max(...depFiles.map(depMtime), depMtime(config.catalogDir), depMtime(join(config.catalogDir, 'catalog.html')), ...(CONFIG_FILE ? [depMtime(CONFIG_FILE)] : []));
79
- const depsChanged = !previous.checkedAt || (previous.depsMtime ?? 0) < currentDeps;
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
- const skipping = !shots && !depsChanged && previous.runKey !== undefined && previous.runKey === runKey;
84
- const screenMtime = (path) => {
85
- try {
86
- return statSync(join(ROOT, path)).mtimeMs;
87
- }
88
- catch {
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 allComponents = keep({ ...previous.components, ...components });
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(), depsMtime: currentDeps, runKey, screens: keep({ ...previous.screens, ...results }), components: allComponents }, null, 2)}\n`);
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/elements.js CHANGED
@@ -37,14 +37,22 @@ export const screenLocale = () => globalThis.__stitch2?.lang;
37
37
  */
38
38
  let idCount = 0;
39
39
  const ID_REFS = ['for', 'aria-labelledby', 'aria-describedby', 'aria-controls', 'aria-owns', 'aria-activedescendant', 'list'];
40
- function uniqueIds(content) {
40
+ function uniqueIds(content, replacing) {
41
+ // The element's children are about to be replaced, not duplicated. Their IDs may also be
42
+ // referenced from outside the component (labels, descriptions and navigation anchors).
43
+ const occupied = new Set(Array.from(document.querySelectorAll('[id]'))
44
+ .filter((el) => !replacing.contains(el)).map((el) => el.id));
41
45
  const renamed = new Map();
42
46
  for (const el of Array.from(content.querySelectorAll('[id]'))) {
43
- if (!document.getElementById(el.id))
47
+ if (!occupied.has(el.id))
44
48
  continue;
45
- const next = el.id + '-s2-' + ++idCount;
49
+ let next;
50
+ do
51
+ next = el.id + '-s2-' + ++idCount;
52
+ while (occupied.has(next) || content.getElementById(next));
46
53
  renamed.set(el.id, next);
47
54
  el.id = next;
55
+ occupied.add(next);
48
56
  }
49
57
  if (!renamed.size)
50
58
  return;
@@ -95,7 +103,7 @@ export function defineElements(components, options) {
95
103
  if (typeof props[key] === 'string' && !root.hasAttribute(`data-${key}`))
96
104
  root.setAttribute(`data-${key}`, props[key]);
97
105
  }
98
- uniqueIds(t.content);
106
+ uniqueIds(t.content, this);
99
107
  this.replaceWith(t.content);
100
108
  if (options.after && !pending++)
101
109
  queueMicrotask(() => {
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 { config } from './config.js';
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
- visitedKinds[s.path].add('all');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitch2",
3
- "version": "0.22.8",
3
+ "version": "0.22.10",
4
4
  "description": "A design canvas, layout and type checks, component consistency and screen versioning for agent-built UI, on Google's DESIGN.md format.",
5
5
  "license": "MIT",
6
6
  "author": "ifokeev",
@@ -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).
@@ -61,6 +61,10 @@ never patch the bundle or restyle the element on a screen. Props are the element
61
61
  an empty attribute is true) and its content is children. A component the screens need but the app lacks is
62
62
  added to the app first, with the same name.
63
63
 
64
+ Keep native field IDs and their external label, ARIA and fragment references stable when nesting components.
65
+ `stitch2/elements` preserves IDs from the element being replaced; only real collisions outside that element
66
+ are renamed, together with references inside the component. Test both cases when changing a shared form.
67
+
64
68
  ## Colours come from DESIGN.md
65
69
 
66
70
  Every colour on a screen and in a component is one of DESIGN.md's: a token class (`bg-surface`,
@@ -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.