@azure-id/orc 1.9.2 → 2.1.0

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