stitch2 0.22.7 → 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 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);
@@ -21,12 +21,14 @@ export interface PageComponents {
21
21
  selector: string;
22
22
  text: string;
23
23
  }[];
24
+ /** Provenance declared by the page. App snapshots are faithful renders, not component implementations. */
25
+ source?: string;
24
26
  }
25
27
  export interface CollectOptions {
26
28
  /** Component marker: elements with data-<prefix>="name". */
27
29
  prefix: string;
28
30
  /** DESIGN.md's primary colour as [r, g, b]: filled buttons in it are primary buttons. */
29
- primary: number[];
31
+ primary?: number[];
30
32
  }
31
33
  /** The options for collectComponents from the config and DESIGN.md's primary colour. */
32
34
  export declare function collectOptions(): CollectOptions;
@@ -10,8 +10,31 @@ import { config } from './config.js';
10
10
  import { readDesign } from './design-md.js';
11
11
  /** The options for collectComponents from the config and DESIGN.md's primary colour. */
12
12
  export function collectOptions() {
13
- const hex = (readDesign().colors.primary ?? '#000000').replace('#', '');
14
- return { prefix: config.prefix, primary: [0, 2, 4].map((i) => Number.parseInt(hex.slice(i, i + 2), 16)) };
13
+ const design = readDesign();
14
+ // DESIGN.md projects created by stitch2 have `colors.primary`; projects that use a richer palette often name
15
+ // the same role and reference it from components.button-primary. Resolve that reference here. Falling back to
16
+ // black made every transparent element look like a primary button.
17
+ const component = design.components['button-primary']?.backgroundColor;
18
+ const ref = /^\{colors\.([\w.-]+)\}$/.exec(component ?? '')?.[1];
19
+ const value = design.colors.primary ?? (ref ? design.colors[ref] : undefined) ?? component;
20
+ const primary = parseColour(value);
21
+ return { prefix: config.prefix, ...(primary ? { primary } : {}) };
22
+ }
23
+ /** CSS colours accepted in DESIGN.md, represented as RGB for the page-side detector. */
24
+ function parseColour(value) {
25
+ if (!value)
26
+ return undefined;
27
+ const hex = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(value.trim())?.[1];
28
+ if (hex) {
29
+ const expanded = hex.length === 3 ? hex.replace(/./g, (c) => c + c) : hex;
30
+ return [0, 2, 4].map((i) => Number.parseInt(expanded.slice(i, i + 2), 16));
31
+ }
32
+ const rgb = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i.exec(value.trim());
33
+ if (!rgb)
34
+ return undefined;
35
+ return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])].every(Number.isFinite)
36
+ ? [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])]
37
+ : undefined;
15
38
  }
16
39
  /** Runs in the page. Plain JS on purpose: Playwright sends its source to the browser. */
17
40
  export function collectComponents(opts) {
@@ -73,11 +96,37 @@ export function collectComponents(opts) {
73
96
  });
74
97
  const iconSet = (root) => [...new Set(icons(root).map(([s]) => s))].join('+');
75
98
  const box = (el) => el.getBoundingClientRect();
