stitch2 0.13.0 → 0.14.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
@@ -68,17 +68,18 @@ file that already exists. From there the agent spends its effort on the design.
68
68
  { "format": "dtcg", "path": "design/tokens.json" }
69
69
  ]
70
70
  ```
71
- - **Versions and approval**: every file is a version of a screen for a device, with its status (draft, review,
72
- approved, archived) and your note in meta tags, so agents can read what you decided (`stitch2 screens`).
71
+ - **Versions, variants and approval**: a screen has versions (v1, v2, …), and each version has variants, its
72
+ layouts for mobile and desktop (`mobile-v3.html`, `desktop-v3.html`). The canvas groups them; you approve a
73
+ version as a whole or one variant at a time, leave notes, and approving archives the previous approved layout.
73
74
  - **DESIGN.md builder**: `stitch2 init` writes a complete DESIGN.md from a few choices (brand colour, modes,
74
75
  fonts, roundness, density), with colours checked for contrast; `stitch2 extract` measures one from an existing
75
76
  site or screens.
76
- - **Fresh-eyes variants** (`stitch2 sandbox`): a folder with your design system, components and skills but no
77
+ - **Fresh-eyes alternatives** (`stitch2 sandbox`): a folder with your design system, components and skills but no
77
78
  screens, where a fresh agent designs from a brief without being anchored to the existing versions;
78
79
  `stitch2 sandbox import` brings the result back as the next version for review. Also the way to test a change
79
80
  to the skills on output only the rules produced.
80
81
  - **Skills** for agents (`skills/`): `stitch2` (the design workflow, principles, typography, components,
81
- versions), `stitch2-design-md` (building the design system), `stitch2-variants` (fresh-eyes versions and
82
+ versions), `stitch2-design-md` (building the design system), `stitch2-alternatives` (fresh-eyes versions and
82
83
  testing skill changes), `stitch2-consistency` (verifying and fixing
83
84
  consistency), `stitch2-i18n` (screens in every language), `stitch2-app-sync` (keeping the app and the designs in step: design first, then the app) and,
84
85
  optional, `stitch2-import-from-google-stitch` (drafting screens in Google Stitch through its MCP server).
package/dist/audit.js CHANGED
@@ -105,10 +105,20 @@ export async function audit(opts) {
105
105
  }
106
106
  }
107
107
  // 3. Fixed bars (tab bar, rest timer, headers) never cover content: check at both scroll ends.
108
+ // A fixed element inside a transformed (or filtered, or contained) box is placed in that box, not on the
109
+ // screen: a catalog showing a tab bar in place, for one. Only bars pinned to the screen count.
110
+ const pinned = (el) => {
111
+ for (let a = el.parentElement; a; a = a.parentElement) {
112
+ const cs = getComputedStyle(a);
113
+ if (cs.transform !== 'none' || cs.filter !== 'none' || cs.perspective !== 'none' || /paint|layout|strict|content/.test(cs.contain))
114
+ return false;
115
+ }
116
+ return true;
117
+ };
108
118
  const bars = all.filter((el) => {
109
119
  const p = getComputedStyle(el).position;
110
120
  const r = el.getBoundingClientRect();
111
- return (p === 'fixed' || p === 'sticky') && r.height <= 240 && r.width >= W * 0.6;
121
+ return (p === 'sticky' || (p === 'fixed' && pinned(el))) && r.height <= 240 && r.width >= W * 0.6;
112
122
  });
113
123
  // Behind an open modal the page is inert and covered, so only the modal's own bars count.
114
124
  // An open modal: the element may be 0px itself when everything in it is fixed (Headless UI's Dialog).
@@ -212,14 +222,35 @@ export async function audit(opts) {
212
222
  return e;
213
223
  return null;
214
224
  };
225
+ // The part of a control its scrolling or clipping ancestors show: a link scrolled out of a nav strip is not drawn
226
+ // where its box would be.
227
+ const visibleBox = (el) => {
228
+ const r = el.getBoundingClientRect();
229
+ let { left, top, right, bottom } = r;
230
+ for (let e = el.parentElement; e && e !== document.body; e = e.parentElement) {
231
+ const cs = getComputedStyle(e);
232
+ if (cs.overflowX === 'visible' && cs.overflowY === 'visible')
233
+ continue;
234
+ const c = e.getBoundingClientRect();
235
+ if (cs.overflowX !== 'visible') {
236
+ left = Math.max(left, c.left);
237
+ right = Math.min(right, c.right);
238
+ }
239
+ if (cs.overflowY !== 'visible') {
240
+ top = Math.max(top, c.top);
241
+ bottom = Math.min(bottom, c.bottom);
242
+ }
243
+ }
244
+ return { left, top, right, bottom };
245
+ };
215
246
  // Behind an open modal (found above) only the modal's own controls count.
216
247
  const solid = interactive.filter((el) => opacity(el) > 0.05 && (!modal || modal.contains(el)));
217
248
  for (const [i, a] of solid.entries())
218
249
  for (const b of solid.slice(i + 1)) {
219
250
  if (a.contains(b) || b.contains(a) || layer(a) !== layer(b))
220
251
  continue;
221
- const r = a.getBoundingClientRect();
222
- const q = b.getBoundingClientRect();
252
+ const r = visibleBox(a);
253
+ const q = visibleBox(b);
223
254
  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)
224
255
  push('error', 'control-overlap', b, `Overlaps another control (“${text(a).slice(0, 30) || a.getAttribute('aria-label') || a.tagName.toLowerCase()}”): give them room`);
225
256
  }
package/dist/canvas.html CHANGED
@@ -15,6 +15,10 @@
15
15
  .row-label { align-items: baseline; display: flex; gap: 12px; overflow: hidden; position: absolute; white-space: nowrap; }
16
16
  .row-label b { color: var(--text); font-size: 24px; }
17
17
  .row-label span { color: var(--muted); font-size: 15px; overflow: hidden; text-overflow: ellipsis; }
18
+ .version-label { align-items: baseline; border-top: 2px solid var(--edge); display: flex; gap: 10px; overflow: hidden; padding-top: 8px; position: absolute; white-space: nowrap; }
19
+ .version-label b { color: var(--text); font-size: 18px; }
20
+ .version-label span { color: var(--muted); font-size: 14px; overflow: hidden; text-overflow: ellipsis; }
21
+ .version-label.review { border-color: var(--warn); } .version-label.approved { border-color: var(--good); }
18
22
  #doc { background: var(--bg); display: none; inset: 0 0 0 var(--side); position: fixed; z-index: 19; }
19
23
  #doc.open { display: block; }
20
24
  .doc-frame { border: 0; height: 100%; width: 100%; }
@@ -27,13 +31,29 @@
27
31
  .seg button { background: none; border: 0; border-radius: 6px; color: var(--muted); cursor: pointer; flex: 1; padding: 4px 6px; white-space: nowrap; }
28
32
  .seg button.on { background: var(--panel2); color: var(--text); }
29
33
  .seg[hidden] { display: none; }
30
- #langFilter { background: var(--bg); border: 1px solid var(--edge); border-radius: 8px; color: var(--text); padding: 5px 8px; width: 100%; }
34
+ .multi { position: relative; }
35
+ .multi[hidden] { display: none; }
36
+ .multi summary { align-items: center; background: var(--bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%23a1a1aa' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 10px center / 12px 8px; border: 1px solid var(--edge); border-radius: 8px; cursor: pointer; display: flex; gap: 8px; list-style: none; padding: 5px 30px 5px 8px; }
37
+ .multi summary::-webkit-details-marker { display: none; }
38
+ .multi .menu { background: var(--panel); border: 1px solid var(--edge); border-radius: 8px; box-shadow: 0 12px 32px rgba(0,0,0,.5); left: 0; max-height: 280px; overflow: auto; padding: 4px; position: absolute; right: 0; top: calc(100% + 4px); z-index: 30; }
39
+ .multi label { align-items: center; border-radius: 6px; color: var(--muted); cursor: pointer; display: flex; gap: 8px; padding: 5px 8px; }
40
+ .multi label:hover { background: var(--panel2); color: var(--text); }
41
+ .multi label.all { border-bottom: 1px solid var(--edge); border-radius: 6px 6px 0 0; margin-bottom: 4px; }
42
+ .multi input { accent-color: var(--accent); margin: 0; }
43
+ .multi .n { color: var(--faint); margin-left: auto; }
44
+ /* Theme and language: native dropdowns drawn like the versions and variants ones. */
45
+ .pick { appearance: none; background: var(--bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%23a1a1aa' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 10px center / 12px 8px; border: 1px solid var(--edge); border-radius: 8px; color: var(--text); cursor: pointer; font: inherit; padding: 5px 30px 5px 8px; width: 100%; }
46
+ .pick[hidden] { display: none; }
31
47
  #tree { flex: 1; overflow: auto; padding: 6px 8px 16px; }
32
48
  #tree h4 { color: var(--faint); font-size: 11px; letter-spacing: .06em; margin: 14px 6px 6px; text-transform: uppercase; }
33
49
  .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%; }
34
50
  .item:hover { background: var(--panel2); color: var(--text); }
35
51
  .item.on { background: #2a1a12; color: var(--text); }
36
52
  .item .meta { color: var(--faint); font-size: 11px; margin-left: auto; white-space: nowrap; }
53
+ .item.variant { padding-left: 30px; }
54
+ #inspector .version-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; }
55
+ #inspector .version-actions span { color: var(--muted); }
56
+ #inspector .version-actions button { background: var(--panel2); border: 1px solid var(--edge); border-radius: 8px; cursor: pointer; padding: 3px 8px; }
37
57
  .screen-name { align-items: center; border-radius: 6px; color: var(--text); cursor: pointer; display: flex; font-weight: 600; gap: 6px; padding: 8px 8px 2px; }
38
58
  .screen-name:hover { background: var(--panel2); }
39
59
  .screen-name .meta { color: var(--faint); font-size: 11px; font-weight: 400; margin-left: auto; }
@@ -106,9 +126,10 @@
106
126
  <b id="labName">stitch2</b>
107
127
  <input id="search" placeholder="Search screens… ( / )" />
108
128
  <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>
109
- <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>
110
- <div class="seg" id="themeFilter" hidden title="Show every screen in this theme (T)"></div>
111
- <select id="langFilter" title="Show every screen in this language (L)" aria-label="Language"></select>
129
+ <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>
130
+ <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>
131
+ <select id="themeFilter" class="pick" hidden title="Show every screen in this theme (T)" aria-label="Theme"></select>
132
+ <select id="langFilter" class="pick" title="Show every screen in this language (L)" aria-label="Language"></select>
112
133
  </header>
113
134
  <nav id="tree"></nav>
114
135
  <footer>
@@ -172,7 +193,7 @@ function applyTheme(f) {
172
193
  function setTheme(t) {
173
194
  theme = t
174
195
  localStorage.setItem('stitch2-theme', t)
175
- for (const b of document.querySelectorAll('#themeFilter button')) b.classList.toggle('on', b.dataset.v === t)
196
+ document.getElementById('themeFilter').value = t
176
197
  for (const f of frames.values()) applyTheme(f)
177
198
  for (const f of frames.values()) badge(f)
178
199
  if (selected) inspector(selected)
@@ -207,7 +228,8 @@ const copyButton = (text, title) => {
207
228
  return b
208
229
  }
209
230
  let view = JSON.parse(localStorage.getItem('stitch2-view') ?? 'null') ?? { x: 40, y: 60, z: 0.4 }
210
- const filters = JSON.parse(localStorage.getItem('stitch2-filters') ?? 'null') ?? { status: 'active', device: 'all' }
231
+ const filters = JSON.parse(localStorage.getItem('stitch2-filters') ?? 'null') ?? { status: 'active' }
232
+ if (typeof filters.device === 'string') { filters.devices = filters.device === 'all' ? [] : [filters.device]; delete filters.device }
211
233
  let screens = []
212
234
  let checks = { screens: {} }
213
235
  let selected = null
@@ -230,16 +252,27 @@ const zoomAt = (cx, cy, z) => {
230
252
  apply()
231
253
  }
232
254
 
233
- // ---- grouping: rows are app screens (components first), frames are versions ----
255
+ // ---- grouping: rows are app screens (components first); in a row, versions (v1, v2, …) in order, and in a
256
+ // version its variants: the layouts for each device (mobile, desktop), files <device>-v<N> sharing N ----
234
257
  const rowKey = (s) => (s.kind === 'components' ? '~components' : s.screen)
235
258
  const rowRank = (k) => (k === '~components' ? -1 : ORDER.includes(k) ? ORDER.indexOf(k) : 100)
236
259
  const sortRows = (keys) => keys.sort((a, b) => rowRank(a) - rowRank(b) || a.localeCompare(b))
260
+ const versionRank = (v) => { const m = /^v(\d+)$/.exec(v); return m ? Number(m[1]) : 0 }
261
+ const DEVICE_RANK = { mobile: 0, tablet: 1, desktop: 2 }
237
262
  const sortVersions = (list) =>
238
- list.sort((a, b) => (a.device === b.device ? 0 : a.device === 'mobile' ? -1 : 1) || STATUS_RANK[a.status] - STATUS_RANK[b.status] || b.mtime - a.mtime)
263
+ list.sort((a, b) => versionRank(a.version) - versionRank(b.version) || a.version.localeCompare(b.version) || (DEVICE_RANK[a.device] ?? 9) - (DEVICE_RANK[b.device] ?? 9) || b.mtime - a.mtime)
264
+ /** A version's variants, and one status for all of them: needs review if any does, else the most settled. */
265
+ const versionsOf = (list) => {
266
+ const by = new Map()
267
+ for (const s of list) { const k = s.kind === 'components' ? s.path : s.version; by.set(k, [...(by.get(k) ?? []), s]) }
268
+ return [...by.entries()].map(([version, variants]) => ({ version, variants, status: variants.some((v) => v.status === 'review') ? 'review' : variants.some((v) => v.status === 'draft') ? 'draft' : variants.some((v) => v.status === 'approved') ? 'approved' : 'archived' }))
269
+ }
270
+ const VERSION_H = 44, VERSION_GAP = 120
239
271
  function shown(s) {
240
272
  const q = document.getElementById('search').value.trim().toLowerCase()
241
273
  if (q && !`${s.screen} ${s.title} ${s.path} ${s.version} ${s.source}`.toLowerCase().includes(q)) return false
242
- if (filters.device !== 'all' && s.kind === 'screen' && s.device !== filters.device) return false
274
+ if (filters.devices?.length && s.kind === 'screen' && !filters.devices.includes(s.device)) return false
275
+ if (filters.versions?.length && s.kind === 'screen' && !filters.versions.includes(s.version)) return false
243
276
  if (s.kind === 'components') return filters.status !== 'review' && filters.status !== 'approved'
244
277
  if (filters.status === 'active') return s.status !== 'archived'
245
278
  if (filters.status === 'all') return true
@@ -258,11 +291,13 @@ function layout() {
258
291
  for (const row of rows()) {
259
292
  for (const s of row.all) frames.get(s.path).el.style.display = row.visible.includes(s) ? '' : 'none'
260
293
  if (!row.visible.length) continue
261
- const groupW = row.visible.reduce((w, s) => w + s.width, 0) + GAP_X * (row.visible.length - 1)
294
+ const groups = versionsOf(row.visible)
295
+ const widthOf = (g) => g.variants.reduce((w, s) => w + s.width, 0) + GAP_X * (g.variants.length - 1)
296
+ const groupW = groups.reduce((w, g) => w + widthOf(g), 0) + VERSION_GAP * (groups.length - 1)
262
297
  if (x > 0 && x + groupW > LINE_W) { x = 0; y += lineH + GAP_Y; lineH = 0 }
263
298
  const label = document.createElement('div')
264
299
  label.className = 'row-label'
265
- const approved = row.all.filter((s) => s.status === 'approved').map((s) => `${s.device} ${s.version}`)
300
+ const approved = row.all.filter((s) => s.status === 'approved').map((s) => `${s.version} ${s.device}`)
266
301
  label.innerHTML = '<b></b><span></span>'
267
302
  label.querySelector('b').textContent = row.key === '~components' ? 'Components' : row.key
268
303
  label.querySelector('span').textContent = row.key === '~components' ? 'the catalog every screen is built from' : approved.length ? `approved: ${approved.join(', ')}` : 'nothing approved yet'
@@ -270,12 +305,26 @@ function layout() {
270
305
  label.style.maxWidth = `${groupW}px`
271
306
  world.append(label)
272
307
  let fx = x, tallest = 0
273
- for (const s of row.visible) {
274
- const f = frames.get(s.path)
275
- f.el.style.left = `${fx}px`
276
- f.el.style.top = `${y + ROW_LABEL_H}px`
277
- fx += s.width + GAP_X
278
- tallest = Math.max(tallest, f.height + LABEL_H)
308
+ const top = row.key === '~components' ? 0 : VERSION_H
309
+ for (const g of groups) {
310
+ if (top) {
311
+ const v = document.createElement('div')
312
+ v.className = `row-label version-label ${g.status}`
313
+ v.innerHTML = '<b></b><span></span>'
314
+ v.querySelector('b').textContent = g.version
315
+ v.querySelector('span').textContent = `${STATUS_LABEL[g.status]} · ${g.variants.map((s) => s.device).join(', ')}`
316
+ v.style.transform = `translate(${fx}px, ${y + ROW_LABEL_H}px)`
317
+ v.style.width = `${widthOf(g)}px`
318
+ world.append(v)
319
+ }
320
+ for (const s of g.variants) {
321
+ const f = frames.get(s.path)
322
+ f.el.style.left = `${fx}px`
323
+ f.el.style.top = `${y + ROW_LABEL_H + top}px`
324
+ fx += s.width + GAP_X
325
+ tallest = Math.max(tallest, f.height + LABEL_H + top)
326
+ }
327
+ fx += VERSION_GAP - GAP_X
279
328
  }
280
329
  lineH = Math.max(lineH, ROW_LABEL_H + tallest)
281
330
  x += groupW + GROUP_GAP
@@ -321,11 +370,17 @@ function tree() {
321
370
  const approved = row.all.some((s) => s.status === 'approved')
322
371
  name.innerHTML = `<span class="dot ${approved ? 'approved' : row.all.some((s) => s.status === 'review') ? 'review' : 'draft'}"></span><span></span><span class="meta"></span>`
323
372
  name.children[1].textContent = row.key
324
- name.querySelector('.meta').textContent = `${row.visible.length} version${row.visible.length > 1 ? 's' : ''}`
373
+ const vs = versionsOf(row.visible)
374
+ name.querySelector('.meta').textContent = `${vs.length} version${vs.length > 1 ? 's' : ''}`
325
375
  name.onclick = () => select(frames.get(row.visible[0].path), true)
326
376
  name.append(copyButton(row.key, 'Copy the screen name'))
327
377
  nav.append(name)
328
- for (const s of row.visible) item(`${s.version} · ${s.device}`, STATUS_LABEL[s.status], selected?.screen.path === s.path, () => select(frames.get(s.path), true), s.status, 'version', ref(s))
378
+ for (const g of vs) {
379
+ const one = g.variants.length === 1
380
+ const first = g.variants[0]
381
+ item(one ? `${g.version} · ${first.device}` : g.version, one ? STATUS_LABEL[first.status] : `${STATUS_LABEL[g.status]} · ${g.variants.length} variants`, one && selected?.screen.path === first.path, () => select(frames.get(first.path), true), g.status, 'version', one ? ref(first) : `${row.key} ${g.version}`)
382
+ if (!one) for (const s of g.variants) item(s.device, STATUS_LABEL[s.status], selected?.screen.path === s.path, () => select(frames.get(s.path), true), s.status, 'variant', ref(s))
383
+ }
329
384
  }
330
385
  }
331
386
 
@@ -377,7 +432,7 @@ function badge(f) {
377
432
  function paintLabel(f) {
378
433
  const s = f.screen
379
434
  f.el.className = `frame ${s.device} ${s.status}${selected === f ? ' selected' : ''}`
380
- f.el.querySelector('.name').textContent = s.kind === 'components' ? 'Catalog' : `${s.version}`
435
+ f.el.querySelector('.name').textContent = s.kind === 'components' ? 'Catalog' : s.device
381
436
  const pill = f.el.querySelector('.pill')
382
437
  pill.className = `pill ${s.status}`
383
438
  pill.textContent = s.kind === 'components' ? 'components' : `${STATUS_LABEL[s.status]} · ${s.source}`
@@ -464,7 +519,16 @@ function inspector(f) {
464
519
  }
465
520
  box.insertBefore(sec, box.querySelector('h4'))
466
521
  }
467
- box.querySelector('.h').textContent = s.kind === 'components' ? 'Component catalog' : `${s.screen} · ${s.version}`
522
+ box.querySelector('.h').textContent = s.kind === 'components' ? 'Component catalog' : `${s.screen} · ${s.version} · ${s.device}`
523
+ const siblings = s.kind === 'screen' ? sortVersions(screens.filter((o) => o.kind === 'screen' && o.screen === s.screen && o.version === s.version)) : []
524
+ if (siblings.length > 1) {
525
+ const vrow = document.createElement('div')
526
+ vrow.className = 'version-actions'
527
+ vrow.innerHTML = '<span></span><button type="button" data-st="approved" title="Approve every variant of this version">Approve version</button><button type="button" data-st="archived" title="Archive every variant of this version">Archive version</button>'
528
+ vrow.querySelector('span').textContent = `${s.version}: ${siblings.map((o) => `${o.device} ${STATUS_LABEL[o.status]}`).join(', ')}`
529
+ for (const b of vrow.querySelectorAll('button')) b.onclick = async () => { for (const o of siblings) if (o.status !== b.dataset.st && !(b.dataset.st === 'approved' && o.status === 'archived')) await setMeta(o.path, { status: b.dataset.st }) }
530
+ box.querySelector('.status').before(vrow)
531
+ }
468
532
  box.querySelector('.f').textContent = `${s.device} · ${s.source}`
469
533
  const a = box.querySelector('.facts a')
470
534
  a.href = `/${s.path}`
@@ -620,6 +684,8 @@ async function load(first, given) {
620
684
  if (f) { f.screen = s; paintLabel(f) } else frameFor(s)
621
685
  }
622
686
  screens = next
687
+ versionFilter()
688
+ variantFilter()
623
689
  layout()
624
690
  await loadChecks()
625
691
  if (selected) inspector(selected)
@@ -627,7 +693,7 @@ async function load(first, given) {
627
693
  }
628
694
 
629
695
  // ---- filters ----
630
- for (const [id, key] of [['statusFilter', 'status'], ['deviceFilter', 'device']]) {
696
+ for (const [id, key] of [['statusFilter', 'status']]) {
631
697
  const seg = document.getElementById(id)
632
698
  for (const b of seg.querySelectorAll('button')) {
633
699
  b.classList.toggle('on', b.dataset.v === filters[key])
@@ -641,6 +707,57 @@ for (const [id, key] of [['statusFilter', 'status'], ['deviceFilter', 'device']]
641
707
  }
642
708
  document.getElementById('search').oninput = layout
643
709
 
710
+ // Versions and variants: multi-selects of every label the screens use (v1 … v12 and imports such as "Stitch";
711
+ // mobile, tablet, desktop), with how many screens carry each; none ticked means all. The catalog always shows.
712
+ const MULTI = []
713
+ for (const id of ['versionFilter', 'variantFilter']) {
714
+ const box = document.getElementById(id)
715
+ MULTI.push(box)
716
+ }
717
+ document.addEventListener('click', (e) => { for (const box of MULTI) if (box.open && !box.contains(e.target)) box.open = false })
718
+ document.addEventListener('keydown', (e) => { const open = MULTI.find((b) => b.open); if (e.key === 'Escape' && open) { open.open = false; e.stopPropagation() } }, true)
719
+ function multiFilter({ id, key, of, sort, all, many, again }) {
720
+ const box = document.getElementById(id)
721
+ const counts = new Map()
722
+ for (const s of screens) if (s.kind === 'screen') counts.set(of(s), (counts.get(of(s)) ?? 0) + 1)
723
+ const labels = [...counts.keys()].sort(sort)
724
+ filters[key] = (filters[key] ?? []).filter((v) => labels.includes(v))
725
+ box.hidden = labels.length < 2
726
+ const picked = filters[key]
727
+ box.querySelector('summary').textContent = !picked.length ? all : picked.length <= 3 ? [...picked].sort(sort).join(', ') : `${picked.length} ${many}`
728
+ const change = (next) => {
729
+ filters[key] = next
730
+ localStorage.setItem('stitch2-filters', JSON.stringify(filters))
731
+ again()
732
+ layout()
733
+ }
734
+ const option = (label, checked, n, onChange, cls = '') => {
735
+ const l = document.createElement('label')
736
+ l.className = cls
737
+ const input = document.createElement('input')
738
+ input.type = 'checkbox'
739
+ input.checked = checked
740
+ input.onchange = onChange
741
+ const name = document.createElement('span')
742
+ name.textContent = label
743
+ const count = document.createElement('span')
744
+ count.className = 'n'
745
+ count.textContent = n
746
+ l.append(input, name, count)
747
+ return l
748
+ }
749
+ box.querySelector('.menu').replaceChildren(
750
+ option(all, !picked.length, screens.filter((s) => s.kind === 'screen').length, () => change([]), 'all'),
751
+ ...labels.map((v) => option(v, picked.includes(v), counts.get(v), () => change(picked.includes(v) ? picked.filter((x) => x !== v) : [...picked, v]))),
752
+ )
753
+ }
754
+ function versionFilter() {
755
+ multiFilter({ id: 'versionFilter', key: 'versions', of: (s) => s.version, sort: (a, b) => a.localeCompare(b, undefined, { numeric: true }), all: 'All versions', many: 'versions', again: versionFilter })
756
+ }
757
+ function variantFilter() {
758
+ multiFilter({ id: 'variantFilter', key: 'devices', of: (s) => s.device, sort: (a, b) => (DEVICE_RANK[a] ?? 9) - (DEVICE_RANK[b] ?? 9), all: 'All variants', many: 'variants', again: variantFilter })
759
+ }
760
+
644
761
  // ---- live reload ----
645
762
  const events = new EventSource('/events')
646
763
  events.onmessage = async (e) => {
@@ -734,10 +851,10 @@ fetch('/api/config').then((r) => r.json()).then((c) => {
734
851
  document.getElementById('labName').textContent = c.name
735
852
  THEMES = c.themes?.length ? c.themes : ['dark']
736
853
  if (!THEMES.includes(theme)) theme = THEMES[0]
737
- const seg = document.getElementById('themeFilter')
738
- seg.hidden = !themed()
739
- seg.innerHTML = THEMES.map((t) => `<button type="button" data-v="${t}">${t[0].toUpperCase()}${t.slice(1)}</button>`).join('')
740
- for (const b of seg.querySelectorAll('button')) b.onclick = () => setTheme(b.dataset.v)
854
+ const pick = document.getElementById('themeFilter')
855
+ pick.hidden = !themed()
856
+ pick.innerHTML = THEMES.map((t) => `<option value="${t}">${t[0].toUpperCase()}${t.slice(1)} theme</option>`).join('')
857
+ pick.onchange = () => setTheme(pick.value)
741
858
  setTheme(theme)
742
859
  SOURCE = c.sourceLocale ?? 'en'
743
860
  LANGS = c.locales?.length ? c.locales : [SOURCE]
@@ -248,7 +248,8 @@ export function consistencyIssues(known, paths) {
248
248
  out[path] = issues;
249
249
  const peers = (families.get(familyOf(path)) ?? []).filter((p) => p !== path);
250
250
  for (const [kind, sigs] of Object.entries(known[path] ?? {})) {
251
- if (sigs.length > 1 && MEASURED.has(kind.split(':')[0]))
251
+ // The catalog shows every variant of a component side by side; a screen shows one.
252
+ if (sigs.length > 1 && MEASURED.has(kind.split(':')[0]) && !catalogs.includes(path))
252
253
  issues.push({
253
254
  severity: 'warning',
254
255
  type: 'consistency',
package/dist/list.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Lists every screen version with its status and the reviewer's note, for agents (and anyone without the
2
+ * Lists every screen version, variant by variant (mobile, desktop), with its status and the reviewer's note, for agents (and anyone without the
3
3
  * canvas open). Approved versions are the reference; review versions wait for the user; notes are feedback.
4
4
  * Usage: stitch2 screens [screen name] [--status approved|review|draft|archived] [--json]
5
5
  */
@@ -9,9 +9,11 @@ const status = args.includes('--status') ? args[args.indexOf('--status') + 1] :
9
9
  const name = args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--status');
10
10
  const rows = listScreens()
11
11
  .filter((s) => s.kind === 'screen' && (!status || s.status === status) && (!name || s.screen === name))
12
- .sort((a, b) => a.screen.localeCompare(b.screen) || a.device.localeCompare(b.device) || b.mtime - a.mtime);
12
+ .sort((a, b) => a.screen.localeCompare(b.screen) ||
13
+ a.version.localeCompare(b.version, undefined, { numeric: true }) ||
14
+ b.device.localeCompare(a.device));
13
15
  if (args.includes('--json'))
14
16
  console.log(JSON.stringify(rows, null, 2));
15
17
  else
16
18
  for (const s of rows)
17
- console.log(`${s.screen.padEnd(20)} ${s.device.padEnd(8)} ${s.version.padEnd(8)} ${s.status.padEnd(9)} ${s.path}${s.note ? `\n${' '.repeat(39)}note: ${s.note}` : ''}`);
19
+ console.log(`${s.screen.padEnd(20)} ${s.version.padEnd(8)} ${s.device.padEnd(8)} ${s.status.padEnd(9)} ${s.path}${s.note ? `\n${' '.repeat(39)}note: ${s.note}` : ''}`);
package/dist/setup.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * --name and --primary, DESIGN.md and its tokens. Existing files are never overwritten, so it is safe to re-run.
7
7
  */
8
8
  import { execFileSync } from 'node:child_process';
9
- import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from 'node:fs';
9
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
10
10
  import { basename, dirname, join, relative, resolve } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { CONFIG_FILE, CONFIG_NAME, config as current } from './config.js';
@@ -42,6 +42,16 @@ const skills = readdirSync(join(pkg, 'skills'));
42
42
  const linked = [];
43
43
  for (const dir of skillDirs) {
44
44
  mkdirSync(resolve(project, dir), { recursive: true });
45
+ // A skill renamed or removed in a newer stitch2 leaves a link to nothing: remove it.
46
+ for (const s of readdirSync(resolve(project, dir))) {
47
+ const at = resolve(project, dir, s);
48
+ if (!lstatSync(at).isSymbolicLink() || existsSync(at))
49
+ continue;
50
+ if (!/(^|[\\/])stitch2[\\/]skills[\\/]/.test(readlinkSync(at)))
51
+ continue;
52
+ unlinkSync(at);
53
+ done.push(`removed ${join(dir, s)} (no longer in stitch2)`);
54
+ }
45
55
  for (const s of skills) {
46
56
  const at = join(dir, s);
47
57
  linked.push(at);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitch2",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",
@@ -24,7 +24,8 @@ References, read before the first screen:
24
24
  - [principles.md](references/principles.md): what makes a screen read well, and the review rubric.
25
25
  - [typography.md](references/typography.md): type levels and how to combine them; most polish is here.
26
26
  - [components.md](references/components.md): build from the shared components; the reuse ladder.
27
- - [screens.md](references/screens.md): screen names, versions, statuses, the user's approval and notes, desktop.
27
+ - [screens.md](references/screens.md): screen names, versions and their variants (mobile, desktop), statuses,
28
+ the user's approval and notes.
28
29
  - [google.md](references/google.md): Google's guidance (Material, Android, web.dev) as rules with numbers.
29
30
  - [guides.md](references/guides.md): the public sources behind the rules.
30
31
 
@@ -40,8 +41,9 @@ References, read before the first screen:
40
41
  3. **Hierarchy before markup.** Name the one thing the user came for, the single primary action (the only
41
42
  accent-filled element in its region), and what is secondary. Give every text element a type level and a
42
43
  colour before writing markup.
43
- 4. **Build** the next version, `<screens>/<screen>/<device>-v<N>.html`, from the project's template (status
44
- `draft`; never edit an approved version). Assemble it from the components; plain markup only where none
44
+ 4. **Build** the next version, `<screens>/<screen>/<device>-v<N>.html`, or a missing variant of an existing
45
+ version (another device, the same N), from the project's template (status `draft`; never edit an approved
46
+ variant). Assemble it from the components; plain markup only where none
45
47
  applies, with token classes, `.type-<level>` classes for text, the spacing scale, and the states DESIGN.md
46
48
  defines. When the config has `i18n`, mark every product string with its message key (`data-t`, see
47
49
  **stitch2-i18n**). Never add classes to a component to change how it looks, and never write a colour value (hex,
@@ -82,11 +84,11 @@ References, read before the first screen:
82
84
  | `lint` | Google's DESIGN.md linter |
83
85
  | `localize [filter]` | Copies remote images a screen uses into its `assets/` folder |
84
86
  | `init`, `extract` | Build DESIGN.md from choices, or measure it from an existing site (stitch2-design-md) |
85
- | `sandbox create <dir> <brief.md>…`, `sandbox import <dir>` | Fresh-eyes versions from agents that see no other versions (stitch2-variants) |
87
+ | `sandbox create <dir> <brief.md>…`, `sandbox import <dir>` | Fresh-eyes versions from agents that see no other versions (stitch2-alternatives) |
86
88
 
87
89
  ## Alternatives and fresh eyes
88
90
 
89
91
  When the user wants options or a new direction, or a design is stuck, do not iterate on the existing versions:
90
- use the **stitch2-variants** skill. Fresh agents in sandboxes design from the brief without seeing the other
92
+ use the **stitch2-alternatives** skill. Fresh agents in sandboxes design from the brief without seeing the other
91
93
  versions, and their screens come back as new versions to compare on the canvas. The same skill tests changes to
92
94
  the skills or DESIGN.md.
@@ -1,14 +1,18 @@
1
1
  # Screens, versions and approval
2
2
 
3
- The canvas and the agents share one model: an app **screen** (home, settings, …) has **versions**, each for a
4
- **device** (mobile or desktop), and each version has a **status**. The user decides; agents read the decision
3
+ The canvas and the agents share one model: an app **screen** (home, settings, …) has **versions**, design
4
+ iterations numbered v1, v2, v3…, and each version has **variants**, its layouts for each device (mobile,
5
+ desktop, later tablet). Each variant has a **status**. The user decides; agents read the decision
5
6
  and build on it. Meta tag names below use the project's prefix (`<p>`, from `stitch2.config.json`).
6
7
 
7
8
  ## Names and files
8
9
 
9
10
  - Screen names are lowercase with hyphens and name what the user sees; one per step of a flow
10
11
  (`checkout-1`, `checkout-2`). Reuse existing names (`stitch2 screens` lists them).
11
- - A version is one file, `<screens dir>/<screen>/<device>-v<N>.html`, N the next free number for that device.
12
+ - A variant is one file, `<screens dir>/<screen>/<device>-v<N>.html`; the variants of a version share N
13
+ (`mobile-v3.html` and `desktop-v3.html` are v3). A new version takes the next free N across the screen's
14
+ variants; a variant added to an existing version takes that version's N (a desktop for v3 is
15
+ `desktop-v3.html`, never v4). A version may start with one variant and get the others later.
12
16
  Its head carries `<p>-screen`, `<p>-device` (mobile | desktop), `<p>-status`, `<p>-source` (stitch | lab),
13
17
  `<p>-frame-width` (390 mobile, 1280 desktop) and, written by the user, `<p>-note`. Older files elsewhere
14
18
  carry the same tags.
@@ -17,9 +21,11 @@ and build on it. Meta tag names below use the project's prefix (`<p>`, from `sti
17
21
 
