@hozu/devtools 0.10.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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -0
  3. package/dist/index.d.ts +6 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +7 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/overlay/api.d.ts +30 -0
  8. package/dist/overlay/api.d.ts.map +1 -0
  9. package/dist/overlay/api.js +83 -0
  10. package/dist/overlay/api.js.map +1 -0
  11. package/dist/overlay/app.d.ts +2 -0
  12. package/dist/overlay/app.d.ts.map +1 -0
  13. package/dist/overlay/app.js +1430 -0
  14. package/dist/overlay/app.js.map +1 -0
  15. package/dist/overlay/dom.d.ts +7 -0
  16. package/dist/overlay/dom.d.ts.map +1 -0
  17. package/dist/overlay/dom.js +33 -0
  18. package/dist/overlay/dom.js.map +1 -0
  19. package/dist/overlay/index.d.ts +2 -0
  20. package/dist/overlay/index.d.ts.map +1 -0
  21. package/dist/overlay/index.js +4 -0
  22. package/dist/overlay/index.js.map +1 -0
  23. package/dist/overlay/layers.d.ts +12 -0
  24. package/dist/overlay/layers.d.ts.map +1 -0
  25. package/dist/overlay/layers.js +131 -0
  26. package/dist/overlay/layers.js.map +1 -0
  27. package/dist/overlay/logo.d.ts +2 -0
  28. package/dist/overlay/logo.d.ts.map +1 -0
  29. package/dist/overlay/logo.js +2 -0
  30. package/dist/overlay/logo.js.map +1 -0
  31. package/dist/overlay/look.d.ts +6 -0
  32. package/dist/overlay/look.d.ts.map +1 -0
  33. package/dist/overlay/look.js +119 -0
  34. package/dist/overlay/look.js.map +1 -0
  35. package/dist/overlay/style.d.ts +3 -0
  36. package/dist/overlay/style.d.ts.map +1 -0
  37. package/dist/overlay/style.js +334 -0
  38. package/dist/overlay/style.js.map +1 -0
  39. package/dist/overlay/text.d.ts +9 -0
  40. package/dist/overlay/text.d.ts.map +1 -0
  41. package/dist/overlay/text.js +51 -0
  42. package/dist/overlay/text.js.map +1 -0
  43. package/dist/plain.d.ts +10 -0
  44. package/dist/plain.d.ts.map +1 -0
  45. package/dist/plain.js +103 -0
  46. package/dist/plain.js.map +1 -0
  47. package/dist/prompt.d.ts +51 -0
  48. package/dist/prompt.d.ts.map +1 -0
  49. package/dist/prompt.js +178 -0
  50. package/dist/prompt.js.map +1 -0
  51. package/dist/requests.d.ts +20 -0
  52. package/dist/requests.d.ts.map +1 -0
  53. package/dist/requests.js +88 -0
  54. package/dist/requests.js.map +1 -0
  55. package/dist/theme.d.ts +20 -0
  56. package/dist/theme.d.ts.map +1 -0
  57. package/dist/theme.js +179 -0
  58. package/dist/theme.js.map +1 -0
  59. package/package.json +49 -0
