stitch2 0.15.3 → 0.17.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
@@ -72,7 +72,8 @@ file that already exists. From there the agent spends its effort on the design.
72
72
  ```
73
73
  - **Versions, variants and approval**: a screen has versions (v1, v2, …), and each version has variants, its
74
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.
75
+ version as a whole or one variant at a time, and leave notes. The approved version is the one current version:
76
+ approving it archives every earlier one, and only it gets implemented.
76
77
  - **DESIGN.md builder**: `stitch2 init` writes a complete DESIGN.md from a few choices (brand colour, modes,
77
78
  fonts, roundness, density), with colours checked for contrast; `stitch2 extract` measures one from an existing
78
79
  site or screens.
@@ -253,7 +254,8 @@ layout first, then a shorter natural word, never a smaller type size.
253
254
  | `stitch2 i18n [filter] [--locale l] [--suggest] [--json]` | Message keys the source or a locale lacks, and screen text with no key |
254
255
  | `stitch2 consistency [kind…]` | The consistency report, `<design>/consistency/index.html` |
255
256
  | `stitch2 type [filter] [--json]` | The type audit |
256
- | `stitch2 screens [name] [--status s] [--json]` | Versions, statuses and notes |
257
+ | `stitch2 screens [name] [--status s] [--json] [--strict]` | Versions, statuses and notes; flags a screen whose current version is unclear |
258
+ | `stitch2 approve <path or filter…>` | Approve variants (the user's decision); every earlier version is archived |
257
259
  | `stitch2 tokens` | Tokens from DESIGN.md |
258
260
  | `stitch2 compare [screen…]` | The live app against the approved screens (`app` in the config) |
259
261
  | `stitch2 elements <entry>` | Bundle your components (`stitch2/elements`) for the screens |
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Approves screen variants, as the user does on the canvas: each becomes the current version of its screen on its
3
+ * device, and every earlier version there is archived. Agents run it when the user approves in words ("approve
4
+ * the v3 screens"), never on their own.
5
+ * Usage: stitch2 approve <path or filter…> (a path under the design folder, or part of one: "connect/", "-v3")
6
+ */
7
+ import { approve, listScreens } from './screens.js';
8
+ const filters = process.argv.slice(2).filter((a) => !a.startsWith('--'));
9
+ if (!filters.length) {
10
+ console.error('stitch2 approve <path or filter…>: which variants? ("home/mobile-v3", "connect/", "-v3")');
11
+ process.exit(1);
12
+ }
13
+ const targets = listScreens().filter((s) => s.kind === 'screen' && s.status !== 'archived' && filters.some((f) => s.path.includes(f)));
14
+ if (!targets.length) {
15
+ console.error(`No screen variant matches ${filters.join(', ')}.`);
16
+ process.exit(1);
17
+ }
18
+ // Oldest first, so approving several versions of one screen leaves the latest current.
19
+ targets.sort((a, b) => a.path.localeCompare(b.path, undefined, { numeric: true }));
20
+ for (const t of targets) {
21
+ const archived = approve(t.path);
22
+ const note = archived.length ? `; archived ${archived.map((s) => s.version).join(', ')}` : '';
23
+ console.log(`approved ${t.screen} ${t.version} ${t.device}${note}`);
24
+ }
package/dist/audit.js CHANGED
@@ -36,6 +36,9 @@ export async function audit(opts) {
36
36
  if (closed && !closed.querySelector(':scope > summary')?.contains(el))
37
37
  return false;
38
38
  const r = el.getBoundingClientRect();
39
+ // Text kept for screen readers only (Tailwind's sr-only: a 1px box, clipped away) is not drawn.
40
+ if (r.width <= 1 && r.height <= 1 && cs.position === 'absolute')
41
+ return false;
39
42
  return r.width > 0 && r.height > 0;
40
43
  };
41
44
  const opacity = (el) => {
package/dist/canvas.html CHANGED
@@ -58,6 +58,11 @@
58
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; }
59
59
  .screen-name:hover { background: var(--panel2); }
60
60
  .screen-name .meta { color: var(--faint); font-size: 11px; font-weight: 400; margin-left: auto; }
61
+ .page-name .chev { color: var(--faint); display: inline-block; font-size: 10px; transition: transform .15s; width: 10px; }
62
+ .page-name.open .chev { transform: rotate(90deg); }
63
+ .screen-name.state { font-weight: 500; padding-left: 26px; }
64
+ .item.version.nested { padding-left: 30px; }
65
+ .item.variant.nested { padding-left: 52px; }
61
66
  .version { padding-left: 20px; }
62
67
  .dot { background: var(--faint); border-radius: 50%; flex: none; height: 8px; width: 8px; }
63
68
  .dot.approved { background: var(--good); } .dot.review { background: var(--warn); } .dot.draft { background: var(--muted); } .dot.archived { background: transparent; border: 1px solid var(--faint); }
@@ -151,6 +156,8 @@ const viewport = document.getElementById('viewport')
151
156
  const world = document.getElementById('world')
152
157
  const SIDE = 288, GAP_X = 72, GAP_Y = 150, GROUP_GAP = 200, LINE_W = 3400, ROW_LABEL_H = 52, LABEL_H = 38
153
158
  let ORDER = [] // screen order, from the config
159
+ // Pages whose screens are unfolded in the sidebar.
160
+ const openPages = new Set((() => { try { return JSON.parse(localStorage.getItem('stitch2-open-pages') ?? '[]') } catch { return [] } })())
154
161
  const STATUS_RANK = { approved: 0, review: 1, draft: 2, archived: 3 }
155
162
  const STATUS_LABEL = { approved: 'approved', review: 'needs review', draft: 'draft', archived: 'archived' }
156
163
  const STATUS_HINT = {
@@ -289,7 +296,19 @@ const zoomAt = (cx, cy, z) => {
289
296
  // version its variants: the layouts for each device (mobile, desktop), files <device>-v<N> sharing N ----
290
297
  const rowKey = (s) => (s.kind === 'components' ? '~components' : s.screen)
291
298
  const rowRank = (k) => (k === '~components' ? -1 : ORDER.includes(k) ? ORDER.indexOf(k) : 100)
292
- const sortRows = (keys) => keys.sort((a, b) => rowRank(a) - rowRank(b) || a.localeCompare(b))
299
+ /** What a screen is within its page: its name without the page's ("entry" for workout-entry), or "main". */
300
+ const stateOf = (screen, page) => (screen === page ? 'main' : screen.startsWith(`${page}-`) ? screen.slice(page.length + 1) : screen)
301
+ // Pages keep their screens together: a page sorts where the first of its screens (or the page itself) is in the
302
+ // config's order, its main screen first, the rest in order.
303
+ const sortRows = (keys, pageOf) => {
304
+ const pageRank = new Map()
305
+ for (const k of keys) { const p = pageOf(k); pageRank.set(p, Math.min(pageRank.get(p) ?? 100, rowRank(k), rowRank(p))) }
306
+ return keys.sort((a, b) => {
307
+ const pa = pageOf(a), pb = pageOf(b)
308
+ if (pa !== pb) return pageRank.get(pa) - pageRank.get(pb) || pa.localeCompare(pb)
309
+ return (a === pa ? -1 : b === pb ? 1 : 0) || rowRank(a) - rowRank(b) || a.localeCompare(b)
310
+ })
311
+ }
293
312
  const versionRank = (v) => { const m = /^v(\d+)$/.exec(v); return m ? Number(m[1]) : 0 }
294
313
  const DEVICE_RANK = { mobile: 0, tablet: 1, desktop: 2 }
295
314
  const sortVersions = (list) =>
@@ -314,16 +333,31 @@ function shown(s) {
314
333
  const rows = () => {
315
334
  const by = new Map()
316
335
  for (const s of screens) by.set(rowKey(s), [...(by.get(rowKey(s)) ?? []), s])
317
- return sortRows([...by.keys()]).map((k) => ({ key: k, all: sortVersions(by.get(k)), visible: sortVersions(by.get(k).filter(shown)) }))
336
+ const pageOf = (k) => (k === '~components' ? '~components' : by.get(k)[0].page || k)
337
+ const sizes = new Map()
338
+ for (const k of by.keys()) sizes.set(pageOf(k), (sizes.get(pageOf(k)) ?? 0) + 1)
339
+ return sortRows([...by.keys()], pageOf).map((k) => ({
340
+ key: k,
341
+ page: pageOf(k),
342
+ // A page with several screens: each is one of its states (entry, menu, empty…).
343
+ state: sizes.get(pageOf(k)) > 1 ? stateOf(k, pageOf(k)) : '',
344
+ all: sortVersions(by.get(k)),
345
+ visible: sortVersions(by.get(k).filter(shown)),
346
+ }))
318
347
  }
319
348
 
320
349
  // Screens flow left to right and wrap, like a design canvas; each screen's versions stay together.
321
350
  function layout() {
322
351
  for (const n of world.querySelectorAll('.row-label')) n.remove()
323
- let x = 0, y = 0, lineH = 0
324
- for (const row of rows()) {
352
+ let x = 0, y = 0, lineH = 0, page = ''
353
+ const all = rows()
354
+ const paged = new Set(all.filter((r) => r.state).map((r) => r.page))
355
+ for (const row of all) {
325
356
  for (const s of row.all) frames.get(s.path).el.style.display = row.visible.includes(s) ? '' : 'none'
326
357
  if (!row.visible.length) continue
358
+ // Each page with several screens starts its own line, so its states read together.
359
+ if (x > 0 && row.page !== page && (paged.has(row.page) || paged.has(page))) { x = 0; y += lineH + GAP_Y; lineH = 0 }
360
+ page = row.page
327
361
  const groups = versionsOf(row.visible)
328
362
  const widthOf = (g) => g.variants.reduce((w, s) => w + s.width, 0) + GAP_X * (g.variants.length - 1)
329
363
  const groupW = groups.reduce((w, g) => w + widthOf(g), 0) + VERSION_GAP * (groups.length - 1)
@@ -332,7 +366,7 @@ function layout() {
332
366
  label.className = 'row-label'
333
367
  const approved = row.all.filter((s) => s.status === 'approved').map((s) => `${s.version} ${s.device}`)
334
368
  label.innerHTML = '<b></b><span></span>'
335
- label.querySelector('b').textContent = row.key === '~components' ? 'Components' : row.key
369
+ label.querySelector('b').textContent = row.key === '~components' ? 'Components' : row.state ? `${row.page} › ${row.state}` : row.key
336
370
  label.querySelector('span').textContent = row.key === '~components' ? 'the catalog every screen is built from' : approved.length ? `approved: ${approved.join(', ')}` : 'nothing approved yet'
337
371
  label.style.transform = `translate(${x}px, ${y}px)`
338
372
  label.style.maxWidth = `${groupW}px`
@@ -438,26 +472,50 @@ function tree() {
438
472
  report('Consistency report', '/consistency/index.html', reports.consistency, 'stitch2 consistency')
439
473
  report('App comparison', '/compare/index.html', reports.compare, 'stitch2 compare')
440
474
  item('Languages', 'screens × languages', docOpen('/languages'), () => openDoc('/languages'))
441
- const all = rows()
475
+ const all = rows().filter((r) => r.key !== '~components' && r.visible.length)
442
476
  head('Screens')
443
- for (const row of all) {
444
- if (row.key === '~components' || !row.visible.length) continue
477
+ const dotOf = (list) => (list.some((s) => s.status === 'approved') ? 'approved' : list.some((s) => s.status === 'review') ? 'review' : 'draft')
478
+ const screenRow = (row, nested) => {
445
479
  const name = document.createElement('div')
446
- name.className = 'screen-name'
447
- const approved = row.all.some((s) => s.status === 'approved')
448
- name.innerHTML = `<span class="dot ${approved ? 'approved' : row.all.some((s) => s.status === 'review') ? 'review' : 'draft'}"></span><span></span><span class="meta"></span>`
449
- name.children[1].textContent = row.key
480
+ name.className = `screen-name${nested ? ' state' : ''}`
481
+ name.innerHTML = `<span class="dot ${dotOf(row.all)}"></span><span></span><span class="meta"></span>`
482
+ name.children[1].textContent = nested ? row.state : row.key
450
483
  const vs = versionsOf(row.visible)
451
484
  name.querySelector('.meta').textContent = `${vs.length} version${vs.length > 1 ? 's' : ''}`
452
485
  name.onclick = () => select(frames.get(row.visible[0].path), true)
453
486
  name.append(copyButton(row.key, 'Copy the screen name'))
454
487
  nav.append(name)
488
+ const sub = nested ? ' nested' : ''
455
489
  for (const g of vs) {
456
490
  const one = g.variants.length === 1
457
491
  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))
492
+ 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${sub}`, one ? ref(first) : `${row.key} ${g.version}`)
493
+ 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${sub}`, ref(s))
494
+ }
495
+ }
496
+ // A page with several screens folds them under its name; it opens on a click, while one of them is selected, or
497
+ // while searching.
498
+ const searching = !!document.getElementById('search').value.trim()
499
+ for (let i = 0; i < all.length; ) {
500
+ const page = all[i].page
501
+ const group = []
502
+ while (i < all.length && all[i].page === page) group.push(all[i++])
503
+ if (!group[0].state) { screenRow(group[0], false); continue }
504
+ const open = searching || openPages.has(page) || group.some((r) => r.all.some((s) => s.path === selected?.screen.path))
505
+ const pageRow = document.createElement('div')
506
+ pageRow.className = `screen-name page-name${open ? ' open' : ''}`
507
+ pageRow.innerHTML = `<span class="chev">▶</span><span class="dot ${dotOf(group.flatMap((r) => r.all))}"></span><span></span><span class="meta"></span>`
508
+ pageRow.children[2].textContent = page
509
+ pageRow.querySelector('.meta').textContent = `${group.length} screens`
510
+ pageRow.title = group.map((r) => r.state).join(', ')
511
+ pageRow.onclick = () => {
512
+ if (openPages.has(page)) openPages.delete(page)
513
+ else openPages.add(page)
514
+ localStorage.setItem('stitch2-open-pages', JSON.stringify([...openPages]))
515
+ tree()
460
516
  }
517
+ nav.append(pageRow)
518
+ if (open) for (const row of group) screenRow(row, true)
461
519
  }
462
520
  }
463
521
 
@@ -527,8 +585,9 @@ function summary() {
527
585
  const review = versions.filter((s) => s.status === 'review').length
528
586
  const approved = new Set(versions.filter((s) => s.status === 'approved').map((s) => s.screen)).size
529
587
  const names = new Set(versions.map((s) => s.screen)).size
588
+ const pages = new Set(versions.map((s) => s.page || s.screen)).size
530
589
  const bad = versions.filter((s) => forTheme(checks.screens?.[s.path]).some((i) => i.severity === 'error')).length
531
- document.getElementById('summary').textContent = `${names} screens · ${approved} approved · ${review} need review${bad ? ` · ${bad} with errors` : ''}`
590
+ document.getElementById('summary').textContent = `${pages < names ? `${pages} pages · ` : ''}${names} screens · ${approved} approved · ${review} need review${bad ? ` · ${bad} with errors` : ''}`
532
591
  }
533
592
 
534
593
  function frameFor(s) {
package/dist/cli.js CHANGED
@@ -42,7 +42,8 @@ const COMMANDS = {
42
42
  'i18n-report.ts',
43
43
  '[filter] [--locale l] [--suggest] [--json] Keys screens use that a locale lacks, and screen text with no key',
44
44
  ],
45
- screens: ['list.ts', '[name] [--status s] [--json] Screen versions, statuses and notes'],
45
+ screens: ['list.ts', '[name] [--status s] [--json] [--strict] Screen versions, statuses, notes, and which version is current'],
46
+ approve: ['approve.ts', '<path or filter…> Approve variants for the user; earlier versions are archived'],
46
47
  localize: ['assets.ts', '[filter] Copy remote images a screen uses into its assets/ folder'],
47
48
  sandbox: [
48
49
  'sandbox.ts',
package/dist/list.js CHANGED
@@ -1,19 +1,39 @@
1
1
  /**
2
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
- * Usage: stitch2 screens [screen name] [--status approved|review|draft|archived] [--json]
4
+ * Ends with what makes the current version unclear (versionProblems); --strict fails on it.
5
+ * Usage: stitch2 screens [screen name] [--status approved|review|draft|archived] [--json] [--strict]
5
6
  */
6
- import { listScreens } from './screens.js';
7
+ import { listScreens, stateOf, versionProblems } from './screens.js';
7
8
  const args = process.argv.slice(2);
8
9
  const status = args.includes('--status') ? args[args.indexOf('--status') + 1] : undefined;
9
10
  const name = args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--status');
10
11
  const rows = listScreens()
11
- .filter((s) => s.kind === 'screen' && (!status || s.status === status) && (!name || s.screen === name))
12
- .sort((a, b) => a.screen.localeCompare(b.screen) ||
12
+ .filter((s) => s.kind === 'screen' && (!status || s.status === status) && (!name || s.screen === name || s.page === name))
13
+ .sort((a, b) => a.page.localeCompare(b.page) ||
14
+ (a.screen === a.page ? -1 : b.screen === b.page ? 1 : 0) ||
15
+ a.screen.localeCompare(b.screen) ||
13
16
  a.version.localeCompare(b.version, undefined, { numeric: true }) ||
14
17
  b.device.localeCompare(a.device));
15
18
  if (args.includes('--json'))
16
19
  console.log(JSON.stringify(rows, null, 2));
17
- else
18
- for (const s of rows)
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}` : ''}`);
20
+ else {
21
+ let page = '';
22
+ for (const s of rows) {
23
+ // A page with several screens gets a heading; its screens are listed under it by their state.
24
+ const several = rows.some((r) => r.page === s.page && r.screen !== s.screen);
25
+ if (several && s.page !== page)
26
+ console.log(`\n${s.page} (page)`);
27
+ page = s.page;
28
+ const label = several ? ` ${stateOf(s)}` : s.screen;
29
+ console.log(`${label.padEnd(20)} ${s.version.padEnd(8)} ${s.device.padEnd(8)} ${s.status.padEnd(9)} ${s.path}${s.note ? `\n${' '.repeat(39)}note: ${s.note}` : ''}`);
30
+ }
31
+ }
32
+ const problems = versionProblems().filter((p) => !name || p.startsWith(`${name} `));
33
+ if (problems.length && !args.includes('--json')) {
34
+ console.log(`\nWhich version is current is unclear (${problems.length}):`);
35
+ for (const p of problems)
36
+ console.log(` ${p}`);
37
+ }
38
+ if (problems.length && args.includes('--strict'))
39
+ process.exit(1);
package/dist/screens.d.ts CHANGED
@@ -19,6 +19,12 @@ export interface Screen {
19
19
  kind: 'screen' | 'components';
20
20
  /** Which app screen this file is a version of (<meta name="<prefix>-screen">), e.g. "home". */
21
21
  screen: string;
22
+ /**
23
+ * The page it belongs to (<meta name="<prefix>-page">): one place in the app whose states and steps are screens of
24
+ * their own, e.g. "workout" for workout, workout-entry and workout-menu. A screen's latest version that names one
25
+ * decides it for all of its versions; without one a screen is its own page.
26
+ */
27
+ page: string;
22
28
  device: 'mobile' | 'desktop';
23
29
  /** Where the version came from: stitch, lab or trial. */
24
30
  source: 'stitch' | 'lab' | 'trial';
@@ -30,5 +36,21 @@ export interface Screen {
30
36
  }
31
37
  export declare const meta: (html: string, name: string) => string | undefined;
32
38
  export declare function listScreens(): Screen[];
39
+ /** What a screen is within its page: its name without the page's ("entry" for workout-entry), or "main". */
40
+ export declare const stateOf: (s: Pick<Screen, 'screen' | 'page'>) => string;
33
41
  /** Sets (or removes, with undefined) <prefix>-* meta tags in a screen file, keeping the rest of it untouched. */
34
42
  export declare function writeMeta(path: string, values: Record<string, string | undefined>): void;
43
+ /**
44
+ * Approves a variant. Approval makes it the one current version of its screen on its device: every earlier
45
+ * version there (approved, in review or a draft) and any other approved one become archived, so nobody can mistake
46
+ * an older one for what is implemented. Later versions (a newer proposal) keep their status.
47
+ * Returns the variants it archived.
48
+ */
49
+ export declare function approve(path: string): Screen[];
50
+ /**
51
+ * What makes it unclear which version of a screen is current: two approved on one device, or an earlier version
52
+ * still in review or a draft next to an approved later one. One line each; empty when every screen is clear.
53
+ */
54
+ export declare function versionProblems(screens?: Screen[]): string[];
55
+ /** A version's number: 3 for "v3"; NaN for one without (a Stitch export, a trial). */
56
+ export declare const versionNumber: (s: Pick<Screen, 'version'>) => number;
package/dist/screens.js CHANGED
@@ -51,6 +51,7 @@ export function listScreens() {
51
51
  mtime: statSync(file).mtimeMs,
52
52
  kind: under(path, config.catalogDir) ? 'components' : 'screen',
53
53
  screen: meta(html, tag('screen')) ?? (versioned ? group.split('/').pop() : name),
54
+ page: meta(html, tag('page')) ?? '',
54
55
  device: (meta(html, tag('device')) ??
55
56
  (versioned?.[1] || (width >= 768 ? 'desktop' : 'mobile'))),
56
57
  source,
@@ -65,8 +66,17 @@ export function listScreens() {
65
66
  });
66
67
  }
67
68
  }
69
+ // One page per screen: the latest version that names one.
70
+ const pages = new Map();
71
+ for (const s of [...out].sort((a, b) => versionNumber(b) - versionNumber(a) || b.mtime - a.mtime))
72
+ if (s.page && !pages.has(s.screen))
73
+ pages.set(s.screen, s.page);
74
+ for (const s of out)
75
+ s.page = pages.get(s.screen) ?? s.screen;
68
76
  return out;
69
77
  }
78
+ /** What a screen is within its page: its name without the page's ("entry" for workout-entry), or "main". */
79
+ export const stateOf = (s) => s.screen === s.page ? 'main' : s.screen.startsWith(`${s.page}-`) ? s.screen.slice(s.page.length + 1) : s.screen;
70
80
  /** Sets (or removes, with undefined) <prefix>-* meta tags in a screen file, keeping the rest of it untouched. */
71
81
  export function writeMeta(path, values) {
72
82
  const file = join(ROOT, path);
@@ -83,3 +93,52 @@ export function writeMeta(path, values) {
83
93
  }
84
94
  writeFileSync(file, html);
85
95
  }
96
+ /**
97
+ * Approves a variant. Approval makes it the one current version of its screen on its device: every earlier
98
+ * version there (approved, in review or a draft) and any other approved one become archived, so nobody can mistake
99
+ * an older one for what is implemented. Later versions (a newer proposal) keep their status.
100
+ * Returns the variants it archived.
101
+ */
102
+ export function approve(path) {
103
+ const all = listScreens();
104
+ const target = all.find((s) => s.path === path);
105
+ if (!target || target.kind !== 'screen')
106
+ throw new Error(`not a screen: ${path}`);
107
+ writeMeta(path, { [tag('status')]: 'approved' });
108
+ const n = versionNumber(target);
109
+ const outdated = all.filter((s) => s.path !== path &&
110
+ s.kind === 'screen' &&
111
+ s.screen === target.screen &&
112
+ s.device === target.device &&
113
+ s.status !== 'archived' &&
114
+ (s.status === 'approved' || versionNumber(s) < n));
115
+ for (const s of outdated)
116
+ writeMeta(s.path, { [tag('status')]: 'archived' });
117
+ return outdated;
118
+ }
119
+ /**
120
+ * What makes it unclear which version of a screen is current: two approved on one device, or an earlier version
121
+ * still in review or a draft next to an approved later one. One line each; empty when every screen is clear.
122
+ */
123
+ export function versionProblems(screens = listScreens()) {
124
+ const out = [];
125
+ const groups = new Map();
126
+ for (const s of screens) {
127
+ if (s.kind !== 'screen' || s.status === 'archived')
128
+ continue;
129
+ const key = `${s.screen} ${s.device}`;
130
+ groups.set(key, [...(groups.get(key) ?? []), s]);
131
+ }
132
+ for (const [key, list] of groups) {
133
+ const approved = list.filter((s) => s.status === 'approved');
134
+ if (approved.length > 1)
135
+ out.push(`${key}: ${approved.map((s) => s.version).join(' and ')} are all approved; approve the current one again`);
136
+ const current = Math.max(...approved.map(versionNumber).filter((n) => !Number.isNaN(n)));
137
+ for (const s of list)
138
+ if (s.status !== 'approved' && versionNumber(s) < current)
139
+ out.push(`${key}: ${s.version} is still ${s.status} although v${current} is approved; archive it (${s.path})`);
140
+ }
141
+ return out.sort();
142
+ }
143
+ /** A version's number: 3 for "v3"; NaN for one without (a Stitch export, a trial). */
144
+ export const versionNumber = (s) => Number(/^v(\d+)$/.exec(s.version)?.[1] ?? Number.NaN);
package/dist/serve.js CHANGED
@@ -28,7 +28,7 @@ function inlineTailwind(html, file) {
28
28
  return `<style type="text/tailwindcss">${readFileSync(css, 'utf8').replace(/\s*\n\s*/g, ' ')}</style>`;
29
29
  });
30
30
  }
31
- import { listScreens, ROOT, STATUSES, tag, writeMeta } from './screens.js';
31
+ import { listScreens, ROOT, STATUSES, tag, writeMeta, approve } from './screens.js';
32
32
  /**
33
33
  * The consistency report covers every screen that is not archived and the components; the app comparison covers
34
34
  * the approved screens. A report is stale once one of those files is newer than it (a status change counts: it
@@ -131,14 +131,9 @@ export function startServer(port) {
131
131
  if (note !== undefined)
132
132
  values[tag('note')] = note.trim() || undefined;
133
133
  writeMeta(path, values);
134
- // One approved version per screen and device: approving archives the previous one.
135
- if (status === 'approved')
136
- for (const s of all)
137
- if (s.path !== path &&
138
- s.screen === target.screen &&
139
- s.device === target.device &&
140
- s.status === 'approved')
141
- writeMeta(s.path, { [tag('status')]: 'archived' });
134
+ // One current version per screen and device: approving archives every earlier one (screens.ts).
135
+ if (status === 'approved' && target.kind === 'screen')
136
+ approve(path);
142
137
  json(listScreens());
143
138
  }
144
139
  catch (e) {
package/dist/setup.js CHANGED
@@ -92,7 +92,7 @@ ${extra}<meta charset="utf-8" />
92
92
  <script src="/components/${prefix}.js" defer></script>
93
93
  </head>
94
94
  `;
