stitch2 0.22.11 → 0.22.12

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
@@ -145,15 +145,13 @@ export async function audit(opts) {
145
145
  (c.display.includes('flex') && c.flexDirection.startsWith('column')) ||
146
146
  (c.display.includes('grid') && c.gridTemplateColumns.split(/\s+/).length === 1);
147
147
  };
148
- const disclosureTextFits = (el) => {
149
- if (!el.matches('button[aria-expanded]:not([aria-haspopup])'))
150
- return false;
148
+ const contentTextFits = (el) => {
151
149
  const c = getComputedStyle(el);
152
- if (!['left', 'start'].includes(c.textAlign))
153
- return false;
154
150
  const r = el.getBoundingClientRect();
155
151
  const top = r.top + parseFloat(c.borderTopWidth) + parseFloat(c.paddingTop);
156
152
  const bottom = r.bottom - parseFloat(c.borderBottomWidth) - parseFloat(c.paddingBottom);
153
+ const left = r.left + parseFloat(c.borderLeftWidth) + parseFloat(c.paddingLeft);
154
+ const right = r.right - parseFloat(c.borderRightWidth) - parseFloat(c.paddingRight);
157
155
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
158
156
  let found = false;
159
157
  for (let n = walker.nextNode(); n; n = walker.nextNode()) {
@@ -163,12 +161,23 @@ export async function audit(opts) {
163
161
  range.selectNodeContents(n);
164
162
  for (const q of range.getClientRects()) {
165
163
  found = true;
166
- if (q.top < top - 1 || q.bottom > bottom + 1)
164
+ if (q.top < top - 1 || q.bottom > bottom + 1 || q.left < left - 1 || q.right > right + 1)
167
165
  return false;
168
166
  }
169
167
  }
170
168
  return found;
171
169
  };
170
+ const disclosureTextFits = (el) => el.matches('button[aria-expanded]:not([aria-haspopup])') &&
171
+ ['left', 'start'].includes(getComputedStyle(el).textAlign) && contentTextFits(el);
172
+ const readableContentControl = (el) => {
173
+ if (el.closest('nav, [role=navigation], [role=menu], [role=tablist], [role=toolbar]'))
174
+ return false;
175
+ const c = getComputedStyle(el);
176
+ // Pretty/balanced wrapping explicitly authors a content title, not a
177
+ // compact action label. It still has to fit without clipping or crowding.
178
+ return ['pretty', 'balance'].includes(c.textWrapStyle) &&
179
+ parseFloat(c.lineHeight) >= parseFloat(c.fontSize) * 1.3 && contentTextFits(el);
180
+ };
172
181
  // 1. Nothing past the screen edge, except inside deliberate horizontal scrollers (chip rows).
173
182
  if (document.documentElement.scrollWidth > W + 1)
174
183
  push('error', 'page-overflow', document.body, `The page scrolls sideways (${document.documentElement.scrollWidth}px wide)`);
@@ -177,6 +186,10 @@ export async function audit(opts) {
177
186
  const r = el.getBoundingClientRect();
178
187
  if (r.right <= W + 1 && r.left >= -1)
179
188
  continue;
189
+ // A nested clip (for example a title's ellipsis) must not hide the fact
190
+ // that its whole card belongs to a deliberate horizontal scroller.
191
+ if (visibleRects(el).every((q) => q.right <= 0 || q.left >= W))
192
+ continue;
180
193
  let clip = null;
181
194
  for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
182
195
  const ox = getComputedStyle(p).overflowX;
@@ -227,26 +240,29 @@ export async function audit(opts) {
227
240
  const isLabel = (el) => el.matches('button, a, label, [role=tab], [role=button], [role=switch], th') ||
228
241
  /(^|\s)(chip|badge|pill|tag)|rounded-full/.test(String(el.className ?? ''));
229
242
  for (const el of all) {
230
- if (!isLabel(el) || text(el).length > 40 || el.querySelector('p, div, li, ul, br'))
243
+ if (!isLabel(el) || el.querySelector('p, div, li, ul, br'))
244
+ continue;
245
+ // A tooltip describes its trigger but is not its painted control label,
246
+ // even when hovered. Hidden hints cannot create a wrap or conceal a real
247
+ // wrapped label by pushing its text beyond the short-label length limit.
248
+ const labelNodes = [];
249
+ const textWalker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
250
+ for (let n = textWalker.nextNode(); n; n = textWalker.nextNode())
251
+ if (n.textContent.trim() && n.parentElement && shown(n.parentElement) && !n.parentElement.closest('[role=tooltip]'))
252
+ labelNodes.push(n);
253
+ if (!labelNodes.length || labelNodes.map((n) => n.textContent).join(' ').trim().length > 40)
231
254
  continue;
232
255
  // Prose links flow with their sentence; vertical contents titles and fitted disclosure headings can
233
256
  // also wrap. Horizontal navigation, tabs and ordinary button labels retain their one-line rule.
234
- if (proseLink(el) || verticalNavigationLink(el) || disclosureTextFits(el))
257
+ if (proseLink(el) || verticalNavigationLink(el) || disclosureTextFits(el) || readableContentControl(el))
235
258
  continue;
236
259
  // A control whose text sits in two or more elements (a list row's title and its meta line) is a composite
237
260
  // control, not a label: its lines are laid out on purpose.
238
- const holders = new Set();
239
- const textWalker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
240
- for (let n = textWalker.nextNode(); n; n = textWalker.nextNode())
241
- if (n.textContent.trim() && n.parentElement)
242
- holders.add(n.parentElement);
261
+ const holders = new Set(labelNodes.map((n) => n.parentElement));
243
262
  if (holders.size > 1)
244
263
  continue;
245
264
  // A wrap is one text node breaking across lines; an icon above its label is layout, not a wrap.
246
- const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
247
- for (let n = walker.nextNode(); n; n = walker.nextNode()) {
248
- if (!n.textContent.trim())
249
- continue;
265
+ for (const n of labelNodes) {
250
266
  // A label allowed two lines on purpose (line-clamp-2: a navigation rail's, under its icon) may wrap.
251
267
  if ((parseInt(getComputedStyle(n.parentElement).getPropertyValue('-webkit-line-clamp')) || 0) > 1)
252
268
  continue;
@@ -338,7 +354,7 @@ export async function audit(opts) {
338
354
  const atEdge = edge === 'bottom' ? b.bottom >= H - 4 && b.top > H / 2 : b.top <= 4 && b.bottom < H / 2;
339
355
  if (!atEdge)
340
356
  continue;
341
- const drawn = paintedParts(bar).flatMap(visibleRects);
357
+ const drawn = paintedParts(bar).flatMap((part) => visibleRects(part));
342
358
  for (const el of content) {
343
359
  if (el.contains(bar))
344
360
  continue;
@@ -354,7 +370,7 @@ export async function audit(opts) {
354
370
  // 3b. Fixed bars never overlap one another, including what sticks out of them (a raised tab-bar button
355
371
  // reaching into a docked timer). Compares the painted parts: backgrounds, borders, text, icons, controls.
356
372
  const fixedBars = outer.filter((b) => getComputedStyle(b).position === 'fixed' && b.getBoundingClientRect().height < window.innerHeight * 0.8);
357
- const parts = (bar) => paintedParts(bar).flatMap(visibleRects);
373
+ const parts = (bar) => paintedParts(bar).flatMap((part) => visibleRects(part));
358
374
  for (const [i, a] of fixedBars.entries())
359
375
  for (const b of fixedBars.slice(i + 1)) {
360
376
  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));
@@ -385,12 +401,58 @@ export async function audit(opts) {
385
401
  return e;
386
402
  return null;
387
403
  };
404
+ const tapRect = (el) => {
405
+ if (el instanceof HTMLInputElement && /^(checkbox|radio)$/.test(el.type))
406
+ for (const label of el.labels ?? []) {
407
+ if (!shown(label) || label.control !== el || getComputedStyle(label).pointerEvents === 'none')
408
+ continue;
409
+ const sized = (box) => box.right - box.left >= opts.comfortable && box.bottom - box.top >= opts.comfortable;
410
+ let r = visibleRects(label).find(sized);
411
+ // A row at a scrollport edge can be fully revealed even inside an
412
+ // overflow-hidden dialog frame. Prove that by scrolling, measuring the
413
+ // actually exposed area, then restoring every scroll position.
414
+ if (!r) {
415
+ const original = label.getBoundingClientRect();
416
+ const scrolls = [];
417
+ for (let p = label.parentElement; p; p = p.parentElement) {
418
+ const c = getComputedStyle(p);
419
+ if ((/auto|scroll/.test(c.overflowX) && p.scrollWidth > p.clientWidth) || (/auto|scroll/.test(c.overflowY) && p.scrollHeight > p.clientHeight))
420
+ scrolls.push({ el: p, left: p.scrollLeft, top: p.scrollTop });
421
+ }
422
+ if (scrolls.length && original.width >= opts.comfortable && original.height >= opts.comfortable) {
423
+ const before = { x: scrollX, y: scrollY };
424
+ try {
425
+ label.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' });
426
+ const revealed = visibleRects(label).find(sized);
427
+ if (revealed)
428
+ r = { left: original.left, top: original.top, right: original.left + revealed.right - revealed.left, bottom: original.top + revealed.bottom - revealed.top };
429
+ }
430
+ finally {
431
+ for (const s of scrolls) {
432
+ s.el.scrollLeft = s.left;
433
+ s.el.scrollTop = s.top;
434
+ }
435
+ window.scrollTo({ left: before.x, top: before.y, behavior: 'instant' });
436
+ }
437
+ }
438
+ }
439
+ // Associated native labels are part of the hit area, not checkbox artwork.
440
+ if (r)
441
+ return { left: r.left, top: r.top, right: r.right, bottom: r.bottom, width: r.right - r.left, height: r.bottom - r.top };
442
+ }
443
+ return el.getBoundingClientRect();
444
+ };
388
445
  for (const el of interactive) {
389
446
  if (interactive.some((o) => o !== el && o.contains(el)))
390
447
  continue;
391
448
  if (proseLink(el))
392
449
  continue;
393
- const r = el.getBoundingClientRect();
450
+ // Scrolling containers can fully clip an otherwise laid-out control. It
451
+ // has no current hit area to measure; being below the window alone does
452
+ // not clip it, and the control is checked after scrolling it into view.
453
+ if (!visibleRects(el).length)
454
+ continue;
455
+ const r = tapRect(el);
394
456
  const min = Math.min(r.width, r.height);
395
457
  // WCAG 2.2 spacing exception: a smaller target passes when a 24px circle on it touches no other target.
396
458
  const cx = r.left + r.width / 2;
@@ -398,7 +460,7 @@ export async function audit(opts) {
398
460
  const crowded = interactive.some((o) => {
399
461
  if (o === el || o.contains(el) || el.contains(o) || layerOf(o) !== layerOf(el))
400
462
  return false;
401
- const q = o.getBoundingClientRect();
463
+ const q = tapRect(o);
402
464
  const dx = Math.max(q.left - cx, 0, cx - q.right);
403
465
  const dy = Math.max(q.top - cy, 0, cy - q.bottom);
404
466
  return Math.hypot(dx, dy) < 12;
@@ -478,7 +540,7 @@ export async function audit(opts) {
478
540
  return q.right <= left + 1 || q.left >= right - 1;
479
541
  };
480
542
  // Behind an open modal (found above) only the modal's own controls count.
481
- const solid = interactive.filter((el) => opacity(el) > 0.05 && (!modal || modal.contains(el)));
543
+ const solid = interactive.filter((el) => opacity(el) > 0.05 && visibleRects(el).length > 0 && (!modal || modal.contains(el)));
482
544
  const emptyOverlays = new Set(solid.filter(isEmptyOverlay));
483
545
  const controlRects = new Map(solid.map((el) => [el, visibleRects(el)]));
484
546
  for (const [i, a] of solid.entries())
@@ -640,9 +702,16 @@ export async function audit(opts) {
640
702
  // 6. Google's guidelines (Material 3, Android's app quality guidelines, web.dev, Lighthouse). Warnings: each names
641
703
  // the rule it comes from, so a screen can break one on purpose (see skills/stitch2/references/google.md).
642
704
  const textRects = (el) => {
643
- const range = document.createRange();
644
- range.selectNodeContents(el);
645
- return [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 1);
705
+ const rects = [];
706
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
707
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
708
+ if (!node.textContent.trim() || !node.parentElement || !shown(node.parentElement) || opacity(node.parentElement) < 0.1)
709
+ continue;
710
+ const range = document.createRange();
711
+ range.selectNodeContents(node);
712
+ rects.push(...range.getClientRects());
713
+ }
714
+ return rects.filter((r) => r.width > 1 && r.height > 1);
646
715
  };
647
716
  const scrollsSideways = (el) => {
648
717
  for (let e = el.parentElement; e && e !== document.body; e = e.parentElement) {
@@ -682,7 +751,9 @@ export async function audit(opts) {
682
751
  continue;
683
752
  const lineChars = [];
684
753
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
685
- for (let t = walker.nextNode(); t; t = walker.nextNode())
754
+ for (let t = walker.nextNode(); t; t = walker.nextNode()) {
755
+ if (!t.parentElement || !shown(t.parentElement) || opacity(t.parentElement) < 0.1)
756
+ continue;
686
757
  for (const m of t.textContent.matchAll(/\S+\s*/g)) {
687
758
  const range = document.createRange();
688
759
  range.setStart(t, m.index);
@@ -694,6 +765,7 @@ export async function audit(opts) {
694
765
  else
695
766
  lineChars.push({ top, n: m[0].replace(/\s+/g, ' ').length });
696
767
  }
768
+ }
697
769
  if (lineChars.length < 2)
698
770
  continue;
699
771
  const longest = Math.max(...lineChars.sort((a, b) => a.top - b.top).slice(0, -1).map((l) => l.n));
@@ -708,7 +780,7 @@ export async function audit(opts) {
708
780
  for (const el of solid) {
709
781
  if (solid.some((o) => o !== el && o.contains(el)) || proseLink(el))
710
782
  continue;
711
- const r = el.getBoundingClientRect();
783
+ const r = tapRect(el);
712
784
  if (r.width >= 48 && r.height >= 48)
713
785
  continue;
714
786
  if (out.some((i) => i.type === 'tap-target' && i.selector === sel(el)))
@@ -718,7 +790,12 @@ export async function audit(opts) {
718
790
  const hit = solid.find((o) => {
719
791
  if (o === el || o.contains(el) || el.contains(o) || layer(o) !== layer(el))
720
792
  return false;
721
- const q = o.getBoundingClientRect();
793
+ // A compound input's clear/reveal/list button occupies reserved padding,
794
+ // not a neighbouring target. The same geometry as overlap checks keeps
795
+ // insufficient padding and unrelated adjacent controls checked.
796
+ if (reservedInputAction(el, o) || reservedInputAction(o, el) || emptyOverlay(el, o) || emptyOverlay(o, el))
797
+ return false;
798
+ const q = tapRect(o);
722
799
  const ox = Math.max(0, Math.min(cx + 24, q.right) - Math.max(cx - 24, q.left));
723
800
  const oy = Math.max(0, Math.min(cy + 24, q.bottom) - Math.max(cy - 24, q.top));
724
801
  return ox * oy > 48;
package/dist/colours.js CHANGED
@@ -9,15 +9,31 @@ import { existsSync, readFileSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
  import { config } from './config.js';
11
11
  import { familyOf } from './consistency.js';
12
- import { ROOT } from './screens.js';
12
+ import { ROOT, meta, tag } from './screens.js';
13
13
  const LITERAL = /(?<![\w&/=-])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b|(?<![a-zA-Z])(?:rgba?|hsla?)\(\s*[\d.][^)]*\)/g;
14
14
  /** Lines with a literal colour, comments blanked out (keeping line numbers). */
15
15
  function literals(source, js) {
16
16
  let s = source.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ' '));
17
17
  if (js)
18
18
  s = s.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')).replace(/^\s*\/\/.*$/gm, '');
19
- else
19
+ else {
20
20
  s = s.replace(/<head[\s>][\s\S]*?<\/head>/i, (m) => m.replace(/[^\n]/g, ' '));
21
+ // Code examples and syntax highlighting are content. Keep the outer tag's
22
+ // styles so an actual code-container background still follows the theme.
23
+ s = s.replace(/(<(pre|code)\b[^>]*>)([\s\S]*?)(<\/\2\s*>)/gi, (_, open, _tag, body, close) => open + body.replace(/[^\n]/g, ' ') + close);
24
+ // SVG drawing colours belong to an asset; the outer SVG's class/style
25
+ // still follows UI tokens (for example a muted search icon).
26
+ s = s.replace(/(<svg\b[^>]*>)([\s\S]*?)(<\/svg\s*>)/gi, (_, open, body, close) => open + body.replace(/[^\n]/g, ' ') + close);
27
+ // Documentation can describe a literal colour without painting it. Only
28
+ // markup attributes are authored styling, not prose or example values.
29
+ const tags = [...s.matchAll(/<(?:[^>"']|"[^"]*"|'[^']*')*>/g)];
30
+ let styled = s.replace(/[^\n]/g, ' ');
31
+ for (const match of tags) {
32
+ const tag = match[0].replace(/\s(?:data-[\w-]+|on[\w]+)\s*=\s*("[^"]*"|'[^']*')/gi, (attribute) => attribute.replace(/[^\n]/g, ' '));
33
+ styled = styled.slice(0, match.index) + tag + styled.slice(match.index + match[0].length);
34
+ }
35
+ s = styled;
36
+ }
21
37
  const out = [];
22
38
  s.split('\n').forEach((text, i) => {
23
39
  for (const m of text.matchAll(LITERAL)) {
@@ -34,6 +50,9 @@ export function colourIssues(path) {
34
50
  if (familyOf(path) !== 'screens')
35
51
  return [];
36
52
  const html = readFileSync(join(ROOT, path), 'utf8');
53
+ // Imported captures document the app as it is, not an editable token source.
54
+ if (meta(html, tag('source')) === 'app')
55
+ return [];
37
56
  const found = literals(html, false).map((l) => ({ ...l, file: `${config.designDir}/${path}` }));
38
57
  if (path.startsWith(`${config.catalogDir}/`))
39
58
  for (const [, src] of html.matchAll(/<script[^>]*\bsrc="\/([^"]+\.js)"/g)) {
@@ -201,9 +201,11 @@ export function collectComponents(opts) {
201
201
  const variant = el.getAttribute('data-variant');
202
202
  const kind = g === 'button'
203
203
  ? `button:${variant ?? 'primary'}:${el.getAttribute('data-size') ?? 'md'}`
204
- : variant
205
- ? `${g}:${variant}`
206
- : g;
204
+ : g === 'card'
205
+ ? `card:${variant ?? 'default'}:${el.getAttribute('data-layout') ?? 'default'}:${el.getAttribute('data-size') ?? 'default'}`
206
+ : variant
207
+ ? `${g}:${variant}`
208
+ : g;
207
209
  if (!(g === 'button' && el.hasAttribute('disabled')))
208
210
  add(kind, el);
209
211
  }
@@ -293,6 +295,11 @@ const NAMES = {
293
295
  };
294
296
  export const nameOf = (kind) => NAMES[kind]?.[0] ?? kind.replace(/:/g, ' ');
295
297
  const key = (s) => JSON.stringify(s.fields);
298
+ // An icon/thumb-only control has no painted typography to compare with a
299
+ // labelled example. Its geometry still matches, and two actual fonts must match.
300
+ const differences = (a, b) => Object.keys({ ...a.fields, ...b.fields })
301
+ .filter((f) => !(f === 'font' && (!a.fields.font || !b.fields.font)) && a.fields[f] !== b.fields[f]);
302
+ const matches = (a, b) => differences(a, b).length === 0;
296
303
  /** Issues for the screens in `paths`, comparing with every screen whose components are known. */
297
304
  export function consistencyIssues(known, paths) {
298
305
  const out = {};
@@ -306,7 +313,7 @@ export function consistencyIssues(known, paths) {
306
313
  const peers = (families.get(familyOf(path)) ?? []).filter((p) => p !== path);
307
314
  for (const [kind, sigs] of Object.entries(known[path] ?? {})) {
308
315
  // The catalog shows every variant of a component side by side; a screen shows one.
309
- if (sigs.length > 1 && MEASURED.has(kind.split(':')[0]) && !catalogs.includes(path))
316
+ if (sigs.some((a, i) => sigs.slice(i + 1).some((b) => !matches(a, b))) && MEASURED.has(kind.split(':')[0]) && !catalogs.includes(path))
310
317
  issues.push({
311
318
  severity: 'warning',
312
319
  type: 'consistency',
@@ -322,11 +329,10 @@ export function consistencyIssues(known, paths) {
322
329
  continue;
323
330
  if (reference.length) {
324
331
  const mine = sigs[0];
325
- if (reference.some((r) => key(r) === key(mine)))
332
+ if (reference.some((r) => matches(r, mine)))
326
333
  continue;
327
334
  const ref = reference[0];
328
- const diffs = Object.keys({ ...mine.fields, ...ref.fields })
329
- .filter((f) => mine.fields[f] !== ref.fields[f])
335
+ const diffs = differences(mine, ref)
330
336
  .map((f) => `${f} ${mine.fields[f] ?? '–'} (catalog ${ref.fields[f] ?? '–'})`);
331
337
  issues.push({
332
338
  severity: 'warning',
@@ -354,7 +360,7 @@ export function consistencyIssues(known, paths) {
354
360
  continue;
355
361
  // No majority: report when this screen matches none of its peers either.
356
362
  if (top.n < 2 || ranked[1]?.n === top.n) {
357
- if (!votes.has(key(mine)) && peers.filter((p) => known[p]?.[kind]).length >= 2)
363
+ if (![...votes.values()].some((v) => matches(mine, v.sig)) && peers.filter((p) => known[p]?.[kind]).length >= 2)
358
364
  issues.push({
359
365
  severity: 'warning',
360
366
  type: 'consistency',
@@ -364,10 +370,9 @@ export function consistencyIssues(known, paths) {
364
370
  });
365
371
  continue;
366
372
  }
367
- if (key(mine) === key(top.sig))
373
+ if (matches(mine, top.sig))
368
374
  continue;
369
- const diffs = Object.keys({ ...mine.fields, ...top.sig.fields })
370
- .filter((f) => mine.fields[f] !== top.sig.fields[f])
375
+ const diffs = differences(mine, top.sig)
371
376
  .map((f) => `${f} ${mine.fields[f] ?? '–'} (others ${top.sig.fields[f] ?? '–'})`);
372
377
  issues.push({
373
378
  severity: 'warning',
package/dist/states.js CHANGED
@@ -140,7 +140,7 @@ export async function catalogStates(page, path, opts) {
140
140
  const noHover = looks.hover?.look === looks.rest?.look;
141
141
  // Pressed is forced on top of hover: a press that adds nothing to hover shows nothing on a touch screen.
142
142
  const noPress = looks.pressed?.look === looks.hover?.look;
143
- if (!c.field && !c.chosen) {
143
+ if (c.clickable && !c.field && !c.chosen) {
144
144
  if (noHover && noPress)
145
145
  add('state-feedback', who + ': neither hovering nor pressing changes it, so a pointer or a tap gives no feedback (DESIGN.md, hover and pressed)');
146
146
  else if (noHover)
@@ -148,6 +148,8 @@ export async function catalogStates(page, path, opts) {
148
148
  else if (noPress)
149
149
  add('state-pressed', who + ': pressing adds nothing to hover, so a tap gives no feedback (DESIGN.md, pressed)');
150
150
  }
151
+ if (!c.clickable && !c.field && looks.focus?.look === looks.rest?.look)
152
+ add('state-focus', who + ': this keyboard-focusable region needs a visible focus indicator, not hover or pressed feedback');
151
153
  // The hand says "this does something" before the click; the arrow over a button reads as plain text.
152
154
  const cursor = looks.hover?.cursor;
153
155
  if (c.clickable && cursor && cursor !== 'pointer')
@@ -31,6 +31,8 @@ export interface TextItem {
31
31
  upper: boolean;
32
32
  numeric: boolean;
33
33
  lines: number;
34
+ /** Literal code uses its language's case and monospace conventions, not UI labels. */
35
+ code?: boolean;
34
36
  }
35
37
  /** Runs in the page. Plain JS on purpose: Playwright sends its source to the browser. */
36
38
  export declare function collectText(): TextItem[];
package/dist/typecheck.js CHANGED
@@ -66,9 +66,10 @@ export function collectText() {
66
66
  color: `#${hex(m[0])}${hex(m[1])}${hex(m[2])}${alpha < 0.99 ? ` ${Math.round(alpha * 100)}%` : ''}`,
67
67
  // Set in capitals: uppercased by CSS, or typed in capitals (6+ letters, so "RPE" and "PR" don't count).
68
68
  upper: cs.textTransform === 'uppercase' ||
69
- (own === own.toUpperCase() && own.replace(/[^A-Z]/g, '').length >= 6),
69
+ (!el.closest('td, dd, output') && own === own.toUpperCase() && own.replace(/[^A-Z]/g, '').length >= 6),
70
70
  numeric: /^[\d\s.,:×x+\-−–/%]+[a-z]{0,4}$/i.test(own) && /\d/.test(own),
71
71
  lines,
72
+ code: Boolean(el.closest('pre, code')),
72
73
  });
73
74
  }
74
75
  return out;
@@ -97,7 +98,7 @@ export function typeIssues(levels, items) {
97
98
  out.push({ severity, type, message, selector: t.selector, text: t.text });
98
99
  };
99
100
  for (const t of items) {
100
- if (levels.length && !matchLevel(levels, t)) {
101
+ if (!t.code && levels.length && !matchLevel(levels, t)) {
101
102
  const key = describe(t);
102
103
  if (!seen.has(key)) {
103
104
  seen.add(key);
@@ -106,9 +107,9 @@ export function typeIssues(levels, items) {
106
107
  }
107
108
  if (t.lines > 1 && t.lineHeight < t.size * 1.15)
108
109
  push('error', 'type-leading', t, `Wrapped text with cramped line height (${Math.round(t.lineHeight)}px for ${t.size}px type)`);
109
- if (t.upper && t.text.length > 2 && t.tracking < 0.03 * t.size)
110
+ if (!t.code && t.upper && t.text.length > 2 && t.tracking < 0.03 * t.size)
110
111
  push('warning', 'type-caps', t, 'Capitals without letter-spacing (use label-caps, +0.06em)');
111
- if (t.mono && t.size >= 18 && /\d[.,:]\d/.test(t.text))
112
+ if (!t.code && t.mono && t.size >= 18 && /\d[.,:]\d/.test(t.text))
112
113
  push('warning', 'type-mono-number', t, 'Large number in a monospace font: its point, comma or colon gets a full-width gap; use Geist with tabular figures');
113
114
  }
114
115
  return out;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitch2",
3
- "version": "0.22.11",
3
+ "version": "0.22.12",
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",
@@ -57,6 +57,14 @@ References, read before the first screen:
57
57
  fixed bars, fixed bars overlapping each other, tap targets, contrast, cramped text), a hand-built copy of a
58
58
  catalog component (`reuse`: use the component or extend it, never rebuild it), and every warning about type
59
59
  (`type-*`), components that differ from the catalog (`consistency`) and colour values (`color`).
60
+ Native checkbox and radio targets include their visible, associated label; the glyph need not be 44px.
61
+ Hidden or noninteractive labels do not enlarge a hit area. Offscreen content in a deliberate horizontal
62
+ rail is not viewport clipping, while a partially visible cut label still needs correction.
63
+ Literal `<pre>`/`<code>` content keeps its language's case and syntax colours, not UI-label typography.
64
+ Native table-data, definition values and output values keep their supplied case too. Explicit CSS uppercase
65
+ and column headings still receive the caps-label tracking check.
66
+ The code container's own authored colours still follow DESIGN.md. Unchanged `source=app` captures are
67
+ not token-migration targets.
60
68
  Text running into other text (`text-overlap`) or out of its card (`overflow`) is an error; a short label cut
61
69
  short with an ellipsis (`truncated`) is a warning to fix with room, not a smaller size. Google's rules are
62
70
  warnings too (`text-small`, `edge-margin`, `measure`, `tap-spacing`, `placeholder-label`, `unnamed-control`,
@@ -78,11 +86,24 @@ References, read before the first screen:
78
86
  An empty, transparent stretched row/card action may sit under a secondary link; painted controls or
79
87
  partially colliding neighbours still fail. Do not silence an overlap by hiding content or removing its
80
88
  accessible label: inspect the visible state and fix actual collisions in the shared component.
89
+ Content-title controls may explicitly use `text-wrap: pretty` or `balance` when their full text fits
90
+ inside the control with readable line height. This does not exempt navigation, menus, tabs or toolbars,
91
+ clipped titles, or ordinary compact action labels. Do not shrink or truncate content to force one line.
81
92
  Inline links within a prose sentence may wrap and receive WCAG's inline-target exception, including
82
93
  sentences inside list items; standalone links do not. Titles in vertical contents/menu lists may wrap,
83
94
  unlike horizontal navigation labels. An in-page disclosure (`aria-expanded`, without `aria-haspopup`)
84
95
  may have a multiline heading when its height and padding fit the complete text. A text input's clear,
85
96
  reveal or list-toggle button may overlap only padding reserved for it, never the editable text area.
97
+ Such a geometrically contained action is one compound input, not a close neighbour for `tap-spacing`;
98
+ a button intruding into editable text or an unrelated adjacent control still fails the checks.
99
+ A transparent stretched row action below its own secondary link is not a competing touch target when
100
+ browser hit-testing proves that link is reachable; painted, blocking or merely adjacent controls still fail.
101
+ Fully clipped controls inside scrolling containers have no painted hit area until scrolled into view;
102
+ this does not exempt visible small controls or content merely below the window.
103
+ A partly scroll-clipped native choice label retains its full hit-area size only when the scrollport can
104
+ reveal the minimum target. Permanent clipping and scrollports too small to reveal it still fail.
105
+ Tooltip copy is reading text, not its trigger's compact label: it may wrap when visible and does not
106
+ create or hide a `label-wraps` issue on the trigger. The actual painted control label is still checked.
86
107
  Cached checks are invalidated when shared components, the catalog or the checker change. A filtered
87
108
  rerun refreshes only those screens; it must not make unrelated screens' older checks appear current.
88
109
  Motion warnings: `motion-token` (a transition length DESIGN.md doesn't name) and `reduced-motion` (something
@@ -45,7 +45,9 @@ clickable element that is not a button or a link gets `role="button"` (and a key
45
45
  it the hand. Override the cursor only with a reason (`cursor-grab` on a drag handle).
46
46
 
47
47
  The catalog shows each component in each of its **data states** (selected, done, active, disabled, error, with
48
- and without an icon). Hover, focus and pressed are not drawn by hand: `stitch2 check` on the catalog forces them on
48
+ and without an icon). Keyboard-focusable reading or scrolling regions are not click actions and need no hover
49
+ or pressed feedback, but must show a visible focus indicator (`state-focus`). Actual actions retain those checks.
50
+ Hover, focus and pressed are not drawn by hand: `stitch2 check` on the catalog forces them on
49
51
  every control and writes the **States** sheet (canvas sidebar), each control at rest, hovered, focused and
50
52
  pressed side by side. It reports a control that gives no feedback (**state-feedback**, **state-hover**,
51
53
  **state-pressed**: pressing adds nothing to hover, so a tap shows nothing) and text that loses contrast in a state
@@ -6,6 +6,8 @@ description: Verify that shared elements (tab bar, header, buttons, chips, icon
6
6
  # Verifying design consistency with stitch2
7
7
 
8
8
  A shared element must be the same everywhere: same size, radius, type, colour, icon set, icons and states.
9
+ Declared card layout and size variants are compared separately: a horizontal row and a media showcase
10
+ have different geometry by design. Each variant still has to match its own catalog reference or peers.
9
11
  stitch2 measures this; your job is to read the evidence, decide what is wrong, and fix it at the source. The
10
12
  reference is the **component catalog** (the screen in the config's `catalogDir`); without a catalog, the
11
13
  majority of screens in the same family.
@@ -28,6 +30,8 @@ majority of screens in the same family.
28
30
  - `reuse`: a lab screen built a catalog component by hand instead of using it.
29
31
  4. Look at the crops yourself. Numbers say what differs; the images say whether it matters. A 1 px height
30
32
  difference from a border is noise; another icon set, label size or button shape is not.
33
+ Font comparisons apply when both controls paint a label. An icon/thumb-only control has no label font;
34
+ its geometry remains checked, and genuine font differences between labelled controls still fail.
31
35
 
32
36
  ## 2. Decide, per difference
33
37