toolroll 0.9.54 → 0.9.56

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 (350) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +29 -12
  3. package/dist/action-ledger.d.ts +2 -4
  4. package/dist/action-ledger.js +2 -10
  5. package/dist/agent-onboard.js +4 -1
  6. package/dist/approval-policy.d.ts +5 -5
  7. package/dist/approval-policy.js +4 -4
  8. package/dist/approval-rules-ui.d.ts +3 -2
  9. package/dist/approval-rules-ui.js +17 -22
  10. package/dist/assignment-ui.d.ts +3 -2
  11. package/dist/assignment-ui.js +4 -11
  12. package/dist/backup-ui.d.ts +2 -1
  13. package/dist/backup-ui.js +16 -27
  14. package/dist/brand-mark.d.ts +3 -2
  15. package/dist/brand-mark.js +4 -4
  16. package/dist/browser/workspace.css +1 -1
  17. package/dist/browser/workspace.js +25 -25
  18. package/dist/browser-shell.d.ts +2 -1
  19. package/dist/browser-shell.js +9 -9
  20. package/dist/browser-workspace.d.ts +14 -15
  21. package/dist/browser-workspace.js +0 -3
  22. package/dist/budget-alerts.js +3 -3
  23. package/dist/chat-actions.d.ts +23 -18
  24. package/dist/chat-actions.js +110 -88
  25. package/dist/chat-ask.d.ts +2 -2
  26. package/dist/chat-ask.js +5 -5
  27. package/dist/chat-channel.d.ts +15 -15
  28. package/dist/chat-channel.js +20 -20
  29. package/dist/chat-controls.d.ts +0 -4
  30. package/dist/chat-controls.js +0 -1
  31. package/dist/chat-core.js +1 -1
  32. package/dist/chat-delivery-state.js +2 -2
  33. package/dist/chat-delivery.d.ts +2 -2
  34. package/dist/chat-delivery.js +27 -34
  35. package/dist/chat-flow.js +3 -3
  36. package/dist/chat-messages.d.ts +1 -1
  37. package/dist/chat-messages.js +4 -4
  38. package/dist/chat-migration.d.ts +1 -1
  39. package/dist/chat-migration.js +11 -11
  40. package/dist/chat-polish.d.ts +5 -4
  41. package/dist/chat-polish.js +9 -10
  42. package/dist/chat-rooms.js +3 -3
  43. package/dist/chat-warmth.d.ts +2 -2
  44. package/dist/check-levels-ui.d.ts +2 -1
  45. package/dist/check-levels-ui.js +26 -53
  46. package/dist/cli.d.ts +3 -1
  47. package/dist/cli.js +15 -10
  48. package/dist/coding-shipping-ui.d.ts +2 -1
  49. package/dist/coding-shipping-ui.js +6 -6
  50. package/dist/coding-ui.d.ts +3 -1
  51. package/dist/coding-ui.js +30 -29
  52. package/dist/contracts/chat-actions.d.ts +32 -27
  53. package/dist/contracts/chat-actions.js +29 -11
  54. package/dist/contracts/chat-content.d.ts +1 -1
  55. package/dist/contracts/chat-content.js +1 -1
  56. package/dist/contracts/chat-tables.js +4 -4
  57. package/dist/contracts/cli.d.ts +1 -3
  58. package/dist/contracts/console-api.d.ts +7 -7
  59. package/dist/contracts/console-api.js +13 -12
  60. package/dist/contracts/flow-propose.d.ts +3 -3
  61. package/dist/contracts/flow.d.ts +132 -125
  62. package/dist/contracts/flow.js +27 -12
  63. package/dist/contracts/health.d.ts +1 -3
  64. package/dist/contracts/lead-context.d.ts +1 -1
  65. package/dist/contracts/lead-context.js +2 -2
  66. package/dist/contracts/lead-tools.d.ts +20 -18
  67. package/dist/contracts/lead-tools.js +15 -15
  68. package/dist/contracts/stage-output.d.ts +1 -1
  69. package/dist/contracts/stage-output.js +1 -1
  70. package/dist/contracts/store-json.d.ts +23 -22
  71. package/dist/contracts/store-json.js +17 -17
  72. package/dist/contracts/{teammate-turn.d.ts → subagent-turn.d.ts} +5 -5
  73. package/dist/contracts/{teammate-turn.js → subagent-turn.js} +7 -7
  74. package/dist/control-ui.d.ts +5 -4
  75. package/dist/control-ui.js +9 -34
  76. package/dist/converse.d.ts +23 -23
  77. package/dist/converse.js +28 -28
  78. package/dist/coordinator-proposals.d.ts +2 -2
  79. package/dist/coordinator-proposals.js +1 -1
  80. package/dist/coordinator.js +1 -1
  81. package/dist/credentials-ui.d.ts +5 -4
  82. package/dist/credentials-ui.js +23 -24
  83. package/dist/demo-chat.d.ts +3 -3
  84. package/dist/demo-chat.js +29 -76
  85. package/dist/deprecations.d.ts +12 -0
  86. package/dist/deprecations.js +20 -0
  87. package/dist/desktop-update-gate.js +8 -4
  88. package/dist/desktop-update.js +6 -1
  89. package/dist/discord-chat.js +6 -6
  90. package/dist/discord-settings.d.ts +3 -2
  91. package/dist/discord-settings.js +17 -42
  92. package/dist/dispatch.d.ts +1 -1
  93. package/dist/dispatch.js +1 -1
  94. package/dist/envelope.js +10 -1
  95. package/dist/evidence-pack.d.ts +2 -1
  96. package/dist/evidence-pack.js +45 -49
  97. package/dist/export-ui.d.ts +7 -2
  98. package/dist/export-ui.js +4 -10
  99. package/dist/export.js +7 -7
  100. package/dist/flow-engine.d.ts +2 -2
  101. package/dist/flow-engine.js +21 -21
  102. package/dist/flow-gallery-ui.d.ts +4 -3
  103. package/dist/flow-gallery-ui.js +28 -43
  104. package/dist/flow-insights.d.ts +1 -1
  105. package/dist/flow-share.js +13 -8
  106. package/dist/flow-starters-ui.d.ts +2 -1
  107. package/dist/flow-starters-ui.js +12 -15
  108. package/dist/flow-steps.d.ts +5 -5
  109. package/dist/flow-steps.js +19 -19
  110. package/dist/flow-triggers.d.ts +2 -1
  111. package/dist/flow-triggers.js +17 -15
  112. package/dist/flows-cli.js +1 -1
  113. package/dist/flows-ui.d.ts +4 -3
  114. package/dist/flows-ui.js +38 -49
  115. package/dist/flows.js +21 -20
  116. package/dist/guides.js +112 -6
  117. package/dist/html.d.ts +76 -0
  118. package/dist/html.js +173 -0
  119. package/dist/integrations-ui.d.ts +2 -1
  120. package/dist/integrations-ui.js +19 -23
  121. package/dist/invoke.js +1 -1
  122. package/dist/kits-ui.d.ts +5 -4
  123. package/dist/kits-ui.js +22 -24
  124. package/dist/kits.d.ts +12 -12
  125. package/dist/kits.js +31 -31
  126. package/dist/knowledge-ui.d.ts +6 -5
  127. package/dist/knowledge-ui.js +26 -25
  128. package/dist/{mate-cli.d.ts → lead-cli.d.ts} +11 -11
  129. package/dist/{mate-cli.js → lead-cli.js} +38 -38
  130. package/dist/lead-commitments.js +4 -4
  131. package/dist/lead-context.d.ts +12 -2
  132. package/dist/lead-context.js +15 -17
  133. package/dist/{mate-contract.d.ts → lead-contract.d.ts} +3 -3
  134. package/dist/{mate-contract.js → lead-contract.js} +7 -6
  135. package/dist/{mate-doors.d.ts → lead-doors.d.ts} +6 -6
  136. package/dist/{mate-doors.js → lead-doors.js} +24 -24
  137. package/dist/lead-follow.d.ts +5 -5
  138. package/dist/lead-follow.js +9 -9
  139. package/dist/lead-people.d.ts +5 -5
  140. package/dist/lead-people.js +14 -14
  141. package/dist/{mate-progress.d.ts → lead-progress.d.ts} +9 -9
  142. package/dist/{mate-progress.js → lead-progress.js} +3 -3
  143. package/dist/{mate-tools.d.ts → lead-tools.d.ts} +19 -19
  144. package/dist/{mate-tools.js → lead-tools.js} +109 -101
  145. package/dist/{mate.d.ts → lead.d.ts} +27 -27
  146. package/dist/{mate.js → lead.js} +102 -102
  147. package/dist/ledger-view.d.ts +2 -1
  148. package/dist/ledger-view.js +27 -29
  149. package/dist/limits-ui.d.ts +3 -2
  150. package/dist/limits-ui.js +8 -8
  151. package/dist/live-bus.d.ts +50 -8
  152. package/dist/live-bus.js +133 -50
  153. package/dist/mcp-connect.d.ts +1 -1
  154. package/dist/mcp-connect.js +3 -3
  155. package/dist/mcp-core.js +1 -1
  156. package/dist/mcp-oauth.js +35 -36
  157. package/dist/memory-pass.d.ts +2 -2
  158. package/dist/memory-pass.js +7 -7
  159. package/dist/models-ui.d.ts +2 -1
  160. package/dist/models-ui.js +25 -35
  161. package/dist/monitoring-ui.d.ts +4 -3
  162. package/dist/monitoring-ui.js +13 -26
  163. package/dist/notification-rows.d.ts +2 -2
  164. package/dist/openrouter-models.d.ts +2 -1
  165. package/dist/openrouter-models.js +5 -9
  166. package/dist/operate.d.ts +3 -3
  167. package/dist/operate.js +46 -41
  168. package/dist/people-audit-ui.d.ts +2 -1
  169. package/dist/people-audit-ui.js +13 -13
  170. package/dist/policy-ui.d.ts +3 -2
  171. package/dist/policy-ui.js +17 -23
  172. package/dist/policy.d.ts +1 -1
  173. package/dist/principal.d.ts +1 -1
  174. package/dist/principal.js +2 -2
  175. package/dist/project-delete-ui.d.ts +4 -3
  176. package/dist/project-delete-ui.js +16 -28
  177. package/dist/project-delete.d.ts +1 -1
  178. package/dist/project-delete.js +26 -26
  179. package/dist/project-memory.js +1 -1
  180. package/dist/project-tools.d.ts +1 -1
  181. package/dist/project-tools.js +1 -1
  182. package/dist/pull-request-ui.d.ts +2 -1
  183. package/dist/pull-request-ui.js +17 -32
  184. package/dist/recipe-ui.d.ts +8 -7
  185. package/dist/recipe-ui.js +39 -32
  186. package/dist/release-adapters.d.ts +60 -0
  187. package/dist/release-adapters.js +435 -0
  188. package/dist/release-cli.d.ts +24 -0
  189. package/dist/release-cli.js +181 -0
  190. package/dist/release-gate.d.ts +80 -0
  191. package/dist/release-gate.js +150 -0
  192. package/dist/release-links.d.ts +29 -0
  193. package/dist/release-links.js +102 -0
  194. package/dist/release.d.ts +331 -0
  195. package/dist/release.js +550 -0
  196. package/dist/reply-shape.d.ts +2 -1
  197. package/dist/reply-shape.js +5 -5
  198. package/dist/repository-context-ui.d.ts +2 -1
  199. package/dist/repository-context-ui.js +2 -7
  200. package/dist/request-budget-ui.d.ts +2 -1
  201. package/dist/request-budget-ui.js +16 -16
  202. package/dist/result-review.d.ts +2 -3
  203. package/dist/result-review.js +3 -9
  204. package/dist/retention-ui.d.ts +7 -1
  205. package/dist/retention-ui.js +7 -11
  206. package/dist/retention.js +4 -4
  207. package/dist/scope.d.ts +2 -2
  208. package/dist/scout-report.d.ts +1 -1
  209. package/dist/scout-report.js +1 -1
  210. package/dist/serve.d.ts +11 -2
  211. package/dist/serve.js +105 -99
  212. package/dist/server/chat.d.ts +7 -10
  213. package/dist/server/chat.js +984 -1021
  214. package/dist/server/chrome.d.ts +291 -0
  215. package/dist/server/chrome.js +747 -0
  216. package/dist/server/console.css +2819 -0
  217. package/dist/server/flows.d.ts +2 -6
  218. package/dist/server/flows.js +854 -773
  219. package/dist/server/guards.d.ts +1 -1
  220. package/dist/server/guards.js +8 -5
  221. package/dist/server/handler-context.d.ts +1 -1
  222. package/dist/server/handler-context.js +1 -1
  223. package/dist/server/handler-registry.d.ts +8 -6
  224. package/dist/server/handler-registry.js +14 -2
  225. package/dist/server/http.d.ts +274 -0
  226. package/dist/server/http.js +239 -0
  227. package/dist/server/live.d.ts +34 -0
  228. package/dist/server/live.js +309 -0
  229. package/dist/server/pages.d.ts +2 -6
  230. package/dist/server/pages.js +808 -789
  231. package/dist/server/people-tokens.d.ts +1 -6
  232. package/dist/server/people-tokens.js +581 -573
  233. package/dist/server/remote.d.ts +4 -7
  234. package/dist/server/remote.js +39 -70
  235. package/dist/server/render-chat.d.ts +402 -0
  236. package/dist/server/render-chat.js +927 -0
  237. package/dist/server/render-pages.d.ts +535 -0
  238. package/dist/server/render-pages.js +1277 -0
  239. package/dist/server/render-people.d.ts +32 -0
  240. package/dist/server/render-people.js +121 -0
  241. package/dist/server/render-results.d.ts +744 -0
  242. package/dist/server/render-results.js +2237 -0
  243. package/dist/server/render-settings.d.ts +154 -0
  244. package/dist/server/render-settings.js +580 -0
  245. package/dist/server/render-tasks.d.ts +632 -0
  246. package/dist/server/render-tasks.js +2345 -0
  247. package/dist/server/request-context.d.ts +46 -0
  248. package/dist/server/request-context.js +17 -0
  249. package/dist/server/route-table.d.ts +25 -3
  250. package/dist/server/route-table.js +167 -77
  251. package/dist/server/runtime.d.ts +28 -20
  252. package/dist/server/runtime.js +7 -1
  253. package/dist/server/session.d.ts +138 -0
  254. package/dist/server/session.js +94 -0
  255. package/dist/server/settings.d.ts +1 -5
  256. package/dist/server/settings.js +2186 -2121
  257. package/dist/server/task-views.d.ts +11 -4
  258. package/dist/server/task-views.js +26 -22
  259. package/dist/server/tasks.d.ts +9 -9
  260. package/dist/server/tasks.js +1489 -1420
  261. package/dist/server-telemetry.d.ts +4 -3
  262. package/dist/server-telemetry.js +4 -8
  263. package/dist/setup-guide.d.ts +2 -2
  264. package/dist/skills-ui.d.ts +4 -3
  265. package/dist/skills-ui.js +14 -17
  266. package/dist/skills.d.ts +1 -1
  267. package/dist/skills.js +2 -1
  268. package/dist/slack-chat.d.ts +2 -2
  269. package/dist/slack-chat.js +6 -6
  270. package/dist/slack-settings.d.ts +3 -2
  271. package/dist/slack-settings.js +17 -42
  272. package/dist/spend-ui.d.ts +5 -4
  273. package/dist/spend-ui.js +17 -40
  274. package/dist/spend.d.ts +14 -14
  275. package/dist/spend.js +31 -31
  276. package/dist/sso-ui.d.ts +3 -2
  277. package/dist/sso-ui.js +26 -27
  278. package/dist/storage-ui.d.ts +2 -1
  279. package/dist/storage-ui.js +14 -33
  280. package/dist/store-baseline.d.ts +19 -0
  281. package/dist/store-baseline.js +2873 -0
  282. package/dist/store.d.ts +197 -249
  283. package/dist/store.js +747 -3084
  284. package/dist/subagent-admin.d.ts +53 -0
  285. package/dist/{teammate-admin.js → subagent-admin.js} +37 -37
  286. package/dist/{teammate-desk.d.ts → subagent-desk.d.ts} +22 -23
  287. package/dist/{teammate-desk.js → subagent-desk.js} +26 -35
  288. package/dist/{teammate-memory.d.ts → subagent-memory.d.ts} +17 -17
  289. package/dist/{teammate-memory.js → subagent-memory.js} +41 -41
  290. package/dist/{teammate-question.d.ts → subagent-question.d.ts} +6 -6
  291. package/dist/{teammate-question.js → subagent-question.js} +17 -17
  292. package/dist/{teammate-tools.d.ts → subagent-tools.d.ts} +18 -18
  293. package/dist/{teammate-tools.js → subagent-tools.js} +36 -36
  294. package/dist/{teammate-week.d.ts → subagent-week.d.ts} +10 -10
  295. package/dist/{teammate-week.js → subagent-week.js} +23 -23
  296. package/dist/{teammate-work.d.ts → subagent-work.d.ts} +21 -21
  297. package/dist/{teammate-work.js → subagent-work.js} +50 -50
  298. package/dist/subagents-ui.d.ts +22 -0
  299. package/dist/subagents-ui.js +137 -0
  300. package/dist/{teammates.d.ts → subagents.d.ts} +11 -11
  301. package/dist/{teammates.js → subagents.js} +21 -21
  302. package/dist/subscription-chat.d.ts +13 -13
  303. package/dist/subscription-chat.js +19 -19
  304. package/dist/surface.d.ts +19 -2
  305. package/dist/surface.js +20 -33
  306. package/dist/task-composer.d.ts +2 -1
  307. package/dist/task-composer.js +7 -7
  308. package/dist/task-status.d.ts +4 -3
  309. package/dist/task-status.js +15 -16
  310. package/dist/team-cli.d.ts +1 -0
  311. package/dist/team-cli.js +14 -2
  312. package/dist/team-http.d.ts +5 -9
  313. package/dist/team-http.js +5 -63
  314. package/dist/team-leads.js +13 -13
  315. package/dist/team-runtime.d.ts +2 -2
  316. package/dist/team-runtime.js +8 -8
  317. package/dist/team-ui.d.ts +2 -1
  318. package/dist/team-ui.js +2 -5
  319. package/dist/teams-chat.js +6 -6
  320. package/dist/teams-settings.d.ts +3 -2
  321. package/dist/teams-settings.js +20 -31
  322. package/dist/telegram-flow.d.ts +3 -3
  323. package/dist/telegram-flow.js +3 -3
  324. package/dist/{telegram-mate.d.ts → telegram-lead.d.ts} +5 -5
  325. package/dist/{telegram-mate.js → telegram-lead.js} +27 -27
  326. package/dist/telegram-settings.d.ts +3 -2
  327. package/dist/telegram-settings.js +14 -25
  328. package/dist/telegram-status.js +1 -1
  329. package/dist/telegram-team.d.ts +1 -1
  330. package/dist/telegram-team.js +5 -5
  331. package/dist/telegram.d.ts +1 -1
  332. package/dist/telegram.js +9 -30
  333. package/dist/text-limits.d.ts +18 -18
  334. package/dist/text-limits.js +18 -18
  335. package/dist/toolroll-update-ui.d.ts +3 -2
  336. package/dist/toolroll-update-ui.js +35 -39
  337. package/dist/toolroll-update.d.ts +8 -0
  338. package/dist/toolroll-update.js +45 -8
  339. package/dist/tools-ui.d.ts +5 -2
  340. package/dist/tools-ui.js +31 -31
  341. package/dist/when-html.d.ts +11 -3
  342. package/dist/when-html.js +5 -6
  343. package/dist/workspace-ui.d.ts +4 -2
  344. package/dist/workspace-ui.js +10 -10
  345. package/package.json +1 -2
  346. package/dist/server/shared.d.ts +0 -3151
  347. package/dist/server/shared.js +0 -12001
  348. package/dist/teammate-admin.d.ts +0 -53
  349. package/dist/teammates-ui.d.ts +0 -20
  350. package/dist/teammates-ui.js +0 -166
