stitch2 0.22.3 → 0.22.5

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.
Files changed (2) hide show
  1. package/dist/check.js +195 -128
  2. package/package.json +1 -1
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
- let browser = await chromium.launch();
51
- let page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2 });
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
- for (const [i, s] of screens.entries()) {
56
- if (i > 0 && i % 50 === 0) {
57
- // Chromium gets fragile after hundreds of page loads in one browser; a fresh one keeps long runs alive.
58
- await page.close().catch(() => { });
59
- await browser.close().catch(() => { });
60
- browser = await chromium.launch();
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
- await page.setViewportSize({ width: s.width, height: 844 });
64
- await page
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
- if (themes.length > 1)
94
- await setTheme(base);
95
- const typed = typeIssues(levels, await page.evaluate(collectText));
96
- if (shots)
97
- await shoot(s);
98
- // A catalog: every control at rest, hovered, focused and pressed (states.ts), then its contrast in each theme.
99
- const states = [];
100
- if (s.kind === 'components') {
101
- const own = await catalogStates(page, s.path, { crop: true });
102
- states.push(...own.issues);
103
- stateRows[s.path] = own.rows;
104
- for (const theme of themes.filter((t) => t !== base)) {
105
- await setTheme(theme);
106
- states.push(...(await catalogStates(page, s.path, { crop: false, theme })).issues.filter((i) => i.type === 'state-contrast'));
107
- }
108
- if (themes.length > 1)
109
- await setTheme(base);
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
- // The other languages, in the screen's own theme. Reported once per element and kind, in the first locale.
112
- const unknownSeen = new Set();
113
- for (const locale of locales) {
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}?lang=${encodeURIComponent(locale)}`, { waitUntil: 'networkidle', timeout: 30_000 })
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
- for (const i of await page.evaluate(audit, auditOpts)) {
119
- const key = `${i.type} ${i.selector}`;
120
- if (i.type === 'contrast' || seen.has(key))
121
- continue;
122
- seen.add(key);
123
- themed.push({ ...i, locale, message: `${locale}: ${i.message}` });
124
- }
125
- const lost = await page.evaluate(() => window.__stitch2?.missing ?? []);
126
- for (const m of lost) {
127
- const selector = `[data-s2-key="${m.all}"]`;
128
- const unknown = m.unknown.split(' ').filter(Boolean);
129
- const fresh = unknown.filter((k) => !unknownSeen.has(k));
130
- fresh.forEach((k) => unknownSeen.add(k));
131
- if (fresh.length)
132
- themed.push({
133
- severity: 'error',
134
- type: 'i18n-unknown-key',
135
- selector,
136
- text: m.text,
137
- message: `No message for ${fresh.join(' ')}: add it to the source language's messages`,
138
- });
139
- const lacks = m.keys.split(' ').filter((k) => !unknown.includes(k));
140
- if (lacks.length)
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(s, undefined, locale);
152
- }
153
- // The edges of the widths this variant covers (config widths) and the breakpoints inside them, in its own theme
154
- // and language: what breaks only when the window is narrower or wider than the frame it was drawn in.
155
- for (const width of s.kind === 'screen' ? checkWidths(s.device).filter((w) => w !== s.width) : []) {
156
- await page.setViewportSize({ width, height: 844 });
157
- await page.goto(`http://127.0.0.1:${server.port}/${s.path}`, { waitUntil: 'networkidle', timeout: 30_000 }).catch(() => { });
158
- await page.waitForTimeout(600);
159
- for (const i of await page.evaluate(audit, auditOpts)) {
160
- const key = `${i.type} ${i.selector}`;
161
- if (i.type === 'contrast' || seen.has(key))
162
- continue;
163
- seen.add(key);
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
- if (shots)
167
- await shoot({ ...s, width }, undefined, undefined, `${width}px`);
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
- await page.setViewportSize({ width: s.width, height: 844 });
170
- results[s.path] = [
171
- ...layout,
172
- ...themed,
173
- ...typed,
174
- ...reuseIssues(s.path, found),
175
- ...colourIssues(s.path),
176
- ...states,
177
- ];
178
- }
179
- await browser.close();
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitch2",
3
- "version": "0.22.3",
3
+ "version": "0.22.5",
4
4
  "description": "A design canvas, layout and type checks, component consistency and screen versioning for agent-built UI, on Google's DESIGN.md format.",
5
5
  "license": "MIT",
6
6
  "author": "ifokeev",