95
- write(join(design, 'screens/_template.html'), `${head('Screen name', `<meta name="${prefix}-screen" content="screen-name" />\n<meta name="${prefix}-device" content="mobile" />\n<meta name="${prefix}-status" content="draft" />\n`)}<!--
95
+ write(join(design, 'screens/_template.html'), `${head('Screen name', `<meta name="${prefix}-screen" content="screen-name" />\n<meta name="${prefix}-page" content="screen-name" />\n<meta name="${prefix}-device" content="mobile" />\n<meta name="${prefix}-status" content="draft" />\n`)}<!--
96
96
  Template for ${name} screens. Copy it to ${design}/screens/<screen>/<device>-v<N>.html and set the ${prefix}-* meta
97
97
  tags (desktop: ${prefix}-device desktop and ${prefix}-frame-width 1280); status draft while you work, review when
98
98
  done. Files starting with _ are not shown on the canvas. Build with the components in the catalog
package/dist/typecheck.js CHANGED
@@ -30,6 +30,9 @@ export function collectText() {
30
30
  const r = el.getBoundingClientRect();
31
31
  if (!r.width || !r.height)
32
32
  continue;
33
+ // Text for screen readers only (Tailwind's sr-only: a 1px box, clipped away) is not drawn.
34
+ if (r.width <= 1 && r.height <= 1 && cs.position === 'absolute')
35
+ continue;
33
36
  let opacity = 1;
34
37
  for (let e = el; e; e = e.parentElement)
35
38
  opacity *= Number(getComputedStyle(e).opacity);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitch2",
3
- "version": "0.15.3",
3
+ "version": "0.17.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",
@@ -66,7 +66,8 @@ References, read before the first screen:
66
66
  6. **Look and critique.** View the render in `<design>/renders/` (crop the first screenful). Score it with the
67
67
  rubric in principles.md, fix what fails, repeat 5–6 at most three times.
68
68
  7. **Hand over.** Set the version's status to `review`, point the user to it on the canvas, and report what
69
- changed, how you answered their notes, and any rubric item you could not satisfy. The user approves.
69
+ changed, how you answered their notes, and any rubric item you could not satisfy. The user approves; only then
70
+ does it go into the app (the approved version is the one current version: see screens.md).
70
71
 
71
72
  ## Commands
72
73
 
@@ -80,7 +81,8 @@ References, read before the first screen:
80
81
  | `i18n [filter] [--locale l] [--suggest]` | Message keys screens use that the source or a locale lacks, and text with no key (stitch2-i18n) |
81
82
  | `consistency [kind…]` | Every shared component cropped from every screen, next to the catalog's version |
82
83
  | `type [filter] [--json]` | Every text style a screen uses and the type level it matches |
83
- | `screens [name] [--status s] [--json]` | Every version with its status and the user's note |
84
+ | `screens [name] [--status s] [--json] [--strict]` | Every version with its status and the user's note, and anything that makes the current version unclear |
85
+ | `approve <path or filter…>` | Approves variants for the user (only on their word); every earlier version of the screen on that device is archived |
84
86
  | `compare [screen…]` | The live app against the approved versions: missing or extra components, order, looks; a side-by-side report |
85
87
  | `elements <entry>` | Bundles the app's components, turned into elements with `stitch2/elements`, for the screens |
86
88
  | `tokens` | `tokens.css` (colours, `.type-*` classes) and `tailwind.tokens.js` from DESIGN.md |
@@ -13,10 +13,23 @@ and build on it. Meta tag names below use the project's prefix (`<p>`, from `sti
13
13
  (`mobile-v3.html` and `desktop-v3.html` are v3). A new version takes the next free N across the screen's
14
14
  variants; a variant added to an existing version takes that version's N (a desktop for v3 is
15
15
  `desktop-v3.html`, never v4). A version may start with one variant and get the others later.
16
- Its head carries `<p>-screen`, `<p>-device` (mobile | desktop), `<p>-status`, `<p>-source` (stitch | lab),
16
+ Its head carries `<p>-screen`, `<p>-page` (see Pages), `<p>-device` (mobile | desktop), `<p>-status`, `<p>-source` (stitch | lab),
17
17
  `<p>-frame-width` (390 mobile, 1280 desktop) and, written by the user, `<p>-note`. Older files elsewhere
18
18
  carry the same tags.
19
19
 
20
+ ## Pages and their screens
21
+
22
+ A place in the app often has several states or steps: the workout with its number pad, its menu and its discard
23
+ question; an exercise list with its detail sheet and a three-step form. Each is a **screen** of its own (it has its own
24
+ versions and approval), and they belong to one **page**: `<p>-page` in the head, the page's name (`workout`). The page's
25
+ main screen has the page's name; the others are named `<page>-<state>` (`workout-entry`, `workout-menu`,
26
+ `create-exercise-1`) and carry the same `<p>-page`. The canvas folds a page's screens under its name and lays them out
27
+ together; `stitch2 screens <page>` lists them. A screen without `<p>-page` is a page of its own.
28
+
29
+ - A new state of an existing place (a sheet, a dialog, an empty, loading or error state, a step) is a new screen in
30
+ that page, not a new page.
31
+ - Copy `<p>-page` along with the other tags into each new version; the latest version that has it decides the page.
32
+
20
33
  ## New version or edit?
