stitch2 0.13.1 → 0.15.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 +12 -6
- package/config.schema.json +5 -0
- package/dist/audit.js +11 -1
- package/dist/canvas.html +256 -38
- package/dist/check.js +24 -5
- package/dist/compare.js +39 -11
- package/dist/config.d.ts +10 -0
- package/dist/config.js +4 -0
- package/dist/consistency.js +10 -2
- package/dist/i18n-report.js +10 -3
- package/dist/i18n.js +15 -7
- package/dist/languages-page.js +18 -4
- package/dist/list.js +5 -3
- package/dist/report-filter.d.ts +7 -0
- package/dist/report-filter.js +23 -0
- package/dist/report.js +5 -3
- package/dist/serve.d.ts +14 -0
- package/dist/serve.js +35 -5
- package/dist/setup.js +11 -1
- package/dist/typecheck.d.ts +2 -0
- package/package.json +1 -1
- package/skills/stitch2/SKILL.md +10 -6
- package/skills/stitch2/references/screens.md +42 -14
- package/skills/{stitch2-variants → stitch2-alternatives}/SKILL.md +4 -4
- package/skills/stitch2-app-sync/SKILL.md +4 -1
- package/skills/stitch2-i18n/SKILL.md +2 -1
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ file that already exists. From there the agent spends its effort on the design.
|
|
|
37
37
|
and status filters, your DESIGN.md drawn as tokens and rules, the component catalog, and an inspector to
|
|
38
38
|
approve, archive or leave a note on a version. Live reload; copyable references to paste to an agent.
|
|
39
39
|
With a light and a dark theme (DESIGN.md's `light-*` colours), a switch (`T`) shows every screen in either one.
|
|
40
|
+
A width switch (`W`) shows every screen at the narrowest or widest window its layout covers.
|
|
40
41
|
With languages configured, a Language switch (`L`) shows every screen in one of them, and the Languages page
|
|
41
42
|
shows a screen side by side in each.
|
|
42
43
|
Pick mode (`P`) highlights any element of a screen and copies a reference with its source line, such as
|
|
@@ -44,7 +45,8 @@ file that already exists. From there the agent spends its effort on the design.
|
|
|
44
45
|
several to copy at once or add to the version's note.
|
|
45
46
|
- **Checks** (`stitch2 check`): content past the frame edge or out of its card, labels that wrap, text running into
|
|
46
47
|
other text, labels cut short with an ellipsis, content under fixed bars, fixed bars that overlap each other, tap
|
|
47
|
-
targets (WCAG 2.2),
|
|
48
|
+
targets (WCAG 2.2), all of it again at both ends of the window widths each layout covers (360 and 599 px for a
|
|
49
|
+
phone, 1024 and 1920 for desktop, or your own ranges), text contrast in every theme, text that is not one of DESIGN.md's type levels, capitals without tracking,
|
|
48
50
|
large numbers in monospace, components built by hand, components that differ between screens, and colours
|
|
49
51
|
written as values instead of DESIGN.md tokens. Google's guidelines (Material 3, Android's app quality
|
|
50
52
|
guidelines, web.dev, Lighthouse) add warnings for text under 11 px, text closer than 16 px (24 px from 600 px)
|
|
@@ -68,17 +70,18 @@ file that already exists. From there the agent spends its effort on the design.
|
|
|
68
70
|
{ "format": "dtcg", "path": "design/tokens.json" }
|
|
69
71
|
]
|
|
70
72
|
```
|
|
71
|
-
- **Versions and approval**:
|
|
72
|
-
|
|
73
|
+
- **Versions, variants and approval**: a screen has versions (v1, v2, …), and each version has variants, its
|
|
74
|
+
layouts for mobile and desktop (`mobile-v3.html`, `desktop-v3.html`). The canvas groups them; you approve a
|
|
75
|
+
version as a whole or one variant at a time, leave notes, and approving archives the previous approved layout.
|
|
73
76
|
- **DESIGN.md builder**: `stitch2 init` writes a complete DESIGN.md from a few choices (brand colour, modes,
|
|
74
77
|
fonts, roundness, density), with colours checked for contrast; `stitch2 extract` measures one from an existing
|
|
75
78
|
site or screens.
|
|
76
|
-
- **Fresh-eyes
|
|
79
|
+
- **Fresh-eyes alternatives** (`stitch2 sandbox`): a folder with your design system, components and skills but no
|
|
77
80
|
screens, where a fresh agent designs from a brief without being anchored to the existing versions;
|
|
78
81
|
`stitch2 sandbox import` brings the result back as the next version for review. Also the way to test a change
|
|
79
82
|
to the skills on output only the rules produced.
|
|
80
83
|
- **Skills** for agents (`skills/`): `stitch2` (the design workflow, principles, typography, components,
|
|
81
|
-
versions), `stitch2-design-md` (building the design system), `stitch2-
|
|
84
|
+
versions), `stitch2-design-md` (building the design system), `stitch2-alternatives` (fresh-eyes versions and
|
|
82
85
|
testing skill changes), `stitch2-consistency` (verifying and fixing
|
|
83
86
|
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
87
|
optional, `stitch2-import-from-google-stitch` (drafting screens in Google Stitch through its MCP server).
|
|
@@ -152,7 +155,10 @@ what differs in structure: shared components the design has and the app lacks (o
|
|
|
152
155
|
order, and how the measured ones look. Mock-ups and the app hold different data, so the screenshots are shown side
|
|
153
156
|
by side in `design/compare/index.html` (the canvas links it) rather than diffed. It also runs check's layout
|
|
154
157
|
rules on the app (content past the edge, fixed bars or controls overlapping), which catches a control the app
|
|
155
|
-
squeezed in although no screen drew it.
|
|
158
|
+
squeezed in although no screen drew it. Each version is then compared again at both edges of its variant’s width
|
|
159
|
+
range (`widths` in the config), so a layout that holds at 390 px but breaks at 1023 px shows up as "at 1023px: …".
|
|
160
|
+
Compare and the consistency report run only when you call them; the canvas sidebar shows when each last ran and,
|
|
161
|
+
once screens it covers have changed since, how many (hover for the list). The `stitch2-app-sync` skill describes the workflow around it: a visible
|
|
156
162
|
change goes component, canvas, approval, then app. Configure it in the config:
|
|
157
163
|
|
|
158
164
|
```json
|
package/config.schema.json
CHANGED
|
@@ -62,6 +62,11 @@
|
|
|
62
62
|
"description": "Tap targets smaller than this (px) get a warning.",
|
|
63
63
|
"default": 32
|
|
64
64
|
},
|
|
65
|
+
"widths": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"description": "The window widths each variant covers, [narrowest, widest] in px, by device (mobile, tablet, desktop). check renders every variant at both ends; the canvas shows them. Defaults: mobile 360–599, tablet 600–1023, desktop 1024–1920.",
|
|
68
|
+
"additionalProperties": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }
|
|
69
|
+
},
|
|
65
70
|
"themes": {
|
|
66
71
|
"type": "array",
|
|
67
72
|
"items": { "type": "string" },
|
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).
|
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,30 @@
|
|
|
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
|
+
.item.stale .meta { color: var(--warn); }
|
|
55
|
+
#inspector .version-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; }
|
|
56
|
+
#inspector .version-actions span { color: var(--muted); }
|
|
57
|
+
#inspector .version-actions button { background: var(--panel2); border: 1px solid var(--edge); border-radius: 8px; cursor: pointer; padding: 3px 8px; }
|
|
37
58
|
.screen-name { align-items: center; border-radius: 6px; color: var(--text); cursor: pointer; display: flex; font-weight: 600; gap: 6px; padding: 8px 8px 2px; }
|
|
38
59
|
.screen-name:hover { background: var(--panel2); }
|
|
39
60
|
.screen-name .meta { color: var(--faint); font-size: 11px; font-weight: 400; margin-left: auto; }
|
|
@@ -106,9 +127,11 @@
|
|
|
106
127
|
<b id="labName">stitch2</b>
|
|
107
128
|
<input id="search" placeholder="Search screens… ( / )" />
|
|
108
129
|
<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="
|
|
130
|
+
<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>
|
|
131
|
+
<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>
|
|
132
|
+
<select id="themeFilter" class="pick" hidden title="Show every screen in this theme (T)" aria-label="Theme"></select>
|
|
133
|
+
<select id="widthFilter" class="pick" title="Show every screen at the narrowest or widest window its variant covers (W)" aria-label="Width"></select>
|
|
134
|
+
<select id="langFilter" class="pick" title="Show every screen in this language (L)" aria-label="Language"></select>
|
|
112
135
|
</header>
|
|
113
136
|
<nav id="tree"></nav>
|
|
114
137
|
<footer>
|
|
@@ -165,6 +188,34 @@ function setLang(l) {
|
|
|
165
188
|
if (selected) inspector(selected)
|
|
166
189
|
summary()
|
|
167
190
|
}
|
|
191
|
+
// Widths: each variant covers a range of windows (config widths); frames show it drawn, at the narrowest or the widest.
|
|
192
|
+
let WIDTHS = {}
|
|
193
|
+
let widthMode = localStorage.getItem('stitch2-width') ?? 'drawn'
|
|
194
|
+
const rangeOf = (s) => WIDTHS[s.device]
|
|
195
|
+
const widthFor = (s) => { const r = rangeOf(s); return !r || widthMode === 'drawn' ? s.drawn : widthMode === 'narrow' ? r[0] : r[1] }
|
|
196
|
+
const bigFrame = (s) => (s.drawn ?? s.width) > 700
|
|
197
|
+
function setWidth(mode) {
|
|
198
|
+
widthMode = mode
|
|
199
|
+
localStorage.setItem('stitch2-width', mode)
|
|
200
|
+
document.getElementById('widthFilter').value = mode
|
|
201
|
+
for (const f of frames.values()) {
|
|
202
|
+
f.screen.width = widthFor(f.screen)
|
|
203
|
+
f.el.style.width = `${f.screen.width}px`
|
|
204
|
+
f.iframe.style.width = `${f.screen.width}px`
|
|
205
|
+
setTimeout(() => measure(f), 300)
|
|
206
|
+
setTimeout(() => measure(f), 1200)
|
|
207
|
+
}
|
|
208
|
+
layout()
|
|
209
|
+
if (selected) inspector(selected)
|
|
210
|
+
}
|
|
211
|
+
/** The width dropdown names the edges of the devices the screens use: "Narrowest: mobile 360, desktop 1024". */
|
|
212
|
+
function widthOptions() {
|
|
213
|
+
const used = [...new Set(screens.map((s) => s.device))].filter((d) => WIDTHS[d]).sort((a, b) => WIDTHS[a][0] - WIDTHS[b][0])
|
|
214
|
+
const edge = (k) => used.map((d) => `${d} ${WIDTHS[d][k]}`).join(', ')
|
|
215
|
+
const wsel = document.getElementById('widthFilter')
|
|
216
|
+
wsel.innerHTML = `<option value="drawn">Drawn width</option><option value="narrow">Narrowest: ${edge(0)}</option><option value="wide">Widest: ${edge(1)}</option>`
|
|
217
|
+
wsel.value = widthMode
|
|
218
|
+
}
|
|
168
219
|
function applyTheme(f) {
|
|
169
220
|
if (!themed()) return
|
|
170
221
|
try { f.iframe.contentDocument.documentElement.dataset.theme = theme } catch {}
|
|
@@ -172,13 +223,15 @@ function applyTheme(f) {
|
|
|
172
223
|
function setTheme(t) {
|
|
173
224
|
theme = t
|
|
174
225
|
localStorage.setItem('stitch2-theme', t)
|
|
175
|
-
|
|
226
|
+
document.getElementById('themeFilter').value = t
|
|
176
227
|
for (const f of frames.values()) applyTheme(f)
|
|
177
228
|
for (const f of frames.values()) badge(f)
|
|
178
229
|
if (selected) inspector(selected)
|
|
179
230
|
summary()
|
|
231
|
+
sendFilter()
|
|
180
232
|
}
|
|
181
|
-
const
|
|
233
|
+
const widthNote = (s) => (s.width !== s.drawn ? ` at ${s.width}px` : '')
|
|
234
|
+
const ref = (s) => (s.kind === 'components' ? `component catalog (${designDir}/${s.path})` : `${s.screen} ${s.device} ${s.version}${themeNote()}${widthNote(s)} (${designDir}/${s.path})`)
|
|
182
235
|
let toastTimer
|
|
183
236
|
async function copy(text) {
|
|
184
237
|
try {
|
|
@@ -207,9 +260,11 @@ const copyButton = (text, title) => {
|
|
|
207
260
|
return b
|
|
208
261
|
}
|
|
209
262
|
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'
|
|
263
|
+
const filters = JSON.parse(localStorage.getItem('stitch2-filters') ?? 'null') ?? { status: 'active' }
|
|
264
|
+
if (typeof filters.device === 'string') { filters.devices = filters.device === 'all' ? [] : [filters.device]; delete filters.device }
|
|
211
265
|
let screens = []
|
|
212
266
|
let checks = { screens: {} }
|
|
267
|
+
let reports = { consistency: null, compare: null }
|
|
213
268
|
let selected = null
|
|
214
269
|
const frames = new Map() // path -> { el, iframe, screen, height }
|
|
215
270
|
|
|
@@ -230,16 +285,27 @@ const zoomAt = (cx, cy, z) => {
|
|
|
230
285
|
apply()
|
|
231
286
|
}
|
|
232
287
|
|
|
233
|
-
// ---- grouping: rows are app screens (components first)
|
|
288
|
+
// ---- grouping: rows are app screens (components first); in a row, versions (v1, v2, …) in order, and in a
|
|
289
|
+
// version its variants: the layouts for each device (mobile, desktop), files <device>-v<N> sharing N ----
|
|
234
290
|
const rowKey = (s) => (s.kind === 'components' ? '~components' : s.screen)
|
|
235
291
|
const rowRank = (k) => (k === '~components' ? -1 : ORDER.includes(k) ? ORDER.indexOf(k) : 100)
|
|
236
292
|
const sortRows = (keys) => keys.sort((a, b) => rowRank(a) - rowRank(b) || a.localeCompare(b))
|
|
293
|
+
const versionRank = (v) => { const m = /^v(\d+)$/.exec(v); return m ? Number(m[1]) : 0 }
|
|
294
|
+
const DEVICE_RANK = { mobile: 0, tablet: 1, desktop: 2 }
|
|
237
295
|
const sortVersions = (list) =>
|
|
238
|
-
list.sort((a, b) => (a.
|
|
296
|
+
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)
|
|
297
|
+
/** A version's variants, and one status for all of them: needs review if any does, else the most settled. */
|
|
298
|
+
const versionsOf = (list) => {
|
|
299
|
+
const by = new Map()
|
|
300
|
+
for (const s of list) { const k = s.kind === 'components' ? s.path : s.version; by.set(k, [...(by.get(k) ?? []), s]) }
|
|
301
|
+
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' }))
|
|
302
|
+
}
|
|
303
|
+
const VERSION_H = 44, VERSION_GAP = 120
|
|
239
304
|
function shown(s) {
|
|
240
305
|
const q = document.getElementById('search').value.trim().toLowerCase()
|
|
241
306
|
if (q && !`${s.screen} ${s.title} ${s.path} ${s.version} ${s.source}`.toLowerCase().includes(q)) return false
|
|
242
|
-
if (filters.
|
|
307
|
+
if (filters.devices?.length && !filters.devices.includes(s.device)) return false
|
|
308
|
+
if (filters.versions?.length && s.kind === 'screen' && !filters.versions.includes(s.version)) return false
|
|
243
309
|
if (s.kind === 'components') return filters.status !== 'review' && filters.status !== 'approved'
|
|
244
310
|
if (filters.status === 'active') return s.status !== 'archived'
|
|
245
311
|
if (filters.status === 'all') return true
|
|
@@ -258,11 +324,13 @@ function layout() {
|
|
|
258
324
|
for (const row of rows()) {
|
|
259
325
|
for (const s of row.all) frames.get(s.path).el.style.display = row.visible.includes(s) ? '' : 'none'
|
|
260
326
|
if (!row.visible.length) continue
|
|
261
|
-
const
|
|
327
|
+
const groups = versionsOf(row.visible)
|
|
328
|
+
const widthOf = (g) => g.variants.reduce((w, s) => w + s.width, 0) + GAP_X * (g.variants.length - 1)
|
|
329
|
+
const groupW = groups.reduce((w, g) => w + widthOf(g), 0) + VERSION_GAP * (groups.length - 1)
|
|
262
330
|
if (x > 0 && x + groupW > LINE_W) { x = 0; y += lineH + GAP_Y; lineH = 0 }
|
|
263
331
|
const label = document.createElement('div')
|
|
264
332
|
label.className = 'row-label'
|
|
265
|
-
const approved = row.all.filter((s) => s.status === 'approved').map((s) => `${s.
|
|
333
|
+
const approved = row.all.filter((s) => s.status === 'approved').map((s) => `${s.version} ${s.device}`)
|
|
266
334
|
label.innerHTML = '<b></b><span></span>'
|
|
267
335
|
label.querySelector('b').textContent = row.key === '~components' ? 'Components' : row.key
|
|
268
336
|
label.querySelector('span').textContent = row.key === '~components' ? 'the catalog every screen is built from' : approved.length ? `approved: ${approved.join(', ')}` : 'nothing approved yet'
|
|
@@ -270,19 +338,62 @@ function layout() {
|
|
|
270
338
|
label.style.maxWidth = `${groupW}px`
|
|
271
339
|
world.append(label)
|
|
272
340
|
let fx = x, tallest = 0
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
341
|
+
const top = row.key === '~components' ? 0 : VERSION_H
|
|
342
|
+
for (const g of groups) {
|
|
343
|
+
if (top) {
|
|
344
|
+
const v = document.createElement('div')
|
|
345
|
+
v.className = `row-label version-label ${g.status}`
|
|
346
|
+
v.innerHTML = '<b></b><span></span>'
|
|
347
|
+
v.querySelector('b').textContent = g.version
|
|
348
|
+
v.querySelector('span').textContent = `${STATUS_LABEL[g.status]} · ${g.variants.map((s) => s.device).join(', ')}`
|
|
349
|
+
v.style.transform = `translate(${fx}px, ${y + ROW_LABEL_H}px)`
|
|
350
|
+
v.style.width = `${widthOf(g)}px`
|
|
351
|
+
world.append(v)
|
|
352
|
+
}
|
|
353
|
+
for (const s of g.variants) {
|
|
354
|
+
const f = frames.get(s.path)
|
|
355
|
+
f.el.style.left = `${fx}px`
|
|
356
|
+
f.el.style.top = `${y + ROW_LABEL_H + top}px`
|
|
357
|
+
fx += s.width + GAP_X
|
|
358
|
+
tallest = Math.max(tallest, f.height + LABEL_H + top)
|
|
359
|
+
}
|
|
360
|
+
fx += VERSION_GAP - GAP_X
|
|
279
361
|
}
|
|
280
362
|
lineH = Math.max(lineH, ROW_LABEL_H + tallest)
|
|
281
363
|
x += groupW + GROUP_GAP
|
|
282
364
|
}
|
|
283
365
|
tree()
|
|
366
|
+
sendFilter()
|
|
284
367
|
}
|
|
285
368
|
|
|
369
|
+
// ---- reports follow the filters: the open report (consistency, app comparison, languages) gets the files the
|
|
370
|
+
// canvas shows and hides the rest; the catalog follows only the variant filter there, as the reference ----
|
|
371
|
+
function reportPaths() {
|
|
372
|
+
return screens.filter((s) => (s.kind === 'components' ? !filters.devices?.length || filters.devices.includes(s.device) : shown(s))).map((s) => s.path)
|
|
373
|
+
}
|
|
374
|
+
function filterLabel() {
|
|
375
|
+
const q = document.getElementById('search').value.trim()
|
|
376
|
+
return [
|
|
377
|
+
...(filters.versions ?? []),
|
|
378
|
+
...(filters.devices ?? []),
|
|
379
|
+
filters.status === 'approved' ? 'approved' : filters.status === 'review' ? 'needs review' : '',
|
|
380
|
+
q ? `“${q}”` : '',
|
|
381
|
+
].filter(Boolean).join(' · ')
|
|
382
|
+
}
|
|
383
|
+
var sentFilter = ''
|
|
384
|
+
function sendFilter(force) {
|
|
385
|
+
const doc = document.getElementById('doc')
|
|
386
|
+
if (!doc.classList.contains('open') || !['/consistency/index.html', '/compare/index.html', '/languages'].includes(docUrl)) return
|
|
387
|
+
const message = { stitch2Filter: { paths: reportPaths(), label: filterLabel(), theme: themed() ? theme : '' } }
|
|
388
|
+
const key = JSON.stringify(message)
|
|
389
|
+
if (!force && key === sentFilter) return
|
|
390
|
+
sentFilter = key
|
|
391
|
+
doc.querySelector('iframe').contentWindow?.postMessage(message, location.origin)
|
|
392
|
+
}
|
|
393
|
+
// A report asks for the filters as soon as its script runs (its load event waits for every frame inside it).
|
|
394
|
+
addEventListener('message', (e) => { if (e.origin === location.origin && e.data?.stitch2Ready) sendFilter(true) })
|
|
395
|
+
document.querySelector('#doc iframe').addEventListener('load', () => sendFilter(true)) // reports made before stitch2Ready
|
|
396
|
+
|
|
286
397
|
function tree() {
|
|
287
398
|
const nav = document.getElementById('tree')
|
|
288
399
|
nav.innerHTML = ''
|
|
@@ -304,15 +415,30 @@ function tree() {
|
|
|
304
415
|
head('Design system')
|
|
305
416
|
const docOpen = (url) => document.getElementById('doc').classList.contains('open') && docUrl === url
|
|
306
417
|
item('DESIGN.md', 'tokens · rules', docOpen('/design'), () => openDoc('/design'))
|
|
307
|
-
|
|
308
|
-
|
|
418
|
+
// One entry for the catalog: the variant filter picks which ones (mobile, desktop); a click steps through them.
|
|
419
|
+
const catalogs = rows().find((r) => r.key === '~components')?.visible ?? []
|
|
420
|
+
const onCatalog = selected?.screen.kind === 'components'
|
|
421
|
+
item('Components', catalogs.length ? `catalog · ${catalogs.map((s) => s.device).join(', ')}` : 'hidden by the filters', onCatalog, () => {
|
|
422
|
+
if (!catalogs.length) return
|
|
423
|
+
const i = catalogs.findIndex((s) => s.path === selected?.screen.path)
|
|
424
|
+
select(frames.get(catalogs[(i + 1) % catalogs.length].path), true)
|
|
425
|
+
}, '', '', onCatalog ? ref(selected.screen) : catalogs[0] ? ref(catalogs[0]) : '')
|
|
426
|
+
// Each report says when it ran and how many of the files it covers, among those the filters show, changed
|
|
427
|
+
// since (then it's stale: run it again).
|
|
428
|
+
const keep = new Set(reportPaths())
|
|
429
|
+
const report = (label, url, state, command) => {
|
|
430
|
+
const changed = state?.changed.filter((p) => keep.has(p) || p.startsWith('components/')) ?? []
|
|
431
|
+
const n = changed.length
|
|
432
|
+
const meta = !state ? 'not run yet' : n ? `${n} changed since` : ago(state.at)
|
|
433
|
+
const b = item(label, meta, docOpen(url), () => openDoc(url), '', n || !state ? 'stale' : '')
|
|
434
|
+
b.title = !state
|
|
435
|
+
? `Run ${command}`
|
|
436
|
+
: `Made ${new Date(state.at).toLocaleString()}${n ? `. Changed since, run ${command} again:\n${changed.slice(0, 12).join('\n')}${n > 12 ? `\n… and ${n - 12} more` : ''}` : ', up to date'}`
|
|
437
|
+
}
|
|
438
|
+
report('Consistency report', '/consistency/index.html', reports.consistency, 'stitch2 consistency')
|
|
439
|
+
report('App comparison', '/compare/index.html', reports.compare, 'stitch2 compare')
|
|
309
440
|
item('Languages', 'screens × languages', docOpen('/languages'), () => openDoc('/languages'))
|
|
310
441
|
const all = rows()
|
|
311
|
-
const comp = all.find((r) => r.key === '~components')
|
|
312
|
-
if (comp?.visible.length) {
|
|
313
|
-
head('Components')
|
|
314
|
-
for (const s of comp.visible) item(s.title.replace(/^[^—]+ — /, ''), 'catalog', selected?.screen.path === s.path, () => select(frames.get(s.path), true), '', '', ref(s))
|
|
315
|
-
}
|
|
316
442
|
head('Screens')
|
|
317
443
|
for (const row of all) {
|
|
318
444
|
if (row.key === '~components' || !row.visible.length) continue
|
|
@@ -321,11 +447,17 @@ function tree() {
|
|
|
321
447
|
const approved = row.all.some((s) => s.status === 'approved')
|
|
322
448
|
name.innerHTML = `<span class="dot ${approved ? 'approved' : row.all.some((s) => s.status === 'review') ? 'review' : 'draft'}"></span><span></span><span class="meta"></span>`
|
|
323
449
|
name.children[1].textContent = row.key
|
|
324
|
-
|
|
450
|
+
const vs = versionsOf(row.visible)
|
|
451
|
+
name.querySelector('.meta').textContent = `${vs.length} version${vs.length > 1 ? 's' : ''}`
|
|
325
452
|
name.onclick = () => select(frames.get(row.visible[0].path), true)
|
|
326
453
|
name.append(copyButton(row.key, 'Copy the screen name'))
|
|
327
454
|
nav.append(name)
|
|
328
|
-
for (const
|
|
455
|
+
for (const g of vs) {
|
|
456
|
+
const one = g.variants.length === 1
|
|
457
|
+
const first = g.variants[0]
|
|
458
|
+
item(one ? `${g.version} · ${first.device}` : g.version, one ? STATUS_LABEL[first.status] : `${STATUS_LABEL[g.status]} · ${g.variants.length} variants`, one && selected?.screen.path === first.path, () => select(frames.get(first.path), true), g.status, 'version', one ? ref(first) : `${row.key} ${g.version}`)
|
|
459
|
+
if (!one) for (const s of g.variants) item(s.device, STATUS_LABEL[s.status], selected?.screen.path === s.path, () => select(frames.get(s.path), true), s.status, 'variant', ref(s))
|
|
460
|
+
}
|
|
329
461
|
}
|
|
330
462
|
}
|
|
331
463
|
|
|
@@ -354,7 +486,7 @@ function zoomTo(f) {
|
|
|
354
486
|
function measure(f) {
|
|
355
487
|
try {
|
|
356
488
|
const doc = f.iframe.contentDocument
|
|
357
|
-
const min = f.screen
|
|
489
|
+
const min = bigFrame(f.screen) ? 800 : 844
|
|
358
490
|
const h = Math.min(6000, Math.max(min, doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0))
|
|
359
491
|
if (Math.abs(h - f.height) > 2) {
|
|
360
492
|
f.height = h
|
|
@@ -377,7 +509,7 @@ function badge(f) {
|
|
|
377
509
|
function paintLabel(f) {
|
|
378
510
|
const s = f.screen
|
|
379
511
|
f.el.className = `frame ${s.device} ${s.status}${selected === f ? ' selected' : ''}`
|
|
380
|
-
f.el.querySelector('.name').textContent = s.kind === 'components' ? 'Catalog' :
|
|
512
|
+
f.el.querySelector('.name').textContent = s.kind === 'components' ? 'Catalog' : s.device
|
|
381
513
|
const pill = f.el.querySelector('.pill')
|
|
382
514
|
pill.className = `pill ${s.status}`
|
|
383
515
|
pill.textContent = s.kind === 'components' ? 'components' : `${STATUS_LABEL[s.status]} · ${s.source}`
|
|
@@ -402,9 +534,9 @@ function frameFor(s) {
|
|
|
402
534
|
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>`
|
|
403
535
|
const iframe = el.querySelector('iframe')
|
|
404
536
|
iframe.style.width = `${s.width}px`
|
|
405
|
-
iframe.style.height = `${s
|
|
537
|
+
iframe.style.height = `${bigFrame(s) ? 800 : 844}px`
|
|
406
538
|
iframe.src = `/${s.path}?pick${langQuery()}`
|
|
407
|
-
const f = { el, iframe, screen: s, height: s
|
|
539
|
+
const f = { el, iframe, screen: s, height: bigFrame(s) ? 800 : 844 }
|
|
408
540
|
iframe.addEventListener('load', () => { applyTheme(f); measure(f); setTimeout(() => measure(f), 800); setTimeout(() => measure(f), 2500) })
|
|
409
541
|
el.querySelector('.shield').addEventListener('click', () => select(f, false))
|
|
410
542
|
el.querySelector('.shield').addEventListener('dblclick', () => { select(f, false); f.el.classList.add('live') })
|
|
@@ -464,7 +596,16 @@ function inspector(f) {
|
|
|
464
596
|
}
|
|
465
597
|
box.insertBefore(sec, box.querySelector('h4'))
|
|
466
598
|
}
|
|
467
|
-
box.querySelector('.h').textContent = s.kind === 'components' ? 'Component catalog' : `${s.screen} · ${s.version}`
|
|
599
|
+
box.querySelector('.h').textContent = s.kind === 'components' ? 'Component catalog' : `${s.screen} · ${s.version} · ${s.device}`
|
|
600
|
+
const siblings = s.kind === 'screen' ? sortVersions(screens.filter((o) => o.kind === 'screen' && o.screen === s.screen && o.version === s.version)) : []
|
|
601
|
+
if (siblings.length > 1) {
|
|
602
|
+
const vrow = document.createElement('div')
|
|
603
|
+
vrow.className = 'version-actions'
|
|
604
|
+
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>'
|
|
605
|
+
vrow.querySelector('span').textContent = `${s.version}: ${siblings.map((o) => `${o.device} ${STATUS_LABEL[o.status]}`).join(', ')}`
|
|
606
|
+
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 }) }
|
|
607
|
+
box.querySelector('.status').before(vrow)
|
|
608
|
+
}
|
|
468
609
|
box.querySelector('.f').textContent = `${s.device} · ${s.source}`
|
|
469
610
|
const a = box.querySelector('.facts a')
|
|
470
611
|
a.href = `/${s.path}`
|
|
@@ -488,7 +629,11 @@ function inspector(f) {
|
|
|
488
629
|
li.querySelector('.sev').textContent = i.severity
|
|
489
630
|
li.querySelector('.msg').textContent = i.message + (i.text ? ` — “${i.text}”` : '')
|
|
490
631
|
li.querySelector('code').textContent = i.selector
|
|
491
|
-
li.onclick = () =>
|
|
632
|
+
li.onclick = () => {
|
|
633
|
+
const r = rangeOf(s)
|
|
634
|
+
if (i.width && r && i.width !== s.width) { setWidth(i.width === r[0] ? 'narrow' : 'wide'); setTimeout(() => locate(f, i.selector), 900) }
|
|
635
|
+
else locate(f, i.selector)
|
|
636
|
+
}
|
|
492
637
|
ul.append(li)
|
|
493
638
|
}
|
|
494
639
|
box.classList.add('open')
|
|
@@ -610,9 +755,22 @@ async function loadChecks() {
|
|
|
610
755
|
if (selected) inspector(selected)
|
|
611
756
|
summary()
|
|
612
757
|
}
|
|
758
|
+
async function loadReports() {
|
|
759
|
+
reports = await fetch('/api/reports').then((r) => r.json()).catch(() => reports)
|
|
760
|
+
tree()
|
|
761
|
+
}
|
|
762
|
+
setInterval(loadReports, 60_000) // keeps "5 min ago" honest
|
|
763
|
+
function ago(t) {
|
|
764
|
+
const m = Math.round((Date.now() - t) / 60000)
|
|
765
|
+
if (m < 1) return 'just now'
|
|
766
|
+
if (m < 60) return `${m} min ago`
|
|
767
|
+
const h = Math.round(m / 60)
|
|
768
|
+
return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`
|
|
769
|
+
}
|
|
613
770
|
|
|
614
771
|
async function load(first, given) {
|
|
615
772
|
const next = given ?? (await fetch('/api/screens').then((r) => r.json()))
|
|
773
|
+
for (const s of next) { s.drawn = s.width; s.width = widthFor(s) }
|
|
616
774
|
const keep = new Set(next.map((s) => s.path))
|
|
617
775
|
for (const [p, f] of frames) if (!keep.has(p)) { f.el.remove(); frames.delete(p); if (selected === f) selected = null }
|
|
618
776
|
for (const s of next) {
|
|
@@ -620,14 +778,18 @@ async function load(first, given) {
|
|
|
620
778
|
if (f) { f.screen = s; paintLabel(f) } else frameFor(s)
|
|
621
779
|
}
|
|
622
780
|
screens = next
|
|
781
|
+
widthOptions()
|
|
782
|
+
versionFilter()
|
|
783
|
+
variantFilter()
|
|
623
784
|
layout()
|
|
624
785
|
await loadChecks()
|
|
786
|
+
await loadReports()
|
|
625
787
|
if (selected) inspector(selected)
|
|
626
788
|
if (first && !localStorage.getItem('stitch2-view')) setTimeout(fit, 1500)
|
|
627
789
|
}
|
|
628
790
|
|
|
629
791
|
// ---- filters ----
|
|
630
|
-
for (const [id, key] of [['statusFilter', 'status']
|
|
792
|
+
for (const [id, key] of [['statusFilter', 'status']]) {
|
|
631
793
|
const seg = document.getElementById(id)
|
|
632
794
|
for (const b of seg.querySelectorAll('button')) {
|
|
633
795
|
b.classList.toggle('on', b.dataset.v === filters[key])
|
|
@@ -641,6 +803,57 @@ for (const [id, key] of [['statusFilter', 'status'], ['deviceFilter', 'device']]
|
|
|
641
803
|
}
|
|
642
804
|
document.getElementById('search').oninput = layout
|
|
643
805
|
|
|
806
|
+
// Versions and variants: multi-selects of every label the screens use (v1 … v12 and imports such as "Stitch";
|
|
807
|
+
// mobile, tablet, desktop), with how many screens carry each; none ticked means all. The catalog follows the variant filter only.
|
|
808
|
+
const MULTI = []
|
|
809
|
+
for (const id of ['versionFilter', 'variantFilter']) {
|
|
810
|
+
const box = document.getElementById(id)
|
|
811
|
+
MULTI.push(box)
|
|
812
|
+
}
|
|
813
|
+
document.addEventListener('click', (e) => { for (const box of MULTI) if (box.open && !box.contains(e.target)) box.open = false })
|
|
814
|
+
document.addEventListener('keydown', (e) => { const open = MULTI.find((b) => b.open); if (e.key === 'Escape' && open) { open.open = false; e.stopPropagation() } }, true)
|
|
815
|
+
function multiFilter({ id, key, of, sort, all, many, again }) {
|
|
816
|
+
const box = document.getElementById(id)
|
|
817
|
+
const counts = new Map()
|
|
818
|
+
for (const s of screens) if (s.kind === 'screen') counts.set(of(s), (counts.get(of(s)) ?? 0) + 1)
|
|
819
|
+
const labels = [...counts.keys()].sort(sort)
|
|
820
|
+
filters[key] = (filters[key] ?? []).filter((v) => labels.includes(v))
|
|
821
|
+
box.hidden = labels.length < 2
|
|
822
|
+
const picked = filters[key]
|
|
823
|
+
box.querySelector('summary').textContent = !picked.length ? all : picked.length <= 3 ? [...picked].sort(sort).join(', ') : `${picked.length} ${many}`
|
|
824
|
+
const change = (next) => {
|
|
825
|
+
filters[key] = next
|
|
826
|
+
localStorage.setItem('stitch2-filters', JSON.stringify(filters))
|
|
827
|
+
again()
|
|
828
|
+
layout()
|
|
829
|
+
}
|
|
830
|
+
const option = (label, checked, n, onChange, cls = '') => {
|
|
831
|
+
const l = document.createElement('label')
|
|
832
|
+
l.className = cls
|
|
833
|
+
const input = document.createElement('input')
|
|
834
|
+
input.type = 'checkbox'
|
|
835
|
+
input.checked = checked
|
|
836
|
+
input.onchange = onChange
|
|
837
|
+
const name = document.createElement('span')
|
|
838
|
+
name.textContent = label
|
|
839
|
+
const count = document.createElement('span')
|
|
840
|
+
count.className = 'n'
|
|
841
|
+
count.textContent = n
|
|
842
|
+
l.append(input, name, count)
|
|
843
|
+
return l
|
|
844
|
+
}
|
|
845
|
+
box.querySelector('.menu').replaceChildren(
|
|
846
|
+
option(all, !picked.length, screens.filter((s) => s.kind === 'screen').length, () => change([]), 'all'),
|
|
847
|
+
...labels.map((v) => option(v, picked.includes(v), counts.get(v), () => change(picked.includes(v) ? picked.filter((x) => x !== v) : [...picked, v]))),
|
|
848
|
+
)
|
|
849
|
+
}
|
|
850
|
+
function versionFilter() {
|
|
851
|
+
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 })
|
|
852
|
+
}
|
|
853
|
+
function variantFilter() {
|
|
854
|
+
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 })
|
|
855
|
+
}
|
|
856
|
+
|
|
644
857
|
// ---- live reload ----
|
|
645
858
|
const events = new EventSource('/events')
|
|
646
859
|
events.onmessage = async (e) => {
|
|
@@ -648,6 +861,7 @@ events.onmessage = async (e) => {
|
|
|
648
861
|
if (paths.some((p) => p === 'checks.json')) await loadChecks()
|
|
649
862
|
const html = paths.filter((p) => p.endsWith('.html'))
|
|
650
863
|
if (html.length) await load(false)
|
|
864
|
+
else if (paths.some((p) => p.startsWith('components/') || p === 'compare.json')) await loadReports()
|
|
651
865
|
if (paths.includes('DESIGN.md') && document.getElementById('doc').classList.contains('open')) document.querySelector('#doc iframe').contentWindow.location.reload()
|
|
652
866
|
// Stylesheets and scripts are shared: tokens.css, tailwind.tokens.js, token exports the screens link
|
|
653
867
|
// (tokens.tailwind.css), the components.
|
|
@@ -709,6 +923,7 @@ addEventListener('keydown', (e) => {
|
|
|
709
923
|
if (e.key === 'f' || e.key === 'F') fit()
|
|
710
924
|
if ((e.key === 't' || e.key === 'T') && themed()) setTheme(THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length])
|
|
711
925
|
if (e.key === 'l' || e.key === 'L') setLang(LANGS[(LANGS.indexOf(lang) + 1) % LANGS.length])
|
|
926
|
+
if (e.key === 'w' || e.key === 'W') setWidth(['drawn', 'narrow', 'wide'][(['drawn', 'narrow', 'wide'].indexOf(widthMode) + 1) % 3])
|
|
712
927
|
if (e.key === '0') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, 1)
|
|
713
928
|
if (e.key === '+' || e.key === '=') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z * 1.2)
|
|
714
929
|
if (e.key === '-') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z / 1.2)
|
|
@@ -734,11 +949,14 @@ fetch('/api/config').then((r) => r.json()).then((c) => {
|
|
|
734
949
|
document.getElementById('labName').textContent = c.name
|
|
735
950
|
THEMES = c.themes?.length ? c.themes : ['dark']
|
|
736
951
|
if (!THEMES.includes(theme)) theme = THEMES[0]
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
952
|
+
const pick = document.getElementById('themeFilter')
|
|
953
|
+
pick.hidden = !themed()
|
|
954
|
+
pick.innerHTML = THEMES.map((t) => `<option value="${t}">${t[0].toUpperCase()}${t.slice(1)} theme</option>`).join('')
|
|
955
|
+
pick.onchange = () => setTheme(pick.value)
|
|
741
956
|
setTheme(theme)
|
|
957
|
+
WIDTHS = c.widths ?? {}
|
|
958
|
+
const wsel = document.getElementById('widthFilter')
|
|
959
|
+
wsel.onchange = () => setWidth(wsel.value)
|
|
742
960
|
SOURCE = c.sourceLocale ?? 'en'
|
|
743
961
|
LANGS = c.locales?.length ? c.locales : [SOURCE]
|
|
744
962
|
if (!LANGS.includes(lang)) lang = SOURCE
|