@songsid/agend 2.3.0-beta.22 → 2.3.0-beta.24

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 (298) hide show
  1. package/README.md +2 -2
  2. package/README.zh-TW.md +2 -2
  3. package/dist/agent-cli.js +4 -2
  4. package/dist/agent-cli.js.map +1 -1
  5. package/dist/agent-endpoint.d.ts +1 -0
  6. package/dist/agent-endpoint.js +4 -0
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/agent-whats-new.d.ts +5 -0
  9. package/dist/agent-whats-new.js +16 -1
  10. package/dist/agent-whats-new.js.map +1 -1
  11. package/dist/auth-api.d.ts +7 -0
  12. package/dist/auth-api.js +54 -21
  13. package/dist/auth-api.js.map +1 -1
  14. package/dist/backend/codex-package.d.ts +30 -0
  15. package/dist/backend/codex-package.js +133 -0
  16. package/dist/backend/codex-package.js.map +1 -0
  17. package/dist/backend/codex.d.ts +2 -0
  18. package/dist/backend/codex.js +9 -2
  19. package/dist/backend/codex.js.map +1 -1
  20. package/dist/backend/grok.js +3 -1
  21. package/dist/backend/grok.js.map +1 -1
  22. package/dist/backend/kiro.d.ts +9 -1
  23. package/dist/backend/kiro.js +102 -1
  24. package/dist/backend/kiro.js.map +1 -1
  25. package/dist/backend/opencode-permission.js +1 -1
  26. package/dist/backend/opencode-permission.js.map +1 -1
  27. package/dist/backend/opencode.js +2 -1
  28. package/dist/backend/opencode.js.map +1 -1
  29. package/dist/binary-probe.js +1 -1
  30. package/dist/bug-report-cli.js +11 -10
  31. package/dist/bug-report-cli.js.map +1 -1
  32. package/dist/bug-report.js +11 -10
  33. package/dist/bug-report.js.map +1 -1
  34. package/dist/bundled-skills.d.ts +7 -3
  35. package/dist/bundled-skills.js +16 -7
  36. package/dist/bundled-skills.js.map +1 -1
  37. package/dist/channel/adapters/discord.js +3 -3
  38. package/dist/channel/adapters/discord.js.map +1 -1
  39. package/dist/channel/mcp-server.js +12 -4
  40. package/dist/channel/mcp-server.js.map +1 -1
  41. package/dist/channel/mcp-tools.d.ts +3 -1
  42. package/dist/channel/mcp-tools.js +36 -0
  43. package/dist/channel/mcp-tools.js.map +1 -1
  44. package/dist/child-process.d.ts +21 -0
  45. package/dist/child-process.js +54 -0
  46. package/dist/child-process.js.map +1 -0
  47. package/dist/cli.js +188 -204
  48. package/dist/cli.js.map +1 -1
  49. package/dist/command-table.d.ts +1 -1
  50. package/dist/command-table.js +1 -1
  51. package/dist/command-table.js.map +1 -1
  52. package/dist/config-validator.js +20 -1
  53. package/dist/config-validator.js.map +1 -1
  54. package/dist/config.js +3 -1
  55. package/dist/config.js.map +1 -1
  56. package/dist/daemon.d.ts +8 -0
  57. package/dist/daemon.js +31 -8
  58. package/dist/daemon.js.map +1 -1
  59. package/dist/darwin-memory.js +1 -1
  60. package/dist/doctor.d.ts +5 -0
  61. package/dist/doctor.js +42 -0
  62. package/dist/doctor.js.map +1 -1
  63. package/dist/export-import.js +4 -3
  64. package/dist/export-import.js.map +1 -1
  65. package/dist/fleet-level-config.d.ts +1 -1
  66. package/dist/fleet-level-config.js +2 -0
  67. package/dist/fleet-level-config.js.map +1 -1
  68. package/dist/fleet-manager.d.ts +45 -0
  69. package/dist/fleet-manager.js +324 -36
  70. package/dist/fleet-manager.js.map +1 -1
  71. package/dist/full-restart.d.ts +5 -5
  72. package/dist/full-restart.js +5 -5
  73. package/dist/full-restart.js.map +1 -1
  74. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +48 -10
  75. package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -1
  76. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +7 -8
  77. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
  78. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +319 -0
  79. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +529 -0
  80. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +17 -4
  81. package/dist/general-knowledge/steering/core-rules.md +1 -1
  82. package/dist/instance-config-impact.js +10 -3
  83. package/dist/instance-config-impact.js.map +1 -1
  84. package/dist/instance-lifecycle.js +4 -4
  85. package/dist/locale.js +296 -4
  86. package/dist/locale.js.map +1 -1
  87. package/dist/login-flows.js +1 -1
  88. package/dist/login-shell.js +3 -1
  89. package/dist/login-shell.js.map +1 -1
  90. package/dist/ls-rows.js +1 -1
  91. package/dist/needs-you-hub.d.ts +98 -1
  92. package/dist/needs-you-hub.js +313 -44
  93. package/dist/needs-you-hub.js.map +1 -1
  94. package/dist/needs-you-live.d.ts +5 -2
  95. package/dist/needs-you-live.js +6 -3
  96. package/dist/needs-you-live.js.map +1 -1
  97. package/dist/needs-you.d.ts +58 -3
  98. package/dist/needs-you.js +36 -3
  99. package/dist/needs-you.js.map +1 -1
  100. package/dist/outbound-schemas.d.ts +3 -3
  101. package/dist/public-link-progress.d.ts +12 -1
  102. package/dist/public-link-progress.js +5 -10
  103. package/dist/public-link-progress.js.map +1 -1
  104. package/dist/public-tunnel-config.d.ts +5 -0
  105. package/dist/public-tunnel-config.js +45 -1
  106. package/dist/public-tunnel-config.js.map +1 -1
  107. package/dist/public-web-link.js +6 -5
  108. package/dist/public-web-link.js.map +1 -1
  109. package/dist/quickstart.js +15 -9
  110. package/dist/quickstart.js.map +1 -1
  111. package/dist/resource-report.js +1 -1
  112. package/dist/scheduler/task-board.d.ts +41 -1
  113. package/dist/scheduler/task-board.js +152 -5
  114. package/dist/scheduler/task-board.js.map +1 -1
  115. package/dist/sd-notify.js +2 -1
  116. package/dist/sd-notify.js.map +1 -1
  117. package/dist/service-installer.d.ts +69 -0
  118. package/dist/service-installer.js +182 -0
  119. package/dist/service-installer.js.map +1 -1
  120. package/dist/service-owner.d.ts +44 -0
  121. package/dist/service-owner.js +48 -0
  122. package/dist/service-owner.js.map +1 -0
  123. package/dist/service-plan.d.ts +8 -0
  124. package/dist/service-plan.js +13 -0
  125. package/dist/service-plan.js.map +1 -1
  126. package/dist/settings-api.d.ts +4 -0
  127. package/dist/settings-api.js +21 -3
  128. package/dist/settings-api.js.map +1 -1
  129. package/dist/settings-change.js +88 -4
  130. package/dist/settings-change.js.map +1 -1
  131. package/dist/settings-confirmation.d.ts +2 -0
  132. package/dist/settings-confirmation.js +14 -8
  133. package/dist/settings-confirmation.js.map +1 -1
  134. package/dist/settings-control.js +2 -1
  135. package/dist/settings-control.js.map +1 -1
  136. package/dist/settings-effect.js +2 -0
  137. package/dist/settings-effect.js.map +1 -1
  138. package/dist/settings-http-confirmation.d.ts +2 -0
  139. package/dist/settings-http-confirmation.js +3 -2
  140. package/dist/settings-http-confirmation.js.map +1 -1
  141. package/dist/settings-request-capability.d.ts +4 -1
  142. package/dist/settings-request-capability.js +11 -3
  143. package/dist/settings-request-capability.js.map +1 -1
  144. package/dist/setup-auth.d.ts +1 -0
  145. package/dist/setup-auth.js +2 -1
  146. package/dist/setup-auth.js.map +1 -1
  147. package/dist/setup-host.d.ts +1 -0
  148. package/dist/setup-host.js +7 -3
  149. package/dist/setup-host.js.map +1 -1
  150. package/dist/setup-wizard.js +1 -1
  151. package/dist/setup-wizard.js.map +1 -1
  152. package/dist/task-board-summary.d.ts +101 -0
  153. package/dist/task-board-summary.js +177 -0
  154. package/dist/task-board-summary.js.map +1 -0
  155. package/dist/task-board-waker.d.ts +8 -1
  156. package/dist/task-board-waker.js +15 -6
  157. package/dist/task-board-waker.js.map +1 -1
  158. package/dist/tmux-control.js +1 -1
  159. package/dist/tmux-control.js.map +1 -1
  160. package/dist/tmux-exec-helper.d.ts +13 -3
  161. package/dist/tmux-exec-helper.js +34 -29
  162. package/dist/tmux-exec-helper.js.map +1 -1
  163. package/dist/tmux-manager.js +1 -1
  164. package/dist/tmux-read.js +1 -1
  165. package/dist/tmux-read.js.map +1 -1
  166. package/dist/topic-commands.d.ts +3 -0
  167. package/dist/topic-commands.js +29 -18
  168. package/dist/topic-commands.js.map +1 -1
  169. package/dist/transcript-monitor.js +5 -0
  170. package/dist/transcript-monitor.js.map +1 -1
  171. package/dist/transcript-sources.d.ts +2 -0
  172. package/dist/transcript-sources.js +4 -0
  173. package/dist/transcript-sources.js.map +1 -1
  174. package/dist/tunnel/cloudflared-install.js +3 -1
  175. package/dist/tunnel/cloudflared-install.js.map +1 -1
  176. package/dist/tunnel/cloudflared.d.ts +1 -1
  177. package/dist/tunnel/cloudflared.js +51 -10
  178. package/dist/tunnel/cloudflared.js.map +1 -1
  179. package/dist/tunnel/named-diagnostics.d.ts +13 -0
  180. package/dist/tunnel/named-diagnostics.js +52 -0
  181. package/dist/tunnel/named-diagnostics.js.map +1 -0
  182. package/dist/tunnel/types.d.ts +2 -1
  183. package/dist/tunnel/types.js.map +1 -1
  184. package/dist/types.d.ts +9 -0
  185. package/dist/ui/app-effects.js +10 -4
  186. package/dist/ui/chat-strings.js +3 -0
  187. package/dist/ui/chat-thread.js +14 -7
  188. package/dist/ui/effect-trial-offer.js +2 -31
  189. package/dist/ui/effects-waker.js +2 -70
  190. package/dist/ui/fleet-task-card.js +5 -5
  191. package/dist/ui/fleet-task-common.js +43 -9
  192. package/dist/ui/fleet-task-work.js +107 -0
  193. package/dist/ui/fleet-tasks.js +46 -12
  194. package/dist/ui/image-lightbox.js +39 -1
  195. package/dist/ui/image-zoom.js +242 -0
  196. package/dist/ui/panel-chat.js +24 -13
  197. package/dist/ui/panel-details.js +2 -0
  198. package/dist/ui/panel-fleet.js +6 -2
  199. package/dist/ui/panel-needs.js +21 -6
  200. package/dist/ui/panel-settings.js +35 -13
  201. package/dist/ui/preview-panel.js +2 -1
  202. package/dist/ui/search-dialog.js +2 -1
  203. package/dist/ui/settings-appearance.js +44 -10
  204. package/dist/ui/settings-dialogs.js +33 -17
  205. package/dist/ui/settings-discord.js +6 -2
  206. package/dist/ui/settings-guests.js +2 -1
  207. package/dist/ui/settings-model.js +2 -11
  208. package/dist/ui/settings-mods.js +8 -3
  209. package/dist/ui/settings-strings.js +32 -0
  210. package/dist/ui/settings-token.js +3 -1
  211. package/dist/ui/settings-web-authority.js +6 -3
  212. package/dist/ui/settings-wizard.js +13 -7
  213. package/dist/ui/shared/app-i18n.js +2 -2
  214. package/dist/ui/shared/app-needs.js +2 -2
  215. package/dist/ui/shared/app-store.js +2 -2
  216. package/dist/ui/shared/app.css +93 -32
  217. package/dist/ui/shared/app.js +5 -1
  218. package/dist/ui/shared/effect-app-runtime.js +7 -5
  219. package/dist/ui/shared/effect-chat-runtime.js +2 -7
  220. package/dist/ui/shared/effect-cursor.js +3 -2
  221. package/dist/ui/shared/effect-display-metadata.js +39 -0
  222. package/dist/ui/shared/effect-registry.js +4 -2
  223. package/dist/ui/shared/effect-runtime-loader.js +3 -1
  224. package/dist/ui/shared/effect-stock-trust.js +5 -5
  225. package/dist/ui/shared/feature-flags.js +127 -0
  226. package/dist/ui/shared/identity-color.js +6 -3
  227. package/dist/ui/shared/instance-nav.js +3 -2
  228. package/dist/ui/shared/panel-view.js +14 -29
  229. package/dist/ui/shared/text-undo.js +210 -0
  230. package/dist/ui/shared/ui-dialog.js +4 -3
  231. package/dist/ui/shared/ui-icons.js +2 -0
  232. package/dist/ui/shared/usage-dialog.js +29 -8
  233. package/dist/ui/shared/view-strings.js +2 -4
  234. package/dist/ui/web-terminal/terminal.html +10 -10
  235. package/dist/ui/web-terminal/terminal.js +58 -12
  236. package/dist/update-dispatch.js +1 -1
  237. package/dist/update-launch.d.ts +1 -1
  238. package/dist/update-launch.js +1 -2
  239. package/dist/update-launch.js.map +1 -1
  240. package/dist/update-preflight.js +1 -1
  241. package/dist/usage/providers.js +1 -1
  242. package/dist/usage/providers.js.map +1 -1
  243. package/dist/view-api.js +1 -1
  244. package/dist/web-api.d.ts +6 -0
  245. package/dist/web-api.js +163 -21
  246. package/dist/web-api.js.map +1 -1
  247. package/dist/web-app-icon-worker.js +2 -1
  248. package/dist/web-app-icon-worker.js.map +1 -1
  249. package/dist/web-app-icon.d.ts +10 -8
  250. package/dist/web-app-icon.js +14 -13
  251. package/dist/web-app-icon.js.map +1 -1
  252. package/dist/web-appearance-tools.d.ts +1 -0
  253. package/dist/web-appearance-tools.js +7 -3
  254. package/dist/web-appearance-tools.js.map +1 -1
  255. package/dist/web-appearance.d.ts +4 -10
  256. package/dist/web-appearance.js +4 -3
  257. package/dist/web-appearance.js.map +1 -1
  258. package/dist/web-auth.d.ts +8 -4
  259. package/dist/web-auth.js +28 -17
  260. package/dist/web-auth.js.map +1 -1
  261. package/dist/web-chat-history.d.ts +14 -0
  262. package/dist/web-chat-history.js +26 -1
  263. package/dist/web-chat-history.js.map +1 -1
  264. package/dist/web-cookie-names.d.ts +5 -0
  265. package/dist/web-cookie-names.js +12 -0
  266. package/dist/web-cookie-names.js.map +1 -0
  267. package/dist/web-experimental-http.d.ts +2 -0
  268. package/dist/web-experimental-http.js +12 -0
  269. package/dist/web-experimental-http.js.map +1 -0
  270. package/dist/web-experimental.d.ts +11 -0
  271. package/dist/web-experimental.js +49 -0
  272. package/dist/web-experimental.js.map +1 -0
  273. package/dist/web-guest-access.d.ts +1 -0
  274. package/dist/web-guest-access.js +4 -2
  275. package/dist/web-guest-access.js.map +1 -1
  276. package/dist/web-mod-store.d.ts +2 -0
  277. package/dist/web-mod-store.js +10 -0
  278. package/dist/web-mod-store.js.map +1 -1
  279. package/dist/web-png.d.ts +4 -0
  280. package/dist/web-png.js +10 -2
  281. package/dist/web-png.js.map +1 -1
  282. package/dist/web-preview.d.ts +2 -0
  283. package/dist/web-preview.js +2 -0
  284. package/dist/web-preview.js.map +1 -1
  285. package/dist/web-returning-device.d.ts +4 -2
  286. package/dist/web-returning-device.js +12 -5
  287. package/dist/web-returning-device.js.map +1 -1
  288. package/dist/web-settings-patch.d.ts +2 -1
  289. package/dist/web-settings-patch.js +5 -1
  290. package/dist/web-settings-patch.js.map +1 -1
  291. package/dist/web-settings-tools.d.ts +1 -0
  292. package/dist/web-settings-tools.js +14 -10
  293. package/dist/web-settings-tools.js.map +1 -1
  294. package/dist/web-terminal.js +1 -1
  295. package/dist/web-theme-store.d.ts +2 -2
  296. package/dist/web-theme-store.js +6 -3
  297. package/dist/web-theme-store.js.map +1 -1
  298. package/package.json +11 -11
