@songsid/agend 2.3.3 → 2.3.4-beta.10

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 (368) hide show
  1. package/README.md +3 -3
  2. package/README.zh-TW.md +3 -3
  3. package/THIRD_PARTY_NOTICES.md +37 -0
  4. package/dist/agend-home.d.ts +13 -0
  5. package/dist/agend-home.js +42 -0
  6. package/dist/agend-home.js.map +1 -1
  7. package/dist/agent-cli-instructions.md +2 -2
  8. package/dist/agent-credentials.d.ts +24 -0
  9. package/dist/agent-credentials.js +66 -0
  10. package/dist/agent-credentials.js.map +1 -0
  11. package/dist/agent-endpoint.d.ts +3 -1
  12. package/dist/agent-endpoint.js +13 -0
  13. package/dist/agent-endpoint.js.map +1 -1
  14. package/dist/auth-api.d.ts +16 -3
  15. package/dist/auth-api.js +32 -8
  16. package/dist/auth-api.js.map +1 -1
  17. package/dist/authority/session-authorizations.d.ts +28 -4
  18. package/dist/authority/session-authorizations.js +63 -17
  19. package/dist/authority/session-authorizations.js.map +1 -1
  20. package/dist/authority/web-authority.d.ts +15 -1
  21. package/dist/authority/web-authority.js +27 -10
  22. package/dist/authority/web-authority.js.map +1 -1
  23. package/dist/backend/antigravity.js +3 -1
  24. package/dist/backend/antigravity.js.map +1 -1
  25. package/dist/backend/claude-code.d.ts +3 -0
  26. package/dist/backend/claude-code.js +100 -5
  27. package/dist/backend/claude-code.js.map +1 -1
  28. package/dist/backend/codex-footer-hints.d.ts +4 -0
  29. package/dist/backend/codex-footer-hints.js +27 -0
  30. package/dist/backend/codex-footer-hints.js.map +1 -0
  31. package/dist/backend/codex-session-lookup.d.ts +35 -0
  32. package/dist/backend/codex-session-lookup.js +93 -0
  33. package/dist/backend/codex-session-lookup.js.map +1 -1
  34. package/dist/backend/codex.d.ts +27 -0
  35. package/dist/backend/codex.js +136 -23
  36. package/dist/backend/codex.js.map +1 -1
  37. package/dist/backend/kiro-identity.d.ts +2 -0
  38. package/dist/backend/kiro-identity.js +13 -6
  39. package/dist/backend/kiro-identity.js.map +1 -1
  40. package/dist/backend/kiro.d.ts +3 -0
  41. package/dist/backend/kiro.js +68 -5
  42. package/dist/backend/kiro.js.map +1 -1
  43. package/dist/backend/muse.js +5 -3
  44. package/dist/backend/muse.js.map +1 -1
  45. package/dist/backend/types.d.ts +13 -1
  46. package/dist/backend/types.js +3 -0
  47. package/dist/backend/types.js.map +1 -1
  48. package/dist/backend-availability.d.ts +6 -0
  49. package/dist/backend-availability.js +13 -5
  50. package/dist/backend-availability.js.map +1 -1
  51. package/dist/cache-ledger.d.ts +85 -0
  52. package/dist/cache-ledger.js +236 -1
  53. package/dist/cache-ledger.js.map +1 -1
  54. package/dist/cache-service.d.ts +5 -0
  55. package/dist/cache-service.js +16 -4
  56. package/dist/cache-service.js.map +1 -1
  57. package/dist/channel/adapters/discord.js +27 -4
  58. package/dist/channel/adapters/discord.js.map +1 -1
  59. package/dist/channel/adapters/telegram.d.ts +51 -0
  60. package/dist/channel/adapters/telegram.js +238 -18
  61. package/dist/channel/adapters/telegram.js.map +1 -1
  62. package/dist/channel/inbox-temp-name.d.ts +9 -0
  63. package/dist/channel/inbox-temp-name.js +13 -0
  64. package/dist/channel/inbox-temp-name.js.map +1 -0
  65. package/dist/channel/mcp-tools.js +3 -3
  66. package/dist/channel/mcp-tools.js.map +1 -1
  67. package/dist/channel/types.d.ts +2 -0
  68. package/dist/chat-log-ids.d.ts +48 -0
  69. package/dist/chat-log-ids.js +182 -0
  70. package/dist/chat-log-ids.js.map +1 -0
  71. package/dist/classic-channel-manager.d.ts +8 -1
  72. package/dist/classic-channel-manager.js +24 -2
  73. package/dist/classic-channel-manager.js.map +1 -1
  74. package/dist/cli.js +4 -6
  75. package/dist/cli.js.map +1 -1
  76. package/dist/config-validator.js +18 -1
  77. package/dist/config-validator.js.map +1 -1
  78. package/dist/cross-instance-notice.d.ts +10 -0
  79. package/dist/cross-instance-notice.js +67 -5
  80. package/dist/cross-instance-notice.js.map +1 -1
  81. package/dist/daemon.d.ts +52 -0
  82. package/dist/daemon.js +538 -35
  83. package/dist/daemon.js.map +1 -1
  84. package/dist/delivery-consumed-watch.d.ts +23 -1
  85. package/dist/delivery-consumed-watch.js +30 -3
  86. package/dist/delivery-consumed-watch.js.map +1 -1
  87. package/dist/delivery-reconciliation.d.ts +7 -0
  88. package/dist/delivery-reconciliation.js +9 -1
  89. package/dist/delivery-reconciliation.js.map +1 -1
  90. package/dist/dialog-screen-text.d.ts +16 -0
  91. package/dist/dialog-screen-text.js +51 -0
  92. package/dist/dialog-screen-text.js.map +1 -0
  93. package/dist/env-file.d.ts +2 -0
  94. package/dist/env-file.js +13 -8
  95. package/dist/env-file.js.map +1 -1
  96. package/dist/export-import.d.ts +7 -0
  97. package/dist/export-import.js +44 -10
  98. package/dist/export-import.js.map +1 -1
  99. package/dist/fleet-context.d.ts +2 -0
  100. package/dist/fleet-manager.d.ts +172 -12
  101. package/dist/fleet-manager.js +897 -139
  102. package/dist/fleet-manager.js.map +1 -1
  103. package/dist/fleet-yaml-patch.d.ts +3 -0
  104. package/dist/fleet-yaml-patch.js +103 -0
  105. package/dist/fleet-yaml-patch.js.map +1 -0
  106. package/dist/general-knowledge/skills/concepts/SKILL.md +393 -0
  107. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +2 -1
  108. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +3 -1
  109. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +41 -4
  110. package/dist/general-knowledge/skills/model-discovery/SKILL.md +2 -0
  111. package/dist/general-knowledge/skills/web-appearance/SKILL.md +1 -1
  112. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +14 -4
  113. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  114. package/dist/install-stream.d.ts +53 -0
  115. package/dist/install-stream.js +134 -0
  116. package/dist/install-stream.js.map +1 -0
  117. package/dist/instance-config-impact.js +1 -0
  118. package/dist/instance-config-impact.js.map +1 -1
  119. package/dist/instance-lifecycle.d.ts +22 -2
  120. package/dist/instance-lifecycle.js +103 -19
  121. package/dist/instance-lifecycle.js.map +1 -1
  122. package/dist/instance-topic-delete.d.ts +77 -0
  123. package/dist/instance-topic-delete.js +123 -0
  124. package/dist/instance-topic-delete.js.map +1 -0
  125. package/dist/instructions.d.ts +2 -2
  126. package/dist/instructions.js +2 -2
  127. package/dist/instructions.js.map +1 -1
  128. package/dist/interaction-observation.d.ts +1 -0
  129. package/dist/interaction-observation.js +1 -1
  130. package/dist/interaction-observation.js.map +1 -1
  131. package/dist/kiro-engine-status.d.ts +11 -0
  132. package/dist/kiro-engine-status.js +6 -0
  133. package/dist/kiro-engine-status.js.map +1 -1
  134. package/dist/kiro-resume-summary.d.ts +37 -0
  135. package/dist/kiro-resume-summary.js +85 -0
  136. package/dist/kiro-resume-summary.js.map +1 -0
  137. package/dist/locale.js +130 -6
  138. package/dist/locale.js.map +1 -1
  139. package/dist/log-service.d.ts +30 -0
  140. package/dist/log-service.js +36 -3
  141. package/dist/log-service.js.map +1 -1
  142. package/dist/log-steps.d.ts +4 -3
  143. package/dist/log-steps.js +32 -7
  144. package/dist/log-steps.js.map +1 -1
  145. package/dist/logger.d.ts +12 -0
  146. package/dist/logger.js +43 -1
  147. package/dist/logger.js.map +1 -1
  148. package/dist/login-controller.js +9 -1
  149. package/dist/login-controller.js.map +1 -1
  150. package/dist/login-flows.d.ts +2 -0
  151. package/dist/login-flows.js +5 -2
  152. package/dist/login-flows.js.map +1 -1
  153. package/dist/login-manager.d.ts +6 -0
  154. package/dist/login-manager.js +10 -1
  155. package/dist/login-manager.js.map +1 -1
  156. package/dist/missed-offline.d.ts +25 -0
  157. package/dist/missed-offline.js +79 -0
  158. package/dist/missed-offline.js.map +1 -0
  159. package/dist/needs-you-hub.d.ts +6 -1
  160. package/dist/needs-you-hub.js +31 -8
  161. package/dist/needs-you-hub.js.map +1 -1
  162. package/dist/needs-you.d.ts +22 -2
  163. package/dist/needs-you.js +17 -3
  164. package/dist/needs-you.js.map +1 -1
  165. package/dist/next-steps.d.ts +12 -2
  166. package/dist/next-steps.js +19 -3
  167. package/dist/next-steps.js.map +1 -1
  168. package/dist/office-share-page.d.ts +1 -1
  169. package/dist/office-share-page.js +6 -2
  170. package/dist/office-share-page.js.map +1 -1
  171. package/dist/office-share-service.d.ts +57 -0
  172. package/dist/office-share-service.js +76 -11
  173. package/dist/office-share-service.js.map +1 -1
  174. package/dist/office-share.d.ts +8 -0
  175. package/dist/office-share.js +34 -0
  176. package/dist/office-share.js.map +1 -1
  177. package/dist/outbound-handlers.js +59 -3
  178. package/dist/outbound-handlers.js.map +1 -1
  179. package/dist/outbound-schemas.d.ts +1 -0
  180. package/dist/outbound-schemas.js +10 -4
  181. package/dist/outbound-schemas.js.map +1 -1
  182. package/dist/package-preimage.js +27 -2
  183. package/dist/package-preimage.js.map +1 -1
  184. package/dist/page-share.d.ts +122 -0
  185. package/dist/page-share.js +199 -0
  186. package/dist/page-share.js.map +1 -0
  187. package/dist/public-preview.d.ts +4 -0
  188. package/dist/public-preview.js +5 -4
  189. package/dist/public-preview.js.map +1 -1
  190. package/dist/public-web-gateway.js +5 -0
  191. package/dist/public-web-gateway.js.map +1 -1
  192. package/dist/queued-handoffs.d.ts +33 -0
  193. package/dist/queued-handoffs.js +75 -0
  194. package/dist/queued-handoffs.js.map +1 -0
  195. package/dist/quickstart-api.d.ts +5 -0
  196. package/dist/quickstart-api.js +1 -1
  197. package/dist/quickstart-api.js.map +1 -1
  198. package/dist/quickstart.d.ts +40 -0
  199. package/dist/quickstart.js +174 -28
  200. package/dist/quickstart.js.map +1 -1
  201. package/dist/remote-card-answer.js +2 -0
  202. package/dist/remote-card-answer.js.map +1 -1
  203. package/dist/remote-card-prompt.js +4 -2
  204. package/dist/remote-card-prompt.js.map +1 -1
  205. package/dist/reply-buttons-controller.js +10 -0
  206. package/dist/reply-buttons-controller.js.map +1 -1
  207. package/dist/scheduler/db.d.ts +2 -1
  208. package/dist/scheduler/db.js +23 -9
  209. package/dist/scheduler/db.js.map +1 -1
  210. package/dist/selection-card.d.ts +20 -0
  211. package/dist/selection-card.js +202 -0
  212. package/dist/selection-card.js.map +1 -0
  213. package/dist/settings-api.d.ts +13 -0
  214. package/dist/settings-api.js +16 -1
  215. package/dist/settings-api.js.map +1 -1
  216. package/dist/settings-change.js +5 -0
  217. package/dist/settings-change.js.map +1 -1
  218. package/dist/settings-effect.js +2 -0
  219. package/dist/settings-effect.js.map +1 -1
  220. package/dist/setup-host.js +2 -2
  221. package/dist/setup-host.js.map +1 -1
  222. package/dist/setup-wizard.js +2 -2
  223. package/dist/setup-wizard.js.map +1 -1
  224. package/dist/stall-reporter.d.ts +21 -0
  225. package/dist/stall-reporter.js +82 -0
  226. package/dist/stall-reporter.js.map +1 -0
  227. package/dist/tmux-control.d.ts +2 -2
  228. package/dist/tmux-control.js +4 -2
  229. package/dist/tmux-control.js.map +1 -1
  230. package/dist/tmux-exec-helper.d.ts +6 -3
  231. package/dist/tmux-exec-helper.js +115 -53
  232. package/dist/tmux-exec-helper.js.map +1 -1
  233. package/dist/tmux-manager.d.ts +6 -0
  234. package/dist/tmux-manager.js +17 -0
  235. package/dist/tmux-manager.js.map +1 -1
  236. package/dist/tool-permissions.d.ts +5 -2
  237. package/dist/tool-permissions.js +6 -5
  238. package/dist/tool-permissions.js.map +1 -1
  239. package/dist/tool-progress.d.ts +2 -0
  240. package/dist/tool-progress.js +19 -3
  241. package/dist/tool-progress.js.map +1 -1
  242. package/dist/topic-commands.js +4 -0
  243. package/dist/topic-commands.js.map +1 -1
  244. package/dist/tunnel/cloudflared-install.d.ts +4 -0
  245. package/dist/tunnel/cloudflared-install.js +20 -1
  246. package/dist/tunnel/cloudflared-install.js.map +1 -1
  247. package/dist/tunnel/cloudflared.d.ts +8 -1
  248. package/dist/tunnel/cloudflared.js +34 -6
  249. package/dist/tunnel/cloudflared.js.map +1 -1
  250. package/dist/tunnel/lease.d.ts +3 -3
  251. package/dist/tunnel/lease.js +23 -19
  252. package/dist/tunnel/lease.js.map +1 -1
  253. package/dist/tunnel/manager.js +6 -7
  254. package/dist/tunnel/manager.js.map +1 -1
  255. package/dist/types.d.ts +10 -0
  256. package/dist/ui/chat-store.js +28 -4
  257. package/dist/ui/chat-strings.js +40 -4
  258. package/dist/ui/chat-thread.js +52 -10
  259. package/dist/ui/fleet-canvas.js +4 -3
  260. package/dist/ui/fleet-office-model.js +55 -22
  261. package/dist/ui/fleet-office-overview.js +6 -38
  262. package/dist/ui/fleet-office-pack.js +4 -3
  263. package/dist/ui/fleet-office-skin.js +114 -21
  264. package/dist/ui/fleet-office-store.js +5 -0
  265. package/dist/ui/fleet-office-zones.js +2 -2
  266. package/dist/ui/fleet-office.js +41 -9
  267. package/dist/ui/fleet-usage.js +90 -27
  268. package/dist/ui/page-share-dialog.js +191 -0
  269. package/dist/ui/panel-chat.js +16 -0
  270. package/dist/ui/panel-log.js +7 -1
  271. package/dist/ui/panel-needs.js +17 -8
  272. package/dist/ui/panel-notices.js +98 -0
  273. package/dist/ui/panel-settings.js +9 -2
  274. package/dist/ui/preview-pages.js +41 -0
  275. package/dist/ui/preview-panel-actions.js +161 -0
  276. package/dist/ui/preview-panel.js +209 -28
  277. package/dist/ui/preview-versions.js +155 -0
  278. package/dist/ui/preview.js +88 -3
  279. package/dist/ui/settings-registry.js +1 -0
  280. package/dist/ui/settings-strings.js +6 -2
  281. package/dist/ui/shared/app-i18n.js +6 -6
  282. package/dist/ui/shared/app-needs.js +1 -1
  283. package/dist/ui/shared/app-route.js +4 -0
  284. package/dist/ui/shared/app-shell.js +9 -5
  285. package/dist/ui/shared/app-store.js +71 -1
  286. package/dist/ui/shared/app-stream.js +27 -1
  287. package/dist/ui/shared/app.css +94 -4
  288. package/dist/ui/shared/app.js +22 -1
  289. package/dist/ui/shared/office/guild.png +0 -0
  290. package/dist/ui/shared/office/school.png +0 -0
  291. package/dist/ui/shared/panel-view.js +339 -17
  292. package/dist/ui/shared/signin.js +12 -1
  293. package/dist/ui/shared/ui-icons.js +3 -0
  294. package/dist/ui/shared/usage-dialog.js +18 -1
  295. package/dist/ui/shared/view-history.css +17 -0
  296. package/dist/ui/shared/view-history.js +438 -0
  297. package/dist/ui/shared/view-strings.js +42 -0
  298. package/dist/ui/shared/web-authorize.js +3 -1
  299. package/dist/unattended-install.d.ts +6 -0
  300. package/dist/unattended-install.js +79 -0
  301. package/dist/unattended-install.js.map +1 -0
  302. package/dist/usage/capacity.d.ts +69 -0
  303. package/dist/usage/capacity.js +119 -0
  304. package/dist/usage/capacity.js.map +1 -0
  305. package/dist/usage/i18n-keys.d.ts +1 -1
  306. package/dist/usage/i18n-keys.js +3 -0
  307. package/dist/usage/i18n-keys.js.map +1 -1
  308. package/dist/usage/profile-timeline.d.ts +16 -0
  309. package/dist/usage/profile-timeline.js +58 -0
  310. package/dist/usage/profile-timeline.js.map +1 -0
  311. package/dist/usage/quota-history.d.ts +43 -0
  312. package/dist/usage/quota-history.js +168 -0
  313. package/dist/usage/quota-history.js.map +1 -0
  314. package/dist/usage/usage-api.d.ts +31 -0
  315. package/dist/usage/usage-api.js +80 -1
  316. package/dist/usage/usage-api.js.map +1 -1
  317. package/dist/usage-report.d.ts +25 -1
  318. package/dist/usage-report.js +55 -4
  319. package/dist/usage-report.js.map +1 -1
  320. package/dist/usage-rollup.d.ts +4 -1
  321. package/dist/usage-rollup.js +12 -3
  322. package/dist/usage-rollup.js.map +1 -1
  323. package/dist/view-api.d.ts +31 -0
  324. package/dist/view-api.js +186 -41
  325. package/dist/view-api.js.map +1 -1
  326. package/dist/view-history.d.ts +84 -0
  327. package/dist/view-history.js +116 -0
  328. package/dist/view-history.js.map +1 -0
  329. package/dist/web-api.d.ts +32 -0
  330. package/dist/web-api.js +195 -5
  331. package/dist/web-api.js.map +1 -1
  332. package/dist/web-auth.js +7 -1
  333. package/dist/web-auth.js.map +1 -1
  334. package/dist/web-chat-history.d.ts +20 -0
  335. package/dist/web-chat-history.js +21 -0
  336. package/dist/web-chat-history.js.map +1 -1
  337. package/dist/web-guest-access.js +5 -1
  338. package/dist/web-guest-access.js.map +1 -1
  339. package/dist/web-login.d.ts +6 -3
  340. package/dist/web-login.js +26 -15
  341. package/dist/web-login.js.map +1 -1
  342. package/dist/web-notices.d.ts +51 -0
  343. package/dist/web-notices.js +118 -0
  344. package/dist/web-notices.js.map +1 -0
  345. package/dist/web-office-pack-tools.d.ts +1 -1
  346. package/dist/web-office-pack-tools.js +1 -1
  347. package/dist/web-office-pack-tools.js.map +1 -1
  348. package/dist/web-only-fleet.d.ts +22 -0
  349. package/dist/web-only-fleet.js +13 -0
  350. package/dist/web-only-fleet.js.map +1 -0
  351. package/dist/web-preview.d.ts +32 -3
  352. package/dist/web-preview.js +52 -19
  353. package/dist/web-preview.js.map +1 -1
  354. package/dist/web-session-authorization.d.ts +13 -2
  355. package/dist/web-session-authorization.js +10 -3
  356. package/dist/web-session-authorization.js.map +1 -1
  357. package/dist/web-session.d.ts +12 -0
  358. package/dist/web-session.js +22 -8
  359. package/dist/web-session.js.map +1 -1
  360. package/dist/web-settings-patch.d.ts +3 -1
  361. package/dist/web-settings-patch.js +4 -1
  362. package/dist/web-settings-patch.js.map +1 -1
  363. package/dist/web-shell-routes.d.ts +2 -0
  364. package/dist/web-shell-routes.js +3 -0
  365. package/dist/web-shell-routes.js.map +1 -1
  366. package/launcher/agend-install.cjs +16 -2
  367. package/launcher/private-home.cjs +1 -1
  368. package/package.json +1 -1
