stitch2 0.0.0-stage → 0.7.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/LICENSE +21 -0
- package/README.md +208 -2
- package/config.schema.json +109 -0
- package/dist/assets.d.ts +1 -0
- package/dist/assets.js +59 -0
- package/dist/audit.d.ts +10 -0
- package/dist/audit.js +259 -0
- package/dist/build-design.d.ts +39 -0
- package/dist/build-design.js +337 -0
- package/dist/build-elements.d.ts +1 -0
- package/dist/build-elements.js +33 -0
- package/dist/canvas.html +689 -0
- package/dist/check.d.ts +1 -0
- package/dist/check.js +86 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +73 -0
- package/dist/colours.d.ts +2 -0
- package/dist/colours.js +57 -0
- package/dist/compare.d.ts +1 -0
- package/dist/compare.js +185 -0
- package/dist/config.d.ts +53 -0
- package/dist/config.js +38 -0
- package/dist/consistency.d.ts +40 -0
- package/dist/consistency.js +336 -0
- package/dist/design-md.d.ts +27 -0
- package/dist/design-md.js +49 -0
- package/dist/design-page.d.ts +1 -0
- package/dist/design-page.js +127 -0
- package/dist/elements.d.ts +32 -0
- package/dist/elements.js +69 -0
- package/dist/exports.d.ts +25 -0
- package/dist/exports.js +86 -0
- package/dist/extract.d.ts +1 -0
- package/dist/extract.js +197 -0
- package/dist/init.d.ts +5 -0
- package/dist/init.js +83 -0
- package/dist/list.d.ts +1 -0
- package/dist/list.js +17 -0
- package/dist/pick.d.ts +10 -0
- package/dist/pick.js +38 -0
- package/dist/report.d.ts +1 -0
- package/dist/report.js +110 -0
- package/dist/sandbox.d.ts +1 -0
- package/dist/sandbox.js +183 -0
- package/dist/screens.d.ts +34 -0
- package/dist/screens.js +85 -0
- package/dist/serve.d.ts +4 -0
- package/dist/serve.js +181 -0
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +214 -0
- package/dist/tokens.d.ts +1 -0
- package/dist/tokens.js +128 -0
- package/dist/type.d.ts +1 -0
- package/dist/type.js +60 -0
- package/dist/typecheck.d.ts +35 -0
- package/dist/typecheck.js +112 -0
- package/package.json +53 -4
- package/skills/stitch2/SKILL.md +80 -0
- package/skills/stitch2/references/components.md +59 -0
- package/skills/stitch2/references/guides.md +20 -0
- package/skills/stitch2/references/principles.md +80 -0
- package/skills/stitch2/references/screens.md +70 -0
- package/skills/stitch2/references/typography.md +106 -0
- package/skills/stitch2-app-sync/SKILL.md +65 -0
- package/skills/stitch2-consistency/SKILL.md +59 -0
- package/skills/stitch2-design-md/SKILL.md +64 -0
- package/skills/stitch2-import-from-google-stitch/SKILL.md +47 -0
- package/skills/stitch2-import-from-google-stitch/references/how-stitch-works.md +36 -0
- package/skills/stitch2-variants/SKILL.md +45 -0
package/dist/check.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/check.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout checks for every screen, the bugs we kept hitting in generated designs: content past the screen
|
|
3
|
+
* edge, labels wrapping onto two lines, content hidden under fixed bars, tiny tap targets, low text
|
|
4
|
+
* contrast, and text styles that are not one of DESIGN.md's type levels. Writes design/checks.json, which
|
|
5
|
+
* the canvas shows as badges.
|
|
6
|
+
* Usage: stitch2 check [path filter…] [--shots] [--strict]
|
|
7
|
+
* --shots also save page-tall renders to design/renders/
|
|
8
|
+
* --strict exit 1 when any screen has errors (for CI and agent loops)
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { chromium } from '@playwright/test';
|
|
13
|
+
import { colourIssues } from './colours.js';
|
|
14
|
+
import { config } from './config.js';
|
|
15
|
+
import { collectComponents, collectOptions, consistencyIssues, reuseIssues, } from './consistency.js';
|
|
16
|
+
import { typeLevels } from './design-md.js';
|
|
17
|
+
import { listScreens, ROOT } from './screens.js';
|
|
18
|
+
import { startServer } from './serve.js';
|
|
19
|
+
import { collectText, typeIssues } from './typecheck.js';
|
|
20
|
+
import { audit } from './audit.js';
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const shots = args.includes('--shots');
|
|
23
|
+
const strict = args.includes('--strict');
|
|
24
|
+
const filters = args.filter((a) => !a.startsWith('--'));
|
|
25
|
+
const screens = listScreens().filter((s) => !filters.length || filters.some((f) => s.path.includes(f)));
|
|
26
|
+
const levels = typeLevels();
|
|
27
|
+
const collect = collectOptions();
|
|
28
|
+
const server = await startServer(0);
|
|
29
|
+
const browser = await chromium.launch();
|
|
30
|
+
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2 });
|
|
31
|
+
const results = {};
|
|
32
|
+
const components = {};
|
|
33
|
+
for (const s of screens) {
|
|
34
|
+
await page.setViewportSize({ width: s.width, height: 844 });
|
|
35
|
+
await page
|
|
36
|
+
.goto(`http://127.0.0.1:${server.port}/${s.path}`, { waitUntil: 'networkidle', timeout: 30_000 })
|
|
37
|
+
.catch(() => { });
|
|
38
|
+
// Tailwind's CDN build, web fonts and the lab components settle after load.
|
|
39
|
+
await page.waitForTimeout(600);
|
|
40
|
+
const found = await page.evaluate(collectComponents, collect);
|
|
41
|
+
components[s.path] = found.components;
|
|
42
|
+
results[s.path] = [
|
|
43
|
+
...(await page.evaluate(audit, { comfortable: config.comfortableTarget })),
|
|
44
|
+
...typeIssues(levels, await page.evaluate(collectText)),
|
|
45
|
+
...reuseIssues(s.path, found),
|
|
46
|
+
...colourIssues(s.path),
|
|
47
|
+
];
|
|
48
|
+
if (shots) {
|
|
49
|
+
const height = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
50
|
+
await page.setViewportSize({ width: s.width, height: Math.max(844, Math.min(height, 6000)) });
|
|
51
|
+
mkdirSync(join(ROOT, 'renders'), { recursive: true });
|
|
52
|
+
await page.screenshot({
|
|
53
|
+
path: join(ROOT, 'renders', s.path.replaceAll('/', '__').replace(/\.html$/, '.png')),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
await browser.close();
|
|
58
|
+
server.close();
|
|
59
|
+
// Merge with the last results, so a filtered run still compares components with every other screen.
|
|
60
|
+
let previous = {};
|
|
61
|
+
try {
|
|
62
|
+
previous = JSON.parse(readFileSync(join(ROOT, 'checks.json'), 'utf8'));
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
const exists = new Set(listScreens().map((s) => s.path));
|
|
66
|
+
const keep = (o) => Object.fromEntries(Object.entries(o).filter(([p]) => exists.has(p)));
|
|
67
|
+
const allComponents = keep({ ...previous.components, ...components });
|
|
68
|
+
// Archived versions are history: they do not vote on how a component should look.
|
|
69
|
+
const archived = new Set(listScreens()
|
|
70
|
+
.filter((s) => s.status === 'archived')
|
|
71
|
+
.map((s) => s.path));
|
|
72
|
+
const consistency = consistencyIssues(Object.fromEntries(Object.entries(allComponents).filter(([p]) => !archived.has(p) || p in results)), Object.keys(results));
|
|
73
|
+
let errors = 0;
|
|
74
|
+
for (const s of screens) {
|
|
75
|
+
const issues = [...results[s.path], ...(consistency[s.path] ?? [])].sort((x, y) => x.severity === y.severity ? 0 : x.severity === 'error' ? -1 : 1);
|
|
76
|
+
results[s.path] = issues;
|
|
77
|
+
const e = issues.filter((i) => i.severity === 'error').length;
|
|
78
|
+
errors += e;
|
|
79
|
+
console.log(`${e ? '✗' : '✓'} ${s.path.padEnd(42)} ${e} errors, ${issues.length - e} warnings`);
|
|
80
|
+
for (const i of issues.filter((i) => i.severity === 'error'))
|
|
81
|
+
console.log(` ${i.type}: ${i.message}${i.text ? ` — “${i.text}”` : ''}`);
|
|
82
|
+
}
|
|
83
|
+
writeFileSync(join(ROOT, 'checks.json'), `${JSON.stringify({ checkedAt: new Date().toISOString(), screens: keep({ ...previous.screens, ...results }), components: allComponents }, null, 2)}\n`);
|
|
84
|
+
console.log(`\n${screens.length} screens checked, ${errors} errors. Results in design/checks.json (badges on the canvas).`);
|
|
85
|
+
if (strict && errors)
|
|
86
|
+
process.exit(1);
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
3
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
4
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
5
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
return path;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* stitch2: a design canvas, checks and screen versioning for agent-built UI.
|
|
12
|
+
* Usage: stitch2 <command> [args] (reads the nearest stitch2.config.json)
|
|
13
|
+
*/
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
const COMMANDS = {
|
|
18
|
+
setup: [
|
|
19
|
+
'setup.ts',
|
|
20
|
+
'[--name N --primary #hex] [--prefix p] [--skills-dir .agents/skills,…] Set a project up: skills, config, template, catalog, starter components',
|
|
21
|
+
],
|
|
22
|
+
init: [
|
|
23
|
+
'init.ts',
|
|
24
|
+
'--name N --primary #hex [--modes dark,light --sans Inter --mono … --base 15 --roundness 16] Write a complete DESIGN.md',
|
|
25
|
+
],
|
|
26
|
+
extract: [
|
|
27
|
+
'extract.ts',
|
|
28
|
+
'<url or html file…> [--name N] [--out file] Write a DESIGN.md measured from an existing site or screen',
|
|
29
|
+
],
|
|
30
|
+
canvas: [
|
|
31
|
+
'serve.ts',
|
|
32
|
+
'The canvas at http://localhost:4400 (PORT and HOST to change): screens, versions, DESIGN.md, approval',
|
|
33
|
+
],
|
|
34
|
+
check: ['check.ts', '[filter] [--shots] [--strict] Layout, type, reuse and consistency checks'],
|
|
35
|
+
consistency: ['report.ts', '[kind] Every shared component cropped from every screen, side by side'],
|
|
36
|
+
type: ['type.ts', '[filter] [--json] Every text style a screen uses, by type level'],
|
|
37
|
+
tokens: ['tokens.ts', 'Generate tokens.css and tailwind.tokens.js from DESIGN.md, and the exports in the config'],
|
|
38
|
+
compare: ['compare.ts', '[screen…] [--strict] The live app against the approved screens ("app" in the config)'],
|
|
39
|
+
elements: ['build-elements.ts', '<entry> [--out file] Bundle the project\'s components (defineElements) for the screens'],
|
|
40
|
+
lint: ['', "Google's DESIGN.md linter on the project's DESIGN.md"],
|
|
41
|
+
screens: ['list.ts', '[name] [--status s] [--json] Screen versions, statuses and notes'],
|
|
42
|
+
localize: ['assets.ts', '[filter] Copy remote images a screen uses into its assets/ folder'],
|
|
43
|
+
sandbox: [
|
|
44
|
+
'sandbox.ts',
|
|
45
|
+
'create <dir> <brief.md>… [--context <screen>…] | import <dir> Fresh-eyes versions from agents that see no other versions',
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
49
|
+
const entry = command ? COMMANDS[command] : undefined;
|
|
50
|
+
if (!entry) {
|
|
51
|
+
console.log('stitch2 <command>\n');
|
|
52
|
+
for (const [name, [, help]] of Object.entries(COMMANDS))
|
|
53
|
+
console.log(` ${name.padEnd(12)} ${help}`);
|
|
54
|
+
process.exit(command ? 1 : 0);
|
|
55
|
+
}
|
|
56
|
+
if (command === 'lint') {
|
|
57
|
+
const { ROOT } = await import('./config.js');
|
|
58
|
+
// The linter's main entry is also its command line (the package only exports for import).
|
|
59
|
+
const file = fileURLToPath(import.meta.resolve('@google/design.md'));
|
|
60
|
+
try {
|
|
61
|
+
execFileSync(process.execPath, [file, 'lint', join(ROOT, 'DESIGN.md'), ...rest], { stdio: 'inherit' });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// Sources run as .ts (Node strips the types); the published build runs as .js.
|
|
69
|
+
const ext = import.meta.url.endsWith('.ts') ? '.ts' : '.js';
|
|
70
|
+
const script = fileURLToPath(new URL(entry[0].replace(/\.ts$/, ext), import.meta.url));
|
|
71
|
+
process.argv = [process.argv[0], script, ...rest];
|
|
72
|
+
await import(__rewriteRelativeImportExtension(script));
|
|
73
|
+
}
|
package/dist/colours.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Literal colours. On a lab screen every colour comes from DESIGN.md through the generated tokens (token classes
|
|
3
|
+
* such as bg-surface, or var(--<prefix>-<name>), mixed with color-mix for a shade), so changing a colour in
|
|
4
|
+
* DESIGN.md and running stitch2 tokens changes it everywhere. A hex, rgb() or hsl() value written into a screen
|
|
5
|
+
* or a component would not follow. Screens are checked for their own markup; the component catalog is also
|
|
6
|
+
* checked for the scripts it loads from the design folder (the components), so each literal is reported once.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { config } from './config.js';
|
|
11
|
+
import { familyOf } from './consistency.js';
|
|
12
|
+
import { ROOT } from './screens.js';
|
|
13
|
+
const LITERAL = /(?<![\w&/=-])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b|(?<![a-zA-Z])(?:rgba?|hsla?)\(\s*[\d.][^)]*\)/g;
|
|
14
|
+
/** Lines with a literal colour, comments blanked out (keeping line numbers). */
|
|
15
|
+
function literals(source, js) {
|
|
16
|
+
let s = source.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ' '));
|
|
17
|
+
if (js)
|
|
18
|
+
s = s.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')).replace(/^\s*\/\/.*$/gm, '');
|
|
19
|
+
else
|
|
20
|
+
s = s.replace(/<head[\s>][\s\S]*?<\/head>/i, (m) => m.replace(/[^\n]/g, ' '));
|
|
21
|
+
const out = [];
|
|
22
|
+
s.split('\n').forEach((text, i) => {
|
|
23
|
+
for (const m of text.matchAll(LITERAL)) {
|
|
24
|
+
if (/href=["']$/.test(text.slice(0, m.index)))
|
|
25
|
+
continue;
|
|
26
|
+
out.push({ line: i + 1, value: m[0], text: text.trim().slice(0, 80) });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
/** Component scripts already reported in this run (two catalogs load the same file). */
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
export function colourIssues(path) {
|
|
34
|
+
if (familyOf(path) !== 'screens')
|
|
35
|
+
return [];
|
|
36
|
+
const html = readFileSync(join(ROOT, path), 'utf8');
|
|
37
|
+
const found = literals(html, false).map((l) => ({ ...l, file: `${config.designDir}/${path}` }));
|
|
38
|
+
if (path.startsWith(`${config.catalogDir}/`))
|
|
39
|
+
for (const [, src] of html.matchAll(/<script[^>]*\bsrc="\/([^"]+\.js)"/g)) {
|
|
40
|
+
const file = join(ROOT, src);
|
|
41
|
+
if (!existsSync(file) || /tokens/.test(src) || seen.has(file))
|
|
42
|
+
continue;
|
|
43
|
+
seen.add(file);
|
|
44
|
+
const js = readFileSync(file, 'utf8');
|
|
45
|
+
// A bundle from stitch2 elements holds the framework's own code; the project checks its component sources.
|
|
46
|
+
if (js.startsWith('/* Generated by stitch2 elements'))
|
|
47
|
+
continue;
|
|
48
|
+
found.push(...literals(js, true).map((l) => ({ ...l, file: `${config.designDir}/${src}` })));
|
|
49
|
+
}
|
|
50
|
+
return found.map((l) => ({
|
|
51
|
+
severity: 'warning',
|
|
52
|
+
type: 'color',
|
|
53
|
+
message: `Colour ${l.value} written into ${l.file}:${l.line}; use a DESIGN.md colour (a token class or var(--${config.prefix}-…)) so it follows DESIGN.md`,
|
|
54
|
+
selector: `${l.file}:${l.line}`,
|
|
55
|
+
text: l.text,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/compare.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* The live app against the approved screens. For every approved version whose screen has a route in the config's
|
|
11
|
+
* "app" section, it opens the approved version and the app page at the same width and compares their structure:
|
|
12
|
+
* which shared components appear (by their data-<prefix> marker), how many, in what order, and how the measured
|
|
13
|
+
* ones look. Data differs between a mock-up and the app, so pixels are shown side by side, not diffed.
|
|
14
|
+
* Writes <design>/compare/index.html (the canvas opens it from its sidebar) and <design>/compare.json.
|
|
15
|
+
*
|
|
16
|
+
* "app": {
|
|
17
|
+
* "url": "http://localhost:4321",
|
|
18
|
+
* "signIn": "design/app/sign-in.mjs", // export default async ({ page, url }) => { … }
|
|
19
|
+
* "routes": {
|
|
20
|
+
* "home": "/app",
|
|
21
|
+
* "exercise-detail": { "path": "/app/library", "prepare": "design/app/open-exercise.mjs" },
|
|
22
|
+
* "sign-in": { "path": "/login", "signedOut": true } // a page of its own, never signed in
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
*
|
|
26
|
+
* Usage: stitch2 compare [screen…] [--strict]
|
|
27
|
+
*/
|
|
28
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { join, resolve } from 'node:path';
|
|
30
|
+
import { pathToFileURL } from 'node:url';
|
|
31
|
+
import { chromium } from '@playwright/test';
|
|
32
|
+
import { audit, LAYOUT_ERRORS } from './audit.js';
|
|
33
|
+
import { BASE, config } from './config.js';
|
|
34
|
+
import { collectComponents, collectOptions } from './consistency.js';
|
|
35
|
+
import { listScreens, ROOT } from './screens.js';
|
|
36
|
+
import { startServer } from './serve.js';
|
|
37
|
+
const app = config.app;
|
|
38
|
+
if (!app?.url || !app.routes) {
|
|
39
|
+
console.error('stitch2 compare: add an "app" section with "url" and "routes" to stitch2.config.json');
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
const args = process.argv.slice(2);
|
|
43
|
+
const only = args.filter((a) => !a.startsWith('--'));
|
|
44
|
+
const strict = args.includes('--strict');
|
|
45
|
+
const routeOf = (screen) => {
|
|
46
|
+
const r = app.routes[screen];
|
|
47
|
+
return typeof r === 'string' ? { path: r } : r;
|
|
48
|
+
};
|
|
49
|
+
const versions = listScreens().filter((s) => s.kind === 'screen' && s.status === 'approved' && routeOf(s.screen) && (!only.length || only.includes(s.screen)));
|
|
50
|
+
const unmapped = [...new Set(listScreens().filter((s) => s.status === 'approved' && !routeOf(s.screen)).map((s) => s.screen))];
|
|
51
|
+
/** Runs in the page: every marked component in document order, and the top-level ones (not inside another). */
|
|
52
|
+
function structure(prefix) {
|
|
53
|
+
const marker = `data-${prefix}`;
|
|
54
|
+
const shown = (el) => {
|
|
55
|
+
const cs = getComputedStyle(el);
|
|
56
|
+
if (cs.display === 'contents')
|
|
57
|
+
return [...el.children].some(shown);
|
|
58
|
+
const r = el.getBoundingClientRect();
|
|
59
|
+
return cs.display !== 'none' && cs.visibility !== 'hidden' && r.width > 0 && r.height > 0;
|
|
60
|
+
};
|
|
61
|
+
const all = [...document.querySelectorAll(`[${marker}]`)].filter(shown);
|
|
62
|
+
const counts = {};
|
|
63
|
+
for (const el of all) {
|
|
64
|
+
const k = el.getAttribute(marker);
|
|
65
|
+
counts[k] = (counts[k] ?? 0) + 1;
|
|
66
|
+
}
|
|
67
|
+
const top = all.filter((el) => !el.parentElement?.closest(`[${marker}]`)).map((el) => el.getAttribute(marker));
|
|
68
|
+
return { counts, top };
|
|
69
|
+
}
|
|
70
|
+
/** Longest common subsequence length, for how much of the design's order the app keeps. */
|
|
71
|
+
function lcs(a, b) {
|
|
72
|
+
const d = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
73
|
+
for (let i = 1; i <= a.length; i++)
|
|
74
|
+
for (let j = 1; j <= b.length; j++)
|
|
75
|
+
d[i][j] = a[i - 1] === b[j - 1] ? d[i - 1][j - 1] + 1 : Math.max(d[i - 1][j], d[i][j - 1]);
|
|
76
|
+
return d[a.length][b.length];
|
|
77
|
+
}
|
|
78
|
+
const load = async (file) => (await import(__rewriteRelativeImportExtension(pathToFileURL(resolve(BASE, file)).href))).default;
|
|
79
|
+
const settle = async (page) => {
|
|
80
|
+
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => { });
|
|
81
|
+
await page.waitForTimeout(800);
|
|
82
|
+
};
|
|
83
|
+
const out = join(ROOT, 'compare');
|
|
84
|
+
mkdirSync(out, { recursive: true });
|
|
85
|
+
const server = await startServer(0);
|
|
86
|
+
const browser = await chromium.launch();
|
|
87
|
+
const context = await browser.newContext({ deviceScaleFactor: 2 });
|
|
88
|
+
const signIn = await context.newPage();
|
|
89
|
+
if (app.signIn)
|
|
90
|
+
await (await load(app.signIn))({ page: signIn, url: app.url });
|
|
91
|
+
await signIn.close();
|
|
92
|
+
const design = await browser.newPage({ deviceScaleFactor: 2 });
|
|
93
|
+
const signedIn = await context.newPage();
|
|
94
|
+
// Sign-in and other public pages often redirect a signed-in visitor, so they get a context of their own.
|
|
95
|
+
const signedOut = await (await browser.newContext({ deviceScaleFactor: 2 })).newPage();
|
|
96
|
+
const results = [];
|
|
97
|
+
const opts = collectOptions();
|
|
98
|
+
for (const s of versions) {
|
|
99
|
+
const route = routeOf(s.screen);
|
|
100
|
+
const live = route.signedOut ? signedOut : signedIn;
|
|
101
|
+
const url = new URL(route.path, app.url).href;
|
|
102
|
+
const height = s.width > 700 ? 800 : 844;
|
|
103
|
+
for (const p of [design, live])
|
|
104
|
+
await p.setViewportSize({ width: s.width, height });
|
|
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);
|
|
109
|
+
const findings = [];
|
|
110
|
+
if (route.prepare) {
|
|
111
|
+
// A failing prepare is this version's error; the run goes on with the others.
|
|
112
|
+
try {
|
|
113
|
+
await (await load(route.prepare))({ page: live, url: app.url });
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
const message = (e instanceof Error ? e.message : String(e)).split('\n')[0];
|
|
117
|
+
findings.push({ severity: 'error', type: 'prepare', message: `${route.prepare} failed: ${message}` });
|
|
118
|
+
}
|
|
119
|
+
await settle(live);
|
|
120
|
+
}
|
|
121
|
+
const [d, a] = [await design.evaluate(structure, config.prefix), await live.evaluate(structure, config.prefix)];
|
|
122
|
+
const [dc, ac] = [
|
|
123
|
+
(await design.evaluate(collectComponents, opts)).components,
|
|
124
|
+
(await live.evaluate(collectComponents, opts)).components,
|
|
125
|
+
];
|
|
126
|
+
const missing = Object.keys(d.counts).filter((k) => !a.counts[k]);
|
|
127
|
+
const extra = Object.keys(a.counts).filter((k) => !d.counts[k]);
|
|
128
|
+
if (missing.length)
|
|
129
|
+
findings.push({ severity: 'error', type: 'missing', message: `In the design, not in the app: ${missing.join(', ')}` });
|
|
130
|
+
if (extra.length)
|
|
131
|
+
findings.push({ severity: 'warning', type: 'extra', message: `In the app, not in the design: ${extra.join(', ')}` });
|
|
132
|
+
const kept = lcs(d.top, a.top);
|
|
133
|
+
if (d.top.length && kept < d.top.length)
|
|
134
|
+
findings.push({
|
|
135
|
+
severity: 'warning',
|
|
136
|
+
type: 'order',
|
|
137
|
+
message: `Layout differs: ${kept} of ${d.top.length} top-level components in the design's order (design: ${d.top.join(' → ')}; app: ${a.top.join(' → ') || 'none'})`,
|
|
138
|
+
});
|
|
139
|
+
for (const [kind, sigs] of Object.entries(dc)) {
|
|
140
|
+
const mine = ac[kind]?.[0];
|
|
141
|
+
const ref = sigs[0];
|
|
142
|
+
if (!mine || !ref || !Object.keys(ref.fields).length)
|
|
143
|
+
continue;
|
|
144
|
+
const diffs = Object.keys({ ...ref.fields, ...mine.fields }).filter((f) => ref.fields[f] !== mine.fields[f]);
|
|
145
|
+
if (diffs.length)
|
|
146
|
+
findings.push({
|
|
147
|
+
severity: 'warning',
|
|
148
|
+
type: 'look',
|
|
149
|
+
message: `${kind} looks different: ${diffs.map((f) => `${f} ${mine.fields[f] ?? '–'} (design ${ref.fields[f] ?? '–'})`).join('; ')}`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// The same layout rules check runs on the screens, on the app: a control squeezed over another shows up here
|
|
153
|
+
// even when the screen never drew that state.
|
|
154
|
+
for (const i of await live.evaluate(audit, { comfortable: config.comfortableTarget }))
|
|
155
|
+
if (i.severity === 'error' && LAYOUT_ERRORS.includes(i.type))
|
|
156
|
+
findings.push({ severity: 'error', type: i.type, message: `${i.message} (“${i.text}”)` });
|
|
157
|
+
const kinds = Object.keys(d.counts);
|
|
158
|
+
const match = kinds.length ? Math.round((100 * kinds.filter((k) => a.counts[k]).length) / kinds.length) : 100;
|
|
159
|
+
const base = `${s.screen}-${s.device}`;
|
|
160
|
+
await design.screenshot({ path: join(out, `${base}-design.png`), fullPage: true });
|
|
161
|
+
await live.screenshot({ path: join(out, `${base}-app.png`), fullPage: true });
|
|
162
|
+
results.push({ screen: s.screen, device: s.device, version: s.version, design: s.path, url, findings, match });
|
|
163
|
+
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
164
|
+
console.log(`${errors ? '✗' : '✓'} ${base.padEnd(30)} ${String(match).padStart(3)}% of the design's components · ${errors} errors, ${findings.length - errors} warnings`);
|
|
165
|
+
for (const f of findings.filter((f) => f.severity === 'error'))
|
|
166
|
+
console.log(` ${f.type}: ${f.message}`);
|
|
167
|
+
}
|
|
168
|
+
await browser.close();
|
|
169
|
+
server.close();
|
|
170
|
+
const esc = (v) => String(v ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
171
|
+
const rows = results
|
|
172
|
+
.map((r) => {
|
|
173
|
+
const base = `${r.screen}-${r.device}`;
|
|
174
|
+
const list = r.findings.length
|
|
175
|
+
? r.findings.map((f) => `<li class="${f.severity}"><b>${f.type}</b> ${esc(f.message)}</li>`).join('')
|
|
176
|
+
: '<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>`;
|
|
178
|
+
})
|
|
179
|
+
.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></body></html>\n`);
|
|
181
|
+
writeFileSync(join(ROOT, 'compare.json'), `${JSON.stringify({ comparedAt: new Date().toISOString(), url: app.url, results, unmapped }, null, 2)}\n`);
|
|
182
|
+
const total = results.reduce((n, r) => n + r.findings.filter((f) => f.severity === 'error').length, 0);
|
|
183
|
+
console.log(`\n${results.length} versions compared, ${total} errors${unmapped.length ? `; no route for ${unmapped.join(', ')}` : ''}. Report: ${config.designDir}/compare/index.html`);
|
|
184
|
+
if (strict && total)
|
|
185
|
+
process.exit(1);
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export interface LabConfig {
|
|
2
|
+
/** Shown on the canvas. */
|
|
3
|
+
name: string;
|
|
4
|
+
/** Folder with DESIGN.md, the generated tokens, components and screens. */
|
|
5
|
+
designDir: string;
|
|
6
|
+
/** Folders under designDir that hold screens, in canvas order. */
|
|
7
|
+
screenDirs: string[];
|
|
8
|
+
/** Folder under designDir whose screens are Stitch exports (their source defaults to "stitch"). */
|
|
9
|
+
stitchDir: string;
|
|
10
|
+
/** Folders under designDir with past experiments: archived by default, compared only among themselves. */
|
|
11
|
+
archiveDirs: string[];
|
|
12
|
+
/** Folder under designDir with the component catalog screen(s). */
|
|
13
|
+
catalogDir: string;
|
|
14
|
+
/**
|
|
15
|
+
* One prefix for everything the lab writes into screens: meta tags (<prefix>-screen, <prefix>-status…),
|
|
16
|
+
* component elements and their marker (<prefix>-button, data-<prefix>) and CSS variables (--<prefix>-…).
|
|
17
|
+
*/
|
|
18
|
+
prefix: string;
|
|
19
|
+
/** Screen names in canvas order; the rest follow alphabetically. */
|
|
20
|
+
order: string[];
|
|
21
|
+
/** Tap targets smaller than this (px) get a warning; under WCAG's 24px they are errors when crowded. */
|
|
22
|
+
comfortableTarget: number;
|
|
23
|
+
/** Skill folders a blind-trial sandbox copies. */
|
|
24
|
+
skills: string[];
|
|
25
|
+
/** Paths under designDir a sandbox leaves out besides screens (notes that would give the trial away). */
|
|
26
|
+
sandboxExclude?: string[];
|
|
27
|
+
/** The live app, for stitch2 compare (see compare.ts): its URL, a sign-in script and a route per screen. */
|
|
28
|
+
app?: {
|
|
29
|
+
url: string;
|
|
30
|
+
signIn?: string;
|
|
31
|
+
routes: Record<string, string | {
|
|
32
|
+
path: string;
|
|
33
|
+
prepare?: string;
|
|
34
|
+
}>;
|
|
35
|
+
};
|
|
36
|
+
/** Token files stitch2 tokens also writes for the app (see exports.ts). */
|
|
37
|
+
exports?: {
|
|
38
|
+
format: 'css' | 'tailwind4' | 'dtcg';
|
|
39
|
+
path: string;
|
|
40
|
+
aliases?: Record<string, string>;
|
|
41
|
+
themes?: {
|
|
42
|
+
dark?: string;
|
|
43
|
+
light?: string;
|
|
44
|
+
};
|
|
45
|
+
}[];
|
|
46
|
+
}
|
|
47
|
+
export declare const CONFIG_NAME = "stitch2.config.json";
|
|
48
|
+
export declare const CONFIG_FILE: string | undefined;
|
|
49
|
+
/** The folder paths in the config are relative to (the project root). */
|
|
50
|
+
export declare const BASE: string;
|
|
51
|
+
export declare const config: LabConfig;
|
|
52
|
+
/** The design folder, with a trailing slash. */
|
|
53
|
+
export declare const ROOT: string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lab's settings, from stitch2.config.json: the nearest one above the working directory, or the file in
|
|
3
|
+
* STITCH2_CONFIG. Paths in it are relative to the file. Without a config the lab uses ./design and defaults.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
const DEFAULTS = {
|
|
8
|
+
name: 'stitch2',
|
|
9
|
+
designDir: 'design',
|
|
10
|
+
screenDirs: ['screens'],
|
|
11
|
+
stitchDir: 'stitch',
|
|
12
|
+
archiveDirs: [],
|
|
13
|
+
catalogDir: 'screens/components',
|
|
14
|
+
prefix: 's2',
|
|
15
|
+
order: [],
|
|
16
|
+
comfortableTarget: 32,
|
|
17
|
+
skills: [],
|
|
18
|
+
};
|
|
19
|
+
export const CONFIG_NAME = 'stitch2.config.json';
|
|
20
|
+
function find() {
|
|
21
|
+
if (process.env.STITCH2_CONFIG)
|
|
22
|
+
return resolve(process.env.STITCH2_CONFIG);
|
|
23
|
+
for (let dir = process.cwd();; dir = dirname(dir)) {
|
|
24
|
+
if (existsSync(join(dir, CONFIG_NAME)))
|
|
25
|
+
return join(dir, CONFIG_NAME);
|
|
26
|
+
if (dirname(dir) === dir)
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export const CONFIG_FILE = find();
|
|
31
|
+
/** The folder paths in the config are relative to (the project root). */
|
|
32
|
+
export const BASE = CONFIG_FILE ? dirname(CONFIG_FILE) : process.cwd();
|
|
33
|
+
export const config = {
|
|
34
|
+
...DEFAULTS,
|
|
35
|
+
...(CONFIG_FILE ? JSON.parse(readFileSync(CONFIG_FILE, 'utf8')) : {}),
|
|
36
|
+
};
|
|
37
|
+
/** The design folder, with a trailing slash. */
|
|
38
|
+
export const ROOT = join(resolve(BASE, config.designDir), '/');
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component consistency across screens. In each page it finds the shared elements (lab components by their
|
|
3
|
+
* data-<prefix> marker; in Stitch exports and hand-written markup by how they look: a fixed bottom bar with three or more
|
|
4
|
+
* buttons, the h1, buttons filled with the primary colour, pill chips, icon-only buttons, caps section labels) and takes a signature of
|
|
5
|
+
* each: size, radius, label font, icon size, and for the tab bar its centre circle. Screens of one family
|
|
6
|
+
* (the Stitch folder, an archive folder, or the lab's own screens) should share one signature per component; check reports
|
|
7
|
+
* the screens that differ from the majority, and lab screens that build a catalog component by hand.
|
|
8
|
+
*/
|
|
9
|
+
import type { Issue } from './typecheck.ts';
|
|
10
|
+
export interface Sig {
|
|
11
|
+
selector: string;
|
|
12
|
+
text: string;
|
|
13
|
+
/** Comparable fields, e.g. { height: '48', font: '15/600/Geist' }. */
|
|
14
|
+
fields: Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
export type Components = Record<string, Sig[]>;
|
|
17
|
+
export interface PageComponents {
|
|
18
|
+
components: Components;
|
|
19
|
+
handBuilt: {
|
|
20
|
+
kind: string;
|
|
21
|
+
selector: string;
|
|
22
|
+
text: string;
|
|
23
|
+
}[];
|
|
24
|
+
}
|
|
25
|
+
export interface CollectOptions {
|
|
26
|
+
/** Component marker: elements with data-<prefix>="name". */
|
|
27
|
+
prefix: string;
|
|
28
|
+
/** DESIGN.md's primary colour as [r, g, b]: filled buttons in it are primary buttons. */
|
|
29
|
+
primary: number[];
|
|
30
|
+
}
|
|
31
|
+
/** The options for collectComponents from the config and DESIGN.md's primary colour. */
|
|
32
|
+
export declare function collectOptions(): CollectOptions;
|
|
33
|
+
/** Runs in the page. Plain JS on purpose: Playwright sends its source to the browser. */
|
|
34
|
+
export declare function collectComponents(opts: CollectOptions): PageComponents;
|
|
35
|
+
export declare function familyOf(path: string): string;
|
|
36
|
+
export declare const nameOf: (kind: string) => string;
|
|
37
|
+
/** Issues for the screens in `paths`, comparing with every screen whose components are known. */
|
|
38
|
+
export declare function consistencyIssues(known: Record<string, Components>, paths: string[]): Record<string, Issue[]>;
|
|
39
|
+
/** Lab screens build catalog components with the components, not by hand. */
|
|
40
|
+
export declare function reuseIssues(path: string, page: PageComponents): Issue[];
|