21
34
 
22
35
  - **New version** for every new attempt: a new direction, a regeneration, an export from a generator, a
@@ -35,8 +48,13 @@ and build on it. Meta tag names below use the project's prefix (`<p>`, from `sti
35
48
  |---|---|---|
36
49
  | `draft` | Work in progress | The agent, while working |
37
50
  | `review` | Finished, waiting for the user ("Needs review") | The agent, when done |
38
- | `approved` | The reference: build on it, match it, implement it | The user only |
39
- | `archived` | History: superseded or rejected; hidden on the canvas | The user; approving a variant archives the previously approved variant of the same device |
51
+ | `approved` | The current version: the reference to build on and match, and the only one the app implements | The user only (on the canvas, or in words: then the agent runs `stitch2 approve`) |
52
+ | `archived` | Outdated: superseded or rejected; hidden on the canvas | Approval: approving a variant archives every earlier version of that screen on that device, whatever its status |
53
+
54
+ **One current version.** For each screen and device there is at most one approved variant, and it is what the app
55
+ shows. Approving one archives every earlier version there (approved, in review or a draft), so nothing older can be
56
+ mistaken for the current one; a later version, a newer proposal, keeps its status. `stitch2 screens` ends with
57
+ anything that breaks this (two approved, an older version still in review); `--strict` fails on it.
40
58
 
