stitch2 0.18.0 → 0.19.0

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 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), text contrast in every theme, text that is not one of DESIGN.md's type levels, capitals without tracking,
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. Each screen is also run in long and right-to-left
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, one `.type-<level>` class per type level (with per-language
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
@@ -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,81 @@ 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.
612
+ if (W >= 600 && W < 1200) {
613
+ const fixedBar = (el) => {
614
+ for (let e = el; e && e !== document.body; e = e.parentElement)
615
+ if (['fixed', 'sticky'].includes(getComputedStyle(e).position))
616
+ return true;
617
+ return false;
618
+ };
619
+ let left = W, right = 0;
620
+ for (const el of all) {
621
+ if (!(ownText(el) || el.matches('img, svg, input, textarea, select, button, [role="button"]')) || fixedBar(el))
622
+ continue;
623
+ const r = el.getBoundingClientRect();
624
+ left = Math.min(left, r.left);
625
+ right = Math.max(right, r.right);
626
+ }
627
+ const used = right - left;
628
+ if (used > 0 && used < W * 0.6)
629
+ push('warning', 'narrow-layout', document.querySelector('main') ?? document.body, 'The content takes ' + Math.round(used) + ' of ' + W + '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');
630
+ }
594
631
  return out.sort((x, y) => (x.severity === y.severity ? 0 : x.severity === 'error' ? -1 : 1));
595
632
  }
