stitch2 0.17.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 +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/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