41
59
  The canvas shows each version with its variants side by side under one heading, and one status for the version:
42
60
  needs review while any variant does. **Approve version** approves every variant in review at once; a single
@@ -47,8 +65,12 @@ variant can still be approved, archived or noted on its own.
47
65
  1. Before designing, run `stitch2 screens` (a name or `--status approved` narrows it) and open the approved
48
66
  versions of the screens next to yours.
49
67
  2. A note is the user's feedback: address every point in your next version or edit, and say how.
50
- 3. Never set `approved` yourself; never build on an archived version unless asked.
51
- 4. Only approved versions get implemented in the product.
68
+ 3. Never approve on your own and never build on an archived version unless asked. When the user approves in words
69
+ ("approve the v3 screens"), run `stitch2 approve <path or filter…>` rather than editing status tags: it archives the
70
+ earlier versions as the canvas does.
71
+ 4. **Implement only approved versions.** A version in review or a draft is not built in the app, not even partly. If
72
+ the user asks to implement one that is not approved yet, that request is their approval: run `stitch2 approve` on it
73
+ first and say so. If the app needs something no approved version shows, design it and hand it over first.
52
74
  5. When the user pastes a reference copied from the canvas, such as `home mobile v2
53
75
  (design/screens/home/mobile-v2.html)`, open that file: it names exactly the version they mean.
