stitch2 0.13.1 → 0.15.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
@@ -37,6 +37,7 @@ file that already exists. From there the agent spends its effort on the design.
37
37
  and status filters, your DESIGN.md drawn as tokens and rules, the component catalog, and an inspector to
38
38
  approve, archive or leave a note on a version. Live reload; copyable references to paste to an agent.
39
39
  With a light and a dark theme (DESIGN.md's `light-*` colours), a switch (`T`) shows every screen in either one.
40
+ A width switch (`W`) shows every screen at the narrowest or widest window its layout covers.
40
41
  With languages configured, a Language switch (`L`) shows every screen in one of them, and the Languages page
41
42
  shows a screen side by side in each.
42
43
  Pick mode (`P`) highlights any element of a screen and copies a reference with its source line, such as
@@ -44,7 +45,8 @@ file that already exists. From there the agent spends its effort on the design.
44
45
  several to copy at once or add to the version's note.
45
46
  - **Checks** (`stitch2 check`): content past the frame edge or out of its card, labels that wrap, text running into
46
47
  other text, labels cut short with an ellipsis, content under fixed bars, fixed bars that overlap each other, tap
47
- targets (WCAG 2.2), text contrast in every theme, text that is not one of DESIGN.md's type levels, capitals without tracking,
48
+ targets (WCAG 2.2), all of it again at both ends of the window widths each layout covers (360 and 599 px for a
49
+ 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,
48
50
  large numbers in monospace, components built by hand, components that differ between screens, and colours
49
51
  written as values instead of DESIGN.md tokens. Google's guidelines (Material 3, Android's app quality
50
52
  guidelines, web.dev, Lighthouse) add warnings for text under 11 px, text closer than 16 px (24 px from 600 px)
@@ -68,17 +70,18 @@ file that already exists. From there the agent spends its effort on the design.
68
70
  { "format": "dtcg", "path": "design/tokens.json" }
69
71
  ]
70
72
  ```
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`).
73
+ - **Versions, variants and approval**: a screen has versions (v1, v2, …), and each version has variants, its
74
+ layouts for mobile and desktop (`mobile-v3.html`, `desktop-v3.html`). The canvas groups them; you approve a
75
+ version as a whole or one variant at a time, leave notes, and approving archives the previous approved layout.
73
76
  - **DESIGN.md builder**: `stitch2 init` writes a complete DESIGN.md from a few choices (brand colour, modes,
74
77
  fonts, roundness, density), with colours checked for contrast; `stitch2 extract` measures one from an existing
75
78
  site or screens.
76
- - **Fresh-eyes variants** (`stitch2 sandbox`): a folder with your design system, components and skills but no
79
+ - **Fresh-eyes alternatives** (`stitch2 sandbox`): a folder with your design system, components and skills but no
77
80
  screens, where a fresh agent designs from a brief without being anchored to the existing versions;
78
81
  `stitch2 sandbox import` brings the result back as the next version for review. Also the way to test a change
79
82
  to the skills on output only the rules produced.
80
83
  - **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