@@ -383,6 +383,17 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
383
383
  .dlg-title { flex: 1; font-size: var(--fs-lg); font-weight: var(--fw-semibold); }
384
384
  .dlg-body { padding: var(--s-2) var(--s-5) var(--s-4); overflow-y: auto; display: flex; flex-direction: column; gap: var(--s-3); }
385
385
  .dlg-actions { display: flex; justify-content: flex-end; gap: var(--s-2); padding: var(--s-3) var(--s-5) var(--s-5); }
386
+ /* A drawer (ui-dialog.js `drawer`): on a desk, the right edge at full height, the page in view beside it. A phone keeps
387
+ the bottom sheet (the rules below are for 900 px and up only). */
388
+ @media (min-width: 900px) {
389
+ .dlg.dlg-drawer { width: min(620px, 100vw); max-width: 100vw; height: 100dvh; max-height: 100dvh; margin: 0 0 0 auto; border-radius: 0;
390
+ background: var(--bg); box-shadow: var(--shadow-2); animation: dlg-drawer-in 0.18s var(--ease); }
391
+ .dlg-drawer .dlg-box { height: 100%; max-height: 100dvh; }
392
+ .dlg-drawer .dlg-head { padding: var(--s-3) var(--s-3) var(--s-3) var(--s-5); border-bottom: 1px solid var(--border); }
393
+ .dlg-drawer .dlg-body { flex: 1; padding-top: var(--s-4); padding-bottom: var(--s-8); }
394
+ }
395
+ @keyframes dlg-drawer-in { from { transform: translateX(28px); opacity: 0.6; } }
396
+ @media (prefers-reduced-motion: reduce) { .dlg.dlg-drawer { animation: none; } }
386
397
 