18
22
  - **New version** for every new attempt: a new direction, a regeneration, an export from a generator, a
19
23
  redesign after feedback on an approved screen. It starts as `draft` and ends as `review`.
24
+ - **New variant** for a device the version lacks (its desktop, its tablet): a new file with the version's N,
25
+ `draft` then `review`, even when the version's other variants are approved.
20
26
  - **Edit in place** only to finish your own draft, or to answer a note on a version still in `review` or
21
27
  `draft`.
22
- - **Never edit** an `approved` or `archived` version: copy it to the next number and change the copy.
28
+ - **Never edit** an `approved` or `archived` variant: copy the version to the next number and change the copy.
23
29
 
24
30
  ## Statuses
25
31
 
@@ -28,7 +34,11 @@ and build on it. Meta tag names below use the project's prefix (`<p>`, from `sti
28
34
  | `draft` | Work in progress | The agent, while working |
29
35
  | `review` | Finished, waiting for the user ("Needs review") | The agent, when done |
30
36
  | `approved` | The reference: build on it, match it, implement it | The user only |
31
- | `archived` | History: superseded or rejected; hidden on the canvas | The user; approving a version archives the previously approved one |
37
+ | `archived` | History: superseded or rejected; hidden on the canvas | The user; approving a variant archives the previously approved variant of the same device |
38
+
39
+ The canvas shows each version with its variants side by side under one heading, and one status for the version:
40
+ needs review while any variant does. **Approve version** approves every variant in review at once; a single
41
+ variant can still be approved, archived or noted on its own.
32
42
 
