stitch2 0.7.3 → 0.9.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
@@ -7,6 +7,10 @@ agents skills that tell them how.
7
7
  > stitch2 is inspired by [Google Stitch](https://stitch.withgoogle.com) and works with it, but it is an
8
8
  > independent project, not affiliated with or endorsed by Google.
9
9
 
10
+ [![Watch stitch2 in 80 seconds on YouTube: an agent's draft drifts from the design system, the checks catch it, and it is fixed and approved on the canvas](https://img.youtube.com/vi/mIhP_umyO3o/maxresdefault.jpg)](https://youtu.be/mIhP_umyO3o)
11
+
12
+ ▶ [Watch the 80-second demo on YouTube](https://youtu.be/mIhP_umyO3o)
13
+
10
14
  ## Quick start with an AI agent
11
15
 
12
16
  Paste this to your coding agent (Codex, Claude Code, Cursor or any agent that runs commands) in your project:
@@ -32,11 +36,13 @@ file that already exists. From there the agent spends its effort on the design.
32
36
  - **Canvas** (`stitch2 canvas`): every screen and its versions on a pan-and-zoom board, a sidebar with search
33
37
  and status filters, your DESIGN.md drawn as tokens and rules, the component catalog, and an inspector to
34
38
  approve, archive or leave a note on a version. Live reload; copyable references to paste to an agent.
39
+ With a light and a dark theme (DESIGN.md's `light-*` colours), a switch (`T`) shows every screen in either one.
35
40
  Pick mode (`P`) highlights any element of a screen and copies a reference with its source line, such as
36
41
  `home mobile v1 (design/screens/home/mobile-v1.html:36) <gg-button> “Start Push”`; Shift-click collects
37
42
  several to copy at once or add to the version's note.
38
- - **Checks** (`stitch2 check`): content past the frame edge, labels that wrap, content under fixed bars, fixed bars that overlap each other, tap
39
- targets (WCAG 2.2), text contrast, text that is not one of DESIGN.md's type levels, capitals without tracking,
43
+ - **Checks** (`stitch2 check`): content past the frame edge or out of its card, labels that wrap, text running into
44
+ other text, labels cut short with an ellipsis, content under fixed bars, fixed bars that overlap each other, tap
45
+ targets (WCAG 2.2), text contrast in every theme, text that is not one of DESIGN.md's type levels, capitals without tracking,
40
46
  large numbers in monospace, components built by hand, components that differ between screens, and colours
41
47
  written as values instead of DESIGN.md tokens.
42
48
  - **Consistency report** (`stitch2 consistency`): every shared component (tab bar, header, buttons, chips, …)
@@ -159,6 +165,24 @@ put a page into the state the design shows (open a sheet, start a workout). The
159
165
  an error on that version and the run goes on. When it opens a modal, wait for something inside it (a marked
160
166
  component): dialog roots are often zero-sized wrappers, which Playwright reports as hidden.
161
167
 
168
+ ### In your own tests: every language, every width
169
+
170
+ The layout rules are a plain function you can run on any page, so an app's own Playwright tests can check what
171
+ mock-ups never show: long translations, right-to-left layouts, narrow phones.
172
+
173
+ ```ts
174
+ import { audit } from 'stitch2/audit'
175
+
176
+ for (const locale of ['de', 'ru', 'ar']) {
177
+ // …open the page in that language…
178
+ const issues = await page.evaluate(audit, { comfortable: 32 })
179
+ const errors = issues.filter((i) => i.severity === 'error') // overflow, text-overlap, label-wraps, …
180
+ }
181
+ ```
182
+
183
+ Warnings worth reading for translations: `truncated` (a label cut short with an ellipsis), `text-crowded` (two
184
+ controls' labels nearly touching) and `clipped`.
185
+
162
186
  ## Commands
163
187
 
164
188
  | Command | What it does |
@@ -167,7 +191,7 @@ component): dialog roots are often zero-sized wrappers, which Playwright reports
167
191
  | `stitch2 init --name N --primary #hex [options]` | Writes a complete DESIGN.md from a few choices |
168
192
  | `stitch2 extract <url or file…>` | Writes a DESIGN.md measured from an existing site or screens |
169
193
  | `stitch2 canvas` | The canvas at http://localhost:4400 (`PORT` and `HOST` to change) |
170
- | `stitch2 check [filter] [--shots] [--strict]` | All checks; `--shots` saves renders to `<design>/renders/` |
194
+ | `stitch2 check [filter] [--shots] [--strict]` | All checks, in every theme; `--shots` saves renders to `<design>/renders/` (`--light.png` for the other theme) |
171
195
  | `stitch2 consistency [kind…]` | The consistency report, `<design>/consistency/index.html` |
172
196
  | `stitch2 type [filter] [--json]` | The type audit |
173
197
  | `stitch2 screens [name] [--status s] [--json]` | Versions, statuses and notes |
@@ -62,6 +62,11 @@
62
62
  "description": "Tap targets smaller than this (px) get a warning.",
63
63
  "default": 32
64
64
  },
65
+ "themes": {
66
+ "type": "array",
67
+ "items": { "type": "string" },
68
+ "description": "The themes every screen must work in, by their data-theme value. Defaults to DESIGN.md's: dark (the plain colours), plus light when it has light-* colours. The canvas switches between them and check runs in each."
69
+ },
65
70
  "skills": {
66
71
  "type": "array",
67
72
  "items": {
package/dist/audit.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The layout audit, shared by check (the design screens) and compare (the live app). Runs inside the page.
2
+ * The layout audit, shared by check (the design screens) and compare (the live app), and exported as
3
+ * stitch2/audit for projects' own tests. Runs inside the page.
3
4
  */
4
5
  import type { Issue } from './typecheck.ts';
5
6
  /** Layout errors that mean the same bug wherever they appear; compare reports these for the app too. */
package/dist/audit.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Layout errors that mean the same bug wherever they appear; compare reports these for the app too. */
2
- export const LAYOUT_ERRORS = ['overflow', 'fixed-overlap', 'control-overlap'];
2
+ export const LAYOUT_ERRORS = ['overflow', 'fixed-overlap', 'control-overlap', 'text-overlap'];
3
3
  /** Runs inside the page. Plain JS on purpose: Playwright sends its source to the browser. */
4
4
  export async function audit(opts) {
5
5
  const W = document.documentElement.clientWidth;
@@ -101,8 +101,15 @@ export async function audit(opts) {
101
101
  const r = el.getBoundingClientRect();
102
102
  return (p === 'fixed' || p === 'sticky') && r.height <= 240 && r.width >= W * 0.6;
103
103
  });
104
- const outer = bars.filter((b) => !bars.some((o) => o !== b && o.contains(b)));
104
+ // Behind an open modal the page is inert and covered, so only the modal's own bars count.
105
+ // An open modal: the element may be 0px itself when everything in it is fixed (Headless UI's Dialog).
106
+ const modal = [...document.querySelectorAll('[aria-modal="true"]')].find((m) => {
107
+ const cs = getComputedStyle(m);
108
+ return cs.display !== 'none' && cs.visibility !== 'hidden' && [m, ...m.querySelectorAll('*')].some(shown);
109
+ });
110
+ const outer = bars.filter((b) => !bars.some((o) => o !== b && o.contains(b)) && (!modal || modal.contains(b)));
105
111
  const content = all.filter((el) => !outer.some((b) => b.contains(el)) &&
112
+ (!modal || modal.contains(el)) &&
106
113
  (ownText(el) || el.matches('button, input, select, textarea, img, svg, canvas, video')) &&
107
114
  opacity(el) > 0.5);
108
115
  const covered = async (edge) => {
@@ -121,7 +128,7 @@ export async function audit(opts) {
121
128
  const overlapY = Math.min(r.bottom, b.bottom) - Math.max(r.top, b.top);
122
129
  const overlapX = Math.min(r.right, b.right) - Math.max(r.left, b.left);
123
130
  if (overlapY > 4 && overlapX > 4)
124
- push('error', `under-${edge}-bar`, el, `Hidden under the fixed ${edge} bar when scrolled to the ${edge} (add padding)`);
131
+ push('error', `under-${edge}-bar`, el, `Hidden under the fixed ${edge} bar (“${text(bar).slice(0, 30) || bar.tagName.toLowerCase()}”) when scrolled to the ${edge} (add padding)`);
125
132
  }
126
133
  }
127
134
  };
@@ -185,8 +192,7 @@ export async function audit(opts) {
185
192
  return e;
186
193
  return null;
187
194
  };
188
- // Behind an open modal the page is inert and the modal covers it, so only the modal's own controls count.
189
- const modal = [...document.querySelectorAll('[aria-modal="true"]')].find(shown);
195
+ // Behind an open modal (found above) only the modal's own controls count.
190
196
  const solid = interactive.filter((el) => opacity(el) > 0.05 && (!modal || modal.contains(el)));
191
197
  for (const [i, a] of solid.entries())
192
198
  for (const b of solid.slice(i + 1)) {
@@ -197,6 +203,95 @@ export async function audit(opts) {
197
203
  if (Math.min(r.right, q.right) - Math.max(r.left, q.left) > 2 && Math.min(r.bottom, q.bottom) - Math.max(r.top, q.top) > 2)
198
204
  push('error', 'control-overlap', b, `Overlaps another control (“${text(a).slice(0, 30) || a.getAttribute('aria-label') || a.tagName.toLowerCase()}”): give them room`);
199
205
  }
206
+ // 4c. Text never collides with other text: labels of two controls overlapping (a long translation spilling into
207
+ // its neighbour in a tab bar) are errors; labels of two controls closer than 4px are warnings.
208
+ const owner = (el) => el.closest('button, a, label, [role=tab], [role=button], [role=switch], [role=checkbox]') ?? el;
209
+ const runs = [];
210
+ {
211
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
212
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
213
+ const el = n.parentElement;
214
+ if (!el || !n.textContent.trim() || !shown(el) || inSvg(el) || opacity(el) < 0.5)
215
+ continue;
216
+ if (modal && !modal.contains(el))
217
+ continue;
218
+ const range = document.createRange();
219
+ range.selectNodeContents(n);
220
+ // Only what shows: text cut off by a clipping parent (an ellipsis) still measures full width.
221
+ const clips = [];
222
+ for (let p = el; p && p !== document.body; p = p.parentElement)
223
+ if (getComputedStyle(p).overflowX !== 'visible')
224
+ clips.push(p.getBoundingClientRect());
225
+ for (const raw of range.getClientRects()) {
226
+ let [l, t, r, b] = [raw.left, raw.top, raw.right, raw.bottom];
227
+ for (const c of clips)
228
+ [l, t, r, b] = [Math.max(l, c.left), Math.max(t, c.top), Math.min(r, c.right), Math.min(b, c.bottom)];
229
+ if (r - l > 1 && b - t > 1)
230
+ runs.push({ el, own: owner(el), r: new DOMRect(l, t, r - l, b - t) });
231
+ }
232
+ }
233
+ }
234
+ const collided = new Set();
235
+ for (const [i, a] of runs.entries())
236
+ for (const b of runs.slice(i + 1)) {
237
+ if (a.own === b.own || a.own.contains(b.own) || b.own.contains(a.own) || layer(a.el) !== layer(b.el))
238
+ continue;
239
+ if (collided.has(a.own) || collided.has(b.own))
240
+ continue;
241
+ const x = Math.min(a.r.right, b.r.right) - Math.max(a.r.left, b.r.left);
242
+ const y = Math.min(a.r.bottom, b.r.bottom) - Math.max(a.r.top, b.r.top);
243
+ // Same row: stacked lines of a tight heading touch at their glyph boxes and are not a collision.
244
+ const sameRow = y > Math.min(a.r.height, b.r.height) * 0.5;
245
+ if (x > 1 && sameRow) {
246
+ collided.add(b.own);
247
+ push('error', 'text-overlap', b.el, 'Text runs into “' + text(a.el).slice(0, 30) + '”: shorten it or give it room');
248
+ }
249
+ else if (sameRow && x > -4 && a.own !== a.el && b.own !== b.el) {
250
+ collided.add(b.own);
251
+ push('warning', 'text-crowded', b.el, 'Touches “' + text(a.el).slice(0, 30) + '” with no gap: a longer word will collide');
252
+ }
253
+ }
254
+ // 4c'. Labels cut short with an ellipsis: fine for a long exercise name in a row, a translation problem in a tab,
255
+ // a button or a short label. Reported as warnings, for the eye to judge.
256
+ for (const el of all) {
257
+ if (!ownText(el) || getComputedStyle(el).textOverflow !== 'ellipsis')
258
+ continue;
259
+ const h = el;
260
+ if (h.scrollWidth > h.clientWidth + 1)
261
+ push('warning', 'truncated', el, 'Cut short with an ellipsis (' + h.scrollWidth + 'px of text in ' + h.clientWidth + 'px)');
262
+ }
263
+ // 4d. Nothing sticks out sideways of the card or bar that holds it (a button pushed past its card's edge by a
264
+ // long label), even while still on screen. Clipping and scrolling containers end the search: what they hide
265
+ // is deliberate.
266
+ const paints = (el) => {
267
+ const cs = getComputedStyle(el);
268
+ return !/rgba\(0, 0, 0, 0\)|transparent/.test(cs.backgroundColor) || Number.parseFloat(cs.borderLeftWidth) > 0;
269
+ };
270
+ const outside = new Set();
271
+ for (const el of all) {
272
+ if (overflowing.has(el) || outside.has(el.parentElement))
273
+ continue;
274
+ if (['absolute', 'fixed'].includes(getComputedStyle(el).position))
275
+ continue;
276
+ let box = null;
277
+ for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
278
+ if (getComputedStyle(p).overflowX !== 'visible')
279
+ break;
280
+ if (paints(p)) {
281
+ box = p;
282
+ break;
283
+ }
284
+ }
285
+ if (!box)
286
+ continue;
287
+ const r = el.getBoundingClientRect();
288
+ const b = box.getBoundingClientRect();
289
+ const out = Math.max(r.right - b.right, b.left - r.left);
290
+ if (out > 2) {
291
+ outside.add(el);
292
+ push('error', 'overflow', el, 'Sticks out of its container by ' + Math.round(out) + 'px');
293
+ }
294
+ }
200
295
  // 5. Text contrast (WCAG AA): 4.5:1, or 3:1 for large text. Skips text over images and dimmed layers.
201
296
  const rgba = (c) => {
202
297
  const m = /rgba?\(([^)]+)\)/.exec(c);
package/dist/canvas.html CHANGED
@@ -26,6 +26,7 @@
26
26
  .seg { background: var(--bg); border: 1px solid var(--edge); border-radius: 8px; display: flex; gap: 2px; padding: 2px; }
27
27
  .seg button { background: none; border: 0; border-radius: 6px; color: var(--muted); cursor: pointer; flex: 1; padding: 4px 6px; white-space: nowrap; }
28
28
  .seg button.on { background: var(--panel2); color: var(--text); }
29
+ .seg[hidden] { display: none; }
29
30
  #tree { flex: 1; overflow: auto; padding: 6px 8px 16px; }
30
31
  #tree h4 { color: var(--faint); font-size: 11px; letter-spacing: .06em; margin: 14px 6px 6px; text-transform: uppercase; }
31
32
  .item { align-items: center; background: none; border: 0; border-radius: 6px; color: var(--muted); cursor: pointer; display: flex; gap: 8px; padding: 6px 8px; text-align: left; width: 100%; }
@@ -105,6 +106,7 @@
105
106
  <input id="search" placeholder="Search screens… ( / )" />
106
107
  <div class="seg" id="statusFilter"><button type="button" data-v="active" class="on" title="Everything except archived versions">Active</button><button type="button" data-v="approved" title="The reference versions agents build on">Approved</button><button type="button" data-v="review" title="Finished by an agent, waiting for your decision">Needs review</button><button type="button" data-v="all" title="Including archived versions">All</button></div>
107
108
  <div class="seg" id="deviceFilter"><button type="button" data-v="all" class="on">All devices</button><button type="button" data-v="mobile">Mobile</button><button type="button" data-v="desktop">Desktop</button></div>
109
+ <div class="seg" id="themeFilter" hidden title="Show every screen in this theme (T)"></div>
108
110
  </header>
109
111
  <nav id="tree"></nav>
110
112
  <footer>
@@ -134,7 +136,26 @@ const STATUS_HINT = {
134
136
  }
135
137
  /** What to paste to an agent: the screen, device and version, and the file. */
136
138
  let designDir = 'design'
137
- const ref = (s) => (s.kind === 'components' ? `component catalog (${designDir}/${s.path})` : `${s.screen} ${s.device} ${s.version} (${designDir}/${s.path})`)
139
+ // Themes: every screen is shown in the one picked here (data-theme on its document); checks follow it.
140
+ let THEMES = ['dark']
141
+ let theme = localStorage.getItem('stitch2-theme') ?? 'dark'
142
+ const themed = () => THEMES.length > 1
143
+ const themeNote = () => (themed() && theme !== THEMES[0] ? ` in the ${theme} theme` : '')
144
+ const forTheme = (issues) => (issues ?? []).filter((i) => !i.theme || !themed() || i.theme === theme)
145
+ function applyTheme(f) {
146
+ if (!themed()) return
147
+ try { f.iframe.contentDocument.documentElement.dataset.theme = theme } catch {}
148
+ }
149
+ function setTheme(t) {
150
+ theme = t
151
+ localStorage.setItem('stitch2-theme', t)
152
+ for (const b of document.querySelectorAll('#themeFilter button')) b.classList.toggle('on', b.dataset.v === t)
153
+ for (const f of frames.values()) applyTheme(f)
154
+ for (const f of frames.values()) badge(f)
155
+ if (selected) inspector(selected)
156
+ summary()
157
+ }
158
+ const ref = (s) => (s.kind === 'components' ? `component catalog (${designDir}/${s.path})` : `${s.screen} ${s.device} ${s.version}${themeNote()} (${designDir}/${s.path})`)
138
159
  let toastTimer
139
160
  async function copy(text) {
140
161
  try {
@@ -321,8 +342,8 @@ function measure(f) {
321
342
 
322
343
  function badge(f) {
323
344
  const b = f.el.querySelector('.badge')
324
- const issues = checks.screens?.[f.screen.path]
325
- if (!issues) { b.className = 'badge'; b.textContent = 'not checked'; return }
345
+ if (!checks.screens?.[f.screen.path]) { b.className = 'badge'; b.textContent = 'not checked'; return }
346
+ const issues = forTheme(checks.screens[f.screen.path])
326
347
  const errors = issues.filter((i) => i.severity === 'error').length
327
348
  const warnings = issues.length - errors
328
349
  b.className = `badge ${errors ? 'bad' : warnings ? 'warn' : 'ok'}`
@@ -346,7 +367,7 @@ function summary() {
346
367
  const review = versions.filter((s) => s.status === 'review').length
347
368
  const approved = new Set(versions.filter((s) => s.status === 'approved').map((s) => s.screen)).size
348
369
  const names = new Set(versions.map((s) => s.screen)).size
349
- const bad = versions.filter((s) => checks.screens?.[s.path]?.some((i) => i.severity === 'error')).length
370
+ const bad = versions.filter((s) => forTheme(checks.screens?.[s.path]).some((i) => i.severity === 'error')).length
350
371
  document.getElementById('summary').textContent = `${names} screens · ${approved} approved · ${review} need review${bad ? ` · ${bad} with errors` : ''}`
351
372
  }
352
373
 
@@ -360,7 +381,7 @@ function frameFor(s) {
360
381
  iframe.style.height = `${s.width > 700 ? 800 : 844}px`
361
382
  iframe.src = `/${s.path}?pick`
362
383
  const f = { el, iframe, screen: s, height: s.width > 700 ? 800 : 844 }
363
- iframe.addEventListener('load', () => { measure(f); setTimeout(() => measure(f), 800); setTimeout(() => measure(f), 2500) })
384
+ iframe.addEventListener('load', () => { applyTheme(f); measure(f); setTimeout(() => measure(f), 800); setTimeout(() => measure(f), 2500) })
364
385
  el.querySelector('.shield').addEventListener('click', () => select(f, false))
365
386
  el.querySelector('.shield').addEventListener('dblclick', () => { select(f, false); f.el.classList.add('live') })
366
387
  el.querySelector('.label').addEventListener('click', () => select(f, true))
@@ -387,7 +408,7 @@ function select(f, zoom) {
387
408
  function inspector(f) {
388
409
  const box = document.getElementById('inspector')
389
410
  const s = f.screen
390
- const issues = checks.screens?.[s.path] ?? []
411
+ const issues = forTheme(checks.screens?.[s.path])
391
412
  box.innerHTML = `<h3><span class="dot ${s.status}"></span><span class="h"></span><button type="button" class="copy" title="Copy a reference to paste to an agent">Copy</button><button type="button" class="close" title="Close (Esc)">×</button></h3>
392
413
  <div class="facts"><span class="f"></span> · <a target="_blank"></a></div>
393
414
  ${s.kind === 'screen' ? `<div class="status">${['approved', 'review', 'draft', 'archived'].map((st) => `<button type="button" data-st="${st}" title="${STATUS_HINT[st]}" class="${s.status === st ? 'on' : ''}">${st === 'approved' ? 'Approve' : st === 'review' ? 'Needs review' : st === 'draft' ? 'Draft' : 'Archive'}</button>`).join('')}</div>
@@ -494,7 +515,7 @@ function elementRef(f, el) {
494
515
  const d = describe(el)
495
516
  const s = f.screen
496
517
  const who = s.kind === 'components' ? 'component catalog' : `${s.screen} ${s.device} ${s.version}`
497
- return `${who} (${designDir}/${s.path}:${d.line}) <${d.tag}>${d.text ? ` “${d.text}”` : ''}`
518
+ return `${who}${themeNote()} (${designDir}/${s.path}:${d.line}) <${d.tag}>${d.text ? ` “${d.text}”` : ''}`
498
519
  }
499
520
  function drawBox(f, el, cls, label) {
500
521
  const r = el.getBoundingClientRect()
@@ -602,7 +623,9 @@ events.onmessage = async (e) => {
602
623
  const html = paths.filter((p) => p.endsWith('.html'))
603
624
  if (html.length) await load(false)
604
625
  if (paths.includes('DESIGN.md') && document.getElementById('doc').classList.contains('open')) document.querySelector('#doc iframe').contentWindow.location.reload()
605
- const shared = paths.some((p) => /^(tokens\.css|tailwind\.tokens\.js|components\/)/.test(p))
626
+ // Stylesheets and scripts are shared: tokens.css, tailwind.tokens.js, token exports the screens link
627
+ // (tokens.tailwind.css), the components.
628
+ const shared = paths.some((p) => /\.(css|js|mjs)$/.test(p) || p.startsWith('components/'))
606
629
  for (const f of frames.values()) {
607
630
  if (shared || html.includes(f.screen.path)) {
608
631
  f.iframe.contentWindow?.location.reload()
@@ -658,6 +681,7 @@ addEventListener('keydown', (e) => {
658
681
  if (e.key === 'p' || e.key === 'P') setPicking(!picking)
659
682
  if (e.key === 'Escape') { closeDoc(); select(null) }
660
683
  if (e.key === 'f' || e.key === 'F') fit()
684
+ if ((e.key === 't' || e.key === 'T') && themed()) setTheme(THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length])
661
685
  if (e.key === '0') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, 1)
662
686
  if (e.key === '+' || e.key === '=') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z * 1.2)
663
687
  if (e.key === '-') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z / 1.2)
@@ -681,6 +705,13 @@ fetch('/api/config').then((r) => r.json()).then((c) => {
681
705
  ORDER = c.order ?? []
682
706
  designDir = c.designDir ?? 'design'
683
707
  document.getElementById('labName').textContent = c.name
708
+ THEMES = c.themes?.length ? c.themes : ['dark']
709
+ if (!THEMES.includes(theme)) theme = THEMES[0]
710
+ const seg = document.getElementById('themeFilter')
711
+ seg.hidden = !themed()
712
+ seg.innerHTML = THEMES.map((t) => `<button type="button" data-v="${t}">${t[0].toUpperCase()}${t.slice(1)}</button>`).join('')
713
+ for (const b of seg.querySelectorAll('button')) b.onclick = () => setTheme(b.dataset.v)
714
+ setTheme(theme)
684
715
  document.title = c.name
685
716
  load(true)
686
717
  })
package/dist/check.js CHANGED
@@ -3,8 +3,10 @@
3
3
  * edge, labels wrapping onto two lines, content hidden under fixed bars, tiny tap targets, low text
4
4
  * contrast, and text styles that are not one of DESIGN.md's type levels. Writes design/checks.json, which
5
5
  * the canvas shows as badges.
6
+ * Every screen is checked in its own theme, then again in each other theme DESIGN.md defines (designThemes):
7
+ * colours change there, so contrast is checked again, tagged with the theme.
6
8
  * Usage: stitch2 check [path filter…] [--shots] [--strict]
7
- * --shots also save page-tall renders to design/renders/
9
+ * --shots also save page-tall renders to design/renders/ (one per theme)
8
10
  * --strict exit 1 when any screen has errors (for CI and agent loops)
9
11
  */
10
12
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -13,7 +15,7 @@ import { chromium } from '@playwright/test';
13
15
  import { colourIssues } from './colours.js';
14
16
  import { config } from './config.js';
15
17
  import { collectComponents, collectOptions, consistencyIssues, reuseIssues, } from './consistency.js';
16
- import { typeLevels } from './design-md.js';
18
+ import { designThemes, typeLevels } from './design-md.js';
17
19
  import { listScreens, ROOT } from './screens.js';
18
20
  import { startServer } from './serve.js';
19
21
  import { collectText, typeIssues } from './typecheck.js';
@@ -24,6 +26,7 @@ const strict = args.includes('--strict');
24
26
  const filters = args.filter((a) => !a.startsWith('--'));
25
27
  const screens = listScreens().filter((s) => !filters.length || filters.some((f) => s.path.includes(f)));
26
28
  const levels = typeLevels();
29
+ const themes = designThemes();
27
30
  const collect = collectOptions();
28
31
  const server = await startServer(0);
29
32
  const browser = await chromium.launch();
@@ -39,23 +42,52 @@ for (const s of screens) {
39
42
  await page.waitForTimeout(600);
40
43
  const found = await page.evaluate(collectComponents, collect);
41
44
  components[s.path] = found.components;
45
+ const own = await page.evaluate(() => document.documentElement.dataset.theme || '');
46
+ const base = own || themes[0];
47
+ const layout = await page.evaluate(audit, { comfortable: config.comfortableTarget });
48
+ for (const i of layout)
49
+ if (i.type === 'contrast')
50
+ i.theme = base;
51
+ // The other themes: what the colours change (contrast), plus anything that only shows there.
52
+ const seen = new Set(layout.map((i) => `${i.type} ${i.selector}`));
53
+ const themed = [];
54
+ for (const theme of themes.filter((t) => t !== base)) {
55
+ await setTheme(theme);
56
+ for (const i of await page.evaluate(audit, { comfortable: config.comfortableTarget }))
57
+ if (i.type === 'contrast' || !seen.has(`${i.type} ${i.selector}`))
58
+ themed.push({ ...i, theme, message: `${theme} theme: ${i.message}` });
59
+ if (shots)
60
+ await shoot(s, theme);
61
+ }
62
+ if (themes.length > 1)
63
+ await setTheme(base);
42
64
  results[s.path] = [
43
- ...(await page.evaluate(audit, { comfortable: config.comfortableTarget })),
65
+ ...layout,
66
+ ...themed,
44
67
  ...typeIssues(levels, await page.evaluate(collectText)),
45
68
  ...reuseIssues(s.path, found),
46
69
  ...colourIssues(s.path),
47
70
  ];
48
- if (shots) {
49
- const height = await page.evaluate(() => document.documentElement.scrollHeight);
50
- await page.setViewportSize({ width: s.width, height: Math.max(844, Math.min(height, 6000)) });
51
- mkdirSync(join(ROOT, 'renders'), { recursive: true });
52
- await page.screenshot({
53
- path: join(ROOT, 'renders', s.path.replaceAll('/', '__').replace(/\.html$/, '.png')),
54
- });
55
- }
71
+ if (shots)
72
+ await shoot(s);
56
73
  }
57
74
  await browser.close();
58
75
  server.close();
76
+ async function setTheme(theme) {
77
+ await page.evaluate((t) => {
78
+ document.documentElement.dataset.theme = t;
79
+ }, theme);
80
+ await page.waitForTimeout(150);
81
+ }
82
+ /** A page-tall render; renders in another theme than the screen's own get a --<theme> suffix. */
83
+ async function shoot(s, theme) {
84
+ const height = await page.evaluate(() => document.documentElement.scrollHeight);
85
+ await page.setViewportSize({ width: s.width, height: Math.max(844, Math.min(height, 6000)) });
86
+ mkdirSync(join(ROOT, 'renders'), { recursive: true });
87
+ const name = s.path.replaceAll('/', '__').replace(/\.html$/, theme ? `--${theme}.png` : '.png');
88
+ await page.screenshot({ path: join(ROOT, 'renders', name) });
89
+ await page.setViewportSize({ width: s.width, height: 844 });
90
+ }
59
91
  // Merge with the last results, so a filtered run still compares components with every other screen.
60
92
  let previous = {};
61
93
  try {
package/dist/config.d.ts CHANGED
@@ -20,6 +20,11 @@ export interface LabConfig {
20
20
  order: string[];
21
21
  /** Tap targets smaller than this (px) get a warning; under WCAG's 24px they are errors when crowded. */
22
22
  comfortableTarget: number;
23
+ /**
24
+ * The themes every screen must work in, by their data-theme value. Defaults to DESIGN.md's: "dark" (the plain
25
+ * colours) and "light" when it has light-* colours. The canvas switches between them; check runs in each.
26
+ */
27
+ themes?: string[];
23
28
  /** Skill folders a blind-trial sandbox copies. */
24
29
  skills: string[];
25
30
  /** Paths under designDir a sandbox leaves out besides screens (notes that would give the trial away). */
@@ -15,6 +15,11 @@ export interface Design {
15
15
  components: Record<string, Record<string, string>>;
16
16
  }
17
17
  export declare function readDesign(file?: string): Design;
18
+ /**
19
+ * The themes screens must work in (their data-theme values): the config's, or DESIGN.md's own, where the plain
20
+ * colours are the dark theme and light-* colours make a light one.
21
+ */
22
+ export declare function designThemes(): string[];
18
23
  /** A type level in pixels, for comparing with computed styles. */
19
24
  export interface Level {
20
25
  name: string;
package/dist/design-md.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { readFileSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { parse } from 'yaml';
8
+ import { config } from './config.js';
8
9
  import { ROOT } from './screens.js';
9
10
  export function readDesign(file = join(ROOT, 'DESIGN.md')) {
10
11
  const text = readFileSync(file, 'utf8');
@@ -31,6 +32,20 @@ export function readDesign(file = join(ROOT, 'DESIGN.md')) {
31
32
  components: raw.components ?? {},
32
33
  };
33
34
  }
35
+ /**
36
+ * The themes screens must work in (their data-theme values): the config's, or DESIGN.md's own, where the plain
37
+ * colours are the dark theme and light-* colours make a light one.
38
+ */
39
+ export function designThemes() {
40
+ if (config.themes?.length)
41
+ return config.themes;
42
+ try {
43
+ return Object.keys(readDesign().colors).some((k) => k.startsWith('light-')) ? ['dark', 'light'] : ['dark'];
44
+ }
45
+ catch {
46
+ return ['dark'];
47
+ }
48
+ }
34
49
  export function typeLevels(design = readDesign()) {
35
50
  return Object.entries(design.typography).map(([name, t]) => {
36
51
  const size = Number.parseFloat(String(t.fontSize));
package/dist/serve.js CHANGED
@@ -9,6 +9,7 @@ import { createServer } from 'node:http';
9
9
  import { dirname, extname, join, normalize } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { CONFIG_FILE, config } from './config.js';
12
+ import { designThemes } from './design-md.js';
12
13
  import { designPage } from './design-page.js';
13
14
  import { annotate } from './pick.js';
14
15
  /**
@@ -114,7 +115,7 @@ export function startServer(port) {
114
115
  return;
115
116
  }
116
117
  if (url.pathname === '/api/config')
117
- return json(config);
118
+ return json({ ...config, themes: designThemes() });
118
119
  if (url.pathname === '/api/screens')
119
120
  return json(listScreens());
120
121
  if (url.pathname === '/api/checks') {
@@ -9,6 +9,8 @@ export interface Issue {
9
9
  message: string;
10
10
  selector: string;
11
11
  text: string;
12
+ /** Set when the issue depends on the theme (contrast): the data-theme it was found in. */
13
+ theme?: string;
12
14
  }
13
15
  export interface TextItem {
14
16
  selector: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitch2",
3
- "version": "0.7.3",
3
+ "version": "0.9.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",
@@ -17,6 +17,10 @@
17
17
  "types": "./dist/elements.d.ts",
18
18
  "default": "./dist/elements.js"
19
19
  },
20
+ "./audit": {
21
+ "types": "./dist/audit.d.ts",
22
+ "default": "./dist/audit.js"
23
+ },
20
24
  "./package.json": "./package.json"
21
25
  },
22
26
  "files": [
@@ -47,6 +47,11 @@ References, read before the first screen:
47
47
  5. **Check** `stitch2 check <path> --shots --strict`. Fix every error (overflow, wrapped labels, content under
48
48
  fixed bars, fixed bars overlapping each other, tap targets, contrast, cramped text) and every warning about type (`type-*`), hand-built
49
49
  components (`reuse`), components that differ from the catalog (`consistency`) and colour values (`color`).
50
+ Text running into other text (`text-overlap`) or out of its card (`overflow`) is an error; a short label cut
51
+ short with an ellipsis (`truncated`) is a warning to fix with room, not a smaller size. When DESIGN.md has a
52
+ light and a dark theme, every screen is checked in both: an issue marked with a theme
53
+ ("light theme: Low contrast…") is fixed in DESIGN.md's colours for that theme when it shows on many
54
+ screens, or in the screen when it is one element. Look at both renders (`--light.png`).
50
55
  6. **Look and critique.** View the render in `<design>/renders/` (crop the first screenful). Score it with the
51
56
  rubric in principles.md, fix what fails, repeat 5–6 at most three times.
52
57
  7. **Hand over.** Set the version's status to `review`, point the user to it on the canvas, and report what