@@ -1,9 +1,10 @@
1
1
  // The Office view's art (fleet-office.js draws with it on a <canvas>). A skin draws a layout that fleet-office-model.js
2
- // (desks) or fleet-office-zones.js (zones) made. Three skins are built in. Two draw everything procedurally — little
2
+ // (desks) or fleet-office-zones.js (zones) made. Five skins are built in. Two draw everything procedurally — little
3
3
  // pixel people, desks, lamps: "pixel" (the desks layout: a room per team, a desk per instance) and "studio" (the zones
4
4
  // layout: work desks, a lounge, an alert corner and a nap corner; people walk to the area for their state). The third,
5
5
  // "cozy" (#2631), is the zones layout on one picture shipped with the page (a warm pixel-art office), the same built-in
6
- // people in it. Every drawing call goes through the OfficeSkin interface below, so a skin pack (pictures from an artist,
6
+ // people in it; "guild" and "school" (#2947) are two more such pictures on the cozy's arrangement. Every drawing call
7
+ // goes through the OfficeSkin interface below, so a skin pack (pictures from an artist,
7
8
  // format v1 in fleet-office-pack.js) can replace any part; a part a pack does not supply falls back to the built-in
8
9
  // drawing. An admin uploads a pack (#2513, the host keeps it: src/web-office-skins.ts); loadPackSkin() reads it back —