84
+ versions), `stitch2-design-md` (building the design system), `stitch2-alternatives` (fresh-eyes versions and
82
85
  testing skill changes), `stitch2-consistency` (verifying and fixing
83
86
  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
87
  optional, `stitch2-import-from-google-stitch` (drafting screens in Google Stitch through its MCP server).
@@ -152,7 +155,10 @@ what differs in structure: shared components the design has and the app lacks (o
152
155
  order, and how the measured ones look. Mock-ups and the app hold different data, so the screenshots are shown side
153
156
  by side in `design/compare/index.html` (the canvas links it) rather than diffed. It also runs check's layout
154
157
  rules on the app (content past the edge, fixed bars or controls overlapping), which catches a control the app
155
- squeezed in although no screen drew it. The `stitch2-app-sync` skill describes the workflow around it: a visible
158
+ squeezed in although no screen drew it. Each version is then compared again at both edges of its variant’s width
159
+ range (`widths` in the config), so a layout that holds at 390 px but breaks at 1023 px shows up as "at 1023px: …".
160
+ Compare and the consistency report run only when you call them; the canvas sidebar shows when each last ran and,
161
+ once screens it covers have changed since, how many (hover for the list). The `stitch2-app-sync` skill describes the workflow around it: a visible
156
162
  change goes component, canvas, approval, then app. Configure it in the config:
157
163
 
158
164
  ```json
@@ -62,6 +62,11 @@
62
62
  "description": "Tap targets smaller than this (px) get a warning.",
63
63
  "default": 32
64
64
  },
65
+ "widths": {
66
+ "type": "object",
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
+ "additionalProperties": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }
69
+ },
65
70
  "themes": {
66
71
  "type": "array",
67
72
  "items": { "type": "string" },
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).
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,30 @@
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
+ .item.stale .meta { color: var(--warn); }
55
+ #inspector .version-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; }
56
+ #inspector .version-actions span { color: var(--muted); }
57
+ #inspector .version-actions button { background: var(--panel2); border: 1px solid var(--edge); border-radius: 8px; cursor: pointer; padding: 3px 8px; }
37
58
  .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
59
  .screen-name:hover { background: var(--panel2); }
39
60
  .screen-name .meta { color: var(--faint); font-size: 11px; font-weight: 400; margin-left: auto; }
@@ -106,9 +127,11 @@
106
127
  <b id="labName">stitch2</b>
107
128
  <input id="search" placeholder="Search screens… ( / )" />
108
129
  <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>
130
+ <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>
131
+ <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>
132
+ <select id="themeFilter" class="pick" hidden title="Show every screen in this theme (T)" aria-label="Theme"></select>
133
+ <select id="widthFilter" class="pick" title="Show every screen at the narrowest or widest window its variant covers (W)" aria-label="Width"></select>
134
+ <select id="langFilter" class="pick" title="Show every screen in this language (L)" aria-label="Language"></select>
112
135
  </header>
113
136
  <nav id="tree"></nav>
114
137
  <footer>
@@ -165,6 +188,34 @@ function setLang(l) {
165
188
  if (selected) inspector(selected)
166
189
  summary()
167
190
  }
191
+ // Widths: each variant covers a range of windows (config widths); frames show it drawn, at the narrowest or the widest.
192
+ let WIDTHS = {}
193
+ let widthMode = localStorage.getItem('stitch2-width') ?? 'drawn'
194
+ const rangeOf = (s) => WIDTHS[s.device]
195
+ const widthFor = (s) => { const r = rangeOf(s); return !r || widthMode === 'drawn' ? s.drawn : widthMode === 'narrow' ? r[0] : r[1] }
196
+ const bigFrame = (s) => (s.drawn ?? s.width) > 700
197
+ function setWidth(mode) {
198
+ widthMode = mode
199
+ localStorage.setItem('stitch2-width', mode)
200
+ document.getElementById('widthFilter').value = mode
201
+ for (const f of frames.values()) {
202
+ f.screen.width = widthFor(f.screen)
203
+ f.el.style.width = `${f.screen.width}px`
204
+ f.iframe.style.width = `${f.screen.width}px`
205
+ setTimeout(() => measure(f), 300)
206
+ setTimeout(() => measure(f), 1200)
207
+ }
208
+ layout()
209
+ if (selected) inspector(selected)
210
+ }
211
+ /** The width dropdown names the edges of the devices the screens use: "Narrowest: mobile 360, desktop 1024". */
212
+ function widthOptions() {
213
+ const used = [...new Set(screens.map((s) => s.device))].filter((d) => WIDTHS[d]).sort((a, b) => WIDTHS[a][0] - WIDTHS[b][0])
214
+ const edge = (k) => used.map((d) => `${d} ${WIDTHS[d][k]}`).join(', ')
215
+ const wsel = document.getElementById('widthFilter')
216
+ wsel.innerHTML = `<option value="drawn">Drawn width</option><option value="narrow">Narrowest: ${edge(0)}</option><option value="wide">Widest: ${edge(1)}</option>`
217
+ wsel.value = widthMode
218
+ }
168
219
  function applyTheme(f) {
169
220
  if (!themed()) return
170
221
  try { f.iframe.contentDocument.documentElement.dataset.theme = theme } catch {}
@@ -172,13 +223,15 @@ function applyTheme(f) {
172
223
  function setTheme(t) {
173
224
  theme = t
174
225
  localStorage.setItem('stitch2-theme', t)
175
- for (const b of document.querySelectorAll('#themeFilter button')) b.classList.toggle('on', b.dataset.v === t)
226
+ document.getElementById('themeFilter').value = t
176
227
  for (const f of frames.values()) applyTheme(f)
177
228
  for (const f of frames.values()) badge(f)
178
229
  if (selected) inspector(selected)
179
230
  summary()
231
+ sendFilter()
180
232
  }
181
- const ref = (s) => (s.kind === 'components' ? `component catalog (${designDir}/${s.path})` : `${s.screen} ${s.device} ${s.version}${themeNote()} (${designDir}/${s.path})`)
233
+ const widthNote = (s) => (s.width !== s.drawn ? ` at ${s.width}px` : '')
234
+ const ref = (s) => (s.kind === 'components' ? `component catalog (${designDir}/${s.path})` : `${s.screen} ${s.device} ${s.version}${themeNote()}${widthNote(s)} (${designDir}/${s.path})`)
182
235
  let toastTimer
183
236
  async function copy(text) {
184
237
  try {
@@ -207,9 +260,11 @@ const copyButton = (text, title) => {
207
260
  return b
208
261
  }
209
262
  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' }
263
+ const filters = JSON.parse(localStorage.getItem('stitch2-filters') ?? 'null') ?? { status: 'active' }
264
+ if (typeof filters.device === 'string') { filters.devices = filters.device === 'all' ? [] : [filters.device]; delete filters.device }
211
265
  let screens = []
212
266
  let checks = { screens: {} }
267
+ let reports = { consistency: null, compare: null }
213
268
  let selected = null
214
269
  const frames = new Map() // path -> { el, iframe, screen, height }
215
270
 
@@ -230,16 +285,27 @@ const zoomAt = (cx, cy, z) => {
230
285
  apply()
231
286
  }
232
287
 
233
- // ---- grouping: rows are app screens (components first), frames are versions ----
288
+ // ---- grouping: rows are app screens (components first); in a row, versions (v1, v2, …) in order, and in a
289
+ // version its variants: the layouts for each device (mobile, desktop), files <device>-v<N> sharing N ----
234
290
  const rowKey = (s) => (s.kind === 'components' ? '~components' : s.screen)
235
291
  const rowRank = (k) => (k === '~components' ? -1 : ORDER.includes(k) ? ORDER.indexOf(k) : 100)
236
292
  const sortRows = (keys) => keys.sort((a, b) => rowRank(a) - rowRank(b) || a.localeCompare(b))
293
+ const versionRank = (v) => { const m = /^v(\d+)$/.exec(v); return m ? Number(m[1]) : 0 }
294
+ const DEVICE_RANK = { mobile: 0, tablet: 1, desktop: 2 }
237
295
  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)
296
+ 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)
297
+ /** A version's variants, and one status for all of them: needs review if any does, else the most settled. */
298
+ const versionsOf = (list) => {
299
+ const by = new Map()
300
+ for (const s of list) { const k = s.kind === 'components' ? s.path : s.version; by.set(k, [...(by.get(k) ?? []), s]) }
301
+ 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' }))
302
+ }
303
+ const VERSION_H = 44, VERSION_GAP = 120
239
304
  function shown(s) {
240
305
  const q = document.getElementById('search').value.trim().toLowerCase()
241
306
  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
307
+ if (filters.devices?.length && !filters.devices.includes(s.device)) return false
308
+ if (filters.versions?.length && s.kind === 'screen' && !filters.versions.includes(s.version)) return false
243
309
  if (s.kind === 'components') return filters.status !== 'review' && filters.status !== 'approved'
244
310
  if (filters.status === 'active') return s.status !== 'archived'
245
311
  if (filters.status === 'all') return true
@@ -258,11 +324,13 @@ function layout() {
258
324
  for (const row of rows()) {
259
325
  for (const s of row.all) frames.get(s.path).el.style.display = row.visible.includes(s) ? '' : 'none'
260
326
  if (!row.visible.length) continue
261
- const groupW = row.visible.reduce((w, s) => w + s.width, 0) + GAP_X * (row.visible.length - 1)
327
+ const groups = versionsOf(row.visible)
328
+ const widthOf = (g) => g.variants.reduce((w, s) => w + s.width, 0) + GAP_X * (g.variants.length - 1)
329
+ const groupW = groups.reduce((w, g) => w + widthOf(g), 0) + VERSION_GAP * (groups.length - 1)
262
330
  if (x > 0 && x + groupW > LINE_W) { x = 0; y += lineH + GAP_Y; lineH = 0 }
263
331
  const label = document.createElement('div')
264
332
  label.className = 'row-label'
265
- const approved = row.all.filter((s) => s.status === 'approved').map((s) => `${s.device} ${s.version}`)
333
+ const approved = row.all.filter((s) => s.status === 'approved').map((s) => `${s.version} ${s.device}`)
266
334
  label.innerHTML = '<b></b><span></span>'
267
335
  label.querySelector('b').textContent = row.key === '~components' ? 'Components' : row.key
268
336
  label.querySelector('span').textContent = row.key === '~components' ? 'the catalog every screen is built from' : approved.length ? `approved: ${approved.join(', ')}` : 'nothing approved yet'
@@ -270,19 +338,62 @@ function layout() {
270
338
  label.style.maxWidth = `${groupW}px`
271
339
  world.append(label)
272
340
  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)
341
+ const top = row.key === '~components' ? 0 : VERSION_H
342
+ for (const g of groups) {
343
+ if (top) {
344
+ const v = document.createElement('div')
345
+ v.className = `row-label version-label ${g.status}`
346
+ v.innerHTML = '<b></b><span></span>'
347
+ v.querySelector('b').textContent = g.version
348
+ v.querySelector('span').textContent = `${STATUS_LABEL[g.status]} · ${g.variants.map((s) => s.device).join(', ')}`
349
+ v.style.transform = `translate(${fx}px, ${y + ROW_LABEL_H}px)`
350
+ v.style.width = `${widthOf(g)}px`
351
+ world.append(v)
352
+ }
353
+ for (const s of g.variants) {
354
+ const f = frames.get(s.path)
355
+ f.el.style.left = `${fx}px`
356
+ f.el.style.top = `${y + ROW_LABEL_H + top}px`
357
+ fx += s.width + GAP_X
358
+ tallest = Math.max(tallest, f.height + LABEL_H + top)
359
+ }
360
+ fx += VERSION_GAP - GAP_X
279
361
  }
280
362
  lineH = Math.max(lineH, ROW_LABEL_H + tallest)
281
363
  x += groupW + GROUP_GAP
282
364
  }
283
365
  tree()
366
+ sendFilter()
284
367
  }
285
368
 
369
+ // ---- reports follow the filters: the open report (consistency, app comparison, languages) gets the files the
370
+ // canvas shows and hides the rest; the catalog follows only the variant filter there, as the reference ----
371
+ function reportPaths() {
372
+ return screens.filter((s) => (s.kind === 'components' ? !filters.devices?.length || filters.devices.includes(s.device) : shown(s))).map((s) => s.path)
373
+ }
374
+ function filterLabel() {
375
+ const q = document.getElementById('search').value.trim()
376
+ return [
377
+ ...(filters.versions ?? []),
378
+ ...(filters.devices ?? []),
379
+ filters.status === 'approved' ? 'approved' : filters.status === 'review' ? 'needs review' : '',
380
+ q ? `“${q}”` : '',
381
+ ].filter(Boolean).join(' · ')
382
+ }
383
+ var sentFilter = ''
384
+ function sendFilter(force) {
385
+ const doc = document.getElementById('doc')
386
+ if (!doc.classList.contains('open') || !['/consistency/index.html', '/compare/index.html', '/languages'].includes(docUrl)) return
387
+ const message = { stitch2Filter: { paths: reportPaths(), label: filterLabel(), theme: themed() ? theme : '' } }
388
+ const key = JSON.stringify(message)
389
+ if (!force && key === sentFilter) return
390
+ sentFilter = key
391
+ doc.querySelector('iframe').contentWindow?.postMessage(message, location.origin)
392
+ }
393
+ // A report asks for the filters as soon as its script runs (its load event waits for every frame inside it).
394
+ addEventListener('message', (e) => { if (e.origin === location.origin && e.data?.stitch2Ready) sendFilter(true) })
395
+ document.querySelector('#doc iframe').addEventListener('load', () => sendFilter(true)) // reports made before stitch2Ready
396
+
286
397
  function tree() {
287
398
  const nav = document.getElementById('tree')
288
399
  nav.innerHTML = ''
@@ -304,15 +415,30 @@ function tree() {
304
415
  head('Design system')
305
416
  const docOpen = (url) => document.getElementById('doc').classList.contains('open') && docUrl === url
306
417
  item('DESIGN.md', 'tokens · rules', docOpen('/design'), () => openDoc('/design'))
307
- item('Consistency report', 'components × screens', docOpen('/consistency/index.html'), () => openDoc('/consistency/index.html'))
308
- item('App comparison', 'approved × live app', docOpen('/compare/index.html'), () => openDoc('/compare/index.html'))
418
+ // One entry for the catalog: the variant filter picks which ones (mobile, desktop); a click steps through them.
419
+ const catalogs = rows().find((r) => r.key === '~components')?.visible ?? []
420
+ const onCatalog = selected?.screen.kind === 'components'
421
+ item('Components', catalogs.length ? `catalog · ${catalogs.map((s) => s.device).join(', ')}` : 'hidden by the filters', onCatalog, () => {
422
+ if (!catalogs.length) return
423
+ const i = catalogs.findIndex((s) => s.path === selected?.screen.path)
424
+ select(frames.get(catalogs[(i + 1) % catalogs.length].path), true)
425
+ }, '', '', onCatalog ? ref(selected.screen) : catalogs[0] ? ref(catalogs[0]) : '')
426
+ // Each report says when it ran and how many of the files it covers, among those the filters show, changed
427
+ // since (then it's stale: run it again).
428
+ const keep = new Set(reportPaths())
429
+ const report = (label, url, state, command) => {
430
+ const changed = state?.changed.filter((p) => keep.has(p) || p.startsWith('components/')) ?? []
431
+ const n = changed.length
432
+ const meta = !state ? 'not run yet' : n ? `${n} changed since` : ago(state.at)
433
+ const b = item(label, meta, docOpen(url), () => openDoc(url), '', n || !state ? 'stale' : '')
434
+ b.title = !state
435
+ ? `Run ${command}`
436
+ : `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'}`
437
+ }
438
+ report('Consistency report', '/consistency/index.html', reports.consistency, 'stitch2 consistency')
439
+ report('App comparison', '/compare/index.html', reports.compare, 'stitch2 compare')
309
440
  item('Languages', 'screens × languages', docOpen('/languages'), () => openDoc('/languages'))
310
441
  const all = rows()
311
- const comp = all.find((r) => r.key === '~components')
312
- if (comp?.visible.length) {
313
- head('Components')
314
- for (const s of comp.visible) item(s.title.replace(/^[^—]+ — /, ''), 'catalog', selected?.screen.path === s.path, () => select(frames.get(s.path), true), '', '', ref(s))
315
- }
316
442
  head('Screens')
317
443
  for (const row of all) {
318
444
  if (row.key === '~components' || !row.visible.length) continue
@@ -321,11 +447,17 @@ function tree() {
321
447
  const approved = row.all.some((s) => s.status === 'approved')
322
448
  name.innerHTML = `<span class="dot ${approved ? 'approved' : row.all.some((s) => s.status === 'review') ? 'review' : 'draft'}"></span><span></span><span class="meta"></span>`
323
449
  name.children[1].textContent = row.key
324
- name.querySelector('.meta').textContent = `${row.visible.length} version${row.visible.length > 1 ? 's' : ''}`
450
+ const vs = versionsOf(row.visible)
451
+ name.querySelector('.meta').textContent = `${vs.length} version${vs.length > 1 ? 's' : ''}`
325
452
  name.onclick = () => select(frames.get(row.visible[0].path), true)
326
453
  name.append(copyButton(row.key, 'Copy the screen name'))
327
454
  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))
455
+ for (const g of vs) {
456
+ const one = g.variants.length === 1
457
+ const first = g.variants[0]
458
+ 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}`)
459
+ 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))
460
+ }
329
461
  }
330
462
  }
331
463
 
@@ -354,7 +486,7 @@ function zoomTo(f) {
354
486
  function measure(f) {
355
487
  try {
356
488
  const doc = f.iframe.contentDocument
357
- const min = f.screen.width > 700 ? 800 : 844
489
+ const min = bigFrame(f.screen) ? 800 : 844
358
490
  const h = Math.min(6000, Math.max(min, doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0))
359
491
  if (Math.abs(h - f.height) > 2) {
360
492
  f.height = h
@@ -377,7 +509,7 @@ function badge(f) {
377
509
  function paintLabel(f) {
378
510
  const s = f.screen
379
511
  f.el.className = `frame ${s.device} ${s.status}${selected === f ? ' selected' : ''}`
380
- f.el.querySelector('.name').textContent = s.kind === 'components' ? 'Catalog' : `${s.version}`
512
+ f.el.querySelector('.name').textContent = s.kind === 'components' ? 'Catalog' : s.device
381
513
  const pill = f.el.querySelector('.pill')
382
514
  pill.className = `pill ${s.status}`
383
515
  pill.textContent = s.kind === 'components' ? 'components' : `${STATUS_LABEL[s.status]} · ${s.source}`
@@ -402,9 +534,9 @@ function frameFor(s) {
402
534
  el.innerHTML = `<div class="label"><span class="name"></span><span class="pill"></span><span class="title"></span><button class="copy" type="button" title="Copy a reference to paste to an agent">Copy</button><button class="badge" type="button"></button></div><div class="device"><iframe loading="lazy" scrolling="no"></iframe><div class="shield"></div></div>`
403
535
  const iframe = el.querySelector('iframe')
404
536
  iframe.style.width = `${s.width}px`
405
- iframe.style.height = `${s.width > 700 ? 800 : 844}px`
537
+ iframe.style.height = `${bigFrame(s) ? 800 : 844}px`
406
538
  iframe.src = `/${s.path}?pick${langQuery()}`
407
- const f = { el, iframe, screen: s, height: s.width > 700 ? 800 : 844 }
539
+ const f = { el, iframe, screen: s, height: bigFrame(s) ? 800 : 844 }
408
540
  iframe.addEventListener('load', () => { applyTheme(f); measure(f); setTimeout(() => measure(f), 800); setTimeout(() => measure(f), 2500) })
409
541
  el.querySelector('.shield').addEventListener('click', () => select(f, false))
410
542
  el.querySelector('.shield').addEventListener('dblclick', () => { select(f, false); f.el.classList.add('live') })
@@ -464,7 +596,16 @@ function inspector(f) {
464
596
  }
465
597
  box.insertBefore(sec, box.querySelector('h4'))
466
598
  }
467
- box.querySelector('.h').textContent = s.kind === 'components' ? 'Component catalog' : `${s.screen} · ${s.version}`
599
+ box.querySelector('.h').textContent = s.kind === 'components' ? 'Component catalog' : `${s.screen} · ${s.version} · ${s.device}`
600
+ const siblings = s.kind === 'screen' ? sortVersions(screens.filter((o) => o.kind === 'screen' && o.screen === s.screen && o.version === s.version)) : []
601
+ if (siblings.length > 1) {
602
+ const vrow = document.createElement('div')
603
+ vrow.className = 'version-actions'
604
+ 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>'
605
+ vrow.querySelector('span').textContent = `${s.version}: ${siblings.map((o) => `${o.device} ${STATUS_LABEL[o.status]}`).join(', ')}`
606
+ 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 }) }
607
+ box.querySelector('.status').before(vrow)
608
+ }
468
609
  box.querySelector('.f').textContent = `${s.device} · ${s.source}`
469
610
  const a = box.querySelector('.facts a')
470
611
  a.href = `/${s.path}`
@@ -488,7 +629,11 @@ function inspector(f) {
488
629
  li.querySelector('.sev').textContent = i.severity
489
630
  li.querySelector('.msg').textContent = i.message + (i.text ? ` — “${i.text}”` : '')
490
631
  li.querySelector('code').textContent = i.selector
491
- li.onclick = () => locate(f, i.selector)
632
+ li.onclick = () => {
633
+ const r = rangeOf(s)
634
+ if (i.width && r && i.width !== s.width) { setWidth(i.width === r[0] ? 'narrow' : 'wide'); setTimeout(() => locate(f, i.selector), 900) }
635
+ else locate(f, i.selector)
636
+ }
492
637
  ul.append(li)
493
638
  }
494
639
  box.classList.add('open')
@@ -610,9 +755,22 @@ async function loadChecks() {
610
755
  if (selected) inspector(selected)
611
756
  summary()
612
757
  }
758
+ async function loadReports() {
759
+ reports = await fetch('/api/reports').then((r) => r.json()).catch(() => reports)
760
+ tree()
761
+ }
762
+ setInterval(loadReports, 60_000) // keeps "5 min ago" honest
763
+ function ago(t) {
764
+ const m = Math.round((Date.now() - t) / 60000)
765
+ if (m < 1) return 'just now'
766
+ if (m < 60) return `${m} min ago`
767
+ const h = Math.round(m / 60)
768
+ return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`
769
+ }
613
770
 
