stitch2 0.22.11 → 0.23.0

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/README.md CHANGED
@@ -42,6 +42,8 @@ file that already exists. From there the agent spends its effort on the design.
42
42
  A width switch (`W`) shows every screen at the narrowest or widest window its layout covers.
43
43
  With languages configured, a Language switch (`L`) shows every screen in one of them, and the Languages page
44
44
  shows a screen side by side in each.
45
+ With app states configured (`modes`: signed in or out, a plan, a role), a dropdown for each (`M` cycles the first)
46
+ shows every screen in one of them, live pages and pictures alike, or as drawn.
45
47
  Pick mode (`P`) highlights any element of a screen and copies a reference with its source line, such as
46
48
  `home mobile v1 (design/screens/home/mobile-v1.html:36) <gg-button> “Start Push”`; Shift-click collects
47
49
  several to copy at once or add to the version's note.
@@ -77,6 +77,31 @@
77
77
  "items": { "type": "string" },
78
78
  "description": "The themes every screen must work in, by their data-theme value. Defaults to DESIGN.md's: dark (the plain colours), plus light when it has light-* colours. The canvas switches between them and check runs in each."
79
79
  },
80
+ "modes": {
81
+ "type": "array",
82
+ "description": "App states every screen can be shown in, picked from the canvas sidebar: signed in or out, a plan, a role. The pick is an attribute on each screen's <html>, so the screens' own CSS and scripts answer to it. The dropdown also offers the screens as drawn, which leaves each one as its file has it.",
83
+ "items": {
84
+ "type": "object",
85
+ "additionalProperties": false,
86
+ "required": ["name", "values"],
87
+ "properties": {
88
+ "name": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", "description": "The id: lowercase letters, digits and hyphens. The canvas remembers the pick under it." },
89
+ "label": { "type": "string", "description": "Shown before each value in the dropdown. Defaults to the name, capitalized." },
90
+ "attribute": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", "description": "The attribute on the screen's <html> that carries the value. Defaults to data-<name>." },
91
+ "values": {
92
+ "type": "array",
93
+ "minItems": 1,
94
+ "description": "What the canvas can show every screen as: a value, or a value with its label.",
95
+ "items": {
96
+ "oneOf": [
97
+ { "type": "string" },
98
+ { "type": "object", "additionalProperties": false, "required": ["value"], "properties": { "value": { "type": "string" }, "label": { "type": "string" } } }
99
+ ]
100
+ }
101
+ }
102
+ }
103
+ }
104
+ },
80
105
  "i18n": {
81
106
  "type": "object",
82
107
  "additionalProperties": false,
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/canvas.html CHANGED
@@ -44,6 +44,7 @@
44
44
  /* Theme and language: native dropdowns drawn like the versions and variants ones. */
45
45
  .pick { appearance: none; background: var(--bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%23a1a1aa' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 10px center / 12px 8px; border: 1px solid var(--edge); border-radius: 8px; color: var(--text); cursor: pointer; font: inherit; padding: 5px 30px 5px 8px; width: 100%; }
46
46
  .pick[hidden] { display: none; }
47
+ #modeFilters { display: contents; }
47
48
  #tree { flex: 1; overflow: auto; padding: 6px 8px 16px; }
48
49
  #tree h4 { color: var(--faint); font-size: 11px; letter-spacing: .06em; margin: 14px 6px 6px; text-transform: uppercase; }
49
50
  .item { align-items: center; background: none; border: 0; border-radius: 6px; color: var(--muted); cursor: pointer; display: flex; gap: 8px; padding: 6px 8px; text-align: left; width: 100%; }
@@ -142,6 +143,7 @@
142
143
  <details id="versionFilter" class="multi" hidden title="Show only the versions you pick (several at once), or all of them"><summary>All versions</summary><div class="menu"></div></details>
143
144
  <details id="variantFilter" class="multi" hidden title="Show only the variants you pick: mobile, tablet, desktop (several at once), or all of them"><summary>All variants</summary><div class="menu"></div></details>
144
145
  <select id="themeFilter" class="pick" hidden title="Show every screen in this theme (T)" aria-label="Theme"></select>
146
+ <div id="modeFilters"></div>
145
147
  <select id="widthFilter" class="pick" title="Show every screen at the narrowest or widest window its variant covers, or at one breakpoint (W)" aria-label="Width"></select>
146
148
  <select id="langFilter" class="pick" title="Show every screen in this language (L)" aria-label="Language"></select>
147
149
  </header>
@@ -179,13 +181,20 @@ let designDir = 'design'
179
181
  let THEMES = ['dark']
180
182
  let theme = localStorage.getItem('stitch2-theme') ?? 'dark'
181
183
  const themed = () => THEMES.length > 1
184
+ // Modes: app states (signed in or out, a plan, a role) every screen can be shown in. Each sets an attribute on the
185
+ // screen's document, like the theme; "As drawn" leaves a screen as its file has it. Pictures of the screens that are
186
+ // not live ask the server for the same states (?mode=name:value).
187
+ let MODES = []
188
+ const modePick = {} // mode name -> picked value, '' for as drawn
189
+ const modeValueLabel = (m, v) => (v ? (m.values.find((x) => x.value === v)?.label ?? v) : 'As drawn')
190
+ const modeNotes = () => MODES.filter((m) => modePick[m.name]).map((m) => `${m.label}: ${modeValueLabel(m, modePick[m.name])}`)
182
191
  // Languages: screens reload in the one picked (?lang=…); the source language shows them as written.
183
192
  let LANGS = ['en']
184
193
  let SOURCE = 'en'
185
194
  let lang = localStorage.getItem('stitch2-lang') ?? 'en'
186
195
  const langQuery = () => (lang === SOURCE ? '' : `&lang=${encodeURIComponent(lang)}`)
187
196
  const themeNote = () =>
188
- [themed() && theme !== THEMES[0] ? `in the ${theme} theme` : '', lang !== SOURCE ? `in ${lang}` : '']
197
+ [themed() && theme !== THEMES[0] ? `in the ${theme} theme` : '', lang !== SOURCE ? `in ${lang}` : '', ...modeNotes()]
189
198
  .filter(Boolean)
190
199
  .map((s) => ' ' + s)
191
200
  .join(',')
@@ -245,6 +254,33 @@ function widthOptions() {
245
254
  if (!widthModes().includes(widthMode)) widthMode = 'drawn'
246
255
  wsel.value = widthMode
247
256
  }
257
+ // The document's own value is kept the first time, so "As drawn" puts it back.
258
+ const authored = new WeakMap()
259
+ function applyModes(f) {
260
+ if (!f.iframe) return
261
+ let root
262
+ try { root = f.iframe.contentDocument.documentElement } catch { return }
263
+ if (!root) return
264
+ const was = authored.get(root) ?? authored.set(root, {}).get(root)
265
+ for (const m of MODES) {
266
+ if (!(m.attribute in was)) was[m.attribute] = root.getAttribute(m.attribute)
267
+ const value = modePick[m.name] || was[m.attribute]
268
+ if (value == null) root.removeAttribute(m.attribute)
269
+ else root.setAttribute(m.attribute, value)
270
+ }
271
+ }
272
+ function setMode(name, value) {
273
+ const m = MODES.find((x) => x.name === name)
274
+ if (!m) return
275
+ modePick[name] = m.values.some((x) => x.value === value) ? value : ''
276
+ localStorage.setItem(`stitch2-mode-${name}`, modePick[name])
277
+ const sel = document.querySelector(`#modeFilters [data-mode="${name}"]`)
278
+ if (sel) sel.value = modePick[name]
279
+ for (const f of frames.values()) applyModes(f)
280
+ if (selected) inspector(selected)
281
+ summary()
282
+ scheduleSync()
283
+ }
248
284
  function applyTheme(f) {
249
285
  if (!themed() || !f.iframe) return
250
286
  try { f.iframe.contentDocument.documentElement.dataset.theme = theme } catch {}
@@ -640,6 +676,7 @@ function shotUrl(s) {
640
676
  const q = new URLSearchParams({ path: s.path, width: String(s.width), m: String(Math.round(s.mtime)), r: String(shotRev) })
641
677
  if (themed()) q.set('theme', theme)
642
678
  if (lang !== SOURCE) q.set('lang', lang)
679
+ for (const m of MODES) if (modePick[m.name]) q.append('mode', `${m.name}:${modePick[m.name]}`)
643
680
  return `/api/shot?${q}`
644
681
  }
645
682
  const pinned = (f) => f === selected || f.el.classList.contains('live') || picked.some((p) => p.f === f)
@@ -670,6 +707,7 @@ function mount(f) {
670
707
  void (async () => {
671
708
  if (!(await waitForStyles()) || f.iframe !== iframe) return
672
709
  applyTheme(f)
710
+ applyModes(f)
673
711
  measure(f)
674
712
  clearTimeout(f.reveal)
675
713
  // The picture stays until the complete, styled page has been measured.
@@ -1176,6 +1214,7 @@ addEventListener('keydown', (e) => {
1176
1214
  if (e.key === 'Escape') { closeDoc(); select(null) }
1177
1215
  if (e.key === 'f' || e.key === 'F') fit()
1178
1216
  if ((e.key === 't' || e.key === 'T') && themed()) setTheme(THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length])
1217
+ if ((e.key === 'm' || e.key === 'M') && MODES.length) { const m = MODES[0], all = ['', ...m.values.map((v) => v.value)]; setMode(m.name, all[(all.indexOf(modePick[m.name]) + 1) % all.length]) }
1179
1218
  if (e.key === 'l' || e.key === 'L') setLang(LANGS[(LANGS.indexOf(lang) + 1) % LANGS.length])
1180
1219
  if (e.key === 'w' || e.key === 'W') { const m = widthModes(); setWidth(m[(m.indexOf(widthMode) + 1) % m.length]) }
1181
1220
  if (e.key === '0') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, 1)
@@ -1214,6 +1253,20 @@ fetch('/api/config').then((r) => r.json()).then((c) => {
1214
1253
  pick.innerHTML = THEMES.map((t) => `<option value="${t}">${t[0].toUpperCase()}${t.slice(1)} theme</option>`).join('')
1215
1254
  pick.onchange = () => setTheme(pick.value)
1216
1255
  setTheme(theme)
1256
+ MODES = c.modes ?? []
1257
+ MODES.forEach((m, i) => {
1258
+ const stored = localStorage.getItem(`stitch2-mode-${m.name}`) ?? ''
1259
+ modePick[m.name] = m.values.some((v) => v.value === stored) ? stored : ''
1260
+ const sel = document.createElement('select')
1261
+ sel.className = 'pick'
1262
+ sel.dataset.mode = m.name
1263
+ sel.setAttribute('aria-label', m.label)
1264
+ sel.title = `Show every screen with ${m.label.toLowerCase()} set to one of these, or as drawn${i === 0 ? ' (M)' : ''}`
1265
+ for (const v of ['', ...m.values.map((x) => x.value)]) sel.add(new Option(`${m.label}: ${modeValueLabel(m, v)}`, v))
1266
+ sel.value = modePick[m.name]
1267
+ sel.onchange = () => setMode(m.name, sel.value)
1268
+ document.getElementById('modeFilters').append(sel)
1269
+ })
1217
1270
  WIDTHS = c.widths ?? {}
1218
1271
  BREAKPOINTS = c.breakpoints ?? {}
1219
1272
  const wsel = document.getElementById('widthFilter')
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/config.d.ts CHANGED
@@ -1,3 +1,20 @@
1
+ /**
2
+ * An app state every screen can be shown in, picked from the canvas sidebar: signed in or out, a plan, a role. The pick
3
+ * is an attribute on each screen's <html> (data-auth="user"), so the screens' own CSS and scripts answer to it.
4
+ */
5
+ export interface ModeConfig {
6
+ /** The id: lowercase letters, digits and hyphens. The canvas remembers the pick under it. */
7
+ name: string;
8
+ /** Shown before each value in the dropdown; defaults to the name, capitalized. */
9
+ label?: string;
10
+ /** The attribute on the screen's <html> that carries the value; defaults to data-<name>. */
11
+ attribute?: string;
12
+ /** What the canvas can show every screen as: a value, or a value with its label. "As drawn" is always offered too. */
13
+ values: (string | {
14
+ value: string;
15
+ label?: string;
16
+ })[];
17
+ }
1
18
  export interface LabConfig {
2
19
  /** Shown on the canvas. */
3
20
  name: string;
@@ -41,6 +58,11 @@ export interface LabConfig {
41
58
  skills: string[];
42
59
  /** Paths under designDir a sandbox leaves out besides screens (notes that would give the trial away). */
43
60
  sandboxExclude?: string[];
61
+ /**
62
+ * App states shown across all screens (see ModeConfig). The canvas has a dropdown for each, with the screens as drawn
63
+ * first; picking a value sets its attribute on every screen, live ones and the pictures of the rest.
64
+ */
65
+ modes?: ModeConfig[];
44
66
  /** The live app, for stitch2 compare (see compare.ts): its URL, a sign-in script and a route per screen. */
45
67
  app?: {
46
68
  url: string;
@@ -91,5 +113,21 @@ export declare const breakpoints: () => Record<string, number>;
91
113
  * rule starts (each one is the narrowest window of its layout, where things are most likely to break).
92
114
  */
93
115
  export declare function checkWidths(device: string): number[];
116
+ export interface Mode {
117
+ name: string;
118
+ label: string;
119
+ attribute: string;
120
+ values: {
121
+ value: string;
122
+ label: string;
123
+ }[];
124
+ }
125
+ /** The configured modes, checked: a name or attribute that isn't plain, a repeat, or a mode without values is left out. */
126
+ export declare function modes(): Mode[];
127
+ /**
128
+ * The attributes a request asks for, from its "name:value" pairs; a pair that isn't a configured mode and value is
129
+ * ignored, so an address can only set what the config offers.
130
+ */
131
+ export declare function modeAttributes(pairs: string[]): Record<string, string>;
94
132
  /** The design folder, with a trailing slash. */
95
133
  export declare const ROOT: string;
package/dist/config.js CHANGED
@@ -51,5 +51,43 @@ export function checkWidths(device) {
51
51
  const inside = Object.values(breakpoints()).filter((b) => b > range[0] && b < range[1]);
52
52
  return [...new Set([range[0], ...inside, range[1]])].sort((a, b) => a - b);
53
53
  }
54
+ const MODE_NAME = /^[a-z][a-z0-9-]*$/;
55
+ const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
56
+ /** The configured modes, checked: a name or attribute that isn't plain, a repeat, or a mode without values is left out. */
57
+ export function modes() {
58
+ const out = [];
59
+ for (const m of Array.isArray(config.modes) ? config.modes : []) {
60
+ if (typeof m?.name !== 'string' || !MODE_NAME.test(m.name) || out.some((o) => o.name === m.name))
61
+ continue;
62
+ const attribute = m.attribute ?? `data-${m.name}`;
63
+ if (!MODE_NAME.test(attribute))
64
+ continue;
65
+ const values = [];
66
+ for (const v of Array.isArray(m.values) ? m.values : []) {
67
+ const value = typeof v === 'string' ? v : v?.value;
68
+ if (typeof value !== 'string' || !value || /[\s"'<>=&:]/.test(value) || values.some((x) => x.value === value))
69
+ continue;
70
+ values.push({ value, label: (typeof v === 'object' && v.label) || capitalize(value) });
71
+ }
72
+ if (values.length)
73
+ out.push({ name: m.name, label: m.label ?? capitalize(m.name), attribute, values });
74
+ }
75
+ return out;
76
+ }
77
+ /**
78
+ * The attributes a request asks for, from its "name:value" pairs; a pair that isn't a configured mode and value is
79
+ * ignored, so an address can only set what the config offers.
80
+ */
81
+ export function modeAttributes(pairs) {
82
+ const picked = {};
83
+ for (const pair of pairs) {
84
+ const at = pair.indexOf(':');
85
+ const mode = modes().find((m) => m.name === pair.slice(0, at));
86
+ const value = pair.slice(at + 1);
87
+ if (at > 0 && mode?.values.some((v) => v.value === value))
88
+ picked[mode.attribute] = value;
89
+ }
90
+ return picked;
91
+ }
54
92
  /** The design folder, with a trailing slash. */
55
93
  export const ROOT = join(resolve(BASE, config.designDir), '/');
@@ -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/serve.js CHANGED
@@ -9,7 +9,7 @@ import { existsSync, readdirSync, readFileSync, statSync, watch } from 'node:fs'
9
9
  import { createServer } from 'node:http';
10
10
  import { dirname, extname, join, normalize } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
- import { breakpoints, CONFIG_FILE, checkWidths, config, widthRanges } from './config.js';
12
+ import { breakpoints, CONFIG_FILE, checkWidths, config, modeAttributes, modes, widthRanges } from './config.js';
13
13
  import { designThemes } from './design-md.js';
14
14
  import { allLocales, localeScript, sourceLocale } from './i18n.js';
15
15
  import { designPage } from './design-page.js';
@@ -156,7 +156,7 @@ export function startServer(port) {
156
156
  return;
157
157
  }
158
158
  if (url.pathname === '/api/config')
159
- return json({ ...config, themes: designThemes(), locales: allLocales(), sourceLocale: sourceLocale(), widths: widthRanges(), breakpoints: breakpoints(), checkWidths: Object.fromEntries(Object.keys(widthRanges()).map((d) => [d, checkWidths(d)])) });
159
+ return json({ ...config, modes: modes(), themes: designThemes(), locales: allLocales(), sourceLocale: sourceLocale(), widths: widthRanges(), breakpoints: breakpoints(), checkWidths: Object.fromEntries(Object.keys(widthRanges()).map((d) => [d, checkWidths(d)])) });
160
160
  if (url.pathname === '/api/screens')
161
161
  return json(listScreens());
162
162
  if (url.pathname === '/api/reports')
@@ -169,6 +169,7 @@ export function startServer(port) {
169
169
  width: Math.min(4000, Math.max(200, Number(q.get('width')) || 390)),
170
170
  theme: q.get('theme') || undefined,
171
171
  lang: q.get('lang') || undefined,
172
+ modes: modeAttributes(q.getAll('mode')),
172
173
  });
173
174
  if (!job) {
174
175
  res.writeHead(404, { 'content-type': 'text/plain' });
package/dist/shots.d.ts CHANGED
@@ -3,6 +3,8 @@ export interface ShotRequest {
3
3
  width: number;
4
4
  theme?: string;
5
5
  lang?: string;
6
+ /** Attributes set on the page's <html> before the picture: the app states picked in the canvas (config modes). */
7
+ modes?: Record<string, string>;
6
8
  }
7
9
  /** The server's file watcher calls this: the next picture looks at the shared files again. */
8
10
  export declare function sharedChanged(): void;
package/dist/shots.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * components), so a canvas with a hundred of them stalls the browser; the canvas keeps only the screens in view live
4
4
  * and draws the rest from these. One headless Chromium renders them, three at a time, and they are kept on disk
5
5
  * (os.tmpdir()/stitch2-shots), keyed by everything that changes the picture: the screen file, the files screens
6
- * share (tokens, components, stylesheets), the theme, the language and its messages, and the width.
6
+ * share (tokens, components, stylesheets), the theme, the modes, the language and its messages, and the width.
7
7
  */
8
8
  import { createHash } from 'node:crypto';
9
9
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
@@ -53,6 +53,7 @@ function keyOf(r) {
53
53
  statSync(file).mtimeMs,
54
54
  sharedStamp(),
55
55
  r.theme ?? '',
56
+ JSON.stringify(Object.entries(r.modes ?? {}).sort(([a], [b]) => a.localeCompare(b))),
56
57
  r.lang ?? '',
57
58
  messages && existsSync(messages) ? statSync(messages).mtimeMs : '',
58
59
  r.width,
@@ -98,6 +99,10 @@ export function shot(base, r) {
98
99
  await page.goto(`${base}/${r.path}?${query}`, { waitUntil: 'networkidle', timeout: 30_000 }).catch(() => { });
99
100
  if (r.theme)
100
101
  await page.evaluate((t) => { document.documentElement.dataset.theme = t; }, r.theme);
102
+ const attributes = Object.entries(r.modes ?? {});
103
+ if (attributes.length)
104
+ await page.evaluate((list) => { for (const [name, value] of list)
105
+ document.documentElement.setAttribute(name, value); }, attributes);
101
106
  await page.evaluate(() => document.fonts.ready);
102
107
  // Tailwind's browser build and the components settle after load.
103
108
  await page.waitForTimeout(400);
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.23.0",
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
@@ -171,13 +171,31 @@ doesn't cover it is drawn at its nearest edge and faded, because the app shows a
171
171
  - A desktop screen is a layout for its range, not a stretched phone: a side navigation instead of the bar,
172
172
  list-detail with both panes, extra columns for feeds.
173
173
 
174
+ ## App states
175
+
176
+ When the product looks different signed in or out, on another plan or for another role, do not draw a copy of each
177
+ screen per state. Draw it once, let its CSS and scripts answer to an attribute on `<html>`, and list the states as
178
+ `modes` in `stitch2.config.json`:
179
+
180
+ ```json
181
+ "modes": [
182
+ { "name": "auth", "label": "Account", "values": [{ "value": "guest", "label": "Guest" }, { "value": "user", "label": "Signed in" }] }
183
+ ]
184
+ ```
185
+
186
+ The canvas sidebar then has a dropdown for each mode. A value sets the attribute (`data-auth="user"`; `attribute`
187
+ names another) on every screen, live frames and the pictures of the rest, and "As drawn" puts each screen back to what
188
+ its file says. `m` cycles the first mode. A screen is still written for the state it shows (a signed-in account page has
189
+ `data-auth="user"` in its file); modes show the other states of the same screen. `stitch2 check` runs on the screens as
190
+ drawn, not in each state, so open the other states on the canvas and look at them before hand-over.
191
+
174
192
  ## The canvas
175
193
 
176
194
  `stitch2 canvas` → http://localhost:4400. The sidebar lists DESIGN.md (drawn as tokens and rules), Components
177
195
  (the catalog for the variants the filter shows; a click steps through them), the consistency report and app
178
196
  comparison (each with when it last ran, or how many covered screens changed since), Languages, and every screen
179
- with its versions. Filters: status (active, approved, needs review, all), versions, variants, theme, width and
180
- language. The reports and Languages follow the filters too: they show only the screens the canvas shows. Selecting a version opens the inspector: status, note, checks, and
197
+ with its versions. Filters: status (active, approved, needs review, all), versions, variants, theme, width,
198
+ language and each app state (`modes`). The reports and Languages follow the filters too: they show only the screens the canvas shows. Selecting a version opens the inspector: status, note, checks, and
181
199
  Copy for its reference. Pick mode (`p`) highlights the element under the pointer; a click copies its reference,
182
200
  Shift-click collects several (Copy all, Add to note). Keys: `/` search, `j`/`k` next and previous, `a` approve,
183
- `p` pick, `f` fit, `0` actual size, double-click to use a frame, `Esc` to leave.
201
+ `p` pick, `f` fit, `m` next app state, `0` actual size, double-click to use a frame, `Esc` to leave.
@@ -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