stitch2 0.0.0-stage → 0.7.2

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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -2
  3. package/config.schema.json +109 -0
  4. package/dist/assets.d.ts +1 -0
  5. package/dist/assets.js +59 -0
  6. package/dist/audit.d.ts +10 -0
  7. package/dist/audit.js +259 -0
  8. package/dist/build-design.d.ts +39 -0
  9. package/dist/build-design.js +337 -0
  10. package/dist/build-elements.d.ts +1 -0
  11. package/dist/build-elements.js +33 -0
  12. package/dist/canvas.html +689 -0
  13. package/dist/check.d.ts +1 -0
  14. package/dist/check.js +86 -0
  15. package/dist/cli.d.ts +2 -0
  16. package/dist/cli.js +73 -0
  17. package/dist/colours.d.ts +2 -0
  18. package/dist/colours.js +57 -0
  19. package/dist/compare.d.ts +1 -0
  20. package/dist/compare.js +185 -0
  21. package/dist/config.d.ts +53 -0
  22. package/dist/config.js +38 -0
  23. package/dist/consistency.d.ts +40 -0
  24. package/dist/consistency.js +336 -0
  25. package/dist/design-md.d.ts +27 -0
  26. package/dist/design-md.js +49 -0
  27. package/dist/design-page.d.ts +1 -0
  28. package/dist/design-page.js +127 -0
  29. package/dist/elements.d.ts +32 -0
  30. package/dist/elements.js +69 -0
  31. package/dist/exports.d.ts +25 -0
  32. package/dist/exports.js +86 -0
  33. package/dist/extract.d.ts +1 -0
  34. package/dist/extract.js +197 -0
  35. package/dist/init.d.ts +5 -0
  36. package/dist/init.js +83 -0
  37. package/dist/list.d.ts +1 -0
  38. package/dist/list.js +17 -0
  39. package/dist/pick.d.ts +10 -0
  40. package/dist/pick.js +38 -0
  41. package/dist/report.d.ts +1 -0
  42. package/dist/report.js +110 -0
  43. package/dist/sandbox.d.ts +1 -0
  44. package/dist/sandbox.js +183 -0
  45. package/dist/screens.d.ts +34 -0
  46. package/dist/screens.js +85 -0
  47. package/dist/serve.d.ts +4 -0
  48. package/dist/serve.js +181 -0
  49. package/dist/setup.d.ts +1 -0
  50. package/dist/setup.js +214 -0
  51. package/dist/tokens.d.ts +1 -0
  52. package/dist/tokens.js +128 -0
  53. package/dist/type.d.ts +1 -0
  54. package/dist/type.js +60 -0
  55. package/dist/typecheck.d.ts +35 -0
  56. package/dist/typecheck.js +112 -0
  57. package/package.json +53 -4
  58. package/skills/stitch2/SKILL.md +80 -0
  59. package/skills/stitch2/references/components.md +59 -0
  60. package/skills/stitch2/references/guides.md +20 -0
  61. package/skills/stitch2/references/principles.md +80 -0
  62. package/skills/stitch2/references/screens.md +70 -0
  63. package/skills/stitch2/references/typography.md +106 -0
  64. package/skills/stitch2-app-sync/SKILL.md +65 -0
  65. package/skills/stitch2-consistency/SKILL.md +59 -0
  66. package/skills/stitch2-design-md/SKILL.md +64 -0
  67. package/skills/stitch2-import-from-google-stitch/SKILL.md +47 -0
  68. package/skills/stitch2-import-from-google-stitch/references/how-stitch-works.md +36 -0
  69. package/skills/stitch2-variants/SKILL.md +45 -0
