@hozu/devtools 0.16.0 → 0.17.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.
@@ -1,11 +1,13 @@
1
1
  import { describeFor, friendlyName, questionFor } from '../plain.js';
2
2
  import { joinRequests, labelOf, openRequestsLine, requestMarkdown, scopesFor, } from '../prompt.js';
3
3
  import { held, hold, node, one, page, remove, save, saved, theme, tree } from './api.js';
4
+ import { assetsBoard } from './assets.js';
4
5
  import { h, read, write } from './dom.js';
5
6
  import { drawer } from './effects.js';
6
7
  import { previewLabel, renderLayers } from './layers.js';
7
8
  import { logo } from './logo.js';
8
9
  import { lookSection, preview } from './look.js';
10
+ import { distances, sizeOf } from './measure.js';
9
11
  import { agentNotes } from './notes.js';
10
12
  import { css, outlineCss } from './style.js';
11
13
  import { firstText, previewText, restoreText, textSection } from './text.js';
@@ -31,6 +33,9 @@ const state = {
31
33
  };
32
34
  const persist = () => write(key, state);
33
35
  const host = document.createElement('hozu-devtools');
36
+ host.setAttribute('data-lenis-prevent', '');
37
+ for (const type of ['wheel', 'touchstart', 'touchmove', 'keydown'])
38
+ host.addEventListener(type, (event) => event.stopPropagation(), { passive: true });
34
39
  const shadow = host.attachShadow({ mode: 'open' });
35
40
  const root = h('div', { class: 'root' });
36
41
  const hover = h('div', { class: 'box', hidden: true }, [h('div', { class: 'tag' })]);
@@ -59,7 +64,119 @@ const api = drawer({
59
64
  },
60
65
  closed: () => { },
61
66
  });
