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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -2
  3. package/config.schema.json +109 -0
  4. package/dist/assets.d.ts +1 -0
  5. package/dist/assets.js +59 -0
  6. package/dist/audit.d.ts +10 -0
  7. package/dist/audit.js +259 -0
  8. package/dist/build-design.d.ts +39 -0
  9. package/dist/build-design.js +337 -0
  10. package/dist/build-elements.d.ts +1 -0
  11. package/dist/build-elements.js +33 -0
  12. package/dist/canvas.html +689 -0
  13. package/dist/check.d.ts +1 -0
  14. package/dist/check.js +86 -0
  15. package/dist/cli.d.ts +2 -0
  16. package/dist/cli.js +73 -0
  17. package/dist/colours.d.ts +2 -0
  18. package/dist/colours.js +57 -0
  19. package/dist/compare.d.ts +1 -0
  20. package/dist/compare.js +185 -0
  21. package/dist/config.d.ts +53 -0
  22. package/dist/config.js +38 -0
  23. package/dist/consistency.d.ts +40 -0
  24. package/dist/consistency.js +336 -0
  25. package/dist/design-md.d.ts +27 -0
  26. package/dist/design-md.js +49 -0
  27. package/dist/design-page.d.ts +1 -0
  28. package/dist/design-page.js +127 -0
  29. package/dist/elements.d.ts +32 -0
  30. package/dist/elements.js +69 -0
  31. package/dist/exports.d.ts +25 -0
  32. package/dist/exports.js +86 -0
  33. package/dist/extract.d.ts +1 -0
  34. package/dist/extract.js +197 -0
  35. package/dist/init.d.ts +5 -0
  36. package/dist/init.js +83 -0
  37. package/dist/list.d.ts +1 -0
  38. package/dist/list.js +17 -0
  39. package/dist/pick.d.ts +10 -0
  40. package/dist/pick.js +38 -0
  41. package/dist/report.d.ts +1 -0
  42. package/dist/report.js +110 -0
  43. package/dist/sandbox.d.ts +1 -0
  44. package/dist/sandbox.js +183 -0
  45. package/dist/screens.d.ts +34 -0
  46. package/dist/screens.js +85 -0
  47. package/dist/serve.d.ts +4 -0
  48. package/dist/serve.js +181 -0
  49. package/dist/setup.d.ts +1 -0
  50. package/dist/setup.js +214 -0
  51. package/dist/tokens.d.ts +1 -0
  52. package/dist/tokens.js +128 -0
  53. package/dist/type.d.ts +1 -0
  54. package/dist/type.js +60 -0
  55. package/dist/typecheck.d.ts +35 -0
  56. package/dist/typecheck.js +112 -0
  57. package/package.json +53 -4
  58. package/skills/stitch2/SKILL.md +80 -0
  59. package/skills/stitch2/references/components.md +59 -0
  60. package/skills/stitch2/references/guides.md +20 -0
  61. package/skills/stitch2/references/principles.md +80 -0
  62. package/skills/stitch2/references/screens.md +70 -0
  63. package/skills/stitch2/references/typography.md +106 -0
  64. package/skills/stitch2-app-sync/SKILL.md +65 -0
  65. package/skills/stitch2-consistency/SKILL.md +59 -0
  66. package/skills/stitch2-design-md/SKILL.md +64 -0
  67. package/skills/stitch2-import-from-google-stitch/SKILL.md +47 -0
  68. package/skills/stitch2-import-from-google-stitch/references/how-stitch-works.md +36 -0
  69. package/skills/stitch2-variants/SKILL.md +45 -0
