@azure-id/orc 1.4.1 → 1.4.2

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.
@@ -73,75 +73,153 @@ function hkCanDrag() {
73
73
  }
74
74
  }
75
75
 
76
+ // THE LAST FETCH, AND THE NODE IT IS PAINTED INTO (v1.4.2). Staging an edit
77
+ // used to re-enter the router — which threw the panel away, refetched four
78
+ // endpoints and rebuilt everything from a skeleton. Every click "refreshed the
79
+ // page", and worse: because the picture came back looking different, a staged
80
+ // change read as an applied one. Nothing is fetched now until Apply, the switch
81
+ // or a preset; a staged op repaints from THIS cache.
82
+ let HK_DATA = null;
83
+ // The node this panel paints into. `section` owns the skeleton and the error
84
+ // box; after the first load, everything goes through here.
85
+ let HK_SLOT = null;
86
+ // The open part editor, if there is one, so a staged edit updates the control
87
+ // the user is looking at instead of leaving it showing the old value.
88
+ let HK_MODAL = null;
89
+ // The comparison drawer's open state and its answers, both surviving a repaint:
90
+ // it costs one render per colour set and per symbol set, so it is fetched once
91
+ // and only when somebody opens it.
92
+ let HK_COMPARE = false;
93
+ let HK_COMPARE_DATA = null;
94
+ // Whether this panel has drawn once. The FIRST paint animates in; every one
95
+ // after it is instant, because nothing arrived — the same cards are still there
96
+ // with one value different.
97
+ let HK_PAINTED = false;
98
+
76
99
  PANELS.hookui = function (host) {
77
100
  head(host, t("hookui.title"), t("hookui.sub"));
78
-
79
- section(
80
- host,
81
- () =>
82
- Promise.all([
83
- read("/api/statusline/show?board=" + HK_BOARD).then((r) => r.data),
84
- read("/api/statusline/components?board=" + HK_BOARD).then((r) => r.data),
85
- read("/api/statusline/presets?board=" + HK_BOARD).then((r) => r.data),
86
- read("/api/statusline/preview?board=" + HK_BOARD + "&width=" + HK_WIDTH + "&state=" + HK_STATE).then((r) => r.data),
87
- ]),
88
- ([show, cat, presets, prev]) => {
89
- const out = frag();
90
- if (!show || !cat) {
91
- out.append(empty(t("hookui.noData")));
92
- return out;
93
- }
94
- // The effective board: what it WILL be once the staged ops run. Every
95
- // count, every slot cap and every legality question downstream reads this
96
- // and never `show.lines` directly.
97
- const plan = hkPlan(show, cat);
98
-
99
- const edits = editSet(() => bar.paint());
100
- const bar = editBar(edits, {
101
- onApply: async (b) => {
102
- await applyActions(edits, b);
103
- HK_OPS = [];
104
- rerender();
105
- },
106
- onReset: () => confirmReset(),
107
- onCancel: () => {
108
- HK_OPS = [];
109
- rerender();
110
- },
111
- resetLabel: t("hookui.resetLayout"),
112
- });
113
- // Filled AFTER the bar exists: `editSet`'s onChange paints it, and a
114
- // paint before that binding is assigned throws.
115
- for (const a of plan.actions) edits.action(a.key, a.route, a.body, a.label);
116
-
117
- out.append(boardTabs(cat));
118
- out.append(gateCard(show, cat));
119
- // A caution renders ABOVE the preview, never inside a tab — a caution you
120
- // have to hunt for is a caution nobody reads.
121
- if (show.errors.length || show.warnings.length) out.append(cautionCard(show));
122
- out.append(previewCard(prev, plan));
123
- out.append(boardCard(cat, plan));
124
- out.append(referenceCard(cat));
125
- out.append(presetsCard(presets, plan));
126
- out.append(advancedCard(cat, plan));
127
- out.append(bar);
128
- return out;
129
- }
130
- );
101
+ HK_DATA = null;
102
+ HK_SLOT = null;
103
+ HK_MODAL = null;
104
+ HK_PAINTED = false;
105
+ section(host, hkLoad, (data) => {
106
+ HK_DATA = data;
107
+ // `stack` is what spaces the cards; `section` gives it to its own slot, and
108
+ // this node lives inside that slot, so it needs its own.
109
+ const slot = el("div", "stack");
110
+ HK_SLOT = slot;
111
+ hkPaint();
112
+ return slot;
113
+ });
131
114
  };
132
115
 
116
+ function hkPreviewUrl() {
117
+ return "/api/statusline/preview?board=" + HK_BOARD + "&width=" + HK_WIDTH + "&state=" + HK_STATE;
118
+ }
119
+
120
+ function hkLoad() {
121
+ return Promise.all([
122
+ read("/api/statusline/show?board=" + HK_BOARD).then((r) => r.data),
123
+ read("/api/statusline/components?board=" + HK_BOARD).then((r) => r.data),
124
+ read("/api/statusline/presets?board=" + HK_BOARD).then((r) => r.data),
125
+ read(hkPreviewUrl()).then((r) => r.data),
126
+ ]).then(([show, cat, presets, prev]) => ({ show, cat, presets, prev }));
127
+ }
128
+
129
+ // THE ONLY PAINT. No fetch, no skeleton, no router. The scroll position is
130
+ // kept by hand: a board you are halfway down must not jump to the top because
131
+ // you moved one chip.
132
+ function hkPaint() {
133
+ if (!HK_SLOT || !HK_DATA) return;
134
+ const y = window.scrollY;
135
+ // NO ENTRANCE ANIMATION ON A REPAINT. `.stack > *` fades and slides each
136
+ // child in on a 30ms stagger, and `.hk-chip` does the same per chip — which
137
+ // is right the first time the panel arrives and is exactly the "the screen
138
+ // blinked like it reloaded" the user reported, on every keystroke, with the
139
+ // network tab empty. `hk-quiet` switches those entrances off; the transitions
140
+ // (hover, the drop marker, the staged edge) are untouched, because they are
141
+ // about the pointer rather than about arriving.
142
+ if (HK_PAINTED) HK_SLOT.classList.add("hk-quiet");
143
+ HK_PAINTED = true;
144
+ const { show, cat, presets, prev } = HK_DATA;
145
+ HK_SLOT.replaceChildren();
146
+ if (!show || !cat) {
147
+ HK_SLOT.append(empty(t("hookui.noData")));
148
+ return;
149
+ }
150
+ // The effective board: what it WILL be once the staged ops run. Every count,
151
+ // every slot cap and every legality question downstream reads this and never
152
+ // `show.lines` directly.
153
+ const plan = hkPlan(show, cat);
154
+
155
+ const edits = editSet(() => bar.paint());
156
+ const bar = editBar(edits, {
157
+ onApply: async (b) => {
158
+ await applyActions(edits, b);
159
+ HK_OPS = [];
160
+ HK_COMPARE_DATA = null;
161
+ await hkReload();
162
+ },
163
+ onReset: () => confirmReset(),
164
+ onCancel: () => {
165
+ HK_OPS = [];
166
+ hkPaint();
167
+ },
168
+ resetLabel: t("hookui.resetLayout"),
169
+ });
170
+ // Filled AFTER the bar exists: `editSet`'s onChange paints it, and a paint
171
+ // before that binding is assigned throws.
172
+ for (const a of plan.actions) edits.action(a.key, a.route, a.body, a.label);
173
+
174
+ HK_SLOT.append(boardTabs(cat));
175
+ HK_SLOT.append(gateCard(show, cat));
176
+ // A caution renders ABOVE the preview, never inside a tab — a caution you
177
+ // have to hunt for is a caution nobody reads.
178
+ if (show.errors.length || show.warnings.length) HK_SLOT.append(cautionCard(show));
179
+ HK_SLOT.append(previewCard(prev, plan, cat));
180
+ HK_SLOT.append(boardCard(cat, plan));
181
+ HK_SLOT.append(referenceCard(cat));
182
+ HK_SLOT.append(presetsCard(presets, plan));
183
+ HK_SLOT.append(advancedCard(cat, plan));
184
+ HK_SLOT.append(bar);
185
+
186
+ // The open editor follows the staged state. A modal the page has stopped
187
+ // showing is not repainted — it is forgotten.
188
+ if (HK_MODAL) {
189
+ const host = document.getElementById("modal-host");
190
+ if (host && host.hidden) HK_MODAL = null;
191
+ else HK_MODAL.repaint(plan);
192
+ }
193
+ window.scrollTo(0, y);
194
+ }
195
+
196
+ // A REFETCH. Used where the disk actually moved: Apply, a preset, the reset,
197
+ // the switch, a board change and a recompile. Never on a staged edit.
198
+ async function hkReload() {
199
+ try {
200
+ HK_DATA = await hkLoad();
201
+ } catch (_) {
202
+ /* the last good data stays on screen — a failed poll is not a blank page */
203
+ }
204
+ hkPaint();
205
+ }
206
+
207
+ // The preview is the ONLY thing a width or fixture change moves, so it is the
208
+ // only thing refetched. Four endpoints for a picture is three too many.
209
+ async function hkPreviewReload() {
210
+ try {
211
+ const r = await read(hkPreviewUrl());
212
+ if (HK_DATA) HK_DATA.prev = r.data;
213
+ } catch (_) {}
214
+ hkPaint();
215
+ }
216
+
133
217
  // EVERY write carries the board it is about. Forgetting it on one route would
