@azure-id/orc 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +401 -0
  2. package/README-id.md +52 -23
  3. package/README.md +50 -22
  4. package/bin/build-agents.js +24 -25
  5. package/bin/clear-logs.js +620 -0
  6. package/bin/cli.js +1155 -158
  7. package/bin/fix.js +459 -0
  8. package/bin/gotcha.js +288 -8
  9. package/bin/habit.js +1538 -1453
  10. package/bin/mockrun-catalog.js +3 -0
  11. package/bin/onboarding-content.js +2 -1
  12. package/bin/pricing.json +28 -21
  13. package/bin/trace-write.js +869 -657
  14. package/bin/verify-contracts.js +108 -27
  15. package/bin/verify-package.js +17 -6
  16. package/bin/webui/api.js +125 -6
  17. package/bin/webui/css/panels/hookui.css +79 -0
  18. package/bin/webui/fixtures/behaviour.js +30 -0
  19. package/bin/webui/fixtures/extra.js +9 -9
  20. package/bin/webui/fixtures/flow.js +81 -81
  21. package/bin/webui/fixtures/hookui.js +506 -431
  22. package/bin/webui/fixtures/index.js +32 -3
  23. package/bin/webui/fixtures/knowledge.js +2 -2
  24. package/bin/webui/fixtures/maintenance.js +123 -42
  25. package/bin/webui/fixtures/rules.js +1007 -1002
  26. package/bin/webui/fixtures/settings.js +6 -7
  27. package/bin/webui/fixtures/stats.js +9 -6
  28. package/bin/webui/fixtures/wait.js +98 -97
  29. package/bin/webui/i18n/en/behaviour.json +13 -0
  30. package/bin/webui/i18n/en/hookui.json +290 -194
  31. package/bin/webui/i18n/en/maintenance.json +71 -52
  32. package/bin/webui/i18n/en/settings.json +60 -60
  33. package/bin/webui/i18n/id/behaviour.json +13 -0
  34. package/bin/webui/i18n/id/hookui.json +290 -194
  35. package/bin/webui/i18n/id/maintenance.json +71 -52
  36. package/bin/webui/i18n/id/settings.json +60 -60
  37. package/bin/webui/js/05-banners.js +181 -172
  38. package/bin/webui/js/06-edit.js +193 -183
  39. package/bin/webui/js/panels/behaviour.js +77 -12
  40. package/bin/webui/js/panels/hookui.js +2196 -1691
  41. package/bin/webui/js/panels/maintenance.js +336 -235
  42. package/bin/webui/js/panels/settings.js +726 -719
  43. package/mock-run/INDEX.md +1 -0
  44. package/mock-run/extra-slots.md +1 -1
  45. package/mock-run/habits.md +1 -1
  46. package/mock-run/orc-aftermath.md +392 -392
  47. package/mock-run/orc-budget.md +29 -33
  48. package/mock-run/orc-cli.md +200 -200
  49. package/mock-run/orc-explain.md +86 -86
  50. package/mock-run/orc-extra.md +389 -392
  51. package/mock-run/orc-fix.md +87 -0
  52. package/mock-run/orc-pattern.md +112 -112
  53. package/mock-run/orc.md +157 -157
  54. package/package.json +1 -1
  55. package/templates/agents/MODEL-MAPPING.md +32 -29
  56. package/templates/agents/orc-analyze-mini-sonnet-5-high.md +2 -2
  57. package/templates/agents/{orc-claude-writer-opus-5-med.md → orc-claude-writer-opus-5-low.md} +4 -4
  58. package/templates/agents/orc-executor-haiku-4-5.md +1 -1
  59. package/templates/agents/orc-executor-opus-5-low.md +1 -1
  60. package/templates/agents/orc-executor-sonnet-4-6-high.md +1 -1
  61. package/templates/agents/orc-executor-sonnet-4-6-med.md +1 -1
  62. package/templates/agents/orc-executor-sonnet-5-high.md +2 -2
  63. package/templates/agents/orc-executor-sonnet-5-low.md +155 -0
  64. package/templates/agents/orc-executor-sonnet-5-med.md +155 -0
  65. package/templates/agents/orc-pattern-codifier-sonnet-5-high.md +2 -2
  66. package/templates/agents/orc-planner-mini-sonnet-5-high.md +2 -2
  67. package/templates/agents/orc-retro-sonnet-5-high.md +2 -2
  68. package/templates/agents/{orc-reviewer-opus-5-med.md → orc-reviewer-opus-5-low.md} +4 -4
  69. package/templates/agents/{orc-wiki-scanner-opus-5-med.md → orc-wiki-scanner-opus-5-low.md} +3 -3
  70. package/templates/agents/orc-wiki-scanner-sonnet-5-high.md +2 -2
  71. package/templates/commands/orc-fix.md +18 -0
  72. package/templates/commands/orc-wiki.md +64 -64
  73. package/templates/hooks/README.md +75 -7
  74. package/templates/hooks/orc-session-hook.js +78 -3
  75. package/templates/hooks/orc-statusline-render.js +317 -23
  76. package/templates/hooks/orc-statusline.js +162 -49
  77. package/templates/hooks/orc-subagent-line.js +23 -5
  78. package/templates/hooks/orc-weather-fetch.js +123 -0
  79. package/templates/skills/_shared/extra-dispatch.md +5 -5
  80. package/templates/skills/_shared/gotchas.md +27 -7
  81. package/templates/skills/_shared/habits.md +2 -2
  82. package/templates/skills/_shared/opus5-only.md +14 -14
  83. package/templates/skills/_shared/phases/review.md +2 -2
  84. package/templates/skills/_shared/phases/trace-verbs.md +17 -3
  85. package/templates/skills/_shared/phases/trace.md +136 -136
  86. package/templates/skills/_shared/review-slice.md +28 -1
  87. package/templates/skills/_shared/wait.md +1 -0
  88. package/templates/skills/orc/README.md +2 -2
  89. package/templates/skills/orc/SKILL.md +1 -1
  90. package/templates/skills/orc/config.md +25 -27
  91. package/templates/skills/orc/examples/full-run-mock.md +2 -1
  92. package/templates/skills/orc/references/effort-and-mode.md +17 -17
  93. package/templates/skills/orc/references/preflight-report.md +3 -3
  94. package/templates/skills/orc/references/ultra-mode.md +1 -1
  95. package/templates/skills/orc/schemas/checkpoint.md +122 -122
  96. package/templates/skills/orc/subskills/orc-review-verify/SKILL.md +3 -3
  97. package/templates/skills/orc/subskills/orc-review-verify/core.md +1 -1
  98. package/templates/skills/orc-analyze/SKILL.md +4 -2
  99. package/templates/skills/orc-claude/SKILL.md +14 -14
  100. package/templates/skills/orc-claude/examples/claude-run-mock.md +65 -65
  101. package/templates/skills/orc-diy/SKILL.md +5 -5
  102. package/templates/skills/orc-fast/SKILL.md +8 -6
  103. package/templates/skills/orc-fix/SKILL.md +110 -0
  104. package/templates/skills/orc-mini/SKILL.md +4 -4
  105. package/templates/skills/orc-mini/examples/mini-run-mock.md +4 -2
  106. package/templates/skills/orc-quick/README.md +1 -1
  107. package/templates/skills/orc-quick/SKILL.md +2 -2
  108. package/templates/skills/orc-quick/references/dispatch-gate.md +3 -2
  109. package/templates/skills/orc-quick/references/gh-mode.md +4 -2
  110. package/templates/skills/orc-retro/SKILL.md +1 -1
  111. package/templates/skills/orc-retro/examples/retro-mock.md +16 -16
  112. package/templates/skills/orc-wiki/README.md +325 -325
  113. package/templates/skills/orc-wiki/SKILL.md +195 -195
  114. package/templates/skills/orc-wiki/examples/wiki-run-mock.md +37 -37
  115. package/templates/skills/orc-wiki/references/extra.md +2 -2
  116. package/templates/skills/orc-wiki/references/partial-refresh.md +276 -271
  117. package/templates/skills/orc-wiki/references/phases/phase-0.md +1 -1
  118. package/templates/skills/orc-wiki/references/phases/phase-2.md +3 -3
  119. package/templates/skills/orc-wiki/references/phases/phase-3c.md +3 -2
  120. package/templates/agents/orc-claude-writer-opus-4-8-high.md +0 -48
  121. package/templates/agents/orc-wiki-scanner-opus-4-8-high.md +0 -74
