justskill-design 0.0.1 → 3.2.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 (67) hide show
  1. package/README.en.md +235 -0
  2. package/README.md +184 -11
  3. package/SKILL.md +435 -0
  4. package/canvas/app.mjs +2870 -0
  5. package/canvas/groups.mjs +204 -0
  6. package/canvas/index.html +141 -0
  7. package/canvas/styles.css +1032 -0
  8. package/canvas/tree.mjs +249 -0
  9. package/cordis.patch.yml +8 -0
  10. package/design.mjs +172 -0
  11. package/docs/dsh-workbench.png +0 -0
  12. package/dsh/client.js +864 -0
  13. package/dsh/index.js +354 -0
  14. package/dsh/scan.js +139 -0
  15. package/dsh/skill.js +41 -0
  16. package/lib/base-css.mjs +92 -0
  17. package/lib/detect.mjs +123 -0
  18. package/lib/diagnostics.mjs +382 -0
  19. package/lib/export-html.mjs +309 -0
  20. package/lib/export-svg.mjs +111 -0
  21. package/lib/fragment.mjs +702 -0
  22. package/lib/geometry.mjs +827 -0
  23. package/lib/icons.mjs +106 -0
  24. package/lib/levenshtein.mjs +133 -0
  25. package/lib/node/cli.mjs +387 -0
  26. package/lib/node/export-svg.mjs +75 -0
  27. package/lib/node/fs.mjs +309 -0
  28. package/lib/node/op-json.mjs +75 -0
  29. package/lib/node/raster-run.mjs +115 -0
  30. package/lib/node/server.mjs +1264 -0
  31. package/lib/node/static.mjs +206 -0
  32. package/lib/node/verbs/add.mjs +220 -0
  33. package/lib/node/verbs/export.mjs +161 -0
  34. package/lib/node/verbs/format.mjs +106 -0
  35. package/lib/node/verbs/import.mjs +126 -0
  36. package/lib/node/verbs/ls.mjs +98 -0
  37. package/lib/node/verbs/migrate.mjs +236 -0
  38. package/lib/node/verbs/move.mjs +76 -0
  39. package/lib/node/verbs/new.mjs +284 -0
  40. package/lib/node/verbs/remove.mjs +41 -0
  41. package/lib/node/verbs/rename.mjs +87 -0
  42. package/lib/node/verbs/serve.mjs +164 -0
  43. package/lib/node/verbs/set.mjs +150 -0
  44. package/lib/node/verbs/show.mjs +122 -0
  45. package/lib/node/verbs/tokens.mjs +167 -0
  46. package/lib/node/verbs/validate.mjs +130 -0
  47. package/lib/node/write-verbs.mjs +215 -0
  48. package/lib/normalize.mjs +288 -0
  49. package/lib/ops.mjs +2063 -0
  50. package/lib/parse.mjs +997 -0
  51. package/lib/raster.mjs +186 -0
  52. package/lib/readers/pen.mjs +1684 -0
  53. package/lib/receipt.mjs +203 -0
  54. package/lib/render.mjs +503 -0
  55. package/lib/serialize.mjs +441 -0
  56. package/lib/spans.mjs +177 -0
  57. package/lib/svg-path.mjs +483 -0
  58. package/lib/table.mjs +1047 -0
  59. package/lib/tokens.mjs +295 -0
  60. package/lib/validate.mjs +929 -0
  61. package/package.json +68 -6
  62. package/references/cookbook.md +156 -0
  63. package/references/images.md +171 -0
  64. package/references/product-flow.md +408 -0
  65. package/references/schema.md +90 -0
  66. package/references/styling.md +181 -0
  67. package/scripts/install.mjs +95 -0
