stitch2 0.22.10 → 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 +105 -28
- package/dist/canvas.html +14 -0
- package/dist/colours.js +21 -2
- package/dist/consistency.js +16 -11
- package/dist/states.js +3 -1
- package/dist/typecheck.d.ts +2 -0
- package/dist/typecheck.js +5 -4
- package/package.json +1 -1
- package/skills/stitch2/SKILL.md +24 -0
- package/skills/stitch2/references/components.md +3 -1
- package/skills/stitch2-consistency/SKILL.md +4 -0
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
|
|
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) ||
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
644
|
-
|
|
645
|
-
|
|
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
|
|
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
|
-
|
|
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/canvas.html
CHANGED
|
@@ -385,6 +385,15 @@ const rows = () => {
|
|
|
385
385
|
|
|
386
386
|
// Screens flow left to right and wrap, like a design canvas; each screen's versions stay together.
|
|
387
387
|
function layout() {
|
|
388
|
+
// Late page/thumbnail heights move later rows. Keep the frame being reviewed
|
|
389
|
+
// at its current screen position, not at a fresh zoomTo position. A frame the
|
|
390
|
+
// user has panned away from, or an active drag, must not pull the camera back.
|
|
391
|
+
const anchor = (() => {
|
|
392
|
+
if (!selected || selected.el.style.display === 'none' || viewport.classList.contains('panning')) return null
|
|
393
|
+
const a = selected.el.getBoundingClientRect(), b = viewport.getBoundingClientRect()
|
|
394
|
+
if (a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom) return null
|
|
395
|
+
return { frame: selected, x: parseFloat(selected.el.style.left), y: parseFloat(selected.el.style.top) }
|
|
396
|
+
})()
|
|
388
397
|
for (const n of world.querySelectorAll('.row-label')) n.remove()
|
|
389
398
|
let x = 0, y = 0, lineH = 0, page = ''
|
|
390
399
|
const all = rows()
|
|
@@ -435,6 +444,11 @@ function layout() {
|
|
|
435
444
|
}
|
|
436
445
|
tree()
|
|
437
446
|
sendFilter()
|
|
447
|
+
if (anchor && selected === anchor.frame && selected.el.style.display !== 'none') {
|
|
448
|
+
const dx = (anchor.x - parseFloat(selected.el.style.left)) * view.z
|
|
449
|
+
const dy = (anchor.y - parseFloat(selected.el.style.top)) * view.z
|
|
450
|
+
if (dx || dy) { view.x += dx; view.y += dy; apply(); return }
|
|
451
|
+
}
|
|
438
452
|
scheduleSync()
|
|
439
453
|
}
|
|
440
454
|
|
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)) {
|
package/dist/consistency.js
CHANGED
|
@@ -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
|
-
:
|
|
205
|
-
?
|
|
206
|
-
:
|
|
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.
|
|
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) =>
|
|
332
|
+
if (reference.some((r) => matches(r, mine)))
|
|
326
333
|
continue;
|
|
327
334
|
const ref = reference[0];
|
|
328
|
-
const diffs =
|
|
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.
|
|
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 (
|
|
373
|
+
if (matches(mine, top.sig))
|
|
368
374
|
continue;
|
|
369
|
-
const diffs =
|
|
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')
|
package/dist/typecheck.d.ts
CHANGED
|
@@ -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.
|
|
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",
|
package/skills/stitch2/SKILL.md
CHANGED
|
@@ -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
|
|
@@ -90,6 +111,9 @@ References, read before the first screen:
|
|
|
90
111
|
States sheet (components.md, States).
|
|
91
112
|
6. **Look and critique.** View the render in `<design>/renders/` (crop the first screenful). Score it with the
|
|
92
113
|
rubric in principles.md, fix what fails, repeat 5–6 at most three times.
|
|
114
|
+
On the canvas, select a later row and zoom out and back while earlier pages/thumbnails load. The selected
|
|
115
|
+
visible frame keeps its screen position when row heights settle; this does not recenter a frame you pan
|
|
116
|
+
away from or interrupt a drag. Check the live canvas as well as the saved render before hand-over.
|
|
93
117
|
7. **Hand over.** Set the version's status to `review`, point the user to it on the canvas, and report what
|
|
94
118
|
changed, how you answered their notes, and any rubric item you could not satisfy. The page's empty and loading
|
|
95
119
|
screens go to review with it; for a page without them (a form, a static page), say why. The user approves; only then
|
|
@@ -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).
|
|
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
|
|