76
- const ember = (el) => getComputedStyle(el).backgroundColor.includes(opts.primary.join(', '));
99
+ const ember = (el) => {
100
+ if (!opts.primary)
101
+ return false;
102
+ const background = getComputedStyle(el).backgroundColor.trim();
103
+ // Computed sRGB backgrounds are rgb()/rgba() in supported browsers. Do not extract arbitrary numbers from
104
+ // oklch(), color-mix() or a CSS variable that did not resolve: a false primary match is worse than no match.
105
+ const values = /^rgba?\(/i.test(background) ? background.match(/[\d.]+/g)?.map(Number) ?? [] : [];
106
+ const alpha = values.length > 3 ? values[3] : 1;
107
+ return (alpha >= 0.05 &&
108
+ values.length >= 3 &&
109
+ opts.primary.every((value, i) => Math.abs(value - values[i]) <= 2));
110
+ };
77
111
  const visible = (el) => {
78
112
  const r = box(el);
79
113
  const c = getComputedStyle(el);
80
- return r.width > 0 && r.height > 0 && c.visibility !== 'hidden' && c.display !== 'none';
114
+ if (r.width <= 0 || r.height <= 0 || c.visibility === 'hidden' || c.display === 'none')
115
+ return false;
116
+ // A closed <details> exposes only its summary. Its descendants can still have boxes in a static snapshot,
117
+ // especially when the snapshot was captured at a responsive breakpoint.
118
+ const closed = el.closest('details:not([open])');
119
+ if (closed && !closed.querySelector(':scope > summary')?.contains(el))
120
+ return false;
121
+ // Match audit/type checks: clipped screen-reader-only nodes and transparent ancestors are not drawn.
122
+ if (r.width <= 1 && r.height <= 1 && c.position === 'absolute')
123
+ return false;
124
+ if (c.clip === 'rect(0px, 0px, 0px, 0px)' || c.clipPath === 'inset(100%)')
125
+ return false;
126
+ let opacity = 1;
127
+ for (let e = el; e; e = e.parentElement)
128
+ opacity *= Number(getComputedStyle(e).opacity);
129
+ return opacity >= 0.05;
81
130
  };
82
131
  const sigOf = (kind, el) => {
83
132
  const c = getComputedStyle(el);
@@ -223,7 +272,8 @@ export function collectComponents(opts) {
223
272
  el.matches('h2, h3, h4, p, span, div, label'))
224
273
  found('section', el);
225
274
  }
226
- return { components, handBuilt };
275
+ const source = document.querySelector(`meta[name="${opts.prefix}-source"]`)?.getAttribute('content') ?? undefined;
276
+ return { components, handBuilt, ...(source ? { source } : {}) };
227
277
  }
228
278
  /** The components whose look is measured and compared; the rest follow their content (see sigOf). */
229
279
  const MEASURED = new Set(['tab-bar', 'header', 'section', 'card', 'button', 'icon-button', 'chip']);
@@ -332,7 +382,9 @@ export function consistencyIssues(known, paths) {
332
382
  }
333
383
  /** Lab screens build catalog components with the components, not by hand. */
