stitch2 0.17.0 → 0.18.1

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
@@ -85,7 +85,11 @@
85
85
  .frame.desktop .device { border-radius: 12px; }
86
86
  .frame.selected .device { outline: 3px solid var(--accent); }
87
87
  .frame.archived { opacity: .55; }
88
- .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; }
89
93
  .device .shield { cursor: pointer; inset: 0; position: absolute; }
90
94
  .frame.live .shield { display: none; }
91
95
  .frame.changed .device { animation: flash 900ms ease-out; }
@@ -190,10 +194,11 @@ function setLang(l) {
190
194
  lang = l
191
195
  localStorage.setItem('stitch2-lang', l)
192
196
  document.getElementById('langFilter').value = l
193
- 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) }
194
198
  for (const f of frames.values()) badge(f)
195
199
  if (selected) inspector(selected)
196
200
  summary()
201
+ scheduleSync()
197
202
  }
198
203
  // Widths: each variant covers a range of windows (config widths); frames show it drawn, at the narrowest or the widest.
199
204
  let WIDTHS = {}
@@ -208,7 +213,7 @@ function setWidth(mode) {
208
213
  for (const f of frames.values()) {
209
214
  f.screen.width = widthFor(f.screen)
210
215
  f.el.style.width = `${f.screen.width}px`
211
- f.iframe.style.width = `${f.screen.width}px`
216
+ size(f)
212
217
  setTimeout(() => measure(f), 300)
213
218
  setTimeout(() => measure(f), 1200)
214
219
  }
@@ -224,7 +229,7 @@ function widthOptions() {
224
229
  wsel.value = widthMode
225
230
  }
226
231
  function applyTheme(f) {
227
- if (!themed()) return
232
+ if (!themed() || !f.iframe) return
228
233
  try { f.iframe.contentDocument.documentElement.dataset.theme = theme } catch {}
229
234
  }
230
235
  function setTheme(t) {
@@ -236,6 +241,7 @@ function setTheme(t) {
236
241
  if (selected) inspector(selected)
237
242
  summary()
238
243
  sendFilter()
244
+ scheduleSync()
239
245
  }
240
246
  const widthNote = (s) => (s.width !== s.drawn ? ` at ${s.width}px` : '')
241
247
  const ref = (s) => (s.kind === 'components' ? `component catalog (${designDir}/${s.path})` : `${s.screen} ${s.device} ${s.version}${themeNote()}${widthNote(s)} (${designDir}/${s.path})`)
@@ -273,7 +279,14 @@ let screens = []
273
279
  let checks = { screens: {} }
274
280
  let reports = { consistency: null, compare: null }
275
281
  let selected = null
276
- 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() }) } }
277
290
 
