stitch2 0.14.0 → 0.15.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 +7 -2
- package/config.schema.json +5 -0
- package/dist/canvas.html +120 -15
- package/dist/check.js +27 -6
- package/dist/compare.js +39 -11
- package/dist/config.d.ts +10 -0
- package/dist/config.js +4 -0
- package/dist/consistency.js +8 -1
- package/dist/i18n-report.js +10 -3
- package/dist/i18n.js +15 -7
- package/dist/languages-page.js +18 -4
- package/dist/report-filter.d.ts +7 -0
- package/dist/report-filter.js +23 -0
- package/dist/report.js +5 -3
- package/dist/serve.d.ts +14 -0
- package/dist/serve.js +35 -5
- package/dist/typecheck.d.ts +2 -0
- package/package.json +1 -1
- package/skills/stitch2/SKILL.md +5 -3
- package/skills/stitch2/references/screens.md +25 -7
- package/skills/stitch2-app-sync/SKILL.md +4 -1
- package/skills/stitch2-i18n/SKILL.md +2 -1
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ file that already exists. From there the agent spends its effort on the design.
|
|
|
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
39
|
With a light and a dark theme (DESIGN.md's `light-*` colours), a switch (`T`) shows every screen in either one.
|
|
40
|
+
A width switch (`W`) shows every screen at the narrowest or widest window its layout covers.
|
|
40
41
|
With languages configured, a Language switch (`L`) shows every screen in one of them, and the Languages page
|
|
41
42
|
shows a screen side by side in each.
|
|
42
43
|
Pick mode (`P`) highlights any element of a screen and copies a reference with its source line, such as
|
|
@@ -44,7 +45,8 @@ file that already exists. From there the agent spends its effort on the design.
|
|
|
44
45
|
several to copy at once or add to the version's note.
|
|
45
46
|
- **Checks** (`stitch2 check`): content past the frame edge or out of its card, labels that wrap, text running into
|
|
46
47
|
other text, labels cut short with an ellipsis, content under fixed bars, fixed bars that overlap each other, tap
|
|
47
|
-
targets (WCAG 2.2),
|
|
48
|
+
targets (WCAG 2.2), all of it again at both ends of the window widths each layout covers (360 and 599 px for a
|
|
49
|
+
phone, 1024 and 1920 for desktop, or your own ranges), text contrast in every theme, text that is not one of DESIGN.md's type levels, capitals without tracking,
|
|
48
50
|
large numbers in monospace, components built by hand, components that differ between screens, and colours
|
|
49
51
|
written as values instead of DESIGN.md tokens. Google's guidelines (Material 3, Android's app quality
|
|
50
52
|
guidelines, web.dev, Lighthouse) add warnings for text under 11 px, text closer than 16 px (24 px from 600 px)
|
|
@@ -153,7 +155,10 @@ what differs in structure: shared components the design has and the app lacks (o
|
|
|
153
155
|
order, and how the measured ones look. Mock-ups and the app hold different data, so the screenshots are shown side
|
|
154
156
|
by side in `design/compare/index.html` (the canvas links it) rather than diffed. It also runs check's layout
|
|
155
157
|
rules on the app (content past the edge, fixed bars or controls overlapping), which catches a control the app
|
|
156
|
-
squeezed in although no screen drew it.
|
|
158
|
+
squeezed in although no screen drew it. Each version is then compared again at both edges of its variant’s width
|
|
159
|
+
range (`widths` in the config), so a layout that holds at 390 px but breaks at 1023 px shows up as "at 1023px: …".
|
|
160
|
+
Compare and the consistency report run only when you call them; the canvas sidebar shows when each last ran and,
|
|
161
|
+
once screens it covers have changed since, how many (hover for the list). The `stitch2-app-sync` skill describes the workflow around it: a visible
|
|
157
162
|
change goes component, canvas, approval, then app. Configure it in the config:
|
|
158
163
|
|
|
159
164
|
```json
|
package/config.schema.json
CHANGED
|
@@ -62,6 +62,11 @@
|
|
|
62
62
|
"description": "Tap targets smaller than this (px) get a warning.",
|
|
63
63
|
"default": 32
|
|
64
64
|
},
|
|
65
|
+
"widths": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"description": "The window widths each variant covers, [narrowest, widest] in px, by device (mobile, tablet, desktop). check renders every variant at both ends; the canvas shows them. Defaults: mobile 360–599, tablet 600–1023, desktop 1024–1920.",
|
|
68
|
+
"additionalProperties": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }
|
|
69
|
+
},
|
|
65
70
|
"themes": {
|
|
66
71
|
"type": "array",
|
|
67
72
|
"items": { "type": "string" },
|
package/dist/canvas.html
CHANGED
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
.item.on { background: #2a1a12; color: var(--text); }
|
|
52
52
|
.item .meta { color: var(--faint); font-size: 11px; margin-left: auto; white-space: nowrap; }
|
|
53
53
|
.item.variant { padding-left: 30px; }
|
|
54
|
+
.item.stale .meta { color: var(--warn); }
|
|
54
55
|
#inspector .version-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; }
|
|
55
56
|
#inspector .version-actions span { color: var(--muted); }
|
|
56
57
|
#inspector .version-actions button { background: var(--panel2); border: 1px solid var(--edge); border-radius: 8px; cursor: pointer; padding: 3px 8px; }
|
|
@@ -129,6 +130,7 @@
|
|
|
129
130
|
<details id="versionFilter" class="multi" hidden title="Show only the versions you pick (several at once), or all of them"><summary>All versions</summary><div class="menu"></div></details>
|
|
130
131
|
<details id="variantFilter" class="multi" hidden title="Show only the variants you pick: mobile, desktop (several at once), or all of them"><summary>All variants</summary><div class="menu"></div></details>
|
|
131
132
|
<select id="themeFilter" class="pick" hidden title="Show every screen in this theme (T)" aria-label="Theme"></select>
|
|
133
|
+
<select id="widthFilter" class="pick" title="Show every screen at the narrowest or widest window its variant covers (W)" aria-label="Width"></select>
|
|
132
134
|
<select id="langFilter" class="pick" title="Show every screen in this language (L)" aria-label="Language"></select>
|
|
133
135
|
</header>
|
|
134
136
|
<nav id="tree"></nav>
|
|
@@ -186,6 +188,34 @@ function setLang(l) {
|
|
|
186
188
|
if (selected) inspector(selected)
|
|
187
189
|
summary()
|
|
188
190
|
}
|
|
191
|
+
// Widths: each variant covers a range of windows (config widths); frames show it drawn, at the narrowest or the widest.
|
|
192
|
+
let WIDTHS = {}
|
|
193
|
+
let widthMode = localStorage.getItem('stitch2-width') ?? 'drawn'
|
|
194
|
+
const rangeOf = (s) => WIDTHS[s.device]
|
|
195
|
+
const widthFor = (s) => { const r = rangeOf(s); return !r || widthMode === 'drawn' ? s.drawn : widthMode === 'narrow' ? r[0] : r[1] }
|
|
196
|
+
const bigFrame = (s) => (s.drawn ?? s.width) > 700
|
|
197
|
+
function setWidth(mode) {
|
|
198
|
+
widthMode = mode
|
|
199
|
+
localStorage.setItem('stitch2-width', mode)
|
|
200
|
+
document.getElementById('widthFilter').value = mode
|
|
201
|
+
for (const f of frames.values()) {
|
|
202
|
+
f.screen.width = widthFor(f.screen)
|
|
203
|
+
f.el.style.width = `${f.screen.width}px`
|
|
204
|
+
f.iframe.style.width = `${f.screen.width}px`
|
|
205
|
+
setTimeout(() => measure(f), 300)
|
|
206
|
+
setTimeout(() => measure(f), 1200)
|
|
207
|
+
}
|
|
208
|
+
layout()
|
|
209
|
+
if (selected) inspector(selected)
|
|
210
|
+
}
|
|
211
|
+
/** The width dropdown names the edges of the devices the screens use: "Narrowest: mobile 360, desktop 1024". */
|
|
212
|
+
function widthOptions() {
|
|
213
|
+
const used = [...new Set(screens.map((s) => s.device))].filter((d) => WIDTHS[d]).sort((a, b) => WIDTHS[a][0] - WIDTHS[b][0])
|
|
214
|
+
const edge = (k) => used.map((d) => `${d} ${WIDTHS[d][k]}`).join(', ')
|
|
215
|
+
const wsel = document.getElementById('widthFilter')
|
|
216
|
+
wsel.innerHTML = `<option value="drawn">Drawn width</option><option value="narrow">Narrowest: ${edge(0)}</option><option value="wide">Widest: ${edge(1)}</option>`
|
|
217
|
+
wsel.value = widthMode
|
|
218
|
+
}
|
|
189
219
|
function applyTheme(f) {
|
|
190
220
|
if (!themed()) return
|
|
191
221
|
try { f.iframe.contentDocument.documentElement.dataset.theme = theme } catch {}
|
|
@@ -198,8 +228,10 @@ function setTheme(t) {
|
|
|
198
228
|
for (const f of frames.values()) badge(f)
|
|
199
229
|
if (selected) inspector(selected)
|
|
200
230
|
summary()
|
|
231
|
+
sendFilter()
|
|
201
232
|
}
|
|
202
|
-
const
|
|
233
|
+
const widthNote = (s) => (s.width !== s.drawn ? ` at ${s.width}px` : '')
|
|
234
|
+
const ref = (s) => (s.kind === 'components' ? `component catalog (${designDir}/${s.path})` : `${s.screen} ${s.device} ${s.version}${themeNote()}${widthNote(s)} (${designDir}/${s.path})`)
|
|
203
235
|
let toastTimer
|
|
204
236
|
async function copy(text) {
|
|
205
237
|
try {
|
|
@@ -232,6 +264,7 @@ const filters = JSON.parse(localStorage.getItem('stitch2-filters') ?? 'null') ??
|
|
|
232
264
|
if (typeof filters.device === 'string') { filters.devices = filters.device === 'all' ? [] : [filters.device]; delete filters.device }
|
|
233
265
|
let screens = []
|
|
234
266
|
let checks = { screens: {} }
|
|
267
|
+
let reports = { consistency: null, compare: null }
|
|
235
268
|
let selected = null
|
|
236
269
|
const frames = new Map() // path -> { el, iframe, screen, height }
|
|
237
270
|
|
|
@@ -271,7 +304,7 @@ const VERSION_H = 44, VERSION_GAP = 120
|
|
|
271
304
|
function shown(s) {
|
|
272
305
|
const q = document.getElementById('search').value.trim().toLowerCase()
|
|
273
306
|
if (q && !`${s.screen} ${s.title} ${s.path} ${s.version} ${s.source}`.toLowerCase().includes(q)) return false
|
|
274
|
-
if (filters.devices?.length &&
|
|
307
|
+
if (filters.devices?.length && !filters.devices.includes(s.device)) return false
|
|
275
308
|
if (filters.versions?.length && s.kind === 'screen' && !filters.versions.includes(s.version)) return false
|
|
276
309
|
if (s.kind === 'components') return filters.status !== 'review' && filters.status !== 'approved'
|
|
277
310
|
if (filters.status === 'active') return s.status !== 'archived'
|
|
@@ -330,8 +363,37 @@ function layout() {
|
|
|
330
363
|
x += groupW + GROUP_GAP
|
|
331
364
|
}
|
|
332
365
|
tree()
|
|
366
|
+
sendFilter()
|
|
333
367
|
}
|
|
334
368
|
|
|
369
|
+
// ---- reports follow the filters: the open report (consistency, app comparison, languages) gets the files the
|
|
370
|
+
// canvas shows and hides the rest; the catalog follows only the variant filter there, as the reference ----
|
|
371
|
+
function reportPaths() {
|
|
372
|
+
return screens.filter((s) => (s.kind === 'components' ? !filters.devices?.length || filters.devices.includes(s.device) : shown(s))).map((s) => s.path)
|
|
373
|
+
}
|
|
374
|
+
function filterLabel() {
|
|
375
|
+
const q = document.getElementById('search').value.trim()
|
|
376
|
+
return [
|
|
377
|
+
...(filters.versions ?? []),
|
|
378
|
+
...(filters.devices ?? []),
|
|
379
|
+
filters.status === 'approved' ? 'approved' : filters.status === 'review' ? 'needs review' : '',
|
|
380
|
+
q ? `“${q}”` : '',
|
|
381
|
+
].filter(Boolean).join(' · ')
|
|
382
|
+
}
|
|
383
|
+
var sentFilter = ''
|
|
384
|
+
function sendFilter(force) {
|
|
385
|
+
const doc = document.getElementById('doc')
|
|
386
|
+
if (!doc.classList.contains('open') || !['/consistency/index.html', '/compare/index.html', '/languages'].includes(docUrl)) return
|
|
387
|
+
const message = { stitch2Filter: { paths: reportPaths(), label: filterLabel(), theme: themed() ? theme : '' } }
|
|
388
|
+
const key = JSON.stringify(message)
|
|
389
|
+
if (!force && key === sentFilter) return
|
|
390
|
+
sentFilter = key
|
|
391
|
+
doc.querySelector('iframe').contentWindow?.postMessage(message, location.origin)
|
|
392
|
+
}
|
|
393
|
+
// A report asks for the filters as soon as its script runs (its load event waits for every frame inside it).
|
|
394
|
+
addEventListener('message', (e) => { if (e.origin === location.origin && e.data?.stitch2Ready) sendFilter(true) })
|
|
395
|
+
document.querySelector('#doc iframe').addEventListener('load', () => sendFilter(true)) // reports made before stitch2Ready
|
|
396
|
+
|
|
335
397
|
function tree() {
|
|
336
398
|
const nav = document.getElementById('tree')
|
|
337
399
|
nav.innerHTML = ''
|
|
@@ -353,15 +415,30 @@ function tree() {
|
|
|
353
415
|
head('Design system')
|
|
354
416
|
const docOpen = (url) => document.getElementById('doc').classList.contains('open') && docUrl === url
|
|
355
417
|
item('DESIGN.md', 'tokens · rules', docOpen('/design'), () => openDoc('/design'))
|
|
356
|
-
|
|
357
|
-
|
|
418
|
+
// One entry for the catalog: the variant filter picks which ones (mobile, desktop); a click steps through them.
|
|
419
|
+
const catalogs = rows().find((r) => r.key === '~components')?.visible ?? []
|
|
420
|
+
const onCatalog = selected?.screen.kind === 'components'
|
|
421
|
+
item('Components', catalogs.length ? `catalog · ${catalogs.map((s) => s.device).join(', ')}` : 'hidden by the filters', onCatalog, () => {
|
|
422
|
+
if (!catalogs.length) return
|
|
423
|
+
const i = catalogs.findIndex((s) => s.path === selected?.screen.path)
|
|
424
|
+
select(frames.get(catalogs[(i + 1) % catalogs.length].path), true)
|
|
425
|
+
}, '', '', onCatalog ? ref(selected.screen) : catalogs[0] ? ref(catalogs[0]) : '')
|
|
426
|
+
// Each report says when it ran and how many of the files it covers, among those the filters show, changed
|
|
427
|
+
// since (then it's stale: run it again).
|
|
428
|
+
const keep = new Set(reportPaths())
|
|
429
|
+
const report = (label, url, state, command) => {
|
|
430
|
+
const changed = state?.changed.filter((p) => keep.has(p) || p.startsWith('components/')) ?? []
|
|
431
|
+
const n = changed.length
|
|
432
|
+
const meta = !state ? 'not run yet' : n ? `${n} changed since` : ago(state.at)
|
|
433
|
+
const b = item(label, meta, docOpen(url), () => openDoc(url), '', n || !state ? 'stale' : '')
|
|
434
|
+
b.title = !state
|
|
435
|
+
? `Run ${command}`
|
|
436
|
+
: `Made ${new Date(state.at).toLocaleString()}${n ? `. Changed since, run ${command} again:\n${changed.slice(0, 12).join('\n')}${n > 12 ? `\n… and ${n - 12} more` : ''}` : ', up to date'}`
|
|
437
|
+
}
|
|
438
|
+
report('Consistency report', '/consistency/index.html', reports.consistency, 'stitch2 consistency')
|
|
439
|
+
report('App comparison', '/compare/index.html', reports.compare, 'stitch2 compare')
|
|
358
440
|
item('Languages', 'screens × languages', docOpen('/languages'), () => openDoc('/languages'))
|
|
359
441
|
const all = rows()
|
|
360
|
-
const comp = all.find((r) => r.key === '~components')
|
|
361
|
-
if (comp?.visible.length) {
|
|
362
|
-
head('Components')
|
|
363
|
-
for (const s of comp.visible) item(s.title.replace(/^[^—]+ — /, ''), 'catalog', selected?.screen.path === s.path, () => select(frames.get(s.path), true), '', '', ref(s))
|
|
364
|
-
}
|
|
365
442
|
head('Screens')
|
|
366
443
|
for (const row of all) {
|
|
367
444
|
if (row.key === '~components' || !row.visible.length) continue
|
|
@@ -406,11 +483,15 @@ function zoomTo(f) {
|
|
|
406
483
|
apply()
|
|
407
484
|
}
|
|
408
485
|
|
|
486
|
+
/** The tallest a frame is drawn, in CSS px (the same limit as check's renders). */
|
|
487
|
+
const MAX_FRAME_HEIGHT = 20000
|
|
488
|
+
|
|
409
489
|
function measure(f) {
|
|
410
490
|
try {
|
|
411
491
|
const doc = f.iframe.contentDocument
|
|
412
|
-
const min = f.screen
|
|
413
|
-
|
|
492
|
+
const min = bigFrame(f.screen) ? 800 : 844
|
|
493
|
+
// As tall as the page, up to MAX_FRAME_HEIGHT: long landing pages are drawn whole.
|
|
494
|
+
const h = Math.min(MAX_FRAME_HEIGHT, Math.max(min, doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0))
|
|
414
495
|
if (Math.abs(h - f.height) > 2) {
|
|
415
496
|
f.height = h
|
|
416
497
|
f.iframe.style.height = `${h}px`
|
|
@@ -457,9 +538,9 @@ function frameFor(s) {
|
|
|
457
538
|
el.innerHTML = `<div class="label"><span class="name"></span><span class="pill"></span><span class="title"></span><button class="copy" type="button" title="Copy a reference to paste to an agent">Copy</button><button class="badge" type="button"></button></div><div class="device"><iframe loading="lazy" scrolling="no"></iframe><div class="shield"></div></div>`
|
|
458
539
|
const iframe = el.querySelector('iframe')
|
|
459
540
|
iframe.style.width = `${s.width}px`
|
|
460
|
-
iframe.style.height = `${s
|
|
541
|
+
iframe.style.height = `${bigFrame(s) ? 800 : 844}px`
|
|
461
542
|
iframe.src = `/${s.path}?pick${langQuery()}`
|
|
462
|
-
const f = { el, iframe, screen: s, height: s
|
|
543
|
+
const f = { el, iframe, screen: s, height: bigFrame(s) ? 800 : 844 }
|
|
463
544
|
iframe.addEventListener('load', () => { applyTheme(f); measure(f); setTimeout(() => measure(f), 800); setTimeout(() => measure(f), 2500) })
|
|
464
545
|
el.querySelector('.shield').addEventListener('click', () => select(f, false))
|
|
465
546
|
el.querySelector('.shield').addEventListener('dblclick', () => { select(f, false); f.el.classList.add('live') })
|
|
@@ -552,7 +633,11 @@ function inspector(f) {
|
|
|
552
633
|
li.querySelector('.sev').textContent = i.severity
|
|
553
634
|
li.querySelector('.msg').textContent = i.message + (i.text ? ` — “${i.text}”` : '')
|
|
554
635
|
li.querySelector('code').textContent = i.selector
|
|
555
|
-
li.onclick = () =>
|
|
636
|
+
li.onclick = () => {
|
|
637
|
+
const r = rangeOf(s)
|
|
638
|
+
if (i.width && r && i.width !== s.width) { setWidth(i.width === r[0] ? 'narrow' : 'wide'); setTimeout(() => locate(f, i.selector), 900) }
|
|
639
|
+
else locate(f, i.selector)
|
|
640
|
+
}
|
|
556
641
|
ul.append(li)
|
|
557
642
|
}
|
|
558
643
|
box.classList.add('open')
|
|
@@ -674,9 +759,22 @@ async function loadChecks() {
|
|
|
674
759
|
if (selected) inspector(selected)
|
|
675
760
|
summary()
|
|
676
761
|
}
|
|
762
|
+
async function loadReports() {
|
|
763
|
+
reports = await fetch('/api/reports').then((r) => r.json()).catch(() => reports)
|
|
764
|
+
tree()
|
|
765
|
+
}
|
|
766
|
+
setInterval(loadReports, 60_000) // keeps "5 min ago" honest
|
|
767
|
+
function ago(t) {
|
|
768
|
+
const m = Math.round((Date.now() - t) / 60000)
|
|
769
|
+
if (m < 1) return 'just now'
|
|
770
|
+
if (m < 60) return `${m} min ago`
|
|
771
|
+
const h = Math.round(m / 60)
|
|
772
|
+
return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`
|
|
773
|
+
}
|
|
677
774
|
|
|
678
775
|
async function load(first, given) {
|
|
679
776
|
const next = given ?? (await fetch('/api/screens').then((r) => r.json()))
|
|
777
|
+
for (const s of next) { s.drawn = s.width; s.width = widthFor(s) }
|
|
680
778
|
const keep = new Set(next.map((s) => s.path))
|
|
681
779
|
for (const [p, f] of frames) if (!keep.has(p)) { f.el.remove(); frames.delete(p); if (selected === f) selected = null }
|
|
682
780
|
for (const s of next) {
|
|
@@ -684,10 +782,12 @@ async function load(first, given) {
|
|
|
684
782
|
if (f) { f.screen = s; paintLabel(f) } else frameFor(s)
|
|
685
783
|
}
|
|
686
784
|
screens = next
|
|
785
|
+
widthOptions()
|
|
687
786
|
versionFilter()
|
|
688
787
|
variantFilter()
|
|
689
788
|
layout()
|
|
690
789
|
await loadChecks()
|
|
790
|
+
await loadReports()
|
|
691
791
|
if (selected) inspector(selected)
|
|
692
792
|
if (first && !localStorage.getItem('stitch2-view')) setTimeout(fit, 1500)
|
|
693
793
|
}
|
|
@@ -708,7 +808,7 @@ for (const [id, key] of [['statusFilter', 'status']]) {
|
|
|
708
808
|
document.getElementById('search').oninput = layout
|
|
709
809
|
|
|
710
810
|
// Versions and variants: multi-selects of every label the screens use (v1 … v12 and imports such as "Stitch";
|
|
711
|
-
// mobile, tablet, desktop), with how many screens carry each; none ticked means all. The catalog
|
|
811
|
+
// mobile, tablet, desktop), with how many screens carry each; none ticked means all. The catalog follows the variant filter only.
|
|
712
812
|
const MULTI = []
|
|
713
813
|
for (const id of ['versionFilter', 'variantFilter']) {
|
|
714
814
|
const box = document.getElementById(id)
|
|
@@ -765,6 +865,7 @@ events.onmessage = async (e) => {
|
|
|
765
865
|
if (paths.some((p) => p === 'checks.json')) await loadChecks()
|
|
766
866
|
const html = paths.filter((p) => p.endsWith('.html'))
|
|
767
867
|
if (html.length) await load(false)
|
|
868
|
+
else if (paths.some((p) => p.startsWith('components/') || p === 'compare.json')) await loadReports()
|
|
768
869
|
if (paths.includes('DESIGN.md') && document.getElementById('doc').classList.contains('open')) document.querySelector('#doc iframe').contentWindow.location.reload()
|
|
769
870
|
// Stylesheets and scripts are shared: tokens.css, tailwind.tokens.js, token exports the screens link
|
|
770
871
|
// (tokens.tailwind.css), the components.
|
|
@@ -826,6 +927,7 @@ addEventListener('keydown', (e) => {
|
|
|
826
927
|
if (e.key === 'f' || e.key === 'F') fit()
|
|
827
928
|
if ((e.key === 't' || e.key === 'T') && themed()) setTheme(THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length])
|
|
828
929
|
if (e.key === 'l' || e.key === 'L') setLang(LANGS[(LANGS.indexOf(lang) + 1) % LANGS.length])
|
|
930
|
+
if (e.key === 'w' || e.key === 'W') setWidth(['drawn', 'narrow', 'wide'][(['drawn', 'narrow', 'wide'].indexOf(widthMode) + 1) % 3])
|
|
829
931
|
if (e.key === '0') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, 1)
|
|
830
932
|
if (e.key === '+' || e.key === '=') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z * 1.2)
|
|
831
933
|
if (e.key === '-') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z / 1.2)
|
|
@@ -856,6 +958,9 @@ fetch('/api/config').then((r) => r.json()).then((c) => {
|
|
|
856
958
|
pick.innerHTML = THEMES.map((t) => `<option value="${t}">${t[0].toUpperCase()}${t.slice(1)} theme</option>`).join('')
|
|
857
959
|
pick.onchange = () => setTheme(pick.value)
|
|
858
960
|
setTheme(theme)
|
|
961
|
+
WIDTHS = c.widths ?? {}
|
|
962
|
+
const wsel = document.getElementById('widthFilter')
|
|
963
|
+
wsel.onchange = () => setWidth(wsel.value)
|
|
859
964
|
SOURCE = c.sourceLocale ?? 'en'
|
|
860
965
|
LANGS = c.locales?.length ? c.locales : [SOURCE]
|
|
861
966
|
if (!LANGS.includes(lang)) lang = SOURCE
|
package/dist/check.js
CHANGED
|
@@ -7,16 +7,17 @@
|
|
|
7
7
|
* colours change there, so contrast is checked again, tagged with the theme.
|
|
8
8
|
* Then again in each check language (i18n.ts: the pseudo-languages and the config's): layout problems that
|
|
9
9
|
* show only there (a label running into its neighbour, text cut off, a control drawn backwards right to left),
|
|
10
|
-
* and keys a locale has no translation for, tagged with the locale.
|
|
10
|
+
* and keys a locale has no translation for, tagged with the locale. Last, each variant at both ends of the widths
|
|
11
|
+
* it covers (config widths: mobile 360–599, desktop 1024–1920 by default), tagged with the width.
|
|
11
12
|
* Usage: stitch2 check [path filter…] [--shots] [--strict] [--locales ru,ar | all | none]
|
|
12
|
-
* --shots also save page-tall renders to design/renders/ (one per theme)
|
|
13
|
+
* --shots also save page-tall renders to design/renders/ (one per theme, language and width edge)
|
|
13
14
|
* --strict exit 1 when any screen has errors (for CI and agent loops)
|
|
14
15
|
*/
|
|
15
16
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
17
|
import { join } from 'node:path';
|
|
17
18
|
import { chromium } from '@playwright/test';
|
|
18
19
|
import { colourIssues } from './colours.js';
|
|
19
|
-
import { config } from './config.js';
|
|
20
|
+
import { config, widthRange } from './config.js';
|
|
20
21
|
import { collectComponents, collectOptions, consistencyIssues, reuseIssues, } from './consistency.js';
|
|
21
22
|
import { designThemes, typeLevels } from './design-md.js';
|
|
22
23
|
import { checkLocales } from './i18n.js';
|
|
@@ -35,6 +36,8 @@ const levels = typeLevels();
|
|
|
35
36
|
const themes = designThemes();
|
|
36
37
|
const collect = collectOptions();
|
|
37
38
|
const server = await startServer(0);
|
|
39
|
+
/** The tallest render, in CSS px: a long landing page is shot whole (the canvas draws frames up to the same height). */
|
|
40
|
+
const MAX_RENDER_HEIGHT = 20000;
|
|
38
41
|
const browser = await chromium.launch();
|
|
39
42
|
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2 });
|
|
40
43
|
const results = {};
|
|
@@ -112,6 +115,24 @@ for (const s of screens) {
|
|
|
112
115
|
if (shots)
|
|
113
116
|
await shoot(s, undefined, locale);
|
|
114
117
|
}
|
|
118
|
+
// The edges of the widths this variant covers (config widths), in its own theme and language: what breaks
|
|
119
|
+
// only when the window is narrower or wider than the frame it was drawn in.
|
|
120
|
+
const range = s.kind === 'screen' ? widthRange(s.device) : undefined;
|
|
121
|
+
for (const width of range ? [...new Set(range)].filter((w) => w !== s.width) : []) {
|
|
122
|
+
await page.setViewportSize({ width, height: 844 });
|
|
123
|
+
await page.goto(`http://127.0.0.1:${server.port}/${s.path}`, { waitUntil: 'networkidle', timeout: 30_000 }).catch(() => { });
|
|
124
|
+
await page.waitForTimeout(600);
|
|
125
|
+
for (const i of await page.evaluate(audit, { comfortable: config.comfortableTarget })) {
|
|
126
|
+
const key = `${i.type} ${i.selector}`;
|
|
127
|
+
if (i.type === 'contrast' || seen.has(key))
|
|
128
|
+
continue;
|
|
129
|
+
seen.add(key);
|
|
130
|
+
themed.push({ ...i, width, message: `at ${width}px: ${i.message}` });
|
|
131
|
+
}
|
|
132
|
+
if (shots)
|
|
133
|
+
await shoot({ ...s, width }, undefined, undefined, `${width}px`);
|
|
134
|
+
}
|
|
135
|
+
await page.setViewportSize({ width: s.width, height: 844 });
|
|
115
136
|
results[s.path] = [
|
|
116
137
|
...layout,
|
|
117
138
|
...themed,
|
|
@@ -129,11 +150,11 @@ async function setTheme(theme) {
|
|
|
129
150
|
await page.waitForTimeout(150);
|
|
130
151
|
}
|
|
131
152
|
/** A page-tall render; renders in another theme or language than the screen's own get a --<theme> suffix. */
|
|
132
|
-
async function shoot(s, theme, locale) {
|
|
153
|
+
async function shoot(s, theme, locale, at) {
|
|
133
154
|
const height = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
134
|
-
await page.setViewportSize({ width: s.width, height: Math.max(844, Math.min(height,
|
|
155
|
+
await page.setViewportSize({ width: s.width, height: Math.max(844, Math.min(height, MAX_RENDER_HEIGHT)) });
|
|
135
156
|
mkdirSync(join(ROOT, 'renders'), { recursive: true });
|
|
136
|
-
const suffix = theme ?? locale;
|
|
157
|
+
const suffix = theme ?? locale ?? at;
|
|
137
158
|
const name = s.path.replaceAll('/', '__').replace(/\.html$/, suffix ? `--${suffix}.png` : '.png');
|
|
138
159
|
await page.screenshot({ path: join(ROOT, 'renders', name) });
|
|
139
160
|
await page.setViewportSize({ width: s.width, height: 844 });
|
package/dist/compare.js
CHANGED
|
@@ -11,6 +11,9 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
11
11
|
* "app" section, it opens the approved version and the app page at the same width and compares their structure:
|
|
12
12
|
* which shared components appear (by their data-<prefix> marker), how many, in what order, and how the measured
|
|
13
13
|
* ones look. Data differs between a mock-up and the app, so pixels are shown side by side, not diffed.
|
|
14
|
+
* Each version is compared at the width it is drawn at, then at both edges of its variant's width range (the
|
|
15
|
+
* config's "widths"): a layout that holds at 390px can still break at 1023px. Findings that only show at an edge
|
|
16
|
+
* carry that width ("at 1023px: …"); screenshots are taken at the drawn width.
|
|
14
17
|
* Writes <design>/compare/index.html (the canvas opens it from its sidebar) and <design>/compare.json.
|
|
15
18
|
*
|
|
16
19
|
* "app": {
|
|
@@ -30,9 +33,10 @@ import { join, resolve } from 'node:path';
|
|
|
30
33
|
import { pathToFileURL } from 'node:url';
|
|
31
34
|
import { chromium } from '@playwright/test';
|
|
32
35
|
import { audit, LAYOUT_ERRORS } from './audit.js';
|
|
33
|
-
import { BASE, config } from './config.js';
|
|
36
|
+
import { BASE, config, widthRange } from './config.js';
|
|
34
37
|
import { collectComponents, collectOptions } from './consistency.js';
|
|
35
38
|
import { listScreens, ROOT } from './screens.js';
|
|
39
|
+
import { filterSnippet } from './report-filter.js';
|
|
36
40
|
import { startServer } from './serve.js';
|
|
37
41
|
const app = config.app;
|
|
38
42
|
if (!app?.url || !app.routes) {
|
|
@@ -95,19 +99,27 @@ const signedIn = await context.newPage();
|
|
|
95
99
|
const signedOut = await (await browser.newContext({ deviceScaleFactor: 2 })).newPage();
|
|
96
100
|
const results = [];
|
|
97
101
|
const opts = collectOptions();
|
|
98
|
-
|
|
102
|
+
/**
|
|
103
|
+
* One version against its app page at one width. The first call opens both pages (and runs prepare); the edges
|
|
104
|
+
* then only resize them, which reflows the layout, keeps the prepared state (a sheet open) and saves the reloads.
|
|
105
|
+
*/
|
|
106
|
+
async function compareAt(s, width, open) {
|
|
99
107
|
const route = routeOf(s.screen);
|
|
100
108
|
const live = route.signedOut ? signedOut : signedIn;
|
|
101
109
|
const url = new URL(route.path, app.url).href;
|
|
102
|
-
const height =
|
|
110
|
+
const height = width > 700 ? 800 : 844;
|
|
103
111
|
for (const p of [design, live])
|
|
104
|
-
await p.setViewportSize({ width
|
|
105
|
-
await design.goto(`http://127.0.0.1:${server.port}/${s.path}`).catch(() => { });
|
|
106
|
-
await settle(design);
|
|
107
|
-
await live.goto(url).catch(() => { });
|
|
108
|
-
await settle(live);
|
|
112
|
+
await p.setViewportSize({ width, height });
|
|
109
113
|
const findings = [];
|
|
110
|
-
if (
|
|
114
|
+
if (!open)
|
|
115
|
+
await live.waitForTimeout(500);
|
|
116
|
+
else {
|
|
117
|
+
await design.goto(`http://127.0.0.1:${server.port}/${s.path}`).catch(() => { });
|
|
118
|
+
await settle(design);
|
|
119
|
+
await live.goto(url).catch(() => { });
|
|
120
|
+
await settle(live);
|
|
121
|
+
}
|
|
122
|
+
if (open && route.prepare) {
|
|
111
123
|
// A failing prepare is this version's error; the run goes on with the others.
|
|
112
124
|
try {
|
|
113
125
|
await (await load(route.prepare))({ page: live, url: app.url });
|
|
@@ -156,9 +168,25 @@ for (const s of versions) {
|
|
|
156
168
|
findings.push({ severity: 'error', type: i.type, message: `${i.message} (“${i.text}”)` });
|
|
157
169
|
const kinds = Object.keys(d.counts);
|
|
158
170
|
const match = kinds.length ? Math.round((100 * kinds.filter((k) => a.counts[k]).length) / kinds.length) : 100;
|
|
171
|
+
return { findings, match, url, live };
|
|
172
|
+
}
|
|
173
|
+
for (const s of versions) {
|
|
174
|
+
const { findings, match, url, live } = await compareAt(s, s.width, true);
|
|
159
175
|
const base = `${s.screen}-${s.device}`;
|
|
160
176
|
await design.screenshot({ path: join(out, `${base}-design.png`), fullPage: true });
|
|
161
177
|
await live.screenshot({ path: join(out, `${base}-app.png`), fullPage: true });
|
|
178
|
+
const seen = new Set(findings.map((f) => `${f.type} ${f.message}`));
|
|
179
|
+
for (const width of widthRange(s.device) ?? []) {
|
|
180
|
+
if (width === s.width)
|
|
181
|
+
continue;
|
|
182
|
+
for (const f of (await compareAt(s, width, false)).findings) {
|
|
183
|
+
const key = `${f.type} ${f.message}`;
|
|
184
|
+
if (seen.has(key))
|
|
185
|
+
continue;
|
|
186
|
+
seen.add(key);
|
|
187
|
+
findings.push({ ...f, width, message: `at ${width}px: ${f.message}` });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
162
190
|
results.push({ screen: s.screen, device: s.device, version: s.version, design: s.path, url, findings, match });
|
|
163
191
|
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
164
192
|
console.log(`${errors ? '✗' : '✓'} ${base.padEnd(30)} ${String(match).padStart(3)}% of the design's components · ${errors} errors, ${findings.length - errors} warnings`);
|
|
@@ -174,10 +202,10 @@ const rows = results
|
|
|
174
202
|
const list = r.findings.length
|
|
175
203
|
? r.findings.map((f) => `<li class="${f.severity}"><b>${f.type}</b> ${esc(f.message)}</li>`).join('')
|
|
176
204
|
: '<li class="ok">Matches the design’s structure.</li>';
|
|
177
|
-
return `<section><h2>${esc(r.screen)} · ${r.device} · ${r.version} <span>${r.match}%</span></h2><p><code>${esc(r.design)}</code> ↔ <a href="${esc(r.url)}">${esc(r.url)}</a></p><ul>${list}</ul><div class="imgs"><figure><figcaption>Approved design</figcaption><img src="${base}-design.png" alt=""></figure><figure><figcaption>App</figcaption><img src="${base}-app.png" alt=""></figure></div></section>`;
|
|
205
|
+
return `<section data-path="${esc(r.design)}"><h2>${esc(r.screen)} · ${r.device} · ${r.version} <span>${r.match}%</span></h2><p><code>${esc(r.design)}</code> ↔ <a href="${esc(r.url)}">${esc(r.url)}</a></p><ul>${list}</ul><div class="imgs"><figure><figcaption>Approved design</figcaption><img src="${base}-design.png" alt=""></figure><figure><figcaption>App</figcaption><img src="${base}-app.png" alt=""></figure></div></section>`;
|
|
178
206
|
})
|
|
179
207
|
.join('');
|
|
180
|
-
writeFileSync(join(out, 'index.html'), `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>App comparison</title><style>body{background:#0b0b0d;color:#f4f4f5;font:14px/1.5 system-ui,sans-serif;margin:0}main{margin:0 auto;max-width:1400px;padding:32px}h1{font-size:22px}h2{font-size:17px;margin:32px 0 4px}h2 span{color:#a1a1aa;font-weight:400;margin-left:8px}code,a{color:#a1a1aa}ul{margin:8px 0;padding-left:18px}li.error b{color:#e5484d}li.warning b{color:#e5b54a}li.ok{color:#3fb27f}.imgs{display:flex;gap:16px;align-items:flex-start}figure{margin:0}figcaption{color:#a1a1aa;font-size:12px;margin-bottom:4px}img{border:1px solid #2e2e33;border-radius:8px;max-width:640px;width:100%}</style></head><body><main><h1>App comparison</h1><p>${new Date().toISOString()} · ${results.length} approved versions against ${esc(app.url)}${unmapped.length ? ` · no route for: ${esc(unmapped.join(', '))}` : ''}</p>${rows}</main
|
|
208
|
+
writeFileSync(join(out, 'index.html'), `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>App comparison</title><style>body{background:#0b0b0d;color:#f4f4f5;font:14px/1.5 system-ui,sans-serif;margin:0}main{margin:0 auto;max-width:1400px;padding:32px}h1{font-size:22px}h2{font-size:17px;margin:32px 0 4px}h2 span{color:#a1a1aa;font-weight:400;margin-left:8px}code,a{color:#a1a1aa}ul{margin:8px 0;padding-left:18px}li.error b{color:#e5484d}li.warning b{color:#e5b54a}li.ok{color:#3fb27f}.imgs{display:flex;gap:16px;align-items:flex-start}figure{margin:0}figcaption{color:#a1a1aa;font-size:12px;margin-bottom:4px}img{border:1px solid #2e2e33;border-radius:8px;max-width:640px;width:100%}</style></head><body><main><h1>App comparison</h1><p>${new Date().toISOString()} · ${results.length} approved versions against ${esc(app.url)}${unmapped.length ? ` · no route for: ${esc(unmapped.join(', '))}` : ''}</p><p id="stitch2-filter" hidden></p>${rows}</main>${filterSnippet}</body></html>\n`);
|
|
181
209
|
writeFileSync(join(ROOT, 'compare.json'), `${JSON.stringify({ comparedAt: new Date().toISOString(), url: app.url, results, unmapped }, null, 2)}\n`);
|
|
182
210
|
const total = results.reduce((n, r) => n + r.findings.filter((f) => f.severity === 'error').length, 0);
|
|
183
211
|
console.log(`\n${results.length} versions compared, ${total} errors${unmapped.length ? `; no route for ${unmapped.join(', ')}` : ''}. Report: ${config.designDir}/compare/index.html`);
|
package/dist/config.d.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface LabConfig {
|
|
|
25
25
|
* colours) and "light" when it has light-* colours. The canvas switches between them; check runs in each.
|
|
26
26
|
*/
|
|
27
27
|
themes?: string[];
|
|
28
|
+
/**
|
|
29
|
+
* The window widths each variant covers, [narrowest, widest] in px, by device. A variant is drawn at its frame
|
|
30
|
+
* width; stitch2 check also renders it at both ends of its range, and the canvas can show it there. Defaults
|
|
31
|
+
* follow Google's window size classes: mobile 360–599 (compact), tablet 600–1023 (medium), desktop 1024–1920.
|
|
32
|
+
*/
|
|
33
|
+
widths?: Record<string, [number, number]>;
|
|
28
34
|
/** Skill folders a blind-trial sandbox copies. */
|
|
29
35
|
skills: string[];
|
|
30
36
|
/** Paths under designDir a sandbox leaves out besides screens (notes that would give the trial away). */
|
|
@@ -68,5 +74,9 @@ export declare const CONFIG_FILE: string | undefined;
|
|
|
68
74
|
/** The folder paths in the config are relative to (the project root). */
|
|
69
75
|
export declare const BASE: string;
|
|
70
76
|
export declare const config: LabConfig;
|
|
77
|
+
/** The widths a variant covers: [narrowest, widest] for its device, from the config or the defaults. */
|
|
78
|
+
export declare const WIDTH_DEFAULTS: Record<string, [number, number]>;
|
|
79
|
+
export declare const widthRanges: () => Record<string, [number, number]>;
|
|
80
|
+
export declare const widthRange: (device: string) => [number, number] | undefined;
|
|
71
81
|
/** The design folder, with a trailing slash. */
|
|
72
82
|
export declare const ROOT: string;
|
package/dist/config.js
CHANGED
|
@@ -34,5 +34,9 @@ export const config = {
|
|
|
34
34
|
...DEFAULTS,
|
|
35
35
|
...(CONFIG_FILE ? JSON.parse(readFileSync(CONFIG_FILE, 'utf8')) : {}),
|
|
36
36
|
};
|
|
37
|
+
/** The widths a variant covers: [narrowest, widest] for its device, from the config or the defaults. */
|
|
38
|
+
export const WIDTH_DEFAULTS = { mobile: [360, 599], tablet: [600, 1023], desktop: [1024, 1920] };
|
|
39
|
+
export const widthRanges = () => ({ ...WIDTH_DEFAULTS, ...config.widths });
|
|
40
|
+
export const widthRange = (device) => widthRanges()[device];
|
|
37
41
|
/** The design folder, with a trailing slash. */
|
|
38
42
|
export const ROOT = join(resolve(BASE, config.designDir), '/');
|
package/dist/consistency.js
CHANGED
|
@@ -147,7 +147,14 @@ export function collectComponents(opts) {
|
|
|
147
147
|
if (!visible(el))
|
|
148
148
|
continue;
|
|
149
149
|
const g = el.getAttribute(marker);
|
|
150
|
-
|
|
150
|
+
// A variant (data-variant, from the component's variant prop) is compared with the same variant only: the
|
|
151
|
+
// simple tab bar with the simple one, a folder section with folder sections.
|
|
152
|
+
const variant = el.getAttribute('data-variant');
|
|
153
|
+
const kind = g === 'button'
|
|
154
|
+
? `button:${variant ?? 'primary'}:${el.getAttribute('data-size') ?? 'md'}`
|
|
155
|
+
: variant
|
|
156
|
+
? `${g}:${variant}`
|
|
157
|
+
: g;
|
|
151
158
|
if (!(g === 'button' && el.hasAttribute('disabled')))
|
|
152
159
|
add(kind, el);
|
|
153
160
|
}
|
package/dist/i18n-report.js
CHANGED
|
@@ -65,9 +65,16 @@ export function scan(html) {
|
|
|
65
65
|
const skip = !!top?.skip || 'data-t-skip' in a || a.translate === 'no';
|
|
66
66
|
for (const [name, value] of Object.entries(a)) {
|
|
67
67
|
const keyed = name === 'data-t' || (name.startsWith('data-t-') && name !== 'data-t-args' && name !== 'data-t-skip');
|
|
68
|
-
if (keyed && value.includes('{'))
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
if (keyed && value.includes('{')) {
|
|
69
|
+
// Innermost first, as the screens resolve them: {site.title|tab={nav.home}} uses both keys.
|
|
70
|
+
let rest = value;
|
|
71
|
+
for (let pass = 0; pass < 5 && rest.includes('{'); pass++)
|
|
72
|
+
rest = rest.replace(/\{([^{}|]+)(?:\|([^{}]*))?\}/g, (_m, key, inline) => {
|
|
73
|
+
if (!key.startsWith('@'))
|
|
74
|
+
uses.push({ key, line, plural: plural || /(^|;)\s*count\s*=/.test(inline ?? '') });
|
|
75
|
+
return 'x';
|
|
76
|
+
});
|
|
77
|
+
}
|
|
71
78
|
else if (name === 'data-t')
|
|
72
79
|
uses.push({ key: value, line, plural });
|
|
73
80
|
else if (keyed && name !== 'data-t-slot')
|
package/dist/i18n.js
CHANGED
|
@@ -171,14 +171,22 @@ function runtime(p) {
|
|
|
171
171
|
let v;
|
|
172
172
|
if (a.value.includes('{')) {
|
|
173
173
|
// A template: each {key} or {key|name=value;name=value} becomes its message, the rest stays as written.
|
|
174
|
+
// Templates nest ({site.title|tab={nav.home}}): the innermost resolve first, until none is left.
|
|
174
175
|
let whole = true;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
176
|
+
// Resolved text waits as \u0001<n>\u0001, so braces inside a message are never read as keys.
|
|
177
|
+
const done = [];
|
|
178
|
+
const back = (s) => s.replace(/\u0001(\d+)\u0001/g, (_m, i) => done[Number(i)]);
|
|
179
|
+
v = a.value;
|
|
180
|
+
for (let pass = 0; pass < 5 && /\{[^{}|]+(?:\|[^{}]*)?\}/.test(v); pass++)
|
|
181
|
+
v = v.replace(/\{([^{}|]+)(?:\|([^{}]*))?\}/g, (_m, key, inline) => {
|
|
182
|
+
keys.push(key);
|
|
183
|
+
const r = t(key, { ...args, ...parseArgs(inline && back(inline), ';') });
|
|
184
|
+
if (r === undefined)
|
|
185
|
+
whole = false;
|
|
186
|
+
done.push(r ?? '');
|
|
187
|
+
return `\u0001${done.length - 1}\u0001`;
|
|
188
|
+
});
|
|
189
|
+
v = back(v);
|
|
182
190
|
if (!whole)
|
|
183
191
|
v = undefined;
|
|
184
192
|
}
|
package/dist/languages-page.js
CHANGED
|
@@ -9,7 +9,7 @@ export function languagesPage(only) {
|
|
|
9
9
|
const screens = listScreens()
|
|
10
10
|
.filter((s) => (only ? s.path === only : s.kind === 'screen' && s.status !== 'archived'))
|
|
11
11
|
.map((s) => ({ path: s.path, label: s.kind === 'components' ? 'Component catalog' : s.screen + ' ' + s.device + ' ' + s.version, width: s.width }));
|
|
12
|
-
const data = { screens, source: sourceLocale(), check: [sourceLocale(), ...checkLocales()], all: allLocales() };
|
|
12
|
+
const data = { only: !!only, screens, source: sourceLocale(), check: [sourceLocale(), ...checkLocales()], all: allLocales() };
|
|
13
13
|
return `<!doctype html><html><head><meta charset="utf-8"><title>Languages</title><style>
|
|
14
14
|
body { background: #0f0f11; color: #e4e4e7; font: 13px/1.4 system-ui, sans-serif; margin: 0; }
|
|
15
15
|
header { align-items: center; background: #0f0f11; border-bottom: 1px solid #26262b; display: flex; gap: 12px; padding: 12px 140px 12px 20px; position: sticky; top: 0; z-index: 1; }
|
|
@@ -39,7 +39,20 @@ export function languagesPage(only) {
|
|
|
39
39
|
<main></main>
|
|
40
40
|
<script>
|
|
41
41
|
const D = ${JSON.stringify(data).replaceAll('<', '\\u003c')}
|
|
42
|
-
|
|
42
|
+
let theme = localStorage.getItem('stitch2-theme')
|
|
43
|
+
// The canvas posts its filters: only the screens it shows, in its theme (see report-filter.ts).
|
|
44
|
+
let keep = null
|
|
45
|
+
let sent = ''
|
|
46
|
+
addEventListener('message', (e) => {
|
|
47
|
+
const f = e.origin === location.origin && e.data && e.data.stitch2Filter
|
|
48
|
+
if (!f || D.only || JSON.stringify(f) === sent) return
|
|
49
|
+
sent = JSON.stringify(f)
|
|
50
|
+
keep = new Set(f.paths)
|
|
51
|
+
theme = f.theme || theme
|
|
52
|
+
document.querySelector('h1').textContent = f.label ? 'Languages · ' + f.label : 'Languages'
|
|
53
|
+
render()
|
|
54
|
+
})
|
|
55
|
+
if (parent !== window) parent.postMessage({ stitch2Ready: true }, location.origin)
|
|
43
56
|
let set = localStorage.getItem('stitch2-lang-set') ?? 'check'
|
|
44
57
|
let zoom = Number(localStorage.getItem('stitch2-lang-zoom') ?? 0.4)
|
|
45
58
|
const name = (l) => l === 'pseudo' ? 'Pseudo: long text' : l === 'pseudo-rtl' ? 'Pseudo: right to left' : (new Intl.DisplayNames([l], { type: 'language' }).of(l) ?? l) + (l === D.source ? ', source' : '')
|
|
@@ -52,8 +65,9 @@ export function languagesPage(only) {
|
|
|
52
65
|
const langs = set === 'all' ? D.all : D.check
|
|
53
66
|
const main = document.querySelector('main')
|
|
54
67
|
main.innerHTML = ''
|
|
55
|
-
|
|
56
|
-
|
|
68
|
+
const list = D.screens.filter((s) => !keep || keep.has(s.path))
|
|
69
|
+
if (!list.length) main.innerHTML = '<section><h2>' + (keep ? 'No screens match the canvas filters.' : 'No screens.') + '</h2></section>'
|
|
70
|
+
for (const s of list) {
|
|
57
71
|
const sec = document.createElement('section')
|
|
58
72
|
sec.innerHTML = '<h2></h2><div class="row"></div>'
|
|
59
73
|
sec.querySelector('h2').textContent = s.label
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reports opened in the canvas follow its filters. The canvas posts { stitch2Filter: { paths, label } } to the
|
|
3
|
+
* report: every element with data-path outside paths hides, a data-group hides when none of its rows (other than
|
|
4
|
+
* data-ref ones, the catalog's reference) is left, and #stitch2-filter says what is shown. Opened on its own, a
|
|
5
|
+
* report shows everything. The report asks for the filters with { stitch2Ready: true } once its script runs.
|
|
6
|
+
*/
|
|
7
|
+
export declare const filterSnippet = "<style>[hidden] { display: none !important; } #stitch2-filter { color: #e5b54a; font-size: 13px; margin: 8px 0 0; }</style>\n<script>\naddEventListener('message', (e) => {\n const f = e.origin === location.origin && e.data && e.data.stitch2Filter\n if (!f) return\n const keep = new Set(f.paths)\n for (const el of document.querySelectorAll('[data-path]')) el.hidden = !keep.has(el.dataset.path)\n for (const g of document.querySelectorAll('[data-group]'))\n g.hidden = ![...g.querySelectorAll('[data-path]:not([data-ref])')].some((el) => !el.hidden)\n const left = [...document.querySelectorAll('[data-path]:not([data-ref])')].filter((el) => !el.closest('[hidden]')).length\n const note = document.getElementById('stitch2-filter')\n if (!note) return\n note.hidden = !f.label && left > 0\n note.textContent = left ? 'Filtered as on the canvas: ' + (f.label || 'active screens') : 'Nothing in this report matches the canvas filters' + (f.label ? ': ' + f.label : '') + '.'\n})\nif (parent !== window) parent.postMessage({ stitch2Ready: true }, location.origin)\n</script>";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reports opened in the canvas follow its filters. The canvas posts { stitch2Filter: { paths, label } } to the
|
|
3
|
+
* report: every element with data-path outside paths hides, a data-group hides when none of its rows (other than
|
|
4
|
+
* data-ref ones, the catalog's reference) is left, and #stitch2-filter says what is shown. Opened on its own, a
|
|
5
|
+
* report shows everything. The report asks for the filters with { stitch2Ready: true } once its script runs.
|
|
6
|
+
*/
|
|
7
|
+
export const filterSnippet = `<style>[hidden] { display: none !important; } #stitch2-filter { color: #e5b54a; font-size: 13px; margin: 8px 0 0; }</style>
|
|
8
|
+
<script>
|
|
9
|
+
addEventListener('message', (e) => {
|
|
10
|
+
const f = e.origin === location.origin && e.data && e.data.stitch2Filter
|
|
11
|
+
if (!f) return
|
|
12
|
+
const keep = new Set(f.paths)
|
|
13
|
+
for (const el of document.querySelectorAll('[data-path]')) el.hidden = !keep.has(el.dataset.path)
|
|
14
|
+
for (const g of document.querySelectorAll('[data-group]'))
|
|
15
|
+
g.hidden = ![...g.querySelectorAll('[data-path]:not([data-ref])')].some((el) => !el.hidden)
|
|
16
|
+
const left = [...document.querySelectorAll('[data-path]:not([data-ref])')].filter((el) => !el.closest('[hidden]')).length
|
|
17
|
+
const note = document.getElementById('stitch2-filter')
|
|
18
|
+
if (!note) return
|
|
19
|
+
note.hidden = !f.label && left > 0
|
|
20
|
+
note.textContent = left ? 'Filtered as on the canvas: ' + (f.label || 'active screens') : 'Nothing in this report matches the canvas filters' + (f.label ? ': ' + f.label : '') + '.'
|
|
21
|
+
})
|
|
22
|
+
if (parent !== window) parent.postMessage({ stitch2Ready: true }, location.origin)
|
|
23
|
+
</script>`;
|
package/dist/report.js
CHANGED
|
@@ -8,6 +8,7 @@ import { join } from 'node:path';
|
|
|
8
8
|
import { chromium } from '@playwright/test';
|
|
9
9
|
import { config } from './config.js';
|
|
10
10
|
import { collectComponents, collectOptions, consistencyIssues, nameOf, } from './consistency.js';
|
|
11
|
+
import { filterSnippet } from './report-filter.js';
|
|
11
12
|
import { listScreens, ROOT } from './screens.js';
|
|
12
13
|
import { startServer } from './serve.js';
|
|
13
14
|
const kinds = process.argv.slice(2).filter((a) => !a.startsWith('--'));
|
|
@@ -92,10 +93,11 @@ const sections = Object.keys(crops)
|
|
|
92
93
|
: note.length
|
|
93
94
|
? note.map((i) => `<p class="bad">${esc(i.message)}</p>`).join('')
|
|
94
95
|
: '<b class="ok">matches</b>';
|
|
95
|
-
|
|
96
|
+
const ref = path.startsWith(config.catalogDir) ? ' data-ref' : '';
|
|
97
|
+
return `<div class="row" data-path="${esc(path)}"${ref}><div class="who"><b>${esc(s.kind === 'components' ? 'Component catalog' : `${s.screen} · ${s.device} · ${s.version}`)}</b><code>${esc(path)}</code>${verdict}</div><div class="imgs">${files.map((f) => `<img src="${esc(f)}" alt="" />`).join('')}</div></div>`;
|
|
96
98
|
})
|
|
97
99
|
.join('');
|
|
98
|
-
return `<section><h2>${esc(nameOf(kind))} <span>${esc(kind)}</span></h2>${rows}</section>`;
|
|
100
|
+
return `<section data-group><h2>${esc(nameOf(kind))} <span>${esc(kind)}</span></h2>${rows}</section>`;
|
|
99
101
|
})
|
|
100
102
|
.join('');
|
|
101
103
|
writeFileSync(join(out, 'index.html'), `<!doctype html><html lang="en"><head><meta charset="utf-8" /><title>Consistency report</title><style>
|
|
@@ -106,5 +108,5 @@ writeFileSync(join(out, 'index.html'), `<!doctype html><html lang="en"><head><me
|
|
|
106
108
|
.who { display: grid; gap: 4px; align-content: start; min-width: 0; overflow-wrap: anywhere; } .imgs { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-start; min-width: 0; }
|
|
107
109
|
img { background: #000; border: 1px solid #2e2e33; border-radius: 8px; max-width: 100%; width: 390px; }
|
|
108
110
|
.ok { color: #3fb27f; } .ref { color: #a1a1aa; } .bad { color: #e5b54a; margin: 0; font-size: 13px; }
|
|
109
|
-
</style></head><body><main><h1>Consistency report</h1><code>${new Date().toISOString()} · ${screens.length} active screens · reference: ${esc(config.catalogDir)}</code>${sections}</main
|
|
111
|
+
</style></head><body><main><h1>Consistency report</h1><code>${new Date().toISOString()} · ${screens.length} active screens · reference: ${esc(config.catalogDir)}</code><p id="stitch2-filter" hidden></p>${sections}</main>${filterSnippet}</body></html>\n`);
|
|
110
112
|
console.log(`consistency report: ${join(out, 'index.html')} (${Object.keys(crops).length} components, ${screens.length} screens)`);
|
package/dist/serve.d.ts
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/** When a report was made and which of the files it covers changed after it; null when it has never run. */
|
|
2
|
+
export interface ReportState {
|
|
3
|
+
at: number;
|
|
4
|
+
changed: string[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The consistency report covers every screen that is not archived and the components; the app comparison covers
|
|
8
|
+
* the approved screens. A report is stale once one of those files is newer than it (a status change counts: it
|
|
9
|
+
* changes what the report covers). Only the design side is known: changes in the app itself don't show here.
|
|
10
|
+
*/
|
|
11
|
+
export declare function reportStates(): {
|
|
12
|
+
consistency: ReportState | null;
|
|
13
|
+
compare: ReportState | null;
|
|
14
|
+
};
|
|
1
15
|
export declare function startServer(port: number): Promise<{
|
|
2
16
|
port: number;
|
|
3
17
|
close: () => void;
|
package/dist/serve.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The stitch2 server: the canvas at /, DESIGN.md drawn at /design, screens side by side per language at /languages, the screen list at /api/screens
|
|
3
|
-
* (POST /api/screens/meta sets a version's status or note), check results at /api/checks,
|
|
4
|
-
* at /events, and every file under design/ by its
|
|
3
|
+
* (POST /api/screens/meta sets a version's status or note), check results at /api/checks, when the reports were
|
|
4
|
+
* made and what changed since at /api/reports, live-reload events at /events, and every file under design/ by its
|
|
5
|
+
* path. Local only (127.0.0.1).
|
|
5
6
|
* Usage: stitch2 canvas (PORT=4400 by default)
|
|
6
7
|
*/
|
|
7
|
-
import { existsSync, readFileSync, statSync, watch } from 'node:fs';
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, statSync, watch } from 'node:fs';
|
|
8
9
|
import { createServer } from 'node:http';
|
|
9
10
|
import { dirname, extname, join, normalize } from 'node:path';
|
|
10
11
|
import { fileURLToPath } from 'node:url';
|
|
11
|
-
import { CONFIG_FILE, config } from './config.js';
|
|
12
|
+
import { CONFIG_FILE, config, widthRanges } from './config.js';
|
|
12
13
|
import { designThemes } from './design-md.js';
|
|
13
14
|
import { allLocales, localeScript, sourceLocale } from './i18n.js';
|
|
14
15
|
import { designPage } from './design-page.js';
|
|
@@ -28,6 +29,33 @@ function inlineTailwind(html, file) {
|
|
|
28
29
|
});
|
|
29
30
|
}
|
|
30
31
|
import { listScreens, ROOT, STATUSES, tag, writeMeta } from './screens.js';
|
|
32
|
+
/**
|
|
33
|
+
* The consistency report covers every screen that is not archived and the components; the app comparison covers
|
|
34
|
+
* the approved screens. A report is stale once one of those files is newer than it (a status change counts: it
|
|
35
|
+
* changes what the report covers). Only the design side is known: changes in the app itself don't show here.
|
|
36
|
+
*/
|
|
37
|
+
export function reportStates() {
|
|
38
|
+
const screens = listScreens();
|
|
39
|
+
const components = (() => {
|
|
40
|
+
const dir = join(ROOT, 'components');
|
|
41
|
+
if (!existsSync(dir))
|
|
42
|
+
return [];
|
|
43
|
+
return readdirSync(dir)
|
|
44
|
+
.filter((f) => /\.(js|mjs|css)$/.test(f))
|
|
45
|
+
.map((f) => ({ path: `components/${f}`, mtime: statSync(join(dir, f)).mtimeMs }));
|
|
46
|
+
})();
|
|
47
|
+
const state = (file, covered) => {
|
|
48
|
+
const report = join(ROOT, file);
|
|
49
|
+
if (!existsSync(report))
|
|
50
|
+
return null;
|
|
51
|
+
const at = statSync(report).mtimeMs;
|
|
52
|
+
return { at, changed: covered.filter((f) => f.mtime > at).map((f) => f.path) };
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
consistency: state('consistency/index.html', [...screens.filter((s) => s.status !== 'archived'), ...components]),
|
|
56
|
+
compare: state('compare/index.html', screens.filter((s) => s.kind === 'screen' && s.status === 'approved')),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
31
59
|
const CANVAS = fileURLToPath(new URL('./canvas.html', import.meta.url));
|
|
32
60
|
const TYPES = {
|
|
33
61
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -121,9 +149,11 @@ export function startServer(port) {
|
|
|
121
149
|
return;
|
|
122
150
|
}
|
|
123
151
|
if (url.pathname === '/api/config')
|
|
124
|
-
return json({ ...config, themes: designThemes(), locales: allLocales(), sourceLocale: sourceLocale() });
|
|
152
|
+
return json({ ...config, themes: designThemes(), locales: allLocales(), sourceLocale: sourceLocale(), widths: widthRanges() });
|
|
125
153
|
if (url.pathname === '/api/screens')
|
|
126
154
|
return json(listScreens());
|
|
155
|
+
if (url.pathname === '/api/reports')
|
|
156
|
+
return json(reportStates());
|
|
127
157
|
if (url.pathname === '/api/checks') {
|
|
128
158
|
const file = join(ROOT, 'checks.json');
|
|
129
159
|
return json(existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { screens: {} });
|
package/dist/typecheck.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface Issue {
|
|
|
13
13
|
theme?: string;
|
|
14
14
|
/** Set when the issue shows only in another language: the locale it was found in. */
|
|
15
15
|
locale?: string;
|
|
16
|
+
/** Set when the issue shows only at another window width: the edge of its variant's range (config widths). */
|
|
17
|
+
width?: number;
|
|
16
18
|
}
|
|
17
19
|
export interface TextItem {
|
|
18
20
|
selector: string;
|
package/package.json
CHANGED
package/skills/stitch2/SKILL.md
CHANGED
|
@@ -24,8 +24,8 @@ References, read before the first screen:
|
|
|
24
24
|
- [principles.md](references/principles.md): what makes a screen read well, and the review rubric.
|
|
25
25
|
- [typography.md](references/typography.md): type levels and how to combine them; most polish is here.
|
|
26
26
|
- [components.md](references/components.md): build from the shared components; the reuse ladder.
|
|
27
|
-
- [screens.md](references/screens.md): screen names, versions and their variants (mobile, desktop),
|
|
28
|
-
the user's approval and notes.
|
|
27
|
+
- [screens.md](references/screens.md): screen names, versions and their variants (mobile, desktop), the widths
|
|
28
|
+
each variant covers, statuses, the user's approval and notes.
|
|
29
29
|
- [google.md](references/google.md): Google's guidance (Material, Android, web.dev) as rules with numbers.
|
|
30
30
|
- [guides.md](references/guides.md): the public sources behind the rules.
|
|
31
31
|
|
|
@@ -60,6 +60,8 @@ References, read before the first screen:
|
|
|
60
60
|
screens, or in the screen when it is one element. Look at both renders (`--light.png`). With `i18n`, check
|
|
61
61
|
also runs the screen in the pseudo-languages and the config's locales; fix what they flag as the
|
|
62
62
|
**stitch2-i18n** skill says.
|
|
63
|
+
An issue tagged with a width ("at 1024px: …") breaks at an edge of the windows the variant covers: fix it with a
|
|
64
|
+
responsive rule in the screen, or a new variant when the layout's structure has to change (screens.md, Widths).
|
|
63
65
|
6. **Look and critique.** View the render in `<design>/renders/` (crop the first screenful). Score it with the
|
|
64
66
|
rubric in principles.md, fix what fails, repeat 5–6 at most three times.
|
|
65
67
|
7. **Hand over.** Set the version's status to `review`, point the user to it on the canvas, and report what
|
|
@@ -73,7 +75,7 @@ References, read before the first screen:
|
|
|
73
75
|
|---|---|
|
|
74
76
|
| `setup [--name N --primary #hex] [options]` | Sets a project up in one step; keeps every file that exists |
|
|
75
77
|
| `canvas` | The canvas at http://localhost:4400: screens and versions, DESIGN.md, the consistency report, approval and notes |
|
|
76
|
-
| `check [filter] [--shots] [--strict] [--locales l,…]` | Layout, type, reuse and consistency checks, in every theme and check language; `--shots` saves renders |
|
|
78
|
+
| `check [filter] [--shots] [--strict] [--locales l,…]` | Layout, type, reuse and consistency checks, in every theme and check language, and at both ends of each variant's widths; `--shots` saves renders |
|
|
77
79
|
| `i18n [filter] [--locale l] [--suggest]` | Message keys screens use that the source or a locale lacks, and text with no key (stitch2-i18n) |
|
|
78
80
|
| `consistency [kind…]` | Every shared component cropped from every screen, next to the catalog's version |
|
|
79
81
|
| `type [filter] [--json]` | Every text style a screen uses and the type level it matches |
|
|
@@ -26,6 +26,8 @@ and build on it. Meta tag names below use the project's prefix (`<p>`, from `sti
|
|
|
26
26
|
- **Edit in place** only to finish your own draft, or to answer a note on a version still in `review` or
|
|
27
27
|
`draft`.
|
|
28
28
|
- **Never edit** an `approved` or `archived` variant: copy the version to the next number and change the copy.
|
|
29
|
+
The exception is the user's own request: a fix they ask for on a version they name ("it had to be on v3")
|
|
30
|
+
goes into that version, which keeps its status; say what changed.
|
|
29
31
|
|
|
30
32
|
## Statuses
|
|
31
33
|
|
|
@@ -70,16 +72,32 @@ with the mobile's N, frame width 1280, the same components and type levels, the
|
|
|
70
72
|
of a tab bar, a content max width, two columns where it helps). Shared components get their desktop form in the
|
|
71
73
|
component, before the first desktop screen.
|
|
72
74
|
|
|
73
|
-
Widths
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
## Widths
|
|
76
|
+
|
|
77
|
+
A variant is drawn at one frame width but covers a range of windows: `widths` in `stitch2.config.json`, by
|
|
78
|
+
device, `[narrowest, widest]` in px. The defaults follow Google's window size classes ([google.md](google.md)):
|
|
79
|
+
mobile 360–599 (compact), tablet 600–1023 (medium), desktop 1024–1920 (expanded and up); a project sets the
|
|
80
|
+
width where its app actually changes layout (gymgym: mobile up to 1023, desktop from 1024).
|
|
81
|
+
|
|
82
|
+
Widths are a dimension like themes and languages, not more files: `stitch2 check` also renders every variant at
|
|
83
|
+
both ends of its range and reports what breaks there ("at 1024px: …"), and the canvas shows every frame at its
|
|
84
|
+
narrowest or widest (the width dropdown, W).
|
|
85
|
+
|
|
86
|
+
- A screen that breaks at an edge gets a **responsive rule inside the same variant**: a two-column grid that drops
|
|
87
|
+
to one, three cards across that become two, a side column that moves under the main one. Use the project's
|
|
88
|
+
breakpoints (Tailwind's `md:`, `lg:`, `xl:`), so the screen keeps one layout that adapts.
|
|
89
|
+
- Only when the **structure** has to change (a bar becomes a side navigation, one pane becomes list-detail) is it a
|
|
90
|
+
new variant: `tablet-v<N>` for 600–1023, with its own frame width (768) and range, the same N as the version.
|
|
91
|
+
- A desktop screen is a layout for its range, not a stretched phone: a side navigation instead of the bar,
|
|
92
|
+
list-detail with both panes, extra columns for feeds.
|
|
77
93
|
|
|
78
94
|
## The canvas
|
|
79
95
|
|
|
80
|
-
`stitch2 canvas` → http://localhost:4400. The sidebar lists DESIGN.md (drawn as tokens and rules),
|
|
81
|
-
|
|
82
|
-
|
|
96
|
+
`stitch2 canvas` → http://localhost:4400. The sidebar lists DESIGN.md (drawn as tokens and rules), Components
|
|
97
|
+
(the catalog for the variants the filter shows; a click steps through them), the consistency report and app
|
|
98
|
+
comparison (each with when it last ran, or how many covered screens changed since), Languages, and every screen
|
|
99
|
+
with its versions. Filters: status (active, approved, needs review, all), versions, variants, theme, width and
|
|
100
|
+
language. The reports and Languages follow the filters too: they show only the screens the canvas shows. Selecting a version opens the inspector: status, note, checks, and
|
|
83
101
|
Copy for its reference. Pick mode (`p`) highlights the element under the pointer; a click copies its reference,
|
|
84
102
|
Shift-click collects several (Copy all, Add to note). Keys: `/` search, `j`/`k` next and previous, `a` approve,
|
|
85
103
|
`p` pick, `f` fit, `0` actual size, double-click to use a frame, `Esc` to leave.
|
|
@@ -44,7 +44,10 @@ Slots are for wiring. If what goes into a slot changes how the component looks,
|
|
|
44
44
|
other, **controls overlapping each other**, small tap targets, contrast, type levels, reuse and consistency.
|
|
45
45
|
- `stitch2 compare` (the live app, against every approved version with a route): which components appear, in
|
|
46
46
|
what order, how the measured ones look, **and the same layout errors as check** (overflow, fixed-overlap,
|
|
47
|
-
control-overlap), so a control squeezed in the app shows up even when no screen drew it.
|
|
47
|
+
control-overlap), so a control squeezed in the app shows up even when no screen drew it. Each version runs at
|
|
48
|
+
its drawn width and at both edges of its width range; a finding seen only at an edge starts with "at <width>px:".
|
|
49
|
+
- Both reports are snapshots that run only when called. The canvas sidebar says "N changed since" once the
|
|
50
|
+
screens they cover have changed; run them again before relying on them.
|
|
48
51
|
- Give every approved screen a route, and every state a `prepare` module that puts the app into it (a sheet
|
|
49
52
|
open, a timer running, a form half filled). An unrouted screen is not compared at all.
|
|
50
53
|
|
|
@@ -44,7 +44,8 @@ Use the app's own files. Never copy messages into the design folder: two copies
|
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
A value with braces is a **template**: each `{key}` or `{key|name=value;name=value}` becomes its message and
|
|
47
|
-
the rest stays as written.
|
|
47
|
+
the rest stays as written. Templates nest, innermost first, for a message that takes another as an argument:
|
|
48
|
+
`data-t-title="{site.screens.title|workout={nav.workout};history={nav.history}}"`. Use it for lines the app builds from several messages and data (a meta line, a
|
|
48
49
|
list of `label=url` links: `data-t-links="{site.features}=#features,{site.docs}=/docs"`); sample data inside
|
|
49
50
|
it stays literal. Without braces, `data-t-<attr>` on a comma list takes one key per item.
|
|
50
51
|
|