@azure-id/orc 2.0.2 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/CHANGELOG.md +455 -0
  2. package/README-id.md +52 -23
  3. package/README.md +36 -20
  4. package/bin/build-agents.js +205 -206
  5. package/bin/clear-logs.js +620 -0
  6. package/bin/cli.js +47417 -46420
  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 +5552 -5471
  15. package/bin/verify-package.js +716 -705
  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 +2036 -2036
  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/a-normal-day.md +2 -2
  45. package/mock-run/extra-slots.md +177 -177
  46. package/mock-run/habits.md +1 -1
  47. package/mock-run/orc-aftermath.md +392 -392
  48. package/mock-run/orc-budget.md +29 -33
  49. package/mock-run/orc-cli.md +200 -200
  50. package/mock-run/orc-explain.md +86 -86
  51. package/mock-run/orc-extra.md +389 -392
  52. package/mock-run/orc-fast.md +2 -2
  53. package/mock-run/orc-fix.md +87 -0
  54. package/mock-run/orc-pattern.md +112 -112
  55. package/mock-run/orc-quick.md +146 -146
  56. package/mock-run/orc.md +4 -4
  57. package/package.json +1 -1
  58. package/templates/agents/MODEL-MAPPING.md +176 -173
  59. package/templates/agents/orc-analyze-mini-sonnet-5-high.md +2 -2
  60. package/templates/agents/{orc-claude-writer-opus-5-med.md → orc-claude-writer-opus-5-low.md} +4 -4
  61. package/templates/agents/orc-executor-haiku-4-5.md +1 -1
  62. package/templates/agents/orc-executor-opus-5-low.md +1 -1
  63. package/templates/agents/orc-executor-sonnet-4-6-high.md +1 -1
  64. package/templates/agents/orc-executor-sonnet-4-6-med.md +1 -1
  65. package/templates/agents/orc-executor-sonnet-5-high.md +2 -2
  66. package/templates/agents/orc-executor-sonnet-5-low.md +155 -0
  67. package/templates/agents/orc-executor-sonnet-5-med.md +155 -0
  68. package/templates/agents/orc-pattern-codifier-sonnet-5-high.md +2 -2
  69. package/templates/agents/orc-planner-mini-sonnet-5-high.md +2 -2
  70. package/templates/agents/{orc-recon-sonnet-4-6-med.md → orc-recon-sonnet-5-med.md} +4 -4
  71. package/templates/agents/orc-retro-sonnet-5-high.md +2 -2
  72. package/templates/agents/{orc-reviewer-opus-5-med.md → orc-reviewer-opus-5-low.md} +4 -4
  73. package/templates/agents/{orc-wiki-scanner-opus-5-med.md → orc-wiki-scanner-opus-5-low.md} +3 -3
  74. package/templates/agents/orc-wiki-scanner-sonnet-5-high.md +2 -2
  75. package/templates/commands/orc-fast.md +10 -10
  76. package/templates/commands/orc-fix.md +18 -0
  77. package/templates/commands/orc-wiki.md +64 -64
  78. package/templates/hooks/README.md +75 -7
  79. package/templates/hooks/orc-session-hook.js +78 -3
  80. package/templates/hooks/orc-statusline-render.js +317 -23
  81. package/templates/hooks/orc-statusline.js +162 -49
  82. package/templates/hooks/orc-subagent-line.js +23 -5
  83. package/templates/hooks/orc-weather-fetch.js +123 -0
  84. package/templates/skills/_shared/extra-dispatch.md +1331 -1331
  85. package/templates/skills/_shared/gotchas.md +27 -7
  86. package/templates/skills/_shared/habits.md +2 -2
  87. package/templates/skills/_shared/opus5-only.md +137 -137
  88. package/templates/skills/_shared/phases/review.md +2 -2
  89. package/templates/skills/_shared/phases/trace-verbs.md +17 -3
  90. package/templates/skills/_shared/phases/trace.md +136 -136
  91. package/templates/skills/_shared/review-slice.md +28 -1
  92. package/templates/skills/_shared/wait.md +1 -0
  93. package/templates/skills/orc/README.md +2 -2
  94. package/templates/skills/orc/SKILL.md +227 -227
  95. package/templates/skills/orc/config.md +25 -27
  96. package/templates/skills/orc/examples/full-run-mock.md +74 -73
  97. package/templates/skills/orc/references/effort-and-mode.md +222 -222
  98. package/templates/skills/orc/references/preflight-report.md +3 -3
  99. package/templates/skills/orc/references/ultra-mode.md +1 -1
  100. package/templates/skills/orc/schemas/checkpoint.md +122 -122
  101. package/templates/skills/orc/subskills/orc-review-verify/SKILL.md +3 -3
  102. package/templates/skills/orc/subskills/orc-review-verify/core.md +1 -1
  103. package/templates/skills/orc-analyze/SKILL.md +4 -2
  104. package/templates/skills/orc-claude/SKILL.md +14 -14
  105. package/templates/skills/orc-claude/examples/claude-run-mock.md +65 -65
  106. package/templates/skills/orc-diy/SKILL.md +5 -5
  107. package/templates/skills/orc-fast/SKILL.md +210 -208
  108. package/templates/skills/orc-fix/SKILL.md +110 -0
  109. package/templates/skills/orc-grill/SKILL.md +232 -232
  110. package/templates/skills/orc-grill/references/grill-doc.md +1 -1
  111. package/templates/skills/orc-mini/SKILL.md +4 -4
  112. package/templates/skills/orc-mini/examples/mini-run-mock.md +4 -2
  113. package/templates/skills/orc-quick/README.md +536 -536
  114. package/templates/skills/orc-quick/SKILL.md +2 -2
  115. package/templates/skills/orc-quick/references/context-doc.md +145 -145
  116. package/templates/skills/orc-quick/references/dispatch-gate.md +220 -219
  117. package/templates/skills/orc-quick/references/gh-mode.md +4 -2
  118. package/templates/skills/orc-retro/SKILL.md +1 -1
  119. package/templates/skills/orc-retro/examples/retro-mock.md +16 -16
  120. package/templates/skills/orc-wiki/README.md +325 -325
  121. package/templates/skills/orc-wiki/SKILL.md +195 -195
  122. package/templates/skills/orc-wiki/examples/wiki-run-mock.md +37 -37
  123. package/templates/skills/orc-wiki/references/extra.md +2 -2
  124. package/templates/skills/orc-wiki/references/partial-refresh.md +276 -271
  125. package/templates/skills/orc-wiki/references/phases/phase-0.md +1 -1
  126. package/templates/skills/orc-wiki/references/phases/phase-2.md +3 -3
  127. package/templates/skills/orc-wiki/references/phases/phase-3c.md +3 -2
  128. package/templates/agents/orc-claude-writer-opus-4-8-high.md +0 -48
  129. package/templates/agents/orc-wiki-scanner-opus-4-8-high.md +0 -74