334
384
  export function reuseIssues(path, page) {
335
- if (familyOf(path) !== 'screens')
385
+ // App snapshots preserve the exact product markup for visual review. They are not authored by the lab and cannot
386
+ // truthfully be rewritten to custom elements; only hand-authored lab screens are checked for component reuse.
387
+ if (familyOf(path) !== 'screens' || page.source === 'app')
336
388
  return [];
337
389
  return page.handBuilt.slice(0, 10).map((h) => ({
338
390
  severity: 'error',
@@ -24,6 +24,8 @@ export declare function motionDurations(design?: Design): number[];
24
24
  * colours are the dark theme and light-* colours make a light one.
25
25
  */
26
26
  export declare function designThemes(): string[];
27
+ /** Keep an authored font stack intact, quoting only a single family whose name needs it. */
28
+ export declare function cssFontStack(value: string | undefined): string;
27
29
  /** A type level in pixels, for comparing with computed styles. */
28
30
  export interface Level {
29
31
  name: string;
package/dist/design-md.js CHANGED
@@ -55,19 +55,51 @@ export function designThemes() {
55
55
  return ['dark'];
56
56
  }
57
57
  }
58
+ /** Convert a CSS length from DESIGN.md to pixels. CSS rem uses the 16px root; em and percentages use `relative`. */
59
+ function cssLength(value, relative, unitless) {
60
+ if (value === undefined || value === null)
61
+ return 0;
62
+ if (typeof value === 'number')
63
+ return unitless === 'relative' ? value * relative : value;
64
+ const match = /^(-?(?:\d+|\d*\.\d+))\s*(px|rem|em|%)?$/i.exec(value.trim());
65
+ if (!match)
66
+ return 0;
67
+ const n = Number(match[1]);
68
+ switch ((match[2] ?? '').toLowerCase()) {
69
+ case 'rem':
70
+ return n * 16;
71
+ case 'em':
72
+ case '%':
73
+ return n * relative * (match[2] === '%' ? 0.01 : 1);
74
+ case 'px':
75
+ return n;
76
+ default:
77
+ return unitless === 'relative' ? n * relative : n;
78
+ }
79
+ }
80
+ /** Keep an authored font stack intact, quoting only a single family whose name needs it. */
81
+ export function cssFontStack(value) {
82
+ const stack = value?.trim() ?? '';
83
+ if (!stack)
84
+ return '';
85
+ if (stack.includes(',') || /^['"].*['"]$/.test(stack) || /^[\w-]+$/.test(stack) || /^var\(/i.test(stack))
86
+ return stack;
87
+ return `"${stack.replaceAll('"', '\\"')}"`;
88
+ }
58
89
  export function typeLevels(design = readDesign()) {
59
90
  return Object.entries(design.typography).map(([name, t]) => {
60
- const size = Number.parseFloat(String(t.fontSize));
61
- const lh = String(t.lineHeight);
91
+ const size = cssLength(t.fontSize, 16, 'px');
92
+ const lineHeightValue = t.lineHeight;
93
+ const lineHeight = typeof lineHeightValue === 'string' && lineHeightValue.trim().toLowerCase() === 'normal'
94
+ ? size * 1.2
95
+ : cssLength(lineHeightValue, size, 'relative');
62
96
  return {
63
97
  name,
64
98
  mono: /mono/i.test(t.fontFamily ?? ''),
65
99
  size,
66
100
  weight: Number(t.fontWeight ?? 400),
67
- lineHeight: lh.endsWith('px') ? Number.parseFloat(lh) : Number.parseFloat(lh) * size,
68
- tracking: t.letterSpacing?.endsWith('em')
69
- ? Number.parseFloat(t.letterSpacing) * size
70
- : Number.parseFloat(t.letterSpacing ?? '0') || 0,
101
+ lineHeight,
102
+ tracking: cssLength(t.letterSpacing, size, 'px'),
71
103
  };
72
104
  });
73
105
  }
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/dist/screens.d.ts CHANGED
@@ -28,8 +28,8 @@ export interface Screen {
28
28
  */
29
29
  page: string;
30
30
  device: 'mobile' | 'tablet' | 'desktop';
31
- /** Where the version came from: stitch, lab or trial. */
32
- source: 'stitch' | 'lab' | 'trial';
31
+ /** Where the version came from: app snapshot, generated draft or authored design. */
32
+ source: 'stitch' | 'lab' | 'trial' | 'app';
33
33
  /** Short version label: "v3", "Stitch", "Trial 2". */
34
34
  version: string;
35
35
  status: Status;
package/dist/tokens.js CHANGED
@@ -10,7 +10,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { argbFromHex, Hct, hexFromArgb, MaterialDynamicColors, SchemeContent, SchemeExpressive, SchemeFidelity, SchemeFruitSalad, SchemeMonochrome, SchemeNeutral, SchemeRainbow, SchemeTonalSpot, SchemeVibrant, } from '@material/material-color-utilities';
12
12
  import { config as lab } from './config.js';
13
- import { readDesign } from './design-md.js';
13
+ import { cssFontStack, readDesign } from './design-md.js';
14
14
  import { POINTER, writeExports } from './exports.js';
15
15
  import { ROOT } from './screens.js';
16
16
  const SCHEMES = {
@@ -71,7 +71,7 @@ const typeDecls = {};
71
71
  const typeRules = Object.entries(design.typography).map(([name, t]) => {
72
72
  const fallback = `var(--${lab.prefix}-font-${/mono/i.test(t.fontFamily ?? '') ? 'mono' : 'sans'}-fallback)`;
73
73
  const decl = [
74
- `font: ${t.fontWeight} ${t.fontSize}/${t.lineHeight} "${t.fontFamily}", ${fallback}`,
74
+ `font: ${t.fontWeight} ${t.fontSize}/${t.lineHeight} ${cssFontStack(t.fontFamily) || 'sans-serif'}, ${fallback}`,
75
75
  `letter-spacing: ${t.letterSpacing ?? '0em'}`,
76
76
  t.fontFeature ? `font-feature-settings: ${t.fontFeature}` : '',
77
77
  name.endsWith('-caps') ? 'text-transform: uppercase' : '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitch2",
3
- "version": "0.22.7",
3
+ "version": "0.22.9",
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).
@@ -76,6 +76,11 @@ series) is added to DESIGN.md first, with a line in the prose saying where it is
76
76
  a primary-filled button, a pill chip, a header icon button, a caps label). A screen that duplicates a
77
77
  component instead of using it is not handed over: use the component, or extend it (step 4 of the ladder)
78
78
  when it cannot do what the screen needs.
79
+ - The reuse detector reports what is actually drawn: descendants of a closed `<details>`, screen-reader-only or
80
+ clipped nodes, and elements hidden by a transparent ancestor are ignored. This keeps collapsed menus and
81
+ accessibility-only labels from becoming false component errors. A faithful app snapshot marked with
82
+ `<meta name="<prefix>-source" content="app">` is a visual baseline, not a component implementation, so its
83
+ markup is not reuse-checked; make a new lab-authored version before editing the snapshot.
79
84
  - **consistency**: a component whose size, radius, font, icon set or icons differ from the catalog's version
80
85
  (or, without a catalog, from the majority of screens in its family), or that appears in two versions on one
81
86
  screen. `stitch2 consistency` shows the same as cropped images; see the stitch2-consistency skill.
@@ -13,7 +13,7 @@ and build on it. Meta tag names below use the project's prefix (`<p>`, from `sti
13
13
  (`mobile-v3.html` and `desktop-v3.html` are v3). A new version takes the next free N across the screen's
14
14
  variants; a variant added to an existing version takes that version's N (a desktop for v3 is
15
15
  `desktop-v3.html`, never v4). A version may start with one variant and get the others later.
16
- Its head carries `<p>-screen`, `<p>-page` (see Pages), `<p>-device` (mobile | desktop), `<p>-status`, `<p>-source` (stitch | lab),
16
+ Its head carries `<p>-screen`, `<p>-page` (see Pages), `<p>-device` (mobile | desktop), `<p>-status`, `<p>-source` (stitch | lab | app),
17
17
  `<p>-frame-width` (390 mobile, 1280 desktop) and, written by the user, `<p>-note`. Older files elsewhere
18
18
  carry the same tags.
19
19
 
@@ -22,6 +22,12 @@ It is not the document height: long pages still appear whole. In canvas frames a
22
22
  `vh`, `svh`, `dvh` and `lvh` keep this viewport height when the frame grows, so full-height sections
23
23
  do not create ever-growing gaps. Width responsiveness stays active.
24
24
 
25
+ `source=app` means an unchanged capture of a running application's rendered DOM,
26
+ not a hand-authored design. Layout and visual checks still apply; the `reuse`
27
+ rule does not demand replacing its already-rendered framework components with
28
+ lab elements. Keep the import URL as provenance. A new design based on a capture
29
+ uses `source=lab` and the shared component catalog, not the snapshot exception.
30
+
25
31
  ## Pages and their screens
26
32
 
27
33
  A place in the app often has several states or steps: the workout with its number pad, its menu and its discard
@@ -6,6 +6,9 @@ project's DESIGN.md defines the levels (front matter `typography`); `stitch2 tok
6
6
  `.type-<level>` class, `stitch2 check` warns about text that is not one of them, and `stitch2 type` lists every
7
7
  style a screen uses.
8
8
 
9
+ Use CSS lengths for the type scale (`px`, `rem`, `em`); checks normalize them to rendered pixels
10
+ using a 16px root. A font-family list stays a CSS stack, not one quoted family name.
11
+
9
12
  ## 1. A type scale is a short list of levels
10
13
 
11
14
  Google's DESIGN.md format suggests 9–15 levels, each fixing family, size, line height, weight and tracking
@@ -40,6 +40,11 @@ majority of screens in the same family.
40
40
  | Two versions of one component on a screen | Pick the catalog's; if both are needed, they are two variants: add the variant to the component |
41
41
  | The catalog itself is missing the variant a screen needs | Add it to the component and the catalog first, then use it |
42
42
 
43
+ The reuse check only considers what is drawn. Descendants of a closed `<details>`, clipped or screen-reader-only
44
+ nodes, and elements hidden by a transparent ancestor are excluded. Screens imported as faithful app snapshots may
45
+ declare `<meta name="<prefix>-source" content="app">`; those baselines are checked visually, but are not expected to
46
+ contain the lab's custom elements. Create a new lab-authored version before changing one.
47
+
43
48
  Never fix consistency by editing one screen to imitate another by hand, and never edit an approved version:
44
49
  make the next version.
45
50
 
@@ -47,6 +52,8 @@ make the next version.
47
52
 
48
53
  1. Re-run `stitch2 check` and `stitch2 consistency`: the fixed screens show "matches" in the report and no
49
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.
50
57
  2. Tell the user, per component, which screens matched, which you fixed (and how: component used, component
51
58
  extended, new version), and which differ on purpose (drafts you left, with the reason). Point to the report
52
59
  on the canvas so they can see it.