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.
@@ -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: #1C1C1E;
21
- --x-dot: rgb(255 255 255 / 0.09);
22
- --x-panel: #252527;
23
- --x-raised: #2E2E31;
24
- --x-line: #3A3A3E;
25
- --x-text: #EDEDEB;
26
- --x-text-soft: #9D9DA3;
27
- --x-accent: #3D9DFF;
28
- --x-wash: rgb(255 255 255 / 0.07);
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-soft); font-variant-numeric: tabular-nums; }
91
+ .version { font-size: 11px; color: var(--x-text-faint); font-variant-numeric: tabular-nums; }
83
92
 
84
- .sidebar-head { display: flex; align-items: baseline; gap: 8px; padding: 16px 16px 10px; }
85
- .sidebar-head h1 { min-width: 0; margin: 0; font: 800 24px/1.1 var(--font-display); overflow-wrap: anywhere; }
86
- .count { flex: none; font-size: 13px; color: var(--x-text-soft); font-variant-numeric: tabular-nums; }
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-soft);
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-soft); opacity: 1; }
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-soft);
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: color-mix(in srgb, var(--x-accent) 24%, transparent); color: #fff; }
167
- .item .dims { flex: none; font-size: 12px; color: var(--x-text-soft); font-variant-numeric: tabular-nums; }
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.45), 0 10px 40px rgb(0 0 0 / 0.35);
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: #FFFFFF;
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-raised);
259
- box-shadow: 0 8px 28px rgb(0 0 0 / 0.4);
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-soft); }
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-raised);
312
- box-shadow: 0 8px 28px rgb(0 0 0 / 0.4);
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-raised);
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;
@@ -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.group.append(el('h2', null, name));
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 search keeps (all of them without a search).
217
+ // The designs the filter and the search keep.
204
218
  function shown() {
205
- return state.query.length ? state.designs.filter(matches) : state.designs;
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 on = matches(design);
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
- els.count.textContent = state.query.length ? `${count} of ${total}` : String(total);
226
- els.noMatch.hidden = !state.query.length || count > 0;
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
- Object.assign(section, { x: 0, y, w: Math.max(0, x - FRAME_GAP), h: height });
250
- y += height + SECTION_GAP;
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
- state.sections.forEach((section, index) => {
268
- const room = index ? SECTION_GAP * z : Infinity;
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 = !section.designs.length || room < 30;
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 true;
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 && await rebuildIfChanged('designs.json changed: canvas rebuilt')) return;
603
- // Without designs.json every HTML file is a design (and its <title> its name); with it, an
604
- // HTML file that isn't on the canvas may be one just added to it. A folder added or removed
605
- // may bring or take a whole group of them.
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 || (pages.length && (state.autoDiscovered || pages.some((file) => !state.designs.some((d) => d.path === `/${file}`))))) {
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
  });
@@ -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>