633
+ /**
634
+ * Runs inside the page with prefers-reduced-motion: reduce. What still moves (an animation of transform,
635
+ * translate, scale, rotate or position) ignores the setting; fading and colour changes are fine.
636
+ */
637
+ export function movingAnimations() {
638
+ const MOVES = ['transform', 'translate', 'scale', 'rotate', 'left', 'top', 'right', 'bottom', 'marginTop', 'marginLeft', 'offsetDistance'];
639
+ const sel = (el) => {
640
+ const parts = [];
641
+ for (let e = el; e && e !== document.body && parts.length < 6; e = e.parentElement) {
642
+ let p = e.tagName.toLowerCase();
643
+ const same = e.parentElement ? [...e.parentElement.children].filter((c) => c.tagName === e.tagName) : [];
644
+ if (same.length > 1)
645
+ p += ':nth-of-type(' + (same.indexOf(e) + 1) + ')';
646
+ parts.unshift(p);
647
+ }
648
+ return parts.join(' > ');
649
+ };
650
+ const out = [];
651
+ const seen = new Set();
652
+ for (const a of document.getAnimations()) {
653
+ const effect = a.effect;
654
+ const el = effect?.target;
655
+ if (!el || seen.has(el) || a.playState !== 'running')
656
+ continue;
657
+ const moves = effect.getKeyframes().some((k) => MOVES.some((p) => p in k));
658
+ if (!moves)
659
+ continue;
660
+ seen.add(el);
661
+ if (out.length < 10)
662
+ out.push({
663
+ severity: 'warning',
664
+ type: 'reduced-motion',
665
+ selector: sel(el),
666
+ text: (el.textContent ?? '').trim().slice(0, 60),
667
+ message: 'Still moves with reduced motion on: wrap the animation in @media (prefers-reduced-motion: no-preference) (Tailwind motion-safe:), or fade instead',
668
+ });
669
+ }
670
+ return out;
671
+ }
@@ -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 widest.
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 widthFor = (s) => { const r = rangeOf(s); return !r || widthMode === 'drawn' ? s.drawn : widthMode === 'narrow' ? r[0] : r[1] }
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
- wsel.innerHTML = `<option value="drawn">Drawn width</option><option value="narrow">Narrowest: ${edge(0)}</option><option value="wide">Widest: ${edge(1)}</option>`
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(['drawn', 'narrow', 'wide'][(['drawn', 'narrow', 'wide'].indexOf(widthMode) + 1) % 3])
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), tagged with the width.
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 edge)
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 { config, widthRange } from './config.js';
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, { comfortable: config.comfortableTarget });
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, { comfortable: config.comfortableTarget }))
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, { comfortable: config.comfortableTarget })) {
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 and language: what breaks
119
- // only when the window is narrower or wider than the frame it was drawn in.
120
- const range = s.kind === 'screen' ? widthRange(s.device) : undefined;
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, { comfortable: config.comfortableTarget })) {
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), '/');
@@ -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
- return [t.header, ...themeBlocks(t, e), `@theme inline {\n${theme.join('\n')}\n}`, ...type, ''].join('\n\n');
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
@@ -11,6 +11,7 @@ export interface ReportState {
11
11
  export declare function reportStates(): {
12
12
  consistency: ReportState | null;
13
13
  compare: ReportState | null;
14
+ states: ReportState | null;
14
15
  };
15
16
  export declare function startServer(port: number): Promise<{
16
17
  port: number;
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. */
@@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitch2",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "A design canvas, layout and type checks, component consistency and screen versioning for agent-built UI, on Google's DESIGN.md format.",
5
5
  "license": "MIT",
6
6
  "author": "ifokeev",
@@ -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
@@ -38,6 +39,9 @@ References, read before the first screen:
38
39
  2. **Inventory.** List every element the brief names, in order, with its real data. That list is the screen:
39
40
  add nothing (status bars, step labels, tips, helper text, extra buttons), drop nothing. Next to each element
40
41
  write the catalog component that shows it; adapt or extend components rather than inventing markup.
42
+ Then list the page's **states**. A page that shows data gets an empty screen and a loading screen, designed
43
+ with its main screen in the same version; a page that loads or saves data that can fail also gets an error
44
+ state. Build them with the main screen; they are part of the page (screens.md, Empty, loading and error).
41
45
  3. **Hierarchy before markup.** Name the one thing the user came for, the single primary action (the only
42
46
  accent-filled element in its region), and what is secondary. Give every text element a type level and a
43
47
  colour before writing markup.
@@ -63,10 +67,15 @@ References, read before the first screen:
63
67
  **stitch2-i18n** skill says.
64
68
  An issue tagged with a width ("at 1024px: …") breaks at an edge of the windows the variant covers: fix it with a
65
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).
66
74
  6. **Look and critique.** View the render in `<design>/renders/` (crop the first screenful). Score it with the
67
75
  rubric in principles.md, fix what fails, repeat 5–6 at most three times.
68
76
  7. **Hand over.** Set the version's status to `review`, point the user to it on the canvas, and report what
69
- changed, how you answered their notes, and any rubric item you could not satisfy. The user approves; only then
77
+ changed, how you answered their notes, and any rubric item you could not satisfy. The page's empty and loading
78
+ screens go to review with it; for a page without them (a form, a static page), say why. The user approves; only then
70
79
  does it go into the app (the approved version is the one current version: see screens.md).
71
80
 
72
81
  ## Commands
@@ -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)).
@@ -56,7 +56,8 @@ short labels never wrap **(check)**.
56
56
  axis or month labels, a legend for a colour scale, captions under figures. They add no new facts.
57
57
  - Icons that stand for different things look different; one generic glyph repeated down a list says nothing.
58
58
  - Charts: one series in the accent, thin lines, few gridlines; label a single line directly instead of a legend.
59
- - Empty and loading states are designed too: one sentence and one action; skeletons the size of the content.
59
+ - Empty and loading states are designed with every page that shows data: one sentence and one action; skeletons
60
+ the size of the content (screens.md, Empty, loading and error).
60
61
 
61
62
  ## Touch and mobile
62
63
 
@@ -86,3 +87,5 @@ Score each pass or fail from the render; done when all pass or the failures are
86
87
  8. **Data realism:** names, numbers and dates look like a real person's data.
87
88
  9. **Consistency:** built from the catalog components; no `reuse` or `consistency` warnings.
88
89
  10. **Checks:** `stitch2 check --strict` reports no errors.
90
+ 11. **States:** the page has its empty and loading screens (and error when data can fail), built from the same
91
+ components and layout as the main screen; or the hand-over says why it needs none.
@@ -30,6 +30,32 @@ together; `stitch2 screens <page>` lists them. A screen without `<p>-page` is a
30
30
  that page, not a new page.