package/canvas/app.mjs ADDED
@@ -0,0 +1,2870 @@
1
+ /**
2
+ * The canvas.
3
+ *
4
+ * Three rules hold this together, and each one is a decision:
5
+ *
6
+ * 1. The browser parses and renders the file itself. It imports the same
7
+ * `lib/parse.mjs` and `lib/render.mjs` the CLI validates with, so the canvas
8
+ * cannot disagree with the tool about what a file means. The service sends
9
+ * text, never a document tree.
10
+ *
11
+ * 2. Each page mounts inside its own shadow root. A page's tokens and page-frame
12
+ * rule get exactly one scope, so a design cannot restyle this chrome and this
13
+ * chrome cannot restyle a design. It is also why the canvas never has to
14
+ * write its own styles into the design file.
15
+ *
16
+ * 3. The canvas only ever sends ops, and the service only ever accepts them
17
+ * through the same write funnel the CLI uses. A drag is a `set` of
18
+ * `style:left` and `style:top`; there is no second write path to keep in
19
+ * sync, and no way for a drag to write something the validator would refuse.
20
+ */
21
+
22
+ import { parse, getAttr, pagesOf } from "/lib/parse.mjs";
23
+ import {
24
+ renderModel, canvasMountCss, pagePaperRadius, renderNodeHtml, styleMapOf, styleStringOf,
25
+ arrangePages, pageTree, resolveTokens, webfontLinks,
26
+ } from "/lib/render.mjs";
27
+ import { collectTokens, lintContrast } from "/lib/validate.mjs";
28
+ import { lintGeometry, overhangOf, MAX_REPORTED_PER_CODE } from "/lib/geometry.mjs";
29
+ import {
30
+ newView, pageRoots, isOpen, toggleNode, openPath, visibleNodes, hasChildren, pathTo,
31
+ expandAll, collapseAll, rungAt, selectedPage,
32
+ } from "./tree.mjs";
33
+ import {
34
+ groupedFields, controlFor, valuesFor, hintFor, rangeFor, scrubFor, isNumericValue,
35
+ } from "./groups.mjs";
36
+
37
+ // -- wire --------------------------------------------------------------------
38
+
39
+ const TOKEN = new URLSearchParams(location.search).get("t") ?? "";
40
+
41
+ /**
42
+ * Embedded mode. The DSH plugin iframes the canvas in a narrow right-sidebar
43
+ * tab; there, the layer tree and the inspector are chrome the reader does not
44
+ * need, and the bar has to fit a fraction of the width. The mode changes only
45
+ * presentation: the file, the ops and ⌘C keep working exactly as they do in a
46
+ * normal browser tab. It is opt-in with `?embed=1`, so opening the URL by hand
47
+ * is unchanged.
48
+ */
49
+ const EMBED = new URLSearchParams(location.search).get("embed") === "1";
50
+
51
+ /**
52
+ * One call to the service.
53
+ *
54
+ * The response object comes back with the payload, because the revision lives in
55
+ * a header and the canvas has to record it: it is how the canvas recognises its
56
+ * own write when the change arrives over the event stream, and how it knows the
57
+ * other writer got there first.
58
+ */
59
+ async function api(path, options = {}) {
60
+ const url = `/api/${path}?t=${encodeURIComponent(TOKEN)}`;
61
+ const init = { headers: {}, ...options };
62
+ if (options.body !== undefined) {
63
+ init.method = options.method ?? "POST";
64
+ init.headers["Content-Type"] = "application/json";
65
+ init.body = JSON.stringify(options.body);
66
+ }
67
+ const response = await fetch(url, init);
68
+ const type = response.headers.get("content-type") ?? "";
69
+ let payload = null;
70
+ try {
71
+ payload = type.includes("application/json") ? await response.json() : await response.text();
72
+ } catch {
73
+ payload = null;
74
+ }
75
+ return {
76
+ status: response.status,
77
+ ok: response.ok,
78
+ payload,
79
+ revision: response.headers.get("X-Design-Revision"),
80
+ };
81
+ }
82
+
83
+ // -- state -------------------------------------------------------------------
84
+
85
+ const state = {
86
+ file: "",
87
+ path: "",
88
+ text: "",
89
+ revision: "",
90
+ readOnly: false,
91
+ readOnlyReason: "",
92
+ doc: null,
93
+ model: null,
94
+ diagnostics: [],
95
+ selection: null,
96
+ hovering: null,
97
+ // No `mode`. There was a `mode: "select"` here with no reader anywhere in the canvas —
98
+ // a toggle nothing toggled and nothing consulted, which reads as if the canvas had
99
+ // states it does not have.
100
+ camera: { x: 40, y: 40, zoom: 1 },
101
+ boxes: new Map(),
102
+ canUndo: false,
103
+ canRedo: false,
104
+ busy: false,
105
+ // What the pointer is over, and which page it is over — ids repeat across
106
+ // pages, so the id alone does not name an element. The selection remembers its
107
+ // page for the same reason: the box has to be drawn around the copy the user
108
+ // picked, not around the first copy with that id in the document.
109
+ hoveringPage: null,
110
+ selectionPage: null,
111
+ // The layer tree: the nested nodes, what is open, and which row has the keyboard.
112
+ tree: [],
113
+ treeView: newView(),
114
+ treeFocus: null,
115
+ revealedFor: null,
116
+ lastSelection: null,
117
+ // Set by a selection made on the canvas: the row has to be opened to and
118
+ // scrolled to even when the selection did not change.
119
+ expandRow: false,
120
+ /*
121
+ * The rung the canvas is standing on, as a depth: 1 is "the page's own
122
+ * children", 2 is "their children", and 0 happens when someone selects a page
123
+ * itself. A depth rather than an element, because the solid box follows the
124
+ * pointer — hovering the next section along has to land on *that* section, and
125
+ * an element-shaped level could only ever name one of them.
126
+ */
127
+ levelDepth: 1,
128
+ // The inspector sections the user folded shut, by section id.
129
+ folded: new Set(),
130
+ };
131
+
132
+ /** Counts mounts, so a font callback from a replaced mount can be ignored. */
133
+ let mountGeneration = 0;
134
+
135
+ const ui = {
136
+ viewport: document.getElementById("viewport"),
137
+ canvas: document.getElementById("canvas"),
138
+ overlay: document.getElementById("overlay"),
139
+ layerTree: document.getElementById("layer-tree"),
140
+ treeExpand: document.getElementById("tree-expand"),
141
+ treeCollapse: document.getElementById("tree-collapse"),
142
+ panelLeft: document.getElementById("panel-left"),
143
+ panelRight: document.getElementById("panel-right"),
144
+ dividerLeft: document.getElementById("divider-left"),
145
+ dividerRight: document.getElementById("divider-right"),
146
+ toggleLeft: document.getElementById("toggle-left"),
147
+ toggleRight: document.getElementById("toggle-right"),
148
+ edgeLeft: document.getElementById("edge-left"),
149
+ edgeRight: document.getElementById("edge-right"),
150
+ inspector: document.getElementById("inspector"),
151
+ selectionName: document.getElementById("selection-name"),
152
+ fileName: document.getElementById("file-name"),
153
+ bar: document.querySelector(".bar"),
154
+ barLeft: document.querySelector(".bar-left"),
155
+ barRight: document.querySelector(".bar-right"),
156
+ revision: document.getElementById("revision"),
157
+ readonly: document.getElementById("readonly"),
158
+ zoomLabel: document.getElementById("zoom-label"),
159
+ problemCount: document.getElementById("problem-count"),
160
+ problemSummary: document.getElementById("problem-summary"),
161
+ problems: document.getElementById("problems"),
162
+ fixPane: document.getElementById("fix-pane"),
163
+ fixHint: document.getElementById("fix-hint"),
164
+ fixText: document.getElementById("fix-textarea"),
165
+ problemList: document.getElementById("problem-list"),
166
+ toast: document.getElementById("toast"),
167
+ };
168
+
169
+ let toastTimer = null;
170
+ function toast(message, kind = "") {
171
+ ui.toast.textContent = message;
172
+ ui.toast.className = `toast ${kind}`.trim();
173
+ ui.toast.hidden = false;
174
+ clearTimeout(toastTimer);
175
+ toastTimer = setTimeout(() => { ui.toast.hidden = true; }, kind === "refused" ? 7000 : 2600);
176
+ }
177
+
178
+ // -- camera ------------------------------------------------------------------
179
+
180
+ function applyCamera() {
181
+ const { x, y, zoom } = state.camera;
182
+ ui.canvas.style.transform = `translate(${x}px, ${y}px) scale(${zoom})`;
183
+ // The reciprocal of the zoom, so the chrome that lives *inside* the scaled
184
+ // element can put itself back at its own size: the page labels read it, and
185
+ // styles.css says why they have to. Written here rather than in a per-label
186
+ // loop because the camera is the only thing it depends on.
187
+ ui.canvas.style.setProperty("--chrome-unzoom", String(1 / zoom));
188
+ // The field's dots, in screen pixels: the pitch follows the camera between
189
+ // 14px and 44px, and the position follows the camera offset, so panning moves
190
+ // the paper under the pages instead of sliding them over a fixed texture.
191
+ // Below 30% a 26px dot grid would be 7.8px apart and read as noise, so the
192
+ // pitch stops shrinking and the dots go away entirely.
193
+ const pitch = Math.min(44, Math.max(14, 26 * zoom));
194
+ ui.viewport.style.backgroundSize = `${pitch}px ${pitch}px`;
195
+ const offsetX = ((x % pitch) + pitch) % pitch;
196
+ const offsetY = ((y % pitch) + pitch) % pitch;
197
+ ui.viewport.style.backgroundPosition = `${offsetX}px ${offsetY}px`;
198
+ ui.viewport.dataset.zoom = zoom < 0.3 ? "far" : "";
199
+ ui.zoomLabel.textContent = `${Math.round(zoom * 100)}%`;
200
+ }
201
+
202
+ function zoomAt(clientX, clientY, factor) {
203
+ const rect = ui.viewport.getBoundingClientRect();
204
+ const px = clientX - rect.left;
205
+ const py = clientY - rect.top;
206
+ const { x, y, zoom } = state.camera;
207
+ const raw = Math.min(4, Math.max(0.1, zoom * factor));
208
+ const next = crossesOne(zoom, raw) ? 1 : raw;
209
+ const scale = next / zoom;
210
+ state.camera = { zoom: next, x: px - (px - x) * scale, y: py - (py - y) * scale };
211
+ applyCamera();
212
+ drawOverlay();
213
+ }
214
+
215
+ /**
216
+ * 100% is a stop on the ladder, not a step that happens to land nearby.
217
+ *
218
+ * The zoom steps multiply by 1.15, so from the 10% a fresh canvas opens at they
219
+ * run ... 81% -> 94% -> 108% and step straight over 1:1. A design tool whose
220
+ * zoom cannot reach 1:1 is a tool where every look at the drawing is a scaled
221
+ * raster, and scaled text is soft text. A step that would cross 100% stops on it
222
+ * instead, in both directions.
223
+ */
224
+ function crossesOne(from, to) {
225
+ return (from < 1 && to > 1) || (from > 1 && to < 1);
226
+ }
227
+
228
+ function fitAll() {
229
+ const pages = state.model?.pages ?? [];
230
+ if (pages.length === 0) return;
231
+ const rect = ui.viewport.getBoundingClientRect();
232
+ let minX = Infinity;
233
+ let minY = Infinity;
234
+ let maxX = -Infinity;
235
+ let maxY = -Infinity;
236
+ for (const page of pages) {
237
+ const box = state.boxes.get(page.id);
238
+ const position = box?.position ?? { x: 0, y: 0 };
239
+ const width = box?.width ?? (parseFloat(page.width) || 390);
240
+ const height = box?.height ?? (parseFloat(page.height) || 844);
241
+ minX = Math.min(minX, position.x);
242
+ minY = Math.min(minY, position.y);
243
+ maxX = Math.max(maxX, position.x + width);
244
+ maxY = Math.max(maxY, position.y + height);
245
+ }
246
+ // The top of the viewport is chrome now: the bar floats over it and a page
247
+ // label sits above the page it names. Fitting the drawing into the whole
248
+ // viewport put the labels under the pills, so the usable area starts below
249
+ // them — 48px of bar plus one 8px gap.
250
+ const pad = 60;
251
+ const topPad = pad + 56;
252
+ const usableW = rect.width - pad * 2;
253
+ const usableH = rect.height - topPad - pad;
254
+ const zoom = Math.min(
255
+ 1.5,
256
+ Math.max(0.1, Math.min(usableW / (maxX - minX || 1), usableH / (maxY - minY || 1))),
257
+ );
258
+ /*
259
+ * The middle of every page, put in the middle of the viewport.
260
+ *
261
+ * This used to anchor the drawing's top-left corner `pad` pixels from the
262
+ * viewport's top-left, which is only centred when the pages and the window have
263
+ * the same shape. They rarely do — a phone-shaped column of artboards in a wide
264
+ * window left the whole right half empty and every page pinned to the corner,
265
+ * which reads as a canvas that failed to fit rather than one that fitted the
266
+ * wrong way.
267
+ */
268
+ state.camera = {
269
+ zoom,
270
+ x: rect.width / 2 - ((minX + maxX) / 2) * zoom,
271
+ y: topPad + usableH / 2 - ((minY + maxY) / 2) * zoom,
272
+ };
273
+ applyCamera();
274
+ drawOverlay();
275
+ }
276
+
277
+ // -- panels ------------------------------------------------------------------
278
+
279
+ /**
280
+ * Panel geometry: which panels are open, and how wide.
281
+ *
282
+ * This is chrome state and it stays chrome state. It is not written into the
283
+ * design file, it is not sent to the service, and it takes no part in a commit:
284
+ * opening a panel is not an edit, and a design file that changed because someone
285
+ * resized a sidebar would break the one promise this whole tool is built on.
286
+ *
287
+ * It is remembered in `localStorage`, keyed per browser rather than per file —
288
+ * which panel you like open is a fact about you, not about the document. A
289
+ * browser that refuses (private mode throws on write) gets the defaults every
290
+ * time: mildly annoying, never broken.
291
+ */
292
+ const PANEL_KEY = "justskill-design.canvas.panels";
293
+ const PANEL_MIN = 160;
294
+ const PANEL_GUTTER = 6; // must match the `--gutter-*` default in styles.css
295
+ const PANEL_DEFAULT = { left: 240, right: 260 };
296
+
297
+ const panels = {
298
+ left: { open: true, width: PANEL_DEFAULT.left },
299
+ right: { open: true, width: PANEL_DEFAULT.right },
300
+ };
301
+
302
+ function loadPanels() {
303
+ let saved = null;
304
+ try {
305
+ saved = JSON.parse(localStorage.getItem(PANEL_KEY) ?? "null");
306
+ } catch {
307
+ saved = null; // no storage, or something else owns that key
308
+ }
309
+ for (const side of ["left", "right"]) {
310
+ const entry = saved?.[side];
311
+ if (!entry) continue;
312
+ if (typeof entry.open === "boolean") panels[side].open = entry.open;
313
+ if (Number.isFinite(entry.width)) panels[side].width = entry.width;
314
+ }
315
+ // Read first, clamp after: a remembered width is clamped against the *other*
316
+ // remembered width, whichever order the two happen to be read in.
317
+ reclampPanels();
318
+ }
319
+
320
+ function savePanels() {
321
+ try {
322
+ localStorage.setItem(PANEL_KEY, JSON.stringify(panels));
323
+ } catch {
324
+ /* the canvas works without remembering anything */
325
+ }
326
+ }
327
+
328
+ /** The narrowest centre column that is still a canvas. */
329
+ const CANVAS_MIN = 160;
330
+
331
+ /**
332
+ * Never narrower than a usable column, and never so wide that the canvas — or
333
+ * the other panel — is squeezed out of the window.
334
+ *
335
+ * The ceiling used to be half the window per side, which is a rule that only
336
+ * holds while one panel is open. Two panels at half the window add up to more
337
+ * than the window: the centre track resolves to zero, and `overflow: hidden`
338
+ * then hides the canvas and the two buttons that live on it, with the state
339
+ * written to `localStorage` for the next visit. Measured against the space that
340
+ * is actually left, the two sides cannot starve the middle.
341
+ */
342
+ function clampPanel(side, width) {
343
+ const other = side === "left" ? "right" : "left";
344
+ const otherTrack = panels[other].open ? panels[other].width + PANEL_GUTTER : 0;
345
+ const ceiling = Math.max(PANEL_MIN, window.innerWidth - otherTrack - PANEL_GUTTER - CANVAS_MIN);
346
+ return Math.round(Math.min(Math.max(width, PANEL_MIN), ceiling));
347
+ }
348
+
349
+ /**
350
+ * Bring both panels back inside the window.
351
+ *
352
+ * Two passes, because the ceiling of one side is computed from the other: a
353
+ * shrink on the right can be what makes room on the left.
354
+ */
355
+ function reclampPanels() {
356
+ withPanelGeometry(() => {
357
+ for (const side of ["left", "right"]) panels[side].width = clampPanel(side, panels[side].width);
358
+ for (const side of ["right", "left"]) panels[side].width = clampPanel(side, panels[side].width);
359
+ });
360
+ }
361
+
362
+ /** The horizontal space the left panel takes from the viewport, in pixels. */
363
+ function leftTrackWidth() {
364
+ return panels.left.open ? panels.left.width + PANEL_GUTTER : 0;
365
+ }
366
+
367
+ /**
368
+ * Run a geometry change and keep the drawing where it was on screen.
369
+ *
370
+ * The canvas is anchored to the viewport's top-left corner, and collapsing or
371
+ * resizing the left panel moves exactly that corner — so the whole design slides
372
+ * sideways with it. A panel is chrome: changing one must not move the thing
373
+ * being designed. The correction is the change in the left track, measured
374
+ * rather than assumed, and the right panel needs none because it moves no
375
+ * corner.
376
+ */
377
+ function withPanelGeometry(change) {
378
+ const before = leftTrackWidth();
379
+ change();
380
+ applyPanels();
381
+ const after = leftTrackWidth();
382
+ if (after !== before) {
383
+ state.camera.x -= after - before;
384
+ applyCamera();
385
+ }
386
+ drawOverlay();
387
+ }
388
+
389
+ /**
390
+ * Write the geometry into the four variables the layout reads.
391
+ *
392
+ * `#stage` sizes its tracks from them and `.drawer` anchors its edges to them.
393
+ * A collapsed panel sets its own to 0, gutter included: a hidden panel that
394
+ * leaves a 6px slot behind is a hidden panel you can still see.
395
+ */
396
+ function applyPanels() {
397
+ const root = document.documentElement.style;
398
+ for (const side of ["left", "right"]) {
399
+ const { open, width } = panels[side];
400
+ root.setProperty(`--panel-${side}`, open ? `${width}px` : "0px");
401
+ root.setProperty(`--gutter-${side}`, open ? `${PANEL_GUTTER}px` : "0px");
402
+ }
403
+ // The bar leaves a 36px slot for a folded panel's tab (28px + the 8px gap the
404
+ // pills already use), so the tab and the pills share the top row instead of
405
+ // stacking. One variable per side; 0 when the panel is open and there is no tab.
406
+ root.setProperty("--edge-left", panels.left.open ? "0px" : "36px");
407
+ root.setProperty("--edge-right", panels.right.open ? "0px" : "36px");
408
+ ui.panelLeft.hidden = !panels.left.open;
409
+ ui.panelRight.hidden = !panels.right.open;
410
+ ui.dividerLeft.hidden = !panels.left.open;
411
+ ui.dividerRight.hidden = !panels.right.open;
412
+ // A focusable `separator` is a widget, and a widget reports its value. Written
413
+ // here rather than in the HTML so the bounds have one definition: `PANEL_MIN`
414
+ // and the same ceiling `clampPanel` uses.
415
+ for (const [side, divider] of [["left", ui.dividerLeft], ["right", ui.dividerRight]]) {
416
+ divider.setAttribute("aria-valuenow", String(panels[side].width));
417
+ divider.setAttribute("aria-valuemin", String(PANEL_MIN));
418
+ divider.setAttribute("aria-valuemax", String(Math.max(PANEL_MIN, Math.round(window.innerWidth / 2))));
419
+ }
420
+ // One panel, two homes for its control: the header when the panel is open,
421
+ // the edge tab when it is closed. Exactly one of them is visible, and both
422
+ // say what the same click will do.
423
+ applyBarFit();
424
+ for (const [side, toggle, edge] of [
425
+ ["left", ui.toggleLeft, ui.edgeLeft],
426
+ ["right", ui.toggleRight, ui.edgeRight],
427
+ ]) {
428
+ const name = side === "left" ? "layers panel" : "inspector";
429
+ const key = side === "left" ? "⌘\\" : "⇧⌘\\";
430
+ toggle.setAttribute("aria-pressed", String(panels[side].open));
431
+ toggle.title = `Hide the ${name} (${key})`;
432
+ edge.hidden = panels[side].open;
433
+ edge.title = `Show the ${name} (${key})`;
434
+ }
435
+ }
436
+
437
+ /**
438
+ * The widest bar that fits the room left between the panels.
439
+ *
440
+ * `styles.css` defines what each step hides. Measured rather than guessed: the
441
+ * groups are `flex: none`, so their widths are the content widths, and the only
442
+ * unknowns are the labels a step already hid. Five reads on a panel change is a
443
+ * fair price for "it never overflows at any window size".
444
+ */
445
+ const BAR_FIT_STEPS = ["full", "tight", "narrow", "mini", "micro"];
446
+ function applyBarFit() {
447
+ const rightTrack = panels.right.open ? panels.right.width + PANEL_GUTTER : 0;
448
+ // A closed panel leaves a 36px tab slot on its side (see `--edge-*` in
449
+ // applyPanels); the bar is inset by it, so the room for the pills is smaller
450
+ // than the window minus the panel tracks. Counting only the tracks made a
451
+ // closed pair look roomier than it is and let the bar overflow in embed mode.
452
+ const edge = (panels.left.open ? 0 : 36) + (panels.right.open ? 0 : 36);
453
+ const room = window.innerWidth - leftTrackWidth() - rightTrack - edge - 24; // the bar's two 12px insets
454
+ for (const fit of BAR_FIT_STEPS) {
455
+ ui.bar.dataset.fit = fit;
456
+ const needed = ui.barLeft.getBoundingClientRect().width + ui.barRight.getBoundingClientRect().width + 12;
457
+ if (needed <= room) return;
458
+ }
459
+ // Nothing fits: the narrowest step stays, and `.bar` is allowed to clip rather
460
+ // than to overlap the tabs. A window this narrow cannot draw a canvas anyway.
461
+ }
462
+
463
+ function setPanelWidth(side, width, { remember = true } = {}) {
464
+ withPanelGeometry(() => { panels[side].width = clampPanel(side, width); });
465
+ if (remember) savePanels();
466
+ }
467
+
468
+ function togglePanel(side) {
469
+ withPanelGeometry(() => { panels[side].open = !panels[side].open; });
470
+ savePanels();
471
+ }
472
+
473
+ /**
474
+ * Put focus on whichever control is still on screen.
475
+ *
476
+ * Closing a panel hides the header the button lives in, so the click would land
477
+ * focus on nothing and the next Tab would start from the top of the document.
478
+ */
479
+ function focusPanelControl(side) {
480
+ const target = panels[side].open
481
+ ? (side === "left" ? ui.toggleLeft : ui.toggleRight)
482
+ : (side === "left" ? ui.edgeLeft : ui.edgeRight);
483
+ target.focus();
484
+ }
485
+
486
+ /**
487
+ * A panel edge, dragged.
488
+ *
489
+ * Pointer capture is what makes this feel like an edge instead of a 6px strip:
490
+ * the pointer is free to leave the divider, and the width still follows it. The
491
+ * keyboard gets the same two keys the divider's `separator` role implies, so the
492
+ * layout is not mouse-only.
493
+ */
494
+ function initDivider(side, element) {
495
+ const outward = side === "left" ? 1 : -1; // which way grows this panel
496
+ let dragging = null;
497
+
498
+ element.addEventListener("pointerdown", (event) => {
499
+ if (event.button !== 0) return;
500
+ dragging = { pointerId: event.pointerId, startX: event.clientX, startWidth: panels[side].width };
501
+ capturePointer(element, event.pointerId);
502
+ element.classList.add("dragging");
503
+ event.preventDefault();
504
+ });
505
+
506
+ element.addEventListener("pointermove", (event) => {
507
+ if (!dragging || event.pointerId !== dragging.pointerId) return;
508
+ setPanelWidth(side, dragging.startWidth + (event.clientX - dragging.startX) * outward, { remember: false });
509
+ });
510
+
511
+ const end = (event) => {
512
+ if (!dragging || event.pointerId !== dragging.pointerId) return;
513
+ dragging = null;
514
+ element.classList.remove("dragging");
515
+ try { element.releasePointerCapture(event.pointerId); } catch { /* already released */ }
516
+ savePanels();
517
+ };
518
+ element.addEventListener("pointerup", end);
519
+ element.addEventListener("pointercancel", end);
520
+
521
+ element.addEventListener("dblclick", () => setPanelWidth(side, PANEL_DEFAULT[side]));
522
+
523
+ element.addEventListener("keydown", (event) => {
524
+ const step = event.shiftKey ? 40 : 8;
525
+ if (event.key === "ArrowLeft") setPanelWidth(side, panels[side].width - step * outward);
526
+ else if (event.key === "ArrowRight") setPanelWidth(side, panels[side].width + step * outward);
527
+ else return;
528
+ event.preventDefault();
529
+ // And it stops here. The canvas nudges the selected element on the same two
530
+ // keys, so a resize keypress that was allowed to bubble resized a panel *and*
531
+ // committed a one-pixel move — a write from a gesture that is supposed to
532
+ // touch nothing but chrome.
533
+ event.stopPropagation();
534
+ });
535
+ }
536
+
537
+ function wirePanels() {
538
+ loadPanels();
539
+ applyPanels();
540
+ initDivider("left", ui.dividerLeft);
541
+ initDivider("right", ui.dividerRight);
542
+ const flip = (side) => {
543
+ togglePanel(side);
544
+ focusPanelControl(side);
545
+ };
546
+ ui.toggleLeft.addEventListener("click", () => flip("left"));
547
+ ui.toggleRight.addEventListener("click", () => flip("right"));
548
+ for (const [side, edge] of [["left", ui.edgeLeft], ["right", ui.edgeRight]]) {
549
+ edge.addEventListener("click", () => flip(side));
550
+ }
551
+ }
552
+
553
+ // -- load and mount ----------------------------------------------------------
554
+
555
+ async function loadDocument({ keepCamera = false } = {}) {
556
+ const [stateResponse, fileResponse] = await Promise.all([api("state"), api("file")]);
557
+ if (fileResponse.status !== 200) {
558
+ toast(`Cannot read the file: ${fileResponse.status}`, "refused");
559
+ return;
560
+ }
561
+ state.file = stateResponse.payload?.file ?? state.file;
562
+ state.path = stateResponse.payload?.path ?? state.path;
563
+ state.readOnly = Boolean(stateResponse.payload?.readOnly);
564
+ state.readOnlyReason = stateResponse.payload?.readOnlyReason ?? "";
565
+ state.canUndo = Boolean(stateResponse.payload?.canUndo);
566
+ state.canRedo = Boolean(stateResponse.payload?.canRedo);
567
+ state.text = fileResponse.payload;
568
+ state.revision = fileResponse.revision ?? stateResponse.payload?.revision ?? "";
569
+
570
+ document.title = `${state.file} — JustSkill Design`;
571
+
572
+ mountAll();
573
+ if (!keepCamera) fitAll();
574
+ buildTree();
575
+ renderChrome();
576
+ }
577
+
578
+ /**
579
+ * The geometric findings, in the shape the drawer already renders.
580
+ *
581
+ * The service sends the file text and its metadata, never a document tree, so
582
+ * the browser is the one that decides what the file means. This check is pure,
583
+ * so it runs here on the same parse the canvas renders — the arithmetic and the
584
+ * drawing cannot disagree about what the file says.
585
+ *
586
+ * It is deliberately the only validator family the drawer shows. The rest of the
587
+ * report belongs to `node design.mjs validate`, which is where a writer reads it.
588
+ */
589
+ function geometryDiagnostics(doc) {
590
+ const { tokens } = collectTokens(doc);
591
+ return lintGeometry(doc, tokens).map((entry) => ({
592
+ severity: entry.severity,
593
+ code: entry.code,
594
+ message: entry.message,
595
+ target: entry.target,
596
+ path: entry.path,
597
+ hint: entry.hint,
598
+ }));
599
+ }
600
+
601
+ /**
602
+ * Contrast, as the drawer wants it.
603
+ *
604
+ * Contrast is in the drawer because of what the count on the Problems button
605
+ * means. It is read as "this file has this many problems", and the canvas is the
606
+ * only place a reader sees the drawing they are being told about. A file the
607
+ * canvas called clean and `design.mjs validate` called 22 warnings is the canvas
608
+ * lying by omission — the same disagreement between two readers of one result that
609
+ * the `add` receipt used to have.
610
+ *
611
+ * The rest of `validateDesign` is deliberately not run. Structure and property
612
+ * findings are about the file's shape, not the drawing, and a file being edited is
613
+ * allowed to be half-written; 43 rows of `structure/missing-id` in a layout tool is
614
+ * noise that buries the layout. `design.mjs validate` is the full report, and it is
615
+ * the one the write funnel consults.
616
+ */
617
+ function contrastDiagnostics(doc) {
618
+ const { tokens } = collectTokens(doc);
619
+ return lintContrast(doc, tokens).map((entry) => ({
620
+ severity: entry.severity ?? "warning",
621
+ code: entry.code,
622
+ message: entry.message,
623
+ target: entry.target,
624
+ path: entry.path,
625
+ hint: entry.hint,
626
+ }));
627
+ }
628
+
629
+ /**
630
+ * The box an absolutely positioned element is really laid out against.
631
+ *
632
+ * A child in flow is laid out by its parent, so the parent is the box to compare
633
+ * it with. An absolutely positioned child is not: it resolves against its nearest
634
+ * positioned ancestor, and a static parent in between is not that box. Comparing
635
+ * with it reported boxes as leaving a parent they were never inside — and the
636
+ * arithmetic pass in `lib/geometry.mjs` applies the same rule, so both passes name
637
+ * the same box and the merge can still collapse them.
638
+ *
639
+ * A page counts, the same way it does there, because a page may not be positioned.
640
+ */
641
+ function containingBlockOf(element) {
642
+ let node = element.parentElement;
643
+ while (node) {
644
+ const position = getComputedStyle(node).position;
645
+ if ((position && position !== "static") || node.getAttribute?.("data-type") === "page") return node;
646
+ node = node.parentElement;
647
+ }
648
+ return null;
649
+ }
650
+
651
+ /**
652
+ * What the browser can prove and the arithmetic cannot.
653
+ *
654
+ * `lib/geometry.mjs` works on the file, so it is blind to anything that needs a
655
+ * measurement: a run of text, the intrinsic size of an image. Here the page is
656
+ * mounted, so every one of those has a real box. This pass reads them.
657
+ *
658
+ * Three traps, each one a way to report something untrue:
659
+ *
660
+ * - **Zoom.** `applyCamera` puts `transform: scale(zoom)` on `ui.canvas`, so
661
+ * every rectangle is in screen pixels. Both rectangles are scaled the same way,
662
+ * so the comparison itself does not care — but the numbers in the message do, so
663
+ * everything is divided back to CSS pixels first.
664
+ * - **A repeated id.** A file can carry the same `data-id` twice — that is a
665
+ * validation error, and the canvas goes read-only for it, but the pages still
666
+ * mount and an op could not tell the two apart. A finding about one of them
667
+ * would name an element the writer cannot address, so a repeated id is skipped.
668
+ * - **Sub-pixel layout.** A fraction of a pixel of overhang is rounding, not a
669
+ * defect. `lib/geometry.mjs` holds the tolerance.
670
+ */
671
+ function measuredGeometryDiagnostics() {
672
+ const zoom = state.camera.zoom || 1;
673
+ const mounted = [];
674
+ const counts = new Map();
675
+
676
+ for (const host of ui.canvas.querySelectorAll(".page-box .page-host")) {
677
+ const root = host.shadowRoot;
678
+ if (!root) continue;
679
+ for (const element of root.querySelectorAll("[data-id]")) {
680
+ const id = element.getAttribute("data-id");
681
+ counts.set(id, (counts.get(id) || 0) + 1);
682
+ mounted.push([element, id]);
683
+ }
684
+ }
685
+
686
+ const worstByParent = new Map();
687
+ for (const [element, id] of mounted) {
688
+ if (counts.get(id) > 1) continue;
689
+ // A turned box has no axis-aligned answer: its painted rect is the bounding
690
+ // box, not the box. Saying anything about it would be a guess dressed as a
691
+ // measurement, so this pass says nothing — the same abstention
692
+ // `lib/geometry.mjs` makes for the arithmetic one.
693
+ if (rotationOfElement(element) !== 0) continue;
694
+ // Which box this element is measured against. A positioned element is the one
695
+ // case where the parent is the wrong answer; for everything else it is right,
696
+ // and it is also the element the finding has to name.
697
+ const selfStyle = getComputedStyle(element);
698
+ const parent = selfStyle.position === "absolute"
699
+ ? containingBlockOf(element)
700
+ : element.parentElement?.closest?.("[data-id]") ?? null;
701
+ if (!parent) continue;
702
+
703
+ const self = element.getBoundingClientRect();
704
+ if (self.width < 1 || self.height < 1) continue;
705
+ const outer = parent.getBoundingClientRect();
706
+ if (outer.width < 1 || outer.height < 1) continue;
707
+
708
+ // Back to CSS pixels, where `getComputedStyle` also lives.
709
+ const style = getComputedStyle(parent);
710
+ const num = (value) => parseFloat(value) || 0;
711
+ const padLeft = num(style.paddingLeft) + num(style.borderLeftWidth);
712
+ const padTop = num(style.paddingTop) + num(style.borderTopWidth);
713
+ const contentW = outer.width / zoom - padLeft - num(style.paddingRight) - num(style.borderRightWidth);
714
+ const contentH = outer.height / zoom - padTop - num(style.paddingBottom) - num(style.borderBottomWidth);
715
+
716
+ const left = (self.left - outer.left) / zoom - padLeft;
717
+ const top = (self.top - outer.top) / zoom - padTop;
718
+ const width = self.width / zoom;
719
+ const height = self.height / zoom;
720
+
721
+ const clipped = ["hidden", "auto", "scroll"].includes(style.overflow);
722
+ const measured = overhangOf(
723
+ { left, top, width, height },
724
+ { width: contentW, height: contentH },
725
+ { positioned: selfStyle.position === "absolute", clips: clipped },
726
+ );
727
+ if (!measured) continue;
728
+ const { over, code } = measured;
729
+
730
+ const parentId = parent.getAttribute("data-id");
731
+
732
+ // Who the finding is about, and how many of them to emit.
733
+ //
734
+ // `geometry/absolute-outside` is about the node that is out of place, so it
735
+ // is reported per node, exactly as `lib/geometry.mjs` reports it. The other
736
+ // two are about the frame that is too small: a row of three children that do
737
+ // not fit produces the same fact three times, and three copies of one fact is
738
+ // how a report stops being read. One per frame is also what the writer has to
739
+ // change, and it is what the arithmetic pass names, so the two passes
740
+ // describe the same defect the same way and the merge can collapse them.
741
+ const aboutChild = code === "geometry/absolute-outside";
742
+ const key = aboutChild ? `${code}|${id}` : `${code}|${parentId}`;
743
+ const previous = worstByParent.get(key);
744
+ if (previous && previous.over >= over) continue;
745
+ worstByParent.set(key, {
746
+ severity: "warning",
747
+ code,
748
+ target: aboutChild ? id : parentId,
749
+ over,
750
+ message: code === "geometry/absolute-outside"
751
+ ? `A positioned child ${Math.round(width)}x${Math.round(height)} at (${Math.round(left)}, ${Math.round(top)}) is not fully inside this box's content area of ${Math.round(contentW)}x${Math.round(contentH)}`
752
+ : code === "geometry/clipped"
753
+ ? `A child overhangs this box's content area of ${Math.round(contentW)}x${Math.round(contentH)} by ${Math.round(over)}px, and the box hides the rest`
754
+ : `A child overhangs this box's content area of ${Math.round(contentW)}x${Math.round(contentH)} by ${Math.round(over)}px`,
755
+ hint: `measured on the canvas: the widest child is ${Math.round(width)}x${Math.round(height)}, so this is the box the browser drew, not an estimate`,
756
+ });
757
+ }
758
+ return [...worstByParent.values()].map(({ over, ...entry }) => entry);
759
+ }
760
+
761
+ /**
762
+ * The arithmetic findings, with the measured ones given precedence.
763
+ *
764
+ * Both passes can name the same element and the same code. The measured one
765
+ * carries the box the browser actually drew, so it is the one worth reading.
766
+ *
767
+ * The per-code limit is applied **after** the merge, because the merge is where
768
+ * the findings the reader will see are finally assembled. `lintGeometry` keeps the
769
+ * limit on its own half, and the measured half can add more of a code the
770
+ * arithmetic half never saw at all — 25 frames whose text overflows are invisible
771
+ * to the arithmetic and 25 rows in the drawer is exactly the report §6 promises
772
+ * not to hand anyone.
773
+ */
774
+ function mergeGeometry(arithmetic, measured) {
775
+ const measuredKeys = new Set(measured.map((entry) => `${entry.code}|${entry.target}`));
776
+ const merged = [
777
+ ...arithmetic.filter((entry) => !measuredKeys.has(`${entry.code}|${entry.target}`)),
778
+ ...measured,
779
+ ];
780
+ const counts = new Map();
781
+ return merged.filter((entry) => {
782
+ const seen = counts.get(entry.code) || 0;
783
+ counts.set(entry.code, seen + 1);
784
+ return seen < MAX_REPORTED_PER_CODE;
785
+ });
786
+ }
787
+
788
+ function recomputeDiagnostics() {
789
+ if (!state.doc) return;
790
+ const arithmetic = geometryDiagnostics(state.doc);
791
+ state.diagnostics = [
792
+ ...(state.doc.diagnostics ?? []),
793
+ ...mergeGeometry(arithmetic, measuredGeometryDiagnostics()),
794
+ ...contrastDiagnostics(state.doc),
795
+ ];
796
+ // `renderProblems` writes the badge as well as the list, so the two cannot
797
+ // disagree. The badge used to be written only by `renderChrome`, which runs
798
+ // before the measured pass finishes: the drawer filled in and the badge went on
799
+ // showing the arithmetic count.
800
+ renderProblems();
801
+ }
802
+
803
+ /**
804
+ * Wait until the mounted pages can be measured.
805
+ *
806
+ * Text laid out in a fallback family is a different width, and an image with no
807
+ * bytes yet is 0x0. A box measured before either arrives is a box the reader never
808
+ * sees, reported as overflow that is not there. Fonts first, then the images that
809
+ * are still loading.
810
+ *
811
+ * The images are given a deadline. An image that never answers — a host that
812
+ * accepts the connection and then says nothing — must not leave the drawer
813
+ * showing a half-finished report for the rest of the session. Two seconds is
814
+ * longer than a local file takes and shorter than a reader's patience; an image
815
+ * that misses it is measured as it stands, and the next mount measures it again.
816
+ */
817
+ const IMAGE_WAIT_MS = 2000;
818
+
819
+ function layoutSettled() {
820
+ const fonts = typeof document.fonts?.ready?.then === "function" ? document.fonts.ready : Promise.resolve();
821
+ return fonts.then(() => {
822
+ const pending = [];
823
+ for (const host of ui.canvas.querySelectorAll(".page-box .page-host")) {
824
+ for (const image of host.shadowRoot?.querySelectorAll("img") ?? []) {
825
+ if (image.complete) continue;
826
+ pending.push(
827
+ new Promise((resolve) => {
828
+ const done = () => resolve();
829
+ image.addEventListener("load", done, { once: true });
830
+ image.addEventListener("error", done, { once: true });
831
+ }),
832
+ );
833
+ }
834
+ }
835
+ if (pending.length === 0) return undefined;
836
+ return Promise.race([
837
+ Promise.all(pending),
838
+ new Promise((resolve) => setTimeout(resolve, IMAGE_WAIT_MS)),
839
+ ]);
840
+ });
841
+ }
842
+
843
+ function mountAll() {
844
+ const started = performance.now();
845
+ const generation = ++mountGeneration;
846
+ state.doc = parse(state.text);
847
+ state.model = renderModel(state.doc);
848
+ // The parser's syntax findings, plus what the layout arithmetic can prove.
849
+ // The parser's syntax findings, plus what the file itself can be held to. The
850
+ // measured pass adds the rest once the page has settled.
851
+ state.diagnostics = [
852
+ ...(state.doc.diagnostics ?? []),
853
+ ...geometryDiagnostics(state.doc),
854
+ ...contrastDiagnostics(state.doc),
855
+ ];
856
+ state.boxes = new Map();
857
+ const positions = arrangePages(state.model.pages, { gap: 48, perRow: 4 });
858
+ state.model.pages.forEach((page, index) => {
859
+ const position = positions[index] ?? { x: 0, y: 0 };
860
+ const declaredHeight = parseFloat(page.height) || 0;
861
+ state.boxes.set(page.id, {
862
+ position,
863
+ width: parseFloat(page.width) || 390,
864
+ // The content decides, unless the page declares a height. The number here
865
+ // is provisional and `measurePageBoxes` replaces it before the camera
866
+ // fits the view.
867
+ height: declaredHeight || 844,
868
+ declaredHeight,
869
+ });
870
+ });
871
+
872
+ applyWebfonts();
873
+
874
+ ui.canvas.textContent = "";
875
+ for (const page of state.model.pages) {
876
+ ui.canvas.appendChild(buildPageBox(page));
877
+ }
878
+ measurePageBoxes();
879
+ applyCamera();
880
+ drawOverlay();
881
+ renderProblems();
882
+
883
+ // The measured pass has to wait until the page has settled. Text laid out in a
884
+ // fallback family is a different width, and an image with no bytes yet is 0x0; a
885
+ // box measured before either arrives is a box the reader never sees, reported as
886
+ // overflow that is not there. The arithmetic pass above needs no waiting, so the
887
+ // drawer is never empty while this runs.
888
+ //
889
+ // It runs once per mount. A later change — a resize, an edit, a font that
890
+ // arrives after its deadline — is measured by the next mount, not by this one.
891
+ layoutSettled().then(() => {
892
+ // A newer mount has already replaced this one; its own pass will run.
893
+ if (generation !== mountGeneration) return;
894
+ recomputeDiagnostics();
895
+ });
896
+
897
+ const ms = Math.round(performance.now() - started);
898
+ ui.revision.textContent = `${state.revision} · ${ms}ms`;
899
+ ui.readonly.hidden = !state.readOnly;
900
+ if (state.readOnly) ui.readonly.title = state.readOnlyReason;
901
+ // Ops need a document to apply to, so a file that does not parse can only be repaired
902
+ // by writing its text back. That is the one thing `/api/save` is for, and this button
903
+ // is the only client that calls it.
904
+ document.getElementById("fix-text").hidden = !state.readOnly;
905
+ }
906
+
907
+ /**
908
+ * Ask for the same fonts `export` declares.
909
+ *
910
+ * A design that wraps differently on the canvas than in the deliverable is a
911
+ * design the author approved without seeing. The canvas cannot inline a font, so
912
+ * it requests the same stylesheets the deliverable will, and a machine with no
913
+ * network falls back exactly as the deliverable would.
914
+ */
915
+ function applyWebfonts() {
916
+ for (const link of document.querySelectorAll("link[data-canvas-font]")) link.remove();
917
+ // One request per family for the whole file. One per PAGE is a request per
918
+ // page for the same family — ten stylesheets for two families, and the same
919
+ // face declared more than once.
920
+ const hrefs = new Set(webfontLinks(state.model));
921
+ for (const href of hrefs) {
922
+ const link = document.createElement("link");
923
+ link.rel = "stylesheet";
924
+ link.href = href;
925
+ link.dataset.canvasFont = "";
926
+ document.head.appendChild(link);
927
+ }
928
+ }
929
+
930
+ function buildPageBox(page) {
931
+ const box = state.boxes.get(page.id);
932
+ const element = document.createElement("div");
933
+ element.className = "page-box";
934
+ element.dataset.pageId = page.id;
935
+ element.style.left = `${box.position.x}px`;
936
+ element.style.top = `${box.position.y}px`;
937
+ element.style.width = `${box.width}px`;
938
+ // A page that declares no height is as tall as its content. Pinning it to a
939
+ // guessed 844 is how a page gets a scrollbar in the file and a hard cut on the
940
+ // canvas, with the bottom third of the design simply not drawn.
941
+ if (box.declaredHeight) element.style.height = `${box.declaredHeight}px`;
942
+
943
+ // The paper is the page's own shape. A page can round its own corners — a card
944
+ // artboard is a 24px card, not a screen — and the paper sits behind
945
+ // it: a paper with the chrome's own 10px radius under a 24px page shows a white
946
+ // crescent in every corner, a shape the page never drew. The page's radius
947
+ // reaches the paper and the clip, so a page that declares a height is cut at
948
+ // its own shape and not at the paper's.
949
+ const pageRadius = pagePaperRadius(page, state.model.tokens);
950
+ if (pageRadius) element.style.borderRadius = pageRadius;
951
+
952
+ const label = document.createElement("div");
953
+ label.className = "page-label";
954
+ label.textContent = page.name;
955
+ element.appendChild(label);
956
+
957
+ const host = document.createElement("div");
958
+ host.className = "page-host";
959
+ host.style.width = "100%";
960
+ host.style.height = box.declaredHeight ? "100%" : "auto";
961
+ host.style.overflow = box.declaredHeight ? "hidden" : "visible";
962
+ host.style.borderRadius = pageRadius || "9px";
963
+ const root = host.attachShadow({ mode: "open" });
964
+ const style = document.createElement("style");
965
+ // `canvasMountCss` is the file's rules plus the one rule the canvas owns: a
966
+ // page's outer margin is the document flow's business, and on the canvas the
967
+ // paper is exactly the page. See that function for what goes wrong without it.
968
+ style.textContent = canvasMountCss(state.model);
969
+ root.appendChild(style);
970
+
971
+ // The mount IS the page: same tag, same declarations, same `data-id`. The
972
+ // canvas used to mount the page's children into a plain block `<div>`, which
973
+ // threw away `display:flex`, `align-items`, `gap`, `padding` and the
974
+ // background in one go. Every child with no width of its own then filled the
975
+ // page instead of hugging its content, and every padded child grew by its own
976
+ // padding. The file said one thing; the canvas drew another.
977
+ const mount = document.createElement("section");
978
+ mount.setAttribute("data-id", page.id);
979
+ mount.setAttribute("data-type", "page");
980
+ mount.setAttribute("data-name", page.name);
981
+ mount.style.cssText = styleStringOf(page.declarations);
982
+ mount.innerHTML = page.markup;
983
+ root.appendChild(mount);
984
+ element.appendChild(host);
985
+
986
+ return element;
987
+ }
988
+
989
+ /** The height a page takes on the canvas: its own, or what its content needs. */
990
+ function measurePageBoxes() {
991
+ for (const page of state.model.pages) {
992
+ const box = state.boxes.get(page.id);
993
+ if (!box || box.declaredHeight) continue;
994
+ const host = ui.canvas.querySelector(`.page-box[data-page-id="${CSS.escape(page.id)}"] .page-host`);
995
+ const mount = host?.shadowRoot?.querySelector("section[data-type=\"page\"]");
996
+ if (!mount) continue;
997
+ const height = mount.getBoundingClientRect().height;
998
+ if (Number.isFinite(height) && height > 0) box.height = height;
999
+ }
1000
+ }
1001
+
1002
+ // -- mapping a click back to a data-id --------------------------------------
1003
+
1004
+ /** The nearest ancestor-or-self that carries a `data-id`. */
1005
+ function addressOf(node) {
1006
+ let current = node;
1007
+ while (current && current.nodeType === 1) {
1008
+ const id = current.getAttribute?.("data-id");
1009
+ if (id) return { id, element: current };
1010
+ current = current.parentNode;
1011
+ }
1012
+ return null;
1013
+ }
1014
+
1015
+ /**
1016
+ * What a canvas event is over, and *which page* it is over.
1017
+ *
1018
+ * The page is not decoration. The canvas mounts every page of the document, and a
1019
+ * page is a separate shadow root, so an id names one element *per page it appears
1020
+ * in* — the same id in two artboards is two mounts, and a lookup that does not say
1021
+ * which artboard to look in answers with the one somewhere else on the canvas.
1022
+ * The composed path knows: the page box is in it, after the element.
1023
+ */
1024
+ function targetFromEvent(event) {
1025
+ const path = event.composedPath ? event.composedPath() : [];
1026
+ const pageBox = path.find((entry) => entry?.classList?.contains("page-box")) ?? null;
1027
+ const pageId = pageBox?.dataset.pageId ?? null;
1028
+ for (const entry of path) {
1029
+ if (entry?.nodeType !== 1) continue;
1030
+ if (entry === pageBox) {
1031
+ const pageNode = state.model.pages.find((p) => p.id === pageId)?.node;
1032
+ return pageNode ? { id: pageId, element: entry, kind: "page", pageId } : null;
1033
+ }
1034
+ if (entry.hasAttribute?.("data-id")) {
1035
+ return { id: entry.getAttribute("data-id"), element: entry, kind: "element", pageId };
1036
+ }
1037
+ }
1038
+ return null;
1039
+ }
1040
+
1041
+ /** The live shadow-root element for a data-id, so the overlay can measure it. */
1042
+ function elementForId(id) {
1043
+ for (const host of ui.canvas.querySelectorAll("*")) {
1044
+ const root = host.shadowRoot;
1045
+ if (!root) continue;
1046
+ const found = root.querySelector(`[data-id="${CSS.escape(id)}"]`);
1047
+ if (found) return found;
1048
+ }
1049
+ const pageBox = ui.canvas.querySelector(`.page-box[data-page-id="${CSS.escape(id)}"]`);
1050
+ return pageBox ?? null;
1051
+ }
1052
+
1053
+ // -- overlay -----------------------------------------------------------------
1054
+
1055
+ /**
1056
+ * The boxes on the canvas, in the order they are painted.
1057
+ *
1058
+ * the selection solid, the saturated blue, with the four resize handles
1059
+ * the level solid, the same two pixels, lighter, no handles — what the
1060
+ * pointer is standing on
1061
+ * its children dashed, one pixel — what a double-click steps into
1062
+ * the one under the pointer dashed, two pixels and brighter, so "what will I
1063
+ * get" is not a guess between two boxes that overlap
1064
+ *
1065
+ * Weight says what kind of box it is — a level, or a child of one — and the
1066
+ * colour says *which* level: the committed one or the one the pointer is standing
1067
+ * on. The other way round made the hover box thinner than the children it was
1068
+ * framing, which is backwards; `styles.css` carries the measurements.
1069
+ *
1070
+ * The level is drawn as a box of its own only when it is not already the
1071
+ * selection, because two borders on one element reads as a mistake.
1072
+ */
1073
+ function drawOverlay() {
1074
+ ui.overlay.textContent = "";
1075
+ const rect = ui.viewport.getBoundingClientRect();
1076
+
1077
+ if (state.selection) {
1078
+ // The page it was picked in, when that is known; otherwise the id is all
1079
+ // there is and the first match is the answer, as it always was.
1080
+ const element = (state.selectionPage ? elementInPage(state.selectionPage, state.selection) : null)
1081
+ ?? elementForId(state.selection);
1082
+ if (element) ui.overlay.appendChild(boxOver(element, rect, "outline", { handles: true, id: state.selection }));
1083
+ }
1084
+
1085
+ const picture = hoverPicture();
1086
+ if (!picture) return;
1087
+
1088
+ if (picture.levelId !== state.selection) {
1089
+ const element = elementInPage(picture.pageId, picture.levelId);
1090
+ if (element) ui.overlay.appendChild(boxOver(element, rect, "outline level", { id: picture.levelId }));
1091
+ }
1092
+
1093
+ for (const child of picture.children) {
1094
+ const element = elementInPage(picture.pageId, child.id);
1095
+ if (!element) continue;
1096
+ const className = child.id === picture.underId ? "outline child under" : "outline child";
1097
+ ui.overlay.appendChild(boxOver(element, rect, className, { id: child.id }));
1098
+ }
1099
+ }
1100
+
1101
+ /**
1102
+ * The angle, in degrees, that an element and its ancestors are turned by.
1103
+ *
1104
+ * The format writes a rotation as `transform: rotate(<deg>)` and nothing else, so
1105
+ * the inline style is the whole truth: no computed matrix to parse, no camera
1106
+ * scale to divide out, no transition to race. The chain stops at the page,
1107
+ * because a page cannot be rotated (`style/transform-on-page`).
1108
+ */
1109
+ const INLINE_ROTATE = /rotate\(\s*(-?\d+(?:\.\d+)?)\s*deg\s*\)/;
1110
+
1111
+ function ownRotationOf(element) {
1112
+ const raw = element?.style?.transform;
1113
+ if (!raw) return 0;
1114
+ const match = INLINE_ROTATE.exec(raw);
1115
+ return match ? Number(match[1]) : 0;
1116
+ }
1117
+
1118
+ function rotationOfElement(element) {
1119
+ let total = 0;
1120
+ for (let node = element; node; node = node.parentElement) {
1121
+ total += ownRotationOf(node);
1122
+ if (node.getAttribute?.("data-type") === "page") break;
1123
+ }
1124
+ return total;
1125
+ }
1126
+
1127
+ /** Turn a vector by `-degrees`, which is how a screen delta becomes a local one. */
1128
+ function unturn(dx, dy, degrees) {
1129
+ if (!degrees) return [dx, dy];
1130
+ const radians = (degrees * Math.PI) / 180;
1131
+ const cos = Math.cos(radians);
1132
+ const sin = Math.sin(radians);
1133
+ return [dx * cos + dy * sin, -dx * sin + dy * cos];
1134
+ }
1135
+
1136
+ /**
1137
+ * A screen-space delta, expressed in the axes of the element's parent.
1138
+ *
1139
+ * `left` and `top` live in the parent's coordinate system, so a drag inside a
1140
+ * rotated card has to be unturned before it is written. The element's own
1141
+ * rotation is not part of this: turning a card does not change which way its
1142
+ * parent's axes point.
1143
+ */
1144
+ function parentDelta(element, dx, dy) {
1145
+ return unturn(dx, dy, rotationOfElement(element) - ownRotationOf(element));
1146
+ }
1147
+
1148
+ function boxOver(element, rect, className, { handles = false, id = null } = {}) {
1149
+ const box = element.getBoundingClientRect();
1150
+ const angle = rotationOfElement(element);
1151
+ const node = document.createElement("div");
1152
+ node.className = className;
1153
+ // Which element this box is around. Nothing reads it but a test and a
1154
+ // console; a box that cannot say what it is a box around is a box nobody can
1155
+ // debug.
1156
+ if (id) node.dataset.id = id;
1157
+ let width = box.width;
1158
+ let height = box.height;
1159
+ if (angle === 0) {
1160
+ node.style.left = `${box.left - rect.left}px`;
1161
+ node.style.top = `${box.top - rect.top}px`;
1162
+ } else {
1163
+ // A turned box has an axis-aligned bounding rect, which is bigger than the
1164
+ // box. Its centre is the same point either way, so the outline is drawn at
1165
+ // the element's real size around that centre and turned with it — otherwise
1166
+ // every tilted card would wear a box with four corners of air in it.
1167
+ const zoom = state.camera.zoom || 1;
1168
+ width = element.offsetWidth * zoom;
1169
+ height = element.offsetHeight * zoom;
1170
+ node.style.left = `${box.left - rect.left + box.width / 2 - width / 2}px`;
1171
+ node.style.top = `${box.top - rect.top + box.height / 2 - height / 2}px`;
1172
+ node.style.transform = `rotate(${angle}deg)`;
1173
+ }
1174
+ node.style.width = `${width}px`;
1175
+ node.style.height = `${height}px`;
1176
+ // At 10% a phone page is 39px wide and a card inside it is 32×15: four 9px
1177
+ // handles on that box cover the thing they are supposed to be grabbing, and a
1178
+ // corner drag at that size cannot be aimed anyway. The ring stays; the handles
1179
+ // wait until the box is big enough to have corners.
1180
+ if (width < 32 || height < 24) node.classList.add("small");
1181
+ if (!handles) return node;
1182
+ for (const [hx, hy] of [[0, 0], [1, 0], [0, 1], [1, 1]]) {
1183
+ const handle = document.createElement("div");
1184
+ handle.className = "handle";
1185
+ handle.dataset.handle = `${hx}${hy}`;
1186
+ handle.style.left = `${hx ? width : 0}px`;
1187
+ handle.style.top = `${hy ? height : 0}px`;
1188
+ node.appendChild(handle);
1189
+ }
1190
+ return node;
1191
+ }
1192
+
1193
+ // -- chrome ------------------------------------------------------------------
1194
+
1195
+ function renderChrome() {
1196
+ ui.fileName.textContent = state.file;
1197
+ renderLayerTree();
1198
+
1199
+ document.getElementById("undo").disabled = !state.canUndo || state.readOnly;
1200
+ document.getElementById("redo").disabled = !state.canRedo || state.readOnly;
1201
+ // A file that does not parse has nothing to export, and the service would answer 409.
1202
+ // A disabled button says so before the click.
1203
+ //
1204
+ // `Export` writes one page, so it needs a page to name. A selected element cannot name
1205
+ // one — a canvas shows every page at once, each a separate shadow root, so an id is not
1206
+ // unique — and the service's answer to a missing `page` is the first page, which is how
1207
+ // a canvas showing page four came to write page one. `Export all` asks nothing of the
1208
+ // selection.
1209
+ const page = selectedPage(state.model?.pages, state.selection, state.selectionPage);
1210
+ const exportButton = document.getElementById("export-page");
1211
+ exportButton.disabled = state.readOnly || !page;
1212
+ // A disabled button cannot be asked why, so its title carries the reason, and there
1213
+ // are two: a file that does not parse has nothing to export at all, while an
1214
+ // exportable file is only waiting for a page to be selected. "Select a page first" on
1215
+ // a file that cannot be exported is the one answer that would be wrong.
1216
+ exportButton.title = state.readOnly
1217
+ ? "The file does not parse, so there is nothing to export."
1218
+ : page
1219
+ ? `Write ${page.name} as a standalone HTML file`
1220
+ : "Select a page first — its row in the Layers panel, or its label on the canvas";
1221
+ document.getElementById("export-all").disabled = state.readOnly;
1222
+
1223
+ renderProblems();
1224
+ renderInspector();
1225
+ }
1226
+
1227
+ // -- the layer tree ----------------------------------------------------------
1228
+
1229
+ /**
1230
+ * Rebuild the nested tree from the model.
1231
+ *
1232
+ * Called when the document changes, not when the selection does: `pageTree()`
1233
+ * walks every element of every page, and doing that on each selection change
1234
+ * (which the first version of this panel did, at more than ten times the data)
1235
+ * is a walk whose result nobody edited.
1236
+ */
1237
+ function buildTree() {
1238
+ state.tree = state.model ? pageRoots(state.model.pages, (page) => pageTree(page.node)) : [];
1239
+ }
1240
+
1241
+ /**
1242
+ * The two buttons in the Layers header.
1243
+ *
1244
+ * They are the standing intent — "everything open" or "everything closed" — not
1245
+ * a loop over the rows that are open right now. That is what makes them O(1) on
1246
+ * a file with three thousand rows, and what makes an expanded branch survive:
1247
+ * the exceptions a person made with a chevron stay exceptions.
1248
+ */
1249
+ ui.treeExpand.addEventListener("click", () => {
1250
+ expandAll(state.treeView);
1251
+ renderLayerTree();
1252
+ });
1253
+
1254
+ ui.treeCollapse.addEventListener("click", () => {
1255
+ collapseAll(state.treeView);
1256
+ renderLayerTree();
1257
+ });
1258
+
1259
+ /** id -> <li>, rebuilt with the tree, so focus and scrolling need no selector. */
1260
+ let treeRows = new Map();
1261
+
1262
+ function renderLayerTree() {
1263
+ const hadFocus = ui.layerTree.contains(document.activeElement);
1264
+ // A row selected from the canvas has to be reachable — a selection hidden
1265
+ // inside a closed ancestor looks like a panel that lost it. Opened on a
1266
+ // *change* of selection, and on an explicit `expand` from the canvas, but not
1267
+ // on every render: doing that re-opened an ancestor the user had just
1268
+ // collapsed, because the chevron's own click renders the tree it folded, and
1269
+ // the chevron became a control that did nothing.
1270
+ const pending = state.expandRow;
1271
+ if (state.selection && (pending || state.selection !== state.lastSelection)) {
1272
+ openPath(state.tree, state.selection, state.treeView);
1273
+ }
1274
+ state.lastSelection = state.selection;
1275
+ state.expandRow = false;
1276
+
1277
+ ui.layerTree.textContent = "";
1278
+ treeRows = new Map();
1279
+
1280
+ if (state.tree.length === 0) {
1281
+ const empty = document.createElement("li");
1282
+ empty.className = "tree-empty";
1283
+ empty.textContent = state.model ? "No pages." : "Loading…";
1284
+ ui.layerTree.appendChild(empty);
1285
+ return;
1286
+ }
1287
+
1288
+ const visible = visibleNodes(state.tree, state.treeView);
1289
+ // Roving tabindex: exactly one row carries the tab stop, and it is the row the
1290
+ // arrow keys are standing on.
1291
+ if (!state.treeFocus || !visible.some((node) => node.id === state.treeFocus)) {
1292
+ state.treeFocus = state.selection && visible.some((node) => node.id === state.selection)
1293
+ ? state.selection
1294
+ : visible[0]?.id ?? null;
1295
+ }
1296
+
1297
+ // Rendered by recursing from the roots, not by walking the flat visible list:
1298
+ // the list contains a node *and* its descendants, so drawing from it would
1299
+ // draw every subtree once for the node and once for each of its ancestors.
1300
+ appendTreeItems(ui.layerTree, state.tree);
1301
+
1302
+ const selected = state.selection ? treeRows.get(state.selection) : null;
1303
+ if (selected && (pending || state.revealedFor !== state.selection)) {
1304
+ state.revealedFor = state.selection;
1305
+ selected.scrollIntoView({ block: "nearest", inline: "nearest" });
1306
+ }
1307
+ // Focus is only taken back if the tree had it: after an edit the canvas
1308
+ // re-renders, and stealing focus out of an inspector field mid-typing would
1309
+ // close the field the user is working in.
1310
+ if (hadFocus && state.treeFocus) treeRows.get(state.treeFocus)?.focus({ preventScroll: true });
1311
+ }
1312
+
1313
+ /**
1314
+ * Append a list of nodes, and the children of the ones that are open.
1315
+ *
1316
+ * The rows are a real nesting — a `<ul>` inside the `<li>` it belongs to — not
1317
+ * the flat list with `padding-left: depth * 13px` this panel used to draw. A flat
1318
+ * list looks the same and cannot be collapsed, and collapse is the reason the
1319
+ * panel exists.
1320
+ */
1321
+ function appendTreeItems(list, nodes) {
1322
+ for (const node of nodes) {
1323
+ const open = isOpen(node, state.treeView);
1324
+ const item = document.createElement("li");
1325
+ item.className = "tree-item";
1326
+ item.setAttribute("role", "treeitem");
1327
+ item.setAttribute("aria-level", String(node.depth + 1));
1328
+ item.setAttribute("aria-selected", String(state.selection === node.id));
1329
+ if (hasChildren(node)) item.setAttribute("aria-expanded", String(open));
1330
+ if (node.hidden) item.dataset.hidden = "true";
1331
+ item.dataset.nodeId = node.id;
1332
+ item.tabIndex = state.treeFocus === node.id ? 0 : -1;
1333
+ item.appendChild(treeRow(node));
1334
+
1335
+ if (open) {
1336
+ const children = document.createElement("ul");
1337
+ children.className = "tree-children";
1338
+ // A `treeitem`'s children are a group, not a list: the role is what makes
1339
+ // the nesting legible to anything that reads the tree rather than sees it.
1340
+ children.setAttribute("role", "group");
1341
+ appendTreeItems(children, node.children);
1342
+ item.appendChild(children);
1343
+ }
1344
+
1345
+ treeRows.set(node.id, item);
1346
+ list.appendChild(item);
1347
+ }
1348
+ }
1349
+
1350
+ function treeRow(node) {
1351
+ const row = document.createElement("div");
1352
+ row.className = "tree-row";
1353
+
1354
+ // The chevron is decorative: the state it shows is `aria-expanded` on the
1355
+ // item, which is what the keyboard and the screen reader read.
1356
+ const chevron = document.createElement("span");
1357
+ if (hasChildren(node)) chevron.className = "chevron";
1358
+ else chevron.className = "chevron leaf";
1359
+ chevron.setAttribute("aria-hidden", "true");
1360
+ row.appendChild(chevron);
1361
+
1362
+ const label = document.createElement("span");
1363
+ label.className = "tree-label";
1364
+ label.textContent = node.name || node.id || `(${node.tag})`;
1365
+ row.appendChild(label);
1366
+
1367
+ const type = document.createElement("span");
1368
+ type.className = "tree-type";
1369
+ type.textContent = node.type ?? node.tag;
1370
+ row.appendChild(type);
1371
+
1372
+ if (node.kind === "page") {
1373
+ const badge = document.createElement("span");
1374
+ badge.className = "badge";
1375
+ badge.textContent = pageBadge(node);
1376
+ row.appendChild(badge);
1377
+ }
1378
+ return row;
1379
+ }
1380
+
1381
+ function pageBadge(node) {
1382
+ const box = state.boxes.get(node.id);
1383
+ return box ? `${box.width}×${Math.round(box.height)}` : "";
1384
+ }
1385
+
1386
+ /**
1387
+ * The nodes from a page down to `id`, or an empty list if it is not in the tree.
1388
+ *
1389
+ * One walk, because everything the pointer needs is a *chain*: which rung to
1390
+ * stand on, which child is under the pointer, and what a click would select are
1391
+ * three readings of the same chain.
1392
+ */
1393
+ function pathNodesOf(id, pageId = null) {
1394
+ // Scoped to a page when the caller knows one, because a chain found in the
1395
+ // wrong page is a chain of the wrong elements.
1396
+ const root = pageId ? state.tree.filter((node) => node.id === pageId) : state.tree;
1397
+ const trail = [];
1398
+ const descend = (nodes) => {
1399
+ for (const node of nodes) {
1400
+ trail.push(node);
1401
+ if (node.id === id) return true;
1402
+ if (descend(node.children)) return true;
1403
+ trail.pop();
1404
+ }
1405
+ return false;
1406
+ };
1407
+ return descend(root) ? [...trail] : [];
1408
+ }
1409
+
1410
+ function treeNodeFor(id) {
1411
+ const path = pathNodesOf(id);
1412
+ return path.length > 0 ? path[path.length - 1] : null;
1413
+ }
1414
+
1415
+ /**
1416
+ * The element for `id`, inside the page it belongs to.
1417
+ *
1418
+ * Ids are unique in the file, but the canvas mounts every page of it, so the same
1419
+ * id can be on screen more than once when a file carries a duplicate. A lookup that
1420
+ * does not say *which page* can answer with the element in a different artboard,
1421
+ * and the box is then drawn around something off screen. Scoping the search to the
1422
+ * page the pointer is in is what keeps the boxes on the thing they name.
1423
+ */
1424
+ function elementInPage(pageId, id) {
1425
+ const pageBox = ui.canvas.querySelector(`.page-box[data-page-id="${CSS.escape(pageId)}"]`);
1426
+ if (!pageBox) return null;
1427
+ if (id === pageId) return pageBox;
1428
+ for (const host of pageBox.querySelectorAll("*")) {
1429
+ const root = host.shadowRoot;
1430
+ if (!root) continue;
1431
+ const found = root.querySelector(`[data-id="${CSS.escape(id)}"]`);
1432
+ if (found) return found;
1433
+ }
1434
+ return null;
1435
+ }
1436
+
1437
+ /**
1438
+ * What the pointer is offering: the level it is standing on, and the children
1439
+ * of that level — one of which a click will select.
1440
+ */
1441
+ function hoverPicture() {
1442
+ if (!state.hovering) return null;
1443
+ const path = pathNodesOf(state.hovering, state.hoveringPage);
1444
+ if (path.length === 0) return null;
1445
+ const rung = rungAt(path.map((node) => node.id), state.levelDepth);
1446
+ const level = path[rung.depth];
1447
+ return {
1448
+ pageId: path[0].id,
1449
+ levelId: rung.levelId,
1450
+ level,
1451
+ children: level.children ?? [],
1452
+ underId: rung.underId,
1453
+ insideChild: rung.insideChild,
1454
+ };
1455
+ }
1456
+
1457
+ /**
1458
+ * The element a gesture would select.
1459
+ *
1460
+ * A plain press takes the **level** — the box the pointer is standing on, the
1461
+ * same one the solid hover box draws — so clicking anywhere inside a card picks
1462
+ * the card and the drag that may follow moves the card. Stepping into the card
1463
+ * is a deliberate second gesture: `drill` takes the child under the pointer.
1464
+ */
1465
+ /**
1466
+ * Is this press the second of a double-click?
1467
+ *
1468
+ * The `dblclick` event cannot answer this: the press captures the pointer on the
1469
+ * viewport so a drag keeps receiving moves, and the spec sends `click` and
1470
+ * `dblclick` to the capture target — measured: the event arrives at the viewport
1471
+ * with no page in its composed path, so the element under the pointer is not in
1472
+ * it any more. Two presses close together in time and place are what a
1473
+ * double-click is, and here both are still measurable.
1474
+ *
1475
+ * The record is kept across presses rather than reset, so a third quick click
1476
+ * steps in again — each extra click goes one rung deeper, which is what the
1477
+ * gesture means everywhere else.
1478
+ */
1479
+ const DRILL_INTERVAL_MS = 400;
1480
+ const DRILL_SLOP_PX = 4;
1481
+ let lastPress = null;
1482
+
1483
+ function isRepeatPress(event, id) {
1484
+ const now = performance.now();
1485
+ const repeat = lastPress !== null
1486
+ && lastPress.id === id
1487
+ && now - lastPress.time <= DRILL_INTERVAL_MS
1488
+ && Math.abs(event.clientX - lastPress.x) <= DRILL_SLOP_PX
1489
+ && Math.abs(event.clientY - lastPress.y) <= DRILL_SLOP_PX;
1490
+ lastPress = { id, x: event.clientX, y: event.clientY, time: now };
1491
+ return repeat;
1492
+ }
1493
+
1494
+ function pressTarget(hoveredId, pageId = null, { drill = false } = {}) {
1495
+ const path = pathNodesOf(hoveredId, pageId);
1496
+ if (path.length === 0) return null;
1497
+ const rung = rungAt(path.map((node) => node.id), state.levelDepth);
1498
+ const index = drill && rung.insideChild ? rung.depth + 1 : rung.depth;
1499
+ return { pageId: path[0].id, node: path[index] };
1500
+ }
1501
+
1502
+ function setHovering(id, pageId = null) {
1503
+ if (state.hovering === id && state.hoveringPage === pageId) return;
1504
+ state.hovering = id;
1505
+ state.hoveringPage = pageId;
1506
+ drawOverlay();
1507
+ }
1508
+
1509
+ /** The page a tree row belongs to: the outermost row it sits in. */
1510
+ function pageIdOfTreeRow(item) {
1511
+ let root = item;
1512
+ for (let el = item?.parentElement; el && el.id !== "layer-tree"; el = el.parentElement) {
1513
+ if (el.classList.contains("tree-item")) root = el;
1514
+ }
1515
+ return root?.dataset.nodeId ?? null;
1516
+ }
1517
+
1518
+ /**
1519
+ * Nothing in the canvas is text to select while the canvas is being dragged.
1520
+ *
1521
+ * Two guards, because this event cannot do the whole job: `selectstart` does not
1522
+ * reach the viewport from inside a page's shadow root, so a listener here only
1523
+ * covers the chrome. The design's own text is covered by `.moving` — the page
1524
+ * hosts resolve their `user-select: auto` against the viewport's `none` — and by
1525
+ * cancelling the press itself in the drag branches. Scoped to a drag in progress:
1526
+ * outside one, selecting a string in a page still works the way it did.
1527
+ */
1528
+ ui.viewport.addEventListener("selectstart", (event) => {
1529
+ if (drag || ui.viewport.classList.contains("panning")) event.preventDefault();
1530
+ });
1531
+
1532
+ /**
1533
+ * The item a pointer event is really over.
1534
+ *
1535
+ * A child list is a `<ul>` inside the parent `<li>`, so the 13px indent band
1536
+ * beside a nested row belongs to the *parent's* item. Resolving `.tree-item`
1537
+ * alone therefore selected the parent when someone clicked just left of a child
1538
+ * — the canvas jumping to a different element than the one under the pointer —
1539
+ * and hover outlined the parent too. A row is what a row is; the band between
1540
+ * rows is nobody's, and does nothing.
1541
+ */
1542
+ function rowItemOf(target) {
1543
+ return target?.closest?.(".tree-row")?.closest(".tree-item") ?? null;
1544
+ }
1545
+
1546
+ /**
1547
+ * Rows are one delegated listener, not one per row.
1548
+ *
1549
+ * An imported design can be thousands of rows; two listeners each is a cost paid
1550
+ * on every render, and the row identity is already in `data-node-id`.
1551
+ */
1552
+ ui.layerTree.addEventListener("mouseover", (event) => {
1553
+ const item = rowItemOf(event.target);
1554
+ setHovering(item?.dataset.nodeId ?? null, item ? pageIdOfTreeRow(item) : null);
1555
+ });
1556
+ ui.layerTree.addEventListener("mouseleave", () => setHovering(null));
1557
+
1558
+ ui.layerTree.addEventListener("click", (event) => {
1559
+ const item = rowItemOf(event.target);
1560
+ const id = item?.dataset.nodeId;
1561
+ if (!id) return;
1562
+ const node = treeNodeFor(id);
1563
+ if (!node) return;
1564
+ state.treeFocus = id;
1565
+
1566
+ if (event.target.closest?.(".chevron") && hasChildren(node)) {
1567
+ // The chevron opens; it does not select. Selecting on the way to a chevron
1568
+ // is how a tree jumps the canvas to a different element than the one asked for.
1569
+ toggleNode(node, state.treeView);
1570
+ renderLayerTree();
1571
+ return;
1572
+ }
1573
+ item.focus({ preventScroll: true });
1574
+ select(id, { reveal: true, pageId: pageIdOfTreeRow(item) });
1575
+ });
1576
+
1577
+ /**
1578
+ * The keys the tree owns, and only those.
1579
+ *
1580
+ * The canvas already listens on `window` for the arrow keys — they nudge the
1581
+ * selected element one pixel, and that is a write to the file — so a key the
1582
+ * tree handles has to stop here. Declaring the set is what makes that true in
1583
+ * both directions: `⌘Z`, `Delete` and `Escape` still mean what they mean
1584
+ * everywhere else, and an arrow key pressed on a leaf, where the tree can do
1585
+ * nothing with it, is still an arrow key the tree consumed. Letting that one
1586
+ * fall through moved an element in the design by one pixel — a write nobody
1587
+ * asked for, from a keypress that looked inert.
1588
+ */
1589
+ const TREE_KEYS = new Set(["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Home", "End", "Enter", " "]);
1590
+
1591
+ ui.layerTree.addEventListener("keydown", (event) => {
1592
+ if (!TREE_KEYS.has(event.key)) return;
1593
+ event.preventDefault();
1594
+ event.stopPropagation();
1595
+
1596
+ const visible = visibleNodes(state.tree, state.treeView);
1597
+ if (visible.length === 0) return;
1598
+ const at = Math.max(0, visible.findIndex((node) => node.id === state.treeFocus));
1599
+ const node = visible[at];
1600
+ const standOn = (next) => {
1601
+ state.treeFocus = next;
1602
+ renderLayerTree();
1603
+ treeRows.get(next)?.focus({ preventScroll: true });
1604
+ };
1605
+
1606
+ switch (event.key) {
1607
+ case "ArrowDown": standOn(visible[Math.min(visible.length - 1, at + 1)].id); break;
1608
+ case "ArrowUp": standOn(visible[Math.max(0, at - 1)].id); break;
1609
+ case "ArrowRight":
1610
+ if (hasChildren(node) && !isOpen(node, state.treeView)) { toggleNode(node, state.treeView); renderLayerTree(); }
1611
+ else if (hasChildren(node)) standOn(visible[at + 1].id);
1612
+ break;
1613
+ case "ArrowLeft": {
1614
+ const path = pathTo(state.tree, node.id);
1615
+ if (hasChildren(node) && isOpen(node, state.treeView)) { toggleNode(node, state.treeView); renderLayerTree(); }
1616
+ else if (path.length > 1) standOn(path[path.length - 2]);
1617
+ break;
1618
+ }
1619
+ case "Home": standOn(visible[0].id); break;
1620
+ case "End": standOn(visible[visible.length - 1].id); break;
1621
+ case "Enter":
1622
+ case " ":
1623
+ select(node.id, { reveal: true, pageId: pageIdOfTreeRow(treeRows.get(node.id) ?? ui.layerTree) });
1624
+ break;
1625
+ }
1626
+ });
1627
+
1628
+ /**
1629
+ * The count on the Problems button.
1630
+ *
1631
+ * It is a summary of the list beside it, so it is written wherever the list is.
1632
+ * Keeping it inside `renderChrome` meant the measured pass — which only redrew the
1633
+ * list — left a badge reading 2 above a drawer holding 7 rows.
1634
+ */
1635
+ function renderProblemCount() {
1636
+ const errors = state.diagnostics.filter((d) => d.severity === "error").length;
1637
+ ui.problemCount.textContent = String(state.diagnostics.length);
1638
+ ui.problemCount.classList.toggle("has-errors", errors > 0);
1639
+ // The drawer's own subtitle: the same count, said in words. The button keeps
1640
+ // the number because a button is a shape you glance at; the sheet has room for
1641
+ // "2 errors · 1 warning".
1642
+ const warnings = state.diagnostics.length - errors;
1643
+ const parts = [];
1644
+ if (errors > 0) parts.push(`${errors} error${errors === 1 ? "" : "s"}`);
1645
+ if (warnings > 0) parts.push(`${warnings} warning${warnings === 1 ? "" : "s"}`);
1646
+ ui.problemSummary.textContent = parts.join(" · ");
1647
+ }
1648
+
1649
+ function renderProblems() {
1650
+ renderProblemCount();
1651
+ ui.problemList.textContent = "";
1652
+ if (state.diagnostics.length === 0) {
1653
+ const row = document.createElement("div");
1654
+ row.className = "issue empty";
1655
+ row.textContent = "No problems. Every check passed on this file.";
1656
+ ui.problemList.appendChild(row);
1657
+ return;
1658
+ }
1659
+ for (const diagnostic of state.diagnostics) {
1660
+ const row = document.createElement("div");
1661
+ row.className = "issue";
1662
+ const sev = document.createElement("span");
1663
+ sev.className = `sev ${diagnostic.severity}`;
1664
+ sev.textContent = diagnostic.severity;
1665
+ const body = document.createElement("div");
1666
+ body.className = "issue-body";
1667
+ const code = document.createElement("span");
1668
+ code.className = "code";
1669
+ code.textContent = diagnostic.code;
1670
+ const message = document.createElement("p");
1671
+ message.className = "msg";
1672
+ message.textContent = diagnostic.message;
1673
+ body.appendChild(code);
1674
+ body.appendChild(message);
1675
+ if (diagnostic.hint) {
1676
+ const hint = document.createElement("p");
1677
+ hint.className = "hint";
1678
+ hint.textContent = diagnostic.hint;
1679
+ body.appendChild(hint);
1680
+ }
1681
+ row.appendChild(sev);
1682
+ row.appendChild(body);
1683
+ if (diagnostic.target) {
1684
+ // A row that jumps to the element is a control, so the keyboard gets it
1685
+ // too: focusable, announced as a button, and Enter/Space do what a click
1686
+ // does. The ring it gets is the same one every other control has.
1687
+ row.tabIndex = 0;
1688
+ row.setAttribute("role", "button");
1689
+ row.addEventListener("click", () => select(diagnostic.target, { reveal: true, expand: true }));
1690
+ row.addEventListener("keydown", (event) => {
1691
+ if (event.key !== "Enter" && event.key !== " ") return;
1692
+ event.preventDefault();
1693
+ row.click();
1694
+ });
1695
+ }
1696
+ ui.problemList.appendChild(row);
1697
+ }
1698
+ }
1699
+
1700
+ // -- inspector ---------------------------------------------------------------
1701
+
1702
+ /**
1703
+ * The inspector: the selected element's own properties, in sections.
1704
+ *
1705
+ * Every row here edits one declaration of the selected element, and every edit
1706
+ * goes through `commit` — the same funnel the CLI writes through. There is no
1707
+ * second write path, so a value typed here is refused exactly when the CLI would
1708
+ * refuse it, and the refusal names the same reason.
1709
+ *
1710
+ * Only the properties the element actually carries are drawn. A property that is
1711
+ * not in the file is not a row: an empty field would be a promise that writing
1712
+ * it adds the declaration and an empty value is the same as no value, which is
1713
+ * not true of this format. (Adding a property is a separate feature.)
1714
+ */
1715
+ function renderInspector() {
1716
+ ui.inspector.textContent = "";
1717
+ const id = state.selection;
1718
+ if (!id) {
1719
+ ui.selectionName.textContent = "Nothing selected";
1720
+ const empty = document.createElement("p");
1721
+ empty.className = "empty";
1722
+ empty.textContent = "Click an element on the canvas, or a row in the layer tree.";
1723
+ ui.inspector.appendChild(empty);
1724
+ // The copy handshake is the way a change gets back to the agent, and an
1725
+ // empty inspector is exactly where a user has not heard of it yet.
1726
+ const copyHint = document.createElement("p");
1727
+ copyHint.className = "empty";
1728
+ copyHint.textContent = "Select an element and press ⌘C (Ctrl+C on Windows/Linux) to copy a pointer to it for the agent.";
1729
+ ui.inspector.appendChild(copyHint);
1730
+ return;
1731
+ }
1732
+ const node = findNode(id);
1733
+ if (!node) {
1734
+ ui.selectionName.textContent = id;
1735
+ const empty = document.createElement("p");
1736
+ empty.className = "empty";
1737
+ empty.textContent = "This element is no longer in the file.";
1738
+ ui.inspector.appendChild(empty);
1739
+ return;
1740
+ }
1741
+
1742
+ ui.selectionName.textContent = `${getAttr(node, "data-name") || id}`;
1743
+ const copyHint = document.createElement("p");
1744
+ copyHint.className = "empty";
1745
+ copyHint.textContent = "Press ⌘C (Ctrl+C on Windows/Linux) to copy a pointer to this element for the agent.";
1746
+ ui.inspector.appendChild(copyHint);
1747
+ ui.inspector.appendChild(identityBlock(id, node));
1748
+
1749
+ const styleMap = styleMapOf(node);
1750
+ for (const section of groupedFields(styleMap.keys())) {
1751
+ ui.inspector.appendChild(sectionBlock(id, section, styleMap));
1752
+ }
1753
+
1754
+ if (node.kind === "element" && ["p", "h1", "h2", "h3", "h4", "h5", "h6", "span"].includes(node.tag)) {
1755
+ ui.inspector.appendChild(contentBlock(id, node));
1756
+ }
1757
+ }
1758
+
1759
+ function sectionHead(id, label, folded) {
1760
+ const head = document.createElement("button");
1761
+ head.type = "button";
1762
+ head.className = "section-head";
1763
+ head.dataset.section = id;
1764
+ head.setAttribute("aria-expanded", String(!folded));
1765
+ head.textContent = label;
1766
+ return head;
1767
+ }
1768
+
1769
+ function focusSectionHead(id) {
1770
+ ui.inspector.querySelector(`.section-head[data-section="${id}"]`)?.focus();
1771
+ }
1772
+
1773
+ function identityBlock(id, node) {
1774
+ const identity = document.createElement("dl");
1775
+ identity.className = "attrs";
1776
+ for (const [label, value] of [
1777
+ ["data-id", id],
1778
+ ["data-type", getAttr(node, "data-type") ?? node.tag],
1779
+ ["tag", node.tag],
1780
+ ]) {
1781
+ const dt = document.createElement("dt");
1782
+ dt.textContent = label;
1783
+ const dd = document.createElement("dd");
1784
+ dd.textContent = value ?? "—";
1785
+ identity.appendChild(dt);
1786
+ identity.appendChild(dd);
1787
+ }
1788
+ return identity;
1789
+ }
1790
+
1791
+ /**
1792
+ * One section: a heading that folds it, and a body of rows.
1793
+ *
1794
+ * Folding is remembered in `state.folded`, not in the DOM: the inspector is
1795
+ * rebuilt on every selection, and a `<details>` that closed itself again on the
1796
+ * next click would be a control that forgets what it was told.
1797
+ */
1798
+ function sectionBlock(id, section, styleMap) {
1799
+ const wrap = document.createElement("div");
1800
+ wrap.className = "section";
1801
+ const folded = state.folded.has(section.id);
1802
+ if (folded) wrap.classList.add("folded");
1803
+
1804
+ const head = sectionHead(section.id, section.label, folded);
1805
+ head.addEventListener("click", () => {
1806
+ if (state.folded.has(section.id)) state.folded.delete(section.id);
1807
+ else state.folded.add(section.id);
1808
+ renderInspector();
1809
+ // The rebuild replaced the button that was just pressed, so a keyboard user
1810
+ // would lose their place on every fold. The tree restores focus the same way.
1811
+ focusSectionHead(section.id);
1812
+ });
1813
+ wrap.appendChild(head);
1814
+
1815
+ const body = document.createElement("div");
1816
+ body.className = "section-body";
1817
+ for (const row of section.rows) {
1818
+ const line = document.createElement("div");
1819
+ // A row holds one property or a pair. A lone property gets the whole row:
1820
+ // half a row is 105px in this panel, and a four-value `padding` does not fit
1821
+ // in it — the value was there, the field just cut it off. A pair still
1822
+ // shares the row, because the two numbers are read against each other.
1823
+ line.className = row.length === 1 ? "field-grid single" : "field-grid";
1824
+ for (const property of row) line.appendChild(buildField(id, property, styleMap.get(property)));
1825
+ body.appendChild(line);
1826
+ }
1827
+ wrap.appendChild(body);
1828
+ return wrap;
1829
+ }
1830
+
1831
+ /**
1832
+ * The text of a text element, which is not a declaration and so is its own row.
1833
+ *
1834
+ * Foldable like any other section, under the id `content` — not `text`, which is
1835
+ * the Text section's id and would fold two different things with one click.
1836
+ */
1837
+ function contentBlock(id, node) {
1838
+ const wrap = document.createElement("div");
1839
+ wrap.className = "section";
1840
+ const folded = state.folded.has("content");
1841
+ if (folded) wrap.classList.add("folded");
1842
+
1843
+ const head = sectionHead("content", "Content", folded);
1844
+ head.addEventListener("click", () => {
1845
+ if (state.folded.has("content")) state.folded.delete("content");
1846
+ else state.folded.add("content");
1847
+ renderInspector();
1848
+ focusSectionHead("content");
1849
+ });
1850
+ wrap.appendChild(head);
1851
+
1852
+ const body = document.createElement("div");
1853
+ body.className = "section-body";
1854
+ const field = document.createElement("div");
1855
+ field.className = "field";
1856
+ const label = document.createElement("label");
1857
+ label.textContent = "text";
1858
+ label.htmlFor = "field-content";
1859
+ field.appendChild(label);
1860
+
1861
+ const input = document.createElement("input");
1862
+ input.type = "text";
1863
+ input.id = "field-content";
1864
+ input.value = (node.children ?? []).filter((child) => child.kind === "text").map((child) => child.value).join("");
1865
+ input.disabled = state.readOnly;
1866
+ input.addEventListener("change", () => {
1867
+ commit([{ op: "set", target: id, prop: "text", value: input.value }]);
1868
+ });
1869
+ field.appendChild(input);
1870
+ body.appendChild(field);
1871
+ wrap.appendChild(body);
1872
+ return wrap;
1873
+ }
1874
+
1875
+ /**
1876
+ * One property, with the editor its grammar asks for.
1877
+ *
1878
+ * `controlFor` reads the property's declared kind out of `lib/table.mjs`; this
1879
+ * function is the only place that turns a kind into an element. The rule for
1880
+ * every branch is the same: never lose a value. A `select` that does not list the
1881
+ * current value gets the current value added to it, so an unusual spelling stays
1882
+ * visible and stays selectable instead of silently snapping to the first option.
1883
+ */
1884
+ function buildField(id, property, value) {
1885
+ const field = document.createElement("div");
1886
+ field.className = "field";
1887
+ const label = document.createElement("label");
1888
+ label.textContent = property;
1889
+ label.title = hintFor(property);
1890
+ field.appendChild(label);
1891
+
1892
+ const bare = String(value);
1893
+ const control = controlFor(property);
1894
+
1895
+ if (control === "segmented" || control === "select") {
1896
+ const values = valuesFor(property);
1897
+ const options = values.includes(bare) ? values : [...values, bare];
1898
+ if (control === "segmented") {
1899
+ field.appendChild(segmentedControl(id, property, bare, options));
1900
+ } else {
1901
+ // A segmented control is a group and names itself; a `select` is a control
1902
+ // and needs the label to point at it.
1903
+ const select = selectControl(id, property, bare, options);
1904
+ label.htmlFor = select.id;
1905
+ field.appendChild(select);
1906
+ }
1907
+ return field;
1908
+ }
1909
+
1910
+ if (control === "color" && /^#[0-9a-fA-F]{6,8}$/.test(bare)) {
1911
+ const input = document.createElement("input");
1912
+ input.type = "color";
1913
+ input.id = `field-${property}`;
1914
+ input.value = bare.slice(0, 7);
1915
+ input.disabled = state.readOnly;
1916
+ input.addEventListener("change", () => {
1917
+ commit([{ op: "set", target: id, prop: `style:${property}`, value: input.value }]);
1918
+ });
1919
+ label.htmlFor = input.id;
1920
+ field.appendChild(input);
1921
+ field.appendChild(valueLine(bare));
1922
+ return field;
1923
+ }
1924
+
1925
+ const input = textControl(id, property, bare, {
1926
+ // A number input sanitizes what it cannot parse to the empty string, which
1927
+ // would hide a value the file really carries (`opacity: var(--fade)`).
1928
+ type: control === "number" && isNumericValue(bare) ? "number" : "text",
1929
+ scrub: scrubFor(property, bare),
1930
+ ...rangeFor(property),
1931
+ });
1932
+ label.htmlFor = input.id;
1933
+ field.appendChild(input);
1934
+
1935
+ // A token is worth showing twice: once as what the file says, once as what the
1936
+ // browser resolved it to. `var(--color-ink)` is the intent; `#111827` is the
1937
+ // colour, and a designer checking contrast needs both.
1938
+ const resolved = resolveTokens(bare, state.model.tokens);
1939
+ if (resolved !== bare) field.appendChild(valueLine(`→ ${resolved}`));
1940
+ return field;
1941
+ }
1942
+
1943
+ function valueLine(text) {
1944
+ const line = document.createElement("div");
1945
+ line.className = "value-line";
1946
+ line.textContent = text;
1947
+ return line;
1948
+ }
1949
+
1950
+ function textControl(id, property, value, { type = "text", scrub = false, min, max } = {}) {
1951
+ const input = document.createElement("input");
1952
+ input.type = type;
1953
+ input.id = `field-${property}`;
1954
+ input.value = value;
1955
+ input.disabled = state.readOnly;
1956
+ const hint = hintFor(property);
1957
+ if (hint) input.placeholder = hint;
1958
+ // The full value on hover: a field can be narrower than its value, and the
1959
+ // value it holds is the one thing this control must not hide.
1960
+ input.title = value;
1961
+ if (min !== undefined) input.min = String(min);
1962
+ if (max !== undefined) input.max = String(max);
1963
+
1964
+ const send = (next) => commit([{ op: "set", target: id, prop: `style:${property}`, value: next }]);
1965
+ input.addEventListener("change", () => send(input.value));
1966
+ if (scrub) {
1967
+ input.className = "scrub";
1968
+ input.title = "Drag left or right to change, or type a value";
1969
+ attachScrub(input, send);
1970
+ }
1971
+ return input;
1972
+ }
1973
+
1974
+ function segmentedControl(id, property, value, options) {
1975
+ const group = document.createElement("div");
1976
+ group.className = "seg";
1977
+ group.setAttribute("role", "group");
1978
+ group.setAttribute("aria-label", property);
1979
+ for (const option of options) {
1980
+ const button = document.createElement("button");
1981
+ button.type = "button";
1982
+ button.className = "seg-option";
1983
+ if (option === value) button.classList.add("active");
1984
+ button.textContent = option;
1985
+ button.disabled = state.readOnly;
1986
+ button.addEventListener("click", () => {
1987
+ commit([{ op: "set", target: id, prop: `style:${property}`, value: option }]);
1988
+ });
1989
+ group.appendChild(button);
1990
+ }
1991
+ return group;
1992
+ }
1993
+
1994
+ function selectControl(id, property, value, options) {
1995
+ const select = document.createElement("select");
1996
+ select.id = `field-${property}`;
1997
+ select.disabled = state.readOnly;
1998
+ for (const option of options) {
1999
+ const entry = document.createElement("option");
2000
+ entry.value = option;
2001
+ entry.textContent = option;
2002
+ if (option === value) entry.selected = true;
2003
+ select.appendChild(entry);
2004
+ }
2005
+ select.addEventListener("change", () => {
2006
+ commit([{ op: "set", target: id, prop: `style:${property}`, value: select.value }]);
2007
+ });
2008
+ return select;
2009
+ }
2010
+
2011
+ /**
2012
+ * A number that scrubs.
2013
+ *
2014
+ * Only the value is previewed while the pointer moves; the op is sent on
2015
+ * release. Sending an op per pixel would fill the change log with steps nobody
2016
+ * asked for, and every one of them is an undo step.
2017
+ */
2018
+ function attachScrub(input, onCommit) {
2019
+ let startX = 0;
2020
+ let startValue = 0;
2021
+ let unit = "";
2022
+ let original = "";
2023
+ let moved = false;
2024
+ let active = false;
2025
+
2026
+ input.addEventListener("pointerdown", (event) => {
2027
+ if (event.button !== 0) return;
2028
+ active = true;
2029
+ moved = false;
2030
+ startX = event.clientX;
2031
+ original = input.value;
2032
+ const match = /^(-?\d+(?:\.\d+)?)(.*)$/.exec(input.value.trim());
2033
+ startValue = match ? Number(match[1]) : 0;
2034
+ unit = match ? match[2] : "";
2035
+ capturePointer(input, event.pointerId);
2036
+ event.preventDefault();
2037
+ });
2038
+
2039
+ input.addEventListener("pointermove", (event) => {
2040
+ if (!active) return;
2041
+ if (event.clientX !== startX) moved = true;
2042
+ const delta = Math.round((event.clientX - startX) / 2);
2043
+ input.value = `${startValue + delta}${unit}`;
2044
+ });
2045
+
2046
+ input.addEventListener("pointerup", (event) => {
2047
+ if (!active) return;
2048
+ active = false;
2049
+ try { input.releasePointerCapture(event.pointerId); } catch { /* released */ }
2050
+ if (!moved) {
2051
+ // A press that never moved is a press, not a drag. The value goes back
2052
+ // exactly as the file spells it — `0.50` is not rewritten to `0.5`, and
2053
+ // `var(--space-md)` cannot be re-read as `0` — so touching a field can
2054
+ // never be the thing that changed it.
2055
+ input.value = original;
2056
+ return;
2057
+ }
2058
+ const next = input.value.trim();
2059
+ if (next !== `${startValue}${unit}`) onCommit(next);
2060
+ });
2061
+ }
2062
+
2063
+ function findNode(id) {
2064
+ if (!state.doc) return null;
2065
+ let found = null;
2066
+ const visit = (node) => {
2067
+ if (found) return;
2068
+ if (node.kind === "element" && getAttr(node, "data-id") === id) { found = node; return; }
2069
+ for (const child of node.children ?? []) visit(child);
2070
+ };
2071
+ visit(state.doc);
2072
+ return found;
2073
+ }
2074
+
2075
+ // -- selection ---------------------------------------------------------------
2076
+
2077
+ /**
2078
+ * Select an element.
2079
+ *
2080
+ * `reveal` pans the canvas to it; `expand` opens the layer tree down to it. They
2081
+ * are two different questions and the answer differs by who asked: a row click
2082
+ * in the tree needs the canvas moved but the tree is already there, while a pick
2083
+ * on the canvas — or a row in the Problems drawer — has to open the tree, even
2084
+ * when the element was already the selection. Without that second half, picking
2085
+ * the same element twice, or picking it after "collapse all", leaves the panel
2086
+ * showing nothing selected, which reads as a panel that missed the click.
2087
+ */
2088
+ function select(id, options = {}) {
2089
+ state.selection = id;
2090
+ state.selectionPage = id ? options.pageId ?? null : null;
2091
+ // Selecting something is also standing on it: the canvas then shows it solid
2092
+ // and its own children dashed, ready for the next click down. Nothing selected
2093
+ // is back to the first rung — the page's own children.
2094
+ state.levelDepth = id ? pathNodesOf(id).length - 1 : 1;
2095
+ if (options.reveal) reveal(id);
2096
+ if (options.expand) state.expandRow = true;
2097
+ renderChrome();
2098
+ // The outline is drawn from `state.selection`, so a selection that does not
2099
+ // redraw it leaves the canvas boxing whatever was selected before. A click on
2100
+ // the canvas forces the redraw by moving the pointer over a different element
2101
+ // first; a row in the Problems drawer never touches the canvas at all, so it
2102
+ // was the case that showed it — the drawer named one element and the canvas
2103
+ // went on highlighting another, for as long as the pointer stayed off the page.
2104
+ drawOverlay();
2105
+ }
2106
+
2107
+ /** Bring an element into view. Camera only: `select` redraws the overlay once. */
2108
+ function reveal(id) {
2109
+ const element = elementForId(id);
2110
+ if (!element) return;
2111
+ const rect = ui.viewport.getBoundingClientRect();
2112
+ const box = element.getBoundingClientRect();
2113
+ const inside = box.left >= rect.left && box.right <= rect.right && box.top >= rect.top && box.bottom <= rect.bottom;
2114
+ if (inside) return;
2115
+ const zoom = state.camera.zoom;
2116
+ state.camera.x += rect.left + rect.width / 2 - (box.left + box.width / 2);
2117
+ state.camera.y += rect.top + rect.height / 2 - (box.top + box.height / 2);
2118
+ applyCamera();
2119
+ }
2120
+
2121
+ // -- writes ------------------------------------------------------------------
2122
+
2123
+ /**
2124
+ * Every write from the canvas goes through here, and every one of them goes
2125
+ * through the service's funnel: revalidate, refuse on a new error, write
2126
+ * atomically. The canvas has no privileged path.
2127
+ */
2128
+ /**
2129
+ * Write ops, re-render, then put the optimistic preview back.
2130
+ *
2131
+ * `onSettled` is what a drag uses to undo the styles it painted on the live
2132
+ * element while it followed the pointer. It runs at every exit — after the
2133
+ * re-render on success, after the re-render on a refusal, and immediately when
2134
+ * the write never happened (read-only, busy) — because the preview has to stay
2135
+ * on screen until the re-rendered element replaces it. Clearing it earlier is
2136
+ * the flash: the card jumps back to where it was dragged from and jumps to the
2137
+ * drop point again when the round trip lands.
2138
+ */
2139
+ async function commit(ops, { onSettled } = {}) {
2140
+ const settle = () => {
2141
+ try { onSettled?.(); } catch { /* cleanup must not mask the op result */ }
2142
+ };
2143
+ if (state.readOnly) {
2144
+ toast("The file is read only. Fix the problem it reports first.", "refused");
2145
+ settle();
2146
+ return;
2147
+ }
2148
+ if (state.busy) {
2149
+ settle();
2150
+ return;
2151
+ }
2152
+ state.busy = true;
2153
+ const originalText = state.text;
2154
+ try {
2155
+ const { status, payload } = await api("ops", { body: { baseRevision: state.revision, ops } });
2156
+ if (status === 200 && payload?.ok) {
2157
+ state.revision = payload.revision;
2158
+ state.canUndo = true;
2159
+ state.canRedo = false;
2160
+ await loadDocument({ keepCamera: true });
2161
+ return;
2162
+ }
2163
+ // A refusal is a result. The whole point of refusing is to show what the
2164
+ // edit would have introduced, so that is what the drawer shows.
2165
+ state.text = originalText;
2166
+ if (payload?.diagnostics?.length) {
2167
+ state.diagnostics = payload.diagnostics;
2168
+ renderProblems();
2169
+ ui.problems.hidden = false;
2170
+ toast(`Refused: ${payload.diagnostics[0].message}`, "refused");
2171
+ } else {
2172
+ toast(`Refused: ${payload?.code ?? status}`, "refused");
2173
+ }
2174
+ await loadDocument({ keepCamera: true });
2175
+ } catch (error) {
2176
+ toast(`The service did not accept that: ${error.message}`, "refused");
2177
+ } finally {
2178
+ state.busy = false;
2179
+ settle();
2180
+ }
2181
+ }
2182
+
2183
+ // -- gestures ----------------------------------------------------------------
2184
+
2185
+ let drag = null;
2186
+
2187
+ /**
2188
+ * Capture is an optimisation, not a requirement: it keeps the events coming when
2189
+ * the pointer leaves the element. It throws when there is no live pointer for
2190
+ * the id, and an uncaught throw here would abort the rest of the handler and
2191
+ * leave the interaction half-applied. So it is attempted, never trusted.
2192
+ */
2193
+ /**
2194
+ * Turn text selection off for the whole duration of a geometry drag.
2195
+ *
2196
+ * One class on the viewport, set at press and cleared on release, covering every
2197
+ * drag kind. `user-select: none` here also reaches the pages: the shadow host
2198
+ * resolves `auto` against it, so the design's own text stops being selectable
2199
+ * while an element is being moved — but only while it is being moved.
2200
+ */
2201
+ function setDragging(on) {
2202
+ ui.viewport.classList.toggle("moving", on);
2203
+ }
2204
+
2205
+ function capturePointer(element, pointerId) {
2206
+ try {
2207
+ element.setPointerCapture(pointerId);
2208
+ } catch {
2209
+ /* the drag still works without capture */
2210
+ }
2211
+ }
2212
+
2213
+ /**
2214
+ * A press on the canvas must also give this document keyboard focus.
2215
+ *
2216
+ * The selection path calls `preventDefault()` on `pointerdown`, which suppresses
2217
+ * the browser's normal focus change. That is invisible in a normal browser tab
2218
+ * (the tab already owns the keyboard), but this page is also embedded in a DSH
2219
+ * right-sidebar iframe: without taking focus, ⌘C goes to the host page and the
2220
+ * canvas never hears it, so the pointer is never copied.
2221
+ */
2222
+ function claimKeyboardFocus() {
2223
+ try {
2224
+ window.focus();
2225
+ } catch {
2226
+ /* an embedding shell may refuse; the parent is the fallback path */
2227
+ }
2228
+ try {
2229
+ document.body?.focus({ preventScroll: true });
2230
+ } catch {
2231
+ /* focus is best effort; a non-focusable body just keeps the old target */
2232
+ }
2233
+ }
2234
+
2235
+ ui.viewport.addEventListener("pointerdown", (event) => {
2236
+ claimKeyboardFocus();
2237
+ const target = event.target;
2238
+ // The panel toggles sit on the canvas but they are not the canvas. Without
2239
+ // this the click falls through to "nothing was hit", which clears the
2240
+ // selection — folding a panel away would also throw away what was selected.
2241
+ if (target?.closest?.(".edge-toggle")) return;
2242
+ const handle = target?.classList?.contains("handle") ? target : null;
2243
+
2244
+ if (handle && state.selection) {
2245
+ const element = elementForId(state.selection);
2246
+ if (element) {
2247
+ setDragging(true);
2248
+ // The element's own size, not its painted bounding rect: a rotated box's
2249
+ // bounding rect is the AABB, and resizing from that starts too big.
2250
+ const start = element.getBoundingClientRect();
2251
+ drag = { kind: "resize", corner: handle.dataset.handle, id: state.selection, startX: event.clientX, startY: event.clientY, startWidth: element.offsetWidth, startHeight: element.offsetHeight, startLeft: start.left, startTop: start.top, element };
2252
+ capturePointer(ui.viewport, event.pointerId);
2253
+ event.preventDefault();
2254
+ return;
2255
+ }
2256
+ }
2257
+
2258
+ const label = target?.classList?.contains("page-label") ? target : null;
2259
+ if (label || event.altKey) {
2260
+ const pageBox = target.closest(".page-box");
2261
+ if (pageBox) {
2262
+ // A press on the label is also a statement about which page this is. The label is
2263
+ // the only part of a page a full-bleed child never covers — every page of
2264
+ // `examples/alarm.html` is filled by a `width:100%;height:100%` root frame — so
2265
+ // without this a page cannot be selected on the canvas at all, and `Export`, which
2266
+ // writes the page that is selected, would be reachable only from the layer tree.
2267
+ // The drag is unchanged: a press that moves still moves the page, and one that does
2268
+ // not move still writes nothing.
2269
+ if (label) select(pageBox.dataset.pageId, { pageId: pageBox.dataset.pageId });
2270
+ drag = pageDrag(pageBox, pageBox.dataset.pageId, event);
2271
+ setDragging(true);
2272
+ capturePointer(ui.viewport, event.pointerId);
2273
+ event.preventDefault();
2274
+ return;
2275
+ }
2276
+ }
2277
+
2278
+ if (target === ui.viewport || target === ui.canvas) {
2279
+ // The background is the one place with nothing under it, so a press here is
2280
+ // a candidate for "clear the selection" — but only if the pointer never
2281
+ // moves. Panning the canvas is not a way of saying "select nothing".
2282
+ drag = {
2283
+ kind: "pan", startX: event.clientX, startY: event.clientY,
2284
+ originX: state.camera.x, originY: state.camera.y, moved: false,
2285
+ };
2286
+ ui.viewport.classList.add("panning");
2287
+ capturePointer(ui.viewport, event.pointerId);
2288
+ // Cancel the press. Without this the pointer drag is *also* the browser's
2289
+ // text selection gesture, and panning the canvas drags a blue smear across
2290
+ // every string in the design. (Cancelling `pointerdown` suppresses the
2291
+ // compatibility mouse events the selection would start from; `user-select`
2292
+ // on `.panning` is the belt to this pair of braces.)
2293
+ event.preventDefault();
2294
+ return;
2295
+ }
2296
+
2297
+ const hit = targetFromEvent(event);
2298
+ if (hit) {
2299
+ // A press takes the level the pointer is offering — the box the solid hover
2300
+ // outline is already drawing — not the deepest element under the cursor and
2301
+ // not one of its children. That is what makes a click a safe gesture: it
2302
+ // selects the card, and a drag that follows moves the card, not the word
2303
+ // under the cursor. Stepping inside is a double-click (`drill`).
2304
+ const target = pressTarget(hit.id, hit.pageId, { drill: isRepeatPress(event, hit.id) })
2305
+ ?? { pageId: hit.pageId, node: treeNodeFor(hit.id) ?? { id: hit.id } };
2306
+ select(target.node.id, { expand: true, pageId: target.pageId });
2307
+ // A page is placed by `data-canvas`, and that is the only spelling for it.
2308
+ // This used to fall through to the frame path below, which writes
2309
+ // `position:absolute` plus `left`/`top`: the canvas ignores those on a page
2310
+ // because it positions one from `data-canvas`, so the file looked right
2311
+ // here while `export` shipped the page pushed off its own top-left corner.
2312
+ const moving = elementInPage(target.pageId, target.node.id) ?? hit.element;
2313
+ drag = hit.kind === "page"
2314
+ ? pageDrag(hit.element, hit.id, event)
2315
+ : { kind: "maybe-move", id: target.node.id, startX: event.clientX, startY: event.clientY, moved: false, element: moving };
2316
+ setDragging(true);
2317
+ capturePointer(ui.viewport, event.pointerId);
2318
+ // A press on the design is not a press on text. Without this the browser
2319
+ // starts its own selection gesture on the text run under the pointer and
2320
+ // keeps extending it while the element follows the pointer — a blue smear
2321
+ // across every string the drag passes, which is exactly what `panning`
2322
+ // already guarded for the background. `selectstart` cannot do this job here:
2323
+ // the text lives inside a page's shadow root and that event does not reach
2324
+ // the viewport from there.
2325
+ event.preventDefault();
2326
+ } else {
2327
+ select(null);
2328
+ }
2329
+ });
2330
+
2331
+ /** A drag that moves a whole page. `data-canvas` is what it commits. */
2332
+ function pageDrag(pageBox, id, event) {
2333
+ const box = state.boxes.get(id);
2334
+ return {
2335
+ kind: "page", id, element: pageBox,
2336
+ startX: event.clientX, startY: event.clientY,
2337
+ originX: box?.position?.x ?? 0, originY: box?.position?.y ?? 0,
2338
+ zoom: state.camera.zoom,
2339
+ };
2340
+ }
2341
+
2342
+ /**
2343
+ * Every gesture that moves geometry redraws the boxes, or they do not move.
2344
+ *
2345
+ * The overlay is a *sibling* of the canvas, not a child of it: it is positioned
2346
+ * in viewport pixels and inherits neither the camera transform nor the page's
2347
+ * position. So a camera move, a page drag, an element drag and a resize all have
2348
+ * to redraw it — and each of them forgot at some point, which is a blue
2349
+ * rectangle that stays exactly where it was while the design slides out from
2350
+ * under it. The cost is one measured box per pointer move, which is what the
2351
+ * pan branch has been paying since it was fixed.
2352
+ */
2353
+ ui.viewport.addEventListener("pointermove", (event) => {
2354
+ if (!drag) {
2355
+ const hit = targetFromEvent(event);
2356
+ setHovering(hit?.id ?? null, hit?.pageId ?? null);
2357
+ return;
2358
+ }
2359
+
2360
+ if (drag.kind === "pan") {
2361
+ if (Math.abs(event.clientX - drag.startX) > 3 || Math.abs(event.clientY - drag.startY) > 3) drag.moved = true;
2362
+ state.camera.x = drag.originX + (event.clientX - drag.startX);
2363
+ state.camera.y = drag.originY + (event.clientY - drag.startY);
2364
+ applyCamera();
2365
+ drawOverlay();
2366
+ return;
2367
+ }
2368
+
2369
+ if (drag.kind === "page") {
2370
+ const zoom = drag.zoom || 1;
2371
+ const x = Math.round(drag.originX + (event.clientX - drag.startX) / zoom);
2372
+ const y = Math.round(drag.originY + (event.clientY - drag.startY) / zoom);
2373
+ drag.element.style.left = `${x}px`;
2374
+ drag.element.style.top = `${y}px`;
2375
+ drag.moved = { x, y };
2376
+ drawOverlay();
2377
+ return;
2378
+ }
2379
+
2380
+ if (drag.kind === "maybe-move") {
2381
+ const dx = event.clientX - drag.startX;
2382
+ const dy = event.clientY - drag.startY;
2383
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) drag.moved = true;
2384
+ if (drag.moved && drag.element) {
2385
+ const [lx, ly] = parentDelta(drag.element, dx / state.camera.zoom, dy / state.camera.zoom);
2386
+ drag.element.style.translate = `${lx}px ${ly}px`;
2387
+ drawOverlay();
2388
+ }
2389
+ return;
2390
+ }
2391
+
2392
+ if (drag.kind === "resize") {
2393
+ const zoom = state.camera.zoom || 1;
2394
+ // A corner handle turns with its box, so the pointer delta has to be turned
2395
+ // back before it means "wider" or "taller" along this element's own axes.
2396
+ const [dx, dy] = unturn(
2397
+ (event.clientX - drag.startX) / zoom,
2398
+ (event.clientY - drag.startY) / zoom,
2399
+ rotationOfElement(drag.element),
2400
+ );
2401
+ const [hx, hy] = drag.corner.split("").map(Number);
2402
+ const width = Math.max(1, Math.round(drag.startWidth + (hx ? dx : 0)));
2403
+ const height = Math.max(1, Math.round(drag.startHeight + (hy ? dy : 0)));
2404
+ drag.element.style.width = `${width}px`;
2405
+ drag.element.style.height = `${height}px`;
2406
+ drag.pending = { width, height };
2407
+ // The four handles are children of the selection box. Without this they stay
2408
+ // where the element *was*, so the thing you are dragging a corner of is no
2409
+ // longer the thing the corners are on.
2410
+ drawOverlay();
2411
+ }
2412
+ });
2413
+
2414
+ /**
2415
+ * A cancelled gesture puts the design back and drops the drag state.
2416
+ *
2417
+ * `pointercancel` arrives when the browser takes the pointer over — a touch
2418
+ * scroll, a system gesture, a window losing the pointer. Without a handler the
2419
+ * drag stayed set: `.moving` kept the design's text unselectable until the next
2420
+ * successful drag ended, and the preview transform stayed on the element. There
2421
+ * is no commit here, because a cancelled gesture is one the user did not finish.
2422
+ */
2423
+ ui.viewport.addEventListener("pointercancel", () => {
2424
+ if (!drag) return;
2425
+ const current = drag;
2426
+ drag = null;
2427
+ ui.viewport.classList.remove("panning");
2428
+ setDragging(false);
2429
+ if (current.element) {
2430
+ if (current.kind === "maybe-move") current.element.style.translate = "";
2431
+ if (current.kind === "page") {
2432
+ current.element.style.left = `${current.originX}px`;
2433
+ current.element.style.top = `${current.originY}px`;
2434
+ }
2435
+ if (current.kind === "resize") {
2436
+ current.element.style.width = `${current.startWidth}px`;
2437
+ current.element.style.height = `${current.startHeight}px`;
2438
+ }
2439
+ }
2440
+ drawOverlay();
2441
+ });
2442
+
2443
+ ui.viewport.addEventListener("pointerup", (event) => {
2444
+ if (!drag) return;
2445
+ const current = drag;
2446
+ drag = null;
2447
+ ui.viewport.classList.remove("panning");
2448
+ setDragging(false);
2449
+
2450
+ // A press on the empty canvas that did not pan is a click on nothing, and
2451
+ // clicking nothing clears the selection — the same thing `Escape` does. The
2452
+ // three-pixel threshold is the same one an element drag uses: a click always
2453
+ // arrives with a pixel or two of jitter, and a jittery click is still a click.
2454
+ if (current.kind === "pan") {
2455
+ if (!current.moved) select(null);
2456
+ return;
2457
+ }
2458
+
2459
+ if (current.kind === "page" && current.moved) {
2460
+ const { x, y } = current.moved;
2461
+ const node = state.model.pages.find((p) => p.id === current.id);
2462
+ if (node) {
2463
+ const preview = current.element;
2464
+ commit([{ op: "set", target: current.id, prop: "data-canvas", value: `${x},${y}` }], {
2465
+ onSettled: () => {
2466
+ if (!preview) return;
2467
+ preview.style.left = `${current.originX}px`;
2468
+ preview.style.top = `${current.originY}px`;
2469
+ },
2470
+ });
2471
+ }
2472
+ return;
2473
+ }
2474
+
2475
+ if (current.kind === "maybe-move" && current.moved) {
2476
+ const rawX = (event.clientX - current.startX) / (state.camera.zoom || 1);
2477
+ const rawY = (event.clientY - current.startY) / (state.camera.zoom || 1);
2478
+ const [movedX, movedY] = current.element ? parentDelta(current.element, rawX, rawY) : [rawX, rawY];
2479
+ const dx = Math.round(movedX);
2480
+ const dy = Math.round(movedY);
2481
+ // The preview transform is left on the element on purpose: it is what the
2482
+ // re-render replaces. `onSettled` clears it whatever the write did.
2483
+ const preview = current.element;
2484
+ const node = findNode(current.id);
2485
+ if (!node) return;
2486
+ const map = styleMapOf(node);
2487
+ // A frame moves by becoming positioned. That is one spelling, not two: the
2488
+ // canonical form has absolute placement as `position:absolute` plus
2489
+ // `left`/`top`, so the canvas writes exactly that.
2490
+ const left = (parseFloat(map.get("left")) || 0) + dx;
2491
+ const top = (parseFloat(map.get("top")) || 0) + dy;
2492
+ commit([
2493
+ { op: "set", target: current.id, prop: "style:position", value: "absolute" },
2494
+ { op: "set", target: current.id, prop: "style:left", value: `${Math.round(left)}px` },
2495
+ { op: "set", target: current.id, prop: "style:top", value: `${Math.round(top)}px` },
2496
+ ], {
2497
+ onSettled: () => { if (preview) preview.style.translate = ""; },
2498
+ });
2499
+ return;
2500
+ }
2501
+
2502
+ if (current.kind === "resize" && current.pending) {
2503
+ const preview = current.element;
2504
+ commit([
2505
+ { op: "set", target: current.id, prop: "style:width", value: `${current.pending.width}px` },
2506
+ { op: "set", target: current.id, prop: "style:height", value: `${current.pending.height}px` },
2507
+ ], {
2508
+ onSettled: () => {
2509
+ if (!preview) return;
2510
+ preview.style.width = `${current.startWidth}px`;
2511
+ preview.style.height = `${current.startHeight}px`;
2512
+ },
2513
+ });
2514
+ }
2515
+ });
2516
+
2517
+ ui.viewport.addEventListener("wheel", (event) => {
2518
+ event.preventDefault();
2519
+ if (event.ctrlKey || event.metaKey) {
2520
+ zoomAt(event.clientX, event.clientY, event.deltaY < 0 ? 1.08 : 1 / 1.08);
2521
+ return;
2522
+ }
2523
+ if (event.shiftKey) state.camera.x -= event.deltaY;
2524
+ else { state.camera.x -= event.deltaX; state.camera.y -= event.deltaY; }
2525
+ applyCamera();
2526
+ drawOverlay();
2527
+ }, { passive: false });
2528
+
2529
+ window.addEventListener("keydown", (event) => {
2530
+ const inField = ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName);
2531
+ if (inField) return;
2532
+ if ((event.metaKey || event.ctrlKey) && event.code === "Backslash") {
2533
+ // The layout, from the keyboard: ⌘\ is the layers panel, ⇧⌘\ the inspector.
2534
+ // The *physical* key is what is checked, not `event.key`: with shift held the
2535
+ // same key reports `|`, and a shortcut that stops working when you press the
2536
+ // modifier it is documented with is a shortcut nobody can use.
2537
+ event.preventDefault();
2538
+ const side = event.shiftKey ? "right" : "left";
2539
+ const before = document.activeElement;
2540
+ togglePanel(side);
2541
+ // The panel that just closed took the focused control with it. Focus goes to
2542
+ // the control that replaced it — the edge tab — so the next Tab continues
2543
+ // from the panel, not from the top of the document.
2544
+ if (before && before !== document.body && !before.getClientRects().length) focusPanelControl(side);
2545
+ return;
2546
+ }
2547
+ if (event.key === " ") {
2548
+ // A focused button activates on space, and that keypress is the button's.
2549
+ if (document.activeElement?.tagName === "BUTTON") return;
2550
+ ui.viewport.classList.add("move-mode");
2551
+ event.preventDefault();
2552
+ return;
2553
+ }
2554
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") {
2555
+ event.preventDefault();
2556
+ if (event.shiftKey) redo(); else undo();
2557
+ return;
2558
+ }
2559
+ if (event.key === "Delete" || event.key === "Backspace") {
2560
+ if (!state.selection) return;
2561
+ event.preventDefault();
2562
+ commit([{ op: "remove", target: state.selection }]);
2563
+ return;
2564
+ }
2565
+ if (event.key === "Escape") {
2566
+ // Up one rung, and the selection goes with it: from a card to the section it
2567
+ // sits in, and from the first rung to nothing selected at all.
2568
+ const path = state.selection ? pathNodesOf(state.selection, state.selectionPage) : [];
2569
+ if (path.length - 1 > 1) select(path[path.length - 2].id, { pageId: path[0].id });
2570
+ else select(null);
2571
+ return;
2572
+ }
2573
+ const arrows = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
2574
+ if (arrows[event.key] && state.selection) {
2575
+ // The nudge is the one branch that writes, so it gets the second guard: the
2576
+ // tree and the panel edges handle the arrow keys themselves and stop their
2577
+ // own events, and this is what keeps a leaked keypress from moving a design
2578
+ // by one pixel. Only the nudge — Delete, ⌘Z, Escape and ⌘\ have to keep
2579
+ // working while a row in the tree has focus, and none of them nudges.
2580
+ if (event.target?.closest?.("#layer-tree, .divider")) return;
2581
+ event.preventDefault();
2582
+ const step = event.shiftKey ? 10 : 1;
2583
+ const [dx, dy] = arrows[event.key];
2584
+ const node = findNode(state.selection);
2585
+ if (!node) return;
2586
+ const map = styleMapOf(node);
2587
+ const left = (parseFloat(map.get("left")) || 0) + dx * step;
2588
+ const top = (parseFloat(map.get("top")) || 0) + dy * step;
2589
+ commit([
2590
+ { op: "set", target: state.selection, prop: "style:position", value: "absolute" },
2591
+ { op: "set", target: state.selection, prop: "style:left", value: `${left}px` },
2592
+ { op: "set", target: state.selection, prop: "style:top", value: `${top}px` },
2593
+ ]);
2594
+ }
2595
+ });
2596
+
2597
+ window.addEventListener("keyup", (event) => {
2598
+ if (event.key === " ") ui.viewport.classList.remove("move-mode");
2599
+ });
2600
+
2601
+ // -- copy for an agent -------------------------------------------------------
2602
+
2603
+ /** `C:\a\b.html` → `file:///c%3A/a/b.html`, `/a/b.html` → `file:///a/b.html`. */
2604
+ function fileUriOf(absolute) {
2605
+ const slashed = absolute.replace(/\\/g, "/");
2606
+ const drive = /^([A-Za-z]):\//.exec(slashed);
2607
+ const rest = drive ? `/${drive[1].toLowerCase()}%3A${slashed.slice(2)}` : slashed;
2608
+ return `file://${rest.split("/").map((part, i) => (drive && i === 1 ? part : encodeURIComponent(part))).join("/")}`;
2609
+ }
2610
+
2611
+ /**
2612
+ * ⌘C on a selection copies a pointer to it, not the design: the reader is an
2613
+ * agent chat box, and the agent reads the element from the file by its id.
2614
+ *
2615
+ * `text/plain` is what every chat box receives. `application/x-lexical-editor`
2616
+ * is only read by Lexical editors — Cursor's chat input is one — which paste it
2617
+ * as a file chip instead of the raw sentence. A custom type can only be written
2618
+ * from the `copy` event; `navigator.clipboard.write` refuses it.
2619
+ */
2620
+ document.addEventListener("copy", (event) => {
2621
+ if (!state.selection || !state.path) return;
2622
+ if (["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName)) return;
2623
+ if (!(window.getSelection()?.isCollapsed ?? true)) return;
2624
+
2625
+ const id = state.selection;
2626
+ const name = getAttr(findNode(id), "data-name") || id;
2627
+ const pageId = state.selectionPage ?? pathNodesOf(id)[0]?.id;
2628
+ const where = pageId && pageId !== id ? `. Page id: ${pageId}` : "";
2629
+ const prompt = `Use justSkill tools. Current file ${fileUriOf(state.path)}${where}. Selected node ids: ${id}`;
2630
+ const ext = state.file.split(".").pop()?.toLowerCase() ?? "";
2631
+
2632
+ event.clipboardData.setData("text/plain", prompt);
2633
+ event.clipboardData.setData("application/x-lexical-editor", JSON.stringify({
2634
+ nodes: [
2635
+ {
2636
+ detail: 0, format: 0, mode: "segmented", style: "",
2637
+ text: prompt,
2638
+ mentionName: `${state.file} (${name})`,
2639
+ type: "mention", version: 1, typeaheadType: "file",
2640
+ metadata: { iconClasses: ["file-icon", "name-file-icon", `${ext}-ext-file-icon`, "ext-file-icon", `${ext}-lang-file-icon`] },
2641
+ },
2642
+ { detail: 0, format: 0, mode: "normal", style: "", text: " ", type: "text", version: 1 },
2643
+ ],
2644
+ }));
2645
+ event.preventDefault();
2646
+ toast(`Copied ${name} for the agent`);
2647
+ });
2648
+
2649
+ // -- top bar actions ---------------------------------------------------------
2650
+
2651
+ document.getElementById("zoom-in").addEventListener("click", () => {
2652
+ const rect = ui.viewport.getBoundingClientRect();
2653
+ zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, 1.15);
2654
+ });
2655
+ document.getElementById("zoom-out").addEventListener("click", () => {
2656
+ const rect = ui.viewport.getBoundingClientRect();
2657
+ zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, 1 / 1.15);
2658
+ });
2659
+ document.getElementById("fit").addEventListener("click", fitAll);
2660
+ document.getElementById("undo").addEventListener("click", undo);
2661
+ document.getElementById("redo").addEventListener("click", redo);
2662
+ document.getElementById("toggle-problems").addEventListener("click", () => {
2663
+ ui.problems.hidden = !ui.problems.hidden;
2664
+ });
2665
+ document.getElementById("export-page").addEventListener("click", () => exportPages(false));
2666
+ document.getElementById("export-all").addEventListener("click", () => exportPages(true));
2667
+ document.getElementById("close-problems").addEventListener("click", () => {
2668
+ ui.problems.hidden = true;
2669
+ });
2670
+ document.getElementById("fix-text").addEventListener("click", openFixText);
2671
+ document.getElementById("fix-save").addEventListener("click", saveFixText);
2672
+
2673
+ /**
2674
+ * Read the file back into a textarea so a person can repair what does not parse.
2675
+ *
2676
+ * `/api/save` has existed since the service did and nothing called it: a file the parser
2677
+ * rejects was read-only, with no way out from the canvas but an editor outside it. A
2678
+ * broken file is exactly when someone wants the canvas open — it is where the diagnostics
2679
+ * are.
2680
+ *
2681
+ * `baseRevision` is sent with the text, so a save that would land on top of a change made
2682
+ * while the textarea was open is answered with `rebased: true` and says so, rather than
2683
+ * replacing the other writer's work in silence.
2684
+ */
2685
+ async function openFixText() {
2686
+ const { payload } = await api("file");
2687
+ if (typeof payload !== "string") {
2688
+ toast("Could not read the file.", "refused");
2689
+ return;
2690
+ }
2691
+ ui.fixText.value = payload;
2692
+ ui.fixHint.textContent = "This file does not parse, so it cannot be edited one change at a time. Fix the text, then save — the whole file is replaced.";
2693
+ ui.fixPane.hidden = false;
2694
+ document.getElementById("fix-save").hidden = false;
2695
+ ui.problems.hidden = false;
2696
+ ui.fixText.focus();
2697
+ }
2698
+
2699
+ async function saveFixText() {
2700
+ const text = ui.fixText.value;
2701
+ const { payload } = await api("save", { body: { baseRevision: state.revision, text } });
2702
+ if (!payload?.ok) {
2703
+ const diagnostics = payload?.diagnostics ?? [];
2704
+ if (diagnostics.length > 0) {
2705
+ state.diagnostics = diagnostics;
2706
+ renderProblems();
2707
+ }
2708
+ ui.fixHint.textContent = payload?.message ?? "The text was refused.";
2709
+ toast(payload?.message ?? "The text was refused.", "refused");
2710
+ return;
2711
+ }
2712
+ ui.fixPane.hidden = true;
2713
+ document.getElementById("fix-save").hidden = true;
2714
+ ui.fixText.value = "";
2715
+ if (payload.rebased) {
2716
+ toast("Saved, but the file had changed while you were typing.", "refused");
2717
+ } else {
2718
+ toast("Saved.", "ok");
2719
+ }
2720
+ await loadDocument({ keepCamera: true });
2721
+ }
2722
+
2723
+ /**
2724
+ * Write the deliverable the user is looking at.
2725
+ *
2726
+ * The canvas cannot write files, so it asks the service to — and the service calls
2727
+ * `lib/export-html.mjs`, the same functions `design.mjs export` calls. A deliverable taken
2728
+ * from here and one taken from the command line are the same bytes, which is the property
2729
+ * that makes "export" mean one thing.
2730
+ *
2731
+ * Which page is not a default. The service answers a missing `page` with the first page,
2732
+ * and a button that silently wrote page one while the canvas showed page four handed over
2733
+ * a deliverable nobody asked for, under a name that said it was the right one. So the
2734
+ * request names the page, `renderChrome` disables the button until a page is selected, and
2735
+ * the check here covers the gap between the click and the render.
2736
+ *
2737
+ * A refusal with diagnostics opens the drawer rather than flashing a toast: the file has a
2738
+ * problem the user has to see, and 7 seconds is not enough time to read a diagnostic.
2739
+ */
2740
+ async function exportPages(all) {
2741
+ const page = all ? null : selectedPage(state.model?.pages, state.selection, state.selectionPage);
2742
+ if (!all && !page) {
2743
+ toast("Select a page first — Export writes the selected page.", "refused");
2744
+ return;
2745
+ }
2746
+ const { payload } = await api("export", { body: all ? { all: true } : { page: page.id } });
2747
+ if (payload?.ok) {
2748
+ const names = payload.files.map((entry) => entry.path.split("/").pop());
2749
+ toast(`Wrote ${names.join(", ")}`, "ok");
2750
+ return;
2751
+ }
2752
+ const diagnostics = payload?.diagnostics ?? [];
2753
+ if (diagnostics.length > 0) {
2754
+ state.diagnostics = diagnostics;
2755
+ renderProblems();
2756
+ ui.problems.hidden = false;
2757
+ }
2758
+ toast(payload?.message ?? "Export refused.", "refused");
2759
+ }
2760
+
2761
+ async function undo() {
2762
+ const { payload } = await api("undo", { body: {} });
2763
+ if (payload?.ok) {
2764
+ state.canUndo = Boolean(payload.canUndo);
2765
+ state.canRedo = Boolean(payload.canRedo);
2766
+ await loadDocument({ keepCamera: true });
2767
+ } else {
2768
+ toast("Nothing to undo.", "");
2769
+ }
2770
+ }
2771
+
2772
+ async function redo() {
2773
+ const { payload } = await api("redo", { body: {} });
2774
+ if (payload?.ok) {
2775
+ state.canUndo = Boolean(payload.canUndo);
2776
+ state.canRedo = Boolean(payload.canRedo);
2777
+ await loadDocument({ keepCamera: true });
2778
+ } else {
2779
+ toast("Nothing to redo.", "");
2780
+ }
2781
+ }
2782
+
2783
+ // -- live updates ------------------------------------------------------------
2784
+
2785
+ /**
2786
+ * The file has two writers, so the canvas has to hear about the other one.
2787
+ * A revision it already has is ignored: the canvas caused that write itself.
2788
+ */
2789
+ function subscribe() {
2790
+ const events = new EventSource(`/api/events?t=${encodeURIComponent(TOKEN)}`);
2791
+ events.addEventListener("changed", async (event) => {
2792
+ let revision = null;
2793
+ try { revision = JSON.parse(event.data)?.revision; } catch { /* plain text */ }
2794
+ if (revision && revision === state.revision) return;
2795
+ await loadDocument({ keepCamera: true });
2796
+ });
2797
+ events.addEventListener("hello", () => { /* the stream is live */ });
2798
+ events.onerror = () => { /* EventSource reconnects on its own */ };
2799
+ }
2800
+
2801
+ window.addEventListener("resize", () => {
2802
+ // A window narrow enough to squeeze a panel re-clamps it, so a remembered
2803
+ // 700px inspector cannot cover the design on a laptop screen — and the
2804
+ // re-clamp goes through the same geometry path as a drag, so giving a column
2805
+ // width back does not slide the drawing sideways with the viewport edge.
2806
+ reclampPanels();
2807
+ });
2808
+ window.addEventListener("beforeunload", () => {
2809
+ navigator.sendBeacon(`/api/bye?t=${encodeURIComponent(TOKEN)}`);
2810
+ });
2811
+
2812
+ window.addEventListener("error", (event) => {
2813
+ toast(`Canvas error: ${event.message}`, "refused");
2814
+ });
2815
+
2816
+ // -- start -------------------------------------------------------------------
2817
+
2818
+ /**
2819
+ * Start. A failure here must be visible: a canvas that renders nothing and says
2820
+ * nothing is the worst possible failure mode, because it looks like a design
2821
+ * with no pages.
2822
+ */
2823
+ wirePanels();
2824
+
2825
+ /**
2826
+ * Enter embedded presentation after the first document load: fold both panels,
2827
+ * then fit the pages into the remaining width. The fold goes through the same
2828
+ * geometry path a click uses, so the camera stays on the design instead of
2829
+ * sliding with the closed panel; it deliberately skips `savePanels()` so the
2830
+ * reader's normal browser preferences are not overwritten by an embed.
2831
+ */
2832
+ function enterEmbed() {
2833
+ document.body.dataset.embed = "1";
2834
+ withPanelGeometry(() => {
2835
+ panels.left.open = false;
2836
+ panels.right.open = false;
2837
+ });
2838
+ const refit = () => {
2839
+ applyBarFit();
2840
+ fitAll();
2841
+ };
2842
+ refit();
2843
+ // The right-sidebar tab can still be widening when the iframe's `load`
2844
+ // settles (the panel expands as the tab opens). A fit measured against that
2845
+ // intermediate width leaves the pages at a needlessly tiny zoom, so fit
2846
+ // again after the first frame and once more after the expansion settles.
2847
+ requestAnimationFrame(() => requestAnimationFrame(refit));
2848
+ setTimeout(refit, 500);
2849
+ }
2850
+
2851
+ loadDocument()
2852
+ .then(() => {
2853
+ if (EMBED) {
2854
+ try {
2855
+ enterEmbed();
2856
+ } catch (error) {
2857
+ console.error("embed mode failed:", error);
2858
+ }
2859
+ }
2860
+ })
2861
+ .then(subscribe)
2862
+ .catch(async (error) => {
2863
+ ui.revision.textContent = "load failed";
2864
+ toast(`Cannot load the design: ${error.message}`, "refused");
2865
+ const panel = document.createElement("p");
2866
+ panel.className = "empty";
2867
+ panel.textContent = `${error.message}`;
2868
+ ui.inspector.prepend(panel);
2869
+ console.error(error);
2870
+ });