33
43
  ## How to use them
34
44
 
@@ -55,15 +65,15 @@ the page needs a state first (an open sheet, a running workout), add a `prepare`
55
65
 
56
66
  ## Desktop
57
67
 
58
- Desktop is a device of the same screen, designed after the mobile version is approved: `desktop-v<N>.html`,
59
- frame width 1280, the same components and type levels, the layout from DESIGN.md (typically a sidebar instead
68
+ Desktop is a variant of a version, designed after the version's mobile variant is approved: `desktop-v<N>.html`
69
+ with the mobile's N, frame width 1280, the same components and type levels, the layout from DESIGN.md (typically a sidebar instead
60
70
  of a tab bar, a content max width, two columns where it helps). Shared components get their desktop form in the
61
71
  component, before the first desktop screen.
62
72
 
63
73
  Widths follow Google's window size classes ([google.md](google.md)): compact under 600 px (the phone frame),
64
74
  medium 600–839, expanded 840–1199, large from 1200 (the desktop frame). A desktop screen is a layout for the
65
75
  width class, not a stretched phone: a side navigation instead of the bar, list-detail with both panes, extra
66
- columns for feeds. When a project needs a tablet, it is a medium-width device of the same screen.
76
+ columns for feeds. When a project needs a tablet, it is one more variant, at a medium width.
67
77
 
