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/audit.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/** Layout errors that mean the same bug wherever they appear; compare reports these for the app too. */
|
|
2
|
+
export const LAYOUT_ERRORS = ['overflow', 'fixed-overlap', 'control-overlap'];
|
|
3
|
+
/** Runs inside the page. Plain JS on purpose: Playwright sends its source to the browser. */
|
|
4
|
+
export async function audit(opts) {
|
|
5
|
+
const W = document.documentElement.clientWidth;
|
|
6
|
+
const out = [];
|
|
7
|
+
const sel = (el) => {
|
|
8
|
+
const parts = [];
|
|
9
|
+
for (let e = el; e && e !== document.body && parts.length < 6; e = e.parentElement) {
|
|
10
|
+
if (e.id) {
|
|
11
|
+
parts.unshift(`#${CSS.escape(e.id)}`);
|
|
12
|
+
break;
|
|
13
|
+
}
|
|
14
|
+
let p = e.tagName.toLowerCase();
|
|
15
|
+
const same = e.parentElement
|
|
16
|
+
? [...e.parentElement.children].filter((c) => c.tagName === e.tagName)
|
|
17
|
+
: [];
|
|
18
|
+
if (same.length > 1)
|
|
19
|
+
p += `:nth-of-type(${same.indexOf(e) + 1})`;
|
|
20
|
+
parts.unshift(p);
|
|
21
|
+
}
|
|
22
|
+
return parts.join(' > ');
|
|
23
|
+
};
|
|
24
|
+
const text = (el) => (el.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 60);
|
|
25
|
+
const push = (severity, type, el, message) => {
|
|
26
|
+
if (out.filter((i) => i.type === type).length >= 10)
|
|
27
|
+
return;
|
|
28
|
+
out.push({ severity, type, message, selector: sel(el), text: text(el) });
|
|
29
|
+
};
|
|
30
|
+
const shown = (el) => {
|
|
31
|
+
const cs = getComputedStyle(el);
|
|
32
|
+
if (cs.display === 'none' || cs.visibility === 'hidden')
|
|
33
|
+
return false;
|
|
34
|
+
const r = el.getBoundingClientRect();
|
|
35
|
+
return r.width > 0 && r.height > 0;
|
|
36
|
+
};
|
|
37
|
+
const opacity = (el) => {
|
|
38
|
+
let o = 1;
|
|
39
|
+
for (let e = el; e; e = e.parentElement)
|
|
40
|
+
o *= Number(getComputedStyle(e).opacity);
|
|
41
|
+
return o;
|
|
42
|
+
};
|
|
43
|
+
const ownText = (el) => [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
|
|
44
|
+
const inSvg = (el) => !!el.closest('svg') && el.tagName.toLowerCase() !== 'svg';
|
|
45
|
+
const all = [...document.body.querySelectorAll('*')].filter((el) => shown(el) && !inSvg(el));
|
|
46
|
+
const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
47
|
+
// 1. Nothing past the screen edge, except inside deliberate horizontal scrollers (chip rows).
|
|
48
|
+
if (document.documentElement.scrollWidth > W + 1)
|
|
49
|
+
push('error', 'page-overflow', document.body, `The page scrolls sideways (${document.documentElement.scrollWidth}px wide)`);
|
|
50
|
+
const overflowing = new Set();
|
|
51
|
+
for (const el of all) {
|
|
52
|
+
const r = el.getBoundingClientRect();
|
|
53
|
+
if (r.right <= W + 1 && r.left >= -1)
|
|
54
|
+
continue;
|
|
55
|
+
let clip = null;
|
|
56
|
+
for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
|
|
57
|
+
const ox = getComputedStyle(p).overflowX;
|
|
58
|
+
if (ox !== 'visible') {
|
|
59
|
+
clip = ox;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (clip === 'auto' || clip === 'scroll')
|
|
64
|
+
continue;
|
|
65
|
+
if (overflowing.has(el.parentElement)) {
|
|
66
|
+
overflowing.add(el);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
overflowing.add(el);
|
|
70
|
+
if (clip) {
|
|
71
|
+
if (ownText(el))
|
|
72
|
+
push('warning', 'clipped', el, 'Text is cut off at the screen edge');
|
|
73
|
+
}
|
|
74
|
+
else
|
|
75
|
+
push('error', 'overflow', el, `Sticks out past the screen edge (${Math.round(r.right - W)}px)`);
|
|
76
|
+
}
|
|
77
|
+
// 2. Short labels (buttons, chips, tabs) stay on one line.
|
|
78
|
+
const isLabel = (el) => el.matches('button, a, label, [role=tab], [role=button], [role=switch], th') ||
|
|
79
|
+
/(^|\s)(chip|badge|pill|tag)|rounded-full/.test(String(el.className ?? ''));
|
|
80
|
+
for (const el of all) {
|
|
81
|
+
if (!isLabel(el) || text(el).length > 40 || el.querySelector('p, div, li, ul, br'))
|
|
82
|
+
continue;
|
|
83
|
+
// A wrap is one text node breaking across lines; an icon above its label is layout, not a wrap.
|
|
84
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
85
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
86
|
+
if (!n.textContent.trim())
|
|
87
|
+
continue;
|
|
88
|
+
const range = document.createRange();
|
|
89
|
+
range.selectNodeContents(n);
|
|
90
|
+
const tops = [...range.getClientRects()].filter((r) => r.width > 1).map((r) => r.top + r.height / 2);
|
|
91
|
+
const lineHeight = parseFloat(getComputedStyle(n.parentElement).fontSize) || 14;
|
|
92
|
+
if (tops.length > 1 && Math.max(...tops) - Math.min(...tops) > lineHeight * 0.6) {
|
|
93
|
+
push('error', 'label-wraps', el, 'Label wraps onto two lines');
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// 3. Fixed bars (tab bar, rest timer, headers) never cover content: check at both scroll ends.
|
|
99
|
+
const bars = all.filter((el) => {
|
|
100
|
+
const p = getComputedStyle(el).position;
|
|
101
|
+
const r = el.getBoundingClientRect();
|
|
102
|
+
return (p === 'fixed' || p === 'sticky') && r.height <= 240 && r.width >= W * 0.6;
|
|
103
|
+
});
|
|
104
|
+
const outer = bars.filter((b) => !bars.some((o) => o !== b && o.contains(b)));
|
|
105
|
+
const content = all.filter((el) => !outer.some((b) => b.contains(el)) &&
|
|
106
|
+
(ownText(el) || el.matches('button, input, select, textarea, img, svg, canvas, video')) &&
|
|
107
|
+
opacity(el) > 0.5);
|
|
108
|
+
const covered = async (edge) => {
|
|
109
|
+
window.scrollTo(0, edge === 'bottom' ? document.documentElement.scrollHeight : 0);
|
|
110
|
+
await frame();
|
|
111
|
+
const H = window.innerHeight;
|
|
112
|
+
for (const bar of outer) {
|
|
113
|
+
const b = bar.getBoundingClientRect();
|
|
114
|
+
const atEdge = edge === 'bottom' ? b.bottom >= H - 4 && b.top > H / 2 : b.top <= 4 && b.bottom < H / 2;
|
|
115
|
+
if (!atEdge)
|
|
116
|
+
continue;
|
|
117
|
+
for (const el of content) {
|
|
118
|
+
if (el.contains(bar))
|
|
119
|
+
continue;
|
|
120
|
+
const r = el.getBoundingClientRect();
|
|
121
|
+
const overlapY = Math.min(r.bottom, b.bottom) - Math.max(r.top, b.top);
|
|
122
|
+
const overlapX = Math.min(r.right, b.right) - Math.max(r.left, b.left);
|
|
123
|
+
if (overlapY > 4 && overlapX > 4)
|
|
124
|
+
push('error', `under-${edge}-bar`, el, `Hidden under the fixed ${edge} bar when scrolled to the ${edge} (add padding)`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
// 3b. Fixed bars never overlap one another, including what sticks out of them (a raised tab-bar button
|
|
129
|
+
// reaching into a docked timer). Compares the painted parts: backgrounds, borders, text, icons, controls.
|
|
130
|
+
const painted = (el) => {
|
|
131
|
+
const cs = getComputedStyle(el);
|
|
132
|
+
return (ownText(el) ||
|
|
133
|
+
el.matches('svg, img, button, input') ||
|
|
134
|
+
!/rgba\(0, 0, 0, 0\)|transparent/.test(cs.backgroundColor) ||
|
|
135
|
+
Number.parseFloat(cs.borderTopWidth) > 0);
|
|
136
|
+
};
|
|
137
|
+
const fixedBars = outer.filter((b) => getComputedStyle(b).position === 'fixed' && b.getBoundingClientRect().height < window.innerHeight * 0.8);
|
|
138
|
+
const parts = (bar) => [bar, ...bar.querySelectorAll('*')].filter((e) => shown(e) && !inSvg(e) && painted(e)).map((e) => e.getBoundingClientRect());
|
|
139
|
+
for (const [i, a] of fixedBars.entries())
|
|
140
|
+
for (const b of fixedBars.slice(i + 1)) {
|
|
141
|
+
const hit = parts(a).some((r) => parts(b).some((q) => Math.min(r.right, q.right) - Math.max(r.left, q.left) > 2 && Math.min(r.bottom, q.bottom) - Math.max(r.top, q.top) > 2));
|
|
142
|
+
if (hit)
|
|
143
|
+
push('error', 'fixed-overlap', b, `Overlaps another fixed bar (“${text(a).slice(0, 30)}”): move one clear of the other`);
|
|
144
|
+
}
|
|
145
|
+
await covered('bottom');
|
|
146
|
+
await covered('top');
|
|
147
|
+
window.scrollTo(0, 0);
|
|
148
|
+
// 4. Tap targets: WCAG 2.2 asks for at least 24px; smaller than the config's comfortableTarget is a warning.
|
|
149
|
+
const interactive = all.filter((el) => el.matches('button, a[href], input:not([type=hidden]), select, textarea, [role=button], [role=tab], [role=switch], [role=checkbox]'));
|
|
150
|
+
for (const el of interactive) {
|
|
151
|
+
if (interactive.some((o) => o !== el && o.contains(el)))
|
|
152
|
+
continue;
|
|
153
|
+
if (el.matches('a') && el.closest('p'))
|
|
154
|
+
continue;
|
|
155
|
+
const r = el.getBoundingClientRect();
|
|
156
|
+
const min = Math.min(r.width, r.height);
|
|
157
|
+
// WCAG 2.2 spacing exception: a smaller target passes when a 24px circle on it touches no other target.
|
|
158
|
+
const cx = r.left + r.width / 2;
|
|
159
|
+
const cy = r.top + r.height / 2;
|
|
160
|
+
const crowded = interactive.some((o) => {
|
|
161
|
+
if (o === el || o.contains(el) || el.contains(o))
|
|
162
|
+
return false;
|
|
163
|
+
const q = o.getBoundingClientRect();
|
|
164
|
+
const dx = Math.max(q.left - cx, 0, cx - q.right);
|
|
165
|
+
const dy = Math.max(q.top - cy, 0, cy - q.bottom);
|
|
166
|
+
return Math.hypot(dx, dy) < 12;
|
|
167
|
+
});
|
|
168
|
+
if (min < 24 && crowded)
|
|
169
|
+
push('error', 'tap-target', el, 'Too small to tap and crowded (' +
|
|
170
|
+
Math.round(r.width) +
|
|
171
|
+
'×' +
|
|
172
|
+
Math.round(r.height) +
|
|
173
|
+
'px, minimum 24)');
|
|
174
|
+
else if (min < 24)
|
|
175
|
+
push('warning', 'tap-target', el, `Small tap target (${Math.round(r.width)}×${Math.round(r.height)}px)`);
|
|
176
|
+
else if (min < opts.comfortable)
|
|
177
|
+
push('warning', 'tap-target', el, `Small tap target (${Math.round(r.width)}×${Math.round(r.height)}px)`);
|
|
178
|
+
}
|
|
179
|
+
// 4b. Controls never overlap one another (a button squeezed over its neighbour). Controls inside one another
|
|
180
|
+
// and invisible overlays (a native picker laid over a row) are fine; fixed and sticky layers (a docked timer,
|
|
181
|
+
// a sticky Save bar floating over a list) are compared apart from the scrolling page.
|
|
182
|
+
const layer = (el) => {
|
|
183
|
+
for (let e = el; e; e = e.parentElement)
|
|
184
|
+
if (['fixed', 'sticky'].includes(getComputedStyle(e).position))
|
|
185
|
+
return e;
|
|
186
|
+
return null;
|
|
187
|
+
};
|
|
188
|
+
// Behind an open modal the page is inert and the modal covers it, so only the modal's own controls count.
|
|
189
|
+
const modal = [...document.querySelectorAll('[aria-modal="true"]')].find(shown);
|
|
190
|
+
const solid = interactive.filter((el) => opacity(el) > 0.05 && (!modal || modal.contains(el)));
|
|
191
|
+
for (const [i, a] of solid.entries())
|
|
192
|
+
for (const b of solid.slice(i + 1)) {
|
|
193
|
+
if (a.contains(b) || b.contains(a) || layer(a) !== layer(b))
|
|
194
|
+
continue;
|
|
195
|
+
const r = a.getBoundingClientRect();
|
|
196
|
+
const q = b.getBoundingClientRect();
|
|
197
|
+
if (Math.min(r.right, q.right) - Math.max(r.left, q.left) > 2 && Math.min(r.bottom, q.bottom) - Math.max(r.top, q.top) > 2)
|
|
198
|
+
push('error', 'control-overlap', b, `Overlaps another control (“${text(a).slice(0, 30) || a.getAttribute('aria-label') || a.tagName.toLowerCase()}”): give them room`);
|
|
199
|
+
}
|
|
200
|
+
// 5. Text contrast (WCAG AA): 4.5:1, or 3:1 for large text. Skips text over images and dimmed layers.
|
|
201
|
+
const rgba = (c) => {
|
|
202
|
+
const m = /rgba?\(([^)]+)\)/.exec(c);
|
|
203
|
+
if (!m)
|
|
204
|
+
return null;
|
|
205
|
+
const [r, g, b, a = '1'] = m[1].split(/[\s,/]+/).filter(Boolean);
|
|
206
|
+
return [Number(r), Number(g), Number(b), Number(a)];
|
|
207
|
+
};
|
|
208
|
+
const lum = (c) => {
|
|
209
|
+
const f = (v) => {
|
|
210
|
+
v /= 255;
|
|
211
|
+
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
212
|
+
};
|
|
213
|
+
return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]);
|
|
214
|
+
};
|
|
215
|
+
const over = (top, under) => {
|
|
216
|
+
const a = top[3];
|
|
217
|
+
return [0, 1, 2].map((i) => top[i] * a + under[i] * (1 - a)).concat(1);
|
|
218
|
+
};
|
|
219
|
+
for (const el of all) {
|
|
220
|
+
// WCAG exempts disabled controls from contrast.
|
|
221
|
+
if (!ownText(el) || opacity(el) < 0.6 || el.matches('input, textarea, select, option'))
|
|
222
|
+
continue;
|
|
223
|
+
if (el.closest('[disabled], [aria-disabled="true"]'))
|
|
224
|
+
continue;
|
|
225
|
+
const layers = [];
|
|
226
|
+
let image = false;
|
|
227
|
+
for (let e = el; e; e = e.parentElement) {
|
|
228
|
+
const cs = getComputedStyle(e);
|
|
229
|
+
if (cs.backgroundImage !== 'none') {
|
|
230
|
+
image = true;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
const c = rgba(cs.backgroundColor);
|
|
234
|
+
if (c && c[3] > 0) {
|
|
235
|
+
layers.push(c);
|
|
236
|
+
if (c[3] === 1)
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (image)
|
|
241
|
+
continue;
|
|
242
|
+
let bg = [255, 255, 255, 1];
|
|
243
|
+
for (const l of layers.reverse())
|
|
244
|
+
bg = over(l, bg);
|
|
245
|
+
const fgRaw = rgba(getComputedStyle(el).color);
|
|
246
|
+
if (!fgRaw)
|
|
247
|
+
continue;
|
|
248
|
+
const fg = over(fgRaw, bg);
|
|
249
|
+
const [a, b] = [lum(fg), lum(bg)].sort((x, y) => y - x);
|
|
250
|
+
const ratio = (a + 0.05) / (b + 0.05);
|
|
251
|
+
const cs = getComputedStyle(el);
|
|
252
|
+
const size = parseFloat(cs.fontSize);
|
|
253
|
+
const large = size >= 24 || (size >= 18.66 && Number(cs.fontWeight) >= 700);
|
|
254
|
+
const need = large ? 3 : 4.5;
|
|
255
|
+
if (ratio < need)
|
|
256
|
+
push(ratio < need - 1 ? 'error' : 'warning', 'contrast', el, `Low contrast ${ratio.toFixed(2)}:1 (needs ${need}:1)`);
|
|
257
|
+
}
|
|
258
|
+
return out.sort((x, y) => (x.severity === y.severity ? 0 : x.severity === 'error' ? -1 : 1));
|
|
259
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type Mode = 'dark' | 'light';
|
|
2
|
+
export interface Choices {
|
|
3
|
+
name: string;
|
|
4
|
+
/** One line on the product and its mood, for the Overview. */
|
|
5
|
+
mood: string;
|
|
6
|
+
primary: string;
|
|
7
|
+
modes: Mode[];
|
|
8
|
+
sans: string;
|
|
9
|
+
mono?: string;
|
|
10
|
+
/** Body text size in px (14 compact, 15 default, 16 comfortable). */
|
|
11
|
+
base: number;
|
|
12
|
+
/** Corner radius of a card-sized element in px; 0 for square, 999 for pill-shaped controls. */
|
|
13
|
+
roundness: number;
|
|
14
|
+
/** Side padding of a phone screen in px. */
|
|
15
|
+
page: number;
|
|
16
|
+
icons: string;
|
|
17
|
+
scheme: string;
|
|
18
|
+
/** Measured type levels (extract); otherwise generated from base. */
|
|
19
|
+
levels?: Record<string, Level>;
|
|
20
|
+
/** Measured colours (extract) to use instead of generated ones, by role. */
|
|
21
|
+
colorOverrides?: Partial<Record<Mode, Partial<Palette>>>;
|
|
22
|
+
}
|
|
23
|
+
export interface Level {
|
|
24
|
+
fontFamily: string;
|
|
25
|
+
fontSize: string;
|
|
26
|
+
fontWeight: number;
|
|
27
|
+
lineHeight: string;
|
|
28
|
+
letterSpacing: string;
|
|
29
|
+
fontFeature?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare const ROLES: readonly ['background', 'surface', 'raised', 'border', 'text', 'text-muted', 'text-faint', 'primary', 'primary-pressed', 'on-primary', 'primary-wash', 'success', 'warning', 'danger'];
|
|
32
|
+
export type Palette = Record<(typeof ROLES)[number], string>;
|
|
33
|
+
export declare const contrast: (a: string, b: string) => number;
|
|
34
|
+
export declare function palette(c: Choices, mode: Mode): Palette;
|
|
35
|
+
/** Tracking that follows size (fonts without optical sizes): tight titles, zero for reading sizes, open small text. */
|
|
36
|
+
export declare const tracking: (size: number, caps?: boolean) => -0.04 | -0.03 | -0.02 | 0 | 0.01 | 0.02 | 0.06;
|
|
37
|
+
export declare function typeScale(c: Choices): Record<string, Level>;
|
|
38
|
+
export declare function radii(r: number): Record<string, string>;
|
|
39
|
+
export declare function buildDesignMd(c: Choices): string;
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a complete DESIGN.md (Google's DESIGN.md format) from a few choices or from measurements: colours from
|
|
3
|
+
* one primary colour with Material 3 tonal palettes (text and status colours adjusted until they reach 4.5:1),
|
|
4
|
+
* a 15-level type scale with tracking that follows size, radius and spacing scales, component tokens with
|
|
5
|
+
* states, and prose sections that state the rules with the real values. Used by `stitch2 init` and
|
|
6
|
+
* `stitch2 extract`.
|
|
7
|
+
*/
|
|
8
|
+
import { argbFromHex, Contrast, Hct, hexFromArgb, SchemeContent, SchemeExpressive, SchemeFidelity, SchemeMonochrome, SchemeNeutral, SchemeTonalSpot, SchemeVibrant, } from '@material/material-color-utilities';
|
|
9
|
+
export const ROLES = [
|
|
10
|
+
'background',
|
|
11
|
+
'surface',
|
|
12
|
+
'raised',
|
|
13
|
+
'border',
|
|
14
|
+
'text',
|
|
15
|
+
'text-muted',
|
|
16
|
+
'text-faint',
|
|
17
|
+
'primary',
|
|
18
|
+
'primary-pressed',
|
|
19
|
+
'on-primary',
|
|
20
|
+
'primary-wash',
|
|
21
|
+
'success',
|
|
22
|
+
'warning',
|
|
23
|
+
'danger',
|
|
24
|
+
];
|
|
25
|
+
const SCHEMES = {
|
|
26
|
+
tonal_spot: SchemeTonalSpot,
|
|
27
|
+
neutral: SchemeNeutral,
|
|
28
|
+
fidelity: SchemeFidelity,
|
|
29
|
+
vibrant: SchemeVibrant,
|
|
30
|
+
expressive: SchemeExpressive,
|
|
31
|
+
content: SchemeContent,
|
|
32
|
+
monochrome: SchemeMonochrome,
|
|
33
|
+
};
|
|
34
|
+
const tone = (hex) => Hct.fromInt(argbFromHex(hex)).tone;
|
|
35
|
+
export const contrast = (a, b) => Contrast.ratioOfTones(tone(a), tone(b));
|
|
36
|
+
const withTone = (hex, t) => {
|
|
37
|
+
const h = Hct.fromInt(argbFromHex(hex));
|
|
38
|
+
return hexFromArgb(Hct.from(h.hue, h.chroma, Math.max(0, Math.min(100, t))).toInt());
|
|
39
|
+
};
|
|
40
|
+
/** Moves a colour's tone away from the background until it reaches the ratio (or the end of the range). */
|
|
41
|
+
function readable(hex, against, ratio, dark) {
|
|
42
|
+
let t = tone(hex);
|
|
43
|
+
let out = hex;
|
|
44
|
+
while (against.some((b) => contrast(out, b) < ratio) && t >= 0 && t <= 100) {
|
|
45
|
+
t += dark ? 1 : -1;
|
|
46
|
+
out = withTone(hex, t);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
export function palette(c, mode) {
|
|
51
|
+
const dark = mode === 'dark';
|
|
52
|
+
const Scheme = SCHEMES[c.scheme] ?? SchemeTonalSpot;
|
|
53
|
+
const scheme = new Scheme(Hct.fromInt(argbFromHex(c.primary)), dark, 0);
|
|
54
|
+
const n = (t) => hexFromArgb(scheme.neutralPalette.tone(t));
|
|
55
|
+
const nv = (t) => hexFromArgb(scheme.neutralVariantPalette.tone(t));
|
|
56
|
+
const surfaces = dark
|
|
57
|
+
? { background: n(6), surface: n(10), raised: n(17), border: nv(25), text: n(95) }
|
|
58
|
+
: { background: n(98), surface: n(100), raised: n(94), border: nv(87), text: n(10) };
|
|
59
|
+
const on = [surfaces.surface, surfaces.raised];
|
|
60
|
+
// The accent is text too (active tabs, links): 4.5:1 on surfaces. Its label (on-primary) needs 4.5:1 on it:
|
|
61
|
+
// when neither near-black nor white gets there, move the accent's tone further the same way.
|
|
62
|
+
let primary = readable(c.primary, on, 4.5, dark);
|
|
63
|
+
const ink = n(6);
|
|
64
|
+
const white = '#ffffff';
|
|
65
|
+
const label = (p) => (contrast(p, ink) >= contrast(p, white) ? ink : white);
|
|
66
|
+
for (let t = tone(primary); contrast(label(primary), primary) < 4.5 && t > 0 && t < 100;) {
|
|
67
|
+
t += label(primary) === ink ? 1 : -1;
|
|
68
|
+
primary = withTone(primary, t);
|
|
69
|
+
}
|
|
70
|
+
const onPrimary = label(primary);
|
|
71
|
+
const status = (hue) => {
|
|
72
|
+
const base = hexFromArgb(Hct.from(hue, 60, dark ? 70 : 40).toInt());
|
|
73
|
+
return readable(base, on, 4.5, dark);
|
|
74
|
+
};
|
|
75
|
+
const rgb = Hct.fromInt(argbFromHex(primary)).toInt();
|
|
76
|
+
const wash = `rgba(${(rgb >> 16) & 255}, ${(rgb >> 8) & 255}, ${rgb & 255}, ${dark ? 0.14 : 0.12})`;
|
|
77
|
+
const p = {
|
|
78
|
+
...surfaces,
|
|
79
|
+
'text-muted': readable(nv(dark ? 75 : 40), on, 4.5, dark),
|
|
80
|
+
'text-faint': nv(dark ? 55 : 60),
|
|
81
|
+
primary,
|
|
82
|
+
'primary-pressed': withTone(primary, tone(primary) - 8),
|
|
83
|
+
'on-primary': onPrimary,
|
|
84
|
+
'primary-wash': wash,
|
|
85
|
+
success: status(150),
|
|
86
|
+
warning: status(80),
|
|
87
|
+
danger: status(25),
|
|
88
|
+
};
|
|
89
|
+
return { ...p, ...(c.colorOverrides?.[mode] ?? {}) };
|
|
90
|
+
}
|
|
91
|
+
const em = (v) => (v ? `${v}em` : '0em');
|
|
92
|
+
/** Tracking that follows size (fonts without optical sizes): tight titles, zero for reading sizes, open small text. */
|
|
93
|
+
export const tracking = (size, caps = false) => caps
|
|
94
|
+
? 0.06
|
|
95
|
+
: size >= 40
|
|
96
|
+
? -0.04
|
|
97
|
+
: size >= 26
|
|
98
|
+
? -0.03
|
|
99
|
+
: size >= 18
|
|
100
|
+
? -0.02
|
|
101
|
+
: size <= 11
|
|
102
|
+
? 0.02
|
|
103
|
+
: size <= 12
|
|
104
|
+
? 0.01
|
|
105
|
+
: 0;
|
|
106
|
+
export function typeScale(c) {
|
|
107
|
+
if (c.levels)
|
|
108
|
+
return c.levels;
|
|
109
|
+
const k = c.base / 15;
|
|
110
|
+
const s = (px) => Math.round(px * k);
|
|
111
|
+
const lh = (px) => Math.round(px / 2) * 2;
|
|
112
|
+
const level = (size, weight, line, opts = {}) => ({
|
|
113
|
+
fontFamily: opts.mono && c.mono ? c.mono : c.sans,
|
|
114
|
+
fontSize: `${size}px`,
|
|
115
|
+
fontWeight: weight,
|
|
116
|
+
lineHeight: `${line}px`,
|
|
117
|
+
letterSpacing: em(tracking(size, opts.caps)),
|
|
118
|
+
...(opts.tnum || opts.mono ? { fontFeature: '"tnum" 1' } : {}),
|
|
119
|
+
});
|
|
120
|
+
const b = c.base;
|
|
121
|
+
return {
|
|
122
|
+
'display-number': level(s(48), 600, lh(s(52)), { tnum: true }),
|
|
123
|
+
'display-stat': level(s(40), 600, lh(s(44)), { tnum: true }),
|
|
124
|
+
'title-screen': level(s(28), 600, lh(s(34))),
|
|
125
|
+
'title-sheet': level(s(22), 600, lh(s(28))),
|
|
126
|
+
'number-lg': level(s(22), 500, lh(s(28)), { tnum: true }),
|
|
127
|
+
'title-card': level(s(18), 600, lh(s(24))),
|
|
128
|
+
body: level(b, 400, lh(b * 1.47)),
|
|
129
|
+
'body-strong': level(b, 500, lh(b + 5)),
|
|
130
|
+
'label-button': level(b, 600, lh(b + 5)),
|
|
131
|
+
meta: level(s(13), 400, lh(s(18))),
|
|
132
|
+
'meta-number': level(s(13), 400, lh(s(18)), { mono: true, tnum: true }),
|
|
133
|
+
'label-chip': level(s(13), 500, lh(s(16))),
|
|
134
|
+
'label-caps': level(s(12), 600, lh(s(16)), { caps: true }),
|
|
135
|
+
caption: level(s(12), 400, lh(s(16))),
|
|
136
|
+
'label-tab': level(s(11), 500, lh(s(14))),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
export function radii(r) {
|
|
140
|
+
const px = (v) => `${Math.round(v)}px`;
|
|
141
|
+
if (r >= 999)
|
|
142
|
+
return {
|
|
143
|
+
sm: '8px',
|
|
144
|
+
input: '9999px',
|
|
145
|
+
btn: '9999px',
|
|
146
|
+
row: '16px',
|
|
147
|
+
card: '20px',
|
|
148
|
+
sheet: '24px',
|
|
149
|
+
full: '9999px',
|
|
150
|
+
};
|
|
151
|
+
return {
|
|
152
|
+
sm: px(r * 0.5),
|
|
153
|
+
input: px(r * 0.75),
|
|
154
|
+
btn: px(r * 0.875),
|
|
155
|
+
row: px(r),
|
|
156
|
+
card: px(r * 1.25),
|
|
157
|
+
sheet: px(r * 1.5),
|
|
158
|
+
full: '9999px',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const yamlString = (v) => (/^[\w#.-]+$/.test(v) && !/^\d/.test(v) ? v : JSON.stringify(v));
|
|
162
|
+
export function buildDesignMd(c) {
|
|
163
|
+
const pal = Object.fromEntries(c.modes.map((m) => [m, palette(c, m)]));
|
|
164
|
+
const main = c.modes[0];
|
|
165
|
+
const levels = typeScale(c);
|
|
166
|
+
const rounded = radii(c.roundness);
|
|
167
|
+
const spacing = {
|
|
168
|
+
xs: '4px',
|
|
169
|
+
sm: '8px',
|
|
170
|
+
md: '12px',
|
|
171
|
+
lg: '16px',
|
|
172
|
+
xl: '24px',
|
|
173
|
+
'2xl': '32px',
|
|
174
|
+
'3xl': '48px',
|
|
175
|
+
page: `${c.page}px`,
|
|
176
|
+
};
|
|
177
|
+
const colors = { ...pal[main] };
|
|
178
|
+
for (const m of c.modes.slice(1))
|
|
179
|
+
for (const [k, v] of Object.entries(pal[m]))
|
|
180
|
+
colors[`${m}-${k}`] = v;
|
|
181
|
+
const ref = (g, k) => `"{${g}.${k}}"`;
|
|
182
|
+
const components = {
|
|
183
|
+
'button-primary': {
|
|
184
|
+
backgroundColor: ref('colors', 'primary'),
|
|
185
|
+
textColor: ref('colors', 'on-primary'),
|
|
186
|
+
typography: ref('typography', 'label-button'),
|
|
187
|
+
rounded: ref('rounded', 'btn'),
|
|
188
|
+
height: '48px',
|
|
189
|
+
padding: '20px',
|
|
190
|
+
},
|
|
191
|
+
'button-primary-pressed': { backgroundColor: ref('colors', 'primary-pressed') },
|
|
192
|
+
'button-primary-disabled': {
|
|
193
|
+
backgroundColor: ref('colors', 'raised'),
|
|
194
|
+
textColor: ref('colors', 'text-faint'),
|
|
195
|
+
},
|
|
196
|
+
'button-secondary': {
|
|
197
|
+
backgroundColor: ref('colors', 'raised'),
|
|
198
|
+
textColor: ref('colors', 'text'),
|
|
199
|
+
typography: ref('typography', 'label-button'),
|
|
200
|
+
rounded: ref('rounded', 'btn'),
|
|
201
|
+
height: '48px',
|
|
202
|
+
padding: '20px',
|
|
203
|
+
},
|
|
204
|
+
'button-secondary-pressed': { backgroundColor: ref('colors', 'border') },
|
|
205
|
+
chip: {
|
|
206
|
+
backgroundColor: ref('colors', 'raised'),
|
|
207
|
+
textColor: ref('colors', 'text-muted'),
|
|
208
|
+
typography: ref('typography', 'label-chip'),
|
|
209
|
+
rounded: ref('rounded', 'full'),
|
|
210
|
+
height: '32px',
|
|
211
|
+
padding: '12px',
|
|
212
|
+
},
|
|
213
|
+
'chip-selected': { backgroundColor: ref('colors', 'primary-wash'), textColor: ref('colors', 'primary') },
|
|
214
|
+
segment: {
|
|
215
|
+
backgroundColor: ref('colors', 'background'),
|
|
216
|
+
textColor: ref('colors', 'text-muted'),
|
|
217
|
+
typography: ref('typography', 'label-chip'),
|
|
218
|
+
rounded: ref('rounded', 'sm'),
|
|
219
|
+
height: '36px',
|
|
220
|
+
},
|
|
221
|
+
'segment-selected': { backgroundColor: ref('colors', 'raised'), textColor: ref('colors', 'text') },
|
|
222
|
+
'tab-item': { textColor: ref('colors', 'text-muted'), typography: ref('typography', 'label-tab') },
|
|
223
|
+
'tab-item-active': { textColor: ref('colors', 'primary') },
|
|
224
|
+
input: {
|
|
225
|
+
backgroundColor: ref('colors', 'raised'),
|
|
226
|
+
textColor: ref('colors', 'text'),
|
|
227
|
+
typography: ref('typography', 'body'),
|
|
228
|
+
rounded: ref('rounded', 'input'),
|
|
229
|
+
height: '48px',
|
|
230
|
+
padding: '14px',
|
|
231
|
+
},
|
|
232
|
+
card: {
|
|
233
|
+
backgroundColor: ref('colors', 'surface'),
|
|
234
|
+
textColor: ref('colors', 'text'),
|
|
235
|
+
rounded: ref('rounded', 'card'),
|
|
236
|
+
padding: '16px',
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
const y = [
|
|
240
|
+
'---',
|
|
241
|
+
'version: alpha',
|
|
242
|
+
`name: ${yamlString(c.name)}`,
|
|
243
|
+
`description: ${JSON.stringify(c.mood)}`,
|
|
244
|
+
'colors:',
|
|
245
|
+
];
|
|
246
|
+
for (const [k, v] of Object.entries(colors))
|
|
247
|
+
y.push(` ${k}: "${v}"`);
|
|
248
|
+
y.push('typography:');
|
|
249
|
+
for (const [k, l] of Object.entries(levels)) {
|
|
250
|
+
y.push(` ${k}:`, ` fontFamily: ${yamlString(l.fontFamily)}`, ` fontSize: ${l.fontSize}`, ` fontWeight: ${l.fontWeight}`, ` lineHeight: ${l.lineHeight}`, ` letterSpacing: ${l.letterSpacing}`);
|
|
251
|
+
if (l.fontFeature)
|
|
252
|
+
y.push(` fontFeature: '${l.fontFeature}'`);
|
|
253
|
+
}
|
|
254
|
+
y.push('rounded:', ...Object.entries(rounded).map(([k, v]) => ` ${k}: ${v}`));
|
|
255
|
+
y.push('spacing:', ...Object.entries(spacing).map(([k, v]) => ` "${k}": ${v}`));
|
|
256
|
+
y.push('components:');
|
|
257
|
+
for (const [k, props] of Object.entries(components)) {
|
|
258
|
+
y.push(` ${k}:`);
|
|
259
|
+
for (const [p, v] of Object.entries(props))
|
|
260
|
+
y.push(` ${p}: ${v}`);
|
|
261
|
+
}
|
|
262
|
+
y.push('---', '');
|
|
263
|
+
const p = pal[main];
|
|
264
|
+
const ratio = (a, b) => contrast(a, b).toFixed(1);
|
|
265
|
+
const lv = (k) => levels[k];
|
|
266
|
+
const sizeOf = (k) => `${lv(k).fontSize.replace('px', '')}/${lv(k).lineHeight.replace('px', '')} ${lv(k).fontWeight}`;
|
|
267
|
+
const prose = `# Design System: ${c.name}
|
|
268
|
+
|
|
269
|
+
## Overview
|
|
270
|
+
${c.mood} ${c.base <= 14 ? 'Dense' : c.base >= 16 ? 'Spacious' : 'Balanced'} density: one clear job per screen and the next action always obvious. ${c.roundness === 0 ? 'Square, precise corners.' : c.roundness >= 999 ? 'Pill-shaped controls and soft cards.' : c.roundness >= 16 ? 'Rounded but firm shapes.' : 'Gently rounded shapes.'} ${c.modes.length > 1 ? `${main === 'dark' ? 'Dark' : 'Light'} first, with a ${c.modes[1]} theme.` : `${main === 'dark' ? 'Dark' : 'Light'} theme.`}
|
|
271
|
+
|
|
272
|
+
## Colors
|
|
273
|
+
- **Surfaces:** background (${p.background}) for the screen, surface (${p.surface}) for cards and bars, raised (${p.raised}) for inputs, chips and pressed rows, border (${p.border}) for 1px dividers.
|
|
274
|
+
- **Text in three levels:** text (${p.text}, ${ratio(p.text, p.surface)}:1 on surface) for titles, values and anything the user acts on; text-muted (${p['text-muted']}, ${ratio(p['text-muted'], p.raised)}:1 on raised) for secondary lines and labels; text-faint (${p['text-faint']}) only for disabled controls and separators, never for information.
|
|
275
|
+
- **Primary** (${p.primary}): the single accent, for the main action, the active tab, selection and focus rings. At most one primary-filled element per screen region. Text on it is on-primary (${p['on-primary']}, ${ratio(p['on-primary'], p.primary)}:1); pressed is ${p['primary-pressed']}; primary-wash tints selected chips.
|
|
276
|
+
- **Status:** success (${p.success}), warning (${p.warning}), danger (${p.danger}), each at least 4.5:1 on surface and raised. They colour small text, icons and thin bars, never large fills.${c.modes.length > 1 ? `\n- The ${c.modes[1]} theme uses the \`${c.modes[1]}-*\` colours with the same roles.` : ''}
|
|
277
|
+
|
|
278
|
+
## Typography
|
|
279
|
+
${c.sans} everywhere${c.mono ? `, with ${c.mono} only for small standalone numbers (meta-number)` : ''}, at three weights: 400 for reading, 500 for names and values, 600 for titles, buttons and caps labels. The fifteen levels in the front matter are the only text styles; each fixes size, line height, weight and tracking together, so pick a level, never a size.
|
|
280
|
+
|
|
281
|
+
- **Hierarchy comes from weight and colour before size.** Most of a screen is ${sizeOf('meta').split('/')[0]}–${sizeOf('body').split('/')[0]}px; one or two elements per screen are large (the title, and the number the screen is about).
|
|
282
|
+
- **Tracking follows size:** titles tightened (−0.02em at 18–22px, −0.03em at 28px, −0.04em at 40px and up), reading sizes 0, small text opened slightly (caption +0.01em, tab labels +0.02em, caps labels +0.06em).
|
|
283
|
+
- **Numbers** use tabular figures. Large values use ${c.sans} with tabular figures so decimal points stay narrow${c.mono ? `; ${c.mono} is for small metadata that stands on its own, never mid-sentence` : ''}. Units sit next to their value at about half its size in text-muted.
|
|
284
|
+
- **Capitals** only in label-caps section labels, never in titles or buttons, and never for scripts without case (Chinese, Korean, Thai, Hindi, Arabic).
|
|
285
|
+
- **Languages:** no letter-spacing for Arabic, Thai, Hindi, Chinese or Korean; Thai and Hindi need at least 1.5 line height; labels survive German and Russian text about 30% longer than English.
|
|
286
|
+
- Body lines stay under 60 characters. No gradient text, no weights below 400 or above 600.
|
|
287
|
+
|
|
288
|
+
## Layout
|
|
289
|
+
- Mobile first at 390px wide (desktop frames 1280px). ${c.page}px side padding, 24px between sections, 12px between items inside a card, 16px card padding; the spacing scale is 4, 8, 12, 16, 24, 32, 48.
|
|
290
|
+
- Screen structure: a title row (title left, one or two icon buttons right), a vertical stack of sections, and a fixed bottom tab bar with safe-area padding.
|
|
291
|
+
- Desktop (1024px and up): a left sidebar replaces the tab bar; content up to 960px wide.
|
|
292
|
+
- Touch targets at least 44px. Nothing overlaps, nothing scrolls sideways except chip rows.
|
|
293
|
+
|
|
294
|
+
## Elevation & Depth
|
|
295
|
+
Depth comes from tonal layering: background recedes, surface cards step forward, raised controls sit on top, with 1px borders where two surfaces meet. No drop shadows on cards; a sheet or docked bar may cast one soft shadow.
|
|
296
|
+
|
|
297
|
+
## Shapes
|
|
298
|
+
${Object.entries(rounded)
|
|
299
|
+
.filter(([k]) => k !== 'full')
|
|
300
|
+
.map(([k, v]) => `${v} ${k === 'sm' ? 'small controls' : k === 'input' ? 'inputs' : k === 'btn' ? 'buttons' : k === 'row' ? 'list rows' : k === 'card' ? 'cards' : 'sheet tops'}`)
|
|
301
|
+
.join(', ')}, and pills for chips. Never mix sharp and rounded corners in one view.
|
|
302
|
+
|
|
303
|
+
## Components
|
|
304
|
+
- **Buttons:** 48px tall, label-button. Primary: primary fill, on-primary text. Secondary: raised fill, text. Danger: transparent with danger text and a border.
|
|
305
|
+
- **Cards:** surface, card radius, 1px border, no shadow; hairline dividers inside, never cards in cards.
|
|
306
|
+
- **Chips:** 32px pills on raised, text-muted label-chip; a selected filter is primary-wash with primary text.
|
|
307
|
+
- **Segmented controls:** a background track with raised for the selected segment, text-muted turning text when selected.
|
|
308
|
+
- **Bottom tab bar:** surface with a top border, up to five items, each a ${c.icons} icon over a label-tab label; the active item is primary.
|
|
309
|
+
- **Inputs:** label above, raised fill, input radius, text-muted placeholder, a 2px primary focus ring.
|
|
310
|
+
- **Sheets:** bottom sheets with a grab handle and sheet-radius top corners.
|
|
311
|
+
- **Empty states:** one sentence of guidance and one action.
|
|
312
|
+
- Icons: ${c.icons}, one set everywhere, the same glyph for the same meaning on every screen.
|
|
313
|
+
|
|
314
|
+
### States (every interactive element shows them)
|
|
315
|
+
- **Pressed:** moves down 1px and darkens one step (primary → primary-pressed, raised → border); circular buttons scale to 0.95.
|
|
316
|
+
- **Hover (pointer devices only):** text-muted turns text; surfaces lighten one step.
|
|
317
|
+
- **Focus (keyboard):** 2px primary ring with a 2px offset.
|
|
318
|
+
- **Selected:** chips primary-wash with primary text; segments raised with text; the active tab primary.
|
|
319
|
+
- **Disabled:** text-faint on raised, no pressed or hover change, the reason shown nearby when not obvious.
|
|
320
|
+
- **Error:** danger border and one meta line in danger under the field, saying how to fix it.
|
|
321
|
+
|
|
322
|
+
## Do's and Don'ts
|
|
323
|
+
- Do build every screen from the shared components; adapt the content or extend the component rather than build a near-copy.
|
|
324
|
+
- Do use primary only for the single most important action in each region.
|
|
325
|
+
- Do pick text styles from the type levels; don't invent sizes between them.
|
|
326
|
+
- Don't use more than one accent colour, gradients behind text, glows or glassmorphism.
|
|
327
|
+
- Don't use placeholder names (John Doe, Acme) or fake round numbers in mock-ups.
|
|
328
|
+
- Don't use circular spinners: show skeletons the size of the content.
|
|
329
|
+
|
|
330
|
+
## Layout Guardrails
|
|
331
|
+
- Fixed bars never cover content: the scroll ends with enough bottom padding for the tab bar.
|
|
332
|
+
- Titles and metadata stay on one line; chips, segments and buttons never wrap their labels.
|
|
333
|
+
- Every element fits the frame: no clipping, no overlap, no horizontal page scroll (chip rows may scroll).
|
|
334
|
+
- Mock-ups show only what the app shows: no phone status bar, no step labels, no tip or promo cards, no helper text or features the screen's description does not ask for.
|
|
335
|
+
`;
|
|
336
|
+
return `${y.join('\n')}${prose}`;
|
|
337
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|