9
10
  // its manifest checked here again (checkSkinPack), its pictures by their content ids — and spriteSkin() draws it.
@@ -243,13 +244,21 @@ function drawDeskFront(ctx, d, look, env) {
243
244
  if (look.match) { ctx.save(); ctx.strokeStyle = pal.focus; ctx.lineWidth = 2; ctx.strokeRect(d.x + 2, d.y + 2, d.w - 4, d.h - 6); ctx.restore(); }
244
245
  }
245
246
 
246
- const Q = 2; // a prop's art pixel
247
+ const Q = 2; // a prop's art pixel, before PROP_SCALE
248
+ /**
249
+ * #2946: the built-in props are drawn this much larger than their 16 px art (a pack's own `props` keep their size). In
250
+ * the scene's units, so they scale with the room as its people do (zoom, a phone, an All offices tile).
251
+ */
252
+ export const PROP_SCALE = 1.75;
247
253
  function drawProp(ctx, it, ms, env) {
248
- const c = env.pal.fx[it.hue] || env.pal.fx.send, x0 = it.x - 8, y0 = it.y - 8 - Math.sin(Math.PI * it.t) * 10;
249
- const b = (c2, r, w, h, col) => px(ctx, x0 + c2 * Q, y0 + r * Q, w * Q, h * Q, col);
254
+ const c = env.pal.fx[it.hue] || env.pal.fx.send, lift = Math.sin(Math.PI * it.t) * 10;
255
+ const b = (c2, r, w, h, col) => px(ctx, -8 + c2 * Q, -8 + r * Q, w * Q, h * Q, col);
250
256
  ctx.save();
257
+ ctx.translate(it.x, it.y);
258
+ ctx.scale(PROP_SCALE, PROP_SCALE);
251
259
  ctx.fillStyle = "rgba(0, 0, 0, 0.18)";
252
- ctx.beginPath(); ctx.ellipse(it.x, it.y + 4, 7, 2.5, 0, 0, Math.PI * 2); ctx.fill();
260
+ ctx.beginPath(); ctx.ellipse(0, 4, 7, 2.5, 0, 0, Math.PI * 2); ctx.fill();
261
+ ctx.translate(0, -lift);
253
262
  if (it.hue === "task") { b(0, 0, 4, 2, c); b(0, 1, 8, 6, c); b(0, 2, 8, 1, "rgba(255, 255, 255, 0.35)"); }
254
263
  else if (it.hue === "report") { b(1, 0, 6, 8, "#fbfbfb"); b(1, 0, 6, 1, c); b(2, 4, 1, 1, c); b(3, 5, 1, 1, c); b(4, 4, 1, 1, c); b(5, 3, 1, 1, c); }
255
264
  else if (it.hue === "broadcast") { b(0, 3, 2, 2, c); b(2, 2, 2, 4, c); b(4, 1, 2, 6, c); if (frameAt(ms, 2, 6)) { b(7, 1, 1, 2, c); b(7, 5, 1, 2, c); } else b(7, 3, 1, 2, c); }
@@ -645,28 +654,112 @@ export const COZY_PACK = {
645
654
  stateZones: { working: "desks", idle: "lounge", needs: "alert", crashed: "alert", stuck: "alert", paused: "nap", stopped: "nap", default: "lounge" },
646
655
  characters: {}, byInstance: {}, props: {}, bubbles: {},
647
656
  };
648
- /** Until its picture is in (loadCozySkin): the studio's floors under the cozy's areas. */
649
- const cozyBare = spriteSkin(COZY_PACK, new Map(), studioSkin);
650
- let cozyJob = null;
657
+ // The cozy's zones on every picture-backed built-in: work, lounge, the alert corner, the nap corner.
658
+ const PICTURE_ZONES = { working: "desks", idle: "lounge", needs: "alert", crashed: "alert", stuck: "alert", paused: "nap", stopped: "nap", default: "lounge" };
659
+ const wps = (list) => list.map(([id, x, y]) => ({ id, x, y }));
660
+
661
+ // ── #2947: two more on the cozy's arrangement — work top-left, the lounge top-right, the nap corner bottom-left, the
662
+ // alert corner bottom-right, one straight aisle up the middle from a bottom-centre door ──
663
+
664
+ /** The guild: a fantasy adventurers' guild hall (src/ui/shared/office/guild.png). */
665
+ export const GUILD_BACKGROUND = "/assets/office-guild.png";
666
+ // Seats: the chairs at the six map desks, the tavern bench, the stools round the table, the bar; the inn beds (a sleeper's
667
+ // middle); in front of the quest board.
668
+ const GUILD_AREAS = {
669
+ desks: { name: "desks", rect: { x: 130, y: 190, w: 400, h: 160 }, seats: pts([180, 200], [320, 200], [450, 200], [180, 315], [320, 315], [450, 315]) },
670
+ lounge: { name: "lounge", rect: { x: 780, y: 190, w: 440, h: 150 }, seats: pts([840, 200], [900, 200], [810, 290], [935, 290], [870, 325], [1040, 300]) },
671
+ alert: { name: "alert", rect: { x: 860, y: 560, w: 260, h: 50 }, seats: pts([900, 590], [1000, 590], [1090, 590]) },
672
+ nap: { name: "nap", rect: { x: 110, y: 540, w: 380, h: 80 }, seats: pts([160, 560], [285, 560], [405, 560]) },
673
+ };
674
+ const GUILD_WAYPOINTS = wps([
675
+ ["door", 640, 650], ["low", 640, 615], ["hall", 640, 345], ["top", 640, 130],
676
+ // the desks: the aisle under the second row (y 345), the one between the rows (y 225), the column beside them (x 540)
677
+ ["d-east", 540, 345], ["d2-3", 450, 345], ["d2-2", 320, 345], ["d2-1", 180, 345],
678
+ ["d-up", 540, 225], ["d1-3", 450, 225], ["d1-2", 320, 225], ["d1-1", 180, 225],
679
+ // the beds and the quest board: the aisle along the bottom (y 615)
680
+ ["n-x", 500, 615], ["n-3", 405, 615], ["n-2", 285, 615], ["n-1", 160, 615],
681
+ ["a-1", 900, 615], ["a-2", 1000, 615], ["a-3", 1090, 615],
682
+ // the tavern: under the round table (y 345), up the bench's west end (x 770) to in front of it (y 215)
683
+ ["l-hall", 770, 345], ["l-tw", 810, 345], ["l-ts", 870, 345], ["l-te", 935, 345], ["l-bar", 1040, 345],
684
+ ["l-up", 770, 215], ["l-b1", 840, 215], ["l-b2", 900, 215],
685
+ ]);
686
+ const GUILD_EDGES = [
687
+ ["door", "low"], ["low", "hall"], ["hall", "top"],
688
+ ["hall", "d-east"], ["d-east", "d2-3"], ["d2-3", "d2-2"], ["d2-2", "d2-1"], ["d-east", "d-up"], ["d-up", "d1-3"], ["d1-3", "d1-2"], ["d1-2", "d1-1"],
689
+ ["low", "n-x"], ["n-x", "n-3"], ["n-3", "n-2"], ["n-2", "n-1"], ["low", "a-1"], ["a-1", "a-2"], ["a-2", "a-3"],
690
+ ["hall", "l-hall"], ["l-hall", "l-tw"], ["l-tw", "l-ts"], ["l-ts", "l-te"], ["l-te", "l-bar"], ["l-hall", "l-up"], ["l-up", "l-b1"], ["l-b1", "l-b2"],
691
+ ];
692
+ export const GUILD_PACK = {
693
+ format: 1, id: "guild", scale: 1, background: GUILD_BACKGROUND, areas: GUILD_AREAS, door: { x: 640, y: 690 },
694
+ waypoints: GUILD_WAYPOINTS, edges: GUILD_EDGES, straight: true, furniture: [], stateZones: PICTURE_ZONES,
695
+ characters: {}, byInstance: {}, props: {}, bubbles: {},
696
+ };
697
+
698
+ /** The school: a classroom (src/ui/shared/office/school.png). */
699
+ export const SCHOOL_BACKGROUND = "/assets/office-school.png";
700
+ // Seats: the chairs at the six student desks; the sofa, the two stools, the vending machine; the nurse's beds; in front
701
+ // of the chalkboard.
702
+ const SCHOOL_AREAS = {
703
+ desks: { name: "desks", rect: { x: 130, y: 205, w: 400, h: 140 }, seats: pts([190, 215], [325, 215], [455, 215], [170, 320], [315, 320], [450, 320]) },
704
+ lounge: { name: "lounge", rect: { x: 770, y: 125, w: 420, h: 110 }, seats: pts([850, 135], [925, 135], [1000, 135], [810, 225], [1035, 225], [1145, 185]) },
705
+ alert: { name: "alert", rect: { x: 840, y: 560, w: 260, h: 50 }, seats: pts([880, 590], [965, 590], [1050, 590]) },
706
+ nap: { name: "nap", rect: { x: 110, y: 540, w: 380, h: 80 }, seats: pts([160, 555], [295, 555], [400, 555]) },
707
+ };
708
+ const SCHOOL_WAYPOINTS = wps([
709
+ ["door", 640, 660], ["low", 640, 625], ["hall", 640, 335], ["mid", 640, 280], ["top", 640, 100],
710
+ // the desks: the aisle under the second row (y 335), the one between the rows (y 232), the column beside them (x 540)
711
+ ["d-east", 540, 335], ["d2-3", 450, 335], ["d2-2", 315, 335], ["d2-1", 170, 335],
712
+ ["d-up", 540, 232], ["d1-3", 455, 232], ["d1-2", 325, 232], ["d1-1", 190, 232],
713
+ // the beds and the chalkboard: the aisle along the bottom (y 625)
714
+ ["n-x", 520, 625], ["n-3", 400, 625], ["n-2", 295, 625], ["n-1", 160, 625],
715
+ ["a-1", 880, 625], ["a-2", 965, 625], ["a-3", 1050, 625],
716
+ // the lounge: under the rug (y 280) to the stools and the machine, up its west edge (x 760) to in front of the sofa (y 150)
717
+ ["l-hall", 760, 280], ["l-w", 810, 280], ["l-e", 1035, 280], ["l-vend", 1145, 280],
718
+ ["l-up", 760, 150], ["l-s1", 850, 150], ["l-s2", 925, 150], ["l-s3", 1000, 150],
719
+ ]);
720
+ const SCHOOL_EDGES = [
721
+ ["door", "low"], ["low", "hall"], ["hall", "mid"], ["mid", "top"],
722
+ ["hall", "d-east"], ["d-east", "d2-3"], ["d2-3", "d2-2"], ["d2-2", "d2-1"], ["d-east", "d-up"], ["d-up", "d1-3"], ["d1-3", "d1-2"], ["d1-2", "d1-1"],
723
+ ["low", "n-x"], ["n-x", "n-3"], ["n-3", "n-2"], ["n-2", "n-1"], ["low", "a-1"], ["a-1", "a-2"], ["a-2", "a-3"],
724
+ ["mid", "l-hall"], ["l-hall", "l-w"], ["l-w", "l-e"], ["l-e", "l-vend"], ["l-hall", "l-up"], ["l-up", "l-s1"], ["l-s1", "l-s2"], ["l-s2", "l-s3"],
725
+ ];
726
+ export const SCHOOL_PACK = {
727
+ format: 1, id: "school", scale: 1, background: SCHOOL_BACKGROUND, areas: SCHOOL_AREAS, door: { x: 640, y: 700 },
728
+ waypoints: SCHOOL_WAYPOINTS, edges: SCHOOL_EDGES, straight: true, furniture: [], stateZones: PICTURE_ZONES,
729
+ characters: {}, byInstance: {}, props: {}, bubbles: {},
730
+ };
731
+
732
+ /**
733
+ * The built-ins drawn from one picture shipped with the page (#2631 cozy, #2947 guild and school): each one's pack, and
734
+ * until its picture is in, the studio's floors under its areas (the bare skin); `job` is the picture's load in flight.
735
+ */
736
+ const PICTURES = new Map([COZY_PACK, GUILD_PACK, SCHOOL_PACK].map((pack) => [pack.id, { pack, bare: spriteSkin(pack, new Map(), studioSkin), job: null }]));
737
+ /** Their ids: a style naming one loads its picture (loadBuiltInSkin). */
738
+ export const PICTURE_SKINS = [...PICTURES.keys()];
651
739
 
652
740
  /** The skins built into this page; an uploaded pack is loaded by loadPackSkin. */
653
- export const SKINS = new Map([["pixel", pixelSkin], ["studio", studioSkin], ["cozy", cozyBare]]);
654
- export const BUILT_IN_SKINS = ["pixel", "studio", "cozy"];
741
+ export const SKINS = new Map([["pixel", pixelSkin], ["studio", studioSkin], ...[...PICTURES].map(([id, p]) => [id, p.bare])]);
742
+ export const BUILT_IN_SKINS = ["pixel", "studio", ...PICTURE_SKINS];
655
743
  /**
656
- * #2631: the cozy's picture, loaded once; the skin drawn from it then takes the cozy's place in SKINS (skinFor). The
657
- * skin, or null while it cannot load (the bare one stays; tried again next time).
744
+ * #2631/#2947: a picture-backed built-in's picture, loaded once; the skin drawn from it then takes the bare one's place
745
+ * in SKINS (skinFor). The skin, or null while it cannot load (the bare one stays; tried again next time); null for an id
746
+ * that is not one of them.
658
747
  */
659
- export function loadCozySkin({ image = defaultImage } = {}) {
660
- if (SKINS.get("cozy") !== cozyBare) return Promise.resolve(SKINS.get("cozy"));
661
- cozyJob ??= image(COZY_BACKGROUND).then((im) => {
662
- cozyJob = null;
748
+ export function loadBuiltInSkin(id, { image = defaultImage } = {}) {
749
+ const p = PICTURES.get(id);
750
+ if (!p) return Promise.resolve(null);
751
+ if (SKINS.get(id) !== p.bare) return Promise.resolve(SKINS.get(id));
752
+ p.job ??= image(p.pack.background).then((im) => {
753
+ p.job = null;
663
754
  if (!im) return null;
664
- const sk = spriteSkin(COZY_PACK, new Map([[COZY_BACKGROUND, im]]), studioSkin);
665
- SKINS.set("cozy", sk);
755
+ const sk = spriteSkin(p.pack, new Map([[p.pack.background, im]]), studioSkin);
756
+ SKINS.set(id, sk);
666
757
  return sk;
667
- }, () => { cozyJob = null; return null; });
668
- return cozyJob;
758
+ }, () => { p.job = null; return null; });
759
+ return p.job;
669
760
  }
761
+ /** #2631: the cozy's picture (loadBuiltInSkin for "cozy"). */
762
+ export const loadCozySkin = (opts = {}) => loadBuiltInSkin("cozy", opts);
670
763
  /** A skin by id: a built-in one, else a pack loaded here (`packs`: id → skin), else the default (pixel). */
671
764
  export const skinFor = (id, packs = null) => SKINS.get(id) || (packs && packs.get(id)) || pixelSkin;
672
765
 
@@ -19,6 +19,8 @@ import { levelsOk } from "./fleet-office-levels.js";
19
19
  const CHOICE_KEY = "agend_org_office", FILTER_KEY = "agend_org_office_filter", SKIN_KEY = "agend_org_office_skin", ASLEEP_KEY = "agend_org_office_hide_asleep", NIGHT_KEY = "agend_org_office_night";
20
20
  /** #2554: "0" when All offices leaves the lobby tile out (default: shown). */
21
21
  const LOBBY_KEY = "agend_org_office_lobby";
22
+ /** #2946: whether the key to the items in the air is open here (default: folded). */
23
+ const KEY_OPEN_KEY = "agend_org_office_key";
22
24
  /** #2540: { "<office>@<style>": [k, cx, cy] (and a 1 after them: a fit) } — the last VIEWS_MAX views, the most recent last. */
23
25
  const VIEW_KEY = "agend_org_office_view", VIEWS_MAX = 32;
24
26
  /** #2540: { "<office>@<style>": "cover" } — the offices whose fit fills the panel (the rest: "contain"), the last VIEWS_MAX. */
@@ -195,6 +197,9 @@ export function createOfficeStore({ fetch: get = globalThis.fetch, storage } = {
195
197
  saveNightOn: (v) => write(NIGHT_KEY, v ? "1" : "0"),
196
198
  /** #2554: whether All offices shows the lobby tile (the instances in no stored office) here (default: shown). */
197
199
  lobbyTile: () => read(LOBBY_KEY) !== "0",
200
+ /** #2946: whether the key to the items carried between desks is open here (default: folded). */
201
+ keyOpen: () => read(KEY_OPEN_KEY) === "1",
202
+ saveKeyOpen: (v) => write(KEY_OPEN_KEY, v ? "1" : "0"),
198
203
  saveLobbyTile: (v) => write(LOBBY_KEY, v ? "1" : "0"),
199
204
  /** The view kept for `key` (an office and its style): { k, cx, cy } (fleet-office-view.js savedOf), or null. */
200
205
  view: (key) => {
@@ -3,7 +3,7 @@
3
3
  // changes it walks (along the pack's waypoints) to the new area. Pure — fleet-office.js draws it. Also here: what a
4
4
  // character says in its speech bubble, and which furniture is active.
5
5
  import { LOGICAL, own } from "./fleet-office-pack.js";
6
- import { FLIGHT, nameHash, pathLength, along, routeVia } from "./fleet-office-model.js";
6
+ import { flightMs, nameHash, pathLength, along, routeVia } from "./fleet-office-model.js";
7
7
  import { hue } from "./fleet-traffic.js";
8
8
 
9
9
  /** The area for a state: the pack's own, else its "default", else its first area. */
@@ -281,7 +281,7 @@ export function createMail({ max = MAIL.MAX } = {}) {
281
281
  if (!drop) continue; // nowhere off the seats: not delivered here
282
282
  const pts = [{ x: L.door.x, y: L.door.y }, drop];
283
283
  letters.push({ id: it.id, from: it.from, to: it.to, kind: it.kind, hue: hue(it.kind), start: now, drop, pts,
284
- ms: Math.min(FLIGHT.MAX_MS, Math.max(FLIGHT.MIN_MS, pathLength(pts) / FLIGHT.SPEED)) });
284
+ ms: flightMs(pathLength(pts)) });
285
285
  started++;
286
286
  }
287
287
  if (letters.length > max) letters = letters.slice(letters.length - max);
@@ -64,7 +64,7 @@ import { nodeText, stateWords } from "./fleet-org.js";
64
64
  import { hits, needle } from "./fleet-canvas-layout.js";
65
65
  import { AUTO_ID, LOBBY, autoLayout, backingScale, coatOf, createFlights, customLayout, deskAt, desksOf, filterOk, lampOf, layoutOk, lightOf, membersOf,
66
66
  officePose, officeRooms, signOf, workOn } from "./fleet-office-model.js";
67
- import { BUILT_IN_SKINS, CHAR_DEPTH, DEFAULT_PAL, bubbleBox, loadCozySkin, loadPackSkin, modeOf, skinFor, tagBox, tagFootprint } from "./fleet-office-skin.js";
67
+ import { BUILT_IN_SKINS, CHAR_DEPTH, DEFAULT_PAL, PICTURE_SKINS, bubbleBox, pixelSkin, loadBuiltInSkin, loadPackSkin, modeOf, skinFor, tagBox, tagFootprint } from "./fleet-office-skin.js";
68
68
  import { CALLING, activeFurniture, bubbleAt, hiddenBy, bubbleRank, NIGHT, WANDER, createMail, createWalkers, isNight, linesFor, npcKey, personAt, placeBubbles, thinBubbles, walkTargets, zonesLayout } from "./fleet-office-zones.js";
69
69
  import { actsOk, createOfficeStore } from "./fleet-office-store.js";
70
70
  import { badgeOf, fixedOf } from "./fleet-office-levels.js";
@@ -86,7 +86,7 @@ register("orgo", {
86
86
  people: "People", peopleN: "People ({0})", findPerson: "Find a person", everyone: "Show everyone", nobody: "No one here matches this filter.",
87
87
  unseated: "{0} without a seat", skinUnknown: "Its style “{0}” is not available here: drawn as Desks.", closed: "Closed", desks: "Desks", tapAgain: "Tap again to open the chat",
88
88
  capWorking: "Typing away", capIdle: "Tea break", capPaused: "Asleep", capNeeds: "Calling for you", capAway: "Desk empty", capStarting: "Just arriving", capQueued: "Waiting to start",
89
- styleLabel: "Style", style_pixel: "Desks", style_studio: "Studio", style_cozy: "Cozy", cozyNotice: "Add your skin", area_desks: "Work", area_lounge: "Lounge", area_alert: "Help desk", area_nap: "Nap corner",
89
+ styleLabel: "Style", style_pixel: "Desks", style_studio: "Studio", style_cozy: "Cozy", style_guild: "Guild", style_school: "School", cozyNotice: "Add your skin", area_desks: "Work", area_lounge: "Lounge", area_alert: "Help desk", area_nap: "Nap corner",
90
90
  b_working: "On it.|Typing…|Almost there|In the zone|One more test", b_idle: "Tea time|Ready when you are|Stretching|Anything for me?",
91
91
  b_paused: "Zzz…|Back later", b_stopped: "Off duty|Zzz…", b_needs: "Need you!|A question for you|Over here!",
92
92
  b_crashed: "Ouch, crashed|Help!", b_stuck: "Hmm, stuck?|Still thinking…",
@@ -96,6 +96,8 @@ register("orgo", {
96
96
  fit: "Fit the office", overview: "Overview: click to move there", fullscreen: "Fullscreen", exitFullscreen: "Exit fullscreen",
97
97
  fill: "Fill", fillTitle: "Fill the panel (the scene's overflow is cropped; drag to see it)", zoomLevel: "Zoom {0}",
98
98
  allOffices: "All offices", labelAll: "All offices: each office is a tile, drawn in its own style. Drag to pan; the wheel, a pinch, + and − zoom, 0 fits, F is fullscreen. Double-click a tile to open that office.",
99
+ key: "Key", keyTitle: "What the items carried between desks are", keyLabel: "Carried between desks",
100
+ keyTask: "Folder: a task", keySend: "Envelope: a message", keyReport: "Paper: a report", keyBroadcast: "Horn: a broadcast",
99
101
  tileLabel: "{0} ({1})", openOffice: "Open the office {0}", lobbyTile: "Lobby tile (in no office)", lobbyEveryone: "Everyone is in an office", overviewNoEdit: "Pick one office to edit offices or a layout",
100
102
  },
101
103
  "zh-TW": {
@@ -104,7 +106,7 @@ register("orgo", {
104
106
  people: "成員", peopleN: "成員({0})", findPerson: "尋找成員", everyone: "顯示全部", nobody: "沒有符合這個篩選的成員。",
105
107
  unseated: "{0} 位沒有座位", skinUnknown: "這裡沒有它的風格「{0}」:以辦公桌顯示。", closed: "休息中", desks: "桌子", tapAgain: "再點一下開啟聊天",
106
108
  capWorking: "埋頭打字中", capIdle: "喝茶休息", capPaused: "睡著了", capNeeds: "在呼叫你", capAway: "空位", capStarting: "剛到座位", capQueued: "等待啟動",
107
- styleLabel: "風格", style_pixel: "辦公桌", style_studio: "工作室", style_cozy: "溫馨", cozyNotice: "換上你的外觀包", area_desks: "工作區", area_lounge: "休息區", area_alert: "求救區", area_nap: "午睡角",
109
+ styleLabel: "風格", style_pixel: "辦公桌", style_studio: "工作室", style_cozy: "溫馨", style_guild: "冒險者公會", style_school: "學校", cozyNotice: "換上你的外觀包", area_desks: "工作區", area_lounge: "休息區", area_alert: "求救區", area_nap: "午睡角",
108
110
  b_working: "處理中|打字中…|快好了|專心中|再測一次", b_idle: "喝茶中|隨時可以開工|伸個懶腰|有事找我嗎?",
109
111
  b_paused: "Zzz…|晚點回來", b_stopped: "下班了|Zzz…", b_needs: "需要你!|有個問題|在這裡!",
110
112
  b_crashed: "糟糕,當掉了|救命!", b_stuck: "嗯,卡住了?|還在想…",
@@ -114,6 +116,8 @@ register("orgo", {
114
116
  fit: "顯示整個辦公室", overview: "總覽:點一下移到那裡", fullscreen: "全螢幕", exitFullscreen: "結束全螢幕",
115
117
  fill: "填滿", fillTitle: "填滿整個面板(超出的部分會被裁切,拖曳即可看到)", zoomLevel: "縮放 {0}",
116
118
  allOffices: "所有辦公室", labelAll: "所有辦公室:每間辦公室是一格,以它自己的風格顯示。拖曳平移;滾輪、雙指、+ 和 − 縮放,0 顯示全部,F 切換全螢幕。雙擊一格即可開啟那間辦公室。",
119
+ key: "圖例", keyTitle: "桌子之間傳遞的物品代表什麼", keyLabel: "桌子之間傳遞的物品",
120
+ keyTask: "資料夾:任務", keySend: "信封:訊息", keyReport: "紙張:回報", keyBroadcast: "喇叭:廣播",
117
121
  tileLabel: "{0}({1})", openOffice: "開啟辦公室「{0}」", lobbyTile: "大廳格(不在任何辦公室)", lobbyEveryone: "每個成員都已在某間辦公室", overviewNoEdit: "請先選一間辦公室,再編輯辦公室或配置",
118
122
  },
119
123
  });
@@ -584,6 +588,28 @@ function People({ nodes, pinned, onChange }) {
584
588
  </div>`;
585
589
  }
586
590
 
591
+ /** #2946: the key's rows — each kind's item as the scene draws it (the built-in prop, in the theme's colour), and its words. */
592
+ export const KEY_KINDS = [["task", "keyTask"], ["send", "keySend"], ["report", "keyReport"], ["broadcast", "keyBroadcast"]];
593
+ const KEY_PX = 32;
594
+ /**
595
+ * #2946 (hxhsandy): the key to the items carried between desks — a strip under the Office's bar (never over the scene),
596
+ * opened by its Key toggle. The shapes are drawn by the built-in skin, so they are the ones in the air, at their size.
597
+ */
598
+ export function OfficeKey({ pal }) {
599
+ const paint = (hue) => (cv) => {
600
+ const g = cv && typeof cv.getContext === "function" ? cv.getContext("2d") : null;
601
+ if (!g) return;
602
+ const r = Math.max(1, Math.round(globalThis.devicePixelRatio || 1));
603
+ if (cv.width !== KEY_PX * r) { cv.width = KEY_PX * r; cv.height = KEY_PX * r; }
604
+ g.setTransform(r, 0, 0, r, 0, 0); g.clearRect(0, 0, KEY_PX, KEY_PX);
605
+ try { pixelSkin.drawProp(g, { hue, kind: hue, x: KEY_PX / 2, y: KEY_PX / 2 + 4, t: 0 }, 0, { pal }); } catch { /* left blank */ }
606
+ };
607
+ return html`<div class="oo-key" role="region" aria-label=${to("keyLabel")}>
608
+ <ul class="oo-key-rows">${KEY_KINDS.map(([hue, k]) => html`<li key=${hue} class="oo-key-row" data-kind=${hue}>
609
+ <canvas class="oo-key-icon" width=${KEY_PX} height=${KEY_PX} aria-hidden="true" ref=${paint(hue)}></canvas><span>${to(k)}</span></li>`)}</ul>
610
+ </div>`;
611
+ }
612
+
587
613
  /**
588
614
  * The view: `view` buildOrgView's answer; `traffic` the live items (null: Live is off); `q` the Find box's needle;
589
615
  * `lease` the tab's (its reads); `onOpen(name)` what a desk opens (the canvas's: the chat; none: a desk opens nothing,
@@ -609,13 +635,14 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
609
635
  const [hideAsleep, setHideAsleep] = useState(() => store.hideAsleep());
610
636
  // Bedtime (#2568), when it is on here: the idle sleep from NIGHT.FROM to NIGHT.TO on this device's clock (read every NIGHT.TICK).
611
637
  const [nightOn, setNightOn] = useState(() => store.nightOn());
638
+ const [keyOpen, setKeyOpen] = useState(() => store.keyOpen());
612
639
  const [nightNow, setNightNow] = useState(() => isNight());
613
640
  useEffect(() => { if (!nightOn) return undefined; setNightNow(isNight()); const h = setInterval(() => setNightNow(isNight()), NIGHT.TICK); return () => clearInterval(h); }, [nightOn]);
614
641
  const night = nightOn && nightNow;
615
642
  const [lobbyOn, setLobbyOn] = useState(() => store.lobbyTile()); // #2554: All offices' lobby tile
616
643
  const [packs, setPacks] = useState([]); // the skin packs the host keeps: [{ id, name, sha }]
617
644
  const [packSkins, setPackSkins] = useState(() => new Map()); // id@sha → a loaded skin, or null (not a pack)
618
- const [cozyRev, setCozyRev] = useState(0); // #2631: the cozy's picture came in (skinFor has it)
645
+ const [cozyRev, setCozyRev] = useState(0); // #2631/#2947: a built-in's picture came in (skinFor has it)
619
646
  // Edit layout's session while it is open: { id, officeId, skin, pack (the style's identity), base (the pack as kept),
620
647
  // start, draft, rev, list (the offices and rev read when it opened — what Save sends) }; null when closed.
621
648
  const [layout, setLayout] = useState(null);
@@ -801,14 +828,16 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
801
828
  }, [neededKey]);
802
829
  const loading = !!packInfo && !packSkins.has(packKey);
803
830
  const baseSkin = packInfo ? packSkins.get(packKey) || skinFor("pixel") : skinFor(STYLES.includes(wanted) ? wanted : custom ? wanted : "pixel");
804
- // #2631: the cozy's picture, once, when this office (or a tile, or the lobby tile) is drawn in it; until then its bare floors.
805
- const cozyWanted = (overview ? [...stored.map((o) => o.skin), ...(lobbyOn ? [style] : [])] : [wanted]).includes("cozy");
831
+ // #2631/#2947: a picture-backed built-in's picture (cozy, guild, school), once, when this office (or a tile, or the lobby
832
+ // tile) is drawn in it and no kept pack has its id; until then its bare floors.
833
+ const picturesWanted = [...new Set((overview ? [...stored.map((o) => o.skin), ...(lobbyOn ? [style] : [])] : [wanted])
834
+ .filter((id) => PICTURE_SKINS.includes(id) && !packOf(id)))].sort().join(",");
806
835
  useEffect(() => {
807
- if (!cozyWanted) return undefined;
836
+ if (!picturesWanted) return undefined;
808
837
  let live = true;
809
- loadCozySkin().then((sk) => { if (live && sk && sk !== baseSkin) setCozyRev((n) => n + 1); });
838
+ for (const id of picturesWanted.split(",")) loadBuiltInSkin(id).then((sk) => { if (live && sk) setCozyRev((n) => n + 1); });
810
839
  return () => { live = false; };
811
- }, [cozyWanted]);
840
+ }, [picturesWanted]);
812
841
  const zones = !!(baseSkin.pack && baseSkin.pack.stateZones);
813
842
  /** The style's identity: a pack's id and sha (a pack replaced is another), or the built-in skin's id. */
814
843
  const styleKey = packKey || baseSkin.id;
@@ -1456,6 +1485,8 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
1456
1485
  ${custom && baseSkin.id !== custom.skin && !loading ? html`<span class="muted oo-note">${to("skinUnknown", styleName(custom.skin))}</span>` : null}
1457
1486
  ${placed.ignored ? html`<span class="muted oo-note">${to("layoutIgnored")}</span>` : null}
1458
1487
  ${styleErr && auto ? html`<span class="note oo-note oo-style-err" role="status">${styleErr}</span>` : null}
1488
+ ${traffic ? html`<button type="button" class=${`btn btn-sm oo-key-btn${keyOpen ? " on" : ""}`} aria-expanded=${keyOpen ? "true" : "false"} title=${to("keyTitle")}
1489
+ onClick=${() => { const v = !keyOpen; store.saveKeyOpen(v); setKeyOpen(v); }}>${to("key")}</button>` : null}
1459
1490
  ${L.unseated ? html`<span class="muted oo-note">${to("unseated", L.unseated)}</span>` : null}
1460
1491
  ${canEdit && storedRev !== null ? html`<button type="button" class="btn btn-sm oo-edit" onClick=${() => { if (!overview) setEditing(true); }} disabled=${!!layout || overview}
1461
1492
  title=${overview ? to("overviewNoEdit") : undefined}>${t("orge.edit")}</button>` : null}
@@ -1463,6 +1494,7 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
1463
1494
  ${canShare ? html`<${ShareButton} offices=${stored} current=${overview ? "all" : custom ? custom.id : null} disabled=${!!layout}
1464
1495
  get=${lease ? (path, o) => lease.fetch(path, o) : undefined} />` : null}
1465
1496
  </div>
1497
+ ${traffic && keyOpen ? html`<${OfficeKey} pal=${pal} />` : null}
1466
1498
  ${editing && !overview ? html`<${OfficeEditor} offices=${stored} rev=${storedRev} view=${view} store=${store} packs=${packs}
1467
1499
  ask=${ask} current=${() => shown.current} onSaved=${adopt} onClose=${() => setEditing(false)} />` : null}
1468
1500
  ${overview || people.length || L.rooms.length ? null : html`<p class="note oo-empty">${to("nobody")} ${auto && (f.team || f.pinned.length) ? html`<button type="button" class="btn btn-sm" onClick=${() => setF({ team: "", pinned: [] })}>${to("everyone")}</button>` : null}</p>`}
@@ -14,11 +14,20 @@ register("fusage", {
14
14
  en: {
15
15
  range: "Range", today: "Today", r7d: "7 days", month: "This month", all: "All", custom: "Custom", from: "From", to: "To", apply: "Apply",
16
16
  group: "Group by", g_instance: "Agent", g_backend: "Backend", g_model: "Model", g_profile: "Credential profile", g_team: "Team (as configured now)",
17
- csv: "Download CSV", basis: "API-equivalent at list price (checked {0}) — not money spent. Subscriptions are not billed per token.",
17
+ csv: "Download CSV", basis: "API-equivalent at list price ({0}) — not money spent.", basisMore: "Subscriptions are not billed per token.",
18
18
  amount: "API-equivalent", input: "Input", output: "Output", reasoning: "Reasoning", read: "Cache read", write: "Cache write", total: "Total tokens", requests: "Requests", messages: "Messages",
19
19
  notPriced: "not priced", partial: "partial", noTeam: "(no team)", unknown: "unknown",
20
- f_cache_write_not_reported: "cache writes not reported (Codex, Muse) — an estimate", f_not_priced: "not priced: {0}", f_source_unknown: "read before backend and profile were recorded",
21
- f_estimated: "estimated from text size — Kiro records no token counts", f_includes_estimates: "includes estimates (Kiro)", f_cost_reported: "amount as the CLI reported it (no list price for this model)", estimatedPart: "of which estimated: {0}",
20
+ // #3066 §3: what a row's numbers are, said once each as a small mark with its tooltip (never a pill per row).
21
+ t_not_priced: "Not priced: {0}", t_partial: "Part of this row's models have no price", t_write_unreported: "Codex/Muse don't report cache writes",
22
+ t_cost_reported: "Amount as the CLI reported it", t_estimated: "Estimated from text size (Kiro records no token counts)", t_reasoning_none: "Not reported by this CLI",
23
+ sourceNote: "Some hours were read before AgEnD recorded their backend and profile.", profileUnrecorded: "profile not recorded",
24
+ l_partial: "◐ partly priced", l_cost_reported: "† amount from the CLI", l_estimated: "≈ estimated",
25
+ // #3066 §2: output speed (Claude after the first token, Codex over the response window).
26
+ speed: "Speed", speedUnit: "{0} tok/s", speedCoverage: "{0} of {1} requests timed", speedSpread: "p10–p90: {0}–{1} tok/s",
27
+ speedNone: "no timing in its transcripts", speedByModel: "Speed by model",
28
+ speedBasis: "Output tokens ÷ the model's own response time (tool time excluded); method after tokdash.",
29
+
30
+ f_includes_estimates: "includes estimates (Kiro)", estimatedPart: "of which estimated: {0}",
22
31
  scanning: "Scanning — totals may still grow.", updated: "Updated {0} · read every 10 min", since: "Collected since {0}", notYet: "Not collected yet",
23
32
  refresh: "Refresh",
24
33
  notMeasured: "Not measured", nm_credit_billed: "Kiro is credit-billed and records no tokens", nm_unsupported: "AgEnD can't read this CLI's usage yet", nm_no_data: "nothing read yet",
@@ -42,11 +51,18 @@ register("fusage", {
42
51
  "zh-TW": {
43
52
  range: "期間", today: "今天", r7d: "7 天", month: "本月", all: "全部", custom: "自訂", from: "從", to: "到", apply: "套用",
44
53
  group: "分組", g_instance: "Agent", g_backend: "Backend", g_model: "模型", g_profile: "憑證 profile", g_team: "Team(依目前設定)",
45
- csv: "下載 CSV", basis: "依牌價換算的 API 等值(查證日期 {0})——不是實際花費。訂閱方案不按 token 計費。",
54
+ csv: "下載 CSV", basis: "依牌價換算的 API 等值({0})——不是實際花費。", basisMore: "訂閱方案不按 token 計費。",
46
55
  amount: "API 等值", input: "輸入", output: "輸出", reasoning: "推理", read: "快取讀取", write: "快取寫入", total: "token 總計", requests: "請求數", messages: "訊息數",
47
56
  notPriced: "未定價", partial: "部分", noTeam: "(不屬於任何 team)", unknown: "未知",
48
- f_cache_write_not_reported: "未回報快取寫入(Codex、Muse)——是估算", f_not_priced: "未定價:{0}", f_source_unknown: "讀取時尚未記錄 backend 與 profile",
49
- f_estimated: "依文字長度估算——Kiro 不記錄 token 數", f_includes_estimates: "含估算值(Kiro)", f_cost_reported: "金額為 CLI 自行回報(價目表沒有此模型)", estimatedPart: "其中估算:{0}",
57
+ t_not_priced: "未定價:{0}", t_partial: "這一列有部分模型沒有價格", t_write_unreported: "Codex/Muse 不回報快取寫入",
58
+ t_cost_reported: "金額為 CLI 自行回報", t_estimated: "依文字長度估算(Kiro 不記錄 token 數)", t_reasoning_none: "這個 CLI 不回報",
59
+ sourceNote: "部分時段在 AgEnD 記錄 backend 與 profile 之前就已讀取。", profileUnrecorded: "未記錄 profile",
60
+ l_partial: "◐ 部分定價", l_cost_reported: "† 金額由 CLI 回報", l_estimated: "≈ 估算值",
61
+ speed: "速度", speedUnit: "{0} token/秒", speedCoverage: "{1} 個請求中有 {0} 個可計時", speedSpread: "p10–p90:{0}–{1} token/秒",
62
+ speedNone: "transcript 沒有計時資訊", speedByModel: "各模型速度",
63
+ speedBasis: "輸出 token ÷ 模型本身的回應時間(不含工具執行時間);方法參考 tokdash。",
64
+
65
+ f_includes_estimates: "含估算值(Kiro)", estimatedPart: "其中估算:{0}",
50
66
  scanning: "掃描中——總數可能還會增加。", updated: "更新於 {0} · 每 10 分鐘讀取一次", since: "自 {0} 起收集", notYet: "尚未收集",
51
67
  refresh: "重新整理",
52
68
  notMeasured: "未量測", nm_credit_billed: "Kiro 以 credit 計費,不記錄 token", nm_unsupported: "AgEnD 目前還讀不到這個 CLI 的用量", nm_no_data: "尚未讀到任何資料",
@@ -95,10 +111,6 @@ export function keyText(group, key) {
95
111
  return key;
96
112
  }
97
113
  const writes = (r) => (r.cacheWrite5m || 0) + (r.cacheWrite1h || 0) + (r.cacheWrite || 0);
98
- function flagText(f, r) {
99
- if (f === "not_priced") return tu("f_not_priced", (r.unpriced || []).join(", "));
100
- return ["cache_write_not_reported", "source_unknown", "estimated", "includes_estimates", "cost_reported"].includes(f) ? tu(`f_${f}`) : f;
101
- }
102
114
 
103
115
  // ── CSV (design §4): the daily rows of this same answer; messages on their own rows (they are per agent, not per model).
104
116
  const CSV_COLUMNS = ["date", "zone", "instance", "backend", "model", "tier", "profile", "input", "output", "reasoning", "cache_read", "cache_write_5m", "cache_write_1h", "cache_write", "requests", "messages", "amount", "basis", "flags", "estimated"];
@@ -318,25 +330,73 @@ function Heatmap({ report, dates }) {
318
330
  </section>`;
319
331
  }
320
332
 
321
- function Row({ r, group, max, measure }) {
333
+ /**
334
+ * A mark with what it means: shown as its sign, said (title, and aria-label for readers) as words. role="img" makes it
335
+ * nameable (#3067 review, Prism P3: aria-label is not allowed on a generic span).
336
+ */
337
+ const mark = (sign, words, cls = "us-mark") => html`<span class=${cls} role="img" title=${words} aria-label=${words}>${sign}</span>`;
338
+ /** "—" that says why there is no number (nameable the same way). */
339
+ const dash = (words) => html`<span class="us-none" role="img" title=${words} aria-label=${words}>—</span>`;
340
+ /** The marks a row's numbers carry (#3066 §3): ◐ partly priced, † the CLI's own amount, ≈ estimated. */
341
+ export function rowMarks(r) {
342
+ const flags = r.flags || [];
343
+ return { partial: r.priced === "partial", cost: flags.includes("cost_reported"), est: flags.includes("estimated") || r.estimated === true,
344
+ writeUnreported: flags.includes("cache_write_not_reported") && writes(r) === 0, unpriced: r.amount === null || r.amount === undefined };
345
+ }
346
+ /** A profile as shown: Codex's older hours were never tagged with one. */
347
+ const profileText = (p) => (p === "unrecorded" ? tu("profileUnrecorded") : p);
348
+
349
+ function Row({ r, group, max, measure, cols }) {
322
350
  const share = max > 0 ? Math.max(0.01, measure(r) / max) : 0;
323
- const meta = [r.backends.join(", "), r.models.join(", "), r.profiles.join(", ")].filter(Boolean).join(" · ");
351
+ const meta = [r.backends.join(", "), r.models.join(", "), r.profiles.map(profileText).join(", ")].filter(Boolean).join(" · ");
352
+ const m = rowMarks(r);
353
+ // Estimated rows (Kiro): every number is an estimate — "≈" before each, the reason in its tooltip.
354
+ const num = (text) => (m.est ? html`${mark("≈", tu("t_estimated"), "us-mark us-approx")}${text}` : text);
355
+ const key = group === "profile" ? profileText(keyText(group, r.key)) : keyText(group, r.key);
324
356
  return html`<tr class="us-row">
325
- <th scope="row" class="us-name"><span class="mono">${keyText(group, r.key)}</span>
357
+ <th scope="row" class="us-name"><span class="mono">${key}</span>
326
358
  <svg class="us-bar" aria-hidden="true"><rect class="us-bar-bg" width="100%" height="6" rx="3" /><rect class="us-bar-fg" width=${`${Math.round(share * 100)}%`} height="6" rx="3" /></svg>
327
- ${group !== "instance" || meta ? html`<span class="us-meta">${meta}</span>` : null}
328
- ${r.flags.length ? html`<span class="us-flags">${r.flags.map((f) => html`<span key=${f} class="ch-tag">${flagText(f, r)}</span>`)}</span>` : null}</th>
329
- <td data-label=${tu("amount")} class=${`num${r.amount === null ? " muted" : ""}`}>${amountText(r)}</td>
330
- <td data-label=${tu("input")} class="num">${tokens(r.input)}</td>
331
- <td data-label=${tu("output")} class="num">${tokens(r.output)}</td>
332
- <td data-label=${tu("reasoning")} class="num">${r.reasoningReported === false ? "—" : tokens(r.reasoning || 0)}</td>
333
- <td data-label=${tu("read")} class="num">${tokens(r.cacheRead)}</td>
334
- <td data-label=${tu("write")} class="num">${r.flags.includes("cache_write_not_reported") && writes(r) === 0 ? "—" : tokens(writes(r))}</td>
335
- <td data-label=${tu("total")} class="num">${tokens(r.total)}</td>
359
+ ${group !== "instance" || meta ? html`<span class="us-meta">${meta}</span>` : null}</th>
360
+ <td data-label=${tu("amount")} class=${`num${m.unpriced ? " muted" : ""}`}>${m.unpriced ? dash(tu("t_not_priced", (r.unpriced || []).join(", "))) : html`${num(money(r.amount))}${m.partial ? mark("◐", tu("t_partial")) : null}${m.cost ? mark("†", tu("t_cost_reported")) : null}`}</td>
361
+ <td data-label=${tu("input")} class="num">${num(tokens(r.input))}</td>
362
+ <td data-label=${tu("output")} class="num">${num(tokens(r.output))}</td>
363
+ ${cols.reasoning ? html`<td data-label=${tu("reasoning")} class="num">${r.reasoningReported === false ? dash(tu("t_reasoning_none")) : num(tokens(r.reasoning || 0))}</td>` : null}
364
+ <td data-label=${tu("read")} class="num">${num(tokens(r.cacheRead))}</td>
365
+ <td data-label=${tu("write")} class="num">${m.writeUnreported ? dash(tu("t_write_unreported")) : num(tokens(writes(r)))}</td>
366
+ <td data-label=${tu("total")} class="num">${num(tokens(r.total))}</td>
336
367
  <td data-label=${tu("requests")} class="num">${n(r.requests)}</td>
337
- <td data-label=${tu("messages")} class="num">${r.messages === null ? "—" : n(r.messages)}</td>
368
+ ${cols.messages ? html`<td data-label=${tu("messages")} class="num">${r.messages === null ? "—" : n(r.messages)}</td>` : null}
369
+ ${cols.speed ? html`<td data-label=${tu("speed")} class="num">${r.speed ? speedText(r.speed) : dash(tu("speedNone"))}</td>` : null}
338
370
  </tr>`;
339
371
  }
372
+ /** #3066 §2: a rate as shown — whole tok/s, one decimal below 10. */
373
+ export const speedText = (s) => tu("speedUnit", s.tokPerSec >= 10 ? Math.round(s.tokPerSec).toLocaleString("en-US") : s.tokPerSec.toFixed(1));
374
+ /** Columns worth a place (#3066 §3): Reasoning only when some row reports it; Messages only when some row has them; Speed (§2) only when some response was timed. */
375
+ export function usageColumns(rows) {
376
+ return { reasoning: rows.some((r) => r.reasoningReported !== false), messages: rows.some((r) => r.messages !== null && r.messages !== undefined),
377
+ speed: rows.some((r) => !!r.speed) };
378
+ }
379
+ /** #3066 §2: each model's speed — rate · requests timed (coverage) · p10–p90 from 30 timed. */
380
+ function SpeedCard({ report }) {
381
+ const list = report.speedByModel || [];
382
+ if (!list.length) return null;
383
+ return html`<section class="us-speed card" aria-labelledby="us-speed-h">
384
+ <h3 id="us-speed-h">${tu("speedByModel")}</h3>
385
+ <ul class="rows">${list.map((m) => html`<li key=${m.model} class="row-item"><span class="grow strong mono">${m.model}</span>
386
+ <span class="num">${speedText(m.speed)}</span>
387
+ <span class="muted">${[m.requests > 0 ? tu("speedCoverage", n(m.speed.timedRequests), n(m.requests)) : null,
388
+ m.speed.p10 !== null && m.speed.p90 !== null ? tu("speedSpread", Math.round(m.speed.p10), Math.round(m.speed.p90)) : null].filter(Boolean).join(" · ")}</span></li>`)}</ul>
389
+ <p class="note">${tu("speedBasis")}</p>
390
+ </section>`;
391
+ }
392
+ /** The legend under the table: only the marks that appear, and the one footnote when an hour's source was not recorded. */
393
+ function Legend({ rows }) {
394
+ const marks = rows.map(rowMarks);
395
+ const parts = [marks.some((m) => m.partial) && tu("l_partial"), marks.some((m) => m.cost) && tu("l_cost_reported"), marks.some((m) => m.est) && tu("l_estimated")].filter(Boolean);
396
+ const source = rows.some((r) => (r.flags || []).includes("source_unknown"));
397
+ if (!parts.length && !source) return null;
398
+ return html`<p class="note us-key">${parts.join(" · ")}${parts.length && source ? html`<br />` : null}${source ? tu("sourceNote") : null}</p>`;
399
+ }
340
400
 
341
401
  /** The tab's body for one answer of GET /ui/usage-report. */
342
402
  export function UsageReport({ report, choice, setChoice, refresh, fetchedAt }) {
@@ -355,6 +415,7 @@ export function UsageReport({ report, choice, setChoice, refresh, fetchedAt }) {
355
415
  const zones = zonesChanged(report);
356
416
  const csvFrom = report.dailyTruncated ? report.dailyFrom : report.range.kind === "all" ? "all" : report.range.from;
357
417
  const rows = report.rows.filter((r) => r.total > 0 || (r.messages || 0) > 0);
418
+ const cols = usageColumns(rows);
358
419
  const anyAmount = rows.some((r) => r.amount !== null && r.amount > 0);
359
420
  const measure = (r) => (anyAmount ? r.amount || 0 : r.total);
360
421
  const max = Math.max(0, ...rows.map(measure));
@@ -375,7 +436,7 @@ export function UsageReport({ report, choice, setChoice, refresh, fetchedAt }) {
375
436
  <label>${tu("from")} <input type="date" value=${draft.from} max=${draft.to} onInput=${(e) => setDraft({ ...draft, from: e.target.value })} /></label>
376
437
  <label>${tu("to")} <input type="date" value=${draft.to} min=${draft.from} onInput=${(e) => setDraft({ ...draft, to: e.target.value })} /></label>
377
438
  <button type="submit" class="btn btn-sm">${tu("apply")}</button></form>` : null}
378
- <p class="us-basis" role="note">${tu("basis", report.pricesChecked)}</p>
439
+ <p class="us-basis" role="note" title=${tu("basisMore")}>${tu("basis", report.pricesChecked)}</p>
379
440
  ${noDaily ? html`<p class="us-partial" role="status">${tu("noDaily")}</p>` : report.dailyTruncated ? html`<p class="us-partial" role="status">${tu("partialDays", report.dailyFrom)}</p>` : null}
380
441
  ${zones ? html`<p class="us-partial" role="status">${tu("zoneChanged", zones.join(", "), report.zone)}</p>` : null}
381
442
  <p class="note us-fresh">${tu("updated", new Date(fetchedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }))} · ${Number.isFinite(since) ? tu("since", new Date(since).toLocaleDateString()) : tu("notYet")} · ${tu("zoneNote", report.zone)}${group === "team" ? ` ${tu("teamsNote")}` : ""}
@@ -389,10 +450,12 @@ export function UsageReport({ report, choice, setChoice, refresh, fetchedAt }) {
389
450
  <div class="ch-metric"><dt>${tu("messages")}</dt><dd>${report.totals.messages === null ? "—" : n(report.totals.messages)}</dd></div>
390
451
  </dl>
391
452
  ${rows.length ? html`<table class="us-table"><thead><tr><th scope="col">${tu(`g_${group}`)}</th><th scope="col" class="num">${tu("amount")}</th>
392
- <th scope="col" class="num">${tu("input")}</th><th scope="col" class="num">${tu("output")}</th><th scope="col" class="num">${tu("reasoning")}</th><th scope="col" class="num">${tu("read")}</th><th scope="col" class="num">${tu("write")}</th>
393
- <th scope="col" class="num">${tu("total")}</th><th scope="col" class="num">${tu("requests")}</th><th scope="col" class="num">${tu("messages")}</th></tr></thead>
394
- <tbody>${rows.map((r) => html`<${Row} key=${r.key} r=${r} group=${group} max=${max} measure=${measure} />`)}</tbody></table>`
453
+ <th scope="col" class="num">${tu("input")}</th><th scope="col" class="num">${tu("output")}</th>${cols.reasoning ? html`<th scope="col" class="num">${tu("reasoning")}</th>` : null}<th scope="col" class="num">${tu("read")}</th><th scope="col" class="num">${tu("write")}</th>
454
+ <th scope="col" class="num">${tu("total")}</th><th scope="col" class="num">${tu("requests")}</th>${cols.messages ? html`<th scope="col" class="num">${tu("messages")}</th>` : null}${cols.speed ? html`<th scope="col" class="num">${tu("speed")}</th>` : null}</tr></thead>
455
+ <tbody>${rows.map((r) => html`<${Row} key=${r.key} r=${r} group=${group} max=${max} measure=${measure} cols=${cols} />`)}</tbody></table>
456
+ <${Legend} rows=${rows} />`
395
457
  : html`<p class="note">${tu("noRows")}</p>`}
458
+ <${SpeedCard} report=${report} />
396
459
  <${Trend} report=${report} dates=${dates} />
397
460
  <${Heatmap} report=${report} dates=${dates} />`}
398
461
  ${report.notMeasured.length || report.shared.length ? html`<section class="us-rest" aria-labelledby="us-rest-h"><h3 id="us-rest-h">${tu("notMeasured")}</h3>