stitch2 0.14.0 → 0.15.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
@@ -37,6 +37,7 @@ file that already exists. From there the agent spends its effort on the design.
37
37
  and status filters, your DESIGN.md drawn as tokens and rules, the component catalog, and an inspector to
38
38
  approve, archive or leave a note on a version. Live reload; copyable references to paste to an agent.
39
39
  With a light and a dark theme (DESIGN.md's `light-*` colours), a switch (`T`) shows every screen in either one.
40
+ A width switch (`W`) shows every screen at the narrowest or widest window its layout covers.
40
41
  With languages configured, a Language switch (`L`) shows every screen in one of them, and the Languages page
41
42
  shows a screen side by side in each.
42
43
  Pick mode (`P`) highlights any element of a screen and copies a reference with its source line, such as
@@ -44,7 +45,8 @@ file that already exists. From there the agent spends its effort on the design.
44
45
  several to copy at once or add to the version's note.
45
46
  - **Checks** (`stitch2 check`): content past the frame edge or out of its card, labels that wrap, text running into
46
47
  other text, labels cut short with an ellipsis, content under fixed bars, fixed bars that overlap each other, tap
47
- targets (WCAG 2.2), text contrast in every theme, text that is not one of DESIGN.md's type levels, capitals without tracking,
48
+ targets (WCAG 2.2), all of it again at both ends of the window widths each layout covers (360 and 599 px for a
49
+ phone, 1024 and 1920 for desktop, or your own ranges), text contrast in every theme, text that is not one of DESIGN.md's type levels, capitals without tracking,
48
50
  large numbers in monospace, components built by hand, components that differ between screens, and colours
49
51
  written as values instead of DESIGN.md tokens. Google's guidelines (Material 3, Android's app quality
50
52
  guidelines, web.dev, Lighthouse) add warnings for text under 11 px, text closer than 16 px (24 px from 600 px)