@@ -0,0 +1,689 @@
1
+ <!doctype html>
2
+ <html lang="en" class="lab">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>stitch2</title>
7
+ <style>
8
+ :root { --accent: #e8662c; --bad: #e5484d; --bg: #0b0b0d; --edge: #2e2e33; --faint: #71717a; --good: #3fb27f; --muted: #a1a1aa; --panel: #141417; --panel2: #1c1c20; --side: 288px; --text: #f4f4f5; --warn: #e5b54a; color-scheme: dark; }
9
+ * { box-sizing: border-box; }
10
+ html, body { background: var(--bg); color: var(--text); font: 13px/1.4 system-ui, -apple-system, 'Segoe UI', sans-serif; height: 100%; margin: 0; overflow: hidden; }
11
+ button, input, textarea { color: inherit; font: inherit; }
12
+ .copy { background: none; border: 0; border-radius: 4px; color: var(--faint); cursor: pointer; flex: none; font-size: 11px; opacity: 0; padding: 1px 5px; }
13
+ .item:hover .copy, .item:focus-within .copy, .screen-name:hover .copy, .frame .label:hover .copy, #inspector .copy { opacity: 1; }
14
+ .copy:hover { background: var(--panel2); color: var(--text); }
15
+ .row-label { align-items: baseline; display: flex; gap: 12px; overflow: hidden; position: absolute; white-space: nowrap; }
16
+ .row-label b { color: var(--text); font-size: 24px; }
17
+ .row-label span { color: var(--muted); font-size: 15px; overflow: hidden; text-overflow: ellipsis; }
18
+ #doc { background: var(--bg); display: none; inset: 0 0 0 var(--side); position: fixed; z-index: 19; }
19
+ #doc.open { display: block; }
20
+ .doc-frame { border: 0; height: 100%; width: 100%; }
21
+ .doc-close { background: var(--panel2); border: 1px solid var(--edge); border-radius: 8px; cursor: pointer; padding: 4px 10px; position: absolute; right: 16px; top: 12px; }
22
+ #side { background: var(--panel); border-right: 1px solid var(--edge); display: flex; flex-direction: column; inset: 0 auto 0 0; position: fixed; width: var(--side); z-index: 20; }
23
+ #side header { border-bottom: 1px solid var(--edge); display: grid; gap: 10px; padding: 14px 14px 10px; }
24
+ #side header b { font-size: 14px; }
25
+ #search { background: var(--panel2); border: 1px solid var(--edge); border-radius: 8px; padding: 7px 10px; width: 100%; }
26
+ .seg { background: var(--bg); border: 1px solid var(--edge); border-radius: 8px; display: flex; gap: 2px; padding: 2px; }
27
+ .seg button { background: none; border: 0; border-radius: 6px; color: var(--muted); cursor: pointer; flex: 1; padding: 4px 6px; white-space: nowrap; }
28
+ .seg button.on { background: var(--panel2); color: var(--text); }
29
+ #tree { flex: 1; overflow: auto; padding: 6px 8px 16px; }
30
+ #tree h4 { color: var(--faint); font-size: 11px; letter-spacing: .06em; margin: 14px 6px 6px; text-transform: uppercase; }
31
+ .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%; }
32
+ .item:hover { background: var(--panel2); color: var(--text); }
33
+ .item.on { background: #2a1a12; color: var(--text); }
34
+ .item .meta { color: var(--faint); font-size: 11px; margin-left: auto; white-space: nowrap; }
35
+ .screen-name { align-items: center; border-radius: 6px; color: var(--text); cursor: pointer; display: flex; font-weight: 600; gap: 6px; padding: 8px 8px 2px; }
36
+ .screen-name:hover { background: var(--panel2); }
37
+ .screen-name .meta { color: var(--faint); font-size: 11px; font-weight: 400; margin-left: auto; }
38
+ .version { padding-left: 20px; }
39
+ .dot { background: var(--faint); border-radius: 50%; flex: none; height: 8px; width: 8px; }
40
+ .dot.approved { background: var(--good); } .dot.review { background: var(--warn); } .dot.draft { background: var(--muted); } .dot.archived { background: transparent; border: 1px solid var(--faint); }
41
+ #side footer { align-items: center; border-top: 1px solid var(--edge); color: var(--muted); display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 14px; }
42
+ #side footer button { background: var(--panel2); border: 1px solid var(--edge); border-radius: 8px; cursor: pointer; padding: 4px 10px; }
43
+ #zoom { font-variant-numeric: tabular-nums; margin-left: auto; }
44
+ #summary { font-size: 12px; width: 100%; }
45
+ #viewport { cursor: grab; inset: 0 0 0 var(--side); overflow: hidden; position: fixed; touch-action: none; }
46
+ #viewport.panning { cursor: grabbing; }
47
+ #world { inset: 0 auto auto 0; position: absolute; transform-origin: 0 0; }
48
+ .frame { position: absolute; }
49
+ .frame .label { align-items: center; cursor: pointer; display: flex; gap: 8px; height: 30px; margin-bottom: 8px; white-space: nowrap; }
50
+ .frame .name { font-size: 15px; font-weight: 600; }
51
+ .frame .pill { border: 1px solid var(--edge); border-radius: 999px; color: var(--muted); font-size: 12px; padding: 1px 8px; }
52
+ .frame .pill.approved { border-color: #1f4d39; color: var(--good); } .frame .pill.review { border-color: #5a4a20; color: var(--warn); }
53
+ .frame .title { color: var(--faint); min-width: 0; overflow: hidden; text-overflow: ellipsis; }
54
+ .frame .badge { background: var(--panel); border: 1px solid var(--edge); border-radius: 999px; color: var(--muted); cursor: pointer; margin-left: auto; padding: 2px 10px; }
55
+ .frame .badge.ok { color: var(--good); } .frame .badge.warn { border-color: #5a4a20; color: var(--warn); } .frame .badge.bad { border-color: #5a2326; color: var(--bad); }
56
+ .device { background: #000; border-radius: 28px; box-shadow: 0 20px 60px rgba(0,0,0,.45); outline: 1px solid var(--edge); overflow: hidden; position: relative; }
57
+ .frame.desktop .device { border-radius: 12px; }
58
+ .frame.selected .device { outline: 3px solid var(--accent); }
59
+ .frame.archived { opacity: .55; }
60
+ .device iframe { background: #fff; border: 0; display: block; }
61
+ .device .shield { cursor: pointer; inset: 0; position: absolute; }
62
+ .frame.live .shield { display: none; }
63
+ .frame.changed .device { animation: flash 900ms ease-out; }
64
+ @keyframes flash { from { outline: 3px solid var(--accent); } to { outline: 1px solid var(--edge); } }
65
+ #inspector { background: var(--panel); border: 1px solid var(--edge); border-radius: 12px; display: none; max-height: calc(100% - 24px); overflow: auto; padding: 14px; position: fixed; right: 12px; top: 12px; width: 360px; z-index: 21; }
66
+ #inspector.open { display: block; }
67
+ #inspector h3 { align-items: center; display: flex; font-size: 16px; gap: 8px; margin: 0; }
68
+ #inspector h3 .close { background: none; border: 0; color: var(--muted); cursor: pointer; font-size: 18px; }
69
+ #inspector h3 .copy { margin-left: auto; }
70
+ #inspector .facts { color: var(--muted); margin: 4px 0 12px; }
71
+ #inspector .facts a { color: var(--muted); }
72
+ #inspector .status { display: grid; gap: 6px; grid-template-columns: repeat(4, 1fr); margin-bottom: 10px; }
73
+ #inspector .status button { background: var(--panel2); border: 1px solid var(--edge); border-radius: 8px; color: var(--muted); cursor: pointer; font-size: 12px; padding: 6px 2px; white-space: nowrap; }
74
+ #inspector .status button.on { border-color: var(--accent); color: var(--text); }
75
+ #inspector textarea { background: var(--panel2); border: 1px solid var(--edge); border-radius: 8px; min-height: 70px; padding: 8px; resize: vertical; width: 100%; }
76
+ #inspector .save { background: var(--panel2); border: 1px solid var(--edge); border-radius: 8px; cursor: pointer; margin: 6px 0 14px; padding: 4px 10px; }
77
+ #inspector h4 { color: var(--faint); font-size: 11px; letter-spacing: .06em; margin: 10px 0 6px; text-transform: uppercase; }
78
+ #inspector ul { margin: 0; padding: 0; }
79
+ #inspector li { border: 1px solid transparent; border-radius: 8px; cursor: pointer; list-style: none; padding: 7px 8px; }
80
+ #inspector li:hover { background: var(--panel2); border-color: var(--edge); }
81
+ #inspector .sev { font-size: 10px; font-weight: 600; margin-right: 6px; text-transform: uppercase; }
82
+ #inspector .error .sev { color: var(--bad); } #inspector .warning .sev { color: var(--warn); }
83
+ #inspector code { color: var(--faint); display: block; font-size: 11px; margin-top: 2px; word-break: break-all; }
84
+ #toast { background: var(--panel2); border: 1px solid var(--edge); border-radius: 8px; bottom: 16px; left: calc(var(--side) + 16px); opacity: 0; padding: 6px 12px; pointer-events: none; position: fixed; transition: opacity .2s; z-index: 30; }
85
+ #toast.on { opacity: 1; }
86
+ .pick-box { pointer-events: none; position: absolute; z-index: 2; }
87
+ .pick-box.hover { background: rgba(232, 102, 44, .10); outline: 2px solid var(--accent); }
88
+ .pick-box.picked { outline: 2px dashed var(--accent); }
89
+ .pick-box span { background: var(--accent); border-radius: 4px; color: #0b0b0d; font-weight: 600; left: 0; padding: 1px 6px; position: absolute; top: 0; transform: translateY(-100%); white-space: nowrap; }
90
+ .pick-box.inside span { transform: none; }
91
+ body.picking .device .shield { cursor: crosshair; }
92
+ #pick.on { border-color: var(--accent); color: var(--text); }
93
+ #inspector .picks li { cursor: default; display: flex; gap: 6px; }
94
+ #inspector .picks code { flex: 1; margin: 0; }
95
+ #inspector .picks button, #inspector .pick-actions button { background: var(--panel2); border: 1px solid var(--edge); border-radius: 8px; cursor: pointer; padding: 3px 8px; }
96
+ #inspector .picks button { align-self: start; padding: 0 6px; }
97
+ #inspector .pick-actions { display: flex; gap: 6px; margin: 6px 0 4px; }
98
+ #inspector .hint { color: var(--faint); font-size: 12px; margin: 0 0 8px; }
99
+ </style>
100
+ </head>
101
+ <body>
102
+ <aside id="side">
103
+ <header>
104
+ <b id="labName">stitch2</b>
105
+ <input id="search" placeholder="Search screens… ( / )" />
106
+ <div class="seg" id="statusFilter"><button type="button" data-v="active" class="on" title="Everything except archived versions">Active</button><button type="button" data-v="approved" title="The reference versions agents build on">Approved</button><button type="button" data-v="review" title="Finished by an agent, waiting for your decision">Needs review</button><button type="button" data-v="all" title="Including archived versions">All</button></div>
107
+ <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>
108
+ </header>
109
+ <nav id="tree"></nav>
110
+ <footer>
111
+ <button type="button" id="fit" title="Fit all (F)">Fit</button>
112
+ <button type="button" id="pick" title="Pick elements to reference (P)">Pick</button>
113
+ <button type="button" id="reload" title="Reload every frame (R)">Reload</button>
114
+ <span id="zoom">100%</span>
115
+ <span id="summary"></span>
116
+ </footer>
117
+ </aside>
118
+ <div id="viewport"><div id="world"></div></div>
119
+ <div id="inspector"></div>
120
+ <div id="toast" role="status"></div>
121
+ <div id="doc"><iframe class="doc-frame" title="DESIGN.md"></iframe><button type="button" class="doc-close">Close (Esc)</button></div>
122
+ <script type="module">
123
+ const viewport = document.getElementById('viewport')
124
+ const world = document.getElementById('world')
125
+ const SIDE = 288, GAP_X = 72, GAP_Y = 150, GROUP_GAP = 200, LINE_W = 3400, ROW_LABEL_H = 52, LABEL_H = 38
126
+ let ORDER = [] // screen order, from the config
127
+ const STATUS_RANK = { approved: 0, review: 1, draft: 2, archived: 3 }
128
+ const STATUS_LABEL = { approved: 'approved', review: 'needs review', draft: 'draft', archived: 'archived' }
129
+ const STATUS_HINT = {
130
+ approved: 'The reference: agents build on it and it gets implemented',
131
+ review: 'Finished by an agent, waiting for your decision',
132
+ draft: 'Still being worked on',
133
+ archived: 'Kept for history, hidden unless you show all',
134
+ }
135
+ /** What to paste to an agent: the screen, device and version, and the file. */
136
+ let designDir = 'design'
137
+ const ref = (s) => (s.kind === 'components' ? `component catalog (${designDir}/${s.path})` : `${s.screen} ${s.device} ${s.version} (${designDir}/${s.path})`)
138
+ let toastTimer
139
+ async function copy(text) {
140
+ try {
141
+ await navigator.clipboard.writeText(text)
142
+ } catch {
143
+ const t = document.createElement('textarea')
144
+ t.value = text
145
+ document.body.append(t)
146
+ t.select()
147
+ document.execCommand('copy')
148
+ t.remove()
149
+ }
150
+ const toast = document.getElementById('toast')
151
+ toast.textContent = `Copied: ${text}`
152
+ toast.classList.add('on')
153
+ clearTimeout(toastTimer)
154
+ toastTimer = setTimeout(() => toast.classList.remove('on'), 1800)
155
+ }
156
+ const copyButton = (text, title) => {
157
+ const b = document.createElement('button')
158
+ b.type = 'button'
159
+ b.className = 'copy'
160
+ b.textContent = 'Copy'
161
+ b.title = title
162
+ b.onclick = (e) => { e.stopPropagation(); copy(text) }
163
+ return b
164
+ }
165
+ let view = JSON.parse(localStorage.getItem('stitch2-view') ?? 'null') ?? { x: 40, y: 60, z: 0.4 }
166
+ const filters = JSON.parse(localStorage.getItem('stitch2-filters') ?? 'null') ?? { status: 'active', device: 'all' }
167
+ let screens = []
168
+ let checks = { screens: {} }
169
+ let selected = null
170
+ const frames = new Map() // path -> { el, iframe, screen, height }
171
+
172
+ const apply = () => {
173
+ world.style.transform = `translate(${view.x}px, ${view.y}px) scale(${view.z})`
174
+ viewport.style.backgroundImage = 'radial-gradient(#26262b 1px, transparent 1px)'
175
+ const s = 24 * view.z
176
+ viewport.style.backgroundSize = `${s}px ${s}px`
177
+ viewport.style.backgroundPosition = `${view.x}px ${view.y}px`
178
+ document.getElementById('zoom').textContent = `${Math.round(view.z * 100)}%`
179
+ localStorage.setItem('stitch2-view', JSON.stringify(view))
180
+ }
181
+ const zoomAt = (cx, cy, z) => {
182
+ z = Math.min(4, Math.max(0.03, z))
183
+ view.x = cx - (cx - view.x) * (z / view.z)
184
+ view.y = cy - (cy - view.y) * (z / view.z)
185
+ view.z = z
186
+ apply()
187
+ }
188
+
189
+ // ---- grouping: rows are app screens (components first), frames are versions ----
190
+ const rowKey = (s) => (s.kind === 'components' ? '~components' : s.screen)
191
+ const rowRank = (k) => (k === '~components' ? -1 : ORDER.includes(k) ? ORDER.indexOf(k) : 100)
192
+ const sortRows = (keys) => keys.sort((a, b) => rowRank(a) - rowRank(b) || a.localeCompare(b))
193
+ const sortVersions = (list) =>
194
+ 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)
195
+ function shown(s) {
196
+ const q = document.getElementById('search').value.trim().toLowerCase()
197
+ if (q && !`${s.screen} ${s.title} ${s.path} ${s.version} ${s.source}`.toLowerCase().includes(q)) return false
198
+ if (filters.device !== 'all' && s.kind === 'screen' && s.device !== filters.device) return false
199
+ if (s.kind === 'components') return filters.status !== 'review' && filters.status !== 'approved'
200
+ if (filters.status === 'active') return s.status !== 'archived'
201
+ if (filters.status === 'all') return true
202
+ return s.status === filters.status
203
+ }
204
+ const rows = () => {
205
+ const by = new Map()
206
+ for (const s of screens) by.set(rowKey(s), [...(by.get(rowKey(s)) ?? []), s])
207
+ return sortRows([...by.keys()]).map((k) => ({ key: k, all: sortVersions(by.get(k)), visible: sortVersions(by.get(k).filter(shown)) }))
208
+ }
209
+
210
+ // Screens flow left to right and wrap, like a design canvas; each screen's versions stay together.
211
+ function layout() {
212
+ for (const n of world.querySelectorAll('.row-label')) n.remove()
213
+ let x = 0, y = 0, lineH = 0
214
+ for (const row of rows()) {
215
+ for (const s of row.all) frames.get(s.path).el.style.display = row.visible.includes(s) ? '' : 'none'
216
+ if (!row.visible.length) continue
217
+ const groupW = row.visible.reduce((w, s) => w + s.width, 0) + GAP_X * (row.visible.length - 1)
218
+ if (x > 0 && x + groupW > LINE_W) { x = 0; y += lineH + GAP_Y; lineH = 0 }
219
+ const label = document.createElement('div')
220
+ label.className = 'row-label'
221
+ const approved = row.all.filter((s) => s.status === 'approved').map((s) => `${s.device} ${s.version}`)
222
+ label.innerHTML = '<b></b><span></span>'
223
+ label.querySelector('b').textContent = row.key === '~components' ? 'Components' : row.key
224
+ label.querySelector('span').textContent = row.key === '~components' ? 'the catalog every screen is built from' : approved.length ? `approved: ${approved.join(', ')}` : 'nothing approved yet'
225
+ label.style.transform = `translate(${x}px, ${y}px)`
226
+ label.style.maxWidth = `${groupW}px`
227
+ world.append(label)
228
+ let fx = x, tallest = 0
229
+ for (const s of row.visible) {
230
+ const f = frames.get(s.path)
231
+ f.el.style.left = `${fx}px`
232
+ f.el.style.top = `${y + ROW_LABEL_H}px`
233
+ fx += s.width + GAP_X
234
+ tallest = Math.max(tallest, f.height + LABEL_H)
235
+ }
236
+ lineH = Math.max(lineH, ROW_LABEL_H + tallest)
237
+ x += groupW + GROUP_GAP
238
+ }
239
+ tree()
240
+ }
241
+
242
+ function tree() {
243
+ const nav = document.getElementById('tree')
244
+ nav.innerHTML = ''
245
+ const head = (t) => { const h = document.createElement('h4'); h.textContent = t; nav.append(h) }
246
+ const item = (label, meta, on, click, dot, cls = '', copyText = '') => {
247
+ const b = document.createElement('div')
248
+ b.setAttribute('role', 'button')
249
+ b.tabIndex = 0
250
+ b.onkeydown = (e) => { if (e.key === 'Enter') click() }
251
+ b.className = `item ${cls}${on ? ' on' : ''}`
252
+ b.innerHTML = `${dot ? `<span class="dot ${dot}"></span>` : ''}<span class="t"></span><span class="meta"></span>`
253
+ b.querySelector('.t').textContent = label
254
+ b.querySelector('.meta').textContent = meta
255
+ b.onclick = click
256
+ if (copyText) b.append(copyButton(copyText, 'Copy a reference to paste to an agent'))
257
+ nav.append(b)
258
+ return b
259
+ }
260
+ head('Design system')
261
+ const docOpen = (url) => document.getElementById('doc').classList.contains('open') && docUrl === url
262
+ item('DESIGN.md', 'tokens · rules', docOpen('/design'), () => openDoc('/design'))
263
+ item('Consistency report', 'components × screens', docOpen('/consistency/index.html'), () => openDoc('/consistency/index.html'))
264
+ item('App comparison', 'approved × live app', docOpen('/compare/index.html'), () => openDoc('/compare/index.html'))
265
+ const all = rows()
266
+ const comp = all.find((r) => r.key === '~components')
267
+ if (comp?.visible.length) {
268
+ head('Components')
269
+ for (const s of comp.visible) item(s.title.replace(/^[^—]+ — /, ''), 'catalog', selected?.screen.path === s.path, () => select(frames.get(s.path), true), '', '', ref(s))
270
+ }
271
+ head('Screens')
272
+ for (const row of all) {
273
+ if (row.key === '~components' || !row.visible.length) continue
274
+ const name = document.createElement('div')
275
+ name.className = 'screen-name'
276
+ const approved = row.all.some((s) => s.status === 'approved')
277
+ name.innerHTML = `<span class="dot ${approved ? 'approved' : row.all.some((s) => s.status === 'review') ? 'review' : 'draft'}"></span><span></span><span class="meta"></span>`
278
+ name.children[1].textContent = row.key
279
+ name.querySelector('.meta').textContent = `${row.visible.length} version${row.visible.length > 1 ? 's' : ''}`
280
+ name.onclick = () => select(frames.get(row.visible[0].path), true)
281
+ name.append(copyButton(row.key, 'Copy the screen name'))
282
+ nav.append(name)
283
+ 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))
284
+ }
285
+ }
286
+
287
+ function fit() {
288
+ let w = 0, h = 0
289
+ for (const f of frames.values()) {
290
+ if (f.el.style.display === 'none') continue
291
+ w = Math.max(w, parseFloat(f.el.style.left) + f.screen.width)
292
+ h = Math.max(h, parseFloat(f.el.style.top) + f.height + LABEL_H)
293
+ }
294
+ if (!w || !h) return
295
+ const z = Math.min((innerWidth - SIDE - 80) / w, (innerHeight - 100) / h, 1)
296
+ view = { z, x: 40, y: 50 }
297
+ apply()
298
+ }
299
+
300
+ /** Bring a frame into view: its width and the first screenful fit, top-aligned. */
301
+ function zoomTo(f) {
302
+ const room = innerWidth - SIDE - (document.getElementById('inspector').classList.contains('open') ? 400 : 0) - 80
303
+ const z = Math.min(1, room / f.screen.width, (innerHeight - 100) / Math.min(f.height + LABEL_H, f.screen.width > 700 ? 900 : 900))
304
+ const fx = parseFloat(f.el.style.left), fy = parseFloat(f.el.style.top)
305
+ view = { z, x: 40 + (room - f.screen.width * z) / 2 - fx * z, y: 30 - (fy - ROW_LABEL_H) * z }
306
+ apply()
307
+ }
308
+
309
+ function measure(f) {
310
+ try {
311
+ const doc = f.iframe.contentDocument
312
+ const min = f.screen.width > 700 ? 800 : 844
313
+ const h = Math.min(6000, Math.max(min, doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0))
314
+ if (Math.abs(h - f.height) > 2) {
315
+ f.height = h
316
+ f.iframe.style.height = `${h}px`
317
+ layout()
318
+ }
319
+ } catch {}
320
+ }
321
+
322
+ function badge(f) {
323
+ const b = f.el.querySelector('.badge')
324
+ const issues = checks.screens?.[f.screen.path]
325
+ if (!issues) { b.className = 'badge'; b.textContent = 'not checked'; return }
326
+ const errors = issues.filter((i) => i.severity === 'error').length
327
+ const warnings = issues.length - errors
328
+ b.className = `badge ${errors ? 'bad' : warnings ? 'warn' : 'ok'}`
329
+ b.textContent = errors ? `${errors} error${errors > 1 ? 's' : ''}${warnings ? ` · ${warnings}` : ''}` : warnings ? `${warnings} warning${warnings > 1 ? 's' : ''}` : '✓ clean'
330
+ }
331
+
332
+ function paintLabel(f) {
333
+ const s = f.screen
334
+ f.el.className = `frame ${s.device} ${s.status}${selected === f ? ' selected' : ''}`
335
+ f.el.querySelector('.name').textContent = s.kind === 'components' ? 'Catalog' : `${s.version}`
336
+ const pill = f.el.querySelector('.pill')
337
+ pill.className = `pill ${s.status}`
338
+ pill.textContent = s.kind === 'components' ? 'components' : `${STATUS_LABEL[s.status]} · ${s.source}`
339
+ pill.title = STATUS_HINT[s.status] ?? ''
340
+ f.el.querySelector('.title').textContent = s.title
341
+ f.el.querySelector('.title').title = s.path
342
+ }
343
+
344
+ function summary() {
345
+ const versions = screens.filter((s) => s.kind === 'screen')
346
+ const review = versions.filter((s) => s.status === 'review').length
347
+ const approved = new Set(versions.filter((s) => s.status === 'approved').map((s) => s.screen)).size
348
+ const names = new Set(versions.map((s) => s.screen)).size
349
+ const bad = versions.filter((s) => checks.screens?.[s.path]?.some((i) => i.severity === 'error')).length
350
+ document.getElementById('summary').textContent = `${names} screens · ${approved} approved · ${review} need review${bad ? ` · ${bad} with errors` : ''}`
351
+ }
352
+
353
+ function frameFor(s) {
354
+ const el = document.createElement('div')
355
+ // The frame is exactly as wide as its device; a long title is cut, never widens it.
356
+ el.style.width = `${s.width}px`
357
+ 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>`
358
+ const iframe = el.querySelector('iframe')
359
+ iframe.style.width = `${s.width}px`
360
+ iframe.style.height = `${s.width > 700 ? 800 : 844}px`
361
+ iframe.src = `/${s.path}?pick`
362
+ const f = { el, iframe, screen: s, height: s.width > 700 ? 800 : 844 }
363
+ iframe.addEventListener('load', () => { measure(f); setTimeout(() => measure(f), 800); setTimeout(() => measure(f), 2500) })
364
+ el.querySelector('.shield').addEventListener('click', () => select(f, false))
365
+ el.querySelector('.shield').addEventListener('dblclick', () => { select(f, false); f.el.classList.add('live') })
366
+ el.querySelector('.label').addEventListener('click', () => select(f, true))
367
+ el.querySelector('.label .copy').addEventListener('click', (e) => { e.stopPropagation(); copy(ref(f.screen)) })
368
+ world.append(el)
369
+ frames.set(s.path, f)
370
+ paintLabel(f)
371
+ badge(f)
372
+ return f
373
+ }
374
+
375
+ // ---- selection and the inspector ----
376
+ function select(f, zoom) {
377
+ for (const g of frames.values()) g.el.classList.remove('live')
378
+ const prev = selected
379
+ selected = f
380
+ if (prev) paintLabel(prev)
381
+ if (f) { paintLabel(f); inspector(f); if (zoom) zoomTo(f) }
382
+ else document.getElementById('inspector').classList.remove('open')
383
+ closeDoc()
384
+ tree()
385
+ }
386
+
387
+ function inspector(f) {
388
+ const box = document.getElementById('inspector')
389
+ const s = f.screen
390
+ const issues = checks.screens?.[s.path] ?? []
391
+ box.innerHTML = `<h3><span class="dot ${s.status}"></span><span class="h"></span><button type="button" class="copy" title="Copy a reference to paste to an agent">Copy</button><button type="button" class="close" title="Close (Esc)">×</button></h3>
392
+ <div class="facts"><span class="f"></span> · <a target="_blank"></a></div>
393
+ ${s.kind === 'screen' ? `<div class="status">${['approved', 'review', 'draft', 'archived'].map((st) => `<button type="button" data-st="${st}" title="${STATUS_HINT[st]}" class="${s.status === st ? 'on' : ''}">${st === 'approved' ? 'Approve' : st === 'review' ? 'Needs review' : st === 'draft' ? 'Draft' : 'Archive'}</button>`).join('')}</div>
394
+ <textarea placeholder="Note for the agent: what to keep, what to change"></textarea><button type="button" class="save">Save note</button>` : ''}
395
+ <h4>Checks</h4><ul></ul>`
396
+ if (picked.length || picking) {
397
+ const sec = document.createElement('div')
398
+ sec.innerHTML = `<h4>Picked elements</h4><p class="hint">Click an element to copy its reference; Shift-click to add more.</p><ul class="picks"></ul>`
399
+ for (const [i, p] of picked.entries()) {
400
+ const li = document.createElement('li')
401
+ li.innerHTML = '<code></code><button type="button" title="Remove">×</button>'
402
+ li.querySelector('code').textContent = `${i + 1}. ${p.ref}`
403
+ li.querySelector('button').onclick = () => { picked.splice(i, 1); paintPicks(); inspector(selected) }
404
+ sec.querySelector('.picks').append(li)
405
+ }
406
+ if (picked.length) {
407
+ const actions = document.createElement('div')
408
+ actions.className = 'pick-actions'
409
+ actions.innerHTML = '<button type="button" data-a="copy">Copy all</button><button type="button" data-a="note">Add to note</button><button type="button" data-a="clear">Clear</button>'
410
+ actions.querySelector('[data-a=copy]').onclick = () => copy(picked.map((p) => p.ref).join('\n'))
411
+ actions.querySelector('[data-a=clear]').onclick = () => { picked = []; paintPicks(); inspector(selected) }
412
+ const noteBox = box.querySelector('textarea')
413
+ if (noteBox) actions.querySelector('[data-a=note]').onclick = () => {
414
+ noteBox.value = `${noteBox.value.trim() ? `${noteBox.value.trim()}\n` : ''}${picked.map((p) => p.ref).join('\n')}\n`
415
+ noteBox.focus()
416
+ }
417
+ else actions.querySelector('[data-a=note]').remove()
418
+ sec.append(actions)
419
+ }
420
+ box.insertBefore(sec, box.querySelector('h4'))
421
+ }
422
+ box.querySelector('.h').textContent = s.kind === 'components' ? 'Component catalog' : `${s.screen} · ${s.version}`
423
+ box.querySelector('.f').textContent = `${s.device} · ${s.source}`
424
+ const a = box.querySelector('.facts a')
425
+ a.href = `/${s.path}`
426
+ a.textContent = s.path
427
+ box.querySelector('h3 .close').onclick = () => select(null)
428
+ box.querySelector('h3 .copy').onclick = () => copy(ref(s))
429
+ for (const b of box.querySelectorAll('.status button')) b.onclick = () => setMeta(s.path, { status: b.dataset.st })
430
+ const note = box.querySelector('textarea')
431
+ if (note) {
432
+ note.value = s.note
433
+ box.querySelector('.save').onclick = () => setMeta(s.path, { note: note.value })
434
+ }
435
+ const ul = box.querySelector('ul:not(.picks)')
436
+ if (!checks.screens?.[s.path]) ul.innerHTML = '<li>Not checked yet: run the check command.</li>'
437
+ else if (!issues.length) ul.innerHTML = '<li>No issues.</li>'
438
+ for (const i of issues) {
439
+ const li = document.createElement('li')
440
+ li.className = i.severity
441
+ li.innerHTML = '<span class="sev"></span><span class="msg"></span><code></code>'
442
+ li.querySelector('.sev').textContent = i.severity
443
+ li.querySelector('.msg').textContent = i.message + (i.text ? ` — “${i.text}”` : '')
444
+ li.querySelector('code').textContent = i.selector
445
+ li.onclick = () => locate(f, i.selector)
446
+ ul.append(li)
447
+ }
448
+ box.classList.add('open')
449
+ }
450
+
451
+ async function setMeta(path, body) {
452
+ const res = await fetch('/api/screens/meta', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path, ...body }) })
453
+ if (!res.ok) return alert(await res.text())
454
+ await load(false, await res.json())
455
+ }
456
+
457
+ /** Outline the element in its frame and bring it to the middle of the canvas. */
458
+ function locate(f, selector) {
459
+ let el
460
+ try { el = f.iframe.contentDocument.querySelector(selector) } catch {}
461
+ if (!el) return
462
+ f.iframe.contentDocument.querySelectorAll('[data-stitch2-hit]').forEach((n) => { n.style.outline = n.dataset.stitch2Hit; n.removeAttribute('data-stitch2-hit') })
463
+ el.dataset.stitch2Hit = el.style.outline
464
+ el.style.outline = '3px solid #e5484d'
465
+ el.style.outlineOffset = '2px'
466
+ const r = el.getBoundingClientRect()
467
+ const fx = parseFloat(f.el.style.left), fy = parseFloat(f.el.style.top) + LABEL_H
468
+ const z = Math.max(view.z, 0.9)
469
+ const cx = (innerWidth - SIDE - 400) / 2
470
+ view = { z, x: cx - (fx + r.left + r.width / 2) * z, y: innerHeight / 2 - (fy + r.top + r.height / 2) * z }
471
+ apply()
472
+ }
473
+
474
+ let docUrl = ''
475
+ // ---- picking elements: hover to highlight, click to copy a reference with the source line ----
476
+ let picking = false
477
+ let picked = [] // { f, el, ref }
478
+ let hoverBox = null
479
+ function elementAt(e) {
480
+ const hit = document.elementsFromPoint(e.clientX, e.clientY).find((n) => n.classList?.contains('shield'))
481
+ const f = hit && [...frames.values()].find((g) => g.el.contains(hit))
482
+ if (!f) return null
483
+ let doc
484
+ try { doc = f.iframe.contentDocument } catch { return null }
485
+ const r = f.iframe.getBoundingClientRect()
486
+ const el = doc?.elementFromPoint((e.clientX - r.left) / view.z, (e.clientY - r.top) / view.z)?.closest('[data-s2-line]')
487
+ return el ? { f, el } : null
488
+ }
489
+ function describe(el) {
490
+ const text = (el.innerText || el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.getAttribute('alt') || el.value || '').trim().replace(/\s+/g, ' ')
491
+ return { tag: el.getAttribute('data-s2-tag') ?? el.localName, line: el.getAttribute('data-s2-line'), text: text.length > 48 ? `${text.slice(0, 47)}…` : text }
492
+ }
493
+ function elementRef(f, el) {
494
+ const d = describe(el)
495
+ const s = f.screen
496
+ const who = s.kind === 'components' ? 'component catalog' : `${s.screen} ${s.device} ${s.version}`
497
+ return `${who} (${designDir}/${s.path}:${d.line}) <${d.tag}>${d.text ? ` “${d.text}”` : ''}`
498
+ }
499
+ function drawBox(f, el, cls, label) {
500
+ const r = el.getBoundingClientRect()
501
+ const b = document.createElement('div')
502
+ b.className = `pick-box ${cls}${r.top < 24 / view.z ? ' inside' : ''}`
503
+ Object.assign(b.style, { left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px`, outlineWidth: `${2 / view.z}px` })
504
+ if (label) {
505
+ const t = document.createElement('span')
506
+ t.textContent = label
507
+ t.style.fontSize = `${12 / view.z}px`
508
+ b.append(t)
509
+ }
510
+ f.el.querySelector('.device').append(b)
511
+ return b
512
+ }
513
+ function paintPicks() {
514
+ document.querySelectorAll('.pick-box.picked').forEach((b) => b.remove())
515
+ picked = picked.filter((p) => p.el.isConnected)
516
+ for (const [i, p] of picked.entries()) drawBox(p.f, p.el, 'picked', String(i + 1))
517
+ }
518
+ function hover(e) {
519
+ hoverBox?.remove()
520
+ hoverBox = null
521
+ const hit = picking && elementAt(e)
522
+ if (!hit) return
523
+ const d = describe(hit.el)
524
+ hoverBox = drawBox(hit.f, hit.el, 'hover', `<${d.tag}> · line ${d.line}`)
525
+ }
526
+ function pickAt(e) {
527
+ const hit = elementAt(e)
528
+ if (!hit) return false
529
+ const p = { f: hit.f, el: hit.el, ref: elementRef(hit.f, hit.el) }
530
+ if (e.shiftKey || e.metaKey || e.ctrlKey) {
531
+ const i = picked.findIndex((q) => q.el === p.el)
532
+ if (i >= 0) picked.splice(i, 1)
533
+ else picked.push(p)
534
+ } else picked = [p]
535
+ if (picked.length) copy(picked.map((q) => q.ref).join('\n'))
536
+ paintPicks()
537
+ select(hit.f, false)
538
+ return true
539
+ }
540
+ function setPicking(on) {
541
+ picking = on
542
+ document.body.classList.toggle('picking', on)
543
+ document.getElementById('pick').classList.toggle('on', on)
544
+ if (!on) { hoverBox?.remove(); hoverBox = null }
545
+ if (selected) inspector(selected)
546
+ }
547
+ function openDoc(url = '/design') {
548
+ docUrl = url
549
+ selected = null
550
+ for (const f of frames.values()) paintLabel(f)
551
+ document.getElementById('inspector').classList.remove('open')
552
+ const doc = document.getElementById('doc')
553
+ doc.querySelector('iframe').src = url
554
+ doc.classList.add('open')
555
+ tree()
556
+ }
557
+ function closeDoc() { document.getElementById('doc').classList.remove('open') }
558
+ document.querySelector('#doc button').onclick = () => { closeDoc(); tree() }
559
+
560
+ async function loadChecks() {
561
+ checks = await fetch('/api/checks').then((r) => r.json()).catch(() => ({ screens: {} }))
562
+ for (const f of frames.values()) badge(f)
563
+ if (selected) inspector(selected)
564
+ summary()
565
+ }
566
+
567
+ async function load(first, given) {
568
+ const next = given ?? (await fetch('/api/screens').then((r) => r.json()))
569
+ const keep = new Set(next.map((s) => s.path))
570
+ for (const [p, f] of frames) if (!keep.has(p)) { f.el.remove(); frames.delete(p); if (selected === f) selected = null }
571
+ for (const s of next) {
572
+ const f = frames.get(s.path)
573
+ if (f) { f.screen = s; paintLabel(f) } else frameFor(s)
574
+ }
575
+ screens = next
576
+ layout()
577
+ await loadChecks()
578
+ if (selected) inspector(selected)
579
+ if (first && !localStorage.getItem('stitch2-view')) setTimeout(fit, 1500)
580
+ }
581
+
582
+ // ---- filters ----
583
+ for (const [id, key] of [['statusFilter', 'status'], ['deviceFilter', 'device']]) {
584
+ const seg = document.getElementById(id)
585
+ for (const b of seg.querySelectorAll('button')) {
586
+ b.classList.toggle('on', b.dataset.v === filters[key])
587
+ b.onclick = () => {
588
+ filters[key] = b.dataset.v
589
+ localStorage.setItem('stitch2-filters', JSON.stringify(filters))
590
+ for (const o of seg.querySelectorAll('button')) o.classList.toggle('on', o === b)
591
+ layout()
592
+ }
593
+ }
594
+ }
595
+ document.getElementById('search').oninput = layout
596
+
597
+ // ---- live reload ----
598
+ const events = new EventSource('/events')
599
+ events.onmessage = async (e) => {
600
+ const { paths } = JSON.parse(e.data)
601
+ if (paths.some((p) => p === 'checks.json')) await loadChecks()
602
+ const html = paths.filter((p) => p.endsWith('.html'))
603
+ if (html.length) await load(false)
604
+ if (paths.includes('DESIGN.md') && document.getElementById('doc').classList.contains('open')) document.querySelector('#doc iframe').contentWindow.location.reload()
605
+ const shared = paths.some((p) => /^(tokens\.css|tailwind\.tokens\.js|components\/)/.test(p))
606
+ for (const f of frames.values()) {
607
+ if (shared || html.includes(f.screen.path)) {
608
+ f.iframe.contentWindow?.location.reload()
609
+ f.el.classList.remove('changed'); void f.el.offsetWidth; f.el.classList.add('changed')
610
+ }
611
+ }
612
+ }
613
+
614
+ // ---- pan, zoom, keys ----
615
+ let drag = null
616
+ viewport.addEventListener('pointerdown', (e) => {
617
+ if (e.target.closest('.badge, .label, .frame.live .device')) return
618
+ drag = { x: e.clientX, y: e.clientY, vx: view.x, vy: view.y, moved: false }
619
+ viewport.classList.add('panning')
620
+ viewport.setPointerCapture(e.pointerId)
621
+ })
622
+ viewport.addEventListener('pointermove', (e) => {
623
+ if (!drag) return hover(e)
624
+ if (Math.abs(e.clientX - drag.x) + Math.abs(e.clientY - drag.y) > 3) drag.moved = true
625
+ view.x = drag.vx + e.clientX - drag.x
626
+ view.y = drag.vy + e.clientY - drag.y
627
+ apply()
628
+ })
629
+ viewport.addEventListener('pointerup', (e) => {
630
+ const d = drag
631
+ drag = null
632
+ viewport.classList.remove('panning')
633
+ if (d?.moved) return
634
+ if (picking && pickAt(e)) return
635
+ // A click without a drag: select the frame under the pointer, or clear the selection on empty canvas.
636
+ const hit = document.elementsFromPoint(e.clientX, e.clientY).find((n) => n.classList?.contains('shield'))
637
+ const f = hit && [...frames.values()].find((g) => g.el.contains(hit))
638
+ if (f) select(f, false)
639
+ else if (!e.target.closest('.frame')) select(null)
640
+ })
641
+ viewport.addEventListener('dblclick', (e) => {
642
+ if (picking) return
643
+ // Double-click a frame to use it like a phone (scroll, tap); Esc or a click outside leaves.
644
+ const hit = document.elementsFromPoint(e.clientX, e.clientY).find((n) => n.classList?.contains('shield'))
645
+ const f = hit && [...frames.values()].find((g) => g.el.contains(hit))
646
+ if (f) { select(f, false); f.el.classList.add('live') }
647
+ })
648
+ viewport.addEventListener('wheel', (e) => {
649
+ e.preventDefault()
650
+ if (e.ctrlKey || e.metaKey) zoomAt(e.clientX - SIDE, e.clientY, view.z * Math.exp(-e.deltaY * 0.01))
651
+ else { view.x -= e.deltaX; view.y -= e.deltaY; apply() }
652
+ }, { passive: false })
653
+ const visibleFrames = () => rows().flatMap((r) => r.visible.map((s) => frames.get(s.path)))
654
+ addEventListener('keydown', (e) => {
655
+ if (e.target.matches('input, textarea')) { if (e.key === 'Escape') e.target.blur(); return }
656
+ if (e.key === '/') { e.preventDefault(); document.getElementById('search').focus() }
657
+ if (e.key === 'Escape' && picking) return setPicking(false)
658
+ if (e.key === 'p' || e.key === 'P') setPicking(!picking)
659
+ if (e.key === 'Escape') { closeDoc(); select(null) }
660
+ if (e.key === 'f' || e.key === 'F') fit()
661
+ if (e.key === '0') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, 1)
662
+ if (e.key === '+' || e.key === '=') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z * 1.2)
663
+ if (e.key === '-') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z / 1.2)
664
+ if (e.key === 'r' || e.key === 'R') for (const f of frames.values()) f.iframe.contentWindow?.location.reload()
665
+ if (['j', 'k', 'ArrowDown', 'ArrowUp'].includes(e.key)) {
666
+ const list = visibleFrames()
667
+ if (!list.length) return
668
+ const i = list.indexOf(selected)
669
+ const next = list[(i + (e.key === 'j' || e.key === 'ArrowDown' ? 1 : -1) + list.length) % list.length]
670
+ select(next, true)
671
+ e.preventDefault()
672
+ }
673
+ if (selected?.screen.kind === 'screen' && e.key === 'a') setMeta(selected.screen.path, { status: 'approved' })
674
+ })
675
+ document.getElementById('fit').onclick = fit
676
+ document.getElementById('pick').onclick = () => setPicking(!picking)
677
+ document.getElementById('reload').onclick = () => { for (const f of frames.values()) f.iframe.contentWindow?.location.reload() }
678
+
679
+ apply()
680
+ fetch('/api/config').then((r) => r.json()).then((c) => {
681
+ ORDER = c.order ?? []
682
+ designDir = c.designDir ?? 'design'
683
+ document.getElementById('labName').textContent = c.name
684
+ document.title = c.name
685
+ load(true)
686
+ })
687
+ </script>
688
+ </body>
689
+ </html>