@@ -1,719 +1,726 @@
1
- "use strict";
2
- /* panels/settings.js — orc ui client
3
- The toolbar, the tier accordions, every control kind, the score ladder.
4
-
5
- Loaded by app.html in the order its numeric prefix names. Classic
6
- script, no import/export: an ES module import carries no query string,
7
- and every static request here needs the per-launch session token. */
8
-
9
-
10
- /* ================================================================ SETTINGS == */
11
-
12
- // The tier IDS are the CLI's (`k.tier`); only their display name and blurb are
13
- // ours to translate. A tier name alone does not say why a key lives there, and
14
- // "advanced" reads as "do not touch" when it means "you need a reason".
15
- //
16
- // Written out as a literal map rather than `t("settings.tier." + tier)` so the
17
- // keys stay GREPPABLE: a build of a string key from a fragment is a key no
18
- // coverage check can see, and the check is the only thing standing between a
19
- // renamed key and a raw dotted string on somebody's screen.
20
- const TIER_LABEL_KEY = { common: "settings.tier.common", advanced: "settings.tier.advanced" };
21
- const TIER_DESC_KEY = { common: "settings.tierDesc.common", advanced: "settings.tierDesc.advanced" };
22
- const TIER_LABEL = (tier) => (TIER_LABEL_KEY[tier] ? t(TIER_LABEL_KEY[tier]) : tier);
23
- const TIER_DESC = (tier) => (TIER_DESC_KEY[tier] ? t(TIER_DESC_KEY[tier]) : "");
24
-
25
- // The settings toolbar: find a key by name or description across every tier,
26
- // narrow to just what you have changed, and open/close all sections. It filters
27
- // what is ALREADY rendered — no refetch, so it stays instant at 36 keys.
28
- function settingsToolbar(d, tiers) {
29
- const bar = el("div", "toolbar");
30
-
31
- const search = el("div", "search");
32
- const input = el("input", "text-input");
33
- input.type = "search";
34
- input.id = "settings-filter";
35
- input.placeholder = t("settings.filter");
36
- input.setAttribute("aria-label", t("settings.filterAria"));
37
- search.append(input);
38
- bar.append(search);
39
-
40
- const changedWrap = el("label", "toggle");
41
- const changed = el("input");
42
- changed.type = "checkbox";
43
- const overridden = d.keys.filter((k) => k.is_overridden).length;
44
- changedWrap.append(changed, document.createTextNode(" " + t("settings.changedOnly", { n: overridden })));
45
- changedWrap.title = overridden ? t("settings.changedTitle") : t("settings.changedNone");
46
- if (!overridden) changed.disabled = true;
47
- bar.append(changedWrap);
48
-
49
- const result = el("span", "toolbar-result");
50
- bar.append(result);
51
-
52
- const toggleAll = el("button", "btn btn-ghost btn-sm", t("settings.collapseAll"));
53
- toggleAll.type = "button";
54
- toggleAll.addEventListener("click", () => {
55
- const anyOpen = tiers.some((x) => !x.wrap.classList.contains("collapsed"));
56
- for (const x of tiers) {
57
- x.wrap.classList.toggle("collapsed", anyOpen);
58
- x.wrap.querySelector(".tier-head").setAttribute("aria-expanded", String(!anyOpen));
59
- }
60
- toggleAll.textContent = anyOpen ? t("settings.expandAll") : t("settings.collapseAll");
61
- });
62
- bar.append(toggleAll);
63
-
64
- const apply = () => {
65
- const q = input.value.trim().toLowerCase();
66
- const onlyChanged = changed.checked;
67
- let shown = 0;
68
- for (const x of tiers) {
69
- let visible = 0;
70
- for (const row of x.rows.children) {
71
- const k = row.dataset.key || "";
72
- const desc = (row.querySelector(".setting-desc") || {}).textContent || "";
73
- const hit =
74
- (!q || k.toLowerCase().includes(q) || desc.toLowerCase().includes(q)) &&
75
- (!onlyChanged || row.dataset.overridden === "1");
76
- row.classList.toggle("hidden", !hit);
77
- if (hit) visible++;
78
- }
79
- shown += visible;
80
- x.count.textContent = visible === x.total ? tn(x.total, "settings.keys") : t("settings.ofTotal", { n: visible, total: x.total });
81
- // A tier with no matches is hidden outright — an empty titled box is
82
- // noise between the ones that DID match.
83
- x.wrap.classList.toggle("hidden", visible === 0);
84
- // A filter that matches inside a closed section would look like no match
85
- // at all, so filtering forces the section open.
86
- if ((q || onlyChanged) && visible) {
87
- x.wrap.classList.remove("collapsed");
88
- x.wrap.querySelector(".tier-head").setAttribute("aria-expanded", "true");
89
- }
90
- }
91
- result.textContent = q || onlyChanged ? tn(shown, "settings.matches") : "";
92
- result.classList.toggle("toolbar-result-none", (q || onlyChanged) && shown === 0);
93
- };
94
-
95
- input.addEventListener("input", apply);
96
- changed.addEventListener("change", apply);
97
- // Esc clears rather than blurs: the filter is the thing in your way.
98
- input.addEventListener("keydown", (e) => {
99
- if (e.key === "Escape" && input.value) {
100
- e.stopPropagation();
101
- input.value = "";
102
- apply();
103
- }
104
- });
105
- return bar;
106
- }
107
-
108
- PANELS.settings = function (host) {
109
- const actions = el("div", "row-actions");
110
- const profBtn = el("button", "btn btn-sm", t("settings.profiles"));
111
- profBtn.type = "button";
112
- profBtn.addEventListener("click", showProfiles);
113
- const recBtn = el("button", "btn btn-sm", t("settings.recommend"));
114
- recBtn.type = "button";
115
- recBtn.addEventListener("click", showRecommend);
116
- actions.append(profBtn, recBtn);
117
- head(host, t("settings.title"), t("settings.sub"), actions);
118
-
119
- const body = el("div", "stack");
120
- host.append(body);
121
- renderSettings(body);
122
- };
123
-
124
- async function renderSettings(body) {
125
- body.replaceChildren(skeleton(8));
126
- let d;
127
- try {
128
- d = (await read("/api/config")).data;
129
- } catch (e) {
130
- body.replaceChildren(empty(t("settings.readFail"), String(e.message)));
131
- return;
132
- }
133
-
134
- const out = frag();
135
-
136
- // One edit set per render, so a re-render is also the discard: there is no
137
- // way for a staged value to outlive the data it was staged against.
138
- let bar = null;
139
- const edits = editSet(() => {
140
- if (bar) bar.paint();
141
- for (const row of body.querySelectorAll(".setting[data-key]")) {
142
- row.classList.toggle("staged", edits.map.has(row.dataset.key));
143
- }
144
- });
145
-
146
- const pathCard = card(t("settings.file.title"));
147
- pathCard.append(
148
- kvList([
149
- [t("settings.file.file"), d.config_path],
150
- [t("settings.file.state"), d.exists ? t("settings.file.exists") : t("settings.file.absent")],
151
- ])
152
- );
153
- // Permanently on, deliberately not a key — say so, or somebody hunts for the
154
- // switch that does not exist.
155
- pathCard.append(el("div", "note", t("settings.file.traceNote")));
156
- if ((d.legacy_keys || []).length)
157
- for (const l of d.legacy_keys)
158
- pathCard.append(el("div", "note", `\`${l.key}\` is still in the file — it was renamed to \`${l.renamed_to}\`, and is read as that.`));
159
- out.append(pathCard);
160
-
161
- // The ladder is a DIAGRAM, not an editor, and it re-morphs when opus5_only
162
- // flips — which is how the precedence rule gets taught rather than described.
163
- out.append(ladderCard(d.score_table));
164
-
165
- // WHO DECIDES WHAT (v1.0.0 W16). The ladder above answers one family;
166
- // this answers all of them, and it is the picture a flat key list cannot
167
- // draw: a contested setting is not decided by the key you are looking at,
168
- // it is decided by the highest rank that resolved.
169
- out.append(ranksCard(d.families, d.families_resolved, d.rank_states));
170
-
171
- // 36 keys in three flat lists is a wall, and the answer to "where is the one
172
- // I want" was scrolling. Each tier is now its own collapsible card, and the
173
- // toolbar filters across ALL of them at once — so finding a key never depends
174
- // on knowing which tier somebody filed it under.
175
- const tiers = [];
176
- out.append(settingsToolbar(d, tiers));
177
-
178
- for (const tier of ["common", "advanced"]) {
179
- const keys = d.keys.filter((k) => k.tier === tier);
180
- if (!keys.length) continue;
181
-
182
- const allInert = keys.every((k) => k.is_shadowed);
183
- const wrap = el("div", "card tier");
184
- wrap.dataset.tier = tier;
185
-
186
- const h = el("button", "tier-head");
187
- h.type = "button";
188
- h.setAttribute("aria-expanded", "true");
189
- h.append(el("span", "tier-caret", "▸"));
190
- h.append(el("h2", null, TIER_LABEL(tier)));
191
- const count = el("span", "tier-count", tn(keys.length, "settings.keys"));
192
- h.append(count);
193
- if (allInert) h.append(chip(t("settings.inert"), "warn"));
194
-
195
- const rows = el("div", "tier-rows");
196
- for (const k of keys) rows.append(settingRow(k, body, edits));
197
-
198
- // Collapse is height-animated rather than a display swap, so the rows below
199
- // it move with the section instead of teleporting.
200
- h.addEventListener("click", () => {
201
- const open = h.getAttribute("aria-expanded") === "true";
202
- h.setAttribute("aria-expanded", String(!open));
203
- wrap.classList.toggle("collapsed", open);
204
- });
205
-
206
- // The collapse animates `grid-template-rows: 1fr -> 0fr`, which needs a real
207
- // element child to collapse against — so the body is wrapped, not folded in
208
- // place. `height: auto` cannot be transitioned; this can.
209
- const inner = el("div", "tier-body-inner");
210
- inner.append(el("div", "tier-desc", TIER_DESC(tier)), rows);
211
- const bodyWrap = el("div", "tier-body");
212
- bodyWrap.append(inner);
213
-
214
- wrap.append(h, bodyWrap);
215
- tiers.push({ tier, wrap, rows, count, total: keys.length });
216
- out.append(wrap);
217
- }
218
-
219
- if ((d.hand_edited || []).length) {
220
- const c = card(t("settings.handEdited.title"));
221
- c.append(el("div", "note", t("settings.handEdited.note")));
222
- for (const k of d.hand_edited) {
223
- const row = el("div", "setting" + (k.is_shadowed ? " shadowed" : ""));
224
- const left = el("div");
225
- const name = el("div", "setting-name");
226
- name.append(document.createTextNode(k.key));
227
- if (k.is_shadowed) {
228
- const lock = el("span", "lock");
229
- lock.append(document.createTextNode("🔒 " + t("settings.shadowed")));
230
- name.append(lock);
231
- }
232
- left.append(name);
233
- left.append(el("div", "setting-desc", t("settings.handEdited.readonly")));
234
- if (k.shadow_reason) left.append(el("div", "shadow-why", k.shadow_reason));
235
- const right = el("div", "setting-control");
236
- right.append(el("div", "readonly-value", String(k.value)));
237
- row.append(left, right);
238
- c.append(row);
239
- }
240
- out.append(c);
241
- }
242
-
243
- // A key ORC REMOVED, still sitting in the user's file. It is NOT an editable
244
- // setting and it is NOT a hand-edited override, so it gets its own block —
245
- // rendering it as either would tell the user a dead line still does
246
- // something. The CLI computed the row; this only draws it.
247
- if ((d.retired_keys || []).length) {
248
- const c = card(t("settings.retired.title"));
249
- c.append(el("div", "note", t("settings.retired.note")));
250
- for (const r of d.retired_keys) {
251
- const row = el("div", "setting shadowed");
252
- const left = el("div");
253
- const name = el("div", "setting-name");
254
- name.append(document.createTextNode(r.key));
255
- const lock = el("span", "lock");
256
- lock.append(document.createTextNode("🔒 " + t("settings.retired.badge")));
257
- name.append(lock);
258
- left.append(name);
259
- left.append(el("div", "setting-desc", t("settings.retired.readonly")));
260
- left.append(el("div", "shadow-why", `${t("settings.retired.removedIn")} ${r.removed_in} — ${r.why}`));
261
- const right = el("div", "setting-control");
262
- right.append(el("div", "readonly-value", String(r.value)));
263
- row.append(left, right);
264
- c.append(row);
265
- }
266
- out.append(c);
267
- }
268
-
269
- // Last block on the panel, and it sticks to the bottom of the viewport once
270
- // something is pending: with 36 keys across three tiers, an Apply you have to
271
- // go looking for is an Apply that gets forgotten.
272
- bar = settingsEditBar(edits, body);
273
- out.append(bar);
274
-
275
- body.replaceChildren(out);
276
- }
277
-
278
- // WHO DECIDES WHAT (v1.0.0 W16) — the contested families, resolved.
279
- //
280
- // THE PANEL DECIDES NOTHING HERE. `families` is the CLI's static registry (which
281
- // ranks exist, in which order, and the question each family answers) and
282
- // `families_resolved` is what those ranks actually did against the config on
283
- // disk right now. Re-deriving a precedence in the browser is the exact drift
284
- // this panel exists to make impossible — the Flow-stepper rule, applied to
285
- // config.
286
- //
287
- // Only CONTESTED families are drawn. A family with one rank has no precedence
288
- // to teach, and a card that lists thirty uncontested rows to show two
289
- // interesting ones is a card nobody reads to the bottom of.
290
- //
291
- // Every state word is the CLI's own, from the closed set it publishes as
292
- // `rank_states` — never a friendlier synonym (`resolved`, `not-read`, `inert`,
293
- // `demoted`, `absent`, `partly-resolved`). A `why` is CLI prose and is never
294
- // translated: it names keys, runs, profiles and commands by their real ids.
295
- const RANK_STATE_KIND = {
296
- resolved: "ok",
297
- "partly-resolved": "ok",
298
- "not-read": null,
299
- inert: "warn",
300
- demoted: "warn",
301
- absent: null,
302
- };
303
-
304
- function ranksCard(families, resolved, states) {
305
- const c = card(t("settings.ranks.title"));
306
- c.id = "ranks-card";
307
- c.append(el("div", "note", t("settings.ranks.note")));
308
-
309
- const names = Object.keys(families || {}).filter((n) => (families[n] || {}).contested);
310
- if (!names.length) {
311
- c.append(el("div", "empty", t("settings.ranks.none")));
312
- return c;
313
- }
314
-
315
- for (const name of names) {
316
- const fam = families[name] || {};
317
- const res = (resolved || {})[name] || {};
318
- const block = el("div", "rank-family");
319
-
320
- const h = el("div", "rank-family-head");
321
- // The family id is a CLI id. The QUESTION beside it is the CLI's too —
322
- // it is the whole reason the grouping means anything.
323
- h.append(el("span", "rank-family-name", name));
324
- h.append(el("span", "rank-family-q", fam.question || ""));
325
- block.append(h);
326
-
327
- // WHAT ANSWERED, stated before the ladder rather than left to be inferred
328
- // from which row happens to be green. `resolved_by` is null on a terminal
329
- // rank, which is not a gap: it means nothing above the floor resolved.
330
- if (res.resolved_at) {
331
- const by = el("div", "rank-answer");
332
- by.append(
333
- document.createTextNode(
334
- res.resolved_by
335
- ? t("settings.ranks.answeredBy", { prio: res.resolved_at, key: res.resolved_by })
336
- : t("settings.ranks.answeredByDefault", { prio: res.resolved_at })
337
- )
338
- );
339
- block.append(by);
340
- }
341
-
342
- for (const r of res.ranks || fam.ranks || []) {
343
- const row = el("div", "rank-row" + (r.state === "resolved" ? " is-resolved" : ""));
344
- row.append(el("span", "rank-prio", r.prio));
345
- // A rank is either a KEY or the family's terminal floor. The floor has no
346
- // key on purpose — it is "the thing that happens when nobody chose" — and
347
- // rendering it as a key would invite somebody to look for it in the file.
348
- row.append(el("span", "rank-key" + (r.key ? "" : " is-terminal"), r.key || r.terminal || ""));
349
- const st = r.state || "absent";
350
- // An unknown state is RENDERED, not swallowed: the CLI owns this set, and
351
- // a panel that silently drops a word it does not recognise is a panel that
352
- // hides the next state somebody adds.
353
- row.append(chip(st, RANK_STATE_KIND[st] === undefined ? null : RANK_STATE_KIND[st]));
354
- if (r.why) row.append(el("div", "rank-why", r.why));
355
- block.append(row);
356
- }
357
- c.append(block);
358
- }
359
-
360
- // The closed set, once, at the bottom. It is what makes an unfamiliar chip
361
- // above readable without leaving the page.
362
- if ((states || []).length) {
363
- const legend = el("div", "rank-legend");
364
- legend.append(el("span", "rank-legend-label", t("settings.ranks.legend")));
365
- for (const s of states) legend.append(chip(s, RANK_STATE_KIND[s] === undefined ? null : RANK_STATE_KIND[s]));
366
- c.append(legend);
367
- }
368
- return c;
369
- }
370
-
371
- function ladderCard(table) {
372
- const c = card(t("settings.ladder.title"));
373
- c.id = "ladder-card"; // the FLIP morph finds it by id, never by a :has() query
374
- // `base` is the CLAUDE table that resolves. Since v0.50.0 `active` can be a
375
- // COMPOSITE (`extra+opus5_only`) because an `orc extra` route row overlays
376
- // the bands it covers — so branching on `active` here would fall through to
377
- // the default arm and draw the WRONG ladder the moment Extra is armed. The
378
- // overlay itself is the Extra panel's rail; this card stays the Claude
379
- // ladder, which is exactly what it has always been.
380
- const active = table.base || table.active;
381
- c.append(
382
- el(
383
- "div",
384
- "note",
385
- active === "opus5_only"
386
- ? t("settings.ladder.opus5")
387
- : active === "rubric_bands_override"
388
- ? t("settings.ladder.override")
389
- : t("settings.ladder.default")
390
- )
391
- );
392
- const ladder = el("div", "ladder");
393
- ladder.id = "ladder";
394
- const rows = active === "opus5_only" ? table.opus5_only : table.default;
395
- for (const r of rows) {
396
- const rung = el("div", "rung");
397
- rung.dataset.agent = r.agent;
398
- rung.append(el("span", "rung-band", `[${r.from},${r.to}${r.inclusive_to ? "]" : ")"}`));
399
- const right = el("div");
400
- const bar = el("div", "rung-bar");
401
- bar.style.width = Math.max(6, ((r.to - r.from) / 100) * 100) + "%";
402
- right.append(bar, el("div", "rung-agent", r.agent));
403
- rung.append(right);
404
- ladder.append(rung);
405
- }
406
- c.append(ladder);
407
- c.append(el("div", "ladder-note", t("settings.ladder.note")));
408
- return c;
409
- }
410
-
411
- // FLIP: measure the old rungs, swap in the new table, then animate each rung
412
- // from where it used to be. The morph is what makes the precedence rule land.
413
- function morphLadder(oldCard, newCard) {
414
- const before = new Map();
415
- for (const r of oldCard.querySelectorAll(".rung")) before.set(r.dataset.agent, r.getBoundingClientRect());
416
- oldCard.replaceWith(newCard);
417
- if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
418
- for (const r of newCard.querySelectorAll(".rung")) {
419
- const from = before.get(r.dataset.agent);
420
- if (!from) {
421
- r.animate([{ opacity: 0, transform: "scaleY(.4)" }, { opacity: 1, transform: "none" }], { duration: 220, easing: "cubic-bezier(.2,.7,.3,1)" });
422
- continue;
423
- }
424
- const to = r.getBoundingClientRect();
425
- const dy = from.top - to.top;
426
- if (!dy) continue;
427
- r.animate([{ transform: `translateY(${dy}px)` }, { transform: "none" }], { duration: 260, easing: "cubic-bezier(.2,.7,.3,1)" });
428
- }
429
- }
430
-
431
- function settingRow(k, panelBody, edits) {
432
- const row = el("div", "setting" + (k.is_shadowed ? " shadowed" : ""));
433
- row.dataset.key = k.key;
434
- // Read by the toolbar's "changed only" filter. It is on the row rather than
435
- // recomputed from the control, because the control's shape differs per kind.
436
- row.dataset.overridden = k.is_overridden ? "1" : "0";
437
-
438
- const left = el("div");
439
- const name = el("div", "setting-name");
440
- name.append(document.createTextNode(k.key));
441
- if (k.is_overridden) name.append(el("span", "dot"));
442
- if (k.is_shadowed) {
443
- const lock = el("span", "lock");
444
- lock.append(document.createTextNode("🔒 " + t("settings.shadowed")));
445
- name.append(lock);
446
- }
447
- left.append(name);
448
- // `k.desc` and `k.shadow_reason` are the CLI's registry text — never
449
- // translated. They name keys, values and precedence rules by their real ids.
450
- left.append(el("div", "setting-desc", k.desc));
451
- if (k.shadow_reason) left.append(el("div", "shadow-why", k.shadow_reason));
452
- // WHICH LANES READ THIS (v1.0.0 W16). `k.lanes[]` is the CLI's own
453
- // `CONFIG_META[].lanes[]` — the same list `orc lane config` filters on, and
454
- // the same list a two-way lint already refuses to let drift. It answers the
455
- // question a settings screen otherwise leaves you guessing at: I changed
456
- // this, so what did I just change? A lane name is a CLI id and is never
457
- // translated.
458
- //
459
- // AN EMPTY LIST IS AN ANSWER AND KEEPS ITS ROW. Ten keys are permanently
460
- // empty and the lint has an allowlist naming every one of them: they are
461
- // OPERATING keys of the `orc extra` bridge — a lane calls `orc extra
462
- // dispatch` and the CLI reads `extra_timeout_s`, so no lane ever names it.
463
- // Skipping the line would make "no lane reads this" look identical to "we
464
- // did not render it", which is the distinction this whole row exists to
465
- // draw.
466
- {
467
- const lanes = el("div", "setting-lanes");
468
- lanes.append(el("span", "setting-lanes-label", t("settings.lanes.label")));
469
- if ((k.lanes || []).length) for (const lane of k.lanes) lanes.append(chip(lane, "lane"));
470
- else lanes.append(el("span", "setting-lanes-none", t("settings.lanes.none")));
471
- left.append(lanes);
472
- }
473
-
474
- const right = el("div", "setting-control");
475
- right.append(controlFor(k, edits));
476
- if (k.is_overridden) {
477
- const reset = el("button", "btn btn-ghost btn-sm", t("setting.resetTo", { value: String(k.default) }));
478
- reset.type = "button";
479
- reset.addEventListener("click", () => edits.reset(k.key));
480
- right.append(reset);
481
- }
482
-
483
- row.append(left, right);
484
- return row;
485
- }
486
-
487
- // The control follows the VALIDATOR, not a hand-kept table: enum → segmented,
488
- // int/range → stepper with the options list as presets, path/repo/model → a
489
- // text input whose validation is the CLI's own exit code.
490
- //
491
- // Since v0.44.1 a control STAGES its value instead of writing it. Each one also
492
- // repaints its own selected state from the staged value, because nothing
493
- // re-renders until Apply — a segmented control that does not follow your click
494
- // would look broken, and a click that neither writes nor moves is worse than no
495
- // control at all.
496
- function controlFor(k, edits) {
497
- const c = k.control || { kind: "text" };
498
- const original = String(k.value);
499
- const stage = (value) => edits.set(k.key, String(value), original);
500
-
501
- if (c.kind === "enum") {
502
- const choices = c.choices || k.options || [];
503
- const seg = el("div", "seg");
504
- const paint = (v) => {
505
- for (const b of seg.children) b.setAttribute("aria-pressed", String(b.dataset.value === String(v)));
506
- };
507
- for (const opt of choices) {
508
- const b = el("button", null, String(opt));
509
- b.type = "button";
510
- b.dataset.value = String(opt);
511
- b.addEventListener("click", () => {
512
- stage(String(opt));
513
- paint(String(opt));
514
- });
515
- seg.append(b);
516
- }
517
- paint(original);
518
- return seg;
519
- }
520
-
521
- if (c.kind === "int" || c.kind === "range") {
522
- const wrap = el("div", "stepper");
523
- const input = el("input");
524
- input.type = "number";
525
- input.value = original;
526
- if (c.min !== null && c.min !== undefined) input.min = String(c.min);
527
- if (c.max !== null && c.max !== undefined) input.max = String(c.max);
528
- // No `set` button here any more: the edit bar's Apply is the one commit
529
- // point, so a second button beside every number would be two ideas of what
530
- // "save" means.
531
- input.addEventListener("input", () => stage(input.value));
532
- wrap.append(input);
533
- const presets = (k.options || []).filter((o) => String(o) !== original);
534
- if (presets.length) {
535
- const seg = el("div", "seg");
536
- for (const p of presets.slice(0, 5)) {
537
- const b = el("button", null, String(p));
538
- b.type = "button";
539
- b.addEventListener("click", () => {
540
- input.value = String(p);
541
- stage(String(p));
542
- });
543
- seg.append(b);
544
- }
545
- const box = el("div");
546
- box.append(wrap, seg);
547
- box.className = "setting-control";
548
- return box;
549
- }
550
- return wrap;
551
- }
552
-
553
- if (c.kind === "subset") {
554
- const chosen = new Set(
555
- original
556
- .replace(/^\[|\]$/g, "")
557
- .split(",")
558
- .map((s) => s.trim())
559
- .filter(Boolean)
560
- );
561
- const seg = el("div", "seg");
562
- for (const opt of c.choices || []) {
563
- const b = el("button", null, String(opt));
564
- b.type = "button";
565
- b.setAttribute("aria-pressed", String(chosen.has(opt)));
566
- b.addEventListener("click", () => {
567
- chosen.has(opt) ? chosen.delete(opt) : chosen.add(opt);
568
- b.setAttribute("aria-pressed", String(chosen.has(opt)));
569
- stage([...chosen].join(",") || "");
570
- });
571
- seg.append(b);
572
- }
573
- return seg;
574
- }
575
-
576
- // path / repo / free text — validation comes from the CLI's exit code, which
577
- // means the rules can never drift from the ones a terminal user gets.
578
- const wrap = el("div");
579
- wrap.className = "setting-control";
580
- const input = el("input", "text-input");
581
- input.value = original;
582
- input.addEventListener("input", () => stage(input.value));
583
- wrap.append(input);
584
- return wrap;
585
- }
586
-
587
- // The Settings edit bar. Reset is `orc config reset` with NO key — the CLI's
588
- // own "put every key back to its default", not a loop of per-key writes. It is
589
- // a real write, so it is confirmed and it discards anything staged first: a
590
- // reset that silently kept four pending edits queued behind it would apply them
591
- // straight back over the defaults.
592
- function settingsEditBar(edits, panelBody) {
593
- const bar = editBar(edits, {
594
- resetLabel: t("edits.resetAll"),
595
- onApply: async (btn) => {
596
- await applyEdits(edits, { set: "/api/config/set", reset: "/api/config/reset" }, btn);
597
- await rerenderSettings(panelBody);
598
- },
599
- onReset: () => {
600
- modal({
601
- title: t("edits.resetAllTitle"),
602
- body: (() => {
603
- const box = el("div", "stack stack-sm");
604
- box.append(el("div", null, t("edits.resetAllBody")));
605
- box.append(el("div", "action-cmd", "orc config reset"));
606
- return box;
607
- })(),
608
- actions: [
609
- { label: t("common.cancel"), onClick: (c) => c() },
610
- {
611
- label: t("edits.resetAllApply"),
612
- cls: "btn-danger",
613
- onClick: async (close) => {
614
- close();
615
- edits.clear();
616
- const r = await post("/api/config/reset", {});
617
- toast(r.command, r.ok ? "ok" : "bad", r.output);
618
- await rerenderSettings(panelBody);
619
- },
620
- },
621
- ],
622
- });
623
- },
624
- onCancel: () => rerenderSettings(panelBody),
625
- });
626
- return bar;
627
- }
628
-
629
- // A re-render must not lose the ladder morph, so the ladder is swapped through
630
- // FLIP while everything else is replaced outright.
631
- async function rerenderSettings(panelBody) {
632
- const old = panelBody.querySelector("#ladder-card");
633
- const snapshot = old ? old.cloneNode(true) : null;
634
- await renderSettings(panelBody);
635
- const fresh = panelBody.querySelector("#ladder-card");
636
- // Only morph when the table ACTUALLY changed — i.e. opus5_only was flipped.
637
- // Any other setting re-renders without a distracting animation.
638
- if (snapshot && fresh && snapshot.textContent !== fresh.textContent) {
639
- // Put the old one back for a beat so FLIP has real geometry to measure.
640
- fresh.parentNode.replaceChild(snapshot, fresh);
641
- morphLadder(snapshot, fresh);
642
- }
643
- }
644
-
645
- async function showProfiles() {
646
- const d = (await read("/api/config/profiles")).data;
647
- const body = el("div", "stack stack-sm");
648
- body.append(el("div", "note", t("profiles.note")));
649
- for (const p of d.profiles) {
650
- const c = el("div", "action");
651
- const left = el("div");
652
- // Profile name and description come from the CLI's registry — untranslated.
653
- left.append(el("div", "setting-name", p.name));
654
- left.append(el("div", "setting-desc", p.desc));
655
- if (p.changes.length) {
656
- const list = el("div", "note");
657
- list.textContent = t("profiles.wouldChange", {
658
- list: p.changes.map((c2) => `${c2.key} ${c2.from} → ${c2.to}`).join(", "),
659
- });
660
- left.append(list);
661
- } else left.append(el("div", "note", t("profiles.noChange")));
662
- const apply = el("button", "btn btn-sm btn-allow-busy" + (p.changes.length ? " btn-primary" : ""), t("profiles.apply"));
663
- apply.type = "button";
664
- apply.disabled = !p.changes.length;
665
- apply.addEventListener("click", async () => {
666
- const r = await post("/api/config/profile", { name: p.name });
667
- toast(r.command, r.ok ? "ok" : "bad", r.output);
668
- close();
669
- route();
670
- });
671
- c.append(left, apply);
672
- body.append(c);
673
- }
674
- const close = modal({ title: t("profiles.title"), body, actions: [{ label: t("common.close"), onClick: (c) => c() }] });
675
- }
676
-
677
- async function showRecommend() {
678
- const d = (await read("/api/config/recommend")).data;
679
- const body = el("div", "stack stack-sm");
680
- body.append(el("div", "note", t("recommend.note")));
681
- const list = el("ul");
682
- for (const r of d.reasons) {
683
- const li = el("li", "note", "• " + r);
684
- list.append(li);
685
- }
686
- body.append(list);
687
- const pick = el("div", "action");
688
- const left = el("div");
689
- left.append(el("div", "setting-name", d.recommended));
690
- left.append(el("div", "setting-desc", d.desc));
691
- const apply = el("button", "btn btn-sm btn-primary btn-allow-busy", t("recommend.applyIt", { name: d.recommended }));
692
- apply.type = "button";
693
- apply.addEventListener("click", async () => {
694
- const r = await post("/api/config/profile", { name: d.recommended });
695
- toast(r.command, r.ok ? "ok" : "bad", r.output);
696
- close();
697
- route();
698
- });
699
- pick.append(left, apply);
700
- body.append(pick);
701
- const close = modal({ title: t("recommend.title"), body, actions: [{ label: t("common.close"), onClick: (c) => c() }] });
702
- }
703
-
704
- /* ==================================================================== RUNS == */
705
-
706
- /* THE LIST IS THE DETAIL VIEW (v0.43.6).
707
- This panel used to be a list with a detail CARD underneath it: clicking the
708
- fourth run rendered its checkpoint below run forty. On a repo with any
709
- history that means scrolling past the entire list to read what you just
710
- clicked, and then scrolling back up to click the next one — the list grows,
711
- so the problem grows with it, which is the shape of a design that does not
712
- survive its own success.
713
-
714
- Now every row EXPANDS IN PLACE. The detail is a child of the row, animated
715
- open with the same `grid-template-rows: 0fr -> 1fr` fold the settings tiers
716
- use, so what you clicked stays exactly where your eye already is. One row is
717
- open at a time — an accordion, not a set of toggles — because two open runs
718
- re-create the scrolling problem in miniature. Detail is fetched on FIRST open
719
- and then kept, so re-opening a row is instant and costs nothing. */
1
+ "use strict";
2
+ /* panels/settings.js — orc ui client
3
+ The toolbar, the tier accordions, every control kind, the score ladder.
4
+
5
+ Loaded by app.html in the order its numeric prefix names. Classic
6
+ script, no import/export: an ES module import carries no query string,
7
+ and every static request here needs the per-launch session token. */
8
+
9
+
10
+ /* ================================================================ SETTINGS == */
11
+
12
+ // The tier IDS are the CLI's (`k.tier`); only their display name and blurb are
13
+ // ours to translate. A tier name alone does not say why a key lives there, and
14
+ // "advanced" reads as "do not touch" when it means "you need a reason".
15
+ //
16
+ // Written out as a literal map rather than `t("settings.tier." + tier)` so the
17
+ // keys stay GREPPABLE: a build of a string key from a fragment is a key no
18
+ // coverage check can see, and the check is the only thing standing between a
19
+ // renamed key and a raw dotted string on somebody's screen.
20
+ const TIER_LABEL_KEY = { common: "settings.tier.common", advanced: "settings.tier.advanced" };
21
+ const TIER_DESC_KEY = { common: "settings.tierDesc.common", advanced: "settings.tierDesc.advanced" };
22
+ const TIER_LABEL = (tier) => (TIER_LABEL_KEY[tier] ? t(TIER_LABEL_KEY[tier]) : tier);
23
+ const TIER_DESC = (tier) => (TIER_DESC_KEY[tier] ? t(TIER_DESC_KEY[tier]) : "");
24
+
25
+ // The settings toolbar: find a key by name or description across every tier,
26
+ // narrow to just what you have changed, and open/close all sections. It filters
27
+ // what is ALREADY rendered — no refetch, so it stays instant at 36 keys.
28
+ function settingsToolbar(d, tiers) {
29
+ const bar = el("div", "toolbar");
30
+
31
+ const search = el("div", "search");
32
+ const input = el("input", "text-input");
33
+ input.type = "search";
34
+ input.id = "settings-filter";
35
+ input.placeholder = t("settings.filter");
36
+ input.setAttribute("aria-label", t("settings.filterAria"));
37
+ search.append(input);
38
+ bar.append(search);
39
+
40
+ const changedWrap = el("label", "toggle");
41
+ const changed = el("input");
42
+ changed.type = "checkbox";
43
+ const overridden = d.keys.filter((k) => k.is_overridden).length;
44
+ changedWrap.append(changed, document.createTextNode(" " + t("settings.changedOnly", { n: overridden })));
45
+ changedWrap.title = overridden ? t("settings.changedTitle") : t("settings.changedNone");
46
+ if (!overridden) changed.disabled = true;
47
+ bar.append(changedWrap);
48
+
49
+ const result = el("span", "toolbar-result");
50
+ bar.append(result);
51
+
52
+ const toggleAll = el("button", "btn btn-ghost btn-sm", t("settings.collapseAll"));
53
+ toggleAll.type = "button";
54
+ toggleAll.addEventListener("click", () => {
55
+ const anyOpen = tiers.some((x) => !x.wrap.classList.contains("collapsed"));
56
+ for (const x of tiers) {
57
+ x.wrap.classList.toggle("collapsed", anyOpen);
58
+ x.wrap.querySelector(".tier-head").setAttribute("aria-expanded", String(!anyOpen));
59
+ }
60
+ toggleAll.textContent = anyOpen ? t("settings.expandAll") : t("settings.collapseAll");
61
+ });
62
+ bar.append(toggleAll);
63
+
64
+ const apply = () => {
65
+ const q = input.value.trim().toLowerCase();
66
+ const onlyChanged = changed.checked;
67
+ let shown = 0;
68
+ for (const x of tiers) {
69
+ let visible = 0;
70
+ for (const row of x.rows.children) {
71
+ const k = row.dataset.key || "";
72
+ const desc = (row.querySelector(".setting-desc") || {}).textContent || "";
73
+ const hit =
74
+ (!q || k.toLowerCase().includes(q) || desc.toLowerCase().includes(q)) &&
75
+ (!onlyChanged || row.dataset.overridden === "1");
76
+ row.classList.toggle("hidden", !hit);
77
+ if (hit) visible++;
78
+ }
79
+ shown += visible;
80
+ x.count.textContent = visible === x.total ? tn(x.total, "settings.keys") : t("settings.ofTotal", { n: visible, total: x.total });
81
+ // A tier with no matches is hidden outright — an empty titled box is
82
+ // noise between the ones that DID match.
83
+ x.wrap.classList.toggle("hidden", visible === 0);
84
+ // A filter that matches inside a closed section would look like no match
85
+ // at all, so filtering forces the section open.
86
+ if ((q || onlyChanged) && visible) {
87
+ x.wrap.classList.remove("collapsed");
88
+ x.wrap.querySelector(".tier-head").setAttribute("aria-expanded", "true");
89
+ }
90
+ }
91
+ result.textContent = q || onlyChanged ? tn(shown, "settings.matches") : "";
92
+ result.classList.toggle("toolbar-result-none", (q || onlyChanged) && shown === 0);
93
+ };
94
+
95
+ input.addEventListener("input", apply);
96
+ changed.addEventListener("change", apply);
97
+ // Esc clears rather than blurs: the filter is the thing in your way.
98
+ input.addEventListener("keydown", (e) => {
99
+ if (e.key === "Escape" && input.value) {
100
+ e.stopPropagation();
101
+ input.value = "";
102
+ apply();
103
+ }
104
+ });
105
+ // `#/settings?key=<key>` (v2.1.0 W7 — the Maintenance row links its day
106
+ // value here) opens the panel filtered to that one key.
107
+ const want = (/[?&]key=([a-z0-9_]+)/.exec(location.hash) || [])[1];
108
+ if (want) {
109
+ input.value = want;
110
+ setTimeout(apply, 0);
111
+ }
112
+ return bar;
113
+ }
114
+
115
+ PANELS.settings = function (host) {
116
+ const actions = el("div", "row-actions");
117
+ const profBtn = el("button", "btn btn-sm", t("settings.profiles"));
118
+ profBtn.type = "button";
119
+ profBtn.addEventListener("click", showProfiles);
120
+ const recBtn = el("button", "btn btn-sm", t("settings.recommend"));
121
+ recBtn.type = "button";
122
+ recBtn.addEventListener("click", showRecommend);
123
+ actions.append(profBtn, recBtn);
124
+ head(host, t("settings.title"), t("settings.sub"), actions);
125
+
126
+ const body = el("div", "stack");
127
+ host.append(body);
128
+ renderSettings(body);
129
+ };
130
+
131
+ async function renderSettings(body) {
132
+ body.replaceChildren(skeleton(8));
133
+ let d;
134
+ try {
135
+ d = (await read("/api/config")).data;
136
+ } catch (e) {
137
+ body.replaceChildren(empty(t("settings.readFail"), String(e.message)));
138
+ return;
139
+ }
140
+
141
+ const out = frag();
142
+
143
+ // One edit set per render, so a re-render is also the discard: there is no
144
+ // way for a staged value to outlive the data it was staged against.
145
+ let bar = null;
146
+ const edits = editSet(() => {
147
+ if (bar) bar.paint();
148
+ for (const row of body.querySelectorAll(".setting[data-key]")) {
149
+ row.classList.toggle("staged", edits.map.has(row.dataset.key));
150
+ }
151
+ });
152
+
153
+ const pathCard = card(t("settings.file.title"));
154
+ pathCard.append(
155
+ kvList([
156
+ [t("settings.file.file"), d.config_path],
157
+ [t("settings.file.state"), d.exists ? t("settings.file.exists") : t("settings.file.absent")],
158
+ ])
159
+ );
160
+ // Permanently on, deliberately not a key — say so, or somebody hunts for the
161
+ // switch that does not exist.
162
+ pathCard.append(el("div", "note", t("settings.file.traceNote")));
163
+ if ((d.legacy_keys || []).length)
164
+ for (const l of d.legacy_keys)
165
+ pathCard.append(el("div", "note", `\`${l.key}\` is still in the file — it was renamed to \`${l.renamed_to}\`, and is read as that.`));
166
+ out.append(pathCard);
167
+
168
+ // The ladder is a DIAGRAM, not an editor, and it re-morphs when opus5_only
169
+ // flips — which is how the precedence rule gets taught rather than described.
170
+ out.append(ladderCard(d.score_table));
171
+
172
+ // WHO DECIDES WHAT (v1.0.0 W16). The ladder above answers one family;
173
+ // this answers all of them, and it is the picture a flat key list cannot
174
+ // draw: a contested setting is not decided by the key you are looking at,
175
+ // it is decided by the highest rank that resolved.
176
+ out.append(ranksCard(d.families, d.families_resolved, d.rank_states));
177
+
178
+ // 36 keys in three flat lists is a wall, and the answer to "where is the one
179
+ // I want" was scrolling. Each tier is now its own collapsible card, and the
180
+ // toolbar filters across ALL of them at once — so finding a key never depends
181
+ // on knowing which tier somebody filed it under.
182
+ const tiers = [];
183
+ out.append(settingsToolbar(d, tiers));
184
+
185
+ for (const tier of ["common", "advanced"]) {
186
+ const keys = d.keys.filter((k) => k.tier === tier);
187
+ if (!keys.length) continue;
188
+
189
+ const allInert = keys.every((k) => k.is_shadowed);
190
+ const wrap = el("div", "card tier");
191
+ wrap.dataset.tier = tier;
192
+
193
+ const h = el("button", "tier-head");
194
+ h.type = "button";
195
+ h.setAttribute("aria-expanded", "true");
196
+ h.append(el("span", "tier-caret", "▸"));
197
+ h.append(el("h2", null, TIER_LABEL(tier)));
198
+ const count = el("span", "tier-count", tn(keys.length, "settings.keys"));
199
+ h.append(count);
200
+ if (allInert) h.append(chip(t("settings.inert"), "warn"));
201
+
202
+ const rows = el("div", "tier-rows");
203
+ for (const k of keys) rows.append(settingRow(k, body, edits));
204
+
205
+ // Collapse is height-animated rather than a display swap, so the rows below
206
+ // it move with the section instead of teleporting.
207
+ h.addEventListener("click", () => {
208
+ const open = h.getAttribute("aria-expanded") === "true";
209
+ h.setAttribute("aria-expanded", String(!open));
210
+ wrap.classList.toggle("collapsed", open);
211
+ });
212
+
213
+ // The collapse animates `grid-template-rows: 1fr -> 0fr`, which needs a real
214
+ // element child to collapse against — so the body is wrapped, not folded in
215
+ // place. `height: auto` cannot be transitioned; this can.
216
+ const inner = el("div", "tier-body-inner");
217
+ inner.append(el("div", "tier-desc", TIER_DESC(tier)), rows);
218
+ const bodyWrap = el("div", "tier-body");
219
+ bodyWrap.append(inner);
220
+
221
+ wrap.append(h, bodyWrap);
222
+ tiers.push({ tier, wrap, rows, count, total: keys.length });
223
+ out.append(wrap);
224
+ }
225
+
226
+ if ((d.hand_edited || []).length) {
227
+ const c = card(t("settings.handEdited.title"));
228
+ c.append(el("div", "note", t("settings.handEdited.note")));
229
+ for (const k of d.hand_edited) {
230
+ const row = el("div", "setting" + (k.is_shadowed ? " shadowed" : ""));
231
+ const left = el("div");
232
+ const name = el("div", "setting-name");
233
+ name.append(document.createTextNode(k.key));
234
+ if (k.is_shadowed) {
235
+ const lock = el("span", "lock");
236
+ lock.append(document.createTextNode("🔒 " + t("settings.shadowed")));
237
+ name.append(lock);
238
+ }
239
+ left.append(name);
240
+ left.append(el("div", "setting-desc", t("settings.handEdited.readonly")));
241
+ if (k.shadow_reason) left.append(el("div", "shadow-why", k.shadow_reason));
242
+ const right = el("div", "setting-control");
243
+ right.append(el("div", "readonly-value", String(k.value)));
244
+ row.append(left, right);
245
+ c.append(row);
246
+ }
247
+ out.append(c);
248
+ }
249
+
250
+ // A key ORC REMOVED, still sitting in the user's file. It is NOT an editable
251
+ // setting and it is NOT a hand-edited override, so it gets its own block —
252
+ // rendering it as either would tell the user a dead line still does
253
+ // something. The CLI computed the row; this only draws it.
254
+ if ((d.retired_keys || []).length) {
255
+ const c = card(t("settings.retired.title"));
256
+ c.append(el("div", "note", t("settings.retired.note")));
257
+ for (const r of d.retired_keys) {
258
+ const row = el("div", "setting shadowed");
259
+ const left = el("div");
260
+ const name = el("div", "setting-name");
261
+ name.append(document.createTextNode(r.key));
262
+ const lock = el("span", "lock");
263
+ lock.append(document.createTextNode("🔒 " + t("settings.retired.badge")));
264
+ name.append(lock);
265
+ left.append(name);
266
+ left.append(el("div", "setting-desc", t("settings.retired.readonly")));
267
+ left.append(el("div", "shadow-why", `${t("settings.retired.removedIn")} ${r.removed_in} — ${r.why}`));
268
+ const right = el("div", "setting-control");
269
+ right.append(el("div", "readonly-value", String(r.value)));
270
+ row.append(left, right);
271
+ c.append(row);
272
+ }
273
+ out.append(c);
274
+ }
275
+
276
+ // Last block on the panel, and it sticks to the bottom of the viewport once
277
+ // something is pending: with 36 keys across three tiers, an Apply you have to
278
+ // go looking for is an Apply that gets forgotten.
279
+ bar = settingsEditBar(edits, body);
280
+ out.append(bar);
281
+
282
+ body.replaceChildren(out);
283
+ }
284
+
285
+ // WHO DECIDES WHAT (v1.0.0 W16) — the contested families, resolved.
286
+ //
287
+ // THE PANEL DECIDES NOTHING HERE. `families` is the CLI's static registry (which
288
+ // ranks exist, in which order, and the question each family answers) and
289
+ // `families_resolved` is what those ranks actually did against the config on
290
+ // disk right now. Re-deriving a precedence in the browser is the exact drift
291
+ // this panel exists to make impossible — the Flow-stepper rule, applied to
292
+ // config.
293
+ //
294
+ // Only CONTESTED families are drawn. A family with one rank has no precedence
295
+ // to teach, and a card that lists thirty uncontested rows to show two
296
+ // interesting ones is a card nobody reads to the bottom of.
297
+ //
298
+ // Every state word is the CLI's own, from the closed set it publishes as
299
+ // `rank_states` — never a friendlier synonym (`resolved`, `not-read`, `inert`,
300
+ // `demoted`, `absent`, `partly-resolved`). A `why` is CLI prose and is never
301
+ // translated: it names keys, runs, profiles and commands by their real ids.
302
+ const RANK_STATE_KIND = {
303
+ resolved: "ok",
304
+ "partly-resolved": "ok",
305
+ "not-read": null,
306
+ inert: "warn",
307
+ demoted: "warn",
308
+ absent: null,
309
+ };
310
+
311
+ function ranksCard(families, resolved, states) {
312
+ const c = card(t("settings.ranks.title"));
313
+ c.id = "ranks-card";
314
+ c.append(el("div", "note", t("settings.ranks.note")));
315
+
316
+ const names = Object.keys(families || {}).filter((n) => (families[n] || {}).contested);
317
+ if (!names.length) {
318
+ c.append(el("div", "empty", t("settings.ranks.none")));
319
+ return c;
320
+ }
321
+
322
+ for (const name of names) {
323
+ const fam = families[name] || {};
324
+ const res = (resolved || {})[name] || {};
325
+ const block = el("div", "rank-family");
326
+
327
+ const h = el("div", "rank-family-head");
328
+ // The family id is a CLI id. The QUESTION beside it is the CLI's too —
329
+ // it is the whole reason the grouping means anything.
330
+ h.append(el("span", "rank-family-name", name));
331
+ h.append(el("span", "rank-family-q", fam.question || ""));
332
+ block.append(h);
333
+
334
+ // WHAT ANSWERED, stated before the ladder rather than left to be inferred
335
+ // from which row happens to be green. `resolved_by` is null on a terminal
336
+ // rank, which is not a gap: it means nothing above the floor resolved.
337
+ if (res.resolved_at) {
338
+ const by = el("div", "rank-answer");
339
+ by.append(
340
+ document.createTextNode(
341
+ res.resolved_by
342
+ ? t("settings.ranks.answeredBy", { prio: res.resolved_at, key: res.resolved_by })
343
+ : t("settings.ranks.answeredByDefault", { prio: res.resolved_at })
344
+ )
345
+ );
346
+ block.append(by);
347
+ }
348
+
349
+ for (const r of res.ranks || fam.ranks || []) {
350
+ const row = el("div", "rank-row" + (r.state === "resolved" ? " is-resolved" : ""));
351
+ row.append(el("span", "rank-prio", r.prio));
352
+ // A rank is either a KEY or the family's terminal floor. The floor has no
353
+ // key on purpose — it is "the thing that happens when nobody chose" — and
354
+ // rendering it as a key would invite somebody to look for it in the file.
355
+ row.append(el("span", "rank-key" + (r.key ? "" : " is-terminal"), r.key || r.terminal || ""));
356
+ const st = r.state || "absent";
357
+ // An unknown state is RENDERED, not swallowed: the CLI owns this set, and
358
+ // a panel that silently drops a word it does not recognise is a panel that
359
+ // hides the next state somebody adds.
360
+ row.append(chip(st, RANK_STATE_KIND[st] === undefined ? null : RANK_STATE_KIND[st]));
361
+ if (r.why) row.append(el("div", "rank-why", r.why));
362
+ block.append(row);
363
+ }
364
+ c.append(block);
365
+ }
366
+
367
+ // The closed set, once, at the bottom. It is what makes an unfamiliar chip
368
+ // above readable without leaving the page.
369
+ if ((states || []).length) {
370
+ const legend = el("div", "rank-legend");
371
+ legend.append(el("span", "rank-legend-label", t("settings.ranks.legend")));
372
+ for (const s of states) legend.append(chip(s, RANK_STATE_KIND[s] === undefined ? null : RANK_STATE_KIND[s]));
373
+ c.append(legend);
374
+ }
375
+ return c;
376
+ }
377
+
378
+ function ladderCard(table) {
379
+ const c = card(t("settings.ladder.title"));
380
+ c.id = "ladder-card"; // the FLIP morph finds it by id, never by a :has() query
381
+ // `base` is the CLAUDE table that resolves. Since v0.50.0 `active` can be a
382
+ // COMPOSITE (`extra+opus5_only`) because an `orc extra` route row overlays
383
+ // the bands it covers — so branching on `active` here would fall through to
384
+ // the default arm and draw the WRONG ladder the moment Extra is armed. The
385
+ // overlay itself is the Extra panel's rail; this card stays the Claude
386
+ // ladder, which is exactly what it has always been.
387
+ const active = table.base || table.active;
388
+ c.append(
389
+ el(
390
+ "div",
391
+ "note",
392
+ active === "opus5_only"
393
+ ? t("settings.ladder.opus5")
394
+ : active === "rubric_bands_override"
395
+ ? t("settings.ladder.override")
396
+ : t("settings.ladder.default")
397
+ )
398
+ );
399
+ const ladder = el("div", "ladder");
400
+ ladder.id = "ladder";
401
+ const rows = active === "opus5_only" ? table.opus5_only : table.default;
402
+ for (const r of rows) {
403
+ const rung = el("div", "rung");
404
+ rung.dataset.agent = r.agent;
405
+ rung.append(el("span", "rung-band", `[${r.from},${r.to}${r.inclusive_to ? "]" : ")"}`));
406
+ const right = el("div");
407
+ const bar = el("div", "rung-bar");
408
+ bar.style.width = Math.max(6, ((r.to - r.from) / 100) * 100) + "%";
409
+ right.append(bar, el("div", "rung-agent", r.agent));
410
+ rung.append(right);
411
+ ladder.append(rung);
412
+ }
413
+ c.append(ladder);
414
+ c.append(el("div", "ladder-note", t("settings.ladder.note")));
415
+ return c;
416
+ }
417
+
418
+ // FLIP: measure the old rungs, swap in the new table, then animate each rung
419
+ // from where it used to be. The morph is what makes the precedence rule land.
420
+ function morphLadder(oldCard, newCard) {
421
+ const before = new Map();
422
+ for (const r of oldCard.querySelectorAll(".rung")) before.set(r.dataset.agent, r.getBoundingClientRect());
423
+ oldCard.replaceWith(newCard);
424
+ if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
425
+ for (const r of newCard.querySelectorAll(".rung")) {
426
+ const from = before.get(r.dataset.agent);
427
+ if (!from) {
428
+ r.animate([{ opacity: 0, transform: "scaleY(.4)" }, { opacity: 1, transform: "none" }], { duration: 220, easing: "cubic-bezier(.2,.7,.3,1)" });
429
+ continue;
430
+ }
431
+ const to = r.getBoundingClientRect();
432
+ const dy = from.top - to.top;
433
+ if (!dy) continue;
434
+ r.animate([{ transform: `translateY(${dy}px)` }, { transform: "none" }], { duration: 260, easing: "cubic-bezier(.2,.7,.3,1)" });
435
+ }
436
+ }
437
+
438
+ function settingRow(k, panelBody, edits) {
439
+ const row = el("div", "setting" + (k.is_shadowed ? " shadowed" : ""));
440
+ row.dataset.key = k.key;
441
+ // Read by the toolbar's "changed only" filter. It is on the row rather than
442
+ // recomputed from the control, because the control's shape differs per kind.
443
+ row.dataset.overridden = k.is_overridden ? "1" : "0";
444
+
445
+ const left = el("div");
446
+ const name = el("div", "setting-name");
447
+ name.append(document.createTextNode(k.key));
448
+ if (k.is_overridden) name.append(el("span", "dot"));
449
+ if (k.is_shadowed) {
450
+ const lock = el("span", "lock");
451
+ lock.append(document.createTextNode("🔒 " + t("settings.shadowed")));
452
+ name.append(lock);
453
+ }
454
+ left.append(name);
455
+ // `k.desc` and `k.shadow_reason` are the CLI's registry text — never
456
+ // translated. They name keys, values and precedence rules by their real ids.
457
+ left.append(el("div", "setting-desc", k.desc));
458
+ if (k.shadow_reason) left.append(el("div", "shadow-why", k.shadow_reason));
459
+ // WHICH LANES READ THIS (v1.0.0 W16). `k.lanes[]` is the CLI's own
460
+ // `CONFIG_META[].lanes[]` — the same list `orc lane config` filters on, and
461
+ // the same list a two-way lint already refuses to let drift. It answers the
462
+ // question a settings screen otherwise leaves you guessing at: I changed
463
+ // this, so what did I just change? A lane name is a CLI id and is never
464
+ // translated.
465
+ //
466
+ // AN EMPTY LIST IS AN ANSWER AND KEEPS ITS ROW. Ten keys are permanently
467
+ // empty and the lint has an allowlist naming every one of them: they are
468
+ // OPERATING keys of the `orc extra` bridge — a lane calls `orc extra
469
+ // dispatch` and the CLI reads `extra_timeout_s`, so no lane ever names it.
470
+ // Skipping the line would make "no lane reads this" look identical to "we
471
+ // did not render it", which is the distinction this whole row exists to
472
+ // draw.
473
+ {
474
+ const lanes = el("div", "setting-lanes");
475
+ lanes.append(el("span", "setting-lanes-label", t("settings.lanes.label")));
476
+ if ((k.lanes || []).length) for (const lane of k.lanes) lanes.append(chip(lane, "lane"));
477
+ else lanes.append(el("span", "setting-lanes-none", t("settings.lanes.none")));
478
+ left.append(lanes);
479
+ }
480
+
481
+ const right = el("div", "setting-control");
482
+ right.append(controlFor(k, edits));
483
+ if (k.is_overridden) {
484
+ const reset = el("button", "btn btn-ghost btn-sm", t("setting.resetTo", { value: String(k.default) }));
485
+ reset.type = "button";
486
+ reset.addEventListener("click", () => edits.reset(k.key));
487
+ right.append(reset);
488
+ }
489
+
490
+ row.append(left, right);
491
+ return row;
492
+ }
493
+
494
+ // The control follows the VALIDATOR, not a hand-kept table: enum → segmented,
495
+ // int/range → stepper with the options list as presets, path/repo/model → a
496
+ // text input whose validation is the CLI's own exit code.
497
+ //
498
+ // Since v0.44.1 a control STAGES its value instead of writing it. Each one also
499
+ // repaints its own selected state from the staged value, because nothing
500
+ // re-renders until Apply — a segmented control that does not follow your click
501
+ // would look broken, and a click that neither writes nor moves is worse than no
502
+ // control at all.
503
+ function controlFor(k, edits) {
504
+ const c = k.control || { kind: "text" };
505
+ const original = String(k.value);
506
+ const stage = (value) => edits.set(k.key, String(value), original);
507
+
508
+ if (c.kind === "enum") {
509
+ const choices = c.choices || k.options || [];
510
+ const seg = el("div", "seg");
511
+ const paint = (v) => {
512
+ for (const b of seg.children) b.setAttribute("aria-pressed", String(b.dataset.value === String(v)));
513
+ };
514
+ for (const opt of choices) {
515
+ const b = el("button", null, String(opt));
516
+ b.type = "button";
517
+ b.dataset.value = String(opt);
518
+ b.addEventListener("click", () => {
519
+ stage(String(opt));
520
+ paint(String(opt));
521
+ });
522
+ seg.append(b);
523
+ }
524
+ paint(original);
525
+ return seg;
526
+ }
527
+
528
+ if (c.kind === "int" || c.kind === "range") {
529
+ const wrap = el("div", "stepper");
530
+ const input = el("input");
531
+ input.type = "number";
532
+ input.value = original;
533
+ if (c.min !== null && c.min !== undefined) input.min = String(c.min);
534
+ if (c.max !== null && c.max !== undefined) input.max = String(c.max);
535
+ // No `set` button here any more: the edit bar's Apply is the one commit
536
+ // point, so a second button beside every number would be two ideas of what
537
+ // "save" means.
538
+ input.addEventListener("input", () => stage(input.value));
539
+ wrap.append(input);
540
+ const presets = (k.options || []).filter((o) => String(o) !== original);
541
+ if (presets.length) {
542
+ const seg = el("div", "seg");
543
+ for (const p of presets.slice(0, 5)) {
544
+ const b = el("button", null, String(p));
545
+ b.type = "button";
546
+ b.addEventListener("click", () => {
547
+ input.value = String(p);
548
+ stage(String(p));
549
+ });
550
+ seg.append(b);
551
+ }
552
+ const box = el("div");
553
+ box.append(wrap, seg);
554
+ box.className = "setting-control";
555
+ return box;
556
+ }
557
+ return wrap;
558
+ }
559
+
560
+ if (c.kind === "subset") {
561
+ const chosen = new Set(
562
+ original
563
+ .replace(/^\[|\]$/g, "")
564
+ .split(",")
565
+ .map((s) => s.trim())
566
+ .filter(Boolean)
567
+ );
568
+ const seg = el("div", "seg");
569
+ for (const opt of c.choices || []) {
570
+ const b = el("button", null, String(opt));
571
+ b.type = "button";
572
+ b.setAttribute("aria-pressed", String(chosen.has(opt)));
573
+ b.addEventListener("click", () => {
574
+ chosen.has(opt) ? chosen.delete(opt) : chosen.add(opt);
575
+ b.setAttribute("aria-pressed", String(chosen.has(opt)));
576
+ stage([...chosen].join(",") || "");
577
+ });
578
+ seg.append(b);
579
+ }
580
+ return seg;
581
+ }
582
+
583
+ // path / repo / free text — validation comes from the CLI's exit code, which
584
+ // means the rules can never drift from the ones a terminal user gets.
585
+ const wrap = el("div");
586
+ wrap.className = "setting-control";
587
+ const input = el("input", "text-input");
588
+ input.value = original;
589
+ input.addEventListener("input", () => stage(input.value));
590
+ wrap.append(input);
591
+ return wrap;
592
+ }
593
+
594
+ // The Settings edit bar. Reset is `orc config reset` with NO key — the CLI's
595
+ // own "put every key back to its default", not a loop of per-key writes. It is
596
+ // a real write, so it is confirmed and it discards anything staged first: a
597
+ // reset that silently kept four pending edits queued behind it would apply them
598
+ // straight back over the defaults.
599
+ function settingsEditBar(edits, panelBody) {
600
+ const bar = editBar(edits, {
601
+ resetLabel: t("edits.resetAll"),
602
+ onApply: async (btn) => {
603
+ await applyEdits(edits, { set: "/api/config/set", reset: "/api/config/reset" }, btn);
604
+ await rerenderSettings(panelBody);
605
+ },
606
+ onReset: () => {
607
+ modal({
608
+ title: t("edits.resetAllTitle"),
609
+ body: (() => {
610
+ const box = el("div", "stack stack-sm");
611
+ box.append(el("div", null, t("edits.resetAllBody")));
612
+ box.append(el("div", "action-cmd", "orc config reset"));
613
+ return box;
614
+ })(),
615
+ actions: [
616
+ { label: t("common.cancel"), onClick: (c) => c() },
617
+ {
618
+ label: t("edits.resetAllApply"),
619
+ cls: "btn-danger",
620
+ onClick: async (close) => {
621
+ close();
622
+ edits.clear();
623
+ const r = await post("/api/config/reset", {});
624
+ toast(r.command, r.ok ? "ok" : "bad", r.output);
625
+ await rerenderSettings(panelBody);
626
+ },
627
+ },
628
+ ],
629
+ });
630
+ },
631
+ onCancel: () => rerenderSettings(panelBody),
632
+ });
633
+ return bar;
634
+ }
635
+
636
+ // A re-render must not lose the ladder morph, so the ladder is swapped through
637
+ // FLIP while everything else is replaced outright.
638
+ async function rerenderSettings(panelBody) {
639
+ const old = panelBody.querySelector("#ladder-card");
640
+ const snapshot = old ? old.cloneNode(true) : null;
641
+ await renderSettings(panelBody);
642
+ const fresh = panelBody.querySelector("#ladder-card");
643
+ // Only morph when the table ACTUALLY changed — i.e. opus5_only was flipped.
644
+ // Any other setting re-renders without a distracting animation.
645
+ if (snapshot && fresh && snapshot.textContent !== fresh.textContent) {
646
+ // Put the old one back for a beat so FLIP has real geometry to measure.
647
+ fresh.parentNode.replaceChild(snapshot, fresh);
648
+ morphLadder(snapshot, fresh);
649
+ }
650
+ }
651
+
652
+ async function showProfiles() {
653
+ const d = (await read("/api/config/profiles")).data;
654
+ const body = el("div", "stack stack-sm");
655
+ body.append(el("div", "note", t("profiles.note")));
656
+ for (const p of d.profiles) {
657
+ const c = el("div", "action");
658
+ const left = el("div");
659
+ // Profile name and description come from the CLI's registry — untranslated.
660
+ left.append(el("div", "setting-name", p.name));
661
+ left.append(el("div", "setting-desc", p.desc));
662
+ if (p.changes.length) {
663
+ const list = el("div", "note");
664
+ list.textContent = t("profiles.wouldChange", {
665
+ list: p.changes.map((c2) => `${c2.key} ${c2.from} → ${c2.to}`).join(", "),
666
+ });
667
+ left.append(list);
668
+ } else left.append(el("div", "note", t("profiles.noChange")));
669
+ const apply = el("button", "btn btn-sm btn-allow-busy" + (p.changes.length ? " btn-primary" : ""), t("profiles.apply"));
670
+ apply.type = "button";
671
+ apply.disabled = !p.changes.length;
672
+ apply.addEventListener("click", async () => {
673
+ const r = await post("/api/config/profile", { name: p.name });
674
+ toast(r.command, r.ok ? "ok" : "bad", r.output);
675
+ close();
676
+ route();
677
+ });
678
+ c.append(left, apply);
679
+ body.append(c);
680
+ }
681
+ const close = modal({ title: t("profiles.title"), body, actions: [{ label: t("common.close"), onClick: (c) => c() }] });
682
+ }
683
+
684
+ async function showRecommend() {
685
+ const d = (await read("/api/config/recommend")).data;
686
+ const body = el("div", "stack stack-sm");
687
+ body.append(el("div", "note", t("recommend.note")));
688
+ const list = el("ul");
689
+ for (const r of d.reasons) {
690
+ const li = el("li", "note", "• " + r);
691
+ list.append(li);
692
+ }
693
+ body.append(list);
694
+ const pick = el("div", "action");
695
+ const left = el("div");
696
+ left.append(el("div", "setting-name", d.recommended));
697
+ left.append(el("div", "setting-desc", d.desc));
698
+ const apply = el("button", "btn btn-sm btn-primary btn-allow-busy", t("recommend.applyIt", { name: d.recommended }));
699
+ apply.type = "button";
700
+ apply.addEventListener("click", async () => {
701
+ const r = await post("/api/config/profile", { name: d.recommended });
702
+ toast(r.command, r.ok ? "ok" : "bad", r.output);
703
+ close();
704
+ route();
705
+ });
706
+ pick.append(left, apply);
707
+ body.append(pick);
708
+ const close = modal({ title: t("recommend.title"), body, actions: [{ label: t("common.close"), onClick: (c) => c() }] });
709
+ }
710
+
711
+ /* ==================================================================== RUNS == */
712
+
713
+ /* THE LIST IS THE DETAIL VIEW (v0.43.6).
714
+ This panel used to be a list with a detail CARD underneath it: clicking the
715
+ fourth run rendered its checkpoint below run forty. On a repo with any
716
+ history that means scrolling past the entire list to read what you just
717
+ clicked, and then scrolling back up to click the next one — the list grows,
718
+ so the problem grows with it, which is the shape of a design that does not
719
+ survive its own success.
720
+
721
+ Now every row EXPANDS IN PLACE. The detail is a child of the row, animated
722
+ open with the same `grid-template-rows: 0fr -> 1fr` fold the settings tiers
723
+ use, so what you clicked stays exactly where your eye already is. One row is
724
+ open at a time — an accordion, not a set of toggles — because two open runs
725
+ re-create the scrolling problem in miniature. Detail is fetched on FIRST open
726
+ and then kept, so re-opening a row is instant and costs nothing. */