htmlboard 0.2.0 → 0.4.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 +83 -69
- package/bin/htmlboard.js +83 -46
- package/package.json +1 -1
- package/skills/htmlboard/SKILL.md +39 -34
- package/src/client/explorer.css +69 -34
- package/src/client/explorer.js +148 -25
- package/src/client/index.html +2 -0
- package/src/designs.js +398 -0
- package/src/index.js +7 -2
- package/src/migrate.js +193 -0
- package/src/server.js +1 -198
- package/src/templates/{artboards/desktop.html → 01-screens/01-desktop.html} +51 -167
- package/src/templates/{artboards/mobile.html → 01-screens/02-mobile.html} +48 -143
- package/src/templates/02-components/01-ui-kit.html +77 -0
- package/src/templates/styles.css +133 -0
- package/src/templates/artboards/components.html +0 -199
- package/src/templates/designs.json +0 -50
package/src/client/explorer.css
CHANGED
|
@@ -16,17 +16,26 @@
|
|
|
16
16
|
src: url(fonts/bricolage-grotesque.woff2) format('woff2');
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/* One cool family of greys at the accent's hue (oklch 258, barely tinted), so the chrome sits with
|
|
20
|
+
the logo's blue and keeps out of the designs' way. Text colours pass WCAG AA on every surface. */
|
|
19
21
|
:root {
|
|
20
|
-
--x-canvas: #
|
|
21
|
-
--x-
|
|
22
|
-
--x-
|
|
23
|
-
--x-
|
|
24
|
-
--x-line: #
|
|
25
|
-
--x-text: #
|
|
26
|
-
--x-text-soft: #
|
|
27
|
-
--x-
|
|
28
|
-
--x-
|
|
22
|
+
--x-canvas: #15171B; /* oklch(0.205 0.008 258) */
|
|
23
|
+
--x-panel: #1E2126; /* oklch(0.248 0.009 258) */
|
|
24
|
+
--x-raised: #282C30; /* oklch(0.29 0.01 258) */
|
|
25
|
+
--x-line: #36393F; /* oklch(0.345 0.011 258) */
|
|
26
|
+
--x-line-soft: #2B2E33; /* oklch(0.3 0.01 258): dividers inside a panel */
|
|
27
|
+
--x-text: #EDEEF1; /* 13.9:1 on the panel */
|
|
28
|
+
--x-text-soft: #A2A8B1; /* 6.7:1 */
|
|
29
|
+
--x-text-faint: #878D95; /* 4.8:1 */
|
|
30
|
+
--x-accent: #3D9DFF; /* the logo's pegs */
|
|
31
|
+
--x-accent-text: #8BC2FF; /* the accent as text: 8.7:1 */
|
|
32
|
+
--x-accent-wash: rgb(61 157 255 / 0.16);
|
|
33
|
+
--x-on-accent: #15171B; /* text on the accent: 6.4:1 (white would be 2.8:1) */
|
|
34
|
+
--x-wash: rgb(255 255 255 / 0.06);
|
|
35
|
+
--x-dot: rgb(255 255 255 / 0.1);
|
|
29
36
|
--x-live: #3DD68C;
|
|
37
|
+
--x-glass: color-mix(in srgb, var(--x-raised) 84%, transparent);
|
|
38
|
+
--x-float: inset 0 1px 0 rgb(255 255 255 / 0.05), 0 10px 32px rgb(0 0 0 / 0.5);
|
|
30
39
|
--font-ui: 'Inter', system-ui, sans-serif;
|
|
31
40
|
--font-display: 'Bricolage Grotesque', 'Inter', system-ui, sans-serif;
|
|
32
41
|
color-scheme: dark;
|
|
@@ -69,21 +78,22 @@ kbd {
|
|
|
69
78
|
flex-direction: column;
|
|
70
79
|
flex: none;
|
|
71
80
|
width: 272px;
|
|
72
|
-
border-right: 1px solid var(--x-line);
|
|
81
|
+
border-right: 1px solid var(--x-line-soft);
|
|
73
82
|
background: var(--x-panel);
|
|
74
83
|
}
|
|
75
84
|
|
|
76
85
|
.app.is-collapsed .sidebar { display: none; }
|
|
77
86
|
|
|
78
87
|
/* htmlboard's own row: the logo (a link to the project) and the version */
|
|
79
|
-
.brand { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 14px 16px 12px; border-bottom: 1px solid var(--x-line); }
|
|
88
|
+
.brand { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 14px 16px 12px; border-bottom: 1px solid var(--x-line-soft); }
|
|
80
89
|
.brand-logo { display: block; border-radius: 4px; }
|
|
81
90
|
.brand-logo img { display: block; width: auto; height: 18px; }
|
|
82
|
-
.version { font-size: 11px; color: var(--x-text-
|
|
91
|
+
.version { font-size: 11px; color: var(--x-text-faint); font-variant-numeric: tabular-nums; }
|
|
83
92
|
|
|
84
|
-
|
|
85
|
-
.sidebar-head
|
|
86
|
-
.
|
|
93
|
+
/* The project's title, the count running on after its last word */
|
|
94
|
+
.sidebar-head { padding: 16px 16px 10px; }
|
|
95
|
+
.sidebar-head h1 { display: inline; margin: 0; font: 800 24px/1.1 var(--font-display); overflow-wrap: anywhere; }
|
|
96
|
+
.count { margin-left: 8px; font-size: 13px; color: var(--x-text-soft); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
87
97
|
|
|
88
98
|
.search { position: relative; margin: 0 12px 4px; }
|
|
89
99
|
|
|
@@ -94,7 +104,7 @@ kbd {
|
|
|
94
104
|
width: 15px;
|
|
95
105
|
height: 15px;
|
|
96
106
|
fill: none;
|
|
97
|
-
stroke: var(--x-text-
|
|
107
|
+
stroke: var(--x-text-faint);
|
|
98
108
|
stroke-width: 2;
|
|
99
109
|
stroke-linecap: round;
|
|
100
110
|
transform: translateY(-50%);
|
|
@@ -105,7 +115,7 @@ kbd {
|
|
|
105
115
|
width: 100%;
|
|
106
116
|
height: 32px;
|
|
107
117
|
padding: 0 30px;
|
|
108
|
-
border: 1px solid var(--x-line);
|
|
118
|
+
border: 1px solid var(--x-line-soft);
|
|
109
119
|
border-radius: 6px;
|
|
110
120
|
background: var(--x-canvas);
|
|
111
121
|
color: var(--x-text);
|
|
@@ -113,8 +123,8 @@ kbd {
|
|
|
113
123
|
font-size: 13px;
|
|
114
124
|
}
|
|
115
125
|
|
|
116
|
-
.search input::placeholder { color: var(--x-text-
|
|
117
|
-
.search input:focus { border-color: var(--x-accent); outline: none; }
|
|
126
|
+
.search input::placeholder { color: var(--x-text-faint); opacity: 1; }
|
|
127
|
+
.search input:focus { border-color: var(--x-accent); outline: none; box-shadow: 0 0 0 3px var(--x-accent-wash); }
|
|
118
128
|
.search input::-webkit-search-cancel-button { appearance: none; }
|
|
119
129
|
|
|
120
130
|
.search kbd {
|
|
@@ -122,14 +132,33 @@ kbd {
|
|
|
122
132
|
right: 7px;
|
|
123
133
|
top: 50%;
|
|
124
134
|
font-size: 11px;
|
|
125
|
-
color: var(--x-text-
|
|
135
|
+
color: var(--x-text-faint);
|
|
126
136
|
transform: translateY(-50%);
|
|
127
137
|
pointer-events: none;
|
|
128
138
|
}
|
|
129
139
|
|
|
140
|
+
/* The filters: all, the devices (and any other category), as chips that wrap */
|
|
141
|
+
.filters { display: flex; flex-wrap: wrap; gap: 4px; margin: 8px 12px 0; }
|
|
142
|
+
|
|
143
|
+
.filter {
|
|
144
|
+
height: 26px;
|
|
145
|
+
padding: 0 10px;
|
|
146
|
+
border: 1px solid var(--x-line-soft);
|
|
147
|
+
border-radius: 13px;
|
|
148
|
+
background: none;
|
|
149
|
+
color: var(--x-text-soft);
|
|
150
|
+
font: inherit;
|
|
151
|
+
font-size: 12px;
|
|
152
|
+
cursor: pointer;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.filter:hover:not(:disabled) { background: var(--x-wash); color: var(--x-text); }
|
|
156
|
+
.filter[aria-pressed='true'] { border-color: transparent; background: var(--x-accent-wash); color: var(--x-accent-text); }
|
|
157
|
+
.filter:disabled { color: var(--x-text-faint); border-style: dashed; cursor: default; }
|
|
158
|
+
|
|
130
159
|
.search input:focus ~ kbd, .search input:not(:placeholder-shown) ~ kbd { display: none; }
|
|
131
160
|
|
|
132
|
-
.list { flex: 1; overflow-y: auto; padding: 0 8px 16px; }
|
|
161
|
+
.list { flex: 1; overflow-y: auto; padding: 0 8px 16px; scrollbar-width: thin; scrollbar-color: var(--x-line) transparent; }
|
|
133
162
|
|
|
134
163
|
.list h2 {
|
|
135
164
|
margin: 18px 8px 4px;
|
|
@@ -143,6 +172,7 @@ kbd {
|
|
|
143
172
|
.list .section-desc { margin: -2px 8px 6px; font-size: 12px; line-height: 1.45; color: var(--x-text-soft); }
|
|
144
173
|
|
|
145
174
|
.list ul { margin: 0; padding: 0; list-style: none; }
|
|
175
|
+
.list section > ul:first-child { margin-top: 12px; } /* a section without a title (the files at the top) */
|
|
146
176
|
|
|
147
177
|
.item {
|
|
148
178
|
display: flex;
|
|
@@ -163,15 +193,16 @@ kbd {
|
|
|
163
193
|
}
|
|
164
194
|
|
|
165
195
|
.item:hover { background: var(--x-wash); }
|
|
166
|
-
.item[aria-current='true'] { background:
|
|
167
|
-
.item .dims { flex: none; font-size: 12px; color: var(--x-text-
|
|
196
|
+
.item[aria-current='true'] { background: var(--x-accent-wash); color: var(--x-text); }
|
|
197
|
+
.item .dims { flex: none; font-size: 12px; color: var(--x-text-faint); font-variant-numeric: tabular-nums; }
|
|
198
|
+
.item[aria-current='true'] .dims { color: var(--x-accent-text); }
|
|
168
199
|
|
|
169
200
|
.no-match { margin: 0; padding: 12px 16px 0; font-size: 13px; color: var(--x-text-soft); }
|
|
170
201
|
|
|
171
202
|
.sidebar-hint {
|
|
172
203
|
margin: 0;
|
|
173
204
|
padding: 12px 16px 16px;
|
|
174
|
-
border-top: 1px solid var(--x-line);
|
|
205
|
+
border-top: 1px solid var(--x-line-soft);
|
|
175
206
|
font-size: 12px;
|
|
176
207
|
line-height: 1.6;
|
|
177
208
|
color: var(--x-text-soft);
|
|
@@ -198,7 +229,7 @@ kbd {
|
|
|
198
229
|
.frame {
|
|
199
230
|
position: absolute;
|
|
200
231
|
background: #FFFFFF;
|
|
201
|
-
box-shadow: 0 1px 3px rgb(0 0 0 / 0.
|
|
232
|
+
box-shadow: 0 1px 3px rgb(0 0 0 / 0.5), 0 12px 40px rgb(0 0 0 / 0.4);
|
|
202
233
|
}
|
|
203
234
|
|
|
204
235
|
/* The canvas takes every pointer event (wheel included, so Ctrl + scroll never zooms the page):
|
|
@@ -235,7 +266,7 @@ kbd {
|
|
|
235
266
|
padding: 2px 6px;
|
|
236
267
|
border-radius: 4px;
|
|
237
268
|
background: var(--x-accent);
|
|
238
|
-
color:
|
|
269
|
+
color: var(--x-on-accent);
|
|
239
270
|
font-size: 11px;
|
|
240
271
|
font-weight: 600;
|
|
241
272
|
line-height: 14px;
|
|
@@ -255,8 +286,9 @@ kbd {
|
|
|
255
286
|
padding: 4px;
|
|
256
287
|
border: 1px solid var(--x-line);
|
|
257
288
|
border-radius: 10px;
|
|
258
|
-
background: var(--x-
|
|
259
|
-
|
|
289
|
+
background: var(--x-glass);
|
|
290
|
+
backdrop-filter: blur(16px) saturate(1.4);
|
|
291
|
+
box-shadow: var(--x-float);
|
|
260
292
|
transform: translateX(-50%);
|
|
261
293
|
}
|
|
262
294
|
|
|
@@ -279,7 +311,7 @@ kbd {
|
|
|
279
311
|
}
|
|
280
312
|
|
|
281
313
|
.tool:hover { background: var(--x-wash); }
|
|
282
|
-
.tool[aria-pressed='true'] { color: var(--x-accent); }
|
|
314
|
+
.tool[aria-pressed='true'] { color: var(--x-accent-text); }
|
|
283
315
|
.tool[aria-disabled='true'] { opacity: 0.4; pointer-events: none; }
|
|
284
316
|
|
|
285
317
|
.tool svg {
|
|
@@ -298,7 +330,7 @@ kbd {
|
|
|
298
330
|
|
|
299
331
|
.live { display: inline-flex; align-items: center; gap: 6px; padding: 0 8px; font-size: 12px; color: var(--x-text-soft); }
|
|
300
332
|
.live::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--x-live); }
|
|
301
|
-
.live.is-off::before { background: var(--x-text-
|
|
333
|
+
.live.is-off::before { background: var(--x-text-faint); }
|
|
302
334
|
|
|
303
335
|
.help {
|
|
304
336
|
position: absolute;
|
|
@@ -308,14 +340,15 @@ kbd {
|
|
|
308
340
|
padding: 14px 16px 10px;
|
|
309
341
|
border: 1px solid var(--x-line);
|
|
310
342
|
border-radius: 10px;
|
|
311
|
-
background: var(--x-
|
|
312
|
-
|
|
343
|
+
background: var(--x-glass);
|
|
344
|
+
backdrop-filter: blur(16px) saturate(1.4);
|
|
345
|
+
box-shadow: var(--x-float);
|
|
313
346
|
font-size: 13px;
|
|
314
347
|
}
|
|
315
348
|
|
|
316
349
|
.help h2 { margin: 0 0 8px; font: 700 18px/1.2 var(--font-display); }
|
|
317
350
|
.help dl { margin: 0; }
|
|
318
|
-
.help dl > div { display: flex; justify-content: space-between; gap: 12px; padding: 5px 0; border-top: 1px solid var(--x-line); }
|
|
351
|
+
.help dl > div { display: flex; justify-content: space-between; gap: 12px; padding: 5px 0; border-top: 1px solid var(--x-line-soft); }
|
|
319
352
|
.help dt { color: var(--x-text-soft); }
|
|
320
353
|
.help dd { margin: 0; text-align: right; }
|
|
321
354
|
|
|
@@ -327,7 +360,9 @@ kbd {
|
|
|
327
360
|
padding: 8px 14px;
|
|
328
361
|
border: 1px solid var(--x-line);
|
|
329
362
|
border-radius: 8px;
|
|
330
|
-
background: var(--x-
|
|
363
|
+
background: var(--x-glass);
|
|
364
|
+
backdrop-filter: blur(16px) saturate(1.4);
|
|
365
|
+
box-shadow: var(--x-float);
|
|
331
366
|
font-size: 13px;
|
|
332
367
|
white-space: nowrap;
|
|
333
368
|
overflow: hidden;
|
package/src/client/explorer.js
CHANGED
|
@@ -23,7 +23,7 @@ const DEFAULT_SIZE = [1200, 800]; // a design whose entry gives no size; without
|
|
|
23
23
|
const $ = (id) => document.getElementById(id);
|
|
24
24
|
const els = {
|
|
25
25
|
app: $('app'), title: $('title'), list: $('list'), count: $('count'), search: $('search'),
|
|
26
|
-
noMatch: $('no-match'), viewport: $('viewport'), world: $('world'), overlay: $('overlay'),
|
|
26
|
+
noMatch: $('no-match'), filters: $('filters'), viewport: $('viewport'), world: $('world'), overlay: $('overlay'),
|
|
27
27
|
hover: $('hover'), selection: $('selection'), size: $('size'), zoom: $('zoom-reset'), open: $('open'),
|
|
28
28
|
live: $('live'), help: $('help'), helpToggle: $('help-toggle'), toggleList: $('toggle-list'),
|
|
29
29
|
toast: $('toast'), notice: $('notice'), noticeTitle: $('notice-title'), noticeText: $('notice-text'),
|
|
@@ -39,6 +39,7 @@ const state = {
|
|
|
39
39
|
selected: null,
|
|
40
40
|
hover: null,
|
|
41
41
|
query: [], // the search's words, folded
|
|
42
|
+
category: '', // the filter: a category, or '' for all
|
|
42
43
|
// What the first view fitted ('all' or a design). Until the user moves the canvas, it is fitted
|
|
43
44
|
// again when the designs' real sizes come in.
|
|
44
45
|
autoFit: null,
|
|
@@ -103,7 +104,8 @@ function build(manifest) {
|
|
|
103
104
|
const section = { name, designs: [], el: el('div', 'section-title', name), group: el('section') };
|
|
104
105
|
els.overlay.insertBefore(section.el, els.hover);
|
|
105
106
|
const items = el('ul');
|
|
106
|
-
section
|
|
107
|
+
// The files at the top of the folder make a first section without a title
|
|
108
|
+
if (name) section.group.append(el('h2', null, name));
|
|
107
109
|
if (entry.description) section.group.append(el('p', 'section-desc', entry.description));
|
|
108
110
|
section.group.append(items);
|
|
109
111
|
for (const board of entry.artboards || []) {
|
|
@@ -125,6 +127,7 @@ function build(manifest) {
|
|
|
125
127
|
} else {
|
|
126
128
|
els.notice.hidden = true;
|
|
127
129
|
}
|
|
130
|
+
buildFilters();
|
|
128
131
|
applySearch();
|
|
129
132
|
layout();
|
|
130
133
|
}
|
|
@@ -133,6 +136,7 @@ function makeDesign(board, section) {
|
|
|
133
136
|
const file = String(board.file);
|
|
134
137
|
const design = {
|
|
135
138
|
id: String(board.id || file),
|
|
139
|
+
category: board.category || '',
|
|
136
140
|
file,
|
|
137
141
|
path: `/${file.split('?')[0]}`,
|
|
138
142
|
title: board.title || file,
|
|
@@ -194,15 +198,60 @@ function growToPage(design) {
|
|
|
194
198
|
layout();
|
|
195
199
|
}
|
|
196
200
|
|
|
197
|
-
// ---------------------------------------------------------------- searching
|
|
201
|
+
// ---------------------------------------------------------------- filtering and searching
|
|
202
|
+
|
|
203
|
+
// The device filters come together, so a project without tablets shows that it has none; then any
|
|
204
|
+
// other category the designs carry (components, or whatever a designs.json names).
|
|
205
|
+
const DEVICES = ['mobile', 'tablet', 'desktop'];
|
|
206
|
+
const LABELS = { '': 'All', mobile: 'Mobile', tablet: 'Tablet', desktop: 'Desktop', components: 'Components' };
|
|
207
|
+
|
|
208
|
+
// The filter lays out only its designs; the search fades the others where they are.
|
|
209
|
+
function inFilter(design) {
|
|
210
|
+
return !state.category || design.category === state.category;
|
|
211
|
+
}
|
|
198
212
|
|
|
199
213
|
function matches(design) {
|
|
200
214
|
return state.query.every((word) => design.search.includes(word));
|
|
201
215
|
}
|
|
202
216
|
|
|
203
|
-
// The designs the
|
|
217
|
+
// The designs the filter and the search keep.
|
|
204
218
|
function shown() {
|
|
205
|
-
return state.
|
|
219
|
+
return state.designs.filter((d) => inFilter(d) && matches(d));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function buildFilters() {
|
|
223
|
+
const counts = new Map();
|
|
224
|
+
for (const d of state.designs) if (d.category) counts.set(d.category, (counts.get(d.category) || 0) + 1);
|
|
225
|
+
const others = [...counts.keys()].filter((c) => !DEVICES.includes(c))
|
|
226
|
+
.sort((a, b) => (a === 'components' ? -1 : b === 'components' ? 1 : a.localeCompare(b)));
|
|
227
|
+
const categories = ['', ...(DEVICES.some((c) => counts.has(c)) ? DEVICES : []), ...others];
|
|
228
|
+
if (state.category && !counts.has(state.category)) state.category = '';
|
|
229
|
+
els.filters.replaceChildren(...categories.map((category, i) => {
|
|
230
|
+
const count = category ? counts.get(category) || 0 : state.designs.length;
|
|
231
|
+
const label = LABELS[category] ?? category.charAt(0).toUpperCase() + category.slice(1);
|
|
232
|
+
const button = el('button', 'filter', label);
|
|
233
|
+
button.type = 'button';
|
|
234
|
+
button.dataset.category = category;
|
|
235
|
+
button.disabled = !count;
|
|
236
|
+
button.title = `${count} design${count === 1 ? '' : 's'}${i < 9 ? ` (${i + 1})` : ''}`;
|
|
237
|
+
button.setAttribute('aria-pressed', String(category === state.category));
|
|
238
|
+
button.addEventListener('click', () => setCategory(category));
|
|
239
|
+
return button;
|
|
240
|
+
}));
|
|
241
|
+
// Nothing to tell apart: no categories, or one that every design shares
|
|
242
|
+
els.filters.hidden = !counts.size || (counts.size === 1 && counts.values().next().value === state.designs.length);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function setCategory(category, { fitAll = true } = {}) {
|
|
246
|
+
if (category === state.category) return;
|
|
247
|
+
state.category = category;
|
|
248
|
+
for (const button of els.filters.children) button.setAttribute('aria-pressed', String(button.dataset.category === category));
|
|
249
|
+
if (state.selected && !inFilter(state.selected)) select(null);
|
|
250
|
+
if (state.hover && !inFilter(state.hover)) state.hover = null;
|
|
251
|
+
state.autoFit = null;
|
|
252
|
+
applySearch();
|
|
253
|
+
layout();
|
|
254
|
+
if (fitAll) fit('all');
|
|
206
255
|
}
|
|
207
256
|
|
|
208
257
|
function applySearch() {
|
|
@@ -210,10 +259,11 @@ function applySearch() {
|
|
|
210
259
|
for (const section of state.sections) {
|
|
211
260
|
let any = false;
|
|
212
261
|
for (const design of section.designs) {
|
|
213
|
-
const
|
|
262
|
+
const kept = inFilter(design);
|
|
263
|
+
const on = kept && matches(design);
|
|
214
264
|
design.li.hidden = !on;
|
|
215
|
-
design.frame.classList.toggle('is-dimmed', !on);
|
|
216
|
-
design.label.classList.toggle('is-dimmed', !on);
|
|
265
|
+
design.frame.classList.toggle('is-dimmed', kept && !on);
|
|
266
|
+
design.label.classList.toggle('is-dimmed', kept && !on);
|
|
217
267
|
if (on) {
|
|
218
268
|
any = true;
|
|
219
269
|
count++;
|
|
@@ -222,8 +272,9 @@ function applySearch() {
|
|
|
222
272
|
section.group.hidden = !any;
|
|
223
273
|
}
|
|
224
274
|
const total = state.designs.length;
|
|
225
|
-
|
|
226
|
-
els.
|
|
275
|
+
const narrowed = state.query.length || state.category;
|
|
276
|
+
els.count.textContent = narrowed ? `${count} of ${total}` : String(total);
|
|
277
|
+
els.noMatch.hidden = !narrowed || count > 0;
|
|
227
278
|
}
|
|
228
279
|
|
|
229
280
|
function search(text) {
|
|
@@ -240,14 +291,17 @@ function layout() {
|
|
|
240
291
|
let x = 0;
|
|
241
292
|
let height = 0;
|
|
242
293
|
for (const design of section.designs) {
|
|
294
|
+
design.frame.hidden = !inFilter(design);
|
|
295
|
+
if (design.frame.hidden) continue;
|
|
243
296
|
design.x = x;
|
|
244
297
|
design.y = y;
|
|
245
298
|
Object.assign(design.frame.style, { left: `${x}px`, top: `${y}px`, width: `${design.w}px`, height: `${design.h}px` });
|
|
246
299
|
x += design.w + FRAME_GAP;
|
|
247
300
|
height = Math.max(height, design.h);
|
|
248
301
|
}
|
|
249
|
-
|
|
250
|
-
y
|
|
302
|
+
// A section the filter empties takes no room
|
|
303
|
+
Object.assign(section, { x: 0, y, w: Math.max(0, x - FRAME_GAP), h: height, shown: x > 0 });
|
|
304
|
+
if (section.shown) y += height + SECTION_GAP;
|
|
251
305
|
}
|
|
252
306
|
if (state.autoFit) fit(state.autoFit, { animate: false });
|
|
253
307
|
else paint();
|
|
@@ -264,19 +318,30 @@ function paint() {
|
|
|
264
318
|
els.viewport.style.backgroundPosition = `${x}px ${y}px`;
|
|
265
319
|
// Above each row: the section's title, then the designs' names. Zoomed far out the gap between
|
|
266
320
|
// rows gets thin: the names give way first, then the title.
|
|
267
|
-
|
|
268
|
-
|
|
321
|
+
let first = true;
|
|
322
|
+
for (const section of state.sections) {
|
|
323
|
+
if (!section.shown) {
|
|
324
|
+
section.el.hidden = true;
|
|
325
|
+
for (const design of section.designs) design.label.hidden = true;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const room = first ? Infinity : SECTION_GAP * z;
|
|
329
|
+
first = false;
|
|
269
330
|
const named = room >= TITLE_SPACE + 2;
|
|
270
331
|
const top = Math.round(y + section.y * z);
|
|
271
|
-
section.el.hidden =
|
|
332
|
+
section.el.hidden = room < 30 || !section.name;
|
|
272
333
|
section.el.style.transform = `translate(${Math.round(x + section.x * z)}px, ${top - (named ? 54 : 30)}px)`;
|
|
273
334
|
for (const design of section.designs) {
|
|
335
|
+
if (design.frame.hidden) {
|
|
336
|
+
design.label.hidden = true;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
274
339
|
const width = design.w * z;
|
|
275
340
|
design.label.hidden = !named || width < MIN_NAMED;
|
|
276
341
|
design.label.style.width = `${Math.round(width)}px`;
|
|
277
342
|
design.label.style.transform = `translate(${Math.round(x + design.x * z)}px, ${top - 22}px)`;
|
|
278
343
|
}
|
|
279
|
-
}
|
|
344
|
+
}
|
|
280
345
|
els.zoom.textContent = `${Math.round(z * 100)}%`;
|
|
281
346
|
paintMarks();
|
|
282
347
|
saveView();
|
|
@@ -351,7 +416,7 @@ function viewFor(rect, top) {
|
|
|
351
416
|
function fit(target, { animate = true } = {}) {
|
|
352
417
|
if (target === 'all') {
|
|
353
418
|
const designs = shown();
|
|
354
|
-
moveTo(viewFor(boundsOf(designs.length ? designs : state.designs), TITLE_SPACE), animate);
|
|
419
|
+
moveTo(viewFor(boundsOf(designs.length ? designs : state.designs.filter(inFilter)), TITLE_SPACE), animate);
|
|
355
420
|
} else {
|
|
356
421
|
moveTo(viewFor(target, NAME_SPACE), animate);
|
|
357
422
|
}
|
|
@@ -459,6 +524,7 @@ function hit(point) {
|
|
|
459
524
|
const cy = (point.y - y) / z;
|
|
460
525
|
for (let i = state.designs.length - 1; i >= 0; i--) {
|
|
461
526
|
const d = state.designs[i];
|
|
527
|
+
if (d.frame.hidden) continue;
|
|
462
528
|
const top = d.y - (d.label.hidden ? 0 : NAME_SPACE / z);
|
|
463
529
|
if (cx >= d.x && cx <= d.x + d.w && cy >= top && cy <= d.y + d.h) return d;
|
|
464
530
|
}
|
|
@@ -552,6 +618,47 @@ function changesBetween(before, after) {
|
|
|
552
618
|
return 'Canvas updated';
|
|
553
619
|
}
|
|
554
620
|
|
|
621
|
+
// The same designs in the same sections and order: what changed is names, sizes or categories.
|
|
622
|
+
function sameShape(manifest) {
|
|
623
|
+
const sections = manifest.sections || [];
|
|
624
|
+
if (sections.length !== state.sections.length) return false;
|
|
625
|
+
let i = 0;
|
|
626
|
+
return sections.every((entry, s) => (entry.name || '') === state.sections[s].name
|
|
627
|
+
&& (entry.artboards || []).length === state.sections[s].designs.length
|
|
628
|
+
&& entry.artboards.every((board) => {
|
|
629
|
+
const design = state.designs[i++];
|
|
630
|
+
return design && design.id === String(board.id || board.file) && design.file === String(board.file);
|
|
631
|
+
}));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Names, sizes, categories and descriptions changed in place: no frame reloads.
|
|
635
|
+
function patch(manifest) {
|
|
636
|
+
const boards = manifest.sections.flatMap((section) => section.artboards.map((board) => [board, section]));
|
|
637
|
+
boards.forEach(([board, section], i) => {
|
|
638
|
+
const design = state.designs[i];
|
|
639
|
+
design.title = board.title || design.file;
|
|
640
|
+
design.label.textContent = design.title;
|
|
641
|
+
design.iframe.title = design.title;
|
|
642
|
+
design.item.querySelector('.name').textContent = design.title;
|
|
643
|
+
if (board.description) design.item.title = board.description;
|
|
644
|
+
else design.item.removeAttribute('title');
|
|
645
|
+
design.category = board.category || '';
|
|
646
|
+
design.search = fold([design.title, design.file, board.description, board.category, ...(board.tags || []), section.name]
|
|
647
|
+
.filter(Boolean).join(' '));
|
|
648
|
+
const autoHeight = !Number(board.height);
|
|
649
|
+
if (Number(board.width)) design.w = Number(board.width);
|
|
650
|
+
if (!autoHeight) design.h = Number(board.height);
|
|
651
|
+
else if (!design.autoHeight) growToPage(design);
|
|
652
|
+
design.autoHeight = autoHeight;
|
|
653
|
+
showDims(design);
|
|
654
|
+
});
|
|
655
|
+
state.manifest = JSON.stringify(manifest);
|
|
656
|
+
buildFilters();
|
|
657
|
+
applySearch();
|
|
658
|
+
layout();
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Reads the designs again: 'rebuilt' (a new canvas), 'patched' (names or sizes) or false (no change).
|
|
555
662
|
// message: a text, or nothing to describe the change.
|
|
556
663
|
async function rebuildIfChanged(message) {
|
|
557
664
|
let manifest;
|
|
@@ -561,10 +668,14 @@ async function rebuildIfChanged(message) {
|
|
|
561
668
|
return false;
|
|
562
669
|
}
|
|
563
670
|
if (JSON.stringify(manifest) === state.manifest) return false;
|
|
671
|
+
if (sameShape(manifest)) {
|
|
672
|
+
patch(manifest);
|
|
673
|
+
return 'patched';
|
|
674
|
+
}
|
|
564
675
|
const before = state.designs.map(({ id, title }) => ({ id, title }));
|
|
565
676
|
rebuild(manifest);
|
|
566
677
|
toast(message || changesBetween(before, state.designs));
|
|
567
|
-
return
|
|
678
|
+
return 'rebuilt';
|
|
568
679
|
}
|
|
569
680
|
|
|
570
681
|
async function reloadAll() {
|
|
@@ -599,13 +710,17 @@ async function applyChanges() {
|
|
|
599
710
|
pending.folders = false;
|
|
600
711
|
|
|
601
712
|
// A new canvas loads every design afresh, so nothing else is left to do.
|
|
602
|
-
if (manifestChanged
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
713
|
+
if (manifestChanged) {
|
|
714
|
+
const result = await rebuildIfChanged('designs.json changed: canvas rebuilt');
|
|
715
|
+
if (result === 'rebuilt') return;
|
|
716
|
+
if (result === 'patched' && !files.length) toast('designs.json changed: names and sizes updated');
|
|
717
|
+
}
|
|
718
|
+
// Every HTML file is a design, and it says its own name, size and variants in its <head>: a saved
|
|
719
|
+
// page may be a new design, a renamed or resized one. A folder added or removed may bring or
|
|
720
|
+
// take a whole group of them.
|
|
606
721
|
const pages = files.filter((file) => /\.html?$/i.test(file));
|
|
607
|
-
if (foldersChanged ||
|
|
608
|
-
if (await rebuildIfChanged()) return;
|
|
722
|
+
if (foldersChanged || pages.length) {
|
|
723
|
+
if (await rebuildIfChanged() === 'rebuilt') return;
|
|
609
724
|
}
|
|
610
725
|
|
|
611
726
|
const paths = files.map((file) => `/${file}`);
|
|
@@ -848,12 +963,13 @@ function wire() {
|
|
|
848
963
|
else if (key === 'ArrowRight' || key === 'ArrowDown') step(1);
|
|
849
964
|
else if (key === 'ArrowLeft' || key === 'ArrowUp') step(-1);
|
|
850
965
|
else if (key === 'Escape') {
|
|
966
|
+
// One thing at a time: the shortcuts, the selection, the search, the filter
|
|
851
967
|
if (!els.help.hidden) toggleHelp(false);
|
|
852
968
|
else if (state.selected) select(null);
|
|
853
969
|
else if (els.search.value) {
|
|
854
970
|
els.search.value = '';
|
|
855
971
|
search('');
|
|
856
|
-
}
|
|
972
|
+
} else if (state.category) setCategory('');
|
|
857
973
|
} else if (key === 'Enter' && !onControl) openSelected();
|
|
858
974
|
else if (key === 'r' || key === 'R') reloadAll();
|
|
859
975
|
else if (key === '/') {
|
|
@@ -861,6 +977,12 @@ function wire() {
|
|
|
861
977
|
els.search.focus();
|
|
862
978
|
els.search.select();
|
|
863
979
|
} else if (key === '?') toggleHelp();
|
|
980
|
+
else if (/^[1-9]$/.test(key) && !event.shiftKey && !els.filters.hidden) {
|
|
981
|
+
// 1 shows all, 2 to 4 mobile, tablet, desktop, then the other categories
|
|
982
|
+
const button = els.filters.children[Number(key) - 1];
|
|
983
|
+
if (button && !button.disabled) setCategory(button.dataset.category);
|
|
984
|
+
else handled = false;
|
|
985
|
+
}
|
|
864
986
|
else if (key === '\\' || key === '[' || key === ']') toggleList();
|
|
865
987
|
else handled = false;
|
|
866
988
|
if (handled) {
|
|
@@ -890,6 +1012,7 @@ function wire() {
|
|
|
890
1012
|
const design = designFromHash();
|
|
891
1013
|
if (design && design !== state.selected) {
|
|
892
1014
|
state.autoFit = null;
|
|
1015
|
+
if (!inFilter(design)) setCategory('', { fitAll: false });
|
|
893
1016
|
select(design, { fit: true });
|
|
894
1017
|
}
|
|
895
1018
|
});
|
package/src/client/index.html
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
<input type="search" id="search" placeholder="Search" aria-label="Search the designs" autocomplete="off" spellcheck="false">
|
|
29
29
|
<kbd aria-hidden="true">/</kbd>
|
|
30
30
|
</div>
|
|
31
|
+
<div class="filters" id="filters" role="group" aria-label="Show" hidden></div>
|
|
31
32
|
<p class="no-match" id="no-match" hidden>No design matches the search.</p>
|
|
32
33
|
<nav class="list" id="list" aria-label="Designs by section"></nav>
|
|
33
34
|
<p class="sidebar-hint">Drag or scroll to move, <kbd>Ctrl</kbd> + scroll or pinch to zoom, double-click a design to fit it. <kbd>?</kbd> lists the shortcuts.</p>
|
|
@@ -83,6 +84,7 @@
|
|
|
83
84
|
<div><dt><kbd>←</kbd> <kbd>→</kbd></dt><dd>Previous, next design</dd></div>
|
|
84
85
|
<div><dt><kbd>Enter</kbd></dt><dd>Open the design on its own</dd></div>
|
|
85
86
|
<div><dt><kbd>/</kbd></dt><dd>Search</dd></div>
|
|
87
|
+
<div><dt><kbd>1</kbd> – <kbd>4</kbd></dt><dd>All, mobile, tablet, desktop</dd></div>
|
|
86
88
|
<div><dt><kbd>R</kbd></dt><dd>Reload the designs</dd></div>
|
|
87
89
|
<div><dt><kbd>\</kbd></dt><dd>Show or hide the list</dd></div>
|
|
88
90
|
<div><dt><kbd>Esc</kbd></dt><dd>Deselect</dd></div>
|