387
398
  /* ── Forms ── */
388
399
  .form { display: flex; flex-direction: column; gap: var(--s-3); }
@@ -514,8 +525,19 @@ html.lb-open .scroller { overflow: hidden; }
514
525
  .lb { position: fixed; inset: 0; width: 100vw; height: 100vh; height: 100dvh; max-width: none; max-height: none; margin: 0; padding: 0; border: 0;
515
526
  background: rgba(0, 0, 0, 0.95); color: #fff; overflow: hidden; overscroll-behavior: contain; }
516
527
  .lb::backdrop { background: rgba(0, 0, 0, 0.6); }
517
- .lb-stage { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 56px 16px 16px; box-sizing: border-box; }
518
- .lb-img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; }
528
+ .lb-stage { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 56px 16px 16px; box-sizing: border-box;
529
+ overflow: hidden; touch-action: none; }
530
+ /* #2333: the picture at its own size, placed and scaled by a CSSOM transform from its top-left corner (image-zoom.js);
531
+ hidden until it has loaded and been fitted. One that fails to load is shown where the browser puts a broken one. */
532
+ .lb-img { position: absolute; left: 0; top: 0; max-width: none; max-height: none; display: block; transform-origin: 0 0; will-change: transform;
533
+ visibility: hidden; cursor: zoom-in; user-select: none; -webkit-user-select: none; -webkit-user-drag: none; -webkit-touch-callout: none; }
534
+ .lb-img.lb-ready { visibility: visible; }
535
+ .lb-img.lb-err { position: static; max-width: 100%; max-height: 100%; visibility: visible; transform: none; cursor: default; }
536
+ .lb-stage.lb-zoomed .lb-img { cursor: grab; }
537
+ .lb-stage.lb-zoomed .lb-img:active { cursor: grabbing; }
538
+ .lb-zoom { display: inline-flex; align-items: center; gap: 2px; }
539
+ .lb .lb-level { min-width: 4.5em; justify-content: center; color: #fff; background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.3); font-variant-numeric: tabular-nums; }
540
+ .lb .lb-fit-width.on { background: rgba(255, 255, 255, 0.22); }
519
541
  .lb-bar { position: absolute; top: 0; left: 0; right: 0; z-index: 1; display: flex; align-items: center; gap: var(--s-2); padding: var(--s-2) var(--s-3);
520
542
  background: rgba(0, 0, 0, 0.75); }
521
543
  .lb-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-sm); }
