stitch2 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/dist/audit.js +34 -3
- package/dist/canvas.html +143 -26
- package/dist/consistency.js +2 -1
- package/dist/list.js +5 -3
- package/dist/setup.js +11 -1
- package/package.json +1 -1
- package/skills/stitch2/SKILL.md +7 -5
- package/skills/stitch2/references/screens.md +18 -8
- package/skills/{stitch2-variants → stitch2-alternatives}/SKILL.md +4 -4
package/README.md
CHANGED
|
@@ -68,17 +68,18 @@ file that already exists. From there the agent spends its effort on the design.
|
|
|
68
68
|
{ "format": "dtcg", "path": "design/tokens.json" }
|
|
69
69
|
]
|
|
70
70
|
```
|
|
71
|
-
- **Versions and approval**:
|
|
72
|
-
|
|
71
|
+
- **Versions, variants and approval**: a screen has versions (v1, v2, …), and each version has variants, its
|
|
72
|
+
layouts for mobile and desktop (`mobile-v3.html`, `desktop-v3.html`). The canvas groups them; you approve a
|
|
73
|
+
version as a whole or one variant at a time, leave notes, and approving archives the previous approved layout.
|
|
73
74
|
- **DESIGN.md builder**: `stitch2 init` writes a complete DESIGN.md from a few choices (brand colour, modes,
|
|
74
75
|
fonts, roundness, density), with colours checked for contrast; `stitch2 extract` measures one from an existing
|
|
75
76
|
site or screens.
|
|
76
|
-
- **Fresh-eyes
|
|
77
|
+
- **Fresh-eyes alternatives** (`stitch2 sandbox`): a folder with your design system, components and skills but no
|
|
77
78
|
screens, where a fresh agent designs from a brief without being anchored to the existing versions;
|
|
78
79
|
`stitch2 sandbox import` brings the result back as the next version for review. Also the way to test a change
|
|
79
80
|
to the skills on output only the rules produced.
|
|
80
81
|
- **Skills** for agents (`skills/`): `stitch2` (the design workflow, principles, typography, components,
|
|
81
|
-
versions), `stitch2-design-md` (building the design system), `stitch2-
|
|
82
|
+
versions), `stitch2-design-md` (building the design system), `stitch2-alternatives` (fresh-eyes versions and
|
|
82
83
|
testing skill changes), `stitch2-consistency` (verifying and fixing
|
|
83
84
|
consistency), `stitch2-i18n` (screens in every language), `stitch2-app-sync` (keeping the app and the designs in step: design first, then the app) and,
|
|
84
85
|
optional, `stitch2-import-from-google-stitch` (drafting screens in Google Stitch through its MCP server).
|
package/dist/audit.js
CHANGED
|
@@ -105,10 +105,20 @@ export async function audit(opts) {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
// 3. Fixed bars (tab bar, rest timer, headers) never cover content: check at both scroll ends.
|
|
108
|
+
// A fixed element inside a transformed (or filtered, or contained) box is placed in that box, not on the
|
|
109
|
+
// screen: a catalog showing a tab bar in place, for one. Only bars pinned to the screen count.
|
|
110
|
+
const pinned = (el) => {
|
|
111
|
+
for (let a = el.parentElement; a; a = a.parentElement) {
|
|
112
|
+
const cs = getComputedStyle(a);
|
|
113
|
+
if (cs.transform !== 'none' || cs.filter !== 'none' || cs.perspective !== 'none' || /paint|layout|strict|content/.test(cs.contain))
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
};
|
|
108
118
|
const bars = all.filter((el) => {
|
|
109
119
|
const p = getComputedStyle(el).position;
|
|
110
120
|
const r = el.getBoundingClientRect();
|
|
111
|
-
return (p === '
|
|
121
|
+
return (p === 'sticky' || (p === 'fixed' && pinned(el))) && r.height <= 240 && r.width >= W * 0.6;
|
|
112
122
|
});
|
|
113
123
|
// Behind an open modal the page is inert and covered, so only the modal's own bars count.
|
|
114
124
|
// An open modal: the element may be 0px itself when everything in it is fixed (Headless UI's Dialog).
|
|
@@ -212,14 +222,35 @@ export async function audit(opts) {
|
|
|
212
222
|
return e;
|
|
213
223
|
return null;
|
|
214
224
|
};
|
|
225
|
+
// The part of a control its scrolling or clipping ancestors show: a link scrolled out of a nav strip is not drawn
|
|
226
|
+
// where its box would be.
|
|
227
|
+
const visibleBox = (el) => {
|
|
228
|
+
const r = el.getBoundingClientRect();
|
|
229
|
+
let { left, top, right, bottom } = r;
|
|
230
|
+
for (let e = el.parentElement; e && e !== document.body; e = e.parentElement) {
|
|
231
|
+
const cs = getComputedStyle(e);
|
|
232
|
+
if (cs.overflowX === 'visible' && cs.overflowY === 'visible')
|
|
233
|
+
continue;
|
|
234
|
+
const c = e.getBoundingClientRect();
|
|
235
|
+
if (cs.overflowX !== 'visible') {
|
|
236
|
+
left = Math.max(left, c.left);
|
|
237
|
+
right = Math.min(right, c.right);
|
|
238
|
+
}
|
|
239
|
+
if (cs.overflowY !== 'visible') {
|
|
240
|
+
top = Math.max(top, c.top);
|
|
241
|
+
bottom = Math.min(bottom, c.bottom);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { left, top, right, bottom };
|
|
245
|
+
};
|
|
215
246
|
// Behind an open modal (found above) only the modal's own controls count.
|
|
216
247
|
const solid = interactive.filter((el) => opacity(el) > 0.05 && (!modal || modal.contains(el)));
|
|
217
248
|
for (const [i, a] of solid.entries())
|
|
218
249
|
for (const b of solid.slice(i + 1)) {
|
|
219
250
|
if (a.contains(b) || b.contains(a) || layer(a) !== layer(b))
|
|
220
251
|
continue;
|
|
221
|
-
const r = a
|
|
222
|
-
const q = b
|
|
252
|
+
const r = visibleBox(a);
|
|
253
|
+
const q = visibleBox(b);
|
|
223
254
|
if (Math.min(r.right, q.right) - Math.max(r.left, q.left) > 2 && Math.min(r.bottom, q.bottom) - Math.max(r.top, q.top) > 2)
|
|
224
255
|
push('error', 'control-overlap', b, `Overlaps another control (“${text(a).slice(0, 30) || a.getAttribute('aria-label') || a.tagName.toLowerCase()}”): give them room`);
|
|
225
256
|
}
|
package/dist/canvas.html
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
.row-label { align-items: baseline; display: flex; gap: 12px; overflow: hidden; position: absolute; white-space: nowrap; }
|
|
16
16
|
.row-label b { color: var(--text); font-size: 24px; }
|
|
17
17
|
.row-label span { color: var(--muted); font-size: 15px; overflow: hidden; text-overflow: ellipsis; }
|
|
18
|
+
.version-label { align-items: baseline; border-top: 2px solid var(--edge); display: flex; gap: 10px; overflow: hidden; padding-top: 8px; position: absolute; white-space: nowrap; }
|
|
19
|
+
.version-label b { color: var(--text); font-size: 18px; }
|
|
20
|
+
.version-label span { color: var(--muted); font-size: 14px; overflow: hidden; text-overflow: ellipsis; }
|
|
21
|
+
.version-label.review { border-color: var(--warn); } .version-label.approved { border-color: var(--good); }
|
|
18
22
|
#doc { background: var(--bg); display: none; inset: 0 0 0 var(--side); position: fixed; z-index: 19; }
|
|
19
23
|
#doc.open { display: block; }
|
|
20
24
|
.doc-frame { border: 0; height: 100%; width: 100%; }
|
|
@@ -27,13 +31,29 @@
|
|
|
27
31
|
.seg button { background: none; border: 0; border-radius: 6px; color: var(--muted); cursor: pointer; flex: 1; padding: 4px 6px; white-space: nowrap; }
|
|
28
32
|
.seg button.on { background: var(--panel2); color: var(--text); }
|
|
29
33
|
.seg[hidden] { display: none; }
|
|
30
|
-
|
|
34
|
+
.multi { position: relative; }
|
|
35
|
+
.multi[hidden] { display: none; }
|
|
36
|
+
.multi summary { align-items: center; background: var(--bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%23a1a1aa' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 10px center / 12px 8px; border: 1px solid var(--edge); border-radius: 8px; cursor: pointer; display: flex; gap: 8px; list-style: none; padding: 5px 30px 5px 8px; }
|
|
37
|
+
.multi summary::-webkit-details-marker { display: none; }
|
|
38
|
+
.multi .menu { background: var(--panel); border: 1px solid var(--edge); border-radius: 8px; box-shadow: 0 12px 32px rgba(0,0,0,.5); left: 0; max-height: 280px; overflow: auto; padding: 4px; position: absolute; right: 0; top: calc(100% + 4px); z-index: 30; }
|
|
39
|
+
.multi label { align-items: center; border-radius: 6px; color: var(--muted); cursor: pointer; display: flex; gap: 8px; padding: 5px 8px; }
|
|
40
|
+
.multi label:hover { background: var(--panel2); color: var(--text); }
|
|
41
|
+
.multi label.all { border-bottom: 1px solid var(--edge); border-radius: 6px 6px 0 0; margin-bottom: 4px; }
|
|
42
|
+
.multi input { accent-color: var(--accent); margin: 0; }
|
|
43
|
+
.multi .n { color: var(--faint); margin-left: auto; }
|
|
44
|
+
/* Theme and language: native dropdowns drawn like the versions and variants ones. */
|
|
45
|
+
.pick { appearance: none; background: var(--bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%23a1a1aa' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 10px center / 12px 8px; border: 1px solid var(--edge); border-radius: 8px; color: var(--text); cursor: pointer; font: inherit; padding: 5px 30px 5px 8px; width: 100%; }
|
|
46
|
+
.pick[hidden] { display: none; }
|
|
31
47
|
#tree { flex: 1; overflow: auto; padding: 6px 8px 16px; }
|
|
32
48
|
#tree h4 { color: var(--faint); font-size: 11px; letter-spacing: .06em; margin: 14px 6px 6px; text-transform: uppercase; }
|
|
33
49
|
.item { align-items: center; background: none; border: 0; border-radius: 6px; color: var(--muted); cursor: pointer; display: flex; gap: 8px; padding: 6px 8px; text-align: left; width: 100%; }
|
|
34
50
|
.item:hover { background: var(--panel2); color: var(--text); }
|
|
35
51
|
.item.on { background: #2a1a12; color: var(--text); }
|
|
36
52
|
.item .meta { color: var(--faint); font-size: 11px; margin-left: auto; white-space: nowrap; }
|
|
53
|
+
.item.variant { padding-left: 30px; }
|
|
54
|
+
#inspector .version-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; }
|
|
55
|
+
#inspector .version-actions span { color: var(--muted); }
|
|
56
|
+
#inspector .version-actions button { background: var(--panel2); border: 1px solid var(--edge); border-radius: 8px; cursor: pointer; padding: 3px 8px; }
|
|
37
57
|
.screen-name { align-items: center; border-radius: 6px; color: var(--text); cursor: pointer; display: flex; font-weight: 600; gap: 6px; padding: 8px 8px 2px; }
|
|
38
58
|
.screen-name:hover { background: var(--panel2); }
|
|
39
59
|
.screen-name .meta { color: var(--faint); font-size: 11px; font-weight: 400; margin-left: auto; }
|
|
@@ -106,9 +126,10 @@
|
|
|
106
126
|
<b id="labName">stitch2</b>
|
|
107
127
|
<input id="search" placeholder="Search screens… ( / )" />
|
|
108
128
|
<div class="seg" id="statusFilter"><button type="button" data-v="active" class="on" title="Everything except archived versions">Active</button><button type="button" data-v="approved" title="The reference versions agents build on">Approved</button><button type="button" data-v="review" title="Finished by an agent, waiting for your decision">Needs review</button><button type="button" data-v="all" title="Including archived versions">All</button></div>
|
|
109
|
-
<
|
|
110
|
-
<
|
|
111
|
-
<select id="
|
|
129
|
+
<details id="versionFilter" class="multi" hidden title="Show only the versions you pick (several at once), or all of them"><summary>All versions</summary><div class="menu"></div></details>
|
|
130
|
+
<details id="variantFilter" class="multi" hidden title="Show only the variants you pick: mobile, desktop (several at once), or all of them"><summary>All variants</summary><div class="menu"></div></details>
|
|
131
|
+
<select id="themeFilter" class="pick" hidden title="Show every screen in this theme (T)" aria-label="Theme"></select>
|
|
132
|
+
<select id="langFilter" class="pick" title="Show every screen in this language (L)" aria-label="Language"></select>
|
|
112
133
|
</header>
|
|
113
134
|
<nav id="tree"></nav>
|
|
114
135
|
<footer>
|
|
@@ -172,7 +193,7 @@ function applyTheme(f) {
|
|
|
172
193
|
function setTheme(t) {
|
|
173
194
|
theme = t
|
|
174
195
|
localStorage.setItem('stitch2-theme', t)
|
|
175
|
-
|
|
196
|
+
document.getElementById('themeFilter').value = t
|
|
176
197
|
for (const f of frames.values()) applyTheme(f)
|
|
177
198
|
for (const f of frames.values()) badge(f)
|
|
178
199
|
if (selected) inspector(selected)
|
|
@@ -207,7 +228,8 @@ const copyButton = (text, title) => {
|
|
|
207
228
|
return b
|
|
208
229
|
}
|
|
209
230
|
let view = JSON.parse(localStorage.getItem('stitch2-view') ?? 'null') ?? { x: 40, y: 60, z: 0.4 }
|
|
210
|
-
const filters = JSON.parse(localStorage.getItem('stitch2-filters') ?? 'null') ?? { status: 'active'
|
|
231
|
+
const filters = JSON.parse(localStorage.getItem('stitch2-filters') ?? 'null') ?? { status: 'active' }
|
|
232
|
+
if (typeof filters.device === 'string') { filters.devices = filters.device === 'all' ? [] : [filters.device]; delete filters.device }
|
|
211
233
|
let screens = []
|
|
212
234
|
let checks = { screens: {} }
|
|
213
235
|
let selected = null
|
|
@@ -230,16 +252,27 @@ const zoomAt = (cx, cy, z) => {
|
|
|
230
252
|
apply()
|
|
231
253
|
}
|
|
232
254
|
|
|
233
|
-
// ---- grouping: rows are app screens (components first)
|
|
255
|
+
// ---- grouping: rows are app screens (components first); in a row, versions (v1, v2, …) in order, and in a
|
|
256
|
+
// version its variants: the layouts for each device (mobile, desktop), files <device>-v<N> sharing N ----
|
|
234
257
|
const rowKey = (s) => (s.kind === 'components' ? '~components' : s.screen)
|
|
235
258
|
const rowRank = (k) => (k === '~components' ? -1 : ORDER.includes(k) ? ORDER.indexOf(k) : 100)
|
|
236
259
|
const sortRows = (keys) => keys.sort((a, b) => rowRank(a) - rowRank(b) || a.localeCompare(b))
|
|
260
|
+
const versionRank = (v) => { const m = /^v(\d+)$/.exec(v); return m ? Number(m[1]) : 0 }
|
|
261
|
+
const DEVICE_RANK = { mobile: 0, tablet: 1, desktop: 2 }
|
|
237
262
|
const sortVersions = (list) =>
|
|
238
|
-
list.sort((a, b) => (a.
|
|
263
|
+
list.sort((a, b) => versionRank(a.version) - versionRank(b.version) || a.version.localeCompare(b.version) || (DEVICE_RANK[a.device] ?? 9) - (DEVICE_RANK[b.device] ?? 9) || b.mtime - a.mtime)
|
|
264
|
+
/** A version's variants, and one status for all of them: needs review if any does, else the most settled. */
|
|
265
|
+
const versionsOf = (list) => {
|
|
266
|
+
const by = new Map()
|
|
267
|
+
for (const s of list) { const k = s.kind === 'components' ? s.path : s.version; by.set(k, [...(by.get(k) ?? []), s]) }
|
|
268
|
+
return [...by.entries()].map(([version, variants]) => ({ version, variants, status: variants.some((v) => v.status === 'review') ? 'review' : variants.some((v) => v.status === 'draft') ? 'draft' : variants.some((v) => v.status === 'approved') ? 'approved' : 'archived' }))
|
|
269
|
+
}
|
|
270
|
+
const VERSION_H = 44, VERSION_GAP = 120
|
|
239
271
|
function shown(s) {
|
|
240
272
|
const q = document.getElementById('search').value.trim().toLowerCase()
|
|
241
273
|
if (q && !`${s.screen} ${s.title} ${s.path} ${s.version} ${s.source}`.toLowerCase().includes(q)) return false
|
|
242
|
-
if (filters.
|
|
274
|
+
if (filters.devices?.length && s.kind === 'screen' && !filters.devices.includes(s.device)) return false
|
|
275
|
+
if (filters.versions?.length && s.kind === 'screen' && !filters.versions.includes(s.version)) return false
|
|
243
276
|
if (s.kind === 'components') return filters.status !== 'review' && filters.status !== 'approved'
|
|
244
277
|
if (filters.status === 'active') return s.status !== 'archived'
|
|
245
278
|
if (filters.status === 'all') return true
|
|
@@ -258,11 +291,13 @@ function layout() {
|
|
|
258
291
|
for (const row of rows()) {
|
|
259
292
|
for (const s of row.all) frames.get(s.path).el.style.display = row.visible.includes(s) ? '' : 'none'
|
|
260
293
|
if (!row.visible.length) continue
|
|
261
|
-
const
|
|
294
|
+
const groups = versionsOf(row.visible)
|
|
295
|
+
const widthOf = (g) => g.variants.reduce((w, s) => w + s.width, 0) + GAP_X * (g.variants.length - 1)
|
|
296
|
+
const groupW = groups.reduce((w, g) => w + widthOf(g), 0) + VERSION_GAP * (groups.length - 1)
|
|
262
297
|
if (x > 0 && x + groupW > LINE_W) { x = 0; y += lineH + GAP_Y; lineH = 0 }
|
|
263
298
|
const label = document.createElement('div')
|
|
264
299
|
label.className = 'row-label'
|
|
265
|
-
const approved = row.all.filter((s) => s.status === 'approved').map((s) => `${s.
|
|
300
|
+
const approved = row.all.filter((s) => s.status === 'approved').map((s) => `${s.version} ${s.device}`)
|
|
266
301
|
label.innerHTML = '<b></b><span></span>'
|
|
267
302
|
label.querySelector('b').textContent = row.key === '~components' ? 'Components' : row.key
|
|
268
303
|
label.querySelector('span').textContent = row.key === '~components' ? 'the catalog every screen is built from' : approved.length ? `approved: ${approved.join(', ')}` : 'nothing approved yet'
|
|
@@ -270,12 +305,26 @@ function layout() {
|
|
|
270
305
|
label.style.maxWidth = `${groupW}px`
|
|
271
306
|
world.append(label)
|
|
272
307
|
let fx = x, tallest = 0
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
308
|
+
const top = row.key === '~components' ? 0 : VERSION_H
|
|
309
|
+
for (const g of groups) {
|
|
310
|
+
if (top) {
|
|
311
|
+
const v = document.createElement('div')
|
|
312
|
+
v.className = `row-label version-label ${g.status}`
|
|
313
|
+
v.innerHTML = '<b></b><span></span>'
|
|
314
|
+
v.querySelector('b').textContent = g.version
|
|
315
|
+
v.querySelector('span').textContent = `${STATUS_LABEL[g.status]} · ${g.variants.map((s) => s.device).join(', ')}`
|
|
316
|
+
v.style.transform = `translate(${fx}px, ${y + ROW_LABEL_H}px)`
|
|
317
|
+
v.style.width = `${widthOf(g)}px`
|
|
318
|
+
world.append(v)
|
|
319
|
+
}
|
|
320
|
+
for (const s of g.variants) {
|
|
321
|
+
const f = frames.get(s.path)
|
|
322
|
+
f.el.style.left = `${fx}px`
|
|
323
|
+
f.el.style.top = `${y + ROW_LABEL_H + top}px`
|
|
324
|
+
fx += s.width + GAP_X
|
|
325
|
+
tallest = Math.max(tallest, f.height + LABEL_H + top)
|
|
326
|
+
}
|
|
327
|
+
fx += VERSION_GAP - GAP_X
|
|
279
328
|
}
|
|
280
329
|
lineH = Math.max(lineH, ROW_LABEL_H + tallest)
|
|
281
330
|
x += groupW + GROUP_GAP
|
|
@@ -321,11 +370,17 @@ function tree() {
|
|
|
321
370
|
const approved = row.all.some((s) => s.status === 'approved')
|
|
322
371
|
name.innerHTML = `<span class="dot ${approved ? 'approved' : row.all.some((s) => s.status === 'review') ? 'review' : 'draft'}"></span><span></span><span class="meta"></span>`
|
|
323
372
|
name.children[1].textContent = row.key
|
|
324
|
-
|
|
373
|
+
const vs = versionsOf(row.visible)
|
|
374
|
+
name.querySelector('.meta').textContent = `${vs.length} version${vs.length > 1 ? 's' : ''}`
|
|
325
375
|
name.onclick = () => select(frames.get(row.visible[0].path), true)
|
|
326
376
|
name.append(copyButton(row.key, 'Copy the screen name'))
|
|
327
377
|
nav.append(name)
|
|
328
|
-
for (const
|
|
378
|
+
for (const g of vs) {
|
|
379
|
+
const one = g.variants.length === 1
|
|
380
|
+
const first = g.variants[0]
|
|
381
|
+
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}`)
|
|
382
|
+
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))
|
|
383
|
+
}
|
|
329
384
|
}
|
|
330
385
|
}
|
|
331
386
|
|
|
@@ -377,7 +432,7 @@ function badge(f) {
|
|
|
377
432
|
function paintLabel(f) {
|
|
378
433
|
const s = f.screen
|
|
379
434
|
f.el.className = `frame ${s.device} ${s.status}${selected === f ? ' selected' : ''}`
|
|
380
|
-
f.el.querySelector('.name').textContent = s.kind === 'components' ? 'Catalog' :
|
|
435
|
+
f.el.querySelector('.name').textContent = s.kind === 'components' ? 'Catalog' : s.device
|
|
381
436
|
const pill = f.el.querySelector('.pill')
|
|
382
437
|
pill.className = `pill ${s.status}`
|
|
383
438
|
pill.textContent = s.kind === 'components' ? 'components' : `${STATUS_LABEL[s.status]} · ${s.source}`
|
|
@@ -464,7 +519,16 @@ function inspector(f) {
|
|
|
464
519
|
}
|
|
465
520
|
box.insertBefore(sec, box.querySelector('h4'))
|
|
466
521
|
}
|
|
467
|
-
box.querySelector('.h').textContent = s.kind === 'components' ? 'Component catalog' : `${s.screen} · ${s.version}`
|
|
522
|
+
box.querySelector('.h').textContent = s.kind === 'components' ? 'Component catalog' : `${s.screen} · ${s.version} · ${s.device}`
|
|
523
|
+
const siblings = s.kind === 'screen' ? sortVersions(screens.filter((o) => o.kind === 'screen' && o.screen === s.screen && o.version === s.version)) : []
|
|
524
|
+
if (siblings.length > 1) {
|
|
525
|
+
const vrow = document.createElement('div')
|
|
526
|
+
vrow.className = 'version-actions'
|
|
527
|
+
vrow.innerHTML = '<span></span><button type="button" data-st="approved" title="Approve every variant of this version">Approve version</button><button type="button" data-st="archived" title="Archive every variant of this version">Archive version</button>'
|
|
528
|
+
vrow.querySelector('span').textContent = `${s.version}: ${siblings.map((o) => `${o.device} ${STATUS_LABEL[o.status]}`).join(', ')}`
|
|
529
|
+
for (const b of vrow.querySelectorAll('button')) b.onclick = async () => { for (const o of siblings) if (o.status !== b.dataset.st && !(b.dataset.st === 'approved' && o.status === 'archived')) await setMeta(o.path, { status: b.dataset.st }) }
|
|
530
|
+
box.querySelector('.status').before(vrow)
|
|
531
|
+
}
|
|
468
532
|
box.querySelector('.f').textContent = `${s.device} · ${s.source}`
|
|
469
533
|
const a = box.querySelector('.facts a')
|
|
470
534
|
a.href = `/${s.path}`
|
|
@@ -620,6 +684,8 @@ async function load(first, given) {
|
|
|
620
684
|
if (f) { f.screen = s; paintLabel(f) } else frameFor(s)
|
|
621
685
|
}
|
|
622
686
|
screens = next
|
|
687
|
+
versionFilter()
|
|
688
|
+
variantFilter()
|
|
623
689
|
layout()
|
|
624
690
|
await loadChecks()
|
|
625
691
|
if (selected) inspector(selected)
|
|
@@ -627,7 +693,7 @@ async function load(first, given) {
|
|
|
627
693
|
}
|
|
628
694
|
|
|
629
695
|
// ---- filters ----
|
|
630
|
-
for (const [id, key] of [['statusFilter', 'status']
|
|
696
|
+
for (const [id, key] of [['statusFilter', 'status']]) {
|
|
631
697
|
const seg = document.getElementById(id)
|
|
632
698
|
for (const b of seg.querySelectorAll('button')) {
|
|
633
699
|
b.classList.toggle('on', b.dataset.v === filters[key])
|
|
@@ -641,6 +707,57 @@ for (const [id, key] of [['statusFilter', 'status'], ['deviceFilter', 'device']]
|
|
|
641
707
|
}
|
|
642
708
|
document.getElementById('search').oninput = layout
|
|
643
709
|
|
|
710
|
+
// Versions and variants: multi-selects of every label the screens use (v1 … v12 and imports such as "Stitch";
|
|
711
|
+
// mobile, tablet, desktop), with how many screens carry each; none ticked means all. The catalog always shows.
|
|
712
|
+
const MULTI = []
|
|
713
|
+
for (const id of ['versionFilter', 'variantFilter']) {
|
|
714
|
+
const box = document.getElementById(id)
|
|
715
|
+
MULTI.push(box)
|
|
716
|
+
}
|
|
717
|
+
document.addEventListener('click', (e) => { for (const box of MULTI) if (box.open && !box.contains(e.target)) box.open = false })
|
|
718
|
+
document.addEventListener('keydown', (e) => { const open = MULTI.find((b) => b.open); if (e.key === 'Escape' && open) { open.open = false; e.stopPropagation() } }, true)
|
|
719
|
+
function multiFilter({ id, key, of, sort, all, many, again }) {
|
|
720
|
+
const box = document.getElementById(id)
|
|
721
|
+
const counts = new Map()
|
|
722
|
+
for (const s of screens) if (s.kind === 'screen') counts.set(of(s), (counts.get(of(s)) ?? 0) + 1)
|
|
723
|
+
const labels = [...counts.keys()].sort(sort)
|
|
724
|
+
filters[key] = (filters[key] ?? []).filter((v) => labels.includes(v))
|
|
725
|
+
box.hidden = labels.length < 2
|
|
726
|
+
const picked = filters[key]
|
|
727
|
+
box.querySelector('summary').textContent = !picked.length ? all : picked.length <= 3 ? [...picked].sort(sort).join(', ') : `${picked.length} ${many}`
|
|
728
|
+
const change = (next) => {
|
|
729
|
+
filters[key] = next
|
|
730
|
+
localStorage.setItem('stitch2-filters', JSON.stringify(filters))
|
|
731
|
+
again()
|
|
732
|
+
layout()
|
|
733
|
+
}
|
|
734
|
+
const option = (label, checked, n, onChange, cls = '') => {
|
|
735
|
+
const l = document.createElement('label')
|
|
736
|
+
l.className = cls
|
|
737
|
+
const input = document.createElement('input')
|
|
738
|
+
input.type = 'checkbox'
|
|
739
|
+
input.checked = checked
|
|
740
|
+
input.onchange = onChange
|
|
741
|
+
const name = document.createElement('span')
|
|
742
|
+
name.textContent = label
|
|
743
|
+
const count = document.createElement('span')
|
|
744
|
+
count.className = 'n'
|
|
745
|
+
count.textContent = n
|
|
746
|
+
l.append(input, name, count)
|
|
747
|
+
return l
|
|
748
|
+
}
|
|
749
|
+
box.querySelector('.menu').replaceChildren(
|
|
750
|
+
option(all, !picked.length, screens.filter((s) => s.kind === 'screen').length, () => change([]), 'all'),
|
|
751
|
+
...labels.map((v) => option(v, picked.includes(v), counts.get(v), () => change(picked.includes(v) ? picked.filter((x) => x !== v) : [...picked, v]))),
|
|
752
|
+
)
|
|
753
|
+
}
|
|
754
|
+
function versionFilter() {
|
|
755
|
+
multiFilter({ id: 'versionFilter', key: 'versions', of: (s) => s.version, sort: (a, b) => a.localeCompare(b, undefined, { numeric: true }), all: 'All versions', many: 'versions', again: versionFilter })
|
|
756
|
+
}
|
|
757
|
+
function variantFilter() {
|
|
758
|
+
multiFilter({ id: 'variantFilter', key: 'devices', of: (s) => s.device, sort: (a, b) => (DEVICE_RANK[a] ?? 9) - (DEVICE_RANK[b] ?? 9), all: 'All variants', many: 'variants', again: variantFilter })
|
|
759
|
+
}
|
|
760
|
+
|
|
644
761
|
// ---- live reload ----
|
|
645
762
|
const events = new EventSource('/events')
|
|
646
763
|
events.onmessage = async (e) => {
|
|
@@ -734,10 +851,10 @@ fetch('/api/config').then((r) => r.json()).then((c) => {
|
|
|
734
851
|
document.getElementById('labName').textContent = c.name
|
|
735
852
|
THEMES = c.themes?.length ? c.themes : ['dark']
|
|
736
853
|
if (!THEMES.includes(theme)) theme = THEMES[0]
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
854
|
+
const pick = document.getElementById('themeFilter')
|
|
855
|
+
pick.hidden = !themed()
|
|
856
|
+
pick.innerHTML = THEMES.map((t) => `<option value="${t}">${t[0].toUpperCase()}${t.slice(1)} theme</option>`).join('')
|
|
857
|
+
pick.onchange = () => setTheme(pick.value)
|
|
741
858
|
setTheme(theme)
|
|
742
859
|
SOURCE = c.sourceLocale ?? 'en'
|
|
743
860
|
LANGS = c.locales?.length ? c.locales : [SOURCE]
|
package/dist/consistency.js
CHANGED
|
@@ -248,7 +248,8 @@ export function consistencyIssues(known, paths) {
|
|
|
248
248
|
out[path] = issues;
|
|
249
249
|
const peers = (families.get(familyOf(path)) ?? []).filter((p) => p !== path);
|
|
250
250
|
for (const [kind, sigs] of Object.entries(known[path] ?? {})) {
|
|
251
|
-
|
|
251
|
+
// The catalog shows every variant of a component side by side; a screen shows one.
|
|
252
|
+
if (sigs.length > 1 && MEASURED.has(kind.split(':')[0]) && !catalogs.includes(path))
|
|
252
253
|
issues.push({
|
|
253
254
|
severity: 'warning',
|
|
254
255
|
type: 'consistency',
|
package/dist/list.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Lists every screen version with its status and the reviewer's note, for agents (and anyone without the
|
|
2
|
+
* Lists every screen version, variant by variant (mobile, desktop), with its status and the reviewer's note, for agents (and anyone without the
|
|
3
3
|
* canvas open). Approved versions are the reference; review versions wait for the user; notes are feedback.
|
|
4
4
|
* Usage: stitch2 screens [screen name] [--status approved|review|draft|archived] [--json]
|
|
5
5
|
*/
|
|
@@ -9,9 +9,11 @@ const status = args.includes('--status') ? args[args.indexOf('--status') + 1] :
|
|
|
9
9
|
const name = args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--status');
|
|
10
10
|
const rows = listScreens()
|
|
11
11
|
.filter((s) => s.kind === 'screen' && (!status || s.status === status) && (!name || s.screen === name))
|
|
12
|
-
.sort((a, b) => a.screen.localeCompare(b.screen) ||
|
|
12
|
+
.sort((a, b) => a.screen.localeCompare(b.screen) ||
|
|
13
|
+
a.version.localeCompare(b.version, undefined, { numeric: true }) ||
|
|
14
|
+
b.device.localeCompare(a.device));
|
|
13
15
|
if (args.includes('--json'))
|
|
14
16
|
console.log(JSON.stringify(rows, null, 2));
|
|
15
17
|
else
|
|
16
18
|
for (const s of rows)
|
|
17
|
-
console.log(`${s.screen.padEnd(20)} ${s.
|
|
19
|
+
console.log(`${s.screen.padEnd(20)} ${s.version.padEnd(8)} ${s.device.padEnd(8)} ${s.status.padEnd(9)} ${s.path}${s.note ? `\n${' '.repeat(39)}note: ${s.note}` : ''}`);
|
package/dist/setup.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* --name and --primary, DESIGN.md and its tokens. Existing files are never overwritten, so it is safe to re-run.
|
|
7
7
|
*/
|
|
8
8
|
import { execFileSync } from 'node:child_process';
|
|
9
|
-
import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
10
10
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { CONFIG_FILE, CONFIG_NAME, config as current } from './config.js';
|
|
@@ -42,6 +42,16 @@ const skills = readdirSync(join(pkg, 'skills'));
|
|
|
42
42
|
const linked = [];
|
|
43
43
|
for (const dir of skillDirs) {
|
|
44
44
|
mkdirSync(resolve(project, dir), { recursive: true });
|
|
45
|
+
// A skill renamed or removed in a newer stitch2 leaves a link to nothing: remove it.
|
|
46
|
+
for (const s of readdirSync(resolve(project, dir))) {
|
|
47
|
+
const at = resolve(project, dir, s);
|
|
48
|
+
if (!lstatSync(at).isSymbolicLink() || existsSync(at))
|
|
49
|
+
continue;
|
|
50
|
+
if (!/(^|[\\/])stitch2[\\/]skills[\\/]/.test(readlinkSync(at)))
|
|
51
|
+
continue;
|
|
52
|
+
unlinkSync(at);
|
|
53
|
+
done.push(`removed ${join(dir, s)} (no longer in stitch2)`);
|
|
54
|
+
}
|
|
45
55
|
for (const s of skills) {
|
|
46
56
|
const at = join(dir, s);
|
|
47
57
|
linked.push(at);
|
package/package.json
CHANGED
package/skills/stitch2/SKILL.md
CHANGED
|
@@ -24,7 +24,8 @@ References, read before the first screen:
|
|
|
24
24
|
- [principles.md](references/principles.md): what makes a screen read well, and the review rubric.
|
|
25
25
|
- [typography.md](references/typography.md): type levels and how to combine them; most polish is here.
|
|
26
26
|
- [components.md](references/components.md): build from the shared components; the reuse ladder.
|
|
27
|
-
- [screens.md](references/screens.md): screen names, versions
|
|
27
|
+
- [screens.md](references/screens.md): screen names, versions and their variants (mobile, desktop), statuses,
|
|
28
|
+
the user's approval and notes.
|
|
28
29
|
- [google.md](references/google.md): Google's guidance (Material, Android, web.dev) as rules with numbers.
|
|
29
30
|
- [guides.md](references/guides.md): the public sources behind the rules.
|
|
30
31
|
|
|
@@ -40,8 +41,9 @@ References, read before the first screen:
|
|
|
40
41
|
3. **Hierarchy before markup.** Name the one thing the user came for, the single primary action (the only
|
|
41
42
|
accent-filled element in its region), and what is secondary. Give every text element a type level and a
|
|
42
43
|
colour before writing markup.
|
|
43
|
-
4. **Build** the next version, `<screens>/<screen>/<device>-v<N>.html`,
|
|
44
|
-
|
|
44
|
+
4. **Build** the next version, `<screens>/<screen>/<device>-v<N>.html`, or a missing variant of an existing
|
|
45
|
+
version (another device, the same N), from the project's template (status `draft`; never edit an approved
|
|
46
|
+
variant). Assemble it from the components; plain markup only where none
|
|
45
47
|
applies, with token classes, `.type-<level>` classes for text, the spacing scale, and the states DESIGN.md
|
|
46
48
|
defines. When the config has `i18n`, mark every product string with its message key (`data-t`, see
|
|
47
49
|
**stitch2-i18n**). Never add classes to a component to change how it looks, and never write a colour value (hex,
|
|
@@ -82,11 +84,11 @@ References, read before the first screen:
|
|
|
82
84
|
| `lint` | Google's DESIGN.md linter |
|
|
83
85
|
| `localize [filter]` | Copies remote images a screen uses into its `assets/` folder |
|
|
84
86
|
| `init`, `extract` | Build DESIGN.md from choices, or measure it from an existing site (stitch2-design-md) |
|
|
85
|
-
| `sandbox create <dir> <brief.md>…`, `sandbox import <dir>` | Fresh-eyes versions from agents that see no other versions (stitch2-
|
|
87
|
+
| `sandbox create <dir> <brief.md>…`, `sandbox import <dir>` | Fresh-eyes versions from agents that see no other versions (stitch2-alternatives) |
|
|
86
88
|
|
|
87
89
|
## Alternatives and fresh eyes
|
|
88
90
|
|
|
89
91
|
When the user wants options or a new direction, or a design is stuck, do not iterate on the existing versions:
|
|
90
|
-
use the **stitch2-
|
|
92
|
+
use the **stitch2-alternatives** skill. Fresh agents in sandboxes design from the brief without seeing the other
|
|
91
93
|
versions, and their screens come back as new versions to compare on the canvas. The same skill tests changes to
|
|
92
94
|
the skills or DESIGN.md.
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
# Screens, versions and approval
|
|
2
2
|
|
|
3
|
-
The canvas and the agents share one model: an app **screen** (home, settings, …) has **versions**,
|
|
4
|
-
|
|
3
|
+
The canvas and the agents share one model: an app **screen** (home, settings, …) has **versions**, design
|
|
4
|
+
iterations numbered v1, v2, v3…, and each version has **variants**, its layouts for each device (mobile,
|
|
5
|
+
desktop, later tablet). Each variant has a **status**. The user decides; agents read the decision
|
|
5
6
|
and build on it. Meta tag names below use the project's prefix (`<p>`, from `stitch2.config.json`).
|
|
6
7
|
|
|
7
8
|
## Names and files
|
|
8
9
|
|
|
9
10
|
- Screen names are lowercase with hyphens and name what the user sees; one per step of a flow
|
|
10
11
|
(`checkout-1`, `checkout-2`). Reuse existing names (`stitch2 screens` lists them).
|
|
11
|
-
- A
|
|
12
|
+
- A variant is one file, `<screens dir>/<screen>/<device>-v<N>.html`; the variants of a version share N
|
|
13
|
+
(`mobile-v3.html` and `desktop-v3.html` are v3). A new version takes the next free N across the screen's
|
|
14
|
+
variants; a variant added to an existing version takes that version's N (a desktop for v3 is
|
|
15
|
+
`desktop-v3.html`, never v4). A version may start with one variant and get the others later.
|
|
12
16
|
Its head carries `<p>-screen`, `<p>-device` (mobile | desktop), `<p>-status`, `<p>-source` (stitch | lab),
|
|
13
17
|
`<p>-frame-width` (390 mobile, 1280 desktop) and, written by the user, `<p>-note`. Older files elsewhere
|
|
14
18
|
carry the same tags.
|
|
@@ -17,9 +21,11 @@ and build on it. Meta tag names below use the project's prefix (`<p>`, from `sti
|
|
|
17
21
|
|
|
18
22
|
- **New version** for every new attempt: a new direction, a regeneration, an export from a generator, a
|
|
19
23
|
redesign after feedback on an approved screen. It starts as `draft` and ends as `review`.
|
|
24
|
+
- **New variant** for a device the version lacks (its desktop, its tablet): a new file with the version's N,
|
|
25
|
+
`draft` then `review`, even when the version's other variants are approved.
|
|
20
26
|
- **Edit in place** only to finish your own draft, or to answer a note on a version still in `review` or
|
|
21
27
|
`draft`.
|
|
22
|
-
- **Never edit** an `approved` or `archived`
|
|
28
|
+
- **Never edit** an `approved` or `archived` variant: copy the version to the next number and change the copy.
|
|
23
29
|
|
|
24
30
|
## Statuses
|
|
25
31
|
|
|
@@ -28,7 +34,11 @@ and build on it. Meta tag names below use the project's prefix (`<p>`, from `sti
|
|
|
28
34
|
| `draft` | Work in progress | The agent, while working |
|
|
29
35
|
| `review` | Finished, waiting for the user ("Needs review") | The agent, when done |
|
|
30
36
|
| `approved` | The reference: build on it, match it, implement it | The user only |
|
|
31
|
-
| `archived` | History: superseded or rejected; hidden on the canvas | The user; approving a
|
|
37
|
+
| `archived` | History: superseded or rejected; hidden on the canvas | The user; approving a variant archives the previously approved variant of the same device |
|
|
38
|
+
|
|
39
|
+
The canvas shows each version with its variants side by side under one heading, and one status for the version:
|
|
40
|
+
needs review while any variant does. **Approve version** approves every variant in review at once; a single
|
|
41
|
+
variant can still be approved, archived or noted on its own.
|
|
32
42
|
|
|
33
43
|
## How to use them
|
|
34
44
|
|
|
@@ -55,15 +65,15 @@ the page needs a state first (an open sheet, a running workout), add a `prepare`
|
|
|
55
65
|
|
|
56
66
|
## Desktop
|
|
57
67
|
|
|
58
|
-
Desktop is a
|
|
59
|
-
frame width 1280, the same components and type levels, the layout from DESIGN.md (typically a sidebar instead
|
|
68
|
+
Desktop is a variant of a version, designed after the version's mobile variant is approved: `desktop-v<N>.html`
|
|
69
|
+
with the mobile's N, frame width 1280, the same components and type levels, the layout from DESIGN.md (typically a sidebar instead
|
|
60
70
|
of a tab bar, a content max width, two columns where it helps). Shared components get their desktop form in the
|
|
61
71
|
component, before the first desktop screen.
|
|
62
72
|
|
|
63
73
|
Widths follow Google's window size classes ([google.md](google.md)): compact under 600 px (the phone frame),
|
|
64
74
|
medium 600–839, expanded 840–1199, large from 1200 (the desktop frame). A desktop screen is a layout for the
|
|
65
75
|
width class, not a stretched phone: a side navigation instead of the bar, list-detail with both panes, extra
|
|
66
|
-
columns for feeds. When a project needs a tablet, it is
|
|
76
|
+
columns for feeds. When a project needs a tablet, it is one more variant, at a medium width.
|
|
67
77
|
|
|
68
78
|
## The canvas
|
|
69
79
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: stitch2-
|
|
2
|
+
name: stitch2-alternatives
|
|
3
3
|
description: Get fresh-eyes versions of a screen from agents that see no other versions (stitch2 sandbox), so alternatives are not anchored to what exists; or test a change to the skills on output only the rules produced. Use when the user wants alternatives or a new direction, feels stuck with a design, wants several options to choose from, or when a skill or DESIGN.md rule changed and you need evidence it helps.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Fresh-eyes
|
|
6
|
+
# Fresh-eyes alternatives with stitch2 sandboxes
|
|
7
7
|
|
|
8
8
|
An agent that can see a screen's existing versions copies them, even when asked for something new. A sandbox
|
|
9
9
|
is a folder with the project's design system, components, catalog, template and skills, and none of its screens,
|
|
@@ -16,8 +16,8 @@ the user to compare on the canvas.
|
|
|
16
16
|
and structure only: colours, fonts and shapes come from DESIGN.md. Name the file after the screen
|
|
17
17
|
(`history.md` designs `history`). For several directions, write one brief per direction with one sentence
|
|
18
18
|
on what differs (`history.md` in sandbox A: "a calendar first"; in sandbox B: "a list first").
|
|
19
|
-
2. **Create one sandbox per
|
|
20
|
-
`--context home stats` adds the approved versions of other screens, so the
|
|
19
|
+
2. **Create one sandbox per alternative**: `stitch2 sandbox create /tmp/<project>-<screen>-a <brief.md>`.
|
|
20
|
+
`--context home stats` adds the approved versions of other screens, so the alternative stays consistent with
|
|
21
21
|
them; `--device desktop` for desktop.
|
|
22
22
|
3. **Run a fresh agent in each**, with no shared conversation: a sub-agent started without forked history,
|
|
23
23
|
working only in that folder, told to read its AGENTS.md and follow the skills there. Without sub-agents, give
|