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.
- package/LICENSE +21 -0
- package/README.md +208 -2
- package/config.schema.json +109 -0
- package/dist/assets.d.ts +1 -0
- package/dist/assets.js +59 -0
- package/dist/audit.d.ts +10 -0
- package/dist/audit.js +259 -0
- package/dist/build-design.d.ts +39 -0
- package/dist/build-design.js +337 -0
- package/dist/build-elements.d.ts +1 -0
- package/dist/build-elements.js +33 -0
- package/dist/canvas.html +689 -0
- package/dist/check.d.ts +1 -0
- package/dist/check.js +86 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +73 -0
- package/dist/colours.d.ts +2 -0
- package/dist/colours.js +57 -0
- package/dist/compare.d.ts +1 -0
- package/dist/compare.js +185 -0
- package/dist/config.d.ts +53 -0
- package/dist/config.js +38 -0
- package/dist/consistency.d.ts +40 -0
- package/dist/consistency.js +336 -0
- package/dist/design-md.d.ts +27 -0
- package/dist/design-md.js +49 -0
- package/dist/design-page.d.ts +1 -0
- package/dist/design-page.js +127 -0
- package/dist/elements.d.ts +32 -0
- package/dist/elements.js +69 -0
- package/dist/exports.d.ts +25 -0
- package/dist/exports.js +86 -0
- package/dist/extract.d.ts +1 -0
- package/dist/extract.js +197 -0
- package/dist/init.d.ts +5 -0
- package/dist/init.js +83 -0
- package/dist/list.d.ts +1 -0
- package/dist/list.js +17 -0
- package/dist/pick.d.ts +10 -0
- package/dist/pick.js +38 -0
- package/dist/report.d.ts +1 -0
- package/dist/report.js +110 -0
- package/dist/sandbox.d.ts +1 -0
- package/dist/sandbox.js +183 -0
- package/dist/screens.d.ts +34 -0
- package/dist/screens.js +85 -0
- package/dist/serve.d.ts +4 -0
- package/dist/serve.js +181 -0
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +214 -0
- package/dist/tokens.d.ts +1 -0
- package/dist/tokens.js +128 -0
- package/dist/type.d.ts +1 -0
- package/dist/type.js +60 -0
- package/dist/typecheck.d.ts +35 -0
- package/dist/typecheck.js +112 -0
- package/package.json +53 -4
- package/skills/stitch2/SKILL.md +80 -0
- package/skills/stitch2/references/components.md +59 -0
- package/skills/stitch2/references/guides.md +20 -0
- package/skills/stitch2/references/principles.md +80 -0
- package/skills/stitch2/references/screens.md +70 -0
- package/skills/stitch2/references/typography.md +106 -0
- package/skills/stitch2-app-sync/SKILL.md +65 -0
- package/skills/stitch2-consistency/SKILL.md +59 -0
- package/skills/stitch2-design-md/SKILL.md +64 -0
- package/skills/stitch2-import-from-google-stitch/SKILL.md +47 -0
- package/skills/stitch2-import-from-google-stitch/references/how-stitch-works.md +36 -0
- package/skills/stitch2-variants/SKILL.md +45 -0
package/dist/canvas.html
ADDED
|
@@ -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>
|