@@ -1040,26 +1062,17 @@ body.side-resizing, body.side-resizing * { cursor: col-resize !important; user-s
1040
1062
  .v-pre .ansi[data-dim] { opacity: .5; }
1041
1063
  .v-term-note { position: absolute; left: var(--s-3); right: var(--s-3); top: var(--s-3); padding: var(--s-2) var(--s-3); border-radius: var(--r-sm); background: var(--warn-bg); border: 1px solid var(--warn-line); color: #f0f0f0; font-size: var(--fs-sm); }
1042
1064
  .v-probe { position: absolute; visibility: hidden; white-space: pre; left: -9999px; top: 0; font-size: 100px; }
1043
- .v-card { flex-shrink: 0; display: flex; align-items: center; gap: var(--s-3); padding: var(--s-2) var(--s-4); background: var(--bg); padding-bottom: max(var(--s-2), env(safe-area-inset-bottom)); }
1044
- .v-card.open { align-items: flex-start; padding-top: var(--s-3); padding-bottom: var(--s-3); }
1045
- .v-avatar { width: 28px; height: 28px; flex-shrink: 0; border-radius: 50%; overflow: hidden; background: var(--surface-2); display: flex; align-items: center; justify-content: center; }
1046
- .v-card.open .v-avatar { width: 48px; height: 48px; }
1047
- .v-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
1048
- .v-initial { color: var(--text-2); font-weight: var(--fw-semibold); text-transform: uppercase; }
1049
- .v-card-body { flex: 1; min-width: 0; }
1050
- .v-card-line { display: flex; align-items: baseline; gap: var(--s-2); font-size: var(--fs-sm); min-width: 0; }
1051
- .v-card-line strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1052
- .v-role { color: var(--accent); font-size: var(--fs-xs); white-space: nowrap; }
1053
- .v-card-meta { color: var(--text-3); font-size: var(--fs-xs); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1054
- .v-card.open .v-card-meta { white-space: normal; }
1055
- .v-card-desc { margin-top: var(--s-2); color: var(--text-2); font-size: var(--fs-sm); white-space: pre-wrap; }
1056
1065
  .help-list { list-style: disc; padding-left: var(--s-5); display: flex; flex-direction: column; gap: var(--s-2); font-size: var(--fs-sm); }
1057
1066
  .err { color: var(--danger); font-size: var(--fs-sm); }
1058
1067
  .u-provider { padding: var(--s-3) var(--s-4); border-radius: var(--r-md); background: var(--surface); display: flex; flex-direction: column; gap: var(--s-2); }
1059
1068
  .u-head { display: flex; align-items: center; gap: var(--s-2); min-width: 0; }
1060
- /* #1749: one line beside ↑↓ — the name gives way first, then the plan badge (each with an ellipsis); ↑↓ never wrap. */
1061
- .u-head strong { min-width: 0; flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1062
- .u-plan { min-width: 0; flex: none; max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1069
+ /* #1749 / #2412: one line beside ↑↓, by flexing alone (no widths assumed: ↑↓ are larger on a phone). The plan badge
1070
+ starts at its minimum (40 px) and grows up to its own width into the room the name and ↑↓ leave, so a long badge ends
1071
+ in an ellipsis (the full name its tooltip) while the name keeps every pixel (#2415 review: a shared shrink, however
1072
+ weighted, still took a fraction of one and painted "Meta Muse Co…"). Only when the name, that minimum badge and ↑↓
1073
+ cannot all fit (a 320 px phone) does the name shrink and end in an ellipsis — nothing leaves the card. */
1074
+ .u-head strong { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1075
+ .u-plan { flex: 1 0 40px; max-width: max-content; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1063
1076
  /* #2014: a credential profile's row — the provider name beside the plan badge, and the profile on its own full-width
1064
1077
  muted line under the head (one line in the rail, whole in the dialog). */
1065
1078
  .u-profile { margin-top: calc(-1 * var(--s-2) + 2px); color: var(--text-3); font-size: var(--fs-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -1094,8 +1107,8 @@ body.side-resizing, body.side-resizing * { cursor: col-resize !important; user-s
1094
1107
  @media (max-width: 1199px) { .u-rail { display: none; } }
1095
1108
  @media (max-width: 899px) {
1096
1109
  .hide-narrow { display: none; }
1097
- .v-card { padding-left: var(--s-3); padding-right: var(--s-3); }
1098
- .v-card .btn { min-height: var(--tap); }
1110
+ .v-id { padding-left: var(--s-3); padding-right: var(--s-3); }
1111
+ .v-id-actions .btn { min-height: var(--tap); }
1099
1112
  }
1100
1113
 
1101
1114
  /* ── Settings (#1408 step 3): lists, forms, the pending bar, the Apply in hand ── */
@@ -1187,8 +1200,6 @@ textarea.s-yaml { white-space: pre; overflow-x: auto; }
1187
1200
  .s-dev-bar .lbl { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
1188
1201
  /* An agent's row: name and model on the first line, its tags and buttons from the second, on every row alike. */
1189
1202
  .s-br { display: block; flex-basis: 100%; height: 0; }
1190
- /* View: the profile line wraps to two lines instead of hiding the model and context. */
1191
- .v-card-meta { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
1192
1203
  /* A text button in Needs you lines up with the text above it, first in its row or wrapped onto a line of its own;
1193
1204
  a row's trailing Delete sits at the right. */
1194
1205
  .n-actions > .btn-ghost { padding-left: 0; padding-right: 0; margin-left: -1px; }
@@ -1704,9 +1715,10 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1704
1715
  .id-card.card-c .id-tagline { -webkit-line-clamp: 2; line-clamp: 2; }
1705
1716
  .id-card.card-c .id-bio { -webkit-line-clamp: 3; line-clamp: 3; }
1706
1717
  }
1707
- /* #1629 r4 §1: the card in View (above the profile row) and its part of Edit profile. */
1708
- .v-id { flex-shrink: 0; padding: var(--s-2) var(--s-4) 0; background: var(--bg); }
1718
+ /* #1629 r4 §1: the identity card in View, with Edit profile under it. */
1719
+ .v-id { flex-shrink: 0; padding: var(--s-2) var(--s-4); background: var(--bg); padding-bottom: max(var(--s-2), env(safe-area-inset-bottom)); }
1709
1720
  .v-id .id-card { margin: 0; }
1721
+ .v-id-actions { display: flex; justify-content: flex-end; padding-top: var(--s-2); }
1710
1722
  .form-sub { margin: var(--s-2) 0 0; font-size: var(--fs-sm); font-weight: var(--fw-semibold); }
1711
1723
  /* #1629 r4.1 Q3 (b): Settings → Appearance, the card's upload limits (three to a row; one on a phone). */
1712
1724
  .grid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--s-3) var(--s-4); margin-bottom: var(--s-3); }
@@ -1750,7 +1762,7 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1750
1762
  .p-fleet > .seg, .p-settings > .seg,
1751
1763
  .p-fleet > .panel-body, .p-needs > .panel-body, .p-settings > .panel-body,
1752
1764
  .p-log > .panel-body, .p-details > .panel-body, .p-view > .panel-body,
1753
- .p-log > .log-bar, .p-view > .v-term, .p-view > .v-id, .p-view > .v-card { position: relative; z-index: 1; }
1765
+ .p-log > .log-bar, .p-view > .v-term, .p-view > .v-id { position: relative; z-index: 1; }
1754
1766
  /* Keep the existing Apply bar's sticky behavior, in its own bounded stock band. */
1755
1767
  .p-settings > .s-pending { z-index: 1; }
1756
1768
 
@@ -1920,19 +1932,22 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1920
1932
  .tb-count { margin-left: auto; font-variant-numeric: tabular-nums; color: var(--text-3); }
1921
1933
  .tb-drop-line { height: 3px; border-radius: 2px; background: var(--accent); }
1922
1934
 
1923
- .tb-tile { display: flex; flex-direction: column; gap: var(--s-2); padding: var(--s-3) var(--s-3) var(--s-3) calc(var(--s-3) + 2px); border-radius: var(--r-md); background: var(--bg); border: 1px solid var(--border); --lbar-color: var(--text-3); cursor: pointer; font-size: var(--fs-sm); min-width: 0; }
1924
- .tb-tile:hover { border-color: var(--border-strong); }
1935
+ .tb-tile { display: flex; flex-direction: column; gap: var(--s-2); padding: var(--s-3) var(--s-3) var(--s-3) calc(var(--s-3) + 2px); border-radius: var(--r-md); background: var(--bg); border: 1px solid var(--border); box-shadow: var(--shadow-1); --lbar-color: transparent; cursor: pointer; font-size: var(--fs-sm); min-width: 0; }
1936
+ .tb-tile:hover { border-color: var(--border-strong); box-shadow: var(--shadow-2); }
1937
+ /* As taskhall: the shared straight bar (#2330) only while the card is under way (in progress, blocked, waiting for
1938
+ you); none on to do or done, and a done tile is dimmed. */
1925
1939
  .tb-tile.st-claimed { --lbar-color: var(--accent); }
1926
1940
  .tb-tile.st-blocked { --lbar-color: var(--danger); }
1927
1941
  .tb-tile.st-review { --lbar-color: var(--warn); }
1928
- .tb-tile.st-done { --lbar-color: var(--ok); }
1942
+ .tb-tile.st-done { background: color-mix(in srgb, var(--bg) 60%, var(--surface)); box-shadow: none; }
1943
+ .tb-tile.st-done .tb-tile-title { color: var(--text-2); }
1929
1944
  .tb-tile.dragging { opacity: 0.4; }
1930
1945
  .tb-tile.flash { animation: tb-flash 1.6s var(--ease); }
1931
1946
  @keyframes tb-flash { from { box-shadow: 0 0 0 3px var(--accent-dim); background: var(--accent-dim); } to { box-shadow: none; } }
1932
1947
  .tb-tile-top { display: flex; align-items: center; gap: var(--s-2); }
1933
- .tb-size { font-size: var(--fs-xs); color: var(--text-3); }
1948
+ .tb-size { padding: 0 5px; border: 1px solid var(--border); border-radius: var(--r-sm); font-size: 11px; font-weight: var(--fw-bold); line-height: 16px; color: var(--text-3); }
1934
1949
  /* The tile's control: its title, a plain-looking button (Enter opens the card, Alt+arrows move it). */
1935
- .tb-tile-title { display: block; width: 100%; padding: 0; background: none; border: 0; border-radius: var(--r-sm); color: var(--text); font: inherit; font-weight: var(--fw-medium); text-align: left; cursor: pointer; overflow-wrap: anywhere; }
1950
+ .tb-tile-title { display: block; width: 100%; padding: 0; background: none; border: 0; border-radius: var(--r-sm); color: var(--text); font: inherit; font-weight: var(--fw-semibold); line-height: 1.4; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
1936
1951
  .tb-tile-title:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
1937
1952
  .tb-tile:has(.tb-tile-title:focus-visible) { border-color: var(--focus); }
1938
1953
  .tb-tile-note { display: flex; gap: var(--s-1); align-items: flex-start; font-size: var(--fs-xs); color: var(--text-2); overflow-wrap: anywhere; }
@@ -1959,12 +1974,47 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1959
1974
  .tb-sub-blocked { padding-left: 22px; font-size: var(--fs-xs); color: var(--danger); overflow-wrap: anywhere; }
1960
1975
  .tb-sub-more { font-size: var(--fs-xs); padding-left: var(--s-1); }
1961
1976
  .tb-tile-foot { display: flex; align-items: center; gap: var(--s-2); }
1962
- .tb-tile-foot > .tb-who { flex: 1; min-width: 0; max-width: none; }
1977
+ .tb-tile-foot > .tb-who, .tb-tile-foot > .tb-person, .tb-tile-foot > .tb-people { flex: 1; min-width: 0; max-width: none; }
1978
+ .tb-people { display: flex; flex-wrap: wrap; gap: var(--s-1) var(--s-3); }
1979
+ /* Who (fleet-task-common.js): a round initial — the instance's identity colour when it has one — then the name. */
1980
+ .tb-person { display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
1981
+ .tb-person > .tb-who { min-width: 0; }
1982
+ .tb-person.none > .tb-who { color: var(--text-3); }
1983
+ .tb-av { display: inline-grid; place-items: center; width: 20px; height: 20px; flex: none; border-radius: 50%; font-size: 11px; font-weight: var(--fw-bold); line-height: 1;
1984
+ background: color-mix(in srgb, var(--id, var(--text-3)) 16%, var(--bg)); color: var(--id-name, var(--text-2)); }
1963
1985
  .tb-tile-stats { display: inline-flex; gap: var(--s-2); font-size: var(--fs-xs); color: var(--text-3); }
1964
1986
  .tb-tile-stats > span { display: inline-flex; align-items: center; gap: 2px; }
1965
1987
  .tb-tile-stats > .all { color: var(--ok); }
1966
1988
  .tb-tile-stats > .reply { color: var(--accent); }
1967
1989
 
1990
+ /* #2246 step 3: who works on what (fleet-task-work.js) — the board's Instance drawer and the Details page's block. */
1991
+ .tw { display: flex; flex-direction: column; gap: var(--s-4); min-width: 0; }
1992
+ .tw-head { margin: 0; font-size: var(--fs-sm); }
1993
+ .tw-error { margin: 0; color: var(--danger); font-size: var(--fs-sm); }
1994
+ .tw-group { display: flex; flex-direction: column; gap: var(--s-2); }
1995
+ .tw-group-head { display: flex; align-items: center; gap: var(--s-2); margin: 0; font-size: var(--fs-xs); font-weight: var(--fw-semibold); color: var(--text-2); }
1996
+ .tw-inst { display: flex; flex-direction: column; gap: var(--s-1); padding: var(--s-3); border: 1px solid var(--border); border-radius: var(--r-md); background: var(--bg); min-width: 0; }
1997
+ .tw-inst-head { display: flex; align-items: center; gap: var(--s-2); min-width: 0; }
1998
+ .tw-inst-head .av { width: 24px; height: 24px; font-size: 11px; }
1999
+ .tw-inst-head .av .av-dot { box-shadow: 0 0 0 2px var(--bg); }
2000
+ .tw-name { font-weight: var(--fw-semibold); font-size: var(--fs-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
2001
+ .tw-state { font-size: var(--fs-xs); white-space: nowrap; }
2002
+ .tw-alt { font-size: var(--fs-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
2003
+ .tw-desc { margin: 0; font-size: var(--fs-xs); overflow-wrap: anywhere; }
2004
+ .tw-none { margin: 0; font-size: var(--fs-xs); }
2005
+ .tw-lines { display: flex; flex-direction: column; gap: 2px; margin: 0; padding: 0; list-style: none; }
2006
+ .tw-line { display: flex; align-items: baseline; gap: var(--s-2); width: 100%; padding: 2px var(--s-1); background: none; border: 0; border-radius: var(--r-sm); color: var(--text); text-align: left; cursor: pointer; font-size: var(--fs-sm); min-width: 0; }
2007
+ .tw-line:hover { background: var(--surface-2); }
2008
+ .tw-line:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
2009
+ .tw-kind { flex-shrink: 0; min-width: 4.5em; font-size: 11px; font-weight: var(--fw-semibold); color: var(--text-2); }
2010
+ .tw-kind.k-doing { color: var(--accent); }
2011
+ .tw-kind.k-blocked, .tw-kind.k-back { color: var(--danger); }
2012
+ .tw-kind.k-review { color: var(--warn); }
2013
+ .tw-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2014
+ .tw-note { flex-shrink: 0; font-size: var(--fs-xs); white-space: nowrap; }
2015
+ .tw-unread { flex-shrink: 0; display: inline-flex; color: var(--accent); }
2016
+ .d-board .tw-lines { margin-top: var(--s-1); }
2017
+
1968
2018
  /* One card, in the app's dialog. */
1969
2019
  .tc { display: flex; flex-direction: column; gap: var(--s-3); min-width: 0; }
1970
2020
  .tc-head { display: flex; align-items: center; gap: var(--s-2); }
@@ -1974,7 +2024,7 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1974
2024
  .tc-ver { font-size: var(--fs-xs); }
1975
2025
  .tc-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-2); padding-bottom: var(--s-3); border-bottom: 1px solid var(--border); }
1976
2026
  .tc-hint { font-size: var(--fs-xs); }
1977
- .tc-meta { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: var(--s-2) var(--s-4); margin: 0; font-size: var(--fs-sm); align-items: center; }
2027
+ .tc-meta { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: var(--s-2) var(--s-4); margin: 0; padding: var(--s-3) var(--s-4); border-radius: var(--r-md); background: var(--surface); font-size: var(--fs-sm); align-items: center; }
1978
2028
  .tc-meta dt { color: var(--text-2); }
1979
2029
  .tc-meta dd { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-2); margin: 0; min-width: 0; }
1980
2030
  .tc-value { color: var(--text); overflow-wrap: anywhere; }
@@ -2012,6 +2062,8 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
2012
2062
  .tb-col { min-height: 0; }
2013
2063
  .tb-form-row { grid-template-columns: minmax(0, 1fr); }
2014
2064
  .tb-search { flex: 1 1 100%; }
2065
+ /* The card's sheet keeps its fields full width (the grey block is the desk drawer's). */
2066
+ .tc-meta { padding: 0; background: none; }
2015
2067
  /* #2299: a phone's waiting row: pill and title on one line, the actions under them (assignee left, buttons right). */
2016
2068
  .tb-wait-main > .tb-pill { min-width: 0; }
2017
2069
  .tb-wait-actions { flex-basis: 100%; margin-left: 0; }
@@ -2022,6 +2074,15 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
2022
2074
  /* …and they do not pile down the board: an older confirmation gives way to two newer toasts; an error never does. */
2023
2075
  .shell:has(.tb-home, .tb-board-page) .toasts > .toast.ok:nth-last-child(n+3) { display: none; }
2024
2076
  }
2077
+ /* On a desk a project's columns reach the bottom of the page, as taskhall's: a card drops anywhere down a column, and
2078
+ an empty column still reads as one. The page's column, the board and its scroller pass the height down as flex. */
2079
+ @media (min-width: 900px) {
2080
+ .col:has(> .tb > .tb-board-page) { display: flex; flex-direction: column; min-height: 100%; }
2081
+ .tb:has(> .tb-board-page) { flex: 1; }
2082
+ .tb-board-page { display: flex; flex-direction: column; flex: 1; }
2083
+ .tb-board-page > .tb-scroll { display: flex; flex-direction: column; flex: 1; }
2084
+ .tb-board-page .tb-columns { flex: 1; align-items: stretch; }
2085
+ }
2025
2086
  @media (prefers-reduced-motion: reduce) {
2026
2087
  .tb-tile.flash { animation: none; }
2027
2088
  .tb-chev { transition: none; }
@@ -8,8 +8,9 @@
8
8
  // #1665 first: the host's preferences go into this browser's cache before any module below reads its key.
9
9
  import { bootPrefs } from "./app-prefs.js";
10
10
  import { html, render } from "./app-html.js";
11
- import { Shell, handleKey, handleSearchKey, showDialog, closeDrawer, openDrawer, shellStore, retryable, setNewInstanceOpener } from "./app-shell.js";
11
+ import { Shell, handleKey, handleSearchKey, showDialog, closeDrawer, openDrawer, shellStore, retryable, setNewInstanceOpener, isMacLike } from "./app-shell.js";
12
12
  import { installSwipe } from "./app-swipe.js";
13
+ import { installTextUndo } from "./text-undo.js";
13
14
  import { startRouter, navStore, navigate } from "./app-nav.js";
14
15
  import { createStream } from "./app-stream.js";
15
16
  import { appStore, applyStatus, applyActivity, guestPage } from "./app-store.js";
@@ -125,6 +126,9 @@ if (!guest) installOwnerCss();
125
126
  document.addEventListener("keydown", handleKey);
126
127
  // #2293: Ctrl+K / ⌘K (and Ctrl+F in a chat) — an admin's full app only; the server refuses a guest's search anyway.
127
128
  if (mode === "full" && !guest) document.addEventListener("keydown", handleSearchKey);
129
+ // #2342: Ctrl/⌘+Z and Ctrl+Shift+Z / Ctrl+Y / ⌘⇧Z in every text field, one history per field (the browser's is one
130
+ // for the whole page, and any script write wipes it).
131
+ installTextUndo({ mac: isMacLike() });
128
132
  // #1605: on a phone (the sidebar is a drawer), swipe right in the main area to open it, left to close it — the same
129
133
  // open/close as ☰ and the scrim. Nothing that owns a sideways drag (code, tables, the terminal, inputs, dialogs) and
130
134
  // never the left edge (iOS: "back").
@@ -13,7 +13,7 @@ const episodes = new WeakSet();
13
13
 
14
14
  /** `current` belongs to the page/session episode. Route cleanup only releases its contribution. */
15
15
  export function createAppEffectRuntime({ window: host, current, observe, onChange = () => {}, onDispose = () => {}, resolveOwnerTarget = null, onOwnerUnavailable = () => {}, factories = {} }) {
16
- let dead = false, error = null, loader = null, registry = null, writer = null, source = null, personal = null, trial = null, pending = null, lastMetadata = null, serial = 0, revision = 0, stamp = null, syncing = false, queued = false;
16
+ let dead = false, claimed = false, error = null, loader = null, registry = null, writer = null, source = null, personal = null, trial = null, pending = null, lastMetadata = null, serial = 0, revision = 0, stamp = null, syncing = false, queued = false;
17
17
  const bindings = new Map(), profiles = new Map();
18
18
  let lastContext = null, playbackUnavailable = null;
19
19
  const identity = effectPlaybackIdentity(host), auth = host?.AgendAuth;
@@ -26,7 +26,7 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
26
26
  };
27
27
  const placementIdentity = p => JSON.stringify([p.by, p.manifest.layer, p.params]);
28
28
  const clock = () => host.performance.now();
29
- let offAuth = null;
29
+ let offAuth = null, disposed = null;
30
30
  const snapshot = s => {
31
31
  const effects = s?.metadata?.effects ?? [], known = registry?.snapshot?.() ?? [];
32
32
  const rows = effects.map(p => {
@@ -76,16 +76,18 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
76
76
  if (pending?.source === s) safe(() => pending.controller.abort());
77
77
  revision++; stamp = null;
78
78
  }
79
- function dispose() {
80
- if (dead) return; dead = true; discard(source);
79
+ function dispose(restartable = false) {
80
+ if (dead) return disposed; dead = true; discard(source);
81
81
  safe(() => trial?.dispose()); safe(() => pending?.controller.abort()); safe(() => registry?.dispose?.()); safe(() => loader?.dispose?.()); safe(() => personal?.dispose()); safe(() => writer?.dispose()); safe(() => offAuth?.());
82
82
  safe(() => host?.removeEventListener?.("pagehide", dispose));
83
83
  for (const row of profiles.values()) safe(() => row.collector.dispose()); profiles.clear(); bindings.clear(); safe(() => onDispose({ error }));
84
+ disposed = Promise.all([safe(() => loader?.whenDisposed?.()), safe(() => registry?.whenDisposed?.())]).then(() => { if (restartable === true && claimed) episodes.delete(host); });
85
+ return disposed;
84
86
  }
85
87
  function fail(reason) { if (dead) return; error = reason; dispose(); safe(() => onChange({ error, rows: [], effects: [], loading: false })); }
86
88
  const unavailable = { ok: false, sync() {}, changedChat() {}, reply() {}, detachChat() {}, attachChat() { return null; }, attachTrial() { return null; }, stopTrial() {}, trialSnapshot: () => ({ rows: [] }), snapshot: () => snapshot(null), dispose };
87
89
  if (!host || typeof host !== "object" || episodes.has(host) || !identity || typeof current !== "function" || typeof observe !== "function" || typeof auth?.onSessionEnd !== "function" || typeof auth?.sessionEnded !== "function") return unavailable;
88
- episodes.add(host); // Claim before invoking current/auth callbacks: one attempt per Window episode.
90
+ episodes.add(host); claimed = true; // Claim before invoking current/auth callbacks: one attempt per Window episode.
89
91
  if (!alive()) { dispose(); return unavailable; }
90
92
  try {
91
93
  offAuth = auth.onSessionEnd(dispose);
@@ -1,3 +1,4 @@
1
+ import { chatEffectIdentity } from "./effect-display-metadata.js";
1
2
  /** #1629: one committed local Chat lease joins the reviewed host-only effect primitives. */
2
3
  import { createEffectRuntimeLoader } from "./effect-runtime-loader.js";
3
4
  import { createEffectRegistry } from "./effect-registry.js";
@@ -43,13 +44,7 @@ export function createEffectWriter(host, current) {
43
44
  }
44
45
 
45
46
  /** Frozen, server-rendered session identity only. A header-token/public/View load has no identity. */
46
- export function chatEffectIdentity(host) {
47
- try {
48
- const b = host.document.body.dataset, x = JSON.parse(host.document.documentElement.getAttribute("data-prefs") || "null");
49
- if (b.mode !== "full" || b.tier === "guest" || b.publicLink === "1" || b.webTransport === "poll" || !["person", "operator"].includes(x?.ownerScope) || x.unreadable === true || typeof x.ownerEpoch !== "string" || !/^[a-f0-9]{16}$/.test(x.ownerEpoch)) return null;
50
- return Object.freeze({ epoch: x.ownerEpoch, scope: x.ownerScope });
51
- } catch { return null; }
52
- }
47
+ export { chatEffectIdentity } from "./effect-display-metadata.js";
53
48
  /** Chat-only public/guest capability stays separate from personal preferences and management. */
54
49
  export function effectPlaybackIdentity(host) {
55
50
  try {
@@ -25,9 +25,10 @@ export function createEffectCursor({ window: host, current, readImage, onChange
25
25
  const unavailable = { ok: false, update() {}, snapshot: () => ({ status: "unavailable", id: null }), dispose() {} };
26
26
  if (!host?.document || owners.has(host) || ![current, readImage, onChange, host.getComputedStyle, host.Blob, host.URL?.createObjectURL, host.URL?.revokeObjectURL, host.AbortController, host.setTimeout, host.clearTimeout, host.performance?.now].every(x => typeof x === "function")) return unavailable;
27
27
  const token = {}; owners.set(host, token);
28
+ let finishDisposed; const disposed = new Promise(resolve => { finishDisposed = resolve; });
28
29
  const doc = host.document; let dead = false, previous = null, wanted = null, pending = null, active = null, blocked = null, generation = 0, checking = false, notifying = false, status = "off";
29
30
  const safe = fn => { try { return fn(); } catch { return null; } };
30
- const release = () => { if (dead && !pending && owners.get(host) === token) owners.delete(host); };
31
+ const release = () => { if (dead && !pending) { if (owners.get(host) === token) owners.delete(host); finishDisposed(); } };
31
32
  function alive() {
32
33
  if (dead || checking) return false;
33
34
  checking = true; const value = safe(() => current() === true && host.document === doc); checking = false;
@@ -121,5 +122,5 @@ export function createEffectCursor({ window: host, current, readImage, onChange
121
122
  }
122
123
  start(); tell();
123
124
  }
124
- return { ok: true, update, snapshot, dispose };
125
+ return { ok: true, update, snapshot, dispose, whenDisposed: () => disposed };
125
126
  }
@@ -0,0 +1,39 @@
1
+ /** #1629: closed host display metadata. Neither this object nor its token grants a trial read. */
2
+ const KEYS = ["v", "offer", "id", "title", "expiresAt"], ID = /^[0-9a-f]{32}$/;
3
+ const FORBIDDEN = /(?!\u200d)[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
4
+ const VISIBLE = /[^\p{M}\p{C}\p{Z}\u115f\u1160\u3164\uffa0\u2800]/u;
5
+ let graphemes = null;
6
+ export function checkEffectTrialOffer(value) {
7
+ try {
8
+ if (!value || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) return null;
9
+ const keys = Reflect.ownKeys(value);
10
+ if (keys.length !== KEYS.length || keys.some(k => typeof k !== "string" || !KEYS.includes(k))) return null;
11
+ const x = Object.create(null);
12
+ for (const key of KEYS) {
13
+ const d = Object.getOwnPropertyDescriptor(value, key);
14
+ if (!d || !("value" in d) || !d.enumerable) return null;
15
+ x[key] = d.value;
16
+ }
17
+ if (x.v !== 1 || typeof x.offer !== "string" || !ID.test(x.offer) || typeof x.id !== "string" || !ID.test(x.id)
18
+ || typeof x.title !== "string" || x.title.length > 400 || x.title.normalize("NFC") !== x.title || x.title.trim() !== x.title
19
+ || FORBIDDEN.test(x.title) || !VISIBLE.test(x.title) || !Number.isSafeInteger(x.expiresAt) || x.expiresAt < 0) return null;
20
+ graphemes ??= new Intl.Segmenter(undefined, { granularity: "grapheme" });
21
+ let count = 0; for (const _part of graphemes.segment(x.title)) if (++count > 40) return null;
22
+ if (count < 1) return null;
23
+ return Object.freeze({ v: 1, offer: x.offer, id: x.id, title: x.title, expiresAt: x.expiresAt });
24
+ } catch { return null; }
25
+ }
26
+ /** Role and sender are host fields, never inferred from a message's text. Start must separately authorize. */
27
+ export function effectTrialMessage(message) {
28
+ if (!message || message.role !== "status" || message.sender !== "agend" || typeof message.instance !== "string"
29
+ || message.instance.length < 1 || message.instance.length > 128) return null;
30
+ return checkEffectTrialOffer(message.effectTrial);
31
+ }
32
+
33
+ export function chatEffectIdentity(host) {
34
+ try {
35
+ const b = host.document.body.dataset, x = JSON.parse(host.document.documentElement.getAttribute("data-prefs") || "null");
36
+ if (b.mode !== "full" || b.tier === "guest" || b.publicLink === "1" || b.webTransport === "poll" || !["person", "operator"].includes(x?.ownerScope) || x.unreadable === true || typeof x.ownerEpoch !== "string" || !/^[a-f0-9]{16}$/.test(x.ownerEpoch)) return null;
37
+ return Object.freeze({ epoch: x.ownerEpoch, scope: x.ownerScope });
38
+ } catch { return null; }
39
+ }
@@ -42,6 +42,7 @@ export function createEffectRegistry({ window: host, origin, boot, load, safety,
42
42
  try { if (!host?.document || !host.document.body || registries.has(host) || typeof load !== "function" || typeof stockSnapshot !== "function" || typeof onChange !== "function" || typeof onProfile !== "function" || typeof onMount !== "function" || typeof onRetire !== "function" || typeof clock !== "function" || !safety || ["allowed", "begin", "finish", "pause", "resume"].some(k => typeof safety[k] !== "function") || ["requestAnimationFrame", "cancelAnimationFrame", "setTimeout", "clearTimeout", "MutationObserver", "ResizeObserver", "AbortController"].some(k => typeof host[k] !== "function")) return { ok: false, reason: "registry_unsupported" }; } catch { return { ok: false, reason: "registry_unsupported" }; }
43
43
  let dead = false, blocked = null, revision = 0, last = null, lastView = null, frameSerial = 0, context = null, descriptors = [], raf = null, timer = null;
44
44
  const slots = new Map(), loads = new Set(), paused = new Map(), offs = [], observed = new Set(), failures = new Map(), results = new Map();
45
+ let finishDisposed; const disposed = new Promise(resolve => { finishDisposed = resolve; });
45
46
  let readingStock = false, stockFailure = "stock_evidence_invalid", warned = null;
46
47
  let root, geometry, observer, resize, longTasks = null, longTasksSupported = false;
47
48
  const time = () => {
@@ -342,7 +343,7 @@ export function createEffectRegistry({ window: host, origin, boot, load, safety,
342
343
  }).catch(() => { if (current(s)) retire(s, "resource_unavailable"); }).finally(() => {
343
344
  if (request.timer !== null) host.clearTimeout(request.timer);
344
345
  loads.delete(request); if (s.request === request) s.request = null;
345
- if (!dead) reconcile(); else if (loads.size === 0 && registries.get(host) === api) registries.delete(host);
346
+ if (!dead) reconcile(); else if (loads.size === 0) { if (registries.get(host) === api) registries.delete(host); finishDisposed(); }
346
347
  });
347
348
  }
348
349
  schedule(); tell();
@@ -530,11 +531,12 @@ export function createEffectRegistry({ window: host, origin, boot, load, safety,
530
531
  }
531
532
  return sent;
532
533
  },
534
+ whenDisposed: () => disposed,
533
535
  dispose() {
534
536
  if (dead) return; dead = true; revision++; stopAll("disposed");
535
537
  if (raf !== null) host.cancelAnimationFrame(raf); if (timer !== null) host.clearTimeout(timer);
536
538
  observer.disconnect(); resize.disconnect(); longTasks?.disconnect(); for (const off of offs) off();
537
- geometry.dispose(); root.remove(); observed.clear(); if (loads.size === 0 && registries.get(host) === api) registries.delete(host);
539
+ geometry.dispose(); root.remove(); observed.clear(); if (loads.size === 0) { if (registries.get(host) === api) registries.delete(host); finishDisposed(); }
538
540
  },
539
541
  };
540
542
  // Read-only host geometry seam; no selector guessing among poster and live roots.
@@ -143,7 +143,8 @@ export function createEffectRuntimeLoader({ window: host, ownerEpoch, current, o
143
143
  if (!host || typeof host !== "object" || loaders.has(host) || typeof host.fetch !== "function" || typeof host.AbortController !== "function" || typeof host.setTimeout !== "function" || typeof host.clearTimeout !== "function" || typeof ownerEpoch !== "string" || !EPOCH.test(ownerEpoch) || typeof current !== "function" || typeof onRevoke !== "function") return { ok: false };
144
144
  const reservation = {}; loaders.set(host, reservation);
145
145
  let dead = false, revoked = false, previous = null; const pending = new Set();
146
- const releaseWindow = () => { if (dead && pending.size === 0 && loaders.get(host) === reservation) loaders.delete(host); };
146
+ let finishDisposed; const disposed = new Promise(resolve => { finishDisposed = resolve; });
147
+ const releaseWindow = () => { if (dead && pending.size === 0) { if (loaders.get(host) === reservation) loaders.delete(host); finishDisposed(); } };
147
148
  // Terminal state is latched before callbacks. Abort releases logical callers, never physical reservations.
148
149
  function revoke(reason) {
149
150
  if (dead || revoked) return;
@@ -215,6 +216,7 @@ export function createEffectRuntimeLoader({ window: host, ownerEpoch, current, o
215
216
  }
216
217
  const ownerRead = (signal, settled = false) => run("placements", `/ui/effects/owner?ownerEpoch=${ownerEpoch}`, checkEffectOwnerSlots, signal, settled);
217
218
  return { ok: true,
219
+ whenDisposed: () => disposed,
218
220
  owner(signal) { return ownerRead(signal); },
219
221
  placements(instance, signal) { return placementRead(instance, signal); },
220
222
  resource(input, signal) { return resourceRead(input, signal); },
@@ -11,10 +11,10 @@ const SPECS = [
11
11
  ["confirmation", ".shell > .pending-stack, .shell .p-settings > .panel-body > .col > .pending-stack.inline"], ["preview", ".shell .chat-split > .pv-panel, .shell .chat-split > .pv-divider"],
12
12
  ["preview-menu", ".shell .thread details.pv-menu"], ["palette", ".shell .dock .cmd-palette"],
13
13
  ["drop", ".shell .p-chat > .drop-overlay"],
14
- ["thread-control", ".shell .chat-main > .amb-note, .shell .p-chat .jump-latest, .shell .thread .msg-tools, .shell .thread .st-more, .shell .thread .cb-head, .shell .thread .cb-more, .shell .thread .tc-head, .shell .thread .tc-more, .shell .thread .pv-head, .shell .thread .pv-note, .shell .thread .pv-banner, .shell .thread .effect-trial-offer, .shell .thread .click-chip"],
14
+ ["thread-control", ".shell .chat-main > .amb-note, .shell .p-chat .jump-latest, .shell .thread .msg-tools, .shell .thread .msg > div.msg-actions, .shell .thread .msg > button.msg-quote, .shell .thread .st-more, .shell .thread .cb-head, .shell .thread .cb-more, .shell .thread .tc-head, .shell .thread .tc-more, .shell .thread .pv-head, .shell .thread .pv-note, .shell .thread .pv-banner, .shell .thread .effect-trial-offer, .shell .thread .click-chip"],
15
15
  ["link", ".shell .thread .md a, .shell .thread .st-text > a.st-link, .shell .dock .prompts .txt > a.st-link"], ["attachment", ".shell .thread .atts"], ["media", ".shell .thread .att-media"],
16
16
  ["panel-navigation", ".shell .p-fleet > .seg, .shell .p-settings > .seg"],
17
- ["panel-content", ".shell .p-fleet > .panel-body, .shell .p-needs > .panel-body, .shell .p-settings > .panel-body, .shell .p-log > .panel-body, .shell .p-details > .panel-body, .shell .p-view > .panel-body, .shell .p-view > .v-term, .shell .p-view > .v-id, .shell .p-view > .v-card"],
17
+ ["panel-content", ".shell .p-fleet > .panel-body, .shell .p-needs > .panel-body, .shell .p-settings > .panel-body, .shell .p-log > .panel-body, .shell .p-details > .panel-body, .shell .p-view > .panel-body, .shell .p-view > .v-term, .shell .p-view > .v-id"],
18
18
  ["panel-controls", ".shell .p-log > .log-bar, .shell .p-settings > .s-pending"],
19
19
  ["session-ended", "body > .agend-session-ended[role=alert]"],
20
20
  ];
@@ -28,7 +28,7 @@ const PANELS = Object.freeze({
28
28
  settings: { required: [".panel-head", ".seg", ".panel-body"], optional: [".s-pending"] },
29
29
  log: { required: [".panel-head", ".panel-body"], optional: [".log-bar"] },
30
30
  details: { required: [".panel-head", ".panel-body"], optional: [] },
31
- view: { required: [".panel-head"], optional: [".panel-body", ".v-term", ".v-id", ".v-card"] },
31
+ view: { required: [".panel-head"], optional: [".panel-body", ".v-term", ".v-id"] },
32
32
  });
33
33
  function panelSurface(doc, chat) {
34
34
  if (chat) return { panel: chat, structural: STRUCTURAL, content: CONTENT, kind: "chat" };
@@ -37,8 +37,8 @@ function panelSurface(doc, chat) {
37
37
  const children = [...panel.children], count = selector => children.filter(el => el.matches(selector)).length;
38
38
  if (spec.required.some(selector => count(selector) !== 1) || spec.optional.some(selector => count(selector) > 1)
39
39
  || children.some(el => !el.matches([...spec.required, ...spec.optional, "dialog.dlg", "dialog.lb"].join(",")))) return null;
40
- if (kind === "view" && !(count(".panel-body") === 1 && !count(".v-term") && !count(".v-id") && !count(".v-card")
41
- || !count(".panel-body") && count(".v-term") === 1 && count(".v-card") === 1)) return null;
40
+ if (kind === "view" && !(count(".panel-body") === 1 && !count(".v-term") && !count(".v-id")
41
+ || !count(".panel-body") && count(".v-term") === 1 && count(".v-id") === 1)) return null;
42
42
  const prefix = `.p-${kind} > `;
43
43
  return { panel, kind, structural: [...STRUCTURAL.slice(0, 5), `.main > .p-${kind}`],
44
44
  content: [...CONTENT.filter(selector => !selector.startsWith(".p-chat") && !selector.startsWith(".chat-main")),
@@ -0,0 +1,127 @@
1
+ // Product feature policy only: this module imports no experimental runtime.
2
+ import { appStore } from "./app-store.js";
3
+ import { navStore } from "./app-nav.js";
4
+ import { ownerEffectsChosen, onOwnerEffectsChanged } from "./app-prefs.js";
5
+ import { register } from "./app-i18n.js";
6
+ import { html, useEffect, useState } from "./app-html.js";
7
+ register("cwControls", { en: { title: "Effects" }, "zh-TW": { title: "效果" } });
8
+ export function canvasWorkerEnabled() { return appStore.get().canvasWorker === true; }
9
+ export function onCanvasWorkerChange(fn) {
10
+ let value = canvasWorkerEnabled();
11
+ return appStore.subscribe(() => { const next = canvasWorkerEnabled(); if (next !== value) { value = next; fn(next); } });
12
+ }
13
+ export function useCanvasWorker() {
14
+ const [value, setValue] = useState(canvasWorkerEnabled);
15
+ useEffect(() => { setValue(canvasWorkerEnabled()); return onCanvasWorkerChange(setValue); }, []);
16
+ return value;
17
+ }
18
+ // Never evaluate the optional module before admission. An obsolete import may finish, but cannot mount.
19
+ export function experimentalComponent(load, select) {
20
+ return function ExperimentalComponent(props) {
21
+ const enabled = useCanvasWorker(), [component, setComponent] = useState(null);
22
+ useEffect(() => {
23
+ let current = true;
24
+ if (enabled) load().then(module => { if (current && canvasWorkerEnabled()) setComponent(() => select(module)); }, () => {});
25
+ return () => { current = false; };
26
+ }, [enabled]);
27
+ return enabled && component ? html`<${component} ...${props} />` : null;
28
+ };
29
+ }
30
+
31
+ /** effect_chat_v of a chat with no placements: sha256(JSON.stringify([])), first 32 hex (fleet-manager's status row). */
32
+ export const EMPTY_CHAT_EFFECTS = "4f53cda18c2baa0c0354bb5f9a3ecbe5";
33
+
34
+ let mod = null, waking = null, chatStore = null, started = false, generation = 0, awakeGeneration = -1, retiring = Promise.resolve();
35
+ const awakeListeners = new Set();
36
+
37
+ /** Whether `name`'s chat has Canvas Worker placements (as the status rows say). */
38
+ export function chatHasEffects(name, app = appStore.get()) {
39
+ const v = name ? app.instances?.find(i => i.name === name)?.effect_chat_v : undefined;
40
+ return canvasWorkerEnabled() && typeof v === "string" && /^[a-f0-9]{32}$/.test(v) && v !== EMPTY_CHAT_EFFECTS;
41
+ }
42
+
43
+ /** The running effects owner, or null while dormant (or when it could not start: as getAppEffects always was). */
44
+ export function getAppEffects(host) { return canvasWorkerEnabled() && mod ? mod.getAppEffects(host) : null; }
45
+
46
+ /** Whether the runtime has been asked to start on this page. */
47
+ export function effectsAwake() { return waking !== null; }
48
+ /** Whether its module has loaded (started, or found it could not: getAppEffects() then says which). */
49
+ export function effectsLoaded() { return mod !== null && awakeGeneration === generation; }
50
+
51
+ /** Follow every completed runtime episode, including a replacement after an experimental off/on. */
52
+ export function onEffectsAwake(fn) {
53
+ if (typeof fn !== "function") return () => {};
54
+ awakeListeners.add(fn);
55
+ if (effectsLoaded()) { try { fn(); } catch { /* a listener's error is its own */ } }
56
+ return () => awakeListeners.delete(fn);
57
+ }
58
+
59
+ /** Load lazily; each explicitly enabled episode owns its startup and cancellation. */
60
+ export function wakeEffects() {
61
+ if (!canvasWorkerEnabled()) return Promise.resolve(null);
62
+ if (waking) return waking;
63
+ const episode = generation;
64
+ waking = (mod ? Promise.resolve(mod) : import("/ui/js/app-effects.js")).then(async m => {
65
+ mod = m;
66
+ await retiring;
67
+ if (episode !== generation || !canvasWorkerEnabled()) return null;
68
+ const owner = chatStore ? m.startAppEffects({ store: chatStore }) : null;
69
+ awakeGeneration = episode;
70
+ for (const fn of [...awakeListeners]) { try { fn(); } catch { /* the rest still hear */ } }
71
+ return owner;
72
+ }, () => null);
73
+ return waking;
74
+ }
75
+
76
+ /** Wake when the open chat has placements or the person has a personal effect. */
77
+ function check() {
78
+ if (!canvasWorkerEnabled()) {
79
+ generation++;
80
+ const owner = mod?.getAppEffects();
81
+ if (owner) retiring = Promise.resolve(owner.dispose?.(true));
82
+ waking = null;
83
+ return;
84
+ }
85
+ if (waking) return;
86
+ const route = navStore.get().route;
87
+ if ((route?.panel === "chat" && chatHasEffects(route.instance)) || ownerEffectsChosen()) void wakeEffects();
88
+ }
89
+
90
+ /** Boot (once per page, with the Chat store the runtime binds to). Starts nothing but the watch. */
91
+ export function startEffectsWaker({ store }) {
92
+ chatStore = store;
93
+ if (started) return;
94
+ started = true;
95
+ appStore.subscribe(check); navStore.subscribe(check); onOwnerEffectsChanged(check);
96
+ check();
97
+ }
98
+
99
+ // Full Canvas Worker metadata stays in the experimental graph, never in ordinary chat boot.
100
+ let metadata = null, metadataPending = null;
101
+ const metadataListeners = new Set();
102
+ export function onCanvasMetadataChange(fn) { metadataListeners.add(fn); return () => metadataListeners.delete(fn); }
103
+ export function loadCanvasWorkerMetadata() {
104
+ if (!canvasWorkerEnabled()) return Promise.resolve(null);
105
+ if (metadata) return Promise.resolve(metadata);
106
+ if (metadataPending?.episode === generation) return metadataPending.promise;
107
+ const episode = generation, op = { episode, promise: null };
108
+ metadataPending = op;
109
+ op.promise = import("./effect-display-metadata.js").then(value => {
110
+ if (episode !== generation || !canvasWorkerEnabled()) return null;
111
+ metadata = value;
112
+ for (const fn of [...metadataListeners]) { try { fn(); } catch { /* independent viewers */ } }
113
+ appStore.set({ canvasMetadataEpoch: episode });
114
+ return metadata;
115
+ }, () => null).finally(() => { if (metadataPending === op) metadataPending = null; });
116
+ return op.promise;
117
+ }
118
+ export function readCanvasTrialMessage(message) {
119
+ if (!canvasWorkerEnabled()) return null;
120
+ if (!metadata) { void loadCanvasWorkerMetadata(); return undefined; }
121
+ return metadata.effectTrialMessage(message);
122
+ }
123
+ export function canvasWorkerMenuAvailable(host) {
124
+ if (!canvasWorkerEnabled()) return false;
125
+ if (!metadata) { void loadCanvasWorkerMetadata(); return false; }
126
+ return metadata.chatEffectIdentity(host) !== null;
127
+ }
@@ -196,6 +196,8 @@ function ColorDialog({ name, it, onClose }) {
196
196
  }, [name]);
197
197
  // The preview: the instance as it would be drawn with the choice here.
198
198
  const preview = identityOf({ ...(it || {}), color: { ...((it && it.color) || {}), person: color || undefined }, color_show: { ...((it && it.color_show) || {}), person: places || undefined } }, { off: false });
199
+ // #2342: a swatch (or "no pick") is a choice, not text: it starts the hex field's undo history afresh (undoing the
200
+ // text back to a typed colour would leave the box and the chosen colour apart — Save would send the other one).
199
201
  const pick = (c) => { setColor(c); setHex(c && HEX.test(c) ? c : ""); setError(""); };
200
202
  const togglePlace = (p) => {
201
203
  const base = places || preview.show;
@@ -211,7 +213,8 @@ function ColorDialog({ name, it, onClose }) {
211
213
  onClose();
212
214
  } catch (err) { setError(t("color.failed", (err && err.message) || "network")); setBusy(false); }
213
215
  }
214
- const onHex = (v) => { setHex(v); const c = /^#[0-9a-fA-F]{6}$/.test(v) ? v.toLowerCase() : null; if (c) { setColor(c); setError(""); } };
216
+ // The text as typed (#2342: trimming it in the box would rewrite it, losing its undo); spaces around it are ignored.
217
+ const onHex = (v) => { setHex(v); const w = v.trim(), c = /^#[0-9a-fA-F]{6}$/.test(w) ? w.toLowerCase() : null; if (c) { setColor(c); setError(""); } };
215
218
  const off = colorsOff();
216
219
  return html`<${Dialog} title=${t("color.title", name)} onClose=${onClose} busy=${busy}
217
220
  actions=${html`<button type="button" class="btn" disabled=${busy || !loaded} onClick=${() => save({ color: null, color_show: null })}>${t("color.reset")}</button>
@@ -226,8 +229,8 @@ function ColorDialog({ name, it, onClose }) {
226
229
  ref=${(el) => { if (el && el.style) { el.style.setProperty("--sw-light", p.light); el.style.setProperty("--sw-dark", p.dark); } }}
227
230
  onClick=${() => pick(`preset:${p.id}`)}></button>`)}
228
231
  <label class="idc-custom">${t("color.custom")}
229
- <input type="color" value=${HEX.test(hex) ? hex : "#808080"} onInput=${(e) => onHex(e.currentTarget.value)} aria-label=${t("color.custom")} />
230
- <input type="text" class="idc-hex" inputmode="text" spellcheck=${false} maxlength="7" placeholder="#rrggbb" value=${hex} onInput=${(e) => onHex(e.currentTarget.value.trim())} aria-label=${t("color.hex")} /></label>
232
+ <input type="color" value=${HEX.test(hex.trim()) ? hex.trim() : "#808080"} onInput=${(e) => onHex(e.currentTarget.value)} aria-label=${t("color.custom")} />
233
+ <input type="text" class="idc-hex" inputmode="text" spellcheck=${false} maxlength="7" placeholder="#rrggbb" value=${hex} onInput=${(e) => onHex(e.currentTarget.value)} aria-label=${t("color.hex")} /></label>
231
234
  <button type="button" class="btn btn-sm" disabled=${!color} onClick=${() => pick(null)}>${t("color.noPick")}</button>
232
235
  </fieldset>
233
236
  <fieldset class="idc-places"><legend>${t("color.places")}</legend>