@@ -0,0 +1,747 @@
1
+ /** The console's page chrome: the shell, navigation, page scripts, the stylesheet and shared presentation helpers. */
2
+ import { accentStyle } from "../accent-colors.js";
3
+ import { APPROVAL_RULES_CSS } from "../approval-rules-ui.js";
4
+ import { ASSIGNMENT_CSS } from "../assignment-ui.js";
5
+ import { BACKUP_CSS } from "../backup-ui.js";
6
+ import { BRAND_MARK_CSS } from "../brand-mark.js";
7
+ import {} from "../browser-workspace.js";
8
+ import { CHAT_POLISH_CSS } from "../chat-polish.js";
9
+ import { CHECK_SETTINGS_CSS } from "../check-levels-ui.js";
10
+ import { CODING_SHIPPING_CSS } from "../coding-shipping-ui.js";
11
+ import { CODING_CSS } from "../coding-ui.js";
12
+ import { CREDENTIALS_CSS } from "../credentials-ui.js";
13
+ import { DISCLOSURE_CSS } from "../disclosure.js";
14
+ import { EVIDENCE_PACK_CSS } from "../evidence-pack.js";
15
+ import { EXPORT_CSS } from "../export-ui.js";
16
+ import { START_COMMAND } from "../first-run.js";
17
+ import { GALLERY_CSS } from "../flow-gallery-ui.js";
18
+ import { STARTERS_CSS } from "../flow-starters-ui.js";
19
+ import { FLOWS_CSS } from "../flows-ui.js";
20
+ import { INTEGRATIONS_CSS } from "../integrations-ui.js";
21
+ import { KITS_CSS } from "../kits-ui.js";
22
+ import { KNOWLEDGE_CSS } from "../knowledge-ui.js";
23
+ import { LEAD_CONTEXT_CSS } from "../lead-context.js";
24
+ import { LIMITS_CSS } from "../limits-ui.js";
25
+ import { MOBILE_VIEWPORT_SCRIPT } from "../mobile-viewport.js";
26
+ import { MODELS_CSS } from "../models-ui.js";
27
+ import { MONITORING_CSS } from "../monitoring-ui.js";
28
+ import { PEOPLE_AUDIT_CSS } from "../people-audit-ui.js";
29
+ import { POLICY_CSS } from "../policy-ui.js";
30
+ import { PROJECT_DELETE_CSS } from "../project-delete-ui.js";
31
+ import { projectName } from "../project.js";
32
+ import { PULL_REQUEST_SETTINGS_CSS } from "../pull-request-ui.js";
33
+ import { RECIPE_CSS } from "../recipe-ui.js";
34
+ import { REQUEST_LIMITS_CSS } from "../request-budget-ui.js";
35
+ import { RETENTION_CSS } from "../retention-ui.js";
36
+ import { SKILLS_CSS } from "../skills-ui.js";
37
+ import { SPEND_CSS } from "../spend-ui.js";
38
+ import { SSO_CSS } from "../sso-ui.js";
39
+ import { STORAGE_CSS } from "../storage-ui.js";
40
+ import {} from "../store.js";
41
+ import { styleAsset } from "../style-asset.js";
42
+ import { spendLine } from "../summary.js";
43
+ import { TASK_STATUS_CSS } from "../task-status.js";
44
+ import { SUBAGENT_CSS } from "../subagents-ui.js";
45
+ import { UPDATES_CSS } from "../toolroll-update-ui.js";
46
+ import { TOOLS_CSS } from "../tools-ui.js";
47
+ import { TRANSITIONS_CSS } from "../transitions-recipes.js";
48
+ import { updateNoticeWords } from "../update-notice.js";
49
+ import { whenHtml } from "../when-html.js";
50
+ import { WORKSPACE_MOTION_CSS } from "../workspace-motion.js";
51
+ import { primaryDestinationOf } from "../workspace-ui.js";
52
+ import { requestContext } from "./request-context.js";
53
+ import { readFileSync } from "node:fs";
54
+ import { join } from "node:path";
55
+ import {} from "node:http";
56
+ import { attributes, html, joinHtml, postForm, scriptElement, styleElement } from "../html.js";
57
+ import { page, respond } from "./http.js";
58
+ import {} from "./render-pages.js";
59
+ import {} from "./session.js";
60
+ /** The spend line for a 7am reader: whole cents, "runs", the gap still named. */
61
+ export function consoleSpend(summary) {
62
+ if (summary.invoked.length === 0)
63
+ return "nothing — no provider was invoked";
64
+ if (summary.measured.some(run => run.authMode === "subscription"))
65
+ return spendLine(summary);
66
+ const dollars = `$${summary.spend.toFixed(2)}`;
67
+ // The measured count rides the SAME clause as the total (Phase 3 A6):
68
+ // a bare sum over a mixed fleet reads as complete, and is not.
69
+ if (summary.measured.length === summary.invoked.length) {
70
+ return `${dollars} across ${summary.invoked.length} run(s)`;
71
+ }
72
+ return `${dollars} measured across ${summary.measured.length} of ${summary.invoked.length} runs — the rest report tokens only`;
73
+ }
74
+ /** ISO to the minute — "2026-08-12 17:56" — for anywhere a person reads a time. */
75
+ export function when(iso) {
76
+ return iso === null ? "" : iso.slice(0, 16).replace("T", " ");
77
+ }
78
+ /** The same minute as a `<time>`: the full stamp on a desk, "16:39" / "Yesterday 16:39" / "Sep 28" on a phone. */
79
+ export function whenTime(iso) {
80
+ return iso === null ? html `` : whenHtml(iso, when(iso));
81
+ }
82
+ /** Overdue is derived at render — display never writes. */
83
+ export function isOverdue(decision, now) {
84
+ if (decision.state === "expired")
85
+ return true;
86
+ return (decision.state === "open" && decision.deadline !== null && decision.deadline <= now.toISOString());
87
+ }
88
+ /**
89
+ * A refusal that stays inside the console: same shell, a problem banner, and
90
+ * a way back — the error path is the one place a console must not stop
91
+ * being a console. Bearer callers still get plain text; they parse, not read.
92
+ */
93
+ export function refuse(response, who, status, message, backHref = "/") {
94
+ if (who === null || who.via === "bearer") {
95
+ return respond(response, status, "text/plain; charset=utf-8", message);
96
+ }
97
+ const body = joinHtml([
98
+ html `<h1>${status === 404 ? "Not found" : "Request refused"}</h1>`,
99
+ // A missing page is plain news, not an error.
100
+ status === 404 ? html `<p>${message}</p>` : html `<div class="problem">${message}</div>`,
101
+ html `<p class="meta refusal-back"><a href="${backHref}">\u2190 Back</a></p>`,
102
+ ], "\n");
103
+ // A signed-in browser keeps the workspace around it: the same navigation as every page.
104
+ const framed = requestContext.getStore()?.refusal;
105
+ if (framed !== undefined && who.via === "cookie")
106
+ return framed(response, status, body);
107
+ return page(response, status, shell("refused", body));
108
+ }
109
+ /** The demo's one-line promise, on every page. */
110
+ export const DEMO_BANNER = `Demo: a scripted lead and sample projects. Nothing calls a model, reaches outside or spends. For your own project, run ${START_COMMAND} in its folder.`;
111
+ /** The demo notice on a phone: one line. */
112
+ export const DEMO_BANNER_SHORT = "Demo: sample projects. Nothing calls a model or spends.";
113
+ /**
114
+ * The Operations Ledger: the design system Alex approved in Figma and the
115
+ * design/ shadcn package, carried as pure CSS on server-rendered HTML.
116
+ * Deliberately not the React library: the console ships zero dependencies
117
+ * and zero page JavaScript under a CSP that forbids scripts, and a look is
118
+ * not worth that posture. The same semantic tokens render both system light
119
+ * and dark themes.
120
+ */
121
+ /** The shared palette, light and dark (2026-09-27, "Signal"): neutral grey
122
+ * grounds, ink for every act a person can take, and one chart magenta, the
123
+ * colour nautical charts print lights and cautions in, for what needs a
124
+ * person (the needs-you count, the act that resolves a screen, focus and
125
+ * selection). Status keeps its own four hues; magenta never means "failed".
126
+ * The frame is the ground the sidebar sits on; work sits on paper sheets
127
+ * inset into it. Semantic names only; every surface (server pages, the
128
+ * React workspace, the pre-script fallback) reads these.
129
+ *
130
+ * The console's stylesheet lives in console.css, shipped beside this module:
131
+ * the palette above in light, dark and pinned themes, then the page rules.
132
+ * Its bytes are the served sheet's (WORKSPACE_STYLE hashes them). */
133
+ export const STYLE = readFileSync(join(import.meta.dirname, "console.css"), "utf8");
134
+ /** Appearance: a three-way segmented switch, one tap per choice. */
135
+ export const THEME_CONTROLS_CSS = `.task-repo select{width:100%;min-height:2.75rem;font-size:1rem}.task-repo-add{margin:.35rem .1rem .5rem}.task-repo-add a{display:inline-flex;align-items:center;min-height:2.25rem}details.result-request-open.result-request-form>summary{border:0;background:transparent;padding:.5rem 0;min-height:2.75rem;font-weight:600;display:list-item;list-style:revert}details.result-request-open.result-request-form>summary::-webkit-details-marker{display:revert}form.js-autosave button[type=submit]{display:none}.provider-row{border-bottom:1px solid var(--so-line);padding:.35rem 0}.provider-row:first-of-type{border-top:1px solid var(--so-line)}.provider-head{display:flex;align-items:center;gap:.75rem;margin:.4rem 0 0}.provider-status{display:inline-flex;align-items:center;gap:.4rem;color:var(--so-muted);font-size:.875rem}.provider-status i{width:.5rem;height:.5rem;border-radius:50%;background:var(--so-muted)}.provider-status--ok i{background:var(--so-success)}.provider-status--warn i{background:var(--so-attention)}.provider-status--off i{background:transparent;border:1.5px solid var(--so-muted)}details.provider-manage>summary{cursor:pointer;color:var(--so-accent-text);font-size:.875rem;min-height:2.5rem;display:list-item;padding-block:.5rem}.card.props .row{display:grid;gap:.1rem;margin:0 0 .75rem}.card.props .row>.meta{display:block;font-size:.75rem}.card.props .row>.meta::first-letter{text-transform:uppercase}.card.props .row>.mono{font-family:var(--font-sans);font-size:.875rem}.card.props .row>.mono .seal{font-family:var(--font-mono);font-size:.8125rem}details.evidence-files{margin:1rem 0}details.evidence-files>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:600}details.evidence-files ul{list-style:none;margin:0;padding:0}details.evidence-files li{display:flex;justify-content:space-between;gap:1rem;padding:.5rem 0;border-bottom:1px solid var(--so-line)}.result-action .result-feedback-link{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;text-decoration:none}@media(hover:hover) and (pointer:fine){.result-action .result-feedback-link:hover{background:var(--so-raised)}}.so-sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.verdict{margin:.5rem 0 .75rem}.verdict-chips{display:flex;flex-wrap:wrap;gap:.4rem;list-style:none;padding:0;margin:0}.verdict-chip{display:inline-flex;align-items:center;gap:.3rem;min-height:1.75rem;padding:.2rem .65rem;border-radius:999px;font-size:.8125rem;font-weight:600;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}.verdict-chip svg{width:.9rem;height:.9rem}.verdict-chip--success{background:var(--so-success-soft);color:var(--so-success)}.verdict-chip--danger{background:var(--so-danger-soft);color:var(--so-danger)}.verdict-chip--warning{background:var(--so-warning-soft);color:var(--so-warning)}.verdict-chip--info{background:var(--so-info-soft);color:var(--so-info)}.verdict-by{margin:.4rem 0 0}details.result-request-open{margin:.5rem 0}details.result-request-open>summary{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;cursor:pointer;list-style:none}details.result-request-open>summary::-webkit-details-marker{display:none}details.result-request-open[open]>summary{margin-bottom:.75rem}.settings-tiles{display:grid;gap:1.25rem;margin:0 0 2rem}.settings-tiles h2{margin:0 0 .5rem;font-size:.875rem;font-weight:600;color:var(--so-muted)}.settings-tiles section>div{display:grid;grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr));gap:.5rem}.settings-tiles a>span{display:grid;gap:.1rem;min-width:0}.settings-tiles .provider-status{font-weight:400;font-size:.8125rem}.settings-tiles a{display:flex;align-items:center;gap:.6rem;min-height:3rem;padding:.65rem .8rem;border:1px solid var(--so-line);border-radius:.625rem;background:var(--so-paper);color:var(--so-ink);text-decoration:none;font-weight:550;font-size:.875rem}@media(hover:hover) and (pointer:fine){.settings-tiles a:hover{border-color:var(--so-input-line);background:var(--so-raised)}}.settings-tiles a>svg{width:1.1rem;height:1.1rem;flex-shrink:0;color:var(--so-accent-text)}details.settings-more{margin:.25rem 0 1.25rem}details.settings-more>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:550}details.settings-more>summary .meta{font-weight:400;margin-left:.35rem}.settings-changed{margin-top:-.25rem}.appearance{margin:0 0 28px}.appearance h2{margin:0 0 10px}.theme-switch{display:inline-flex;flex-wrap:nowrap;max-width:100%;gap:4px;padding:4px;margin:0;border:1px solid var(--so-line);border-radius:10px;background:var(--so-raised)}.theme-switch .theme-choice,.so-native-region .theme-switch .theme-choice{flex:1 1 0;width:auto;white-space:nowrap;min-height:40px;padding:8px 16px;border:0;border-radius:7px;background:transparent;color:var(--so-muted);font:inherit;font-weight:550;box-shadow:none;cursor:pointer}@media(hover:hover) and (pointer:fine){.theme-switch .theme-choice:hover{color:var(--so-ink)}}.theme-switch .theme-choice[aria-pressed="true"]{background:var(--so-paper);color:var(--so-ink);box-shadow:0 1px 2px rgb(0 0 0 / .1)}.appearance .meta{margin:8px 0 0}@media(max-width:600px){.theme-switch .theme-choice{min-height:44px}}.update-notes{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit;max-height:18rem;overflow:auto}`;
136
+ /** The page CSS this module writes itself (not the imported modules'), for the stylesheet contract tests. */
137
+ export const PAGE_CSS = STYLE + THEME_CONTROLS_CSS;
138
+ /** The Inbox tabs (console v2): a segmented track of real links; the dot marks a tab with something new, on phones only. */
139
+ export const INBOX_TABS_CSS = '.inbox-tabs{display:inline-flex;gap:2px;max-width:100%;overflow-x:auto;margin:4px 0 14px;padding:2px;border-radius:12px;background:var(--so-raised);scrollbar-width:none}' +
140
+ '.inbox-tabs a{position:relative;display:inline-flex;align-items:center;gap:6px;min-height:28px;padding:0 10px;border-radius:8px;color:var(--so-muted);font-size:13px;font-weight:500;text-decoration:none;white-space:nowrap}' +
141
+ '.inbox-tabs a[aria-current="page"]{background:var(--so-paper);color:var(--so-ink);box-shadow:var(--so-pill-shadow)}' +
142
+ '.inbox-tab-count{min-width:18px;padding:0 5px;border-radius:9999px;font:500 11px/18px var(--so-mono,ui-monospace,monospace);font-variant-numeric:tabular-nums;text-align:center}' +
143
+ '.inbox-tab-count--needs{background:var(--so-signal);color:var(--so-on-signal)}' +
144
+ '.inbox-ask{margin:18px 0 0}.inbox-ask>h2{display:flex;align-items:baseline;gap:8px;margin:0 0 4px;font-size:15px;font-weight:600}.inbox-ask>h2 .count{font:500 12px var(--so-mono,ui-monospace,monospace);font-variant-numeric:tabular-nums;color:var(--so-muted)}.inbox-ask h3{font-size:13px;font-weight:600;margin:12px 0 4px}' +
145
+ '.inbox-unread{display:none;position:absolute;top:4px;right:3px;width:6px;height:6px;border-radius:50%;background:var(--so-signal)}' +
146
+ '@media (max-width:760px){.inbox-tabs{display:flex;width:100%}.inbox-tabs a{flex:1;justify-content:center;min-height:44px;padding:0 6px}.inbox-unread{display:block}}';
147
+ export const WORKSPACE_STYLE = styleAsset(STYLE + BRAND_MARK_CSS + INBOX_TABS_CSS + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + INTEGRATIONS_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + SUBAGENT_CSS + KITS_CSS + STARTERS_CSS + GALLERY_CSS + SSO_CSS + CREDENTIALS_CSS + REQUEST_LIMITS_CSS + PEOPLE_AUDIT_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + TASK_STATUS_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_SETTINGS_CSS + CHECK_SETTINGS_CSS + DISCLOSURE_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
148
+ /**
149
+ * A server-rendered region that stays current: when the page's live stream
150
+ * says something changed (GET /live, live-bus.ts), fetch this page's own
151
+ * fragment and swap it in place — no flicker, no scroll reset. Inside the
152
+ * workspace the page's one stream is already open (browser/live.ts) and
153
+ * announces each event on `window`; a page without it opens its own stream
154
+ * to the workspace room. A redirect or auth failure navigates to /login
155
+ * instead of ever inserting the login page into the region (finding 4). The
156
+ * swapped markup is this server's own rendering of the same route — escaped
157
+ * at the sink like every page, fetched same-origin — and the nonce'd CSP
158
+ * refuses to execute anything the region could smuggle. Nothing reads on a timer.
159
+ */
160
+ export function regionScript(regionId, fragmentName, path) {
161
+ // Where the fragment lives: the page's own URL by default; an explicit
162
+ // path when a page embeds another entity's region (the task page embeds
163
+ // the live run's peek, slice 1c — no proxy route exists for it).
164
+ const target = path === undefined ? "location.pathname+q" : JSON.stringify(`${path}?fragment=${fragmentName}`);
165
+ // The named-region refresher (attended review, finding 2): swaps exactly one
166
+ // element, never a form-bearing pane. Failures are VISIBLE — a frozen
167
+ // page must never look live (finding 6): the stamp says how old the
168
+ // region is. One fetch in flight at a time; a change during it fetches once more.
169
+ return (`(function(){var region=document.getElementById(${JSON.stringify(regionId)});if(!region)return;` +
170
+ `var stamp=document.getElementById(${JSON.stringify(regionId)}+"-stamp");` +
171
+ `var last=Date.now();var busy=false;var again=false;var failed=false;var stopped=false;` +
172
+ // The swap must not steal what the reader was holding (arc 4,
173
+ // findings 1/15/16): before replacing the region, remember the
174
+ // focused row (roving set only), each scrolled lane's first visible
175
+ // card, and which board lane was centered; put them back after —
176
+ // scroll first, focus last with preventScroll so it cannot undo the
177
+ // scroll pass. All coordinates come from bounding rects, never
178
+ // offsetLeft against an unpositioned parent (finding 21).
179
+ `function laneKey(l){var m=/(^|\\s)(lane-[a-z]+)(\\s|$)/.exec(l.className);return m?m[2]:null;}` +
180
+ `function keep(){var data={lanes:[],focus:null,pager:-1,fold:{}};` +
181
+ `region.querySelectorAll("details.lane").forEach(function(d){var k=laneKey(d);if(k)data.fold[k]=d.open;});` +
182
+ `var act=document.activeElement;` +
183
+ `if(act&&region.contains(act)&&act.matches&&act.matches("a.row, a.lane-card"))data.focus=act.getAttribute("href");` +
184
+ `var board=region.querySelector(".board");` +
185
+ `if(board&&board.scrollLeft>0){var lanes=board.querySelectorAll(".lane");` +
186
+ `var bc=board.getBoundingClientRect();var mid=bc.left+bc.width/2;var best=-1,bd=1e9;` +
187
+ `for(var i=0;i<lanes.length;i++){var r=lanes[i].getBoundingClientRect();var d=Math.abs(r.left+r.width/2-mid);if(d<bd){bd=d;best=i;}}` +
188
+ `data.pager=best;}` +
189
+ `var ls=region.querySelectorAll(".lane");` +
190
+ `for(var i=0;i<ls.length;i++){var l=ls[i];if(l.scrollTop<=0)continue;var key=laneKey(l);if(!key)continue;` +
191
+ `var first=null,off=0;var cards=l.querySelectorAll("a.lane-card");var lr=l.getBoundingClientRect();` +
192
+ `for(var j=0;j<cards.length;j++){var cr=cards[j].getBoundingClientRect();if(cr.bottom>lr.top){first=cards[j].getAttribute("href");off=cr.top-lr.top;break;}}` +
193
+ `data.lanes.push({key:key,href:first,off:off,top:l.scrollTop});}` +
194
+ `return data;}` +
195
+ `function restore(data){` +
196
+ `region.querySelectorAll("details.lane").forEach(function(d){var k=laneKey(d);if(k&&k in data.fold){if(data.fold[k])d.setAttribute("open","");else d.removeAttribute("open");}});` +
197
+ `for(var i=0;i<data.lanes.length;i++){var d=data.lanes[i];var l=region.querySelector(".lane."+d.key);if(!l)continue;` +
198
+ `var done=false;` +
199
+ `if(d.href){var cards=l.querySelectorAll("a.lane-card");` +
200
+ `for(var j=0;j<cards.length;j++){if(cards[j].getAttribute("href")===d.href){` +
201
+ `l.scrollTop=Math.max(0,l.scrollTop+cards[j].getBoundingClientRect().top-l.getBoundingClientRect().top-d.off);done=true;break;}}}` +
202
+ `if(!done)l.scrollTop=d.top;}` +
203
+ `if(data.pager>=0){var board=region.querySelector(".board");` +
204
+ `if(board){var lanes=board.querySelectorAll(".lane");var at=Math.min(data.pager,lanes.length-1);` +
205
+ `if(at>=0){var br=board.getBoundingClientRect();var lr=lanes[at].getBoundingClientRect();` +
206
+ `board.scrollLeft=board.scrollLeft+(lr.left+lr.width/2)-(br.left+br.width/2);}}}` +
207
+ `if(data.focus!==null){var links=region.querySelectorAll("a.row, a.lane-card");` +
208
+ `for(var i=0;i<links.length;i++){if(links[i].getAttribute("href")===data.focus){links[i].focus({preventScroll:true});break;}}}}` +
209
+ `function tell(){if(!stamp||stopped)return;var s=Math.round((Date.now()-last)/1000);` +
210
+ `stamp.textContent=failed?"stale — retrying ("+s+"s old)":"updated "+s+"s ago";}` +
211
+ `setInterval(tell,1000);` +
212
+ `function refresh(){if(stopped)return;if(busy){again=true;return;}busy=true;again=false;` +
213
+ `var q=location.search?location.search+"&fragment="+${JSON.stringify(fragmentName)}:"?fragment="+${JSON.stringify(fragmentName)};` +
214
+ `fetch(${target},{redirect:"manual",cache:"no-store"})` +
215
+ `.then(function(r){if(r.type==="opaqueredirect"||r.status===401||r.status===403){location.href="/login";return null;}` +
216
+ `return r.ok?r.text():null;})` +
217
+ `.then(function(t){if(t){var kept=keep();region.innerHTML=t;restore(kept);last=Date.now();failed=false;}else{failed=true;}})` +
218
+ `.catch(function(){failed=true;})` +
219
+ // A fragment that marks itself final stops: a finished or abandoned build is not fetched again.
220
+ `.then(function(){busy=false;tell();if(region.querySelector("[data-region-stop]")){stopped=true;if(stamp)stamp.textContent="";return;}if(again)refresh();});}` +
221
+ `function heard(name){if(name==="change"||name==="reload")refresh();}` +
222
+ `if(document.documentElement.dataset.live==="1"){addEventListener("so:live",function(e){heard(e.detail&&e.detail.event);});}` +
223
+ `else if(typeof EventSource!=="undefined"){var es=new EventSource("/live?room=workspace");` +
224
+ `es.addEventListener("change",function(){heard("change");});es.addEventListener("reload",function(){heard("reload");});` +
225
+ `document.addEventListener("visibilitychange",function(){if(!document.hidden)refresh();});}` +
226
+ `})();`);
227
+ }
228
+ /**
229
+ * The live transcript's DEDICATED poller (arc 1 §4): a JSON byte-offset
230
+ * protocol, not an HTML fragment — the response is raw sanitized text and
231
+ * the ONLY sink is textContent, so nothing here can become markup. One
232
+ * request in flight, visibility-paused, stops on `final`, and a `replaced`
233
+ * answer restarts from zero with a visible line — never a silent re-read.
234
+ */
235
+ export function transcriptScript(path, elementId = "live-transcript") {
236
+ const base = path === undefined ? "location.pathname" : JSON.stringify(path);
237
+ return (`(function(){var out=document.getElementById(${JSON.stringify(elementId)});if(!out)return;` +
238
+ `var from=0,busy=false,stopped=false;` +
239
+ `function near(){return out.scrollHeight-out.scrollTop-out.clientHeight<40;}` +
240
+ `function go(){if(stopped)return;if(busy||document.hidden){later();return;}busy=true;` +
241
+ `fetch(${base}+"?fragment=transcript&from="+from,{redirect:"manual",cache:"no-store"})` +
242
+ `.then(function(r){if(r.type==="opaqueredirect"||r.status===401||r.status===403){stopped=true;return null;}` +
243
+ `if(r.status===409)return{error:"replaced"};return r.ok?r.json():null;})` +
244
+ `.then(function(d){busy=false;if(d===null){later();return;}if(stopped)return;` +
245
+ `if(d.error==="replaced"){from=0;out.textContent="[the view restarted]\\n";later();return;}` +
246
+ `if(d.error){stopped=true;return;}` +
247
+ `var stick=near();` +
248
+ `if(typeof d.text==="string"&&d.text!==""){out.appendChild(document.createTextNode(d.text));if(stick)out.scrollTop=out.scrollHeight;}` +
249
+ `if(typeof d.nextOffset==="number"&&d.nextOffset>=from){from=d.nextOffset;}` +
250
+ `if(d.final===true){stopped=true;var m=document.getElementById(${JSON.stringify(`${elementId}-state`)});` +
251
+ `if(m)m.textContent="the agent finished — the record on this page is the story";return;}` +
252
+ `later();})` +
253
+ `.catch(function(){busy=false;later();});}` +
254
+ `function later(){setTimeout(go,2000);}go();})();`);
255
+ }
256
+ /**
257
+ * The chrome layer: the jump palette and elapsed tickers. Pure
258
+ * navigation — no key ever posts, so the palette cannot approve anything;
259
+ * ceremonies stay POST + password + CSRF, untouched. Reads its index from
260
+ * a non-executable JSON script tag rendered by the same authorized page.
261
+ */
262
+ /** The rail's presentation-only state is safe beside a password ceremony:
263
+ * it never reads a field or sends a request, and keeps the chat collapsible
264
+ * from its very first screen while the palette and global keys remain absent. */
265
+ export function sidebarScript() {
266
+ return (MOBILE_VIEWPORT_SCRIPT +
267
+ `(function(){var app=document.querySelector(".app"),sideToggle=document.querySelector(".side-toggle");` +
268
+ `function setSide(collapsed){if(!app||!sideToggle)return;app.classList.toggle("sidebar-collapsed",collapsed);` +
269
+ `sideToggle.setAttribute("aria-expanded",String(!collapsed));sideToggle.setAttribute("aria-label",collapsed?"expand sidebar":"collapse sidebar");` +
270
+ `sideToggle.setAttribute("title",collapsed?"expand sidebar":"collapse sidebar");}` +
271
+ `if(app&&sideToggle){var sideCollapsed=false;try{sideCollapsed=localStorage.getItem("standing-orders:sidebar-collapsed")==="1";}catch(e){}` +
272
+ `setSide(sideCollapsed);sideToggle.addEventListener("click",function(){var next=!app.classList.contains("sidebar-collapsed");setSide(next);` +
273
+ `try{localStorage.setItem("standing-orders:sidebar-collapsed",next?"1":"0");}catch(e){}});}})();`);
274
+ }
275
+ export function chromeScript() {
276
+ return (sidebarScript() +
277
+ `(function(){` +
278
+ // The app-icon badge (Phase 2E): the page's server-rendered waiting
279
+ // count is authoritative over any stale push — synced on every chrome
280
+ // page load through the worker, cleared at zero, honest no-op where
281
+ // push never enrolled.
282
+ `try{var waiting=document.querySelector("[data-waiting]");` +
283
+ `if(waiting&&navigator.serviceWorker&&navigator.serviceWorker.controller){` +
284
+ `navigator.serviceWorker.controller.postMessage({badge:Number(waiting.getAttribute("data-waiting"))||0});}}catch(e){}` +
285
+ // elapsed tickers: server timestamps, client arithmetic, display only
286
+ `function tick(){var nodes=document.querySelectorAll("time[data-elapsed-since]");` +
287
+ `for(var i=0;i<nodes.length;i++){var t=Date.parse(nodes[i].getAttribute("data-elapsed-since"));` +
288
+ `if(!isFinite(t))continue;var s=Math.max(0,Math.floor((Date.now()-t)/1000));` +
289
+ `var m=Math.floor(s/60);var h=Math.floor(m/60);` +
290
+ `nodes[i].textContent=h>0?h+"h "+(m%60)+"m":m>0?m+"m "+(s%60)+"s":s+"s";}}` +
291
+ `setInterval(tick,1000);tick();` +
292
+ // The rail's two accordion groups stay exclusive: opening one closes
293
+ // the other. Each <details> already carries its own open/closed state
294
+ // and keyboard operation natively — this only enforces "at most one
295
+ // open" on top of that, and no-ops wherever the groups are absent.
296
+ `var navGroups=document.querySelectorAll(".nav-group");` +
297
+ `navGroups.forEach(function(g){g.addEventListener("toggle",function(){` +
298
+ `if(g.open){navGroups.forEach(function(o){if(o!==g)o.removeAttribute("open");});}});});` +
299
+ // the palette
300
+ `var raw=document.getElementById("palette-index");if(!raw)return;` +
301
+ `var index;try{index=JSON.parse(raw.textContent||"[]");}catch(e){return;}` +
302
+ `var open=false,box=null,list=null,input=null,items=[],paletteBack=null;` +
303
+ `function close(){if(!open)return;open=false;box.remove();box=null;if(paletteBack&&paletteBack.isConnected)paletteBack.focus();paletteBack=null;}` +
304
+ `function go(href){location.href=href;}` +
305
+ `function render(filter){list.textContent="";items=[];var n=0;` +
306
+ `for(var i=0;i<index.length&&n<12;i++){var e=index[i];` +
307
+ `if(filter&&(e.label.toLowerCase().indexOf(filter.toLowerCase())===-1))continue;` +
308
+ `var li=document.createElement("li");li.setAttribute("role","option");li.textContent=e.label;` +
309
+ `li.setAttribute("data-href",e.href);if(n===0)li.setAttribute("aria-selected","true");` +
310
+ `li.addEventListener("click",function(ev){go(ev.currentTarget.getAttribute("data-href"));});` +
311
+ `list.appendChild(li);items.push(li);n++;}}` +
312
+ `function pick(delta){var at=-1;for(var i=0;i<items.length;i++)if(items[i].getAttribute("aria-selected")==="true")at=i;` +
313
+ `if(at>=0)items[at].removeAttribute("aria-selected");var next=Math.max(0,Math.min(items.length-1,at+delta));` +
314
+ `if(items[next])items[next].setAttribute("aria-selected","true");}` +
315
+ `function show(){if(open)return;paletteBack=document.activeElement;open=true;` +
316
+ `box=document.createElement("div");box.className="palette";box.setAttribute("role","dialog");box.setAttribute("aria-label","jump to");` +
317
+ `input=document.createElement("input");input.type="text";input.placeholder="jump to\u2026";input.setAttribute("autocomplete","off");` +
318
+ `list=document.createElement("ul");list.setAttribute("role","listbox");` +
319
+ `box.appendChild(input);box.appendChild(list);document.body.appendChild(box);` +
320
+ `input.addEventListener("input",function(){render(input.value);});` +
321
+ `input.addEventListener("keydown",function(ev){` +
322
+ `if(ev.key==="Escape"){close();ev.preventDefault();}` +
323
+ `else if(ev.key==="ArrowDown"){pick(1);ev.preventDefault();}` +
324
+ `else if(ev.key==="ArrowUp"){pick(-1);ev.preventDefault();}` +
325
+ `else if(ev.key==="Enter"){for(var i=0;i<items.length;i++)if(items[i].getAttribute("aria-selected")==="true")go(items[i].getAttribute("data-href"));ev.preventDefault();}});` +
326
+ `render("");input.focus();}` +
327
+ // the shortcuts overlay: display-only; focus moves in on open and
328
+ // back out on close; every other shortcut sleeps while it is up
329
+ `var help=document.querySelector(".kbd-help");var helpBack=null;` +
330
+ `function helpOpen(){return help!==null&&!help.hidden;}` +
331
+ `function toggleHelp(){if(!help)return;` +
332
+ `if(help.hidden){helpBack=document.activeElement;help.hidden=false;help.focus();}` +
333
+ `else{help.hidden=true;if(helpBack&&helpBack.focus)helpBack.focus();helpBack=null;}}` +
334
+ `document.addEventListener("click",function(ev){if(helpOpen()&&!help.contains(ev.target))toggleHelp();` +
335
+ `document.querySelectorAll("details.switcher[open],details.work-tools[open]").forEach(function(d){if(!d.contains(ev.target))d.removeAttribute("open");});});` +
336
+ `document.addEventListener("keydown",function(ev){if(ev.key!=="Escape"||ev.isComposing)return;var d=ev.target.closest&&ev.target.closest("details.switcher[open],details.work-tools[open]");if(d){d.open=false;d.querySelector("summary").focus();ev.preventDefault();}});` +
337
+ // j/k: a roving focus over the page's rows — only from body or from
338
+ // inside the set, clamped at the ends, preventDefault only on a real
339
+ // move (finding 9); held keys may repeat
340
+ `function rove(delta,ev){` +
341
+ `var set=Array.prototype.slice.call(document.querySelectorAll("a.row, a.lane-card"));` +
342
+ `if(set.length===0)return;` +
343
+ `var cur=document.activeElement;var at=set.indexOf(cur);` +
344
+ `if(cur&&cur!==document.body&&cur!==document.documentElement&&at===-1)return;` +
345
+ `var next=at===-1?(delta>0?0:set.length-1):Math.max(0,Math.min(set.length-1,at+delta));` +
346
+ `if(next===at)return;` +
347
+ `set[next].focus();ev.preventDefault();}` +
348
+ // key routing: never inside editable targets, no modifiers, no IME
349
+ // composition (finding 4); repeats allowed only for j/k
350
+ `var pending=null;` +
351
+ `document.addEventListener("keydown",function(ev){` +
352
+ `if(ev.isComposing||ev.metaKey||ev.ctrlKey||ev.altKey)return;` +
353
+ `var t=ev.target;var tag=t&&t.tagName?t.tagName.toLowerCase():"";` +
354
+ `if(tag==="input"||tag==="textarea"||tag==="select"||tag==="button"||(t&&t.isContentEditable))return;` +
355
+ `if(ev.key==="Escape"){if(helpOpen()){toggleHelp();ev.preventDefault();return;}close();return;}` +
356
+ `if(helpOpen())return;` +
357
+ `if(ev.repeat&&ev.key!=="j"&&ev.key!=="k")return;` +
358
+ `if(ev.key==="/"){show();ev.preventDefault();return;}` +
359
+ `if(ev.key==="?"){toggleHelp();ev.preventDefault();return;}` +
360
+ `if(ev.key==="j"||ev.key==="k"){rove(ev.key==="j"?1:-1,ev);return;}` +
361
+ `if(pending==="g"){pending=null;` +
362
+ `var map={b:"/board",i:"/",w:"/workbench",d:"/done",q:"/board?view=order",f:"/fleet",t:"/tasks",a:"/activity",p:"/projects"};` +
363
+ `if(map[ev.key]){go(map[ev.key]);ev.preventDefault();}return;}` +
364
+ `if(ev.key==="g"){pending="g";setTimeout(function(){pending=null;},800);}});` +
365
+ `})();`);
366
+ }
367
+ export const ROLE_TITLES = { plan: "Planner", build: "Builder", review: "Reviewer", repair: "Repair" };
368
+ export function screen(title, body, options = {}) {
369
+ return { title, body, ...options };
370
+ }
371
+ /**
372
+ * The sensitivity classifier (arc 4, findings 3/17): a body showing a
373
+ * password input renders WITHOUT the chrome additions (palette, overlay,
374
+ * global keys) — the page's own functional script still ships. Tolerant
375
+ * of quoting, casing, and whitespace; `data-type="password"` and prose
376
+ * mentioning passwords do not match. This is defense-in-depth over a
377
+ * file whose only HTML producer is its own double-quoted template
378
+ * convention — forceSensitive is the escape hatch for what a regex
379
+ * cannot judge.
380
+ */
381
+ export const SENSITIVE_INPUT = /<input\b[^>]*[\s"']type\s*=\s*["']?password/i;
382
+ /** The design contract every page carries (impeccable direction, 2026-09-27). */
383
+ export const DESIGN_CONTRACT = html `<!-- THESIS: a control plane that stays quiet until a person is needed; it refuses the dashboard default of coloured status everywhere and an accent on every button.
384
+ OWN-WORLD: a neutral grey frame with paper sheets inset into it (Arc, Linear), Geist for words and Geist Mono for machine facts, ink for every act a person can take, one chart magenta only for what waits on a person plus focus and selection; hairlines and one soft sheet shadow, no glass, no gradients.
385
+ STORY: glance, see the accent count, open the one thing that needs you, act with the one accent verb, leave.
386
+ FIRST VIEWPORT: sidebar on the frame (accent mark, ink New task, the current page as a raised pill, the accent needs-you count); the main sheet with a 52px header over compact 13px rows; the Crew sheet beside it.
387
+ FORM: the Raycast and Arc canon, user-pinned; seed 9c849086.
388
+ FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md -->`;
389
+ /** The shortcuts overlay: display-only, toggled by the chrome layer,
390
+ * absent from sensitive pages. Navigation help in plain words — no key
391
+ * ever posts. */
392
+ export const KBD_HELP = html `<div class="kbd-help" hidden role="dialog" aria-label="keyboard shortcuts" tabindex="-1">\
393
+ <h2>Keyboard shortcuts</h2><table>\
394
+ <tr><td><kbd>/</kbd></td><td>jump to a page or an open task</td></tr>\
395
+ <tr><td><kbd>g</kbd> then <kbd>i</kbd></td><td>go to the inbox</td></tr>\
396
+ <tr><td><kbd>g</kbd> then <kbd>b</kbd></td><td>go to the board</td></tr>\
397
+ <tr><td><kbd>g</kbd> then <kbd>q</kbd></td><td>go to the queue</td></tr>\
398
+ <tr><td><kbd>g</kbd> then <kbd>f</kbd></td><td>go to the fleet</td></tr>\
399
+ <tr><td><kbd>g</kbd> then <kbd>w</kbd></td><td>go to the workbench</td></tr>\
400
+ <tr><td><kbd>g</kbd> then <kbd>t</kbd></td><td>go to the task list</td></tr>\
401
+ <tr><td><kbd>g</kbd> then <kbd>a</kbd></td><td>go to the activity view</td></tr>\
402
+ <tr><td><kbd>g</kbd> then <kbd>p</kbd></td><td>go to the projects</td></tr>\
403
+ <tr><td><kbd>g</kbd> then <kbd>d</kbd></td><td>go to what is done</td></tr>\
404
+ <tr><td><kbd>j</kbd> / <kbd>k</kbd></td><td>move through the rows on this page</td></tr>\
405
+ <tr><td><kbd>Escape</kbd></td><td>close this</td></tr>\
406
+ </table></div>`;
407
+ export function shell(title, body, options = {}) {
408
+ const main = body;
409
+ const head = joinHtml([
410
+ html `<!doctype html>`,
411
+ html `<html lang="en"${themeAttribute()}><head><meta charset="utf-8">`,
412
+ // viewport-fit=cover is what makes env(safe-area-inset-*) non-zero on a
413
+ // notched phone; without it the tab bar sits under the home indicator.
414
+ html `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">`,
415
+ ...(requestContext.getStore()?.theme
416
+ ? [html `<meta name="theme-color" content="${requestContext.getStore()?.theme === "dark" ? "#0b0b0b" : "#efefef"}">`]
417
+ : [html `<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0b0b0b">`, html `<meta name="theme-color" media="(prefers-color-scheme: light)" content="#efefef">`]),
418
+ html `<meta name="mobile-web-app-capable" content="yes">`,
419
+ html `<meta name="apple-mobile-web-app-capable" content="yes">`,
420
+ html `<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">`,
421
+ html `<link rel="icon" href="/icon.svg" type="image/svg+xml">`,
422
+ // Live status with zero JavaScript: the page asks the browser to fetch
423
+ // it again. Only ever on read-only briefing pages — a refresh on a page
424
+ // with a form would eat what somebody was typing.
425
+ // A refresh is a reload, and reloads never cross-fade. No page opts
426
+ // out of the fade either: opening one from a page that fades makes
427
+ // the browser report the aborted fade as an error.
428
+ ...(options.refreshSeconds === undefined
429
+ ? []
430
+ : [html `<meta http-equiv="refresh" content="${Math.max(5, Math.floor(options.refreshSeconds))}">`]),
431
+ // With the in-place swapper, the whole-page refresh survives only as
432
+ // the no-JavaScript fallback — and CSS view transitions run without
433
+ // JavaScript, so the fallback carries its own opt-out.
434
+ ...(options.live?.fallbackRefresh !== true
435
+ ? []
436
+ : [html `<noscript><meta http-equiv="refresh" content="30"><style>@view-transition { navigation: none; }</style></noscript>`]),
437
+ html `<title>${title}</title><link rel="stylesheet" href="${WORKSPACE_STYLE.path}">${accentHead()}</head><body>${DESIGN_CONTRACT}`,
438
+ ], "\n");
439
+ const tail = options.live === undefined
440
+ ? html `</body></html>`
441
+ : html `${scriptElement(options.live.script, { nonce: options.live.nonce })}</body></html>`;
442
+ if (options.chrome === undefined) {
443
+ // Chromeless: the login page and refusal pages.
444
+ return joinHtml([head, html `<main>`, main, html `</main>`, tail], "\n");
445
+ }
446
+ const chrome = options.chrome;
447
+ const item = (key, href, label, count) => html `<a href="${href}" aria-label="${label}" title="${label}"${chrome.active === key ? html ` class="active"` : ""}${key === "inbox" && count !== undefined ? html ` data-waiting="${count}"` : ""}>\
448
+ ${label}\
449
+ ${count !== undefined && count > 0 ? html ` <span class="count badge badge-open">${count}${key === "inbox" && chrome.inboxSaturated ? "+" : ""}</span>` : ""}</a>`;
450
+ // The scope bar (portfolio arc §1): ONE row naming which rows this screen
451
+ // can show — derived from the route's declared scope, falling back to the
452
+ // session default. It replaced the sidebar workspace card and the mobile
453
+ // pill's link as the single scope truth. Display and GET navigation only;
454
+ // switching projects stays the POST + CSRF /projects flow.
455
+ const effectiveScope = chrome.scope ?? (chrome.project === null ? "all" : "project");
456
+ const peekCounts = chrome.active === "code" || chrome.projectPeek === null || chrome.projectPeek === undefined || effectiveScope !== "project" ? null : chrome.projectPeek;
457
+ const scopeCounts = peekCounts === null
458
+ ? null
459
+ : html `${peekCounts.waiting > 0 ? html `<span class="hot">${peekCounts.waiting} needs you</span>` : html `<span>0 needs you</span>`}\
460
+ <span>${peekCounts.running} live</span><span>${peekCounts.queued} queued</span>`;
461
+ // On a desk each count is the road to what it counts; inside the phone
462
+ // pill's summary they stay text, since the pill itself is the switcher.
463
+ const scopeStatus = peekCounts === null
464
+ ? null
465
+ : html `<span class="scope-status">\
466
+ ${peekCounts.waiting > 0 ? html `<a class="hot" href="/work?view=needs-you">${peekCounts.waiting} needs you</a>` : html `<a href="/work?view=needs-you">0 needs you</a>`}\
467
+ <a href="/runs">${peekCounts.running} live</a><a href="/board?view=order">${peekCounts.queued} queued</a></span>`;
468
+ const scopeName = effectiveScope === "project" && chrome.project !== null ? projectName(chrome.project) : "All projects";
469
+ // The switcher (board pass): the scope's name opens a menu of every
470
+ // enrolled project — one tap to open one, or to widen to all — as plain
471
+ // POST forms carrying the session's csrf, returning to this screen.
472
+ // Inert wherever the chrome may carry no forms: a sensitive page, or a
473
+ // request without a cookie session.
474
+ const canSwitch = options.sensitive !== true && chrome.csrf !== undefined && chrome.csrf !== "" && chrome.projects !== undefined;
475
+ const switcherMenu = (foot) => {
476
+ if (!canSwitch)
477
+ return null;
478
+ const allCurrent = effectiveScope !== "project";
479
+ const current = html ` class="current" aria-current="true"`;
480
+ const rows = [
481
+ postForm("/projects/select", html `<button type="submit"${allCurrent ? current : ""}>All projects</button>`, { returnTo: chrome.returnTo ?? "/", hidden: { path: "" } }),
482
+ ...(chrome.projects ?? []).map(one => postForm("/projects/open", html `<button type="submit"${!allCurrent && chrome.project === one.path ? current : ""}>${one.name}</button>`, { returnTo: chrome.returnTo ?? "/", hidden: { path: one.path } })),
483
+ ];
484
+ return html `<div class="switcher-menu" role="menu">${rows}${foot}</div>`;
485
+ };
486
+ // No "scope" label word: in this product "scope" names a task's approved
487
+ // terms — the bar just states which projects the screen is showing.
488
+ const scopeBar = html `<div class="scope-bar">\
489
+ ${canSwitch
490
+ ? html `<details class="switcher"><summary class="name">${scopeName}${CHEVRON_ICON}</summary>${switcherMenu(null)}</details>`
491
+ : html `<span class="name">${scopeName}</span>`}\
492
+ ${scopeStatus}</div>`;
493
+ // The rail's own accordion (sidebar rework): open the group holding the
494
+ // active page, closed otherwise — the client toggle keeps it exclusive.
495
+ const navGroup = (key, label, rows, open) => html `<details class="nav-group" data-group="${key}"${open ? html ` open` : ""}>\
496
+ <summary>${label}${CHEVRON_ICON}</summary>\
497
+ <nav class="nav-group-items">${rows.map(row => item(row.key, row.href, row.label))}</nav>\
498
+ </details>`;
499
+ // The three primary destinations (workspace package 1): Chat, Work,
500
+ // Projects — every old page keeps its own active key and lights the
501
+ // destination it now lives under. The count rides Work: it is the
502
+ // saturated needs-you count, exactly as the inbox row wore it.
503
+ const primary = chrome.active === "code" ? "work" : primaryDestinationOf(chrome.active);
504
+ const primaryItem = (key, href, label, count) => html `<a href="${href}" aria-label="${key === "work" && count !== undefined && count > 0 && chrome.inboxLabel !== undefined ? `${label}, ${chrome.inboxLabel}` : label}" title="${label}"${primary === key ? html ` class="active" aria-current="page"` : ""}${key === "work" && count !== undefined ? html ` data-waiting="${count}"` : ""}>\
505
+ <span class="glyph">${NAV_ICONS[key] ?? ""}</span>${label}\
506
+ ${count !== undefined && count > 0 ? html ` <span${chrome.inboxLabel === undefined ? "" : html ` aria-label="${chrome.inboxLabel}" title="${chrome.inboxLabel}"`} class="count badge badge-open">${count}${chrome.inboxSaturated ? "+" : ""}</span>` : ""}</a>`;
507
+ const side = joinHtml([
508
+ html `<aside class="side">`,
509
+ html `<div class="side-head"><a class="brand" href="${chrome.chat === true ? "/chat" : "/work"}"><span class="brand-long">Toolroll</span><span class="brand-short">T</span></a>`,
510
+ ...(options.sidebarToggle === true
511
+ ? [html `<button type="button" class="side-toggle" aria-label="collapse sidebar" aria-expanded="true" title="collapse sidebar">${strokeIcon(html `<path d="m15 18-6-6 6-6"/>`)}</button>`]
512
+ : []),
513
+ html `</div>`,
514
+ html `<nav>`,
515
+ // Chat is present only where the ceiling ever allows it (unchanged
516
+ // gating); Work and Projects always. Every specialist tool is a dim
517
+ // text row inside one of the two accordion groups below.
518
+ ...(chrome.chat ? [primaryItem("chat", "/chat", "Chat")] : []),
519
+ primaryItem("work", "/work", "Tasks", chrome.inboxCount),
520
+ primaryItem("flows", "/flows", "Flows"),
521
+ primaryItem("projects", "/projects", "Projects"),
522
+ html `</nav>`,
523
+ ...(chrome.active === "code" ? [] : [html `<a class="new-task" href="/tasks/new" aria-label="New task">+ New task</a>`]),
524
+ html `<nav class="nav-groups">`,
525
+ navGroup("tools", "Work tools", workToolRows(chrome.projectScoped, chrome.chat, chrome.code), TOOL_KEYS.has(chrome.active)),
526
+ navGroup("settings", "Settings", settingsRows(chrome.projectScoped, chrome.settings), SETTINGS_KEYS.has(chrome.active)),
527
+ html `</nav>`,
528
+ html `<span class="grow"></span>`,
529
+ html `</aside>`,
530
+ ], "\n");
531
+ // The sandbox banner: every page, no dismissal — a screenshot of a demo
532
+ // must not pass as production (adoption review, finding 8; the FENCE is
533
+ // the refuseDemo gate in operate.ts, this is the honest label).
534
+ const update = chrome.update;
535
+ const demoBanner = html `\
536
+ ${chrome.demo === true ? html `<div class="banner"><span class="badge">Demo</span>${DEMO_BANNER.replace(/^Demo: /, "")}</div>` : ""}\
537
+ ${chrome.modeBanner === undefined ? "" : html `<div class="banner"><span class="badge badge-running">Mode</span>${chrome.modeBanner.words} \u00b7 <a href="/mode">the terms \u00b7 end it</a></div>`}\
538
+ ${(chrome.signIn ?? []).map(one => html `<div class="banner sign-in-banner" data-sign-in="${one.provider}"><strong>${one.title}</strong> \u00b7 run <code>${one.command}</code> on this computer, then resume.${one.detail === "" ? "" : ` ${one.detail}`}\
539
+ ${postForm(one.resumeHref, html `<button type="submit">${one.resumeLabel}</button>`, { attrs: { class: "inline" } })}</div>`)}\
540
+ ${chrome.updateWaiting === undefined ? "" : html `<div class="banner update-waiting" role="status">${chrome.updateWaiting.words} \u00b7 <a href="/settings/updates">Update status</a></div>`}\
541
+ ${update === undefined ? "" : html `<div class="banner update-banner" data-update="${update.version}">${updateNoticeWords(update)} \u00b7 <a href="${update.href}">What's new</a>\
542
+ ${postForm(update.dismissHref, html `<button type="submit">Dismiss</button>`, { attrs: { class: "inline" }, hidden: { version: update.version } })}</div>`}`;
543
+ // The scope bar sits between the banners and the main/split body, so it
544
+ // can never disappear with a responsive pane (portfolio arc §1).
545
+ const content = chrome.listPane === undefined
546
+ ? html `<div class="content">${demoBanner}${scopeBar}<main>${main}</main></div>`
547
+ : html `<div class="content">${demoBanner}${scopeBar}<div class="split">\
548
+ <div class="list-pane">${chrome.listPane}</div>\
549
+ <div class="detail"><main>${main}</main></div>\
550
+ </div></div>`;
551
+ // The phone chrome (arc 4): a top bar with the project one tap from
552
+ // switching and quick capture, and a bottom tab bar with the always-
553
+ // visible destinations (chat where allowed, inbox, board, builds,
554
+ // projects) a thumb visits — everything else behind /menu. CSS shows
555
+ // these only below 760px; desktop keeps the sidebar untouched.
556
+ const mobileTop = joinHtml([
557
+ html `<header class="mobile-top">`,
558
+ html `<a class="brand-mini" href="${chrome.chat === true ? "/chat" : "/work"}">T</a>`,
559
+ // On a phone the pill IS the scope row (mobile pass): the project's
560
+ // name, its three counts, and the one /projects link at that
561
+ // breakpoint — the scope bar hides below 760px so the header is one
562
+ // row, not three. Desktop keeps the scope bar's link and hides this.
563
+ canSwitch
564
+ ? html `<details class="project-pill switcher"><summary><span class="name">${scopeName}${CHEVRON_ICON}</span>${scopeCounts === null ? "" : html `<span class="pill-status">${scopeCounts}</span>`}</summary>${switcherMenu(html `<a class="manage" href="/projects">manage projects</a>`)}</details>`
565
+ : html `<a class="project-pill" href="/projects"><span class="name">${scopeName}</span>${scopeCounts === null ? "" : html `<span class="pill-status">${scopeCounts}</span>`}</a>`,
566
+ ...(chrome.active === "code" ? [] : [html `<a class="mobile-new" href="/tasks/new">+ task</a>`]),
567
+ // Settings and the specialist tools are a header action on a phone,
568
+ // never a fourth primary tab (workspace package 1).
569
+ html `<a class="mobile-more" href="/menu" aria-label="tools and settings" title="tools and settings">${strokeIcon(html `<path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/>`)}</a>`,
570
+ html `</header>`,
571
+ ]);
572
+ // Drawn icons, one stroke weight, inline and CSP-safe — never unicode
573
+ // glyphs standing in for an icon system.
574
+ const TAB_ICONS = {
575
+ code: strokeIcon(html `<path d="m8 6-6 6 6 6m8-12 6 6-6 6M14 4l-4 16"/>`),
576
+ chat: strokeIcon(CHAT_PATHS),
577
+ work: strokeIcon(WORK_PATHS),
578
+ projects: strokeIcon(FOLDER_PATHS),
579
+ flows: strokeIcon(html `<rect width="8" height="8" x="3" y="3" rx="2"/><path d="M7 11v4a2 2 0 0 0 2 2h4"/><rect width="8" height="8" x="13" y="13" rx="2"/>`),
580
+ };
581
+ const tab = (key, href, label, count) =>
582
+ // A phone tab says THAT something waits, with a dot; the number is on
583
+ // the Work views themselves (Linear Mobile's rule — one tap away).
584
+ html `<a href="${href}"${primary === key ? html ` class="active" aria-current="page"` : ""}><span class="glyph">${TAB_ICONS[key]}</span>${label}\
585
+ ${count !== undefined && count > 0 ? html `<span class="dot-badge" role="img" aria-label="${chrome.inboxLabel ?? `${count} waiting`}"></span>` : ""}</a>`;
586
+ // Three primary tabs, the same three as the rail: chat where allowed,
587
+ // work, projects. Tools and settings sit behind the header's menu action.
588
+ const tabbar = joinHtml([
589
+ html `<nav class="tabbar">`,
590
+ ...(chrome.chat ? [tab("chat", "/chat", "Chat")] : []),
591
+ tab("work", "/work", "Tasks", chrome.inboxCount),
592
+ tab("flows", "/flows", "Flows"),
593
+ tab("projects", "/projects", "Projects"),
594
+ html `</nav>`,
595
+ ]);
596
+ return joinHtml([head, html `<div class="app">`, side, mobileTop, content, tabbar, html `</div>`, tail], "\n");
597
+ }
598
+ /** ONE way a person is named on any surface (U3): the same chip everywhere. */
599
+ export function personChip(name) {
600
+ return html `<span class="mono">${name}</span>`;
601
+ }
602
+ /** The brand, as the workspace sidebar shows it. */
603
+ export const BRAND_HTML = html `<span class="so-brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>Toolroll`;
604
+ /** A page on its own, in the workspace's look but with no script: the brand, then the page. */
605
+ export function focusDocument(title, body) {
606
+ return shell(title, html `<div class="focus-page"><a class="so-wordmark focus-brand" href="/chat">${BRAND_HTML}</a>${body}</div>`);
607
+ }
608
+ export function chatMoney(microusd) {
609
+ return microusd === null ? "unknown" : `$${(microusd / 1_000_000).toFixed(2)}`;
610
+ }
611
+ export function relativeAge(iso, now) {
612
+ const minutes = Math.max(0, Math.round((now.getTime() - Date.parse(iso)) / 60_000));
613
+ if (minutes < 60)
614
+ return `${minutes}m ago`;
615
+ const hours = Math.round(minutes / 60);
616
+ return hours < 48 ? `${hours}h ago` : `${Math.round(hours / 24)}d ago`;
617
+ }
618
+ /** The project chip every all-scope row wears: null is UNPLACED, said so. */
619
+ export function projectChip(repo) {
620
+ return repo === null || repo === undefined
621
+ ? html ` <span class="badge">Unplaced</span>`
622
+ : html ` <span class="badge">${projectName(repo)}</span>`;
623
+ }
624
+ /**
625
+ * Creating work is the product's first verb, so it gets a whole calm page:
626
+ * a title, the goal that becomes the scope draft, and the project it lands
627
+ * in — then straight to the approve card, which is the aha the flow serves.
628
+ */
629
+ /** One queue card. Taken work renders pinned — visible, never draggable. */
630
+ /** The person's pinned theme from their own cookie; null follows the device. */
631
+ export function pinnedTheme(cookieHeader) {
632
+ const value = /(?:^|;\s*)so-theme=(light|dark)(?:;|$)/.exec(cookieHeader ?? "")?.[1];
633
+ return value === "light" || value === "dark" ? value : null;
634
+ }
635
+ export function themeAttribute() {
636
+ return attributes({ "data-theme": requestContext.getStore()?.theme ?? null });
637
+ }
638
+ /** A chosen accent (Settings → Appearance) re-pigments the signal tokens only, after the shared stylesheet. */
639
+ export function accentHead() {
640
+ const accent = requestContext.getStore()?.accent ?? null;
641
+ return accent === null ? html `` : styleElement(accentStyle(accent), { "data-accent": accent });
642
+ }
643
+ /** Drawn, one stroke weight, like the tab bar's icons — never a glyph. */
644
+ export const GRIP_ICON = html `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">\
645
+ <circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/>\
646
+ <circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>`;
647
+ export const TO_FRONT_ICON = html `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">\
648
+ <path d="M12 19V5"/><path d="m5 12 7-7 7 7"/></svg>`;
649
+ /** One stroke weight, from the tab bar's set: the sidebar's primary rows
650
+ * wear an icon each; the foot's list stays text, the way Linear's does. */
651
+ export const strokeIcon = (paths) => html `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${paths}</svg>`;
652
+ /** Where the queue lives now: the board, flipped to dispatch order. */
653
+ export const QUEUE_VIEW = "/board?view=order";
654
+ export const FOLDER_PATHS = html `<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/>`;
655
+ export const CHAT_PATHS = html `<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>`;
656
+ export const WORK_PATHS = html `<path d="M9 6h11"/><path d="M9 12h11"/><path d="M9 18h11"/><path d="m4 6 1 1 2-2"/><path d="m4 12 1 1 2-2"/><path d="m4 18 1 1 2-2"/>`;
657
+ export const NAV_ICONS = {
658
+ code: strokeIcon(html `<path d="m8 6-6 6 6 6m8-12 6 6-6 6M14 4l-4 16"/>`),
659
+ chat: strokeIcon(CHAT_PATHS),
660
+ work: strokeIcon(WORK_PATHS),
661
+ projects: strokeIcon(FOLDER_PATHS),
662
+ flows: strokeIcon(html `<rect width="8" height="8" x="3" y="3" rx="2"/><path d="M7 11v4a2 2 0 0 0 2 2h4"/><rect width="8" height="8" x="13" y="13" rx="2"/>`),
663
+ };
664
+ /**
665
+ * The workspace shell (package 1): three primary rows (chat, work,
666
+ * projects) above two accordion groups. Work tools is where work is
667
+ * arranged, planned, and scheduled — the board, its order view, the
668
+ * flat task list, recipes, the portfolio, and the action
669
+ * ledger. Settings is who and what the fleet runs on — fleet,
670
+ * requirements, people, operating mode, system — plus the agent-defaults
671
+ * page where the console offers it. Both draw from the same two lists on
672
+ * a desk (accordion groups) and on a phone (/menu sections), so the two
673
+ * never disagree, and a project-scoped login sees exactly the rows it
674
+ * saw before: moving a link never widens role or project visibility.
675
+ */
676
+ export function workToolRows(scoped = false, _offersChat = false, offersCode = false) {
677
+ const rows = [
678
+ // D5: coding sessions are deprecated; /code still opens this release, but nothing links to it.
679
+ { key: "inbox", href: "/inbox", label: "Inbox", hint: "questions, approvals, and repairs to act on" },
680
+ { key: "board", href: "/board", label: "Board", hint: "lanes by state, with the order view" },
681
+ { key: "tasks", href: "/tasks", label: "Task list", hint: "everything, filterable by state" },
682
+ { key: "recipes", href: "/recipes", label: "Recipes", hint: "choose, customize, and reuse a workflow" },
683
+ { key: "workbench", href: "/workbench", label: "Portfolio", hint: "every project and live build in one place" },
684
+ { key: "ledger", href: "/ledger", label: "Action ledger", hint: "who acted, what happened, and the result" },
685
+ { key: "spend", href: "/spend", label: "Spend", hint: "what agent work cost, and monthly budgets" },
686
+ ];
687
+ return scoped ? rows.filter(row => row.key !== "workbench" && row.key !== "spend") : rows;
688
+ }
689
+ export function settingsRows(scoped = false, offersSettings = false) {
690
+ const rows = [
691
+ ...(offersSettings ? [{ key: "settings", href: "/settings", label: "Settings", hint: "agent defaults, alerts, credentials" }] : []),
692
+ { key: "fleet", href: "/fleet", label: "Fleet", hint: "who is working, and on what" },
693
+ { key: "caps", href: "/caps", label: "Requirements", hint: "tools and credentials builds need" },
694
+ { key: "people", href: "/people", label: "People", hint: "who can sign in, and what they have done" },
695
+ { key: "mode", href: "/mode", label: "Operating mode", hint: "the signed posture this repository runs under" },
696
+ { key: "system", href: "/system", label: "System", hint: "workers, providers, and grants" },
697
+ ];
698
+ return scoped ? rows.filter(row => row.key === "people" || row.key === "settings") : rows;
699
+ }
700
+ /** Which accordion group opens by default for a given active page. */
701
+ export const TOOL_KEYS = new Set(["code", "inbox", "board", "queue", "tasks", "workbench", "recipes", "ledger", "spend"]);
702
+ export const SETTINGS_KEYS = new Set(["settings", "fleet", "caps", "people", "mode", "system"]);
703
+ /** The builds screen's views (reduction pass §1): done, the review queue,
704
+ * and activity are ways of looking at builds, not destinations. */
705
+ export function buildsViews(current) {
706
+ const views = [
707
+ ["builds", "/runs", "builds"],
708
+ ["done", "/done", "done"],
709
+ ["review", "/review", "review"],
710
+ ["activity", "/activity", "activity"],
711
+ ];
712
+ return html `<p class="meta board-view">${joinHtml(views.map(([key, href, label]) => (key === current ? html `<strong>${label}</strong>` : html `<a href="${href}">${label}</a>`)), " \u00b7 ")}</p>`;
713
+ }
714
+ export const CHEVRON_ICON = html `<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">\
715
+ <path d="m6 9 6 6 6-6"/></svg>`;
716
+ /** The drag grip: a 2rem touch-sized handle that owns its touches
717
+ * (touch-action: none), so a finger on it drags instead of scrolling. */
718
+ export const GRIP_HANDLE = html `<span class="queue-handle" aria-hidden="true">${GRIP_ICON}</span>`;
719
+ /** Badges, labels and buttons read in sentence case, whatever word a record stores. */
720
+ export const sentenceCase = (word) => word.charAt(0).toUpperCase() + word.slice(1);
721
+ /** Only an https github.com pull URL earns an anchor (audit IV-11) — a
722
+ * corrupted row renders as text, never as navigation. */
723
+ export function safePrUrl(url) {
724
+ if (url === null)
725
+ return null;
726
+ try {
727
+ const parsed = new URL(url);
728
+ if (parsed.protocol !== "https:" || parsed.hostname !== "github.com")
729
+ return null;
730
+ if (!/^\/[^/]+\/[^/]+\/pull\/[0-9]+$/.test(parsed.pathname))
731
+ return null;
732
+ return parsed.href;
733
+ }
734
+ catch {
735
+ return null;
736
+ }
737
+ }
738
+ /** A shortened fingerprint for display — enough to compare by eye; the
739
+ * full value rides in the title attribute and in every form field. */
740
+ export function shortDigest(digest) {
741
+ return digest.length <= 12 ? html `${digest}` : html `<span title="${digest}">${digest.slice(0, 12)}…</span>`;
742
+ }
743
+ /** One display line, bounded — turn text is data, never layout. */
744
+ export function oneLineOf(text, cap) {
745
+ const flat = text.replace(/\s+/g, " ").trim();
746
+ return flat.length <= cap ? flat : `${flat.slice(0, cap - 1)}…`;
747
+ }