@azure-id/orc 1.2.1 → 1.4.1

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.
@@ -0,0 +1,1313 @@
1
+ "use strict";
2
+ /* panels/hookui.js — orc ui client
3
+ The CLI Hook Interface: compose ORC's status line.
4
+
5
+ THE ONE DESIGN DECISION THIS PANEL IS BUILT AROUND: you are composing
6
+ something you cannot see while you compose it. The bar lives in a terminal at
7
+ the bottom of a different window. Every other ORC panel shows you STATE; this
8
+ one has to show you a RESULT — continuously, accurately, and in the forms it
9
+ can take. If the preview is not what the hook will print, this panel is worse
10
+ than editing JSON, because it is confidently wrong.
11
+
12
+ It is not, and that is structural rather than careful: `orc statusline
13
+ preview` renders through the SAME engine the hook does
14
+ (templates/hooks/orc-statusline-render.js), so the two cannot diverge.
15
+
16
+ THE PANEL DERIVES NOTHING. Not a component id, not a renderer name, not a
17
+ glyph set, not a ramp, not a colour token, not a separator, not a state word,
18
+ not the board's own rules. All of it arrives from `statusline components
19
+ --json`, and a test greps this file and both string tables for those
20
+ literals. (The Flow-stepper rule, on a fourth surface.)
21
+
22
+ v1.4.1 — THE BOARD IS THE ONLY PLACE ANYTHING IS APPLIED, AND EVERY WRITE'S
23
+ POSITION IS COMPUTED WHEN THAT WRITE RUNS. The list of parts used to carry
24
+ three `+ line N` buttons per row, so the act of building the bar lived a long
25
+ way from the bar — and each of those buttons computed a position from the
26
+ SAVED layout rather than from the layout being staged. Three staged adds all
27
+ landed on position 1, and the CLI read the second and third as EDITS of the
28
+ first: that is why adding three parts produced one. Structural edits are now
29
+ semantic ops against stable refs, replayed by `hkPlan` in order, and every
30
+ position is derived at the moment that op will execute. The list below the
31
+ board is a REFERENCE now: it says what each part shows, and it applies
32
+ nothing. */
33
+
34
+ /* -------------------------------------------------------------- HOOK UI */
35
+
36
+ // The preview widths the panel offers. Not a fact about the layout — a fact
37
+ // about terminals people actually use — so it lives here rather than coming
38
+ // from the CLI. They are COLUMN COUNTS: how many character cells wide the
39
+ // terminal is. The card says so out loud, because three bare numbers over a
40
+ // picture is a control nobody can guess the meaning of.
41
+ const HK_WIDTHS = [80, 120, 160];
42
+ let HK_WIDTH = 120;
43
+ let HK_STATE = "healthy";
44
+ // The reference list's search box and its open group, both surviving a
45
+ // re-render so a paint does not close a list the user just opened.
46
+ let HK_SEARCH = "";
47
+ let HK_GROUP = null;
48
+ // WHICH BOARD. The main status line, or the row Claude Code draws per subagent
49
+ // in the agent panel. The panel does not know what boards exist — `components
50
+ // --json` carries the list — it only remembers which one you are looking at, so
51
+ // a re-render does not put you back on the other one.
52
+ let HK_BOARD = "status";
53
+
54
+ // THE STAGED OPS, in the order they were made. NOT a map of finished HTTP
55
+ // bodies: a body carries a POSITION, and a position computed when the op was
56
+ // staged is wrong the moment an earlier op shifts the line under it. These are
57
+ // semantic ops against stable refs; `hkPlan` turns them into writes — and
58
+ // recomputes every position — each time the board paints.
59
+ let HK_OPS = [];
60
+ // The chip being dragged. Transient: cleared on every drop and every paint.
61
+ let HK_DRAG = null;
62
+
63
+ // DRAG IS OFF ON A NARROW SCREEN, and off for real rather than merely restyled:
64
+ // a 40-pixel drop gap on a phone is not a target, so the three buttons on every
65
+ // chip are the path there. The query matches 06-responsive.css's own breakpoint
66
+ // — a chip that still says `draggable` while its handle is hidden is a promise
67
+ // the page cannot keep.
68
+ function hkCanDrag() {
69
+ try {
70
+ return !window.matchMedia("(max-width: 600px)").matches;
71
+ } catch (_) {
72
+ return true;
73
+ }
74
+ }
75
+
76
+ PANELS.hookui = function (host) {
77
+ 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
+ );
131
+ };
132
+
133
+ // EVERY write carries the board it is about. Forgetting it on one route would
134
+ // edit the other board's layout, which is the one mistake here that is silent.
135
+ function bd(body) {
136
+ return Object.assign({ board: HK_BOARD }, body);
137
+ }
138
+
139
+ function rerender() {
140
+ const h = location.hash;
141
+ location.hash = "#/";
142
+ location.hash = h || "#/hookui";
143
+ }
144
+
145
+ /* ══ the staged plan ══════════════════════════════════════════════════════
146
+ Replays every staged op onto a working copy of the saved layout and returns
147
+ BOTH the effective board and the ordered writes that produce it. ONE
148
+ function, because a second idea of where a part will land is exactly the bug
149
+ this replaces: the board drew one arrangement and the writes produced
150
+ another, silently. */
151
+
152
+ // Ops are staged by REF, never by position. A ref is either an item id the CLI
153
+ // already gave us, or `new:<n>` for something staged in this session.
154
+ function hkOp(op) {
155
+ // A second edit to the same field of the same part REPLACES the first — one
156
+ // decision typed twice is one decision, and the pending list has to read as a
157
+ // list of decisions rather than a list of keystrokes.
158
+ if (op.op === "set") HK_OPS = HK_OPS.filter((o) => !(o.op === "set" && o.ref === op.ref && o.field === op.field));
159
+ if (op.op === "sep") HK_OPS = HK_OPS.filter((o) => !(o.op === "sep" && o.line === op.line));
160
+ if (op.op === "doc") HK_OPS = HK_OPS.filter((o) => !(o.op === "doc" && o.field === op.field));
161
+ HK_OPS.push(op);
162
+ rerender();
163
+ }
164
+
165
+ function hkPlan(show, cat) {
166
+ const structural = new Set((cat.components || []).filter((c) => c.structural).map((c) => c.id));
167
+ const lines = (show.lines || []).map((l) => ({
168
+ line: l.line,
169
+ separator: l.separator,
170
+ items: (l.items || []).map((it) => ({ ref: it.id, type: it.type, src: it, over: {}, isNew: false })),
171
+ }));
172
+ const actions = [];
173
+ const doc = { theme: show.theme, glyphs: show.glyphs };
174
+ const find = (ref) => {
175
+ for (const l of lines) {
176
+ const i = l.items.findIndex((x) => x.ref === ref);
177
+ if (i >= 0) return { l, i, it: l.items[i] };
178
+ }
179
+ return null;
180
+ };
181
+
182
+ HK_OPS.forEach((op, oi) => {
183
+ const key = "op" + (oi + 1);
184
+ if (op.op === "add") {
185
+ const l = lines[op.line - 1];
186
+ if (!l) return;
187
+ // THE POSITION IS COMPUTED HERE, not when the button was pressed. Three
188
+ // adds in a row get 1, 2 and 3 and append; three adds computed at stage
189
+ // time all got 1, and the CLI read 2 and 3 as EDITS of the first.
190
+ const pos = l.items.length + 1;
191
+ l.items.push({ ref: "new:" + oi, type: op.type, src: null, over: {}, isNew: true });
192
+ actions.push({ key, route: "/api/statusline/set", body: bd({ line: op.line, pos, type: op.type }), label: t("hookui.opAdd", { id: op.type, n: op.line }) });
193
+ } else if (op.op === "remove") {
194
+ const f = find(op.ref);
195
+ if (!f) return;
196
+ actions.push({ key, route: "/api/statusline/remove", body: bd({ at: f.l.line + ":" + (f.i + 1) }), label: t("hookui.opRemove", { id: f.it.type }) });
197
+ f.l.items.splice(f.i, 1);
198
+ } else if (op.op === "move") {
199
+ const f = find(op.ref);
200
+ if (!f) return;
201
+ const from = f.l.line + ":" + (f.i + 1);
202
+ f.l.items.splice(f.i, 1);
203
+ const dst = lines[op.toLine - 1];
204
+ if (!dst) return;
205
+ const at = Math.max(0, Math.min(op.toPos - 1, dst.items.length));
206
+ dst.items.splice(at, 0, f.it);
207
+ actions.push({ key, route: "/api/statusline/move", body: bd({ from, to: dst.line + ":" + (at + 1) }), label: t("hookui.opMove", { id: f.it.type, n: dst.line }) });
208
+ } else if (op.op === "clone") {
209
+ const f = find(op.ref);
210
+ if (!f) return;
211
+ actions.push({ key, route: "/api/statusline/clone", body: bd({ at: f.l.line + ":" + (f.i + 1) }), label: t("hookui.opClone", { id: f.it.type }) });
212
+ f.l.items.splice(f.i + 1, 0, { ref: "new:" + oi, type: f.it.type, src: f.it.src, over: Object.assign({}, f.it.over), isNew: true });
213
+ } else if (op.op === "set") {
214
+ const f = find(op.ref);
215
+ if (!f) return;
216
+ f.it.over[op.field] = op.value;
217
+ if (op.field === "type") f.it.type = op.value;
218
+ actions.push({ key, route: "/api/statusline/set", body: bd(Object.assign({ line: f.l.line, pos: f.i + 1 }, { [op.field]: op.value })), label: op.label });
219
+ } else if (op.op === "sep") {
220
+ const l = lines[op.line - 1];
221
+ if (!l) return;
222
+ l.separator = op.value;
223
+ actions.push({ key, route: "/api/statusline/line", body: bd({ line: op.line, separator: op.value }), label: op.label });
224
+ } else if (op.op === "doc") {
225
+ doc[op.field] = op.value;
226
+ actions.push({ key, route: "/api/statusline/doc", body: bd({ [op.field]: op.value }), label: op.label });
227
+ }
228
+ });
229
+
230
+ for (const l of lines) {
231
+ // A spacer is not a thing the line SAYS, so it does not eat one of the
232
+ // five. Which ids those are is the CLI's answer, carried per component.
233
+ l.counted = l.items.filter((x) => !structural.has(x.type)).length;
234
+ l.full = l.counted >= cat.max_per_line;
235
+ }
236
+ return { lines, actions, doc, dirty: HK_OPS.length > 0, max: cat.max_per_line };
237
+ }
238
+
239
+ // The dense-prefix rule, applied to ONE line of the EFFECTIVE board — never the
240
+ // saved one, or a part staged onto line 1 would leave line 2 still refusing.
241
+ // The reason travels with the answer, because a disabled zone with no reason is
242
+ // a shrug.
243
+ function lineLegal(plan, n) {
244
+ if (n === 1) return { ok: true };
245
+ const above = plan.lines[n - 2];
246
+ if (above && above.items.length > 0) return { ok: true };
247
+ return { ok: false, why: t("hookui.fillFirst", { n: n - 1 }) };
248
+ }
249
+
250
+ // Why a part cannot go on this line RIGHT NOW; a null answer means it can. Both
251
+ // reasons are the user's to read BEFORE the click, never after it.
252
+ function addBlocked(plan, n) {
253
+ const legal = lineLegal(plan, n);
254
+ if (!legal.ok) return legal.why;
255
+ const line = plan.lines[n - 1];
256
+ if (!line) return t("hookui.noSuchLine");
257
+ if (line.full) return t("hookui.lineFull", { n: n, max: plan.max });
258
+ return null;
259
+ }
260
+
261
+ /* ══ the board tabs ═══════════════════════════════════════════════════════ */
262
+ // The two boards. Named by the CLI (`boards` in the catalogue), labelled by the
263
+ // panel — the id is data and the label is prose, which is the split everywhere
264
+ // else in this file too.
265
+ //
266
+ // The keys are SPELLED OUT rather than assembled from the id, for the same
267
+ // reason the tier labels are: a key built from a fragment is invisible to the
268
+ // coverage check that keeps the two string tables honest.
269
+ const HK_BOARD_LABEL = { status: "hookui.boardStatus", subagent: "hookui.boardSubagent" };
270
+ const HK_BOARD_SUB = { status: "hookui.boardStatusSub", subagent: "hookui.boardSubagentSub" };
271
+ function boardTabs(cat) {
272
+ const c = card(null);
273
+ const row = el("div", "row-actions hk-boards");
274
+ for (const b of cat.boards || ["status"]) {
275
+ const btn = el("button", "btn btn-sm" + (HK_BOARD === b ? " btn-primary" : " btn-ghost"),
276
+ t(HK_BOARD_LABEL[b] || HK_BOARD_LABEL.status));
277
+ btn.type = "button";
278
+ btn.addEventListener("click", () => {
279
+ if (HK_BOARD === b) return;
280
+ // Switching boards with ops staged would send them to a board they were
281
+ // never about: every op carries a board, but the POSITIONS in them were
282
+ // computed against this one.
283
+ if (HK_OPS.length) return toast(t("hookui.applyFirst"), "bad");
284
+ HK_BOARD = b;
285
+ HK_SEARCH = "";
286
+ rerender();
287
+ });
288
+ row.append(btn);
289
+ }
290
+ c.append(row);
291
+ c.append(el("div", "note", t(HK_BOARD_SUB[HK_BOARD] || HK_BOARD_SUB.status)));
292
+ return c;
293
+ }
294
+
295
+ /* ══ the gate ═════════════════════════════════════════════════════════════ */
296
+ // INVERTED from the Extra panel's. There, nothing exists until you connect;
297
+ // here the board is LIVE WHILE OFF, because you must be able to compose before
298
+ // you arm. What is gated is the switch, not the work.
299
+ function gateCard(show, cat) {
300
+ const c = card(t("hookui.gate"));
301
+ const row = el("div", "row-actions");
302
+ row.append(chip(show.enabled ? t("hookui.on") : t("hookui.off"), show.enabled ? "ok" : null));
303
+ if (show.saved) row.append(chip(t("hookui.saved"), null));
304
+ c.append(row);
305
+ c.append(el("div", "note", show.enabled ? t("hookui.gateOnNote") : t("hookui.gateOffNote")));
306
+
307
+ const b = el("button", "btn btn-sm" + (show.enabled ? " btn-ghost" : " btn-primary"),
308
+ show.enabled ? t("hookui.turnOff") : t("hookui.turnOn"));
309
+ b.type = "button";
310
+ // ENABLED WITH NOTHING SAVED IS NOT A STATE. The switch cannot reach `on`
311
+ // past a layout that does not validate, and the refusal names the violation
312
+ // rather than saying "invalid".
313
+ if (!show.enabled && !show.ok) {
314
+ b.disabled = true;
315
+ b.title = show.errors[0] || "";
316
+ c.append(el("div", "note note-warn", show.errors[0] || ""));
317
+ }
318
+ b.addEventListener("click", async () => {
319
+ // The KEY IS THE CLI'S. Naming it here would be a second idea of which
320
+ // switch belongs to which board.
321
+ const r = await post("/api/config/set", { key: cat.config_key, value: show.enabled ? "off" : "on" });
322
+ if (!r.ok) toast(t("hookui.switchFailed"), "bad", (r.output || "").trim());
323
+ rerender();
324
+ });
325
+ const acts = el("div", "row-actions");
326
+ acts.append(b);
327
+ c.append(acts);
328
+ return c;
329
+ }
330
+
331
+ /* ══ cautions ═════════════════════════════════════════════════════════════ */
332
+ // An error is a refusal; a warning is a fact the user then owns. They are drawn
333
+ // differently and they are never merged into one count.
334
+ function cautionCard(show) {
335
+ const c = card(t("hookui.cautions"));
336
+ for (const e of show.errors) {
337
+ const row = el("div", "hk-caution hk-caution-bad");
338
+ row.append(el("span", "hk-caution-mark", "✕"));
339
+ row.append(el("span", null, e));
340
+ c.append(row);
341
+ }
342
+ for (const w of show.warnings) {
343
+ const row = el("div", "hk-caution");
344
+ row.append(el("span", "hk-caution-mark", "⚠"));
345
+ row.append(el("span", null, w));
346
+ c.append(row);
347
+ }
348
+ return c;
349
+ }
350
+
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) {
362
+ const right = el("div", "row-actions");
363
+ for (const w of HK_WIDTHS) {
364
+ const b = el("button", "btn btn-xs" + (HK_WIDTH === w ? " btn-primary" : " btn-ghost"), tn(w, "hookui.cols"));
365
+ b.type = "button";
366
+ b.addEventListener("click", () => {
367
+ HK_WIDTH = w;
368
+ rerender();
369
+ });
370
+ right.append(b);
371
+ }
372
+ const c = card(t("hookui.preview"), right);
373
+ c.append(el("div", "note", t("hookui.previewAbout")));
374
+ if (plan.dirty) c.append(el("div", "note note-warn", t("hookui.previewStale")));
375
+ if (!prev || !prev.ok) {
376
+ c.append(el("div", "note", t("hookui.previewUnavailable")));
377
+ return c;
378
+ }
379
+
380
+ // 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.
382
+ c.append(el("div", "note", t("hookui.fixturesAbout")));
383
+ const states = el("div", "row-actions hk-fixtures");
384
+ for (const [k, label] of Object.entries(prev.fixtures || {})) {
385
+ const b = el("button", "btn btn-xs" + (HK_STATE === k ? " btn-primary" : " btn-ghost"), label);
386
+ b.type = "button";
387
+ b.addEventListener("click", () => {
388
+ HK_STATE = k;
389
+ rerender();
390
+ });
391
+ states.append(b);
392
+ }
393
+ c.append(states);
394
+
395
+ c.append(ansiBlock(prev.text, "hk-preview"));
396
+
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")));
402
+ for (const [key, label] of [
403
+ ["no_color", t("hookui.degNoColor")],
404
+ ["ascii", t("hookui.degAscii")],
405
+ ["no_motion", t("hookui.degNoMotion")],
406
+ ]) {
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);
411
+ }
412
+ c.append(deg);
413
+
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;
418
+ }
419
+
420
+ // ANSI → DOM. Never innerHTML: this string comes from the CLI, and the rule for
421
+ // every other rendered artefact in this panel holds here too.
422
+ function ansiBlock(text, cls) {
423
+ const pre = el("pre", cls);
424
+ for (const line of String(text || "").split("\n")) {
425
+ const row = el("div", "hk-line");
426
+ let style = null;
427
+ // A minimal SGR reader: colour, background and the emphasis attributes the
428
+ // compiler can emit. Anything it does not recognise is DROPPED rather than
429
+ // printed, because a stray escape in the middle of a preview is worse than
430
+ // a missing colour.
431
+ const re = /\[([0-9;]*)m/g;
432
+ let last = 0;
433
+ let m;
434
+ const push = (s) => {
435
+ if (!s) return;
436
+ const span = el("span", null, s);
437
+ // THROUGH CSSOM, NEVER A `style` ATTRIBUTE. This page is served under
438
+ // `style-src 'self'`, which blocks a parsed style attribute outright —
439
+ // so every colour this preview drew was thrown away by the browser and
440
+ // the whole thing rendered grey, with the reason only in a console
441
+ // nobody had open. Assigning the property is not a parse and is not
442
+ // blocked, and weakening the policy to `unsafe-inline` for a preview is
443
+ // not a trade worth making.
444
+ if (style) for (const k of Object.keys(style)) span.style[k] = style[k];
445
+ row.append(span);
446
+ };
447
+ while ((m = re.exec(line))) {
448
+ push(line.slice(last, m.index));
449
+ style = sgrStyle(m[1], style);
450
+ last = m.index + m[0].length;
451
+ }
452
+ push(line.slice(last));
453
+ if (!row.childNodes.length) row.append(document.createTextNode(" "));
454
+ pre.append(row);
455
+ }
456
+ return pre;
457
+ }
458
+
459
+ // The 16 ANSI slots, as the browser's own colours. These are NOT ORC's colour
460
+ // tokens — they are what a terminal would paint, approximated, because the
461
+ // panel cannot know the user's terminal palette and says so.
462
+ const HK_ANSI = [
463
+ "#000000", "#cc0000", "#4e9a06", "#c4a000", "#3465a4", "#75507b", "#06989a", "#d3d7cf",
464
+ ];
465
+ const HK_ANSI_BRIGHT = [
466
+ "#555753", "#ef2929", "#8ae234", "#fce94f", "#729fcf", "#ad7fa8", "#34e2e2", "#eeeeec",
467
+ ];
468
+
469
+ // An OBJECT of CSS properties rather than a declaration string, because the
470
+ // caller assigns them one at a time through CSSOM — see `push` above.
471
+ function sgrStyle(codes, prev) {
472
+ const parts = String(codes || "0").split(";").filter((x) => x !== "");
473
+ if (!parts.length || parts[0] === "0") return null;
474
+ const out = Object.assign({}, prev || {});
475
+ for (let i = 0; i < parts.length; i++) {
476
+ const n = Number(parts[i]);
477
+ if (n === 1) out.fontWeight = "700";
478
+ else if (n === 2) out.opacity = ".6";
479
+ else if (n === 3) out.fontStyle = "italic";
480
+ else if (n === 4) out.textDecoration = "underline";
481
+ else if (n === 9) out.textDecoration = "line-through";
482
+ else if (n === 7) out.filter = "invert(1)";
483
+ else if (n >= 30 && n <= 37) out.color = HK_ANSI[n - 30];
484
+ else if (n >= 90 && n <= 97) out.color = HK_ANSI_BRIGHT[n - 90];
485
+ else if (n >= 40 && n <= 47) out.background = HK_ANSI[n - 40];
486
+ else if (n === 39) out.color = "inherit";
487
+ else if (n === 38 && parts[i + 1] === "2") {
488
+ out.color = "rgb(" + parts[i + 2] + "," + parts[i + 3] + "," + parts[i + 4] + ")";
489
+ i += 4;
490
+ } else if (n === 48 && parts[i + 1] === "2") {
491
+ out.background = "rgb(" + parts[i + 2] + "," + parts[i + 3] + "," + parts[i + 4] + ")";
492
+ i += 4;
493
+ }
494
+ }
495
+ return out;
496
+ }
497
+
498
+ /* ══ the board ════════════════════════════════════════════════════════════ */
499
+ // THE ILLEGAL DROP IS MADE IMPOSSIBLE rather than allowed and then complained
500
+ // about: an illegal zone renders disabled with its reason on the zone itself. A
501
+ // user should never be able to do the wrong thing and then be told off for it.
502
+ //
503
+ // The CLI still validates. The board is a convenience; it is never the
504
+ // guarantee.
505
+ function boardCard(cat, plan) {
506
+ const c = card(t("hookui.board"));
507
+ c.append(el("div", "note", t("hookui.boardAbout")));
508
+ c.append(el("div", "note", cat.dense_prefix));
509
+
510
+ for (const line of plan.lines) {
511
+ const legal = lineLegal(plan, line.line);
512
+ const wrap = el("div", "hk-line-wrap" + (legal.ok ? "" : " hk-line-blocked"));
513
+
514
+ const h = el("div", "hk-line-head");
515
+ const title = el("div", "hk-line-title");
516
+ title.append(el("span", "hk-line-name", tn(line.line, "hookui.lineN")));
517
+ title.append(el("span", "hk-line-count" + (line.full ? " hk-full" : ""), tn(line.counted, "hookui.slotsUsed", { max: plan.max })));
518
+ h.append(title);
519
+ h.append(separatorPicker(cat, line));
520
+ wrap.append(h);
521
+
522
+ const zone = el("div", "hk-zone");
523
+ zone.setAttribute("role", "list");
524
+ for (const item of line.items) zone.append(chipEl(item, line, plan, cat));
525
+
526
+ if (!legal.ok) {
527
+ const blocked = el("div", "hk-blocked");
528
+ blocked.append(el("span", "hk-caution-mark", "✕"));
529
+ blocked.append(el("span", null, legal.why));
530
+ zone.append(blocked);
531
+ }
532
+ // THE ADD BUTTON ALWAYS RENDERS, and carries its own refusal. A control
533
+ // that vanishes when it cannot be used teaches nobody why — and the two
534
+ // refusals are DIFFERENT facts (nothing above it yet · this line is full),
535
+ // so they are two sentences and never one "invalid".
536
+ const b = el("button", "hk-add", t("hookui.add"));
537
+ b.type = "button";
538
+ if (!legal.ok) {
539
+ b.disabled = true;
540
+ b.title = legal.why;
541
+ } else if (line.full) {
542
+ b.disabled = true;
543
+ b.title = t("hookui.lineFull", { n: line.line, max: plan.max });
544
+ } else {
545
+ b.addEventListener("click", () => partPicker(cat, plan, line.line, null));
546
+ }
547
+ zone.append(b);
548
+ if (b.disabled && legal.ok) zone.append(el("div", "note note-warn hk-zone-why", b.title));
549
+ dropTarget(zone, line, plan);
550
+ wrap.append(zone);
551
+ c.append(wrap);
552
+ }
553
+ c.append(el("div", "note", t("hookui.dragHint")));
554
+ return c;
555
+ }
556
+
557
+ // THE SEPARATOR IS A DROPDOWN, and its options are the CLI's. A free-text box
558
+ // asked a person to invent a rhythm and then to type a character that may not
559
+ // be on their keyboard. A value the layout already carries that is NOT in the
560
+ // set keeps its slot, leads the list and is disabled — the `fixed_executor`
561
+ // rule: the state must be visible, never re-offerable.
562
+ function separatorPicker(cat, line) {
563
+ const wrap = el("label", "hk-line-opts");
564
+ wrap.append(el("span", "note", t("hookui.separator")));
565
+ const sel = el("select", "hk-select");
566
+ const known = cat.separators || [];
567
+ if (!known.some((s) => s.value === line.separator)) {
568
+ const o = el("option", null, t("hookui.sepCustom", { s: JSON.stringify(line.separator) }));
569
+ o.value = line.separator;
570
+ o.disabled = true;
571
+ o.selected = true;
572
+ sel.append(o);
573
+ }
574
+ for (const s of known) {
575
+ // The NAME is the CLI's word; the sample is the literal string, shown
576
+ // between two marks so a run of spaces is visible at all.
577
+ const o = el("option", null, s.name + " ‹" + s.value + "›");
578
+ o.value = s.value;
579
+ if (s.value === line.separator) o.selected = true;
580
+ sel.append(o);
581
+ }
582
+ sel.addEventListener("change", () => {
583
+ const hit = known.find((s) => s.value === sel.value);
584
+ hkOp({ op: "sep", line: line.line, value: sel.value, label: t("hookui.opSep", { n: line.line, s: hit ? hit.name : sel.value }) });
585
+ });
586
+ wrap.append(sel);
587
+ return wrap;
588
+ }
589
+
590
+ /* ══ a chip ═══════════════════════════════════════════════════════════════ */
591
+ // A chip shows ITS OWN RENDERED OUTPUT, not its component id — that is what the
592
+ // user is arranging — with the id and a one-line description under it, because
593
+ // "what is this thing" is the first question anybody asks of a bar full of
594
+ // symbols, and the answer used to live only in a tooltip.
595
+ function chipEl(item, line, plan, cat) {
596
+ const comp = (cat.components || []).find((x) => x.id === item.type);
597
+ const wrap = el("div", "hk-chip" + (item.isNew ? " hk-chip-new" : ""));
598
+ wrap.setAttribute("role", "listitem");
599
+ wrap.setAttribute("tabindex", "0");
600
+ wrap.draggable = hkCanDrag();
601
+ wrap.title = comp && comp.summary ? comp.summary : item.type;
602
+
603
+ const face = el("div", "hk-chip-face");
604
+ face.append(el("span", "hk-grip", "∷"));
605
+ face.append(ansiBlock(chipSample(item, comp), "hk-preview hk-preview-chip"));
606
+ wrap.append(face);
607
+
608
+ wrap.append(el("div", "hk-chip-id", item.type));
609
+ if (comp && comp.summary) wrap.append(el("div", "hk-chip-desc", comp.summary));
610
+
611
+ // EDIT · MOVE · REMOVE, on the chip. Each opens a modal that says what it is
612
+ // about to do. The drag is the shortcut, never the mechanism: pointer drag is
613
+ // unusable for a real fraction of people, and it is switched off entirely
614
+ // below the width at which a drop gap stops being a target.
615
+ const acts = el("div", "hk-chip-acts");
616
+ const mk = (label, cls, fn) => {
617
+ const b = el("button", "hk-act " + cls, label);
618
+ b.type = "button";
619
+ b.addEventListener("click", fn);
620
+ acts.append(b);
621
+ };
622
+ mk(t("hookui.edit"), "hk-act-edit", () => editModal(item, line, comp, cat));
623
+ mk(t("hookui.move"), "hk-act-move", () => moveModal(item, line, plan));
624
+ mk(t("hookui.remove"), "hk-act-remove", () => removeModal(item, comp));
625
+ wrap.append(acts);
626
+
627
+ // Keyboard: the settled pattern, and every drag has an equivalent here. A
628
+ // board only reachable by mouse is a board a lot of people cannot use.
629
+ wrap.addEventListener("keydown", (ev) => {
630
+ const pos = line.items.indexOf(item) + 1;
631
+ if (ev.key === "Delete" || ev.key === "Backspace") {
632
+ ev.preventDefault();
633
+ removeModal(item, comp);
634
+ } else if (ev.key === "e" || ev.key === "E") {
635
+ ev.preventDefault();
636
+ editModal(item, line, comp, cat);
637
+ } else if (ev.key === "ArrowLeft" && pos > 1) {
638
+ ev.preventDefault();
639
+ moveTo(item, line.line, pos - 1);
640
+ } else if (ev.key === "ArrowRight" && pos < line.items.length) {
641
+ ev.preventDefault();
642
+ moveTo(item, line.line, pos + 1);
643
+ } else if (ev.key === "ArrowUp" || ev.key === "ArrowDown") {
644
+ const target = line.line + (ev.key === "ArrowUp" ? -1 : 1);
645
+ if (target < 1 || target > plan.lines.length) return;
646
+ ev.preventDefault();
647
+ const why = addBlocked(plan, target);
648
+ // A line that cannot take it is SKIPPED and the live region says why —
649
+ // never a silent no-op.
650
+ if (why) return announce(why);
651
+ moveTo(item, target, 1);
652
+ }
653
+ });
654
+
655
+ wrap.addEventListener("dragstart", (ev) => {
656
+ HK_DRAG = { ref: item.ref, from: line.line };
657
+ wrap.classList.add("hk-chip-dragging");
658
+ try {
659
+ ev.dataTransfer.effectAllowed = "move";
660
+ ev.dataTransfer.setData("text/plain", item.ref);
661
+ } catch (_) {}
662
+ });
663
+ wrap.addEventListener("dragend", () => {
664
+ HK_DRAG = null;
665
+ wrap.classList.remove("hk-chip-dragging");
666
+ for (const n of document.querySelectorAll(".hk-drop-before, .hk-drop-after"))
667
+ n.classList.remove("hk-drop-before", "hk-drop-after");
668
+ });
669
+ wrap.addEventListener("dragover", (ev) => {
670
+ if (!HK_DRAG || HK_DRAG.ref === item.ref) return;
671
+ ev.preventDefault();
672
+ const r = wrap.getBoundingClientRect();
673
+ const before = ev.clientX < r.left + r.width / 2;
674
+ wrap.classList.toggle("hk-drop-before", before);
675
+ wrap.classList.toggle("hk-drop-after", !before);
676
+ });
677
+ wrap.addEventListener("dragleave", () => wrap.classList.remove("hk-drop-before", "hk-drop-after"));
678
+ wrap.addEventListener("drop", (ev) => {
679
+ if (!HK_DRAG || HK_DRAG.ref === item.ref) return;
680
+ ev.preventDefault();
681
+ ev.stopPropagation();
682
+ const before = wrap.classList.contains("hk-drop-before");
683
+ const pos = line.items.indexOf(item) + 1;
684
+ dropOnto(line, plan, before ? pos : pos + 1);
685
+ });
686
+ return wrap;
687
+ }
688
+
689
+ // What this chip will look like in the bar. A part staged in this session has
690
+ // no rendered sample from the CLI yet, so it borrows its component's default —
691
+ // which is exactly what it will render as until somebody changes its shape.
692
+ function chipSample(item, comp) {
693
+ if (!comp) return item.type;
694
+ const render = item.over.render || (item.src ? item.src.render : (comp.defaults && comp.defaults.render) || comp.renderers[0]);
695
+ const s = comp.previews ? comp.previews[render] : null;
696
+ return s || item.type;
697
+ }
698
+
699
+ // A zone is a drop target across its WHOLE area, so a drop past the last chip
700
+ // appends instead of doing nothing.
701
+ function dropTarget(zone, line, plan) {
702
+ zone.addEventListener("dragover", (ev) => {
703
+ if (!HK_DRAG) return;
704
+ if (HK_DRAG.from !== line.line && addBlocked(plan, line.line)) return;
705
+ ev.preventDefault();
706
+ zone.classList.add("hk-zone-hot");
707
+ });
708
+ zone.addEventListener("dragleave", () => zone.classList.remove("hk-zone-hot"));
709
+ zone.addEventListener("drop", (ev) => {
710
+ zone.classList.remove("hk-zone-hot");
711
+ if (!HK_DRAG) return;
712
+ ev.preventDefault();
713
+ dropOnto(line, plan, line.items.length + 1);
714
+ });
715
+ }
716
+
717
+ function dropOnto(line, plan, pos) {
718
+ if (!HK_DRAG) return;
719
+ const all = plan.lines.reduce((acc, l) => acc.concat(l.items), []);
720
+ const moving = all.find((x) => x.ref === HK_DRAG.ref);
721
+ const from = HK_DRAG.from;
722
+ HK_DRAG = null;
723
+ if (!moving) return;
724
+ // Reordering WITHIN a line never changes how many parts it holds, so the
725
+ // five-slot cap and the dense-prefix rule only apply to a move that crosses
726
+ // lines.
727
+ if (from !== line.line) {
728
+ const why = addBlocked(plan, line.line);
729
+ if (why) return toast(why, "bad");
730
+ }
731
+ moveTo(moving, line.line, pos);
732
+ }
733
+
734
+ function moveTo(item, toLine, toPos) {
735
+ hkOp({ op: "move", ref: item.ref, toLine, toPos });
736
+ announce(t("hookui.opMove", { id: item.type, n: toLine }));
737
+ }
738
+
739
+ // `aria-grabbed` is deprecated and is not used. State is announced through a
740
+ // polite live region instead — VoiceOver does not reliably read a dynamically
741
+ // updated description, so the live region is the mechanism, not a fallback.
742
+ function announce(msg) {
743
+ let region = document.getElementById("hk-live");
744
+ if (!region) {
745
+ region = el("div", "sr-only");
746
+ region.id = "hk-live";
747
+ region.setAttribute("role", "status");
748
+ region.setAttribute("aria-live", "polite");
749
+ document.body.append(region);
750
+ }
751
+ region.textContent = msg;
752
+ }
753
+
754
+ /* ══ the part picker ══════════════════════════════════════════════════════ */
755
+ // ONE MODAL, one search box, one click. It is the only place a part is ever
756
+ // chosen — for a new slot and for swapping an existing one — so there is
757
+ // exactly one answer to "how do I put something on the bar".
758
+ //
759
+ // `replace` names the chip whose part is being swapped; absent, this is an add.
760
+ function partPicker(cat, plan, line, replace) {
761
+ let q = "";
762
+ // NOT named `body`: that name is reserved for a panel's own card container,
763
+ // which must carry `stack`. This is a modal's contents and spaces itself.
764
+ const pane = el("div", "hk-picker");
765
+
766
+ const searchRow = el("div", "hk-picker-search");
767
+ const search = el("input", "hk-input hk-picker-input");
768
+ search.type = "search";
769
+ search.placeholder = t("hookui.searchPlaceholder");
770
+ const count = el("span", "note");
771
+ searchRow.append(search, count);
772
+ pane.append(searchRow);
773
+ pane.append(el("div", "note", replace ? t("hookui.pickerReplaceAbout") : tn(line, "hookui.pickerAbout")));
774
+
775
+ const list = el("div", "hk-picker-list");
776
+ pane.append(list);
777
+
778
+ const closer = () => close();
779
+ const paint = () => {
780
+ list.replaceChildren();
781
+ const rows = (cat.components || []).filter((c) => (q ? matches(c, q) : true));
782
+ count.textContent = tn(rows.length, "hookui.matches");
783
+ if (!rows.length) {
784
+ list.append(empty(t("hookui.noMatch")));
785
+ return;
786
+ }
787
+ for (const comp of rows) list.append(pickerRow(comp, line, replace, closer));
788
+ };
789
+ search.addEventListener("input", () => {
790
+ q = search.value.trim().toLowerCase();
791
+ paint();
792
+ });
793
+ paint();
794
+
795
+ const close = modal({
796
+ title: replace ? t("hookui.pickerReplaceTitle") : tn(line, "hookui.pickerTitle"),
797
+ body: pane,
798
+ actions: [{ label: t("common.cancel"), cls: "btn-ghost", onClick: (c) => c() }],
799
+ });
800
+ setTimeout(() => search.focus(), 40);
801
+ }
802
+
803
+ // A row says WHAT the part is, in a sentence, next to what it will look like.
804
+ // A REFUSED part is drawn and is NOT clickable — never a disabled button, and
805
+ // never absent: "we decided against this" and "we forgot" must not look the
806
+ // same, which is why the row exists at all.
807
+ function pickerRow(comp, line, replace, close) {
808
+ const refused = comp.cost === "refused";
809
+ const row = el(refused ? "div" : "button", "hk-pick-row" + (refused ? " hk-pick-row-off" : ""));
810
+ if (!refused) row.type = "button";
811
+
812
+ const top = el("div", "hk-pick-top");
813
+ top.append(el("span", "hk-prow-id", comp.id));
814
+ // The cost chip is the CLI's own word. A friendlier synonym would be a state
815
+ // that does not exist.
816
+ top.append(chip(comp.cost, refused ? "bad" : null));
817
+ row.append(top);
818
+ if (comp.summary) row.append(el("div", "hk-pick-desc", comp.summary));
819
+ const sample = comp.previews && comp.previews[(comp.defaults && comp.defaults.render) || comp.renderers[0]];
820
+ if (sample) row.append(ansiBlock(sample, "hk-preview hk-preview-chip"));
821
+ if (refused) {
822
+ row.append(el("div", "note note-warn", comp.refused_reason));
823
+ return row;
824
+ }
825
+ row.addEventListener("click", () => {
826
+ if (replace) hkOp({ op: "set", ref: replace.ref, field: "type", value: comp.id, label: t("hookui.opSwap", { id: comp.id }) });
827
+ else hkOp({ op: "add", line, type: comp.id });
828
+ close();
829
+ });
830
+ return row;
831
+ }
832
+
833
+ // Fuzzy plus INITIALISM: with a catalogue this size a substring search is not
834
+ // enough — two letters should find a two-word name, which plain substring
835
+ // matching does not.
836
+ function matches(comp, q) {
837
+ const id = comp.id.toLowerCase();
838
+ if (id.includes(q)) return true;
839
+ if ((comp.summary || "").toLowerCase().includes(q)) return true;
840
+ if ((comp.label || "").toLowerCase().includes(q)) return true;
841
+ const initials = id.split("-").map((p) => p[0]).join("");
842
+ if (initials.startsWith(q)) return true;
843
+ let i = 0;
844
+ for (const ch of id) if (ch === q[i]) i++;
845
+ return i === q.length;
846
+ }
847
+
848
+ /* ══ move ═════════════════════════════════════════════════════════════════ */
849
+ // A modal that says what it is about to do — and on a narrow screen it is the
850
+ // ONLY way to move a chip, because drag is off below the width at which a drop
851
+ // gap stops being a target.
852
+ function moveModal(item, line, plan) {
853
+ const body = el("div", "stack");
854
+ body.append(el("div", "note", t("hookui.moveAbout", { id: item.type })));
855
+
856
+ for (const target of plan.lines) {
857
+ const legal = lineLegal(plan, target.line);
858
+ const sameLine = target.line === line.line;
859
+ const box = el("div", "hk-move-line");
860
+ box.append(el("div", "hk-line-name", tn(target.line, "hookui.lineN")));
861
+ // A LINE THAT CANNOT TAKE IT SAYS SO ONCE. Six identical disabled slots is
862
+ // the same refusal printed six times, and the reason is about the LINE
863
+ // rather than about any one slot on it.
864
+ const blocked = sameLine ? null : addBlocked(plan, target.line);
865
+ if (blocked) {
866
+ box.append(el("div", "note note-warn", blocked));
867
+ body.append(box);
868
+ continue;
869
+ }
870
+ const slots = el("div", "hk-move-slots");
871
+ const n = Math.max(1, target.items.length + (sameLine ? 0 : 1));
872
+ for (let p = 1; p <= n; p++) {
873
+ const b = el("button", "btn btn-xs btn-ghost", tn(p, "hookui.slotN"));
874
+ b.type = "button";
875
+ if (sameLine && p === line.items.indexOf(item) + 1) {
876
+ b.disabled = true;
877
+ b.title = t("hookui.alreadyHere");
878
+ } else {
879
+ b.addEventListener("click", () => {
880
+ close();
881
+ moveTo(item, target.line, p);
882
+ });
883
+ }
884
+ slots.append(b);
885
+ }
886
+ box.append(slots);
887
+ body.append(box);
888
+ }
889
+
890
+ const close = modal({
891
+ title: t("hookui.moveTitle"),
892
+ body,
893
+ actions: [{ label: t("common.cancel"), cls: "btn-ghost", onClick: (c) => c() }],
894
+ });
895
+ }
896
+
897
+ /* ══ remove ═══════════════════════════════════════════════════════════════ */
898
+ function removeModal(item, comp) {
899
+ const body = el("div", "stack");
900
+ body.append(el("div", "note", t("hookui.removeAbout", { id: item.type })));
901
+ if (comp && comp.summary) body.append(el("div", "note", comp.summary));
902
+ modal({
903
+ title: t("hookui.removeTitle"),
904
+ body,
905
+ actions: [
906
+ { label: t("common.cancel"), cls: "btn-ghost", onClick: (c) => c() },
907
+ {
908
+ label: t("hookui.remove"),
909
+ cls: "btn-primary",
910
+ onClick: (c) => {
911
+ c();
912
+ hkOp({ op: "remove", ref: item.ref });
913
+ },
914
+ },
915
+ ],
916
+ });
917
+ }
918
+
919
+ /* ══ the part editor ══════════════════════════════════════════════════════ */
920
+ // A MODAL, because the board is what the user is looking at, and a drawer that
921
+ // opened inside a chip pushed the whole board down under the thing being
922
+ // edited.
923
+ //
924
+ // NO FIELD NAMES, AND EVERY CONTROL CARRIES A SENTENCE. `min_width` and
925
+ // `hide_when` are the JSON's words; this says "Least width" and explains what
926
+ // it does. The JSON is for the diff, the editor is for the person — and the
927
+ // ids, renderers and state words that DO appear are the CLI's own and are never
928
+ // translated.
929
+ function editModal(item, line, comp, cat) {
930
+ // NOT named `body`: that name is reserved for a panel's own card container,
931
+ // which must carry `stack`. This is a modal's contents and spaces itself.
932
+ const pane = el("div", "hk-editor");
933
+ if (!comp) {
934
+ 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() }] });
936
+ return;
937
+ }
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", () => {
970
+ close();
971
+ stage("render", r, t("hookui.shapeChange", { id: comp.id, r }));
972
+ });
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
+ }
1016
+
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);
1041
+ }
1042
+ pane.append(checks);
1043
+
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")));
1050
+
1051
+ const close = modal({
1052
+ title: comp.id,
1053
+ body: pane,
1054
+ actions: [
1055
+ // 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
1057
+ // scale. It reads the SAVED item, so it is offered only for one.
1058
+ ...(item.src
1059
+ ? [{
1060
+ label: t("hookui.explain"),
1061
+ cls: "btn-ghost",
1062
+ onClick: async () => {
1063
+ const r = await read("/api/statusline/explain?board=" + HK_BOARD + "&at=" + line.line + ":" + (line.items.indexOf(item) + 1));
1064
+ if (!r.data || !r.data.ok) return toast(t("hookui.explainFailed"), "bad");
1065
+ modal({
1066
+ title: comp.id,
1067
+ pane: kvList(r.data.resolved.map((x) => [x.field, x.source])),
1068
+ actions: [{ label: t("common.close"), cls: "btn-ghost", onClick: (c) => c() }],
1069
+ });
1070
+ },
1071
+ }]
1072
+ : []),
1073
+ { label: t("common.close"), cls: "btn-ghost", onClick: (c) => c() },
1074
+ ],
1075
+ });
1076
+ }
1077
+
1078
+ // `partPicker` reads a board only to answer "can a part go on that line". A
1079
+ // SWAP changes nothing about where the part sits, so it is handed a board with
1080
+ // no lines and never asks.
1081
+ const HK_NO_LINES = { lines: [], max: 5 };
1082
+
1083
+ function sectionHead(name, about) {
1084
+ const w = el("div", "hk-section-wrap");
1085
+ w.append(el("div", "hk-section", name));
1086
+ if (about) w.append(el("div", "note", about));
1087
+ return w;
1088
+ }
1089
+
1090
+ function numField(value, lo, hi, onSet) {
1091
+ const i = el("input", "hk-input hk-num");
1092
+ i.type = "number";
1093
+ i.min = String(lo);
1094
+ i.max = String(hi);
1095
+ if (value !== null && value !== undefined) i.value = String(value);
1096
+ i.addEventListener("change", () => onSet(i.value));
1097
+ return i;
1098
+ }
1099
+
1100
+ function textField(value, onSet) {
1101
+ const i = el("input", "hk-input");
1102
+ i.type = "text";
1103
+ i.value = value == null ? "" : String(value);
1104
+ i.addEventListener("change", () => onSet(i.value));
1105
+ return i;
1106
+ }
1107
+
1108
+ // A control, its name, and — because a noun on its own is not an explanation —
1109
+ // the sentence that says what it does. That sentence is most of this release:
1110
+ // a settings form whose labels are nouns is a form people close.
1111
+ function labelled(name, node, about) {
1112
+ const row = el("div", "hk-field");
1113
+ const left = el("div", "hk-field-left");
1114
+ left.append(el("span", "hk-field-name", name));
1115
+ if (about) left.append(el("span", "note", about));
1116
+ row.append(left);
1117
+ row.append(node);
1118
+ return row;
1119
+ }
1120
+
1121
+ // A row of options. The VALUES are the CLI's and are printed verbatim — a
1122
+ // translated renderer name is a renderer that does not exist.
1123
+ function pickRow(options, current, onPick) {
1124
+ const row = el("div", "hk-picks");
1125
+ for (const o of options || []) {
1126
+ const b = el("button", "hk-pick" + (String(current) === String(o) ? " hk-pick-on" : ""), String(o));
1127
+ b.type = "button";
1128
+ b.addEventListener("click", () => onPick(o));
1129
+ row.append(b);
1130
+ }
1131
+ return row;
1132
+ }
1133
+
1134
+ /* ══ the reference list ═══════════════════════════════════════════════════ */
1135
+ // WHAT EACH PART SHOWS, and nothing else. It used to carry three `+ line N`
1136
+ // buttons per row, which put the act of building the bar a long way from the
1137
+ // bar — and computed a position against a layout that was no longer the one
1138
+ // being staged. Adding happens on the board now; this is a reference.
1139
+ function referenceCard(cat) {
1140
+ const right = el("div", "row-actions");
1141
+ const search = el("input", "hk-input hk-palette-search");
1142
+ search.type = "search";
1143
+ search.value = HK_SEARCH;
1144
+ search.placeholder = t("hookui.searchPlaceholder");
1145
+ right.append(search);
1146
+
1147
+ const c = card(t("hookui.reference"), right);
1148
+ c.append(el("div", "note", t("hookui.referenceAbout")));
1149
+ const body = el("div", "stack");
1150
+ c.append(body);
1151
+
1152
+ function paint() {
1153
+ body.replaceChildren();
1154
+ const q = HK_SEARCH.trim().toLowerCase();
1155
+ const hits = q ? (cat.components || []).filter((x) => matches(x, q)) : null;
1156
+ if (hits) {
1157
+ body.append(el("div", "note", tn(hits.length, "hookui.matches")));
1158
+ for (const comp of hits) body.append(referenceRow(comp));
1159
+ if (!hits.length) body.append(empty(t("hookui.noMatch")));
1160
+ return;
1161
+ }
1162
+ for (const [g, name] of Object.entries(cat.groups)) {
1163
+ const rows = (cat.components || []).filter((x) => x.group === g);
1164
+ if (!rows.length) continue;
1165
+ const inner = el("div");
1166
+ for (const comp of rows) inner.append(referenceRow(comp));
1167
+ body.append(collapsible({
1168
+ title: name,
1169
+ count: String(rows.length),
1170
+ content: inner,
1171
+ collapsed: HK_GROUP !== g,
1172
+ }));
1173
+ }
1174
+ }
1175
+ search.addEventListener("input", () => {
1176
+ HK_SEARCH = search.value;
1177
+ paint();
1178
+ });
1179
+ paint();
1180
+ return c;
1181
+ }
1182
+
1183
+ function referenceRow(comp) {
1184
+ const row = el("div", "hk-prow");
1185
+ const top = el("div", "hk-prow-head");
1186
+ top.append(el("span", "hk-prow-id", comp.id));
1187
+ top.append(chip(comp.cost, comp.cost === "refused" ? "bad" : null));
1188
+ row.append(top);
1189
+ if (comp.summary) row.append(el("div", "hk-prow-desc", comp.summary));
1190
+ const sample = comp.previews && comp.previews[(comp.defaults && comp.defaults.render) || comp.renderers[0]];
1191
+ if (sample) row.append(ansiBlock(sample, "hk-preview hk-preview-chip"));
1192
+ // A REFUSED part keeps its slot and carries the measurement that refused it:
1193
+ // "we decided against this" and "we forgot" must not look the same.
1194
+ if (comp.cost === "refused") row.append(el("div", "note note-warn hk-prow-why", comp.refused_reason));
1195
+ return row;
1196
+ }
1197
+
1198
+ /* ══ presets ══════════════════════════════════════════════════════════════ */
1199
+ // Applying one REPLACES the layout, so it is always confirmed and the loss is
1200
+ // NAMED — the `orc diy init --force` rule.
1201
+ function presetsCard(presets, plan) {
1202
+ const c = card(t("hookui.presets"));
1203
+ c.append(el("div", "note", t("hookui.presetsAbout")));
1204
+ if (!presets || !presets.presets) return c;
1205
+ for (const p of presets.presets) {
1206
+ const row = el("div", "hk-preset");
1207
+ const h = el("div", "hk-preset-head");
1208
+ h.append(el("span", "hk-prow-id", p.name));
1209
+ if (p.active) h.append(chip(t("hookui.active"), "ok"));
1210
+ row.append(h);
1211
+ row.append(el("div", "note", p.summary));
1212
+ if (p.preview) row.append(ansiBlock(p.preview, "hk-preview hk-preview-sm"));
1213
+ // The ACTIVE row keeps its slot and drops its button.
1214
+ if (!p.active) {
1215
+ const b = el("button", "btn btn-xs btn-ghost", t("hookui.apply"));
1216
+ b.type = "button";
1217
+ // A preset is written STRAIGHT AWAY, so it cannot run while ops are
1218
+ // staged: those ops carry positions computed against a layout the preset
1219
+ // is about to replace.
1220
+ if (plan.dirty) {
1221
+ b.disabled = true;
1222
+ b.title = t("hookui.applyFirst");
1223
+ } else {
1224
+ b.addEventListener("click", () => confirmApply(p.name));
1225
+ }
1226
+ row.append(b);
1227
+ }
1228
+ c.append(row);
1229
+ }
1230
+ return c;
1231
+ }
1232
+
1233
+ function confirmApply(name) {
1234
+ modal({
1235
+ title: t("hookui.applyTitle", { name }),
1236
+ body: el("div", "note", t("hookui.applyWarn")),
1237
+ actions: [
1238
+ { label: t("common.cancel"), cls: "btn-ghost", onClick: (c) => c() },
1239
+ {
1240
+ label: t("hookui.apply"),
1241
+ cls: "btn-primary",
1242
+ onClick: async (c) => {
1243
+ c();
1244
+ const r = await post("/api/statusline/apply", bd({ name }));
1245
+ if (!r.ok) toast(t("hookui.applyFailed"), "bad", (r.output || "").trim());
1246
+ HK_OPS = [];
1247
+ rerender();
1248
+ },
1249
+ },
1250
+ ],
1251
+ });
1252
+ }
1253
+
1254
+ function confirmReset() {
1255
+ modal({
1256
+ title: t("hookui.resetTitle"),
1257
+ body: el("div", "note", t("hookui.resetWarn")),
1258
+ actions: [
1259
+ { label: t("common.cancel"), cls: "btn-ghost", onClick: (c) => c() },
1260
+ {
1261
+ label: t("hookui.resetLayout"),
1262
+ cls: "btn-primary",
1263
+ onClick: async (c) => {
1264
+ c();
1265
+ await post("/api/statusline/reset", bd({}));
1266
+ HK_OPS = [];
1267
+ rerender();
1268
+ },
1269
+ },
1270
+ ],
1271
+ });
1272
+ }
1273
+
1274
+ /* ══ more ═════════════════════════════════════════════════════════════════ */
1275
+ // THE COLOUR SET IS A DOCUMENT SETTING, and it now goes to the command that
1276
+ // writes document settings. It used to be routed through `statusline line 1
1277
+ // --theme`, which set ONE line's override while the picker read the document's
1278
+ // value back — so the button never moved and only a third of the bar changed.
1279
+ // One command per scope, and the panel calls the one that matches the control.
1280
+ function advancedCard(cat, plan) {
1281
+ const c = card(t("hookui.advanced"));
1282
+
1283
+ const themes = el("div", "hk-theme-list");
1284
+ for (const name of Object.keys(cat.themes || {})) {
1285
+ const b = el("button", "hk-theme" + (plan.doc.theme === name ? " hk-theme-on" : ""));
1286
+ b.type = "button";
1287
+ b.append(el("span", "hk-theme-name", name));
1288
+ // WHY somebody would pick it — the CLI's sentence, not one written here.
1289
+ const about = (cat.themes_about || {})[name];
1290
+ if (about) b.append(el("span", "note", about));
1291
+ b.addEventListener("click", () => hkOp({ op: "doc", field: "theme", value: name, label: t("hookui.themeChange", { v: name }) }));
1292
+ themes.append(b);
1293
+ }
1294
+ c.append(labelled(t("hookui.theme"), themes, t("hookui.themeAbout")));
1295
+ c.append(el("div", "note", t("hookui.themeOverride")));
1296
+
1297
+ c.append(labelled(t("hookui.glyphs"),
1298
+ pickRow(cat.glyph_sets, plan.doc.glyphs, (v) => hkOp({ op: "doc", field: "glyphs", value: v, label: t("hookui.glyphsDocChange", { v }) })),
1299
+ t("hookui.glyphsDocAbout")));
1300
+
1301
+ const re = el("button", "btn btn-sm btn-ghost", t("hookui.recompile"));
1302
+ re.type = "button";
1303
+ re.addEventListener("click", async () => {
1304
+ const r = await post("/api/statusline/compile", bd({}));
1305
+ toast(r.ok ? t("hookui.recompiled") : t("hookui.recompileFailed"), r.ok ? "ok" : "bad");
1306
+ rerender();
1307
+ });
1308
+ const acts = el("div", "row-actions");
1309
+ acts.append(re);
1310
+ c.append(acts);
1311
+ c.append(el("div", "note", t("hookui.compileNote")));
1312
+ return c;
1313
+ }