@@ -153,7 +155,10 @@ what differs in structure: shared components the design has and the app lacks (o
153
155
  order, and how the measured ones look. Mock-ups and the app hold different data, so the screenshots are shown side
154
156
  by side in `design/compare/index.html` (the canvas links it) rather than diffed. It also runs check's layout
155
157
  rules on the app (content past the edge, fixed bars or controls overlapping), which catches a control the app
156
- squeezed in although no screen drew it. The `stitch2-app-sync` skill describes the workflow around it: a visible
158
+ squeezed in although no screen drew it. Each version is then compared again at both edges of its variant’s width
159
+ range (`widths` in the config), so a layout that holds at 390 px but breaks at 1023 px shows up as "at 1023px: …".
160
+ Compare and the consistency report run only when you call them; the canvas sidebar shows when each last ran and,
161
+ once screens it covers have changed since, how many (hover for the list). The `stitch2-app-sync` skill describes the workflow around it: a visible
157
162
  change goes component, canvas, approval, then app. Configure it in the config:
158
163
 
159
164
  ```json
@@ -62,6 +62,11 @@
62
62
  "description": "Tap targets smaller than this (px) get a warning.",
63
63
  "default": 32
64
64
  },
65
+ "widths": {
66
+ "type": "object",
67
+ "description": "The window widths each variant covers, [narrowest, widest] in px, by device (mobile, tablet, desktop). check renders every variant at both ends; the canvas shows them. Defaults: mobile 360–599, tablet 600–1023, desktop 1024–1920.",
68
+ "additionalProperties": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }
69
+ },
65
70
  "themes": {
66
71
  "type": "array",
67
72
  "items": { "type": "string" },
package/dist/canvas.html CHANGED
@@ -51,6 +51,7 @@
51
51
  .item.on { background: #2a1a12; color: var(--text); }
52
52
  .item .meta { color: var(--faint); font-size: 11px; margin-left: auto; white-space: nowrap; }
53
53
  .item.variant { padding-left: 30px; }
54
+ .item.stale .meta { color: var(--warn); }
54
55
  #inspector .version-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; }
55
56
  #inspector .version-actions span { color: var(--muted); }
56
57
  #inspector .version-actions button { background: var(--panel2); border: 1px solid var(--edge); border-radius: 8px; cursor: pointer; padding: 3px 8px; }
@@ -129,6 +130,7 @@
129
130
  <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>
130
131
  <details id="variantFilter" class="multi" hidden title="Show only the variants you pick: mobile, desktop (several at once), or all of them"><summary>All variants</summary><div class="menu"></div></details>
131
132
  <select id="themeFilter" class="pick" hidden title="Show every screen in this theme (T)" aria-label="Theme"></select>
133
+ <select id="widthFilter" class="pick" title="Show every screen at the narrowest or widest window its variant covers (W)" aria-label="Width"></select>
132
134
  <select id="langFilter" class="pick" title="Show every screen in this language (L)" aria-label="Language"></select>
133
135
  </header>
134
136
  <nav id="tree"></nav>
@@ -186,6 +188,34 @@ function setLang(l) {
186
188
  if (selected) inspector(selected)
187
189
  summary()
188
190
  }
191
+ // Widths: each variant covers a range of windows (config widths); frames show it drawn, at the narrowest or the widest.
192
+ let WIDTHS = {}
193
+ let widthMode = localStorage.getItem('stitch2-width') ?? 'drawn'
194
+ const rangeOf = (s) => WIDTHS[s.device]
195
+ const widthFor = (s) => { const r = rangeOf(s); return !r || widthMode === 'drawn' ? s.drawn : widthMode === 'narrow' ? r[0] : r[1] }
196
+ const bigFrame = (s) => (s.drawn ?? s.width) > 700
197
+ function setWidth(mode) {
198
+ widthMode = mode
199
+ localStorage.setItem('stitch2-width', mode)
200
+ document.getElementById('widthFilter').value = mode
201
+ for (const f of frames.values()) {
202
+ f.screen.width = widthFor(f.screen)
203
+ f.el.style.width = `${f.screen.width}px`
204
+ f.iframe.style.width = `${f.screen.width}px`
205
+ setTimeout(() => measure(f), 300)
206
+ setTimeout(() => measure(f), 1200)
207
+ }
208
+ layout()
209
+ if (selected) inspector(selected)
210
+ }
211
+ /** The width dropdown names the edges of the devices the screens use: "Narrowest: mobile 360, desktop 1024". */
212
+ function widthOptions() {
213
+ const used = [...new Set(screens.map((s) => s.device))].filter((d) => WIDTHS[d]).sort((a, b) => WIDTHS[a][0] - WIDTHS[b][0])
214
+ const edge = (k) => used.map((d) => `${d} ${WIDTHS[d][k]}`).join(', ')
215
+ const wsel = document.getElementById('widthFilter')
216
+ wsel.innerHTML = `<option value="drawn">Drawn width</option><option value="narrow">Narrowest: ${edge(0)}</option><option value="wide">Widest: ${edge(1)}</option>`
217
+ wsel.value = widthMode
218
+ }
189
219
  function applyTheme(f) {
190
220
  if (!themed()) return
191
221
  try { f.iframe.contentDocument.documentElement.dataset.theme = theme } catch {}
@@ -198,8 +228,10 @@ function setTheme(t) {
198
228
  for (const f of frames.values()) badge(f)
199
229
  if (selected) inspector(selected)
200
230
  summary()
231
+ sendFilter()
201
232
  }
202
- const ref = (s) => (s.kind === 'components' ? `component catalog (${designDir}/${s.path})` : `${s.screen} ${s.device} ${s.version}${themeNote()} (${designDir}/${s.path})`)
233
+ const widthNote = (s) => (s.width !== s.drawn ? ` at ${s.width}px` : '')
234
+ const ref = (s) => (s.kind === 'components' ? `component catalog (${designDir}/${s.path})` : `${s.screen} ${s.device} ${s.version}${themeNote()}${widthNote(s)} (${designDir}/${s.path})`)
203
235
  let toastTimer
204
236
  async function copy(text) {
205
237
  try {
@@ -232,6 +264,7 @@ const filters = JSON.parse(localStorage.getItem('stitch2-filters') ?? 'null') ??
232
264
  if (typeof filters.device === 'string') { filters.devices = filters.device === 'all' ? [] : [filters.device]; delete filters.device }
233
265
  let screens = []
234
266
  let checks = { screens: {} }
267
+ let reports = { consistency: null, compare: null }
235
268
  let selected = null
236
269
  const frames = new Map() // path -> { el, iframe, screen, height }
237
270
 
@@ -271,7 +304,7 @@ const VERSION_H = 44, VERSION_GAP = 120
271
304
  function shown(s) {
272
305
  const q = document.getElementById('search').value.trim().toLowerCase()
273
306
  if (q && !`${s.screen} ${s.title} ${s.path} ${s.version} ${s.source}`.toLowerCase().includes(q)) return false
274
- if (filters.devices?.length && s.kind === 'screen' && !filters.devices.includes(s.device)) return false
307
+ if (filters.devices?.length && !filters.devices.includes(s.device)) return false
275
308
  if (filters.versions?.length && s.kind === 'screen' && !filters.versions.includes(s.version)) return false
276
309
  if (s.kind === 'components') return filters.status !== 'review' && filters.status !== 'approved'
277
310
  if (filters.status === 'active') return s.status !== 'archived'
@@ -330,8 +363,37 @@ function layout() {
330
363
  x += groupW + GROUP_GAP
331
364
  }
332
365
  tree()
366
+ sendFilter()
333
367
  }
334
368
 
369
+ // ---- reports follow the filters: the open report (consistency, app comparison, languages) gets the files the
370
+ // canvas shows and hides the rest; the catalog follows only the variant filter there, as the reference ----
371
+ function reportPaths() {
372
+ return screens.filter((s) => (s.kind === 'components' ? !filters.devices?.length || filters.devices.includes(s.device) : shown(s))).map((s) => s.path)
373
+ }
374
+ function filterLabel() {
375
+ const q = document.getElementById('search').value.trim()
376
+ return [
377
+ ...(filters.versions ?? []),
378
+ ...(filters.devices ?? []),
379
+ filters.status === 'approved' ? 'approved' : filters.status === 'review' ? 'needs review' : '',
380
+ q ? `“${q}”` : '',
381
+ ].filter(Boolean).join(' · ')
382
+ }
383
+ var sentFilter = ''
384
+ function sendFilter(force) {
385
+ const doc = document.getElementById('doc')
386
+ if (!doc.classList.contains('open') || !['/consistency/index.html', '/compare/index.html', '/languages'].includes(docUrl)) return
387
+ const message = { stitch2Filter: { paths: reportPaths(), label: filterLabel(), theme: themed() ? theme : '' } }
388
+ const key = JSON.stringify(message)
389
+ if (!force && key === sentFilter) return
390
+ sentFilter = key
391
+ doc.querySelector('iframe').contentWindow?.postMessage(message, location.origin)
392
+ }
393
+ // A report asks for the filters as soon as its script runs (its load event waits for every frame inside it).
394
+ addEventListener('message', (e) => { if (e.origin === location.origin && e.data?.stitch2Ready) sendFilter(true) })
395
+ document.querySelector('#doc iframe').addEventListener('load', () => sendFilter(true)) // reports made before stitch2Ready
396
+
335
397
  function tree() {
336
398
  const nav = document.getElementById('tree')
337
399
  nav.innerHTML = ''
@@ -353,15 +415,30 @@ function tree() {
353
415
  head('Design system')
354
416
  const docOpen = (url) => document.getElementById('doc').classList.contains('open') && docUrl === url
355
417
  item('DESIGN.md', 'tokens · rules', docOpen('/design'), () => openDoc('/design'))
356
- item('Consistency report', 'components × screens', docOpen('/consistency/index.html'), () => openDoc('/consistency/index.html'))
357
- item('App comparison', 'approved × live app', docOpen('/compare/index.html'), () => openDoc('/compare/index.html'))
418
+ // One entry for the catalog: the variant filter picks which ones (mobile, desktop); a click steps through them.
419
+ const catalogs = rows().find((r) => r.key === '~components')?.visible ?? []
420
+ const onCatalog = selected?.screen.kind === 'components'
421
+ item('Components', catalogs.length ? `catalog · ${catalogs.map((s) => s.device).join(', ')}` : 'hidden by the filters', onCatalog, () => {
422
+ if (!catalogs.length) return
423
+ const i = catalogs.findIndex((s) => s.path === selected?.screen.path)
424
+ select(frames.get(catalogs[(i + 1) % catalogs.length].path), true)
425
+ }, '', '', onCatalog ? ref(selected.screen) : catalogs[0] ? ref(catalogs[0]) : '')
426
+ // Each report says when it ran and how many of the files it covers, among those the filters show, changed
427
+ // since (then it's stale: run it again).
428
+ const keep = new Set(reportPaths())
429
+ const report = (label, url, state, command) => {
430
+ const changed = state?.changed.filter((p) => keep.has(p) || p.startsWith('components/')) ?? []
431
+ const n = changed.length
432
+ const meta = !state ? 'not run yet' : n ? `${n} changed since` : ago(state.at)
433
+ const b = item(label, meta, docOpen(url), () => openDoc(url), '', n || !state ? 'stale' : '')
434
+ b.title = !state
435
+ ? `Run ${command}`
436
+ : `Made ${new Date(state.at).toLocaleString()}${n ? `. Changed since, run ${command} again:\n${changed.slice(0, 12).join('\n')}${n > 12 ? `\n… and ${n - 12} more` : ''}` : ', up to date'}`
437
+ }
438
+ report('Consistency report', '/consistency/index.html', reports.consistency, 'stitch2 consistency')
439
+ report('App comparison', '/compare/index.html', reports.compare, 'stitch2 compare')
358
440
  item('Languages', 'screens × languages', docOpen('/languages'), () => openDoc('/languages'))
359
441
  const all = rows()
360
- const comp = all.find((r) => r.key === '~components')
361
- if (comp?.visible.length) {
362
- head('Components')
363
- for (const s of comp.visible) item(s.title.replace(/^[^—]+ — /, ''), 'catalog', selected?.screen.path === s.path, () => select(frames.get(s.path), true), '', '', ref(s))
364
- }
365
442
  head('Screens')
366
443
  for (const row of all) {
367
444
  if (row.key === '~components' || !row.visible.length) continue
@@ -409,7 +486,7 @@ function zoomTo(f) {
409
486
  function measure(f) {
410
487
  try {
411
488
  const doc = f.iframe.contentDocument
412
- const min = f.screen.width > 700 ? 800 : 844
489
+ const min = bigFrame(f.screen) ? 800 : 844
413
490
  const h = Math.min(6000, Math.max(min, doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0))
414
491
  if (Math.abs(h - f.height) > 2) {
415
492
  f.height = h
@@ -457,9 +534,9 @@ function frameFor(s) {
457
534
  el.innerHTML = `<div class="label"><span class="name"></span><span class="pill"></span><span class="title"></span><button class="copy" type="button" title="Copy a reference to paste to an agent">Copy</button><button class="badge" type="button"></button></div><div class="device"><iframe loading="lazy" scrolling="no"></iframe><div class="shield"></div></div>`
458
535
  const iframe = el.querySelector('iframe')
459
536
  iframe.style.width = `${s.width}px`
460
- iframe.style.height = `${s.width > 700 ? 800 : 844}px`
537
+ iframe.style.height = `${bigFrame(s) ? 800 : 844}px`
461
538
  iframe.src = `/${s.path}?pick${langQuery()}`
462
- const f = { el, iframe, screen: s, height: s.width > 700 ? 800 : 844 }
539
+ const f = { el, iframe, screen: s, height: bigFrame(s) ? 800 : 844 }
463
540
  iframe.addEventListener('load', () => { applyTheme(f); measure(f); setTimeout(() => measure(f), 800); setTimeout(() => measure(f), 2500) })
464
541
  el.querySelector('.shield').addEventListener('click', () => select(f, false))
465
542
  el.querySelector('.shield').addEventListener('dblclick', () => { select(f, false); f.el.classList.add('live') })
@@ -552,7 +629,11 @@ function inspector(f) {
552
629
  li.querySelector('.sev').textContent = i.severity
553
630
  li.querySelector('.msg').textContent = i.message + (i.text ? ` — “${i.text}”` : '')
554
631
  li.querySelector('code').textContent = i.selector
555
- li.onclick = () => locate(f, i.selector)
632
+ li.onclick = () => {
633
+ const r = rangeOf(s)
634
+ if (i.width && r && i.width !== s.width) { setWidth(i.width === r[0] ? 'narrow' : 'wide'); setTimeout(() => locate(f, i.selector), 900) }
635
+ else locate(f, i.selector)
636
+ }
556
637
  ul.append(li)
557
638
  }
558
639
  box.classList.add('open')
@@ -674,9 +755,22 @@ async function loadChecks() {
674
755
  if (selected) inspector(selected)
675
756
  summary()
676
757
  }
758
+ async function loadReports() {
759
+ reports = await fetch('/api/reports').then((r) => r.json()).catch(() => reports)
760
+ tree()
761
+ }
762
+ setInterval(loadReports, 60_000) // keeps "5 min ago" honest
763
+ function ago(t) {
764
+ const m = Math.round((Date.now() - t) / 60000)
765
+ if (m < 1) return 'just now'
766
+ if (m < 60) return `${m} min ago`
767
+ const h = Math.round(m / 60)
768
+ return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`
769
+ }
677
770
 