614
771
  async function load(first, given) {
615
772
  const next = given ?? (await fetch('/api/screens').then((r) => r.json()))
773
+ for (const s of next) { s.drawn = s.width; s.width = widthFor(s) }
616
774
  const keep = new Set(next.map((s) => s.path))
617
775
  for (const [p, f] of frames) if (!keep.has(p)) { f.el.remove(); frames.delete(p); if (selected === f) selected = null }
618
776
  for (const s of next) {
@@ -620,14 +778,18 @@ async function load(first, given) {
620
778
  if (f) { f.screen = s; paintLabel(f) } else frameFor(s)
621
779
  }
622
780
  screens = next
781
+ widthOptions()
782
+ versionFilter()
783
+ variantFilter()
623
784
  layout()
624
785
  await loadChecks()
786
+ await loadReports()
625
787
  if (selected) inspector(selected)
626
788
  if (first && !localStorage.getItem('stitch2-view')) setTimeout(fit, 1500)
627
789
  }
628
790
 
629
791
  // ---- filters ----
630
- for (const [id, key] of [['statusFilter', 'status'], ['deviceFilter', 'device']]) {
792
+ for (const [id, key] of [['statusFilter', 'status']]) {
631
793
  const seg = document.getElementById(id)
632
794
  for (const b of seg.querySelectorAll('button')) {
633
795
  b.classList.toggle('on', b.dataset.v === filters[key])
@@ -641,6 +803,57 @@ for (const [id, key] of [['statusFilter', 'status'], ['deviceFilter', 'device']]
641
803
  }
642
804
  document.getElementById('search').oninput = layout
643
805
 
806
+ // Versions and variants: multi-selects of every label the screens use (v1 … v12 and imports such as "Stitch";
807
+ // mobile, tablet, desktop), with how many screens carry each; none ticked means all. The catalog follows the variant filter only.
808
+ const MULTI = []
809
+ for (const id of ['versionFilter', 'variantFilter']) {
810
+ const box = document.getElementById(id)
811
+ MULTI.push(box)
812
+ }
813
+ document.addEventListener('click', (e) => { for (const box of MULTI) if (box.open && !box.contains(e.target)) box.open = false })
814
+ document.addEventListener('keydown', (e) => { const open = MULTI.find((b) => b.open); if (e.key === 'Escape' && open) { open.open = false; e.stopPropagation() } }, true)
815
+ function multiFilter({ id, key, of, sort, all, many, again }) {
816
+ const box = document.getElementById(id)
817
+ const counts = new Map()
818
+ for (const s of screens) if (s.kind === 'screen') counts.set(of(s), (counts.get(of(s)) ?? 0) + 1)
819
+ const labels = [...counts.keys()].sort(sort)
820
+ filters[key] = (filters[key] ?? []).filter((v) => labels.includes(v))
821
+ box.hidden = labels.length < 2
822
+ const picked = filters[key]
823
+ box.querySelector('summary').textContent = !picked.length ? all : picked.length <= 3 ? [...picked].sort(sort).join(', ') : `${picked.length} ${many}`
824
+ const change = (next) => {
825
+ filters[key] = next
826
+ localStorage.setItem('stitch2-filters', JSON.stringify(filters))
827
+ again()
828
+ layout()
829
+ }
830
+ const option = (label, checked, n, onChange, cls = '') => {
831
+ const l = document.createElement('label')
832
+ l.className = cls
833
+ const input = document.createElement('input')
834
+ input.type = 'checkbox'
835
+ input.checked = checked
836
+ input.onchange = onChange
837
+ const name = document.createElement('span')
838
+ name.textContent = label
839
+ const count = document.createElement('span')
840
+ count.className = 'n'
841
+ count.textContent = n
842
+ l.append(input, name, count)
843
+ return l
844
+ }
845
+ box.querySelector('.menu').replaceChildren(
846
+ option(all, !picked.length, screens.filter((s) => s.kind === 'screen').length, () => change([]), 'all'),
847
+ ...labels.map((v) => option(v, picked.includes(v), counts.get(v), () => change(picked.includes(v) ? picked.filter((x) => x !== v) : [...picked, v]))),
848
+ )
849
+ }
850
+ function versionFilter() {
851
+ 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 })
852
+ }
853
+ function variantFilter() {
854
+ 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 })
855
+ }
856
+
644
857
  // ---- live reload ----