31
31
  - Copy `<p>-page` along with the other tags into each new version; the latest version that has it decides the page.
32
32
 
33
+ ## Empty, loading and error
34
+
35
+ Every page that shows data has these states, and the app shows them on most first visits: a new user sees the
36
+ empty screen before any other, and every user sees the loading one. Design them with the page's main screen,
37
+ not after it.
38
+
39
+ - **Which pages:** any page that lists, counts or charts the user's data, or loads it from a server. A form, a
40
+ dialog, a static page (sign-in, a landing page) needs none; say so in the hand-over.
41
+ - **Names:** `<page>-empty` and `<page>-loading` (`history-empty`, `history-loading`), `<page>-error` when it
42
+ has one, with the page's `<p>-page`. Same version number and same variants (mobile, desktop) as the main
43
+ screen. A page whose main screen is a new version gets new versions of its states when they change.
44
+ - **Empty:** the page's header and navigation stay where they are. In place of the content: what this place is
45
+ for in one sentence, and the one action that fills it ("Create your first routine"). Use the page's own
46
+ components where they help (a suggested program as a list row), not a large illustration. An empty search or
47
+ filter result is a different empty: it says nothing matched and how to widen it (clear the filter), and keeps
48
+ the search field and filters.
49
+ - **Loading:** the same layout as the main screen, with skeletons in place of the data: blocks the size and
50
+ shape of the rows, cards and numbers that will arrive (typography.md, Text in each state). The header,
51
+ navigation and buttons that need no data stay real and usable. No spinner in the middle of the page and no
52
+ "Loading…" text; a spinner belongs only inside a button or a row that is saving. Build skeletons from a
53
+ catalog component, so every page loads the same way.
54
+ - **Error:** the page's frame stays; where the data would be, one sentence on what failed and a Retry action.
55
+ Data already shown stays on screen; a failed save says so next to what failed and keeps the user's input.
56
+ - **Partial states** follow the same rules inside a section: an empty list in a page that has other data shows
57
+ one line and an action in that section only.
58
+
33
59
  ## New version or edit?
34
60
 
35
61
  - **New version** for every new attempt: a new direction, a regeneration, an export from a generator, a
@@ -105,6 +131,18 @@ Widths are a dimension like themes and languages, not more files: `stitch2 check
105
131
  both ends of its range and reports what breaks there ("at 1024px: …"), and the canvas shows every frame at its
106
132
  narrowest or widest (the width dropdown, W).
107
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
+
108
146
  - A screen that breaks at an edge gets a **responsive rule inside the same variant**: a two-column grid that drops
109
147
  to one, three cards across that become two, a side column that moves under the main one. Use the project's
110
148
  breakpoints (Tailwind's `md:`, `lg:`, `xl:`), so the screen keeps one layout that adapts.
@@ -25,6 +25,11 @@ a component rebuilt by hand. The checks can only judge what the canvas draws, so
25
25
  single current version and the app matches it.
26
26
  4. **App**: the app picks the component up; wire it, then run `stitch2 compare <screen>`.
27
27
 
28
+ A page in the app is its main screen and its states: implement its approved empty, loading and error screens with
29
+ it. The app's loader is the loading screen's skeleton, shown until the data arrives, never a blank page or a lone
30
+ spinner; data appearing all at once with no loading state is a bug. A page whose states were never designed goes
31
+ back to the canvas for them first.
32
+
28
33
  Fixing a bug found in the app follows the same path: reproduce it on the canvas (a screen or catalog state that
29
34
  shows it), fix the component, check, then the app. If the canvas cannot show the bug, the canvas is missing a
30
35
  state: add it.
@@ -69,4 +74,3 @@ Read the findings as:
69
74
  2. `stitch2 check --strict` passes on the screens you touched (archived screens excepted).
70
75
  3. `stitch2 compare` has no errors for the screens you touched; warnings are explained (a kept feature) or fixed.
71
76
  4. The user has seen the canvas versions of anything that changed how a component looks.
72
-
@@ -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
- 5. **Content & voice** and **Do's and Don'ts**: add the product's data style, naming and banned patterns.
39
- 6. Keep every rule checkable: values, not adjectives.
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.