@@ -0,0 +1,336 @@
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 { config } from './config.js';
10
+ import { readDesign } from './design-md.js';
11
+ /** The options for collectComponents from the config and DESIGN.md's primary colour. */
12
+ export function collectOptions() {
13
+ const hex = (readDesign().colors.primary ?? '#000000').replace('#', '');
14
+ return { prefix: config.prefix, primary: [0, 2, 4].map((i) => Number.parseInt(hex.slice(i, i + 2), 16)) };
15
+ }
16
+ /** Runs in the page. Plain JS on purpose: Playwright sends its source to the browser. */
17
+ export function collectComponents(opts) {
18
+ const sel = (el) => {
19
+ const parts = [];
20
+ for (let e = el; e && e !== document.body && parts.length < 6; e = e.parentElement) {
21
+ if (e.id) {
22
+ parts.unshift(`#${CSS.escape(e.id)}`);
23
+ break;
24
+ }
25
+ let p = e.tagName.toLowerCase();
26
+ const same = e.parentElement
27
+ ? [...e.parentElement.children].filter((c) => c.tagName === e.tagName)
28
+ : [];
29
+ if (same.length > 1)
30
+ p += `:nth-of-type(${same.indexOf(e) + 1})`;
31
+ parts.unshift(p);
32
+ }
33
+ return parts.join(' > ');
34
+ };
35
+ const px = (v) => String(Math.round(Number.parseFloat(v) || 0));
36
+ const ownText = (el) => [...el.childNodes]
37
+ .filter((n) => n.nodeType === 3)
38
+ .map((n) => n.textContent.trim())
39
+ .join(' ')
40
+ .trim();
41
+ const isIconFont = (el) => /material|symbols|icons/i.test(getComputedStyle(el).fontFamily);
42
+ const firstText = (root, match) => {
43
+ for (const el of [root, ...root.querySelectorAll('*')]) {
44
+ const t = ownText(el);
45
+ if (t && !isIconFont(el) && (!match || match.test(t)))
46
+ return el;
47
+ }
48
+ return null;
49
+ };
50
+ const font = (el) => {
51
+ if (!el)
52
+ return '';
53
+ const c = getComputedStyle(el);
54
+ const size = Number.parseFloat(c.fontSize);
55
+ const tr = c.letterSpacing === 'normal' ? 0 : Number.parseFloat(c.letterSpacing) / size;
56
+ return `${size}/${c.fontWeight}/${c.fontFamily.split(',')[0].replaceAll(/["']/g, '').trim()}${tr ? `/${tr.toFixed(2)}em` : ''}`;
57
+ };
58
+ const iconSize = (root) => {
59
+ const i = root.querySelector('svg, i, .material-symbols-outlined, [class*="material-symbols"]');
60
+ return i ? px(String(i.getBoundingClientRect().width)) : '';
61
+ };
62
+ // Which icon set and which glyphs: Lucide SVGs carry lucide-<name>, Material Symbols are ligature text.
63
+ const icons = (root) => [...root.querySelectorAll('svg, [data-lucide], [class*="material-symbols"], .material-icons')]
64
+ .filter((i) => !i.parentElement?.closest('svg'))
65
+ .map((i) => {
66
+ const cls = String(i.getAttribute('class') ?? '');
67
+ const lucide = /\blucide-([a-z0-9-]+)/.exec(cls)?.[1] ?? i.getAttribute('data-lucide');
68
+ if (lucide)
69
+ return ['lucide', lucide];
70
+ if (/material/.test(cls) || /material|symbols/i.test(getComputedStyle(i).fontFamily))
71
+ return ['material-symbols', (i.textContent ?? '').trim()];
72
+ return ['svg', ''];
73
+ });
74
+ const iconSet = (root) => [...new Set(icons(root).map(([s]) => s))].join('+');
75
+ const box = (el) => el.getBoundingClientRect();
76
+ const ember = (el) => getComputedStyle(el).backgroundColor.includes(opts.primary.join(', '));
77
+ const visible = (el) => {
78
+ const r = box(el);
79
+ const c = getComputedStyle(el);
80
+ return r.width > 0 && r.height > 0 && c.visibility !== 'hidden' && c.display !== 'none';
81
+ };
82
+ const sigOf = (kind, el) => {
83
+ const c = getComputedStyle(el);
84
+ const r = box(el);
85
+ const f = {};
86
+ const base = kind.split(':')[0];
87
+ if (base === 'tab-bar') {
88
+ f.height = px(String(r.height));
89
+ f.background = c.backgroundColor;
90
+ f.label = font(firstText(el));
91
+ f.icon = iconSize(el);
92
+ f.iconSet = iconSet(el);
93
+ f.icons = icons(el)
94
+ .map(([, n]) => n)
95
+ .filter(Boolean)
96
+ .join(',');
97
+ const circle = [...el.querySelectorAll('*')].find((e) => ember(e) && Math.abs(box(e).width - box(e).height) < 2);
98
+ if (circle) {
99
+ f.circle = px(String(box(circle).width));
100
+ f.lift = px(String(r.top - box(circle).top));
101
+ }
102
+ f.centreLabel = /\b(Start|Resume)\b/.test(el.textContent ?? '') ? 'yes' : 'no';
103
+ }
104
+ else if (base === 'header') {
105
+ f.title = font(el.matches('h1') ? el : (el.querySelector('h1') ?? firstText(el)));
106
+ }
107
+ else if (base === 'section') {
108
+ f.label = font(el.matches('h2, h3, p, span') ? el : (el.querySelector('h2, h3') ?? firstText(el)));
109
+ }
110
+ else if (base === 'card') {
111
+ f.radius = px(c.borderTopLeftRadius);
112
+ f.background = c.backgroundColor;
113
+ }
114
+ else if (base === 'button' || base === 'icon-button' || base === 'chip') {
115
+ f.height = px(String(r.height));
116
+ f.radius = px(c.borderTopLeftRadius);
117
+ f.font = font(firstText(el));
118
+ if (base === 'icon-button') {
119
+ f.icon = iconSize(el);
120
+ f.iconSet = iconSet(el);
121
+ f.width = px(String(r.width));
122
+ f.background = c.backgroundColor;
123
+ }
124
+ }
125
+ // Every other component (rows, sheets, charts, a sidebar, …) takes its size from its content, and its code
126
+ // guarantees the rest: only its presence is recorded, so screens are not flagged for holding other content.
127
+ for (const k of Object.keys(f))
128
+ if (!f[k])
129
+ delete f[k];
130
+ return {
131
+ selector: sel(el),
132
+ text: (el.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 40),
133
+ fields: f,
134
+ };
135
+ };
136
+ const components = {};
137
+ const add = (kind, el) => {
138
+ const s = sigOf(kind, el);
139
+ components[kind] ??= [];
140
+ if (!components[kind].some((x) => JSON.stringify(x.fields) === JSON.stringify(s.fields)))
141
+ components[kind].push(s);
142
+ };
143
+ const handBuilt = [];
144
+ // 1. Lab components announce themselves.
145
+ const marker = `data-${opts.prefix}`;
146
+ for (const el of document.querySelectorAll(`[${marker}]`)) {
147
+ if (!visible(el))
148
+ continue;
149
+ const g = el.getAttribute(marker);
150
+ const kind = g === 'button' ? `button:${el.getAttribute('data-variant') ?? 'primary'}:${el.getAttribute('data-size') ?? 'md'}` : g;
151
+ if (!(g === 'button' && el.hasAttribute('disabled')))
152
+ add(kind, el);
153
+ }
154
+ // 2. Everything else is recognised by how it looks.
155
+ const H = window.innerHeight;
156
+ const W = document.documentElement.clientWidth;
157
+ const loose = (el) => !el.closest(`[${marker}]`);
158
+ const all = [...document.body.querySelectorAll('*')].filter((el) => visible(el) && loose(el));
159
+ const found = (kind, el) => {
160
+ add(kind, el);
161
+ handBuilt.push({
162
+ kind,
163
+ selector: sel(el),
164
+ text: (el.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 40),
165
+ });
166
+ };
167
+ // A tab bar: a full-width bar fixed to the bottom with three or more buttons or links.
168
+ const bar = all.find((el) => {
169
+ const r = box(el);
170
+ return (getComputedStyle(el).position === 'fixed' &&
171
+ r.bottom >= H - 2 &&
172
+ r.width >= W * 0.9 &&
173
+ r.height < 140 &&
174
+ el.querySelectorAll('button, a').length >= 3);
175
+ });
176
+ if (bar)
177
+ found('tab-bar', bar);
178
+ const inBar = (el) => !!bar && bar.contains(el);
179
+ const h1 = all.find((el) => el.matches('h1'));
180
+ if (h1)
181
+ found('header', h1);
182
+ for (const el of all) {
183
+ if (inBar(el) || !el.matches('button, a, [role=button]'))
184
+ continue;
185
+ if (el.parentElement?.closest('button, a'))
186
+ continue;
187
+ const r = box(el);
188
+ const text = (el.textContent ?? '').trim();
189
+ const hasIcon = !!el.querySelector('svg, i, [class*="material-symbols"]');
190
+ const radius = Number.parseFloat(getComputedStyle(el).borderTopLeftRadius);
191
+ const textless = !firstText(el);
192
+ if (ember(el) && text && r.width > r.height * 1.5 && r.height >= 36)
193
+ found('button:primary:md', el);
194
+ // Header actions: icon-only buttons near the top (stars and steppers further down are other things).
195
+ else if (textless &&
196
+ hasIcon &&
197
+ r.width >= 32 &&
198
+ r.width <= 56 &&
199
+ Math.abs(r.width - r.height) < 4 &&
200
+ !ember(el) &&
201
+ r.top + window.scrollY < 140)
202
+ found('icon-button', el);
203
+ else if (!textless && radius >= r.height / 2 - 1 && r.height >= 26 && r.height <= 40 && text.length <= 24)
204
+ found('chip', el);
205
+ }
206
+ for (const el of all) {
207
+ if (inBar(el) || el.closest('button, a'))
208
+ continue;
209
+ const t = ownText(el);
210
+ const c = getComputedStyle(el);
211
+ // Caps labels: uppercased by CSS or typed in capitals, without digits ("RPE 8" is a value, not a label).
212
+ if (t.length >= 3 &&
213
+ !/\d/.test(t) &&
214
+ Number.parseFloat(c.fontSize) <= 13 &&
215
+ (c.textTransform === 'uppercase' || (t === t.toUpperCase() && /[A-Z]{4}/.test(t.replace(/\s/g, '')))) &&
216
+ el.matches('h2, h3, h4, p, span, div, label'))
217
+ found('section', el);
218
+ }
219
+ return { components, handBuilt };
220
+ }
221
+ /** The components whose look is measured and compared; the rest follow their content (see sigOf). */
222
+ const MEASURED = new Set(['tab-bar', 'header', 'section', 'card', 'button', 'icon-button', 'chip']);
223
+ export function familyOf(path) {
224
+ if (path.startsWith(`${config.stitchDir}/`))
225
+ return config.stitchDir;
226
+ return config.archiveDirs.find((d) => path.startsWith(`${d}/`)) ?? 'screens';
227
+ }
228
+ const p = config.prefix;
229
+ const NAMES = {
230
+ 'tab-bar': ['Tab bar', `${p}-tab-bar`],
231
+ header: ['Screen header', `${p}-header`],
232
+ section: ['Section label', `${p}-section`],
233
+ chip: ['Chip', `${p}-chip`],
234
+ 'icon-button': ['Icon button', `${p}-icon-button`],
235
+ 'button:primary:md': ['Primary button', `${p}-button`],
236
+ };
237
+ export const nameOf = (kind) => NAMES[kind]?.[0] ?? kind.replace(/:/g, ' ');
238
+ const key = (s) => JSON.stringify(s.fields);
239
+ /** Issues for the screens in `paths`, comparing with every screen whose components are known. */
240
+ export function consistencyIssues(known, paths) {
241
+ const out = {};
242
+ const families = new Map();
243
+ const catalogs = Object.keys(known).filter((p) => p.startsWith(`${config.catalogDir}/`));
244
+ for (const p of Object.keys(known))
245
+ families.set(familyOf(p), [...(families.get(familyOf(p)) ?? []), p]);
246
+ for (const path of paths) {
247
+ const issues = [];
248
+ out[path] = issues;
249
+ const peers = (families.get(familyOf(path)) ?? []).filter((p) => p !== path);
250
+ for (const [kind, sigs] of Object.entries(known[path] ?? {})) {
251
+ if (sigs.length > 1 && MEASURED.has(kind.split(':')[0]))
252
+ issues.push({
253
+ severity: 'warning',
254
+ type: 'consistency',
255
+ message: `${sigs.length} different versions of the ${nameOf(kind).toLowerCase()} on this screen`,
256
+ selector: sigs[1].selector,
257
+ text: sigs[1].text,
258
+ });
259
+ // The component catalog is the reference for every screen that is not an archived experiment.
260
+ const reference = familyOf(path) === config.stitchDir || familyOf(path) === 'screens'
261
+ ? catalogs.flatMap((c) => known[c]?.[kind] ?? [])
262
+ : [];
263
+ if (catalogs.includes(path))
264
+ continue;
265
+ if (reference.length) {
266
+ const mine = sigs[0];
267
+ if (reference.some((r) => key(r) === key(mine)))
268
+ continue;
269
+ const ref = reference[0];
270
+ const diffs = Object.keys({ ...mine.fields, ...ref.fields })
271
+ .filter((f) => mine.fields[f] !== ref.fields[f])
272
+ .map((f) => `${f} ${mine.fields[f] ?? '–'} (catalog ${ref.fields[f] ?? '–'})`);
273
+ issues.push({
274
+ severity: 'warning',
275
+ type: 'consistency',
276
+ message: `${nameOf(kind)} differs from the component catalog: ${diffs.join('; ')}`,
277
+ selector: mine.selector,
278
+ text: mine.text,
279
+ });
280
+ continue;
281
+ }
282
+ // The majority version among the other screens of the family.
283
+ const votes = new Map();
284
+ for (const p of peers) {
285
+ const first = known[p]?.[kind]?.[0];
286
+ if (!first)
287
+ continue;
288
+ const v = votes.get(key(first)) ?? { n: 0, sig: first };
289
+ v.n++;
290
+ votes.set(key(first), v);
291
+ }
292
+ const ranked = [...votes.values()].sort((a, b) => b.n - a.n);
293
+ const top = ranked[0];
294
+ const mine = sigs[0];
295
+ if (!top)
296
+ continue;
297
+ // No majority: report when this screen matches none of its peers either.
298
+ if (top.n < 2 || ranked[1]?.n === top.n) {
299
+ if (!votes.has(key(mine)) && peers.filter((p) => known[p]?.[kind]).length >= 2)
300
+ issues.push({
301
+ severity: 'warning',
302
+ type: 'consistency',
303
+ message: `${nameOf(kind)} matches none of the other ${ranked.reduce((n, v) => n + v.n, 0)} screens (${ranked.length + 1} different versions in this family)`,
304
+ selector: mine.selector,
305
+ text: mine.text,
306
+ });
307
+ continue;
308
+ }
309
+ if (key(mine) === key(top.sig))
310
+ continue;
311
+ const diffs = Object.keys({ ...mine.fields, ...top.sig.fields })
312
+ .filter((f) => mine.fields[f] !== top.sig.fields[f])
313
+ .map((f) => `${f} ${mine.fields[f] ?? '–'} (others ${top.sig.fields[f] ?? '–'})`);
314
+ issues.push({
315
+ severity: 'warning',
316
+ type: 'consistency',
317
+ message: `${nameOf(kind)} differs from ${top.n} other screens: ${diffs.join('; ')}`,
318
+ selector: mine.selector,
319
+ text: mine.text,
320
+ });
321
+ }
322
+ }
323
+ return out;
324
+ }
325
+ /** Lab screens build catalog components with the components, not by hand. */
326
+ export function reuseIssues(path, page) {
327
+ if (familyOf(path) !== 'screens')
328
+ return [];
329
+ return page.handBuilt.slice(0, 10).map((h) => ({
330
+ severity: 'warning',
331
+ type: 'reuse',
332
+ message: `${nameOf(h.kind)} built by hand; use <${NAMES[h.kind]?.[1] ?? 'the catalog component'}> (see the component catalog, ${config.catalogDir})`,
333
+ selector: h.selector,
334
+ text: h.text,
335
+ }));
336
+ }
@@ -0,0 +1,27 @@
1
+ export interface TypeLevel {
2
+ fontFamily?: string;
3
+ fontSize?: string;
4
+ fontWeight?: number | string;
5
+ lineHeight?: string | number;
6
+ letterSpacing?: string;
7
+ fontFeature?: string;
8
+ }
9
+ export interface Design {
10
+ name: string;
11
+ colors: Record<string, string>;
12
+ typography: Record<string, TypeLevel>;
13
+ rounded: Record<string, string>;
14
+ spacing: Record<string, string>;
15
+ components: Record<string, Record<string, string>>;
16
+ }
17
+ export declare function readDesign(file?: string): Design;
18
+ /** A type level in pixels, for comparing with computed styles. */
19
+ export interface Level {
20
+ name: string;
21
+ mono: boolean;
22
+ size: number;
23
+ weight: number;
24
+ lineHeight: number;
25
+ tracking: number;
26
+ }
27
+ export declare function typeLevels(design?: Design): Level[];
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Reads design/DESIGN.md's YAML front matter (the DESIGN.md format: colors, typography, rounded, spacing,
3
+ * components) and resolves {path.to.token} references.
4
+ */
5
+ import { readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { parse } from 'yaml';
8
+ import { ROOT } from './screens.js';
9
+ export function readDesign(file = join(ROOT, 'DESIGN.md')) {
10
+ const text = readFileSync(file, 'utf8');
11
+ const front = /^---\n([\s\S]*?)\n---/.exec(text)?.[1];
12
+ if (!front)
13
+ throw new Error(`${file} has no YAML front matter`);
14
+ const raw = parse(front);
15
+ const resolve = (value, depth = 0) => {
16
+ if (typeof value !== 'string')
17
+ return value;
18
+ const ref = /^\{([\w.-]+)\}$/.exec(value)?.[1];
19
+ if (!ref || depth > 10)
20
+ return value;
21
+ const target = ref.split('.').reduce((o, k) => o?.[k], raw);
22
+ return resolve(target, depth + 1);
23
+ };
24
+ const colors = Object.fromEntries(Object.entries(raw.colors ?? {}).map(([k, v]) => [k, String(resolve(v))]));
25
+ return {
26
+ name: raw.name ?? '',
27
+ colors,
28
+ typography: raw.typography ?? {},
29
+ rounded: raw.rounded ?? {},
30
+ spacing: raw.spacing ?? {},
31
+ components: raw.components ?? {},
32
+ };
33
+ }
34
+ export function typeLevels(design = readDesign()) {
35
+ return Object.entries(design.typography).map(([name, t]) => {
36
+ const size = Number.parseFloat(String(t.fontSize));
37
+ const lh = String(t.lineHeight);
38
+ return {
39
+ name,
40
+ mono: /mono/i.test(t.fontFamily ?? ''),
41
+ size,
42
+ weight: Number(t.fontWeight ?? 400),
43
+ lineHeight: lh.endsWith('px') ? Number.parseFloat(lh) : Number.parseFloat(lh) * size,
44
+ tracking: t.letterSpacing?.endsWith('em')
45
+ ? Number.parseFloat(t.letterSpacing) * size
46
+ : Number.parseFloat(t.letterSpacing ?? '0') || 0,
47
+ };
48
+ });
49
+ }
@@ -0,0 +1 @@
1
+ export declare function designPage(): string;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The design system page (/design on the stitch2 server), with its own neutral styling: DESIGN.md's tokens drawn as swatches, live type
3
+ * samples, spacing bars, radii and component tokens, followed by its prose. Rendered on each request.
4
+ */
5
+ import { readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { readDesign } from './design-md.js';
8
+ import { ROOT } from './screens.js';
9
+ const esc = (s) => String(s ?? '')
10
+ .replaceAll('&', '&amp;')
11
+ .replaceAll('<', '&lt;')
12
+ .replaceAll('>', '&gt;')
13
+ .replaceAll('"', '&quot;');
14
+ /** Enough Markdown for DESIGN.md's prose: headings, lists, paragraphs, bold, inline code. */
15
+ function markdown(md) {
16
+ const inline = (s) => esc(s)
17
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
18
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
19
+ const out = [];
20
+ let list = false;
21
+ let para = [];
22
+ const flush = () => {
23
+ if (para.length)
24
+ out.push(`<p>${inline(para.join(' '))}</p>`);
25
+ para = [];
26
+ };
27
+ let item = null;
28
+ const endItem = () => {
29
+ if (item)
30
+ out.push(`<li>${inline(item.join(' '))}</li>`);
31
+ item = null;
32
+ };
33
+ for (const line of md.split('\n')) {
34
+ const h = /^(#{1,4})\s+(.*)$/.exec(line);
35
+ const li = /^-\s+(.*)$/.exec(line);
36
+ if (h) {
37
+ flush();
38
+ endItem();
39
+ if (list)
40
+ out.push('</ul>');
41
+ list = false;
42
+ out.push(`<h${h[1].length + 1}>${inline(h[2])}</h${h[1].length + 1}>`);
43
+ }
44
+ else if (li) {
45
+ flush();
46
+ endItem();
47
+ if (!list)
48
+ out.push('<ul>');
49
+ list = true;
50
+ item = [li[1]];
51
+ }
52
+ else if (!line.trim()) {
53
+ flush();
54
+ endItem();
55
+ if (list)
56
+ out.push('</ul>');
57
+ list = false;
58
+ }
59
+ else if (item)
60
+ item.push(line.trim());
61
+ else
62
+ para.push(line.trim());
63
+ }
64
+ flush();
65
+ endItem();
66
+ if (list)
67
+ out.push('</ul>');
68
+ return out.join('\n');
69
+ }
70
+ export function designPage() {
71
+ const d = readDesign();
72
+ const text = readFileSync(join(ROOT, 'DESIGN.md'), 'utf8');
73
+ const prose = text.replace(/^---\n[\s\S]*?\n---\n/, '');
74
+ const colours = Object.entries(d.colors).filter(([n, v]) => n !== 'primary' || !Object.entries(d.colors).some(([k, w]) => k !== n && w === v));
75
+ // Load the families DESIGN.md names from Google Fonts, so the type samples render in them.
76
+ const families = [
77
+ ...new Set(Object.values(d.typography)
78
+ .map((t) => t.fontFamily)
79
+ .filter(Boolean)),
80
+ ];
81
+ const fonts = `https://fonts.googleapis.com/css2?${families.map((f) => `family=${encodeURIComponent(f).replaceAll('%20', '+')}:wght@400;500;600;700`).join('&')}&display=swap`;
82
+ const swatch = ([name, value]) => `<div class="sw"><span style="background:${esc(value)}"></span><b>${esc(name)}</b><code>${esc(value)}</code></div>`;
83
+ const type = Object.entries(d.typography)
84
+ .map(([name, t]) => `<div class="ty"><div class="type-${esc(name)}">${/number|stat|timer/.test(name) ? '82.5 · 1:32 · 80.7' : name.endsWith('caps') ? 'Recent workouts' : 'Barbell Bench Press'}</div><code>${esc(name)} · ${esc(t.fontFamily)} ${esc(t.fontSize)}/${esc(t.lineHeight)} ${esc(t.fontWeight)}${t.letterSpacing && t.letterSpacing !== '0em' ? ` ${esc(t.letterSpacing)}` : ''}</code></div>`)
85
+ .join('');
86
+ const spacing = Object.entries(d.spacing)
87
+ .map(([n, v]) => `<div class="sp"><code>${esc(n)}</code><span style="width:${esc(v)}"></span><code>${esc(v)}</code></div>`)
88
+ .join('');
89
+ const radii = Object.entries(d.rounded)
90
+ .map(([n, v]) => `<div class="ra"><span style="border-radius:${esc(v)}"></span><code>${esc(n)} ${esc(v)}</code></div>`)
91
+ .join('');
92
+ const components = Object.entries(d.components)
93
+ .map(([n, props]) => `<tr><td><b>${esc(n)}</b></td><td>${Object.entries(props)
94
+ .map(([k, v]) => `<code>${esc(k)}: ${esc(v)}</code>`)
95
+ .join(' ')}</td></tr>`)
96
+ .join('');
97
+ return `<!doctype html>
98
+ <html lang="en" data-theme="dark"><head><meta charset="utf-8" /><title>DESIGN.md — ${esc(d.name)}</title>
99
+ <link href="${esc(fonts)}" rel="stylesheet" />
100
+ <link rel="stylesheet" href="/tokens.css" />
101
+ <style>
102
+ body { margin: 0; background: #0f0f11; color: #f4f4f5; font: 15px/1.55 Geist, system-ui, sans-serif; }
103
+ main { max-width: 980px; margin: 0 auto; padding: 32px 32px 96px; }
104
+ h1 { font-size: 32px; letter-spacing: -0.03em; margin: 0 0 4px; }
105
+ h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: #a1a1aa; margin: 40px 0 12px; }
106
+ h3, h4 { margin: 24px 0 8px; }
107
+ section.prose h2 { font-size: 20px; text-transform: none; letter-spacing: -0.02em; color: #f4f4f5; }
108
+ code { font: 12px/1.4 "Geist Mono", ui-monospace, monospace; color: #a1a1aa; }
109
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
110
+ .sw { display: grid; gap: 4px; } .sw span { height: 56px; border-radius: 12px; border: 1px solid #2e2e33; }
111
+ .ty { padding: 14px 0; border-bottom: 1px solid #2e2e33; display: grid; gap: 6px; }
112
+ .sp { display: grid; grid-template-columns: 70px auto 1fr; gap: 12px; align-items: center; margin: 6px 0; }
113
+ .sp span { height: 12px; background: #a1a1aa; border-radius: 3px; }
114
+ .ra { display: grid; gap: 6px; justify-items: start; } .ra span { width: 72px; height: 72px; background: #232327; border: 1px solid #2e2e33; }
115
+ table { border-collapse: collapse; width: 100%; } td { border-bottom: 1px solid #2e2e33; padding: 8px 8px 8px 0; vertical-align: top; } td code { margin-right: 10px; display: inline-block; }
116
+ .prose li { margin: 4px 0; } .prose strong { color: #f4f4f5; } .prose { color: #d4d4d8; }
117
+ </style></head>
118
+ <body><main>
119
+ <h1>${esc(d.name)} design system</h1><code>design/DESIGN.md · tokens in the front matter, rules below</code>
120
+ <h2>Colours</h2><div class="grid">${colours.map(swatch).join('')}</div>
121
+ <h2>Type levels</h2>${type}
122
+ <h2>Spacing</h2>${spacing}
123
+ <h2>Radii</h2><div class="grid">${radii}</div>
124
+ <h2>Components</h2><table>${components}</table>
125
+ <section class="prose">${markdown(prose)}</section>
126
+ </main></body></html>`;
127
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Shared components for screens: turns a project's own components (React, Preact, Vue, Solid, …) into the
3
+ * <prefix>-<name> elements stitch2 screens use, so a screen shows exactly the markup the app renders. Each
4
+ * element renders once into plain markup in the page (no shadow DOM, so the checks, pick mode and Tailwind
5
+ * see it), marks its root data-<prefix>="<name>" (plus data-variant and data-size from those props), and
6
+ * keeps the screen's source line for pick mode.
7
+ *
8
+ * import { createElement } from 'react'
9
+ * import { renderToStaticMarkup } from 'react-dom/server'
10
+ * import { defineElements } from 'stitch2/elements'
11
+ * import { Button, Row } from './ui'
12
+ * defineElements({ button: Button, row: Row }, {
13
+ * prefix: 'gg',
14
+ * render: (C, props) => renderToStaticMarkup(createElement(C, props)),
15
+ * })
16
+ *
17
+ * Attributes become props: kebab-case to camelCase, an empty attribute (<gg-row chevron>) is true, other
18
+ * values stay strings. The element's content becomes children, rendered where the component puts them.
19
+ * Bundle the entry with `stitch2 elements <entry>› and load the bundle in the screens and the template.
20
+ */
21
+ export interface ElementsOptions<C> {
22
+ /** The project's prefix (stitch2.config.json): elements are <prefix>-<name>. */
23
+ prefix: string;
24
+ /**
25
+ * Renders one component with its props to static HTML; props.children is a placeholder string. Synchronous
26
+ * (React, Preact, Solid) or a promise (Vue's renderToString).
27
+ */
28
+ render: (component: C, props: Record<string, unknown>) => string | Promise<string>;
29
+ /** Runs after every element has rendered, e.g. to draw icon placeholders (lucide.createIcons). */
30
+ after?: () => void;
31
+ }
32
+ export declare function defineElements<C>(components: Record<string, C>, options: ElementsOptions<C>): void;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Shared components for screens: turns a project's own components (React, Preact, Vue, Solid, …) into the
3
+ * <prefix>-<name> elements stitch2 screens use, so a screen shows exactly the markup the app renders. Each
4
+ * element renders once into plain markup in the page (no shadow DOM, so the checks, pick mode and Tailwind
5
+ * see it), marks its root data-<prefix>="<name>" (plus data-variant and data-size from those props), and
6
+ * keeps the screen's source line for pick mode.
7
+ *
8
+ * import { createElement } from 'react'
9
+ * import { renderToStaticMarkup } from 'react-dom/server'
10
+ * import { defineElements } from 'stitch2/elements'
11
+ * import { Button, Row } from './ui'
12
+ * defineElements({ button: Button, row: Row }, {
13
+ * prefix: 'gg',
14
+ * render: (C, props) => renderToStaticMarkup(createElement(C, props)),
15
+ * })
16
+ *
17
+ * Attributes become props: kebab-case to camelCase, an empty attribute (<gg-row chevron>) is true, other
18
+ * values stay strings. The element's content becomes children, rendered where the component puts them.
19
+ * Bundle the entry with `stitch2 elements <entry>› and load the bundle in the screens and the template.
20
+ */
21
+ const camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
22
+ export function defineElements(components, options) {
23
+ const { prefix, render } = options;
24
+ let pending = 0;
25
+ for (const [name, component] of Object.entries(components)) {
26
+ const tag = `${prefix}-${name}`;
27
+ if (customElements.get(tag))
28
+ continue;
29
+ customElements.define(tag, class extends HTMLElement {
30
+ connectedCallback() {
31
+ const props = {};
32
+ for (const a of Array.from(this.attributes)) {
33
+ if (a.name.startsWith('data-s2-'))
34
+ continue;
35
+ props[camel(a.name)] = a.value === '' ? true : a.value;
36
+ }
37
+ const inner = this.innerHTML.trim();
38
+ const token = `s2children${Math.random().toString(36).slice(2)}`;
39
+ if (inner)
40
+ props.children = token;
41
+ const out = render(component, props);
42
+ if (typeof out === 'string')
43
+ this.mount(out, inner, token, props);
44
+ else
45
+ void out.then((html) => this.mount(html, inner, token, props));
46
+ }
47
+ mount(html, inner, token, props) {
48
+ if (inner)
49
+ html = html.replace(token, inner);
50
+ const t = document.createElement('template');
51
+ t.innerHTML = html;
52
+ const root = t.content.firstElementChild;
53
+ if (root) {
54
+ if (!root.hasAttribute(`data-${prefix}`))
55
+ root.setAttribute(`data-${prefix}`, name);
56
+ for (const key of ['variant', 'size'])
57
+ if (typeof props[key] === 'string' && !root.hasAttribute(`data-${key}`))
58
+ root.setAttribute(`data-${key}`, props[key]);
59
+ }
60
+ this.replaceWith(t.content);
61
+ if (options.after && !pending++)
62
+ queueMicrotask(() => {
63
+ pending = 0;
64
+ options.after?.();
65
+ });
66
+ }
67
+ });
68
+ }
69
+ }
@@ -0,0 +1,25 @@
1
+ import type { Design } from './design-md.ts';
2
+ export interface TokenExport {
3
+ format: 'css' | 'tailwind4' | 'dtcg';
4
+ /** Output file, relative to stitch2.config.json. */
5
+ path: string;
6
+ /** App variable name → DESIGN.md colour name (or any CSS value). */
7
+ aliases?: Record<string, string>;
8
+ /** Theme selectors; default the lab's: :root, [data-theme='dark'] and [data-theme='light']. */
9
+ themes?: {
10
+ dark?: string;
11
+ light?: string;
12
+ };
13
+ }
14
+ export interface Tokens {
15
+ design: Design;
16
+ dark: Record<string, string>;
17
+ light: Record<string, string>;
18
+ /** Declarations per type level, e.g. ["font: 600 28px/34px \"Geist\", …", "letter-spacing: -0.03em"]. */
19
+ type: Record<string, string[]>;
20
+ /** Extra variables for :root (font fallbacks). */
21
+ root: Record<string, string>;
22
+ header: string;
23
+ }
24
+ /** Writes every export in the config; returns their paths. */
25
+ export declare function writeExports(t: Tokens, list?: TokenExport[]): string[];