678
771
  async function load(first, given) {
679
772
  const next = given ?? (await fetch('/api/screens').then((r) => r.json()))
773
+ for (const s of next) { s.drawn = s.width; s.width = widthFor(s) }
680
774
  const keep = new Set(next.map((s) => s.path))
681
775
  for (const [p, f] of frames) if (!keep.has(p)) { f.el.remove(); frames.delete(p); if (selected === f) selected = null }
682
776
  for (const s of next) {
@@ -684,10 +778,12 @@ async function load(first, given) {
684
778
  if (f) { f.screen = s; paintLabel(f) } else frameFor(s)
685
779
  }
686
780
  screens = next
781
+ widthOptions()
687
782
  versionFilter()
688
783
  variantFilter()
689
784
  layout()
690
785
  await loadChecks()
786
+ await loadReports()
691
787
  if (selected) inspector(selected)
692
788
  if (first && !localStorage.getItem('stitch2-view')) setTimeout(fit, 1500)
693
789
  }
@@ -708,7 +804,7 @@ for (const [id, key] of [['statusFilter', 'status']]) {
708
804
  document.getElementById('search').oninput = layout
709
805
 
710
806
  // Versions and variants: multi-selects of every label the screens use (v1 … v12 and imports such as "Stitch";
711
- // mobile, tablet, desktop), with how many screens carry each; none ticked means all. The catalog always shows.
807
+ // mobile, tablet, desktop), with how many screens carry each; none ticked means all. The catalog follows the variant filter only.
712
808
  const MULTI = []
713
809
  for (const id of ['versionFilter', 'variantFilter']) {
714
810
  const box = document.getElementById(id)
@@ -765,6 +861,7 @@ events.onmessage = async (e) => {
765
861
  if (paths.some((p) => p === 'checks.json')) await loadChecks()
766
862
  const html = paths.filter((p) => p.endsWith('.html'))
767
863
  if (html.length) await load(false)
864
+ else if (paths.some((p) => p.startsWith('components/') || p === 'compare.json')) await loadReports()
768
865
  if (paths.includes('DESIGN.md') && document.getElementById('doc').classList.contains('open')) document.querySelector('#doc iframe').contentWindow.location.reload()
769
866
  // Stylesheets and scripts are shared: tokens.css, tailwind.tokens.js, token exports the screens link
770
867
  // (tokens.tailwind.css), the components.
@@ -826,6 +923,7 @@ addEventListener('keydown', (e) => {
826
923
  if (e.key === 'f' || e.key === 'F') fit()
827
924
  if ((e.key === 't' || e.key === 'T') && themed()) setTheme(THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length])
828
925
  if (e.key === 'l' || e.key === 'L') setLang(LANGS[(LANGS.indexOf(lang) + 1) % LANGS.length])
926
+ if (e.key === 'w' || e.key === 'W') setWidth(['drawn', 'narrow', 'wide'][(['drawn', 'narrow', 'wide'].indexOf(widthMode) + 1) % 3])
829
927
  if (e.key === '0') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, 1)
830
928
  if (e.key === '+' || e.key === '=') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z * 1.2)
831
929
  if (e.key === '-') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z / 1.2)
@@ -856,6 +954,9 @@ fetch('/api/config').then((r) => r.json()).then((c) => {
856
954
  pick.innerHTML = THEMES.map((t) => `<option value="${t}">${t[0].toUpperCase()}${t.slice(1)} theme</option>`).join('')
857
955
  pick.onchange = () => setTheme(pick.value)
858
956
  setTheme(theme)
957
+ WIDTHS = c.widths ?? {}
958
+ const wsel = document.getElementById('widthFilter')
959
+ wsel.onchange = () => setWidth(wsel.value)
859
960
  SOURCE = c.sourceLocale ?? 'en'
860
961
  LANGS = c.locales?.length ? c.locales : [SOURCE]
861
962
  if (!LANGS.includes(lang)) lang = SOURCE
package/dist/check.js CHANGED
@@ -7,16 +7,17 @@
7
7
  * colours change there, so contrast is checked again, tagged with the theme.
8
8
  * Then again in each check language (i18n.ts: the pseudo-languages and the config's): layout problems that
9
9
  * show only there (a label running into its neighbour, text cut off, a control drawn backwards right to left),
10
- * and keys a locale has no translation for, tagged with the locale.
10
+ * and keys a locale has no translation for, tagged with the locale. Last, each variant at both ends of the widths
11
+ * it covers (config widths: mobile 360–599, desktop 1024–1920 by default), tagged with the width.
11
12
  * Usage: stitch2 check [path filter…] [--shots] [--strict] [--locales ru,ar | all | none]
12
- * --shots also save page-tall renders to design/renders/ (one per theme)
13
+ * --shots also save page-tall renders to design/renders/ (one per theme, language and width edge)
13
14
  * --strict exit 1 when any screen has errors (for CI and agent loops)
14
15
  */
15
16
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
17
  import { join } from 'node:path';
17
18
  import { chromium } from '@playwright/test';
18
19
  import { colourIssues } from './colours.js';
19
- import { config } from './config.js';
20
+ import { config, widthRange } from './config.js';
20
21
  import { collectComponents, collectOptions, consistencyIssues, reuseIssues, } from './consistency.js';
21
22
  import { designThemes, typeLevels } from './design-md.js';
22
23
  import { checkLocales } from './i18n.js';
@@ -112,6 +113,24 @@ for (const s of screens) {
112
113
  if (shots)
113
114
  await shoot(s, undefined, locale);
114
115
  }
116
+ // The edges of the widths this variant covers (config widths), in its own theme and language: what breaks
117
+ // only when the window is narrower or wider than the frame it was drawn in.
118
+ const range = s.kind === 'screen' ? widthRange(s.device) : undefined;
119
+ for (const width of range ? [...new Set(range)].filter((w) => w !== s.width) : []) {
120
+ await page.setViewportSize({ width, height: 844 });
121
+ await page.goto(`http://127.0.0.1:${server.port}/${s.path}`, { waitUntil: 'networkidle', timeout: 30_000 }).catch(() => { });
122
+ await page.waitForTimeout(600);
123
+ for (const i of await page.evaluate(audit, { comfortable: config.comfortableTarget })) {
124
+ const key = `${i.type} ${i.selector}`;
125
+ if (i.type === 'contrast' || seen.has(key))
126
+ continue;
127
+ seen.add(key);
128
+ themed.push({ ...i, width, message: `at ${width}px: ${i.message}` });
129
+ }
130
+ if (shots)
131
+ await shoot({ ...s, width }, undefined, undefined, `${width}px`);
132
+ }
133
+ await page.setViewportSize({ width: s.width, height: 844 });
115
134
  results[s.path] = [
116
135
  ...layout,
117
136
  ...themed,
@@ -129,11 +148,11 @@ async function setTheme(theme) {
129
148
  await page.waitForTimeout(150);
130
149
  }
131
150
  /** A page-tall render; renders in another theme or language than the screen's own get a --<theme> suffix. */
132
- async function shoot(s, theme, locale) {
151
+ async function shoot(s, theme, locale, at) {
133
152
  const height = await page.evaluate(() => document.documentElement.scrollHeight);
134
153
  await page.setViewportSize({ width: s.width, height: Math.max(844, Math.min(height, 6000)) });
135
154
  mkdirSync(join(ROOT, 'renders'), { recursive: true });
136
- const suffix = theme ?? locale;
155
+ const suffix = theme ?? locale ?? at;
137
156
  const name = s.path.replaceAll('/', '__').replace(/\.html$/, suffix ? `--${suffix}.png` : '.png');
138
157
  await page.screenshot({ path: join(ROOT, 'renders', name) });
139
158
  await page.setViewportSize({ width: s.width, height: 844 });
package/dist/compare.js CHANGED
@@ -11,6 +11,9 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
11
11
  * "app" section, it opens the approved version and the app page at the same width and compares their structure:
12
12
  * which shared components appear (by their data-<prefix> marker), how many, in what order, and how the measured
13
13
  * ones look. Data differs between a mock-up and the app, so pixels are shown side by side, not diffed.
14
+ * Each version is compared at the width it is drawn at, then at both edges of its variant's width range (the
15
+ * config's "widths"): a layout that holds at 390px can still break at 1023px. Findings that only show at an edge
16
+ * carry that width ("at 1023px: …"); screenshots are taken at the drawn width.
14
17
  * Writes <design>/compare/index.html (the canvas opens it from its sidebar) and <design>/compare.json.
15
18
  *
16
19
  * "app": {
@@ -30,9 +33,10 @@ import { join, resolve } from 'node:path';
30
33
  import { pathToFileURL } from 'node:url';
31
34
  import { chromium } from '@playwright/test';
32
35
  import { audit, LAYOUT_ERRORS } from './audit.js';
33
- import { BASE, config } from './config.js';
36
+ import { BASE, config, widthRange } from './config.js';
34
37
  import { collectComponents, collectOptions } from './consistency.js';
35
38
  import { listScreens, ROOT } from './screens.js';
39
+ import { filterSnippet } from './report-filter.js';
36
40
  import { startServer } from './serve.js';
37
41
  const app = config.app;
38
42
  if (!app?.url || !app.routes) {
@@ -95,19 +99,27 @@ const signedIn = await context.newPage();
95
99
  const signedOut = await (await browser.newContext({ deviceScaleFactor: 2 })).newPage();
96
100
  const results = [];
97
101
  const opts = collectOptions();
98
- for (const s of versions) {
102
+ /**
103
+ * One version against its app page at one width. The first call opens both pages (and runs prepare); the edges
104
+ * then only resize them, which reflows the layout, keeps the prepared state (a sheet open) and saves the reloads.
105
+ */
106
+ async function compareAt(s, width, open) {
99
107
  const route = routeOf(s.screen);
100
108
  const live = route.signedOut ? signedOut : signedIn;
101
109
  const url = new URL(route.path, app.url).href;
102
- const height = s.width > 700 ? 800 : 844;
110
+ const height = width > 700 ? 800 : 844;
103
111
  for (const p of [design, live])
104
- await p.setViewportSize({ width: s.width, height });
105
- await design.goto(`http://127.0.0.1:${server.port}/${s.path}`).catch(() => { });
106
- await settle(design);
107
- await live.goto(url).catch(() => { });
108
- await settle(live);
112
+ await p.setViewportSize({ width, height });
109
113
  const findings = [];
110
- if (route.prepare) {
114
+ if (!open)
115
+ await live.waitForTimeout(500);
116
+ else {
117
+ await design.goto(`http://127.0.0.1:${server.port}/${s.path}`).catch(() => { });
118
+ await settle(design);
119
+ await live.goto(url).catch(() => { });
120
+ await settle(live);
121
+ }
122
+ if (open && route.prepare) {
111
123
  // A failing prepare is this version's error; the run goes on with the others.
112
124
  try {
113
125
  await (await load(route.prepare))({ page: live, url: app.url });
@@ -156,9 +168,25 @@ for (const s of versions) {
156
168
  findings.push({ severity: 'error', type: i.type, message: `${i.message} (“${i.text}”)` });
157
169
  const kinds = Object.keys(d.counts);
158
170
  const match = kinds.length ? Math.round((100 * kinds.filter((k) => a.counts[k]).length) / kinds.length) : 100;
171
+ return { findings, match, url, live };
172
+ }
173
+ for (const s of versions) {
174
+ const { findings, match, url, live } = await compareAt(s, s.width, true);
159
175
  const base = `${s.screen}-${s.device}`;
160
176
  await design.screenshot({ path: join(out, `${base}-design.png`), fullPage: true });
161
177
  await live.screenshot({ path: join(out, `${base}-app.png`), fullPage: true });
178
+ const seen = new Set(findings.map((f) => `${f.type} ${f.message}`));
179
+ for (const width of widthRange(s.device) ?? []) {
180
+ if (width === s.width)
181
+ continue;
182
+ for (const f of (await compareAt(s, width, false)).findings) {
183
+ const key = `${f.type} ${f.message}`;
184
+ if (seen.has(key))
185
+ continue;
186
+ seen.add(key);
187
+ findings.push({ ...f, width, message: `at ${width}px: ${f.message}` });
188
+ }
189
+ }
162
190
  results.push({ screen: s.screen, device: s.device, version: s.version, design: s.path, url, findings, match });
163
191
  const errors = findings.filter((f) => f.severity === 'error').length;
164
192
  console.log(`${errors ? '✗' : '✓'} ${base.padEnd(30)} ${String(match).padStart(3)}% of the design's components · ${errors} errors, ${findings.length - errors} warnings`);
@@ -174,10 +202,10 @@ const rows = results
174
202
  const list = r.findings.length
175
203
  ? r.findings.map((f) => `<li class="${f.severity}"><b>${f.type}</b> ${esc(f.message)}</li>`).join('')
176
204
  : '<li class="ok">Matches the design’s structure.</li>';
177
- return `<section><h2>${esc(r.screen)} · ${r.device} · ${r.version} <span>${r.match}%</span></h2><p><code>${esc(r.design)}</code> ↔ <a href="${esc(r.url)}">${esc(r.url)}</a></p><ul>${list}</ul><div class="imgs"><figure><figcaption>Approved design</figcaption><img src="${base}-design.png" alt=""></figure><figure><figcaption>App</figcaption><img src="${base}-app.png" alt=""></figure></div></section>`;
205
+ return `<section data-path="${esc(r.design)}"><h2>${esc(r.screen)} · ${r.device} · ${r.version} <span>${r.match}%</span></h2><p><code>${esc(r.design)}</code> ↔ <a href="${esc(r.url)}">${esc(r.url)}</a></p><ul>${list}</ul><div class="imgs"><figure><figcaption>Approved design</figcaption><img src="${base}-design.png" alt=""></figure><figure><figcaption>App</figcaption><img src="${base}-app.png" alt=""></figure></div></section>`;
178
206
  })
179
207
  .join('');
180
- writeFileSync(join(out, 'index.html'), `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>App comparison</title><style>body{background:#0b0b0d;color:#f4f4f5;font:14px/1.5 system-ui,sans-serif;margin:0}main{margin:0 auto;max-width:1400px;padding:32px}h1{font-size:22px}h2{font-size:17px;margin:32px 0 4px}h2 span{color:#a1a1aa;font-weight:400;margin-left:8px}code,a{color:#a1a1aa}ul{margin:8px 0;padding-left:18px}li.error b{color:#e5484d}li.warning b{color:#e5b54a}li.ok{color:#3fb27f}.imgs{display:flex;gap:16px;align-items:flex-start}figure{margin:0}figcaption{color:#a1a1aa;font-size:12px;margin-bottom:4px}img{border:1px solid #2e2e33;border-radius:8px;max-width:640px;width:100%}</style></head><body><main><h1>App comparison</h1><p>${new Date().toISOString()} · ${results.length} approved versions against ${esc(app.url)}${unmapped.length ? ` · no route for: ${esc(unmapped.join(', '))}` : ''}</p>${rows}</main></body></html>\n`);
208
+ writeFileSync(join(out, 'index.html'), `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>App comparison</title><style>body{background:#0b0b0d;color:#f4f4f5;font:14px/1.5 system-ui,sans-serif;margin:0}main{margin:0 auto;max-width:1400px;padding:32px}h1{font-size:22px}h2{font-size:17px;margin:32px 0 4px}h2 span{color:#a1a1aa;font-weight:400;margin-left:8px}code,a{color:#a1a1aa}ul{margin:8px 0;padding-left:18px}li.error b{color:#e5484d}li.warning b{color:#e5b54a}li.ok{color:#3fb27f}.imgs{display:flex;gap:16px;align-items:flex-start}figure{margin:0}figcaption{color:#a1a1aa;font-size:12px;margin-bottom:4px}img{border:1px solid #2e2e33;border-radius:8px;max-width:640px;width:100%}</style></head><body><main><h1>App comparison</h1><p>${new Date().toISOString()} · ${results.length} approved versions against ${esc(app.url)}${unmapped.length ? ` · no route for: ${esc(unmapped.join(', '))}` : ''}</p><p id="stitch2-filter" hidden></p>${rows}</main>${filterSnippet}</body></html>\n`);
181
209
  writeFileSync(join(ROOT, 'compare.json'), `${JSON.stringify({ comparedAt: new Date().toISOString(), url: app.url, results, unmapped }, null, 2)}\n`);
182
210
  const total = results.reduce((n, r) => n + r.findings.filter((f) => f.severity === 'error').length, 0);
183
211
  console.log(`\n${results.length} versions compared, ${total} errors${unmapped.length ? `; no route for ${unmapped.join(', ')}` : ''}. Report: ${config.designDir}/compare/index.html`);
package/dist/config.d.ts CHANGED
@@ -25,6 +25,12 @@ export interface LabConfig {
25
25
  * colours) and "light" when it has light-* colours. The canvas switches between them; check runs in each.
26
26
  */
27
27
  themes?: string[];
28
+ /**
29
+ * The window widths each variant covers, [narrowest, widest] in px, by device. A variant is drawn at its frame
30
+ * width; stitch2 check also renders it at both ends of its range, and the canvas can show it there. Defaults
31
+ * follow Google's window size classes: mobile 360–599 (compact), tablet 600–1023 (medium), desktop 1024–1920.
32
+ */
33
+ widths?: Record<string, [number, number]>;
28
34
  /** Skill folders a blind-trial sandbox copies. */
29
35
  skills: string[];
30
36
  /** Paths under designDir a sandbox leaves out besides screens (notes that would give the trial away). */
@@ -68,5 +74,9 @@ export declare const CONFIG_FILE: string | undefined;
68
74
  /** The folder paths in the config are relative to (the project root). */
69
75
  export declare const BASE: string;
70
76
  export declare const config: LabConfig;
77
+ /** The widths a variant covers: [narrowest, widest] for its device, from the config or the defaults. */
78
+ export declare const WIDTH_DEFAULTS: Record<string, [number, number]>;
79
+ export declare const widthRanges: () => Record<string, [number, number]>;
80
+ export declare const widthRange: (device: string) => [number, number] | undefined;
71
81
  /** The design folder, with a trailing slash. */
72
82
  export declare const ROOT: string;
package/dist/config.js CHANGED
@@ -34,5 +34,9 @@ export const config = {
34
34
  ...DEFAULTS,
35
35
  ...(CONFIG_FILE ? JSON.parse(readFileSync(CONFIG_FILE, 'utf8')) : {}),
36
36
  };
37
+ /** The widths a variant covers: [narrowest, widest] for its device, from the config or the defaults. */
38
+ export const WIDTH_DEFAULTS = { mobile: [360, 599], tablet: [600, 1023], desktop: [1024, 1920] };
39
+ export const widthRanges = () => ({ ...WIDTH_DEFAULTS, ...config.widths });
40
+ export const widthRange = (device) => widthRanges()[device];
37
41
  /** The design folder, with a trailing slash. */
38
42
  export const ROOT = join(resolve(BASE, config.designDir), '/');
@@ -147,7 +147,14 @@ export function collectComponents(opts) {
147
147
  if (!visible(el))
148
148
  continue;
149
149
  const g = el.getAttribute(marker);
150
- const kind = g === 'button' ? `button:${el.getAttribute('data-variant') ?? 'primary'}:${el.getAttribute('data-size') ?? 'md'}` : g;
150
+ // A variant (data-variant, from the component's variant prop) is compared with the same variant only: the
151
+ // simple tab bar with the simple one, a folder section with folder sections.
152
+ const variant = el.getAttribute('data-variant');
153
+ const kind = g === 'button'
154
+ ? `button:${variant ?? 'primary'}:${el.getAttribute('data-size') ?? 'md'}`
155
+ : variant
156
+ ? `${g}:${variant}`
157
+ : g;
151
158
  if (!(g === 'button' && el.hasAttribute('disabled')))
152
159
  add(kind, el);
153
160
  }
@@ -65,9 +65,16 @@ export function scan(html) {
65
65
  const skip = !!top?.skip || 'data-t-skip' in a || a.translate === 'no';
66
66
  for (const [name, value] of Object.entries(a)) {
67
67
  const keyed = name === 'data-t' || (name.startsWith('data-t-') && name !== 'data-t-args' && name !== 'data-t-skip');
68
- if (keyed && value.includes('{'))
69
- for (const m of value.matchAll(/\{([^{}|@][^{}|]*)(?:\|([^}]*))?\}/g))
70
- uses.push({ key: m[1], line, plural: plural || /(^|;)\s*count\s*=/.test(m[2] ?? '') });
68
+ if (keyed && value.includes('{')) {
69
+ // Innermost first, as the screens resolve them: {site.title|tab={nav.home}} uses both keys.
70
+ let rest = value;
71
+ for (let pass = 0; pass < 5 && rest.includes('{'); pass++)
72
+ rest = rest.replace(/\{([^{}|]+)(?:\|([^{}]*))?\}/g, (_m, key, inline) => {
73
+ if (!key.startsWith('@'))
74
+ uses.push({ key, line, plural: plural || /(^|;)\s*count\s*=/.test(inline ?? '') });
75
+ return 'x';
76
+ });
77
+ }
71
78
  else if (name === 'data-t')
72
79
  uses.push({ key: value, line, plural });
73
80
  else if (keyed && name !== 'data-t-slot')
package/dist/i18n.js CHANGED
@@ -171,14 +171,22 @@ function runtime(p) {
171
171
  let v;
172
172
  if (a.value.includes('{')) {
173
173
  // A template: each {key} or {key|name=value;name=value} becomes its message, the rest stays as written.
174
+ // Templates nest ({site.title|tab={nav.home}}): the innermost resolve first, until none is left.
174
175
  let whole = true;
175
- v = a.value.replace(/\{([^{}|]+)(?:\|([^}]*))?\}/g, (_m, key, inline) => {
176
- keys.push(key);
177
- const r = t(key, { ...args, ...parseArgs(inline, ';') });
178
- if (r === undefined)
179
- whole = false;
180
- return r ?? '';
181
- });
176
+ // Resolved text waits as \u0001<n>\u0001, so braces inside a message are never read as keys.
177
+ const done = [];
178
+ const back = (s) => s.replace(/\u0001(\d+)\u0001/g, (_m, i) => done[Number(i)]);
179
+ v = a.value;
180
+ for (let pass = 0; pass < 5 && /\{[^{}|]+(?:\|[^{}]*)?\}/.test(v); pass++)
181
+ v = v.replace(/\{([^{}|]+)(?:\|([^{}]*))?\}/g, (_m, key, inline) => {
182
+ keys.push(key);
183
+ const r = t(key, { ...args, ...parseArgs(inline && back(inline), ';') });
184
+ if (r === undefined)
185
+ whole = false;
186
+ done.push(r ?? '');
187
+ return `\u0001${done.length - 1}\u0001`;
188
+ });
189
+ v = back(v);
182
190
  if (!whole)
183
191
  v = undefined;
184
192
  }
@@ -9,7 +9,7 @@ export function languagesPage(only) {
9
9
  const screens = listScreens()
10
10
  .filter((s) => (only ? s.path === only : s.kind === 'screen' && s.status !== 'archived'))
11
11
  .map((s) => ({ path: s.path, label: s.kind === 'components' ? 'Component catalog' : s.screen + ' ' + s.device + ' ' + s.version, width: s.width }));
12
- const data = { screens, source: sourceLocale(), check: [sourceLocale(), ...checkLocales()], all: allLocales() };
12
+ const data = { only: !!only, screens, source: sourceLocale(), check: [sourceLocale(), ...checkLocales()], all: allLocales() };
13
13
  return `<!doctype html><html><head><meta charset="utf-8"><title>Languages</title><style>
14
14
  body { background: #0f0f11; color: #e4e4e7; font: 13px/1.4 system-ui, sans-serif; margin: 0; }
15
15
  header { align-items: center; background: #0f0f11; border-bottom: 1px solid #26262b; display: flex; gap: 12px; padding: 12px 140px 12px 20px; position: sticky; top: 0; z-index: 1; }
@@ -39,7 +39,20 @@ export function languagesPage(only) {
39
39
  <main></main>
40
40
  <script>
41
41
  const D = ${JSON.stringify(data).replaceAll('<', '\\u003c')}
42
- const theme = localStorage.getItem('stitch2-theme')
42
+ let theme = localStorage.getItem('stitch2-theme')
43
+ // The canvas posts its filters: only the screens it shows, in its theme (see report-filter.ts).
44
+ let keep = null
45
+ let sent = ''
46
+ addEventListener('message', (e) => {
47
+ const f = e.origin === location.origin && e.data && e.data.stitch2Filter
48
+ if (!f || D.only || JSON.stringify(f) === sent) return
49
+ sent = JSON.stringify(f)
50
+ keep = new Set(f.paths)
51
+ theme = f.theme || theme
52
+ document.querySelector('h1').textContent = f.label ? 'Languages · ' + f.label : 'Languages'
53
+ render()
54
+ })
55
+ if (parent !== window) parent.postMessage({ stitch2Ready: true }, location.origin)
43
56
  let set = localStorage.getItem('stitch2-lang-set') ?? 'check'
44
57
  let zoom = Number(localStorage.getItem('stitch2-lang-zoom') ?? 0.4)
45
58
  const name = (l) => l === 'pseudo' ? 'Pseudo: long text' : l === 'pseudo-rtl' ? 'Pseudo: right to left' : (new Intl.DisplayNames([l], { type: 'language' }).of(l) ?? l) + (l === D.source ? ', source' : '')
@@ -52,8 +65,9 @@ export function languagesPage(only) {
52
65
  const langs = set === 'all' ? D.all : D.check
53
66
  const main = document.querySelector('main')
54
67
  main.innerHTML = ''
55
- if (!D.screens.length) main.innerHTML = '<section><h2>No screens.</h2></section>'
56
- for (const s of D.screens) {
68
+ const list = D.screens.filter((s) => !keep || keep.has(s.path))
69
+ if (!list.length) main.innerHTML = '<section><h2>' + (keep ? 'No screens match the canvas filters.' : 'No screens.') + '</h2></section>'
70
+ for (const s of list) {
57
71
  const sec = document.createElement('section')
58
72
  sec.innerHTML = '<h2></h2><div class="row"></div>'
59
73
  sec.querySelector('h2').textContent = s.label
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Reports opened in the canvas follow its filters. The canvas posts { stitch2Filter: { paths, label } } to the
3
+ * report: every element with data-path outside paths hides, a data-group hides when none of its rows (other than
4
+ * data-ref ones, the catalog's reference) is left, and #stitch2-filter says what is shown. Opened on its own, a
5
+ * report shows everything. The report asks for the filters with { stitch2Ready: true } once its script runs.
6
+ */
7
+ export declare const filterSnippet = "<style>[hidden] { display: none !important; } #stitch2-filter { color: #e5b54a; font-size: 13px; margin: 8px 0 0; }</style>\n<script>\naddEventListener('message', (e) => {\n const f = e.origin === location.origin && e.data && e.data.stitch2Filter\n if (!f) return\n const keep = new Set(f.paths)\n for (const el of document.querySelectorAll('[data-path]')) el.hidden = !keep.has(el.dataset.path)\n for (const g of document.querySelectorAll('[data-group]'))\n g.hidden = ![...g.querySelectorAll('[data-path]:not([data-ref])')].some((el) => !el.hidden)\n const left = [...document.querySelectorAll('[data-path]:not([data-ref])')].filter((el) => !el.closest('[hidden]')).length\n const note = document.getElementById('stitch2-filter')\n if (!note) return\n note.hidden = !f.label && left > 0\n note.textContent = left ? 'Filtered as on the canvas: ' + (f.label || 'active screens') : 'Nothing in this report matches the canvas filters' + (f.label ? ': ' + f.label : '') + '.'\n})\nif (parent !== window) parent.postMessage({ stitch2Ready: true }, location.origin)\n</script>";
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Reports opened in the canvas follow its filters. The canvas posts { stitch2Filter: { paths, label } } to the
3
+ * report: every element with data-path outside paths hides, a data-group hides when none of its rows (other than
4
+ * data-ref ones, the catalog's reference) is left, and #stitch2-filter says what is shown. Opened on its own, a
5
+ * report shows everything. The report asks for the filters with { stitch2Ready: true } once its script runs.
6
+ */
7
+ export const filterSnippet = `<style>[hidden] { display: none !important; } #stitch2-filter { color: #e5b54a; font-size: 13px; margin: 8px 0 0; }</style>
8
+ <script>
9
+ addEventListener('message', (e) => {
10
+ const f = e.origin === location.origin && e.data && e.data.stitch2Filter
11
+ if (!f) return
12
+ const keep = new Set(f.paths)
13
+ for (const el of document.querySelectorAll('[data-path]')) el.hidden = !keep.has(el.dataset.path)
14
+ for (const g of document.querySelectorAll('[data-group]'))
15
+ g.hidden = ![...g.querySelectorAll('[data-path]:not([data-ref])')].some((el) => !el.hidden)
16
+ const left = [...document.querySelectorAll('[data-path]:not([data-ref])')].filter((el) => !el.closest('[hidden]')).length
17
+ const note = document.getElementById('stitch2-filter')
18
+ if (!note) return
19
+ note.hidden = !f.label && left > 0
20
+ note.textContent = left ? 'Filtered as on the canvas: ' + (f.label || 'active screens') : 'Nothing in this report matches the canvas filters' + (f.label ? ': ' + f.label : '') + '.'
21
+ })
22
+ if (parent !== window) parent.postMessage({ stitch2Ready: true }, location.origin)
23
+ </script>`;
package/dist/report.js CHANGED
@@ -8,6 +8,7 @@ import { join } from 'node:path';
8
8
  import { chromium } from '@playwright/test';
9
9
  import { config } from './config.js';
10
10
  import { collectComponents, collectOptions, consistencyIssues, nameOf, } from './consistency.js';
11
+ import { filterSnippet } from './report-filter.js';
11
12
  import { listScreens, ROOT } from './screens.js';
12
13
  import { startServer } from './serve.js';
13
14
  const kinds = process.argv.slice(2).filter((a) => !a.startsWith('--'));
@@ -92,10 +93,11 @@ const sections = Object.keys(crops)
92
93
  : note.length
93
94
  ? note.map((i) => `<p class="bad">${esc(i.message)}</p>`).join('')
94
95
  : '<b class="ok">matches</b>';
95
- return `<div class="row"><div class="who"><b>${esc(s.kind === 'components' ? 'Component catalog' : `${s.screen} · ${s.device} · ${s.version}`)}</b><code>${esc(path)}</code>${verdict}</div><div class="imgs">${files.map((f) => `<img src="${esc(f)}" alt="" />`).join('')}</div></div>`;
96
+ const ref = path.startsWith(config.catalogDir) ? ' data-ref' : '';
97
+ return `<div class="row" data-path="${esc(path)}"${ref}><div class="who"><b>${esc(s.kind === 'components' ? 'Component catalog' : `${s.screen} · ${s.device} · ${s.version}`)}</b><code>${esc(path)}</code>${verdict}</div><div class="imgs">${files.map((f) => `<img src="${esc(f)}" alt="" />`).join('')}</div></div>`;
96
98
  })
97
99
  .join('');
98
- return `<section><h2>${esc(nameOf(kind))} <span>${esc(kind)}</span></h2>${rows}</section>`;
100
+ return `<section data-group><h2>${esc(nameOf(kind))} <span>${esc(kind)}</span></h2>${rows}</section>`;
99
101
  })
100
102
  .join('');
101
103
  writeFileSync(join(out, 'index.html'), `<!doctype html><html lang="en"><head><meta charset="utf-8" /><title>Consistency report</title><style>
@@ -106,5 +108,5 @@ writeFileSync(join(out, 'index.html'), `<!doctype html><html lang="en"><head><me
106
108
  .who { display: grid; gap: 4px; align-content: start; min-width: 0; overflow-wrap: anywhere; } .imgs { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-start; min-width: 0; }
107
109
  img { background: #000; border: 1px solid #2e2e33; border-radius: 8px; max-width: 100%; width: 390px; }
108
110
  .ok { color: #3fb27f; } .ref { color: #a1a1aa; } .bad { color: #e5b54a; margin: 0; font-size: 13px; }
109
- </style></head><body><main><h1>Consistency report</h1><code>${new Date().toISOString()} · ${screens.length} active screens · reference: ${esc(config.catalogDir)}</code>${sections}</main></body></html>\n`);
111
+ </style></head><body><main><h1>Consistency report</h1><code>${new Date().toISOString()} · ${screens.length} active screens · reference: ${esc(config.catalogDir)}</code><p id="stitch2-filter" hidden></p>${sections}</main>${filterSnippet}</body></html>\n`);
110
112
  console.log(`consistency report: ${join(out, 'index.html')} (${Object.keys(crops).length} components, ${screens.length} screens)`);
package/dist/serve.d.ts CHANGED
@@ -1,3 +1,17 @@
1
+ /** When a report was made and which of the files it covers changed after it; null when it has never run. */
2
+ export interface ReportState {
3
+ at: number;
4
+ changed: string[];
5
+ }
6
+ /**
7
+ * The consistency report covers every screen that is not archived and the components; the app comparison covers
8
+ * the approved screens. A report is stale once one of those files is newer than it (a status change counts: it
9
+ * changes what the report covers). Only the design side is known: changes in the app itself don't show here.
10
+ */
11
+ export declare function reportStates(): {
12
+ consistency: ReportState | null;
13
+ compare: ReportState | null;
14
+ };
1
15
  export declare function startServer(port: number): Promise<{
2
16
  port: number;
3
17
  close: () => void;
package/dist/serve.js CHANGED
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * The stitch2 server: the canvas at /, DESIGN.md drawn at /design, screens side by side per language at /languages, the screen list at /api/screens
3
- * (POST /api/screens/meta sets a version's status or note), check results at /api/checks, live-reload events
4
- * at /events, and every file under design/ by its path. Local only (127.0.0.1).
3
+ * (POST /api/screens/meta sets a version's status or note), check results at /api/checks, when the reports were
4
+ * made and what changed since at /api/reports, live-reload events at /events, and every file under design/ by its
5
+ * path. Local only (127.0.0.1).
5
6
  * Usage: stitch2 canvas (PORT=4400 by default)
6
7
  */
7
- import { existsSync, readFileSync, statSync, watch } from 'node:fs';
8
+ import { existsSync, readdirSync, readFileSync, statSync, watch } from 'node:fs';
8
9
  import { createServer } from 'node:http';
9
10
  import { dirname, extname, join, normalize } from 'node:path';
10
11
  import { fileURLToPath } from 'node:url';
11
- import { CONFIG_FILE, config } from './config.js';
12
+ import { CONFIG_FILE, config, widthRanges } from './config.js';
12
13
  import { designThemes } from './design-md.js';
13
14
  import { allLocales, localeScript, sourceLocale } from './i18n.js';
14
15
  import { designPage } from './design-page.js';
@@ -28,6 +29,33 @@ function inlineTailwind(html, file) {
28
29
  });
29
30
  }
30
31
  import { listScreens, ROOT, STATUSES, tag, writeMeta } from './screens.js';
32
+ /**
33
+ * The consistency report covers every screen that is not archived and the components; the app comparison covers
34
+ * the approved screens. A report is stale once one of those files is newer than it (a status change counts: it
35
+ * changes what the report covers). Only the design side is known: changes in the app itself don't show here.
36
+ */
37
+ export function reportStates() {
38
+ const screens = listScreens();
39
+ const components = (() => {
40
+ const dir = join(ROOT, 'components');
41
+ if (!existsSync(dir))
42
+ return [];
43
+ return readdirSync(dir)
44
+ .filter((f) => /\.(js|mjs|css)$/.test(f))
45
+ .map((f) => ({ path: `components/${f}`, mtime: statSync(join(dir, f)).mtimeMs }));
46
+ })();
47
+ const state = (file, covered) => {
48
+ const report = join(ROOT, file);
49
+ if (!existsSync(report))
50
+ return null;
51
+ const at = statSync(report).mtimeMs;
52
+ return { at, changed: covered.filter((f) => f.mtime > at).map((f) => f.path) };
53
+ };
54
+ return {
55
+ consistency: state('consistency/index.html', [...screens.filter((s) => s.status !== 'archived'), ...components]),
56
+ compare: state('compare/index.html', screens.filter((s) => s.kind === 'screen' && s.status === 'approved')),
57
+ };
58
+ }
31
59
  const CANVAS = fileURLToPath(new URL('./canvas.html', import.meta.url));
32
60
  const TYPES = {
33
61
  '.html': 'text/html; charset=utf-8',
@@ -121,9 +149,11 @@ export function startServer(port) {
121
149
  return;
122
150
  }
123
151
  if (url.pathname === '/api/config')
124
- return json({ ...config, themes: designThemes(), locales: allLocales(), sourceLocale: sourceLocale() });
152
+ return json({ ...config, themes: designThemes(), locales: allLocales(), sourceLocale: sourceLocale(), widths: widthRanges() });
125
153
  if (url.pathname === '/api/screens')
126
154
  return json(listScreens());
155
+ if (url.pathname === '/api/reports')
156
+ return json(reportStates());
127
157
  if (url.pathname === '/api/checks') {
128
158
  const file = join(ROOT, 'checks.json');
129
159
  return json(existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { screens: {} });
@@ -13,6 +13,8 @@ export interface Issue {
13
13
  theme?: string;
14
14
  /** Set when the issue shows only in another language: the locale it was found in. */
15
15
  locale?: string;
16
+ /** Set when the issue shows only at another window width: the edge of its variant's range (config widths). */
17
+ width?: number;
16
18
  }
17
19
  export interface TextItem {
18
20
  selector: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitch2",
3
- "version": "0.14.0",
3
+ "version": "0.15.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",
@@ -24,8 +24,8 @@ References, read before the first screen:
24
24
  - [principles.md](references/principles.md): what makes a screen read well, and the review rubric.
25
25
  - [typography.md](references/typography.md): type levels and how to combine them; most polish is here.
26
26
  - [components.md](references/components.md): build from the shared components; the reuse ladder.
27
- - [screens.md](references/screens.md): screen names, versions and their variants (mobile, desktop), statuses,
28
- the user's approval and notes.
27
+ - [screens.md](references/screens.md): screen names, versions and their variants (mobile, desktop), the widths
28
+ each variant covers, statuses, the user's approval and notes.
29
29
  - [google.md](references/google.md): Google's guidance (Material, Android, web.dev) as rules with numbers.
30
30
  - [guides.md](references/guides.md): the public sources behind the rules.
31
31
 
@@ -60,6 +60,8 @@ References, read before the first screen:
60
60
  screens, or in the screen when it is one element. Look at both renders (`--light.png`). With `i18n`, check
61
61
  also runs the screen in the pseudo-languages and the config's locales; fix what they flag as the
62
62
  **stitch2-i18n** skill says.
63
+ An issue tagged with a width ("at 1024px: …") breaks at an edge of the windows the variant covers: fix it with a
64
+ responsive rule in the screen, or a new variant when the layout's structure has to change (screens.md, Widths).
63
65
  6. **Look and critique.** View the render in `<design>/renders/` (crop the first screenful). Score it with the
64
66
  rubric in principles.md, fix what fails, repeat 5–6 at most three times.
65
67
  7. **Hand over.** Set the version's status to `review`, point the user to it on the canvas, and report what
@@ -73,7 +75,7 @@ References, read before the first screen:
73
75
  |---|---|
74
76
  | `setup [--name N --primary #hex] [options]` | Sets a project up in one step; keeps every file that exists |
75
77
  | `canvas` | The canvas at http://localhost:4400: screens and versions, DESIGN.md, the consistency report, approval and notes |
76
- | `check [filter] [--shots] [--strict] [--locales l,…]` | Layout, type, reuse and consistency checks, in every theme and check language; `--shots` saves renders |
78
+ | `check [filter] [--shots] [--strict] [--locales l,…]` | Layout, type, reuse and consistency checks, in every theme and check language, and at both ends of each variant's widths; `--shots` saves renders |
77
79
  | `i18n [filter] [--locale l] [--suggest]` | Message keys screens use that the source or a locale lacks, and text with no key (stitch2-i18n) |
78
80
  | `consistency [kind…]` | Every shared component cropped from every screen, next to the catalog's version |
79
81
  | `type [filter] [--json]` | Every text style a screen uses and the type level it matches |
@@ -26,6 +26,8 @@ and build on it. Meta tag names below use the project's prefix (`<p>`, from `sti
26
26
  - **Edit in place** only to finish your own draft, or to answer a note on a version still in `review` or
27
27
  `draft`.
28
28
  - **Never edit** an `approved` or `archived` variant: copy the version to the next number and change the copy.
29
+ The exception is the user's own request: a fix they ask for on a version they name ("it had to be on v3")
30
+ goes into that version, which keeps its status; say what changed.
29
31
 
30
32
  ## Statuses
31
33
 
@@ -70,16 +72,32 @@ with the mobile's N, frame width 1280, the same components and type levels, the
70
72
  of a tab bar, a content max width, two columns where it helps). Shared components get their desktop form in the
71
73
  component, before the first desktop screen.
72
74
 
73
- Widths follow Google's window size classes ([google.md](google.md)): compact under 600 px (the phone frame),
74
- medium 600–839, expanded 840–1199, large from 1200 (the desktop frame). A desktop screen is a layout for the
75
- width class, not a stretched phone: a side navigation instead of the bar, list-detail with both panes, extra
76
- columns for feeds. When a project needs a tablet, it is one more variant, at a medium width.
75
+ ## Widths
76
+
77
+ A variant is drawn at one frame width but covers a range of windows: `widths` in `stitch2.config.json`, by
78
+ device, `[narrowest, widest]` in px. The defaults follow Google's window size classes ([google.md](google.md)):
79
+ mobile 360–599 (compact), tablet 600–1023 (medium), desktop 1024–1920 (expanded and up); a project sets the
80
+ width where its app actually changes layout (gymgym: mobile up to 1023, desktop from 1024).
81
+
82
+ Widths are a dimension like themes and languages, not more files: `stitch2 check` also renders every variant at
83
+ both ends of its range and reports what breaks there ("at 1024px: …"), and the canvas shows every frame at its
84
+ narrowest or widest (the width dropdown, W).
85
+
86
+ - A screen that breaks at an edge gets a **responsive rule inside the same variant**: a two-column grid that drops
87
+ to one, three cards across that become two, a side column that moves under the main one. Use the project's
88
+ breakpoints (Tailwind's `md:`, `lg:`, `xl:`), so the screen keeps one layout that adapts.
89
+ - Only when the **structure** has to change (a bar becomes a side navigation, one pane becomes list-detail) is it a
90
+ new variant: `tablet-v<N>` for 600–1023, with its own frame width (768) and range, the same N as the version.
91
+ - A desktop screen is a layout for its range, not a stretched phone: a side navigation instead of the bar,
92
+ list-detail with both panes, extra columns for feeds.
77
93
 
78
94
  ## The canvas
79
95
 
80
- `stitch2 canvas` → http://localhost:4400. The sidebar lists DESIGN.md (drawn as tokens and rules), the
81
- consistency report, the component catalog and every screen with its versions; filters for active, approved,
82
- needs review or all, and mobile or desktop. Selecting a version opens the inspector: status, note, checks, and
96
+ `stitch2 canvas` → http://localhost:4400. The sidebar lists DESIGN.md (drawn as tokens and rules), Components
97
+ (the catalog for the variants the filter shows; a click steps through them), the consistency report and app
98
+ comparison (each with when it last ran, or how many covered screens changed since), Languages, and every screen
99
+ with its versions. Filters: status (active, approved, needs review, all), versions, variants, theme, width and
100
+ 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
83
101
  Copy for its reference. Pick mode (`p`) highlights the element under the pointer; a click copies its reference,
84
102
  Shift-click collects several (Copy all, Add to note). Keys: `/` search, `j`/`k` next and previous, `a` approve,
85
103
  `p` pick, `f` fit, `0` actual size, double-click to use a frame, `Esc` to leave.
@@ -44,7 +44,10 @@ Slots are for wiring. If what goes into a slot changes how the component looks,
44
44
  other, **controls overlapping each other**, small tap targets, contrast, type levels, reuse and consistency.
45
45
  - `stitch2 compare` (the live app, against every approved version with a route): which components appear, in
46
46
  what order, how the measured ones look, **and the same layout errors as check** (overflow, fixed-overlap,
47
- control-overlap), so a control squeezed in the app shows up even when no screen drew it.
47
+ control-overlap), so a control squeezed in the app shows up even when no screen drew it. Each version runs at
48
+ its drawn width and at both edges of its width range; a finding seen only at an edge starts with "at <width>px:".
49
+ - Both reports are snapshots that run only when called. The canvas sidebar says "N changed since" once the
50
+ screens they cover have changed; run them again before relying on them.
48
51
  - Give every approved screen a route, and every state a `prepare` module that puts the app into it (a sheet
49
52
  open, a timer running, a form half filled). An unrouted screen is not compared at all.
50
53
 
@@ -44,7 +44,8 @@ Use the app's own files. Never copy messages into the design folder: two copies
44
44
  ```
45
45
 
46
46
  A value with braces is a **template**: each `{key}` or `{key|name=value;name=value}` becomes its message and
47
- the rest stays as written. Use it for lines the app builds from several messages and data (a meta line, a
47
+ the rest stays as written. Templates nest, innermost first, for a message that takes another as an argument:
48
+ `data-t-title="{site.screens.title|workout={nav.workout};history={nav.history}}"`. Use it for lines the app builds from several messages and data (a meta line, a
48
49
  list of `label=url` links: `data-t-links="{site.features}=#features,{site.docs}=/docs"`); sample data inside
49
50
  it stays literal. Without braces, `data-t-<attr>` on a comma list takes one key per item.
50
51