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/exports.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token exports: DESIGN.md's tokens written for the app, so the product and the canvas share one source. Listed
|
|
3
|
+
* in stitch2.config.json under "exports" and written by stitch2 tokens:
|
|
4
|
+
* css CSS variables for both themes and the .type-<level> classes
|
|
5
|
+
* tailwind4 the same, plus an @theme block (bg-<colour>, rounded-<name>, font-sans) and the type levels as
|
|
6
|
+
* @utility type-<level>, for a Tailwind v4 stylesheet to @import. Spacing stays out of @theme:
|
|
7
|
+
* names like lg or 2xl would also resize max-w-lg and friends; it is --<prefix>-spacing-<name>.
|
|
8
|
+
* dtcg W3C Design Tokens JSON, for Style Dictionary and other platforms (iOS, Android)
|
|
9
|
+
* "aliases" maps the app's own variable names to DESIGN.md colours ({ "background": "floor" } writes
|
|
10
|
+
* --background: var(--<prefix>-floor) in each theme), so an existing theme (shadcn's, say) keeps its names.
|
|
11
|
+
*/
|
|
12
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { dirname, resolve } from 'node:path';
|
|
14
|
+
import { BASE, config } from './config.js';
|
|
15
|
+
const p = () => config.prefix;
|
|
16
|
+
function themeBlocks(t, e) {
|
|
17
|
+
const dark = e.themes?.dark ?? ":root, [data-theme='dark']";
|
|
18
|
+
const light = e.themes?.light ?? "[data-theme='light']";
|
|
19
|
+
const alias = (palette) => Object.entries(e.aliases ?? {}).map(([k, v]) => ` --${k}: ${v in palette ? `var(--${p()}-${v})` : v};`);
|
|
20
|
+
const block = (selector, palette, scheme) => [
|
|
21
|
+
`${selector} {`,
|
|
22
|
+
` color-scheme: ${scheme};`,
|
|
23
|
+
...Object.entries(palette).map(([k, v]) => ` --${p()}-${k}: ${v};`),
|
|
24
|
+
...alias(palette),
|
|
25
|
+
'}',
|
|
26
|
+
].join('\n');
|
|
27
|
+
// The block on :root goes first: a theme class (.dark) has the same specificity and must come later to win.
|
|
28
|
+
const themes = [block(dark, t.dark, 'dark'), block(light, t.light, 'light')];
|
|
29
|
+
if (/:root/.test(light) && !/:root/.test(dark))
|
|
30
|
+
themes.reverse();
|
|
31
|
+
const root = { ...t.root, ...Object.fromEntries(Object.entries(t.design.spacing).map(([k, v]) => [`${p()}-spacing-${k}`, v])) };
|
|
32
|
+
return [...themes, `:root {\n${Object.entries(root).map(([k, v]) => ` --${k}: ${v};`).join('\n')}\n}`];
|
|
33
|
+
}
|
|
34
|
+
function css(t, e) {
|
|
35
|
+
const type = Object.entries(t.type).map(([n, d]) => `.type-${n} { ${d.join('; ')}; }`);
|
|
36
|
+
return [t.header, ...themeBlocks(t, e), ...type, ''].join('\n\n');
|
|
37
|
+
}
|
|
38
|
+
function tailwind4(t, e) {
|
|
39
|
+
const families = Object.values(t.design.typography).map((x) => x.fontFamily ?? '');
|
|
40
|
+
const sans = families.find((f) => !/mono/i.test(f));
|
|
41
|
+
const mono = families.find((f) => /mono/i.test(f));
|
|
42
|
+
const theme = [
|
|
43
|
+
...Object.keys(t.dark).map((k) => ` --color-${k}: var(--${p()}-${k});`),
|
|
44
|
+
...Object.entries(t.design.rounded).map(([k, v]) => ` --radius-${k}: ${v};`),
|
|
45
|
+
...(sans ? [` --font-sans: "${sans}", var(--${p()}-font-sans-fallback);`] : []),
|
|
46
|
+
...(mono ? [` --font-mono: "${mono}", var(--${p()}-font-mono-fallback);`] : []),
|
|
47
|
+
];
|
|
48
|
+
const type = Object.entries(t.type).map(([n, d]) => `@utility type-${n} {\n${d.map((x) => ` ${x};`).join('\n')}\n}`);
|
|
49
|
+
return [t.header, ...themeBlocks(t, e), `@theme inline {\n${theme.join('\n')}\n}`, ...type, ''].join('\n\n');
|
|
50
|
+
}
|
|
51
|
+
function dtcg(t) {
|
|
52
|
+
const group = (type, o) => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, { $type: type, $value: v }]));
|
|
53
|
+
const typography = Object.fromEntries(Object.entries(t.design.typography).map(([k, v]) => [
|
|
54
|
+
k,
|
|
55
|
+
{
|
|
56
|
+
$type: 'typography',
|
|
57
|
+
$value: {
|
|
58
|
+
fontFamily: v.fontFamily,
|
|
59
|
+
fontSize: v.fontSize,
|
|
60
|
+
fontWeight: Number(v.fontWeight ?? 400),
|
|
61
|
+
lineHeight: v.lineHeight,
|
|
62
|
+
letterSpacing: v.letterSpacing ?? '0em',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
]));
|
|
66
|
+
return `${JSON.stringify({
|
|
67
|
+
$description: t.header.replace(/^\/\* | \*\/$/g, ''),
|
|
68
|
+
color: group('color', t.dark),
|
|
69
|
+
'color-light': group('color', t.light),
|
|
70
|
+
radius: group('dimension', t.design.rounded),
|
|
71
|
+
spacing: group('dimension', t.design.spacing),
|
|
72
|
+
typography,
|
|
73
|
+
}, null, 2)}\n`;
|
|
74
|
+
}
|
|
75
|
+
/** Writes every export in the config; returns their paths. */
|
|
76
|
+
export function writeExports(t, list = config.exports ?? []) {
|
|
77
|
+
return list.map((e) => {
|
|
78
|
+
const file = resolve(BASE, e.path);
|
|
79
|
+
const body = e.format === 'css' ? css(t, e) : e.format === 'tailwind4' ? tailwind4(t, e) : e.format === 'dtcg' ? dtcg(t) : null;
|
|
80
|
+
if (body === null)
|
|
81
|
+
throw new Error(`unknown export format "${e.format}"; use css, tailwind4 or dtcg`);
|
|
82
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
83
|
+
writeFileSync(file, body);
|
|
84
|
+
return e.path;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/extract.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes a DESIGN.md measured from an existing site or screen, the way Stitch builds a design system from a
|
|
3
|
+
* website: renders each page at 390px in Chromium, measures the colours (backgrounds by area, text by amount),
|
|
4
|
+
* the fonts, every text style, corner radii and the page padding, maps them to roles and type levels, and fills
|
|
5
|
+
* what it could not measure from the generated scale.
|
|
6
|
+
* stitch2 extract <url or .html file…> [--name N] [--mood "…"] [--out file] [--force]
|
|
7
|
+
* Then read the result, correct what was guessed, and refine the prose.
|
|
8
|
+
*/
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
import { pathToFileURL } from 'node:url';
|
|
11
|
+
import { chromium } from '@playwright/test';
|
|
12
|
+
import { tracking } from './build-design.js';
|
|
13
|
+
import { choicesFrom, parseArgs, writeDesign } from './init.js';
|
|
14
|
+
import { collectText } from './typecheck.js';
|
|
15
|
+
/** Runs in the page. Plain JS on purpose: Playwright sends its source to the browser. */
|
|
16
|
+
function measure() {
|
|
17
|
+
const hex = (c) => {
|
|
18
|
+
const m = c.match(/[\d.]+/g);
|
|
19
|
+
if (!m || (m[3] !== undefined && Number(m[3]) < 0.9))
|
|
20
|
+
return '';
|
|
21
|
+
return `#${m
|
|
22
|
+
.slice(0, 3)
|
|
23
|
+
.map((v) => Math.round(Number(v)).toString(16).padStart(2, '0'))
|
|
24
|
+
.join('')}`;
|
|
25
|
+
};
|
|
26
|
+
const add = (o, k, n) => {
|
|
27
|
+
if (k)
|
|
28
|
+
o[k] = (o[k] ?? 0) + n;
|
|
29
|
+
};
|
|
30
|
+
const out = { backgrounds: {}, borders: {}, accents: {}, radii: {}, lefts: {} };
|
|
31
|
+
add(out.backgrounds, hex(getComputedStyle(document.body).backgroundColor) ||
|
|
32
|
+
hex(getComputedStyle(document.documentElement).backgroundColor) ||
|
|
33
|
+
'#ffffff', innerWidth * innerHeight);
|
|
34
|
+
for (const el of document.body.querySelectorAll('*')) {
|
|
35
|
+
const cs = getComputedStyle(el);
|
|
36
|
+
const r = el.getBoundingClientRect();
|
|
37
|
+
if (!r.width || !r.height || cs.visibility === 'hidden')
|
|
38
|
+
continue;
|
|
39
|
+
const bg = hex(cs.backgroundColor);
|
|
40
|
+
if (bg)
|
|
41
|
+
add(out.backgrounds, bg, Math.min(r.width * r.height, innerWidth * innerHeight));
|
|
42
|
+
if (Number.parseFloat(cs.borderTopWidth) >= 1)
|
|
43
|
+
add(out.borders, hex(cs.borderTopColor), 1);
|
|
44
|
+
// Filled controls carry the brand colour.
|
|
45
|
+
if (bg && el.matches('button, a, [role=button]') && (el.textContent ?? '').trim())
|
|
46
|
+
add(out.accents, bg, 1);
|
|
47
|
+
const radius = Number.parseFloat(cs.borderTopLeftRadius);
|
|
48
|
+
if (radius > 0 && radius < 200 && (bg || Number.parseFloat(cs.borderTopWidth) >= 1) && r.width >= 200)
|
|
49
|
+
add(out.radii, String(Math.round(radius)), 1);
|
|
50
|
+
// Page padding: where wide surfaces (cards, fields) start.
|
|
51
|
+
if ((bg || Number.parseFloat(cs.borderTopWidth) >= 1) && r.width >= 200 && r.left > 0 && r.left < 48)
|
|
52
|
+
add(out.lefts, String(Math.round(r.left)), 1);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
const args = parseArgs(process.argv.slice(2));
|
|
57
|
+
const sources = process.argv
|
|
58
|
+
.slice(2)
|
|
59
|
+
.filter((a, i, all) => !a.startsWith('--') && !(all[i - 1]?.startsWith('--') && !(all[i - 1] === '--force')));
|
|
60
|
+
if (!sources.length) {
|
|
61
|
+
console.error('usage: stitch2 extract <url or .html file…> [--name N] [--mood "…"] [--out file] [--force]');
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
const browser = await chromium.launch();
|
|
65
|
+
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
|
66
|
+
const m = { backgrounds: {}, borders: {}, accents: {}, radii: {}, lefts: {} };
|
|
67
|
+
const texts = [];
|
|
68
|
+
for (const src of sources) {
|
|
69
|
+
const url = /^https?:/.test(src) ? src : pathToFileURL(resolve(src)).href;
|
|
70
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 45_000 }).catch(() => { });
|
|
71
|
+
await page.waitForTimeout(800);
|
|
72
|
+
const one = await page.evaluate(measure);
|
|
73
|
+
for (const k of Object.keys(m))
|
|
74
|
+
for (const [v, n] of Object.entries(one[k]))
|
|
75
|
+
m[k][v] = (m[k][v] ?? 0) + n;
|
|
76
|
+
texts.push(...(await page.evaluate(collectText)));
|
|
77
|
+
}
|
|
78
|
+
await browser.close();
|
|
79
|
+
const ranked = (o) => Object.entries(o)
|
|
80
|
+
.sort((a, b) => b[1] - a[1])
|
|
81
|
+
.map(([k]) => k);
|
|
82
|
+
const lum = (hex) => {
|
|
83
|
+
const c = [1, 3, 5]
|
|
84
|
+
.map((i) => Number.parseInt(hex.slice(i, i + 2), 16) / 255)
|
|
85
|
+
.map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
|
|
86
|
+
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
|
|
87
|
+
};
|
|
88
|
+
const chroma = (hex) => {
|
|
89
|
+
const c = [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16));
|
|
90
|
+
return Math.max(...c) - Math.min(...c);
|
|
91
|
+
};
|
|
92
|
+
// Surfaces: the most-covered backgrounds that are not the brand colour, in order of area.
|
|
93
|
+
const bgs = ranked(m.backgrounds).filter((c) => chroma(c) < 40);
|
|
94
|
+
const background = bgs[0] ?? '#ffffff';
|
|
95
|
+
const mode = lum(background) < 0.4 ? 'dark' : 'light';
|
|
96
|
+
const primary = ranked(m.accents).find((c) => chroma(c) >= 40) ??
|
|
97
|
+
ranked(m.backgrounds).find((c) => chroma(c) >= 40) ??
|
|
98
|
+
'#2F6FEB';
|
|
99
|
+
// Text: by amount, plain text colours (alpha-free), the most common is text, the next readable grey is muted.
|
|
100
|
+
const weight = (t) => Math.max(1, t.text.length);
|
|
101
|
+
const textColours = {};
|
|
102
|
+
for (const t of texts)
|
|
103
|
+
if (!t.color.includes('%'))
|
|
104
|
+
textColours[t.color] = (textColours[t.color] ?? 0) + weight(t);
|
|
105
|
+
// The two most-used greys: the one with more contrast on the background is text, the other muted.
|
|
106
|
+
const contrastWith = (a, b) => {
|
|
107
|
+
const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);
|
|
108
|
+
return (x + 0.05) / (y + 0.05);
|
|
109
|
+
};
|
|
110
|
+
const greys = ranked(textColours)
|
|
111
|
+
.filter((c) => chroma(c) < 40)
|
|
112
|
+
.slice(0, 2)
|
|
113
|
+
.sort((a, b) => contrastWith(b, background) - contrastWith(a, background));
|
|
114
|
+
const overrides = { background, primary };
|
|
115
|
+
if (bgs[1])
|
|
116
|
+
overrides.surface = bgs[1];
|
|
117
|
+
if (bgs[2])
|
|
118
|
+
overrides.raised = bgs[2];
|
|
119
|
+
if (greys[0])
|
|
120
|
+
overrides.text = greys[0];
|
|
121
|
+
if (greys[1])
|
|
122
|
+
overrides['text-muted'] = greys[1];
|
|
123
|
+
const border = ranked(m.borders).find((c) => chroma(c) < 40);
|
|
124
|
+
if (border)
|
|
125
|
+
overrides.border = border;
|
|
126
|
+
// Fonts and type levels from the measured text styles.
|
|
127
|
+
const fam = {};
|
|
128
|
+
for (const t of texts)
|
|
129
|
+
if (!t.mono)
|
|
130
|
+
fam[t.family] = (fam[t.family] ?? 0) + weight(t);
|
|
131
|
+
const sans = ranked(fam)[0] ?? 'Inter';
|
|
132
|
+
const mono = texts.find((t) => t.mono)?.family;
|
|
133
|
+
const styles = new Map();
|
|
134
|
+
for (const t of texts) {
|
|
135
|
+
const k = `${t.mono}/${t.size}/${t.weight}/${t.upper}`;
|
|
136
|
+
const s = styles.get(k) ?? { t, n: 0 };
|
|
137
|
+
s.n += weight(t);
|
|
138
|
+
styles.set(k, s);
|
|
139
|
+
}
|
|
140
|
+
const all = [...styles.values()].sort((a, b) => b.n - a.n);
|
|
141
|
+
const pick = (f) => all.find((s) => f(s.t))?.t;
|
|
142
|
+
const body = pick((t) => !t.mono && !t.upper && t.size >= 14 && t.size <= 17 && t.weight < 500);
|
|
143
|
+
const base = body?.size ?? 15;
|
|
144
|
+
const toLevel = (t) => ({
|
|
145
|
+
fontFamily: t.family,
|
|
146
|
+
fontSize: `${t.size}px`,
|
|
147
|
+
fontWeight: t.weight,
|
|
148
|
+
lineHeight: `${Math.round(t.lineHeight)}px`,
|
|
149
|
+
letterSpacing: t.tracking
|
|
150
|
+
? `${(t.tracking / t.size).toFixed(3)}em`
|
|
151
|
+
: t.upper
|
|
152
|
+
? `${tracking(t.size, true)}em`
|
|
153
|
+
: '0em',
|
|
154
|
+
...(t.numeric || t.mono ? { fontFeature: '"tnum" 1' } : {}),
|
|
155
|
+
});
|
|
156
|
+
const measuredLevels = {
|
|
157
|
+
'title-screen': pick((t) => !t.numeric && t.size >= 24),
|
|
158
|
+
'title-card': pick((t) => !t.numeric && t.size >= 17 && t.size < 24 && t.weight >= 600),
|
|
159
|
+
'display-stat': pick((t) => t.numeric && t.size >= 30),
|
|
160
|
+
'number-lg': pick((t) => t.numeric && t.size >= 18 && t.size < 30),
|
|
161
|
+
body,
|
|
162
|
+
'body-strong': pick((t) => !t.mono && !t.upper && t.size >= 14 && t.size <= 17 && t.weight >= 500),
|
|
163
|
+
meta: pick((t) => !t.mono && !t.upper && t.size >= 12 && t.size < base && t.weight < 500),
|
|
164
|
+
'meta-number': pick((t) => t.mono && t.size <= 14),
|
|
165
|
+
'label-chip': pick((t) => !t.upper && t.size >= 12 && t.size < base && t.weight >= 500),
|
|
166
|
+
'label-caps': pick((t) => t.upper && t.size <= 13),
|
|
167
|
+
caption: pick((t) => !t.upper && t.size <= 12 && t.weight < 500),
|
|
168
|
+
'label-tab': pick((t) => t.size <= 11),
|
|
169
|
+
};
|
|
170
|
+
const radius = Number(ranked(m.radii)[0] ?? 16);
|
|
171
|
+
// The outermost surfaces start at the page padding; inner rows start further in.
|
|
172
|
+
const padding = Math.min(...Object.entries(m.lefts)
|
|
173
|
+
.filter(([, n]) => n >= 2)
|
|
174
|
+
.map(([l]) => Number(l)), 16);
|
|
175
|
+
const choices = choicesFrom(args, {
|
|
176
|
+
name: typeof args.name === 'string' ? args.name : 'Extracted design',
|
|
177
|
+
mood: `Measured from ${sources.join(', ')}; refine this line with the product and its mood.`,
|
|
178
|
+
primary,
|
|
179
|
+
modes: [mode],
|
|
180
|
+
sans,
|
|
181
|
+
mono,
|
|
182
|
+
base,
|
|
183
|
+
roundness: Math.round(radius / 1.25),
|
|
184
|
+
page: padding >= 8 && padding <= 32 ? padding : 16,
|
|
185
|
+
colorOverrides: { [mode]: overrides },
|
|
186
|
+
});
|
|
187
|
+
// Measured levels win; the rest come from the generated scale for the measured base size and fonts.
|
|
188
|
+
const { typeScale } = await import('./build-design.js');
|
|
189
|
+
const generated = typeScale({ ...choices, levels: undefined });
|
|
190
|
+
choices.levels = Object.fromEntries(Object.entries(generated).map(([k, l]) => [k, measuredLevels[k] ? toLevel(measuredLevels[k]) : l]));
|
|
191
|
+
const found = Object.entries(measuredLevels)
|
|
192
|
+
.filter(([, t]) => t)
|
|
193
|
+
.map(([k]) => k);
|
|
194
|
+
console.log(`measured ${texts.length} text elements on ${sources.length} page(s): ${mode} theme, primary ${primary}, ${sans}${mono ? ` + ${mono}` : ''}, body ${base}px, card radius ${radius}px, page padding ${padding}px`);
|
|
195
|
+
console.log(` type levels measured: ${found.join(', ') || 'none'}; the rest generated`);
|
|
196
|
+
console.log(` colours measured: ${Object.keys(overrides).join(', ')}; the rest generated from the primary`);
|
|
197
|
+
writeDesign(choices, args);
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type Choices } from './build-design.ts';
|
|
2
|
+
export declare function parseArgs(argv: string[]): Record<string, string | true>;
|
|
3
|
+
export declare function choicesFrom(a: Record<string, string | true>, defaults?: Partial<Choices>): Choices;
|
|
4
|
+
/** Writes the file and prints the contrast of the pairs that matter. */
|
|
5
|
+
export declare function writeDesign(c: Choices, a: Record<string, string | true>): void;
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes a complete DESIGN.md from a few choices (the way Stitch builds a design system from a theme):
|
|
3
|
+
* stitch2 init --name "Acme Fit" --primary "#2F6FEB" [--mood "…"] [--modes dark,light] [--sans Inter]
|
|
4
|
+
* [--mono "JetBrains Mono"] [--base 15] [--roundness 16] [--page 16] [--icons Lucide]
|
|
5
|
+
* [--scheme tonal_spot|neutral|fidelity|vibrant|expressive|content|monochrome] [--out <file>] [--force]
|
|
6
|
+
* Then refine the prose with the brand's specifics, and run stitch2 lint and stitch2 tokens.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
import { buildDesignMd, contrast, palette } from './build-design.js';
|
|
11
|
+
import { ROOT } from './config.js';
|
|
12
|
+
export function parseArgs(argv) {
|
|
13
|
+
const out = {};
|
|
14
|
+
for (let i = 0; i < argv.length; i++) {
|
|
15
|
+
const a = argv[i];
|
|
16
|
+
if (!a.startsWith('--'))
|
|
17
|
+
continue;
|
|
18
|
+
const next = argv[i + 1];
|
|
19
|
+
if (next === undefined || next.startsWith('--'))
|
|
20
|
+
out[a.slice(2)] = true;
|
|
21
|
+
else {
|
|
22
|
+
out[a.slice(2)] = next;
|
|
23
|
+
i++;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
export function choicesFrom(a, defaults = {}) {
|
|
29
|
+
const str = (k, d) => (typeof a[k] === 'string' ? a[k] : d);
|
|
30
|
+
const num = (k, d) => (typeof a[k] === 'string' ? Number(a[k]) : d);
|
|
31
|
+
const primary = str('primary', defaults.primary ?? '#2F6FEB');
|
|
32
|
+
if (!/^#[0-9a-f]{6}$/i.test(primary))
|
|
33
|
+
throw new Error(`--primary must be a #rrggbb colour, got ${primary}`);
|
|
34
|
+
return {
|
|
35
|
+
name: str('name', defaults.name ?? 'My app'),
|
|
36
|
+
mood: str('mood', defaults.mood ?? 'A focused, calm app that gets out of the way.'),
|
|
37
|
+
primary,
|
|
38
|
+
modes: str('modes', (defaults.modes ?? ['dark', 'light']).join(','))
|
|
39
|
+
.split(',')
|
|
40
|
+
.map((m) => m.trim()),
|
|
41
|
+
sans: str('sans', defaults.sans ?? 'Inter'),
|
|
42
|
+
mono: typeof a.mono === 'string' ? a.mono : defaults.mono,
|
|
43
|
+
base: num('base', defaults.base ?? 15),
|
|
44
|
+
roundness: num('roundness', defaults.roundness ?? 16),
|
|
45
|
+
page: num('page', defaults.page ?? 16),
|
|
46
|
+
icons: str('icons', defaults.icons ?? 'Lucide'),
|
|
47
|
+
scheme: str('scheme', defaults.scheme ?? 'tonal_spot'),
|
|
48
|
+
levels: defaults.levels,
|
|
49
|
+
colorOverrides: defaults.colorOverrides,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Writes the file and prints the contrast of the pairs that matter. */
|
|
53
|
+
export function writeDesign(c, a) {
|
|
54
|
+
const out = resolve(typeof a.out === 'string' ? a.out : join(ROOT, 'DESIGN.md'));
|
|
55
|
+
if (existsSync(out) && !a.force) {
|
|
56
|
+
console.error(`${out} exists; pass --force to replace it, or --out to write elsewhere`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
60
|
+
writeFileSync(out, buildDesignMd(c));
|
|
61
|
+
const tokens = join(dirname(out), 'tokens.config.json');
|
|
62
|
+
if (!existsSync(tokens))
|
|
63
|
+
writeFileSync(tokens, `${JSON.stringify({ seed: 'primary', scheme: c.scheme, contrast: 0 }, null, 2)}\n`);
|
|
64
|
+
console.log(`wrote ${out}`);
|
|
65
|
+
for (const m of c.modes) {
|
|
66
|
+
const p = palette(c, m);
|
|
67
|
+
if (p.primary.toLowerCase() !== c.primary.toLowerCase())
|
|
68
|
+
console.log(` ${m}: primary adjusted from ${c.primary} to ${p.primary} so it and its label reach 4.5:1`);
|
|
69
|
+
const pairs = [
|
|
70
|
+
['text on surface', p.text, p.surface],
|
|
71
|
+
['text-muted on raised', p['text-muted'], p.raised],
|
|
72
|
+
['on-primary on primary', p['on-primary'], p.primary],
|
|
73
|
+
['primary on surface', p.primary, p.surface],
|
|
74
|
+
['danger on raised', p.danger, p.raised],
|
|
75
|
+
];
|
|
76
|
+
console.log(` ${m}: ${pairs.map(([n, f, b]) => `${n} ${contrast(f, b).toFixed(1)}:1`).join(' · ')}`);
|
|
77
|
+
}
|
|
78
|
+
console.log('next: refine the prose (brand, voice, components), then stitch2 lint and stitch2 tokens');
|
|
79
|
+
}
|
|
80
|
+
if (/init\.[jt]s$/.test(process.argv[1] ?? '')) {
|
|
81
|
+
const a = parseArgs(process.argv.slice(2));
|
|
82
|
+
writeDesign(choicesFrom(a), a);
|
|
83
|
+
}
|
package/dist/list.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/list.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lists every screen version with its status and the reviewer's note, for agents (and anyone without the
|
|
3
|
+
* canvas open). Approved versions are the reference; review versions wait for the user; notes are feedback.
|
|
4
|
+
* Usage: stitch2 screens [screen name] [--status approved|review|draft|archived] [--json]
|
|
5
|
+
*/
|
|
6
|
+
import { listScreens } from './screens.js';
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const status = args.includes('--status') ? args[args.indexOf('--status') + 1] : undefined;
|
|
9
|
+
const name = args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--status');
|
|
10
|
+
const rows = listScreens()
|
|
11
|
+
.filter((s) => s.kind === 'screen' && (!status || s.status === status) && (!name || s.screen === name))
|
|
12
|
+
.sort((a, b) => a.screen.localeCompare(b.screen) || a.device.localeCompare(b.device) || b.mtime - a.mtime);
|
|
13
|
+
if (args.includes('--json'))
|
|
14
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
15
|
+
else
|
|
16
|
+
for (const s of rows)
|
|
17
|
+
console.log(`${s.screen.padEnd(20)} ${s.device.padEnd(8)} ${s.version.padEnd(8)} ${s.status.padEnd(9)} ${s.path}${s.note ? `\n${' '.repeat(39)}note: ${s.note}` : ''}`);
|
package/dist/pick.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Element picking on the canvas. When the canvas loads a screen (with ?pick), every element in its body gets
|
|
3
|
+
* data-s2-line, its line in the source file, and a small script carries that attribute over to the markup a
|
|
4
|
+
* custom element renders in its place (a light-DOM component that replaces itself keeps its source line). The
|
|
5
|
+
* canvas reads the attribute to highlight an element and copy a reference such as
|
|
6
|
+
* home mobile v1 (design/screens/home/mobile-v1.html:37) <gg-button> “Start Push”
|
|
7
|
+
* The source file is never changed, and nothing is added to the lines above the body.
|
|
8
|
+
*/
|
|
9
|
+
/** The page with source lines on its body's elements and the shim at the start of its head. */
|
|
10
|
+
export declare function annotate(html: string): string;
|
package/dist/pick.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Element picking on the canvas. When the canvas loads a screen (with ?pick), every element in its body gets
|
|
3
|
+
* data-s2-line, its line in the source file, and a small script carries that attribute over to the markup a
|
|
4
|
+
* custom element renders in its place (a light-DOM component that replaces itself keeps its source line). The
|
|
5
|
+
* canvas reads the attribute to highlight an element and copy a reference such as
|
|
6
|
+
* home mobile v1 (design/screens/home/mobile-v1.html:37) <gg-button> “Start Push”
|
|
7
|
+
* The source file is never changed, and nothing is added to the lines above the body.
|
|
8
|
+
*/
|
|
9
|
+
/** Tags that are never picked by themselves: their nearest picked ancestor stands for them. */
|
|
10
|
+
const SKIP = new Set(['body', 'br', 'i', 'script', 'style', 'template', 'path', 'line', 'polyline', 'polygon', 'circle', 'ellipse', 'rect', 'g', 'defs', 'stop', 'use', 'text', 'tspan']);
|
|
11
|
+
/** Runs in the screen before its own scripts: components that replace themselves pass their line on. */
|
|
12
|
+
const SHIM = `<script>(()=>{const d=customElements.define.bind(customElements);customElements.define=(name,cls,opts)=>{const p=cls.prototype,cb=p.connectedCallback;if(cb)p.connectedCallback=function(...a){const line=this.getAttribute('data-s2-line'),parent=this.parentNode,before=this.previousSibling,after=this.nextSibling;const r=cb.apply(this,a);if(line){if(this.isConnected)this.setAttribute('data-s2-tag',name);else if(parent)for(let n=before?before.nextSibling:parent.firstChild;n&&n!==after;n=n.nextSibling)if(n.nodeType===1&&!n.hasAttribute('data-s2-line')){n.setAttribute('data-s2-line',line);n.setAttribute('data-s2-tag',name)}}return r};return d(name,cls,opts)}})()</script>`;
|
|
13
|
+
/** The page with source lines on its body's elements and the shim at the start of its head. */
|
|
14
|
+
export function annotate(html) {
|
|
15
|
+
const bodyAt = html.search(/<body[\s>]/i);
|
|
16
|
+
if (bodyAt < 0)
|
|
17
|
+
return html;
|
|
18
|
+
let line = html.slice(0, bodyAt).split('\n').length;
|
|
19
|
+
let out = html.slice(0, bodyAt);
|
|
20
|
+
let last = bodyAt;
|
|
21
|
+
const re = /<!--[\s\S]*?-->|<(script|style|textarea)\b[\s\S]*?<\/\1\s*>|<([a-zA-Z][\w-]*)/g;
|
|
22
|
+
re.lastIndex = bodyAt;
|
|
23
|
+
for (let m = re.exec(html); m; m = re.exec(html)) {
|
|
24
|
+
const chunk = html.slice(last, m.index);
|
|
25
|
+
line += chunk.split('\n').length - 1;
|
|
26
|
+
out += chunk;
|
|
27
|
+
const tag = m[2]?.toLowerCase();
|
|
28
|
+
if (m[1] === 'textarea')
|
|
29
|
+
out += m[0].replace(/^<textarea/i, `<textarea data-s2-line="${line}"`);
|
|
30
|
+
else
|
|
31
|
+
out += tag && !SKIP.has(tag) ? `${m[0]} data-s2-line="${line}"` : m[0];
|
|
32
|
+
line += m[0].split('\n').length - 1;
|
|
33
|
+
last = m.index + m[0].length;
|
|
34
|
+
}
|
|
35
|
+
out += html.slice(last);
|
|
36
|
+
// On the head's own line, so the body's line numbers stay those of the file.
|
|
37
|
+
return out.replace(/<head(\s[^>]*)?>/i, (h) => h + SHIM);
|
|
38
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consistency report: every shared component cropped from every active screen, next to the component
|
|
3
|
+
* catalog's version, with what differs. Writes <design>/consistency/index.html, which the canvas opens from
|
|
4
|
+
* its sidebar. Usage: stitch2 consistency [kind…] (kinds: tab-bar, header, icon-button, chip, …)
|
|
5
|
+
*/
|
|
6
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { chromium } from '@playwright/test';
|
|
9
|
+
import { config } from './config.js';
|
|
10
|
+
import { collectComponents, collectOptions, consistencyIssues, nameOf, } from './consistency.js';
|
|
11
|
+
import { listScreens, ROOT } from './screens.js';
|
|
12
|
+
import { startServer } from './serve.js';
|
|
13
|
+
const kinds = process.argv.slice(2).filter((a) => !a.startsWith('--'));
|
|
14
|
+
const screens = listScreens().filter((s) => s.status !== 'archived');
|
|
15
|
+
const out = join(ROOT, 'consistency');
|
|
16
|
+
mkdirSync(out, { recursive: true });
|
|
17
|
+
const safe = (s) => s.replace(/[^a-z0-9]+/gi, '_');
|
|
18
|
+
const esc = (s) => String(s ?? '')
|
|
19
|
+
.replaceAll('&', '&')
|
|
20
|
+
.replaceAll('<', '<')
|
|
21
|
+
.replaceAll('>', '>')
|
|
22
|
+
.replaceAll('"', '"');
|
|
23
|
+
const server = await startServer(0);
|
|
24
|
+
const browser = await chromium.launch();
|
|
25
|
+
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2 });
|
|
26
|
+
const known = {};
|
|
27
|
+
const crops = {}; // kind -> path -> image files
|
|
28
|
+
for (const s of screens) {
|
|
29
|
+
await page.setViewportSize({ width: s.width, height: 844 });
|
|
30
|
+
await page
|
|
31
|
+
.goto(`http://127.0.0.1:${server.port}/${s.path}`, { waitUntil: 'networkidle', timeout: 30_000 })
|
|
32
|
+
.catch(() => { });
|
|
33
|
+
await page.waitForTimeout(600);
|
|
34
|
+
const found = await page.evaluate(collectComponents, collectOptions());
|
|
35
|
+
known[s.path] = found.components;
|
|
36
|
+
for (const [kind, sigs] of Object.entries(found.components)) {
|
|
37
|
+
if (kinds.length && !kinds.includes(kind.split(':')[0]))
|
|
38
|
+
continue;
|
|
39
|
+
const files = [];
|
|
40
|
+
for (const [i, sig] of sigs.entries()) {
|
|
41
|
+
const file = `${safe(kind)}/${safe(s.path)}-${i}.png`;
|
|
42
|
+
mkdirSync(join(out, safe(kind)), { recursive: true });
|
|
43
|
+
try {
|
|
44
|
+
// Crop the element plus anything drawn outside it (a raised centre button above a tab bar).
|
|
45
|
+
const el = page.locator(sig.selector).first();
|
|
46
|
+
await el.scrollIntoViewIfNeeded({ timeout: 3000 });
|
|
47
|
+
const clip = await el.evaluate((node) => {
|
|
48
|
+
const r = node.getBoundingClientRect();
|
|
49
|
+
let top = r.top;
|
|
50
|
+
let bottom = r.bottom;
|
|
51
|
+
for (const c of node.querySelectorAll('*')) {
|
|
52
|
+
const q = c.getBoundingClientRect();
|
|
53
|
+
if (q.width && q.height) {
|
|
54
|
+
top = Math.min(top, q.top);
|
|
55
|
+
bottom = Math.max(bottom, q.bottom);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
x: Math.max(0, r.left),
|
|
60
|
+
y: Math.max(0, top),
|
|
61
|
+
width: r.width,
|
|
62
|
+
height: bottom - Math.max(0, top),
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
if (clip.width < 1 || clip.height < 1)
|
|
66
|
+
continue;
|
|
67
|
+
await page.screenshot({ path: join(out, file), clip });
|
|
68
|
+
files.push(file);
|
|
69
|
+
}
|
|
70
|
+
catch { }
|
|
71
|
+
}
|
|
72
|
+
crops[kind] ??= {};
|
|
73
|
+
crops[kind][s.path] = files;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
await browser.close();
|
|
77
|
+
server.close();
|
|
78
|
+
const issues = consistencyIssues(known, Object.keys(known));
|
|
79
|
+
const ORDER = ['tab-bar', 'header', 'icon-button', 'button:primary:md', 'chip', 'section'];
|
|
80
|
+
const rank = (k) => (ORDER.includes(k) ? ORDER.indexOf(k) : ORDER.length);
|
|
81
|
+
const byPath = new Map(screens.map((s) => [s.path, s]));
|
|
82
|
+
const sections = Object.keys(crops)
|
|
83
|
+
.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))
|
|
84
|
+
.map((kind) => {
|
|
85
|
+
const rows = Object.entries(crops[kind])
|
|
86
|
+
.sort(([a], [b]) => a.startsWith(config.catalogDir) ? -1 : b.startsWith(config.catalogDir) ? 1 : a.localeCompare(b))
|
|
87
|
+
.map(([path, files]) => {
|
|
88
|
+
const s = byPath.get(path);
|
|
89
|
+
const note = (issues[path] ?? []).filter((i) => i.message.startsWith(nameOf(kind)) || i.message.includes(nameOf(kind).toLowerCase()));
|
|
90
|
+
const verdict = path.startsWith(config.catalogDir)
|
|
91
|
+
? '<b class="ref">catalog: the reference</b>'
|
|
92
|
+
: note.length
|
|
93
|
+
? note.map((i) => `<p class="bad">${esc(i.message)}</p>`).join('')
|
|
94
|
+
: '<b class="ok">matches</b>';
|
|
95
|
+
return `<div class="row"><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
|
+
})
|
|
97
|
+
.join('');
|
|
98
|
+
return `<section><h2>${esc(nameOf(kind))} <span>${esc(kind)}</span></h2>${rows}</section>`;
|
|
99
|
+
})
|
|
100
|
+
.join('');
|
|
101
|
+
writeFileSync(join(out, 'index.html'), `<!doctype html><html lang="en"><head><meta charset="utf-8" /><title>Consistency report</title><style>
|
|
102
|
+
body { background: #0f0f11; color: #f4f4f5; font: 14px/1.5 system-ui, sans-serif; margin: 0; }
|
|
103
|
+
main { margin: 0 auto; max-width: 1200px; padding: 32px; }
|
|
104
|
+
h1 { font-size: 26px; margin: 0 0 4px; } h2 { font-size: 18px; margin: 40px 0 12px; } h2 span, code { color: #71717a; font: 12px ui-monospace, monospace; }
|
|
105
|
+
.row { border-top: 1px solid #2e2e33; display: grid; gap: 20px; grid-template-columns: 320px 1fr; padding: 14px 0; }
|
|
106
|
+
.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
|
+
img { background: #000; border: 1px solid #2e2e33; border-radius: 8px; max-width: 100%; width: 390px; }
|
|
108
|
+
.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></body></html>\n`);
|
|
110
|
+
console.log(`consistency report: ${join(out, 'index.html')} (${Object.keys(crops).length} components, ${screens.length} screens)`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|