@@ -1,1691 +1,2196 @@
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
- // 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
-
99
- PANELS.hookui = function (host) {
100
- head(host, t("hookui.title"), t("hookui.sub"));
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
- });
114
- };
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
-
217
- // EVERY write carries the board it is about. Forgetting it on one route would
218
- // edit the other board's layout, which is the one mistake here that is silent.
219
- function bd(body) {
220
- return Object.assign({ board: HK_BOARD }, body);
221
- }
222
-
223
- /* ══ the staged plan ══════════════════════════════════════════════════════
224
- Replays every staged op onto a working copy of the saved layout and returns
225
- BOTH the effective board and the ordered writes that produce it. ONE
226
- function, because a second idea of where a part will land is exactly the bug
227
- this replaces: the board drew one arrangement and the writes produced
228
- another, silently. */
229
-
230
- // Ops are staged by REF, never by position. A ref is either an item id the CLI
231
- // already gave us, or `new:<n>` for something staged in this session.
232
- function hkOp(op) {
233
- // A second edit to the same field of the same part REPLACES the first — one
234
- // decision typed twice is one decision, and the pending list has to read as a
235
- // list of decisions rather than a list of keystrokes.
236
- if (op.op === "set") HK_OPS = HK_OPS.filter((o) => !(o.op === "set" && o.ref === op.ref && o.field === op.field));
237
- if (op.op === "sep") HK_OPS = HK_OPS.filter((o) => !(o.op === "sep" && o.line === op.line));
238
- if (op.op === "doc") HK_OPS = HK_OPS.filter((o) => !(o.op === "doc" && o.field === op.field));
239
- HK_OPS.push(op);
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();
244
- }
245
-
246
- function hkPlan(show, cat) {
247
- const structural = new Set((cat.components || []).filter((c) => c.structural).map((c) => c.id));
248
- const lines = (show.lines || []).map((l) => ({
249
- line: l.line,
250
- separator: l.separator,
251
- items: (l.items || []).map((it) => ({ ref: it.id, type: it.type, src: it, over: {}, isNew: false })),
252
- }));
253
- const actions = [];
254
- const doc = { theme: show.theme, glyphs: show.glyphs };
255
- const find = (ref) => {
256
- for (const l of lines) {
257
- const i = l.items.findIndex((x) => x.ref === ref);
258
- if (i >= 0) return { l, i, it: l.items[i] };
259
- }
260
- return null;
261
- };
262
-
263
- HK_OPS.forEach((op, oi) => {
264
- const key = "op" + (oi + 1);
265
- if (op.op === "add") {
266
- const l = lines[op.line - 1];
267
- if (!l) return;
268
- // THE POSITION IS COMPUTED HERE, not when the button was pressed. Three
269
- // adds in a row get 1, 2 and 3 and append; three adds computed at stage
270
- // time all got 1, and the CLI read 2 and 3 as EDITS of the first.
271
- const pos = l.items.length + 1;
272
- l.items.push({ ref: "new:" + oi, type: op.type, src: null, over: {}, isNew: true });
273
- 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 }) });
274
- } else if (op.op === "remove") {
275
- const f = find(op.ref);
276
- if (!f) return;
277
- actions.push({ key, route: "/api/statusline/remove", body: bd({ at: f.l.line + ":" + (f.i + 1) }), label: t("hookui.opRemove", { id: f.it.type }) });
278
- f.l.items.splice(f.i, 1);
279
- } else if (op.op === "move") {
280
- const f = find(op.ref);
281
- if (!f) return;
282
- const from = f.l.line + ":" + (f.i + 1);
283
- f.l.items.splice(f.i, 1);
284
- const dst = lines[op.toLine - 1];
285
- if (!dst) return;
286
- const at = Math.max(0, Math.min(op.toPos - 1, dst.items.length));
287
- dst.items.splice(at, 0, f.it);
288
- 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 }) });
289
- } else if (op.op === "clone") {
290
- const f = find(op.ref);
291
- if (!f) return;
292
- actions.push({ key, route: "/api/statusline/clone", body: bd({ at: f.l.line + ":" + (f.i + 1) }), label: t("hookui.opClone", { id: f.it.type }) });
293
- 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 });
294
- } else if (op.op === "set") {
295
- const f = find(op.ref);
296
- if (!f) return;
297
- f.it.over[op.field] = op.value;
298
- if (op.field === "type") f.it.type = op.value;
299
- 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 });
300
- } else if (op.op === "sep") {
301
- const l = lines[op.line - 1];
302
- if (!l) return;
303
- l.separator = op.value;
304
- actions.push({ key, route: "/api/statusline/line", body: bd({ line: op.line, separator: op.value }), label: op.label });
305
- } else if (op.op === "doc") {
306
- doc[op.field] = op.value;
307
- actions.push({ key, route: "/api/statusline/doc", body: bd({ [op.field]: op.value }), label: op.label });
308
- }
309
- });
310
-
311
- for (const l of lines) {
312
- // A spacer is not a thing the line SAYS, so it does not eat one of the
313
- // five. Which ids those are is the CLI's answer, carried per component.
314
- l.counted = l.items.filter((x) => !structural.has(x.type)).length;
315
- l.full = l.counted >= cat.max_per_line;
316
- }
317
- return { lines, actions, doc, dirty: HK_OPS.length > 0, max: cat.max_per_line };
318
- }
319
-
320
- // The dense-prefix rule, applied to ONE line of the EFFECTIVE board — never the
321
- // saved one, or a part staged onto line 1 would leave line 2 still refusing.
322
- // The reason travels with the answer, because a disabled zone with no reason is
323
- // a shrug.
324
- function lineLegal(plan, n) {
325
- if (n === 1) return { ok: true };
326
- const above = plan.lines[n - 2];
327
- if (above && above.items.length > 0) return { ok: true };
328
- return { ok: false, why: t("hookui.fillFirst", { n: n - 1 }) };
329
- }
330
-
331
- // Why a part cannot go on this line RIGHT NOW; a null answer means it can. Both
332
- // reasons are the user's to read BEFORE the click, never after it.
333
- function addBlocked(plan, n) {
334
- const legal = lineLegal(plan, n);
335
- if (!legal.ok) return legal.why;
336
- const line = plan.lines[n - 1];
337
- if (!line) return t("hookui.noSuchLine");
338
- if (line.full) return t("hookui.lineFull", { n: n, max: plan.max });
339
- return null;
340
- }
341
-
342
- /* ══ the board tabs ═══════════════════════════════════════════════════════ */
343
- // The two boards. Named by the CLI (`boards` in the catalogue), labelled by the
344
- // panel — the id is data and the label is prose, which is the split everywhere
345
- // else in this file too.
346
- //
347
- // The keys are SPELLED OUT rather than assembled from the id, for the same
348
- // reason the tier labels are: a key built from a fragment is invisible to the
349
- // coverage check that keeps the two string tables honest.
350
- const HK_BOARD_LABEL = { status: "hookui.boardStatus", subagent: "hookui.boardSubagent" };
351
- const HK_BOARD_SUB = { status: "hookui.boardStatusSub", subagent: "hookui.boardSubagentSub" };
352
- function boardTabs(cat) {
353
- const c = card(null);
354
- const row = el("div", "row-actions hk-boards");
355
- for (const b of cat.boards || ["status"]) {
356
- const btn = el("button", "btn btn-sm" + (HK_BOARD === b ? " btn-primary" : " btn-ghost"),
357
- t(HK_BOARD_LABEL[b] || HK_BOARD_LABEL.status));
358
- btn.type = "button";
359
- btn.addEventListener("click", () => {
360
- if (HK_BOARD === b) return;
361
- // Switching boards with ops staged would send them to a board they were
362
- // never about: every op carries a board, but the POSITIONS in them were
363
- // computed against this one.
364
- if (HK_OPS.length) return toast(t("hookui.applyFirst"), "bad");
365
- HK_BOARD = b;
366
- HK_SEARCH = "";
367
- HK_COMPARE_DATA = null;
368
- hkReload();
369
- });
370
- row.append(btn);
371
- }
372
- c.append(row);
373
- c.append(el("div", "note", t(HK_BOARD_SUB[HK_BOARD] || HK_BOARD_SUB.status)));
374
- return c;
375
- }
376
-
377
- /* ══ the gate ═════════════════════════════════════════════════════════════ */
378
- // INVERTED from the Extra panel's. There, nothing exists until you connect;
379
- // here the board is LIVE WHILE OFF, because you must be able to compose before
380
- // you arm. What is gated is the switch, not the work.
381
- function gateCard(show, cat) {
382
- const c = card(t("hookui.gate"));
383
- const row = el("div", "row-actions");
384
- row.append(chip(show.enabled ? t("hookui.on") : t("hookui.off"), show.enabled ? "ok" : null));
385
- if (show.saved) row.append(chip(t("hookui.saved"), null));
386
- c.append(row);
387
- c.append(el("div", "note", show.enabled ? t("hookui.gateOnNote") : t("hookui.gateOffNote")));
388
-
389
- const b = el("button", "btn btn-sm" + (show.enabled ? " btn-ghost" : " btn-primary"),
390
- show.enabled ? t("hookui.turnOff") : t("hookui.turnOn"));
391
- b.type = "button";
392
- // ENABLED WITH NOTHING SAVED IS NOT A STATE. The switch cannot reach `on`
393
- // past a layout that does not validate, and the refusal names the violation
394
- // rather than saying "invalid".
395
- if (!show.enabled && !show.ok) {
396
- b.disabled = true;
397
- b.title = show.errors[0] || "";
398
- c.append(el("div", "note note-warn", show.errors[0] || ""));
399
- }
400
- b.addEventListener("click", async () => {
401
- // The KEY IS THE CLI'S. Naming it here would be a second idea of which
402
- // switch belongs to which board.
403
- const r = await post("/api/config/set", { key: cat.config_key, value: show.enabled ? "off" : "on" });
404
- if (!r.ok) toast(t("hookui.switchFailed"), "bad", (r.output || "").trim());
405
- hkReload();
406
- });
407
- const acts = el("div", "row-actions");
408
- acts.append(b);
409
- c.append(acts);
410
- return c;
411
- }
412
-
413
- /* ══ cautions ═════════════════════════════════════════════════════════════ */
414
- // An error is a refusal; a warning is a fact the user then owns. They are drawn
415
- // differently and they are never merged into one count.
416
- function cautionCard(show) {
417
- const c = card(t("hookui.cautions"));
418
- for (const e of show.errors) {
419
- const row = el("div", "hk-caution hk-caution-bad");
420
- row.append(el("span", "hk-caution-mark", "✕"));
421
- row.append(el("span", null, e));
422
- c.append(row);
423
- }
424
- for (const w of show.warnings) {
425
- const row = el("div", "hk-caution");
426
- row.append(el("span", "hk-caution-mark", "⚠"));
427
- row.append(el("span", null, w));
428
- c.append(row);
429
- }
430
- return c;
431
- }
432
-
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) {
458
- const right = el("div", "row-actions");
459
- for (const w of HK_WIDTHS) {
460
- const b = el("button", "btn btn-xs" + (HK_WIDTH === w ? " btn-primary" : " btn-ghost"), tn(w, "hookui.cols"));
461
- b.type = "button";
462
- b.addEventListener("click", () => {
463
- HK_WIDTH = w;
464
- HK_COMPARE_DATA = null;
465
- hkPreviewReload();
466
- });
467
- right.append(b);
468
- }
469
- const c = card(t("hookui.preview"), right);
470
- c.append(el("div", "note", t("hookui.previewAbout")));
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")));
474
- if (!prev || !prev.ok) {
475
- c.append(el("div", "note", t("hookui.previewUnavailable")));
476
- return c;
477
- }
478
-
479
- // The fixture picker. `--fixtures` must carry one of every state INCLUDING
480
- // the ugly ones - you cannot design a degraded line on a healthy session.
481
- c.append(el("div", "note", t("hookui.fixturesAbout")));
482
- const states = el("div", "row-actions hk-fixtures");
483
- for (const [k, label] of Object.entries(prev.fixtures || {})) {
484
- const b = el("button", "btn btn-xs" + (HK_STATE === k ? " btn-primary" : " btn-ghost"), label);
485
- b.type = "button";
486
- b.addEventListener("click", () => {
487
- HK_STATE = k;
488
- HK_COMPARE_DATA = null;
489
- hkPreviewReload();
490
- });
491
- states.append(b);
492
- }
493
- c.append(states);
494
-
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
- }
605
-
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")));
612
- for (const [key, label] of [
613
- ["no_color", t("hookui.degNoColor")],
614
- ["ascii", t("hookui.degAscii")],
615
- ["no_motion", t("hookui.degNoMotion")],
616
- ]) {
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));
625
- }
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
- }
631
-
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;
638
- }
639
-
640
- // ANSI → DOM. Never innerHTML: this string comes from the CLI, and the rule for
641
- // every other rendered artefact in this panel holds here too.
642
- function ansiBlock(text, cls) {
643
- const pre = el("pre", cls);
644
- for (const line of String(text || "").split("\n")) {
645
- const row = el("div", "hk-line");
646
- let style = null;
647
- // A minimal SGR reader: colour, background and the emphasis attributes the
648
- // compiler can emit. Anything it does not recognise is DROPPED rather than
649
- // printed, because a stray escape in the middle of a preview is worse than
650
- // a missing colour.
651
- const re = /\[([0-9;]*)m/g;
652
- let last = 0;
653
- let m;
654
- const push = (s) => {
655
- if (!s) return;
656
- const span = el("span", null, s);
657
- // THROUGH CSSOM, NEVER A `style` ATTRIBUTE. This page is served under
658
- // `style-src 'self'`, which blocks a parsed style attribute outright —
659
- // so every colour this preview drew was thrown away by the browser and
660
- // the whole thing rendered grey, with the reason only in a console
661
- // nobody had open. Assigning the property is not a parse and is not
662
- // blocked, and weakening the policy to `unsafe-inline` for a preview is
663
- // not a trade worth making.
664
- if (style) for (const k of Object.keys(style)) span.style[k] = style[k];
665
- row.append(span);
666
- };
667
- while ((m = re.exec(line))) {
668
- push(line.slice(last, m.index));
669
- style = sgrStyle(m[1], style);
670
- last = m.index + m[0].length;
671
- }
672
- push(line.slice(last));
673
- if (!row.childNodes.length) row.append(document.createTextNode(" "));
674
- pre.append(row);
675
- }
676
- return pre;
677
- }
678
-
679
- // The 16 ANSI slots, as the browser's own colours. These are NOT ORC's colour
680
- // tokens — they are what a terminal would paint, approximated, because the
681
- // panel cannot know the user's terminal palette and says so.
682
- const HK_ANSI = [
683
- "#000000", "#cc0000", "#4e9a06", "#c4a000", "#3465a4", "#75507b", "#06989a", "#d3d7cf",
684
- ];
685
- const HK_ANSI_BRIGHT = [
686
- "#555753", "#ef2929", "#8ae234", "#fce94f", "#729fcf", "#ad7fa8", "#34e2e2", "#eeeeec",
687
- ];
688
-
689
- // An OBJECT of CSS properties rather than a declaration string, because the
690
- // caller assigns them one at a time through CSSOM — see `push` above.
691
- function sgrStyle(codes, prev) {
692
- const parts = String(codes || "0").split(";").filter((x) => x !== "");
693
- if (!parts.length || parts[0] === "0") return null;
694
- const out = Object.assign({}, prev || {});
695
- for (let i = 0; i < parts.length; i++) {
696
- const n = Number(parts[i]);
697
- if (n === 1) out.fontWeight = "700";
698
- else if (n === 2) out.opacity = ".6";
699
- else if (n === 3) out.fontStyle = "italic";
700
- else if (n === 4) out.textDecoration = "underline";
701
- else if (n === 9) out.textDecoration = "line-through";
702
- else if (n === 7) out.filter = "invert(1)";
703
- else if (n >= 30 && n <= 37) out.color = HK_ANSI[n - 30];
704
- else if (n >= 90 && n <= 97) out.color = HK_ANSI_BRIGHT[n - 90];
705
- else if (n >= 40 && n <= 47) out.background = HK_ANSI[n - 40];
706
- else if (n === 39) out.color = "inherit";
707
- else if (n === 38 && parts[i + 1] === "2") {
708
- out.color = "rgb(" + parts[i + 2] + "," + parts[i + 3] + "," + parts[i + 4] + ")";
709
- i += 4;
710
- } else if (n === 48 && parts[i + 1] === "2") {
711
- out.background = "rgb(" + parts[i + 2] + "," + parts[i + 3] + "," + parts[i + 4] + ")";
712
- i += 4;
713
- }
714
- }
715
- return out;
716
- }
717
-
718
- /* ══ the board ════════════════════════════════════════════════════════════ */
719
- // THE ILLEGAL DROP IS MADE IMPOSSIBLE rather than allowed and then complained
720
- // about: an illegal zone renders disabled with its reason on the zone itself. A
721
- // user should never be able to do the wrong thing and then be told off for it.
722
- //
723
- // The CLI still validates. The board is a convenience; it is never the
724
- // guarantee.
725
- function boardCard(cat, plan) {
726
- const c = card(t("hookui.board"));
727
- c.append(el("div", "note", t("hookui.boardAbout")));
728
- c.append(el("div", "note", cat.dense_prefix));
729
-
730
- for (const line of plan.lines) {
731
- const legal = lineLegal(plan, line.line);
732
- const wrap = el("div", "hk-line-wrap" + (legal.ok ? "" : " hk-line-blocked"));
733
-
734
- const h = el("div", "hk-line-head");
735
- const title = el("div", "hk-line-title");
736
- title.append(el("span", "hk-line-name", tn(line.line, "hookui.lineN")));
737
- title.append(el("span", "hk-line-count" + (line.full ? " hk-full" : ""), tn(line.counted, "hookui.slotsUsed", { max: plan.max })));
738
- h.append(title);
739
- h.append(separatorPicker(cat, line));
740
- wrap.append(h);
741
-
742
- const zone = el("div", "hk-zone");
743
- zone.setAttribute("role", "list");
744
- for (const item of line.items) zone.append(chipEl(item, line, plan, cat));
745
-
746
- if (!legal.ok) {
747
- const blocked = el("div", "hk-blocked");
748
- blocked.append(el("span", "hk-caution-mark", "✕"));
749
- blocked.append(el("span", null, legal.why));
750
- zone.append(blocked);
751
- }
752
- // THE ADD BUTTON ALWAYS RENDERS, and carries its own refusal. A control
753
- // that vanishes when it cannot be used teaches nobody why — and the two
754
- // refusals are DIFFERENT facts (nothing above it yet · this line is full),
755
- // so they are two sentences and never one "invalid".
756
- const b = el("button", "hk-add", t("hookui.add"));
757
- b.type = "button";
758
- if (!legal.ok) {
759
- b.disabled = true;
760
- b.title = legal.why;
761
- } else if (line.full) {
762
- b.disabled = true;
763
- b.title = t("hookui.lineFull", { n: line.line, max: plan.max });
764
- } else {
765
- b.addEventListener("click", () => partPicker(cat, plan, line.line, null));
766
- }
767
- zone.append(b);
768
- if (b.disabled && legal.ok) zone.append(el("div", "note note-warn hk-zone-why", b.title));
769
- dropTarget(zone, line, plan);
770
- wrap.append(zone);
771
- c.append(wrap);
772
- }
773
- c.append(el("div", "note", t("hookui.dragHint")));
774
- return c;
775
- }
776
-
777
- // THE SEPARATOR IS A DROPDOWN, and its options are the CLI's. A free-text box
778
- // asked a person to invent a rhythm and then to type a character that may not
779
- // be on their keyboard. A value the layout already carries that is NOT in the
780
- // set keeps its slot, leads the list and is disabled — the `fixed_executor`
781
- // rule: the state must be visible, never re-offerable.
782
- function separatorPicker(cat, line) {
783
- const wrap = el("label", "hk-line-opts");
784
- wrap.append(el("span", "note", t("hookui.separator")));
785
- const sel = el("select", "hk-select");
786
- const known = cat.separators || [];
787
- if (!known.some((s) => s.value === line.separator)) {
788
- const o = el("option", null, t("hookui.sepCustom", { s: JSON.stringify(line.separator) }));
789
- o.value = line.separator;
790
- o.disabled = true;
791
- o.selected = true;
792
- sel.append(o);
793
- }
794
- for (const s of known) {
795
- // The NAME is the CLI's word; the sample is the literal string, shown
796
- // between two marks so a run of spaces is visible at all.
797
- const o = el("option", null, s.name + " ‹" + s.value + "›");
798
- o.value = s.value;
799
- if (s.value === line.separator) o.selected = true;
800
- sel.append(o);
801
- }
802
- sel.addEventListener("change", () => {
803
- const hit = known.find((s) => s.value === sel.value);
804
- hkOp({ op: "sep", line: line.line, value: sel.value, label: t("hookui.opSep", { n: line.line, s: hit ? hit.name : sel.value }) });
805
- });
806
- wrap.append(sel);
807
- return wrap;
808
- }
809
-
810
- /* ══ a chip ═══════════════════════════════════════════════════════════════ */
811
- // A chip shows ITS OWN RENDERED OUTPUT, not its component id — that is what the
812
- // user is arranging — with the id and a one-line description under it, because
813
- // "what is this thing" is the first question anybody asks of a bar full of
814
- // symbols, and the answer used to live only in a tooltip.
815
- function chipEl(item, line, plan, cat) {
816
- const comp = (cat.components || []).find((x) => x.id === item.type);
817
- const wrap = el("div", "hk-chip" + (item.isNew ? " hk-chip-new" : ""));
818
- wrap.setAttribute("role", "listitem");
819
- wrap.setAttribute("tabindex", "0");
820
- wrap.draggable = hkCanDrag();
821
- wrap.title = comp && comp.summary ? comp.summary : item.type;
822
-
823
- const face = el("div", "hk-chip-face");
824
- face.append(el("span", "hk-grip", "∷"));
825
- face.append(ansiBlock(chipSample(item, comp), "hk-preview hk-preview-chip"));
826
- wrap.append(face);
827
-
828
- wrap.append(el("div", "hk-chip-id", item.type));
829
- if (comp && comp.summary) wrap.append(el("div", "hk-chip-desc", comp.summary));
830
-
831
- // EDIT · MOVE · REMOVE, on the chip. Each opens a modal that says what it is
832
- // about to do. The drag is the shortcut, never the mechanism: pointer drag is
833
- // unusable for a real fraction of people, and it is switched off entirely
834
- // below the width at which a drop gap stops being a target.
835
- const acts = el("div", "hk-chip-acts");
836
- const mk = (label, cls, fn) => {
837
- const b = el("button", "hk-act " + cls, label);
838
- b.type = "button";
839
- b.addEventListener("click", fn);
840
- acts.append(b);
841
- };
842
- mk(t("hookui.edit"), "hk-act-edit", () => editModal(item, line, comp, cat));
843
- mk(t("hookui.move"), "hk-act-move", () => moveModal(item, line, plan));
844
- mk(t("hookui.remove"), "hk-act-remove", () => removeModal(item, comp));
845
- wrap.append(acts);
846
-
847
- // Keyboard: the settled pattern, and every drag has an equivalent here. A
848
- // board only reachable by mouse is a board a lot of people cannot use.
849
- wrap.addEventListener("keydown", (ev) => {
850
- const pos = line.items.indexOf(item) + 1;
851
- if (ev.key === "Delete" || ev.key === "Backspace") {
852
- ev.preventDefault();
853
- removeModal(item, comp);
854
- } else if (ev.key === "e" || ev.key === "E") {
855
- ev.preventDefault();
856
- editModal(item, line, comp, cat);
857
- } else if (ev.key === "ArrowLeft" && pos > 1) {
858
- ev.preventDefault();
859
- moveTo(item, line.line, pos - 1);
860
- } else if (ev.key === "ArrowRight" && pos < line.items.length) {
861
- ev.preventDefault();
862
- moveTo(item, line.line, pos + 1);
863
- } else if (ev.key === "ArrowUp" || ev.key === "ArrowDown") {
864
- const target = line.line + (ev.key === "ArrowUp" ? -1 : 1);
865
- if (target < 1 || target > plan.lines.length) return;
866
- ev.preventDefault();
867
- const why = addBlocked(plan, target);
868
- // A line that cannot take it is SKIPPED and the live region says why —
869
- // never a silent no-op.
870
- if (why) return announce(why);
871
- moveTo(item, target, 1);
872
- }
873
- });
874
-
875
- wrap.addEventListener("dragstart", (ev) => {
876
- HK_DRAG = { ref: item.ref, from: line.line };
877
- wrap.classList.add("hk-chip-dragging");
878
- try {
879
- ev.dataTransfer.effectAllowed = "move";
880
- ev.dataTransfer.setData("text/plain", item.ref);
881
- } catch (_) {}
882
- });
883
- wrap.addEventListener("dragend", () => {
884
- HK_DRAG = null;
885
- wrap.classList.remove("hk-chip-dragging");
886
- for (const n of document.querySelectorAll(".hk-drop-before, .hk-drop-after"))
887
- n.classList.remove("hk-drop-before", "hk-drop-after");
888
- });
889
- wrap.addEventListener("dragover", (ev) => {
890
- if (!HK_DRAG || HK_DRAG.ref === item.ref) return;
891
- ev.preventDefault();
892
- const r = wrap.getBoundingClientRect();
893
- const before = ev.clientX < r.left + r.width / 2;
894
- wrap.classList.toggle("hk-drop-before", before);
895
- wrap.classList.toggle("hk-drop-after", !before);
896
- });
897
- wrap.addEventListener("dragleave", () => wrap.classList.remove("hk-drop-before", "hk-drop-after"));
898
- wrap.addEventListener("drop", (ev) => {
899
- if (!HK_DRAG || HK_DRAG.ref === item.ref) return;
900
- ev.preventDefault();
901
- ev.stopPropagation();
902
- const before = wrap.classList.contains("hk-drop-before");
903
- const pos = line.items.indexOf(item) + 1;
904
- dropOnto(line, plan, before ? pos : pos + 1);
905
- });
906
- return wrap;
907
- }
908
-
909
- // What this chip will look like in the bar. A part staged in this session has
910
- // no rendered sample from the CLI yet, so it borrows its component's default —
911
- // which is exactly what it will render as until somebody changes its shape.
912
- function chipSample(item, comp) {
913
- if (!comp) return item.type;
914
- const render = item.over.render || (item.src ? item.src.render : (comp.defaults && comp.defaults.render) || comp.renderers[0]);
915
- const s = comp.previews ? comp.previews[render] : null;
916
- return s || item.type;
917
- }
918
-
919
- // A zone is a drop target across its WHOLE area, so a drop past the last chip
920
- // appends instead of doing nothing.
921
- function dropTarget(zone, line, plan) {
922
- zone.addEventListener("dragover", (ev) => {
923
- if (!HK_DRAG) return;
924
- if (HK_DRAG.from !== line.line && addBlocked(plan, line.line)) return;
925
- ev.preventDefault();
926
- zone.classList.add("hk-zone-hot");
927
- });
928
- zone.addEventListener("dragleave", () => zone.classList.remove("hk-zone-hot"));
929
- zone.addEventListener("drop", (ev) => {
930
- zone.classList.remove("hk-zone-hot");
931
- if (!HK_DRAG) return;
932
- ev.preventDefault();
933
- dropOnto(line, plan, line.items.length + 1);
934
- });
935
- }
936
-
937
- function dropOnto(line, plan, pos) {
938
- if (!HK_DRAG) return;
939
- const all = plan.lines.reduce((acc, l) => acc.concat(l.items), []);
940
- const moving = all.find((x) => x.ref === HK_DRAG.ref);
941
- const from = HK_DRAG.from;
942
- HK_DRAG = null;
943
- if (!moving) return;
944
- // Reordering WITHIN a line never changes how many parts it holds, so the
945
- // five-slot cap and the dense-prefix rule only apply to a move that crosses
946
- // lines.
947
- if (from !== line.line) {
948
- const why = addBlocked(plan, line.line);
949
- if (why) return toast(why, "bad");
950
- }
951
- moveTo(moving, line.line, pos);
952
- }
953
-
954
- function moveTo(item, toLine, toPos) {
955
- hkOp({ op: "move", ref: item.ref, toLine, toPos });
956
- announce(t("hookui.opMove", { id: item.type, n: toLine }));
957
- }
958
-
959
- // `aria-grabbed` is deprecated and is not used. State is announced through a
960
- // polite live region instead — VoiceOver does not reliably read a dynamically
961
- // updated description, so the live region is the mechanism, not a fallback.
962
- function announce(msg) {
963
- let region = document.getElementById("hk-live");
964
- if (!region) {
965
- region = el("div", "sr-only");
966
- region.id = "hk-live";
967
- region.setAttribute("role", "status");
968
- region.setAttribute("aria-live", "polite");
969
- document.body.append(region);
970
- }
971
- region.textContent = msg;
972
- }
973
-
974
- /* ══ the part picker ══════════════════════════════════════════════════════ */
975
- // ONE MODAL, one search box, one click. It is the only place a part is ever
976
- // chosen — for a new slot and for swapping an existing one — so there is
977
- // exactly one answer to "how do I put something on the bar".
978
- //
979
- // `replace` names the chip whose part is being swapped; absent, this is an add.
980
- function partPicker(cat, plan, line, replace) {
981
- HK_MODAL = null;
982
- let q = "";
983
- // NOT named `body`: that name is reserved for a panel's own card container,
984
- // which must carry `stack`. This is a modal's contents and spaces itself.
985
- const pane = el("div", "hk-picker");
986
-
987
- const searchRow = el("div", "hk-picker-search");
988
- const search = el("input", "hk-input hk-picker-input");
989
- search.type = "search";
990
- search.placeholder = t("hookui.searchPlaceholder");
991
- const count = el("span", "note");
992
- searchRow.append(search, count);
993
- pane.append(searchRow);
994
- pane.append(el("div", "note", replace ? t("hookui.pickerReplaceAbout") : tn(line, "hookui.pickerAbout")));
995
-
996
- const list = el("div", "hk-picker-list");
997
- pane.append(list);
998
-
999
- const closer = () => close();
1000
- const paint = () => {
1001
- list.replaceChildren();
1002
- const rows = (cat.components || []).filter((c) => (q ? matches(c, q) : true));
1003
- count.textContent = tn(rows.length, "hookui.matches");
1004
- if (!rows.length) {
1005
- list.append(empty(t("hookui.noMatch")));
1006
- return;
1007
- }
1008
- for (const comp of rows) list.append(pickerRow(comp, line, replace, closer));
1009
- };
1010
- search.addEventListener("input", () => {
1011
- q = search.value.trim().toLowerCase();
1012
- paint();
1013
- });
1014
- paint();
1015
-
1016
- const close = modal({
1017
- title: replace ? t("hookui.pickerReplaceTitle") : tn(line, "hookui.pickerTitle"),
1018
- body: pane,
1019
- actions: [{ label: t("common.cancel"), cls: "btn-ghost", onClick: (c) => c() }],
1020
- });
1021
- setTimeout(() => search.focus(), 40);
1022
- }
1023
-
1024
- // A row says WHAT the part is, in a sentence, next to what it will look like.
1025
- // A REFUSED part is drawn and is NOT clickable — never a disabled button, and
1026
- // never absent: "we decided against this" and "we forgot" must not look the
1027
- // same, which is why the row exists at all.
1028
- function pickerRow(comp, line, replace, close) {
1029
- const refused = comp.cost === "refused";
1030
- const row = el(refused ? "div" : "button", "hk-pick-row" + (refused ? " hk-pick-row-off" : ""));
1031
- if (!refused) row.type = "button";
1032
-
1033
- const top = el("div", "hk-pick-top");
1034
- top.append(el("span", "hk-prow-id", comp.id));
1035
- // The cost chip is the CLI's own word. A friendlier synonym would be a state
1036
- // that does not exist.
1037
- top.append(chip(comp.cost, refused ? "bad" : null));
1038
- row.append(top);
1039
- if (comp.summary) row.append(el("div", "hk-pick-desc", comp.summary));
1040
- const sample = comp.previews && comp.previews[(comp.defaults && comp.defaults.render) || comp.renderers[0]];
1041
- if (sample) row.append(ansiBlock(sample, "hk-preview hk-preview-chip"));
1042
- if (refused) {
1043
- row.append(el("div", "note note-warn", comp.refused_reason));
1044
- return row;
1045
- }
1046
- row.addEventListener("click", () => {
1047
- if (replace) hkOp({ op: "set", ref: replace.ref, field: "type", value: comp.id, label: t("hookui.opSwap", { id: comp.id }) });
1048
- else hkOp({ op: "add", line, type: comp.id });
1049
- close();
1050
- });
1051
- return row;
1052
- }
1053
-
1054
- // Fuzzy plus INITIALISM: with a catalogue this size a substring search is not
1055
- // enough — two letters should find a two-word name, which plain substring
1056
- // matching does not.
1057
- function matches(comp, q) {
1058
- const id = comp.id.toLowerCase();
1059
- if (id.includes(q)) return true;
1060
- if ((comp.summary || "").toLowerCase().includes(q)) return true;
1061
- if ((comp.label || "").toLowerCase().includes(q)) return true;
1062
- const initials = id.split("-").map((p) => p[0]).join("");
1063
- if (initials.startsWith(q)) return true;
1064
- let i = 0;
1065
- for (const ch of id) if (ch === q[i]) i++;
1066
- return i === q.length;
1067
- }
1068
-
1069
- /* ══ move ═════════════════════════════════════════════════════════════════ */
1070
- // A modal that says what it is about to do — and on a narrow screen it is the
1071
- // ONLY way to move a chip, because drag is off below the width at which a drop
1072
- // gap stops being a target.
1073
- function moveModal(item, line, plan) {
1074
- HK_MODAL = null;
1075
- const body = el("div", "stack");
1076
- body.append(el("div", "note", t("hookui.moveAbout", { id: item.type })));
1077
-
1078
- for (const target of plan.lines) {
1079
- const legal = lineLegal(plan, target.line);
1080
- const sameLine = target.line === line.line;
1081
- const box = el("div", "hk-move-line");
1082
- box.append(el("div", "hk-line-name", tn(target.line, "hookui.lineN")));
1083
- // A LINE THAT CANNOT TAKE IT SAYS SO ONCE. Six identical disabled slots is
1084
- // the same refusal printed six times, and the reason is about the LINE
1085
- // rather than about any one slot on it.
1086
- const blocked = sameLine ? null : addBlocked(plan, target.line);
1087
- if (blocked) {
1088
- box.append(el("div", "note note-warn", blocked));
1089
- body.append(box);
1090
- continue;
1091
- }
1092
- const slots = el("div", "hk-move-slots");
1093
- const n = Math.max(1, target.items.length + (sameLine ? 0 : 1));
1094
- for (let p = 1; p <= n; p++) {
1095
- const b = el("button", "btn btn-xs btn-ghost", tn(p, "hookui.slotN"));
1096
- b.type = "button";
1097
- if (sameLine && p === line.items.indexOf(item) + 1) {
1098
- b.disabled = true;
1099
- b.title = t("hookui.alreadyHere");
1100
- } else {
1101
- b.addEventListener("click", () => {
1102
- close();
1103
- moveTo(item, target.line, p);
1104
- });
1105
- }
1106
- slots.append(b);
1107
- }
1108
- box.append(slots);
1109
- body.append(box);
1110
- }
1111
-
1112
- const close = modal({
1113
- title: t("hookui.moveTitle"),
1114
- body,
1115
- actions: [{ label: t("common.cancel"), cls: "btn-ghost", onClick: (c) => c() }],
1116
- });
1117
- }
1118
-
1119
- /* ══ remove ═══════════════════════════════════════════════════════════════ */
1120
- function removeModal(item, comp) {
1121
- HK_MODAL = null;
1122
- const body = el("div", "stack");
1123
- body.append(el("div", "note", t("hookui.removeAbout", { id: item.type })));
1124
- if (comp && comp.summary) body.append(el("div", "note", comp.summary));
1125
- modal({
1126
- title: t("hookui.removeTitle"),
1127
- body,
1128
- actions: [
1129
- { label: t("common.cancel"), cls: "btn-ghost", onClick: (c) => c() },
1130
- {
1131
- label: t("hookui.remove"),
1132
- cls: "btn-primary",
1133
- onClick: (c) => {
1134
- c();
1135
- hkOp({ op: "remove", ref: item.ref });
1136
- },
1137
- },
1138
- ],
1139
- });
1140
- }
1141
-
1142
- /* ══ the part editor ══════════════════════════════════════════════════════ */
1143
- // A MODAL, because the board is what the user is looking at, and a drawer that
1144
- // opened inside a chip pushed the whole board down under the thing being
1145
- // edited.
1146
- //
1147
- // NO FIELD NAMES, AND EVERY CONTROL CARRIES A SENTENCE. `min_width` and
1148
- // `hide_when` are the JSON's words; this says "Least width" and explains what
1149
- // it does. The JSON is for the diff, the editor is for the person — and the
1150
- // ids, renderers and state words that DO appear are the CLI's own and are never
1151
- // translated.
1152
- function editModal(item0, line0, comp, cat) {
1153
- // NOT named `body`: that name is reserved for a panel's own card container,
1154
- // which must carry `stack`. This is a modal's contents and spaces itself.
1155
- const pane = el("div", "hk-editor");
1156
- if (!comp) {
1157
- pane.append(el("div", "note", t("hookui.unknownComponent")));
1158
- modal({ title: item0.type, body: pane, actions: [{ label: t("common.close"), cls: "btn-ghost", onClick: (c) => c() }] });
1159
- return;
1160
- }
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", () => {
1269
- close();
1270
- partPicker(cat, HK_NO_LINES, 0, item);
1271
- });
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
- }
1345
-
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")));
1379
- }
1380
-
1381
- paint(null);
1382
-
1383
- const close = modal({
1384
- title: comp.id,
1385
- body: pane,
1386
- actions: [
1387
- // WHERE EACH VALUE CAME FROM. It is how a user finds their own overrides
1388
- // again after a colour set change - `orc lane config`'s problem, at part
1389
- // scale. It reads the SAVED item, so it is offered only for one.
1390
- ...(item.src
1391
- ? [{
1392
- label: t("hookui.explain"),
1393
- cls: "btn-ghost",
1394
- onClick: async () => {
1395
- const r = await read("/api/statusline/explain?board=" + HK_BOARD + "&at=" + line.line + ":" + (line.items.indexOf(item) + 1));
1396
- if (!r.data || !r.data.ok) return toast(t("hookui.explainFailed"), "bad");
1397
- HK_MODAL = null;
1398
- modal({
1399
- title: comp.id,
1400
- body: kvList(r.data.resolved.map((x) => [x.field, x.source])),
1401
- actions: [{ label: t("common.close"), cls: "btn-ghost", onClick: (c) => c() }],
1402
- });
1403
- },
1404
- }]
1405
- : []),
1406
- {
1407
- label: t("common.close"),
1408
- cls: "btn-ghost",
1409
- onClick: (c) => {
1410
- HK_MODAL = null;
1411
- c();
1412
- },
1413
- },
1414
- ],
1415
- });
1416
- // Registered LAST, so a repaint that fires during construction cannot reach a
1417
- // half-built pane.
1418
- HK_MODAL = { repaint: paint };
1419
- }
1420
-
1421
- // `partPicker` reads a board only to answer "can a part go on that line". A
1422
- // SWAP changes nothing about where the part sits, so it is handed a board with
1423
- // no lines and never asks.
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
- }
1458
-
1459
- function sectionHead(name, about) {
1460
- const w = el("div", "hk-section-wrap");
1461
- w.append(el("div", "hk-section", name));
1462
- if (about) w.append(el("div", "note", about));
1463
- return w;
1464
- }
1465
-
1466
- function numField(value, lo, hi, onSet) {
1467
- const i = el("input", "hk-input hk-num");
1468
- i.type = "number";
1469
- i.min = String(lo);
1470
- i.max = String(hi);
1471
- if (value !== null && value !== undefined) i.value = String(value);
1472
- i.addEventListener("change", () => onSet(i.value));
1473
- return i;
1474
- }
1475
-
1476
- function textField(value, onSet) {
1477
- const i = el("input", "hk-input");
1478
- i.type = "text";
1479
- i.value = value == null ? "" : String(value);
1480
- i.addEventListener("change", () => onSet(i.value));
1481
- return i;
1482
- }
1483
-
1484
- // A control, its name, and — because a noun on its own is not an explanation —
1485
- // the sentence that says what it does. That sentence is most of this release:
1486
- // a settings form whose labels are nouns is a form people close.
1487
- function labelled(name, node, about) {
1488
- const row = el("div", "hk-field");
1489
- const left = el("div", "hk-field-left");
1490
- left.append(el("span", "hk-field-name", name));
1491
- if (about) left.append(el("span", "note", about));
1492
- row.append(left);
1493
- row.append(node);
1494
- return row;
1495
- }
1496
-
1497
- // A row of options. The VALUES are the CLI's and are printed verbatim — a
1498
- // translated renderer name is a renderer that does not exist.
1499
- function pickRow(options, current, onPick) {
1500
- const row = el("div", "hk-picks");
1501
- for (const o of options || []) {
1502
- const b = el("button", "hk-pick" + (String(current) === String(o) ? " hk-pick-on" : ""), String(o));
1503
- b.type = "button";
1504
- b.addEventListener("click", () => onPick(o));
1505
- row.append(b);
1506
- }
1507
- return row;
1508
- }
1509
-
1510
- /* ══ the reference list ═══════════════════════════════════════════════════ */
1511
- // WHAT EACH PART SHOWS, and nothing else. It used to carry three `+ line N`
1512
- // buttons per row, which put the act of building the bar a long way from the
1513
- // bar — and computed a position against a layout that was no longer the one
1514
- // being staged. Adding happens on the board now; this is a reference.
1515
- function referenceCard(cat) {
1516
- const right = el("div", "row-actions");
1517
- const search = el("input", "hk-input hk-palette-search");
1518
- search.type = "search";
1519
- search.value = HK_SEARCH;
1520
- search.placeholder = t("hookui.searchPlaceholder");
1521
- right.append(search);
1522
-
1523
- const c = card(t("hookui.reference"), right);
1524
- c.append(el("div", "note", t("hookui.referenceAbout")));
1525
- const body = el("div", "stack");
1526
- c.append(body);
1527
-
1528
- function paint() {
1529
- body.replaceChildren();
1530
- const q = HK_SEARCH.trim().toLowerCase();
1531
- const hits = q ? (cat.components || []).filter((x) => matches(x, q)) : null;
1532
- if (hits) {
1533
- body.append(el("div", "note", tn(hits.length, "hookui.matches")));
1534
- for (const comp of hits) body.append(referenceRow(comp));
1535
- if (!hits.length) body.append(empty(t("hookui.noMatch")));
1536
- return;
1537
- }
1538
- for (const [g, name] of Object.entries(cat.groups)) {
1539
- const rows = (cat.components || []).filter((x) => x.group === g);
1540
- if (!rows.length) continue;
1541
- const inner = el("div");
1542
- for (const comp of rows) inner.append(referenceRow(comp));
1543
- body.append(collapsible({
1544
- title: name,
1545
- count: String(rows.length),
1546
- content: inner,
1547
- collapsed: HK_GROUP !== g,
1548
- }));
1549
- }
1550
- }
1551
- search.addEventListener("input", () => {
1552
- HK_SEARCH = search.value;
1553
- paint();
1554
- });
1555
- paint();
1556
- return c;
1557
- }
1558
-
1559
- function referenceRow(comp) {
1560
- const row = el("div", "hk-prow");
1561
- const top = el("div", "hk-prow-head");
1562
- top.append(el("span", "hk-prow-id", comp.id));
1563
- top.append(chip(comp.cost, comp.cost === "refused" ? "bad" : null));
1564
- row.append(top);
1565
- if (comp.summary) row.append(el("div", "hk-prow-desc", comp.summary));
1566
- const sample = comp.previews && comp.previews[(comp.defaults && comp.defaults.render) || comp.renderers[0]];
1567
- if (sample) row.append(ansiBlock(sample, "hk-preview hk-preview-chip"));
1568
- // A REFUSED part keeps its slot and carries the measurement that refused it:
1569
- // "we decided against this" and "we forgot" must not look the same.
1570
- if (comp.cost === "refused") row.append(el("div", "note note-warn hk-prow-why", comp.refused_reason));
1571
- return row;
1572
- }
1573
-
1574
- /* ══ presets ══════════════════════════════════════════════════════════════ */
1575
- // Applying one REPLACES the layout, so it is always confirmed and the loss is
1576
- // NAMED — the `orc diy init --force` rule.
1577
- function presetsCard(presets, plan) {
1578
- const c = card(t("hookui.presets"));
1579
- c.append(el("div", "note", t("hookui.presetsAbout")));
1580
- if (!presets || !presets.presets) return c;
1581
- for (const p of presets.presets) {
1582
- const row = el("div", "hk-preset");
1583
- const h = el("div", "hk-preset-head");
1584
- h.append(el("span", "hk-prow-id", p.name));
1585
- if (p.active) h.append(chip(t("hookui.active"), "ok"));
1586
- row.append(h);
1587
- row.append(el("div", "note", p.summary));
1588
- if (p.preview) row.append(ansiBlock(p.preview, "hk-preview hk-preview-sm"));
1589
- // The ACTIVE row keeps its slot and drops its button.
1590
- if (!p.active) {
1591
- const b = el("button", "btn btn-xs btn-ghost", t("hookui.apply"));
1592
- b.type = "button";
1593
- // A preset is written STRAIGHT AWAY, so it cannot run while ops are
1594
- // staged: those ops carry positions computed against a layout the preset
1595
- // is about to replace.
1596
- if (plan.dirty) {
1597
- b.disabled = true;
1598
- b.title = t("hookui.applyFirst");
1599
- } else {
1600
- b.addEventListener("click", () => confirmApply(p.name));
1601
- }
1602
- row.append(b);
1603
- }
1604
- c.append(row);
1605
- }
1606
- return c;
1607
- }
1608
-
1609
- function confirmApply(name) {
1610
- modal({
1611
- title: t("hookui.applyTitle", { name }),
1612
- body: el("div", "note", t("hookui.applyWarn")),
1613
- actions: [
1614
- { label: t("common.cancel"), cls: "btn-ghost", onClick: (c) => c() },
1615
- {
1616
- label: t("hookui.apply"),
1617
- cls: "btn-primary",
1618
- onClick: async (c) => {
1619
- c();
1620
- const r = await post("/api/statusline/apply", bd({ name }));
1621
- if (!r.ok) toast(t("hookui.applyFailed"), "bad", (r.output || "").trim());
1622
- HK_OPS = [];
1623
- HK_COMPARE_DATA = null;
1624
- hkReload();
1625
- },
1626
- },
1627
- ],
1628
- });
1629
- }
1630
-
1631
- function confirmReset() {
1632
- modal({
1633
- title: t("hookui.resetTitle"),
1634
- body: el("div", "note", t("hookui.resetWarn")),
1635
- actions: [
1636
- { label: t("common.cancel"), cls: "btn-ghost", onClick: (c) => c() },
1637
- {
1638
- label: t("hookui.resetLayout"),
1639
- cls: "btn-primary",
1640
- onClick: async (c) => {
1641
- c();
1642
- await post("/api/statusline/reset", bd({}));
1643
- HK_OPS = [];
1644
- HK_COMPARE_DATA = null;
1645
- hkReload();
1646
- },
1647
- },
1648
- ],
1649
- });
1650
- }
1651
-
1652
- /* ══ more ═════════════════════════════════════════════════════════════════ */
1653
- // THE COLOUR SET IS A DOCUMENT SETTING, and it now goes to the command that
1654
- // writes document settings. It used to be routed through `statusline line 1
1655
- // --theme`, which set ONE line's override while the picker read the document's
1656
- // value back — so the button never moved and only a third of the bar changed.
1657
- // One command per scope, and the panel calls the one that matches the control.
1658
- function advancedCard(cat, plan) {
1659
- const c = card(t("hookui.advanced"));
1660
-
1661
- const themes = el("div", "hk-theme-list");
1662
- for (const name of Object.keys(cat.themes || {})) {
1663
- const b = el("button", "hk-theme" + (plan.doc.theme === name ? " hk-theme-on" : ""));
1664
- b.type = "button";
1665
- b.append(el("span", "hk-theme-name", name));
1666
- // WHY somebody would pick it — the CLI's sentence, not one written here.
1667
- const about = (cat.themes_about || {})[name];
1668
- if (about) b.append(el("span", "note", about));
1669
- b.addEventListener("click", () => hkOp({ op: "doc", field: "theme", value: name, label: t("hookui.themeChange", { v: name }) }));
1670
- themes.append(b);
1671
- }
1672
- c.append(labelled(t("hookui.theme"), themes, t("hookui.themeAbout")));
1673
- c.append(el("div", "note", t("hookui.themeOverride")));
1674
-
1675
- c.append(labelled(t("hookui.glyphs"),
1676
- pickRow(cat.glyph_sets, plan.doc.glyphs, (v) => hkOp({ op: "doc", field: "glyphs", value: v, label: t("hookui.glyphsDocChange", { v }) })),
1677
- t("hookui.glyphsDocAbout")));
1678
-
1679
- const re = el("button", "btn btn-sm btn-ghost", t("hookui.recompile"));
1680
- re.type = "button";
1681
- re.addEventListener("click", async () => {
1682
- const r = await post("/api/statusline/compile", bd({}));
1683
- toast(r.ok ? t("hookui.recompiled") : t("hookui.recompileFailed"), r.ok ? "ok" : "bad");
1684
- hkReload();
1685
- });
1686
- const acts = el("div", "row-actions");
1687
- acts.append(re);
1688
- c.append(acts);
1689
- c.append(el("div", "note", t("hookui.compileNote")));
1690
- return c;
1691
- }
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
+ // 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
+ // v2.0.4 — THE STAGED PREVIEW. The staged writes, replayed by the server into a
99
+ // throwaway copy of the layout and drawn by the hook's own engine, so the
100
+ // picture shows the unsaved changes BEFORE Apply. `HK_STAGE_SEQ` drops a stale
101
+ // answer: only the answer to the LAST request is ever painted.
102
+ let HK_STAGED = null;
103
+ let HK_STAGE_SEQ = 0;
104
+ let HK_STAGE_TIMER = null;
105
+ const HK_STAGE_WAIT_MS = 400;
106
+ // The lines whose separator box is open ("Custom…" was picked), kept across a
107
+ // repaint so the box does not close under the user.
108
+ const HK_SEP_OPEN = new Set();
109
+ // v2.0.4 — THE ANIMATED PREVIEW. When `show.animated` is set, the preview asks
110
+ // the CLI for several frames and the card steps through them for at most 20
111
+ // seconds after a paint, then rests on frame 0. One timer only: every paint
112
+ // and every navigation stops it first. Never under prefers-reduced-motion.
113
+ const HK_ANIM_FRAMES = 12;
114
+ const HK_ANIM_FRAME_MS = 1000;
115
+ const HK_ANIM_MAX_MS = 20000;
116
+ let HK_ANIM_TIMER = null;
117
+
118
+ PANELS.hookui = function (host) {
119
+ head(host, t("hookui.title"), t("hookui.sub"));
120
+ HK_DATA = null;
121
+ HK_SLOT = null;
122
+ HK_MODAL = null;
123
+ HK_PAINTED = false;
124
+ hkAnimStop();
125
+ hkStageClear();
126
+ section(host, hkLoad, (data) => {
127
+ HK_DATA = data;
128
+ // `stack` is what spaces the cards; `section` gives it to its own slot, and
129
+ // this node lives inside that slot, so it needs its own.
130
+ const slot = el("div", "stack");
131
+ HK_SLOT = slot;
132
+ hkPaint();
133
+ return slot;
134
+ });
135
+ };
136
+
137
+ function hkPreviewUrl(animated) {
138
+ const base = "/api/statusline/preview?board=" + HK_BOARD + "&width=" + HK_WIDTH + "&state=" + HK_STATE;
139
+ return animated ? base + "&frames=" + HK_ANIM_FRAMES + "&frame_ms=" + HK_ANIM_FRAME_MS : base;
140
+ }
141
+
142
+ // Whether the saved layout moves. The CLI says so in `show.animated`.
143
+ function hkAnimated() {
144
+ return !!(HK_DATA && HK_DATA.show && HK_DATA.show.animated);
145
+ }
146
+
147
+ function hkReducedMotion() {
148
+ try {
149
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
150
+ } catch (_) {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ function hkAnimStop() {
156
+ clearInterval(HK_ANIM_TIMER);
157
+ HK_ANIM_TIMER = null;
158
+ }
159
+
160
+ // Step the preview node through `frames`, then rest on frame 0. The node is
161
+ // replaced through the same ansiBlock the still picture uses.
162
+ function hkAnimStart(node, frames) {
163
+ hkAnimStop();
164
+ if (!node || !Array.isArray(frames) || frames.length < 2 || hkReducedMotion()) return;
165
+ const started = Date.now();
166
+ let i = 0;
167
+ let cur = node;
168
+ const cls = node.className;
169
+ HK_ANIM_TIMER = setInterval(() => {
170
+ const done = Date.now() - started >= HK_ANIM_MAX_MS;
171
+ if (!cur.isConnected) return hkAnimStop();
172
+ i = done ? 0 : (i + 1) % frames.length;
173
+ const next = ansiBlock(String(frames[i] || ""), cls);
174
+ cur.replaceWith(next);
175
+ cur = next;
176
+ if (done) hkAnimStop();
177
+ }, HK_ANIM_FRAME_MS);
178
+ }
179
+
180
+ function hkLoad() {
181
+ const animated = hkAnimated();
182
+ return Promise.all([
183
+ read("/api/statusline/show?board=" + HK_BOARD).then((r) => r.data),
184
+ read("/api/statusline/components?board=" + HK_BOARD).then((r) => r.data),
185
+ read("/api/statusline/presets?board=" + HK_BOARD).then((r) => r.data),
186
+ read(hkPreviewUrl(animated)).then((r) => r.data),
187
+ ]).then(async ([show, cat, presets, prev]) => {
188
+ // The layout became animated since the last load: ask again for frames.
189
+ if (show && show.animated && !animated) {
190
+ try {
191
+ prev = (await read(hkPreviewUrl(true))).data || prev;
192
+ } catch (_) {}
193
+ }
194
+ return { show, cat, presets, prev };
195
+ });
196
+ }
197
+
198
+ // THE ONLY PAINT. No fetch, no skeleton, no router. The scroll position is
199
+ // kept by hand: a board you are halfway down must not jump to the top because
200
+ // you moved one chip.
201
+ function hkPaint() {
202
+ if (!HK_SLOT || !HK_DATA) return;
203
+ hkAnimStop();
204
+ const y = window.scrollY;
205
+ // NO ENTRANCE ANIMATION ON A REPAINT. `.stack > *` fades and slides each
206
+ // child in on a 30ms stagger, and `.hk-chip` does the same per chip — which
207
+ // is right the first time the panel arrives and is exactly the "the screen
208
+ // blinked like it reloaded" the user reported, on every keystroke, with the
209
+ // network tab empty. `hk-quiet` switches those entrances off; the transitions
210
+ // (hover, the drop marker, the staged edge) are untouched, because they are
211
+ // about the pointer rather than about arriving.
212
+ if (HK_PAINTED) HK_SLOT.classList.add("hk-quiet");
213
+ HK_PAINTED = true;
214
+ const { show, cat, presets, prev } = HK_DATA;
215
+ HK_SLOT.replaceChildren();
216
+ if (!show || !cat) {
217
+ HK_SLOT.append(empty(t("hookui.noData")));
218
+ return;
219
+ }
220
+ // The effective board: what it WILL be once the staged ops run. Every count,
221
+ // every slot cap and every legality question downstream reads this and never
222
+ // `show.lines` directly.
223
+ const plan = hkPlan(show, cat);
224
+
225
+ const edits = editSet(() => bar.paint());
226
+ const bar = editBar(edits, {
227
+ onApply: async (b) => {
228
+ // POSITIONAL writes: after one refusal the rest would hit the wrong part.
229
+ await applyActions(edits, b, { stopOnFail: true, stoppedText: (n) => t("hookui.notSent", { n }) });
230
+ HK_OPS = [];
231
+ hkStageClear();
232
+ HK_COMPARE_DATA = null;
233
+ await hkReload();
234
+ },
235
+ onReset: () => confirmReset(),
236
+ onCancel: () => {
237
+ HK_OPS = [];
238
+ hkStageClear();
239
+ hkPaint();
240
+ },
241
+ resetLabel: t("hookui.resetLayout"),
242
+ });
243
+ // Filled AFTER the bar exists: `editSet`'s onChange paints it, and a paint
244
+ // before that binding is assigned throws.
245
+ for (const a of plan.actions) edits.action(a.key, a.route, a.body, a.label);
246
+
247
+ HK_SLOT.append(boardTabs(cat));
248
+ HK_SLOT.append(gateCard(show, cat));
249
+ // A caution renders ABOVE the preview, never inside a tab — a caution you
250
+ // have to hunt for is a caution nobody reads.
251
+ if (show.errors.length || show.warnings.length) HK_SLOT.append(cautionCard(show));
252
+ HK_SLOT.append(previewCard(prev, plan, cat));
253
+ HK_SLOT.append(boardCard(cat, plan));
254
+ HK_SLOT.append(referenceCard(cat));
255
+ HK_SLOT.append(presetsCard(presets, plan));
256
+ HK_SLOT.append(advancedCard(cat, plan));
257
+ HK_SLOT.append(bar);
258
+
259
+ // The open editor follows the staged state. A modal the page has stopped
260
+ // showing is not repainted — it is forgotten.
261
+ if (HK_MODAL) {
262
+ const host = document.getElementById("modal-host");
263
+ if (host && host.hidden) HK_MODAL = null;
264
+ else HK_MODAL.repaint(plan);
265
+ }
266
+ window.scrollTo(0, y);
267
+ }
268
+
269
+ // A REFETCH. Used where the disk actually moved: Apply, a preset, the reset,
270
+ // the switch, a board change and a recompile. Never on a staged edit.
271
+ async function hkReload() {
272
+ try {
273
+ HK_DATA = await hkLoad();
274
+ } catch (_) {
275
+ /* the last good data stays on screen — a failed poll is not a blank page */
276
+ }
277
+ hkPaint();
278
+ }
279
+
280
+ // The preview is the ONLY thing a width or fixture change moves, so it is the
281
+ // only thing refetched. Four endpoints for a picture is three too many.
282
+ async function hkPreviewReload() {
283
+ try {
284
+ const r = await read(hkPreviewUrl(hkAnimated()));
285
+ if (HK_DATA) HK_DATA.prev = r.data;
286
+ } catch (_) {}
287
+ hkPaint();
288
+ // The staged picture was drawn at the old width or state; draw it again.
289
+ hkStageSchedule();
290
+ }
291
+
292
+ /* ══ the staged preview ═══════════════════════════════════════════════════
293
+ After a staged change, and after a short pause, the server replays the
294
+ staged writes into a TEMP copy of the layout and previews that copy. The
295
+ real project is never written. A burst of changes is ONE request, and an
296
+ answer that arrives after a newer request was sent is dropped. */
297
+ function hkStageSchedule() {
298
+ clearTimeout(HK_STAGE_TIMER);
299
+ if (!HK_OPS.length || !HK_DATA) {
300
+ HK_STAGED = null;
301
+ return;
302
+ }
303
+ HK_STAGE_TIMER = setTimeout(hkStageSend, HK_STAGE_WAIT_MS);
304
+ }
305
+
306
+ async function hkStageSend() {
307
+ if (!HK_OPS.length || !HK_DATA) return;
308
+ const seq = ++HK_STAGE_SEQ;
309
+ const plan = hkPlan(HK_DATA.show, HK_DATA.cat);
310
+ let r = null;
311
+ try {
312
+ r = await post("/api/statusline/stage-preview", {
313
+ board: HK_BOARD,
314
+ width: HK_WIDTH,
315
+ state: HK_STATE,
316
+ ...(hkAnimated() ? { frames: HK_ANIM_FRAMES, frame_ms: HK_ANIM_FRAME_MS } : {}),
317
+ actions: plan.actions.map((a) => ({ route: a.route, body: a.body })),
318
+ });
319
+ } catch (e) {
320
+ r = { ok: false, failed: { index: -1, output: String((e && e.message) || "") } };
321
+ }
322
+ // A NEWER REQUEST WAS SENT, or the ops were cleared: this answer is stale.
323
+ if (seq !== HK_STAGE_SEQ || !HK_OPS.length) return;
324
+ if (r && r.ok && r.preview) HK_STAGED = { preview: r.preview, show: r.show || null };
325
+ else {
326
+ const f = (r && r.failed) || { index: -1, output: "" };
327
+ const hit = plan.actions[f.index];
328
+ HK_STAGED = { failed: { label: hit ? hit.label : "", output: String(f.output || "") } };
329
+ }
330
+ hkStagePaint();
331
+ }
332
+
333
+ // A value typed but not yet staged must not be wiped by the repaint this
334
+ // answer triggers. The paint waits until that field loses focus.
335
+ function hkStagePaint() {
336
+ const a = document.activeElement;
337
+ if (a && a.tagName === "INPUT" && a.dataset && "built" in a.dataset && a.value !== a.dataset.built) {
338
+ a.addEventListener("blur", () => hkPaint(), { once: true });
339
+ return;
340
+ }
341
+ hkPaint();
342
+ }
343
+
344
+ function hkStageClear() {
345
+ clearTimeout(HK_STAGE_TIMER);
346
+ HK_STAGE_SEQ++;
347
+ HK_STAGED = null;
348
+ }
349
+
350
+ // The staged `show` item at this chip's place, when the staged preview has
351
+ // one. The replay ran the same ops in the same order, so the place matches.
352
+ function stagedItem(item, line) {
353
+ if (!HK_STAGED || !HK_STAGED.show || !line) return null;
354
+ const l = (HK_STAGED.show.lines || []).find((x) => x.line === line.line);
355
+ const i = line.items.indexOf(item);
356
+ return l && i >= 0 && l.items ? l.items[i] || null : null;
357
+ }
358
+
359
+ // EVERY write carries the board it is about. Forgetting it on one route would
360
+ // edit the other board's layout, which is the one mistake here that is silent.
361
+ function bd(body) {
362
+ return Object.assign({ board: HK_BOARD }, body);
363
+ }
364
+
365
+ /* ══ the staged plan ══════════════════════════════════════════════════════
366
+ Replays every staged op onto a working copy of the saved layout and returns
367
+ BOTH the effective board and the ordered writes that produce it. ONE
368
+ function, because a second idea of where a part will land is exactly the bug
369
+ this replaces: the board drew one arrangement and the writes produced
370
+ another, silently. */
371
+
372
+ // Ops are staged by REF, never by position. A ref is either an item id the CLI
373
+ // already gave us, or `new:<n>` for something staged in this session.
374
+ function hkOp(op) {
375
+ // A second edit to the same field of the same part REPLACES the first — one
376
+ // decision typed twice is one decision, and the pending list has to read as a
377
+ // list of decisions rather than a list of keystrokes.
378
+ if (op.op === "set") HK_OPS = HK_OPS.filter((o) => !(o.op === "set" && o.ref === op.ref && o.field === op.field));
379
+ if (op.op === "line") HK_OPS = HK_OPS.filter((o) => !(o.op === "line" && o.line === op.line && o.field === op.field));
380
+ if (op.op === "doc") HK_OPS = HK_OPS.filter((o) => !(o.op === "doc" && o.field === op.field));
381
+ HK_OPS.push(op);
382
+ // A REPAINT, NEVER A REFETCH. Nothing has been written, so nothing on disk
383
+ // has moved — and re-entering the router here is what made every click look
384
+ // like a save.
385
+ hkPaint();
386
+ hkStageSchedule();
387
+ }
388
+
389
+ function hkPlan(show, cat) {
390
+ const structural = new Set((cat.components || []).filter((c) => c.structural).map((c) => c.id));
391
+ const lines = (show.lines || []).map((l) => ({
392
+ line: l.line,
393
+ separator: l.separator,
394
+ align: l.align,
395
+ sep_color: l.sep_color,
396
+ prefix: l.prefix,
397
+ items: (l.items || []).map((it) => ({ ref: it.id, type: it.type, src: it, over: {}, isNew: false })),
398
+ }));
399
+ const actions = [];
400
+ const doc = { theme: show.theme, glyphs: show.glyphs };
401
+ const find = (ref) => {
402
+ for (const l of lines) {
403
+ const i = l.items.findIndex((x) => x.ref === ref);
404
+ if (i >= 0) return { l, i, it: l.items[i] };
405
+ }
406
+ return null;
407
+ };
408
+
409
+ HK_OPS.forEach((op, oi) => {
410
+ const key = "op" + (oi + 1);
411
+ if (op.op === "add") {
412
+ const l = lines[op.line - 1];
413
+ if (!l) return;
414
+ // THE POSITION IS COMPUTED HERE, not when the button was pressed. Three
415
+ // adds in a row get 1, 2 and 3 and append; three adds computed at stage
416
+ // time all got 1, and the CLI read 2 and 3 as EDITS of the first.
417
+ const pos = l.items.length + 1;
418
+ l.items.push({ ref: "new:" + oi, type: op.type, src: null, over: {}, isNew: true });
419
+ 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 }) });
420
+ } else if (op.op === "remove") {
421
+ const f = find(op.ref);
422
+ if (!f) return;
423
+ actions.push({ key, route: "/api/statusline/remove", body: bd({ at: f.l.line + ":" + (f.i + 1) }), label: t("hookui.opRemove", { id: f.it.type }) });
424
+ f.l.items.splice(f.i, 1);
425
+ } else if (op.op === "move") {
426
+ const f = find(op.ref);
427
+ if (!f) return;
428
+ const from = f.l.line + ":" + (f.i + 1);
429
+ f.l.items.splice(f.i, 1);
430
+ const dst = lines[op.toLine - 1];
431
+ if (!dst) return;
432
+ const at = Math.max(0, Math.min(op.toPos - 1, dst.items.length));
433
+ dst.items.splice(at, 0, f.it);
434
+ 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 }) });
435
+ } else if (op.op === "clone") {
436
+ const f = find(op.ref);
437
+ if (!f) return;
438
+ actions.push({ key, route: "/api/statusline/clone", body: bd({ at: f.l.line + ":" + (f.i + 1) }), label: t("hookui.opClone", { id: f.it.type }) });
439
+ 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 });
440
+ } else if (op.op === "set") {
441
+ const f = find(op.ref);
442
+ if (!f) return;
443
+ f.it.over[op.field] = op.value;
444
+ if (op.field === "type") f.it.type = op.value;
445
+ // A SETTING is one key of the `params` object; the route sends it as one
446
+ // `--param key=value`, so two settings are two ops and never collide.
447
+ const body = op.field.startsWith("param.")
448
+ ? { params: { [op.field.slice(6)]: op.value } }
449
+ : { [op.field]: op.value };
450
+ actions.push({ key, route: "/api/statusline/set", body: bd(Object.assign({ line: f.l.line, pos: f.i + 1 }, body)), label: op.label });
451
+ } else if (op.op === "line") {
452
+ // One LINE field (separator, its colour, align, prefix) per op.
453
+ const l = lines[op.line - 1];
454
+ if (!l) return;
455
+ l[op.field] = op.value;
456
+ actions.push({ key, route: "/api/statusline/line", body: bd({ line: op.line, [op.field]: op.value }), label: op.label });
457
+ } else if (op.op === "doc") {
458
+ doc[op.field] = op.value;
459
+ actions.push({ key, route: "/api/statusline/doc", body: bd({ [op.field]: op.value }), label: op.label });
460
+ }
461
+ });
462
+
463
+ for (const l of lines) {
464
+ // A spacer is not a thing the line SAYS, so it does not eat one of the
465
+ // five. Which ids those are is the CLI's answer, carried per component.
466
+ l.counted = l.items.filter((x) => !structural.has(x.type)).length;
467
+ l.full = l.counted >= cat.max_per_line;
468
+ }
469
+ return { lines, actions, doc, dirty: HK_OPS.length > 0, max: cat.max_per_line };
470
+ }
471
+
472
+ // The dense-prefix rule, applied to ONE line of the EFFECTIVE board — never the
473
+ // saved one, or a part staged onto line 1 would leave line 2 still refusing.
474
+ // The reason travels with the answer, because a disabled zone with no reason is
475
+ // a shrug.
476
+ function lineLegal(plan, n) {
477
+ if (n === 1) return { ok: true };
478
+ const above = plan.lines[n - 2];
479
+ if (above && above.items.length > 0) return { ok: true };
480
+ return { ok: false, why: t("hookui.fillFirst", { n: n - 1 }) };
481
+ }
482
+
483
+ // Why a part cannot go on this line RIGHT NOW; a null answer means it can. Both
484
+ // reasons are the user's to read BEFORE the click, never after it.
485
+ function addBlocked(plan, n) {
486
+ const legal = lineLegal(plan, n);
487
+ if (!legal.ok) return legal.why;
488
+ const line = plan.lines[n - 1];
489
+ if (!line) return t("hookui.noSuchLine");
490
+ if (line.full) return t("hookui.lineFull", { n: n, max: plan.max });
491
+ return null;
492
+ }
493
+
494
+ /* ══ the board tabs ═══════════════════════════════════════════════════════ */
495
+ // The two boards. Named by the CLI (`boards` in the catalogue), labelled by the
496
+ // panel — the id is data and the label is prose, which is the split everywhere
497
+ // else in this file too.
498
+ //
499
+ // The keys are SPELLED OUT rather than assembled from the id, for the same
500
+ // reason the tier labels are: a key built from a fragment is invisible to the
501
+ // coverage check that keeps the two string tables honest.
502
+ const HK_BOARD_LABEL = { status: "hookui.boardStatus", subagent: "hookui.boardSubagent" };
503
+ const HK_BOARD_SUB = { status: "hookui.boardStatusSub", subagent: "hookui.boardSubagentSub" };
504
+ function boardTabs(cat) {
505
+ const c = card(null);
506
+ const row = el("div", "row-actions hk-boards");
507
+ for (const b of cat.boards || ["status"]) {
508
+ const btn = el("button", "btn btn-sm" + (HK_BOARD === b ? " btn-primary" : " btn-ghost"),
509
+ t(HK_BOARD_LABEL[b] || HK_BOARD_LABEL.status));
510
+ btn.type = "button";
511
+ btn.addEventListener("click", () => {
512
+ if (HK_BOARD === b) return;
513
+ // Switching boards with ops staged would send them to a board they were
514
+ // never about: every op carries a board, but the POSITIONS in them were
515
+ // computed against this one.
516
+ if (HK_OPS.length) return toast(t("hookui.applyFirst"), "bad");
517
+ HK_BOARD = b;
518
+ hkStageClear();
519
+ HK_SEARCH = "";
520
+ HK_COMPARE_DATA = null;
521
+ hkReload();
522
+ });
523
+ row.append(btn);
524
+ }
525
+ c.append(row);
526
+ c.append(el("div", "note", t(HK_BOARD_SUB[HK_BOARD] || HK_BOARD_SUB.status)));
527
+ return c;
528
+ }
529
+
530
+ /* ══ the gate ═════════════════════════════════════════════════════════════ */
531
+ // INVERTED from the Extra panel's. There, nothing exists until you connect;
532
+ // here the board is LIVE WHILE OFF, because you must be able to compose before
533
+ // you arm. What is gated is the switch, not the work.
534
+ function gateCard(show, cat) {
535
+ const c = card(t("hookui.gate"));
536
+ const row = el("div", "row-actions");
537
+ row.append(chip(show.enabled ? t("hookui.on") : t("hookui.off"), show.enabled ? "ok" : null));
538
+ if (show.saved) row.append(chip(t("hookui.saved"), null));
539
+ // ON IS NOT "SHOWING". The installed hook can be a different ORC, or it
540
+ // fell back to the built-in lines; the CLI says which, and the fix.
541
+ const h = show.hook;
542
+ if (show.enabled && h && (!h.match || h.fallback)) {
543
+ row.append(chip(t("hookui.notInTerminal"), "bad"));
544
+ c.append(row);
545
+ let s = t("hookui.hookSkew", { version: h.version || "?", cli: h.cli_version || "?" });
546
+ if (h.fallback) s += " " + t("hookui.hookFallback", { finding: h.fallback.finding || "" });
547
+ c.append(el("div", "note note-warn", s));
548
+ if (h.fix) {
549
+ // Same row as the global-install banner: command, then Copy.
550
+ const fx = el("div", "banner-fix");
551
+ fx.append(el("code", "action-cmd", h.fix));
552
+ const cp = el("button", "btn btn-ghost btn-sm", t("common.copy"));
553
+ cp.type = "button";
554
+ cp.addEventListener("click", () => copy(h.fix, t("common.copied")));
555
+ fx.append(cp);
556
+ c.append(fx);
557
+ }
558
+ } else {
559
+ c.append(row);
560
+ c.append(el("div", "note", show.enabled ? t("hookui.gateOnNote") : t("hookui.gateOffNote")));
561
+ }
562
+
563
+ const b = el("button", "btn btn-sm" + (show.enabled ? " btn-ghost" : " btn-primary"),
564
+ show.enabled ? t("hookui.turnOff") : t("hookui.turnOn"));
565
+ b.type = "button";
566
+ // ENABLED WITH NOTHING SAVED IS NOT A STATE. The switch cannot reach `on`
567
+ // past a layout that does not validate, and the refusal names the violation
568
+ // rather than saying "invalid".
569
+ if (!show.enabled && !show.ok) {
570
+ b.disabled = true;
571
+ b.title = show.errors[0] || "";
572
+ c.append(el("div", "note note-warn", show.errors[0] || ""));
573
+ }
574
+ b.addEventListener("click", async () => {
575
+ // The KEY IS THE CLI'S. Naming it here would be a second idea of which
576
+ // switch belongs to which board.
577
+ const r = await post("/api/config/set", { key: cat.config_key, value: show.enabled ? "off" : "on" });
578
+ if (!r.ok) toast(t("hookui.switchFailed"), "bad", (r.output || "").trim());
579
+ hkReload();
580
+ });
581
+ const acts = el("div", "row-actions");
582
+ acts.append(b);
583
+ c.append(acts);
584
+ return c;
585
+ }
586
+
587
+ /* ══ cautions ═════════════════════════════════════════════════════════════ */
588
+ // An error is a refusal; a warning is a fact the user then owns. They are drawn
589
+ // differently and they are never merged into one count.
590
+ function cautionCard(show) {
591
+ const c = card(t("hookui.cautions"));
592
+ for (const e of show.errors) {
593
+ const row = el("div", "hk-caution hk-caution-bad");
594
+ row.append(el("span", "hk-caution-mark", "✕"));
595
+ row.append(el("span", null, e));
596
+ c.append(row);
597
+ }
598
+ for (const w of show.warnings) {
599
+ const row = el("div", "hk-caution");
600
+ row.append(el("span", "hk-caution-mark", "⚠"));
601
+ row.append(el("span", null, w));
602
+ c.append(row);
603
+ }
604
+ return c;
605
+ }
606
+
607
+ /* == the preview ==========================================================
608
+ Pinned above the board and never scrolled away. `orc statusline preview
609
+ --json`'s output, rendered as DOM and never as HTML.
610
+
611
+ v1.4.2 - IT IS DRAWN AS A TERMINAL, because that is the claim it is making.
612
+ A status line in a bare grey box is a string; the same string inside a
613
+ window of the stated width, on a terminal ground, with a ruler that shows
614
+ where column 80 falls, is a picture of the thing. Nothing about the bytes
615
+ changed - the engine is still the hook's own - what changed is that the
616
+ reader can now believe it.
617
+
618
+ IT DRAWS THE SAVED LAYOUT, and while anything is staged it SAYS SO. The
619
+ preview renders through the hook's own engine, which reads the file - so a
620
+ preview of unsaved edits would need a second engine, which is the one thing
621
+ this design refuses. "These changes are not in the picture yet" is the honest
622
+ version, and it is also the answer to "I pressed Apply and nothing happened":
623
+ something did, and this is where it shows up.
624
+
625
+ THE HEIGHT IS THE OTHER HALF. Three strippings, four colour sets and seven
626
+ symbol sets is a wall of terminal, and it pushed the board - the thing the
627
+ user is actually working in - off the screen. All of it lives in ONE drawer
628
+ now, closed by default, and its rows are FETCHED ONLY WHEN IT IS OPENED:
629
+ eleven extra renders nobody asked to see is eleven subprocesses nobody asked
630
+ to pay for. */
631
+ function previewCard(prev, plan, cat) {
632
+ const right = el("div", "row-actions");
633
+ for (const w of HK_WIDTHS) {
634
+ const b = el("button", "btn btn-xs" + (HK_WIDTH === w ? " btn-primary" : " btn-ghost"), tn(w, "hookui.cols"));
635
+ b.type = "button";
636
+ b.addEventListener("click", () => {
637
+ HK_WIDTH = w;
638
+ HK_COMPARE_DATA = null;
639
+ hkPreviewReload();
640
+ });
641
+ right.append(b);
642
+ }
643
+ const c = card(t("hookui.preview"), right);
644
+ c.append(el("div", "note", t("hookui.previewAbout")));
645
+ // v2.0.4 — THE PICTURE INCLUDES THE STAGED CHANGES once the server has
646
+ // replayed them into a temp copy, and it SAYS SO. A change the CLI refused is
647
+ // named here, with the CLI's own text, before anybody presses Apply.
648
+ const staged = plan.dirty && HK_STAGED && HK_STAGED.preview && HK_STAGED.preview.ok ? HK_STAGED.preview : null;
649
+ if (plan.dirty && HK_STAGED && HK_STAGED.failed) {
650
+ c.append(el("div", "note note-warn", t("hookui.stageFailed", { label: HK_STAGED.failed.label || "?" })));
651
+ if (HK_STAGED.failed.output) c.append(el("pre", "hk-stage-out", HK_STAGED.failed.output));
652
+ } else if (staged) {
653
+ c.append(el("div", "note hk-staged-tag", tn(plan.actions.length, "hookui.previewStagedN")));
654
+ } else if (plan.dirty) {
655
+ // THE STAGED BANNER IS AN INSTRUCTION, not a disclaimer. It says what is
656
+ // missing from the picture AND what to press to put it there.
657
+ c.append(el("div", "note note-warn", tn(plan.actions.length, "hookui.previewStaleN")));
658
+ }
659
+ if (!prev || !prev.ok) {
660
+ c.append(el("div", "note", t("hookui.previewUnavailable")));
661
+ return c;
662
+ }
663
+
664
+ // The fixture picker. `--fixtures` must carry one of every state INCLUDING
665
+ // the ugly ones - you cannot design a degraded line on a healthy session.
666
+ c.append(el("div", "note", t("hookui.fixturesAbout")));
667
+ const states = el("div", "row-actions hk-fixtures");
668
+ for (const [k, label] of Object.entries(prev.fixtures || {})) {
669
+ const b = el("button", "btn btn-xs" + (HK_STATE === k ? " btn-primary" : " btn-ghost"), label);
670
+ b.type = "button";
671
+ b.addEventListener("click", () => {
672
+ HK_STATE = k;
673
+ HK_COMPARE_DATA = null;
674
+ hkPreviewReload();
675
+ });
676
+ states.append(b);
677
+ }
678
+ c.append(states);
679
+
680
+ const shown = staged || prev;
681
+ const win = termWindow(shown.text, {
682
+ title: tn(HK_WIDTH, "hookui.termTitle"),
683
+ // The colour set and the symbol set the picture was drawn with are the
684
+ // CLI's answer, carried on the preview itself. Naming them here would be a
685
+ // second idea of what is in force.
686
+ tags: [shown.theme, shown.glyphs].filter(Boolean).concat(staged ? [t("hookui.stagedTag")] : []),
687
+ ruler: true,
688
+ });
689
+ c.append(win);
690
+ // v2.0.4 — the moving parts move here too, for a short while after a paint.
691
+ if (hkAnimated() && Array.isArray(shown.frames)) hkAnimStart(win.querySelector(".hk-preview"), shown.frames);
692
+
693
+ // WHAT EACH LINE COSTS IN CELLS, under the window it belongs to. A number in
694
+ // a row of chips a long way from the picture is a number nobody reads.
695
+ const meta = el("div", "row-actions hk-widths");
696
+ (shown.static_width || []).forEach((w, i) => {
697
+ if (!w) return;
698
+ meta.append(chip(tn(i + 1, "hookui.lineN") + " " + tn(w, "hookui.cells"), null));
699
+ });
700
+ if (meta.childNodes.length) c.append(meta);
701
+
702
+ c.append(compareDrawer(prev, cat));
703
+ return c;
704
+ }
705
+
706
+ // A TERMINAL, drawn as one. The chrome is not decoration: the title bar states
707
+ // the width the picture was rendered at, and the ruler shows where that width
708
+ // falls - which is the whole question a status line asks.
709
+ function termWindow(text, { title, tags, ruler, small }) {
710
+ const box = el("div", "hk-term" + (small ? " hk-term-sm" : ""));
711
+ const bar = el("div", "hk-term-bar");
712
+ const dots = el("div", "hk-term-dots");
713
+ for (let i = 0; i < 3; i++) dots.append(el("span", "hk-term-dot"));
714
+ bar.append(dots);
715
+ if (title) bar.append(el("span", "hk-term-title", title));
716
+ const tagRow = el("div", "hk-term-tags");
717
+ for (const g of tags || []) tagRow.append(el("span", "hk-term-tag", g));
718
+ bar.append(tagRow);
719
+ box.append(bar);
720
+
721
+ const screen = el("div", "hk-term-screen");
722
+ if (ruler) screen.append(rulerRow(HK_WIDTH));
723
+ screen.append(ansiBlock(text, "hk-preview hk-preview-flat"));
724
+ box.append(screen);
725
+ return box;
726
+ }
727
+
728
+ // The column ruler. Ten-cell ticks with the count at each, in the SAME
729
+ // monospaced grid as the line under it - so "this is 44 cells" stops being a
730
+ // number and becomes a place on the picture.
731
+ function rulerRow(cols) {
732
+ const row = el("div", "hk-ruler");
733
+ let out = "";
734
+ for (let i = 0; i < cols; i++) {
735
+ if (i % 10 === 0) {
736
+ const n = String(i);
737
+ out += n;
738
+ i += n.length - 1;
739
+ } else out += i % 5 === 0 ? "+" : "\u00b7";
740
+ }
741
+ row.textContent = out.slice(0, cols);
742
+ return row;
743
+ }
744
+
745
+ /* == the comparison drawer ================================================
746
+ Three strippings, every colour set and every symbol set - the same layout,
747
+ the same fixture, the same width, through the same engine. A colour set
748
+ picked from its NAME is a guess; picked from its picture it is a decision.
749
+
750
+ It is CLOSED by default and its rows are fetched on the first open, because
751
+ each one is a real render. */
752
+ function compareDrawer(prev, cat) {
753
+ const wrap = el("div", "hk-compare");
754
+ const head = el("button", "hk-compare-head");
755
+ head.type = "button";
756
+ head.setAttribute("aria-expanded", String(HK_COMPARE));
757
+ head.append(el("span", "tier-caret", "\u25b8"));
758
+ head.append(el("span", "hk-compare-title", t("hookui.compare")));
759
+ head.append(el("span", "note", t("hookui.compareAbout")));
760
+ // NOT named `body`: that name is reserved for a panel's own card container,
761
+ // which must carry `stack`. This is a drawer's contents and spaces itself.
762
+ const drawer = el("div", "hk-compare-body");
763
+ wrap.classList.toggle("hk-compare-open", HK_COMPARE);
764
+ head.addEventListener("click", () => {
765
+ HK_COMPARE = !HK_COMPARE;
766
+ wrap.classList.toggle("hk-compare-open", HK_COMPARE);
767
+ head.setAttribute("aria-expanded", String(HK_COMPARE));
768
+ if (HK_COMPARE && !HK_COMPARE_DATA) loadCompare(drawer, cat, prev);
769
+ });
770
+ wrap.append(head, drawer);
771
+ if (HK_COMPARE) {
772
+ if (HK_COMPARE_DATA) paintCompare(drawer, prev);
773
+ else loadCompare(drawer, cat, prev);
774
+ }
775
+ return wrap;
776
+ }
777
+
778
+ async function loadCompare(body, cat, prev) {
779
+ body.replaceChildren(skeleton(3));
780
+ const themes = Object.keys(cat.themes || {});
781
+ const glyphs = cat.glyph_sets || [];
782
+ const one = (extra) => read(hkPreviewUrl() + extra).then((r) => r.data).catch(() => null);
783
+ try {
784
+ const [themeRows, glyphRows] = await Promise.all([
785
+ Promise.all(themes.map((n) => one("&theme=" + encodeURIComponent(n)).then((d) => [n, d]))),
786
+ Promise.all(glyphs.map((n) => one("&glyphs=" + encodeURIComponent(n)).then((d) => [n, d]))),
787
+ ]);
788
+ HK_COMPARE_DATA = { themes: themeRows, glyphs: glyphRows };
789
+ } catch (_) {
790
+ HK_COMPARE_DATA = { themes: [], glyphs: [] };
791
+ }
792
+ paintCompare(body, prev);
793
+ }
794
+
795
+ function paintCompare(body, prev) {
796
+ body.replaceChildren();
797
+ // THE THREE STRIPPINGS FIRST. You cannot design an ASCII fallback you cannot
798
+ // see, and a design whose only distinction is colour becomes ambiguous the
799
+ // moment NO_COLOR is set.
800
+ body.append(el("div", "hk-compare-group", t("hookui.degrades")));
801
+ for (const [key, label] of [
802
+ ["no_color", t("hookui.degNoColor")],
803
+ ["ascii", t("hookui.degAscii")],
804
+ ["no_motion", t("hookui.degNoMotion")],
805
+ ]) {
806
+ body.append(compareRow(label, (prev.strippings || {})[key]));
807
+ }
808
+ const d = HK_COMPARE_DATA || { themes: [], glyphs: [] };
809
+ if (d.themes.length) {
810
+ body.append(el("div", "hk-compare-group", t("hookui.compareThemes")));
811
+ // The NAME is the CLI's word, printed verbatim - a translated colour set is
812
+ // a colour set that does not exist.
813
+ for (const [name, row] of d.themes) body.append(compareRow(name, row && row.ok ? row.text : null));
814
+ }
815
+ if (d.glyphs.length) {
816
+ body.append(el("div", "hk-compare-group", t("hookui.compareGlyphs")));
817
+ for (const [name, row] of d.glyphs) body.append(compareRow(name, row && row.ok ? row.text : null));
818
+ }
819
+ }
820
+
821
+ function compareRow(label, text) {
822
+ const row = el("div", "hk-compare-row");
823
+ row.append(el("div", "hk-compare-label", label));
824
+ if (text == null) row.append(el("div", "note", t("hookui.previewUnavailable")));
825
+ else row.append(termWindow(text, { title: label, tags: [], ruler: false, small: true }));
826
+ return row;
827
+ }
828
+
829
+ // ANSI → DOM. Never innerHTML: this string comes from the CLI, and the rule for
830
+ // every other rendered artefact in this panel holds here too.
831
+ function ansiBlock(text, cls) {
832
+ const pre = el("pre", cls);
833
+ for (const line of String(text || "").split("\n")) {
834
+ const row = el("div", "hk-line");
835
+ let style = null;
836
+ // A minimal SGR reader: colour, background and the emphasis attributes the
837
+ // compiler can emit. Anything it does not recognise is DROPPED rather than
838
+ // printed, because a stray escape in the middle of a preview is worse than
839
+ // a missing colour.
840
+ const re = /\[([0-9;]*)m/g;
841
+ let last = 0;
842
+ let m;
843
+ const push = (s) => {
844
+ if (!s) return;
845
+ const span = el("span", null, s);
846
+ // THROUGH CSSOM, NEVER A `style` ATTRIBUTE. This page is served under
847
+ // `style-src 'self'`, which blocks a parsed style attribute outright —
848
+ // so every colour this preview drew was thrown away by the browser and
849
+ // the whole thing rendered grey, with the reason only in a console
850
+ // nobody had open. Assigning the property is not a parse and is not
851
+ // blocked, and weakening the policy to `unsafe-inline` for a preview is
852
+ // not a trade worth making.
853
+ if (style) for (const k of Object.keys(style)) span.style[k] = style[k];
854
+ row.append(span);
855
+ };
856
+ while ((m = re.exec(line))) {
857
+ push(line.slice(last, m.index));
858
+ style = sgrStyle(m[1], style);
859
+ last = m.index + m[0].length;
860
+ }
861
+ push(line.slice(last));
862
+ if (!row.childNodes.length) row.append(document.createTextNode(" "));
863
+ pre.append(row);
864
+ }
865
+ return pre;
866
+ }
867
+
868
+ // The 16 ANSI slots, as the browser's own colours. These are NOT ORC's colour
869
+ // tokens — they are what a terminal would paint, approximated, because the
870
+ // panel cannot know the user's terminal palette and says so.
871
+ const HK_ANSI = [
872
+ "#000000", "#cc0000", "#4e9a06", "#c4a000", "#3465a4", "#75507b", "#06989a", "#d3d7cf",
873
+ ];
874
+ const HK_ANSI_BRIGHT = [
875
+ "#555753", "#ef2929", "#8ae234", "#fce94f", "#729fcf", "#ad7fa8", "#34e2e2", "#eeeeec",
876
+ ];
877
+
878
+ // An OBJECT of CSS properties rather than a declaration string, because the
879
+ // caller assigns them one at a time through CSSOM — see `push` above.
880
+ function sgrStyle(codes, prev) {
881
+ const parts = String(codes || "0").split(";").filter((x) => x !== "");
882
+ if (!parts.length || parts[0] === "0") return null;
883
+ const out = Object.assign({}, prev || {});
884
+ for (let i = 0; i < parts.length; i++) {
885
+ const n = Number(parts[i]);
886
+ if (n === 1) out.fontWeight = "700";
887
+ else if (n === 2) out.opacity = ".6";
888
+ else if (n === 3) out.fontStyle = "italic";
889
+ else if (n === 4) out.textDecoration = "underline";
890
+ else if (n === 9) out.textDecoration = "line-through";
891
+ else if (n === 7) out.filter = "invert(1)";
892
+ else if (n >= 30 && n <= 37) out.color = HK_ANSI[n - 30];
893
+ else if (n >= 90 && n <= 97) out.color = HK_ANSI_BRIGHT[n - 90];
894
+ else if (n >= 40 && n <= 47) out.background = HK_ANSI[n - 40];
895
+ else if (n === 39) out.color = "inherit";
896
+ else if (n === 38 && parts[i + 1] === "2") {
897
+ out.color = "rgb(" + parts[i + 2] + "," + parts[i + 3] + "," + parts[i + 4] + ")";
898
+ i += 4;
899
+ } else if (n === 48 && parts[i + 1] === "2") {
900
+ out.background = "rgb(" + parts[i + 2] + "," + parts[i + 3] + "," + parts[i + 4] + ")";
901
+ i += 4;
902
+ }
903
+ }
904
+ return out;
905
+ }
906
+
907
+ /* ══ the board ════════════════════════════════════════════════════════════ */
908
+ // THE ILLEGAL DROP IS MADE IMPOSSIBLE rather than allowed and then complained
909
+ // about: an illegal zone renders disabled with its reason on the zone itself. A
910
+ // user should never be able to do the wrong thing and then be told off for it.
911
+ //
912
+ // The CLI still validates. The board is a convenience; it is never the
913
+ // guarantee.
914
+ function boardCard(cat, plan) {
915
+ const c = card(t("hookui.board"));
916
+ c.append(el("div", "note", t("hookui.boardAbout")));
917
+ c.append(el("div", "note", cat.dense_prefix));
918
+
919
+ for (const line of plan.lines) {
920
+ const legal = lineLegal(plan, line.line);
921
+ const wrap = el("div", "hk-line-wrap" + (legal.ok ? "" : " hk-line-blocked"));
922
+
923
+ const h = el("div", "hk-line-head");
924
+ const title = el("div", "hk-line-title");
925
+ title.append(el("span", "hk-line-name", tn(line.line, "hookui.lineN")));
926
+ title.append(el("span", "hk-line-count" + (line.full ? " hk-full" : ""), tn(line.counted, "hookui.slotsUsed", { max: plan.max })));
927
+ h.append(title);
928
+ h.append(separatorPicker(cat, line));
929
+ wrap.append(h);
930
+
931
+ const zone = el("div", "hk-zone");
932
+ zone.setAttribute("role", "list");
933
+ for (const item of line.items) zone.append(chipEl(item, line, plan, cat));
934
+
935
+ if (!legal.ok) {
936
+ const blocked = el("div", "hk-blocked");
937
+ blocked.append(el("span", "hk-caution-mark", "✕"));
938
+ blocked.append(el("span", null, legal.why));
939
+ zone.append(blocked);
940
+ }
941
+ // THE ADD BUTTON ALWAYS RENDERS, and carries its own refusal. A control
942
+ // that vanishes when it cannot be used teaches nobody why — and the two
943
+ // refusals are DIFFERENT facts (nothing above it yet · this line is full),
944
+ // so they are two sentences and never one "invalid".
945
+ const b = el("button", "hk-add", t("hookui.add"));
946
+ b.type = "button";
947
+ if (!legal.ok) {
948
+ b.disabled = true;
949
+ b.title = legal.why;
950
+ } else if (line.full) {
951
+ b.disabled = true;
952
+ b.title = t("hookui.lineFull", { n: line.line, max: plan.max });
953
+ } else {
954
+ b.addEventListener("click", () => partPicker(cat, plan, line.line, null));
955
+ }
956
+ zone.append(b);
957
+ if (b.disabled && legal.ok) zone.append(el("div", "note note-warn hk-zone-why", b.title));
958
+ dropTarget(zone, line, plan);
959
+ wrap.append(zone);
960
+ c.append(wrap);
961
+ }
962
+ c.append(el("div", "note", t("hookui.dragHint")));
963
+ return c;
964
+ }
965
+
966
+ // THE SEPARATOR IS A DROPDOWN, and its options are the CLI's. A free-text box
967
+ // asked a person to invent a rhythm and then to type a character that may not
968
+ // be on their keyboard — so the text box is the LAST entry, "Custom…", and it
969
+ // opens only when asked for. A value the layout already carries that is not in
970
+ // the set opens it, so the state stays visible.
971
+ //
972
+ // v2.0.4 — the line head also carries the separator's colour, the line's
973
+ // alignment and its prefix. Every option list is the CLI's; each is one `line`
974
+ // op, so two line fields never overwrite each other in the pending list.
975
+ const HK_SEP_CUSTOM = "\u0000custom";
976
+ function separatorPicker(cat, line) {
977
+ const box = el("div", "hk-line-opts-wrap");
978
+ const wrap = el("label", "hk-line-opts");
979
+ wrap.append(el("span", "note", t("hookui.separator")));
980
+ const sel = el("select", "hk-select");
981
+ const known = cat.separators || [];
982
+ const lineOp = (field, value, label) => hkOp({ op: "line", line: line.line, field, value, label });
983
+ const custom = HK_SEP_OPEN.has(line.line) || !known.some((s) => s.value === line.separator);
984
+ for (const s of known) {
985
+ // The NAME is the CLI's word; the sample is the literal string, shown
986
+ // between two marks so a run of spaces is visible at all.
987
+ const o = el("option", null, s.name + " ‹" + s.value + "›");
988
+ o.value = s.value;
989
+ if (!custom && s.value === line.separator) o.selected = true;
990
+ sel.append(o);
991
+ }
992
+ const co = el("option", null, t("hookui.sepCustomPick"));
993
+ co.value = HK_SEP_CUSTOM;
994
+ if (custom) co.selected = true;
995
+ sel.append(co);
996
+ sel.addEventListener("change", () => {
997
+ if (sel.value === HK_SEP_CUSTOM) {
998
+ HK_SEP_OPEN.add(line.line);
999
+ return hkPaint();
1000
+ }
1001
+ HK_SEP_OPEN.delete(line.line);
1002
+ const hit = known.find((s) => s.value === sel.value);
1003
+ lineOp("separator", sel.value, t("hookui.opSep", { n: line.line, s: hit ? hit.name : sel.value }));
1004
+ });
1005
+ wrap.append(sel);
1006
+ box.append(wrap);
1007
+ if (custom) {
1008
+ const txt = textField(line.separator, (v) => lineOp("separator", v, t("hookui.opSep", { n: line.line, s: JSON.stringify(v) })), true);
1009
+ txt.classList.add("hk-short");
1010
+ txt.setAttribute("aria-label", t("hookui.sepCustomBox"));
1011
+ box.append(txt);
1012
+ }
1013
+
1014
+ // The separator's colour: "shared" (the line's own), a named slot, or a hex
1015
+ // value the layout already carries, which keeps its own entry.
1016
+ const colour = el("label", "hk-line-opts");
1017
+ colour.append(el("span", "note", t("hookui.sepColour")));
1018
+ const cs = el("select", "hk-select");
1019
+ const sharedOpt = el("option", null, t("hookui.shared"));
1020
+ sharedOpt.value = "";
1021
+ cs.append(sharedOpt);
1022
+ const slots = (cat.colors || []).map(String);
1023
+ if (line.sep_color && !slots.includes(String(line.sep_color))) slots.push(String(line.sep_color));
1024
+ for (const c of slots) {
1025
+ const o = el("option", null, c);
1026
+ o.value = c;
1027
+ if (String(line.sep_color) === c) o.selected = true;
1028
+ cs.append(o);
1029
+ }
1030
+ cs.addEventListener("change", () => lineOp("sep_color", cs.value, t("hookui.opSepColour", { n: line.line, v: cs.value || t("hookui.shared") })));
1031
+ colour.append(cs);
1032
+ box.append(colour);
1033
+
1034
+ if ((cat.line_aligns || []).length) {
1035
+ const al = el("div", "hk-line-opts");
1036
+ al.append(el("span", "note", t("hookui.lineAlign")));
1037
+ al.append(pickRow(cat.line_aligns, line.align || null,
1038
+ (v) => lineOp("align", v, t("hookui.opLineAlign", { n: line.line, v: v || t("hookui.shared") })),
1039
+ { label: t("hookui.shared"), value: "", on: !line.align }));
1040
+ box.append(al);
1041
+ }
1042
+
1043
+ const pre = el("label", "hk-line-opts");
1044
+ pre.append(el("span", "note", t("hookui.linePrefix")));
1045
+ const pt = textField(line.prefix, (v) => lineOp("prefix", v, t("hookui.opLinePrefix", { n: line.line, v })), true);
1046
+ pt.classList.add("hk-short");
1047
+ pre.append(pt);
1048
+ box.append(pre);
1049
+ return box;
1050
+ }
1051
+
1052
+ /* ══ a chip ═══════════════════════════════════════════════════════════════ */
1053
+ // A chip shows ITS OWN RENDERED OUTPUT, not its component id — that is what the
1054
+ // user is arranging — with the id and a one-line description under it, because
1055
+ // "what is this thing" is the first question anybody asks of a bar full of
1056
+ // symbols, and the answer used to live only in a tooltip.
1057
+ function chipEl(item, line, plan, cat) {
1058
+ const comp = (cat.components || []).find((x) => x.id === item.type);
1059
+ const wrap = el("div", "hk-chip" + (item.isNew ? " hk-chip-new" : ""));
1060
+ wrap.setAttribute("role", "listitem");
1061
+ wrap.setAttribute("tabindex", "0");
1062
+ wrap.draggable = hkCanDrag();
1063
+ wrap.title = comp && comp.summary ? comp.summary : item.type;
1064
+
1065
+ const face = el("div", "hk-chip-face");
1066
+ face.append(el("span", "hk-grip", "∷"));
1067
+ face.append(ansiBlock(chipSample(item, comp, line), "hk-preview hk-preview-chip"));
1068
+ wrap.append(face);
1069
+
1070
+ wrap.append(el("div", "hk-chip-id", item.type));
1071
+ if (comp && comp.summary) wrap.append(el("div", "hk-chip-desc", comp.summary));
1072
+
1073
+ // EDIT · MOVE · REMOVE, on the chip. Each opens a modal that says what it is
1074
+ // about to do. The drag is the shortcut, never the mechanism: pointer drag is
1075
+ // unusable for a real fraction of people, and it is switched off entirely
1076
+ // below the width at which a drop gap stops being a target.
1077
+ const acts = el("div", "hk-chip-acts");
1078
+ const mk = (label, cls, fn) => {
1079
+ const b = el("button", "hk-act " + cls, label);
1080
+ b.type = "button";
1081
+ b.addEventListener("click", fn);
1082
+ acts.append(b);
1083
+ };
1084
+ mk(t("hookui.edit"), "hk-act-edit", () => editModal(item, line, comp, cat));
1085
+ mk(t("hookui.move"), "hk-act-move", () => moveModal(item, line, plan));
1086
+ mk(t("hookui.remove"), "hk-act-remove", () => removeModal(item, comp));
1087
+ wrap.append(acts);
1088
+
1089
+ // Keyboard: the settled pattern, and every drag has an equivalent here. A
1090
+ // board only reachable by mouse is a board a lot of people cannot use.
1091
+ wrap.addEventListener("keydown", (ev) => {
1092
+ const pos = line.items.indexOf(item) + 1;
1093
+ if (ev.key === "Delete" || ev.key === "Backspace") {
1094
+ ev.preventDefault();
1095
+ removeModal(item, comp);
1096
+ } else if (ev.key === "e" || ev.key === "E") {
1097
+ ev.preventDefault();
1098
+ editModal(item, line, comp, cat);
1099
+ } else if (ev.key === "ArrowLeft" && pos > 1) {
1100
+ ev.preventDefault();
1101
+ moveTo(item, line.line, pos - 1);
1102
+ } else if (ev.key === "ArrowRight" && pos < line.items.length) {
1103
+ ev.preventDefault();
1104
+ moveTo(item, line.line, pos + 1);
1105
+ } else if (ev.key === "ArrowUp" || ev.key === "ArrowDown") {
1106
+ const target = line.line + (ev.key === "ArrowUp" ? -1 : 1);
1107
+ if (target < 1 || target > plan.lines.length) return;
1108
+ ev.preventDefault();
1109
+ const why = addBlocked(plan, target);
1110
+ // A line that cannot take it is SKIPPED and the live region says why —
1111
+ // never a silent no-op.
1112
+ if (why) return announce(why);
1113
+ moveTo(item, target, 1);
1114
+ }
1115
+ });
1116
+
1117
+ wrap.addEventListener("dragstart", (ev) => {
1118
+ HK_DRAG = { ref: item.ref, from: line.line };
1119
+ wrap.classList.add("hk-chip-dragging");
1120
+ try {
1121
+ ev.dataTransfer.effectAllowed = "move";
1122
+ ev.dataTransfer.setData("text/plain", item.ref);
1123
+ } catch (_) {}
1124
+ });
1125
+ wrap.addEventListener("dragend", () => {
1126
+ HK_DRAG = null;
1127
+ wrap.classList.remove("hk-chip-dragging");
1128
+ for (const n of document.querySelectorAll(".hk-drop-before, .hk-drop-after"))
1129
+ n.classList.remove("hk-drop-before", "hk-drop-after");
1130
+ });
1131
+ wrap.addEventListener("dragover", (ev) => {
1132
+ if (!HK_DRAG || HK_DRAG.ref === item.ref) return;
1133
+ ev.preventDefault();
1134
+ const r = wrap.getBoundingClientRect();
1135
+ const before = ev.clientX < r.left + r.width / 2;
1136
+ wrap.classList.toggle("hk-drop-before", before);
1137
+ wrap.classList.toggle("hk-drop-after", !before);
1138
+ });
1139
+ wrap.addEventListener("dragleave", () => wrap.classList.remove("hk-drop-before", "hk-drop-after"));
1140
+ wrap.addEventListener("drop", (ev) => {
1141
+ if (!HK_DRAG || HK_DRAG.ref === item.ref) return;
1142
+ ev.preventDefault();
1143
+ ev.stopPropagation();
1144
+ const before = wrap.classList.contains("hk-drop-before");
1145
+ const pos = line.items.indexOf(item) + 1;
1146
+ dropOnto(line, plan, before ? pos : pos + 1);
1147
+ });
1148
+ return wrap;
1149
+ }
1150
+
1151
+ // What this chip will look like in the bar. A part staged in this session has
1152
+ // no rendered sample from the CLI yet, so it borrows its component's default —
1153
+ // which is exactly what it will render as until somebody changes its shape.
1154
+ function chipSample(item, comp, line) {
1155
+ // v2.0.4 — the staged preview's own render of THIS place, when it has one:
1156
+ // the real engine's answer beats the per-shape sample below.
1157
+ const st = stagedItem(item, line);
1158
+ if (st && st.sample) return st.sample;
1159
+ // Nothing staged: the CLI's own render of THIS item with its saved design.
1160
+ if (Object.keys(item.over).length === 0 && item.src && item.src.sample) return item.src.sample;
1161
+ if (!comp) return item.type;
1162
+ const render = item.over.render || (item.src ? item.src.render : (comp.defaults && comp.defaults.render) || comp.renderers[0]);
1163
+ const s = comp.previews ? comp.previews[render] : null;
1164
+ return s || item.type;
1165
+ }
1166
+
1167
+ // A zone is a drop target across its WHOLE area, so a drop past the last chip
1168
+ // appends instead of doing nothing.
1169
+ function dropTarget(zone, line, plan) {
1170
+ zone.addEventListener("dragover", (ev) => {
1171
+ if (!HK_DRAG) return;
1172
+ if (HK_DRAG.from !== line.line && addBlocked(plan, line.line)) return;
1173
+ ev.preventDefault();
1174
+ zone.classList.add("hk-zone-hot");
1175
+ });
1176
+ zone.addEventListener("dragleave", () => zone.classList.remove("hk-zone-hot"));
1177
+ zone.addEventListener("drop", (ev) => {
1178
+ zone.classList.remove("hk-zone-hot");
1179
+ if (!HK_DRAG) return;
1180
+ ev.preventDefault();
1181
+ dropOnto(line, plan, line.items.length + 1);
1182
+ });
1183
+ }
1184
+
1185
+ function dropOnto(line, plan, pos) {
1186
+ if (!HK_DRAG) return;
1187
+ const all = plan.lines.reduce((acc, l) => acc.concat(l.items), []);
1188
+ const moving = all.find((x) => x.ref === HK_DRAG.ref);
1189
+ const from = HK_DRAG.from;
1190
+ HK_DRAG = null;
1191
+ if (!moving) return;
1192
+ // Reordering WITHIN a line never changes how many parts it holds, so the
1193
+ // five-slot cap and the dense-prefix rule only apply to a move that crosses
1194
+ // lines.
1195
+ if (from !== line.line) {
1196
+ const why = addBlocked(plan, line.line);
1197
+ if (why) return toast(why, "bad");
1198
+ }
1199
+ moveTo(moving, line.line, pos);
1200
+ }
1201
+
1202
+ function moveTo(item, toLine, toPos) {
1203
+ hkOp({ op: "move", ref: item.ref, toLine, toPos });
1204
+ announce(t("hookui.opMove", { id: item.type, n: toLine }));
1205
+ }
1206
+
1207
+ // `aria-grabbed` is deprecated and is not used. State is announced through a
1208
+ // polite live region instead — VoiceOver does not reliably read a dynamically
1209
+ // updated description, so the live region is the mechanism, not a fallback.
1210
+ function announce(msg) {
1211
+ let region = document.getElementById("hk-live");
1212
+ if (!region) {
1213
+ region = el("div", "sr-only");
1214
+ region.id = "hk-live";
1215
+ region.setAttribute("role", "status");
1216
+ region.setAttribute("aria-live", "polite");
1217
+ document.body.append(region);
1218
+ }
1219
+ region.textContent = msg;
1220
+ }
1221
+
1222
+ /* ══ the part picker ══════════════════════════════════════════════════════ */
1223
+ // ONE MODAL, one search box, one click. It is the only place a part is ever
1224
+ // chosen — for a new slot and for swapping an existing one — so there is
1225
+ // exactly one answer to "how do I put something on the bar".
1226
+ //
1227
+ // `replace` names the chip whose part is being swapped; absent, this is an add.
1228
+ function partPicker(cat, plan, line, replace) {
1229
+ HK_MODAL = null;
1230
+ let q = "";
1231
+ // NOT named `body`: that name is reserved for a panel's own card container,
1232
+ // which must carry `stack`. This is a modal's contents and spaces itself.
1233
+ const pane = el("div", "hk-picker");
1234
+
1235
+ const searchRow = el("div", "hk-picker-search");
1236
+ const search = el("input", "hk-input hk-picker-input");
1237
+ search.type = "search";
1238
+ search.placeholder = t("hookui.searchPlaceholder");
1239
+ const count = el("span", "note");
1240
+ searchRow.append(search, count);
1241
+ pane.append(searchRow);
1242
+ pane.append(el("div", "note", replace ? t("hookui.pickerReplaceAbout") : tn(line, "hookui.pickerAbout")));
1243
+
1244
+ const list = el("div", "hk-picker-list");
1245
+ pane.append(list);
1246
+
1247
+ const closer = () => close();
1248
+ const paint = () => {
1249
+ list.replaceChildren();
1250
+ const rows = (cat.components || []).filter((c) => (q ? matches(c, q) : true));
1251
+ count.textContent = tn(rows.length, "hookui.matches");
1252
+ if (!rows.length) {
1253
+ list.append(empty(t("hookui.noMatch")));
1254
+ return;
1255
+ }
1256
+ for (const comp of rows) list.append(pickerRow(comp, line, replace, closer));
1257
+ };
1258
+ search.addEventListener("input", () => {
1259
+ q = search.value.trim().toLowerCase();
1260
+ paint();
1261
+ });
1262
+ paint();
1263
+
1264
+ const close = modal({
1265
+ title: replace ? t("hookui.pickerReplaceTitle") : tn(line, "hookui.pickerTitle"),
1266
+ body: pane,
1267
+ actions: [{ label: t("common.cancel"), cls: "btn-ghost", onClick: (c) => c() }],
1268
+ });
1269
+ setTimeout(() => search.focus(), 40);
1270
+ }
1271
+
1272
+ // A row says WHAT the part is, in a sentence, next to what it will look like.
1273
+ // A REFUSED part is drawn and is NOT clickable — never a disabled button, and
1274
+ // never absent: "we decided against this" and "we forgot" must not look the
1275
+ // same, which is why the row exists at all.
1276
+ function pickerRow(comp, line, replace, close) {
1277
+ const refused = comp.cost === "refused";
1278
+ const row = el(refused ? "div" : "button", "hk-pick-row" + (refused ? " hk-pick-row-off" : ""));
1279
+ if (!refused) row.type = "button";
1280
+
1281
+ const top = el("div", "hk-pick-top");
1282
+ top.append(el("span", "hk-prow-id", comp.id));
1283
+ // The cost chip is the CLI's own word. A friendlier synonym would be a state
1284
+ // that does not exist.
1285
+ top.append(chip(comp.cost, refused ? "bad" : null));
1286
+ row.append(top);
1287
+ if (comp.summary) row.append(el("div", "hk-pick-desc", comp.summary));
1288
+ const sample = comp.previews && comp.previews[(comp.defaults && comp.defaults.render) || comp.renderers[0]];
1289
+ if (sample) row.append(ansiBlock(sample, "hk-preview hk-preview-chip"));
1290
+ if (refused) {
1291
+ row.append(el("div", "note note-warn", comp.refused_reason));
1292
+ return row;
1293
+ }
1294
+ row.addEventListener("click", () => {
1295
+ if (replace) hkOp({ op: "set", ref: replace.ref, field: "type", value: comp.id, label: t("hookui.opSwap", { id: comp.id }) });
1296
+ else hkOp({ op: "add", line, type: comp.id });
1297
+ close();
1298
+ });
1299
+ return row;
1300
+ }
1301
+
1302
+ // Fuzzy plus INITIALISM: with a catalogue this size a substring search is not
1303
+ // enough — two letters should find a two-word name, which plain substring
1304
+ // matching does not.
1305
+ function matches(comp, q) {
1306
+ const id = comp.id.toLowerCase();
1307
+ if (id.includes(q)) return true;
1308
+ if ((comp.summary || "").toLowerCase().includes(q)) return true;
1309
+ if ((comp.label || "").toLowerCase().includes(q)) return true;
1310
+ const initials = id.split("-").map((p) => p[0]).join("");
1311
+ if (initials.startsWith(q)) return true;
1312
+ let i = 0;
1313
+ for (const ch of id) if (ch === q[i]) i++;
1314
+ return i === q.length;
1315
+ }
1316
+
1317
+ /* ══ move ═════════════════════════════════════════════════════════════════ */
1318
+ // A modal that says what it is about to do — and on a narrow screen it is the
1319
+ // ONLY way to move a chip, because drag is off below the width at which a drop
1320
+ // gap stops being a target.
1321
+ function moveModal(item, line, plan) {
1322
+ HK_MODAL = null;
1323
+ const body = el("div", "stack");
1324
+ body.append(el("div", "note", t("hookui.moveAbout", { id: item.type })));
1325
+
1326
+ for (const target of plan.lines) {
1327
+ const legal = lineLegal(plan, target.line);
1328
+ const sameLine = target.line === line.line;
1329
+ const box = el("div", "hk-move-line");
1330
+ box.append(el("div", "hk-line-name", tn(target.line, "hookui.lineN")));
1331
+ // A LINE THAT CANNOT TAKE IT SAYS SO ONCE. Six identical disabled slots is
1332
+ // the same refusal printed six times, and the reason is about the LINE
1333
+ // rather than about any one slot on it.
1334
+ const blocked = sameLine ? null : addBlocked(plan, target.line);
1335
+ if (blocked) {
1336
+ box.append(el("div", "note note-warn", blocked));
1337
+ body.append(box);
1338
+ continue;
1339
+ }
1340
+ const slots = el("div", "hk-move-slots");
1341
+ const n = Math.max(1, target.items.length + (sameLine ? 0 : 1));
1342
+ for (let p = 1; p <= n; p++) {
1343
+ const b = el("button", "btn btn-xs btn-ghost", tn(p, "hookui.slotN"));
1344
+ b.type = "button";
1345
+ if (sameLine && p === line.items.indexOf(item) + 1) {
1346
+ b.disabled = true;
1347
+ b.title = t("hookui.alreadyHere");
1348
+ } else {
1349
+ b.addEventListener("click", () => {
1350
+ close();
1351
+ moveTo(item, target.line, p);
1352
+ });
1353
+ }
1354
+ slots.append(b);
1355
+ }
1356
+ box.append(slots);
1357
+ body.append(box);
1358
+ }
1359
+
1360
+ const close = modal({
1361
+ title: t("hookui.moveTitle"),
1362
+ body,
1363
+ actions: [{ label: t("common.cancel"), cls: "btn-ghost", onClick: (c) => c() }],
1364
+ });
1365
+ }
1366
+
1367
+ /* ══ remove ═══════════════════════════════════════════════════════════════ */
1368
+ function removeModal(item, comp) {
1369
+ HK_MODAL = null;
1370
+ const body = el("div", "stack");
1371
+ body.append(el("div", "note", t("hookui.removeAbout", { id: item.type })));
1372
+ if (comp && comp.summary) body.append(el("div", "note", comp.summary));
1373
+ modal({
1374
+ title: t("hookui.removeTitle"),
1375
+ body,
1376
+ actions: [
1377
+ { label: t("common.cancel"), cls: "btn-ghost", onClick: (c) => c() },
1378
+ {
1379
+ label: t("hookui.remove"),
1380
+ cls: "btn-primary",
1381
+ onClick: (c) => {
1382
+ c();
1383
+ hkOp({ op: "remove", ref: item.ref });
1384
+ },
1385
+ },
1386
+ ],
1387
+ });
1388
+ }
1389
+
1390
+ /* ══ the part editor ══════════════════════════════════════════════════════ */
1391
+ // A MODAL, because the board is what the user is looking at, and a drawer that
1392
+ // opened inside a chip pushed the whole board down under the thing being
1393
+ // edited.
1394
+ //
1395
+ // NO FIELD NAMES, AND EVERY CONTROL CARRIES A SENTENCE. `min_width` and
1396
+ // `hide_when` are the JSON's words; this says "Least width" and explains what
1397
+ // it does. The JSON is for the diff, the editor is for the person — and the
1398
+ // ids, renderers and state words that DO appear are the CLI's own and are never
1399
+ // translated.
1400
+ function editModal(item0, line0, comp, cat) {
1401
+ // NOT named `body`: that name is reserved for a panel's own card container,
1402
+ // which must carry `stack`. This is a modal's contents and spaces itself.
1403
+ const pane = el("div", "hk-editor");
1404
+ if (!comp) {
1405
+ pane.append(el("div", "note", t("hookui.unknownComponent")));
1406
+ modal({ title: item0.type, body: pane, actions: [{ label: t("common.close"), cls: "btn-ghost", onClick: (c) => c() }] });
1407
+ return;
1408
+ }
1409
+
1410
+ // THE EDITOR FOLLOWS THE STAGED STATE (v1.4.2). It used to be built once and
1411
+ // then left standing while the panel behind it was thrown away and rebuilt,
1412
+ // so every control it showed was the value from BEFORE the change the user
1413
+ // had just made: you picked a colour, the swatch did not move, and the only
1414
+ // reasonable conclusion was that the editor did not work. The part is
1415
+ // re-found by its stable ref on every repaint, so what is on screen is always
1416
+ // the effective value.
1417
+ let ref = item0.ref;
1418
+ let item = item0;
1419
+ let line = line0;
1420
+
1421
+ const paint = (plan) => {
1422
+ if (plan) {
1423
+ let found = null;
1424
+ for (const l of plan.lines) {
1425
+ const hit = l.items.find((x) => x.ref === ref);
1426
+ if (hit) {
1427
+ found = { l, hit };
1428
+ break;
1429
+ }
1430
+ }
1431
+ // The part is gone — a staged remove, or an Apply that took it away.
1432
+ // Closing is the only honest answer; editing something that no longer
1433
+ // exists is worse than losing the dialog.
1434
+ if (!found) return close();
1435
+ item = found.hit;
1436
+ line = found.l;
1437
+ }
1438
+ // THE CARET SURVIVES THE REPAINT. The editor rebuilds on every keystroke
1439
+ // now, and a text box that loses focus after one letter is not a text box.
1440
+ // The path is positional because the controls are the same controls in the
1441
+ // same order across a repaint; when they are not, focus is simply not
1442
+ // restored, which is the right failure.
1443
+ const keep = focusPath(pane);
1444
+ pane.replaceChildren();
1445
+ // Same rule inside the dialog: it rebuilds on every keystroke, and a
1446
+ // shape gallery that fades in each time you type a letter is the blink
1447
+ // again in a smaller box.
1448
+ pane.classList.add("hk-quiet");
1449
+ build();
1450
+ restoreFocus(pane, keep);
1451
+ };
1452
+
1453
+ // The value in force for one field: what is staged, else what the CLI
1454
+ // resolved, else the fallback. `show --json` now carries EVERY field, so this
1455
+ // can finally answer for all of them — before v1.4.2 it emitted twelve of
1456
+ // twenty-four and a control for one of the other twelve always read blank.
1457
+ const val = (field, dflt) =>
1458
+ field in item.over
1459
+ ? item.over[field]
1460
+ : item.src && item.src[field] !== undefined && item.src[field] !== null
1461
+ ? item.src[field]
1462
+ : dflt;
1463
+ // Whether the USER set this one, as opposed to inheriting it from the
1464
+ // catalogue, the colour set or the file. A control that cannot tell those
1465
+ // apart cannot say what it is about to change.
1466
+ const mine = (field) => field in item.over || !!(item.src && item.src.authored && field in item.src.authored);
1467
+ // An emptied box, a "shared" pick: the write DELETES the key (inherit), so
1468
+ // the pending list says that instead of "set to nothing".
1469
+ const cleared = (v) => v === "" || v === null || v === cat.inherit_token;
1470
+ const stage = (field, value, label) =>
1471
+ hkOp({ op: "set", ref, field, value, label: cleared(value) ? t("hookui.clearChange", { id: comp.id, field }) : label });
1472
+ // The leading "shared" option: on while the field is not the user's own.
1473
+ const shared = (field, value) => ({ label: t("hookui.shared"), value, on: !mine(field) || cleared(val(field, null)) });
1474
+ const own = (field) => (shared(field).on ? null : val(field, null));
1475
+ // A field name, plus a mark when the value is the user's own.
1476
+ const fieldName = (name, field) => (mine(field) ? name + " \u2022" : name);
1477
+
1478
+ // WHETHER THE CURRENT SHAPE USES THIS FIELD AT ALL. The CLI's answer, per
1479
+ // renderer, carried on the renderer table — the panel does not know which
1480
+ // shapes draw a label any more than it knows what a label is. Twenty-eight of
1481
+ // the thirty-five renderers ignore `label` entirely, and before this the Name
1482
+ // box was offered on every one of them: it took the text, the CLI wrote it to
1483
+ // disk correctly, and nothing ever appeared on the bar.
1484
+ //
1485
+ // The control is DISABLED and keeps its slot with the reason on it, never
1486
+ // hidden — "this shape cannot use it" and "we forgot to offer it" must not
1487
+ // look the same, and a value already set has to stay visible.
1488
+ const usesOf = (field) => {
1489
+ const rend = cat.renderers[val("render", (comp.defaults && comp.defaults.render) || comp.renderers[0])];
1490
+ if (!rend || !rend.uses) return true;
1491
+ return rend.uses.includes(field);
1492
+ };
1493
+ // Wraps a control that this shape ignores: greyed, inert, and carrying the
1494
+ // sentence that says which shapes DO use it.
1495
+ const gated = (field, node, shapes) => {
1496
+ if (usesOf(field)) return node;
1497
+ const box = el("div", "hk-inert");
1498
+ for (const b of node.querySelectorAll ? node.querySelectorAll("button, input, select") : []) b.disabled = true;
1499
+ if (node.tagName === "INPUT" || node.tagName === "SELECT") node.disabled = true;
1500
+ box.append(node);
1501
+ box.append(el("div", "note note-warn", shapes
1502
+ ? t("hookui.shapeIgnoresLabel", { shapes: shapes.join(", ") })
1503
+ : t("hookui.shapeIgnores")));
1504
+ return box;
1505
+ };
1506
+
1507
+ // v2.0.4 — the small builders every new control goes through. Each option
1508
+ // list is the CLI's (`cat.*`), each value is staged with `stage`, and a
1509
+ // "shared" pick clears the key so the part inherits again.
1510
+ const HEX = /^#[0-9a-fA-F]{6}$/;
1511
+ const isHex = (v) => typeof v === "string" && HEX.test(v);
1512
+ const change = (nameKey, v) => t("hookui.fieldChange", { id: comp.id, field: t(nameKey), v: String(v) });
1513
+ const lim = (k) => (cat.limits || {})[k];
1514
+ // A list the CLI stores as an array and the panel stages as "a,b".
1515
+ const listOf = (v) => (Array.isArray(v) ? v.map(String) : v == null || v === "" ? [] : String(v).split(","));
1516
+ // A map the CLI stores as an object and the panel stages as "k=v,k=v".
1517
+ const mapOf = (v) => {
1518
+ if (v && typeof v === "object" && !Array.isArray(v)) return Object.assign({}, v);
1519
+ const out = {};
1520
+ for (const pair of listOf(v)) {
1521
+ const at = pair.indexOf("=");
1522
+ if (at > 0) out[pair.slice(0, at)] = pair.slice(at + 1);
1523
+ }
1524
+ return out;
1525
+ };
1526
+ const mapText = (m) => Object.entries(m).filter(([, v]) => v !== "" && v != null).map(([k, v]) => k + "=" + v).join(",");
1527
+ // One row of CLI options with the "shared" pick first.
1528
+ const choice = (field, options, nameKey, aboutKey, gate, tips) => {
1529
+ const node = pickRow(options, own(field), (v) => stage(field, v, change(nameKey, v)), shared(field, ""), tips);
1530
+ return labelled(fieldName(t(nameKey), field), gate ? gated(field, node) : node, t(aboutKey));
1531
+ };
1532
+ const text = (field, nameKey, aboutKey, gate, cls) => {
1533
+ const cur = val(field, "");
1534
+ const node = textField(Array.isArray(cur) ? cur.join(",") : cur, (v) => stage(field, v, change(nameKey, v)));
1535
+ if (cls) node.classList.add(cls);
1536
+ return labelled(fieldName(t(nameKey), field), gate ? gated(field, node) : node, t(aboutKey));
1537
+ };
1538
+ const num = (field, lo, hi, nameKey, aboutKey, gate) => {
1539
+ const node = numField(val(field, null), lo, hi, (v) => stage(field, v, change(nameKey, v)));
1540
+ return labelled(fieldName(t(nameKey), field), gate ? gated(field, node) : node, t(aboutKey));
1541
+ };
1542
+ // EVERY COLOUR PICKER: "shared" first, then the named slots, then a hex
1543
+ // entry — the browser's own colour picker AND a short box that takes
1544
+ // `#rrggbb`. The hex string is what is staged; the CLI validates it.
1545
+ const colourPick = (field, nameKey, aboutKey, gate) => {
1546
+ const box = el("div", "hk-colour");
1547
+ const cur = own(field);
1548
+ box.append(pickRow(cat.colors, cur, (v) => stage(field, v, change(nameKey, v)), shared(field, cat.inherit_token)));
1549
+ const hex = el("div", "hk-hex");
1550
+ const pick = el("input", "hk-hex-pick");
1551
+ pick.type = "color";
1552
+ pick.value = isHex(cur) ? cur.toLowerCase() : "#000000";
1553
+ pick.setAttribute("aria-label", t("hookui.hexPick"));
1554
+ pick.addEventListener("change", () => stage(field, pick.value, change(nameKey, pick.value)));
1555
+ const box2 = el("input", "hk-input hk-short");
1556
+ box2.type = "text";
1557
+ box2.placeholder = t("hookui.hexPlaceholder");
1558
+ box2.value = isHex(cur) ? cur : "";
1559
+ box2.dataset.built = box2.value;
1560
+ box2.setAttribute("aria-label", t("hookui.hexBox"));
1561
+ // Staged only once it IS a colour — "#22c" is a keystroke, not a value.
1562
+ box2.addEventListener("input", () => {
1563
+ const v = box2.value.trim();
1564
+ if (isHex(v)) stage(field, v, change(nameKey, v));
1565
+ });
1566
+ hex.append(pick, box2);
1567
+ box.append(hex);
1568
+ return labelled(fieldName(t(nameKey), field), gate ? gated(field, box) : box, t(aboutKey));
1569
+ };
1570
+
1571
+ function build() {
1572
+ const live = el("div", "hk-editor-live");
1573
+ // WHAT THIS IS, EXACTLY. `previews` is a per-renderer sample the CLI drew
1574
+ // from its own fixture, so it shows the SHAPE this part will take - never
1575
+ // the words or the numbers this component will carry at run time. Claiming
1576
+ // otherwise would make the one picture in this dialog a lie.
1577
+ live.append(el("div", "note", t("hookui.editorLive")));
1578
+ live.append(termWindow(chipSample(item, comp, line), { title: comp.id, tags: [], ruler: false, small: true }));
1579
+ pane.append(live);
1580
+ if (comp.summary) pane.append(el("div", "note", comp.summary));
1581
+ pane.append(el("div", "note", t("hookui.editorStaged")));
1582
+
1583
+ // Swapping the part itself, from inside the editor: "this slot, but a
1584
+ // different thing" is a real edit and it should not need a remove and an add.
1585
+ const swap = el("button", "btn btn-xs btn-ghost hk-swap", t("hookui.swap"));
1586
+ swap.type = "button";
1587
+ swap.addEventListener("click", () => {
1588
+ close();
1589
+ partPicker(cat, HK_NO_LINES, 0, item);
1590
+ });
1591
+ pane.append(swap);
1592
+
1593
+ // THE SHAPE PICKER IS A GALLERY, NOT A DROPDOWN. Every renderer the component
1594
+ // declares, drawn with this component's real current value. A dropdown
1595
+ // containing a renderer's name tells nobody anything, and this is the
1596
+ // difference between a feature people use and a settings form they close.
1597
+ pane.append(sectionHead(t("hookui.shape"), t("hookui.shapeAbout")));
1598
+ const gallery = el("div", "hk-gallery");
1599
+ const curRender = val("render", (comp.defaults && comp.defaults.render) || comp.renderers[0]);
1600
+ for (const r of comp.renderers) {
1601
+ const b = el("button", "hk-sample" + (curRender === r ? " hk-sample-on" : ""));
1602
+ b.type = "button";
1603
+ b.title = r;
1604
+ b.append(ansiBlock(comp.previews && comp.previews[r] ? comp.previews[r] : r, "hk-preview hk-preview-chip"));
1605
+ b.append(el("span", "hk-sample-id", r));
1606
+ // IT NO LONGER CLOSES. Closing was how the old editor hid the fact that
1607
+ // it could not update itself; now the gallery marks the new shape and the
1608
+ // sample above redraws, which is what a person expects a picker to do.
1609
+ b.addEventListener("click", () => stage("render", r, t("hookui.shapeChange", { id: comp.id, r })));
1610
+ gallery.append(b);
1611
+ }
1612
+ pane.append(gallery);
1613
+ // AT THE CONTROL, at the moment the choice is made. Which shape you pick
1614
+ // decides whether the Name box below is live at all, so the sentence
1615
+ // belongs here rather than in a surprise further down the dialog.
1616
+ pane.append(el("div", "note", t("hookui.shapeDecides", { shapes: (cat.label_renderers || []).join(", ") })));
1617
+
1618
+ pane.append(sectionHead(t("hookui.glyphs"), t("hookui.glyphsAbout")));
1619
+ pane.append(pickRow(cat.glyph_sets, val("glyphs", null), (v) => stage("glyphs", v, t("hookui.glyphChange", { id: comp.id, v }))));
1620
+
1621
+ pane.append(sectionHead(t("hookui.words"), t("hookui.wordsAbout")));
1622
+ const label = el("input", "hk-input");
1623
+ label.type = "text";
1624
+ const cur = val("label", "");
1625
+ label.value = cur == null ? "" : String(cur);
1626
+ label.placeholder = t("hookui.labelPlaceholder");
1627
+ // BOTH EVENTS. `change` alone fires on blur, so a name typed and then
1628
+ // followed by a click on Close raced the close and was sometimes never
1629
+ // staged at all. `input` stages every keystroke; `hkOp` already collapses
1630
+ // repeated edits of one field into one decision, so the pending list still
1631
+ // reads as a list of decisions.
1632
+ const push = () => stage("label", label.value, t("hookui.labelChange", { id: comp.id, v: label.value }));
1633
+ label.addEventListener("change", push);
1634
+ label.addEventListener("input", push);
1635
+ pane.append(labelled(fieldName(t("hookui.label"), "label"), gated("label", label, cat.label_renderers), t("hookui.labelAbout")));
1636
+ pane.append(choice("label_pos", cat.label_positions, "hookui.labelPos", "hookui.labelPosAbout", true));
1637
+ pane.append(choice("value_pos", cat.value_positions, "hookui.valuePos", "hookui.valuePosAbout", true));
1638
+ // Each case button carries the CLI's own sample of what it does.
1639
+ 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 })), null, cat.case_samples)), t("hookui.caseAbout")));
1640
+ 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")));
1641
+ 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")));
1642
+
1643
+ // A CAPTION is a second row of text above or below the part. `{value}` and
1644
+ // `{label}` are the CLI's tokens, listed from `caption_tokens`.
1645
+ pane.append(sectionHead(t("hookui.caption"), t("hookui.captionAbout", { tokens: (cat.caption_tokens || []).join(" "), n: lim("caption") || "" })));
1646
+ pane.append(text("caption", "hookui.captionText", "hookui.captionTextAbout", true));
1647
+ pane.append(choice("caption_pos", cat.caption_positions, "hookui.captionPos", "hookui.captionPosAbout", true));
1648
+ pane.append(choice("caption_align", cat.caption_aligns, "hookui.captionAlign", "hookui.captionAlignAbout", true));
1649
+ pane.append(choice("caption_case", cat.cases, "hookui.captionCase", "hookui.captionCaseAbout", true, cat.case_samples));
1650
+
1651
+ pane.append(sectionHead(t("hookui.colour"), t("hookui.colourAbout")));
1652
+ pane.append(colourPick("label_color", "hookui.labelColour", "hookui.labelColourAbout", false));
1653
+ pane.append(colourPick("value_color", "hookui.valueColour", "hookui.valueColourAbout", false));
1654
+ pane.append(colourPick("bg", "hookui.bgColour", "hookui.bgColourAbout", false));
1655
+ pane.append(colourPick("caption_color", "hookui.captionColour", "hookui.captionColourAbout", true));
1656
+ if (comp.bounded)
1657
+ 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")));
1658
+ // R3, AT THE CONTROL, at the moment the choice is made - never in a
1659
+ // validation summary later.
1660
+ if (comp.states) pane.append(el("div", "note note-warn", t("hookui.stateColourWarn", { s: comp.states.join(", ") })));
1661
+
1662
+ // "Weight", never "font size". A terminal owns its font, and a picker that
1663
+ // did nothing would be worse than not offering one.
1664
+ // v2.0.4 — the bar's own look, only when the current shape draws one.
1665
+ const barFields = ["fill_color", "empty_color", "fill_char", "empty_char", "ramp_colors", "ramp_stops", "threshold"];
1666
+ if (barFields.some((f) => usesOf(f))) {
1667
+ pane.append(sectionHead(t("hookui.barLook"), t("hookui.barLookAbout")));
1668
+ pane.append(colourPick("fill_color", "hookui.fillColour", "hookui.fillColourAbout", true));
1669
+ pane.append(colourPick("empty_color", "hookui.emptyColour", "hookui.emptyColourAbout", true));
1670
+ pane.append(text("fill_char", "hookui.fillChar", "hookui.charAbout", true, "hk-char"));
1671
+ pane.append(text("empty_char", "hookui.emptyChar", "hookui.charAbout", true, "hk-char"));
1672
+ const rc = lim("ramp_colors") || [];
1673
+ pane.append(labelled(fieldName(t("hookui.rampColours"), "ramp_colors"), gated("ramp_colors", textField(listOf(val("ramp_colors", null)).join(","), (v) => stage("ramp_colors", v, change("hookui.rampColours", v)))), t("hookui.rampColoursAbout", { lo: rc[0] || "", hi: rc[1] || "" })));
1674
+ pane.append(labelled(fieldName(t("hookui.rampStops"), "ramp_stops"), gated("ramp_stops", textField(listOf(val("ramp_stops", null)).join(","), (v) => stage("ramp_stops", v, change("hookui.rampStops", v)))), t("hookui.rampStopsAbout")));
1675
+ pane.append(num("threshold", 0, 100, "hookui.threshold", "hookui.thresholdAbout", true));
1676
+ }
1677
+
1678
+ // "Weight", never "font size". A terminal owns its font, and a picker that
1679
+ // did nothing would be worse than not offering one. v2.0.4 — SEVERAL at
1680
+ // once (bold AND underline): each button toggles one, and "shared" clears.
1681
+ const weights = el("div", "hk-picks");
1682
+ const on = new Set(shared("emphasis").on ? [] : listOf(val("emphasis", [])));
1683
+ const lead = el("button", "hk-pick" + (on.size ? "" : " hk-pick-on"), t("hookui.shared"));
1684
+ lead.type = "button";
1685
+ lead.addEventListener("click", () => stage("emphasis", "", ""));
1686
+ weights.append(lead);
1687
+ for (const e of (cat.emphasis || []).filter((x) => !(cat.refused_emphasis || []).includes(x))) {
1688
+ const b = el("button", "hk-pick" + (on.has(e) ? " hk-pick-on" : ""), String(e));
1689
+ b.type = "button";
1690
+ b.setAttribute("aria-pressed", String(on.has(e)));
1691
+ b.addEventListener("click", () => {
1692
+ const next = new Set(on);
1693
+ if (next.has(e)) next.delete(e);
1694
+ else next.add(e);
1695
+ const v = [...next].join(",");
1696
+ stage("emphasis", v, t("hookui.emphasisChange", { id: comp.id, v }));
1697
+ });
1698
+ weights.append(b);
1699
+ }
1700
+ pane.append(labelled(fieldName(t("hookui.emphasis"), "emphasis"), weights, t("hookui.emphasisAbout")));
1701
+ pane.append(el("div", "note", t("hookui.fontWhy")));
1702
+
1703
+ // FRAME AND SPACING: the brackets around the part, the blank cells on each
1704
+ // side, and how a padded value sits. Every range is the CLI's `limits`.
1705
+ pane.append(sectionHead(t("hookui.frame"), t("hookui.frameAbout")));
1706
+ const br = listOf(val("brackets", null));
1707
+ const stageBr = (l, r) => stage("brackets", l === "" && r === "" ? "" : l + "," + r, change("hookui.brackets", l + " " + r));
1708
+ let brR = null;
1709
+ const brL = textField(br[0] || "", (v) => stageBr(v, brR.value));
1710
+ brR = textField(br[1] || "", (v) => stageBr(brL.value, v));
1711
+ brL.classList.add("hk-short");
1712
+ brR.classList.add("hk-short");
1713
+ brL.setAttribute("aria-label", t("hookui.bracketLeft"));
1714
+ brR.setAttribute("aria-label", t("hookui.bracketRight"));
1715
+ const brPair = el("div", "hk-pair");
1716
+ brPair.append(brL, brR);
1717
+ pane.append(labelled(fieldName(t("hookui.brackets"), "brackets"), gated("brackets", brPair), t("hookui.bracketsAbout", { n: lim("brackets") || "" })));
1718
+ const pad = listOf(val("padding", null));
1719
+ const padOf = (k, i) => (k in item.over ? item.over[k] : pad[i] !== undefined ? pad[i] : null);
1720
+ const padPair = el("div", "hk-pair");
1721
+ const padL = numField(padOf("pad_left", 0), 0, lim("padding"), (v) => stage("pad_left", v, change("hookui.padLeft", v)));
1722
+ const padR = numField(padOf("pad_right", 1), 0, lim("padding"), (v) => stage("pad_right", v, change("hookui.padRight", v)));
1723
+ padL.setAttribute("aria-label", t("hookui.padLeft"));
1724
+ padR.setAttribute("aria-label", t("hookui.padRight"));
1725
+ padPair.append(padL, padR);
1726
+ pane.append(labelled(fieldName(t("hookui.padding"), "padding"), gated("padding", padPair), t("hookui.paddingAbout")));
1727
+ pane.append(choice("align", cat.aligns, "hookui.align", "hookui.alignAbout", true));
1728
+ pane.append(choice("sign", cat.signs, "hookui.sign", "hookui.signAbout", true));
1729
+ pane.append(num("max_len", 0, lim("max_len"), "hookui.maxLen", "hookui.maxLenAbout", true));
1730
+ pane.append(choice("unknown", cat.unknowns, "hookui.unknown", "hookui.unknownAbout", false));
1731
+
1732
+ // PER STATE: a colour and a glyph for each state the component declares.
1733
+ // The state words are the CLI's own and are printed verbatim.
1734
+ if (comp.states && comp.states.length) {
1735
+ pane.append(sectionHead(t("hookui.states"), t("hookui.statesAbout")));
1736
+ const cmap = mapOf(val("color_by_state", null));
1737
+ const gmap = mapOf(val("glyph_by_state", null));
1738
+ for (const st of comp.states) {
1739
+ const row = el("div", "hk-state-row");
1740
+ const sel = el("select", "hk-select");
1741
+ const none = el("option", null, t("hookui.shared"));
1742
+ none.value = "";
1743
+ sel.append(none);
1744
+ const slots = (cat.colors || []).map(String);
1745
+ if (cmap[st] && !slots.includes(String(cmap[st]))) slots.push(String(cmap[st]));
1746
+ for (const c of slots) {
1747
+ const o = el("option", null, c);
1748
+ o.value = c;
1749
+ if (String(cmap[st] || "") === c) o.selected = true;
1750
+ sel.append(o);
1751
+ }
1752
+ sel.setAttribute("aria-label", t("hookui.stateColour", { s: st }));
1753
+ sel.addEventListener("change", () => stage("color_by_state", mapText(Object.assign({}, cmap, { [st]: sel.value })), t("hookui.stateChange", { id: comp.id, s: st, v: sel.value || t("hookui.shared") })));
1754
+ const g = textField(gmap[st] || "", (v) => stage("glyph_by_state", mapText(Object.assign({}, gmap, { [st]: v })), t("hookui.stateChange", { id: comp.id, s: st, v })));
1755
+ g.classList.add("hk-short", "hk-char");
1756
+ g.setAttribute("aria-label", t("hookui.stateGlyph", { s: st }));
1757
+ row.append(el("span", "hk-state-name", st), sel, gated("glyph_by_state", g));
1758
+ pane.append(row);
1759
+ }
1760
+ }
1761
+
1762
+ // SETTINGS: the component's own parameters, as the CLI declares them — a
1763
+ // fixed list is buttons, free text is a box. Each key is its own op.
1764
+ const params = comp.params && typeof comp.params === "object" ? Object.entries(comp.params) : [];
1765
+ if (params.length) {
1766
+ pane.append(sectionHead(t("hookui.settings"), t("hookui.settingsAbout")));
1767
+ for (const [key, spec] of params) {
1768
+ const field = "param." + key;
1769
+ const saved = item.src && item.src.params ? item.src.params[key] : undefined;
1770
+ const cur = field in item.over ? item.over[field] : saved !== undefined && saved !== null ? saved : spec && spec.dflt;
1771
+ const onSet = (v) => stage(field, v, t("hookui.paramChange", { id: comp.id, k: key, v: String(v) }));
1772
+ const node = spec && spec.options ? pickRow(spec.options, cur, onSet) : textField(cur, onSet);
1773
+ pane.append(labelled(key, node, spec && spec.options ? t("hookui.paramOptionsAbout") : t("hookui.paramFreeAbout", { n: lim("param_text") || "" })));
1774
+ }
1775
+ }
1776
+
1777
+ if (comp.bounded || (comp.defaults && comp.defaults.format)) {
1778
+ pane.append(sectionHead(t("hookui.numbers"), t("hookui.numbersAbout")));
1779
+ 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")));
1780
+ 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")));
1781
+ // `min_width` is not cosmetic: a value that changes width shifts every part
1782
+ // to its right on the keystroke it happens, and that jitter is the main
1783
+ // reason people turn a status line off again.
1784
+ 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")));
1785
+ 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")));
1786
+ }
1787
+
1788
+ const rend = cat.renderers[curRender];
1789
+ if (rend && rend.width)
1790
+ 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")));
1791
+
1792
+ // A CHECKLIST, not an enum: "hide when zero" and "hide when there is no run"
1793
+ // are independent facts and a user wants both at once.
1794
+ pane.append(sectionHead(t("hookui.whenToShow"), t("hookui.whenToShowAbout")));
1795
+ const checks = el("div", "hk-checks");
1796
+ const active = new Set(val("hide_when", []) || []);
1797
+ for (const h of cat.hide_when) {
1798
+ if (h.id === "never") continue;
1799
+ const lab = el("label", "hk-check");
1800
+ const box = el("input");
1801
+ box.type = "checkbox";
1802
+ box.checked = active.has(h.id);
1803
+ box.addEventListener("change", () => {
1804
+ const next = new Set(active);
1805
+ if (box.checked) next.add(h.id);
1806
+ else next.delete(h.id);
1807
+ const v = next.size ? [...next].join(",") : "never";
1808
+ stage("hide_when", v, t("hookui.hideChange", { id: comp.id, v }));
1809
+ });
1810
+ lab.append(box, el("span", null, h.id), el("span", "note", h.says));
1811
+ checks.append(lab);
1812
+ }
1813
+ pane.append(checks);
1814
+
1815
+ // RESPONSIVE WIDTH: the range of terminal widths in which this part is worth
1816
+ // its cells. Strictly better than truncating the right of an overflowing
1817
+ // line, because the USER chooses what survives a narrow terminal.
1818
+ pane.append(sectionHead(t("hookui.narrow"), t("hookui.narrowAbout")));
1819
+ 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")));
1820
+ 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")));
1821
+ }
1822
+
1823
+ paint(null);
1824
+
1825
+ const close = modal({
1826
+ title: comp.id,
1827
+ body: pane,
1828
+ actions: [
1829
+ // WHERE EACH VALUE CAME FROM. It is how a user finds their own overrides
1830
+ // again after a colour set change - `orc lane config`'s problem, at part
1831
+ // scale. It reads the SAVED item, so it is offered only for one.
1832
+ ...(item.src
1833
+ ? [{
1834
+ label: t("hookui.explain"),
1835
+ cls: "btn-ghost",
1836
+ onClick: async () => {
1837
+ const r = await read("/api/statusline/explain?board=" + HK_BOARD + "&at=" + line.line + ":" + (line.items.indexOf(item) + 1));
1838
+ if (!r.data || !r.data.ok) return toast(t("hookui.explainFailed"), "bad");
1839
+ HK_MODAL = null;
1840
+ modal({
1841
+ title: comp.id,
1842
+ body: kvList(r.data.resolved.map((x) => [x.field, x.source])),
1843
+ actions: [{ label: t("common.close"), cls: "btn-ghost", onClick: (c) => c() }],
1844
+ });
1845
+ },
1846
+ }]
1847
+ : []),
1848
+ {
1849
+ label: t("common.close"),
1850
+ cls: "btn-ghost",
1851
+ onClick: (c) => {
1852
+ HK_MODAL = null;
1853
+ c();
1854
+ },
1855
+ },
1856
+ ],
1857
+ });
1858
+ // Registered LAST, so a repaint that fires during construction cannot reach a
1859
+ // half-built pane.
1860
+ HK_MODAL = { repaint: paint };
1861
+ }
1862
+
1863
+ // `partPicker` reads a board only to answer "can a part go on that line". A
1864
+ // SWAP changes nothing about where the part sits, so it is handed a board with
1865
+ // no lines and never asks.
1866
+ const HK_NO_LINES = { lines: [], max: 0 };
1867
+
1868
+ // Where the caret is, as a path of child indexes from the editor's own root,
1869
+ // plus the selection inside the field. Not an id: a control here has no
1870
+ // identity of its own, and inventing one for every field would be a second
1871
+ // idea of what the editor holds.
1872
+ function focusPath(root) {
1873
+ const a = document.activeElement;
1874
+ if (!a || !root.contains(a)) return null;
1875
+ const path = [];
1876
+ let n = a;
1877
+ while (n && n !== root) {
1878
+ path.unshift([...n.parentNode.childNodes].indexOf(n));
1879
+ n = n.parentNode;
1880
+ }
1881
+ const sel = "selectionStart" in a && a.selectionStart !== null ? [a.selectionStart, a.selectionEnd] : null;
1882
+ return { path, sel };
1883
+ }
1884
+
1885
+ function restoreFocus(root, keep) {
1886
+ if (!keep) return;
1887
+ let n = root;
1888
+ for (const i of keep.path) {
1889
+ n = n && n.childNodes[i];
1890
+ if (!n) return;
1891
+ }
1892
+ if (!n.focus) return;
1893
+ n.focus();
1894
+ if (keep.sel && "setSelectionRange" in n) {
1895
+ try {
1896
+ n.setSelectionRange(keep.sel[0], keep.sel[1]);
1897
+ } catch (_) {}
1898
+ }
1899
+ }
1900
+
1901
+ function sectionHead(name, about) {
1902
+ const w = el("div", "hk-section-wrap");
1903
+ w.append(el("div", "hk-section", name));
1904
+ if (about) w.append(el("div", "note", about));
1905
+ return w;
1906
+ }
1907
+
1908
+ function numField(value, lo, hi, onSet) {
1909
+ const i = el("input", "hk-input hk-num");
1910
+ i.type = "number";
1911
+ i.min = String(lo);
1912
+ // No upper bound when the CLI sent none — the CLI still validates.
1913
+ if (hi !== undefined && hi !== null) i.max = String(hi);
1914
+ if (value !== null && value !== undefined) i.value = String(value);
1915
+ // The value it was BUILT with: a staged-preview repaint waits while the box
1916
+ // holds something different (see hkStagePaint).
1917
+ i.dataset.built = i.value;
1918
+ i.addEventListener("change", () => onSet(i.value));
1919
+ return i;
1920
+ }
1921
+
1922
+ // `blurOnly` is for a box on the BOARD, which repaints without putting the
1923
+ // caret back; a box in the editor stages per keystroke, as the Name box does.
1924
+ function textField(value, onSet, blurOnly) {
1925
+ const i = el("input", "hk-input");
1926
+ i.type = "text";
1927
+ i.value = value == null ? "" : String(value);
1928
+ i.dataset.built = i.value;
1929
+ i.addEventListener("change", () => onSet(i.value));
1930
+ if (!blurOnly) i.addEventListener("input", () => onSet(i.value));
1931
+ return i;
1932
+ }
1933
+
1934
+ // A control, its name, and — because a noun on its own is not an explanation —
1935
+ // the sentence that says what it does. That sentence is most of this release:
1936
+ // a settings form whose labels are nouns is a form people close.
1937
+ function labelled(name, node, about) {
1938
+ const row = el("div", "hk-field");
1939
+ const left = el("div", "hk-field-left");
1940
+ left.append(el("span", "hk-field-name", name));
1941
+ if (about) left.append(el("span", "note", about));
1942
+ row.append(left);
1943
+ row.append(node);
1944
+ return row;
1945
+ }
1946
+
1947
+ // A row of options. The VALUES are the CLI's and are printed verbatim — a
1948
+ // translated renderer name is a renderer that does not exist.
1949
+ // `lead` ({label, value, on}) is an optional first button that is not a CLI
1950
+ // value by name — the "shared" pick, whose value is the CLI's inherit token.
1951
+ // `tips` (optional) maps an option to a sample of what it does — the CLI's
1952
+ // `case_samples` — shown under the option and as its tooltip.
1953
+ function pickRow(options, current, onPick, lead, tips) {
1954
+ const row = el("div", "hk-picks");
1955
+ if (lead) {
1956
+ const b = el("button", "hk-pick" + (lead.on ? " hk-pick-on" : ""), lead.label);
1957
+ b.type = "button";
1958
+ b.addEventListener("click", () => onPick(lead.value));
1959
+ row.append(b);
1960
+ }
1961
+ for (const o of options || []) {
1962
+ const b = el("button", "hk-pick" + (String(current) === String(o) ? " hk-pick-on" : ""), String(o));
1963
+ b.type = "button";
1964
+ if (tips && tips[o]) {
1965
+ b.title = tips[o];
1966
+ b.append(el("span", "hk-pick-tip", tips[o]));
1967
+ }
1968
+ b.addEventListener("click", () => onPick(o));
1969
+ row.append(b);
1970
+ }
1971
+ return row;
1972
+ }
1973
+
1974
+ /* ══ the reference list ═══════════════════════════════════════════════════ */
1975
+ // WHAT EACH PART SHOWS, and nothing else. It used to carry three `+ line N`
1976
+ // buttons per row, which put the act of building the bar a long way from the
1977
+ // bar — and computed a position against a layout that was no longer the one
1978
+ // being staged. Adding happens on the board now; this is a reference.
1979
+ function referenceCard(cat) {
1980
+ const right = el("div", "row-actions");
1981
+ const search = el("input", "hk-input hk-palette-search");
1982
+ search.type = "search";
1983
+ search.value = HK_SEARCH;
1984
+ search.placeholder = t("hookui.searchPlaceholder");
1985
+ right.append(search);
1986
+
1987
+ const c = card(t("hookui.reference"), right);
1988
+ c.append(el("div", "note", t("hookui.referenceAbout")));
1989
+ const body = el("div", "stack");
1990
+ c.append(body);
1991
+
1992
+ function paint() {
1993
+ body.replaceChildren();
1994
+ const q = HK_SEARCH.trim().toLowerCase();
1995
+ const hits = q ? (cat.components || []).filter((x) => matches(x, q)) : null;
1996
+ if (hits) {
1997
+ body.append(el("div", "note", tn(hits.length, "hookui.matches")));
1998
+ for (const comp of hits) body.append(referenceRow(comp));
1999
+ if (!hits.length) body.append(empty(t("hookui.noMatch")));
2000
+ return;
2001
+ }
2002
+ for (const [g, name] of Object.entries(cat.groups)) {
2003
+ const rows = (cat.components || []).filter((x) => x.group === g);
2004
+ if (!rows.length) continue;
2005
+ const inner = el("div");
2006
+ for (const comp of rows) inner.append(referenceRow(comp));
2007
+ body.append(collapsible({
2008
+ title: name,
2009
+ count: String(rows.length),
2010
+ content: inner,
2011
+ collapsed: HK_GROUP !== g,
2012
+ }));
2013
+ }
2014
+ }
2015
+ search.addEventListener("input", () => {
2016
+ HK_SEARCH = search.value;
2017
+ paint();
2018
+ });
2019
+ paint();
2020
+ return c;
2021
+ }
2022
+
2023
+ function referenceRow(comp) {
2024
+ const row = el("div", "hk-prow");
2025
+ const top = el("div", "hk-prow-head");
2026
+ top.append(el("span", "hk-prow-id", comp.id));
2027
+ top.append(chip(comp.cost, comp.cost === "refused" ? "bad" : null));
2028
+ row.append(top);
2029
+ if (comp.summary) row.append(el("div", "hk-prow-desc", comp.summary));
2030
+ const sample = comp.previews && comp.previews[(comp.defaults && comp.defaults.render) || comp.renderers[0]];
2031
+ if (sample) row.append(ansiBlock(sample, "hk-preview hk-preview-chip"));
2032
+ // A REFUSED part keeps its slot and carries the measurement that refused it:
2033
+ // "we decided against this" and "we forgot" must not look the same.
2034
+ if (comp.cost === "refused") row.append(el("div", "note note-warn hk-prow-why", comp.refused_reason));
2035
+ return row;
2036
+ }
2037
+
2038
+ /* ══ presets ══════════════════════════════════════════════════════════════ */
2039
+ // Applying one REPLACES the layout, so it is always confirmed and the loss is
2040
+ // NAMED — the `orc diy init --force` rule.
2041
+ function presetsCard(presets, plan) {
2042
+ const c = card(t("hookui.presets"));
2043
+ c.append(el("div", "note", t("hookui.presetsAbout")));
2044
+ if (!presets || !presets.presets) return c;
2045
+ for (const p of presets.presets) {
2046
+ const row = el("div", "hk-preset");
2047
+ const h = el("div", "hk-preset-head");
2048
+ h.append(el("span", "hk-prow-id", p.name));
2049
+ if (p.active) h.append(chip(t("hookui.active"), "ok"));
2050
+ row.append(h);
2051
+ row.append(el("div", "note", p.summary));
2052
+ if (p.preview) row.append(ansiBlock(p.preview, "hk-preview hk-preview-sm"));
2053
+ // The ACTIVE row keeps its slot and drops its button.
2054
+ if (!p.active) {
2055
+ const b = el("button", "btn btn-xs btn-ghost", t("hookui.apply"));
2056
+ b.type = "button";
2057
+ // A preset is written STRAIGHT AWAY, so it cannot run while ops are
2058
+ // staged: those ops carry positions computed against a layout the preset
2059
+ // is about to replace.
2060
+ if (plan.dirty) {
2061
+ b.disabled = true;
2062
+ b.title = t("hookui.applyFirst");
2063
+ } else {
2064
+ b.addEventListener("click", () => confirmApply(p.name));
2065
+ }
2066
+ row.append(b);
2067
+ }
2068
+ c.append(row);
2069
+ }
2070
+ return c;
2071
+ }
2072
+
2073
+ function confirmApply(name) {
2074
+ modal({
2075
+ title: t("hookui.applyTitle", { name }),
2076
+ body: el("div", "note", t("hookui.applyWarn")),
2077
+ actions: [
2078
+ { label: t("common.cancel"), cls: "btn-ghost", onClick: (c) => c() },
2079
+ {
2080
+ label: t("hookui.apply"),
2081
+ cls: "btn-primary",
2082
+ onClick: async (c) => {
2083
+ c();
2084
+ const r = await post("/api/statusline/apply", bd({ name }));
2085
+ if (!r.ok) toast(t("hookui.applyFailed"), "bad", (r.output || "").trim());
2086
+ HK_OPS = [];
2087
+ hkStageClear();
2088
+ HK_COMPARE_DATA = null;
2089
+ hkReload();
2090
+ },
2091
+ },
2092
+ ],
2093
+ });
2094
+ }
2095
+
2096
+ function confirmReset() {
2097
+ modal({
2098
+ title: t("hookui.resetTitle"),
2099
+ body: el("div", "note", t("hookui.resetWarn")),
2100
+ actions: [
2101
+ { label: t("common.cancel"), cls: "btn-ghost", onClick: (c) => c() },
2102
+ {
2103
+ label: t("hookui.resetLayout"),
2104
+ cls: "btn-primary",
2105
+ onClick: async (c) => {
2106
+ c();
2107
+ await post("/api/statusline/reset", bd({}));
2108
+ HK_OPS = [];
2109
+ hkStageClear();
2110
+ HK_COMPARE_DATA = null;
2111
+ hkReload();
2112
+ },
2113
+ },
2114
+ ],
2115
+ });
2116
+ }
2117
+
2118
+ /* ══ more ═════════════════════════════════════════════════════════════════ */
2119
+ // THE COLOUR SET IS A DOCUMENT SETTING, and it now goes to the command that
2120
+ // writes document settings. It used to be routed through `statusline line 1
2121
+ // --theme`, which set ONE line's override while the picker read the document's
2122
+ // value back — so the button never moved and only a third of the bar changed.
2123
+ // One command per scope, and the panel calls the one that matches the control.
2124
+ function advancedCard(cat, plan) {
2125
+ const c = card(t("hookui.advanced"));
2126
+
2127
+ const themes = el("div", "hk-theme-list");
2128
+ for (const name of Object.keys(cat.themes || {})) {
2129
+ const b = el("button", "hk-theme" + (plan.doc.theme === name ? " hk-theme-on" : ""));
2130
+ b.type = "button";
2131
+ b.append(el("span", "hk-theme-name", name));
2132
+ // WHY somebody would pick it — the CLI's sentence, not one written here.
2133
+ const about = (cat.themes_about || {})[name];
2134
+ if (about) b.append(el("span", "note", about));
2135
+ b.addEventListener("click", () => hkOp({ op: "doc", field: "theme", value: name, label: t("hookui.themeChange", { v: name }) }));
2136
+ themes.append(b);
2137
+ }
2138
+ c.append(labelled(t("hookui.theme"), themes, t("hookui.themeAbout")));
2139
+ c.append(el("div", "note", t("hookui.themeOverride")));
2140
+
2141
+ c.append(labelled(t("hookui.glyphs"),
2142
+ pickRow(cat.glyph_sets, plan.doc.glyphs, (v) => hkOp({ op: "doc", field: "glyphs", value: v, label: t("hookui.glyphsDocChange", { v }) })),
2143
+ t("hookui.glyphsDocAbout")));
2144
+
2145
+ const re = el("button", "btn btn-sm btn-ghost", t("hookui.recompile"));
2146
+ re.type = "button";
2147
+ re.addEventListener("click", async () => {
2148
+ const r = await post("/api/statusline/compile", bd({}));
2149
+ toast(r.ok ? t("hookui.recompiled") : t("hookui.recompileFailed"), r.ok ? "ok" : "bad");
2150
+ hkReload();
2151
+ });
2152
+ const acts = el("div", "row-actions");
2153
+ acts.append(re);
2154
+ c.append(acts);
2155
+ c.append(el("div", "note", t("hookui.compileNote")));
2156
+ c.append(animationBlock());
2157
+ return c;
2158
+ }
2159
+
2160
+ // v2.0.4 — THE REDRAW TIMER. A settings write, not a staged layout op: the
2161
+ // button writes straight away, says so, and reloads.
2162
+ function animationBlock() {
2163
+ const show = (HK_DATA && HK_DATA.show) || {};
2164
+ const cur = show.refresh_interval || null;
2165
+ const box = el("div", "hk-anim");
2166
+ box.append(el("div", "hk-anim-head", t("hookui.animTitle")));
2167
+ box.append(el("div", "note", cur ? t("hookui.animNowN", { n: cur }) : t("hookui.animNowOff")));
2168
+ const row = el("div", "row-actions");
2169
+ // v2.1.0 (DE-22) — the sub-second values are SHOWN and DISABLED, with the
2170
+ // floor sentence: Claude Code never starts a timer below 1 s (measured).
2171
+ for (const v of [0.2, 0.5, 0.7]) {
2172
+ const b = el("button", "btn btn-xs btn-ghost", t("hookui.animSec", { n: v }));
2173
+ b.type = "button";
2174
+ b.disabled = true;
2175
+ b.title = t("hookui.animFloor");
2176
+ b.setAttribute("aria-disabled", "true");
2177
+ row.append(b);
2178
+ }
2179
+ for (const v of [null, 1, 2, 3, 5, 10]) {
2180
+ const on = (cur || null) === v;
2181
+ const b = el("button", "btn btn-xs" + (on ? " btn-primary" : " btn-ghost"), v ? t("hookui.animSec", { n: v }) : t("hookui.animOff"));
2182
+ b.type = "button";
2183
+ b.addEventListener("click", async () => {
2184
+ const r = await post("/api/statusline/refresh", bd({ seconds: v ? String(v) : "off" }));
2185
+ if (r.ok) toast(v ? t("hookui.animSetN", { n: v }) : t("hookui.animSetOff"), "ok");
2186
+ else toast(t("hookui.animFailed"), "bad", (r.output || "").trim());
2187
+ hkReload();
2188
+ });
2189
+ row.append(b);
2190
+ }
2191
+ box.append(row);
2192
+ box.append(el("div", "note", t("hookui.animCost")));
2193
+ if (!cur && (show.animated || show.needs_refresh))
2194
+ box.append(el("div", "note note-warn", t("hookui.animNeeds")));
2195
+ return box;
2196
+ }