@songsid/agend 2.3.0-beta.6 → 2.3.0-beta.7

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 (180) hide show
  1. package/dist/agent-cli-instructions.md +1 -1
  2. package/dist/agent-cli.js +21 -2
  3. package/dist/agent-cli.js.map +1 -1
  4. package/dist/agent-endpoint.js +4 -0
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/agent-file-read.js +68 -8
  7. package/dist/agent-file-read.js.map +1 -1
  8. package/dist/agent-whats-new.js +4 -0
  9. package/dist/agent-whats-new.js.map +1 -1
  10. package/dist/auth-api.js +3 -1
  11. package/dist/auth-api.js.map +1 -1
  12. package/dist/authority/authority-state.d.ts +38 -0
  13. package/dist/authority/authority-state.js +114 -0
  14. package/dist/authority/authority-state.js.map +1 -0
  15. package/dist/authority/session-authorizations.d.ts +43 -0
  16. package/dist/authority/session-authorizations.js +102 -0
  17. package/dist/authority/session-authorizations.js.map +1 -0
  18. package/dist/authority/web-authority.d.ts +31 -11
  19. package/dist/authority/web-authority.js +39 -12
  20. package/dist/authority/web-authority.js.map +1 -1
  21. package/dist/cache-ledger.d.ts +16 -3
  22. package/dist/cache-ledger.js +33 -13
  23. package/dist/cache-ledger.js.map +1 -1
  24. package/dist/cache-service.d.ts +55 -3
  25. package/dist/cache-service.js +152 -16
  26. package/dist/cache-service.js.map +1 -1
  27. package/dist/channel/mcp-tools.js +12 -6
  28. package/dist/channel/mcp-tools.js.map +1 -1
  29. package/dist/classic-channel-manager.d.ts +2 -2
  30. package/dist/classic-channel-manager.js +2 -6
  31. package/dist/classic-channel-manager.js.map +1 -1
  32. package/dist/config-validator.js +10 -4
  33. package/dist/config-validator.js.map +1 -1
  34. package/dist/daemon.js +2 -0
  35. package/dist/daemon.js.map +1 -1
  36. package/dist/delivery-outbox.d.ts +8 -0
  37. package/dist/delivery-outbox.js +7 -1
  38. package/dist/delivery-outbox.js.map +1 -1
  39. package/dist/fleet-manager.d.ts +109 -2
  40. package/dist/fleet-manager.js +280 -12
  41. package/dist/fleet-manager.js.map +1 -1
  42. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +2 -1
  43. package/dist/general-knowledge/skills/multi-channel/SKILL.md +2 -1
  44. package/dist/general-knowledge/skills/web-appearance/SKILL.md +81 -25
  45. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +55 -8
  46. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  47. package/dist/human-delivery-receipts.d.ts +14 -0
  48. package/dist/human-delivery-receipts.js +42 -6
  49. package/dist/human-delivery-receipts.js.map +1 -1
  50. package/dist/instance-names.d.ts +4 -0
  51. package/dist/instance-names.js +12 -0
  52. package/dist/instance-names.js.map +1 -0
  53. package/dist/instructions.d.ts +8 -1
  54. package/dist/instructions.js +9 -2
  55. package/dist/instructions.js.map +1 -1
  56. package/dist/locale.js +52 -0
  57. package/dist/locale.js.map +1 -1
  58. package/dist/outbound-handlers.d.ts +6 -0
  59. package/dist/outbound-handlers.js +45 -1
  60. package/dist/outbound-handlers.js.map +1 -1
  61. package/dist/outbound-schemas.d.ts +57 -1
  62. package/dist/outbound-schemas.js +33 -1
  63. package/dist/outbound-schemas.js.map +1 -1
  64. package/dist/public-web-gateway.js +8 -2
  65. package/dist/public-web-gateway.js.map +1 -1
  66. package/dist/rollout-index.d.ts +5 -0
  67. package/dist/rollout-index.js +26 -5
  68. package/dist/rollout-index.js.map +1 -1
  69. package/dist/settings-api.d.ts +6 -0
  70. package/dist/settings-api.js +50 -1
  71. package/dist/settings-api.js.map +1 -1
  72. package/dist/settings-change.js +27 -0
  73. package/dist/settings-change.js.map +1 -1
  74. package/dist/settings-effect.js +8 -1
  75. package/dist/settings-effect.js.map +1 -1
  76. package/dist/tool-permissions.d.ts +0 -7
  77. package/dist/tool-permissions.js +73 -5
  78. package/dist/tool-permissions.js.map +1 -1
  79. package/dist/topic-commands.d.ts +2 -1
  80. package/dist/topic-commands.js +2 -4
  81. package/dist/topic-commands.js.map +1 -1
  82. package/dist/types.d.ts +13 -2
  83. package/dist/ui/app-effects.js +43 -25
  84. package/dist/ui/chat-ambient.js +306 -0
  85. package/dist/ui/chat-effect-posters.js +6 -7
  86. package/dist/ui/chat-effects.js +38 -15
  87. package/dist/ui/chat-strings.js +17 -4
  88. package/dist/ui/panel-chat.js +15 -7
  89. package/dist/ui/panel-settings.js +4 -1
  90. package/dist/ui/settings-appearance.js +8 -0
  91. package/dist/ui/settings-mods.js +19 -6
  92. package/dist/ui/settings-owner-effects.js +3 -3
  93. package/dist/ui/settings-strings.js +28 -0
  94. package/dist/ui/settings-web-authority.js +91 -0
  95. package/dist/ui/shared/app-session.js +2 -0
  96. package/dist/ui/shared/app-shell.js +2 -1
  97. package/dist/ui/shared/app-themes.js +24 -6
  98. package/dist/ui/shared/app.css +28 -0
  99. package/dist/ui/shared/effect-app-runtime.js +67 -18
  100. package/dist/ui/shared/effect-chat-runtime.js +16 -3
  101. package/dist/ui/shared/effect-frame.js +17 -3
  102. package/dist/ui/shared/effect-policy.js +18 -4
  103. package/dist/ui/shared/effect-registry.js +11 -18
  104. package/dist/ui/shared/effect-stock-trust.js +2 -2
  105. package/dist/ui/shared/effect-trial-stage.js +18 -7
  106. package/dist/ui/shared/fx-motion.js +58 -0
  107. package/dist/ui/shared/web-authorize.js +151 -0
  108. package/dist/usage-report.d.ts +147 -0
  109. package/dist/usage-report.js +195 -0
  110. package/dist/usage-report.js.map +1 -0
  111. package/dist/usage-rollup.d.ts +51 -0
  112. package/dist/usage-rollup.js +168 -0
  113. package/dist/usage-rollup.js.map +1 -0
  114. package/dist/web-ambient.d.ts +72 -0
  115. package/dist/web-ambient.js +154 -0
  116. package/dist/web-ambient.js.map +1 -0
  117. package/dist/web-api.d.ts +32 -0
  118. package/dist/web-api.js +143 -18
  119. package/dist/web-api.js.map +1 -1
  120. package/dist/web-appearance-tools.d.ts +3 -0
  121. package/dist/web-appearance-tools.js +18 -3
  122. package/dist/web-appearance-tools.js.map +1 -1
  123. package/dist/web-appearance-view.d.ts +14 -2
  124. package/dist/web-appearance-view.js +32 -5
  125. package/dist/web-appearance-view.js.map +1 -1
  126. package/dist/web-appearance.d.ts +15 -1
  127. package/dist/web-appearance.js +77 -1
  128. package/dist/web-appearance.js.map +1 -1
  129. package/dist/web-asset-parts.d.ts +2 -2
  130. package/dist/web-asset-parts.js +3 -3
  131. package/dist/web-asset-parts.js.map +1 -1
  132. package/dist/web-auth.js +3 -1
  133. package/dist/web-auth.js.map +1 -1
  134. package/dist/web-effect-bootstrap.d.ts +12 -0
  135. package/dist/web-effect-bootstrap.js +49 -0
  136. package/dist/web-effect-bootstrap.js.map +1 -0
  137. package/dist/web-effect-bundle.js +46 -1
  138. package/dist/web-effect-bundle.js.map +1 -1
  139. package/dist/web-effect-playback.d.ts +30 -0
  140. package/dist/web-effect-playback.js +71 -0
  141. package/dist/web-effect-playback.js.map +1 -0
  142. package/dist/web-effect-profiles.d.ts +4 -4
  143. package/dist/web-effect-service.d.ts +11 -4
  144. package/dist/web-effect-service.js +25 -4
  145. package/dist/web-effect-service.js.map +1 -1
  146. package/dist/web-effect-tools.d.ts +1 -0
  147. package/dist/web-effect-tools.js +2 -1
  148. package/dist/web-effect-tools.js.map +1 -1
  149. package/dist/web-effect-worker.d.ts +1 -1
  150. package/dist/web-effect-worker.js +2 -2
  151. package/dist/web-effect-worker.js.map +1 -1
  152. package/dist/web-guest-access.js +3 -1
  153. package/dist/web-guest-access.js.map +1 -1
  154. package/dist/web-mod-agent.js +5 -1
  155. package/dist/web-mod-agent.js.map +1 -1
  156. package/dist/web-mod-bundle.d.ts +9 -1
  157. package/dist/web-mod-bundle.js +62 -3
  158. package/dist/web-mod-bundle.js.map +1 -1
  159. package/dist/web-mod-export.d.ts +9 -0
  160. package/dist/web-mod-export.js +21 -0
  161. package/dist/web-mod-export.js.map +1 -1
  162. package/dist/web-mod-store.d.ts +2 -1
  163. package/dist/web-mod-store.js +13 -6
  164. package/dist/web-mod-store.js.map +1 -1
  165. package/dist/web-session-authorization.d.ts +132 -0
  166. package/dist/web-session-authorization.js +193 -0
  167. package/dist/web-session-authorization.js.map +1 -0
  168. package/dist/web-session.d.ts +5 -0
  169. package/dist/web-session.js +16 -0
  170. package/dist/web-session.js.map +1 -1
  171. package/dist/web-settings-patch.d.ts +24 -2
  172. package/dist/web-settings-patch.js +47 -1
  173. package/dist/web-settings-patch.js.map +1 -1
  174. package/dist/web-settings-tools.d.ts +8 -0
  175. package/dist/web-settings-tools.js +14 -1
  176. package/dist/web-settings-tools.js.map +1 -1
  177. package/dist/web-theme-store.d.ts +39 -0
  178. package/dist/web-theme-store.js +122 -11
  179. package/dist/web-theme-store.js.map +1 -1
  180. package/package.json +1 -1
