stitch2 0.16.0 → 0.18.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
@@ -36,6 +36,8 @@ file that already exists. From there the agent spends its effort on the design.
36
36
  - **Canvas** (`stitch2 canvas`): every screen and its versions on a pan-and-zoom board, a sidebar with search
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
+ Only the screens in view are live pages; the rest, and every screen when you zoom out, are pictures the
40
+ canvas renders with Playwright and keeps on disk, so a project with hundreds of screens stays fast.
39
41
  With a light and a dark theme (DESIGN.md's `light-*` colours), a switch (`T`) shows every screen in either one.
40
42
  A width switch (`W`) shows every screen at the narrowest or widest window its layout covers.
41
43
  With languages configured, a Language switch (`L`) shows every screen in one of them, and the Languages page
package/dist/canvas.html CHANGED
@@ -58,6 +58,11 @@
58
58
  .screen-name { align-items: center; border-radius: 6px; color: var(--text); cursor: pointer; display: flex; font-weight: 600; gap: 6px; padding: 8px 8px 2px; }
59
59
  .screen-name:hover { background: var(--panel2); }
60
60
  .screen-name .meta { color: var(--faint); font-size: 11px; font-weight: 400; margin-left: auto; }
61
+ .page-name .chev { color: var(--faint); display: inline-block; font-size: 10px; transition: transform .15s; width: 10px; }
62
+ .page-name.open .chev { transform: rotate(90deg); }
63
+ .screen-name.state { font-weight: 500; padding-left: 26px; }
64
+ .item.version.nested { padding-left: 30px; }
65
+ .item.variant.nested { padding-left: 52px; }
61
66
  .version { padding-left: 20px; }
62
67
  .dot { background: var(--faint); border-radius: 50%; flex: none; height: 8px; width: 8px; }
63
68
  .dot.approved { background: var(--good); } .dot.review { background: var(--warn); } .dot.draft { background: var(--muted); } .dot.archived { background: transparent; border: 1px solid var(--faint); }
@@ -80,7 +85,11 @@
80
85
  .frame.desktop .device { border-radius: 12px; }
81
86
  .frame.selected .device { outline: 3px solid var(--accent); }
82
87
  .frame.archived { opacity: .55; }
83
- .device iframe { background: #fff; border: 0; display: block; }
88
+ /* A frame shows its picture (rendered by the server) until its live page is ready; only frames in view are live. */
89
+ .device iframe { background: #fff; border: 0; display: block; left: 0; position: absolute; top: 0; visibility: hidden; }
90
+ .frame.ready .device iframe { visibility: visible; }
91
+ .device .shot { display: block; left: 0; pointer-events: none; position: absolute; top: 0; user-select: none; }
92
+ .device .shot:not([src]) { display: none; }
84
93
  .device .shield { cursor: pointer; inset: 0; position: absolute; }
85
94
  .frame.live .shield { display: none; }
86
95
  .frame.changed .device { animation: flash 900ms ease-out; }
@@ -151,6 +160,8 @@ const viewport = document.getElementById('viewport')
151
160
  const world = document.getElementById('world')
152
161
  const SIDE = 288, GAP_X = 72, GAP_Y = 150, GROUP_GAP = 200, LINE_W = 3400, ROW_LABEL_H = 52, LABEL_H = 38
153
162
  let ORDER = [] // screen order, from the config
163
+ // Pages whose screens are unfolded in the sidebar.
164
+ const openPages = new Set((() => { try { return JSON.parse(localStorage.getItem('stitch2-open-pages') ?? '[]') } catch { return [] } })())
154
165
  const STATUS_RANK = { approved: 0, review: 1, draft: 2, archived: 3 }
155
166
  const STATUS_LABEL = { approved: 'approved', review: 'needs review', draft: 'draft', archived: 'archived' }
156
167
  const STATUS_HINT = {
@@ -183,10 +194,11 @@ function setLang(l) {
183
194
  lang = l
184
195
  localStorage.setItem('stitch2-lang', l)
185
196
  document.getElementById('langFilter').value = l
186
- for (const f of frames.values()) f.iframe.src = `/${f.screen.path}?pick${langQuery()}`
197
+ for (const f of frames.values()) if (f.iframe) { f.el.classList.remove('ready'); f.iframe.src = frameSrc(f.screen) }
187
198
  for (const f of frames.values()) badge(f)
188
199
  if (selected) inspector(selected)
189
200
  summary()
201
+ scheduleSync()
190
202
  }
191
203
  // Widths: each variant covers a range of windows (config widths); frames show it drawn, at the narrowest or the widest.
192
204
  let WIDTHS = {}
@@ -201,7 +213,7 @@ function setWidth(mode) {
201
213
  for (const f of frames.values()) {
202
214
  f.screen.width = widthFor(f.screen)
203
215
  f.el.style.width = `${f.screen.width}px`
204
- f.iframe.style.width = `${f.screen.width}px`
216
+ size(f)
205
217
  setTimeout(() => measure(f), 300)
206
218
  setTimeout(() => measure(f), 1200)
207
219
  }
@@ -217,7 +229,7 @@ function widthOptions() {
217
229
  wsel.value = widthMode
218
230
  }
219
231
  function applyTheme(f) {
220
- if (!themed()) return
232
+ if (!themed() || !f.iframe) return
221
233
  try { f.iframe.contentDocument.documentElement.dataset.theme = theme } catch {}
222
234
  }
223
235
  function setTheme(t) {
@@ -229,6 +241,7 @@ function setTheme(t) {
229
241
  if (selected) inspector(selected)
230
242
  summary()
231
243
  sendFilter()
244
+ scheduleSync()
232
245
  }
233
246
  const widthNote = (s) => (s.width !== s.drawn ? ` at ${s.width}px` : '')
234
247
  const ref = (s) => (s.kind === 'components' ? `component catalog (${designDir}/${s.path})` : `${s.screen} ${s.device} ${s.version}${themeNote()}${widthNote(s)} (${designDir}/${s.path})`)
@@ -266,7 +279,14 @@ let screens = []
266
279
  let checks = { screens: {} }
267
280
  let reports = { consistency: null, compare: null }
268
281
  let selected = null
269
- const frames = new Map() // path -> { el, iframe, screen, height }
282
+ const frames = new Map() // path -> { el, device, img, iframe (while live), screen, height }
283
+ // Heights measured before, so the layout doesn't jump while pictures and pages load.
284
+ const HEIGHTS = JSON.parse(localStorage.getItem('stitch2-heights') ?? '{}')
285
+ let heightsTimer
286
+ const saveHeights = () => { clearTimeout(heightsTimer); heightsTimer = setTimeout(() => localStorage.setItem('stitch2-heights', JSON.stringify(HEIGHTS)), 1000) }
287
+ let layoutQueued = false
288
+ /** Many frames measure themselves at once while loading: one layout per animation frame. */
289
+ const scheduleLayout = () => { if (!layoutQueued) { layoutQueued = true; requestAnimationFrame(() => { layoutQueued = false; layout() }) } }
270
290
 
271
291
  const apply = () => {
272
292
  world.style.transform = `translate(${view.x}px, ${view.y}px) scale(${view.z})`
@@ -276,6 +296,7 @@ const apply = () => {
276
296
  viewport.style.backgroundPosition = `${view.x}px ${view.y}px`
277
297
  document.getElementById('zoom').textContent = `${Math.round(view.z * 100)}%`
278
298
  localStorage.setItem('stitch2-view', JSON.stringify(view))
299
+ scheduleSync()
279
300
  }
280
301
  const zoomAt = (cx, cy, z) => {
281
302
  z = Math.min(4, Math.max(0.03, z))
@@ -289,7 +310,19 @@ const zoomAt = (cx, cy, z) => {
289
310
  // version its variants: the layouts for each device (mobile, desktop), files <device>-v<N> sharing N ----
290
311
  const rowKey = (s) => (s.kind === 'components' ? '~components' : s.screen)
291
312
  const rowRank = (k) => (k === '~components' ? -1 : ORDER.includes(k) ? ORDER.indexOf(k) : 100)
292
- const sortRows = (keys) => keys.sort((a, b) => rowRank(a) - rowRank(b) || a.localeCompare(b))
313
+ /** What a screen is within its page: its name without the page's ("entry" for workout-entry), or "main". */
314
+ const stateOf = (screen, page) => (screen === page ? 'main' : screen.startsWith(`${page}-`) ? screen.slice(page.length + 1) : screen)
315
+ // Pages keep their screens together: a page sorts where the first of its screens (or the page itself) is in the
316
+ // config's order, its main screen first, the rest in order.
317
+ const sortRows = (keys, pageOf) => {
318
+ const pageRank = new Map()
319
+ for (const k of keys) { const p = pageOf(k); pageRank.set(p, Math.min(pageRank.get(p) ?? 100, rowRank(k), rowRank(p))) }
320
+ return keys.sort((a, b) => {
321
+ const pa = pageOf(a), pb = pageOf(b)
322
+ if (pa !== pb) return pageRank.get(pa) - pageRank.get(pb) || pa.localeCompare(pb)
323
+ return (a === pa ? -1 : b === pb ? 1 : 0) || rowRank(a) - rowRank(b) || a.localeCompare(b)
324
+ })
325
+ }
293
326
  const versionRank = (v) => { const m = /^v(\d+)$/.exec(v); return m ? Number(m[1]) : 0 }
294
327
  const DEVICE_RANK = { mobile: 0, tablet: 1, desktop: 2 }
295
328
  const sortVersions = (list) =>
@@ -314,16 +347,31 @@ function shown(s) {
314
347
  const rows = () => {
315
348
  const by = new Map()
316
349
  for (const s of screens) by.set(rowKey(s), [...(by.get(rowKey(s)) ?? []), s])
317
- return sortRows([...by.keys()]).map((k) => ({ key: k, all: sortVersions(by.get(k)), visible: sortVersions(by.get(k).filter(shown)) }))
350
+ const pageOf = (k) => (k === '~components' ? '~components' : by.get(k)[0].page || k)
351
+ const sizes = new Map()
352
+ for (const k of by.keys()) sizes.set(pageOf(k), (sizes.get(pageOf(k)) ?? 0) + 1)
353
+ return sortRows([...by.keys()], pageOf).map((k) => ({
354
+ key: k,
355
+ page: pageOf(k),
356
+ // A page with several screens: each is one of its states (entry, menu, empty…).
357
+ state: sizes.get(pageOf(k)) > 1 ? stateOf(k, pageOf(k)) : '',
358
+ all: sortVersions(by.get(k)),
359
+ visible: sortVersions(by.get(k).filter(shown)),
360
+ }))
318
361
  }
319
362
 
320
363
  // Screens flow left to right and wrap, like a design canvas; each screen's versions stay together.
321
364
  function layout() {
322
365
  for (const n of world.querySelectorAll('.row-label')) n.remove()
323
- let x = 0, y = 0, lineH = 0
324
- for (const row of rows()) {
366
+ let x = 0, y = 0, lineH = 0, page = ''
367
+ const all = rows()
368
+ const paged = new Set(all.filter((r) => r.state).map((r) => r.page))
369
+ for (const row of all) {
325
370
  for (const s of row.all) frames.get(s.path).el.style.display = row.visible.includes(s) ? '' : 'none'
326
371
  if (!row.visible.length) continue
372
+ // Each page with several screens starts its own line, so its states read together.
373
+ if (x > 0 && row.page !== page && (paged.has(row.page) || paged.has(page))) { x = 0; y += lineH + GAP_Y; lineH = 0 }
374
+ page = row.page
327
375
  const groups = versionsOf(row.visible)
328
376
  const widthOf = (g) => g.variants.reduce((w, s) => w + s.width, 0) + GAP_X * (g.variants.length - 1)
329
377
  const groupW = groups.reduce((w, g) => w + widthOf(g), 0) + VERSION_GAP * (groups.length - 1)
@@ -332,7 +380,7 @@ function layout() {
332
380
  label.className = 'row-label'
333
381
  const approved = row.all.filter((s) => s.status === 'approved').map((s) => `${s.version} ${s.device}`)
334
382
  label.innerHTML = '<b></b><span></span>'
335
- label.querySelector('b').textContent = row.key === '~components' ? 'Components' : row.key
383
+ label.querySelector('b').textContent = row.key === '~components' ? 'Components' : row.state ? `${row.page} › ${row.state}` : row.key
336
384
  label.querySelector('span').textContent = row.key === '~components' ? 'the catalog every screen is built from' : approved.length ? `approved: ${approved.join(', ')}` : 'nothing approved yet'
337
385
  label.style.transform = `translate(${x}px, ${y}px)`
338
386
  label.style.maxWidth = `${groupW}px`
@@ -364,6 +412,7 @@ function layout() {
364
412
  }
365
413
  tree()
366
414
  sendFilter()
415
+ scheduleSync()
367
416
  }
368
417
 
369
418
  // ---- reports follow the filters: the open report (consistency, app comparison, languages) gets the files the
@@ -438,26 +487,50 @@ function tree() {
438
487
  report('Consistency report', '/consistency/index.html', reports.consistency, 'stitch2 consistency')
439
488
  report('App comparison', '/compare/index.html', reports.compare, 'stitch2 compare')
440
489
  item('Languages', 'screens × languages', docOpen('/languages'), () => openDoc('/languages'))
441
- const all = rows()
490
+ const all = rows().filter((r) => r.key !== '~components' && r.visible.length)
442
491
  head('Screens')
443
- for (const row of all) {
444
- if (row.key === '~components' || !row.visible.length) continue
492
+ const dotOf = (list) => (list.some((s) => s.status === 'approved') ? 'approved' : list.some((s) => s.status === 'review') ? 'review' : 'draft')
493
+ const screenRow = (row, nested) => {
445
494
  const name = document.createElement('div')
446
- name.className = 'screen-name'
447
- const approved = row.all.some((s) => s.status === 'approved')
448
- name.innerHTML = `<span class="dot ${approved ? 'approved' : row.all.some((s) => s.status === 'review') ? 'review' : 'draft'}"></span><span></span><span class="meta"></span>`
449
- name.children[1].textContent = row.key
495
+ name.className = `screen-name${nested ? ' state' : ''}`
496
+ name.innerHTML = `<span class="dot ${dotOf(row.all)}"></span><span></span><span class="meta"></span>`
497
+ name.children[1].textContent = nested ? row.state : row.key
450
498
  const vs = versionsOf(row.visible)
451
499
  name.querySelector('.meta').textContent = `${vs.length} version${vs.length > 1 ? 's' : ''}`
452
500
  name.onclick = () => select(frames.get(row.visible[0].path), true)
453
501
  name.append(copyButton(row.key, 'Copy the screen name'))
454
502
  nav.append(name)
503
+ const sub = nested ? ' nested' : ''
455
504
  for (const g of vs) {
456
505
  const one = g.variants.length === 1
457
506
  const first = g.variants[0]
458
- item(one ? `${g.version} · ${first.device}` : g.version, one ? STATUS_LABEL[first.status] : `${STATUS_LABEL[g.status]} · ${g.variants.length} variants`, one && selected?.screen.path === first.path, () => select(frames.get(first.path), true), g.status, 'version', one ? ref(first) : `${row.key} ${g.version}`)
459
- if (!one) for (const s of g.variants) item(s.device, STATUS_LABEL[s.status], selected?.screen.path === s.path, () => select(frames.get(s.path), true), s.status, 'variant', ref(s))
507
+ item(one ? `${g.version} · ${first.device}` : g.version, one ? STATUS_LABEL[first.status] : `${STATUS_LABEL[g.status]} · ${g.variants.length} variants`, one && selected?.screen.path === first.path, () => select(frames.get(first.path), true), g.status, `version${sub}`, one ? ref(first) : `${row.key} ${g.version}`)
508
+ if (!one) for (const s of g.variants) item(s.device, STATUS_LABEL[s.status], selected?.screen.path === s.path, () => select(frames.get(s.path), true), s.status, `variant${sub}`, ref(s))
509
+ }
510
+ }
511
+ // A page with several screens folds them under its name; it opens on a click, while one of them is selected, or
512
+ // while searching.
513
+ const searching = !!document.getElementById('search').value.trim()
514
+ for (let i = 0; i < all.length; ) {
515
+ const page = all[i].page
516
+ const group = []
517
+ while (i < all.length && all[i].page === page) group.push(all[i++])
518
+ if (!group[0].state) { screenRow(group[0], false); continue }
519
+ const open = searching || openPages.has(page) || group.some((r) => r.all.some((s) => s.path === selected?.screen.path))
520
+ const pageRow = document.createElement('div')
521
+ pageRow.className = `screen-name page-name${open ? ' open' : ''}`
522
+ pageRow.innerHTML = `<span class="chev">▶</span><span class="dot ${dotOf(group.flatMap((r) => r.all))}"></span><span></span><span class="meta"></span>`
523
+ pageRow.children[2].textContent = page
524
+ pageRow.querySelector('.meta').textContent = `${group.length} screens`
525
+ pageRow.title = group.map((r) => r.state).join(', ')
526
+ pageRow.onclick = () => {
527
+ if (openPages.has(page)) openPages.delete(page)
528
+ else openPages.add(page)
529
+ localStorage.setItem('stitch2-open-pages', JSON.stringify([...openPages]))
530
+ tree()
460
531
  }
532
+ nav.append(pageRow)
533
+ if (open) for (const row of group) screenRow(row, true)
461
534
  }
462
535
  }
463
536
 
@@ -487,19 +560,128 @@ function zoomTo(f) {
487
560
  const MAX_FRAME_HEIGHT = 20000
488
561
 
489
562
  function measure(f) {
563
+ if (!f.iframe) return
490
564
  try {
491
565
  const doc = f.iframe.contentDocument
492
- const min = bigFrame(f.screen) ? 800 : 844
493
566
  // As tall as the page, up to MAX_FRAME_HEIGHT: long landing pages are drawn whole.
494
- const h = Math.min(MAX_FRAME_HEIGHT, Math.max(min, doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0))
495
- if (Math.abs(h - f.height) > 2) {
496
- f.height = h
497
- f.iframe.style.height = `${h}px`
498
- layout()
499
- }
567
+ setHeight(f, Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0))
500
568
  } catch {}
501
569
  }
502
570
 
571
+ const minHeight = (s) => (bigFrame(s) ? 800 : 844)
572
+ function setHeight(f, h) {
573
+ h = Math.min(MAX_FRAME_HEIGHT, Math.max(minHeight(f.screen), h))
574
+ HEIGHTS[`${f.screen.path}@${f.screen.width}`] = h
575
+ saveHeights()
576
+ if (Math.abs(h - f.height) <= 2) return
577
+ f.height = h
578
+ size(f)
579
+ scheduleLayout()
580
+ }
581
+ /** The device box, the picture and the live page all take the frame's width and height. */
582
+ function size(f) {
583
+ const w = `${f.screen.width}px`, h = `${f.height}px`
584
+ Object.assign(f.device.style, { width: w, height: h })
585
+ f.img.style.width = w
586
+ if (f.iframe) Object.assign(f.iframe.style, { width: w, height: h })
587
+ }
588
+ const frameSrc = (s) => `/${s.path}?pick${langQuery()}`
589
+
590
+ // ---- live frames: a screen is a whole page, so only the frames in view, at a zoom where you can work with them,
591
+ // are live (at most MAX_LIVE, nearest the middle first), plus the selected one and those with picked elements.
592
+ // The rest show the picture the server renders (/api/shot). Without pictures (no Chromium), frames in view stay
593
+ // live at any zoom, still at most MAX_LIVE. ----
594
+ const LIVE_ZOOM = 0.3, MAX_LIVE = 12, SHOTS_AT_ONCE = 3
595
+ let shotRev = 0, shotErrors = 0, syncTimer
596
+ const shotsFailed = () => shotErrors > 2
597
+ const scheduleSync = () => { clearTimeout(syncTimer); syncTimer = setTimeout(syncLive, 120) }
598
+ function shotUrl(s) {
599
+ const q = new URLSearchParams({ path: s.path, width: String(s.width), m: String(Math.round(s.mtime)), r: String(shotRev) })
600
+ if (themed()) q.set('theme', theme)
601
+ if (lang !== SOURCE) q.set('lang', lang)
602
+ return `/api/shot?${q}`
603
+ }
604
+ const pinned = (f) => f === selected || f.el.classList.contains('live') || picked.some((p) => p.f === f)
605
+ function mount(f) {
606
+ if (f.iframe) return
607
+ const iframe = document.createElement('iframe')
608
+ iframe.setAttribute('scrolling', 'no')
609
+ f.iframe = iframe
610
+ size(f)
611
+ iframe.addEventListener('load', () => {
612
+ if (f.iframe !== iframe) return
613
+ applyTheme(f)
614
+ measure(f)
615
+ clearTimeout(f.reveal)
616
+ // Tailwind's browser build and the components settle just after load: the picture stays until then.
617
+ f.reveal = setTimeout(() => {
618
+ if (f.iframe !== iframe) return
619
+ measure(f)
620
+ f.el.classList.add('ready')
621
+ for (const go of f.waiters.splice(0)) go()
622
+ }, 350)
623
+ setTimeout(() => measure(f), 1200)
624
+ setTimeout(() => measure(f), 2500)
625
+ })
626
+ iframe.src = frameSrc(f.screen)
627
+ f.device.insertBefore(iframe, f.img.nextSibling)
628
+ }
629
+ function unmount(f) {
630
+ if (!f.iframe) return
631
+ clearTimeout(f.reveal)
632
+ // Cleared first: a page still loading can fire its load event as it's removed.
633
+ const iframe = f.iframe
634
+ f.iframe = null
635
+ iframe.remove()
636
+ f.el.classList.remove('ready', 'live')
637
+ }
638
+ /** Resolves once the frame's live page is ready: for what reads its document (locating an issue). */
639
+ function whenLive(f) {
640
+ mount(f)
641
+ return f.el.classList.contains('ready') ? Promise.resolve() : new Promise((go) => f.waiters.push(go))
642
+ }
643
+ let shotsLoading = 0
644
+ let shotQueue = [] // [frame, url], nearest the middle first
645
+ function pumpShots() {
646
+ while (shotsLoading < SHOTS_AT_ONCE && shotQueue.length) {
647
+ const [f, url] = shotQueue.shift()
648
+ if (f.img.dataset.loading || f.img.getAttribute('src') === url || !frames.has(f.screen.path)) continue
649
+ shotsLoading++
650
+ f.img.dataset.loading = url
651
+ f.img.src = url
652
+ }
653
+ }
654
+ function shotDone(f, ok) {
655
+ if (!f.img.dataset.loading) return
656
+ delete f.img.dataset.loading
657
+ shotsLoading--
658
+ if (ok) { shotErrors = 0; if (!f.iframe) setHeight(f, f.img.naturalHeight) } else if (++shotErrors > 2) scheduleSync()
659
+ pumpShots()
660
+ }
661
+ function syncLive() {
662
+ const vw = innerWidth - SIDE, vh = innerHeight
663
+ const near = [], kept = new Set()
664
+ for (const f of frames.values()) {
665
+ if (f.el.style.display === 'none') { if (!pinned(f)) unmount(f); continue }
666
+ const x = view.x + parseFloat(f.el.style.left) * view.z, y = view.y + parseFloat(f.el.style.top) * view.z
667
+ const w = f.screen.width * view.z, h = (f.height + LABEL_H) * view.z
668
+ const beyond = (m) => x + w < -vw * m || x > vw * (1 + m) || y + h < -vh * m || y > vh * (1 + m)
669
+ if (!beyond(0.5)) near.push({ f, d: Math.hypot(x + w / 2 - vw / 2, y + Math.min(h, vh) / 2 - vh / 2) })
670
+ if (!beyond(1)) kept.add(f)
671
+ }
672
+ near.sort((a, b) => a.d - b.d)
673
+ const live = new Set([...frames.values()].filter(pinned))
674
+ if (view.z >= LIVE_ZOOM || shotsFailed()) {
675
+ for (const { f } of near) { if (live.size >= MAX_LIVE) break; live.add(f) }
676
+ // Frames just out of view stay live while there's room, so panning back and forth doesn't reload them.
677
+ for (const f of kept) { if (live.size >= MAX_LIVE) break; if (f.iframe) live.add(f) }
678
+ }
679
+ for (const f of frames.values()) if (f.iframe && !live.has(f)) unmount(f)
680
+ for (const f of live) mount(f)
681
+ shotQueue = near.filter(({ f }) => !f.el.classList.contains('ready')).map(({ f }) => [f, shotUrl(f.screen)])
682
+ pumpShots()
683
+ }
684
+
503
685
  function badge(f) {
504
686
  const b = f.el.querySelector('.badge')
505
687
  if (!checks.screens?.[f.screen.path]) { b.className = 'badge'; b.textContent = 'not checked'; return }
@@ -512,7 +694,8 @@ function badge(f) {
512
694
 
513
695
  function paintLabel(f) {
514
696
  const s = f.screen
515
- f.el.className = `frame ${s.device} ${s.status}${selected === f ? ' selected' : ''}`
697
+ const kept = ['ready', 'live', 'changed'].filter((c) => f.el.classList.contains(c))
698
+ f.el.className = ['frame', s.device, s.status, selected === f ? 'selected' : '', ...kept].filter(Boolean).join(' ')
516
699
  f.el.querySelector('.name').textContent = s.kind === 'components' ? 'Catalog' : s.device
517
700
  const pill = f.el.querySelector('.pill')
518
701
  pill.className = `pill ${s.status}`
@@ -527,23 +710,22 @@ function summary() {
527
710
  const review = versions.filter((s) => s.status === 'review').length
528
711
  const approved = new Set(versions.filter((s) => s.status === 'approved').map((s) => s.screen)).size
529
712
  const names = new Set(versions.map((s) => s.screen)).size
713
+ const pages = new Set(versions.map((s) => s.page || s.screen)).size
530
714
  const bad = versions.filter((s) => forTheme(checks.screens?.[s.path]).some((i) => i.severity === 'error')).length
531
- document.getElementById('summary').textContent = `${names} screens · ${approved} approved · ${review} need review${bad ? ` · ${bad} with errors` : ''}`
715
+ document.getElementById('summary').textContent = `${pages < names ? `${pages} pages · ` : ''}${names} screens · ${approved} approved · ${review} need review${bad ? ` · ${bad} with errors` : ''}`
532
716
  }
533
717
 
534
718
  function frameFor(s) {
535
719
  const el = document.createElement('div')
536
720
  // The frame is exactly as wide as its device; a long title is cut, never widens it.
537
721
  el.style.width = `${s.width}px`
538
- 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>`
539
- const iframe = el.querySelector('iframe')
540
- iframe.style.width = `${s.width}px`
541
- iframe.style.height = `${bigFrame(s) ? 800 : 844}px`
542
- iframe.src = `/${s.path}?pick${langQuery()}`
543
- const f = { el, iframe, screen: s, height: bigFrame(s) ? 800 : 844 }
544
- iframe.addEventListener('load', () => { applyTheme(f); measure(f); setTimeout(() => measure(f), 800); setTimeout(() => measure(f), 2500) })
722
+ 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"><img class="shot" alt="" decoding="async" draggable="false"><div class="shield"></div></div>`
723
+ const img = el.querySelector('.shot')
724
+ const f = { el, device: el.querySelector('.device'), img, iframe: null, screen: s, height: HEIGHTS[`${s.path}@${s.width}`] ?? minHeight(s), waiters: [] }
725
+ img.addEventListener('load', () => shotDone(f, true))
726
+ img.addEventListener('error', () => shotDone(f, false))
727
+ size(f)
545
728
  el.querySelector('.shield').addEventListener('click', () => select(f, false))
546
- el.querySelector('.shield').addEventListener('dblclick', () => { select(f, false); f.el.classList.add('live') })
547
729
  el.querySelector('.label').addEventListener('click', () => select(f, true))
548
730
  el.querySelector('.label .copy').addEventListener('click', (e) => { e.stopPropagation(); copy(ref(f.screen)) })
549
731
  world.append(el)
@@ -559,10 +741,11 @@ function select(f, zoom) {
559
741
  const prev = selected
560
742
  selected = f
561
743
  if (prev) paintLabel(prev)
562
- if (f) { paintLabel(f); inspector(f); if (zoom) zoomTo(f) }
744
+ if (f) { mount(f); paintLabel(f); inspector(f); if (zoom) zoomTo(f) }
563
745
  else document.getElementById('inspector').classList.remove('open')
564
746
  closeDoc()
565
747
  tree()
748
+ scheduleSync()
566
749
  }
567
750
 
568
751
  function inspector(f) {
@@ -650,7 +833,8 @@ async function setMeta(path, body) {
650
833
  }
651
834
 
652
835
  /** Outline the element in its frame and bring it to the middle of the canvas. */
653
- function locate(f, selector) {
836
+ async function locate(f, selector) {
837
+ await whenLive(f)
654
838
  let el
655
839
  try { el = f.iframe.contentDocument.querySelector(selector) } catch {}
656
840
  if (!el) return
@@ -674,7 +858,7 @@ let hoverBox = null
674
858
  function elementAt(e) {
675
859
  const hit = document.elementsFromPoint(e.clientX, e.clientY).find((n) => n.classList?.contains('shield'))
676
860
  const f = hit && [...frames.values()].find((g) => g.el.contains(hit))
677
- if (!f) return null
861
+ if (!f?.iframe) return null
678
862
  let doc
679
863
  try { doc = f.iframe.contentDocument } catch { return null }
680
864
  const r = f.iframe.getBoundingClientRect()
@@ -870,12 +1054,14 @@ events.onmessage = async (e) => {
870
1054
  // Stylesheets and scripts are shared: tokens.css, tailwind.tokens.js, token exports the screens link
871
1055
  // (tokens.tailwind.css), the components.
872
1056
  const shared = paths.some((p) => /\.(css|js|mjs)$/.test(p) || p.startsWith('components/'))
1057
+ if (shared) shotRev++
873
1058
  for (const f of frames.values()) {
874
1059
  if (shared || html.includes(f.screen.path)) {
875
- f.iframe.contentWindow?.location.reload()
1060
+ f.iframe?.contentWindow?.location.reload()
876
1061
  f.el.classList.remove('changed'); void f.el.offsetWidth; f.el.classList.add('changed')
877
1062
  }
878
1063
  }
1064
+ scheduleSync()
879
1065
  }
880
1066
 
881
1067
  // ---- pan, zoom, keys ----
@@ -931,7 +1117,7 @@ addEventListener('keydown', (e) => {
931
1117
  if (e.key === '0') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, 1)
932
1118
  if (e.key === '+' || e.key === '=') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z * 1.2)
933
1119
  if (e.key === '-') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z / 1.2)
934
- if (e.key === 'r' || e.key === 'R') for (const f of frames.values()) f.iframe.contentWindow?.location.reload()
1120
+ if (e.key === 'r' || e.key === 'R') reloadAll()
935
1121
  if (['j', 'k', 'ArrowDown', 'ArrowUp'].includes(e.key)) {
936
1122
  const list = visibleFrames()
937
1123
  if (!list.length) return
@@ -944,7 +1130,13 @@ addEventListener('keydown', (e) => {
944
1130
  })
945
1131
  document.getElementById('fit').onclick = fit
946
1132
  document.getElementById('pick').onclick = () => setPicking(!picking)
947
- document.getElementById('reload').onclick = () => { for (const f of frames.values()) f.iframe.contentWindow?.location.reload() }
1133
+ /** Reload the live frames and draw fresh pictures of the rest. */
1134
+ function reloadAll() {
1135
+ shotRev++
1136
+ for (const f of frames.values()) f.iframe?.contentWindow?.location.reload()
1137
+ scheduleSync()
1138
+ }
1139
+ document.getElementById('reload').onclick = reloadAll
948
1140
 
949
1141
  apply()
950
1142
  fetch('/api/config').then((r) => r.json()).then((c) => {
package/dist/i18n.d.ts CHANGED
@@ -9,6 +9,7 @@ export declare const isRtl: (locale: string) => boolean;
9
9
  export declare const allLocales: () => string[];
10
10
  /** The locales check runs: with an i18n block, the pseudo-languages plus the configured ones; else only --locales. */
11
11
  export declare function checkLocales(override?: string): string[];
12
+ export declare function messagesFile(locale: string): string | undefined;
12
13
  /**
13
14
  * A locale's messages: JSON, or a JS/TS module (its default export, else its first exported object; Node strips
14
15
  * the types). Re-read when the file changes. Empty when the file is missing.
package/dist/i18n.js CHANGED
@@ -49,7 +49,7 @@ export function checkLocales(override) {
49
49
  const c = i18nConfig().check;
50
50
  return [...PSEUDO, ...(c === 'all' ? realLocales() : (c ?? []))];
51
51
  }
52
- function messagesFile(locale) {
52
+ export function messagesFile(locale) {
53
53
  const m = i18nConfig().messages;
54
54
  if (!m)
55
55
  return undefined;
package/dist/list.js CHANGED
@@ -4,20 +4,31 @@
4
4
  * Ends with what makes the current version unclear (versionProblems); --strict fails on it.
5
5
  * Usage: stitch2 screens [screen name] [--status approved|review|draft|archived] [--json] [--strict]
6
6
  */
7
- import { listScreens, versionProblems } from './screens.js';
7
+ import { listScreens, stateOf, versionProblems } from './screens.js';
8
8
  const args = process.argv.slice(2);
9
9
  const status = args.includes('--status') ? args[args.indexOf('--status') + 1] : undefined;
10
10
  const name = args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--status');
11
11
  const rows = listScreens()
12
- .filter((s) => s.kind === 'screen' && (!status || s.status === status) && (!name || s.screen === name))
13
- .sort((a, b) => a.screen.localeCompare(b.screen) ||
12
+ .filter((s) => s.kind === 'screen' && (!status || s.status === status) && (!name || s.screen === name || s.page === name))
13
+ .sort((a, b) => a.page.localeCompare(b.page) ||
14
+ (a.screen === a.page ? -1 : b.screen === b.page ? 1 : 0) ||
15
+ a.screen.localeCompare(b.screen) ||
14
16
  a.version.localeCompare(b.version, undefined, { numeric: true }) ||
15
17
  b.device.localeCompare(a.device));
16
18
  if (args.includes('--json'))
17
19
  console.log(JSON.stringify(rows, null, 2));
18
- else
19
- for (const s of rows)
20
- console.log(`${s.screen.padEnd(20)} ${s.version.padEnd(8)} ${s.device.padEnd(8)} ${s.status.padEnd(9)} ${s.path}${s.note ? `\n${' '.repeat(39)}note: ${s.note}` : ''}`);
20
+ else {
21
+ let page = '';
22
+ for (const s of rows) {
23
+ // A page with several screens gets a heading; its screens are listed under it by their state.
24
+ const several = rows.some((r) => r.page === s.page && r.screen !== s.screen);
25
+ if (several && s.page !== page)
26
+ console.log(`\n${s.page} (page)`);
27
+ page = s.page;
28
+ const label = several ? ` ${stateOf(s)}` : s.screen;
29
+ console.log(`${label.padEnd(20)} ${s.version.padEnd(8)} ${s.device.padEnd(8)} ${s.status.padEnd(9)} ${s.path}${s.note ? `\n${' '.repeat(39)}note: ${s.note}` : ''}`);
30
+ }
31
+ }
21
32
  const problems = versionProblems().filter((p) => !name || p.startsWith(`${name} `));
22
33
  if (problems.length && !args.includes('--json')) {
23
34
  console.log(`\nWhich version is current is unclear (${problems.length}):`);
package/dist/screens.d.ts CHANGED
@@ -19,6 +19,12 @@ export interface Screen {
19
19
  kind: 'screen' | 'components';
20
20
  /** Which app screen this file is a version of (<meta name="<prefix>-screen">), e.g. "home". */
21
21
  screen: string;
22
+ /**
23
+ * The page it belongs to (<meta name="<prefix>-page">): one place in the app whose states and steps are screens of
24
+ * their own, e.g. "workout" for workout, workout-entry and workout-menu. A screen's latest version that names one
25
+ * decides it for all of its versions; without one a screen is its own page.
26
+ */
27
+ page: string;
22
28
  device: 'mobile' | 'desktop';
23
29
  /** Where the version came from: stitch, lab or trial. */
24
30
  source: 'stitch' | 'lab' | 'trial';
@@ -30,6 +36,8 @@ export interface Screen {
30
36
  }
31
37
  export declare const meta: (html: string, name: string) => string | undefined;
32
38
  export declare function listScreens(): Screen[];
39
+ /** What a screen is within its page: its name without the page's ("entry" for workout-entry), or "main". */
40
+ export declare const stateOf: (s: Pick<Screen, 'screen' | 'page'>) => string;
33
41
  /** Sets (or removes, with undefined) <prefix>-* meta tags in a screen file, keeping the rest of it untouched. */
34
42
  export declare function writeMeta(path: string, values: Record<string, string | undefined>): void;
35
43
  /**
package/dist/screens.js CHANGED
@@ -51,6 +51,7 @@ export function listScreens() {
51
51
  mtime: statSync(file).mtimeMs,
52
52
  kind: under(path, config.catalogDir) ? 'components' : 'screen',
53
53
  screen: meta(html, tag('screen')) ?? (versioned ? group.split('/').pop() : name),
54
+ page: meta(html, tag('page')) ?? '',
54
55
  device: (meta(html, tag('device')) ??
55
56
  (versioned?.[1] || (width >= 768 ? 'desktop' : 'mobile'))),
56
57
  source,
@@ -65,8 +66,17 @@ export function listScreens() {
65
66
  });
66
67
  }
67
68
  }
69
+ // One page per screen: the latest version that names one.
70
+ const pages = new Map();
71
+ for (const s of [...out].sort((a, b) => versionNumber(b) - versionNumber(a) || b.mtime - a.mtime))
72
+ if (s.page && !pages.has(s.screen))
73
+ pages.set(s.screen, s.page);
74
+ for (const s of out)
75
+ s.page = pages.get(s.screen) ?? s.screen;
68
76
  return out;
69
77
  }
78
+ /** What a screen is within its page: its name without the page's ("entry" for workout-entry), or "main". */
79
+ export const stateOf = (s) => s.screen === s.page ? 'main' : s.screen.startsWith(`${s.page}-`) ? s.screen.slice(s.page.length + 1) : s.screen;
70
80
  /** Sets (or removes, with undefined) <prefix>-* meta tags in a screen file, keeping the rest of it untouched. */
71
81
  export function writeMeta(path, values) {
72
82
  const file = join(ROOT, path);
package/dist/serve.js CHANGED
@@ -15,6 +15,7 @@ import { allLocales, localeScript, sourceLocale } from './i18n.js';
15
15
  import { designPage } from './design-page.js';
16
16
  import { languagesPage } from './languages-page.js';
17
17
  import { annotate } from './pick.js';
18
+ import { closeShots, sharedChanged, shot } from './shots.js';
18
19
  /**
19
20
  * Inlines <link rel="stylesheet" type="text/tailwindcss" href="…">: the Tailwind v4 browser build reads @theme and
20
21
  * @utility only from inline <style type="text/tailwindcss"> blocks. On one line, so source line numbers hold.
@@ -63,17 +64,21 @@ const TYPES = {
63
64
  '.js': 'text/javascript; charset=utf-8',
64
65
  '.json': 'application/json',
65
66
  '.png': 'image/png',
67
+ '.jpg': 'image/jpeg',
66
68
  '.svg': 'image/svg+xml',
67
69
  '.md': 'text/markdown; charset=utf-8',
68
70
  };
69
71
  export function startServer(port) {
70
72
  const clients = new Set();
71
73
  const changed = new Set();
74
+ let base = '';
72
75
  let timer;
73
76
  // Editors write files in bursts; one event per quiet moment is enough.
74
77
  const watcher = watch(ROOT, { recursive: true }, (_event, file) => {
75
78
  if (!file || file.startsWith('renders'))
76
79
  return;
80
+ if (!file.toString().endsWith('.html'))
81
+ sharedChanged();
77
82
  changed.add(file.toString());
78
83
  clearTimeout(timer);
79
84
  timer = setTimeout(() => {
@@ -149,6 +154,29 @@ export function startServer(port) {
149
154
  return json(listScreens());
150
155
  if (url.pathname === '/api/reports')
151
156
  return json(reportStates());
157
+ // A picture of a screen, for the frames the canvas doesn't keep live (off screen, or too small to read).
158
+ if (url.pathname === '/api/shot') {
159
+ const q = url.searchParams;
160
+ const job = shot(base, {
161
+ path: q.get('path') ?? '',
162
+ width: Math.min(4000, Math.max(200, Number(q.get('width')) || 390)),
163
+ theme: q.get('theme') || undefined,
164
+ lang: q.get('lang') || undefined,
165
+ });
166
+ if (!job) {
167
+ res.writeHead(404, { 'content-type': 'text/plain' });
168
+ return res.end('not found');
169
+ }
170
+ job.then((jpg) => {
171
+ // The canvas puts the screen's change time in the address, so a picture never changes under it.
172
+ res.writeHead(200, { 'content-type': TYPES['.jpg'], 'cache-control': 'private, max-age=86400' });
173
+ res.end(jpg);
174
+ }, (e) => {
175
+ res.writeHead(503, { 'content-type': 'text/plain' });
176
+ res.end(`no picture: ${String(e)}. Install Chromium for Playwright: npx playwright install chromium`);
177
+ });
178
+ return;
179
+ }
152
180
  if (url.pathname === '/api/checks') {
153
181
  const file = join(ROOT, 'checks.json');
154
182
  return json(existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { screens: {} });
@@ -195,6 +223,9 @@ export function startServer(port) {
195
223
  server.listen(port, host, () => {
196
224
  const address = server.address();
197
225
  const actual = typeof address === 'object' && address ? address.port : port;
226
+ // The renderer for pictures reaches the server where it listens; on every interface, through loopback.
227
+ const at = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host.includes(':') ? `[${host}]` : host;
228
+ base = `http://${at}:${actual}`;
198
229
  if (!process.env.HOST) {
199
230
  v6 = createServer((req, res) => server.emit('request', req, res));
200
231
  v6.on('error', () => { }).listen(actual, '::1');
@@ -206,6 +237,7 @@ export function startServer(port) {
206
237
  clearInterval(keepAlive);
207
238
  for (const c of clients)
208
239
  c.end();
240
+ void closeShots();
209
241
  server.close();
210
242
  v6?.close();
211
243
  },
package/dist/setup.js CHANGED
@@ -92,7 +92,7 @@ ${extra}<meta charset="utf-8" />
92
92
  <script src="/components/${prefix}.js" defer></script>
93
93
  </head>
94
94
  `;
95
- write(join(design, 'screens/_template.html'), `${head('Screen name', `<meta name="${prefix}-screen" content="screen-name" />\n<meta name="${prefix}-device" content="mobile" />\n<meta name="${prefix}-status" content="draft" />\n`)}<!--
95
+ write(join(design, 'screens/_template.html'), `${head('Screen name', `<meta name="${prefix}-screen" content="screen-name" />\n<meta name="${prefix}-page" content="screen-name" />\n<meta name="${prefix}-device" content="mobile" />\n<meta name="${prefix}-status" content="draft" />\n`)}<!--
96
96
  Template for ${name} screens. Copy it to ${design}/screens/<screen>/<device>-v<N>.html and set the ${prefix}-* meta
97
97
  tags (desktop: ${prefix}-device desktop and ${prefix}-frame-width 1280); status draft while you work, review when
98
98
  done. Files starting with _ are not shown on the canvas. Build with the components in the catalog
@@ -0,0 +1,11 @@
1
+ export interface ShotRequest {
2
+ path: string;
3
+ width: number;
4
+ theme?: string;
5
+ lang?: string;
6
+ }
7
+ /** The server's file watcher calls this: the next picture looks at the shared files again. */
8
+ export declare function sharedChanged(): void;
9
+ /** A JPEG of the screen as the canvas would show it, page-tall up to MAX_HEIGHT; null when the file is gone. */
10
+ export declare function shot(base: string, r: ShotRequest): Promise<Buffer> | null;
11
+ export declare function closeShots(): Promise<void>;
package/dist/shots.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Pictures of screens for the canvas. A live screen is a whole page (Tailwind's browser build, fonts, icons, the
3
+ * components), so a canvas with a hundred of them stalls the browser; the canvas keeps only the screens in view live
4
+ * and draws the rest from these. One headless Chromium renders them, three at a time, and they are kept on disk
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.
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { messagesFile, sourceLocale } from './i18n.js';
13
+ import { ROOT } from './screens.js';
14
+ /** The tallest picture, in CSS px: the same limit as the canvas frames and check's renders. */
15
+ const MAX_HEIGHT = 20000;
16
+ const DIR = join(tmpdir(), 'stitch2-shots');
17
+ /** Folders under the design directory that hold outputs, not inputs: they never change a picture. */
18
+ const OUTPUTS = new Set(['renders', 'consistency', 'compare', 'node_modules', '.git']);
19
+ const REPORTS = new Set(['checks.json', 'compare.json', 'type-audit.json']);
20
+ let shared;
21
+ /** The newest of the files screens share: anything under the design directory that isn't a screen or an output. */
22
+ function sharedStamp() {
23
+ if (shared !== undefined)
24
+ return shared;
25
+ let newest = 0;
26
+ const walk = (dir, depth) => {
27
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
28
+ if (e.isDirectory()) {
29
+ if (depth === 0 && OUTPUTS.has(e.name))
30
+ continue;
31
+ if (depth < 4)
32
+ walk(join(dir, e.name), depth + 1);
33
+ }
34
+ else if (!e.name.endsWith('.html') && !(depth === 0 && REPORTS.has(e.name)))
35
+ newest = Math.max(newest, statSync(join(dir, e.name)).mtimeMs);
36
+ }
37
+ };
38
+ walk(ROOT, 0);
39
+ return (shared = newest);
40
+ }
41
+ /** The server's file watcher calls this: the next picture looks at the shared files again. */
42
+ export function sharedChanged() {
43
+ shared = undefined;
44
+ }
45
+ function keyOf(r) {
46
+ const file = join(ROOT, r.path);
47
+ if (!existsSync(file))
48
+ return null;
49
+ const messages = r.lang && r.lang !== sourceLocale() ? messagesFile(r.lang) : undefined;
50
+ const parts = [
51
+ r.path,
52
+ statSync(file).mtimeMs,
53
+ sharedStamp(),
54
+ r.theme ?? '',
55
+ r.lang ?? '',
56
+ messages && existsSync(messages) ? statSync(messages).mtimeMs : '',
57
+ r.width,
58
+ ];
59
+ return createHash('sha1').update(parts.join('|')).digest('hex');
60
+ }
61
+ let browser;
62
+ let busy = 0;
63
+ const waiting = [];
64
+ const pending = new Map();
65
+ async function slot(work) {
66
+ while (busy >= 3)
67
+ await new Promise((go) => waiting.push(go));
68
+ busy++;
69
+ try {
70
+ return await work();
71
+ }
72
+ finally {
73
+ busy--;
74
+ waiting.shift()?.();
75
+ }
76
+ }
77
+ /** A JPEG of the screen as the canvas would show it, page-tall up to MAX_HEIGHT; null when the file is gone. */
78
+ export function shot(base, r) {
79
+ const key = keyOf(r);
80
+ if (!key)
81
+ return null;
82
+ const file = join(DIR, `${key}.jpg`);
83
+ if (existsSync(file))
84
+ return Promise.resolve(readFileSync(file));
85
+ const inFlight = pending.get(key);
86
+ if (inFlight)
87
+ return inFlight;
88
+ const job = slot(async () => {
89
+ browser ??= launch();
90
+ const page = await (await browser).newPage({ viewport: { width: r.width, height: 844 }, deviceScaleFactor: 1 });
91
+ try {
92
+ const lang = r.lang && r.lang !== sourceLocale() ? `?lang=${encodeURIComponent(r.lang)}` : '';
93
+ await page.goto(`${base}/${r.path}${lang}`, { waitUntil: 'networkidle', timeout: 30_000 }).catch(() => { });
94
+ if (r.theme)
95
+ await page.evaluate((t) => { document.documentElement.dataset.theme = t; }, r.theme);
96
+ await page.evaluate(() => document.fonts.ready);
97
+ // Tailwind's browser build and the components settle after load.
98
+ await page.waitForTimeout(400);
99
+ const height = await page.evaluate(() => Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0));
100
+ await page.setViewportSize({ width: r.width, height: Math.min(MAX_HEIGHT, Math.max(844, height)) });
101
+ const jpg = await page.screenshot({ type: 'jpeg', quality: 80 });
102
+ mkdirSync(DIR, { recursive: true });
103
+ writeFileSync(file, jpg);
104
+ return jpg;
105
+ }
106
+ finally {
107
+ await page.close();
108
+ }
109
+ });
110
+ pending.set(key, job);
111
+ const done = () => pending.delete(key);
112
+ job.then(done, done);
113
+ return job;
114
+ }
115
+ export async function closeShots() {
116
+ const b = await browser?.catch(() => undefined);
117
+ browser = undefined;
118
+ await b?.close();
119
+ }
120
+ /**
121
+ * Playwright's own signal handlers close the browser but leave the process running; the canvas would outlive
122
+ * Ctrl-C. Here a signal closes Chromium, then exits.
123
+ */
124
+ async function launch() {
125
+ const { chromium } = await import('@playwright/test');
126
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'])
127
+ process.once(signal, () => {
128
+ void closeShots().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143));
129
+ });
130
+ return chromium.launch({ handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false });
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitch2",
3
- "version": "0.16.0",
3
+ "version": "0.18.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",
@@ -13,10 +13,23 @@ and build on it. Meta tag names below use the project's prefix (`<p>`, from `sti
13
13
  (`mobile-v3.html` and `desktop-v3.html` are v3). A new version takes the next free N across the screen's
14
14
  variants; a variant added to an existing version takes that version's N (a desktop for v3 is
15
15
  `desktop-v3.html`, never v4). A version may start with one variant and get the others later.
16
- Its head carries `<p>-screen`, `<p>-device` (mobile | desktop), `<p>-status`, `<p>-source` (stitch | lab),
16
+ Its head carries `<p>-screen`, `<p>-page` (see Pages), `<p>-device` (mobile | desktop), `<p>-status`, `<p>-source` (stitch | lab),
17
17
  `<p>-frame-width` (390 mobile, 1280 desktop) and, written by the user, `<p>-note`. Older files elsewhere
18
18
  carry the same tags.
19
19
 
20
+ ## Pages and their screens
21
+
22
+ A place in the app often has several states or steps: the workout with its number pad, its menu and its discard
23
+ question; an exercise list with its detail sheet and a three-step form. Each is a **screen** of its own (it has its own
24
+ versions and approval), and they belong to one **page**: `<p>-page` in the head, the page's name (`workout`). The page's
25
+ main screen has the page's name; the others are named `<page>-<state>` (`workout-entry`, `workout-menu`,
26
+ `create-exercise-1`) and carry the same `<p>-page`. The canvas folds a page's screens under its name and lays them out
27
+ together; `stitch2 screens <page>` lists them. A screen without `<p>-page` is a page of its own.
28
+
29
+ - A new state of an existing place (a sheet, a dialog, an empty, loading or error state, a step) is a new screen in
30
+ that page, not a new page.
31
+ - Copy `<p>-page` along with the other tags into each new version; the latest version that has it decides the page.
32
+
20
33
  ## New version or edit?
21
34
 
22
35
  - **New version** for every new attempt: a new direction, a regeneration, an export from a generator, a