@azure-id/orc 1.9.2 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/CHANGELOG.md +321 -0
  2. package/README-id.md +21 -36
  3. package/README.md +25 -36
  4. package/bin/build-agents.js +117 -20
  5. package/bin/cli.js +725 -29
  6. package/bin/gotcha-import.js +1081 -0
  7. package/bin/gotcha.js +1286 -0
  8. package/bin/graph-query.js +1 -1
  9. package/bin/graph.js +717 -717
  10. package/bin/habit.js +1453 -0
  11. package/bin/mockrun-catalog.js +281 -276
  12. package/bin/run-undo.js +398 -0
  13. package/bin/trace-write.js +657 -0
  14. package/bin/verify-contracts.js +525 -79
  15. package/bin/verify-package.js +51 -4
  16. package/bin/webui/api.js +26 -0
  17. package/bin/webui/app.html +239 -232
  18. package/bin/webui/css/00-tokens.css +110 -92
  19. package/bin/webui/css/04-motion.css +87 -0
  20. package/bin/webui/css/06-responsive.css +203 -178
  21. package/bin/webui/css/panels/behaviour.css +205 -0
  22. package/bin/webui/fixtures/behaviour.js +532 -0
  23. package/bin/webui/fixtures/index.js +34 -0
  24. package/bin/webui/fixtures/knowledge.js +7 -1
  25. package/bin/webui/fixtures/stats.js +16 -0
  26. package/bin/webui/i18n/en/behaviour.json +143 -0
  27. package/bin/webui/i18n/en/nav.json +25 -24
  28. package/bin/webui/i18n/en/tour.json +37 -35
  29. package/bin/webui/i18n/id/behaviour.json +143 -0
  30. package/bin/webui/i18n/id/nav.json +25 -24
  31. package/bin/webui/i18n/id/tour.json +37 -35
  32. package/bin/webui/js/01-i18n.js +155 -154
  33. package/bin/webui/js/90-tour.js +498 -494
  34. package/bin/webui/js/91-shortcuts.js +126 -126
  35. package/bin/webui/js/99-boot.js +121 -118
  36. package/bin/webui/js/panels/behaviour.js +1022 -0
  37. package/mock-run/INDEX.md +109 -107
  38. package/mock-run/gotcha-import.md +118 -0
  39. package/mock-run/habits.md +129 -0
  40. package/mock-run/orc-quick.md +6 -1
  41. package/package.json +1 -1
  42. package/templates/agents/MODEL-MAPPING.md +7 -7
  43. package/templates/agents/orc-advisor-opus-5-xhigh.md +1 -7
  44. package/templates/agents/orc-analyze-mini-opus-5-med.md +1 -6
  45. package/templates/agents/orc-analyze-mini-sonnet-5-high.md +1 -4
  46. package/templates/agents/orc-claude-writer-opus-4-8-high.md +48 -53
  47. package/templates/agents/orc-claude-writer-opus-5-med.md +1 -8
  48. package/templates/agents/orc-context-combiner-opus-5-high.md +1 -11
  49. package/templates/agents/orc-executor-haiku-4-5.md +14 -6
  50. package/templates/agents/orc-executor-opus-4-7-high.md +14 -6
  51. package/templates/agents/orc-executor-opus-4-7-med.md +14 -6
  52. package/templates/agents/orc-executor-opus-4-8-high.md +14 -6
  53. package/templates/agents/orc-executor-opus-5-high.md +14 -6
  54. package/templates/agents/orc-executor-opus-5-low.md +14 -6
  55. package/templates/agents/orc-executor-opus-5-med.md +14 -6
  56. package/templates/agents/orc-executor-sonnet-4-6-high.md +14 -6
  57. package/templates/agents/orc-executor-sonnet-4-6-med.md +14 -6
  58. package/templates/agents/orc-executor-sonnet-5-high.md +14 -6
  59. package/templates/agents/orc-graph-noter-sonnet-4-6-med.md +1 -10
  60. package/templates/agents/orc-judge-opus-5-xhigh.md +5 -9
  61. package/templates/agents/orc-learn-writer-opus-5-low.md +1 -7
  62. package/templates/agents/orc-pattern-codifier-opus-5-med.md +1 -8
  63. package/templates/agents/orc-pattern-codifier-sonnet-5-high.md +58 -63
  64. package/templates/agents/orc-planner-mini-opus-5-med.md +1 -4
  65. package/templates/agents/orc-planner-mini-sonnet-5-high.md +1 -2
  66. package/templates/agents/orc-planner-opus-5-med.md +1 -4
  67. package/templates/agents/orc-recon-opus-5-low.md +1 -8
  68. package/templates/agents/orc-recon-sonnet-4-6-med.md +1 -8
  69. package/templates/agents/orc-retro-opus-5-med.md +6 -8
  70. package/templates/agents/orc-retro-sonnet-5-high.md +6 -7
  71. package/templates/agents/orc-reviewer-opus-5-med.md +52 -16
  72. package/templates/agents/orc-scout-opus-5-low.md +1 -6
  73. package/templates/agents/orc-scout-sonnet-4-6-high.md +35 -39
  74. package/templates/agents/orc-system-analyst-opus-5-high.md +1 -6
  75. package/templates/agents/orc-test-author-opus-5-med.md +4 -5
  76. package/templates/agents/orc-trace-writer-haiku-4-5.md +3 -7
  77. package/templates/agents/orc-verifier-opus-5-med.md +16 -8
  78. package/templates/agents/orc-wiki-scanner-opus-4-8-high.md +74 -79
  79. package/templates/agents/orc-wiki-scanner-opus-5-med.md +1 -8
  80. package/templates/agents/orc-wiki-scanner-sonnet-5-high.md +97 -106
  81. package/templates/commands/orc-analyze.md +13 -21
  82. package/templates/commands/orc-fast.md +10 -15
  83. package/templates/commands/orc-poly.md +12 -21
  84. package/templates/commands/orc-pr-driver.md +11 -30
  85. package/templates/commands/orc-pr-setup.md +10 -31
  86. package/templates/commands/orc-route.md +11 -41
  87. package/templates/commands/orc-test.md +5 -60
  88. package/templates/hooks/README.md +34 -0
  89. package/templates/hooks/orc-session-hook.js +264 -0
  90. package/templates/hooks/orc-statusline.js +3 -1
  91. package/templates/skills/_shared/README.md +9 -0
  92. package/templates/skills/_shared/code-graph.md +47 -55
  93. package/templates/skills/_shared/config-precedence.md +3 -1
  94. package/templates/skills/_shared/extra-dispatch.md +73 -88
  95. package/templates/skills/_shared/gotchas.md +228 -177
  96. package/templates/skills/_shared/habits.md +101 -0
  97. package/templates/skills/_shared/lane-contract.md +84 -0
  98. package/templates/skills/_shared/phases/README.md +142 -83
  99. package/templates/skills/_shared/phases/analyst-gates.md +10 -21
  100. package/templates/skills/_shared/phases/execution.md +8 -14
  101. package/templates/skills/_shared/phases/house-rules.md +27 -32
  102. package/templates/skills/_shared/phases/intake.md +127 -133
  103. package/templates/skills/_shared/phases/mock-example.md +46 -56
  104. package/templates/skills/_shared/phases/plan-handoff.md +91 -97
  105. package/templates/skills/_shared/phases/planning.md +7 -17
  106. package/templates/skills/_shared/phases/preflight.md +19 -42
  107. package/templates/skills/_shared/phases/review.md +23 -27
  108. package/templates/skills/_shared/phases/rules.md +18 -42
  109. package/templates/skills/_shared/phases/scoring.md +55 -65
  110. package/templates/skills/_shared/phases/security-checklist.md +46 -50
  111. package/templates/skills/_shared/phases/security.md +45 -55
  112. package/templates/skills/_shared/phases/ship.md +6 -15
  113. package/templates/skills/_shared/phases/stop-resume.md +2 -5
  114. package/templates/skills/_shared/phases/summary.md +73 -48
  115. package/templates/skills/_shared/phases/testgen.md +41 -51
  116. package/templates/skills/_shared/phases/trace-verbs.md +433 -0
  117. package/templates/skills/_shared/phases/trace.md +136 -367
  118. package/templates/skills/_shared/phases/verify.md +62 -70
  119. package/templates/skills/_shared/phases/wave-grouping.md +128 -133
  120. package/templates/skills/_shared/phases/wiki-consult.md +10 -6
  121. package/templates/skills/_shared/read-ladder.md +2 -55
  122. package/templates/skills/_shared/return-validation.md +17 -70
  123. package/templates/skills/_shared/review-slice.md +79 -0
  124. package/templates/skills/_shared/smoke-gate.md +46 -28
  125. package/templates/skills/context-combiner/SKILL.md +15 -44
  126. package/templates/skills/orc/SKILL.md +34 -62
  127. package/templates/skills/orc/references/pattern-gate.md +89 -89
  128. package/templates/skills/orc/references/phases/intake.md +41 -47
  129. package/templates/skills/orc/references/phases/integration.md +13 -19
  130. package/templates/skills/orc/references/preflight-report.md +8 -9
  131. package/templates/skills/orc/references/ultra-mode.md +8 -6
  132. package/templates/skills/orc/subskills/orc-execution/SKILL.md +27 -73
  133. package/templates/skills/orc/subskills/orc-execution/core.md +12 -99
  134. package/templates/skills/orc/subskills/orc-execution/subagent.md +14 -13
  135. package/templates/skills/orc/subskills/orc-review-verify/SKILL.md +11 -52
  136. package/templates/skills/orc/subskills/orc-review-verify/core.md +52 -135
  137. package/templates/skills/orc/subskills/orc-review-verify/subagent.md +7 -7
  138. package/templates/skills/orc/subskills/orc-testgen/SKILL.md +11 -22
  139. package/templates/skills/orc/subskills/orc-testgen/core.md +20 -59
  140. package/templates/skills/orc/subskills/orc-testgen/subagent.md +7 -7
  141. package/templates/skills/orc-advisor/SKILL.md +56 -60
  142. package/templates/skills/orc-analyze/SKILL.md +30 -66
  143. package/templates/skills/orc-analyze/schemas/report-audit.md +2 -1
  144. package/templates/skills/orc-analyze/schemas/report-prose.md +2 -1
  145. package/templates/skills/orc-analyze-mini/SKILL.md +35 -70
  146. package/templates/skills/orc-diy/README.md +31 -0
  147. package/templates/skills/orc-diy/SKILL.md +23 -74
  148. package/templates/skills/orc-diy/references/blocks/pattern.md +18 -18
  149. package/templates/skills/orc-fast/SKILL.md +44 -72
  150. package/templates/skills/orc-judge/SKILL.md +77 -82
  151. package/templates/skills/orc-mini/SKILL.md +61 -109
  152. package/templates/skills/orc-pattern/SKILL.md +27 -48
  153. package/templates/skills/orc-poly/SKILL.md +32 -61
  154. package/templates/skills/orc-pr-driver/SKILL.md +25 -51
  155. package/templates/skills/orc-pr-driver/references/green-gate.md +113 -105
  156. package/templates/skills/orc-pr-setup/SKILL.md +20 -45
  157. package/templates/skills/orc-quick/README.md +43 -2
  158. package/templates/skills/orc-quick/SKILL.md +76 -107
  159. package/templates/skills/orc-quick/references/dispatch-gate.md +16 -5
  160. package/templates/skills/orc-quick/references/gh-mode.md +48 -1
  161. package/templates/skills/orc-quick/references/look.md +3 -1
  162. package/templates/skills/orc-retro/SKILL.md +19 -18
  163. package/templates/skills/orc-retro/examples/retro-mock.md +1 -1
  164. package/templates/skills/orc-route/SKILL.md +25 -45
  165. package/templates/skills/orc-test/SKILL.md +16 -37
  166. package/templates/skills/orc-verify/SKILL.md +14 -32
  167. package/templates/skills/orc-wait/SKILL.md +156 -163
  168. package/templates/skills/orc-wiki/references/phases/phase-0.md +1 -6
  169. package/templates/skills/orc-wiki/references/phases/phase-1.md +1 -6
  170. package/templates/skills/orc-wiki/references/phases/phase-2.md +1 -6
  171. package/templates/skills/orc-wiki/references/phases/phase-3.md +1 -6
  172. package/templates/skills/orc-wiki/references/phases/phase-3c.md +1 -6