134
218
  // edit the other board's layout, which is the one mistake here that is silent.
135
219
  function bd(body) {
136
220
  return Object.assign({ board: HK_BOARD }, body);
137
221
  }
138
222
 
139
- function rerender() {
140
- const h = location.hash;
141
- location.hash = "#/";
142
- location.hash = h || "#/hookui";
143
- }
144
-
145
223
  /* ══ the staged plan ══════════════════════════════════════════════════════
146
224
  Replays every staged op onto a working copy of the saved layout and returns
147
225
  BOTH the effective board and the ordered writes that produce it. ONE
@@ -159,7 +237,10 @@ function hkOp(op) {
159
237
  if (op.op === "sep") HK_OPS = HK_OPS.filter((o) => !(o.op === "sep" && o.line === op.line));
160
238
  if (op.op === "doc") HK_OPS = HK_OPS.filter((o) => !(o.op === "doc" && o.field === op.field));
161
239
  HK_OPS.push(op);
162
- rerender();
240
+ // A REPAINT, NEVER A REFETCH. Nothing has been written, so nothing on disk
241
+ // has moved — and re-entering the router here is what made every click look
242
+ // like a save.
243
+ hkPaint();
163
244
  }
164
245
 
165
246
  function hkPlan(show, cat) {
@@ -283,7 +364,8 @@ function boardTabs(cat) {
283
364
  if (HK_OPS.length) return toast(t("hookui.applyFirst"), "bad");
284
365
  HK_BOARD = b;
285
366
  HK_SEARCH = "";
286
- rerender();
367
+ HK_COMPARE_DATA = null;
368
+ hkReload();
287
369
  });
288
370
  row.append(btn);
289
371
  }
@@ -320,7 +402,7 @@ function gateCard(show, cat) {
320
402
  // switch belongs to which board.
321
403
  const r = await post("/api/config/set", { key: cat.config_key, value: show.enabled ? "off" : "on" });
322
404
  if (!r.ok) toast(t("hookui.switchFailed"), "bad", (r.output || "").trim());
323
- rerender();
405
+ hkReload();
324
406
  });
325
407
  const acts = el("div", "row-actions");
326
408
  acts.append(b);
@@ -348,37 +430,54 @@ function cautionCard(show) {
348
430
  return c;
349
431
  }
350
432
 
351
- /* ══ the preview ══════════════════════════════════════════════════════════ */
352
- // Pinned above the board and never scrolled away. `orc statusline preview
353
- // --json`'s output, rendered as DOM and never as HTML.
354
- //
355
- // IT DRAWS THE SAVED LAYOUT, and while anything is staged it SAYS SO. The
356
- // preview renders through the hook's own engine, which reads the file so a
357
- // preview of unsaved edits would need a second engine, which is the one thing
358
- // this design refuses. "These changes are not in the picture yet" is the honest
359
- // version, and it is also the answer to "I pressed Apply and nothing happened":
360
- // something did, and this is where it shows up.
361
- function previewCard(prev, plan) {
433
+ /* == the preview ==========================================================
434
+ Pinned above the board and never scrolled away. `orc statusline preview
435
+ --json`'s output, rendered as DOM and never as HTML.
436
+
437
+ v1.4.2 - IT IS DRAWN AS A TERMINAL, because that is the claim it is making.
438
+ A status line in a bare grey box is a string; the same string inside a
439
+ window of the stated width, on a terminal ground, with a ruler that shows
440
+ where column 80 falls, is a picture of the thing. Nothing about the bytes
441
+ changed - the engine is still the hook's own - what changed is that the
442
+ reader can now believe it.
443
+
444
+ IT DRAWS THE SAVED LAYOUT, and while anything is staged it SAYS SO. The
445
+ preview renders through the hook's own engine, which reads the file - so a
446
+ preview of unsaved edits would need a second engine, which is the one thing
447
+ this design refuses. "These changes are not in the picture yet" is the honest
448
+ version, and it is also the answer to "I pressed Apply and nothing happened":
449
+ something did, and this is where it shows up.
450
+
451
+ THE HEIGHT IS THE OTHER HALF. Three strippings, four colour sets and seven
452
+ symbol sets is a wall of terminal, and it pushed the board - the thing the
453
+ user is actually working in - off the screen. All of it lives in ONE drawer
454
+ now, closed by default, and its rows are FETCHED ONLY WHEN IT IS OPENED:
455
+ eleven extra renders nobody asked to see is eleven subprocesses nobody asked
456
+ to pay for. */
457
+ function previewCard(prev, plan, cat) {
362
458
  const right = el("div", "row-actions");
363
459
  for (const w of HK_WIDTHS) {
364
460
  const b = el("button", "btn btn-xs" + (HK_WIDTH === w ? " btn-primary" : " btn-ghost"), tn(w, "hookui.cols"));
365
461
  b.type = "button";
366
462
  b.addEventListener("click", () => {
367
463
  HK_WIDTH = w;
368
- rerender();
464
+ HK_COMPARE_DATA = null;
465
+ hkPreviewReload();
369
466
  });
370
467
  right.append(b);
371
468
  }
372
469
  const c = card(t("hookui.preview"), right);
373
470
  c.append(el("div", "note", t("hookui.previewAbout")));
374
- if (plan.dirty) c.append(el("div", "note note-warn", t("hookui.previewStale")));
471
+ // THE STAGED BANNER IS AN INSTRUCTION, not a disclaimer. It says what is
472
+ // missing from the picture AND what to press to put it there.
473
+ if (plan.dirty) c.append(el("div", "note note-warn", tn(plan.actions.length, "hookui.previewStaleN")));
375
474
  if (!prev || !prev.ok) {
376
475
  c.append(el("div", "note", t("hookui.previewUnavailable")));
377
476
  return c;
378
477
  }
379
478
 
380
479
  // The fixture picker. `--fixtures` must carry one of every state INCLUDING
381
- // the ugly ones you cannot design a degraded line on a healthy session.
480
+ // the ugly ones - you cannot design a degraded line on a healthy session.
382
481
  c.append(el("div", "note", t("hookui.fixturesAbout")));
383
482
  const states = el("div", "row-actions hk-fixtures");
384
483
  for (const [k, label] of Object.entries(prev.fixtures || {})) {
@@ -386,35 +485,156 @@ function previewCard(prev, plan) {
386
485
  b.type = "button";
387
486
  b.addEventListener("click", () => {
388
487
  HK_STATE = k;
389
- rerender();
488
+ HK_COMPARE_DATA = null;
489
+ hkPreviewReload();
390
490
  });
391
491
  states.append(b);
392
492
  }
393
493
  c.append(states);
394
494
 
395
- c.append(ansiBlock(prev.text, "hk-preview"));
495
+ c.append(termWindow(prev.text, {
496
+ title: tn(HK_WIDTH, "hookui.termTitle"),
497
+ // The colour set and the symbol set the picture was drawn with are the
498
+ // CLI's answer, carried on the preview itself. Naming them here would be a
499
+ // second idea of what is in force.
500
+ tags: [prev.theme, prev.glyphs].filter(Boolean),
501
+ ruler: true,
502
+ }));
503
+
504
+ // WHAT EACH LINE COSTS IN CELLS, under the window it belongs to. A number in
505
+ // a row of chips a long way from the picture is a number nobody reads.
506
+ const meta = el("div", "row-actions hk-widths");
507
+ (prev.static_width || []).forEach((w, i) => {
508
+ if (!w) return;
509
+ meta.append(chip(tn(i + 1, "hookui.lineN") + " " + tn(w, "hookui.cells"), null));
510
+ });
511
+ if (meta.childNodes.length) c.append(meta);
512
+
513
+ c.append(compareDrawer(prev, cat));
514
+ return c;
515
+ }
516
+
517
+ // A TERMINAL, drawn as one. The chrome is not decoration: the title bar states
518
+ // the width the picture was rendered at, and the ruler shows where that width
519
+ // falls - which is the whole question a status line asks.
520
+ function termWindow(text, { title, tags, ruler, small }) {
521
+ const box = el("div", "hk-term" + (small ? " hk-term-sm" : ""));
522
+ const bar = el("div", "hk-term-bar");
523
+ const dots = el("div", "hk-term-dots");
524
+ for (let i = 0; i < 3; i++) dots.append(el("span", "hk-term-dot"));
525
+ bar.append(dots);
526
+ if (title) bar.append(el("span", "hk-term-title", title));
527
+ const tagRow = el("div", "hk-term-tags");
528
+ for (const g of tags || []) tagRow.append(el("span", "hk-term-tag", g));
529
+ bar.append(tagRow);
530
+ box.append(bar);
531
+
532
+ const screen = el("div", "hk-term-screen");
533
+ if (ruler) screen.append(rulerRow(HK_WIDTH));
534
+ screen.append(ansiBlock(text, "hk-preview hk-preview-flat"));
535
+ box.append(screen);
536
+ return box;
537
+ }
538
+
539
+ // The column ruler. Ten-cell ticks with the count at each, in the SAME
540
+ // monospaced grid as the line under it - so "this is 44 cells" stops being a
541
+ // number and becomes a place on the picture.
542
+ function rulerRow(cols) {
543
+ const row = el("div", "hk-ruler");
544
+ let out = "";
545
+ for (let i = 0; i < cols; i++) {
546
+ if (i % 10 === 0) {
547
+ const n = String(i);
548
+ out += n;
549
+ i += n.length - 1;
550
+ } else out += i % 5 === 0 ? "+" : "\u00b7";
551
+ }
552
+ row.textContent = out.slice(0, cols);
553
+ return row;
554
+ }
555
+
556
+ /* == the comparison drawer ================================================
557
+ Three strippings, every colour set and every symbol set - the same layout,
558
+ the same fixture, the same width, through the same engine. A colour set
559
+ picked from its NAME is a guess; picked from its picture it is a decision.
560
+
561
+ It is CLOSED by default and its rows are fetched on the first open, because
562
+ each one is a real render. */
563
+ function compareDrawer(prev, cat) {
564
+ const wrap = el("div", "hk-compare");
565
+ const head = el("button", "hk-compare-head");
566
+ head.type = "button";
567
+ head.setAttribute("aria-expanded", String(HK_COMPARE));
568
+ head.append(el("span", "tier-caret", "\u25b8"));
569
+ head.append(el("span", "hk-compare-title", t("hookui.compare")));
570
+ head.append(el("span", "note", t("hookui.compareAbout")));
571
+ // NOT named `body`: that name is reserved for a panel's own card container,
572
+ // which must carry `stack`. This is a drawer's contents and spaces itself.
573
+ const drawer = el("div", "hk-compare-body");
574
+ wrap.classList.toggle("hk-compare-open", HK_COMPARE);
575
+ head.addEventListener("click", () => {
576
+ HK_COMPARE = !HK_COMPARE;
577
+ wrap.classList.toggle("hk-compare-open", HK_COMPARE);
578
+ head.setAttribute("aria-expanded", String(HK_COMPARE));
579
+ if (HK_COMPARE && !HK_COMPARE_DATA) loadCompare(drawer, cat, prev);
580
+ });
581
+ wrap.append(head, drawer);
582
+ if (HK_COMPARE) {
583
+ if (HK_COMPARE_DATA) paintCompare(drawer, prev);
584
+ else loadCompare(drawer, cat, prev);
585
+ }
586
+ return wrap;
587
+ }
588
+
589
+ async function loadCompare(body, cat, prev) {
590
+ body.replaceChildren(skeleton(3));
591
+ const themes = Object.keys(cat.themes || {});
592
+ const glyphs = cat.glyph_sets || [];
593
+ const one = (extra) => read(hkPreviewUrl() + extra).then((r) => r.data).catch(() => null);
594
+ try {
595
+ const [themeRows, glyphRows] = await Promise.all([
596
+ Promise.all(themes.map((n) => one("&theme=" + encodeURIComponent(n)).then((d) => [n, d]))),
597
+ Promise.all(glyphs.map((n) => one("&glyphs=" + encodeURIComponent(n)).then((d) => [n, d]))),
598
+ ]);
599
+ HK_COMPARE_DATA = { themes: themeRows, glyphs: glyphRows };
600
+ } catch (_) {
601
+ HK_COMPARE_DATA = { themes: [], glyphs: [] };
602
+ }
603
+ paintCompare(body, prev);
604
+ }
396
605
 
397
- // THE THREE STRIPPINGS, always visible — not an afterthought tab. You cannot
398
- // design an ASCII fallback you cannot see, and a design whose only
399
- // distinction is colour becomes ambiguous the moment NO_COLOR is set.
400
- const deg = el("div", "hk-degrade");
401
- deg.append(el("div", "note", t("hookui.degrades")));
606
+ function paintCompare(body, prev) {
607
+ body.replaceChildren();
608
+ // THE THREE STRIPPINGS FIRST. You cannot design an ASCII fallback you cannot
609
+ // see, and a design whose only distinction is colour becomes ambiguous the
610
+ // moment NO_COLOR is set.
611
+ body.append(el("div", "hk-compare-group", t("hookui.degrades")));
402
612
  for (const [key, label] of [
403
613
  ["no_color", t("hookui.degNoColor")],
404
614
  ["ascii", t("hookui.degAscii")],
405
615
  ["no_motion", t("hookui.degNoMotion")],
406
616
  ]) {
407
- const row = el("div", "hk-degrade-row");
408
- row.append(el("span", "hk-degrade-label", label));
409
- row.append(ansiBlock(prev.strippings[key], "hk-preview hk-preview-sm"));
410
- deg.append(row);
617
+ body.append(compareRow(label, (prev.strippings || {})[key]));
618
+ }
619
+ const d = HK_COMPARE_DATA || { themes: [], glyphs: [] };
620
+ if (d.themes.length) {
621
+ body.append(el("div", "hk-compare-group", t("hookui.compareThemes")));
622
+ // The NAME is the CLI's word, printed verbatim - a translated colour set is
623
+ // a colour set that does not exist.
624
+ for (const [name, row] of d.themes) body.append(compareRow(name, row && row.ok ? row.text : null));
411
625
  }
412
- c.append(deg);
626
+ if (d.glyphs.length) {
627
+ body.append(el("div", "hk-compare-group", t("hookui.compareGlyphs")));
628
+ for (const [name, row] of d.glyphs) body.append(compareRow(name, row && row.ok ? row.text : null));
629
+ }
630
+ }
413
631
 
414
- const meta = el("div", "row-actions");
415
- for (const w of prev.static_width || []) if (w) meta.append(chip(tn(w, "hookui.cells"), null));
416
- c.append(meta);
417
- return c;
632
+ function compareRow(label, text) {
633
+ const row = el("div", "hk-compare-row");
634
+ row.append(el("div", "hk-compare-label", label));
635
+ if (text == null) row.append(el("div", "note", t("hookui.previewUnavailable")));
636
+ else row.append(termWindow(text, { title: label, tags: [], ruler: false, small: true }));
637
+ return row;
418
638
  }
419
639
 
420
640
  // ANSI → DOM. Never innerHTML: this string comes from the CLI, and the rule for
@@ -758,6 +978,7 @@ function announce(msg) {
758
978
  //
759
979
  // `replace` names the chip whose part is being swapped; absent, this is an add.
760
980
  function partPicker(cat, plan, line, replace) {
981
+ HK_MODAL = null;
761
982
  let q = "";
762
983
  // NOT named `body`: that name is reserved for a panel's own card container,
763
984
  // which must carry `stack`. This is a modal's contents and spaces itself.
@@ -850,6 +1071,7 @@ function matches(comp, q) {
850
1071
  // ONLY way to move a chip, because drag is off below the width at which a drop
851
1072
  // gap stops being a target.
852
1073
  function moveModal(item, line, plan) {
1074
+ HK_MODAL = null;
853
1075
  const body = el("div", "stack");
854
1076
  body.append(el("div", "note", t("hookui.moveAbout", { id: item.type })));
855
1077
 
@@ -896,6 +1118,7 @@ function moveModal(item, line, plan) {
896
1118
 
897
1119
  /* ══ remove ═══════════════════════════════════════════════════════════════ */
898
1120
  function removeModal(item, comp) {
1121
+ HK_MODAL = null;
899
1122
  const body = el("div", "stack");
900
1123
  body.append(el("div", "note", t("hookui.removeAbout", { id: item.type })));
901
1124
  if (comp && comp.summary) body.append(el("div", "note", comp.summary));
@@ -926,134 +1149,243 @@ function removeModal(item, comp) {
926
1149
  // it does. The JSON is for the diff, the editor is for the person — and the
927
1150
  // ids, renderers and state words that DO appear are the CLI's own and are never
928
1151
  // translated.
929
- function editModal(item, line, comp, cat) {
1152
+ function editModal(item0, line0, comp, cat) {
930
1153
  // NOT named `body`: that name is reserved for a panel's own card container,
931
1154
  // which must carry `stack`. This is a modal's contents and spaces itself.
932
1155
  const pane = el("div", "hk-editor");
933
1156
  if (!comp) {
934
1157
  pane.append(el("div", "note", t("hookui.unknownComponent")));
935
- modal({ title: item.type, body: pane, actions: [{ label: t("common.close"), cls: "btn-ghost", onClick: (c) => c() }] });
1158
+ modal({ title: item0.type, body: pane, actions: [{ label: t("common.close"), cls: "btn-ghost", onClick: (c) => c() }] });
936
1159
  return;
937
1160
  }
938
- const val = (field, dflt) => (field in item.over ? item.over[field] : item.src && item.src[field] !== undefined && item.src[field] !== null ? item.src[field] : dflt);
939
- const stage = (field, value, label) => hkOp({ op: "set", ref: item.ref, field, value, label });
940
-
941
- const live = el("div", "hk-editor-live");
942
- live.append(ansiBlock(chipSample(item, comp), "hk-preview hk-preview-sm"));
943
- pane.append(live);
944
- if (comp.summary) pane.append(el("div", "note", comp.summary));
945
-
946
- // Swapping the part itself, from inside the editor: "this slot, but a
947
- // different thing" is a real edit and it should not need a remove and an add.
948
- const swap = el("button", "btn btn-xs btn-ghost hk-swap", t("hookui.swap"));
949
- swap.type = "button";
950
- swap.addEventListener("click", () => {
951
- close();
952
- partPicker(cat, HK_NO_LINES, 0, item);
953
- });
954
- pane.append(swap);
955
-
956
- // THE SHAPE PICKER IS A GALLERY, NOT A DROPDOWN. Every renderer the component
957
- // declares, drawn with this component's real current value. A dropdown
958
- // containing a renderer's name tells nobody anything, and this is the
959
- // difference between a feature people use and a settings form they close.
960
- pane.append(sectionHead(t("hookui.shape"), t("hookui.shapeAbout")));
961
- const gallery = el("div", "hk-gallery");
962
- const curRender = val("render", (comp.defaults && comp.defaults.render) || comp.renderers[0]);
963
- for (const r of comp.renderers) {
964
- const b = el("button", "hk-sample" + (curRender === r ? " hk-sample-on" : ""));
965
- b.type = "button";
966
- b.title = r;
967
- b.append(ansiBlock(comp.previews && comp.previews[r] ? comp.previews[r] : r, "hk-preview hk-preview-chip"));
968
- b.append(el("span", "hk-sample-id", r));
969
- b.addEventListener("click", () => {
1161
+
1162
+ // THE EDITOR FOLLOWS THE STAGED STATE (v1.4.2). It used to be built once and
1163
+ // then left standing while the panel behind it was thrown away and rebuilt,
1164
+ // so every control it showed was the value from BEFORE the change the user
1165
+ // had just made: you picked a colour, the swatch did not move, and the only
1166
+ // reasonable conclusion was that the editor did not work. The part is
1167
+ // re-found by its stable ref on every repaint, so what is on screen is always
1168
+ // the effective value.
1169
+ let ref = item0.ref;
1170
+ let item = item0;
1171
+ let line = line0;
1172
+
1173
+ const paint = (plan) => {
1174
+ if (plan) {
1175
+ let found = null;
1176
+ for (const l of plan.lines) {
1177
+ const hit = l.items.find((x) => x.ref === ref);
1178
+ if (hit) {
1179
+ found = { l, hit };
1180
+ break;
1181
+ }
1182
+ }
1183
+ // The part is gone — a staged remove, or an Apply that took it away.
1184
+ // Closing is the only honest answer; editing something that no longer
1185
+ // exists is worse than losing the dialog.
1186
+ if (!found) return close();
1187
+ item = found.hit;
1188
+ line = found.l;
1189
+ }
1190
+ // THE CARET SURVIVES THE REPAINT. The editor rebuilds on every keystroke
1191
+ // now, and a text box that loses focus after one letter is not a text box.
1192
+ // The path is positional because the controls are the same controls in the
1193
+ // same order across a repaint; when they are not, focus is simply not
1194
+ // restored, which is the right failure.
1195
+ const keep = focusPath(pane);
1196
+ pane.replaceChildren();
1197
+ // Same rule inside the dialog: it rebuilds on every keystroke, and a
1198
+ // shape gallery that fades in each time you type a letter is the blink
1199
+ // again in a smaller box.
1200
+ pane.classList.add("hk-quiet");
1201
+ build();
1202
+ restoreFocus(pane, keep);
1203
+ };
1204
+
1205
+ // The value in force for one field: what is staged, else what the CLI
1206
+ // resolved, else the fallback. `show --json` now carries EVERY field, so this
1207
+ // can finally answer for all of them — before v1.4.2 it emitted twelve of
1208
+ // twenty-four and a control for one of the other twelve always read blank.
1209
+ const val = (field, dflt) =>
1210
+ field in item.over
1211
+ ? item.over[field]
1212
+ : item.src && item.src[field] !== undefined && item.src[field] !== null
1213
+ ? item.src[field]
1214
+ : dflt;
1215
+ // Whether the USER set this one, as opposed to inheriting it from the
1216
+ // catalogue, the colour set or the file. A control that cannot tell those
1217
+ // apart cannot say what it is about to change.
1218
+ const mine = (field) => field in item.over || !!(item.src && item.src.authored && field in item.src.authored);
1219
+ const stage = (field, value, label) => hkOp({ op: "set", ref, field, value, label });
1220
+ // A field name, plus a mark when the value is the user's own.
1221
+ const fieldName = (name, field) => (mine(field) ? name + " \u2022" : name);
1222
+
1223
+ // WHETHER THE CURRENT SHAPE USES THIS FIELD AT ALL. The CLI's answer, per
1224
+ // renderer, carried on the renderer table — the panel does not know which
1225
+ // shapes draw a label any more than it knows what a label is. Twenty-eight of
1226
+ // the thirty-five renderers ignore `label` entirely, and before this the Name
1227
+ // box was offered on every one of them: it took the text, the CLI wrote it to
1228
+ // disk correctly, and nothing ever appeared on the bar.
1229
+ //
1230
+ // The control is DISABLED and keeps its slot with the reason on it, never
1231
+ // hidden — "this shape cannot use it" and "we forgot to offer it" must not
1232
+ // look the same, and a value already set has to stay visible.
1233
+ const usesOf = (field) => {
1234
+ const rend = cat.renderers[val("render", (comp.defaults && comp.defaults.render) || comp.renderers[0])];
1235
+ if (!rend || !rend.uses) return true;
1236
+ return rend.uses.includes(field);
1237
+ };
1238
+ // Wraps a control that this shape ignores: greyed, inert, and carrying the
1239
+ // sentence that says which shapes DO use it.
1240
+ const gated = (field, node, shapes) => {
1241
+ if (usesOf(field)) return node;
1242
+ const box = el("div", "hk-inert");
1243
+ for (const b of node.querySelectorAll ? node.querySelectorAll("button, input, select") : []) b.disabled = true;
1244
+ if (node.tagName === "INPUT" || node.tagName === "SELECT") node.disabled = true;
1245
+ box.append(node);
1246
+ box.append(el("div", "note note-warn", shapes
1247
+ ? t("hookui.shapeIgnoresLabel", { shapes: shapes.join(", ") })
1248
+ : t("hookui.shapeIgnores")));
1249
+ return box;
1250
+ };
1251
+
1252
+ function build() {
1253
+ const live = el("div", "hk-editor-live");
1254
+ // WHAT THIS IS, EXACTLY. `previews` is a per-renderer sample the CLI drew
1255
+ // from its own fixture, so it shows the SHAPE this part will take - never
1256
+ // the words or the numbers this component will carry at run time. Claiming
1257
+ // otherwise would make the one picture in this dialog a lie.
1258
+ live.append(el("div", "note", t("hookui.editorLive")));
1259
+ live.append(termWindow(chipSample(item, comp), { title: comp.id, tags: [], ruler: false, small: true }));
1260
+ pane.append(live);
1261
+ if (comp.summary) pane.append(el("div", "note", comp.summary));
1262
+ pane.append(el("div", "note", t("hookui.editorStaged")));
1263
+
1264
+ // Swapping the part itself, from inside the editor: "this slot, but a
1265
+ // different thing" is a real edit and it should not need a remove and an add.
1266
+ const swap = el("button", "btn btn-xs btn-ghost hk-swap", t("hookui.swap"));
1267
+ swap.type = "button";
1268
+ swap.addEventListener("click", () => {
970
1269
  close();
971
- stage("render", r, t("hookui.shapeChange", { id: comp.id, r }));
1270
+ partPicker(cat, HK_NO_LINES, 0, item);
972
1271
  });
973
- gallery.append(b);
974
- }
975
- pane.append(gallery);
976
-
977
- pane.append(sectionHead(t("hookui.glyphs"), t("hookui.glyphsAbout")));
978
- pane.append(pickRow(cat.glyph_sets, val("glyphs", null), (v) => stage("glyphs", v, t("hookui.glyphChange", { id: comp.id, v }))));
979
-
980
- pane.append(sectionHead(t("hookui.words"), t("hookui.wordsAbout")));
981
- const label = el("input", "hk-input");
982
- label.type = "text";
983
- const cur = val("label", "");
984
- label.value = cur == null ? "" : String(cur);
985
- label.placeholder = t("hookui.labelPlaceholder");
986
- label.addEventListener("change", () => stage("label", label.value, t("hookui.labelChange", { id: comp.id, v: label.value })));
987
- pane.append(labelled(t("hookui.label"), label, t("hookui.labelAbout")));
988
- pane.append(labelled(t("hookui.case"), pickRow(cat.cases, val("case", null), (v) => stage("case", v, t("hookui.caseChange", { id: comp.id, v }))), t("hookui.caseAbout")));
989
- pane.append(labelled(t("hookui.before"), textField(val("prefix", ""), (v) => stage("prefix", v, t("hookui.beforeChange", { id: comp.id, v }))), t("hookui.beforeAbout")));
990
- pane.append(labelled(t("hookui.after"), textField(val("suffix", ""), (v) => stage("suffix", v, t("hookui.afterChange", { id: comp.id, v }))), t("hookui.afterAbout")));
991
-
992
- pane.append(sectionHead(t("hookui.colour"), t("hookui.colourAbout")));
993
- pane.append(labelled(t("hookui.labelColour"), pickRow(cat.colors, val("label_color", null), (v) => stage("label_color", v, t("hookui.colourChange", { id: comp.id, v }))), t("hookui.labelColourAbout")));
994
- pane.append(labelled(t("hookui.valueColour"), pickRow(cat.colors, val("value_color", null), (v) => stage("value_color", v, t("hookui.colourChange", { id: comp.id, v }))), t("hookui.valueColourAbout")));
995
- if (comp.bounded)
996
- pane.append(labelled(t("hookui.ramp"), pickRow(Object.keys(cat.ramps), val("ramp", null), (v) => stage("ramp", v, t("hookui.rampChange", { id: comp.id, v }))), t("hookui.rampAbout")));
997
- // R3, AT THE CONTROL, at the moment the choice is made — never in a
998
- // validation summary later.
999
- if (comp.states) pane.append(el("div", "note note-warn", t("hookui.stateColourWarn", { s: comp.states.join(", ") })));
1000
-
1001
- // "Weight", never "font size". A terminal owns its font, and a picker that
1002
- // did nothing would be worse than not offering one.
1003
- pane.append(labelled(t("hookui.emphasis"), pickRow((cat.emphasis || []).filter((e) => !(cat.refused_emphasis || []).includes(e)), (val("emphasis", []) || [])[0], (v) => stage("emphasis", v, t("hookui.emphasisChange", { id: comp.id, v }))), t("hookui.emphasisAbout")));
1004
- pane.append(el("div", "note", t("hookui.fontWhy")));
1005
-
1006
- if (comp.bounded || (comp.defaults && comp.defaults.format)) {
1007
- pane.append(sectionHead(t("hookui.numbers"), t("hookui.numbersAbout")));
1008
- pane.append(labelled(t("hookui.format"), pickRow(cat.formats, val("format", null), (v) => stage("format", v, t("hookui.formatChange", { id: comp.id, v }))), t("hookui.formatAbout")));
1009
- pane.append(labelled(t("hookui.compact"), pickRow(cat.compact, val("compact", null), (v) => stage("compact", v, t("hookui.compactChange", { id: comp.id, v }))), t("hookui.compactAbout")));
1010
- // `min_width` is not cosmetic: a value that changes width shifts every part
1011
- // to its right on the keystroke it happens, and that jitter is the main
1012
- // reason people turn a status line off again.
1013
- pane.append(labelled(t("hookui.minWidth"), numField(val("min_width", null), 0, 8, (v) => stage("min_width", v, t("hookui.minWidthChange", { id: comp.id, v }))), t("hookui.minWidthWarn")));
1014
- pane.append(labelled(t("hookui.precision"), numField(val("precision", null), 0, 2, (v) => stage("precision", v, t("hookui.precisionChange", { id: comp.id, v }))), t("hookui.precisionAbout")));
1015
- }
1272
+ pane.append(swap);
1273
+
1274
+ // THE SHAPE PICKER IS A GALLERY, NOT A DROPDOWN. Every renderer the component
1275
+ // declares, drawn with this component's real current value. A dropdown
1276
+ // containing a renderer's name tells nobody anything, and this is the
1277
+ // difference between a feature people use and a settings form they close.
1278
+ pane.append(sectionHead(t("hookui.shape"), t("hookui.shapeAbout")));
1279
+ const gallery = el("div", "hk-gallery");
1280
+ const curRender = val("render", (comp.defaults && comp.defaults.render) || comp.renderers[0]);
1281
+ for (const r of comp.renderers) {
1282
+ const b = el("button", "hk-sample" + (curRender === r ? " hk-sample-on" : ""));
1283
+ b.type = "button";
1284
+ b.title = r;
1285
+ b.append(ansiBlock(comp.previews && comp.previews[r] ? comp.previews[r] : r, "hk-preview hk-preview-chip"));
1286
+ b.append(el("span", "hk-sample-id", r));
1287
+ // IT NO LONGER CLOSES. Closing was how the old editor hid the fact that
1288
+ // it could not update itself; now the gallery marks the new shape and the
1289
+ // sample above redraws, which is what a person expects a picker to do.
1290
+ b.addEventListener("click", () => stage("render", r, t("hookui.shapeChange", { id: comp.id, r })));
1291
+ gallery.append(b);
1292
+ }
1293
+ pane.append(gallery);
1294
+ // AT THE CONTROL, at the moment the choice is made. Which shape you pick
1295
+ // decides whether the Name box below is live at all, so the sentence
1296
+ // belongs here rather than in a surprise further down the dialog.
1297
+ pane.append(el("div", "note", t("hookui.shapeDecides", { shapes: (cat.label_renderers || []).join(", ") })));
1298
+
1299
+ pane.append(sectionHead(t("hookui.glyphs"), t("hookui.glyphsAbout")));
1300
+ pane.append(pickRow(cat.glyph_sets, val("glyphs", null), (v) => stage("glyphs", v, t("hookui.glyphChange", { id: comp.id, v }))));
1301
+
1302
+ pane.append(sectionHead(t("hookui.words"), t("hookui.wordsAbout")));
1303
+ const label = el("input", "hk-input");
1304
+ label.type = "text";
1305
+ const cur = val("label", "");
1306
+ label.value = cur == null ? "" : String(cur);
1307
+ label.placeholder = t("hookui.labelPlaceholder");
1308
+ // BOTH EVENTS. `change` alone fires on blur, so a name typed and then
1309
+ // followed by a click on Close raced the close and was sometimes never
1310
+ // staged at all. `input` stages every keystroke; `hkOp` already collapses
1311
+ // repeated edits of one field into one decision, so the pending list still
1312
+ // reads as a list of decisions.
1313
+ const push = () => stage("label", label.value, t("hookui.labelChange", { id: comp.id, v: label.value }));
1314
+ label.addEventListener("change", push);
1315
+ label.addEventListener("input", push);
1316
+ pane.append(labelled(fieldName(t("hookui.label"), "label"), gated("label", label, cat.label_renderers), t("hookui.labelAbout")));
1317
+ pane.append(labelled(fieldName(t("hookui.case"), "case"), gated("case", pickRow(cat.cases, val("case", null), (v) => stage("case", v, t("hookui.caseChange", { id: comp.id, v })))), t("hookui.caseAbout")));
1318
+ pane.append(labelled(fieldName(t("hookui.before"), "prefix"), gated("prefix", textField(val("prefix", ""), (v) => stage("prefix", v, t("hookui.beforeChange", { id: comp.id, v })))), t("hookui.beforeAbout")));
1319
+ pane.append(labelled(fieldName(t("hookui.after"), "suffix"), gated("suffix", textField(val("suffix", ""), (v) => stage("suffix", v, t("hookui.afterChange", { id: comp.id, v })))), t("hookui.afterAbout")));
1320
+
1321
+ pane.append(sectionHead(t("hookui.colour"), t("hookui.colourAbout")));
1322
+ pane.append(labelled(fieldName(t("hookui.labelColour"), "label_color"), pickRow(cat.colors, val("label_color", null), (v) => stage("label_color", v, t("hookui.colourChange", { id: comp.id, v }))), t("hookui.labelColourAbout")));
1323
+ pane.append(labelled(fieldName(t("hookui.valueColour"), "value_color"), pickRow(cat.colors, val("value_color", null), (v) => stage("value_color", v, t("hookui.colourChange", { id: comp.id, v }))), t("hookui.valueColourAbout")));
1324
+ if (comp.bounded)
1325
+ pane.append(labelled(fieldName(t("hookui.ramp"), "ramp"), pickRow(Object.keys(cat.ramps), val("ramp", null), (v) => stage("ramp", v, t("hookui.rampChange", { id: comp.id, v }))), t("hookui.rampAbout")));
1326
+ // R3, AT THE CONTROL, at the moment the choice is made - never in a
1327
+ // validation summary later.
1328
+ if (comp.states) pane.append(el("div", "note note-warn", t("hookui.stateColourWarn", { s: comp.states.join(", ") })));
1329
+
1330
+ // "Weight", never "font size". A terminal owns its font, and a picker that
1331
+ // did nothing would be worse than not offering one.
1332
+ pane.append(labelled(fieldName(t("hookui.emphasis"), "emphasis"), pickRow((cat.emphasis || []).filter((e) => !(cat.refused_emphasis || []).includes(e)), (val("emphasis", []) || [])[0], (v) => stage("emphasis", v, t("hookui.emphasisChange", { id: comp.id, v }))), t("hookui.emphasisAbout")));
1333
+ pane.append(el("div", "note", t("hookui.fontWhy")));
1334
+
1335
+ if (comp.bounded || (comp.defaults && comp.defaults.format)) {
1336
+ pane.append(sectionHead(t("hookui.numbers"), t("hookui.numbersAbout")));
1337
+ pane.append(labelled(fieldName(t("hookui.format"), "format"), gated("format", pickRow(cat.formats, val("format", null), (v) => stage("format", v, t("hookui.formatChange", { id: comp.id, v })))), t("hookui.formatAbout")));
1338
+ pane.append(labelled(fieldName(t("hookui.compact"), "compact"), gated("compact", pickRow(cat.compact, val("compact", null), (v) => stage("compact", v, t("hookui.compactChange", { id: comp.id, v })))), t("hookui.compactAbout")));
1339
+ // `min_width` is not cosmetic: a value that changes width shifts every part
1340
+ // to its right on the keystroke it happens, and that jitter is the main
1341
+ // reason people turn a status line off again.
1342
+ pane.append(labelled(fieldName(t("hookui.minWidth"), "min_width"), gated("min_width", numField(val("min_width", null), 0, 8, (v) => stage("min_width", v, t("hookui.minWidthChange", { id: comp.id, v })))), t("hookui.minWidthWarn")));
1343
+ pane.append(labelled(fieldName(t("hookui.precision"), "precision"), gated("precision", numField(val("precision", null), 0, 2, (v) => stage("precision", v, t("hookui.precisionChange", { id: comp.id, v })))), t("hookui.precisionAbout")));
1344
+ }
1016
1345
 
1017
- const rend = cat.renderers[curRender];
1018
- if (rend && rend.width)
1019
- pane.append(labelled(t("hookui.width"), numField(val("width", null), rend.width[0], rend.width[1], (v) => stage("width", v, t("hookui.widthChange", { id: comp.id, v }))), t("hookui.widthWhy")));
1020
-
1021
- // A CHECKLIST, not an enum: "hide when zero" and "hide when there is no run"
1022
- // are independent facts and a user wants both at once.
1023
- pane.append(sectionHead(t("hookui.whenToShow"), t("hookui.whenToShowAbout")));
1024
- const checks = el("div", "hk-checks");
1025
- const active = new Set(val("hide_when", []) || []);
1026
- for (const h of cat.hide_when) {
1027
- if (h.id === "never") continue;
1028
- const lab = el("label", "hk-check");
1029
- const box = el("input");
1030
- box.type = "checkbox";
1031
- box.checked = active.has(h.id);
1032
- box.addEventListener("change", () => {
1033
- const next = new Set(active);
1034
- if (box.checked) next.add(h.id);
1035
- else next.delete(h.id);
1036
- const v = next.size ? [...next].join(",") : "never";
1037
- stage("hide_when", v, t("hookui.hideChange", { id: comp.id, v }));
1038
- });
1039
- lab.append(box, el("span", null, h.id), el("span", "note", h.says));
1040
- checks.append(lab);
1346
+ const rend = cat.renderers[curRender];
1347
+ if (rend && rend.width)
1348
+ pane.append(labelled(fieldName(t("hookui.width"), "width"), numField(val("width", null), rend.width[0], rend.width[1], (v) => stage("width", v, t("hookui.widthChange", { id: comp.id, v }))), t("hookui.widthWhy")));
1349
+
1350
+ // A CHECKLIST, not an enum: "hide when zero" and "hide when there is no run"
1351
+ // are independent facts and a user wants both at once.
1352
+ pane.append(sectionHead(t("hookui.whenToShow"), t("hookui.whenToShowAbout")));
1353
+ const checks = el("div", "hk-checks");
1354
+ const active = new Set(val("hide_when", []) || []);
1355
+ for (const h of cat.hide_when) {
1356
+ if (h.id === "never") continue;
1357
+ const lab = el("label", "hk-check");
1358
+ const box = el("input");
1359
+ box.type = "checkbox";
1360
+ box.checked = active.has(h.id);
1361
+ box.addEventListener("change", () => {
1362
+ const next = new Set(active);
1363
+ if (box.checked) next.add(h.id);
1364
+ else next.delete(h.id);
1365
+ const v = next.size ? [...next].join(",") : "never";
1366
+ stage("hide_when", v, t("hookui.hideChange", { id: comp.id, v }));
1367
+ });
1368
+ lab.append(box, el("span", null, h.id), el("span", "note", h.says));
1369
+ checks.append(lab);
1370
+ }
1371
+ pane.append(checks);
1372
+
1373
+ // RESPONSIVE WIDTH: the range of terminal widths in which this part is worth
1374
+ // its cells. Strictly better than truncating the right of an overflowing
1375
+ // line, because the USER chooses what survives a narrow terminal.
1376
+ pane.append(sectionHead(t("hookui.narrow"), t("hookui.narrowAbout")));
1377
+ pane.append(labelled(fieldName(t("hookui.minCols"), "min_cols"), numField(val("min_cols", null), 0, 200, (v) => stage("min_cols", v, t("hookui.minColsChange", { id: comp.id, v }))), t("hookui.minColsAbout")));
1378
+ pane.append(labelled(fieldName(t("hookui.priority"), "priority"), numField(val("priority", null), 1, 5, (v) => stage("priority", v, t("hookui.priorityChange", { id: comp.id, v }))), t("hookui.priorityWhy")));
1041
1379
  }
1042
- pane.append(checks);
1043
1380
 
1044
- // RESPONSIVE WIDTH: the range of terminal widths in which this part is worth
1045
- // its cells. Strictly better than truncating the right of an overflowing
1046
- // line, because the USER chooses what survives a narrow terminal.
1047
- pane.append(sectionHead(t("hookui.narrow"), t("hookui.narrowAbout")));
1048
- pane.append(labelled(t("hookui.minCols"), numField(val("min_cols", null), 0, 200, (v) => stage("min_cols", v, t("hookui.minColsChange", { id: comp.id, v }))), t("hookui.minColsAbout")));
1049
- pane.append(labelled(t("hookui.priority"), numField(val("priority", null), 1, 5, (v) => stage("priority", v, t("hookui.priorityChange", { id: comp.id, v }))), t("hookui.priorityWhy")));
1381
+ paint(null);
1050
1382
 
1051
1383
  const close = modal({
1052
1384
  title: comp.id,
1053
1385
  body: pane,
1054
1386
  actions: [
1055
1387
  // WHERE EACH VALUE CAME FROM. It is how a user finds their own overrides
1056
- // again after a colour set change `orc lane config`'s problem, at part
1388
+ // again after a colour set change - `orc lane config`'s problem, at part
1057
1389
  // scale. It reads the SAVED item, so it is offered only for one.
1058
1390
  ...(item.src
1059
1391
  ? [{
@@ -1062,23 +1394,67 @@ function editModal(item, line, comp, cat) {
1062
1394
  onClick: async () => {
1063
1395
  const r = await read("/api/statusline/explain?board=" + HK_BOARD + "&at=" + line.line + ":" + (line.items.indexOf(item) + 1));
1064
1396
  if (!r.data || !r.data.ok) return toast(t("hookui.explainFailed"), "bad");
1397
+ HK_MODAL = null;
1065
1398
  modal({
1066
1399
  title: comp.id,
1067
- pane: kvList(r.data.resolved.map((x) => [x.field, x.source])),
1400
+ body: kvList(r.data.resolved.map((x) => [x.field, x.source])),
1068
1401
  actions: [{ label: t("common.close"), cls: "btn-ghost", onClick: (c) => c() }],
1069
1402
  });
1070
1403
  },
1071
1404
  }]
1072
1405
  : []),
1073
- { label: t("common.close"), cls: "btn-ghost", onClick: (c) => c() },
1406
+ {
1407
+ label: t("common.close"),
1408
+ cls: "btn-ghost",
1409
+ onClick: (c) => {
1410
+ HK_MODAL = null;
1411
+ c();
1412
+ },
1413
+ },
1074
1414
  ],
1075
1415
  });
1416
+ // Registered LAST, so a repaint that fires during construction cannot reach a
1417
+ // half-built pane.
1418
+ HK_MODAL = { repaint: paint };
1076
1419
  }
1077
1420
 
1078
1421
  // `partPicker` reads a board only to answer "can a part go on that line". A
1079
1422
  // SWAP changes nothing about where the part sits, so it is handed a board with
1080
1423
  // no lines and never asks.
1081
- const HK_NO_LINES = { lines: [], max: 5 };
1424
+ const HK_NO_LINES = { lines: [], max: 0 };
1425
+
1426
+ // Where the caret is, as a path of child indexes from the editor's own root,
1427
+ // plus the selection inside the field. Not an id: a control here has no
1428
+ // identity of its own, and inventing one for every field would be a second
1429
+ // idea of what the editor holds.
1430
+ function focusPath(root) {
1431
+ const a = document.activeElement;
1432
+ if (!a || !root.contains(a)) return null;
1433
+ const path = [];
1434
+ let n = a;
1435
+ while (n && n !== root) {
1436
+ path.unshift([...n.parentNode.childNodes].indexOf(n));
1437
+ n = n.parentNode;
1438
+ }
1439
+ const sel = "selectionStart" in a && a.selectionStart !== null ? [a.selectionStart, a.selectionEnd] : null;
1440
+ return { path, sel };
1441
+ }
1442
+
1443
+ function restoreFocus(root, keep) {
1444
+ if (!keep) return;
1445
+ let n = root;
1446
+ for (const i of keep.path) {
1447
+ n = n && n.childNodes[i];
1448
+ if (!n) return;
1449
+ }
1450
+ if (!n.focus) return;
1451
+ n.focus();
1452
+ if (keep.sel && "setSelectionRange" in n) {
1453
+ try {
1454
+ n.setSelectionRange(keep.sel[0], keep.sel[1]);
1455
+ } catch (_) {}
1456
+ }
1457
+ }
1082
1458
 
1083
1459
  function sectionHead(name, about) {
1084
1460
  const w = el("div", "hk-section-wrap");
@@ -1244,7 +1620,8 @@ function confirmApply(name) {
1244
1620
  const r = await post("/api/statusline/apply", bd({ name }));
1245
1621
  if (!r.ok) toast(t("hookui.applyFailed"), "bad", (r.output || "").trim());
1246
1622
  HK_OPS = [];
1247
- rerender();
1623
+ HK_COMPARE_DATA = null;
1624
+ hkReload();
1248
1625
  },
1249
1626
  },
1250
1627
  ],
@@ -1264,7 +1641,8 @@ function confirmReset() {
1264
1641
  c();
1265
1642
  await post("/api/statusline/reset", bd({}));
1266
1643
  HK_OPS = [];
1267
- rerender();
1644
+ HK_COMPARE_DATA = null;
1645
+ hkReload();
1268
1646
  },
1269
1647
  },
1270
1648
  ],
@@ -1303,7 +1681,7 @@ function advancedCard(cat, plan) {
1303
1681
  re.addEventListener("click", async () => {
1304
1682
  const r = await post("/api/statusline/compile", bd({}));
1305
1683
  toast(r.ok ? t("hookui.recompiled") : t("hookui.recompileFailed"), r.ok ? "ok" : "bad");
1306
- rerender();
1684
+ hkReload();
1307
1685
  });
1308
1686
  const acts = el("div", "row-actions");
1309
1687
  acts.append(re);