54
76
  6. A reference to an element adds its source line, its tag and its text: `home mobile v1
@@ -59,7 +81,7 @@ variant can still be approved, archived or noted on its own.
59
81
 
60
82
  ## Implementing an approved version
61
83
 
62
- Build the screen in the app from the same components the screens use, in the version's order, with the app's
84
+ Check first with `stitch2 screens <screen>` that the version is approved and is the only approved one on its device. Build the screen in the app from the same components the screens use, in the version's order, with the app's
63
85
  own data, strings and behaviour; keep the app's tests passing. Then run `stitch2 compare <screen>` with the app
64
86
  running: fix every `missing` component and look at the side-by-side report. `extra` and `order` warnings are
65
87
  fine when the app needs them (a desktop-only control, a frame drawn by layout); say so when you hand over. When
@@ -19,7 +19,10 @@ a component rebuilt by hand. The checks can only judge what the canvas draws, so
19
19
  approved screen if the change is visible there; a catalog entry for a new variant or state), and run
20
20
  `stitch2 check <screens> --strict`. Look at the screens yourself: the check finds overlaps and overflow, not
21
21
  ugliness.
22
- 3. **Approval**: the user approves the new versions (or the catalog change) as for any design.
22
+ 3. **Approval**: the user approves the new versions (or the catalog change) as for any design. Nothing goes into
23
+ the app before this: a version in review is not implemented, not even partly. When the user asks to implement
24
+ one in review, run `stitch2 approve` on it first; that archives the earlier versions, so the approved one is the
25
+ single current version and the app matches it.
23
26
  4. **App**: the app picks the component up; wire it, then run `stitch2 compare <screen>`.
24
27
 
25
28
  Fixing a bug found in the app follows the same path: reproduce it on the canvas (a screen or catalog state that
@@ -62,7 +65,8 @@ Read the findings as:
62
65
 
63
66
  ## Before you call it done
64
67
 
65
- 1. `stitch2 check --strict` passes on the screens you touched (archived screens excepted).
66
- 2. `stitch2 compare` has no errors for the screens you touched; warnings are explained (a kept feature) or fixed.
67
- 3. The user has seen the canvas versions of anything that changed how a component looks.
68
+ 1. Every screen you implemented is approved, and `stitch2 screens --strict` passes: one current version each.
69
+ 2. `stitch2 check --strict` passes on the screens you touched (archived screens excepted).
70
+ 3. `stitch2 compare` has no errors for the screens you touched; warnings are explained (a kept feature) or fixed.
71
+ 4. The user has seen the canvas versions of anything that changed how a component looks.
68
72