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 +2 -0
- package/dist/canvas.html +160 -27
- package/dist/i18n.d.ts +1 -0
- package/dist/i18n.js +1 -1
- package/dist/serve.js +32 -0
- package/dist/shots.d.ts +11 -0
- package/dist/shots.js +131 -0
- package/package.json +1 -1
- package/skills/stitch2/SKILL.md +5 -1
- package/skills/stitch2/references/principles.md +4 -1
- package/skills/stitch2/references/screens.md +26 -0
- package/skills/stitch2-app-sync/SKILL.md +5 -1
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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"><
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
|
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')
|
|
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
|
-
|
|
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
|
},
|
package/dist/shots.d.ts
ADDED
|
@@ -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
package/skills/stitch2/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|