stitch2 0.24.0 → 0.24.2
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 +1 -1
- package/dist/audit.js +11 -2
- package/dist/canvas.html +9 -1
- package/dist/check.js +96 -12
- package/package.json +1 -1
- package/skills/stitch2/SKILL.md +3 -0
- package/skills/stitch2/references/google.md +2 -1
package/README.md
CHANGED
|
@@ -262,7 +262,7 @@ layout first, then a shorter natural word, never a smaller type size.
|
|
|
262
262
|
| `stitch2 init --name N --primary #hex [options]` | Writes a complete DESIGN.md from a few choices |
|
|
263
263
|
| `stitch2 extract <url or file…>` | Writes a DESIGN.md measured from an existing site or screens |
|
|
264
264
|
| `stitch2 canvas` | The canvas at http://localhost:4400 (`PORT` and `HOST` to change) |
|
|
265
|
-
| `stitch2 check [filter] [--shots] [--strict] [--locales l,…]` | All checks, in every theme, app state and check language; `--shots` saves renders to `<design>/renders/` (`--light.png`, `--pseudo.png`, `--auth-user.png`…) |
|
|
265
|
+
| `stitch2 check [filter] [--shots] [--strict] [--locales l,…]` | All checks, in every theme, app state and check language; `--shots` saves renders to `<design>/renders/` (`--light.png`, `--pseudo.png`, `--auth-user.png`…). Reruns visit only the screens that changed; after a change to stitch2, DESIGN.md, the tokens, the components or the catalog every screen is visited again, which takes minutes on a large project. Progress is printed to stderr, and `checks.json` is saved as screens finish and when the run is stopped (Ctrl-C), so the next run continues where it left off |
|
|
266
266
|
| `stitch2 i18n [filter] [--locale l] [--suggest] [--json]` | Message keys the source or a locale lacks, and screen text with no key |
|
|
267
267
|
| `stitch2 consistency [kind…]` | The consistency report, `<design>/consistency/index.html` |
|
|
268
268
|
| `stitch2 type [filter] [--json]` | The type audit |
|
package/dist/audit.js
CHANGED
|
@@ -842,8 +842,17 @@ export async function audit(opts) {
|
|
|
842
842
|
if (!el.matches('nav, [role=navigation], [role=tablist]') && !el.querySelector('nav, [role=navigation], [role=tablist]'))
|
|
843
843
|
continue;
|
|
844
844
|
const items = [...el.querySelectorAll('a[href], button, [role=tab]')].filter((i) => shown(i) && !i.parentElement?.closest('a[href], button, [role=tab]'));
|
|
845
|
-
|
|
846
|
-
|
|
845
|
+
// The destinations stand in one row. A panel above them (a workout that is running, a player) belongs to the bar
|
|
846
|
+
// but is not a destination: the widest row counts.
|
|
847
|
+
const tops = items.map((i) => i.getBoundingClientRect().top).sort((a, b) => a - b);
|
|
848
|
+
let widest = 0;
|
|
849
|
+
for (let from = 0, row = 0; row < tops.length; row++) {
|
|
850
|
+
if (tops[row] - tops[from] > 12)
|
|
851
|
+
from = row;
|
|
852
|
+
widest = Math.max(widest, row - from + 1);
|
|
853
|
+
}
|
|
854
|
+
if (widest > 5)
|
|
855
|
+
push('warning', 'nav-destinations', el, widest + " destinations in the bottom navigation: Material's navigation bar holds 3–5");
|
|
847
856
|
}
|
|
848
857
|
// 6h. Keyboard focus is visible (Material's focus state, web.dev, WCAG 2.4.7): focusing a control changes how
|
|
849
858
|
// it or its frame looks. Transitions are paused so a ring that fades in still counts.
|
package/dist/canvas.html
CHANGED
|
@@ -563,7 +563,15 @@ function tree() {
|
|
|
563
563
|
item('Languages', 'screens × languages', docOpen('/languages'), () => openDoc('/languages'))
|
|
564
564
|
const all = rows().filter((r) => r.key !== '~components' && r.visible.length)
|
|
565
565
|
head('Screens')
|
|
566
|
-
|
|
566
|
+
// A rollup is green only when nothing is pending: like versionsOf, review outranks approved, and archived
|
|
567
|
+
// versions do not count.
|
|
568
|
+
const dotOf = (list) => {
|
|
569
|
+
const live = list.filter((s) => s.status !== 'archived')
|
|
570
|
+
if (live.some((s) => s.status === 'review')) return 'review'
|
|
571
|
+
if (live.some((s) => s.status !== 'approved')) return 'draft'
|
|
572
|
+
if (live.some((s) => s.status === 'approved')) return 'approved'
|
|
573
|
+
return 'draft'
|
|
574
|
+
}
|
|
567
575
|
const screenRow = (row, nested) => {
|
|
568
576
|
const name = document.createElement('div')
|
|
569
577
|
name.className = `screen-name${nested ? ' state' : ''}`
|
package/dist/check.js
CHANGED
|
@@ -17,9 +17,12 @@
|
|
|
17
17
|
* --shots also save page-tall renders to design/renders/ (one per theme, app state, language and width)
|
|
18
18
|
* --strict exit 1 when any screen has errors (for CI and agent loops)
|
|
19
19
|
* Screens run four at a time; reruns only visit screens that changed since design/checks.json (or
|
|
20
|
-
* everything DESIGN.md, the tokens, the components
|
|
20
|
+
* everything DESIGN.md, the tokens, the components, the catalog or stitch2 itself changed since: a first run after
|
|
21
|
+
* that visits every screen and takes minutes on a large project). Progress goes to stderr, one line per screen
|
|
22
|
+
* ("check: 37/588 …, about 14 min left"). design/checks.json is saved as screens finish and when the run is stopped
|
|
23
|
+
* (Ctrl-C, kill), so the next run continues with the screens that are left instead of starting over.
|
|
21
24
|
*/
|
|
22
|
-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
23
26
|
import { join } from 'node:path';
|
|
24
27
|
import { fileURLToPath } from 'node:url';
|
|
25
28
|
import { chromium } from '@playwright/test';
|
|
@@ -56,7 +59,8 @@ const server = await startServer(0);
|
|
|
56
59
|
/** The tallest render, in CSS px: a long landing page is shot whole (the canvas draws frames up to the same height). */
|
|
57
60
|
const MAX_RENDER_HEIGHT = 20000;
|
|
58
61
|
const fresh = async () => {
|
|
59
|
-
|
|
62
|
+
// Signals are handled below: a stopped run saves what it has before the browsers close.
|
|
63
|
+
const browser = await chromium.launch({ handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false });
|
|
60
64
|
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2 });
|
|
61
65
|
return { browser, page, visits: 0 };
|
|
62
66
|
};
|
|
@@ -93,6 +97,63 @@ for (const s of screens) {
|
|
|
93
97
|
components[s.path] = previous.components[s.path];
|
|
94
98
|
}
|
|
95
99
|
}
|
|
100
|
+
const exists = new Set(listScreens().map((s) => s.path));
|
|
101
|
+
const keep = (o) => Object.fromEntries(Object.entries(o).filter(([p]) => exists.has(p)));
|
|
102
|
+
const freshPreviousComponents = Object.fromEntries(Object.entries(previous.components ?? {})
|
|
103
|
+
.filter(([path]) => previous.cacheKeys?.[path]?.startsWith(`${currentDeps}:`)
|
|
104
|
+
&& previous.cacheKeys[path].endsWith(`:${fingerprintPaths([join(ROOT, path)])}`)));
|
|
105
|
+
// What this run has finished is saved while it runs, not only at the end: a run that is stopped, killed or crashes
|
|
106
|
+
// keeps its screens (their cache keys are current), and the next run visits only the rest. Written to a temporary
|
|
107
|
+
// file and renamed, so the canvas never reads half a file.
|
|
108
|
+
const writeChecks = (data) => {
|
|
109
|
+
const file = join(ROOT, 'checks.json');
|
|
110
|
+
writeFileSync(`${file}.tmp`, `${JSON.stringify({ checkedAt: new Date().toISOString(), ...data }, null, 2)}\n`);
|
|
111
|
+
renameSync(`${file}.tmp`, file);
|
|
112
|
+
};
|
|
113
|
+
/** Screens finished in this run. Catalogs wait for the end: their States sheet is only written then. */
|
|
114
|
+
const finished = [];
|
|
115
|
+
let lastSaved = Date.now();
|
|
116
|
+
const saveProgress = () => {
|
|
117
|
+
const keys = { ...previous.cacheKeys };
|
|
118
|
+
const rows = { ...previous.screens };
|
|
119
|
+
for (const p of finished) {
|
|
120
|
+
keys[p] = cacheKeys[p];
|
|
121
|
+
rows[p] = results[p];
|
|
122
|
+
}
|
|
123
|
+
writeChecks({ cacheKeys: keep(keys), screens: keep(rows), components: keep({ ...freshPreviousComponents, ...components }) });
|
|
124
|
+
lastSaved = Date.now();
|
|
125
|
+
};
|
|
126
|
+
const note = (line) => process.stderr.write(`check: ${line}\n`);
|
|
127
|
+
const duration = (seconds) => seconds < 90 ? `${Math.round(seconds)} s` : seconds < 5400 ? `${Math.round(seconds / 60)} min` : `${(seconds / 3600).toFixed(1)} h`;
|
|
128
|
+
const sharedChanged = !!previous.cacheKeys && screens.length > 0 && screens.every((s) => !previous.cacheKeys[s.path]?.startsWith(`${currentDeps}:`));
|
|
129
|
+
if (!todo.length)
|
|
130
|
+
note(`all ${screens.length} screens are unchanged since the last run`);
|
|
131
|
+
else if (todo.length < screens.length)
|
|
132
|
+
note(`visiting ${todo.length} of ${screens.length} screens; the others are unchanged since the last run`);
|
|
133
|
+
else
|
|
134
|
+
note(`visiting all ${screens.length} screens` +
|
|
135
|
+
(shots
|
|
136
|
+
? ' (--shots renders every one)'
|
|
137
|
+
: sharedChanged
|
|
138
|
+
? ' (DESIGN.md, the tokens, the components, the catalog or stitch2 itself changed, which rechecks every screen)'
|
|
139
|
+
: ''));
|
|
140
|
+
if (todo.length > 40)
|
|
141
|
+
note('this takes a while: the finished screens are kept, so stopping it (Ctrl-C) loses nothing');
|
|
142
|
+
let stopping = false;
|
|
143
|
+
let finalising = false;
|
|
144
|
+
for (const [signal, code] of [['SIGINT', 130], ['SIGTERM', 143], ['SIGHUP', 129]])
|
|
145
|
+
process.on(signal, () => {
|
|
146
|
+
// Everything is visited: the last write below is quick, and a partial save must not replace it.
|
|
147
|
+
if (finalising)
|
|
148
|
+
process.exit(code);
|
|
149
|
+
if (stopping)
|
|
150
|
+
return;
|
|
151
|
+
stopping = true;
|
|
152
|
+
saveProgress();
|
|
153
|
+
note(`stopped with ${finished.length} of ${todo.length} screens finished; they are kept, run it again to continue`);
|
|
154
|
+
const closed = Promise.all(pool.map((w) => w.browser.close().catch(() => { })));
|
|
155
|
+
void Promise.race([closed, new Promise((r) => setTimeout(r, 3000))]).finally(() => process.exit(code));
|
|
156
|
+
});
|
|
96
157
|
const checkOne = async (s, worker) => {
|
|
97
158
|
if (worker.visits > 0 && worker.visits % 20 === 0) {
|
|
98
159
|
// A check loads each screen several times (themes, languages, widths); a fresh browser keeps long
|
|
@@ -248,16 +309,40 @@ const checkOne = async (s, worker) => {
|
|
|
248
309
|
message: `check crashed on this screen (${String(e).split('\n')[0]}): rerun to retry`,
|
|
249
310
|
},
|
|
250
311
|
];
|
|
251
|
-
|
|
312
|
+
if (!stopping)
|
|
313
|
+
console.error(`check: ${s.path} crashed (${String(e).split('\n')[0]}), recorded and continuing`);
|
|
252
314
|
}
|
|
253
315
|
};
|
|
254
316
|
let cursor = 0;
|
|
317
|
+
const startedAt = Date.now();
|
|
318
|
+
let completed = 0;
|
|
319
|
+
const progress = (path, seconds) => {
|
|
320
|
+
completed++;
|
|
321
|
+
const left = todo.length - completed;
|
|
322
|
+
const eta = completed >= 3 && left > 0 ? `, about ${duration(((Date.now() - startedAt) / 1000 / completed) * left)} left` : '';
|
|
323
|
+
note(`${completed}/${todo.length} ${path} (${duration(seconds)}${eta})`);
|
|
324
|
+
};
|
|
255
325
|
// One screen per worker at a time: workers pull from the queue, so a page is never shared.
|
|
256
326
|
const drive = async (worker) => {
|
|
257
|
-
while (cursor < todo.length)
|
|
258
|
-
|
|
327
|
+
while (cursor < todo.length && !stopping) {
|
|
328
|
+
const s = todo[cursor++];
|
|
329
|
+
const began = Date.now();
|
|
330
|
+
await checkOne(s, worker);
|
|
331
|
+
// A stopped run closes the browsers under the screens still being checked: those are not results.
|
|
332
|
+
if (stopping)
|
|
333
|
+
return;
|
|
334
|
+
if (s.kind !== 'components')
|
|
335
|
+
finished.push(s.path);
|
|
336
|
+
progress(s.path, (Date.now() - began) / 1000);
|
|
337
|
+
if (Date.now() - lastSaved > 15_000)
|
|
338
|
+
saveProgress();
|
|
339
|
+
}
|
|
259
340
|
};
|
|
260
341
|
await Promise.all(pool.map((w) => drive(w)));
|
|
342
|
+
// A stopped run has saved and is closing the browsers; the handler above ends the process.
|
|
343
|
+
if (stopping)
|
|
344
|
+
await new Promise(() => { });
|
|
345
|
+
finalising = true;
|
|
261
346
|
await Promise.all(pool.map(async (w) => {
|
|
262
347
|
await w.page.close().catch(() => { });
|
|
263
348
|
await w.browser.close().catch(() => { });
|
|
@@ -291,11 +376,6 @@ async function shoot(page, s, theme, locale, at) {
|
|
|
291
376
|
await page.screenshot({ path: join(ROOT, 'renders', name) });
|
|
292
377
|
await page.setViewportSize({ width: s.width, height: 844 });
|
|
293
378
|
}
|
|
294
|
-
const exists = new Set(listScreens().map((s) => s.path));
|
|
295
|
-
const keep = (o) => Object.fromEntries(Object.entries(o).filter(([p]) => exists.has(p)));
|
|
296
|
-
const freshPreviousComponents = Object.fromEntries(Object.entries(previous.components ?? {})
|
|
297
|
-
.filter(([path]) => previous.cacheKeys?.[path]?.startsWith(`${currentDeps}:`)
|
|
298
|
-
&& previous.cacheKeys[path].endsWith(`:${fingerprintPaths([join(ROOT, path)])}`)));
|
|
299
379
|
const allComponents = keep({ ...freshPreviousComponents, ...components });
|
|
300
380
|
// Archived versions are history: they do not vote on how a component should look.
|
|
301
381
|
const archived = new Set(listScreens()
|
|
@@ -312,7 +392,11 @@ for (const s of screens) {
|
|
|
312
392
|
for (const i of issues.filter((i) => i.severity === 'error'))
|
|
313
393
|
console.log(` ${i.type}: ${i.message}${i.text ? ` — “${i.text}”` : ''}`);
|
|
314
394
|
}
|
|
315
|
-
|
|
395
|
+
writeChecks({
|
|
396
|
+
cacheKeys: keep({ ...previous.cacheKeys, ...cacheKeys }),
|
|
397
|
+
screens: keep({ ...previous.screens, ...results }),
|
|
398
|
+
components: allComponents,
|
|
399
|
+
});
|
|
316
400
|
console.log(`\n${screens.length} screens checked, ${errors} errors. Results in design/checks.json (badges on the canvas).`);
|
|
317
401
|
if (strict && errors)
|
|
318
402
|
process.exit(1);
|
package/package.json
CHANGED
package/skills/stitch2/SKILL.md
CHANGED
|
@@ -108,6 +108,9 @@ References, read before the first screen:
|
|
|
108
108
|
create or hide a `label-wraps` issue on the trigger. The actual painted control label is still checked.
|
|
109
109
|
Cached checks are invalidated when shared components, the catalog or the checker change. A filtered
|
|
110
110
|
rerun refreshes only those screens; it must not make unrelated screens' older checks appear current.
|
|
111
|
+
The first unfiltered run after such a change visits every screen and can take many minutes on a large project:
|
|
112
|
+
it prints `check: n/total …` with an estimate to stderr, so leave it running and read that, and do not start a
|
|
113
|
+
second `check` beside it. Stopping it (Ctrl-C, kill) keeps the finished screens; the next run visits only the rest.
|
|
111
114
|
Motion warnings: `motion-token` (a transition length DESIGN.md doesn't name) and `reduced-motion` (something
|
|
112
115
|
still moves when the person asked for less); see motion.md. On the catalog, `state-*` warnings come from the
|
|
113
116
|
States sheet (components.md, States).
|
|
@@ -71,7 +71,8 @@ the rest are judged from the render. Where Google and DESIGN.md disagree, DESIGN
|
|
|
71
71
|
## Components
|
|
72
72
|
|
|
73
73
|
- **Navigation bar:** three to five destinations, each an icon with a one-line label; more destinations go to a
|
|
74
|
-
rail, a drawer or a page **(check: nav-destinations; label-wraps)**.
|
|
74
|
+
rail, a drawer or a page **(check: nav-destinations; label-wraps)**. The check counts the destinations in one row:
|
|
75
|
+
a panel stacked above them in the same bar (a running workout's Resume and Discard, a player) is not a destination.
|
|
75
76
|
- **Button emphasis:** filled for the one main action, tonal or outlined for secondary, text buttons for
|
|
76
77
|
low-emphasis; one floating action button at most.
|
|
77
78
|
- **Icon-only controls have a name** that says what they do ("Share", not "Share button"); repeated items in a
|