62
- root.append(bench, panel, dock, api.el);
67
+ const screenCookie = 'hozu-dev-preview';
68
+ const screenOf = () => {
69
+ const raw = new RegExp(`(?:^|;\\s*)${screenCookie}=([^;]+)`).exec(document.cookie)?.[1];
70
+ try {
71
+ return raw ? decodeURIComponent(raw) : null;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ };
77
+ const setScreen = (value) => {
78
+ document.cookie = value
79
+ ? `${screenCookie}=${encodeURIComponent(value)}; path=/; SameSite=Lax`
80
+ : `${screenCookie}=; path=/; max-age=0; SameSite=Lax`;
81
+ };
82
+ const showKey = 'hozu-devtools:assets-show';
83
+ function showInstances(c) {
84
+ const here = c.uses.flatMap((u) => [...doc.querySelectorAll(`[data-hz="${CSS.escape(u.node)}"]`)]);
85
+ if (!here.length) {
86
+ const page = c.uses.flatMap((u) => u.pages).find((p) => !p.params);
87
+ if (!page)
88
+ return;
89
+ try {
90
+ sessionStorage.setItem(showKey, c.id);
91
+ }
92
+ catch { }
93
+ win.location.href = page.path;
94
+ return;
95
+ }
96
+ assets.close();
97
+ state.mode = 'select';
98
+ state.picks = [
99
+ ...state.picks.filter(hasContent),
100
+ ...here.map((el) => ({
101
+ id: el.getAttribute('data-hz') ?? '',
102
+ index: indexOf(el),
103
+ note: '',
104
+ scope: 'component',
105
+ visible: visibleOf(el),
106
+ })),
107
+ ];
108
+ state.active = state.picks.length - 1;
109
+ state.panel = 'inspector';
110
+ persist();
111
+ renderDock();
112
+ drawPicks();
113
+ void renderPanel();
114
+ }
115
+ const assets = assetsBoard({
116
+ plain: state.audience === 'builder',
117
+ theme: () => theme(),
118
+ stylesheets: () => [...doc.querySelectorAll('link[rel="stylesheet"]')].map((l) => l.href).filter(Boolean),
119
+ show: (c) => showInstances(c),
120
+ change: (c, note) => {
121
+ const first = c.uses[0];
122
+ if (!first)
123
+ return;
124
+ state.picks.push({ id: first.node, index: 0, note, scope: 'component', visible: '' });
125
+ state.active = state.picks.length - 1;
126
+ persist();
127
+ assets.close();
128
+ renderDock();
129
+ open('changes', null, 'draft');
130
+ },
131
+ screen: (route, path, name) => {
132
+ setScreen(`${route}:${name}`);
133
+ if (win.location.pathname === path)
134
+ win.location.reload();
135
+ else
136
+ win.location.href = path;
137
+ },
138
+ });
139
+ async function screensSection() {
140
+ const res = await fetch(`/_hozu/dev/previews?path=${encodeURIComponent(win.location.pathname)}`, {
141
+ cache: 'no-store',
142
+ }).catch(() => null);
143
+ const out = res?.ok
144
+ ? (await res.json())
145
+ : null;
146
+ if (!out?.route || !out.previews.length)
147
+ return null;
148
+ return h('div', { class: 'sec' }, [
149
+ h('div', { class: 'label' }, ['Previews (previews.ts)']),
150
+ ...out.previews.map((p) => {
151
+ const value = `${out.route}:${p.name}`;
152
+ const on = out.current === value;
153
+ return h('div', { class: 'look' }, [
154
+ h('span', { class: 'what' }, [p.name]),
155
+ h('button', {
156
+ class: on ? 'link done' : 'link',
157
+ type: 'button',
158
+ onclick: () => {
159
+ setScreen(on ? null : value);
160
+ win.location.reload();
161
+ },
162
+ }, [on ? 'Exit' : 'Preview']),
163
+ ]);
164
+ }),
165
+ ]);
166
+ }
167
+ root.append(bench, panel, dock, api.el, assets.board);
168
+ try {
169
+ const pending = sessionStorage.getItem(showKey);
170
+ if (pending) {
171
+ sessionStorage.removeItem(showKey);
172
+ void assets.catalog().then((list) => {
173
+ const c = list?.find((x) => x.id === pending);
174
+ if (c)
175
+ setTimeout(() => showInstances(c), 300);
176
+ });
177
+ }
178
+ }
179
+ catch { }
63
180
  document.documentElement.append(host);
64
181
  const darkScheme = matchMedia('(prefers-color-scheme: dark)');
65
182
  function applyTheme() {
@@ -72,6 +189,8 @@ let win = window;
72
189
  let frameEl = null;
73
190
  const frames = h('div');
74
191
  let outline = null;
192
+ let measuring = false;
193
+ const measure = h('div', { class: 'measure', hidden: true });
75
194
  const agent = agentNotes({
76
195
  doc: () => doc,
77
196
  win: () => win,
@@ -89,7 +208,7 @@ function mountOutline(target) {
89
208
  outline?.remove();
90
209
  const el = target.createElement('hozu-devtools-outline');
91
210
  el.style.cssText = 'all:initial;position:fixed;inset:0;pointer-events:none;z-index:2147483646';
92
- el.attachShadow({ mode: 'open' }).append(h('style', {}, [outlineCss]), frames, boxes, agent.layer, hover);
211
+ el.attachShadow({ mode: 'open' }).append(h('style', {}, [outlineCss]), frames, boxes, agent.layer, hover, measure);
93
212
  target.documentElement.append(el);
94
213
  outline = el;
95
214
  }
@@ -131,6 +250,9 @@ function place(box, el) {
131
250
  target.style.width = `${r.width + 4}px`;
132
251
  target.style.height = `${r.height + 4}px`;
133
252
  }
253
+ const size = box.querySelector(':scope > .size');
254
+ if (size)
255
+ size.textContent = sizeOf(r);
134
256
  const tag = box.firstElementChild;
135
257
  if (!tag?.classList.contains('tag'))
136
258
  return;
@@ -175,6 +297,37 @@ async function showHover(el, force = false) {
175
297
  if (hovered === el)
176
298
  tag.replaceChildren(...tagText(n, el));
177
299
  }
300
+ function drawMeasure() {
301
+ const active = state.picks[state.active];
302
+ const from = active ? elementOf(active) : hovered;
303
+ const to = active ? hovered : (hovered?.parentElement?.closest('[data-hz]') ?? null);
304
+ if (!measuring || state.mode !== 'select' || !from || !to || from === to) {
305
+ measure.hidden = true;
306
+ return;
307
+ }
308
+ const a = from.getBoundingClientRect();
309
+ const b = to.getBoundingClientRect();
310
+ const px = (n) => `${n}px`;
311
+ const target = h('div', { class: 'm-target' });
312
+ Object.assign(target.style, { left: px(b.left), top: px(b.top), width: px(b.width), height: px(b.height) });
313
+ const parts = [target];
314
+ for (const l of distances(a, b)) {
315
+ const line = h('div', { class: `m-line ${l.axis}` });
316
+ const label = h('div', { class: 'm-label' }, [String(Math.round(l.to - l.from))]);
317
+ const mid = (l.from + l.to) / 2;
318
+ if (l.axis === 'h') {
319
+ Object.assign(line.style, { left: px(l.from), top: px(l.at), width: px(l.to - l.from) });
320
+ Object.assign(label.style, { left: px(mid), top: px(l.at) });
321
+ }
322
+ else {
323
+ Object.assign(line.style, { left: px(l.at), top: px(l.from), height: px(l.to - l.from) });
324
+ Object.assign(label.style, { left: px(l.at), top: px(mid) });
325
+ }
326
+ parts.push(line, label);
327
+ }
328
+ measure.replaceChildren(...parts);
329
+ measure.hidden = false;
330
+ }
178
331
  function drawPicks() {
179
332
  for (const p of state.picks) {
180
333
  preview(elementOf(p), p.style ?? []);
@@ -185,6 +338,7 @@ function drawPicks() {
185
338
  boxes.replaceChildren(...state.picks.map((p, i) => {
186
339
  const box = framed(h('div', { class: 'box selected' }, [
187
340
  state.picks.length > 1 ? h('div', { class: 'badge' }, [String(i + 1)]) : null,
341
+ h('div', { class: 'size' }),
188
342
  ]), true);
189
343
  place(box, state.mode === 'select' ? elementOf(p) : null);
190
344
  return box;
@@ -203,6 +357,7 @@ function frame() {
203
357
  }
204
358
  if (hovered && (state.mode === 'select' || forced))
205
359
  place(hover, hovered);
360
+ drawMeasure();
206
361
  agent.frame();
207
362
  requestAnimationFrame(frame);
208
363
  }
@@ -272,12 +427,13 @@ function listen(target) {
272
427
  return;
273
428
  const mouse = event;
274
429
  const clicked = markedFrom(event);
275
- const el = mouse.altKey ? (clicked?.parentElement?.closest('[data-hz]') ?? clicked) : clicked;
430
+ const el = clicked;
276
431
  if (!el)
277
432
  return;
278
433
  select({ id: el.getAttribute('data-hz') ?? '', index: indexOf(el), visible: visibleOf(el) }, mouse.shiftKey);
279
434
  }, { capture: true, signal });
280
435
  target.addEventListener('pointermove', (event) => {
436
+ measuring = event.altKey;
281
437
  if (state.mode !== 'select')
282
438
  return;
283
439
  const el = ours(event) ? null : markedFrom(event);
@@ -285,30 +441,60 @@ function listen(target) {
285
441
  void showHover(el);
286
442
  }, { capture: true, passive: true, signal });
287
443
  target.addEventListener('keydown', onKey, { capture: true, signal });
444
+ target.addEventListener('keyup', (event) => {
445
+ measuring = event.altKey;
446
+ }, { capture: true, signal });
447
+ target.addEventListener('blur', () => {
448
+ measuring = false;
449
+ }, { signal });
288
450
  return stop;
289
451
  }
452
+ /** The marked parts directly inside `parent` (or the page), in document order. */
453
+ function partsIn(parent) {
454
+ const all = [...(parent ?? doc).querySelectorAll('[data-hz]')];
455
+ return all.filter((e) => (e.parentElement?.closest('[data-hz]') ?? null) === parent);
456
+ }
290
457
  function walk(direction) {
291
458
  const active = state.picks[state.active];
292
459
  if (!active)
293
460
  return;
294
461
  const el = elementOf(active);
295
462
  const own = el?.getAttribute('data-hz') === active.id;
463
+ const beside = () => {
464
+ if (!el || !own)
465
+ return null;
466
+ const list = partsIn(el.parentElement?.closest('[data-hz]') ?? null).filter((e) => {
467
+ const b = e.getBoundingClientRect();
468
+ return e === el || (b.width > 0 && b.height > 0);
469
+ });
470
+ const at = list.indexOf(el);
471
+ return list[(at + (direction === 'next' ? 1 : -1) + list.length) % list.length] ?? null;
472
+ };
296
473
  const next = direction === 'up'
297
474
  ? own
298
475
  ? el?.parentElement?.closest('[data-hz]')
299
476
  : el
300
- : own
301
- ? el?.querySelector('[data-hz]')
302
- : null;
303
- if (!next)
477
+ : direction === 'down'
478
+ ? own
479
+ ? el?.querySelector('[data-hz]')
480
+ : null
481
+ : beside();
482
+ if (!next || ((direction === 'next' || direction === 'previous') && next === el))
304
483
  return;
305
- state.picks[state.active] = {
306
- ...active,
307
- id: next.getAttribute('data-hz') ?? '',
308
- index: indexOf(next),
309
- visible: visibleOf(next),
310
- scope: null,
311
- };
484
+ const moved = { id: next.getAttribute('data-hz') ?? '', index: indexOf(next), visible: visibleOf(next) };
485
+ if (hasContent(active)) {
486
+ const existing = state.picks.findIndex((p) => p.id === moved.id && p.index === moved.index);
487
+ if (existing >= 0)
488
+ state.active = existing;
489
+ else {
490
+ state.picks.push({ ...moved, note: '', scope: null });
491
+ state.active = state.picks.length - 1;
492
+ }
493
+ }
494
+ else {
495
+ preview(el, []);
496
+ state.picks[state.active] = { ...moved, note: '', scope: null };
497
+ }
312
498
  persist();
313
499
  drawPicks();
314
500
  void renderPanel();
@@ -325,6 +511,8 @@ function setMode(mode) {
325
511
  const typingIn = (event) => event.composedPath().some((t) => ['TEXTAREA', 'INPUT', 'SELECT'].includes(t.tagName));
326
512
  function onKey(event) {
327
513
  const typing = typingIn(event);
514
+ if (event.key === 'Escape' && assets.isOpen())
515
+ return assets.close();
328
516
  if (event.altKey && event.shiftKey && event.code === 'KeyS') {
329
517
  event.preventDefault();
330
518
  return setMode(state.mode === 'select' ? 'browse' : 'select');
@@ -340,12 +528,27 @@ function onKey(event) {
340
528
  persist();
341
529
  return void renderPanel();
342
530
  }
343
- if (state.mode === 'select' &&
344
- (event.key === 'ArrowUp' || event.key === 'ArrowDown') &&
345
- state.picks.length) {
346
- event.preventDefault();
347
- walk(event.key === 'ArrowUp' ? 'up' : 'down');
531
+ if (event.key === 'Alt') {
532
+ measuring = true;
533
+ if (state.mode === 'select')
534
+ event.preventDefault();
535
+ return;
348
536
  }
537
+ if (state.mode !== 'select' || !state.picks.length || event.composedPath().includes(host))
538
+ return;
539
+ const step = event.key === 'ArrowUp' || (event.key === 'Enter' && event.shiftKey)
540
+ ? 'up'
541
+ : event.key === 'ArrowDown' || event.key === 'Enter'
542
+ ? 'down'
543
+ : event.key === 'Tab'
544
+ ? event.shiftKey
545
+ ? 'previous'
546
+ : 'next'
547
+ : null;
548
+ if (!step)
549
+ return;
550
+ event.preventDefault();
551
+ walk(step);
349
552
  }
350
553
  let listening = listen(window);
351
554
  const reload = () => win.location.reload();
@@ -440,6 +643,12 @@ function renderDock() {
440
643
  void renderPanel();
441
644
  },
442
645
  }, ['Layers']),
646
+ h('button', {
647
+ class: 'act',
648
+ type: 'button',
649
+ title: 'Every component of the app, its variants, previews and uses; and the design tokens',
650
+ onclick: () => void assets.open(),
651
+ }, ['Assets']),
443
652
  h('button', {
444
653
  class: 'act',
445
654
  type: 'button',
@@ -455,7 +664,7 @@ function renderDock() {
455
664
  state.panel = state.picks.length ? 'inspector' : null;
456
665
  openBench();
457
666
  },
458
- }, ['Workbench']),
667
+ }, ['Frame']),
459
668
  h('button', {
460
669
  class: 'act gear',
461
670
  type: 'button',
@@ -467,6 +676,17 @@ function renderDock() {
467
676
  void renderPanel();
468
677
  },
469
678
  }, ['⚙']),
679
+ screenOf()
680
+ ? h('button', {
681
+ class: 'previewing',
682
+ type: 'button',
683
+ title: 'Exit the preview from previews.ts',
684
+ onclick: () => {
685
+ setScreen(null);
686
+ win.location.reload();
687
+ },
688
+ }, [`Screen: ${screenOf().slice(screenOf().indexOf(':') + 1)}`, ' · Exit'])
689
+ : null,
470
690
  held()
471
691
  ? h('button', {
472
692
  class: 'previewing',
@@ -479,7 +699,8 @@ function renderDock() {
479
699
  ? h('div', { class: 'tip', role: 'note' }, [
480
700
  h('span', {}, [h('kbd', {}, ['Click']), ' a part to select it']),
481
701
  h('span', {}, [h('kbd', {}, ['Shift']), '+click adds another']),
482
- h('span', {}, [h('kbd', {}, ['Alt']), '+click picks its parent']),
702
+ h('span', {}, [h('kbd', {}, ['⇧ Enter']), ' picks its parent']),
703
+ h('span', {}, [h('kbd', {}, ['Alt']), ' measures']),
483
704
  h('span', {}, [h('kbd', {}, ['Esc']), ' stops']),
484
705
  ])
485
706
  : null,
@@ -979,8 +1200,13 @@ async function renderPanel() {
979
1200
  return renderSettings();
980
1201
  if (state.panel === 'notes')
981
1202
  return agent.render(panel);
982
- if (state.panel === 'layers')
983
- return renderLayers(panel, await tree(win.location.pathname), layersHost(() => open(state.picks.length ? 'inspector' : null)));
1203
+ if (state.panel === 'layers') {
1204
+ await renderLayers(panel, await tree(win.location.pathname), layersHost(() => open(state.picks.length ? 'inspector' : null)));
1205
+ const screens = await screensSection();
1206
+ if (screens)
1207
+ panel.append(screens);
1208
+ return;
1209
+ }
984
1210
  const active = state.picks[state.active];
985
1211
  if (state.panel !== 'inspector' || !active) {
986
1212
  panel.hidden = state.view !== 'workbench';
@@ -1065,7 +1291,7 @@ async function renderChanges() {
1065
1291
  const done = h('button', {
1066
1292
  class: 'link done',
1067
1293
  type: 'button',
1068
- title: 'Done or not needed: remove it',
1294
+ title: 'Resolved or not needed: remove it',
1069
1295
  onclick: async () => {
1070
1296
  if (!armed) {
1071
1297
  armed = true;
@@ -1075,7 +1301,7 @@ async function renderChanges() {
1075
1301
  await remove(r.number);
1076
1302
  void renderPanel();
1077
1303
  },
1078
- }, ['Done']);
1304
+ }, ['Resolve']);
1079
1305
  return h('div', { class: 'req' }, [
1080
1306
  h('span', { class: 'n' }, [r.number]),
1081
1307
  h('button', { class: 't open-req', type: 'button', onclick: () => open('changes', r.number, 'saved') }, [r.title]),
@@ -1088,7 +1314,7 @@ async function renderChanges() {
1088
1314
  message ? h('div', { class: 'notice' }, message) : null,
1089
1315
  ...(list.length
1090
1316
  ? [...list].reverse().map(row)
1091
- : [h('div', { class: 'empty' }, ['No open requests. Done ones are removed.'])]),
1317
+ : [h('div', { class: 'empty' }, ['No open requests. Resolved ones are removed.'])]),
1092
1318
  list.length
1093
1319
  ? h('div', { class: 'sec' }, [
1094
1320
  h('div', { class: 'choice' }, [
@@ -1200,7 +1426,7 @@ async function renderRequest(number) {
1200
1426
  renderDock();
1201
1427
  open('changes', null, 'saved');
1202
1428
  },
1203
- }, ['Done · remove']);
1429
+ }, ['Resolve · remove']);
1204
1430
  panel.replaceChildren(h('div', { class: 'head' }, [
1205
1431
  h('button', { class: 'back', type: 'button', onclick: () => open('changes', null, 'saved') }, [
1206
1432
  '← Saved',
@@ -1299,7 +1525,10 @@ function renderSettings() {
1299
1525
  ['Alt+Shift+S', 'Select on or off'],
1300
1526
  ['Click', 'Select'],
1301
1527
  ['Shift+click', 'Add to the request'],
1302
- ['Alt+click', 'Select the area around it'],
1528
+ ['Shift+Enter', 'Select the area around it'],
1529
+ ['Enter', 'Select inside it'],
1530
+ ['Tab / Shift+Tab', 'Next / previous part beside it'],
1531
+ ['Alt (hold)', 'Measure: W × H and the distance to the part under the pointer'],
1303
1532
  ['Double-click', 'Select a text'],
1304
1533
  ['↑ ↓', 'Around it / inside it'],
1305
1534
  ['Esc', 'Back to Browse'],
@@ -1316,6 +1545,10 @@ const left = h('div', { class: 'bench-left' });
1316
1545
  const stage = h('div', { class: 'stage' });
1317
1546
  const sizer = h('div', { class: 'sizer' });
1318
1547
  const bar = h('div', { class: 'bench-bar' });
1548
+ addEventListener('keydown', (event) => {
1549
+ if (state.view === 'workbench' && win !== window)
1550
+ onKey(event);
1551
+ }, true);
1319
1552
  function retarget(frame) {
1320
1553
  listening.abort();
1321
1554
  const w = frame?.contentWindow ?? null;
@@ -1418,7 +1651,13 @@ function renderBar() {
1418
1651
  'aria-pressed': api.isOpen() ? 'true' : 'false',
1419
1652
  onclick: () => api.toggle(),
1420
1653
  }, ['API']),
1421
- h('button', { class: 'act exit', type: 'button', onclick: () => closeBench() }, ['Exit workbench']),
1654
+ h('button', {
1655
+ class: 'act',
1656
+ type: 'button',
1657
+ title: 'Every component of the app, its variants, previews and uses; and the design tokens',
1658
+ onclick: () => void assets.open(),
1659
+ }, ['Assets']),
1660
+ h('button', { class: 'act exit', type: 'button', onclick: () => closeBench() }, ['Exit frame']),
1422
1661
  ]));
1423
1662
  }
1424
1663
  async function renderLeft() {
@@ -1435,7 +1674,7 @@ function openBench() {
1435
1674
  bench.hidden = false;
1436
1675
  const frame = h('iframe', {
1437
1676
  src: location.href,
1438
- title: 'The page in Workbench',
1677
+ title: 'The page in Frame',
1439
1678
  'data-hozu-bench': true,
1440
1679
  });
1441
1680
  frame.addEventListener('load', () => {
@@ -1506,7 +1745,7 @@ addEventListener('resize', layout);
1506
1745
  addEventListener('keydown', (event) => {
1507
1746
  if (event.key === 'Escape' && !typingIn(event) && bench.classList.contains('layers-open'))
1508
1747
  closeLayers();
1509
- });
1748
+ }, true);
1510
1749
  renderDock();
1511
1750
  drawPicks();
1512
1751
  void renderPanel();