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/setup.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sets a project up for stitch2 in one step, for people and agents alike:
|
|
3
|
+
* stitch2 setup [--name "My app" --primary "#2F6FEB" [init options]] [--prefix ma] [--skills-dir .agents/skills,…]
|
|
4
|
+
* Links stitch2's skills into the agent's skills folder(s), writes stitch2.config.json, a screen template, a
|
|
5
|
+
* component catalog and a few starter components (light-DOM custom elements on DESIGN.md's tokens), and, with
|
|
6
|
+
* --name and --primary, DESIGN.md and its tokens. Existing files are never overwritten, so it is safe to re-run.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { CONFIG_FILE, CONFIG_NAME, config as current } from './config.js';
|
|
13
|
+
import { parseArgs } from './init.js';
|
|
14
|
+
const args = parseArgs(process.argv.slice(2));
|
|
15
|
+
const str = (k) => (typeof args[k] === 'string' ? args[k] : undefined);
|
|
16
|
+
const project = process.cwd();
|
|
17
|
+
const pkg = fileURLToPath(new URL('..', import.meta.url));
|
|
18
|
+
const cli = fileURLToPath(new URL(`./cli${import.meta.url.endsWith('.ts') ? '.ts' : '.js'}`, import.meta.url));
|
|
19
|
+
const done = [];
|
|
20
|
+
const skipped = [];
|
|
21
|
+
/** Writes a file unless it exists; records which. */
|
|
22
|
+
function write(file, content) {
|
|
23
|
+
const path = resolve(project, file);
|
|
24
|
+
if (existsSync(path))
|
|
25
|
+
return skipped.push(file);
|
|
26
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
27
|
+
writeFileSync(path, content);
|
|
28
|
+
done.push(file);
|
|
29
|
+
}
|
|
30
|
+
/** Two letters from the product name ("Acme Fit" → af), so custom elements read <af-button>. */
|
|
31
|
+
function prefixFrom(name) {
|
|
32
|
+
const words = name.toLowerCase().match(/[a-z][a-z0-9]*/g) ?? [];
|
|
33
|
+
const p = words.length > 1 ? words.map((w) => w[0]).join('').slice(0, 3) : (words[0] ?? '').slice(0, 2);
|
|
34
|
+
return p.length >= 2 ? p : 'ui';
|
|
35
|
+
}
|
|
36
|
+
const name = str('name') ?? (CONFIG_FILE ? current.name : basename(project));
|
|
37
|
+
const prefix = CONFIG_FILE ? current.prefix : (str('prefix') ?? prefixFrom(name));
|
|
38
|
+
const design = CONFIG_FILE ? current.designDir : 'design';
|
|
39
|
+
// 1. Skills: linked, so they update with the package.
|
|
40
|
+
const skillDirs = (str('skills-dir') ?? '.agents/skills').split(',').map((d) => d.trim()).filter(Boolean);
|
|
41
|
+
const skills = readdirSync(join(pkg, 'skills'));
|
|
42
|
+
const linked = [];
|
|
43
|
+
for (const dir of skillDirs) {
|
|
44
|
+
mkdirSync(resolve(project, dir), { recursive: true });
|
|
45
|
+
for (const s of skills) {
|
|
46
|
+
const at = join(dir, s);
|
|
47
|
+
linked.push(at);
|
|
48
|
+
if (existsSync(resolve(project, at))) {
|
|
49
|
+
skipped.push(at);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
symlinkSync(relative(resolve(project, dir), join(pkg, 'skills', s)), resolve(project, at), 'dir');
|
|
53
|
+
done.push(`${at} → stitch2/skills/${s}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// 2. The config.
|
|
57
|
+
const schema = relative(project, join(pkg, 'config.schema.json'));
|
|
58
|
+
write(CONFIG_NAME, `${JSON.stringify({
|
|
59
|
+
...(schema.startsWith('node_modules') ? { $schema: `./${schema}` } : {}),
|
|
60
|
+
name: `${name} design lab`,
|
|
61
|
+
designDir: design,
|
|
62
|
+
screenDirs: ['screens'],
|
|
63
|
+
catalogDir: 'screens/components',
|
|
64
|
+
prefix,
|
|
65
|
+
order: [],
|
|
66
|
+
skills: linked.filter((s) => s.startsWith(skillDirs[0])),
|
|
67
|
+
}, null, 2)}\n`);
|
|
68
|
+
// 3. Screen template, catalog and starter components.
|
|
69
|
+
const head = (title, extra) => `<!doctype html>
|
|
70
|
+
<html lang="en" data-theme="dark">
|
|
71
|
+
<head>
|
|
72
|
+
${extra}<meta charset="utf-8" />
|
|
73
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
74
|
+
<meta name="${prefix}-frame-width" content="390" />
|
|
75
|
+
<title>${name} — ${title}</title>
|
|
76
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
77
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
78
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
|
79
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
80
|
+
<script src="/tailwind.tokens.js"></script>
|
|
81
|
+
<link rel="stylesheet" href="/tokens.css" />
|
|
82
|
+
<script src="/components/${prefix}.js" defer></script>
|
|
83
|
+
</head>
|
|
84
|
+
`;
|
|
85
|
+
write(join(design, 'screens/_template.html'), `${head('Screen name', `<meta name="${prefix}-screen" content="screen-name" />\n<meta name="${prefix}-device" content="mobile" />\n<meta name="${prefix}-status" content="draft" />\n`)}<!--
|
|
86
|
+
Template for ${name} screens. Copy it to ${design}/screens/<screen>/<device>-v<N>.html and set the ${prefix}-* meta
|
|
87
|
+
tags (desktop: ${prefix}-device desktop and ${prefix}-frame-width 1280); status draft while you work, review when
|
|
88
|
+
done. Files starting with _ are not shown on the canvas. Build with the components in the catalog
|
|
89
|
+
(${design}/screens/components/catalog.html: header, section, card, row, button, chip); write plain markup only for what no component covers, with token
|
|
90
|
+
classes (bg-background/surface/raised, border-border, text-text/text-muted, bg-primary, rounded-btn/card) and one
|
|
91
|
+
.type-<level> class per text element. Fonts: load the families DESIGN.md names.
|
|
92
|
+
-->
|
|
93
|
+
<body class="bg-background font-sans text-text antialiased">
|
|
94
|
+
<main class="mx-auto max-w-[390px] px-4 pb-24 pt-6">
|
|
95
|
+
<${prefix}-header title="Title"></${prefix}-header>
|
|
96
|
+
</main>
|
|
97
|
+
</body>
|
|
98
|
+
</html>
|
|
99
|
+
`);
|
|
100
|
+
write(join(design, 'screens/components/catalog.html'), `${head('Components', '')}<!-- The component catalog: every shared element and its variants. Screens use these elements and never rebuild
|
|
101
|
+
them by hand; to change one, edit ${design}/components/${prefix}.js and look here. -->
|
|
102
|
+
<body class="bg-background font-sans text-text antialiased">
|
|
103
|
+
<main class="mx-auto max-w-[390px] px-4 pb-24 pt-6">
|
|
104
|
+
<${prefix}-header title="Components" meta="${name}"><${prefix}-button variant="secondary">Action</${prefix}-button></${prefix}-header>
|
|
105
|
+
<${prefix}-section label="Buttons">
|
|
106
|
+
<${prefix}-button full>Primary</${prefix}-button>
|
|
107
|
+
<${prefix}-button variant="secondary" full>Secondary</${prefix}-button>
|
|
108
|
+
<${prefix}-button variant="ghost" full>Ghost</${prefix}-button>
|
|
109
|
+
</${prefix}-section>
|
|
110
|
+
<${prefix}-section label="Chips">
|
|
111
|
+
<div class="flex flex-wrap gap-2"><${prefix}-chip selected>Selected</${prefix}-chip><${prefix}-chip>Chip</${prefix}-chip></div>
|
|
112
|
+
</${prefix}-section>
|
|
113
|
+
<${prefix}-section label="Card">
|
|
114
|
+
<${prefix}-card><p class="type-title-card text-text">Card title</p><p class="mt-1 type-body text-text-muted">Supporting text in a card.</p></${prefix}-card>
|
|
115
|
+
</${prefix}-section>
|
|
116
|
+
<${prefix}-section label="Rows in a list card">
|
|
117
|
+
<${prefix}-card list>
|
|
118
|
+
<${prefix}-row name="A row" meta="With a meta line" chevron></${prefix}-row>
|
|
119
|
+
<${prefix}-row name="Another row" value="42"></${prefix}-row>
|
|
120
|
+
</${prefix}-card>
|
|
121
|
+
</${prefix}-section>
|
|
122
|
+
</main>
|
|
123
|
+
</body>
|
|
124
|
+
</html>
|
|
125
|
+
`);
|
|
126
|
+
write(join(design, `components/${prefix}.js`), `// ${name}'s starter components for the design screens: light-DOM custom elements on DESIGN.md's tokens (the
|
|
127
|
+
// classes come from tailwind.tokens.js and tokens.css). Each marks its root data-${prefix}="<name>", which stitch2's
|
|
128
|
+
// checks and compare read. Extend them, or replace this file with your app's own components (stitch2 elements).
|
|
129
|
+
const P = '${prefix}'
|
|
130
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c])
|
|
131
|
+
const flag = (el, a) => el.hasAttribute(a) && el.getAttribute(a) !== 'false'
|
|
132
|
+
const focus = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
|
|
133
|
+
const chevron =
|
|
134
|
+
'<svg class="size-4 shrink-0 text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>'
|
|
135
|
+
|
|
136
|
+
/** Defines <P-name>: render(el) returns the markup; the element's own children go into [data-slot]. */
|
|
137
|
+
function define(name, render) {
|
|
138
|
+
customElements.define(
|
|
139
|
+
\`\${P}-\${name}\`,
|
|
140
|
+
class extends HTMLElement {
|
|
141
|
+
connectedCallback() {
|
|
142
|
+
// Children are parsed after the opening tag: render once the document is complete.
|
|
143
|
+
if (document.readyState === 'loading') {
|
|
144
|
+
document.addEventListener('DOMContentLoaded', () => this.connectedCallback(), { once: true })
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
if (this.dataset.rendered) return
|
|
148
|
+
this.dataset.rendered = '1'
|
|
149
|
+
const kids = [...this.childNodes]
|
|
150
|
+
this.style.display = 'contents'
|
|
151
|
+
this.innerHTML = render(this)
|
|
152
|
+
const root = this.firstElementChild
|
|
153
|
+
root?.setAttribute(\`data-\${P}\`, name)
|
|
154
|
+
const slot = this.querySelector('[data-slot]')
|
|
155
|
+
if (slot) slot.append(...kids)
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
define(
|
|
162
|
+
'header',
|
|
163
|
+
(el) => \`<header class="mb-6 flex items-start justify-between gap-3"><div class="min-w-0">\${
|
|
164
|
+
el.getAttribute('meta') ? \`<p class="type-meta text-text-muted">\${esc(el.getAttribute('meta'))}</p>\` : ''
|
|
165
|
+
}<h1 class="truncate type-title-screen text-text">\${esc(el.getAttribute('title'))}</h1></div><div data-slot class="flex shrink-0 items-center gap-2"></div></header>\`,
|
|
166
|
+
)
|
|
167
|
+
define(
|
|
168
|
+
'section',
|
|
169
|
+
(el) =>
|
|
170
|
+
\`<section class="mb-6"><h2 class="mb-2 type-label-caps text-text-muted">\${esc(el.getAttribute('label'))}</h2><div data-slot class="flex flex-col gap-3"></div></section>\`,
|
|
171
|
+
)
|
|
172
|
+
define('button', (el) => {
|
|
173
|
+
const look = {
|
|
174
|
+
primary: 'bg-primary text-on-primary hover:bg-primary-pressed',
|
|
175
|
+
secondary: 'bg-raised text-text hover:bg-border',
|
|
176
|
+
ghost: 'text-text-muted hover:text-text',
|
|
177
|
+
}[el.getAttribute('variant') ?? 'primary']
|
|
178
|
+
return \`<button type="button" data-slot class="inline-flex h-12 items-center justify-center gap-2 whitespace-nowrap rounded-btn px-5 type-label-button \${look} \${flag(el, 'full') ? 'w-full' : ''} \${focus}"></button>\`
|
|
179
|
+
})
|
|
180
|
+
define('chip', (el) => {
|
|
181
|
+
const on = flag(el, 'selected')
|
|
182
|
+
return \`<button type="button" aria-pressed="\${on}" data-slot class="inline-flex h-8 items-center rounded-full px-3 type-label-chip \${on ? 'bg-primary-wash text-primary' : 'bg-raised text-text-muted hover:text-text'} \${focus}"></button>\`
|
|
183
|
+
})
|
|
184
|
+
define(
|
|
185
|
+
'card',
|
|
186
|
+
(el) =>
|
|
187
|
+
\`<div data-slot class="rounded-card border border-border bg-surface \${flag(el, 'list') ? 'overflow-hidden [&>*+*>*]:border-t [&>*+*>*]:border-border' : 'p-4'}"></div>\`,
|
|
188
|
+
)
|
|
189
|
+
define(
|
|
190
|
+
'row',
|
|
191
|
+
(el) => \`<div class="flex min-h-16 items-center gap-3 px-4 py-3"><span class="block min-w-0 flex-1"><span class="block truncate type-body-strong text-text">\${esc(el.getAttribute('name'))}</span>\${
|
|
192
|
+
el.getAttribute('meta') ? \`<span class="block truncate type-meta text-text-muted">\${esc(el.getAttribute('meta'))}</span>\` : ''
|
|
193
|
+
}</span>\${el.getAttribute('value') ? \`<span class="shrink-0 type-body-strong text-text">\${esc(el.getAttribute('value'))}</span>\` : ''}\${flag(el, 'chevron') ? chevron : ''}<span data-slot class="contents"></span></div>\`,
|
|
194
|
+
)
|
|
195
|
+
`);
|
|
196
|
+
// 4. DESIGN.md and tokens, through the commands themselves (they read the config just written).
|
|
197
|
+
const run = (cmd, more = []) => execFileSync(process.execPath, [cli, cmd, ...more], { cwd: project, stdio: 'inherit' });
|
|
198
|
+
const designMd = join(project, design, 'DESIGN.md');
|
|
199
|
+
if (!existsSync(designMd) && str('primary')) {
|
|
200
|
+
const pass = Object.entries(args)
|
|
201
|
+
.filter(([k]) => !['prefix', 'skills-dir'].includes(k))
|
|
202
|
+
.flatMap(([k, v]) => (v === true ? [`--${k}`] : [`--${k}`, v]));
|
|
203
|
+
run('init', pass.includes('--name') ? pass : ['--name', name, ...pass]);
|
|
204
|
+
}
|
|
205
|
+
if (existsSync(designMd))
|
|
206
|
+
run('tokens');
|
|
207
|
+
console.log(`\nstitch2 setup (${prefix}-* elements, design in ${design}/)`);
|
|
208
|
+
for (const f of done)
|
|
209
|
+
console.log(` wrote ${f}`);
|
|
210
|
+
for (const f of skipped)
|
|
211
|
+
console.log(` kept ${f}`);
|
|
212
|
+
console.log(existsSync(designMd)
|
|
213
|
+
? '\nnext: stitch2 canvas, then draft screens from the template (the stitch2 skill has the workflow)'
|
|
214
|
+
: '\nnext: stitch2 init --name "…" --primary "#rrggbb" (or stitch2 extract <url>), then stitch2 tokens and stitch2 canvas');
|
package/dist/tokens.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design tokens for lab screens, generated from design/DESIGN.md's front matter (the DESIGN.md format Stitch
|
|
3
|
+
* and Google's design.md linter use), plus Material 3 colour roles from one seed with Google's
|
|
4
|
+
* material-color-utilities (settings in tokens.config.json next to DESIGN.md).
|
|
5
|
+
* Writes tokens.css (colour variables, dark by default and [data-theme=light], and one .type-<level>
|
|
6
|
+
* class per typography level) and tailwind.tokens.js (a Tailwind CDN config using the variables).
|
|
7
|
+
* Usage: stitch2 tokens
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { argbFromHex, Hct, hexFromArgb, MaterialDynamicColors, SchemeContent, SchemeExpressive, SchemeFidelity, SchemeFruitSalad, SchemeMonochrome, SchemeNeutral, SchemeRainbow, SchemeTonalSpot, SchemeVibrant, } from '@material/material-color-utilities';
|
|
12
|
+
import { config as lab } from './config.js';
|
|
13
|
+
import { readDesign } from './design-md.js';
|
|
14
|
+
import { writeExports } from './exports.js';
|
|
15
|
+
import { ROOT } from './screens.js';
|
|
16
|
+
const SCHEMES = {
|
|
17
|
+
fidelity: SchemeFidelity,
|
|
18
|
+
tonal_spot: SchemeTonalSpot,
|
|
19
|
+
vibrant: SchemeVibrant,
|
|
20
|
+
expressive: SchemeExpressive,
|
|
21
|
+
content: SchemeContent,
|
|
22
|
+
neutral: SchemeNeutral,
|
|
23
|
+
monochrome: SchemeMonochrome,
|
|
24
|
+
rainbow: SchemeRainbow,
|
|
25
|
+
fruit_salad: SchemeFruitSalad,
|
|
26
|
+
};
|
|
27
|
+
const config = JSON.parse(readFileSync(join(ROOT, 'tokens.config.json'), 'utf8'));
|
|
28
|
+
const Scheme = SCHEMES[config.scheme];
|
|
29
|
+
if (!Scheme)
|
|
30
|
+
throw new Error(`unknown scheme "${config.scheme}"; use one of ${Object.keys(SCHEMES).join(', ')}`);
|
|
31
|
+
const design = readDesign();
|
|
32
|
+
const seed = design.colors[config.seed] ?? config.seed;
|
|
33
|
+
const source = Hct.fromInt(argbFromHex(seed));
|
|
34
|
+
/** Every Material role for one mode, keyed like "primary-container". */
|
|
35
|
+
function roles(dark) {
|
|
36
|
+
const scheme = new Scheme(source, dark, config.contrast ?? 0);
|
|
37
|
+
const out = {};
|
|
38
|
+
// The roles are static DynamicColor properties of MaterialDynamicColors (0.3.x).
|
|
39
|
+
const all = Object.values(MaterialDynamicColors).filter((c) => typeof c === 'object' && c !== null && 'getArgb' in c && 'name' in c);
|
|
40
|
+
for (const color of all) {
|
|
41
|
+
if (color.name.endsWith('_palette_key_color'))
|
|
42
|
+
continue;
|
|
43
|
+
out[color.name.replaceAll('_', '-')] = hexFromArgb(color.getArgb(scheme));
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
// DESIGN.md colours: plain names are the dark theme, light-<name> overrides them in the light theme.
|
|
48
|
+
const dark = {};
|
|
49
|
+
const light = {};
|
|
50
|
+
for (const [name, value] of Object.entries(design.colors)) {
|
|
51
|
+
// "primary" is often an alias ("{colors.ember}"); keep it only when it is a colour of its own.
|
|
52
|
+
if (name === 'primary' && Object.entries(design.colors).some(([k, v]) => k !== name && v === value))
|
|
53
|
+
continue;
|
|
54
|
+
if (name.startsWith('light-'))
|
|
55
|
+
light[name.slice(6)] = value;
|
|
56
|
+
else
|
|
57
|
+
dark[name] = value;
|
|
58
|
+
}
|
|
59
|
+
for (const name of Object.keys(dark))
|
|
60
|
+
light[name] ??= dark[name];
|
|
61
|
+
const vars = (selector, palette, md) => [
|
|
62
|
+
`${selector} {`,
|
|
63
|
+
...Object.entries(palette).map(([k, v]) => ` --${lab.prefix}-${k}: ${v};`),
|
|
64
|
+
...Object.entries(md).map(([k, v]) => ` --md-${k}: ${v};`),
|
|
65
|
+
'}',
|
|
66
|
+
].join('\n');
|
|
67
|
+
// Scripts without case or with joined letters: no tracking, no caps, and room for tall marks.
|
|
68
|
+
const UNCASED = ['ar', 'zh', 'ko', 'th', 'hi'].map((l) => `:lang(${l})`);
|
|
69
|
+
const FALLBACK = '"Noto Sans Arabic", "Noto Sans Thai", "Noto Sans Devanagari", "PingFang SC", "Noto Sans SC", "Apple SD Gothic Neo", "Noto Sans KR", system-ui, sans-serif';
|
|
70
|
+
const typeDecls = {};
|
|
71
|
+
const typeRules = Object.entries(design.typography).map(([name, t]) => {
|
|
72
|
+
const fallback = `var(--${lab.prefix}-font-${/mono/i.test(t.fontFamily ?? '') ? 'mono' : 'sans'}-fallback)`;
|
|
73
|
+
const decl = [
|
|
74
|
+
`font: ${t.fontWeight} ${t.fontSize}/${t.lineHeight} "${t.fontFamily}", ${fallback}`,
|
|
75
|
+
`letter-spacing: ${t.letterSpacing ?? '0em'}`,
|
|
76
|
+
t.fontFeature ? `font-feature-settings: ${t.fontFeature}` : '',
|
|
77
|
+
name.endsWith('-caps') ? 'text-transform: uppercase' : '',
|
|
78
|
+
].filter(Boolean);
|
|
79
|
+
typeDecls[name] = decl;
|
|
80
|
+
return `.type-${name} { ${decl.join('; ')}; }`;
|
|
81
|
+
});
|
|
82
|
+
const typeCss = [
|
|
83
|
+
...typeRules,
|
|
84
|
+
`${UNCASED.map((l) => `${l} [class*="type-"], [class*="type-"]${l}`).join(', ')} { letter-spacing: 0; text-transform: none; }`,
|
|
85
|
+
`${['th', 'hi'].map((l) => `:lang(${l}) [class*="type-"]`).join(', ')} { line-height: 1.5; }`,
|
|
86
|
+
];
|
|
87
|
+
const md = { dark: roles(true), light: roles(false) };
|
|
88
|
+
const header = `/* Generated by stitch2 tokens from DESIGN.md (Material roles: seed ${seed}, ${config.scheme}). Do not edit. */`;
|
|
89
|
+
writeFileSync(join(ROOT, 'tokens.css'), [
|
|
90
|
+
header,
|
|
91
|
+
vars(":root, [data-theme='dark']", dark, md.dark),
|
|
92
|
+
vars("[data-theme='light']", light, md.light),
|
|
93
|
+
":root { color-scheme: dark; } [data-theme='light'] { color-scheme: light; }",
|
|
94
|
+
`:root { --${lab.prefix}-font-sans-fallback: ${FALLBACK}; --${lab.prefix}-font-mono-fallback: ui-monospace, monospace; }`,
|
|
95
|
+
'/* Typography levels: one class sets family, size, line height, weight, tracking and figures. */',
|
|
96
|
+
typeCss.join('\n'),
|
|
97
|
+
'',
|
|
98
|
+
].join('\n\n'));
|
|
99
|
+
const colors = Object.fromEntries(Object.keys(dark).map((k) => [k, `var(--${lab.prefix}-${k})`]));
|
|
100
|
+
colors.md = Object.fromEntries(Object.keys(md.dark).map((k) => [k, `var(--md-${k})`]));
|
|
101
|
+
const families = Object.values(design.typography).map((t) => t.fontFamily ?? '');
|
|
102
|
+
const tailwind = {
|
|
103
|
+
darkMode: ['selector', "[data-theme='dark']"],
|
|
104
|
+
theme: {
|
|
105
|
+
extend: {
|
|
106
|
+
colors,
|
|
107
|
+
borderRadius: design.rounded,
|
|
108
|
+
spacing: design.spacing,
|
|
109
|
+
fontFamily: {
|
|
110
|
+
sans: [families.find((f) => !/mono/i.test(f)) ?? 'Geist', 'system-ui', 'sans-serif'],
|
|
111
|
+
mono: [families.find((f) => /mono/i.test(f)) ?? 'Geist Mono', 'ui-monospace', 'monospace'],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
writeFileSync(join(ROOT, 'tailwind.tokens.js'), `${header.replace('/*', '//').replace(' */', '')}\n// Load after the Tailwind CDN script: bg-floor, text-chalk, bg-ember, rounded-card, p-lg, font-mono, bg-md-primary-container. Text styles: the .type-<level> classes in tokens.css.\ntailwind.config = ${JSON.stringify(tailwind, null, 2)}\n`);
|
|
117
|
+
const exported = writeExports({
|
|
118
|
+
design,
|
|
119
|
+
dark,
|
|
120
|
+
light,
|
|
121
|
+
type: typeDecls,
|
|
122
|
+
root: {
|
|
123
|
+
[`${lab.prefix}-font-sans-fallback`]: FALLBACK,
|
|
124
|
+
[`${lab.prefix}-font-mono-fallback`]: 'ui-monospace, monospace',
|
|
125
|
+
},
|
|
126
|
+
header: `/* Generated by stitch2 tokens from DESIGN.md. Do not edit: change DESIGN.md and run stitch2 tokens. */`,
|
|
127
|
+
});
|
|
128
|
+
console.log(`tokens: ${Object.keys(dark).length} colours, ${typeRules.length} type levels, ${Object.keys(md.dark).length} Material roles (${config.scheme}, seed ${seed})${exported.length ? `; exported ${exported.join(', ')}` : ''}`);
|
package/dist/type.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/type.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typography audit: renders each screen and lists every distinct text style it uses (family, size, line
|
|
3
|
+
* height, weight, tracking, colour, case), how often, a sample, and which DESIGN.md type level it matches
|
|
4
|
+
* ("—" when none). Use it to compare type systems (Stitch versus hand-made screens) and to find stray styles.
|
|
5
|
+
* Usage: stitch2 type [path filter…] [--json] (--json writes design/type-audit.json)
|
|
6
|
+
*/
|
|
7
|
+
import { writeFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { chromium } from '@playwright/test';
|
|
10
|
+
import { typeLevels } from './design-md.js';
|
|
11
|
+
import { listScreens, ROOT } from './screens.js';
|
|
12
|
+
import { startServer } from './serve.js';
|
|
13
|
+
import { collectText, matchLevel } from './typecheck.js';
|
|
14
|
+
const key = (t) => `${t.family} ${t.size}/${Math.round(t.lineHeight)} w${t.weight}${t.tracking ? ` ${(t.tracking / t.size).toFixed(3)}em` : ''}${t.upper ? ' CAPS' : ''} ${t.color}`;
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
const filters = args.filter((a) => !a.startsWith('--'));
|
|
17
|
+
const screens = listScreens().filter((s) => !filters.length || filters.some((f) => s.path.includes(f)));
|
|
18
|
+
const levels = typeLevels();
|
|
19
|
+
const server = await startServer(0);
|
|
20
|
+
const browser = await chromium.launch();
|
|
21
|
+
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
|
22
|
+
const report = {};
|
|
23
|
+
for (const s of screens) {
|
|
24
|
+
await page.setViewportSize({ width: s.width, height: 844 });
|
|
25
|
+
await page
|
|
26
|
+
.goto(`http://127.0.0.1:${server.port}/${s.path}`, { waitUntil: 'networkidle', timeout: 30_000 })
|
|
27
|
+
.catch(() => { });
|
|
28
|
+
await page.waitForTimeout(600);
|
|
29
|
+
const styles = new Map();
|
|
30
|
+
for (const t of await page.evaluate(collectText)) {
|
|
31
|
+
const k = key(t);
|
|
32
|
+
const hit = styles.get(k);
|
|
33
|
+
if (hit)
|
|
34
|
+
hit.count++;
|
|
35
|
+
else
|
|
36
|
+
styles.set(k, {
|
|
37
|
+
key: k,
|
|
38
|
+
level: matchLevel(levels, t)?.name ?? '—',
|
|
39
|
+
count: 1,
|
|
40
|
+
numeric: t.numeric,
|
|
41
|
+
sample: t.text,
|
|
42
|
+
item: t,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
report[s.path] = [...styles.values()].sort((a, b) => b.item.size - a.item.size || b.item.weight - a.item.weight);
|
|
46
|
+
}
|
|
47
|
+
await browser.close();
|
|
48
|
+
server.close();
|
|
49
|
+
if (args.includes('--json'))
|
|
50
|
+
writeFileSync(join(ROOT, 'type-audit.json'), `${JSON.stringify(report, null, 2)}\n`);
|
|
51
|
+
const uniq = (xs) => [...new Set(xs)];
|
|
52
|
+
for (const [path, styles] of Object.entries(report)) {
|
|
53
|
+
const texts = styles.reduce((n, s) => n + s.count, 0);
|
|
54
|
+
const onScale = styles.filter((s) => s.level !== '—').reduce((n, s) => n + s.count, 0);
|
|
55
|
+
console.log(`\n${path}: ${styles.length} styles, ${Math.round((onScale / texts) * 100)}% of text on the scale; sizes ${uniq(styles.map((s) => s.item.size)).join('/')}, weights ${uniq(styles.map((s) => s.item.weight))
|
|
56
|
+
.sort()
|
|
57
|
+
.join('/')}, colours ${uniq(styles.map((s) => s.item.color)).length}`);
|
|
58
|
+
for (const s of styles)
|
|
59
|
+
console.log(` ${String(s.count).padStart(3)}× ${s.level.padEnd(14)} ${s.key.padEnd(52)} ${s.numeric ? '#' : ' '} “${s.sample}”`);
|
|
60
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typography checks shared by check and type: collects every rendered text style in a page and compares it
|
|
3
|
+
* with DESIGN.md's type levels.
|
|
4
|
+
*/
|
|
5
|
+
import type { Level } from './design-md.ts';
|
|
6
|
+
export interface Issue {
|
|
7
|
+
severity: 'error' | 'warning';
|
|
8
|
+
type: string;
|
|
9
|
+
message: string;
|
|
10
|
+
selector: string;
|
|
11
|
+
text: string;
|
|
12
|
+
}
|
|
13
|
+
export interface TextItem {
|
|
14
|
+
selector: string;
|
|
15
|
+
text: string;
|
|
16
|
+
family: string;
|
|
17
|
+
mono: boolean;
|
|
18
|
+
size: number;
|
|
19
|
+
weight: number;
|
|
20
|
+
/** px */
|
|
21
|
+
lineHeight: number;
|
|
22
|
+
/** px */
|
|
23
|
+
tracking: number;
|
|
24
|
+
color: string;
|
|
25
|
+
upper: boolean;
|
|
26
|
+
numeric: boolean;
|
|
27
|
+
lines: number;
|
|
28
|
+
}
|
|
29
|
+
/** Runs in the page. Plain JS on purpose: Playwright sends its source to the browser. */
|
|
30
|
+
export declare function collectText(): TextItem[];
|
|
31
|
+
/** The type level a rendered style matches, if any. Line height only matters for text that can wrap. */
|
|
32
|
+
export declare function matchLevel(levels: Level[], t: TextItem): Level | undefined;
|
|
33
|
+
export declare function nearestLevel(levels: Level[], t: TextItem): Level;
|
|
34
|
+
/** Typography issues for one screen: off-scale styles, cramped wrapped text, untracked caps, mono decimals. */
|
|
35
|
+
export declare function typeIssues(levels: Level[], items: TextItem[]): Issue[];
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** Runs in the page. Plain JS on purpose: Playwright sends its source to the browser. */
|
|
2
|
+
export function collectText() {
|
|
3
|
+
const sel = (el) => {
|
|
4
|
+
const parts = [];
|
|
5
|
+
for (let e = el; e && e !== document.body && parts.length < 6; e = e.parentElement) {
|
|
6
|
+
if (e.id) {
|
|
7
|
+
parts.unshift(`#${CSS.escape(e.id)}`);
|
|
8
|
+
break;
|
|
9
|
+
}
|
|
10
|
+
let p = e.tagName.toLowerCase();
|
|
11
|
+
const same = e.parentElement
|
|
12
|
+
? [...e.parentElement.children].filter((c) => c.tagName === e.tagName)
|
|
13
|
+
: [];
|
|
14
|
+
if (same.length > 1)
|
|
15
|
+
p += `:nth-of-type(${same.indexOf(e) + 1})`;
|
|
16
|
+
parts.unshift(p);
|
|
17
|
+
}
|
|
18
|
+
return parts.join(' > ');
|
|
19
|
+
};
|
|
20
|
+
const hex = (n) => Math.round(n).toString(16).padStart(2, '0');
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const el of document.body.querySelectorAll('*')) {
|
|
23
|
+
const nodes = [...el.childNodes].filter((n) => n.nodeType === 3 && n.textContent.trim());
|
|
24
|
+
if (!nodes.length || el.closest('script,style,svg,[aria-hidden="true"]'))
|
|
25
|
+
continue;
|
|
26
|
+
const cs = getComputedStyle(el);
|
|
27
|
+
// Icon fonts (Material Symbols) render ligature words as glyphs: not text.
|
|
28
|
+
if (/material|symbols|icons/i.test(cs.fontFamily) || cs.visibility === 'hidden')
|
|
29
|
+
continue;
|
|
30
|
+
const r = el.getBoundingClientRect();
|
|
31
|
+
if (!r.width || !r.height)
|
|
32
|
+
continue;
|
|
33
|
+
let opacity = 1;
|
|
34
|
+
for (let e = el; e; e = e.parentElement)
|
|
35
|
+
opacity *= Number(getComputedStyle(e).opacity);
|
|
36
|
+
if (opacity < 0.05)
|
|
37
|
+
continue;
|
|
38
|
+
const own = nodes.map((n) => n.textContent.trim()).join(' ');
|
|
39
|
+
const size = Number.parseFloat(cs.fontSize);
|
|
40
|
+
const lineHeight = cs.lineHeight === 'normal' ? size * 1.2 : Number.parseFloat(cs.lineHeight);
|
|
41
|
+
// Lines: line boxes whose middles are more than half a line apart.
|
|
42
|
+
const mids = [];
|
|
43
|
+
for (const n of nodes) {
|
|
44
|
+
const range = document.createRange();
|
|
45
|
+
range.selectNodeContents(n);
|
|
46
|
+
for (const q of range.getClientRects())
|
|
47
|
+
if (q.width > 1)
|
|
48
|
+
mids.push(q.top + q.height / 2);
|
|
49
|
+
}
|
|
50
|
+
mids.sort((a, b) => a - b);
|
|
51
|
+
const lines = mids.filter((m, i) => i === 0 || m - mids[i - 1] > lineHeight / 2).length;
|
|
52
|
+
const m = cs.color.match(/[\d.]+/g).map(Number);
|
|
53
|
+
const alpha = (m[3] ?? 1) * opacity;
|
|
54
|
+
out.push({
|
|
55
|
+
selector: sel(el),
|
|
56
|
+
text: own.slice(0, 40),
|
|
57
|
+
family: cs.fontFamily.split(',')[0].replaceAll(/["']/g, '').trim(),
|
|
58
|
+
mono: /mono/i.test(cs.fontFamily.split(',')[0]),
|
|
59
|
+
size,
|
|
60
|
+
weight: Number(cs.fontWeight),
|
|
61
|
+
lineHeight,
|
|
62
|
+
tracking: cs.letterSpacing === 'normal' ? 0 : Number.parseFloat(cs.letterSpacing),
|
|
63
|
+
color: `#${hex(m[0])}${hex(m[1])}${hex(m[2])}${alpha < 0.99 ? ` ${Math.round(alpha * 100)}%` : ''}`,
|
|
64
|
+
// Set in capitals: uppercased by CSS, or typed in capitals (6+ letters, so "RPE" and "PR" don't count).
|
|
65
|
+
upper: cs.textTransform === 'uppercase' ||
|
|
66
|
+
(own === own.toUpperCase() && own.replace(/[^A-Z]/g, '').length >= 6),
|
|
67
|
+
numeric: /^[\d\s.,:×x+\-−–/%]+[a-z]{0,4}$/i.test(own) && /\d/.test(own),
|
|
68
|
+
lines,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/** The type level a rendered style matches, if any. Line height only matters for text that can wrap. */
|
|
74
|
+
export function matchLevel(levels, t) {
|
|
75
|
+
return levels.find((l) => l.mono === t.mono &&
|
|
76
|
+
Math.abs(l.size - t.size) < 0.5 &&
|
|
77
|
+
l.weight === t.weight &&
|
|
78
|
+
Math.abs(l.tracking - t.tracking) <= 0.012 * t.size + 0.05 &&
|
|
79
|
+
Math.abs(l.lineHeight - t.lineHeight) < 1.5);
|
|
80
|
+
}
|
|
81
|
+
export function nearestLevel(levels, t) {
|
|
82
|
+
const score = (l) => Math.abs(l.size - t.size) * 2 + Math.abs(l.weight - t.weight) / 100 + (l.mono === t.mono ? 0 : 3);
|
|
83
|
+
return [...levels].sort((a, b) => score(a) - score(b))[0];
|
|
84
|
+
}
|
|
85
|
+
const describe = (t) => `${t.mono ? 'mono ' : ''}${t.size}/${Math.round(t.lineHeight)} ${t.weight}${t.tracking ? ` ${(t.tracking / t.size).toFixed(2)}em` : ''}`;
|
|
86
|
+
const describeLevel = (l) => `${l.name} (${l.mono ? 'mono ' : ''}${l.size}/${l.lineHeight} ${l.weight}${l.tracking ? ` ${(l.tracking / l.size).toFixed(2)}em` : ''})`;
|
|
87
|
+
/** Typography issues for one screen: off-scale styles, cramped wrapped text, untracked caps, mono decimals. */
|
|
88
|
+
export function typeIssues(levels, items) {
|
|
89
|
+
const out = [];
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
const push = (severity, type, t, message) => {
|
|
92
|
+
if (out.filter((i) => i.type === type).length >= 10)
|
|
93
|
+
return;
|
|
94
|
+
out.push({ severity, type, message, selector: t.selector, text: t.text });
|
|
95
|
+
};
|
|
96
|
+
for (const t of items) {
|
|
97
|
+
if (levels.length && !matchLevel(levels, t)) {
|
|
98
|
+
const key = describe(t);
|
|
99
|
+
if (!seen.has(key)) {
|
|
100
|
+
seen.add(key);
|
|
101
|
+
push('warning', 'type-scale', t, `Off the type scale: ${key}; nearest level ${describeLevel(nearestLevel(levels, t))}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (t.lines > 1 && t.lineHeight < t.size * 1.15)
|
|
105
|
+
push('error', 'type-leading', t, `Wrapped text with cramped line height (${Math.round(t.lineHeight)}px for ${t.size}px type)`);
|
|
106
|
+
if (t.upper && t.text.length > 2 && t.tracking < 0.03 * t.size)
|
|
107
|
+
push('warning', 'type-caps', t, 'Capitals without letter-spacing (use label-caps, +0.06em)');
|
|
108
|
+
if (t.mono && t.size >= 18 && /\d[.,:]\d/.test(t.text))
|
|
109
|
+
push('warning', 'type-mono-number', t, 'Large number in a monospace font: its point, comma or colon gets a full-width gap; use Geist with tabular figures');
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stitch2",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.7.2",
|
|
4
|
+
"description": "A design canvas, layout and type checks, component consistency and screen versioning for agent-built UI, on Google's DESIGN.md format.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "ifokeev",
|
|
7
|
+
"homepage": "https://github.com/ifokeev/stitch2#readme",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/ifokeev/stitch2/issues"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"stitch2": "dist/cli.js"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
"./elements": {
|
|
17
|
+
"types": "./dist/elements.d.ts",
|
|
18
|
+
"default": "./dist/elements.js"
|
|
19
|
+
},
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"skills",
|
|
25
|
+
"config.schema.json",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=24"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@google/design.md": "0.4.0",
|
|
34
|
+
"@material/material-color-utilities": "0.3.0",
|
|
35
|
+
"@playwright/test": "1.63.0",
|
|
36
|
+
"yaml": "2.9.1",
|
|
37
|
+
"esbuild": "0.28.2"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"design",
|
|
41
|
+
"design-system",
|
|
42
|
+
"design-md",
|
|
43
|
+
"agents",
|
|
44
|
+
"skills",
|
|
45
|
+
"canvas",
|
|
46
|
+
"ui",
|
|
47
|
+
"stitch",
|
|
48
|
+
"playwright"
|
|
49
|
+
],
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "git+https://github.com/ifokeev/stitch2.git"
|
|
53
|
+
},
|
|
54
|
+
"packageManager": "pnpm@10.32.1"
|
|
55
|
+
}
|