@@ -7,17 +7,20 @@ import { chatEffectState, chatEffectIdentity } from "/assets/effect-chat-runtime
7
7
  import { Dialog } from "/assets/ui-dialog.js";
8
8
  import { navigate } from "/assets/app-nav.js";
9
9
  import { toast } from "/assets/ui-toast.js";
10
+ import { setThemesShown, themesShown, themesStore } from "/assets/app-themes.js";
10
11
  import { getAppEffects } from "./app-effects.js";
11
12
 
12
13
  register("chatFx", {
13
- en: { title: "Effects", loading: "Checking effects…", empty: "No effects in this chat.", failed: "Effects stopped because their resources could not be verified. Reload the page to try again.", reload: "Reload page", pause: "Pause", resume: "Resume", remove: "Remove from chat", removeFailed: "Could not remove this effect. Reload the page to check its state.", css: "Agent foreground effects are paused while your custom CSS is on or cannot be verified.", setting: "Open Web appearance settings", running: "Running", starting: "Starting", off: "Stopped", resumeFailed: "This effect cannot resume: the page or its safety history is not ready. Reload the page or choose another effect.", queued: "Waiting for a slot", unknown: "Playback is off or unavailable here.", budget: "Paused to protect page responsiveness. Resume keeps the three-pauses safety limit.", paused: "Paused", low: "Reduced quality",
14
+ en: { transport: "Off: the isolated effect frame could not start. Reload to try again.", enableHere: "Turn effects on here", viewerShow: "Show themes, effects and widgets here", clock: "Off: the page timing clock is unavailable. Reload to try again.", gatesUnknown: "Off: the playback conditions could not be verified.", resource: "Off: this effect could not be loaded or verified. Reload to try again.", frontQueue: "Waiting for a foreground slot.", consent: "This page has older playback information. Reload to use the isolated effect player.", previewMissing: "Off: the isolated effect frame is unavailable. Reload to try again.", unsupported: "Off: this browser lacks required Canvas Worker or layout support.", calibration: "Measuring this page's frame rate before playback.", baseline: "Off: this page's frame-rate calibration failed. Reload to try again.", safetyUnknown: "Stopped: this device's safety history could not be verified. Reload to check it.", rawReason: "Playback blocked: {0}", missingRow: "Playback state is unavailable (runtime_unavailable).", region: "Off: this effect does not declare the chat region.", blocked: "Placed effects are blocked for this viewer:", deviceSetting: "Open device preferences", webSetting: "Open Web settings", reloadHint: "Reload page", consentHint: "Allow HTML previews in device preferences.",
15
+ title: "Effects", loading: "Checking effects…", empty: "No effects in this chat.", failed: "Effects stopped because their resources could not be verified. Reload the page to try again.", reload: "Reload page", pause: "Pause", resume: "Resume", remove: "Remove from chat", removeFailed: "Could not remove this effect. Reload the page to check its state.", css: "Agent foreground effects are paused while your custom CSS is on or cannot be verified.", setting: "Open Web appearance settings", running: "Running", starting: "Starting", off: "Stopped", resumeFailed: "This effect cannot resume: the page or its safety history is not ready. Reload the page or choose another effect.", queued: "Waiting for a slot", unknown: "Playback is off or unavailable here.", budget: "Paused to protect page responsiveness. Resume keeps the three-pauses safety limit.", paused: "Paused", low: "Reduced quality",
14
16
  typing: "Waiting while you type — it plays when you leave the message box.", covered: "Waiting while a menu or panel covers the chat.",
15
17
  dialogOpen: "Paused while this dialog is open.", wasRunning: "Was running; paused while this dialog is open.", modal: "Waiting while a dialog is open.",
16
18
  off3: "Turned off on this device after three automatic pauses. Resume cannot restart it here.", interrupted: "Stopped: the page closed while it was playing. Resume to play it again.",
17
19
  cssOff: "Off while your custom CSS is on.", reducedMotion: "Off: this device asks for reduced motion.", saveData: "Off: data saver is on.",
18
20
  previewOff: "Off: previews are not allowed on this device.", hiddenTab: "Waiting until this tab is visible.", offscreen: "Waiting until the chat is on screen.",
19
21
  safeMode: "Off in safe mode.", publicLink: "Off on a public link.", disabled: "Effects are switched off." },
20
- "zh-TW": { title: "效果", loading: "正在確認效果…", empty: "這個聊天沒有放置效果。", failed: "無法確認效果資源,已停止播放。重新載入頁面後再試。", reload: "重新載入頁面", pause: "暫停", resume: "恢復", remove: "從聊天移除", removeFailed: "無法移除效果。請重新載入頁面確認狀態。", css: "自訂 CSS 開啟或無法確認時,代理的前景效果會暫停。", setting: "開啟網頁外觀設定", running: "播放中", starting: "啟動中", off: "已停止", resumeFailed: "頁面或安全紀錄尚未就緒,無法恢復此效果。請重新載入頁面,或選擇其他效果。", queued: "等待空位", unknown: "這裡已關閉播放,或無法播放效果。", budget: "為維持頁面順暢,已暫停效果。恢復仍保留三次暫停的安全上限。", paused: "已暫停", low: "降低畫質",
22
+ "zh-TW": { transport: "已關閉:無法啟動隔離效果框架。請重新載入再試。", enableHere: "在這裡開啟效果", viewerShow: "在這裡顯示主題、效果與小工具", clock: "已關閉:頁面計時無法使用。請重新載入再試。", gatesUnknown: "已關閉:無法確認播放條件。", resource: "已關閉:無法載入或驗證此效果。請重新載入再試。", frontQueue: "等待前景空位。", consent: "這個頁面的播放資訊已過期。請重新載入以使用隔離效果播放器。", previewMissing: "已關閉:隔離效果框架無法使用。請重新載入再試。", unsupported: "已關閉:這個瀏覽器缺少 Canvas Worker 或版面驗證支援。", calibration: "正在量測頁面影格率,完成後才開始播放。", baseline: "已關閉:頁面影格率校準失敗。請重新載入再試。", safetyUnknown: "已停止:無法確認這台裝置的安全紀錄。請重新載入確認。", rawReason: "播放受阻:{0}", missingRow: "無法取得播放狀態(runtime_unavailable)。", region: "已關閉:此效果未宣告聊天區域。", blocked: "放置的效果目前無法在這個畫面播放:", deviceSetting: "開啟裝置偏好設定", webSetting: "開啟網頁設定", reloadHint: "重新載入頁面", consentHint: "請在裝置偏好設定允許 HTML 預覽。",
23
+ title: "效果", loading: "正在確認效果…", empty: "這個聊天沒有放置效果。", failed: "無法確認效果資源,已停止播放。重新載入頁面後再試。", reload: "重新載入頁面", pause: "暫停", resume: "恢復", remove: "從聊天移除", removeFailed: "無法移除效果。請重新載入頁面確認狀態。", css: "自訂 CSS 開啟或無法確認時,代理的前景效果會暫停。", setting: "開啟網頁外觀設定", running: "播放中", starting: "啟動中", off: "已停止", resumeFailed: "頁面或安全紀錄尚未就緒,無法恢復此效果。請重新載入頁面,或選擇其他效果。", queued: "等待空位", unknown: "這裡已關閉播放,或無法播放效果。", budget: "為維持頁面順暢,已暫停效果。恢復仍保留三次暫停的安全上限。", paused: "已暫停", low: "降低畫質",
21
24
  typing: "輸入時暫停,離開訊息輸入框就會播放。", covered: "選單或面板蓋住聊天時暫停。",
22
25
  dialogOpen: "開著這個對話框時暫停。", wasRunning: "原本在播放;開著這個對話框時暫停。", modal: "有對話框開著時暫停。",
23
26
  off3: "已自動暫停三次,這台裝置已關閉此效果,無法在這裡恢復。", interrupted: "播放時頁面被關閉而停止。按「恢復」再播放一次。",
@@ -26,36 +29,53 @@ register("chatFx", {
26
29
  safeMode: "安全模式下關閉。", publicLink: "公開連結上關閉。", disabled: "效果已關閉。" },
27
30
  });
28
31
 
29
- /** Local read/admin sessions keep controls; public, View, poll and header-only pages have no runtime identity. */
32
+ /** Read/admin dashboard sessions keep management controls; public/guest Chat playback has no owner identity. */
30
33
  export function chatEffectsMenuAvailable() { return chatEffectIdentity(window) !== null; }
31
34
 
32
35
  // #2055: what a row's state means, in words — never only "Stopped" when the runtime knows why.
33
36
  const RECENT_MS = 5000;
34
- const EDITABLE = "input, textarea, select, [contenteditable]:not([contenteditable=false])";
35
- const REASON_KEY = { owner_css: "chatFx.cssOff", reduced_motion: "chatFx.reducedMotion", save_data: "chatFx.saveData", preview_unavailable: "chatFx.previewOff",
37
+ const REASON_KEY = { __proto__: null, transport_unavailable: "chatFx.transport", owner_css: "chatFx.cssOff", reduced_motion: "chatFx.reducedMotion", save_data: "chatFx.saveData", preview_unavailable: "chatFx.previewMissing", preview_not_consented: "chatFx.consent", unsupported_host: "chatFx.unsupported", registry_unsupported: "chatFx.unsupported",
38
+ baseline_unsupported: "chatFx.baseline", baseline_unavailable: "chatFx.baseline", baseline_timeout: "chatFx.baseline", calibrating: "chatFx.calibration", safety_unknown: "chatFx.safetyUnknown", region_unavailable: "chatFx.region",
36
39
  hidden: "chatFx.hiddenTab", offscreen: "chatFx.offscreen", safe_mode: "chatFx.safeMode", public_link: "chatFx.publicLink", disabled: "chatFx.disabled",
40
+ clock_unavailable: "chatFx.clock", gates_unknown: "chatFx.gatesUnknown", runtime_unavailable: "chatFx.missingRow", resource_unavailable: "chatFx.resource", resource_timeout: "chatFx.resource", front_limit: "chatFx.frontQueue",
37
41
  safety_off: "chatFx.off3", interrupted: "chatFx.interrupted" };
38
42
  /** `ownOpen`: this Effects dialog is open; `wasRunning`: the effect was running when it opened (shown as past, never as current). */
39
43
  export function effectRowText(row, { ownOpen = false, wasRunning = false, doc = typeof document === "undefined" ? null : document } = {}) {
40
- if (!row) return t("chatFx.off");
44
+ if (!row) return t("chatFx.missingRow");
41
45
  // The sticky history first (it is what Resume answers), then a pause, then what is playing or queued.
42
46
  if (row.reason === "safety_off" || row.reason === "interrupted") return t(REASON_KEY[row.reason]);
43
47
  if (row.reason === "paused" || row.reason === "host_budget") return t("chatFx.paused");
48
+ if (REASON_KEY[row.reason]) return t(REASON_KEY[row.reason]);
49
+ if (row.reason != null && !["stock_overlay", "modal", "frame_limit"].includes(row.reason))
50
+ return t("chatFx.rawReason", typeof row.reason === "string" ? row.reason.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 80) : "unknown");
44
51
  if (row.phase === "running") return t("chatFx.running");
45
52
  if (row.phase) return t("chatFx.starting");
46
53
  if (row.verdict === "queued") return t("chatFx.queued");
47
54
  if (row.reason === "stock_overlay" || row.reason === "modal") {
48
55
  if (ownOpen) return t(wasRunning ? "chatFx.wasRunning" : "chatFx.dialogOpen");
49
56
  if (row.reason === "modal") return t("chatFx.modal");
50
- // Only a focused editable proves "typing"; a menu or another overlay is the same barrier, said generically.
51
- const a = doc?.activeElement;
52
- return t(a && a !== doc.body && typeof a.matches === "function" && (a.matches(EDITABLE) || a.isContentEditable === true) ? "chatFx.typing" : "chatFx.covered");
57
+ return t("chatFx.covered");
53
58
  }
54
- return REASON_KEY[row.reason] ? t(REASON_KEY[row.reason]) : t("chatFx.off");
59
+ return t("chatFx.rawReason", typeof row.reason === "string" ? row.reason.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 80) : "unknown");
60
+ }
61
+
62
+ /** Fixed local actions: no server/Worker-provided destination or raw reason in a URL. */
63
+ export function effectReasonAction(reason) {
64
+ if (reason === "preview_not_consented" || reason === "preview_unavailable") return { reload: true, label: "chatFx.reloadHint" };
65
+ if (["disabled", "owner_css"].includes(reason)) return { href: "/settings/appearance", label: "chatFx.setting" };
66
+ if (["transport_unavailable", "baseline_unsupported", "baseline_unavailable", "baseline_timeout", "runtime_unavailable", "resource_unavailable", "resource_timeout", "clock_unavailable", "safety_unknown"].includes(reason)) return { reload: true, label: "chatFx.reloadHint" };
67
+ return null;
68
+ }
69
+ const localViewer = () => document.body.dataset.publicLink === "1" || document.body.dataset.tier === "guest";
70
+ function ReasonAction({ reason, onClose = () => {} }) {
71
+ if (reason === "disabled" && localViewer()) return html`<button type="button" class="btn" onClick=${() => { onClose(); setThemesShown(true); }}>${t("chatFx.enableHere")}</button>`;
72
+ const action = document.body.dataset.tier === "guest" && reason === "owner_css" ? { reload: true, label: "chatFx.reloadHint" } : effectReasonAction(reason);
73
+ return action ? html`<button type="button" class="btn" onClick=${() => { onClose(); if (action.reload) location.reload(); else navigate(action.href); }}>${t(action.label)}</button>` : null;
55
74
  }
56
75
 
57
76
  export function ChatEffects({ name, lease, element, store, open, onClose }) {
58
77
  const cssLife = useStore(ownerCssLifecycle);
78
+ useStore(themesStore); // Viewer-local off/on is visible immediately, independent of runtime callbacks.
59
79
  // Which effects were running just before this dialog opened ("was running" is said in the past, never shown as
60
80
  // current). The ⋯ menu that opens it is a barrier too, so "just before" is within RECENT_MS of the observed stop.
61
81
  // Tracked where updates arrive (an unchanged snapshot is not republished while an effect keeps running).
@@ -77,7 +97,7 @@ export function ChatEffects({ name, lease, element, store, open, onClose }) {
77
97
  setBusy(false); onClose(); setCanWrite(false); setState(null);
78
98
  const host = window, owner = getAppEffects(host);
79
99
  const current = () => live && lease.current();
80
- if (!owner) { setState({ error: null, rows: [], effects: [], unavailable: true }); return cleanup; }
100
+ if (!owner) { setState({ error: null, rows: [], effects: [], unavailable: true, reason: typeof host.OffscreenCanvas !== "function" || typeof host.Worker !== "function" ? "unsupported_host" : "runtime_unavailable" }); return cleanup; }
81
101
  releases.push(owner.onEnd(() => { if (current()) setState({ error: "resource_unavailable", rows: [], effects: [], unavailable: true }); }));
82
102
  playing.current = new Map(); // evidence belongs to this name and lease only
83
103
  const track = rows => {
@@ -93,13 +113,13 @@ export function ChatEffects({ name, lease, element, store, open, onClose }) {
93
113
  if (!current()) return;
94
114
  track(value?.rows);
95
115
  const g = owner.gates();
96
- setState({ ...value, unavailable: !g.enabled || !g.show || !g.previewReady || !g.optIn || g.safe || g.publicLink || g.reducedMotion || g.saveData });
116
+ setState({ ...value, unavailable: !g.enabled || !g.show || !g.transportReady || g.safe || g.reducedMotion || g.saveData });
97
117
  };
98
118
  api = owner.attachChat({ name, element: element.current, current,
99
119
  observe: () => ({ state: chatEffectState(appStore.get(), name) }), onChange: changed });
100
120
  runtime.current = api;
101
- if (!api?.ok) { setState({ error: null, rows: [], effects: [], unavailable: true }); return cleanup; }
102
- host.AgendAuth?.canWrite?.().then(value => { if (current()) setCanWrite(value === true); }).catch(() => {});
121
+ if (!api?.ok) { setState({ error: null, rows: [], effects: [], unavailable: true, reason: "runtime_unavailable" }); return cleanup; }
122
+ if (chatEffectIdentity(host)) host.AgendAuth?.canWrite?.().then(value => { if (current()) setCanWrite(value === true); }).catch(() => {});
103
123
  api.sync();
104
124
  return cleanup;
105
125
  } catch {
@@ -117,11 +137,14 @@ export function ChatEffects({ name, lease, element, store, open, onClose }) {
117
137
  wasOpen.current = open;
118
138
  const css = cssLife.state !== "off" && effects.some(p => p.by !== "human" && p.manifest.layer === "front");
119
139
  const remove = async id => { if (busy) return; setBusy(true); const api = runtime.current; const ok = await api?.remove?.(id); if (!lease.current() || runtime.current !== api) return; setBusy(false); if (!ok) toast(t("chatFx.removeFailed"), false); };
120
- return html`${open ? html`<${Dialog} title=${t("chatFx.title")} onClose=${onClose} busy=${busy}>
140
+ const blocked = effects.flatMap(p => { const row = rows.find(r => r.id === p.id); return row?.phase === "running" && row.reason == null ? [] : [{ p, row }]; });
141
+ return html`${!open && localViewer() && effects.length ? html`<div class="chat-fx-notice"><label><input type="checkbox" checked=${themesShown()} onChange=${e => setThemesShown(e.currentTarget.checked)} /> ${t("chatFx.viewerShow")}</label></div>` : null}${!open && state?.error && !effects.length ? html`<div class="chat-fx-notice" role="status">${effectRowText({ reason: state.error })} <${ReasonAction} reason=${state.error} /></div>` : null}${!open && state?.reason ? html`<div class="chat-fx-notice" role="status">${effectRowText({ reason: state.reason })} <${ReasonAction} reason=${state.reason} /></div>` : null}${!open && blocked.length ? html`<div class="chat-fx-notice" role="status"><strong>${t("chatFx.blocked")}</strong>
142
+ ${blocked.map(({ p, row }) => html`<span key=${p.id}>${p.manifest.title}: ${effectRowText(row)} <${ReasonAction} reason=${row?.reason} /></span>`)}</div>` : null}${open ? html`<${Dialog} title=${t("chatFx.title")} onClose=${onClose} busy=${busy}>
121
143
  ${state?.error ? html`<p>${t("chatFx.failed")}</p><button type="button" class="btn" onClick=${() => location.reload()}>${t("chatFx.reload")}</button>` : null}
122
144
  ${css ? html`<p>${t("chatFx.css")}</p><button type="button" class="btn" onClick=${() => { onClose(); navigate("/settings/appearance"); }}>${t("chatFx.setting")}</button>` : null}
123
145
  ${!effects.length && !state?.error ? html`<p>${t(state?.unavailable ? "chatFx.unknown" : state?.loading || !state ? "chatFx.loading" : "chatFx.empty")}</p>` : null}
124
146
  ${effects.map(p => { const row = rows.find(r => r.id === p.id); return html`<section key=${p.id}><h3>${p.manifest.title}</h3><p>${effectRowText(row, { ownOpen: open, wasRunning: before.current?.get(p.id) === true })}${row?.quality === "low" ? ` · ${t("chatFx.low")}` : ""}</p>
147
+ <${ReasonAction} reason=${row?.reason} onClose=${onClose} />
125
148
  ${row?.reason === "host_budget" ? html`<p>${t("chatFx.budget")}</p>` : null}
126
149
  <button type="button" class="btn" disabled=${busy} onClick=${() => runtime.current?.pause?.(p.id)}>${t("chatFx.pause")}</button>
127
150
  <button type="button" class="btn" disabled=${busy} onClick=${() => { if (!runtime.current?.resume?.(p.id)) toast(t(rows.find(r => r.id === p.id)?.reason === "safety_off" ? "chatFx.off3" : "chatFx.resumeFailed"), false); }}>${t("chatFx.resume")}</button>
@@ -4,14 +4,14 @@ import { register } from "/assets/app-i18n.js";
4
4
  register("chat", {
5
5
  en: {
6
6
  effectTrialReason_checking: "Checking this session…",
7
- effectTrialReason_localAdmin: "Sign in as an admin on this computer to Try.",
7
+ effectTrialReason_localAdmin: "Sign in as an admin outside the public link to Try.",
8
8
  effectTrialReason_chatChanged: "Open the chat that offered this trial.",
9
9
  effectTrialReason_busy: "Stop the current trial before starting another.",
10
10
  effectTrialReason_safe: "Turn off safe mode to Try.",
11
11
  effectTrialReason_effectsOff: "Turn on effects to Try.",
12
12
  effectTrialReason_reducedMotion: "Your device requests reduced motion.",
13
13
  effectTrialReason_saveData: "Your device requests reduced data use.",
14
- effectTrialReason_transport: "The local effect service is unavailable.",
14
+ effectTrialReason_transport: "The isolated effect player is unavailable. Reload this page.",
15
15
  effectTrialReason_unsupported: "Canvas Worker playback is unavailable in this browser.",
16
16
  effectTrialReason_inactive: "Return to this page to Try.",
17
17
  effectTrialReason_dialog: "Close the other menu or dialog to Try.",
@@ -117,18 +117,25 @@ register("chat", {
117
117
  themeResetDone: "{0}'s theme was reset.", themeLocked: "{0}'s theme is locked: the agent can't change it.", themeUnlocked: "{0}'s theme is unlocked.",
118
118
  themeFailed: "The theme was not changed ({0}).",
119
119
  widgetRemoveHeader: "Remove the chat header widget", widgetRemoveDetails: "Remove the Details widget", widgetRemoved: "{0}'s widget was removed.",
120
+ // #2117: an agent's ambient effect — why it is not showing here, and a person's Clear.
121
+ ambientBlocked_hidden: "Ambient effect hidden: agents' themes are off — turn them on",
122
+ ambientBlocked_safe: "Ambient effect hidden: safe mode (?safe=1) — leave safe mode",
123
+ ambientBlocked_reducedMotion: "Ambient effect hidden: this device follows reduced motion — play effects anyway",
124
+ ambientBlocked_unsupported: "Ambient effect hidden: this browser can't animate it",
125
+ ambientClearLoop: "Clear the ambient effect", ambientClearClick: "Clear the click effect", ambientCleared: "{0}'s ambient effect was cleared.",
126
+ ambientMotionNote: "Your system asks for reduced motion; effects are on.", ambientMotionNoteLink: "Turn off here", ambientMotionNoteClose: "Close",
120
127
  configured: "(configured)", fleetDefault: "(fleet default)",
121
128
  },
122
129
  "zh-TW": {
123
130
  effectTrialReason_checking: "正在確認工作階段…",
124
- effectTrialReason_localAdmin: "請在這台電腦以管理員登入後試播。",
131
+ effectTrialReason_localAdmin: "請在公開連結以外的儀表板以管理員登入後試播。",
125
132
  effectTrialReason_chatChanged: "請開啟發出試播邀請的聊天。",
126
133
  effectTrialReason_busy: "請先停止目前的試播。",
127
134
  effectTrialReason_safe: "請先關閉安全模式。",
128
135
  effectTrialReason_effectsOff: "請先開啟效果。",
129
136
  effectTrialReason_reducedMotion: "裝置目前要求減少動態效果。",
130
137
  effectTrialReason_saveData: "裝置目前要求節省數據。",
131
- effectTrialReason_transport: "本機效果服務目前無法使用。",
138
+ effectTrialReason_transport: "隔離效果播放器目前無法使用。請重新載入頁面。",
132
139
  effectTrialReason_unsupported: "此瀏覽器無法播放 Canvas Worker 效果。",
133
140
  effectTrialReason_inactive: "請回到本頁後試播。",
134
141
  effectTrialReason_dialog: "請先關閉其他選單或對話框。",
@@ -231,6 +238,12 @@ register("chat", {
231
238
  themeResetDone: "已重設 {0} 的主題。", themeLocked: "已鎖定 {0} 的主題:agent 無法變更。", themeUnlocked: "已解除 {0} 的主題鎖定。",
232
239
  themeFailed: "主題沒有變更({0})。",
233
240
  widgetRemoveHeader: "移除聊天標頭的小工具", widgetRemoveDetails: "移除詳細資料頁的小工具", widgetRemoved: "已移除 {0} 的小工具。",
241
+ ambientBlocked_hidden: "環境效果未顯示:已關閉 agent 的主題——開啟",
242
+ ambientBlocked_safe: "環境效果未顯示:安全模式(?safe=1)——離開安全模式",
243
+ ambientBlocked_reducedMotion: "環境效果未顯示:這台裝置跟隨「減少動態效果」——仍然播放",
244
+ ambientBlocked_unsupported: "環境效果未顯示:此瀏覽器無法播放",
245
+ ambientClearLoop: "清除環境效果", ambientClearClick: "清除點擊效果", ambientCleared: "已清除 {0} 的環境效果。",
246
+ ambientMotionNote: "你的系統要求減少動態效果;效果目前開著。", ambientMotionNoteLink: "在這裡關閉", ambientMotionNoteClose: "關閉",
234
247
  configured: "(設定值)", fleetDefault: "(fleet 預設)",
235
248
  },
236
249
  });
@@ -9,6 +9,7 @@
9
9
  import { html, useEffect, useLayoutEffect, useRef, useState } from "/assets/app-html.js";
10
10
  import { t } from "/assets/app-i18n.js";
11
11
  import { appStore, guestPage, useStore } from "/assets/app-store.js";
12
+ import { AuthorizeChip } from "/assets/web-authorize.js";
12
13
  import { useLease } from "/assets/app-ctx.js";
13
14
  import { modelText, namesOf, namesStore } from "/assets/instance-nav.js";
14
15
  import { PanelHeader, onPanelKey, setTitle, statusClass, statusLabel, openDrawer, requestNewInstance } from "/assets/app-shell.js";
@@ -23,7 +24,7 @@ import { Icon } from "/assets/ui-icons.js";
23
24
  import { toast } from "/assets/ui-toast.js";
24
25
  import { confirmDialog } from "/assets/ui-confirm.js";
25
26
  import { savePref } from "/assets/app-prefs.js";
26
- import { applyTheme, resolvedMode, themeFor, themeImageUrl, themeMenuItems, themesStore } from "/assets/app-themes.js";
27
+ import { applyTheme, clearAmbient, resolvedMode, themeFor, themeImageUrl, themeMenuItems, themesStore } from "/assets/app-themes.js";
27
28
  import { colorMenuItem } from "/assets/identity-color.js";
28
29
  import { IdentityCard, useCard } from "/assets/identity-card.js";
29
30
  import { AgentWidget, instanceBinds, widgetFor } from "/assets/agent-widget.js";
@@ -56,6 +57,7 @@ import { installTour, refreshTourSpot } from "./chat-tour.js";
56
57
  import { LightboxHost, openLightbox } from "./image-lightbox.js";
57
58
  import { ChatEffectPosters } from "/ui/js/chat-effect-posters.js";
58
59
  import { ChatEffects, chatEffectsMenuAvailable } from "./chat-effects.js";
60
+ import { AmbientMotionNote, ChatAmbient, ambientMenuItems } from "./chat-ambient.js";
59
61
  import { startAppEffects, getAppEffects } from "./app-effects.js";
60
62
 
61
63
  const R = () => globalThis.AgendChatRender;
@@ -116,8 +118,8 @@ export function boot({ stream, boot: bootData, deps = {} }) {
116
118
  }
117
119
  // #1604: the preview opt-in and Tour live in Settings → This device now (and on each preview card's menu).
118
120
  installTour();
119
- // #1920 phase 3: effects are an admin's to place (a guest's page reads none: each read would be refused).
120
- if (!guestPage()) startAppEffects({ store });
121
+ // #2118: viewing current in-scope Chat effects needs no management or personal-slot authority.
122
+ startAppEffects({ store });
121
123
  return store;
122
124
  }
123
125
 
@@ -275,12 +277,14 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
275
277
  colorMenuItem(name, inst),
276
278
  // #1629: a person's control over this agent's theme — not over the public link (it only views themes).
277
279
  ...themeMenuItems(name),
280
+ // #2117: its ambient — why it is not showing here (with the way to change that), and a person's Clear.
281
+ ...ambientMenuItems(name, { publicLink: document.body.dataset.publicLink === "1", clear: clearAmbient }),
278
282
  chatEffectsMenuAvailable() ? { key: "effects", label: t("chatFx.title"), onSelect: () => setDialog("effects") } : null,
279
283
  { key: "delete", label: t("chat.delete"), icon: "trash", danger: true, onSelect: () => setDialog("delete") },
280
284
  ];
281
285
  const cls = statusClass(inst, exec, awaiting);
282
286
  // #1920 phase 3: a guest has this chat only — no Details, View or Log to switch to, no lifecycle, colour, theme,
283
- // effects or delete (a guest's status frame carries no model or effort either, so those chips do not show).
287
+ // effect management or delete (a guest's status frame carries no model or effort either, so those chips do not show).
284
288
  const guest = guestPage();
285
289
  // #1269 quick actions: the model and effort it runs, each a way to /model or /effort (the same pick list).
286
290
  const pick = (command) => store.runCommand(name, command, "", { alive: () => lease.current() });
@@ -288,14 +292,17 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
288
292
  ${inst.model ? html`<button type="button" class="hd-chip" title=${t("chat.chipModel", modelText(inst))} aria-label=${t("chat.chipModel", modelText(inst))} onClick=${() => pick("model")}>${modelText(inst)}</button>` : null}
289
293
  ${inst.effort ? html`<button type="button" class="hd-chip" title=${t("chat.chipEffort", inst.effort)} aria-label=${t("chat.chipEffort", inst.effort)} onClick=${() => pick("effort")}>${inst.effort}</button>` : null}`;
290
294
  return html`<div class=${`panel p-chat${wrap ? " wrap-code" : ""}`} ref=${view}>
291
- <${PanelHeader} title=${headerName(namesOf(inst || { name }, names.primary))} sub=${sub} nav=${guest ? null : html`<${InstanceSwitch} name=${name} current="chat" />`}><${ChatEffectPosters} name=${name} lease=${lease} element=${effectSurface} />${guest ? null : html`<${ChatEffects} name=${name} lease=${lease} element=${effectSurface} store=${store} open=${dialog === "effects"} onClose=${() => setDialog(null)} />`}<${HeaderTools} navKey=${navKey} />${guest ? null : html`<${Menu} items=${items} label=${t("app.more")} />`}</${PanelHeader}>
295
+ <${PanelHeader} title=${headerName(namesOf(inst || { name }, names.primary))} sub=${sub} nav=${guest ? null : html`<${InstanceSwitch} name=${name} current="chat" />`} notice=${html`<${ChatEffects} name=${name} lease=${lease} element=${effectSurface} store=${store} open=${dialog === "effects"} onClose=${() => setDialog(null)} />`}><${ChatEffectPosters} name=${name} lease=${lease} element=${effectSurface} /><${HeaderTools} navKey=${navKey} />${guest ? null : html`<${Menu} items=${items} label=${t("app.more")} />`}</${PanelHeader}>
292
296
  <${ChatCard} key=${name} name=${name} card=${card} />
293
297
  <${AgentWidget} slot="chat_header" widget=${widgetFor(name, "chat_header")} binds=${instanceBinds(inst, exec, awaiting)} />
294
298
  <div class="chat-split" ref=${split}>
295
299
  <div ref=${effectSurface} class=${`chat-main${theme && theme.background ? " has-th-bg" : ""}`}>
296
300
  ${theme && theme.background ? html`<img ref=${bg} class=${`th-bg th-bg-${theme.background.fit}`} src=${themeImageUrl(name, theme.background.image)} alt="" aria-hidden="true" decoding="async" />` : null}
301
+ <${ChatAmbient} name=${name} surface=${effectSurface} part="layer" />
302
+ <${AmbientMotionNote} name=${name} />
297
303
  <${Thread} name=${name} th=${thread} />
298
304
  <${Dock} name=${name} inst=${inst} lease=${lease} exec=${exec} awaiting=${awaiting} workingLabel=${theme ? theme.workingLabel : undefined} />
305
+ <${ChatAmbient} name=${name} surface=${effectSurface} part="front" />
299
306
  </div>
300
307
  <${PreviewPanel} name=${name} split=${split} msgs=${() => store.state.msgs[name] || []} subscribe=${(fn) => store.subscribe(fn)}
301
308
  keyOf=${msgKey} reveal=${(k) => thread.current && thread.current.reveal(k)} download=${downloadHtml} allow=${(commit) => setPreviewOptIn(true, commit)} />
@@ -319,8 +326,9 @@ function Thread({ name, th }) {
319
326
  effectTrialStatus: x => getAppEffects()?.trialStatus(x) ?? "localAdmin",
320
327
  fixEffectTrial: (x, reason) => {
321
328
  if (reason === "busy") getAppEffects()?.stopTrial();
329
+ else if (reason === "transport") location.reload();
322
330
  else if (reason === "localAdmin") location.href = "/signin?next=" + encodeURIComponent(location.pathname + location.search);
323
- else navigate("/ui/settings?section=appearance");
331
+ else navigate("/settings/appearance");
324
332
  },
325
333
  canTryEffect: x => getAppEffects()?.canTry(x) === true,
326
334
  startEffectTrial: x => { const seq = navStore.get().seq; void getAppEffects()?.startTrial(x).then(ok => { if (!ok && live && navStore.get().seq === seq && document.hasFocus?.() === true && document.visibilityState === "visible") toast(t("chat.effectTrialUnavailable")); }); },
@@ -384,7 +392,7 @@ function Dock({ name, inst, lease, exec, awaiting, workingLabel }) {
384
392
  <button type="button" class="btn btn-sm" onClick=${() => store.discardFailed(name)}>${t("chat.discard")}</button></div>` : null}
385
393
  <${Composer} name=${name} lease=${lease} busy=${R().isBusy(exec)} />
386
394
  <p class="hint" title=${t(guestPage() ? "chat.guestMore" : "chat.composerMore")}>${t("chat.composerHint")}</p>
387
- ${guestPage() ? html`<p class="guest-note">${t("chat.guestNote")}</p>` : null}
395
+ ${guestPage() ? html`<p class="guest-note">${t("chat.guestNote")}</p>` : html`<${AuthorizeChip} />`}
388
396
  </div></div>`;
389
397
  }
390
398
 
@@ -44,6 +44,7 @@ import { startTour } from "./chat-tour.js";
44
44
  import "./chat-strings.js";
45
45
  import { AppearanceSection } from "./settings-appearance.js";
46
46
  import { GuestsCard } from "./settings-guests.js";
47
+ import { WebAuthorityCard } from "./settings-web-authority.js";
47
48
 
48
49
  const tn = (k, ...v) => t(`settings.${k}`, ...v);
49
50
  const ICONS = { device: "user", appearance: "palette", agents: "bot", bots: "plug", classic: "room", general: "sliders", web: "globe", advanced: "code" };
@@ -772,6 +773,8 @@ export const WEB_READ_ONLY = [
772
773
  function WebSection({ ctx, staged }) {
773
774
  const { schema } = ctx;
774
775
  const web = ctx.fleet.web || {}, local = (web.session && web.session.local) || {}, term = ctx.fleet.web_terminal || {};
776
+ // Over the public link the fields read only; the Guests and Web authority cards are not offered (the server never
777
+ // serves their reads there).
775
778
  const overPublicLink = globalThis.document?.body?.dataset?.publicLink === "1";
776
779
  const original = useMemo(() => ({
777
780
  echo: web.echo_to_channel ?? true,
@@ -867,7 +870,7 @@ function WebSection({ ctx, staged }) {
867
870
  </div>
868
871
  </section>
869
872
  </fieldset>
870
- <${GuestsCard} ctx=${ctx} staged=${staged} disabled=${overPublicLink} />
873
+ ${overPublicLink ? null : html`<${GuestsCard} ctx=${ctx} staged=${staged} /><${WebAuthorityCard} ctx=${ctx} staged=${staged} />`}
871
874
  <section class="card s-web-readonly"><h3>${tn("webReadOnlyTitle")}</h3>
872
875
  <p class="note">${tn("webReadOnlyLead")}</p>
873
876
  <dl class="s-web-ro">${WEB_READ_ONLY.map((k) => html`<div key=${k}><dt><code>${k}</code></dt><dd>${shown(k)}</dd></div>`)}</dl>
@@ -10,6 +10,7 @@ import { Icon } from "/assets/ui-icons.js";
10
10
  import { toast } from "/assets/ui-toast.js";
11
11
  import { ownerCssOffHere, ownerCssTrial, safeMode, setOwnerCssActive, setOwnerCssOffHere, startOwnerCssTrial } from "/assets/owner-css.js";
12
12
  import { setThemesShown, themesStore } from "/assets/app-themes.js";
13
+ import { followsReducedMotion, onMotionChange, setFollowsReducedMotion } from "/assets/fx-motion.js";
13
14
  import { CARD_SIZES, cardSizeDefault, cardSizeStore, setCardSizeDefault } from "/assets/identity-card.js";
14
15
  import { AgentWidget, checkWidget, fleetBinds, setSidebarWidget, sidebarWidgetRaw, sidebarWidgetStore, widgetProblem } from "/assets/agent-widget.js";
15
16
  import { confirmedWrite } from "./settings-confirm.js";
@@ -87,6 +88,7 @@ function IdentityColorCard() {
87
88
  */
88
89
  export async function restoreAppearanceDefaults() {
89
90
  setThemesShown(true);
91
+ setFollowsReducedMotion(false);
90
92
  setCardSizeDefault("m");
91
93
  savePref("card.sizeByInstance", null);
92
94
  setSidebarWidget(null);
@@ -187,10 +189,16 @@ function CardLimitsCard() {
187
189
  /** #1629 r2 (d): whether this person's screens show agents' themes (and, as they come, effects) — synced. */
188
190
  function ThemesCard() {
189
191
  const s = useStore(themesStore);
192
+ // #2117: this device's own switch (not synced): effects play unless it follows the system's reduced-motion setting.
193
+ const [follow, setFollow] = useState(followsReducedMotion);
194
+ useEffect(() => onMotionChange(() => setFollow(followsReducedMotion())), []);
190
195
  return html`<section class="card s-appearance s-synced"><${CardTitle} title=${tn("themesTitle")} topic="themes" />
191
196
  <label class="check"><input type="checkbox" checked=${s.shown} disabled=${safeMode()} onChange=${(e) => setThemesShown(e.target.checked)} />
192
197
  <span>${tn("themesShow")}</span></label>
193
198
  <p class="sub">${safeMode() ? tn("themesSafeMode") : tn("themesShowLead")}</p>
199
+ <label class="check"><input type="checkbox" id="fx-follow-reduced-motion" checked=${follow} onChange=${(e) => setFollowsReducedMotion(e.target.checked)} />
200
+ <span>${tn("themesFollowMotion")}</span></label>
201
+ <p class="sub">${tn("themesFollowMotionLead")}</p>
194
202
  </section>`;
195
203
  }
196
204
 
@@ -41,9 +41,9 @@ function why(body) {
41
41
 
42
42
  /** The parts a plan offers, each with its default: everything but custom CSS. */
43
43
  export function defaultChoice(plan) {
44
- const c = { theme: false, identity: false, effects: false, ownerCss: false, widgets: {}, replace: false };
44
+ const c = { theme: false, identity: false, effects: false, ownerCss: false, ambient: false, widgets: {}, replace: false };
45
45
  for (const it of plan.items) {
46
- if (it.part === "theme" || it.part === "identity") c[it.part] = true;
46
+ if (it.part === "theme" || it.part === "identity" || it.part === "ambient") c[it.part] = true;
47
47
  else if (it.part === "widget") c.widgets[it.slot] = true;
48
48
  else if (it.part === "effect") c.effects = true;
49
49
  // ownerCss stays false: "read it before turning it on" (r4 §4).
@@ -100,6 +100,19 @@ function PlanItem({ it, choice, setChoice, binds, fleet }) {
100
100
  return html`<li class="s-mod-item">${tick(tn(`modPartWidget_${it.slot}`), !!choice.widgets[it.slot], (v) => setChoice({ ...choice, widgets: { ...choice.widgets, [it.slot]: v } }))}
101
101
  <div class="s-mod-detail s-mod-widget">${w ? html`<${AgentWidget} slot=${it.slot} widget=${w} binds=${it.slot === "sidebar" ? fleet : binds} />` : null}</div></li>`;
102
102
  }
103
+ if (it.part === "ambient") {
104
+ // #2117: each ambient as it will be drawn: its preset, glyphs (pictures shown) and colours; every field below.
105
+ const glyph = (g, i) => typeof g === "string" ? html`<span key=${i} class="s-mod-text">${g}</span>`
106
+ : html`<img key=${i} class="s-mod-glyph" src=${`/ui/mods/staged/${g.asset}`} alt="" loading="lazy" />`;
107
+ const one = (kind) => {
108
+ const a = it[kind];
109
+ if (!a || !Array.isArray(a.glyphs)) return null;
110
+ return html`<div key=${kind} class="s-mod-kv"><span class="k">${a.preset}</span><span class="v s-mod-sws">${a.glyphs.map(glyph)}${(a.colors || []).map((c) => html`<${Swatch} key=${c} color=${c} />`)}</span></div>`;
111
+ };
112
+ return html`<li class="s-mod-item">${tick(tn("modPartAmbient"), !!choice.ambient, (v) => setChoice({ ...choice, ambient: v }))}
113
+ <div class="s-mod-detail">${one("loop")}${one("click")}
114
+ <details class="s-mod-raw"><summary>${tn("modAllFields")}</summary><pre class="s-mod-css-text">${JSON.stringify({ loop: it.loop, click: it.click }, null, 2)}</pre></details></div></li>`;
115
+ }
103
116
  if (it.part === "effect") {
104
117
  return html`<li class="s-mod-item">${tick(tn("modPartEffect", it.title), choice.effects, (v) => setChoice({ ...choice, effects: v }))}
105
118
  <div class="s-mod-detail"><span>${kib(it.bytes)} · ${tn("modRegions", it.regions.join(", "))}</span>${it.wants.length ? html`<span>${tn("modWants", it.wants.join(", "))}</span>` : null}</div></li>`;
@@ -120,7 +133,7 @@ function Plan({ staged, onDone }) {
120
133
  const [target, setTarget] = useState("");
121
134
  const [busy, setBusy] = useState(false);
122
135
  const [error, setError] = useState("");
123
- const toInstance = choice.theme || choice.identity || !!choice.widgets.chat_header || !!choice.widgets.details;
136
+ const toInstance = choice.theme || choice.identity || choice.ambient || !!choice.widgets.chat_header || !!choice.widgets.details;
124
137
  const names = (app.instances || []).map((i) => i.name);
125
138
  const install = async () => {
126
139
  setBusy(true); setError("");
@@ -162,13 +175,13 @@ function Plan({ staged, onDone }) {
162
175
 
163
176
  function ExportForm() {
164
177
  const app = useStore(appStore);
165
- const [f, setF] = useState({ instance: "", name: "", version: "1.0.0", title: "", theme: true, identity: true, header: true, details: true, sidebar: false, ownerCss: false });
178
+ const [f, setF] = useState({ instance: "", name: "", version: "1.0.0", title: "", theme: true, identity: true, header: true, details: true, ambient: true, sidebar: false, ownerCss: false });
166
179
  const [error, setError] = useState("");
167
180
  const set = (k) => (e) => setF({ ...f, [k]: e.target.type === "checkbox" ? e.target.checked : e.target.value });
168
181
  const go = async () => {
169
182
  setError("");
170
183
  const body = { instance: f.instance || undefined, name: f.name, version: f.version, title: f.title,
171
- include: { theme: f.theme, identity: f.identity, ownerCss: f.ownerCss, widgets: { chat_header: f.header, details: f.details, sidebar: f.sidebar } } };
184
+ include: { theme: f.theme, identity: f.identity, ambient: f.ambient, ownerCss: f.ownerCss, widgets: { chat_header: f.header, details: f.details, sidebar: f.sidebar } } };
172
185
  let res;
173
186
  try { res = await fetch("/ui/mods/export", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); }
174
187
  catch { setError(tn("modFailed", "network")); return; }
@@ -182,7 +195,7 @@ function ExportForm() {
182
195
  return html`<details class="s-mod-export"><summary>${tn("modExport")}</summary>
183
196
  <p class="sub">${tn("modExportLead")}</p>
184
197
  <label class="field"><span>${tn("modExportInstance")}</span><select value=${f.instance} onChange=${set("instance")}><option value="">—</option>${(app.instances || []).map((i) => html`<option key=${i.name} value=${i.name}>${i.name}</option>`)}</select></label>
185
- <div class="s-mod-boxes">${box("theme", tn("modPartTheme"))}${box("identity", tn("modPartIdentity"))}${box("header", tn("modPartWidget_chat_header"))}${box("details", tn("modPartWidget_details"))}${box("sidebar", tn("modPartWidget_sidebar"))}${box("ownerCss", tn("modPartCss"))}</div>
198
+ <div class="s-mod-boxes">${box("theme", tn("modPartTheme"))}${box("identity", tn("modPartIdentity"))}${box("header", tn("modPartWidget_chat_header"))}${box("details", tn("modPartWidget_details"))}${box("ambient", tn("modPartAmbient"))}${box("sidebar", tn("modPartWidget_sidebar"))}${box("ownerCss", tn("modPartCss"))}</div>
186
199
  <label class="field"><span>${tn("modExportName")}</span><input value=${f.name} placeholder="my-look" onInput=${set("name")} /></label>
187
200
  <label class="field"><span>${tn("modExportVersion")}</span><input value=${f.version} onInput=${set("version")} /></label>
188
201
  <label class="field"><span>${tn("modExportTitle")}</span><input value=${f.title} onInput=${set("title")} /></label>
@@ -13,7 +13,7 @@ register("ownerEffects", {
13
13
  unknown: "Choices are unavailable. Reload to try again.", changed: "This page can no longer access these choices. Reload and sign in again.",
14
14
  unavailable: "No longer available", clear: "Clear", reload: "Reload choices", saved: "Saved.", failed: "Couldn't save. Reload choices and try again.",
15
15
  savedRefresh: "Saved, but the choices couldn't be refreshed. Reload to check.", readOnly: "Sign in as an admin to change these choices.",
16
- playback: "Plays once Effects and previews are allowed.",
16
+ playback: "Effects don't require HTML-preview permission.",
17
17
  pause: "Pause", resume: "Resume", running: "Running", cursorFailed: "The cursor could not be applied here. Clear or reload the choice to try again.", starting: "Starting", queued: "Waiting for a slot", paused: "Paused", off: "Playback is off or unavailable here.", budget: "Paused to keep the page responsive.", resumeFailed: "This effect cannot resume: the page or its safety history is not ready. Reload the page or choose another effect.",
18
18
  },
19
19
  "zh-TW": {
@@ -23,7 +23,7 @@ register("ownerEffects", {
23
23
  unknown: "目前無法取得選擇,請重新載入再試。", changed: "這個頁面已無法存取選擇,請重新載入並登入。",
24
24
  unavailable: "已無法使用", clear: "清除", reload: "重新載入選擇", saved: "已儲存。", failed: "無法儲存,請重新載入選擇再試。",
25
25
  savedRefresh: "已儲存,但無法更新畫面;請重新載入確認。", readOnly: "請以管理員身分登入才能修改選擇。",
26
- playback: "開啟效果並允許預覽後才會播放。",
26
+ playback: "效果不需要 HTML 預覽權限。",
27
27
  pause: "暫停", resume: "恢復", running: "播放中", cursorFailed: "這裡無法套用游標。可清除或重新載入選擇後再試。", starting: "啟動中", queued: "等待空位", paused: "已暫停", off: "這裡已關閉播放,或無法播放效果。", budget: "為維持頁面順暢,已暫停。", resumeFailed: "頁面或安全紀錄尚未就緒,無法恢復此效果。請重新載入頁面,或選擇其他效果。",
28
28
  },
29
29
  });
@@ -39,7 +39,7 @@ export function OwnerEffects({ catalog, busy = false }) {
39
39
  useEffect(() => { life.current.alive = true; const stop = watchOwnerEffects(); return () => { life.current.alive = false; life.current.seq++; stop(); }; }, []);
40
40
  useEffect(() => {
41
41
  let live = true; const entry = getAppEffects();
42
- const pull = () => { if (!live) return; const gates = entry?.gates(); setRuntime({ entry, snapshot: entry?.api?.ownerSnapshot?.(), cursor: entry?.cursorSnapshot?.(), available: !!entry && !entry.dead && gates.enabled && gates.show && gates.previewReady && gates.optIn && !gates.safe && !gates.publicLink && !gates.reducedMotion && !gates.saveData }); };
42
+ const pull = () => { if (!live) return; const gates = entry?.gates(); setRuntime({ entry, snapshot: entry?.api?.ownerSnapshot?.(), cursor: entry?.cursorSnapshot?.(), available: !!entry && !entry.dead && gates.enabled && gates.show && gates.transportReady && !gates.safe && !gates.publicLink && !gates.reducedMotion && !gates.saveData }); };
43
43
  const off = entry?.subscribe(pull), end = entry?.onEnd(pull); pull();
44
44
  return () => { live = false; off?.(); end?.(); };
45
45
  }, []);
@@ -316,6 +316,8 @@ register("settings", {
316
316
  themesTitle: "Agents' themes",
317
317
  themesShow: "Show agents' themes, effects and widgets",
318
318
  themesShowLead: "When off, every chat looks the same.",
319
+ themesFollowMotion: "Effects: follow the system's reduced-motion setting",
320
+ themesFollowMotionLead: "This device only: effects stay still while the system reduces motion.",
319
321
  themesSafeMode: "This page was opened with ?safe=1: agents' themes are not shown on it.",
320
322
  widgetTitle: "Your sidebar widget",
321
323
  widgetLead: "A small panel under your agent list, written as JSON.",
@@ -337,6 +339,7 @@ register("settings", {
337
339
  modReplace: "Replace the installed version {0}",
338
340
  modPartTheme: "Theme (colours, font, pictures)",
339
341
  modPartIdentity: "Card text (tagline and bio)",
342
+ modPartAmbient: "Ambient effect (falling, rising, twinkling or click glyphs)",
340
343
  modPartWidget_chat_header: "Chat header widget",
341
344
  modPartWidget_details: "Details page widget",
342
345
  modPartWidget_sidebar: "Your sidebar widget",
@@ -439,6 +442,17 @@ register("settings", {
439
442
  guestNarrow: "Narrow", guestNotReachable: "(no longer reachable)", guestReachesNothing: "This account reaches no agent from chat right now, so there is nothing to narrow.", guestNarrowLift: "Remove narrowing", guestNarrowHint: "Tick what this guest may still reach. Narrowing only takes away (it applies at once); widening or removing it waits for a fleet admin's confirmation.",
440
443
  guestSignOut: "Sign out", guestSignOutTitle: "Sign out this guest?", guestSignOutConfirm: "{0}'s guest sessions end now, their sign-in code stops working and their browsers are forgotten. They can sign in again with /web while guest sign-in is on.",
441
444
  guestSignedOut: "{0} is signed out.", guestSignedOutNotDurable: "{0} is signed out for now, but it could not be saved: a restart may bring their sessions back.", guestSignOutFailed: "That did not work — try again.",
445
+ vaTitle: "Web authority", vaReview: "Review web authority changes",
446
+ vaHint: "Whether a signed-in fleet admin's web messages carry their own account's authority, recorded in each message's delivery receipt. A session signed in with a code sent privately to that account, on the dashboard reached directly, is authorized once from Discord or Telegram; the public link and guests never are. Agents don't act on it yet: they still treat web messages as recognition only.",
447
+ vaLoadFailed: "Web authority could not be loaded.",
448
+ vaEnabled: "Web messages carry authority", vaEnabledHint: "On by default: a session carries authority only once its account confirms it from Discord or Telegram. Off: no web message carries authority, and any already given reads as none at once; authority given before it was turned off never comes back.",
449
+ vaStuckOff: "Web authority is off for now: its state could not be saved on this computer (see the fleet log). It comes back on by itself once it can be.",
450
+ vaLoopbackOnly: "Only this computer can authorize", vaLoopbackOnlyHint: "On: only a browser on this computer qualifies, not one on your network or tailnet, and authority already given from elsewhere reads as none. Turning it off again doesn't bring that back.",
451
+ vaLanWarning: "The dashboard also answers to {0} (web.allowed_hosts). Anyone who reaches it there with a stolen sign-in could give authority in your name. Turn on \"Only this computer can authorize\" unless you trust everyone who can reach it.",
452
+ vaHours: "An authorization lasts (hours, {0}–{1})", vaHoursRange: "Must be a number from 1 to 48",
453
+ vaSessionsTitle: "Authorized sessions", vaSessionsNone: "No session is authorized now.", vaSessionUntil: "Authorized {0} · until {1}",
454
+ vaRevoke: "Revoke web authority", vaRevokeTitle: "Revoke all web authority?", vaRevokeConfirm: "Every authority given from the web so far reads as none from now on, and every session's authorization ends. Nobody is signed out: a session carries authority again once it is authorized again.",
455
+ vaRevoked: "Web authority given so far is revoked.", vaRevokedNotDurable: "Revoked for now, but it could not be saved: web authority stays off until it can be, and a restart before then undoes the revocation.", vaRevokeFailed: "That did not work — try again.",
442
456
  webReadOnlyLead: "Read-only here: these decide what the dashboard listens on and exposes. Edit fleet.yaml, then run agend reload (web.preview_port and web.preview_origin take a restart of AgEnD).",
443
457
  notSet: "not set (default)",
444
458
  defaultsTitle: "Defaults",
@@ -823,6 +837,8 @@ register("settings", {
823
837
  themesTitle: "Agent 的主題",
824
838
  themesShow: "顯示 agent 的主題、效果與小工具",
825
839
  themesShowLead: "關閉時每個聊天看起來都一樣。",
840
+ themesFollowMotion: "效果:跟隨系統的「減少動態效果」設定",
841
+ themesFollowMotionLead: "只套用於這台裝置:系統減少動態效果時,效果保持靜止。",
826
842
  themesSafeMode: "這個頁面是用 ?safe=1 開啟的:不顯示 agent 的主題。",
827
843
  widgetTitle: "你的側邊欄小工具",
828
844
  widgetLead: "agent 清單下方的小面板,用 JSON 撰寫。",
@@ -844,6 +860,7 @@ register("settings", {
844
860
  modReplace: "取代已安裝的 {0} 版",
845
861
  modPartTheme: "主題(顏色、字型、圖片)",
846
862
  modPartIdentity: "身分卡文字(標語與簡介)",
863
+ modPartAmbient: "環境效果(飄落、上升、閃爍或點擊符號)",
847
864
  modPartWidget_chat_header: "聊天標頭小工具",
848
865
  modPartWidget_details: "詳細資料頁小工具",
849
866
  modPartWidget_sidebar: "你的側邊欄小工具",
@@ -946,6 +963,17 @@ register("settings", {
946
963
  guestNarrow: "縮小範圍", guestNotReachable: "(已無法使用)", guestReachesNothing: "這個帳號目前在聊天中沒有能用的 agent,沒有可以縮小的範圍。", guestNarrowLift: "取消縮小", guestNarrowHint: "勾選這位訪客仍可使用的 agent。縮小只會拿走權限(立即生效);放寬或取消縮小需 fleet admin 確認。",
947
964
  guestSignOut: "登出", guestSignOutTitle: "登出這位訪客?", guestSignOutConfirm: "{0} 的訪客 session 會立即結束,登入碼失效,記住的瀏覽器也會被忘記。訪客登入開啟期間,他可以再傳 /web 登入。",
948
965
  guestSignedOut: "已登出 {0}。", guestSignedOutNotDurable: "已暫時登出 {0},但無法保存:重新啟動後他的 session 可能會恢復。", guestSignOutFailed: "沒有成功,請再試一次。",
966
+ vaTitle: "網頁授權", vaReview: "檢視網頁授權變更",
967
+ vaHint: "已登入的 fleet 管理者從網頁送出的訊息,是否帶有他本人帳號的授權,記錄在每則訊息的送達收據中。用私下傳給該帳號的登入碼、透過直接連線的 dashboard 登入的 session,要在 Discord 或 Telegram 上授權一次;公開連結與訪客一律不行。目前 agent 還不會依它行動:仍只把網頁訊息當成辨識。",
968
+ vaLoadFailed: "無法載入網頁授權。",
969
+ vaEnabled: "網頁訊息帶有授權", vaEnabledHint: "預設開啟:session 要等它的帳號在 Discord 或 Telegram 上確認後才帶有授權。關閉:網頁訊息都不帶授權,已給出的授權立即視為無;關閉前給出的授權不會恢復。",
970
+ vaStuckOff: "網頁授權目前是關閉的:它的狀態無法存到這台電腦上(請看 fleet 紀錄)。可以存入後會自動恢復。",
971
+ vaLoopbackOnly: "只有這台電腦可以授權", vaLoopbackOnlyHint: "開啟:只有這台電腦上的瀏覽器符合,網路或 tailnet 上的不算,之前從其他地方給出的授權視為無。再關掉也不會讓那些授權恢復。",
972
+ vaLanWarning: "dashboard 也接受 {0} 的連線(web.allowed_hosts)。任何人只要從那裡連上、又拿到被盜的登入,就能以你的名義給出授權。除非你信任所有能連到那裡的人,否則請開啟「只有這台電腦可以授權」。",
973
+ vaHours: "一次授權的有效期(小時,{0}–{1})", vaHoursRange: "必須是 1 到 48 之間的數字",
974
+ vaSessionsTitle: "已授權的 session", vaSessionsNone: "目前沒有已授權的 session。", vaSessionUntil: "{0} 授權 · 到 {1}",
975
+ vaRevoke: "撤銷網頁授權", vaRevokeTitle: "撤銷所有網頁授權?", vaRevokeConfirm: "到目前為止從網頁給出的授權,從現在起都視為無,所有 session 的授權也會結束。不會登出任何人:session 再授權一次後,送出的訊息會再帶有授權。",
976
+ vaRevoked: "已撤銷到目前為止的網頁授權。", vaRevokedNotDurable: "已暫時撤銷,但無法保存:在可以保存之前網頁授權會保持關閉,而在那之前重新啟動會讓這次撤銷失效。", vaRevokeFailed: "沒有成功,請再試一次。",
949
977
  webReadOnlyLead: "這裡僅供檢視:這些設定決定儀表板監聽和公開的內容。請編輯 fleet.yaml 後執行 agend reload(web.preview_port 與 web.preview_origin 需要重新啟動 AgEnD)。",
950
978
  notSet: "未設定(預設)",
951
979
  defaultsTitle: "預設值",