68
78
  ## The canvas
69
79
 
@@ -1,9 +1,9 @@
1
1
  ---
2
- name: stitch2-variants
2
+ name: stitch2-alternatives
3
3
  description: Get fresh-eyes versions of a screen from agents that see no other versions (stitch2 sandbox), so alternatives are not anchored to what exists; or test a change to the skills on output only the rules produced. Use when the user wants alternatives or a new direction, feels stuck with a design, wants several options to choose from, or when a skill or DESIGN.md rule changed and you need evidence it helps.
4
4
  ---
5
5
 
6
- # Fresh-eyes variants with stitch2 sandboxes
6
+ # Fresh-eyes alternatives with stitch2 sandboxes
7
7
 
8
8
  An agent that can see a screen's existing versions copies them, even when asked for something new. A sandbox
9
9
  is a folder with the project's design system, components, catalog, template and skills, and none of its screens,
@@ -16,8 +16,8 @@ the user to compare on the canvas.
16
16
  and structure only: colours, fonts and shapes come from DESIGN.md. Name the file after the screen
17
17
  (`history.md` designs `history`). For several directions, write one brief per direction with one sentence
18
18
  on what differs (`history.md` in sandbox A: "a calendar first"; in sandbox B: "a list first").
19
- 2. **Create one sandbox per variant**: `stitch2 sandbox create /tmp/<project>-<screen>-a <brief.md>`.
20
- `--context home stats` adds the approved versions of other screens, so the variant stays consistent with
19
+ 2. **Create one sandbox per alternative**: `stitch2 sandbox create /tmp/<project>-<screen>-a <brief.md>`.
20
+ `--context home stats` adds the approved versions of other screens, so the alternative stays consistent with
21
21
  them; `--device desktop` for desktop.
22
22
  3. **Run a fresh agent in each**, with no shared conversation: a sub-agent started without forked history,
23
23
  working only in that folder, told to read its AGENTS.md and follow the skills there. Without sub-agents, give