stitch2 0.16.0 → 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/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/list.js CHANGED
@@ -4,20 +4,31 @@
4
4
  * Ends with what makes the current version unclear (versionProblems); --strict fails on it.
5
5
  * Usage: stitch2 screens [screen name] [--status approved|review|draft|archived] [--json] [--strict]
6
6
  */
7
- import { listScreens, versionProblems } from './screens.js';
7
+ import { listScreens, stateOf, versionProblems } from './screens.js';
8
8
  const args = process.argv.slice(2);
9
9
  const status = args.includes('--status') ? args[args.indexOf('--status') + 1] : undefined;
10
10
  const name = args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--status');
11
11
  const rows = listScreens()
12
- .filter((s) => s.kind === 'screen' && (!status || s.status === status) && (!name || s.screen === name))
13
- .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) ||
14
16
  a.version.localeCompare(b.version, undefined, { numeric: true }) ||
15
17
  b.device.localeCompare(a.device));
16
18
  if (args.includes('--json'))
17
19
  console.log(JSON.stringify(rows, null, 2));
18
- else
19
- for (const s of rows)
20
- 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
+ }
21
32
  const problems = versionProblems().filter((p) => !name || p.startsWith(`${name} `));
22
33
  if (problems.length && !args.includes('--json')) {
23
34
  console.log(`\nWhich version is current is unclear (${problems.length}):`);
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,6 +36,8 @@ 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;
35
43
  /**
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);
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitch2",
3
- "version": "0.16.0",
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",
@@ -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