278
291
  const apply = () => {
279
292
  world.style.transform = `translate(${view.x}px, ${view.y}px) scale(${view.z})`
@@ -283,6 +296,7 @@ const apply = () => {
283
296
  viewport.style.backgroundPosition = `${view.x}px ${view.y}px`
284
297
  document.getElementById('zoom').textContent = `${Math.round(view.z * 100)}%`
285
298
  localStorage.setItem('stitch2-view', JSON.stringify(view))
299
+ scheduleSync()
286
300
  }
287
301
  const zoomAt = (cx, cy, z) => {
288
302
  z = Math.min(4, Math.max(0.03, z))
@@ -398,6 +412,7 @@ function layout() {
398
412
  }
399
413
  tree()
400
414
  sendFilter()
415
+ scheduleSync()
401
416
  }
402
417
 
403
418
  // ---- reports follow the filters: the open report (consistency, app comparison, languages) gets the files the
@@ -545,19 +560,128 @@ function zoomTo(f) {
545
560
  const MAX_FRAME_HEIGHT = 20000
546
561
 
547
562
  function measure(f) {
563
+ if (!f.iframe) return
548
564
  try {
549
565
  const doc = f.iframe.contentDocument
550
- const min = bigFrame(f.screen) ? 800 : 844
551
566
  // As tall as the page, up to MAX_FRAME_HEIGHT: long landing pages are drawn whole.
552
- const h = Math.min(MAX_FRAME_HEIGHT, Math.max(min, doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0))
553
- if (Math.abs(h - f.height) > 2) {
554
- f.height = h
555
- f.iframe.style.height = `${h}px`
556
- layout()
557
- }
567
+ setHeight(f, Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0))
558
568
  } catch {}
559
569
  }
560
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
+
561
685
  function badge(f) {
562
686
  const b = f.el.querySelector('.badge')
563
687
  if (!checks.screens?.[f.screen.path]) { b.className = 'badge'; b.textContent = 'not checked'; return }
@@ -570,7 +694,8 @@ function badge(f) {
570
694
 
571
695
  function paintLabel(f) {
572
696
  const s = f.screen
573
- 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(' ')
574
699
  f.el.querySelector('.name').textContent = s.kind === 'components' ? 'Catalog' : s.device
575
700
  const pill = f.el.querySelector('.pill')
576
701
  pill.className = `pill ${s.status}`
@@ -594,15 +719,13 @@ function frameFor(s) {
594
719
  const el = document.createElement('div')
595
720
  // The frame is exactly as wide as its device; a long title is cut, never widens it.
596
721
  el.style.width = `${s.width}px`
597
- 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>`
598
- const iframe = el.querySelector('iframe')
599
- iframe.style.width = `${s.width}px`
600
- iframe.style.height = `${bigFrame(s) ? 800 : 844}px`
601
- iframe.src = `/${s.path}?pick${langQuery()}`
602
- const f = { el, iframe, screen: s, height: bigFrame(s) ? 800 : 844 }
603
- 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)
604
728
  el.querySelector('.shield').addEventListener('click', () => select(f, false))
605
- el.querySelector('.shield').addEventListener('dblclick', () => { select(f, false); f.el.classList.add('live') })
606
729
  el.querySelector('.label').addEventListener('click', () => select(f, true))
607
730
  el.querySelector('.label .copy').addEventListener('click', (e) => { e.stopPropagation(); copy(ref(f.screen)) })
608
731
  world.append(el)
@@ -618,10 +741,11 @@ function select(f, zoom) {
618
741
  const prev = selected
619
742
  selected = f
620
743
  if (prev) paintLabel(prev)
621
- if (f) { paintLabel(f); inspector(f); if (zoom) zoomTo(f) }
744
+ if (f) { mount(f); paintLabel(f); inspector(f); if (zoom) zoomTo(f) }
622
745
  else document.getElementById('inspector').classList.remove('open')
623
746
  closeDoc()
624
747
  tree()
748
+ scheduleSync()
625
749
  }
626
750
 
627
751
  function inspector(f) {
@@ -709,7 +833,8 @@ async function setMeta(path, body) {
709
833
  }
710
834
 
711
835
  /** Outline the element in its frame and bring it to the middle of the canvas. */
712
- function locate(f, selector) {
836
+ async function locate(f, selector) {
837
+ await whenLive(f)
713
838
  let el
714
839
  try { el = f.iframe.contentDocument.querySelector(selector) } catch {}
715
840
  if (!el) return
@@ -733,7 +858,7 @@ let hoverBox = null
733
858
  function elementAt(e) {
734
859
  const hit = document.elementsFromPoint(e.clientX, e.clientY).find((n) => n.classList?.contains('shield'))
735
860
  const f = hit && [...frames.values()].find((g) => g.el.contains(hit))
736
- if (!f) return null
861
+ if (!f?.iframe) return null
737
862
  let doc
738
863
  try { doc = f.iframe.contentDocument } catch { return null }
739
864
  const r = f.iframe.getBoundingClientRect()
@@ -929,12 +1054,14 @@ events.onmessage = async (e) => {
929
1054
  // Stylesheets and scripts are shared: tokens.css, tailwind.tokens.js, token exports the screens link
930
1055
  // (tokens.tailwind.css), the components.
931
1056
  const shared = paths.some((p) => /\.(css|js|mjs)$/.test(p) || p.startsWith('components/'))
1057
+ if (shared) shotRev++
932
1058
  for (const f of frames.values()) {
933
1059
  if (shared || html.includes(f.screen.path)) {
934
- f.iframe.contentWindow?.location.reload()
1060
+ f.iframe?.contentWindow?.location.reload()
935
1061
  f.el.classList.remove('changed'); void f.el.offsetWidth; f.el.classList.add('changed')
936
1062
  }
937
1063
  }
1064
+ scheduleSync()
938
1065
  }
939
1066
 
940
1067
  // ---- pan, zoom, keys ----
@@ -990,7 +1117,7 @@ addEventListener('keydown', (e) => {
990
1117
  if (e.key === '0') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, 1)
991
1118
  if (e.key === '+' || e.key === '=') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z * 1.2)
992
1119
  if (e.key === '-') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z / 1.2)
993
- 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()
994
1121
  if (['j', 'k', 'ArrowDown', 'ArrowUp'].includes(e.key)) {
995
1122
  const list = visibleFrames()
996
1123
  if (!list.length) return
@@ -1003,7 +1130,13 @@ addEventListener('keydown', (e) => {
1003
1130
  })
1004
1131
  document.getElementById('fit').onclick = fit
1005
1132
  document.getElementById('pick').onclick = () => setPicking(!picking)
1006
- 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
1007
1140
 
1008
1141
  apply()
1009
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/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
  },
@@ -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.17.0",
3
+ "version": "0.18.1",
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",
@@ -38,6 +38,9 @@ References, read before the first screen:
38
38
  2. **Inventory.** List every element the brief names, in order, with its real data. That list is the screen:
39
39
  add nothing (status bars, step labels, tips, helper text, extra buttons), drop nothing. Next to each element
40
40
  write the catalog component that shows it; adapt or extend components rather than inventing markup.
41
+ Then list the page's **states**. A page that shows data gets an empty screen and a loading screen, designed
42
+ with its main screen in the same version; a page that loads or saves data that can fail also gets an error
43
+ state. Build them with the main screen; they are part of the page (screens.md, Empty, loading and error).
41
44
  3. **Hierarchy before markup.** Name the one thing the user came for, the single primary action (the only
42
45
  accent-filled element in its region), and what is secondary. Give every text element a type level and a
43
46
  colour before writing markup.
@@ -66,7 +69,8 @@ References, read before the first screen:
66
69
  6. **Look and critique.** View the render in `<design>/renders/` (crop the first screenful). Score it with the
67
70
  rubric in principles.md, fix what fails, repeat 5–6 at most three times.
68
71
  7. **Hand over.** Set the version's status to `review`, point the user to it on the canvas, and report what
69
- changed, how you answered their notes, and any rubric item you could not satisfy. The user approves; only then
72
+ changed, how you answered their notes, and any rubric item you could not satisfy. The page's empty and loading
73
+ screens go to review with it; for a page without them (a form, a static page), say why. The user approves; only then
70
74
  does it go into the app (the approved version is the one current version: see screens.md).
71
75
 
72
76
  ## Commands
@@ -56,7 +56,8 @@ short labels never wrap **(check)**.
56
56
  axis or month labels, a legend for a colour scale, captions under figures. They add no new facts.
57
57
  - Icons that stand for different things look different; one generic glyph repeated down a list says nothing.
58
58
  - Charts: one series in the accent, thin lines, few gridlines; label a single line directly instead of a legend.
59
- - Empty and loading states are designed too: one sentence and one action; skeletons the size of the content.
59
+ - Empty and loading states are designed with every page that shows data: one sentence and one action; skeletons
60
+ the size of the content (screens.md, Empty, loading and error).
60
61
 
61
62
  ## Touch and mobile
62
63
 
@@ -86,3 +87,5 @@ Score each pass or fail from the render; done when all pass or the failures are
86
87
  8. **Data realism:** names, numbers and dates look like a real person's data.
87
88
  9. **Consistency:** built from the catalog components; no `reuse` or `consistency` warnings.
88
89
  10. **Checks:** `stitch2 check --strict` reports no errors.
90
+ 11. **States:** the page has its empty and loading screens (and error when data can fail), built from the same
91
+ components and layout as the main screen; or the hand-over says why it needs none.
@@ -30,6 +30,32 @@ together; `stitch2 screens <page>` lists them. A screen without `<p>-page` is a
30
30
  that page, not a new page.
31
31
  - Copy `<p>-page` along with the other tags into each new version; the latest version that has it decides the page.
32
32
 
33
+ ## Empty, loading and error
34
+
35
+ Every page that shows data has these states, and the app shows them on most first visits: a new user sees the
36
+ empty screen before any other, and every user sees the loading one. Design them with the page's main screen,
37
+ not after it.
38
+
39
+ - **Which pages:** any page that lists, counts or charts the user's data, or loads it from a server. A form, a
40
+ dialog, a static page (sign-in, a landing page) needs none; say so in the hand-over.
41
+ - **Names:** `<page>-empty` and `<page>-loading` (`history-empty`, `history-loading`), `<page>-error` when it
42
+ has one, with the page's `<p>-page`. Same version number and same variants (mobile, desktop) as the main
43
+ screen. A page whose main screen is a new version gets new versions of its states when they change.
44
+ - **Empty:** the page's header and navigation stay where they are. In place of the content: what this place is
45
+ for in one sentence, and the one action that fills it ("Create your first routine"). Use the page's own
46
+ components where they help (a suggested program as a list row), not a large illustration. An empty search or
47
+ filter result is a different empty: it says nothing matched and how to widen it (clear the filter), and keeps
48
+ the search field and filters.
49
+ - **Loading:** the same layout as the main screen, with skeletons in place of the data: blocks the size and
50
+ shape of the rows, cards and numbers that will arrive (typography.md, Text in each state). The header,
51
+ navigation and buttons that need no data stay real and usable. No spinner in the middle of the page and no
52
+ "Loading…" text; a spinner belongs only inside a button or a row that is saving. Build skeletons from a
53
+ catalog component, so every page loads the same way.
54
+ - **Error:** the page's frame stays; where the data would be, one sentence on what failed and a Retry action.
55
+ Data already shown stays on screen; a failed save says so next to what failed and keeps the user's input.
56
+ - **Partial states** follow the same rules inside a section: an empty list in a page that has other data shows
57
+ one line and an action in that section only.
58
+
33
59
  ## New version or edit?
34
60
 
35
61
  - **New version** for every new attempt: a new direction, a regeneration, an export from a generator, a
@@ -25,6 +25,11 @@ a component rebuilt by hand. The checks can only judge what the canvas draws, so
25
25
  single current version and the app matches it.
26
26
  4. **App**: the app picks the component up; wire it, then run `stitch2 compare <screen>`.
27
27
 
28
+ A page in the app is its main screen and its states: implement its approved empty, loading and error screens with
29
+ it. The app's loader is the loading screen's skeleton, shown until the data arrives, never a blank page or a lone
30
+ spinner; data appearing all at once with no loading state is a bug. A page whose states were never designed goes
31
+ back to the canvas for them first.
32
+
28
33
  Fixing a bug found in the app follows the same path: reproduce it on the canvas (a screen or catalog state that
29
34
  shows it), fix the component, check, then the app. If the canvas cannot show the bug, the canvas is missing a
30
35
  state: add it.
@@ -69,4 +74,3 @@ Read the findings as:
69
74
  2. `stitch2 check --strict` passes on the screens you touched (archived screens excepted).
70
75
  3. `stitch2 compare` has no errors for the screens you touched; warnings are explained (a kept feature) or fixed.
71
76
  4. The user has seen the canvas versions of anything that changed how a component looks.
72
-