stitch2 0.18.1 → 0.19.1
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/README.md +9 -3
- package/config.schema.json +5 -0
- package/dist/audit.d.ts +6 -0
- package/dist/audit.js +97 -0
- package/dist/build-design.js +10 -0
- package/dist/canvas.html +27 -8
- package/dist/check.js +44 -13
- package/dist/cli.js +20 -1
- package/dist/config.d.ts +13 -0
- package/dist/config.js +13 -0
- package/dist/design-md.d.ts +4 -0
- package/dist/design-md.js +10 -1
- package/dist/exports.js +27 -1
- package/dist/serve.d.ts +1 -0
- package/dist/serve.js +8 -2
- package/dist/shots.js +1 -1
- package/dist/states.d.ts +28 -0
- package/dist/states.js +203 -0
- package/dist/tokens.js +5 -0
- package/package.json +1 -1
- package/skills/stitch2/SKILL.md +5 -0
- package/skills/stitch2/references/components.md +21 -0
- package/skills/stitch2/references/motion.md +48 -0
- package/skills/stitch2/references/screens.md +12 -0
- package/skills/stitch2-design-md/SKILL.md +7 -2
package/README.md
CHANGED
|
@@ -48,18 +48,24 @@ file that already exists. From there the agent spends its effort on the design.
|
|
|
48
48
|
- **Checks** (`stitch2 check`): content past the frame edge or out of its card, navigation whose links scroll or are cut (in any language), labels that wrap, text running into
|
|
49
49
|
other text, labels cut short with an ellipsis, content under fixed bars, fixed bars that overlap each other, tap
|
|
50
50
|
targets (WCAG 2.2), all of it again at both ends of the window widths each layout covers (360 and 599 px for a
|
|
51
|
-
phone, 1024 and 1920 for desktop, or your own ranges)
|
|
51
|
+
phone, 1024 and 1920 for desktop, or your own ranges) and at every breakpoint inside them (Tailwind's sm, md,
|
|
52
|
+
lg, xl, 2xl, or your own), a phone column left in a tablet-wide window, text contrast in every theme, text that is not one of DESIGN.md's type levels, capitals without tracking,
|
|
52
53
|
large numbers in monospace, components built by hand, components that differ between screens, and colours
|
|
53
54
|
written as values instead of DESIGN.md tokens. Google's guidelines (Material 3, Android's app quality
|
|
54
55
|
guidelines, web.dev, Lighthouse) add warnings for text under 11 px, text closer than 16 px (24 px from 600 px)
|
|
55
56
|
to the edge, lines over 75 characters, targets under 48 px without room around them, placeholder-only labels,
|
|
56
57
|
icon-only controls without a name, more than five bottom-navigation destinations and controls without visible
|
|
57
|
-
focus.
|
|
58
|
+
focus. Motion: transitions whose length isn't one of DESIGN.md's motion tokens, and anything that still moves
|
|
59
|
+
with reduced motion on. Each screen is also run in long and right-to-left
|
|
58
60
|
pseudo-languages and your locales (see below).
|
|
61
|
+
- **States** (from `stitch2 check` on the component catalog): every control at rest, hovered, focused and
|
|
62
|
+
pressed, forced through Chromium so the components need nothing extra, side by side on one sheet; with
|
|
63
|
+
warnings for a control that gives no feedback and text that loses contrast in a state.
|
|
59
64
|
- **Consistency report** (`stitch2 consistency`): every shared component (tab bar, header, buttons, chips, …)
|
|
60
65
|
cropped from every screen, next to the catalog's version, with what differs (sizes, fonts, icon set, icons).
|
|
61
66
|
- **Type audit** (`stitch2 type`): every text style a screen uses, and the level it matches.
|
|
62
|
-
- **Tokens** (`stitch2 tokens`): CSS variables,
|
|
67
|
+
- **Tokens** (`stitch2 tokens`): CSS variables, motion tokens (durations and easings, a stitch2 extension of
|
|
68
|
+
DESIGN.md), one `.type-<level>` class per type level (with per-language
|
|
63
69
|
rules for scripts without capitals) and a Tailwind config, from DESIGN.md's front matter, plus Material 3
|
|
64
70
|
colour roles from a seed colour. **Exports** write the same tokens for your app, so DESIGN.md drives the
|
|
65
71
|
product as well as the screens: plain CSS variables, a Tailwind v4 theme (`@theme` plus `@utility type-*`), or
|
package/config.schema.json
CHANGED
|
@@ -67,6 +67,11 @@
|
|
|
67
67
|
"description": "The window widths each variant covers, [narrowest, widest] in px, by device (mobile, tablet, desktop). check renders every variant at both ends; the canvas shows them. Defaults: mobile 360–599, tablet 600–1023, desktop 1024–1920.",
|
|
68
68
|
"additionalProperties": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }
|
|
69
69
|
},
|
|
70
|
+
"breakpoints": {
|
|
71
|
+
"type": "object",
|
|
72
|
+
"description": "The window widths where the screens' layouts change (their responsive rules), in px by name. check also renders each variant at every breakpoint inside its range; the canvas can show every screen at one. Defaults to Tailwind's: sm 640, md 768, lg 1024, xl 1280, 2xl 1536.",
|
|
73
|
+
"additionalProperties": { "type": "number" }
|
|
74
|
+
},
|
|
70
75
|
"themes": {
|
|
71
76
|
"type": "array",
|
|
72
77
|
"items": { "type": "string" },
|
package/dist/audit.d.ts
CHANGED
|
@@ -8,4 +8,10 @@ export declare const LAYOUT_ERRORS: string[];
|
|
|
8
8
|
/** Runs inside the page. Plain JS on purpose: Playwright sends its source to the browser. */
|
|
9
9
|
export declare function audit(opts: {
|
|
10
10
|
comfortable: number;
|
|
11
|
+
durations?: number[];
|
|
11
12
|
}): Promise<Issue[]>;
|
|
13
|
+
/**
|
|
14
|
+
* Runs inside the page with prefers-reduced-motion: reduce. What still moves (an animation of transform,
|
|
15
|
+
* translate, scale, rotate or position) ignores the setting; fading and colour changes are fine.
|
|
16
|
+
*/
|
|
17
|
+
export declare function movingAnimations(): Issue[];
|
package/dist/audit.js
CHANGED
|
@@ -591,5 +591,102 @@ export async function audit(opts) {
|
|
|
591
591
|
}
|
|
592
592
|
before?.focus?.({ preventScroll: true });
|
|
593
593
|
still.remove();
|
|
594
|
+
// 7. Motion uses DESIGN.md's durations (its motion: duration-* tokens): a transition of any other length is a
|
|
595
|
+
// value someone typed, and screens drift apart in how they move.
|
|
596
|
+
if (opts.durations?.length) {
|
|
597
|
+
const named = opts.durations.map((d) => Math.round(d));
|
|
598
|
+
for (const el of all) {
|
|
599
|
+
const cs = getComputedStyle(el);
|
|
600
|
+
if (cs.transitionProperty === 'none')
|
|
601
|
+
continue;
|
|
602
|
+
const odd = cs.transitionDuration
|
|
603
|
+
.split(',')
|
|
604
|
+
.map((v) => (v.trim().endsWith('ms') ? parseFloat(v) : parseFloat(v) * 1000))
|
|
605
|
+
.find((ms) => ms > 0 && !named.includes(Math.round(ms)));
|
|
606
|
+
if (odd !== undefined)
|
|
607
|
+
push('warning', 'motion-token', el, 'Moves for ' + Math.round(odd) + "ms, a duration DESIGN.md's motion doesn't name (" + named.join(', ') + 'ms): use a duration-* token');
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
// 6i. A medium window (600–1199 px: tablets, small laptops, Material's medium and expanded classes) is used: a
|
|
611
|
+
// phone column in the middle of it, with most of the window empty, is a layout that stopped adapting. Only for a
|
|
612
|
+
// page longer than the window (a list or a feed): a sign-in card or an empty state is narrow on purpose. Bars
|
|
613
|
+
// fixed to an edge (a tab bar, a side navigation) don't count, and a side navigation's width is not available.
|
|
614
|
+
const H = innerHeight;
|
|
615
|
+
if (W >= 600 && W < 1200 && document.documentElement.scrollHeight > H * 1.15) {
|
|
616
|
+
const rail = (e) => {
|
|
617
|
+
const cs = getComputedStyle(e);
|
|
618
|
+
if (cs.position !== 'fixed' && cs.position !== 'sticky')
|
|
619
|
+
return false;
|
|
620
|
+
const r = e.getBoundingClientRect();
|
|
621
|
+
return r.left <= 1 || r.right >= W - 1 || r.top <= 1 || r.bottom >= H - 1;
|
|
622
|
+
};
|
|
623
|
+
const rails = new Set();
|
|
624
|
+
let side = 0;
|
|
625
|
+
for (const el of all)
|
|
626
|
+
if (rail(el)) {
|
|
627
|
+
rails.add(el);
|
|
628
|
+
const r = el.getBoundingClientRect();
|
|
629
|
+
if (r.height >= H * 0.5 && r.width < W * 0.4)
|
|
630
|
+
side = Math.max(side, r.width);
|
|
631
|
+
}
|
|
632
|
+
const inRail = (el) => [...rails].some((r) => r.contains(el));
|
|
633
|
+
const room = W - side;
|
|
634
|
+
let left = W, right = 0;
|
|
635
|
+
for (const el of all) {
|
|
636
|
+
if (el === document.body || el === document.documentElement || inRail(el))
|
|
637
|
+
continue;
|
|
638
|
+
const cs = getComputedStyle(el);
|
|
639
|
+
const box = (cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') || parseFloat(cs.borderTopWidth) > 0;
|
|
640
|
+
if (!(ownText(el) || box || el.matches('img, svg, input, textarea, select, button, [role="button"]')))
|
|
641
|
+
continue;
|
|
642
|
+
const r = el.getBoundingClientRect();
|
|
643
|
+
if (r.width >= room * 0.95)
|
|
644
|
+
continue;
|
|
645
|
+
left = Math.min(left, r.left);
|
|
646
|
+
right = Math.max(right, r.right);
|
|
647
|
+
}
|
|
648
|
+
const used = right - left;
|
|
649
|
+
if (used > 0 && used < room * 0.6)
|
|
650
|
+
push('warning', 'narrow-layout', document.querySelector('main') ?? document.body, 'The content takes ' + Math.round(used) + ' of ' + Math.round(room) + 'px: a phone column in a wide window. Use the room (more columns, a side column) with the breakpoints, or give these widths to another variant');
|
|
651
|
+
}
|
|
594
652
|
return out.sort((x, y) => (x.severity === y.severity ? 0 : x.severity === 'error' ? -1 : 1));
|
|
595
653
|
}
|
|
654
|
+
/**
|
|
655
|
+
* Runs inside the page with prefers-reduced-motion: reduce. What still moves (an animation of transform,
|
|
656
|
+
* translate, scale, rotate or position) ignores the setting; fading and colour changes are fine.
|
|
657
|
+
*/
|
|
658
|
+
export function movingAnimations() {
|
|
659
|
+
const MOVES = ['transform', 'translate', 'scale', 'rotate', 'left', 'top', 'right', 'bottom', 'marginTop', 'marginLeft', 'offsetDistance'];
|
|
660
|
+
const sel = (el) => {
|
|
661
|
+
const parts = [];
|
|
662
|
+
for (let e = el; e && e !== document.body && parts.length < 6; e = e.parentElement) {
|
|
663
|
+
let p = e.tagName.toLowerCase();
|
|
664
|
+
const same = e.parentElement ? [...e.parentElement.children].filter((c) => c.tagName === e.tagName) : [];
|
|
665
|
+
if (same.length > 1)
|
|
666
|
+
p += ':nth-of-type(' + (same.indexOf(e) + 1) + ')';
|
|
667
|
+
parts.unshift(p);
|
|
668
|
+
}
|
|
669
|
+
return parts.join(' > ');
|
|
670
|
+
};
|
|
671
|
+
const out = [];
|
|
672
|
+
const seen = new Set();
|
|
673
|
+
for (const a of document.getAnimations()) {
|
|
674
|
+
const effect = a.effect;
|
|
675
|
+
const el = effect?.target;
|
|
676
|
+
if (!el || seen.has(el) || a.playState !== 'running')
|
|
677
|
+
continue;
|
|
678
|
+
const moves = effect.getKeyframes().some((k) => MOVES.some((p) => p in k));
|
|
679
|
+
if (!moves)
|
|
680
|
+
continue;
|
|
681
|
+
seen.add(el);
|
|
682
|
+
if (out.length < 10)
|
|
683
|
+
out.push({
|
|
684
|
+
severity: 'warning',
|
|
685
|
+
type: 'reduced-motion',
|
|
686
|
+
selector: sel(el),
|
|
687
|
+
text: (el.textContent ?? '').trim().slice(0, 60),
|
|
688
|
+
message: 'Still moves with reduced motion on: wrap the animation in @media (prefers-reduced-motion: no-preference) (Tailwind motion-safe:), or fade instead',
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
return out;
|
|
692
|
+
}
|
package/dist/build-design.js
CHANGED
|
@@ -253,6 +253,9 @@ export function buildDesignMd(c) {
|
|
|
253
253
|
}
|
|
254
254
|
y.push('rounded:', ...Object.entries(rounded).map(([k, v]) => ` ${k}: ${v}`));
|
|
255
255
|
y.push('spacing:', ...Object.entries(spacing).map(([k, v]) => ` "${k}": ${v}`));
|
|
256
|
+
// stitch2's motion tokens (Material's durations and easings): tokens turns them into CSS variables, ease-*
|
|
257
|
+
// classes, duration-* utilities and Tailwind's default transition.
|
|
258
|
+
y.push('motion:', ' duration-short: 150ms', ' duration-medium: 250ms', ' duration-long: 400ms', ' ease-standard: cubic-bezier(0.2, 0, 0, 1)', ' ease-enter: cubic-bezier(0.05, 0.7, 0.1, 1)', ' ease-exit: cubic-bezier(0.3, 0, 0.8, 0.15)');
|
|
256
259
|
y.push('components:');
|
|
257
260
|
for (const [k, props] of Object.entries(components)) {
|
|
258
261
|
y.push(` ${k}:`);
|
|
@@ -319,6 +322,13 @@ ${Object.entries(rounded)
|
|
|
319
322
|
- **Disabled:** text-faint on raised, no pressed or hover change, the reason shown nearby when not obvious.
|
|
320
323
|
- **Error:** danger border and one meta line in danger under the field, saying how to fix it.
|
|
321
324
|
|
|
325
|
+
## Motion & Interaction
|
|
326
|
+
- Durations and easings are the motion tokens: short (150ms) for a control's own change (a press, a toggle),
|
|
327
|
+
medium (250ms) for something entering or opening (a sheet, a menu), long (400ms) only for a whole screen.
|
|
328
|
+
ease-standard inside the screen, ease-enter for what arrives, ease-exit for what leaves.
|
|
329
|
+
- Animate transform and opacity only; nothing animates on its own except a skeleton's pulse.
|
|
330
|
+
- With reduced motion, nothing moves: fade or switch instead.
|
|
331
|
+
|
|
322
332
|
## Do's and Don'ts
|
|
323
333
|
- Do build every screen from the shared components; adapt the content or extend the component rather than build a near-copy.
|
|
324
334
|
- Do use primary only for the single most important action in each region.
|
package/dist/canvas.html
CHANGED
|
@@ -85,6 +85,8 @@
|
|
|
85
85
|
.frame.desktop .device { border-radius: 12px; }
|
|
86
86
|
.frame.selected .device { outline: 3px solid var(--accent); }
|
|
87
87
|
.frame.archived { opacity: .55; }
|
|
88
|
+
/* At a breakpoint (the width dropdown), a variant whose range doesn't cover it is drawn at its nearest edge, faded. */
|
|
89
|
+
.frame.outside { opacity: .35; }
|
|
88
90
|
/* A frame shows its picture (rendered by the server) until its live page is ready; only frames in view are live. */
|
|
89
91
|
.device iframe { background: #fff; border: 0; display: block; left: 0; position: absolute; top: 0; visibility: hidden; }
|
|
90
92
|
.frame.ready .device iframe { visibility: visible; }
|
|
@@ -139,7 +141,7 @@
|
|
|
139
141
|
<details id="versionFilter" class="multi" hidden title="Show only the versions you pick (several at once), or all of them"><summary>All versions</summary><div class="menu"></div></details>
|
|
140
142
|
<details id="variantFilter" class="multi" hidden title="Show only the variants you pick: mobile, desktop (several at once), or all of them"><summary>All variants</summary><div class="menu"></div></details>
|
|
141
143
|
<select id="themeFilter" class="pick" hidden title="Show every screen in this theme (T)" aria-label="Theme"></select>
|
|
142
|
-
<select id="widthFilter" class="pick" title="Show every screen at the narrowest or widest window its variant covers (W)" aria-label="Width"></select>
|
|
144
|
+
<select id="widthFilter" class="pick" title="Show every screen at the narrowest or widest window its variant covers, or at one breakpoint (W)" aria-label="Width"></select>
|
|
143
145
|
<select id="langFilter" class="pick" title="Show every screen in this language (L)" aria-label="Language"></select>
|
|
144
146
|
</header>
|
|
145
147
|
<nav id="tree"></nav>
|
|
@@ -200,11 +202,22 @@ function setLang(l) {
|
|
|
200
202
|
summary()
|
|
201
203
|
scheduleSync()
|
|
202
204
|
}
|
|
203
|
-
// Widths: each variant covers a range of windows (config widths); frames show it drawn, at the narrowest or the
|
|
205
|
+
// Widths: each variant covers a range of windows (config widths); frames show it drawn, at the narrowest or the
|
|
206
|
+
// widest, or at one breakpoint (config breakpoints): "at:768" puts every variant at 768 px, or the nearest edge of
|
|
207
|
+
// its range when 768 is outside it (then the frame is faded: the app shows another variant there).
|
|
204
208
|
let WIDTHS = {}
|
|
209
|
+
let BREAKPOINTS = {}
|
|
205
210
|
let widthMode = localStorage.getItem('stitch2-width') ?? 'drawn'
|
|
206
211
|
const rangeOf = (s) => WIDTHS[s.device]
|
|
207
|
-
const
|
|
212
|
+
const atWidth = () => (widthMode.startsWith('at:') ? Number(widthMode.slice(3)) : 0)
|
|
213
|
+
const widthFor = (s) => {
|
|
214
|
+
const r = rangeOf(s), at = atWidth()
|
|
215
|
+
if (!r || widthMode === 'drawn') return s.drawn
|
|
216
|
+
if (at) return Math.min(r[1], Math.max(r[0], at))
|
|
217
|
+
return widthMode === 'narrow' ? r[0] : r[1]
|
|
218
|
+
}
|
|
219
|
+
const outside = (s) => { const r = rangeOf(s), at = atWidth(); return !!(r && at && (at < r[0] || at > r[1])) }
|
|
220
|
+
const widthModes = () => ['drawn', 'narrow', 'wide', ...Object.values(BREAKPOINTS).sort((a, b) => a - b).map((w) => `at:${w}`)]
|
|
208
221
|
const bigFrame = (s) => (s.drawn ?? s.width) > 700
|
|
209
222
|
function setWidth(mode) {
|
|
210
223
|
widthMode = mode
|
|
@@ -214,6 +227,7 @@ function setWidth(mode) {
|
|
|
214
227
|
f.screen.width = widthFor(f.screen)
|
|
215
228
|
f.el.style.width = `${f.screen.width}px`
|
|
216
229
|
size(f)
|
|
230
|
+
f.el.classList.toggle('outside', outside(f.screen))
|
|
217
231
|
setTimeout(() => measure(f), 300)
|
|
218
232
|
setTimeout(() => measure(f), 1200)
|
|
219
233
|
}
|
|
@@ -225,7 +239,9 @@ function widthOptions() {
|
|
|
225
239
|
const used = [...new Set(screens.map((s) => s.device))].filter((d) => WIDTHS[d]).sort((a, b) => WIDTHS[a][0] - WIDTHS[b][0])
|
|
226
240
|
const edge = (k) => used.map((d) => `${d} ${WIDTHS[d][k]}`).join(', ')
|
|
227
241
|
const wsel = document.getElementById('widthFilter')
|
|
228
|
-
|
|
242
|
+
const at = Object.entries(BREAKPOINTS).sort((a, b) => a[1] - b[1]).map(([name, w]) => `<option value="at:${w}">At ${w} px (${name})</option>`).join('')
|
|
243
|
+
wsel.innerHTML = `<option value="drawn">Drawn width</option><option value="narrow">Narrowest: ${edge(0)}</option><option value="wide">Widest: ${edge(1)}</option>${at}`
|
|
244
|
+
if (!widthModes().includes(widthMode)) widthMode = 'drawn'
|
|
229
245
|
wsel.value = widthMode
|
|
230
246
|
}
|
|
231
247
|
function applyTheme(f) {
|
|
@@ -277,7 +293,7 @@ const filters = JSON.parse(localStorage.getItem('stitch2-filters') ?? 'null') ??
|
|
|
277
293
|
if (typeof filters.device === 'string') { filters.devices = filters.device === 'all' ? [] : [filters.device]; delete filters.device }
|
|
278
294
|
let screens = []
|
|
279
295
|
let checks = { screens: {} }
|
|
280
|
-
let reports = { consistency: null, compare: null }
|
|
296
|
+
let reports = { consistency: null, compare: null, states: null }
|
|
281
297
|
let selected = null
|
|
282
298
|
const frames = new Map() // path -> { el, device, img, iframe (while live), screen, height }
|
|
283
299
|
// Heights measured before, so the layout doesn't jump while pictures and pages load.
|
|
@@ -432,7 +448,7 @@ function filterLabel() {
|
|
|
432
448
|
var sentFilter = ''
|
|
433
449
|
function sendFilter(force) {
|
|
434
450
|
const doc = document.getElementById('doc')
|
|
435
|
-
if (!doc.classList.contains('open') || !['/consistency/index.html', '/compare/index.html', '/languages'].includes(docUrl)) return
|
|
451
|
+
if (!doc.classList.contains('open') || !['/consistency/index.html', '/compare/index.html', '/languages', '/states/index.html'].includes(docUrl)) return
|
|
436
452
|
const message = { stitch2Filter: { paths: reportPaths(), label: filterLabel(), theme: themed() ? theme : '' } }
|
|
437
453
|
const key = JSON.stringify(message)
|
|
438
454
|
if (!force && key === sentFilter) return
|
|
@@ -485,6 +501,7 @@ function tree() {
|
|
|
485
501
|
: `Made ${new Date(state.at).toLocaleString()}${n ? `. Changed since, run ${command} again:\n${changed.slice(0, 12).join('\n')}${n > 12 ? `\n… and ${n - 12} more` : ''}` : ', up to date'}`
|
|
486
502
|
}
|
|
487
503
|
report('Consistency report', '/consistency/index.html', reports.consistency, 'stitch2 consistency')
|
|
504
|
+
report('States', '/states/index.html', reports.states, 'stitch2 check on the catalog')
|
|
488
505
|
report('App comparison', '/compare/index.html', reports.compare, 'stitch2 compare')
|
|
489
506
|
item('Languages', 'screens × languages', docOpen('/languages'), () => openDoc('/languages'))
|
|
490
507
|
const all = rows().filter((r) => r.key !== '~components' && r.visible.length)
|
|
@@ -695,6 +712,7 @@ function badge(f) {
|
|
|
695
712
|
function paintLabel(f) {
|
|
696
713
|
const s = f.screen
|
|
697
714
|
const kept = ['ready', 'live', 'changed'].filter((c) => f.el.classList.contains(c))
|
|
715
|
+
if (outside(s)) kept.push('outside')
|
|
698
716
|
f.el.className = ['frame', s.device, s.status, selected === f ? 'selected' : '', ...kept].filter(Boolean).join(' ')
|
|
699
717
|
f.el.querySelector('.name').textContent = s.kind === 'components' ? 'Catalog' : s.device
|
|
700
718
|
const pill = f.el.querySelector('.pill')
|
|
@@ -818,7 +836,7 @@ function inspector(f) {
|
|
|
818
836
|
li.querySelector('code').textContent = i.selector
|
|
819
837
|
li.onclick = () => {
|
|
820
838
|
const r = rangeOf(s)
|
|
821
|
-
if (i.width && r && i.width !== s.width) { setWidth(i.width === r[0] ? 'narrow' : 'wide'); setTimeout(() => locate(f, i.selector), 900) }
|
|
839
|
+
if (i.width && r && i.width !== s.width) { setWidth(i.width === r[0] ? 'narrow' : i.width === r[1] ? 'wide' : `at:${i.width}`); setTimeout(() => locate(f, i.selector), 900) }
|
|
822
840
|
else locate(f, i.selector)
|
|
823
841
|
}
|
|
824
842
|
ul.append(li)
|
|
@@ -1113,7 +1131,7 @@ addEventListener('keydown', (e) => {
|
|
|
1113
1131
|
if (e.key === 'f' || e.key === 'F') fit()
|
|
1114
1132
|
if ((e.key === 't' || e.key === 'T') && themed()) setTheme(THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length])
|
|
1115
1133
|
if (e.key === 'l' || e.key === 'L') setLang(LANGS[(LANGS.indexOf(lang) + 1) % LANGS.length])
|
|
1116
|
-
if (e.key === 'w' || e.key === 'W') setWidth([
|
|
1134
|
+
if (e.key === 'w' || e.key === 'W') { const m = widthModes(); setWidth(m[(m.indexOf(widthMode) + 1) % m.length]) }
|
|
1117
1135
|
if (e.key === '0') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, 1)
|
|
1118
1136
|
if (e.key === '+' || e.key === '=') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z * 1.2)
|
|
1119
1137
|
if (e.key === '-') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z / 1.2)
|
|
@@ -1151,6 +1169,7 @@ fetch('/api/config').then((r) => r.json()).then((c) => {
|
|
|
1151
1169
|
pick.onchange = () => setTheme(pick.value)
|
|
1152
1170
|
setTheme(theme)
|
|
1153
1171
|
WIDTHS = c.widths ?? {}
|
|
1172
|
+
BREAKPOINTS = c.breakpoints ?? {}
|
|
1154
1173
|
const wsel = document.getElementById('widthFilter')
|
|
1155
1174
|
wsel.onchange = () => setWidth(wsel.value)
|
|
1156
1175
|
SOURCE = c.sourceLocale ?? 'en'
|
package/dist/check.js
CHANGED
|
@@ -8,23 +8,25 @@
|
|
|
8
8
|
* Then again in each check language (i18n.ts: the pseudo-languages and the config's): layout problems that
|
|
9
9
|
* show only there (a label running into its neighbour, text cut off, a control drawn backwards right to left),
|
|
10
10
|
* and keys a locale has no translation for, tagged with the locale. Last, each variant at both ends of the widths
|
|
11
|
-
* it covers (config widths: mobile 360–599, desktop 1024–1920 by default)
|
|
11
|
+
* it covers (config widths: mobile 360–599, desktop 1024–1920 by default) and at every breakpoint inside them
|
|
12
|
+
* (config breakpoints, Tailwind's by default), tagged with the width.
|
|
12
13
|
* Usage: stitch2 check [path filter…] [--shots] [--strict] [--locales ru,ar | all | none]
|
|
13
|
-
* --shots also save page-tall renders to design/renders/ (one per theme, language and width
|
|
14
|
+
* --shots also save page-tall renders to design/renders/ (one per theme, language and width)
|
|
14
15
|
* --strict exit 1 when any screen has errors (for CI and agent loops)
|
|
15
16
|
*/
|
|
16
17
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
17
18
|
import { join } from 'node:path';
|
|
18
19
|
import { chromium } from '@playwright/test';
|
|
19
20
|
import { colourIssues } from './colours.js';
|
|
20
|
-
import {
|
|
21
|
+
import { checkWidths, config } from './config.js';
|
|
21
22
|
import { collectComponents, collectOptions, consistencyIssues, reuseIssues, } from './consistency.js';
|
|
22
|
-
import { designThemes, typeLevels } from './design-md.js';
|
|
23
|
+
import { designThemes, motionDurations, typeLevels } from './design-md.js';
|
|
23
24
|
import { checkLocales } from './i18n.js';
|
|
24
25
|
import { listScreens, ROOT } from './screens.js';
|
|
25
26
|
import { startServer } from './serve.js';
|
|
27
|
+
import { catalogStates, writeStatesSheet } from './states.js';
|
|
26
28
|
import { collectText, typeIssues } from './typecheck.js';
|
|
27
|
-
import { audit } from './audit.js';
|
|
29
|
+
import { audit, movingAnimations } from './audit.js';
|
|
28
30
|
const args = process.argv.slice(2);
|
|
29
31
|
const shots = args.includes('--shots');
|
|
30
32
|
const strict = args.includes('--strict');
|
|
@@ -35,6 +37,13 @@ const screens = listScreens().filter((s) => !filters.length || filters.some((f)
|
|
|
35
37
|
const levels = typeLevels();
|
|
36
38
|
const themes = designThemes();
|
|
37
39
|
const collect = collectOptions();
|
|
40
|
+
/** What the layout audit needs: the comfortable tap size and DESIGN.md's motion durations. */
|
|
41
|
+
const auditOpts = { comfortable: config.comfortableTarget, durations: (() => { try {
|
|
42
|
+
return motionDurations();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
} })() };
|
|
38
47
|
const server = await startServer(0);
|
|
39
48
|
/** The tallest render, in CSS px: a long landing page is shot whole (the canvas draws frames up to the same height). */
|
|
40
49
|
const MAX_RENDER_HEIGHT = 20000;
|
|
@@ -42,6 +51,7 @@ const browser = await chromium.launch();
|
|
|
42
51
|
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2 });
|
|
43
52
|
const results = {};
|
|
44
53
|
const components = {};
|
|
54
|
+
const stateRows = {};
|
|
45
55
|
for (const s of screens) {
|
|
46
56
|
await page.setViewportSize({ width: s.width, height: 844 });
|
|
47
57
|
await page
|
|
@@ -53,7 +63,12 @@ for (const s of screens) {
|
|
|
53
63
|
components[s.path] = found.components;
|
|
54
64
|
const own = await page.evaluate(() => document.documentElement.dataset.theme || '');
|
|
55
65
|
const base = own || themes[0];
|
|
56
|
-
const layout = await page.evaluate(audit,
|
|
66
|
+
const layout = await page.evaluate(audit, auditOpts);
|
|
67
|
+
// With reduced motion asked for, nothing keeps moving (DESIGN.md motion, WCAG 2.3.3).
|
|
68
|
+
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
69
|
+
await page.waitForTimeout(100);
|
|
70
|
+
layout.push(...(await page.evaluate(movingAnimations)));
|
|
71
|
+
await page.emulateMedia({ reducedMotion: null });
|
|
57
72
|
for (const i of layout)
|
|
58
73
|
if (i.type === 'contrast')
|
|
59
74
|
i.theme = base;
|
|
@@ -62,7 +77,7 @@ for (const s of screens) {
|
|
|
62
77
|
const themed = [];
|
|
63
78
|
for (const theme of themes.filter((t) => t !== base)) {
|
|
64
79
|
await setTheme(theme);
|
|
65
|
-
for (const i of await page.evaluate(audit,
|
|
80
|
+
for (const i of await page.evaluate(audit, auditOpts))
|
|
66
81
|
if (i.type === 'contrast' || !seen.has(`${i.type} ${i.selector}`))
|
|
67
82
|
themed.push({ ...i, theme, message: `${theme} theme: ${i.message}` });
|
|
68
83
|
if (shots)
|
|
@@ -73,6 +88,19 @@ for (const s of screens) {
|
|
|
73
88
|
const typed = typeIssues(levels, await page.evaluate(collectText));
|
|
74
89
|
if (shots)
|
|
75
90
|
await shoot(s);
|
|
91
|
+
// A catalog: every control at rest, hovered, focused and pressed (states.ts), then its contrast in each theme.
|
|
92
|
+
const states = [];
|
|
93
|
+
if (s.kind === 'components') {
|
|
94
|
+
const own = await catalogStates(page, s.path, { crop: true });
|
|
95
|
+
states.push(...own.issues);
|
|
96
|
+
stateRows[s.path] = own.rows;
|
|
97
|
+
for (const theme of themes.filter((t) => t !== base)) {
|
|
98
|
+
await setTheme(theme);
|
|
99
|
+
states.push(...(await catalogStates(page, s.path, { crop: false, theme })).issues.filter((i) => i.type === 'state-contrast'));
|
|
100
|
+
}
|
|
101
|
+
if (themes.length > 1)
|
|
102
|
+
await setTheme(base);
|
|
103
|
+
}
|
|
76
104
|
// The other languages, in the screen's own theme. Reported once per element and kind, in the first locale.
|
|
77
105
|
const unknownSeen = new Set();
|
|
78
106
|
for (const locale of locales) {
|
|
@@ -80,7 +108,7 @@ for (const s of screens) {
|
|
|
80
108
|
.goto(`http://127.0.0.1:${server.port}/${s.path}?lang=${encodeURIComponent(locale)}`, { waitUntil: 'networkidle', timeout: 30_000 })
|
|
81
109
|
.catch(() => { });
|
|
82
110
|
await page.waitForTimeout(600);
|
|
83
|
-
for (const i of await page.evaluate(audit,
|
|
111
|
+
for (const i of await page.evaluate(audit, auditOpts)) {
|
|
84
112
|
const key = `${i.type} ${i.selector}`;
|
|
85
113
|
if (i.type === 'contrast' || seen.has(key))
|
|
86
114
|
continue;
|
|
@@ -115,14 +143,13 @@ for (const s of screens) {
|
|
|
115
143
|
if (shots)
|
|
116
144
|
await shoot(s, undefined, locale);
|
|
117
145
|
}
|
|
118
|
-
// The edges of the widths this variant covers (config widths), in its own theme
|
|
119
|
-
// only when the window is narrower or wider than the frame it was drawn in.
|
|
120
|
-
const
|
|
121
|
-
for (const width of range ? [...new Set(range)].filter((w) => w !== s.width) : []) {
|
|
146
|
+
// The edges of the widths this variant covers (config widths) and the breakpoints inside them, in its own theme
|
|
147
|
+
// and language: what breaks only when the window is narrower or wider than the frame it was drawn in.
|
|
148
|
+
for (const width of s.kind === 'screen' ? checkWidths(s.device).filter((w) => w !== s.width) : []) {
|
|
122
149
|
await page.setViewportSize({ width, height: 844 });
|
|
123
150
|
await page.goto(`http://127.0.0.1:${server.port}/${s.path}`, { waitUntil: 'networkidle', timeout: 30_000 }).catch(() => { });
|
|
124
151
|
await page.waitForTimeout(600);
|
|
125
|
-
for (const i of await page.evaluate(audit,
|
|
152
|
+
for (const i of await page.evaluate(audit, auditOpts)) {
|
|
126
153
|
const key = `${i.type} ${i.selector}`;
|
|
127
154
|
if (i.type === 'contrast' || seen.has(key))
|
|
128
155
|
continue;
|
|
@@ -139,9 +166,13 @@ for (const s of screens) {
|
|
|
139
166
|
...typed,
|
|
140
167
|
...reuseIssues(s.path, found),
|
|
141
168
|
...colourIssues(s.path),
|
|
169
|
+
...states,
|
|
142
170
|
];
|
|
143
171
|
}
|
|
144
172
|
await browser.close();
|
|
173
|
+
// The states sheet: the catalogs checked now, and the rows of the others from their last check.
|
|
174
|
+
if (Object.keys(stateRows).length)
|
|
175
|
+
writeStatesSheet(stateRows, listScreens().filter((s) => s.kind === 'components').map((s) => s.path));
|
|
145
176
|
server.close();
|
|
146
177
|
async function setTheme(theme) {
|
|
147
178
|
await page.evaluate((t) => {
|
package/dist/cli.js
CHANGED
|
@@ -62,12 +62,31 @@ if (command === 'lint') {
|
|
|
62
62
|
const { ROOT } = await import('./config.js');
|
|
63
63
|
// The linter's main entry is also its command line (the package only exports for import).
|
|
64
64
|
const file = fileURLToPath(import.meta.resolve('@google/design.md'));
|
|
65
|
+
let out;
|
|
65
66
|
try {
|
|
66
|
-
execFileSync(process.execPath, [file, 'lint', join(ROOT, 'DESIGN.md'), ...rest], { stdio: 'inherit' });
|
|
67
|
+
out = execFileSync(process.execPath, [file, 'lint', join(ROOT, 'DESIGN.md'), ...rest], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] });
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
out = String(e.stdout ?? '');
|
|
71
|
+
}
|
|
72
|
+
// motion is stitch2's extension of the format (duration-* and ease-* tokens): the linter only notes that it
|
|
73
|
+
// doesn't know the key, so that note is left out.
|
|
74
|
+
let report;
|
|
75
|
+
try {
|
|
76
|
+
report = JSON.parse(out);
|
|
67
77
|
}
|
|
68
78
|
catch {
|
|
79
|
+
process.stdout.write(out);
|
|
69
80
|
process.exit(1);
|
|
70
81
|
}
|
|
82
|
+
const ours = (f) => f.path === 'motion' && f.rule === 'token-like-ignored';
|
|
83
|
+
const dropped = (report.findings ?? []).filter(ours);
|
|
84
|
+
report.findings = (report.findings ?? []).filter((f) => !ours(f));
|
|
85
|
+
if (report.summary && dropped.length)
|
|
86
|
+
report.summary.warnings = (report.summary.warnings ?? 0) - dropped.length;
|
|
87
|
+
console.log(JSON.stringify(report, null, 2));
|
|
88
|
+
if ((report.summary?.errors ?? 0) > 0)
|
|
89
|
+
process.exit(1);
|
|
71
90
|
}
|
|
72
91
|
else {
|
|
73
92
|
// Sources run as .ts (Node strips the types); the published build runs as .js.
|
package/dist/config.d.ts
CHANGED
|
@@ -31,6 +31,12 @@ export interface LabConfig {
|
|
|
31
31
|
* follow Google's window size classes: mobile 360–599 (compact), tablet 600–1023 (medium), desktop 1024–1920.
|
|
32
32
|
*/
|
|
33
33
|
widths?: Record<string, [number, number]>;
|
|
34
|
+
/**
|
|
35
|
+
* The window widths where the screens' layouts change (their responsive rules, Tailwind's sm: md: lg: …), in px.
|
|
36
|
+
* check renders each variant at every breakpoint inside its range besides the two ends, and the canvas can show
|
|
37
|
+
* every screen at one of them. Defaults to Tailwind's: sm 640, md 768, lg 1024, xl 1280, 2xl 1536.
|
|
38
|
+
*/
|
|
39
|
+
breakpoints?: Record<string, number>;
|
|
34
40
|
/** Skill folders a blind-trial sandbox copies. */
|
|
35
41
|
skills: string[];
|
|
36
42
|
/** Paths under designDir a sandbox leaves out besides screens (notes that would give the trial away). */
|
|
@@ -78,5 +84,12 @@ export declare const config: LabConfig;
|
|
|
78
84
|
export declare const WIDTH_DEFAULTS: Record<string, [number, number]>;
|
|
79
85
|
export declare const widthRanges: () => Record<string, [number, number]>;
|
|
80
86
|
export declare const widthRange: (device: string) => [number, number] | undefined;
|
|
87
|
+
export declare const BREAKPOINT_DEFAULTS: Record<string, number>;
|
|
88
|
+
export declare const breakpoints: () => Record<string, number>;
|
|
89
|
+
/**
|
|
90
|
+
* The widths check renders a variant at: both ends of its range and every breakpoint inside it, where a responsive
|
|
91
|
+
* rule starts (each one is the narrowest window of its layout, where things are most likely to break).
|
|
92
|
+
*/
|
|
93
|
+
export declare function checkWidths(device: string): number[];
|
|
81
94
|
/** The design folder, with a trailing slash. */
|
|
82
95
|
export declare const ROOT: string;
|
package/dist/config.js
CHANGED
|
@@ -38,5 +38,18 @@ export const config = {
|
|
|
38
38
|
export const WIDTH_DEFAULTS = { mobile: [360, 599], tablet: [600, 1023], desktop: [1024, 1920] };
|
|
39
39
|
export const widthRanges = () => ({ ...WIDTH_DEFAULTS, ...config.widths });
|
|
40
40
|
export const widthRange = (device) => widthRanges()[device];
|
|
41
|
+
export const BREAKPOINT_DEFAULTS = { sm: 640, md: 768, lg: 1024, xl: 1280, '2xl': 1536 };
|
|
42
|
+
export const breakpoints = () => config.breakpoints ?? BREAKPOINT_DEFAULTS;
|
|
43
|
+
/**
|
|
44
|
+
* The widths check renders a variant at: both ends of its range and every breakpoint inside it, where a responsive
|
|
45
|
+
* rule starts (each one is the narrowest window of its layout, where things are most likely to break).
|
|
46
|
+
*/
|
|
47
|
+
export function checkWidths(device) {
|
|
48
|
+
const range = widthRange(device);
|
|
49
|
+
if (!range)
|
|
50
|
+
return [];
|
|
51
|
+
const inside = Object.values(breakpoints()).filter((b) => b > range[0] && b < range[1]);
|
|
52
|
+
return [...new Set([range[0], ...inside, range[1]])].sort((a, b) => a - b);
|
|
53
|
+
}
|
|
41
54
|
/** The design folder, with a trailing slash. */
|
|
42
55
|
export const ROOT = join(resolve(BASE, config.designDir), '/');
|
package/dist/design-md.d.ts
CHANGED
|
@@ -13,8 +13,12 @@ export interface Design {
|
|
|
13
13
|
rounded: Record<string, string>;
|
|
14
14
|
spacing: Record<string, string>;
|
|
15
15
|
components: Record<string, Record<string, string>>;
|
|
16
|
+
/** duration-<name>: "150ms", ease-<name>: "cubic-bezier(0.2, 0, 0, 1)". */
|
|
17
|
+
motion: Record<string, string>;
|
|
16
18
|
}
|
|
17
19
|
export declare function readDesign(file?: string): Design;
|
|
20
|
+
/** The durations DESIGN.md's motion names, in ms (for check: a transition of any other length is a warning). */
|
|
21
|
+
export declare function motionDurations(design?: Design): number[];
|
|
18
22
|
/**
|
|
19
23
|
* The themes screens must work in (their data-theme values): the config's, or DESIGN.md's own, where the plain
|
|
20
24
|
* colours are the dark theme and light-* colours make a light one.
|
package/dist/design-md.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
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.
|
|
3
|
+
* components) and resolves {path.to.token} references. stitch2 adds motion: duration-<name> and ease-<name>
|
|
4
|
+
* tokens (Google's format has no motion yet; stitch2 lint leaves the linter's note about the key out).
|
|
4
5
|
*/
|
|
5
6
|
import { readFileSync } from 'node:fs';
|
|
6
7
|
import { join } from 'node:path';
|
|
@@ -30,8 +31,16 @@ export function readDesign(file = join(ROOT, 'DESIGN.md')) {
|
|
|
30
31
|
rounded: raw.rounded ?? {},
|
|
31
32
|
spacing: raw.spacing ?? {},
|
|
32
33
|
components: raw.components ?? {},
|
|
34
|
+
motion: Object.fromEntries(Object.entries(raw.motion ?? {}).map(([k, v]) => [k, String(v)])),
|
|
33
35
|
};
|
|
34
36
|
}
|
|
37
|
+
/** The durations DESIGN.md's motion names, in ms (for check: a transition of any other length is a warning). */
|
|
38
|
+
export function motionDurations(design = readDesign()) {
|
|
39
|
+
return Object.entries(design.motion)
|
|
40
|
+
.filter(([k]) => k.startsWith('duration-'))
|
|
41
|
+
.map(([, v]) => (v.trim().endsWith('ms') ? parseFloat(v) : parseFloat(v) * 1000))
|
|
42
|
+
.filter((n) => Number.isFinite(n));
|
|
43
|
+
}
|
|
35
44
|
/**
|
|
36
45
|
* The themes screens must work in (their data-theme values): the config's, or DESIGN.md's own, where the plain
|
|
37
46
|
* colours are the dark theme and light-* colours make a light one.
|
package/dist/exports.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* tailwind4 the same, plus an @theme block (bg-<colour>, rounded-<name>, font-sans) and the type levels as
|
|
6
6
|
* @utility type-<level>, for a Tailwind v4 stylesheet to @import. Spacing stays out of @theme:
|
|
7
7
|
* names like lg or 2xl would also resize max-w-lg and friends; it is --<prefix>-spacing-<name>.
|
|
8
|
+
* Motion: ease-<name> classes, duration-<name> utilities, and Tailwind's default transition (the
|
|
9
|
+
* plain transition, transition-colors…) set to the first duration and ease-standard.
|
|
8
10
|
* dtcg W3C Design Tokens JSON, for Style Dictionary and other platforms (iOS, Android)
|
|
9
11
|
* "aliases" maps the app's own variable names to DESIGN.md colours ({ "background": "floor" } writes
|
|
10
12
|
* --background: var(--<prefix>-floor) in each theme), so an existing theme (shadcn's, say) keeps its names.
|
|
@@ -44,9 +46,25 @@ function tailwind4(t, e) {
|
|
|
44
46
|
...Object.entries(t.design.rounded).map(([k, v]) => ` --radius-${k}: ${v};`),
|
|
45
47
|
...(sans ? [` --font-sans: "${sans}", var(--${p()}-font-sans-fallback);`] : []),
|
|
46
48
|
...(mono ? [` --font-mono: "${mono}", var(--${p()}-font-mono-fallback);`] : []),
|
|
49
|
+
...motionTheme(t.design.motion),
|
|
47
50
|
];
|
|
48
51
|
const type = Object.entries(t.type).map(([n, d]) => `@utility type-${n} {\n${d.map((x) => ` ${x};`).join('\n')}\n}`);
|
|
49
|
-
|
|
52
|
+
const durations = Object.keys(t.design.motion)
|
|
53
|
+
.filter((k) => k.startsWith('duration-'))
|
|
54
|
+
.map((k) => `@utility ${k} {\n transition-duration: var(--${p()}-${k});\n animation-duration: var(--${p()}-${k});\n}`);
|
|
55
|
+
return [t.header, ...themeBlocks(t, e), `@theme inline {\n${theme.join('\n')}\n}`, ...type, ...durations, ''].join('\n\n');
|
|
56
|
+
}
|
|
57
|
+
/** @theme entries for motion: --ease-<name> (Tailwind's ease-* classes) and the default transition. */
|
|
58
|
+
function motionTheme(motion) {
|
|
59
|
+
const keys = Object.keys(motion);
|
|
60
|
+
const eases = keys.filter((k) => k.startsWith('ease-'));
|
|
61
|
+
const first = keys.includes('duration-short') ? 'duration-short' : keys.find((k) => k.startsWith('duration-'));
|
|
62
|
+
const standard = eases.find((k) => k === 'ease-standard') ?? eases[0];
|
|
63
|
+
return [
|
|
64
|
+
...eases.map((k) => ` --${k}: var(--${p()}-${k});`),
|
|
65
|
+
...(first ? [` --default-transition-duration: var(--${p()}-${first});`] : []),
|
|
66
|
+
...(standard ? [` --default-transition-timing-function: var(--${p()}-${standard});`] : []),
|
|
67
|
+
];
|
|
50
68
|
}
|
|
51
69
|
function dtcg(t) {
|
|
52
70
|
const group = (type, o) => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, { $type: type, $value: v }]));
|
|
@@ -69,6 +87,14 @@ function dtcg(t) {
|
|
|
69
87
|
'color-light': group('color', t.light),
|
|
70
88
|
radius: group('dimension', t.design.rounded),
|
|
71
89
|
spacing: group('dimension', t.design.spacing),
|
|
90
|
+
...(Object.keys(t.design.motion).length
|
|
91
|
+
? {
|
|
92
|
+
duration: group('duration', Object.fromEntries(Object.entries(t.design.motion).filter(([k]) => k.startsWith('duration-')).map(([k, v]) => [k.slice(9), v]))),
|
|
93
|
+
easing: Object.fromEntries(Object.entries(t.design.motion)
|
|
94
|
+
.filter(([k]) => k.startsWith('ease-'))
|
|
95
|
+
.map(([k, v]) => [k.slice(5), { $type: 'cubicBezier', $value: (v.match(/-?[\d.]+/g) ?? []).map(Number) }])),
|
|
96
|
+
}
|
|
97
|
+
: {}),
|
|
72
98
|
typography,
|
|
73
99
|
}, null, 2)}\n`;
|
|
74
100
|
}
|
package/dist/serve.d.ts
CHANGED
package/dist/serve.js
CHANGED
|
@@ -9,7 +9,7 @@ import { existsSync, readdirSync, readFileSync, statSync, watch } from 'node:fs'
|
|
|
9
9
|
import { createServer } from 'node:http';
|
|
10
10
|
import { dirname, extname, join, normalize } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
|
-
import { CONFIG_FILE, config, widthRanges } from './config.js';
|
|
12
|
+
import { breakpoints, CONFIG_FILE, checkWidths, config, widthRanges } from './config.js';
|
|
13
13
|
import { designThemes } from './design-md.js';
|
|
14
14
|
import { allLocales, localeScript, sourceLocale } from './i18n.js';
|
|
15
15
|
import { designPage } from './design-page.js';
|
|
@@ -55,6 +55,8 @@ export function reportStates() {
|
|
|
55
55
|
return {
|
|
56
56
|
consistency: state('consistency/index.html', [...screens.filter((s) => s.status !== 'archived'), ...components]),
|
|
57
57
|
compare: state('compare/index.html', screens.filter((s) => s.kind === 'screen' && s.status === 'approved')),
|
|
58
|
+
// The states sheet is made when check runs on a catalog: it covers the catalogs and the components.
|
|
59
|
+
states: state('states/index.html', [...screens.filter((s) => s.kind === 'components'), ...components]),
|
|
58
60
|
};
|
|
59
61
|
}
|
|
60
62
|
const CANVAS = fileURLToPath(new URL('./canvas.html', import.meta.url));
|
|
@@ -118,6 +120,10 @@ export function startServer(port) {
|
|
|
118
120
|
res.writeHead(200, { 'content-type': TYPES['.html'], 'cache-control': 'no-store' });
|
|
119
121
|
return res.end('<body style="background:#0f0f11;color:#a1a1aa;font:15px system-ui;padding:32px">No consistency report yet: run <code>stitch2 consistency</code>.</body>');
|
|
120
122
|
}
|
|
123
|
+
if (url.pathname === '/states/index.html' && !existsSync(join(ROOT, 'states/index.html'))) {
|
|
124
|
+
res.writeHead(200, { 'content-type': TYPES['.html'], 'cache-control': 'no-store' });
|
|
125
|
+
return res.end('<body style="background:#0f0f11;color:#a1a1aa;font:15px system-ui;padding:32px">No states sheet yet: run <code>stitch2 check</code> on the component catalog.</body>');
|
|
126
|
+
}
|
|
121
127
|
if (url.pathname === '/api/screens/meta' && req.method === 'POST') {
|
|
122
128
|
let body = '';
|
|
123
129
|
req.on('data', (c) => {
|
|
@@ -149,7 +155,7 @@ export function startServer(port) {
|
|
|
149
155
|
return;
|
|
150
156
|
}
|
|
151
157
|
if (url.pathname === '/api/config')
|
|
152
|
-
return json({ ...config, themes: designThemes(), locales: allLocales(), sourceLocale: sourceLocale(), widths: widthRanges() });
|
|
158
|
+
return json({ ...config, themes: designThemes(), locales: allLocales(), sourceLocale: sourceLocale(), widths: widthRanges(), breakpoints: breakpoints(), checkWidths: Object.fromEntries(Object.keys(widthRanges()).map((d) => [d, checkWidths(d)])) });
|
|
153
159
|
if (url.pathname === '/api/screens')
|
|
154
160
|
return json(listScreens());
|
|
155
161
|
if (url.pathname === '/api/reports')
|
package/dist/shots.js
CHANGED
|
@@ -15,7 +15,7 @@ import { ROOT } from './screens.js';
|
|
|
15
15
|
const MAX_HEIGHT = 20000;
|
|
16
16
|
const DIR = join(tmpdir(), 'stitch2-shots');
|
|
17
17
|
/** Folders under the design directory that hold outputs, not inputs: they never change a picture. */
|
|
18
|
-
const OUTPUTS = new Set(['renders', 'consistency', 'compare', 'node_modules', '.git']);
|
|
18
|
+
const OUTPUTS = new Set(['renders', 'consistency', 'compare', 'states', 'node_modules', '.git']);
|
|
19
19
|
const REPORTS = new Set(['checks.json', 'compare.json', 'type-audit.json']);
|
|
20
20
|
let shared;
|
|
21
21
|
/** The newest of the files screens share: anything under the design directory that isn't a screen or an output. */
|
package/dist/states.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Page } from '@playwright/test';
|
|
2
|
+
import type { Issue } from './typecheck.ts';
|
|
3
|
+
export declare const STATES: readonly ['rest', 'hover', 'focus', 'pressed'];
|
|
4
|
+
type State = (typeof STATES)[number];
|
|
5
|
+
export interface StateRow {
|
|
6
|
+
name: string;
|
|
7
|
+
label: string;
|
|
8
|
+
selector: string;
|
|
9
|
+
/** The crop's width in CSS px (the picture is twice that on a 2x render). */
|
|
10
|
+
width?: number;
|
|
11
|
+
crops: Partial<Record<State, string>>;
|
|
12
|
+
notes: string[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The states of every control in the catalog open in the page, in the current theme. With crop, saves a picture
|
|
16
|
+
* of each state under <design>/states/ and returns the rows of the sheet.
|
|
17
|
+
*/
|
|
18
|
+
export declare function catalogStates(page: Page, path: string, opts: {
|
|
19
|
+
crop: boolean;
|
|
20
|
+
theme?: string;
|
|
21
|
+
}): Promise<{
|
|
22
|
+
issues: Issue[];
|
|
23
|
+
rows: StateRow[];
|
|
24
|
+
}>;
|
|
25
|
+
/** Writes the states sheet, keeping the rows of catalogs this run didn't check (states/states.json). */
|
|
26
|
+
export declare function writeStatesSheet(fresh: Record<string, StateRow[]>, catalogs: string[]): void;
|
|
27
|
+
export declare const statesSheetExists: () => boolean;
|
|
28
|
+
export {};
|
package/dist/states.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive states of the component catalog: every control in a catalog (a button, a chip, a row that opens
|
|
3
|
+
* something) drawn at rest, hovered, focused and pressed, forced through the DevTools protocol so the components
|
|
4
|
+
* need nothing extra. check runs it on each catalog: a control whose hover or press changes nothing gives no
|
|
5
|
+
* feedback, and text must keep its contrast in every state. The crops make <design>/states/index.html, the
|
|
6
|
+
* states sheet the canvas opens from its sidebar.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { config } from './config.js';
|
|
11
|
+
import { filterSnippet } from './report-filter.js';
|
|
12
|
+
import { ROOT } from './screens.js';
|
|
13
|
+
export const STATES = ['rest', 'hover', 'focus', 'pressed'];
|
|
14
|
+
const FORCED = { rest: [], hover: ['hover'], focus: ['focus', 'focus-visible'], pressed: ['hover', 'active'] };
|
|
15
|
+
/** Controls drawn per catalog, so a long catalog stays quick to check. */
|
|
16
|
+
const MAX_CONTROLS = 80;
|
|
17
|
+
/** Runs in the page: one control of each kind and look per component, marked data-s2-state. */
|
|
18
|
+
function findControls(opts) {
|
|
19
|
+
const SEL = 'button, a[href], input:not([type="hidden"]), select, textarea, summary, [role="button"], [role="switch"], [role="tab"], [role="checkbox"], [role="radio"], [role="option"], [tabindex]:not([tabindex="-1"])';
|
|
20
|
+
const path = (el) => {
|
|
21
|
+
const parts = [];
|
|
22
|
+
for (let e = el; e && e !== document.body && parts.length < 6; e = e.parentElement) {
|
|
23
|
+
let p = e.tagName.toLowerCase();
|
|
24
|
+
const same = e.parentElement ? [...e.parentElement.children].filter((c) => c.tagName === e.tagName) : [];
|
|
25
|
+
if (same.length > 1)
|
|
26
|
+
p += ':nth-of-type(' + (same.indexOf(e) + 1) + ')';
|
|
27
|
+
parts.unshift(p);
|
|
28
|
+
}
|
|
29
|
+
return parts.join(' > ');
|
|
30
|
+
};
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const root of document.querySelectorAll('[' + opts.attr + ']')) {
|
|
34
|
+
const name = root.getAttribute(opts.attr) || '';
|
|
35
|
+
const controls = root.matches(SEL) ? [root] : [...root.querySelectorAll(SEL)];
|
|
36
|
+
for (const c of controls) {
|
|
37
|
+
// The innermost component owns a control: a button inside a card belongs to the button.
|
|
38
|
+
if (c.closest('[' + opts.attr + ']') !== root && !c.matches('[' + opts.attr + ']'))
|
|
39
|
+
continue;
|
|
40
|
+
const h = c;
|
|
41
|
+
const r = h.getBoundingClientRect();
|
|
42
|
+
if (!r.width || !r.height || h.disabled || h.getAttribute('aria-disabled') === 'true')
|
|
43
|
+
continue;
|
|
44
|
+
const key = name + '|' + h.tagName + '|' + h.className + '|' + (h.getAttribute('aria-pressed') ?? '') + (h.getAttribute('aria-selected') ?? '');
|
|
45
|
+
if (seen.has(key) || out.length >= opts.max)
|
|
46
|
+
continue;
|
|
47
|
+
seen.add(key);
|
|
48
|
+
const label = (h.getAttribute('aria-label') || h.textContent || h.getAttribute('placeholder') || '').trim().replace(/\s+/g, ' ').slice(0, 40);
|
|
49
|
+
h.setAttribute('data-s2-state', String(out.length));
|
|
50
|
+
const field = h.matches('input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]), textarea, select, [contenteditable="true"]');
|
|
51
|
+
const chosen = ['aria-pressed', 'aria-selected', 'aria-checked'].some((a) => h.getAttribute(a) === 'true') || (h.hasAttribute('aria-current') && h.getAttribute('aria-current') !== 'false');
|
|
52
|
+
out.push({ i: out.length, name, label, selector: path(h), field, chosen });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/** Runs in the page: how a control looks (itself and what it holds), and its text's contrast on its background. */
|
|
58
|
+
function lookOf(i) {
|
|
59
|
+
const el = document.querySelector('[data-s2-state="' + i + '"]');
|
|
60
|
+
const props = (e) => {
|
|
61
|
+
const c = getComputedStyle(e);
|
|
62
|
+
// translate, scale and rotate are their own properties in Tailwind v4 (active:translate-y-px), apart from transform.
|
|
63
|
+
return [c.backgroundColor, c.color, c.borderColor, c.outlineStyle === 'none' ? '' : c.outlineColor + c.outlineWidth, c.boxShadow, c.transform, c.translate, c.scale, c.rotate, c.opacity, c.textDecorationLine, c.fill, c.stroke, c.filter].join('|');
|
|
64
|
+
};
|
|
65
|
+
const look = [el, ...el.querySelectorAll('*')].slice(0, 12).map(props).join('#');
|
|
66
|
+
const rgb = (s) => (s.match(/[\d.]+/g) ?? []).map(Number);
|
|
67
|
+
const lum = ([r, g, b]) => {
|
|
68
|
+
const f = (v) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
|
|
69
|
+
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
|
70
|
+
};
|
|
71
|
+
let bg = [255, 255, 255];
|
|
72
|
+
for (let e = el; e; e = e.parentElement) {
|
|
73
|
+
const c = rgb(getComputedStyle(e).backgroundColor);
|
|
74
|
+
if (c.length >= 3 && (c[3] ?? 1) >= 0.95) {
|
|
75
|
+
bg = c;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
let worst = 21;
|
|
80
|
+
for (const t of [el, ...el.querySelectorAll('*')]) {
|
|
81
|
+
if (![...t.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()))
|
|
82
|
+
continue;
|
|
83
|
+
const cs = getComputedStyle(t);
|
|
84
|
+
const fg = rgb(cs.color);
|
|
85
|
+
if ((fg[3] ?? 1) < 0.5 || Number(cs.opacity) < 0.5)
|
|
86
|
+
continue;
|
|
87
|
+
const [a, b] = [lum(fg), lum(bg)];
|
|
88
|
+
const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
89
|
+
const large = parseFloat(cs.fontSize) >= 24 || (parseFloat(cs.fontSize) >= 18.66 && Number(cs.fontWeight) >= 700);
|
|
90
|
+
worst = Math.min(worst, ratio / (large ? 3 : 4.5));
|
|
91
|
+
}
|
|
92
|
+
return { look, contrast: worst };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The states of every control in the catalog open in the page, in the current theme. With crop, saves a picture
|
|
96
|
+
* of each state under <design>/states/ and returns the rows of the sheet.
|
|
97
|
+
*/
|
|
98
|
+
export async function catalogStates(page, path, opts) {
|
|
99
|
+
const controls = await page.evaluate(findControls, { attr: 'data-' + config.prefix, max: MAX_CONTROLS });
|
|
100
|
+
const still = await page.addStyleTag({ content: '*, *::before, *::after { transition: none !important; animation: none !important; caret-color: transparent !important; }' });
|
|
101
|
+
const cdp = await page.context().newCDPSession(page);
|
|
102
|
+
await cdp.send('DOM.enable');
|
|
103
|
+
await cdp.send('CSS.enable');
|
|
104
|
+
const { root } = await cdp.send('DOM.getDocument', { depth: 0 });
|
|
105
|
+
const issues = [];
|
|
106
|
+
const rows = [];
|
|
107
|
+
const tag = opts.theme ? opts.theme + ' theme: ' : '';
|
|
108
|
+
const dir = join(ROOT, 'states');
|
|
109
|
+
for (const c of controls) {
|
|
110
|
+
const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector: '[data-s2-state="' + c.i + '"]' });
|
|
111
|
+
if (!nodeId)
|
|
112
|
+
continue;
|
|
113
|
+
const looks = {};
|
|
114
|
+
const row = { name: c.name, label: c.label, selector: c.selector, crops: {}, notes: [] };
|
|
115
|
+
const el = page.locator('[data-s2-state="' + c.i + '"]');
|
|
116
|
+
await el.scrollIntoViewIfNeeded({ timeout: 2000 }).catch(() => { });
|
|
117
|
+
for (const state of STATES) {
|
|
118
|
+
await cdp.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: FORCED[state] });
|
|
119
|
+
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
|
120
|
+
looks[state] = await page.evaluate(lookOf, c.i);
|
|
121
|
+
if (opts.crop) {
|
|
122
|
+
const box = await el.boundingBox();
|
|
123
|
+
if (box && box.width >= 1 && box.height >= 1) {
|
|
124
|
+
const pad = 8;
|
|
125
|
+
const file = path.replace(/[^a-z0-9]+/gi, '_') + '/' + c.i + '-' + state + '.png';
|
|
126
|
+
mkdirSync(join(dir, file, '..'), { recursive: true });
|
|
127
|
+
await page.screenshot({ path: join(dir, file), clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad), width: box.width + pad * 2, height: box.height + pad * 2 } });
|
|
128
|
+
row.crops[state] = file;
|
|
129
|
+
row.width = Math.round(box.width + pad * 2);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
await cdp.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: [] });
|
|
134
|
+
const who = c.name + (c.label ? ' “' + c.label + '”' : '');
|
|
135
|
+
const add = (type, message) => {
|
|
136
|
+
issues.push({ severity: 'warning', type, selector: c.selector, text: c.label, message: tag + message, ...(opts.theme ? { theme: opts.theme } : {}) });
|
|
137
|
+
row.notes.push(tag + message);
|
|
138
|
+
};
|
|
139
|
+
const noHover = looks.hover?.look === looks.rest?.look;
|
|
140
|
+
// Pressed is forced on top of hover: a press that adds nothing to hover shows nothing on a touch screen.
|
|
141
|
+
const noPress = looks.pressed?.look === looks.hover?.look;
|
|
142
|
+
if (!c.field && !c.chosen) {
|
|
143
|
+
if (noHover && noPress)
|
|
144
|
+
add('state-feedback', who + ': neither hovering nor pressing changes it, so a pointer or a tap gives no feedback (DESIGN.md, hover and pressed)');
|
|
145
|
+
else if (noHover)
|
|
146
|
+
add('state-hover', who + ': hovering changes nothing, so a pointer gives no feedback (DESIGN.md, hover)');
|
|
147
|
+
else if (noPress)
|
|
148
|
+
add('state-pressed', who + ': pressing adds nothing to hover, so a tap gives no feedback (DESIGN.md, pressed)');
|
|
149
|
+
}
|
|
150
|
+
for (const state of ['hover', 'focus', 'pressed'])
|
|
151
|
+
if ((looks[state]?.contrast ?? 21) < 1 && (looks.rest?.contrast ?? 21) >= 1) {
|
|
152
|
+
add('state-contrast', who + ': low text contrast when ' + (state === 'focus' ? 'focused' : state === 'hover' ? 'hovered' : 'pressed'));
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
rows.push(row);
|
|
156
|
+
}
|
|
157
|
+
await cdp.detach();
|
|
158
|
+
await still.evaluate((n) => n.remove());
|
|
159
|
+
await page.evaluate(() => document.querySelectorAll('[data-s2-state]').forEach((n) => n.removeAttribute('data-s2-state')));
|
|
160
|
+
return { issues, rows };
|
|
161
|
+
}
|
|
162
|
+
const esc = (s) => String(s ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
163
|
+
/** Writes the states sheet, keeping the rows of catalogs this run didn't check (states/states.json). */
|
|
164
|
+
export function writeStatesSheet(fresh, catalogs) {
|
|
165
|
+
const dir = join(ROOT, 'states');
|
|
166
|
+
mkdirSync(dir, { recursive: true });
|
|
167
|
+
const store = join(dir, 'states.json');
|
|
168
|
+
let all = {};
|
|
169
|
+
try {
|
|
170
|
+
all = JSON.parse(readFileSync(store, 'utf8'));
|
|
171
|
+
}
|
|
172
|
+
catch { }
|
|
173
|
+
all = Object.fromEntries(Object.entries({ ...all, ...fresh }).filter(([p]) => catalogs.includes(p)));
|
|
174
|
+
writeFileSync(store, JSON.stringify(all, null, 1));
|
|
175
|
+
const sections = Object.entries(all)
|
|
176
|
+
.map(([path, rows]) => {
|
|
177
|
+
const byName = new Map();
|
|
178
|
+
for (const r of rows)
|
|
179
|
+
byName.set(r.name, [...(byName.get(r.name) ?? []), r]);
|
|
180
|
+
const groups = [...byName.entries()]
|
|
181
|
+
.map(([name, list]) => '<h3>' + esc(name) + '</h3>' +
|
|
182
|
+
list
|
|
183
|
+
.map((r) => '<div class="row"><div class="who"><b>' + esc(r.label || name) + '</b><code>' + esc(r.selector) + '</code>' +
|
|
184
|
+
(r.notes.length ? r.notes.map((n) => '<p class="bad">' + esc(n) + '</p>').join('') : '<span class="ok">every state differs</span>') +
|
|
185
|
+
'</div><div class="imgs">' +
|
|
186
|
+
STATES.map((st) => '<figure>' + (r.crops[st] ? '<img src="' + esc(r.crops[st]) + '" alt=""' + (r.width ? ' width="' + Math.min(r.width, 360) + '"' : '') + ' />' : '<span class="none">—</span>') + '<figcaption>' + st + '</figcaption></figure>').join('') +
|
|
187
|
+
'</div></div>')
|
|
188
|
+
.join(''))
|
|
189
|
+
.join('');
|
|
190
|
+
return '<section data-path="' + esc(path) + '"><h2>' + esc(path) + '</h2>' + groups + '</section>';
|
|
191
|
+
})
|
|
192
|
+
.join('');
|
|
193
|
+
writeFileSync(join(dir, 'index.html'), '<!doctype html><html lang="en"><head><meta charset="utf-8" /><title>States</title><style>' +
|
|
194
|
+
'body { background: #0f0f11; color: #f4f4f5; font: 14px/1.5 system-ui, sans-serif; margin: 0; } main { margin: 0 auto; max-width: 1200px; padding: 32px; }' +
|
|
195
|
+
'h1 { font-size: 26px; margin: 0 0 4px; } h2 { font: 13px ui-monospace, monospace; color: #a1a1aa; margin: 40px 0 0; } h3 { font-size: 17px; margin: 28px 0 8px; } code { color: #71717a; font: 12px ui-monospace, monospace; }' +
|
|
196
|
+
'.row { border-top: 1px solid #2e2e33; display: grid; gap: 20px; grid-template-columns: 280px 1fr; padding: 12px 0; } .who { display: grid; gap: 4px; align-content: start; min-width: 0; overflow-wrap: anywhere; }' +
|
|
197
|
+
'.imgs { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end; } figure { margin: 0; } figcaption { color: #71717a; font-size: 12px; margin-top: 4px; } img { background: #000; border: 1px solid #2e2e33; border-radius: 6px; display: block; max-width: 360px; }' +
|
|
198
|
+
'.ok { color: #3fb27f; } .bad { color: #e5b54a; margin: 0; font-size: 13px; } .none { color: #71717a; }' +
|
|
199
|
+
'</style></head><body><main><h1>States</h1><code>' + new Date().toISOString() + ' · every control in the component catalog at rest, hovered, focused and pressed</code><p id="stitch2-filter" hidden></p>' +
|
|
200
|
+
sections +
|
|
201
|
+
'</main>' + filterSnippet + '</body></html>\n');
|
|
202
|
+
}
|
|
203
|
+
export const statesSheetExists = () => existsSync(join(ROOT, 'states/index.html'));
|
package/dist/tokens.js
CHANGED
|
@@ -92,6 +92,10 @@ writeFileSync(join(ROOT, 'tokens.css'), [
|
|
|
92
92
|
vars("[data-theme='light']", light, md.light),
|
|
93
93
|
":root { color-scheme: dark; } [data-theme='light'] { color-scheme: light; }",
|
|
94
94
|
`:root { --${lab.prefix}-font-sans-fallback: ${FALLBACK}; --${lab.prefix}-font-mono-fallback: ui-monospace, monospace; }`,
|
|
95
|
+
// Motion (duration-* and ease-*), when DESIGN.md names it.
|
|
96
|
+
...(Object.keys(design.motion).length
|
|
97
|
+
? [`:root { ${Object.entries(design.motion).map(([k, v]) => `--${lab.prefix}-${k}: ${v};`).join(' ')} }`]
|
|
98
|
+
: []),
|
|
95
99
|
'/* Typography levels: one class sets family, size, line height, weight, tracking and figures. */',
|
|
96
100
|
typeCss.join('\n'),
|
|
97
101
|
'',
|
|
@@ -122,6 +126,7 @@ const exported = writeExports({
|
|
|
122
126
|
root: {
|
|
123
127
|
[`${lab.prefix}-font-sans-fallback`]: FALLBACK,
|
|
124
128
|
[`${lab.prefix}-font-mono-fallback`]: 'ui-monospace, monospace',
|
|
129
|
+
...Object.fromEntries(Object.entries(design.motion).map(([k, v]) => [`${lab.prefix}-${k}`, v])),
|
|
125
130
|
},
|
|
126
131
|
header: `/* Generated by stitch2 tokens from DESIGN.md. Do not edit: change DESIGN.md and run stitch2 tokens. */`,
|
|
127
132
|
});
|
package/package.json
CHANGED
package/skills/stitch2/SKILL.md
CHANGED
|
@@ -27,6 +27,7 @@ References, read before the first screen:
|
|
|
27
27
|
- [screens.md](references/screens.md): screen names, versions and their variants (mobile, desktop), the widths
|
|
28
28
|
each variant covers, statuses, the user's approval and notes.
|
|
29
29
|
- [google.md](references/google.md): Google's guidance (Material, Android, web.dev) as rules with numbers.
|
|
30
|
+
- [motion.md](references/motion.md): motion tokens, what may move and how, reduced motion.
|
|
30
31
|
- [guides.md](references/guides.md): the public sources behind the rules.
|
|
31
32
|
|
|
32
33
|
## Workflow
|
|
@@ -66,6 +67,10 @@ References, read before the first screen:
|
|
|
66
67
|
**stitch2-i18n** skill says.
|
|
67
68
|
An issue tagged with a width ("at 1024px: …") breaks at an edge of the windows the variant covers: fix it with a
|
|
68
69
|
responsive rule in the screen, or a new variant when the layout's structure has to change (screens.md, Widths).
|
|
70
|
+
Widths include the breakpoints inside the range; `narrow-layout` is a phone column left in a wide window.
|
|
71
|
+
Motion warnings: `motion-token` (a transition length DESIGN.md doesn't name) and `reduced-motion` (something
|
|
72
|
+
still moves when the person asked for less); see motion.md. On the catalog, `state-*` warnings come from the
|
|
73
|
+
States sheet (components.md, States).
|
|
69
74
|
6. **Look and critique.** View the render in `<design>/renders/` (crop the first screenful). Score it with the
|
|
70
75
|
rubric in principles.md, fix what fails, repeat 5–6 at most three times.
|
|
71
76
|
7. **Hand over.** Set the version's status to `review`, point the user to it on the canvas, and report what
|
|
@@ -24,6 +24,26 @@ Go down only when the step above cannot work:
|
|
|
24
24
|
|
|
25
25
|
Never restyle a component on one screen, copy its markup by hand, or build a near-twin ("a chip, but 30 px").
|
|
26
26
|
|
|
27
|
+
## States
|
|
28
|
+
|
|
29
|
+
Every interactive component has its states, defined once in the component and described in DESIGN.md:
|
|
30
|
+
|
|
31
|
+
| State | What changes (DESIGN.md says how) |
|
|
32
|
+
|---|---|
|
|
33
|
+
| Hover (pointer only) | the surface lightens or the text brightens one step |
|
|
34
|
+
| Pressed | darkens one step and moves 1 px (or scales to 0.95); on a touch screen this is the only feedback |
|
|
35
|
+
| Focus (keyboard) | a ring, never removed without a replacement |
|
|
36
|
+
| Selected / current | shown by the accent or a raised surface; hover and press may stay as they are |
|
|
37
|
+
| Disabled | faint, no hover or press change, the reason nearby |
|
|
38
|
+
|
|
39
|
+
The catalog shows each component in each of its **data states** (selected, done, active, disabled, error, with
|
|
40
|
+
and without an icon). Hover, focus and pressed are not drawn by hand: `stitch2 check` on the catalog forces them on
|
|
41
|
+
every control and writes the **States** sheet (canvas sidebar), each control at rest, hovered, focused and
|
|
42
|
+
pressed side by side. It reports a control that gives no feedback (**state-feedback**, **state-hover**,
|
|
43
|
+
**state-pressed**: pressing adds nothing to hover, so a tap shows nothing) and text that loses contrast in a state
|
|
44
|
+
(**state-contrast**). Fix them in the component, once. Selected and current controls and text fields are exempt
|
|
45
|
+
from the feedback checks.
|
|
46
|
+
|
|
27
47
|
## Components from the app
|
|
28
48
|
|
|
29
49
|
When the project builds its elements from the app's own components (`stitch2/elements›, bundled with
|
|
@@ -51,6 +71,7 @@ series) is added to DESIGN.md first, with a line in the prose saying where it is
|
|
|
51
71
|
screen. `stitch2 consistency` shows the same as cropped images; see the stitch2-consistency skill.
|
|
52
72
|
- **color**: a hex, `rgb()` or `hsl()` value in a lab screen's markup, or (reported on the catalog) in the
|
|
53
73
|
component scripts it loads, with the file and line.
|
|
74
|
+
- **state-***: from the catalog's States sheet, above.
|
|
54
75
|
|
|
55
76
|
## Generated drafts
|
|
56
77
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Motion: how screens move
|
|
2
|
+
|
|
3
|
+
Motion confirms what just happened (a set ticked, a sheet opened) and shows where things come from. It is part
|
|
4
|
+
of the design system, like colour: durations and easings are **motion tokens** in DESIGN.md, components use them,
|
|
5
|
+
and screens get motion from their components. A static screen cannot show movement, so the rules live in
|
|
6
|
+
DESIGN.md's prose and in the components, and the checks hold them.
|
|
7
|
+
|
|
8
|
+
## Tokens
|
|
9
|
+
|
|
10
|
+
stitch2 extends DESIGN.md's front matter with a `motion:` section (Google's format has none yet; `stitch2 lint`
|
|
11
|
+
leaves out the linter's note about the key). `stitch2 init` writes Material's values:
|
|
12
|
+
|
|
13
|
+
```yaml
|
|
14
|
+
motion:
|
|
15
|
+
duration-short: 150ms # a control's own change: press, toggle, tick
|
|
16
|
+
duration-medium: 250ms # something entering or opening: a sheet, a menu, a toast
|
|
17
|
+
duration-long: 400ms # a whole screen changing
|
|
18
|
+
ease-standard: cubic-bezier(0.2, 0, 0, 1) # movement inside the screen
|
|
19
|
+
ease-enter: cubic-bezier(0.05, 0.7, 0.1, 1) # what arrives (decelerates)
|
|
20
|
+
ease-exit: cubic-bezier(0.3, 0, 0.8, 0.15) # what leaves (accelerates)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`stitch2 tokens` makes them `--<prefix>-duration-short` and `--<prefix>-ease-standard` variables, `ease-standard` and
|
|
24
|
+
`duration-short` classes in the Tailwind 4 export, and sets Tailwind's plain `transition` (and `transition-colors`,
|
|
25
|
+
…) to duration-short with ease-standard, so a component that only says `transition` already follows them.
|
|
26
|
+
|
|
27
|
+
## Rules
|
|
28
|
+
|
|
29
|
+
- **Animate transform and opacity.** Moving layout properties (width, top, margin) janks and reflows.
|
|
30
|
+
- **One duration per kind of change** from the tokens; never a typed value (`duration-[180ms]`)
|
|
31
|
+
**(check: motion-token)**. Bigger or farther changes take the longer token; nothing over long except a
|
|
32
|
+
full-screen change.
|
|
33
|
+
- **Enter with ease-enter, leave with ease-exit**, and leave faster than you entered (medium in, short out).
|
|
34
|
+
- **Nothing moves on its own**, except a skeleton's pulse while content loads. No looping decoration, no
|
|
35
|
+
staggered list entrances in the app (speed matters more), no parallax.
|
|
36
|
+
- **Reduced motion:** when the person asks for less motion, nothing moves: animations that move or scale are
|
|
37
|
+
wrapped in `@media (prefers-reduced-motion: no-preference)` (Tailwind `motion-safe:`), and a fade or an instant
|
|
38
|
+
change replaces them **(check: reduced-motion)**. Fades and colour changes may stay.
|
|
39
|
+
- **Feedback is instant:** a press shows within one frame (the pressed state), and the motion that follows never
|
|
40
|
+
delays the next tap.
|
|
41
|
+
- **Describe screen-level motion in DESIGN.md's Motion & Interaction section** (how a sheet opens, what a tick
|
|
42
|
+
does), in values: "slides up 250ms ease-enter", not "smoothly".
|
|
43
|
+
|
|
44
|
+
## On the canvas
|
|
45
|
+
|
|
46
|
+
Frames are pictures until you double-click one: then it is live, and its hover, press and transitions play.
|
|
47
|
+
The **States** sheet shows every control of the catalog at rest, hovered, focused and pressed (see
|
|
48
|
+
[components.md](components.md)).
|
|
@@ -131,6 +131,18 @@ Widths are a dimension like themes and languages, not more files: `stitch2 check
|
|
|
131
131
|
both ends of its range and reports what breaks there ("at 1024px: …"), and the canvas shows every frame at its
|
|
132
132
|
narrowest or widest (the width dropdown, W).
|
|
133
133
|
|
|
134
|
+
Inside a range the layout changes at the project's **breakpoints** (`breakpoints` in the config, Tailwind's by
|
|
135
|
+
default: sm 640, md 768, lg 1024, xl 1280, 2xl 1536). Each one starts a layout at its narrowest, where it is most
|
|
136
|
+
likely to break, so check also renders every variant at each breakpoint inside its range (gymgym's mobile variant
|
|
137
|
+
at 360, 640, 768 and 1023). On the canvas, "At 768 px (md)" shows every screen at that window; a variant that
|
|
138
|
+
doesn't cover it is drawn at its nearest edge and faded, because the app shows another variant there.
|
|
139
|
+
|
|
140
|
+
- Design for the whole range, not only the frame: a phone layout stretched to 1023 px with a 390 px column in the
|
|
141
|
+
middle of the window is a defect at that width. Let it use the room (two columns of cards, a wider measure
|
|
142
|
+
capped by the type rules) or give the range to a new variant.
|
|
143
|
+
- Use the breakpoints the config names and no others (no `min-[700px]:`), so check covers every layout the
|
|
144
|
+
screen has.
|
|
145
|
+
|
|
134
146
|
- A screen that breaks at an edge gets a **responsive rule inside the same variant**: a two-column grid that drops
|
|
135
147
|
to one, three cards across that become two, a side column that moves under the main one. Use the project's
|
|
136
148
|
breakpoints (Tailwind's `md:`, `lg:`, `xl:`), so the screen keeps one layout that adapts.
|
|
@@ -35,13 +35,18 @@ The generated prose is correct but generic. Make it the product's:
|
|
|
35
35
|
surfaces and the primary with the user.
|
|
36
36
|
3. **Typography**: keep the levels; adjust sizes only together with the user and only on the scale.
|
|
37
37
|
4. **Components**: name the product's real components (its tab bar items, its core list row) and their states.
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
Each interactive one gets hover, pressed, focus, selected and disabled in the prose, in values ("darkens to
|
|
39
|
+
primary-pressed and moves 1px"); the States sheet then shows whether the components do it.
|
|
40
|
+
5. **Motion**: keep the `motion:` tokens (duration-short, -medium, -long; ease-standard, -enter, -exit) and say in
|
|
41
|
+
Motion & Interaction what moves, with which token. See the stitch2 skill's motion.md.
|
|
42
|
+
6. **Content & voice** and **Do's and Don'ts**: add the product's data style, naming and banned patterns.
|
|
43
|
+
7. Keep every rule checkable: values, not adjectives.
|
|
40
44
|
|
|
41
45
|
## 3. Validate and show
|
|
42
46
|
|
|
43
47
|
1. `stitch2 lint`: 0 errors. Warnings about disabled text contrast (WCAG exempts it), semi-transparent washes
|
|
44
48
|
(the linter cannot measure them) and colours no component references are expected.
|
|
49
|
+
`motion:` is stitch2's extension of the format; the linter doesn't know it, and stitch2 lint leaves that note out.
|
|
45
50
|
2. `stitch2 tokens`, then open the canvas: **DESIGN.md** in the sidebar draws the swatches, live type levels,
|
|
46
51
|
radii and component tokens. Walk the user through it; change and repeat until they agree.
|
|
47
52
|
3. Then build the component catalog and the first screens with the stitch2 skill, and run stitch2-consistency.
|