@@ -0,0 +1,1430 @@
1
+ import { describeFor, friendlyName, questionFor } from '../plain.js';
2
+ import { joinRequests, labelOf, openRequestsLine, requestMarkdown, scopesFor, } from '../prompt.js';
3
+ import { held, hold, node, one, page, remove, save, saved, theme, tree } from './api.js';
4
+ import { h, read, write } from './dom.js';
5
+ import { previewLabel, renderLayers } from './layers.js';
6
+ import { logo } from './logo.js';
7
+ import { lookSection, preview } from './look.js';
8
+ import { css, outlineCss } from './style.js';
9
+ import { firstText, previewText, restoreText, textSection } from './text.js';
10
+ const key = `hozu-devtools:${location.host}`;
11
+ const given = document.querySelector('script[src$="/_hozu/devtools/overlay/index.js"]')
12
+ ?.dataset.mode;
13
+ const state = {
14
+ mode: 'browse',
15
+ picks: [],
16
+ active: 0,
17
+ panel: null,
18
+ opened: null,
19
+ tab: 'draft',
20
+ dock: null,
21
+ audience: given === 'developer' ? 'developer' : 'builder',
22
+ excerpt: false,
23
+ folded: false,
24
+ spot: null,
25
+ view: 'overlay',
26
+ theme: 'system',
27
+ device: { name: 'Phone', width: 390, height: 844 },
28
+ ...read(key, {}),
29
+ };
30
+ const persist = () => write(key, state);
31
+ const host = document.createElement('hozu-devtools');
32
+ const shadow = host.attachShadow({ mode: 'open' });
33
+ const root = h('div', { class: 'root' });
34
+ const hover = h('div', { class: 'box', hidden: true }, [h('div', { class: 'tag' })]);
35
+ const boxes = h('div');
36
+ const dock = h('div', { class: 'dock', role: 'toolbar', 'aria-label': 'Hozu DevTools' });
37
+ const panel = h('div', {
38
+ class: 'panel',
39
+ role: 'dialog',
40
+ 'aria-label': 'Hozu DevTools inspector',
41
+ hidden: true,
42
+ });
43
+ const bench = h('div', { class: 'bench', hidden: true });
44
+ shadow.append(h('style', {}, [css]), root);
45
+ root.append(bench, panel, dock);
46
+ document.documentElement.append(host);
47
+ const darkScheme = matchMedia('(prefers-color-scheme: dark)');
48
+ function applyTheme() {
49
+ root.dataset.theme = state.theme === 'system' ? (darkScheme.matches ? 'dark' : 'light') : state.theme;
50
+ }
51
+ darkScheme.addEventListener('change', applyTheme);
52
+ applyTheme();
53
+ let doc = document;
54
+ let win = window;
55
+ let frameEl = null;
56
+ const frames = h('div');
57
+ let outline = null;
58
+ function mountOutline(target) {
59
+ outline?.remove();
60
+ const el = target.createElement('hozu-devtools-outline');
61
+ el.style.cssText = 'all:initial;position:fixed;inset:0;pointer-events:none;z-index:2147483646';
62
+ el.attachShadow({ mode: 'open' }).append(h('style', {}, [outlineCss]), frames, boxes, hover);
63
+ target.documentElement.append(el);
64
+ outline = el;
65
+ }
66
+ mountOutline(document);
67
+ const frameOf = new WeakMap();
68
+ const framed = (box, selected) => {
69
+ const frame = h('div', { class: selected ? 'frame selected' : 'frame', hidden: true });
70
+ frames.append(frame);
71
+ frameOf.set(box, frame);
72
+ return box;
73
+ };
74
+ framed(hover, false);
75
+ const ours = (event) => event.composedPath().includes(host);
76
+ const parentId = (id) => id.slice(0, id.lastIndexOf('/'));
77
+ const isText = (n) => n?.kind === 'text';
78
+ function elementOf(pick) {
79
+ const all = doc.querySelectorAll(`[data-hz="${CSS.escape(pick.id)}"]`);
80
+ if (all.length)
81
+ return all[Math.min(pick.index, all.length - 1)] ?? null;
82
+ return pick.id.includes('/') ? elementOf({ id: parentId(pick.id), index: pick.index }) : null;
83
+ }
84
+ function indexOf(el) {
85
+ const id = el.getAttribute('data-hz') ?? '';
86
+ return [...doc.querySelectorAll(`[data-hz="${CSS.escape(id)}"]`)].indexOf(el);
87
+ }
88
+ const visibleOf = (el) => (el?.innerText ?? '').trim().replace(/\s+/g, ' ').slice(0, 80);
89
+ function place(box, el) {
90
+ const r = el?.getBoundingClientRect();
91
+ const frame = frameOf.get(box);
92
+ const hide = !r || (r.width === 0 && r.height === 0);
93
+ box.hidden = hide;
94
+ if (frame)
95
+ frame.hidden = hide;
96
+ if (!r || hide)
97
+ return;
98
+ for (const target of frame ? [box, frame] : [box]) {
99
+ target.style.left = `${r.left - 2}px`;
100
+ target.style.top = `${r.top - 2}px`;
101
+ target.style.width = `${r.width + 4}px`;
102
+ target.style.height = `${r.height + 4}px`;
103
+ }
104
+ const tag = box.firstElementChild;
105
+ if (!tag?.classList.contains('tag'))
106
+ return;
107
+ tag.style.left = '';
108
+ const below = r.top < 28 || r.top - tag.offsetHeight - 8 < 0;
109
+ box.classList.toggle('below', below);
110
+ const view = win.innerWidth;
111
+ const overflow = r.left - 3 + tag.offsetWidth - (view - 4);
112
+ tag.style.left =
113
+ overflow > 0 ? `${Math.max(-r.left + 4, -1 - overflow)}px` : r.left < 2 ? `${2 - r.left}px` : '';
114
+ }
115
+ let hovered = null;
116
+ function tagText(n, el) {
117
+ if (!n)
118
+ return [el.tagName.toLowerCase()];
119
+ if (state.audience === 'builder')
120
+ return [
121
+ friendlyName(n),
122
+ n.component && n.component.uses > 1 ? h('b', {}, [` shared · ${n.component.uses} places`]) : '',
123
+ ];
124
+ return [
125
+ h('b', {}, [n.kind === 'element' ? '' : `${n.kind} `]),
126
+ labelOf(n),
127
+ n.location ? ` ${n.location.file}:${n.location.line}` : '',
128
+ ];
129
+ }
130
+ let forced = false;
131
+ async function showHover(el, force = false) {
132
+ hovered = el;
133
+ forced = force;
134
+ if (!el || (state.mode !== 'select' && !force)) {
135
+ hover.hidden = true;
136
+ const frame = frameOf.get(hover);
137
+ if (frame)
138
+ frame.hidden = true;
139
+ return;
140
+ }
141
+ place(hover, el);
142
+ const tag = hover.firstElementChild;
143
+ tag.replaceChildren(el.tagName.toLowerCase());
144
+ const n = await node(el.getAttribute('data-hz') ?? '');
145
+ if (hovered === el)
146
+ tag.replaceChildren(...tagText(n, el));
147
+ }
148
+ function drawPicks() {
149
+ for (const p of state.picks) {
150
+ preview(elementOf(p), p.style ?? []);
151
+ previewText(elementOf(p), p.text);
152
+ }
153
+ for (const box of boxes.children)
154
+ frameOf.get(box)?.remove();
155
+ boxes.replaceChildren(...state.picks.map((p, i) => {
156
+ const box = framed(h('div', { class: 'box selected' }, [
157
+ state.picks.length > 1 ? h('div', { class: 'badge' }, [String(i + 1)]) : null,
158
+ ]), true);
159
+ place(box, state.mode === 'select' ? elementOf(p) : null);
160
+ return box;
161
+ }));
162
+ }
163
+ function frame() {
164
+ if (state.picks.length) {
165
+ let i = 0;
166
+ for (const box of boxes.children) {
167
+ const p = state.picks[i++];
168
+ if (p && state.mode === 'select')
169
+ place(box, elementOf(p));
170
+ else
171
+ place(box, null);
172
+ }
173
+ }
174
+ if (hovered && (state.mode === 'select' || forced))
175
+ place(hover, hovered);
176
+ requestAnimationFrame(frame);
177
+ }
178
+ const hasContent = (p) => p.note.trim() !== '' || (p.style?.length ?? 0) > 0 || !!p.text;
179
+ const drafted = () => state.picks.filter(hasContent);
180
+ function select(pick, add) {
181
+ const same = (p) => p.id === pick.id && p.index === pick.index;
182
+ if (!add)
183
+ state.picks = state.picks.filter((p) => hasContent(p) || same(p));
184
+ const existing = state.picks.findIndex(same);
185
+ if (existing >= 0)
186
+ state.active = existing;
187
+ else {
188
+ state.picks.push({ ...pick, note: '', scope: null });
189
+ state.active = state.picks.length - 1;
190
+ }
191
+ state.panel = 'inspector';
192
+ persist();
193
+ drawPicks();
194
+ void renderPanel();
195
+ }
196
+ function markedFrom(event) {
197
+ for (const target of event.composedPath())
198
+ if (target !== host && target.nodeType === 1 && target.hasAttribute('data-hz'))
199
+ return target;
200
+ return null;
201
+ }
202
+ async function selectText(event) {
203
+ const el = markedFrom(event);
204
+ if (!el)
205
+ return;
206
+ const n = await node(el.getAttribute('data-hz') ?? '');
207
+ const texts = n?.children.filter((c) => c.kind === 'text') ?? [];
208
+ if (!n || !texts.length)
209
+ return;
210
+ const caret = doc.caretPositionFromPoint?.(event.clientX, event.clientY)?.offsetNode;
211
+ const domTexts = [...el.childNodes].filter((c) => c.nodeType === 3 && c.textContent?.trim());
212
+ const at = caret ? domTexts.indexOf(caret) : -1;
213
+ const hit = caret?.textContent?.trim() ?? '';
214
+ const match = texts.find((t) => t.source?.kind === 'literal' && t.source.detail.trim() === hit) ??
215
+ (at >= 0 && domTexts.length === texts.length ? texts[at] : texts[0]);
216
+ if (match)
217
+ select({ id: match.id, index: indexOf(el), visible: hit || visibleOf(el) }, event.shiftKey);
218
+ }
219
+ const blocked = [
220
+ 'pointerdown',
221
+ 'mousedown',
222
+ 'pointerup',
223
+ 'mouseup',
224
+ 'click',
225
+ 'dblclick',
226
+ 'auxclick',
227
+ 'submit',
228
+ ];
229
+ function listen(target) {
230
+ const stop = new AbortController();
231
+ const signal = stop.signal;
232
+ for (const type of blocked)
233
+ target.addEventListener(type, (event) => {
234
+ if (state.mode !== 'select' || ours(event))
235
+ return;
236
+ event.preventDefault();
237
+ event.stopImmediatePropagation();
238
+ if (type === 'dblclick')
239
+ void selectText(event);
240
+ if (type !== 'click')
241
+ return;
242
+ const mouse = event;
243
+ const clicked = markedFrom(event);
244
+ const el = mouse.altKey ? (clicked?.parentElement?.closest('[data-hz]') ?? clicked) : clicked;
245
+ if (!el)
246
+ return;
247
+ select({ id: el.getAttribute('data-hz') ?? '', index: indexOf(el), visible: visibleOf(el) }, mouse.shiftKey);
248
+ }, { capture: true, signal });
249
+ target.addEventListener('pointermove', (event) => {
250
+ if (state.mode !== 'select')
251
+ return;
252
+ const el = ours(event) ? null : markedFrom(event);
253
+ if (el !== hovered)
254
+ void showHover(el);
255
+ }, { capture: true, passive: true, signal });
256
+ target.addEventListener('keydown', onKey, { capture: true, signal });
257
+ return stop;
258
+ }
259
+ function walk(direction) {
260
+ const active = state.picks[state.active];
261
+ if (!active)
262
+ return;
263
+ const el = elementOf(active);
264
+ const own = el?.getAttribute('data-hz') === active.id;
265
+ const next = direction === 'up'
266
+ ? own
267
+ ? el?.parentElement?.closest('[data-hz]')
268
+ : el
269
+ : own
270
+ ? el?.querySelector('[data-hz]')
271
+ : null;
272
+ if (!next)
273
+ return;
274
+ state.picks[state.active] = {
275
+ ...active,
276
+ id: next.getAttribute('data-hz') ?? '',
277
+ index: indexOf(next),
278
+ visible: visibleOf(next),
279
+ scope: null,
280
+ };
281
+ persist();
282
+ drawPicks();
283
+ void renderPanel();
284
+ }
285
+ function setMode(mode) {
286
+ state.mode = mode;
287
+ if (mode === 'select')
288
+ state.folded = false;
289
+ if (mode === 'browse')
290
+ void showHover(null);
291
+ persist();
292
+ renderDock();
293
+ }
294
+ function onKey(event) {
295
+ const typing = event
296
+ .composedPath()
297
+ .some((t) => ['TEXTAREA', 'INPUT', 'SELECT'].includes(t.tagName));
298
+ if (event.altKey && event.shiftKey && event.code === 'KeyS') {
299
+ event.preventDefault();
300
+ return setMode(state.mode === 'select' ? 'browse' : 'select');
301
+ }
302
+ if (typing)
303
+ return;
304
+ if (event.key === 'Escape' && state.mode === 'select')
305
+ return setMode('browse');
306
+ if (event.key === 'Escape' && state.panel) {
307
+ state.panel = null;
308
+ persist();
309
+ return void renderPanel();
310
+ }
311
+ if (state.mode === 'select' &&
312
+ (event.key === 'ArrowUp' || event.key === 'ArrowDown') &&
313
+ state.picks.length) {
314
+ event.preventDefault();
315
+ walk(event.key === 'ArrowUp' ? 'up' : 'down');
316
+ }
317
+ }
318
+ let listening = listen(window);
319
+ const reload = () => win.location.reload();
320
+ function layersHost(close) {
321
+ return {
322
+ plain: state.audience === 'builder',
323
+ shown: (id) => doc.querySelector(`[data-hz="${CSS.escape(id)}"]`),
324
+ hover: (el) => void showHover(el, true),
325
+ pick: (id, el) => {
326
+ void showHover(null);
327
+ select({ id, index: indexOf(el), visible: visibleOf(el) }, false);
328
+ },
329
+ hold: (p) => hold(p, reload),
330
+ close,
331
+ };
332
+ }
333
+ function renderDock() {
334
+ dock.hidden = state.view === 'workbench';
335
+ if (state.view === 'workbench')
336
+ return renderBar();
337
+ const button = (label, mode) => h('button', {
338
+ class: 'mode',
339
+ type: 'button',
340
+ 'aria-pressed': String(state.mode === mode),
341
+ onclick: () => setMode(mode),
342
+ }, [label]);
343
+ const grip = h('div', {
344
+ class: 'grip',
345
+ role: 'button',
346
+ tabindex: '0',
347
+ 'aria-expanded': String(!state.folded),
348
+ 'aria-label': state.folded ? 'Open Hozu DevTools' : 'Fold Hozu DevTools',
349
+ title: 'Hozu DevTools · click to fold · drag to move · Alt+Shift+S toggles Select',
350
+ onkeydown: (e) => {
351
+ const key = e.key;
352
+ if (key === 'Enter' || key === ' ') {
353
+ e.preventDefault();
354
+ fold();
355
+ }
356
+ },
357
+ }, [h('img', { src: logo, alt: '', width: '20', height: '20', draggable: 'false' })]);
358
+ dock.classList.toggle('folded', state.folded);
359
+ if (state.folded) {
360
+ dock.replaceChildren(grip);
361
+ placeDock();
362
+ return;
363
+ }
364
+ dock.replaceChildren(...present([
365
+ grip,
366
+ h('div', { class: 'seg' }, [button('Browse', 'browse'), button('Select', 'select')]),
367
+ h('button', {
368
+ class: 'act',
369
+ type: 'button',
370
+ onclick: () => {
371
+ if (state.panel === 'changes')
372
+ open(state.picks.length ? 'inspector' : null);
373
+ else
374
+ open('changes', null, drafted().length ? 'draft' : 'saved');
375
+ },
376
+ }, ['Changes', h('span', { class: 'count', 'data-count': true })]),
377
+ h('button', {
378
+ class: 'act',
379
+ type: 'button',
380
+ title: 'Select this page: its title, description and other head fields',
381
+ onclick: async () => {
382
+ const n = await page(win.location.pathname);
383
+ if (n)
384
+ select({ id: n.id, index: 0, visible: doc.title }, state.mode === 'select' && state.picks.length > 0);
385
+ },
386
+ }, ['Page']),
387
+ h('button', {
388
+ class: 'act',
389
+ type: 'button',
390
+ title: 'The parts of this page, and its states that are not on screen',
391
+ onclick: () => {
392
+ state.panel = state.panel === 'layers' ? (state.picks.length ? 'inspector' : null) : 'layers';
393
+ persist();
394
+ void renderPanel();
395
+ },
396
+ }, ['Layers']),
397
+ h('button', {
398
+ class: 'act',
399
+ type: 'button',
400
+ title: 'The page at an exact size, with its parts on the left and the inspector on the right',
401
+ onclick: () => {
402
+ state.panel = state.picks.length ? 'inspector' : null;
403
+ openBench();
404
+ },
405
+ }, ['Workbench']),
406
+ h('button', {
407
+ class: 'act gear',
408
+ type: 'button',
409
+ title: 'Settings',
410
+ 'aria-label': 'Settings',
411
+ onclick: () => {
412
+ state.panel = state.panel === 'settings' ? (state.picks.length ? 'inspector' : null) : 'settings';
413
+ persist();
414
+ void renderPanel();
415
+ },
416
+ }, ['⚙']),
417
+ held()
418
+ ? h('button', {
419
+ class: 'previewing',
420
+ type: 'button',
421
+ title: 'Exit the preview',
422
+ onclick: () => hold(null, reload),
423
+ }, [h('span', { 'data-preview': true }, ['Preview']), ' · Exit'])
424
+ : null,
425
+ state.mode === 'select' && !held()
426
+ ? h('div', { class: 'hint' }, ['Click selects · Shift adds · Alt goes up · Esc stops'])
427
+ : null,
428
+ ]));
429
+ const holding = held();
430
+ if (holding)
431
+ void tree(win.location.pathname).then((t) => {
432
+ const label = dock.querySelector('[data-preview]');
433
+ if (label)
434
+ label.textContent = `Preview: ${previewLabel(t, holding)}`;
435
+ });
436
+ const count = dock.querySelector('[data-count]');
437
+ if (count)
438
+ count.textContent = drafted().length ? String(drafted().length) : '';
439
+ placeDock();
440
+ }
441
+ function placeDock() {
442
+ const r = dock.getBoundingClientRect();
443
+ const x = state.dock
444
+ ? Math.min(Math.max(0, state.dock.x), innerWidth - r.width)
445
+ : (innerWidth - r.width) / 2;
446
+ const y = state.dock
447
+ ? Math.min(Math.max(0, state.dock.y), innerHeight - r.height)
448
+ : innerHeight - r.height - 24;
449
+ dock.style.left = `${x}px`;
450
+ dock.style.top = `${y}px`;
451
+ }
452
+ function fold() {
453
+ state.folded = !state.folded;
454
+ if (state.folded) {
455
+ state.mode = 'browse';
456
+ state.panel = null;
457
+ void showHover(null);
458
+ void renderPanel();
459
+ }
460
+ persist();
461
+ renderDock();
462
+ }
463
+ dock.addEventListener('pointerdown', (event) => {
464
+ if (!event.target.closest('.grip'))
465
+ return;
466
+ const r = dock.getBoundingClientRect();
467
+ const dx = event.clientX - r.left;
468
+ const dy = event.clientY - r.top;
469
+ let moved = false;
470
+ const move = (e) => {
471
+ if (!moved && Math.hypot(e.clientX - event.clientX, e.clientY - event.clientY) < 4)
472
+ return;
473
+ moved = true;
474
+ state.dock = { x: e.clientX - dx, y: e.clientY - dy };
475
+ placeDock();
476
+ };
477
+ const up = () => {
478
+ if (moved)
479
+ persist();
480
+ else
481
+ fold();
482
+ removeEventListener('pointermove', move);
483
+ removeEventListener('pointerup', up);
484
+ };
485
+ addEventListener('pointermove', move);
486
+ addEventListener('pointerup', up);
487
+ });
488
+ addEventListener('resize', placeDock);
489
+ function placePanel() {
490
+ if (state.view === 'workbench' || !state.spot) {
491
+ panel.style.left = panel.style.top = panel.style.right = '';
492
+ return;
493
+ }
494
+ const r = panel.getBoundingClientRect();
495
+ const x = Math.min(Math.max(0, state.spot.x), innerWidth - Math.min(r.width, innerWidth));
496
+ const y = Math.min(Math.max(0, state.spot.y), innerHeight - 48);
497
+ panel.style.right = 'auto';
498
+ panel.style.left = `${x}px`;
499
+ panel.style.top = `${y}px`;
500
+ }
501
+ panel.addEventListener('pointerdown', (event) => {
502
+ const target = event.target;
503
+ if (state.view === 'workbench' ||
504
+ !target.closest('.head') ||
505
+ target.closest('button, input, select, textarea, a'))
506
+ return;
507
+ event.preventDefault();
508
+ const head = target.closest('.head');
509
+ head.setPointerCapture(event.pointerId);
510
+ const r = panel.getBoundingClientRect();
511
+ const dx = event.clientX - r.left;
512
+ const dy = event.clientY - r.top;
513
+ const move = (e) => {
514
+ state.spot = { x: e.clientX - dx, y: e.clientY - dy };
515
+ placePanel();
516
+ };
517
+ const up = () => {
518
+ persist();
519
+ head.removeEventListener('pointermove', move);
520
+ head.removeEventListener('pointerup', up);
521
+ };
522
+ head.addEventListener('pointermove', move);
523
+ head.addEventListener('pointerup', up);
524
+ });
525
+ panel.addEventListener('dblclick', (event) => {
526
+ const target = event.target;
527
+ if (!target.closest('.head') || target.closest('button, input, select, textarea, a'))
528
+ return;
529
+ state.spot = null;
530
+ persist();
531
+ placePanel();
532
+ });
533
+ addEventListener('resize', placePanel);
534
+ const where = (what, l) => l
535
+ ? h('div', { class: 'loc' }, [
536
+ h('span', { class: 'what' }, [what]),
537
+ h('span', {}, [`${l.file}:${l.line}:${l.column}`]),
538
+ h('button', {
539
+ type: 'button',
540
+ title: 'Copy the location',
541
+ onclick: () => void copy(`${l.file}:${l.line}:${l.column}`),
542
+ }, ['Copy']),
543
+ ])
544
+ : null;
545
+ function excerpt(n) {
546
+ if (!n.excerpt)
547
+ return null;
548
+ return h('pre', {}, n.excerpt.lines.map((line, i) => {
549
+ const number = n.excerpt.start + i;
550
+ return h('span', { class: number === n.location?.line ? 'on' : '' }, [
551
+ h('i', {}, [String(number).padStart(3)]),
552
+ line,
553
+ ]);
554
+ }));
555
+ }
556
+ const present = (nodes) => nodes.filter((n) => n !== null);
557
+ const section = (label, children) => children.some(Boolean)
558
+ ? h('div', { class: 'sec' }, [h('div', { class: 'label' }, [label]), ...children])
559
+ : null;
560
+ const kindTitle = {
561
+ element: 'Element',
562
+ component: 'Component use',
563
+ text: 'Text',
564
+ query: 'Query',
565
+ when: 'State branch',
566
+ if: 'Branch',
567
+ list: 'List',
568
+ html: 'Raw HTML',
569
+ embed: 'Embed',
570
+ other: 'Node',
571
+ };
572
+ function kicker(n) {
573
+ if (state.audience === 'builder')
574
+ return n.page ? `Page ${n.page.path}` : `On ${win.location.pathname}`;
575
+ const kind = n.component
576
+ ? 'Component use'
577
+ : n.kind === 'text' && n.source?.kind === 'data'
578
+ ? 'Data text'
579
+ : (kindTitle[n.kind] ?? n.kind);
580
+ if (n.page)
581
+ return `Page · route ${n.page.path}`;
582
+ return `${kind} · view ${n.owner?.feature}.${n.owner?.view}`;
583
+ }
584
+ function titleFor(n, active) {
585
+ const text = (active.visible || n.text || '').slice(0, 40);
586
+ if (state.audience === 'builder')
587
+ return [friendlyName(n), text && !n.page ? h('span', { class: 'quote' }, [`“${text}”`]) : null];
588
+ return [
589
+ n.page
590
+ ? n.page.head.title || n.page.route
591
+ : n.tag
592
+ ? `<${n.tag}>`
593
+ : n.kind === 'text'
594
+ ? `“${text}”`
595
+ : (kindTitle[n.kind] ?? n.kind),
596
+ n.component ? h('code', {}, [n.component.ref]) : null,
597
+ ];
598
+ }
599
+ function textRows(n) {
600
+ const sources = n.source
601
+ ? [{ id: n.id, source: n.source }]
602
+ : n.children.flatMap((c) => (c.source ? [{ id: c.id, source: c.source }] : []));
603
+ return sources.map(({ source }) => h('div', { class: 'row' }, [
604
+ h('b', {}, [source.kind === 'literal' ? 'text ' : `${source.kind} `]),
605
+ source.kind === 'literal' ? `“${source.detail}”` : source.detail,
606
+ source.location ? ` ${source.location.file}:${source.location.line}` : '',
607
+ (source.uses ?? 1) > 1 ? h('span', { class: 'warn' }, [` · shared by ${source.uses} places`]) : null,
608
+ ]));
609
+ }
610
+ async function copy(text) {
611
+ try {
612
+ await navigator.clipboard.writeText(text);
613
+ }
614
+ catch {
615
+ const area = h('textarea');
616
+ area.value = text;
617
+ shadow.append(area);
618
+ area.select();
619
+ document.execCommand('copy');
620
+ area.remove();
621
+ }
622
+ }
623
+ async function request() {
624
+ const active = state.picks[state.active];
625
+ const chosen = drafted().length ? drafted() : active ? [active] : [];
626
+ const nodes = await Promise.all(chosen.map((p) => node(p.id)));
627
+ return {
628
+ items: chosen.flatMap((p, i) => {
629
+ const n = nodes[i];
630
+ return n
631
+ ? [
632
+ {
633
+ node: n,
634
+ note: p.note,
635
+ scope: p.scope ?? scopesFor(n)[0].scope,
636
+ visible: p.visible,
637
+ style: p.style ?? [],
638
+ ...(p.text ? { text: p.text } : {}),
639
+ },
640
+ ]
641
+ : [];
642
+ }),
643
+ context: {
644
+ path: win.location.pathname + win.location.search,
645
+ viewport: { width: win.innerWidth, height: win.innerHeight },
646
+ preview: held() ? previewLabel(await tree(win.location.pathname), held()) : null,
647
+ },
648
+ };
649
+ }
650
+ function status(el, kind, parts) {
651
+ el.className = `status ${kind}`;
652
+ el.replaceChildren(...parts);
653
+ }
654
+ const open = (panelName, opened = null, tab = state.tab) => {
655
+ state.panel = panelName;
656
+ state.opened = opened;
657
+ state.tab = tab;
658
+ persist();
659
+ void renderPanel();
660
+ };
661
+ const closeButton = (onclick) => h('button', { class: 'close', type: 'button', 'aria-label': 'Close', onclick }, ['×']);
662
+ function choose(active, id, visible) {
663
+ state.picks[state.active] = { ...active, id, scope: null, visible };
664
+ persist();
665
+ drawPicks();
666
+ void renderPanel();
667
+ }
668
+ function removeActive() {
669
+ const gone = state.picks[state.active];
670
+ if (gone)
671
+ preview(elementOf(gone), []);
672
+ if (gone)
673
+ restoreText(elementOf(gone), gone.text);
674
+ state.picks.splice(state.active, 1);
675
+ state.active = Math.max(0, state.active - 1);
676
+ if (!state.picks.length)
677
+ state.panel = null;
678
+ persist();
679
+ drawPicks();
680
+ void renderPanel();
681
+ }
682
+ function scopeField(n, active) {
683
+ const scopes = scopesFor(n);
684
+ if (scopes.length < 2)
685
+ return null;
686
+ const scope = active.scope ?? scopes[0].scope;
687
+ const asked = state.audience === 'builder' ? questionFor(n) : null;
688
+ return h('fieldset', {}, [
689
+ asked ? h('legend', {}, [asked.question]) : null,
690
+ ...scopes.map((s) => h('label', {}, [
691
+ h('input', {
692
+ type: 'radio',
693
+ name: 'scope',
694
+ value: s.scope,
695
+ checked: s.scope === scope,
696
+ onchange: () => {
697
+ active.scope = s.scope;
698
+ persist();
699
+ },
700
+ }),
701
+ asked?.options[s.scope] ?? s.label,
702
+ ])),
703
+ ]);
704
+ }
705
+ let notice = null;
706
+ function clearDraft() {
707
+ for (const p of state.picks) {
708
+ preview(elementOf(p), []);
709
+ restoreText(elementOf(p), p.text);
710
+ }
711
+ state.picks = [];
712
+ state.active = 0;
713
+ persist();
714
+ drawPicks();
715
+ }
716
+ function exportBlock() {
717
+ const count = (drafted().length || (state.picks[state.active] ? 1 : 0));
718
+ const result = h('div', { class: 'status', role: 'status' });
719
+ const draft = h('textarea', {
720
+ class: 'draft',
721
+ 'aria-label': 'The request, as the agent will read it',
722
+ hidden: true,
723
+ });
724
+ const generate = async () => requestMarkdown(await request(), { excerpt: state.excerpt, theme: await theme() });
725
+ const markdown = async () => (draft.hidden ? generate() : draft.value);
726
+ const edit = h('button', {
727
+ class: 'link',
728
+ type: 'button',
729
+ onclick: async () => {
730
+ if (draft.hidden)
731
+ draft.value = await generate();
732
+ draft.hidden = !draft.hidden;
733
+ edit.textContent = draft.hidden ? 'Edit before sending' : 'Discard edits';
734
+ },
735
+ }, ['Edit before sending']);
736
+ const many = count > 1 ? ` (${count})` : '';
737
+ return h('div', { class: 'export' }, [
738
+ h('div', { class: 'row-end' }, [edit]),
739
+ draft,
740
+ h('div', { class: 'actions' }, [
741
+ h('button', {
742
+ class: 'primary',
743
+ type: 'button',
744
+ onclick: async () => {
745
+ await copy(await markdown());
746
+ status(result, 'ok', [
747
+ `Copied ${count === 1 ? 'the change' : `all ${count} changes`}. Paste it to your agent.`,
748
+ ]);
749
+ },
750
+ }, [`Copy for AI${many}`]),
751
+ h('button', {
752
+ type: 'button',
753
+ onclick: async () => {
754
+ try {
755
+ const done = await save(await markdown());
756
+ const ask = `Do the Hozu request ${done.file}`;
757
+ await copy(ask);
758
+ notice = ['Saved ', h('code', {}, [done.file]), `. Copied “${ask}”: paste it to your agent.`];
759
+ clearDraft();
760
+ renderDock();
761
+ open('changes', null, 'saved');
762
+ }
763
+ catch (e) {
764
+ status(result, 'err', [`Not saved: ${e.message}`]);
765
+ }
766
+ },
767
+ }, [`Save request${many}`]),
768
+ h('button', {
769
+ type: 'button',
770
+ onclick: async () => {
771
+ const a = h('a', {
772
+ href: URL.createObjectURL(new Blob([await markdown()], { type: 'text/markdown' })),
773
+ download: 'hozu-request.md',
774
+ });
775
+ a.click();
776
+ status(result, 'ok', ['Downloaded hozu-request.md']);
777
+ },
778
+ }, ['Download .md']),
779
+ ]),
780
+ result,
781
+ ]);
782
+ }
783
+ function textFor(n, active) {
784
+ const from = active.text?.from ?? (n.kind === 'text' ? active.visible || null : firstText(elementOf(active)));
785
+ if (!from || n.page)
786
+ return null;
787
+ return textSection(from, active.text, state.audience === 'builder', (change) => {
788
+ const had = hasContent(active);
789
+ restoreText(elementOf(active), active.text);
790
+ if (change)
791
+ active.text = change;
792
+ else
793
+ delete active.text;
794
+ previewText(elementOf(active), active.text);
795
+ persist();
796
+ if (had !== hasContent(active))
797
+ renderDock();
798
+ void renderPanel();
799
+ });
800
+ }
801
+ function requestSection(n, active) {
802
+ const others = state.picks.filter((p) => p !== active && hasContent(p)).length;
803
+ const note = h('textarea', {
804
+ 'aria-label': 'What should change?',
805
+ placeholder: 'What should change? For example: “Make it bigger and use the brand red”',
806
+ oninput: (event) => {
807
+ const had = hasContent(active);
808
+ active.note = event.target.value;
809
+ persist();
810
+ if (had !== hasContent(active))
811
+ renderDock();
812
+ },
813
+ });
814
+ note.value = active.note;
815
+ return h('div', { class: 'sec' }, [
816
+ h('div', { class: 'label' }, ['Change']),
817
+ scopeField(n, active),
818
+ note,
819
+ others
820
+ ? h('div', { class: 'plain more' }, [
821
+ `This request also has ${others} other ${others === 1 ? 'change' : 'changes'}. `,
822
+ h('button', { class: 'link', type: 'button', onclick: () => open('changes', null, 'draft') }, [
823
+ 'Review all',
824
+ ]),
825
+ ])
826
+ : h('div', { class: 'hint-text' }, [
827
+ 'Described parts stay in this request when you select the next one.',
828
+ ]),
829
+ exportBlock(),
830
+ ]);
831
+ }
832
+ function builderSections(n) {
833
+ const children = n.children.filter((c) => c.kind !== 'text');
834
+ return [
835
+ section('About it', describeFor(n).map((line) => h('div', { class: 'plain' }, [line]))),
836
+ h('div', { class: 'sec' }, [
837
+ h('div', { class: 'nav' }, [
838
+ h('button', { type: 'button', onclick: () => walk('up') }, ['↑ Select the area around it']),
839
+ children.length && !isText(n)
840
+ ? h('button', { type: 'button', onclick: () => walk('down') }, ['↓ Inside'])
841
+ : null,
842
+ h('button', { type: 'button', onclick: removeActive }, ['Remove']),
843
+ ]),
844
+ n.location
845
+ ? h('div', { class: 'file' }, [
846
+ `${n.location.file}:${n.location.line}`,
847
+ h('button', {
848
+ class: 'link',
849
+ type: 'button',
850
+ onclick: () => void copy(`${n.location.file}:${n.location.line}`),
851
+ }, ['Copy']),
852
+ ])
853
+ : null,
854
+ ]),
855
+ ];
856
+ }
857
+ function developerSections(n, active) {
858
+ const children = n.children.filter((c) => c.kind !== 'text' || c.text);
859
+ return [
860
+ section('Where', [
861
+ where(n.page ? 'page' : 'view', n.location),
862
+ n.page ? where('route', n.page.routeLocation) : null,
863
+ n.component ? where('component', n.component.declaration) : null,
864
+ n.events.length ? where('machine', n.machine) : null,
865
+ excerpt(n),
866
+ ]),
867
+ section('Component', n.component
868
+ ? [
869
+ h('div', {}, [
870
+ h('span', { class: 'chip red' }, [n.component.ref]),
871
+ h('span', { class: 'chip' }, [
872
+ `used in ${n.component.uses} ${n.component.uses === 1 ? 'place' : 'places'}`,
873
+ ]),
874
+ ...Object.entries(n.component.variant).map(([k, v]) => h('span', { class: 'chip' }, [`${k}=${v}`])),
875
+ ]),
876
+ ]
877
+ : []),
878
+ section('Head', n.page
879
+ ? [
880
+ ...['title', 'description', 'image'].map((f) => h('div', { class: 'row' }, [h('b', {}, [`${f} `]), n.page.head[f] ?? '—'])),
881
+ h('div', { class: 'row' }, [h('b', {}, ['views ']), n.page.views.join(', ')]),
882
+ n.page.head.noindex ? h('div', { class: 'row' }, [h('b', {}, ['noindex'])]) : null,
883
+ n.page.head.query
884
+ ? h('div', { class: 'row' }, [h('b', {}, ['query ']), n.page.head.query])
885
+ : null,
886
+ ]
887
+ : []),
888
+ section('Text', textRows(n)),
889
+ section('Shown when', n.conditions.map((c) => h('div', { class: 'row' }, [
890
+ h('b', {}, [`${c.kind} `]),
891
+ c.detail,
892
+ c.location ? ` ${c.location.file}:${c.location.line}` : '',
893
+ ]))),
894
+ section('Behaviour', n.events.map((e) => h('div', { class: 'row' }, [
895
+ h('span', { class: 'chip red' }, [`on ${e.dom}`]),
896
+ h('span', { class: 'chip' }, [e.event]),
897
+ ...e.transitions.map((t) => h('span', { class: 'chip' }, [`${t.from} → ${t.to}${t.guarded ? ' if …' : ''}`])),
898
+ ]))),
899
+ section('Inside', [
900
+ children.length
901
+ ? h('div', { class: 'kids' }, children.map((c) => h('button', {
902
+ type: 'button',
903
+ onclick: () => choose(active, c.id, c.kind === 'text' ? (c.source?.detail ?? '') : active.visible),
904
+ }, [c.kind === 'text' ? `text ${c.text}` : `${c.kind} ${c.id.split('/').slice(-1)[0]}`])))
905
+ : null,
906
+ h('div', { class: 'nav' }, [
907
+ h('button', { type: 'button', onclick: () => walk('up') }, ['↑ Parent']),
908
+ isText(n) ? null : h('button', { type: 'button', onclick: () => walk('down') }, ['↓ Child']),
909
+ h('button', { type: 'button', onclick: removeActive }, ['Remove']),
910
+ ]),
911
+ ]),
912
+ h('div', { class: 'sec meta' }, [`node ${n.id}`, n.pointer ? ` · ${n.pointer}` : '']),
913
+ ];
914
+ }
915
+ async function renderPanel() {
916
+ placePanel();
917
+ if (state.panel === 'changes')
918
+ return state.opened ? renderRequest(state.opened) : renderChanges();
919
+ if (state.panel === 'settings')
920
+ return renderSettings();
921
+ if (state.panel === 'layers')
922
+ return renderLayers(panel, await tree(win.location.pathname), layersHost(() => open(state.picks.length ? 'inspector' : null)));
923
+ const active = state.picks[state.active];
924
+ if (state.panel !== 'inspector' || !active) {
925
+ panel.hidden = state.view !== 'workbench';
926
+ if (state.view === 'workbench')
927
+ panel.replaceChildren(h('div', { class: 'empty' }, [
928
+ h('b', {}, ['Nothing selected. ']),
929
+ 'Choose Select and click a part of the page, or pick one in Layers.',
930
+ ]));
931
+ return;
932
+ }
933
+ const n = await node(active.id);
934
+ if (!n) {
935
+ panel.hidden = false;
936
+ panel.replaceChildren(h('div', { class: 'empty' }, [
937
+ h('b', {}, ['This part is gone.']),
938
+ ' The code changed since you selected it: select it again.',
939
+ ]));
940
+ return;
941
+ }
942
+ const nodes = await Promise.all(state.picks.map((p) => node(p.id)));
943
+ panel.hidden = false;
944
+ panel.replaceChildren(...present([
945
+ h('div', { class: 'head' }, [
946
+ h('div', { class: 'kicker' }, [kicker(n)]),
947
+ h('h2', { class: 'title' }, titleFor(n, active)),
948
+ closeButton(() => open(null)),
949
+ state.picks.length > 1
950
+ ? h('div', { class: 'picks' }, state.picks.map((p, i) => h('button', {
951
+ type: 'button',
952
+ 'aria-current': String(i === state.active),
953
+ onclick: () => {
954
+ state.active = i;
955
+ persist();
956
+ void renderPanel();
957
+ },
958
+ }, [
959
+ `${i + 1} ${nodes[i] ? (state.audience === 'builder' ? friendlyName(nodes[i]) : labelOf(nodes[i])) : p.id}`,
960
+ ])))
961
+ : null,
962
+ ]),
963
+ ...(state.audience === 'builder' ? builderSections(n) : developerSections(n, active)),
964
+ textFor(n, active),
965
+ n.kind === 'element' || n.kind === 'component'
966
+ ? lookSection(elementOf(active), active.style ?? [], await theme(), state.audience === 'builder', (changes) => {
967
+ active.style = changes;
968
+ persist();
969
+ preview(elementOf(active), changes);
970
+ void renderPanel();
971
+ })
972
+ : null,
973
+ requestSection(n, active),
974
+ ]));
975
+ }
976
+ function tabs(current, saved) {
977
+ const tab = (name, label) => h('button', {
978
+ class: 'tab',
979
+ type: 'button',
980
+ 'aria-pressed': String(current === name),
981
+ onclick: () => open('changes', null, name),
982
+ }, [label]);
983
+ return h('div', { class: 'tabs' }, [
984
+ tab('draft', `This request · ${drafted().length}`),
985
+ tab('saved', `Saved · ${saved}`),
986
+ ]);
987
+ }
988
+ async function renderChanges() {
989
+ const list = await saved();
990
+ const current = state.tab;
991
+ panel.hidden = false;
992
+ const head = h('div', { class: 'head' }, [
993
+ h('div', { class: 'kicker' }, [current === 'draft' ? 'Not sent yet' : '.hozu/requests']),
994
+ h('h2', { class: 'title' }, ['Changes']),
995
+ closeButton(() => open(state.picks.length ? 'inspector' : null)),
996
+ tabs(current, list.length),
997
+ ]);
998
+ if (current === 'saved') {
999
+ const message = notice;
1000
+ notice = null;
1001
+ const result = h('div', { class: 'status', role: 'status' });
1002
+ const row = (r) => {
1003
+ let armed = false;
1004
+ const done = h('button', {
1005
+ class: 'link done',
1006
+ type: 'button',
1007
+ title: 'Done or not needed: remove it',
1008
+ onclick: async () => {
1009
+ if (!armed) {
1010
+ armed = true;
1011
+ done.textContent = 'Remove?';
1012
+ return;
1013
+ }
1014
+ await remove(r.number);
1015
+ void renderPanel();
1016
+ },
1017
+ }, ['Done']);
1018
+ return h('div', { class: 'req' }, [
1019
+ h('span', { class: 'n' }, [r.number]),
1020
+ h('button', { class: 't open-req', type: 'button', onclick: () => open('changes', r.number, 'saved') }, [r.title]),
1021
+ done,
1022
+ r.locations.length ? h('span', { class: 'l' }, [r.locations.join(' · ')]) : null,
1023
+ ]);
1024
+ };
1025
+ panel.replaceChildren(...present([
1026
+ head,
1027
+ message ? h('div', { class: 'notice' }, message) : null,
1028
+ ...(list.length
1029
+ ? [...list].reverse().map(row)
1030
+ : [h('div', { class: 'empty' }, ['No open requests. Done ones are removed.'])]),
1031
+ list.length
1032
+ ? h('div', { class: 'sec' }, [
1033
+ h('div', { class: 'choice' }, [
1034
+ h('button', {
1035
+ class: 'primary',
1036
+ type: 'button',
1037
+ onclick: async () => {
1038
+ const full = await Promise.all(list.map((r) => one(r.number)));
1039
+ await copy(joinRequests(full));
1040
+ status(result, 'ok', [
1041
+ `Copied all ${list.length} as one prompt. Paste it to your agent.`,
1042
+ ]);
1043
+ },
1044
+ }, [`Copy all ${list.length} for AI`]),
1045
+ h('span', { class: 'why' }, [
1046
+ 'One prompt with every open request: the fewest tokens and no file reads. Works in any chat.',
1047
+ ]),
1048
+ h('button', {
1049
+ type: 'button',
1050
+ onclick: async () => {
1051
+ await copy(openRequestsLine(list));
1052
+ status(result, 'ok', ['Copied the file paths.']);
1053
+ },
1054
+ }, ['Copy file paths only']),
1055
+ h('span', { class: 'why' }, [
1056
+ 'For an agent in this project that should read the files itself (or run hozu requests --full).',
1057
+ ]),
1058
+ ]),
1059
+ result,
1060
+ ])
1061
+ : null,
1062
+ ]));
1063
+ return;
1064
+ }
1065
+ const items = drafted();
1066
+ const nodes = await Promise.all(items.map((p) => node(p.id)));
1067
+ panel.replaceChildren(...present([
1068
+ head,
1069
+ items.length
1070
+ ? h('div', { class: 'sec' }, items.map((p, i) => {
1071
+ const n = nodes[i];
1072
+ return h('div', { class: 'item' }, [
1073
+ h('span', { class: 'n' }, [String(i + 1)]),
1074
+ h('div', { class: 'what' }, [
1075
+ h('b', {}, [n ? (state.audience === 'builder' ? friendlyName(n) : labelOf(n)) : p.id]),
1076
+ h('span', {}, [
1077
+ [
1078
+ p.note.trim(),
1079
+ p.style?.length
1080
+ ? `${p.style.length} style ${p.style.length === 1 ? 'change' : 'changes'}`
1081
+ : '',
1082
+ ]
1083
+ .filter(Boolean)
1084
+ .join(' · '),
1085
+ ]),
1086
+ ]),
1087
+ h('button', {
1088
+ class: 'link',
1089
+ type: 'button',
1090
+ onclick: () => {
1091
+ state.active = state.picks.indexOf(p);
1092
+ open('inspector');
1093
+ },
1094
+ }, ['Edit']),
1095
+ h('button', {
1096
+ class: 'link',
1097
+ type: 'button',
1098
+ onclick: () => {
1099
+ preview(elementOf(p), []);
1100
+ restoreText(elementOf(p), p.text);
1101
+ state.picks.splice(state.picks.indexOf(p), 1);
1102
+ state.active = Math.max(0, Math.min(state.active, state.picks.length - 1));
1103
+ persist();
1104
+ drawPicks();
1105
+ renderDock();
1106
+ void renderPanel();
1107
+ },
1108
+ }, ['Remove']),
1109
+ ]);
1110
+ }))
1111
+ : h('div', { class: 'empty' }, [
1112
+ h('b', {}, ['Nothing yet. ']),
1113
+ 'Choose Select, click a part and describe the change. Every described part is added here.',
1114
+ ]),
1115
+ items.length ? h('div', { class: 'sec' }, [exportBlock()]) : null,
1116
+ ]));
1117
+ }
1118
+ async function renderRequest(number) {
1119
+ panel.hidden = false;
1120
+ let r;
1121
+ try {
1122
+ r = await one(number);
1123
+ }
1124
+ catch {
1125
+ return open('changes', null, 'saved');
1126
+ }
1127
+ const result = h('div', { class: 'status', role: 'status' });
1128
+ let armed = false;
1129
+ const del = h('button', {
1130
+ type: 'button',
1131
+ class: 'danger',
1132
+ onclick: async () => {
1133
+ if (!armed) {
1134
+ armed = true;
1135
+ del.textContent = 'Click again to remove';
1136
+ return;
1137
+ }
1138
+ await remove(r.number);
1139
+ renderDock();
1140
+ open('changes', null, 'saved');
1141
+ },
1142
+ }, ['Done · remove']);
1143
+ panel.replaceChildren(h('div', { class: 'head' }, [
1144
+ h('button', { class: 'back', type: 'button', onclick: () => open('changes', null, 'saved') }, [
1145
+ '← Saved',
1146
+ ]),
1147
+ h('h2', { class: 'title' }, [r.title]),
1148
+ h('div', { class: 'kicker' }, [r.file]),
1149
+ closeButton(() => open(state.picks.length ? 'inspector' : null)),
1150
+ ]), h('div', { class: 'sec' }, [h('pre', { class: 'md' }, [r.markdown])]), h('div', { class: 'sec' }, [
1151
+ h('div', { class: 'actions' }, [
1152
+ h('button', {
1153
+ class: 'primary',
1154
+ type: 'button',
1155
+ onclick: async () => {
1156
+ await copy(`Do the Hozu request ${r.file}`);
1157
+ status(result, 'ok', [`Copied “Do the Hozu request ${r.file}”.`]);
1158
+ },
1159
+ }, ['Copy “do this request”']),
1160
+ h('button', {
1161
+ type: 'button',
1162
+ onclick: async () => {
1163
+ await copy(r.markdown);
1164
+ status(result, 'ok', ['Copied the whole request.']);
1165
+ },
1166
+ }, ['Copy request']),
1167
+ del,
1168
+ ]),
1169
+ result,
1170
+ ]));
1171
+ }
1172
+ function renderSettings() {
1173
+ panel.hidden = false;
1174
+ const choice = (value, label, detail) => h('label', { class: 'option' }, [
1175
+ h('input', {
1176
+ type: 'radio',
1177
+ name: 'audience',
1178
+ checked: state.audience === value,
1179
+ onchange: () => {
1180
+ state.audience = value;
1181
+ persist();
1182
+ renderDock();
1183
+ void renderPanel();
1184
+ },
1185
+ }),
1186
+ h('span', {}, [h('b', {}, [label]), h('span', {}, [detail])]),
1187
+ ]);
1188
+ panel.replaceChildren(h('div', { class: 'head' }, [
1189
+ h('div', { class: 'kicker' }, ['Hozu DevTools']),
1190
+ h('h2', { class: 'title' }, ['Settings']),
1191
+ closeButton(() => open(state.picks.length ? 'inspector' : null)),
1192
+ ]), h('div', { class: 'sec' }, [
1193
+ h('div', { class: 'label' }, ['Appearance']),
1194
+ h('div', { class: 'tabs', role: 'radiogroup', 'aria-label': 'Appearance' }, [
1195
+ ['system', 'System'],
1196
+ ['light', 'Light'],
1197
+ ['dark', 'Dark'],
1198
+ ].map(([value, label]) => h('button', {
1199
+ class: 'tab',
1200
+ type: 'button',
1201
+ role: 'radio',
1202
+ 'aria-checked': String(state.theme === value),
1203
+ 'aria-pressed': String(state.theme === value),
1204
+ onclick: () => {
1205
+ state.theme = value;
1206
+ persist();
1207
+ applyTheme();
1208
+ renderSettings();
1209
+ },
1210
+ }, [label]))),
1211
+ ]), h('div', { class: 'sec' }, [
1212
+ h('div', { class: 'label' }, ['Show']),
1213
+ h('fieldset', {}, [
1214
+ choice('builder', 'Builder', 'Plain words: what it is, what a change reaches. No code names.'),
1215
+ choice('developer', 'Developer', 'Files, the code excerpt, components, conditions, transitions and node ids.'),
1216
+ ]),
1217
+ ]), h('div', { class: 'sec' }, [
1218
+ h('div', { class: 'label' }, ['Requests']),
1219
+ h('label', { class: 'option' }, [
1220
+ h('input', {
1221
+ type: 'checkbox',
1222
+ checked: state.excerpt,
1223
+ onchange: (event) => {
1224
+ state.excerpt = event.target.checked;
1225
+ persist();
1226
+ },
1227
+ }),
1228
+ h('span', {}, [
1229
+ h('b', {}, ['Add the code excerpt']),
1230
+ h('span', {}, [
1231
+ 'Seven lines around each place. The agent reads the file anyway, so it is off by default.',
1232
+ ]),
1233
+ ]),
1234
+ ]),
1235
+ ]), h('div', { class: 'sec' }, [
1236
+ h('div', { class: 'label' }, ['Keys']),
1237
+ ...[
1238
+ ['Alt+Shift+S', 'Select on or off'],
1239
+ ['Click', 'Select'],
1240
+ ['Shift+click', 'Add to the request'],
1241
+ ['Alt+click', 'Select the area around it'],
1242
+ ['Double-click', 'Select a text'],
1243
+ ['↑ ↓', 'Around it / inside it'],
1244
+ ['Esc', 'Back to Browse'],
1245
+ ].map(([k, v]) => h('div', { class: 'keyrow' }, [h('kbd', {}, [k]), v])),
1246
+ ]));
1247
+ }
1248
+ const devices = [
1249
+ { name: 'Phone', width: 390, height: 844 },
1250
+ { name: 'Tablet', width: 820, height: 1180 },
1251
+ { name: 'Laptop', width: 1280, height: 800 },
1252
+ { name: 'Desktop', width: 1440, height: 900 },
1253
+ ];
1254
+ const left = h('div', { class: 'bench-left' });
1255
+ const stage = h('div', { class: 'stage' });
1256
+ const sizer = h('div', { class: 'sizer' });
1257
+ const bar = h('div', { class: 'bench-bar' });
1258
+ function retarget(frame) {
1259
+ listening.abort();
1260
+ const w = frame?.contentWindow ?? null;
1261
+ frameEl = w ? frame : null;
1262
+ win = w ?? window;
1263
+ doc = w?.document ?? document;
1264
+ listening = listen(win);
1265
+ hovered = null;
1266
+ mountOutline(doc);
1267
+ drawPicks();
1268
+ }
1269
+ let dragScale = null;
1270
+ function layout() {
1271
+ if (!frameEl)
1272
+ return;
1273
+ const box = stage.getBoundingClientRect();
1274
+ const { width, height } = state.device;
1275
+ const scale = dragScale ?? Math.min(1, (box.width - 48) / width, (box.height - 48) / height);
1276
+ sizer.style.width = `${width * scale}px`;
1277
+ sizer.style.height = `${height * scale}px`;
1278
+ frameEl.style.width = `${width}px`;
1279
+ frameEl.style.height = `${height}px`;
1280
+ frameEl.style.transform = `scale(${scale})`;
1281
+ const size = bar.querySelector('[data-size]');
1282
+ if (size)
1283
+ size.textContent = `${width} × ${height}${scale < 1 ? ` · ${Math.round(scale * 100)}%` : ''}`;
1284
+ }
1285
+ function setDevice(device) {
1286
+ state.device = {
1287
+ ...device,
1288
+ width: Math.max(320, Math.round(device.width)),
1289
+ height: Math.max(400, Math.round(device.height)),
1290
+ };
1291
+ persist();
1292
+ renderBar();
1293
+ layout();
1294
+ }
1295
+ function renderBar() {
1296
+ const mode = (label, value) => h('button', {
1297
+ class: 'mode',
1298
+ type: 'button',
1299
+ 'aria-pressed': String(state.mode === value),
1300
+ onclick: () => setMode(value),
1301
+ }, [label]);
1302
+ const select = h('select', {
1303
+ 'aria-label': 'Device',
1304
+ onchange: (e) => {
1305
+ const d = devices.find((x) => x.name === e.target.value);
1306
+ if (d)
1307
+ setDevice(d);
1308
+ },
1309
+ }, [
1310
+ ...devices.map((d) => h('option', { value: d.name, selected: d.name === state.device.name }, [`${d.name} ${d.width}`])),
1311
+ devices.some((d) => d.name === state.device.name)
1312
+ ? null
1313
+ : h('option', { value: 'Custom', selected: true }, ['Custom']),
1314
+ ]);
1315
+ bar.replaceChildren(...present([
1316
+ h('div', { class: 'grip' }, [h('img', { src: logo, alt: 'Hozu', width: '18', height: '18' })]),
1317
+ h('div', { class: 'seg' }, [mode('Browse', 'browse'), mode('Select', 'select')]),
1318
+ select,
1319
+ h('button', {
1320
+ class: 'act',
1321
+ type: 'button',
1322
+ title: 'Rotate',
1323
+ 'aria-label': 'Rotate',
1324
+ onclick: () => setDevice({ name: state.device.name, width: state.device.height, height: state.device.width }),
1325
+ }, ['⟲']),
1326
+ h('span', { class: 'size', 'data-size': true }),
1327
+ h('span', { class: 'spacer' }),
1328
+ held()
1329
+ ? h('button', { class: 'previewing', type: 'button', onclick: () => hold(null, reload) }, [
1330
+ 'Preview · Exit',
1331
+ ])
1332
+ : null,
1333
+ h('button', {
1334
+ class: 'act',
1335
+ type: 'button',
1336
+ onclick: () => open(state.panel === 'changes' ? null : 'changes', null, drafted().length ? 'draft' : 'saved'),
1337
+ }, ['Changes', h('span', { class: 'count' }, [drafted().length ? String(drafted().length) : ''])]),
1338
+ h('button', {
1339
+ class: 'act gear',
1340
+ type: 'button',
1341
+ 'aria-label': 'Settings',
1342
+ onclick: () => open(state.panel === 'settings' ? null : 'settings'),
1343
+ }, ['⚙']),
1344
+ h('button', { class: 'act exit', type: 'button', onclick: () => closeBench() }, ['Exit workbench']),
1345
+ ]));
1346
+ }
1347
+ async function renderLeft() {
1348
+ await renderLayers(left, await tree(win.location.pathname), layersHost(() => { }));
1349
+ }
1350
+ function openBench() {
1351
+ state.view = 'workbench';
1352
+ persist();
1353
+ root.classList.add('benching');
1354
+ bench.hidden = false;
1355
+ const frame = h('iframe', {
1356
+ src: location.href,
1357
+ title: 'The page in Workbench',
1358
+ 'data-hozu-bench': true,
1359
+ });
1360
+ frame.addEventListener('load', () => {
1361
+ retarget(frame);
1362
+ renderBar();
1363
+ layout();
1364
+ void renderLeft();
1365
+ void renderPanel();
1366
+ });
1367
+ const grip = h('div', { class: 'resize', title: 'Drag to resize', 'aria-hidden': 'true' });
1368
+ grip.addEventListener('pointerdown', (event) => {
1369
+ event.preventDefault();
1370
+ grip.setPointerCapture(event.pointerId);
1371
+ const start = { x: event.clientX, y: event.clientY, ...state.device };
1372
+ dragScale = sizer.getBoundingClientRect().width / state.device.width;
1373
+ sizer.classList.add('dragging');
1374
+ let next = { ...state.device };
1375
+ let queued = 0;
1376
+ const move = (e) => {
1377
+ next = {
1378
+ name: 'Custom',
1379
+ width: Math.max(320, Math.round(start.width + (e.clientX - start.x) / (dragScale ?? 1))),
1380
+ height: Math.max(400, Math.round(start.height + (e.clientY - start.y) / (dragScale ?? 1))),
1381
+ };
1382
+ if (queued)
1383
+ return;
1384
+ queued = requestAnimationFrame(() => {
1385
+ queued = 0;
1386
+ state.device = next;
1387
+ layout();
1388
+ });
1389
+ };
1390
+ const up = () => {
1391
+ grip.removeEventListener('pointermove', move);
1392
+ grip.removeEventListener('pointerup', up);
1393
+ grip.removeEventListener('pointercancel', up);
1394
+ cancelAnimationFrame(queued);
1395
+ dragScale = null;
1396
+ sizer.classList.remove('dragging');
1397
+ setDevice(next);
1398
+ };
1399
+ grip.addEventListener('pointermove', move);
1400
+ grip.addEventListener('pointerup', up);
1401
+ grip.addEventListener('pointercancel', up);
1402
+ });
1403
+ sizer.replaceChildren(frame, grip);
1404
+ stage.replaceChildren(sizer);
1405
+ bench.replaceChildren(left, h('div', { class: 'bench-center' }, [bar, stage]));
1406
+ renderBar();
1407
+ renderDock();
1408
+ requestAnimationFrame(layout);
1409
+ }
1410
+ function closeBench() {
1411
+ const at = frameEl?.contentWindow?.location.href;
1412
+ state.view = 'overlay';
1413
+ persist();
1414
+ if (at && at !== location.href)
1415
+ return void location.assign(at);
1416
+ retarget(null);
1417
+ root.classList.remove('benching');
1418
+ bench.hidden = true;
1419
+ bench.replaceChildren();
1420
+ renderDock();
1421
+ void renderPanel();
1422
+ }
1423
+ addEventListener('resize', layout);
1424
+ renderDock();
1425
+ drawPicks();
1426
+ void renderPanel();
1427
+ requestAnimationFrame(frame);
1428
+ if (state.view === 'workbench')
1429
+ openBench();
1430
+ //# sourceMappingURL=app.js.map