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