stitch2 0.22.3 → 0.22.6
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/dist/canvas.html +21 -12
- package/dist/check.js +195 -128
- package/dist/screens.d.ts +2 -0
- package/dist/screens.js +1 -0
- package/dist/serve.js +7 -1
- package/dist/shots.js +8 -3
- package/dist/viewport.d.ts +3 -0
- package/dist/viewport.js +68 -0
- package/package.json +1 -1
- package/skills/stitch2/references/screens.md +5 -0
package/dist/canvas.html
CHANGED
|
@@ -92,6 +92,7 @@
|
|
|
92
92
|
.frame.ready .device iframe { visibility: visible; }
|
|
93
93
|
.device .shot { display: block; left: 0; pointer-events: none; position: absolute; top: 0; user-select: none; }
|
|
94
94
|
.device .shot:not([src]) { display: none; }
|
|
95
|
+
.frame.ready .device .shot { visibility: hidden; }
|
|
95
96
|
.device .shield { cursor: pointer; inset: 0; position: absolute; }
|
|
96
97
|
.frame.live .shield { display: none; }
|
|
97
98
|
.frame.changed .device { animation: flash 900ms ease-out; }
|
|
@@ -288,7 +289,9 @@ const copyButton = (text, title) => {
|
|
|
288
289
|
b.onclick = (e) => { e.stopPropagation(); copy(text) }
|
|
289
290
|
return b
|
|
290
291
|
}
|
|
291
|
-
|
|
292
|
+
const VIEW_KEY = 'stitch2-view-v2'
|
|
293
|
+
const savedView = JSON.parse(localStorage.getItem(VIEW_KEY) ?? 'null')
|
|
294
|
+
let view = savedView ?? { x: 40, y: 60, z: 0.4 }
|
|
292
295
|
const filters = JSON.parse(localStorage.getItem('stitch2-filters') ?? 'null') ?? { status: 'active' }
|
|
293
296
|
if (typeof filters.device === 'string') { filters.devices = filters.device === 'all' ? [] : [filters.device]; delete filters.device }
|
|
294
297
|
let screens = []
|
|
@@ -297,9 +300,9 @@ let reports = { consistency: null, compare: null, states: null }
|
|
|
297
300
|
let selected = null
|
|
298
301
|
const frames = new Map() // path -> { el, device, img, iframe (while live), screen, height }
|
|
299
302
|
// Heights measured before, so the layout doesn't jump while pictures and pages load.
|
|
300
|
-
const HEIGHTS = JSON.parse(localStorage.getItem('stitch2-heights') ?? '{}')
|
|
303
|
+
const HEIGHTS = JSON.parse(localStorage.getItem('stitch2-heights-v2') ?? '{}')
|
|
301
304
|
let heightsTimer
|
|
302
|
-
const saveHeights = () => { clearTimeout(heightsTimer); heightsTimer = setTimeout(() => localStorage.setItem('stitch2-heights', JSON.stringify(HEIGHTS)), 1000) }
|
|
305
|
+
const saveHeights = () => { clearTimeout(heightsTimer); heightsTimer = setTimeout(() => localStorage.setItem('stitch2-heights-v2', JSON.stringify(HEIGHTS)), 1000) }
|
|
303
306
|
let layoutQueued = false
|
|
304
307
|
/** Many frames measure themselves at once while loading: one layout per animation frame. */
|
|
305
308
|
const scheduleLayout = () => { if (!layoutQueued) { layoutQueued = true; requestAnimationFrame(() => { layoutQueued = false; layout() }) } }
|
|
@@ -311,7 +314,7 @@ const apply = () => {
|
|
|
311
314
|
viewport.style.backgroundSize = `${s}px ${s}px`
|
|
312
315
|
viewport.style.backgroundPosition = `${view.x}px ${view.y}px`
|
|
313
316
|
document.getElementById('zoom').textContent = `${Math.round(view.z * 100)}%`
|
|
314
|
-
localStorage.setItem(
|
|
317
|
+
localStorage.setItem(VIEW_KEY, JSON.stringify(view))
|
|
315
318
|
scheduleSync()
|
|
316
319
|
}
|
|
317
320
|
const zoomAt = (cx, cy, z) => {
|
|
@@ -580,8 +583,11 @@ function measure(f) {
|
|
|
580
583
|
if (!f.iframe) return
|
|
581
584
|
try {
|
|
582
585
|
const doc = f.iframe.contentDocument
|
|
586
|
+
// A previously tall iframe supplies a scrollHeight floor even after content shrinks.
|
|
587
|
+
f.iframe.style.height = `${f.screen.viewportHeight ?? 844}px`
|
|
583
588
|
// As tall as the page, up to MAX_FRAME_HEIGHT: long landing pages are drawn whole.
|
|
584
589
|
setHeight(f, Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0))
|
|
590
|
+
size(f)
|
|
585
591
|
} catch {}
|
|
586
592
|
}
|
|
587
593
|
|
|
@@ -602,13 +608,13 @@ function size(f) {
|
|
|
602
608
|
f.img.style.width = w
|
|
603
609
|
if (f.iframe) Object.assign(f.iframe.style, { width: w, height: h })
|
|
604
610
|
}
|
|
605
|
-
const frameSrc = (s) => `/${s.path}?pick${langQuery()}`
|
|
611
|
+
const frameSrc = (s) => `/${s.path}?pick&canvas=${s.viewportHeight ?? 844}${langQuery()}`
|
|
606
612
|
|
|
607
613
|
// ---- live frames: a screen is a whole page, so only the frames in view, at a zoom where you can work with them,
|
|
608
614
|
// are live (at most MAX_LIVE, nearest the middle first), plus the selected one and those with picked elements.
|
|
609
615
|
// The rest show the picture the server renders (/api/shot). Without pictures (no Chromium), frames in view stay
|
|
610
616
|
// live at any zoom, still at most MAX_LIVE. ----
|
|
611
|
-
const LIVE_ZOOM = 0.3, MAX_LIVE =
|
|
617
|
+
const LIVE_ZOOM = 0.3, MAX_LIVE = 6, SHOTS_AT_ONCE = 3
|
|
612
618
|
let shotRev = 0, shotErrors = 0, syncTimer
|
|
613
619
|
const shotsFailed = () => shotErrors > 2
|
|
614
620
|
const scheduleSync = () => { clearTimeout(syncTimer); syncTimer = setTimeout(syncLive, 120) }
|
|
@@ -637,8 +643,8 @@ function mount(f) {
|
|
|
637
643
|
f.el.classList.add('ready')
|
|
638
644
|
for (const go of f.waiters.splice(0)) go()
|
|
639
645
|
}, 350)
|
|
640
|
-
setTimeout(() => measure(f), 1200)
|
|
641
|
-
setTimeout(() => measure(f), 2500)
|
|
646
|
+
setTimeout(() => { if (f.iframe === iframe) measure(f) }, 1200)
|
|
647
|
+
setTimeout(() => { if (f.iframe === iframe) measure(f) }, 2500)
|
|
642
648
|
})
|
|
643
649
|
iframe.src = frameSrc(f.screen)
|
|
644
650
|
f.device.insertBefore(iframe, f.img.nextSibling)
|
|
@@ -672,7 +678,8 @@ function shotDone(f, ok) {
|
|
|
672
678
|
if (!f.img.dataset.loading) return
|
|
673
679
|
delete f.img.dataset.loading
|
|
674
680
|
shotsLoading--
|
|
675
|
-
if (ok) { shotErrors = 0; if (!f.iframe) setHeight(f, f.img.naturalHeight) } else
|
|
681
|
+
if (ok) { shotErrors = 0; if (!f.iframe) setHeight(f, f.img.naturalHeight) } else shotErrors++
|
|
682
|
+
scheduleSync()
|
|
676
683
|
pumpShots()
|
|
677
684
|
}
|
|
678
685
|
function syncLive() {
|
|
@@ -693,9 +700,11 @@ function syncLive() {
|
|
|
693
700
|
// Frames just out of view stay live while there's room, so panning back and forth doesn't reload them.
|
|
694
701
|
for (const f of kept) { if (live.size >= MAX_LIVE) break; if (f.iframe) live.add(f) }
|
|
695
702
|
}
|
|
696
|
-
|
|
703
|
+
const visible = new Set(near.map(({ f }) => f))
|
|
704
|
+
// Keep the page until its picture is loaded; switching zoom must never reveal an empty frame.
|
|
705
|
+
for (const f of frames.values()) if (f.iframe && !live.has(f) && (!visible.has(f) || (f.img.complete && f.img.naturalWidth))) unmount(f)
|
|
697
706
|
for (const f of live) mount(f)
|
|
698
|
-
shotQueue = near.
|
|
707
|
+
shotQueue = near.map(({ f }) => [f, shotUrl(f.screen)])
|
|
699
708
|
pumpShots()
|
|
700
709
|
}
|
|
701
710
|
|
|
@@ -991,7 +1000,7 @@ async function load(first, given) {
|
|
|
991
1000
|
await loadChecks()
|
|
992
1001
|
await loadReports()
|
|
993
1002
|
if (selected) inspector(selected)
|
|
994
|
-
if (first && !
|
|
1003
|
+
if (first && !savedView) setTimeout(fit, 1500)
|
|
995
1004
|
}
|
|
996
1005
|
|
|
997
1006
|
// ---- filters ----
|
package/dist/check.js
CHANGED
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
* Usage: stitch2 check [path filter…] [--shots] [--strict] [--locales ru,ar | all | none]
|
|
14
14
|
* --shots also save page-tall renders to design/renders/ (one per theme, language and width)
|
|
15
15
|
* --strict exit 1 when any screen has errors (for CI and agent loops)
|
|
16
|
+
* Screens run four at a time; reruns only visit screens that changed since design/checks.json (or
|
|
17
|
+
* everything DESIGN.md, the tokens, the components or the catalog changed since).
|
|
16
18
|
*/
|
|
17
|
-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
18
20
|
import { join } from 'node:path';
|
|
19
21
|
import { chromium } from '@playwright/test';
|
|
20
22
|
import { colourIssues } from './colours.js';
|
|
21
|
-
import { checkWidths, config } from './config.js';
|
|
23
|
+
import { CONFIG_FILE, checkWidths, config } from './config.js';
|
|
22
24
|
import { collectComponents, collectOptions, consistencyIssues, reuseIssues, } from './consistency.js';
|
|
23
25
|
import { designThemes, motionDurations, typeLevels } from './design-md.js';
|
|
24
26
|
import { checkLocales } from './i18n.js';
|
|
@@ -47,148 +49,219 @@ const auditOpts = { comfortable: config.comfortableTarget, durations: (() => { t
|
|
|
47
49
|
const server = await startServer(0);
|
|
48
50
|
/** The tallest render, in CSS px: a long landing page is shot whole (the canvas draws frames up to the same height). */
|
|
49
51
|
const MAX_RENDER_HEIGHT = 20000;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
const fresh = async () => {
|
|
53
|
+
const browser = await chromium.launch();
|
|
54
|
+
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2 });
|
|
55
|
+
return { browser, page, visits: 0 };
|
|
56
|
+
};
|
|
57
|
+
const CONCURRENCY = 4;
|
|
58
|
+
const pool = await Promise.all(Array.from({ length: CONCURRENCY }, fresh));
|
|
52
59
|
const results = {};
|
|
53
60
|
const components = {};
|
|
54
61
|
const stateRows = {};
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2 });
|
|
62
|
+
// Screens unchanged since the last green run keep their results when nothing they depend on changed
|
|
63
|
+
// (DESIGN.md, tokens, components, the catalog): reruns then only visit what actually changed.
|
|
64
|
+
const depFiles = ['DESIGN.md', 'tokens.css', 'tokens.json'];
|
|
65
|
+
const depMtime = (p) => {
|
|
66
|
+
try {
|
|
67
|
+
return statSync(join(ROOT, p)).mtimeMs;
|
|
62
68
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
.goto(`http://127.0.0.1:${server.port}/${s.path}`, { waitUntil: 'networkidle', timeout: 30_000 })
|
|
66
|
-
.catch(() => { });
|
|
67
|
-
// Tailwind's CDN build, web fonts and the lab components settle after load.
|
|
68
|
-
await page.waitForTimeout(600);
|
|
69
|
-
const found = await page.evaluate(collectComponents, collect);
|
|
70
|
-
components[s.path] = found.components;
|
|
71
|
-
const own = await page.evaluate(() => document.documentElement.dataset.theme || '');
|
|
72
|
-
const base = own || themes[0];
|
|
73
|
-
const layout = await page.evaluate(audit, auditOpts);
|
|
74
|
-
// With reduced motion asked for, nothing keeps moving (DESIGN.md motion, WCAG 2.3.3).
|
|
75
|
-
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
76
|
-
await page.waitForTimeout(100);
|
|
77
|
-
layout.push(...(await page.evaluate(movingAnimations)));
|
|
78
|
-
await page.emulateMedia({ reducedMotion: null });
|
|
79
|
-
for (const i of layout)
|
|
80
|
-
if (i.type === 'contrast')
|
|
81
|
-
i.theme = base;
|
|
82
|
-
// The other themes: what the colours change (contrast), plus anything that only shows there.
|
|
83
|
-
const seen = new Set(layout.map((i) => `${i.type} ${i.selector}`));
|
|
84
|
-
const themed = [];
|
|
85
|
-
for (const theme of themes.filter((t) => t !== base)) {
|
|
86
|
-
await setTheme(theme);
|
|
87
|
-
for (const i of await page.evaluate(audit, auditOpts))
|
|
88
|
-
if (i.type === 'contrast' || !seen.has(`${i.type} ${i.selector}`))
|
|
89
|
-
themed.push({ ...i, theme, message: `${theme} theme: ${i.message}` });
|
|
90
|
-
if (shots)
|
|
91
|
-
await shoot(s, theme);
|
|
69
|
+
catch {
|
|
70
|
+
return 0;
|
|
92
71
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
72
|
+
};
|
|
73
|
+
let previous = {};
|
|
74
|
+
try {
|
|
75
|
+
previous = JSON.parse(readFileSync(join(ROOT, 'checks.json'), 'utf8'));
|
|
76
|
+
}
|
|
77
|
+
catch { }
|
|
78
|
+
const currentDeps = Math.max(...depFiles.map(depMtime), depMtime(config.catalogDir), depMtime(join(config.catalogDir, 'catalog.html')), ...(CONFIG_FILE ? [depMtime(CONFIG_FILE)] : []));
|
|
79
|
+
const depsChanged = !previous.checkedAt || (previous.depsMtime ?? 0) < currentDeps;
|
|
80
|
+
// Renders are the point of --shots: every screen is visited, nothing is skipped.
|
|
81
|
+
// ...as are explicit locales: different flags check different things.
|
|
82
|
+
const runKey = JSON.stringify({ locales, shots });
|
|
83
|
+
const skipping = !shots && !depsChanged && previous.runKey !== undefined && previous.runKey === runKey;
|
|
84
|
+
const screenMtime = (path) => {
|
|
85
|
+
try {
|
|
86
|
+
return statSync(join(ROOT, path)).mtimeMs;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return Infinity;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const checkedAt = previous.checkedAt ? Date.parse(previous.checkedAt) : 0;
|
|
93
|
+
const todo = screens.filter((s) => !skipping || !previous.screens?.[s.path] || screenMtime(s.path) >= checkedAt);
|
|
94
|
+
for (const s of screens) {
|
|
95
|
+
if (!todo.includes(s)) {
|
|
96
|
+
results[s.path] = previous.screens[s.path];
|
|
97
|
+
if (previous.components?.[s.path])
|
|
98
|
+
components[s.path] = previous.components[s.path];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const checkOne = async (s, worker) => {
|
|
102
|
+
if (worker.visits > 0 && worker.visits % 20 === 0) {
|
|
103
|
+
// A check loads each screen several times (themes, languages, widths); a fresh browser keeps long
|
|
104
|
+
// runs alive.
|
|
105
|
+
await worker.page.close().catch(() => { });
|
|
106
|
+
await worker.browser.close().catch(() => { });
|
|
107
|
+
const next = await fresh();
|
|
108
|
+
worker.browser = next.browser;
|
|
109
|
+
worker.page = next.page;
|
|
110
110
|
}
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
111
|
+
worker.visits++;
|
|
112
|
+
const page = worker.page;
|
|
113
|
+
try {
|
|
114
|
+
await page.setViewportSize({ width: s.width, height: 844 });
|
|
114
115
|
await page
|
|
115
|
-
.goto(`http://127.0.0.1:${server.port}/${s.path}
|
|
116
|
+
.goto(`http://127.0.0.1:${server.port}/${s.path}`, { waitUntil: 'networkidle', timeout: 30_000 })
|
|
116
117
|
.catch(() => { });
|
|
118
|
+
// Tailwind's CDN build, web fonts and the lab components settle after load.
|
|
117
119
|
await page.waitForTimeout(600);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
themed.push({
|
|
142
|
-
severity: 'warning',
|
|
143
|
-
type: 'i18n-missing',
|
|
144
|
-
locale,
|
|
145
|
-
selector,
|
|
146
|
-
text: m.text,
|
|
147
|
-
message: `${locale}: no translation for ${lacks.join(' ')}`,
|
|
148
|
-
});
|
|
120
|
+
const found = await page.evaluate(collectComponents, collect);
|
|
121
|
+
components[s.path] = found.components;
|
|
122
|
+
const own = await page.evaluate(() => document.documentElement.dataset.theme || '');
|
|
123
|
+
const base = own || themes[0];
|
|
124
|
+
const layout = await page.evaluate(audit, auditOpts);
|
|
125
|
+
// With reduced motion asked for, nothing keeps moving (DESIGN.md motion, WCAG 2.3.3).
|
|
126
|
+
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
127
|
+
await page.waitForTimeout(100);
|
|
128
|
+
layout.push(...(await page.evaluate(movingAnimations)));
|
|
129
|
+
await page.emulateMedia({ reducedMotion: null });
|
|
130
|
+
for (const i of layout)
|
|
131
|
+
if (i.type === 'contrast')
|
|
132
|
+
i.theme = base;
|
|
133
|
+
// The other themes: what the colours change (contrast), plus anything that only shows there.
|
|
134
|
+
const seen = new Set(layout.map((i) => `${i.type} ${i.selector}`));
|
|
135
|
+
const themed = [];
|
|
136
|
+
for (const theme of themes.filter((t) => t !== base)) {
|
|
137
|
+
await setTheme(page, theme);
|
|
138
|
+
for (const i of await page.evaluate(audit, auditOpts))
|
|
139
|
+
if (i.type === 'contrast' || !seen.has(`${i.type} ${i.selector}`))
|
|
140
|
+
themed.push({ ...i, theme, message: `${theme} theme: ${i.message}` });
|
|
141
|
+
if (shots)
|
|
142
|
+
await shoot(page, s, theme);
|
|
149
143
|
}
|
|
144
|
+
if (themes.length > 1)
|
|
145
|
+
await setTheme(page, base);
|
|
146
|
+
const typed = typeIssues(levels, await page.evaluate(collectText));
|
|
150
147
|
if (shots)
|
|
151
|
-
await shoot(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
themed.push({ ...i, width, message: `at ${width}px: ${i.message}` });
|
|
148
|
+
await shoot(page, s);
|
|
149
|
+
// A catalog: every control at rest, hovered, focused and pressed (states.ts), then its contrast in each theme.
|
|
150
|
+
const states = [];
|
|
151
|
+
if (s.kind === 'components') {
|
|
152
|
+
const own = await catalogStates(page, s.path, { crop: true });
|
|
153
|
+
states.push(...own.issues);
|
|
154
|
+
stateRows[s.path] = own.rows;
|
|
155
|
+
for (const theme of themes.filter((t) => t !== base)) {
|
|
156
|
+
await setTheme(page, theme);
|
|
157
|
+
states.push(...(await catalogStates(page, s.path, { crop: false, theme })).issues.filter((i) => i.type === 'state-contrast'));
|
|
158
|
+
}
|
|
159
|
+
if (themes.length > 1)
|
|
160
|
+
await setTheme(page, base);
|
|
165
161
|
}
|
|
166
|
-
|
|
167
|
-
|
|
162
|
+
// The other languages, in the screen's own theme. Reported once per element and kind, in the first locale.
|
|
163
|
+
const unknownSeen = new Set();
|
|
164
|
+
for (const locale of locales) {
|
|
165
|
+
await page
|
|
166
|
+
.goto(`http://127.0.0.1:${server.port}/${s.path}?lang=${encodeURIComponent(locale)}`, { waitUntil: 'networkidle', timeout: 30_000 })
|
|
167
|
+
.catch(() => { });
|
|
168
|
+
await page.waitForTimeout(600);
|
|
169
|
+
for (const i of await page.evaluate(audit, auditOpts)) {
|
|
170
|
+
const key = `${i.type} ${i.selector}`;
|
|
171
|
+
if (i.type === 'contrast' || seen.has(key))
|
|
172
|
+
continue;
|
|
173
|
+
seen.add(key);
|
|
174
|
+
themed.push({ ...i, locale, message: `${locale}: ${i.message}` });
|
|
175
|
+
}
|
|
176
|
+
const lost = await page.evaluate(() => window.__stitch2?.missing ?? []);
|
|
177
|
+
for (const m of lost) {
|
|
178
|
+
const selector = `[data-s2-key="${m.all}"]`;
|
|
179
|
+
const unknown = m.unknown.split(' ').filter(Boolean);
|
|
180
|
+
const fresh = unknown.filter((k) => !unknownSeen.has(k));
|
|
181
|
+
fresh.forEach((k) => unknownSeen.add(k));
|
|
182
|
+
if (fresh.length)
|
|
183
|
+
themed.push({
|
|
184
|
+
severity: 'error',
|
|
185
|
+
type: 'i18n-unknown-key',
|
|
186
|
+
selector,
|
|
187
|
+
text: m.text,
|
|
188
|
+
message: `No message for ${fresh.join(' ')}: add it to the source language's messages`,
|
|
189
|
+
});
|
|
190
|
+
const lacks = m.keys.split(' ').filter((k) => !unknown.includes(k));
|
|
191
|
+
if (lacks.length)
|
|
192
|
+
themed.push({
|
|
193
|
+
severity: 'warning',
|
|
194
|
+
type: 'i18n-missing',
|
|
195
|
+
locale,
|
|
196
|
+
selector,
|
|
197
|
+
text: m.text,
|
|
198
|
+
message: `${locale}: no translation for ${lacks.join(' ')}`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (shots)
|
|
202
|
+
await shoot(page, s, undefined, locale);
|
|
203
|
+
}
|
|
204
|
+
// The edges of the widths this variant covers (config widths) and the breakpoints inside them, in its own theme
|
|
205
|
+
// and language: what breaks only when the window is narrower or wider than the frame it was drawn in.
|
|
206
|
+
for (const width of s.kind === 'screen' ? checkWidths(s.device).filter((w) => w !== s.width) : []) {
|
|
207
|
+
await page.setViewportSize({ width, height: 844 });
|
|
208
|
+
await page.goto(`http://127.0.0.1:${server.port}/${s.path}`, { waitUntil: 'networkidle', timeout: 30_000 }).catch(() => { });
|
|
209
|
+
await page.waitForTimeout(600);
|
|
210
|
+
for (const i of await page.evaluate(audit, auditOpts)) {
|
|
211
|
+
const key = `${i.type} ${i.selector}`;
|
|
212
|
+
if (i.type === 'contrast' || seen.has(key))
|
|
213
|
+
continue;
|
|
214
|
+
seen.add(key);
|
|
215
|
+
themed.push({ ...i, width, message: `at ${width}px: ${i.message}` });
|
|
216
|
+
}
|
|
217
|
+
if (shots)
|
|
218
|
+
await shoot(page, { ...s, width }, undefined, undefined, `${width}px`);
|
|
219
|
+
}
|
|
220
|
+
await page.setViewportSize({ width: s.width, height: 844 });
|
|
221
|
+
results[s.path] = [
|
|
222
|
+
...layout,
|
|
223
|
+
...themed,
|
|
224
|
+
...typed,
|
|
225
|
+
...reuseIssues(s.path, found),
|
|
226
|
+
...colourIssues(s.path),
|
|
227
|
+
...states,
|
|
228
|
+
];
|
|
168
229
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
|
|
230
|
+
catch (e) {
|
|
231
|
+
// A dead page must not kill the other workers: record the crash visibly, retry next run.
|
|
232
|
+
results[s.path] = [
|
|
233
|
+
{
|
|
234
|
+
severity: 'error',
|
|
235
|
+
type: 'check-crashed',
|
|
236
|
+
message: `check crashed on this screen (${String(e).split('\n')[0]}): rerun to retry`,
|
|
237
|
+
},
|
|
238
|
+
];
|
|
239
|
+
console.error(`check: ${s.path} crashed, recorded and continuing`);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
let cursor = 0;
|
|
243
|
+
// One screen per worker at a time: workers pull from the queue, so a page is never shared.
|
|
244
|
+
const drive = async (worker) => {
|
|
245
|
+
while (cursor < todo.length)
|
|
246
|
+
await checkOne(todo[cursor++], worker);
|
|
247
|
+
};
|
|
248
|
+
await Promise.all(pool.map((w) => drive(w)));
|
|
249
|
+
await Promise.all(pool.map(async (w) => {
|
|
250
|
+
await w.page.close().catch(() => { });
|
|
251
|
+
await w.browser.close().catch(() => { });
|
|
252
|
+
}));
|
|
180
253
|
// The states sheet: the catalogs checked now, and the rows of the others from their last check.
|
|
181
254
|
if (Object.keys(stateRows).length)
|
|
182
255
|
writeStatesSheet(stateRows, listScreens().filter((s) => s.kind === 'components').map((s) => s.path));
|
|
183
256
|
server.close();
|
|
184
|
-
async function setTheme(theme) {
|
|
257
|
+
async function setTheme(page, theme) {
|
|
185
258
|
await page.evaluate((t) => {
|
|
186
259
|
document.documentElement.dataset.theme = t;
|
|
187
260
|
}, theme);
|
|
188
261
|
await page.waitForTimeout(150);
|
|
189
262
|
}
|
|
190
263
|
/** A page-tall render; renders in another theme or language than the screen's own get a --<theme> suffix. */
|
|
191
|
-
async function shoot(s, theme, locale, at) {
|
|
264
|
+
async function shoot(page, s, theme, locale, at) {
|
|
192
265
|
const height = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
193
266
|
await page.setViewportSize({ width: s.width, height: Math.max(844, Math.min(height, MAX_RENDER_HEIGHT)) });
|
|
194
267
|
mkdirSync(join(ROOT, 'renders'), { recursive: true });
|
|
@@ -197,12 +270,6 @@ async function shoot(s, theme, locale, at) {
|
|
|
197
270
|
await page.screenshot({ path: join(ROOT, 'renders', name) });
|
|
198
271
|
await page.setViewportSize({ width: s.width, height: 844 });
|
|
199
272
|
}
|
|
200
|
-
// Merge with the last results, so a filtered run still compares components with every other screen.
|
|
201
|
-
let previous = {};
|
|
202
|
-
try {
|
|
203
|
-
previous = JSON.parse(readFileSync(join(ROOT, 'checks.json'), 'utf8'));
|
|
204
|
-
}
|
|
205
|
-
catch { }
|
|
206
273
|
const exists = new Set(listScreens().map((s) => s.path));
|
|
207
274
|
const keep = (o) => Object.fromEntries(Object.entries(o).filter(([p]) => exists.has(p)));
|
|
208
275
|
const allComponents = keep({ ...previous.components, ...components });
|
|
@@ -221,7 +288,7 @@ for (const s of screens) {
|
|
|
221
288
|
for (const i of issues.filter((i) => i.severity === 'error'))
|
|
222
289
|
console.log(` ${i.type}: ${i.message}${i.text ? ` — “${i.text}”` : ''}`);
|
|
223
290
|
}
|
|
224
|
-
writeFileSync(join(ROOT, 'checks.json'), `${JSON.stringify({ checkedAt: new Date().toISOString(), screens: keep({ ...previous.screens, ...results }), components: allComponents }, null, 2)}\n`);
|
|
291
|
+
writeFileSync(join(ROOT, 'checks.json'), `${JSON.stringify({ checkedAt: new Date().toISOString(), depsMtime: currentDeps, runKey, screens: keep({ ...previous.screens, ...results }), components: allComponents }, null, 2)}\n`);
|
|
225
292
|
console.log(`\n${screens.length} screens checked, ${errors} errors. Results in design/checks.json (badges on the canvas).`);
|
|
226
293
|
if (strict && errors)
|
|
227
294
|
process.exit(1);
|
package/dist/screens.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface Screen {
|
|
|
14
14
|
title: string;
|
|
15
15
|
/** Frame width in px: <meta name="<prefix>-frame-width" content="390">, default 390 (phone). */
|
|
16
16
|
width: number;
|
|
17
|
+
/** Viewport height for vh/svh/dvh layout; independent of the full document height. */
|
|
18
|
+
viewportHeight: number;
|
|
17
19
|
mtime: number;
|
|
18
20
|
/** "screen", or "components" for the component catalog. */
|
|
19
21
|
kind: 'screen' | 'components';
|
package/dist/screens.js
CHANGED
|
@@ -48,6 +48,7 @@ export function listScreens() {
|
|
|
48
48
|
name,
|
|
49
49
|
title: /<title>([^<]*)<\/title>/i.exec(html)?.[1]?.trim() ?? '',
|
|
50
50
|
width,
|
|
51
|
+
viewportHeight: Math.min(4000, Math.max(200, Number(meta(html, tag('frame-height'))) || 844)),
|
|
51
52
|
mtime: statSync(file).mtimeMs,
|
|
52
53
|
kind: under(path, config.catalogDir) ? 'components' : 'screen',
|
|
53
54
|
screen: meta(html, tag('screen')) ?? (versioned ? group.split('/').pop() : name),
|
package/dist/serve.js
CHANGED
|
@@ -16,6 +16,7 @@ import { designPage } from './design-page.js';
|
|
|
16
16
|
import { languagesPage } from './languages-page.js';
|
|
17
17
|
import { annotate } from './pick.js';
|
|
18
18
|
import { closeShots, sharedChanged, shot } from './shots.js';
|
|
19
|
+
import { pinViewportHeight } from './viewport.js';
|
|
19
20
|
/**
|
|
20
21
|
* Inlines <link rel="stylesheet" type="text/tailwindcss" href="…">: the Tailwind v4 browser build reads @theme and
|
|
21
22
|
* @utility only from inline <style type="text/tailwindcss"> blocks. On one line, so source line numbers hold.
|
|
@@ -210,7 +211,12 @@ export function startServer(port) {
|
|
|
210
211
|
// The canvas loads screens with ?pick: their elements carry source lines for picking.
|
|
211
212
|
if (extname(file) === '.html') {
|
|
212
213
|
const html = inlineTailwind(readFileSync(file, 'utf8'), file);
|
|
213
|
-
|
|
214
|
+
let page = url.searchParams.has('pick') ? annotate(html) : html;
|
|
215
|
+
if (url.searchParams.has('canvas')) {
|
|
216
|
+
const requested = Number(url.searchParams.get('canvas')) || 844;
|
|
217
|
+
const height = Math.min(4000, Math.max(200, requested));
|
|
218
|
+
page = page.replace(/<head(\s[^>]*)?>/i, (head) => head + `<script>(${pinViewportHeight.toString()})(${height})</script>`);
|
|
219
|
+
}
|
|
214
220
|
const lang = url.searchParams.get('lang');
|
|
215
221
|
if (!lang || lang === sourceLocale())
|
|
216
222
|
return res.end(page);
|
package/dist/shots.js
CHANGED
|
@@ -10,7 +10,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSy
|
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { messagesFile, sourceLocale } from './i18n.js';
|
|
13
|
-
import { ROOT } from './screens.js';
|
|
13
|
+
import { ROOT, meta, tag } from './screens.js';
|
|
14
14
|
/** The tallest picture, in CSS px: the same limit as the canvas frames and check's renders. */
|
|
15
15
|
const MAX_HEIGHT = 20000;
|
|
16
16
|
const DIR = join(tmpdir(), 'stitch2-shots');
|
|
@@ -48,6 +48,7 @@ function keyOf(r) {
|
|
|
48
48
|
return null;
|
|
49
49
|
const messages = r.lang && r.lang !== sourceLocale() ? messagesFile(r.lang) : undefined;
|
|
50
50
|
const parts = [
|
|
51
|
+
'viewport-v2',
|
|
51
52
|
r.path,
|
|
52
53
|
statSync(file).mtimeMs,
|
|
53
54
|
sharedStamp(),
|
|
@@ -89,8 +90,12 @@ export function shot(base, r) {
|
|
|
89
90
|
browser ??= launch();
|
|
90
91
|
const page = await (await browser).newPage({ viewport: { width: r.width, height: 844 }, deviceScaleFactor: 1 });
|
|
91
92
|
try {
|
|
92
|
-
const
|
|
93
|
-
await page.
|
|
93
|
+
const viewport = Math.min(4000, Math.max(200, Number(meta(readFileSync(join(ROOT, r.path), 'utf8'), tag('frame-height'))) || 844));
|
|
94
|
+
await page.setViewportSize({ width: r.width, height: viewport });
|
|
95
|
+
const query = new URLSearchParams({ canvas: String(viewport) });
|
|
96
|
+
if (r.lang && r.lang !== sourceLocale())
|
|
97
|
+
query.set('lang', r.lang);
|
|
98
|
+
await page.goto(`${base}/${r.path}?${query}`, { waitUntil: 'networkidle', timeout: 30_000 }).catch(() => { });
|
|
94
99
|
if (r.theme)
|
|
95
100
|
await page.evaluate((t) => { document.documentElement.dataset.theme = t; }, r.theme);
|
|
96
101
|
await page.evaluate(() => document.fonts.ready);
|
package/dist/viewport.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Runs inside a page. Canvas frames grow to the document, but viewport units
|
|
2
|
+
* must keep describing the original screen rather than that growing frame. */
|
|
3
|
+
export function pinViewportHeight(height) {
|
|
4
|
+
// shortcut: cross-origin and shadow-root styles keep native units; localize sheets before supporting those screens.
|
|
5
|
+
const freeze = () => {
|
|
6
|
+
const units = /url\(\s*(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|(?:\\.|[^)])*)\s*\)|(["'])(?:\\.|(?!\1)[\s\S])*?\1|(-?(?:\d*\.)?\d+)(?:s|l|d)?v(h|min|max)\b/gi;
|
|
7
|
+
const value = (text) => text.replace(units, (match, quoted, amount, axis) => {
|
|
8
|
+
if (quoted || amount == null)
|
|
9
|
+
return match;
|
|
10
|
+
const unit = axis.toLowerCase();
|
|
11
|
+
const size = unit === 'h' ? height : unit === 'min' ? Math.min(innerWidth, height) : Math.max(innerWidth, height);
|
|
12
|
+
return `${Number(amount) * size / 100}px`;
|
|
13
|
+
});
|
|
14
|
+
const style = (declarations) => {
|
|
15
|
+
for (const property of Array.from(declarations)) {
|
|
16
|
+
const current = declarations.getPropertyValue(property);
|
|
17
|
+
const next = value(current);
|
|
18
|
+
if (next !== current)
|
|
19
|
+
declarations.setProperty(property, next, declarations.getPropertyPriority(property));
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const walk = (rules) => {
|
|
23
|
+
for (const rule of Array.from(rules)) {
|
|
24
|
+
if ('style' in rule)
|
|
25
|
+
style(rule.style);
|
|
26
|
+
if ('cssRules' in rule) {
|
|
27
|
+
try {
|
|
28
|
+
walk(rule.cssRules);
|
|
29
|
+
}
|
|
30
|
+
catch { /* Cross-origin nested rules remain browser-owned. */ }
|
|
31
|
+
}
|
|
32
|
+
if ('styleSheet' in rule) {
|
|
33
|
+
try {
|
|
34
|
+
const imported = rule.styleSheet;
|
|
35
|
+
if (imported)
|
|
36
|
+
walk(imported.cssRules);
|
|
37
|
+
}
|
|
38
|
+
catch { /* Cross-origin imported sheets remain browser-owned. */ }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
43
|
+
try {
|
|
44
|
+
walk(sheet.cssRules);
|
|
45
|
+
}
|
|
46
|
+
catch { /* Cross-origin sheets remain browser-owned. */ }
|
|
47
|
+
}
|
|
48
|
+
for (const element of document.querySelectorAll('[style]'))
|
|
49
|
+
style(element.style);
|
|
50
|
+
};
|
|
51
|
+
let pending = false;
|
|
52
|
+
const schedule = () => {
|
|
53
|
+
if (pending)
|
|
54
|
+
return;
|
|
55
|
+
pending = true;
|
|
56
|
+
requestAnimationFrame(() => { pending = false; freeze(); });
|
|
57
|
+
};
|
|
58
|
+
// Fonts, async style links and browser-built Tailwind can arrive after DOMContentLoaded.
|
|
59
|
+
document.addEventListener('DOMContentLoaded', freeze, { once: true });
|
|
60
|
+
document.addEventListener('load', freeze, true);
|
|
61
|
+
new MutationObserver(schedule).observe(document.documentElement, {
|
|
62
|
+
attributes: true,
|
|
63
|
+
attributeFilter: ['style'],
|
|
64
|
+
childList: true,
|
|
65
|
+
subtree: true,
|
|
66
|
+
});
|
|
67
|
+
freeze();
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -17,6 +17,11 @@ and build on it. Meta tag names below use the project's prefix (`<p>`, from `sti
|
|
|
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
|
+
`<p>-frame-height` optionally sets the viewport height the design was drawn at (default 844 px).
|
|
21
|
+
It is not the document height: long pages still appear whole. In canvas frames and their pictures,
|
|
22
|
+
`vh`, `svh`, `dvh` and `lvh` keep this viewport height when the frame grows, so full-height sections
|
|
23
|
+
do not create ever-growing gaps. Width responsiveness stays active.
|
|
24
|
+
|
|
20
25
|
## Pages and their screens
|
|
21
26
|
|
|
22
27
|
A place in the app often has several states or steps: the workout with its number pad, its menu and its discard
|