645
858
  const events = new EventSource('/events')
646
859
  events.onmessage = async (e) => {
@@ -648,6 +861,7 @@ events.onmessage = async (e) => {
648
861
  if (paths.some((p) => p === 'checks.json')) await loadChecks()
649
862
  const html = paths.filter((p) => p.endsWith('.html'))
650
863
  if (html.length) await load(false)
864
+ else if (paths.some((p) => p.startsWith('components/') || p === 'compare.json')) await loadReports()
651
865
  if (paths.includes('DESIGN.md') && document.getElementById('doc').classList.contains('open')) document.querySelector('#doc iframe').contentWindow.location.reload()
652
866
  // Stylesheets and scripts are shared: tokens.css, tailwind.tokens.js, token exports the screens link
653
867
  // (tokens.tailwind.css), the components.
@@ -709,6 +923,7 @@ addEventListener('keydown', (e) => {
709
923
  if (e.key === 'f' || e.key === 'F') fit()
710
924
  if ((e.key === 't' || e.key === 'T') && themed()) setTheme(THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length])
711
925
  if (e.key === 'l' || e.key === 'L') setLang(LANGS[(LANGS.indexOf(lang) + 1) % LANGS.length])
926
+ if (e.key === 'w' || e.key === 'W') setWidth(['drawn', 'narrow', 'wide'][(['drawn', 'narrow', 'wide'].indexOf(widthMode) + 1) % 3])
712
927
  if (e.key === '0') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, 1)
713
928
  if (e.key === '+' || e.key === '=') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z * 1.2)
714
929
  if (e.key === '-') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z / 1.2)
@@ -734,11 +949,14 @@ fetch('/api/config').then((r) => r.json()).then((c) => {
734
949
  document.getElementById('labName').textContent = c.name
735
950
  THEMES = c.themes?.length ? c.themes : ['dark']
736
951
  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)
952
+ const pick = document.getElementById('themeFilter')
953
+ pick.hidden = !themed()
954
+ pick.innerHTML = THEMES.map((t) => `<option value="${t}">${t[0].toUpperCase()}${t.slice(1)} theme</option>`).join('')
955
+ pick.onchange = () => setTheme(pick.value)
741
956
  setTheme(theme)
957
+ WIDTHS = c.widths ?? {}
958
+ const wsel = document.getElementById('widthFilter')
959
+ wsel.onchange = () => setWidth(wsel.value)
742
960
  SOURCE = c.sourceLocale ?? 'en'
743
961
  LANGS = c.locales?.length ? c.locales : [SOURCE]
744
962
  if (!LANGS.includes(lang)) lang = SOURCE