stitch2 0.13.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -6
- package/config.schema.json +5 -0
- package/dist/audit.js +11 -1
- package/dist/canvas.html +256 -38
- package/dist/check.js +24 -5
- package/dist/compare.js +39 -11
- package/dist/config.d.ts +10 -0
- package/dist/config.js +4 -0
- package/dist/consistency.js +10 -2
- package/dist/i18n-report.js +10 -3
- package/dist/i18n.js +15 -7
- package/dist/languages-page.js +18 -4
- package/dist/list.js +5 -3
- 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/setup.js +11 -1
- package/dist/typecheck.d.ts +2 -0
- package/package.json +1 -1
- package/skills/stitch2/SKILL.md +10 -6
- package/skills/stitch2/references/screens.md +42 -14
- package/skills/{stitch2-variants → stitch2-alternatives}/SKILL.md +4 -4
- package/skills/stitch2-app-sync/SKILL.md +4 -1
- package/skills/stitch2-i18n/SKILL.md +2 -1
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';
|
|
@@ -112,6 +113,24 @@ for (const s of screens) {
|
|
|
112
113
|
if (shots)
|
|
113
114
|
await shoot(s, undefined, locale);
|
|
114
115
|
}
|
|
116
|
+
// The edges of the widths this variant covers (config widths), in its own theme and language: what breaks
|
|
117
|
+
// only when the window is narrower or wider than the frame it was drawn in.
|
|
118
|
+
const range = s.kind === 'screen' ? widthRange(s.device) : undefined;
|
|
119
|
+
for (const width of range ? [...new Set(range)].filter((w) => w !== s.width) : []) {
|
|
120
|
+
await page.setViewportSize({ width, height: 844 });
|
|
121
|
+
await page.goto(`http://127.0.0.1:${server.port}/${s.path}`, { waitUntil: 'networkidle', timeout: 30_000 }).catch(() => { });
|
|
122
|
+
await page.waitForTimeout(600);
|
|
123
|
+
for (const i of await page.evaluate(audit, { comfortable: config.comfortableTarget })) {
|
|
124
|
+
const key = `${i.type} ${i.selector}`;
|
|
125
|
+
if (i.type === 'contrast' || seen.has(key))
|
|
126
|
+
continue;
|
|
127
|
+
seen.add(key);
|
|
128
|
+
themed.push({ ...i, width, message: `at ${width}px: ${i.message}` });
|
|
129
|
+
}
|
|
130
|
+
if (shots)
|
|
131
|
+
await shoot({ ...s, width }, undefined, undefined, `${width}px`);
|
|
132
|
+
}
|
|
133
|
+
await page.setViewportSize({ width: s.width, height: 844 });
|
|
115
134
|
results[s.path] = [
|
|
116
135
|
...layout,
|
|
117
136
|
...themed,
|
|
@@ -129,11 +148,11 @@ async function setTheme(theme) {
|
|
|
129
148
|
await page.waitForTimeout(150);
|
|
130
149
|
}
|
|
131
150
|
/** 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) {
|
|
151
|
+
async function shoot(s, theme, locale, at) {
|
|
133
152
|
const height = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
134
153
|
await page.setViewportSize({ width: s.width, height: Math.max(844, Math.min(height, 6000)) });
|
|
135
154
|
mkdirSync(join(ROOT, 'renders'), { recursive: true });
|
|
136
|
-
const suffix = theme ?? locale;
|
|
155
|
+
const suffix = theme ?? locale ?? at;
|
|
137
156
|
const name = s.path.replaceAll('/', '__').replace(/\.html$/, suffix ? `--${suffix}.png` : '.png');
|
|
138
157
|
await page.screenshot({ path: join(ROOT, 'renders', name) });
|
|
139
158
|
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
|
}
|
|
@@ -248,7 +255,8 @@ export function consistencyIssues(known, paths) {
|
|
|
248
255
|
out[path] = issues;
|
|
249
256
|
const peers = (families.get(familyOf(path)) ?? []).filter((p) => p !== path);
|
|
250
257
|
for (const [kind, sigs] of Object.entries(known[path] ?? {})) {
|
|
251
|
-
|
|
258
|
+
// The catalog shows every variant of a component side by side; a screen shows one.
|
|
259
|
+
if (sigs.length > 1 && MEASURED.has(kind.split(':')[0]) && !catalogs.includes(path))
|
|
252
260
|
issues.push({
|
|
253
261
|
severity: 'warning',
|
|
254
262
|
type: 'consistency',
|
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
|
package/dist/list.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Lists every screen version with its status and the reviewer's note, for agents (and anyone without the
|
|
2
|
+
* Lists every screen version, variant by variant (mobile, desktop), with its status and the reviewer's note, for agents (and anyone without the
|
|
3
3
|
* canvas open). Approved versions are the reference; review versions wait for the user; notes are feedback.
|
|
4
4
|
* Usage: stitch2 screens [screen name] [--status approved|review|draft|archived] [--json]
|
|
5
5
|
*/
|
|
@@ -9,9 +9,11 @@ const status = args.includes('--status') ? args[args.indexOf('--status') + 1] :
|
|
|
9
9
|
const name = args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--status');
|
|
10
10
|
const rows = listScreens()
|
|
11
11
|
.filter((s) => s.kind === 'screen' && (!status || s.status === status) && (!name || s.screen === name))
|
|
12
|
-
.sort((a, b) => a.screen.localeCompare(b.screen) ||
|
|
12
|
+
.sort((a, b) => a.screen.localeCompare(b.screen) ||
|
|
13
|
+
a.version.localeCompare(b.version, undefined, { numeric: true }) ||
|
|
14
|
+
b.device.localeCompare(a.device));
|
|
13
15
|
if (args.includes('--json'))
|
|
14
16
|
console.log(JSON.stringify(rows, null, 2));
|
|
15
17
|
else
|
|
16
18
|
for (const s of rows)
|
|
17
|
-
console.log(`${s.screen.padEnd(20)} ${s.
|
|
19
|
+
console.log(`${s.screen.padEnd(20)} ${s.version.padEnd(8)} ${s.device.padEnd(8)} ${s.status.padEnd(9)} ${s.path}${s.note ? `\n${' '.repeat(39)}note: ${s.note}` : ''}`);
|
|
@@ -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/setup.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* --name and --primary, DESIGN.md and its tokens. Existing files are never overwritten, so it is safe to re-run.
|
|
7
7
|
*/
|
|
8
8
|
import { execFileSync } from 'node:child_process';
|
|
9
|
-
import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
10
10
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { CONFIG_FILE, CONFIG_NAME, config as current } from './config.js';
|
|
@@ -42,6 +42,16 @@ const skills = readdirSync(join(pkg, 'skills'));
|
|
|
42
42
|
const linked = [];
|
|
43
43
|
for (const dir of skillDirs) {
|
|
44
44
|
mkdirSync(resolve(project, dir), { recursive: true });
|
|
45
|
+
// A skill renamed or removed in a newer stitch2 leaves a link to nothing: remove it.
|
|
46
|
+
for (const s of readdirSync(resolve(project, dir))) {
|
|
47
|
+
const at = resolve(project, dir, s);
|
|
48
|
+
if (!lstatSync(at).isSymbolicLink() || existsSync(at))
|
|
49
|
+
continue;
|
|
50
|
+
if (!/(^|[\\/])stitch2[\\/]skills[\\/]/.test(readlinkSync(at)))
|
|
51
|
+
continue;
|
|
52
|
+
unlinkSync(at);
|
|
53
|
+
done.push(`removed ${join(dir, s)} (no longer in stitch2)`);
|
|
54
|
+
}
|
|
45
55
|
for (const s of skills) {
|
|
46
56
|
const at = join(dir, s);
|
|
47
57
|
linked.push(at);
|
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,7 +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
|
|
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.
|
|
28
29
|
- [google.md](references/google.md): Google's guidance (Material, Android, web.dev) as rules with numbers.
|
|
29
30
|
- [guides.md](references/guides.md): the public sources behind the rules.
|
|
30
31
|
|
|
@@ -40,8 +41,9 @@ References, read before the first screen:
|
|
|
40
41
|
3. **Hierarchy before markup.** Name the one thing the user came for, the single primary action (the only
|
|
41
42
|
accent-filled element in its region), and what is secondary. Give every text element a type level and a
|
|
42
43
|
colour before writing markup.
|
|
43
|
-
4. **Build** the next version, `<screens>/<screen>/<device>-v<N>.html`,
|
|
44
|
-
|
|
44
|
+
4. **Build** the next version, `<screens>/<screen>/<device>-v<N>.html`, or a missing variant of an existing
|
|
45
|
+
version (another device, the same N), from the project's template (status `draft`; never edit an approved
|
|
46
|
+
variant). Assemble it from the components; plain markup only where none
|
|
45
47
|
applies, with token classes, `.type-<level>` classes for text, the spacing scale, and the states DESIGN.md
|
|
46
48
|
defines. When the config has `i18n`, mark every product string with its message key (`data-t`, see
|
|
47
49
|
**stitch2-i18n**). Never add classes to a component to change how it looks, and never write a colour value (hex,
|
|
@@ -58,6 +60,8 @@ References, read before the first screen:
|
|
|
58
60
|
screens, or in the screen when it is one element. Look at both renders (`--light.png`). With `i18n`, check
|
|
59
61
|
also runs the screen in the pseudo-languages and the config's locales; fix what they flag as the
|
|
60
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).
|
|
61
65
|
6. **Look and critique.** View the render in `<design>/renders/` (crop the first screenful). Score it with the
|
|
62
66
|
rubric in principles.md, fix what fails, repeat 5–6 at most three times.
|
|
63
67
|
7. **Hand over.** Set the version's status to `review`, point the user to it on the canvas, and report what
|
|
@@ -71,7 +75,7 @@ References, read before the first screen:
|
|
|
71
75
|
|---|---|
|
|
72
76
|
| `setup [--name N --primary #hex] [options]` | Sets a project up in one step; keeps every file that exists |
|
|
73
77
|
| `canvas` | The canvas at http://localhost:4400: screens and versions, DESIGN.md, the consistency report, approval and notes |
|
|
74
|
-
| `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 |
|
|
75
79
|
| `i18n [filter] [--locale l] [--suggest]` | Message keys screens use that the source or a locale lacks, and text with no key (stitch2-i18n) |
|
|
76
80
|
| `consistency [kind…]` | Every shared component cropped from every screen, next to the catalog's version |
|
|
77
81
|
| `type [filter] [--json]` | Every text style a screen uses and the type level it matches |
|
|
@@ -82,11 +86,11 @@ References, read before the first screen:
|
|
|
82
86
|
| `lint` | Google's DESIGN.md linter |
|
|
83
87
|
| `localize [filter]` | Copies remote images a screen uses into its `assets/` folder |
|
|
84
88
|
| `init`, `extract` | Build DESIGN.md from choices, or measure it from an existing site (stitch2-design-md) |
|
|
85
|
-
| `sandbox create <dir> <brief.md>…`, `sandbox import <dir>` | Fresh-eyes versions from agents that see no other versions (stitch2-
|
|
89
|
+
| `sandbox create <dir> <brief.md>…`, `sandbox import <dir>` | Fresh-eyes versions from agents that see no other versions (stitch2-alternatives) |
|
|
86
90
|
|
|
87
91
|
## Alternatives and fresh eyes
|
|
88
92
|
|
|
89
93
|
When the user wants options or a new direction, or a design is stuck, do not iterate on the existing versions:
|
|
90
|
-
use the **stitch2-
|
|
94
|
+
use the **stitch2-alternatives** skill. Fresh agents in sandboxes design from the brief without seeing the other
|
|
91
95
|
versions, and their screens come back as new versions to compare on the canvas. The same skill tests changes to
|
|
92
96
|
the skills or DESIGN.md.
|