@@ -0,0 +1,1022 @@
1
+ "use strict";
2
+ /* panels/behaviour.js — orc ui client
3
+ Behaviour (v2.0.0 W7): what the lanes learned about how YOU work, from your
4
+ own answers, in this project only. Five tabs — Habits, Rhythm, Gotchas,
5
+ Review quality, Answer log — above them a portrait and four tiles.
6
+
7
+ THE RULE OF THIS PANEL: it renders the CLI's lines and derives no habit.
8
+ Every sentence (`portrait[].line`, `rows[].line`, `effect`, `rule`), every
9
+ share, streak, state word, class word, `by=` value, band and command arrives
10
+ from `orc habit show|log --json` and `orc gotcha list|card|quality --json`.
11
+ The panel translates only its OWN prose (DE-19). It never branches on a
12
+ state word: the actions come from `commands{}`, the colours from a
13
+ `data-state` / `data-by` / `data-source` attribute that
14
+ css/panels/behaviour.css maps to a token. A `never` habit has NO button —
15
+ only the command a person types.
16
+
17
+ Loaded by app.html in the order its numeric prefix names. Classic
18
+ script, no import/export: an ES module import carries no query string,
19
+ and every static request here needs the per-launch session token. */
20
+
21
+ const BH_TABS = ["habits", "rhythm", "gotchas", "quality", "log"];
22
+ const BH_WINDOWS = ["30d", "90d", "all"];
23
+ const BH_SVG = "http://www.w3.org/2000/svg";
24
+ let bhTab = "habits";
25
+ let bhWindow = "90d";
26
+ // Set after the first paint. A repaint after a write adds `bh-quiet`, so no
27
+ // entrance plays twice — the v1.4.2 `hk-quiet` rule.
28
+ let bhPainted = false;
29
+
30
+ // Panel prose for the words the CLI publishes. Looked up BY the CLI's word; a
31
+ // word this table does not know renders bare, never invented.
32
+ const BH_STATE_HELP = () => ({
33
+ observed: t("behaviour.state.observed"),
34
+ proposed: t("behaviour.state.proposed"),
35
+ applied: t("behaviour.state.applied"),
36
+ declined: t("behaviour.state.declined"),
37
+ "never-ask": t("behaviour.state.neverAsk"),
38
+ stale: t("behaviour.state.stale"),
39
+ shadowed: t("behaviour.state.shadowed"),
40
+ never: t("behaviour.state.never"),
41
+ });
42
+ const BH_CLASS_HELP = () => ({
43
+ apply: t("behaviour.class.apply"),
44
+ suggest: t("behaviour.class.suggest"),
45
+ never: t("behaviour.class.never"),
46
+ });
47
+ const BH_BY_HELP = () => ({
48
+ user: t("behaviour.by.user"),
49
+ ledger: t("behaviour.by.ledger"),
50
+ learned: t("behaviour.by.learned"),
51
+ config: t("behaviour.by.config"),
52
+ default: t("behaviour.by.default"),
53
+ });
54
+ const BH_MODE_HELP = () => ({
55
+ off: t("behaviour.mode.off"),
56
+ observe: t("behaviour.mode.observe"),
57
+ propose: t("behaviour.mode.propose"),
58
+ });
59
+ const BH_TAB_LABEL = () => ({
60
+ habits: t("behaviour.tab.habits"),
61
+ rhythm: t("behaviour.tab.rhythm"),
62
+ gotchas: t("behaviour.tab.gotchas"),
63
+ quality: t("behaviour.tab.quality"),
64
+ log: t("behaviour.tab.log"),
65
+ });
66
+
67
+ PANELS.behaviour = function (host) {
68
+ bhPainted = false;
69
+ const right = el("div", "bh-headright");
70
+ head(host, t("behaviour.title"), t("behaviour.sub"), right);
71
+ const live = el("div", "bh-sr");
72
+ live.id = "bh-live";
73
+ live.setAttribute("aria-live", "polite");
74
+ host.append(live);
75
+ const body = el("div", "stack");
76
+ host.append(body);
77
+ bhLoad(body, right);
78
+ };
79
+
80
+ // The window switch and the learning switch live in the page head. The window
81
+ // is a READ; the learning switch is a WRITE, through the one confirmation.
82
+ async function bhLoad(body, right, focusId) {
83
+ if (!bhPainted) body.replaceChildren(skeleton(5));
84
+ let hab;
85
+ try {
86
+ hab = await read("/api/habits?window=" + encodeURIComponent(bhWindow));
87
+ } catch (e) {
88
+ body.replaceChildren(failBox(e));
89
+ return;
90
+ }
91
+ const d = hab.data || {};
92
+ right.replaceChildren(bhModeSwitch(d, body, right));
93
+ // `habits: off` (exit 3) — ONE card instead of the tabs.
94
+ if (d.ok === false && Array.isArray(d.on)) {
95
+ body.classList.toggle("bh-quiet", bhPainted);
96
+ body.replaceChildren(bhOffCard(d), bhFooter(null, true));
97
+ bhPainted = true;
98
+ return;
99
+ }
100
+ right.append(bhWindowSwitch(body, right));
101
+ const [log, states, gotchas, quality, cands] = await Promise.all([
102
+ read("/api/habits/log").catch(() => ({ data: null })),
103
+ read("/api/habits/states").catch(() => ({ data: null })),
104
+ read("/api/gotchas").catch(() => ({ data: null })),
105
+ read("/api/gotcha/quality?window=" + encodeURIComponent(bhWindow)).catch(() => ({ data: null })),
106
+ read("/api/gotcha/candidates").catch(() => ({ data: null })),
107
+ ]);
108
+ const ctx = { d, log: log.data, states: states.data, gotchas: gotchas.data, quality: quality.data, cands: cands.data, body, right };
109
+ body.classList.toggle("bh-quiet", bhPainted);
110
+ const out = frag();
111
+ out.append(bhHero(d));
112
+ const tabs = bhTabs(ctx);
113
+ out.append(tabs.bar);
114
+ out.append(tabs.panel);
115
+ out.append(bhFooter(d, false));
116
+ body.replaceChildren(out);
117
+ tabs.fill();
118
+ bhPainted = true;
119
+ bhRailDot(d);
120
+ if (focusId) {
121
+ const card = body.querySelector('[data-habit="' + focusId + '"]');
122
+ if (card) card.focus();
123
+ }
124
+ }
125
+
126
+ function bhModeSwitch(d, body, right) {
127
+ const seg = el("div", "bh-seg bh-seg-mode");
128
+ seg.setAttribute("role", "group");
129
+ seg.setAttribute("aria-label", t("behaviour.learning"));
130
+ seg.append(el("span", "bh-seg-label", t("behaviour.learning")));
131
+ const help = BH_MODE_HELP();
132
+ for (const m of d.modes || []) {
133
+ const b = el("button", null, m);
134
+ b.type = "button";
135
+ b.setAttribute("aria-pressed", String(m === d.mode));
136
+ b.addEventListener("click", () => {
137
+ if (m === d.mode) return;
138
+ const cmd = "orc config set habits " + m;
139
+ bhConfirm({
140
+ title: t("behaviour.modeTitle", { mode: m }),
141
+ what: help[m] || "",
142
+ command: cmd,
143
+ go: async () => {
144
+ const r = await post("/api/config/set", { key: "habits", value: m });
145
+ toast(r.ok ? t("behaviour.modeDone", { mode: m }) : t("common.writeFail"), r.ok ? "ok" : "bad", r.output);
146
+ bhSay(t("behaviour.modeDone", { mode: m }));
147
+ bhLoad(body, right);
148
+ },
149
+ });
150
+ });
151
+ seg.append(b);
152
+ }
153
+ return seg;
154
+ }
155
+
156
+ function bhWindowSwitch(body, right) {
157
+ const seg = el("div", "bh-seg");
158
+ seg.setAttribute("role", "group");
159
+ seg.setAttribute("aria-label", t("behaviour.window"));
160
+ for (const w of BH_WINDOWS) {
161
+ const b = el("button", null, w);
162
+ b.type = "button";
163
+ b.setAttribute("aria-pressed", String(w === bhWindow));
164
+ b.addEventListener("click", () => {
165
+ if (w === bhWindow) return;
166
+ bhWindow = w;
167
+ bhLoad(body, right);
168
+ });
169
+ seg.append(b);
170
+ }
171
+ return seg;
172
+ }
173
+
174
+ // ── the off card ────────────────────────────────────────────────────────────
175
+ function bhOffCard(d) {
176
+ const c = el("section", "bh-hero bh-off");
177
+ c.append(el("div", "bh-eyebrow", t("behaviour.off.eyebrow")));
178
+ c.append(el("h2", "bh-off-title", t("behaviour.off.title")));
179
+ const ul = el("ul", "bh-off-list");
180
+ const help = BH_MODE_HELP();
181
+ for (const m of d.modes || []) {
182
+ const li = el("li");
183
+ li.append(el("span", "mono", m + " "), document.createTextNode(help[m] || ""));
184
+ ul.append(li);
185
+ }
186
+ if (d.kept) ul.append(el("li", null, t("behaviour.off.kept", { n: d.kept })));
187
+ c.append(ul);
188
+ c.append(el("div", "note", t("behaviour.off.how")));
189
+ for (const cmd of d.on || []) c.append(bhCmd(cmd));
190
+ return c;
191
+ }
192
+
193
+ // ── the hero: portrait + four tiles ─────────────────────────────────────────
194
+ function bhHero(d) {
195
+ const h = el("section", "bh-hero");
196
+ h.append(bhOrbit());
197
+ const eb = el("div", "bh-eyebrow");
198
+ eb.append(document.createTextNode(t("behaviour.portrait", { window: d.window || bhWindow })));
199
+ const mc = chip("habits: " + d.mode);
200
+ mc.dataset.mode = d.mode;
201
+ eb.append(mc);
202
+ h.append(eb);
203
+ if (!d.proposal && !(d.counts || {}).proposed && (d.rows || []).length) h.append(el("div", "note", t("behaviour.noProposal")));
204
+ const ul = el("ul", "bh-portrait");
205
+ (d.portrait || []).forEach((p, i) => {
206
+ const li = el("li");
207
+ li.style.animationDelay = 120 + i * 110 + "ms";
208
+ li.append(el("span", "bh-k mono", p.k), el("span", null, p.line));
209
+ ul.append(li);
210
+ });
211
+ if (!(d.portrait || []).length) h.append(empty(t("behaviour.emptyTitle"), t("behaviour.emptyHint")));
212
+ else h.append(ul);
213
+ const T = d.tiles || {};
214
+ const tiles = el("div", "bh-tiles");
215
+ tiles.append(
216
+ bhTile(t("behaviour.tile.runs"), T.runs, null, t("behaviour.tile.runsNote", { n: d.traces || 0 })),
217
+ bhTile(t("behaviour.tile.answers"), T.answers, null, t("behaviour.tile.answersNote")),
218
+ bhTile(t("behaviour.tile.saved"), T.saved, T.saved_prev, t("behaviour.tile.savedNote")),
219
+ bhTile(t("behaviour.tile.qpr"), T.qpr, T.qpr_prev, t("behaviour.tile.qprNote"))
220
+ );
221
+ h.append(tiles);
222
+ return h;
223
+ }
224
+
225
+ // The orbit turns ONCE, slowly, and rests (WCAG 2.2.2). Reduced motion
226
+ // removes the satellites in 04-motion.css.
227
+ function bhOrbit() {
228
+ const s = bhSv("svg", { class: "bh-orbit", viewBox: "0 0 260 260", "aria-hidden": "true" });
229
+ for (const r of [50, 85, 120]) s.append(bhSv("circle", { cx: 130, cy: 130, r }));
230
+ s.append(bhSv("circle", { class: "sat", cx: 130, cy: 45, r: 4 }));
231
+ s.append(bhSv("circle", { class: "sat sat-b", cx: 215, cy: 130, r: 3 }));
232
+ return s;
233
+ }
234
+
235
+ function bhTile(label, value, prev, note) {
236
+ const tile = el("div", "bh-tile");
237
+ tile.append(el("div", "bh-tile-l", label));
238
+ const v = el("div", "bh-tile-v");
239
+ const num = el("span", null, "—");
240
+ v.append(num);
241
+ if (prev !== null && prev !== undefined && value !== null && value !== undefined)
242
+ v.append(el("small", null, t("behaviour.tile.prev", { n: prev })));
243
+ tile.append(v);
244
+ if (note) tile.append(el("div", "bh-tile-n", note));
245
+ bhCount(num, value);
246
+ return tile;
247
+ }
248
+
249
+ // Numbers count up, finitely, and REST on the CLI's own value. Reduced motion
250
+ // and a repaint skip the count and write the value at once.
251
+ function bhCount(node, to) {
252
+ if (to === null || to === undefined) return;
253
+ const final = String(to);
254
+ if (bhPainted || bhStill() || typeof to !== "number") {
255
+ node.textContent = final;
256
+ return;
257
+ }
258
+ const dec = Number.isInteger(to) ? 0 : 1;
259
+ const t0 = performance.now();
260
+ const tick = (now) => {
261
+ const k = Math.min(1, (now - t0) / 900);
262
+ const e = 1 - Math.pow(1 - k, 3);
263
+ node.textContent = k < 1 ? (to * e).toFixed(dec) : final;
264
+ if (k < 1) requestAnimationFrame(tick);
265
+ };
266
+ requestAnimationFrame(tick);
267
+ }
268
+ function bhStill() {
269
+ return window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
270
+ }
271
+
272
+ // ── the tabs ────────────────────────────────────────────────────────────────
273
+ function bhTabs(ctx) {
274
+ const d = ctx.d;
275
+ const bar = el("div", "bh-tabs");
276
+ bar.setAttribute("role", "tablist");
277
+ bar.setAttribute("aria-label", t("behaviour.tabsLabel"));
278
+ const ink = el("span", "bh-tab-ink");
279
+ const panel = el("div", "stack bh-tabpanel");
280
+ panel.setAttribute("role", "tabpanel");
281
+ const label = BH_TAB_LABEL();
282
+ const g = (ctx.gotchas && ctx.gotchas.panel) || null;
283
+ const counts = {
284
+ habits: (d.rows || []).length,
285
+ gotchas: g ? g.rows.length : null,
286
+ log: ctx.log && ctx.log.events ? ctx.log.events.length : null,
287
+ };
288
+ const btns = [];
289
+ for (const id of BH_TABS) {
290
+ const b = el("button", "bh-tab");
291
+ b.type = "button";
292
+ b.id = "bh-tab-" + id;
293
+ b.setAttribute("role", "tab");
294
+ b.setAttribute("aria-controls", "bh-tabpanel");
295
+ b.append(document.createTextNode(label[id]));
296
+ if (counts[id] !== null && counts[id] !== undefined) {
297
+ const n = el("span", "bh-count", String(counts[id]));
298
+ if (id === "habits" && (d.counts || {}).proposed > 0) n.classList.add("hot");
299
+ b.append(n);
300
+ }
301
+ b.addEventListener("click", () => select(id, false));
302
+ b.addEventListener("keydown", (e) => {
303
+ const i = BH_TABS.indexOf(id);
304
+ const to =
305
+ e.key === "ArrowRight" ? BH_TABS[(i + 1) % BH_TABS.length]
306
+ : e.key === "ArrowLeft" ? BH_TABS[(i - 1 + BH_TABS.length) % BH_TABS.length]
307
+ : e.key === "Home" ? BH_TABS[0]
308
+ : e.key === "End" ? BH_TABS[BH_TABS.length - 1]
309
+ : null;
310
+ if (!to) return;
311
+ e.preventDefault();
312
+ select(to, true);
313
+ });
314
+ btns.push(b);
315
+ bar.append(b);
316
+ }
317
+ bar.append(ink);
318
+ panel.id = "bh-tabpanel";
319
+ function moveInk() {
320
+ const a = bar.querySelector('[aria-selected="true"]');
321
+ if (!a) return;
322
+ ink.style.left = a.offsetLeft + "px";
323
+ ink.style.width = a.offsetWidth + "px";
324
+ }
325
+ function select(id, focus) {
326
+ bhTab = id;
327
+ for (const b of btns) {
328
+ const on = b.id === "bh-tab-" + id;
329
+ b.setAttribute("aria-selected", String(on));
330
+ b.tabIndex = on ? 0 : -1;
331
+ if (on && focus) b.focus();
332
+ }
333
+ panel.setAttribute("aria-labelledby", "bh-tab-" + id);
334
+ panel.replaceChildren();
335
+ ({ habits: bhHabits, rhythm: bhRhythm, gotchas: bhGotchas, quality: bhQuality, log: bhLog })[id](panel, ctx);
336
+ requestAnimationFrame(moveInk);
337
+ }
338
+ return { bar, panel, fill: () => select(BH_TABS.includes(bhTab) ? bhTab : "habits", false) };
339
+ }
340
+
341
+ // ── Habits ──────────────────────────────────────────────────────────────────
342
+ function bhHabits(slot, ctx) {
343
+ const d = ctx.d;
344
+ const rows = d.rows || [];
345
+ if ((d.next_run || []).length) {
346
+ const nr = card(t("behaviour.nextRun"), el("span", "note", t("behaviour.nextRunNote")));
347
+ const list = el("ul", "bh-next");
348
+ for (const n of d.next_run) {
349
+ const li = el("li");
350
+ li.append(el("span", "bh-lane mono", n.lane), el("span", null, n.line));
351
+ list.append(li);
352
+ }
353
+ nr.append(list);
354
+ slot.append(nr);
355
+ }
356
+
357
+ const legend = card(t("behaviour.legendTitle"));
358
+ const lg = el("div", "bh-legend");
359
+ const sh = BH_STATE_HELP();
360
+ for (const s of d.state_words || []) {
361
+ const w = el("span", "note");
362
+ w.append(bhStateChip(s), document.createTextNode(" " + (sh[s] || "")));
363
+ lg.append(w);
364
+ }
365
+ legend.append(lg);
366
+ const cl = el("div", "bh-legend");
367
+ const ch = BH_CLASS_HELP();
368
+ for (const c of d.classes || []) {
369
+ const w = el("span", "note");
370
+ const tag = el("span", "bh-cls", c);
371
+ tag.dataset.cls = c;
372
+ w.append(tag, document.createTextNode(" " + (ch[c] || "")));
373
+ cl.append(w);
374
+ }
375
+ legend.append(cl);
376
+ slot.append(legend);
377
+
378
+ if (!rows.length) {
379
+ const e = card(null);
380
+ e.append(empty(t("behaviour.emptyTitle"), t("behaviour.emptyHint")));
381
+ slot.append(e);
382
+ } else {
383
+ // The CLI sends the rows in the order that needs you first; the panel does
384
+ // not sort them.
385
+ const grid = el("div", "bh-habits");
386
+ rows.forEach((r, i) => {
387
+ const c = bhHabitCard(r, ctx);
388
+ c.style.animationDelay = Math.min(i, 12) * 55 + "ms";
389
+ grid.append(c);
390
+ });
391
+ slot.append(grid);
392
+ }
393
+
394
+ if (ctx.states && (ctx.states.history || []).length) {
395
+ const list = el("div", "bh-history");
396
+ for (const h of ctx.states.history) {
397
+ const row = el("div", "bh-hist-row");
398
+ row.append(el("span", "mono bh-faint", new Date(h.at).toLocaleString()), el("span", "mono", h.id), el("span", null, `${h.from} → ${h.to}`));
399
+ const by = chip(h.by);
400
+ by.dataset.by = h.by;
401
+ row.append(by);
402
+ if (h.inverse) row.append(bhCmd(h.inverse));
403
+ list.append(row);
404
+ }
405
+ slot.append(collapsible({ title: t("behaviour.history"), count: String(ctx.states.history.length), collapsed: true, desc: t("behaviour.historyNote"), content: list }));
406
+ }
407
+ }
408
+
409
+ function bhStateChip(s) {
410
+ const c = chip(s);
411
+ c.dataset.state = s;
412
+ return c;
413
+ }
414
+
415
+ function bhHabitCard(r, ctx) {
416
+ const c = el("article", "bh-habit");
417
+ c.dataset.state = r.state;
418
+ c.dataset.habit = r.id;
419
+ c.tabIndex = -1;
420
+ const top = el("div", "bh-habit-top");
421
+ for (const l of r.lanes || []) top.append(el("span", "bh-lane mono", l));
422
+ top.append(el("span", "bh-qid mono", r.qid));
423
+ const cls = el("span", "bh-cls", r.effective_class);
424
+ cls.dataset.cls = r.effective_class;
425
+ top.append(cls, bhStateChip(r.state));
426
+ c.append(top);
427
+
428
+ const bodyRow = el("div", "bh-habit-body");
429
+ bodyRow.append(bhRing(r.share, r.state));
430
+ const txt = el("div");
431
+ txt.append(el("h3", null, r.title));
432
+ if (r.line) txt.append(el("div", "bh-line", r.line));
433
+ bodyRow.append(txt);
434
+ c.append(bodyRow);
435
+
436
+ const opts = r.options || [];
437
+ const total = opts.reduce((s, o) => s + o.n, 0);
438
+ if (total) {
439
+ const dist = el("div", "bh-dist");
440
+ dist.setAttribute("role", "img");
441
+ dist.setAttribute("aria-label", opts.map((o) => `${o.label} ${o.n}`).join(", "));
442
+ const leg = el("div", "bh-dist-legend");
443
+ opts.forEach((o, i) => {
444
+ if (o.n) {
445
+ const s = el("span", "bh-grow-x" + (i === 0 ? " is-top" : ""));
446
+ s.style.flex = String(o.n);
447
+ s.style.animationDelay = 250 + i * 120 + "ms";
448
+ dist.append(s);
449
+ }
450
+ const l = el("span");
451
+ l.append(el("i", i === 0 ? "is-top" : null), document.createTextNode(o.label + " "), el("span", "mono", String(o.n)));
452
+ leg.append(l);
453
+ });
454
+ c.append(dist, leg);
455
+ }
456
+
457
+ const meta = el("div", "bh-habit-meta");
458
+ if (r.rule) meta.append(bhMeta(t("behaviour.field.rule"), r.rule));
459
+ meta.append(bhMeta(t("behaviour.field.context"), r.context || t("behaviour.anyContext")));
460
+ if ((r.streak || []).length) meta.append(bhStreak(r.streak));
461
+ c.append(meta);
462
+ if (r.why) c.append(el("div", "note", r.why));
463
+ if (r.effect) c.append(el("div", "bh-why", r.effect));
464
+ c.append(bhActions(r, ctx));
465
+ return c;
466
+ }
467
+
468
+ function bhMeta(label, value) {
469
+ const s = el("span");
470
+ s.append(document.createTextNode(label + " "), el("b", null, value));
471
+ return s;
472
+ }
473
+
474
+ // The 10-answer streak uses SHAPES, never colour alone (WCAG 1.4.1):
475
+ // ● the top choice · ○ another choice · ◐ Enter on a pre-selected option.
476
+ function bhStreak(streak) {
477
+ const st = el("span", "bh-streak");
478
+ const top = streak.filter((x) => x === 1).length;
479
+ const pre = streak.filter((x) => x > 0 && x < 1).length;
480
+ st.setAttribute("role", "img");
481
+ st.setAttribute("aria-label", t("behaviour.streakLabel", { n: streak.length, top, pre }));
482
+ for (const x of streak) st.append(el("i", x ? "on" : null, x === 1 ? "●" : x > 0 ? "◐" : "○"));
483
+ return st;
484
+ }
485
+
486
+ function bhRing(share, state) {
487
+ const R = 26;
488
+ const C = 2 * Math.PI * R;
489
+ const pct = Math.round((share || 0) * 100);
490
+ const s = bhSv("svg", { class: "bh-ring", viewBox: "0 0 64 64", role: "img", "aria-label": t("behaviour.ringLabel", { pct }) });
491
+ s.dataset.state = state;
492
+ s.append(bhSv("circle", { class: "bg", cx: 32, cy: 32, r: R }));
493
+ const fg = bhSv("circle", { class: "fg", cx: 32, cy: 32, r: R, "stroke-dasharray": C, "stroke-dashoffset": bhPainted ? C * (1 - (share || 0)) : C });
494
+ s.append(fg);
495
+ const tx = bhSv("text", { x: 32, y: 30 });
496
+ tx.textContent = pct + "%";
497
+ const t2 = bhSv("text", { x: 32, y: 44, class: "s" });
498
+ t2.textContent = t("behaviour.ringTop");
499
+ s.append(tx, t2);
500
+ // A transition in 04-motion.css, so the reduced-motion cap makes it instant.
501
+ requestAnimationFrame(() => requestAnimationFrame(() => fg.setAttribute("stroke-dashoffset", String(C * (1 - (share || 0))))));
502
+ return s;
503
+ }
504
+
505
+ // The actions come from `commands{}` — the CLI decides which exist. The
506
+ // `manual` command (a `never` habit) gets NO button, on purpose: a safety
507
+ // check is switched off by a person typing it.
508
+ function bhActions(r, ctx) {
509
+ const cm = r.commands || {};
510
+ const wrap = el("div", "bh-actions-wrap");
511
+ const act = el("div", "row-actions");
512
+ const B = (labelText, cls, route, body, command, title, what) => {
513
+ const b = el("button", "btn btn-sm " + cls, labelText);
514
+ b.type = "button";
515
+ b.addEventListener("click", () =>
516
+ bhConfirm({ title, what, command, go: () => bhWrite(route, body, r.id, ctx, t("behaviour.done", { id: r.id, command })) })
517
+ );
518
+ return b;
519
+ };
520
+ if (cm.accept) {
521
+ act.append(
522
+ B(t("behaviour.act.accept"), "btn-primary", "/api/habit/accept", { id: r.id }, cm.accept, t("behaviour.act.acceptTitle", { id: r.id }), t("behaviour.act.acceptWhat")),
523
+ B(t("behaviour.act.decline"), "btn-ghost", "/api/habit/decline", { id: r.id }, cm.decline, t("behaviour.act.declineTitle"), t("behaviour.act.declineWhat")),
524
+ B(t("behaviour.act.never"), "btn-ghost", "/api/habit/decline", { id: r.id, never: true }, cm.never, t("behaviour.act.neverTitle"), t("behaviour.act.neverWhat"))
525
+ );
526
+ wrap.append(act, bhCmd(cm.accept));
527
+ } else if (cm.forget) {
528
+ act.append(B(t("behaviour.act.forget"), "", "/api/habit/forget", { id: r.id }, cm.forget, t("behaviour.act.forgetTitle", { id: r.id }), t("behaviour.act.forgetWhat")));
529
+ wrap.append(act, bhCmd(cm.forget));
530
+ } else if (cm.reset) {
531
+ act.append(B(t("behaviour.act.reset"), "", "/api/habit/reset", { id: r.id }, cm.reset, t("behaviour.act.resetTitle"), t("behaviour.act.resetWhat")));
532
+ wrap.append(act, bhCmd(cm.reset));
533
+ } else if (cm.manual) {
534
+ wrap.append(el("div", "note", t("behaviour.act.manual")), bhCmd(cm.manual));
535
+ }
536
+ return wrap;
537
+ }
538
+
539
+ // THE ONE CONFIRMATION every write goes through: it names the command, says
540
+ // what changes, and has Cancel / Run it. `modal()` traps nothing it does not
541
+ // own, closes on Escape, and `closeModal` is its single exit.
542
+ function bhConfirm({ title, what, command, go }) {
543
+ const b = frag();
544
+ if (what) b.append(el("p", null, what));
545
+ b.append(el("div", "note", t("behaviour.confirmNote")));
546
+ b.append(el("pre", "cmd", command));
547
+ const opener = document.activeElement;
548
+ modal({
549
+ title,
550
+ body: b,
551
+ actions: [
552
+ { label: t("common.cancel"), cls: "btn-ghost", onClick: (close) => { close(); if (opener && opener.focus) opener.focus(); } },
553
+ { label: t("behaviour.runIt"), cls: "btn-primary", onClick: (close) => { close(); go(); } },
554
+ ],
555
+ });
556
+ }
557
+
558
+ // A write re-fetches `/api/habits` and repaints QUIETLY: nothing arrives again,
559
+ // and focus moves to the card that changed. The undo is on that card, never
560
+ // in a toast that disappears (WCAG 2.2.1).
561
+ async function bhWrite(route, body, id, ctx, said) {
562
+ let r;
563
+ try {
564
+ r = await post(route, body);
565
+ } catch (e) {
566
+ toast(t("common.writeFail"), "bad", String(e.message || e));
567
+ return;
568
+ }
569
+ toast(r.ok ? said : t("common.writeFail"), r.ok ? "ok" : "bad", r.output);
570
+ if (r.ok) bhSay(said + " " + t("behaviour.undoWhere"));
571
+ bhLoad(ctx.body, ctx.right, id);
572
+ }
573
+
574
+ function bhSay(text) {
575
+ const live = $("#bh-live");
576
+ if (live) live.textContent = text;
577
+ }
578
+
579
+ // ── Rhythm ──────────────────────────────────────────────────────────────────
580
+ function bhRhythm(slot, ctx) {
581
+ const rh = ctx.d.rhythm || {};
582
+ const g = el("div", "grid grid-2");
583
+
584
+ const hc = card(t("behaviour.rhythm.when"), el("span", "note", t("behaviour.rhythm.whenNote")));
585
+ const heat = rh.heat || [];
586
+ const flat = heat.flat();
587
+ const max = Math.max(0, ...flat);
588
+ const days = [t("behaviour.day.mon"), t("behaviour.day.tue"), t("behaviour.day.wed"), t("behaviour.day.thu"), t("behaviour.day.fri"), t("behaviour.day.sat"), t("behaviour.day.sun")];
589
+ const grid = el("div", "bh-heat");
590
+ grid.setAttribute("role", "img");
591
+ grid.setAttribute("aria-label", t("behaviour.rhythm.heatLabel", { n: flat.reduce((a, b) => a + b, 0) }));
592
+ grid.append(el("span"));
593
+ for (let h = 0; h < 24; h++) grid.append(el("span", "h", h % 3 === 0 ? String(h).padStart(2, "0") : ""));
594
+ heat.forEach((row, di) => {
595
+ grid.append(el("span", "d", days[di] || ""));
596
+ row.forEach((v, h) => {
597
+ const cell = el("span", "c");
598
+ cell.title = t("behaviour.rhythm.cell", { day: days[di] || "", hour: String(h).padStart(2, "0"), n: v });
599
+ // k = the CLI's cell value over the max; the colour is a token mix.
600
+ if (v && max) cell.style.background = `color-mix(in srgb, var(--accent) ${Math.round(18 + (82 * v) / max)}%, var(--surface-3))`;
601
+ cell.style.animationDelay = Math.min(h * 18 + di * 26, 700) + "ms";
602
+ grid.append(cell);
603
+ });
604
+ });
605
+ hc.append(grid);
606
+ g.append(hc);
607
+
608
+ const lanes = rh.lanes || [];
609
+ const tot = lanes.reduce((s, l) => s + l.n, 0);
610
+ const mc = card(t("behaviour.rhythm.lanes"), el("span", "note", tn(tot, "behaviour.rhythm.runs")));
611
+ if (tot) {
612
+ const mix = el("div", "bh-mix");
613
+ mix.setAttribute("role", "img");
614
+ mix.setAttribute("aria-label", lanes.map((l) => `${l.lane} ${l.n}`).join(", "));
615
+ const ml = el("div", "bh-mix-legend");
616
+ lanes.forEach((l, i) => {
617
+ const s = el("span", "bh-grow-x", l.n / tot > 0.09 ? Math.round((100 * l.n) / tot) + "%" : "");
618
+ s.style.flex = String(l.n);
619
+ s.style.background = `var(--s${(i % 7) + 1})`;
620
+ s.style.animationDelay = i * 90 + "ms";
621
+ mix.append(s);
622
+ const row = el("div");
623
+ const sw = el("i");
624
+ sw.style.background = `var(--s${(i % 7) + 1})`;
625
+ row.append(sw, el("span", "mono", l.lane), el("span", "mono bh-dim", String(l.n)));
626
+ ml.append(row);
627
+ });
628
+ mc.append(mix, ml);
629
+ } else mc.append(empty(t("behaviour.emptyTitle"), t("behaviour.emptyHint")));
630
+ g.append(mc);
631
+ slot.append(g);
632
+
633
+ const g2 = el("div", "grid grid-2");
634
+ const qpr = rh.qpr || [];
635
+ const qc = card(t("behaviour.rhythm.qpr"));
636
+ const known = qpr.filter((v) => v !== null && v !== undefined);
637
+ if (known.length >= 2) {
638
+ qc.append(bhLine(qpr, { kind: "accent", fmt: (v) => String(v), label: t("behaviour.rhythm.qprLabel", { from: known[0], to: known[known.length - 1], n: qpr.length }) }));
639
+ qc.append(el("div", "note", t("behaviour.rhythm.qprNote")));
640
+ } else qc.append(empty(t("behaviour.emptyTitle"), t("behaviour.emptyHint")));
641
+ g2.append(qc);
642
+
643
+ const wc = card(t("behaviour.rhythm.how"));
644
+ const how = rh.how || [];
645
+ const keys = rh.by || [];
646
+ const colMax = Math.max(0, ...how.map((w) => keys.reduce((s, k) => s + (w[k] || 0), 0)));
647
+ if (colMax) {
648
+ const bars = el("div", "bh-how");
649
+ bars.setAttribute("role", "img");
650
+ bars.setAttribute("aria-label", t("behaviour.rhythm.howLabel", { n: how.length }));
651
+ how.forEach((w, i) => {
652
+ const col = el("div", "bh-how-col");
653
+ col.title = keys.map((k) => `${k} ${w[k] || 0}`).join(" · ");
654
+ keys.forEach((k, j) => {
655
+ if (!w[k]) return;
656
+ const s = el("span", "bh-grow-y");
657
+ s.dataset.by = k;
658
+ s.style.height = (100 * w[k]) / colMax + "%";
659
+ s.style.animationDelay = i * 45 + j * 30 + "ms";
660
+ col.append(s);
661
+ });
662
+ bars.append(col);
663
+ });
664
+ wc.append(bars);
665
+ const lg = el("div", "bh-dist-legend");
666
+ const bh = BH_BY_HELP();
667
+ for (const k of keys) {
668
+ const l = el("span");
669
+ const sw = el("i");
670
+ sw.dataset.by = k;
671
+ l.append(sw, el("span", "mono", k), document.createTextNode(" — " + (bh[k] || "")));
672
+ lg.append(l);
673
+ }
674
+ wc.append(lg);
675
+ } else wc.append(empty(t("behaviour.emptyTitle"), t("behaviour.emptyHint")));
676
+ g2.append(wc);
677
+ slot.append(g2);
678
+ }
679
+
680
+ // A line chart that draws itself (stroke-dash, finite). The first and last
681
+ // values are labelled; a null week keeps its slot and draws no point.
682
+ function bhLine(vals, o) {
683
+ const W = 520;
684
+ const H = 150;
685
+ const P = 26;
686
+ const pts = vals.map((v, i) => [i, v]).filter((p) => p[1] !== null && p[1] !== undefined);
687
+ const ys = pts.map((p) => p[1]).concat(o.target !== undefined ? [o.target] : []);
688
+ // The scale is drawing, not data: it frames the CLI's numbers with a margin.
689
+ const lo = Math.min(...ys);
690
+ const hi = Math.max(...ys);
691
+ const pad = (hi - lo) * 0.3 || hi * 0.2 || 1;
692
+ const min = Math.max(0, lo - pad);
693
+ const max = hi + pad;
694
+ const x = (i) => P + ((W - 2 * P) * i) / Math.max(1, vals.length - 1);
695
+ const y = (v) => H - P + 4 - ((H - 2 * P) * (v - min)) / (max - min || 1);
696
+ const s = bhSv("svg", { class: "bh-chart", viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": o.label });
697
+ s.dataset.kind = o.kind;
698
+ for (let k = 0; k <= 4; k++) {
699
+ const yy = P - 4 + ((H - 2 * P) * k) / 4;
700
+ s.append(bhSv("line", { class: "grid-l", x1: P, x2: W - P, y1: yy, y2: yy }));
701
+ }
702
+ const dPath = pts.map((p, i) => (i ? "L" : "M") + x(p[0]).toFixed(1) + " " + y(p[1]).toFixed(1)).join(" ");
703
+ const ln = bhSv("path", { class: "ln" + (bhPainted ? "" : " bh-draw"), d: dPath });
704
+ s.append(ln);
705
+ requestAnimationFrame(() => {
706
+ try {
707
+ ln.style.setProperty("--len", String(ln.getTotalLength()));
708
+ } catch (_) {}
709
+ });
710
+ [pts[0], pts[pts.length - 1]].forEach((p, i) => {
711
+ s.append(bhSv("circle", { class: "pt bh-fade-late", cx: x(p[0]), cy: y(p[1]), r: 4 }));
712
+ const tx = bhSv("text", { class: "bh-fade-late", x: x(p[0]) + (i ? -6 : 6), y: y(p[1]) - 9, "text-anchor": i ? "end" : "start" });
713
+ tx.textContent = o.fmt(p[1]);
714
+ s.append(tx);
715
+ });
716
+ if (o.target !== undefined && o.target !== null) {
717
+ const ty = y(o.target);
718
+ s.append(bhSv("line", { class: "target", x1: P, x2: W - P, y1: ty, y2: ty }));
719
+ const tt = bhSv("text", { x: W - P, y: ty - 5, "text-anchor": "end" });
720
+ tt.textContent = t("behaviour.target", { v: o.fmt(o.target) });
721
+ s.append(tt);
722
+ }
723
+ return s;
724
+ }
725
+
726
+ // ── Gotchas ─────────────────────────────────────────────────────────────────
727
+ function bhGotchas(slot, ctx) {
728
+ const G = ctx.gotchas;
729
+ const P = G && G.panel;
730
+ if (!P) {
731
+ const c = card(t("behaviour.g.title"));
732
+ c.append(empty(t("behaviour.g.none"), t("behaviour.g.noneHint")));
733
+ slot.append(c);
734
+ bhCardPreview(slot);
735
+ return;
736
+ }
737
+ const sync = el("div", "row-actions");
738
+ sync.append(el("span", "note", P.sync && P.sync.last_sync ? t("behaviour.g.lastSync", { at: new Date(P.sync.last_sync).toLocaleString() }) : t("behaviour.g.noSync")));
739
+ for (const s of (P.sync && P.sync.sources) || []) {
740
+ const c = chip(`${s.name} ${s.state}`);
741
+ c.dataset.sync = s.state;
742
+ sync.append(c);
743
+ }
744
+ const sc = card(t("behaviour.g.sources"), el("span", "note", t("behaviour.g.alwaysOn")));
745
+ const tot = (P.by_source || []).reduce((s, x) => s + x.n, 0);
746
+ if (tot) {
747
+ const bar = el("div", "bh-src-bar");
748
+ bar.setAttribute("role", "img");
749
+ bar.setAttribute("aria-label", P.by_source.map((s) => `${s.source} ${s.n}`).join(", "));
750
+ P.by_source.forEach((s, i) => {
751
+ const b = el("span", "bh-grow-x", s.n / tot > 0.08 ? `${s.source} ${s.n}` : "");
752
+ b.dataset.source = s.source;
753
+ b.style.flex = String(s.n);
754
+ b.style.animationDelay = i * 80 + "ms";
755
+ b.title = `${s.source}: ${s.n}`;
756
+ bar.append(b);
757
+ });
758
+ sc.append(bar);
759
+ }
760
+ const chips = el("div", "bh-legend");
761
+ for (const s of P.statuses || []) {
762
+ const c = chip(`${(P.status_counts || {})[s] || 0} ${s}`);
763
+ c.dataset.status = s;
764
+ chips.append(c);
765
+ }
766
+ sc.append(chips, el("div", "note", t("behaviour.g.statusNote")), sync);
767
+ slot.append(sc);
768
+
769
+ const g = el("div", "grid grid-2");
770
+ const lc = card(t("behaviour.g.list"), el("span", "note", t("behaviour.g.listNote")));
771
+ (P.rows || []).forEach((r, i) => {
772
+ const row = el("div", "bh-lesson");
773
+ row.style.animationDelay = Math.min(i, 12) * 45 + "ms";
774
+ row.append(el("div", "bh-lesson-id mono", r.id));
775
+ const mid = el("div");
776
+ mid.append(el("div", "bh-lesson-rule", r.fix && r.fix.charAt(0) !== "(" ? `${r.symptom} → ${r.fix}` : r.symptom || ""));
777
+ const sub = el("div", "bh-lesson-sub");
778
+ const src = el("span", "bh-src mono", r.source);
779
+ src.dataset.source = r.source;
780
+ sub.append(src);
781
+ if (r.rule) sub.append(el("span", "mono", r.rule));
782
+ if (r.scope) sub.append(el("span", "bh-scope mono", r.scope));
783
+ if (r.status_reason) sub.append(el("span", null, r.status_reason));
784
+ mid.append(sub);
785
+ row.append(mid);
786
+ const right = el("div", "bh-lesson-right");
787
+ const st = chip(r.status);
788
+ st.dataset.status = r.status;
789
+ right.append(st, bhSpark(r.weeks || []));
790
+ row.append(right);
791
+ lc.append(row);
792
+ });
793
+ g.append(lc);
794
+ slot.append(g);
795
+ bhCardPreview(g);
796
+ }
797
+
798
+ function bhSpark(weeks) {
799
+ const sp = el("div", "bh-spark");
800
+ const mx = Math.max(1, ...weeks);
801
+ sp.setAttribute("role", "img");
802
+ sp.setAttribute("aria-label", t("behaviour.g.spark", { n: weeks.reduce((a, b) => a + b, 0), w: weeks.length }));
803
+ weeks.forEach((h, i) => {
804
+ const b = el("i", "bh-grow-y" + (h ? "" : " zero"));
805
+ b.style.height = (h ? 20 + (80 * h) / mx : 8) + "%";
806
+ b.style.animationDelay = 200 + i * 40 + "ms";
807
+ sp.append(b);
808
+ });
809
+ return sp;
810
+ }
811
+
812
+ // What the reviewer will see: the EXACT card `orc gotcha card` returns. It
813
+ // re-reads on input, debounced 300 ms. Zero matches is the CLI's own answer.
814
+ function bhCardPreview(host) {
815
+ const pc = card(t("behaviour.g.preview"), el("span", "note", t("behaviour.g.previewFree")));
816
+ pc.append(el("div", "note", t("behaviour.g.previewHint")));
817
+ const inp = el("input", "bh-input");
818
+ inp.type = "text";
819
+ inp.placeholder = "src/routes/export.js, client/src/i18n/en.json";
820
+ inp.setAttribute("aria-label", t("behaviour.g.previewLabel"));
821
+ const cmdBox = el("pre", "cmd", "orc gotcha card --files <paths> --json");
822
+ const out = el("pre", "bh-card-out", "");
823
+ out.setAttribute("aria-live", "polite");
824
+ let timer = null;
825
+ let seq = 0;
826
+ const paint = async () => {
827
+ const files = inp.value.split(",").map((s) => s.trim()).filter(Boolean).join(",");
828
+ if (!files) {
829
+ cmdBox.textContent = "orc gotcha card --files <paths> --json";
830
+ out.textContent = t("behaviour.g.previewEmpty");
831
+ return;
832
+ }
833
+ cmdBox.textContent = `orc gotcha card --files ${files} --json`;
834
+ const mine = ++seq;
835
+ let r;
836
+ try {
837
+ r = await read("/api/gotcha/card?files=" + encodeURIComponent(files));
838
+ } catch (e) {
839
+ if (mine === seq) out.textContent = String(e.message || e);
840
+ return;
841
+ }
842
+ if (mine !== seq) return;
843
+ const c = r.data || {};
844
+ out.textContent = c.matched ? c.text : t("behaviour.g.noCard", { n: c.known || 0 });
845
+ };
846
+ inp.addEventListener("input", () => {
847
+ clearTimeout(timer);
848
+ timer = setTimeout(paint, 300);
849
+ });
850
+ pc.append(inp, cmdBox, out);
851
+ paint();
852
+ host.append(pc);
853
+ }
854
+
855
+ // ── Review quality ──────────────────────────────────────────────────────────
856
+ function bhQuality(slot, ctx) {
857
+ const Q = ctx.quality;
858
+ if (!Q) {
859
+ const c = card(t("behaviour.q.cats"));
860
+ c.append(empty(t("behaviour.q.none")));
861
+ slot.append(c);
862
+ return;
863
+ }
864
+ // Below the floor, every chart KEEPS its slot and shows the CLI's sentence.
865
+ // Not knowing is an answer.
866
+ const floor = (c) => c.append(el("div", "bh-floor", Q.floor_line || ""));
867
+ const g = el("div", "grid grid-2");
868
+ const cc = card(t("behaviour.q.cats"), el("span", "note", tn(Q.reviews || 0, "behaviour.q.reviews")));
869
+ if (Q.below_floor) floor(cc);
870
+ else {
871
+ const bands = Q.bands || {};
872
+ (Q.categories || []).forEach((c, i) => {
873
+ const row = el("div", "bh-cat");
874
+ const acc = c.acceptance;
875
+ const bar = el("div", "bh-cat-bar");
876
+ const s = el("span", "bh-grow-x");
877
+ if (acc !== null && acc !== undefined) {
878
+ s.style.width = Math.round(acc * 100) + "%";
879
+ // The band thresholds are the CLI's numbers (`bands{}`), not the panel's.
880
+ s.dataset.band = acc >= bands.ok ? "ok" : acc >= bands.warn ? "warn" : "bad";
881
+ }
882
+ s.style.animationDelay = i * 90 + "ms";
883
+ bar.append(s);
884
+ if (Q.target !== undefined) {
885
+ const tick = el("i", "tick");
886
+ tick.style.left = Q.target * 100 + "%";
887
+ tick.title = t("behaviour.target", { v: Math.round(Q.target * 100) + "%" });
888
+ bar.append(tick);
889
+ }
890
+ row.append(el("div", "bh-cat-name mono", c.category), bar, el("div", "bh-cat-num mono", `${acc === null ? "—" : Math.round(acc * 100) + "%"} · n ${c.findings}`));
891
+ cc.append(row);
892
+ });
893
+ }
894
+ g.append(cc);
895
+
896
+ const series = Q.series || [];
897
+ const tc = card(t("behaviour.q.trend"));
898
+ const acc = series.map((p) => p.acceptance);
899
+ if (Q.below_floor) floor(tc);
900
+ else if (acc.filter((v) => v !== null).length >= 2)
901
+ tc.append(bhLine(acc, { kind: "ok", fmt: (v) => Math.round(v * 100) + "%", target: Q.target, label: t("behaviour.q.trendLabel", { n: series.length }) }));
902
+ else tc.append(empty(t("behaviour.q.none")));
903
+ g.append(tc);
904
+ slot.append(g);
905
+
906
+ const g2 = el("div", "grid grid-2");
907
+ const nc = card(t("behaviour.q.p3"));
908
+ const p3 = series.map((p) => p.p3);
909
+ if (Q.below_floor) floor(nc);
910
+ else if (p3.length >= 2) {
911
+ nc.append(bhLine(p3, { kind: "warn", fmt: (v) => String(v), label: t("behaviour.q.p3Label", { n: series.length }) }));
912
+ nc.append(el("div", "note", t("behaviour.q.p3Note")));
913
+ } else nc.append(empty(t("behaviour.q.none")));
914
+ g2.append(nc);
915
+
916
+ const sups = (ctx.cands && ctx.cands.suppressions) || [];
917
+ const sp = card(t("behaviour.q.sups"), el("span", "note", tn(sups.length, "behaviour.q.waiting")));
918
+ if (!sups.length) sp.append(empty(t("behaviour.q.noSups")));
919
+ for (const c of sups) {
920
+ const row = el("div", "bh-lesson");
921
+ row.append(el("div", "bh-lesson-id mono", c.id));
922
+ const mid = el("div");
923
+ mid.append(el("div", "bh-lesson-rule", c.sig || c.rule || ""));
924
+ const sub = el("div", "bh-lesson-sub");
925
+ if (c.category) sub.append(el("span", "mono", c.category));
926
+ if (c.scope) sub.append(el("span", "bh-scope mono", c.scope));
927
+ sub.append(el("span", null, tn(c.disputed, "behaviour.q.disputed")));
928
+ mid.append(sub);
929
+ row.append(mid);
930
+ const cmd = "orc gotcha accept " + c.id;
931
+ const b = el("button", "btn btn-sm", t("behaviour.q.accept"));
932
+ b.type = "button";
933
+ b.addEventListener("click", () =>
934
+ bhConfirm({
935
+ title: t("behaviour.q.acceptTitle", { id: c.id }),
936
+ what: t("behaviour.q.acceptWhat"),
937
+ command: cmd,
938
+ go: async () => {
939
+ const r = await post("/api/gotcha/accept", { id: c.id });
940
+ toast(r.ok ? t("behaviour.done", { id: c.id, command: cmd }) : t("common.writeFail"), r.ok ? "ok" : "bad", r.output);
941
+ if (r.ok) bhSay(t("behaviour.done", { id: c.id, command: cmd }));
942
+ bhLoad(ctx.body, ctx.right);
943
+ },
944
+ })
945
+ );
946
+ row.append(b);
947
+ sp.append(row);
948
+ }
949
+ g2.append(sp);
950
+ slot.append(g2);
951
+ }
952
+
953
+ // ── Answer log ──────────────────────────────────────────────────────────────
954
+ function bhLog(slot, ctx) {
955
+ const c = card(t("behaviour.log.title"), el("span", "note", t("behaviour.log.from")));
956
+ const events = (ctx.log && ctx.log.events) || [];
957
+ if (!events.length) c.append(empty(t("behaviour.emptyTitle"), t("behaviour.emptyHint")));
958
+ const list = el("div", "bh-log");
959
+ events.forEach((e, i) => {
960
+ const row = el("div", "bh-log-row");
961
+ row.style.animationDelay = Math.min(i, 12) * 70 + "ms";
962
+ const q = el("span", "bh-log-q");
963
+ q.append(el("span", "mono", e.qid), document.createTextNode(" → "), el("b", null, e.chose));
964
+ const by = chip(e.by);
965
+ by.dataset.by = e.by;
966
+ row.append(el("span", "t mono", new Date(e.at).toLocaleString()), el("span", "ln mono", e.lane), q, by);
967
+ list.append(row);
968
+ });
969
+ c.append(list);
970
+ c.append(el("div", "note", t("behaviour.log.note")));
971
+ slot.append(c);
972
+ }
973
+
974
+ // ── Footer (every tab) ──────────────────────────────────────────────────────
975
+ function bhFooter(d, off) {
976
+ const f = el("div", "bh-foot");
977
+ f.append(el("span", "bh-lock", "🔒 " + t("behaviour.localOnly")));
978
+ for (const p of (d && d.files) || []) f.append(el("span", "mono", p));
979
+ const tail = el("span");
980
+ if (off) tail.append(document.createTextNode(t("behaviour.offCosts") + " "), el("span", "mono", "orc config set habits observe"));
981
+ else tail.append(document.createTextNode(t("behaviour.turnOff") + " "), el("span", "mono", "orc config set habits off"));
982
+ f.append(tail);
983
+ return f;
984
+ }
985
+
986
+ // ── the rail dot: the ONLY place outside this panel that shows habit state ──
987
+ // A proposal waiting (`counts.proposed > 0`) puts a dot on the rail link that
988
+ // pulses three times and stops.
989
+ function bhRailDot(d) {
990
+ const a = document.querySelector('#nav a[data-panel="behaviour"]');
991
+ if (!a) return;
992
+ const has = !!(d && d.counts && d.counts.proposed > 0);
993
+ let dot = a.querySelector(".bh-dot");
994
+ if (has && !dot) {
995
+ dot = el("span", "bh-dot");
996
+ dot.setAttribute("aria-label", t("behaviour.railDot"));
997
+ dot.setAttribute("role", "img");
998
+ a.append(dot);
999
+ } else if (!has && dot) dot.remove();
1000
+ }
1001
+ async function behaviourRailDot() {
1002
+ try {
1003
+ const r = await read("/api/habits");
1004
+ bhRailDot(r.data);
1005
+ } catch (_) {}
1006
+ }
1007
+
1008
+ function bhCmd(text) {
1009
+ const b = el("div", "bh-cmd");
1010
+ b.append(el("span", "mono", "$ " + text));
1011
+ const cp = el("button", "copy-btn", t("common.copy").toLowerCase());
1012
+ cp.type = "button";
1013
+ cp.addEventListener("click", () => copy(text, text));
1014
+ b.append(cp);
1015
+ return b;
1016
+ }
1017
+
1018
+ function bhSv(tag, attrs) {
1019
+ const n = document.createElementNS(BH_SVG, tag);
1020
+ for (const k in attrs || {}) n.setAttribute(k, attrs[k]);
1021
+ return n;
1022
+ }