@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
@@ -1,15 +1,15 @@
1
1
  /** #1629: one session-only app effect owner. Importing this module starts nothing. */
2
2
  import { appStore } from "/assets/app-store.js";
3
3
  import { navStore } from "/assets/app-nav.js";
4
- import { onPrefsChanged, prefInForce } from "/assets/app-prefs.js";
5
- import { resolvedMode, themesStore } from "/assets/app-themes.js";
4
+ import { onPrefsChanged } from "/assets/app-prefs.js";
5
+ import { resolvedMode, themesStore, themesShown } from "/assets/app-themes.js";
6
6
  import { ownerCssLifecycle, safeMode } from "/assets/owner-css.js";
7
7
  import { constrainedDevice } from "/assets/effect-policy.js";
8
8
  import { createAppEffectRuntime } from "/assets/effect-app-runtime.js";
9
- import { effectTransportReady } from "/assets/effect-frame.js";
9
+ import { effectBootstrapReady } from "/assets/effect-frame.js";
10
10
  import { createEffectCursor } from "/assets/effect-cursor.js";
11
11
  import { createOwnerEffectRegions } from "/assets/effect-owner-regions.js";
12
- import { chatEffectIdentity } from "/assets/effect-chat-runtime.js";
12
+ import { chatEffectIdentity, effectPlaybackIdentity } from "/assets/effect-chat-runtime.js";
13
13
  import { createEffectTrialController } from "./effect-trial-controller.js";
14
14
  import { effectTrialMessage } from "./effect-trial-offer.js";
15
15
  import { claimPanel, panelVersion, panelStore, openTrialPanel, closePanel } from "./preview-panel.js";
@@ -20,8 +20,9 @@ export function getAppEffects(host = window) { const value = owners.get(host); r
20
20
  export function startAppEffects({ store, window: host = window, factories = {} } = {}) {
21
21
  const existing = owners.get(host);
22
22
  if (existing) { if (!existing.dead) existing.bindStore(store); return getAppEffects(host); }
23
- if (!chatEffectIdentity(host) || typeof host.AgendAuth?.onSessionEnd !== "function" || typeof host.AgendAuth?.sessionEnded !== "function") return null;
24
- const doc = host.document, releases = [], ended = new Set(), updates = new Set(), trialUpdates = new Set(); let replyOff = null, replyStore = null, regions = null, cursor = null, cursorStamp = null, trialControl = null, trialIntent = null, trialHint = null, trialSerial = 0, trialStamp = null, chatBinding = null;
23
+ const playbackIdentity = effectPlaybackIdentity(host), ownerIdentity = chatEffectIdentity(host);
24
+ if (!playbackIdentity || typeof host.AgendAuth?.onSessionEnd !== "function" || typeof host.AgendAuth?.sessionEnded !== "function") return null;
25
+ const doc = host.document, releases = [], ended = new Set(), updates = new Set(), trialUpdates = new Set(); let replyOff = null, replyStore = null, regions = null, cursor = null, cursorStamp = null, trialControl = null, trialIntent = null, trialHint = ownerIdentity ? null : false, trialSerial = 0, trialStamp = null, chatBinding = null;
25
26
  const safe = fn => { try { return fn(); } catch { return null; } };
26
27
  const hold = off => { if (entry.dead) safe(() => off?.()); else releases.push(off); };
27
28
  const entry = { dead: false, api: null,
@@ -76,14 +77,33 @@ export function startAppEffects({ store, window: host = window, factories = {} }
76
77
  },
77
78
  };
78
79
  owners.set(host, entry); // Reentrant setup never constructs another owner.
79
- let media, coarse, preview;
80
+ let media, coarse;
81
+ function ownerCssForPlayback() {
82
+ if (doc.body.dataset.tier !== "guest") return ownerCssLifecycle.get().state;
83
+ // Guests never install owner CSS. Prove its absence rather than borrowing the uninstalled
84
+ // lifecycle's initial unknown, and refuse unexpected styles instead of trusting a missing id.
85
+ try {
86
+ if (doc.body.dataset.ownerCss || doc.querySelectorAll("#owner-css").length) return "unknown";
87
+ // A CSP-refused or disabled STYLE has no paint. Recheck on every proof so enabling
88
+ // it never borrows an earlier absence verdict (WebKit capture inserts a refused no-op).
89
+ for (const style of doc.querySelectorAll("style")) if (style.sheet && style.sheet.disabled !== true) return "unknown";
90
+ const allowed = new Set(["/assets/tokens.css", "/assets/app.css"]);
91
+ for (const link of doc.querySelectorAll('link[rel~="stylesheet"]')) {
92
+ const url = new URL(link.getAttribute("href"), host.location.origin);
93
+ if (url.origin !== host.location.origin || !allowed.has(url.pathname) || url.search || url.hash) return "unknown";
94
+ }
95
+ return "off";
96
+ } catch { return "unknown"; }
97
+ }
98
+ function guestChatVersion(value) {
99
+ if (doc.body.dataset.tier !== "guest") return null;
100
+ const name = navStore.get().route?.instance, v = value.instances?.find(i => i.name === name)?.effect_chat_v;
101
+ return typeof v === "string" && /^[a-f0-9]{32}$/.test(v) ? v : null;
102
+ }
80
103
  function context() {
81
- let ready = false; try { ready = preview?.availability().ok === true; } catch { /* unavailable */ }
82
104
  return { safe: safeMode(), publicLink: doc.body.dataset.publicLink === "1", reducedMotion: media?.matches !== false, saveData: host.navigator?.connection?.saveData === true,
83
- hidden: doc.visibilityState !== "visible", enabled: typeof host.OffscreenCanvas === "function" && typeof host.Worker === "function" && typeof host.MutationObserver === "function" && typeof host.ResizeObserver === "function",
84
- show: prefInForce("fx.show")?.value !== false, previewReady: ready,
85
- trialReady: effectTransportReady(host, doc.body.dataset.previewOrigin, doc.body.dataset.previewBoot),
86
- optIn: prefInForce("preview.optIn")?.value === true && preview?.optedIn() === true && preview?.never() !== true,
105
+ hidden: doc.visibilityState !== "visible", enabled: entry.api?.playbackAvailable !== false && typeof host.OffscreenCanvas === "function" && typeof host.Worker === "function" && typeof host.MutationObserver === "function" && typeof host.ResizeObserver === "function",
106
+ show: themesShown(), transportReady: effectBootstrapReady(host, host.location?.origin, doc.body.dataset.effectBoot),
87
107
  constrained: constrainedDevice({ memory: host.navigator?.deviceMemory, cores: host.navigator?.hardwareConcurrency, coarse: coarse?.matches === true, width: host.innerWidth }),
88
108
  modal: !!doc.querySelector("dialog[open],.menu-list,.pop,[role=alertdialog],.oc-keep,.shell.sb-open"), openInstance: null };
89
109
  }
@@ -100,7 +120,7 @@ export function startAppEffects({ store, window: host = window, factories = {} }
100
120
  if (c.reducedMotion) return "reducedMotion";
101
121
  if (c.saveData) return "saveData";
102
122
  if (!c.enabled) return "unsupported";
103
- if (!c.trialReady) return "transport";
123
+ if (!c.transportReady) return "transport";
104
124
  if (c.publicLink) return "localAdmin";
105
125
  if (c.hidden || doc.hasFocus?.() !== true) return "inactive";
106
126
  if (c.modal) return "dialog";
@@ -109,7 +129,7 @@ export function startAppEffects({ store, window: host = window, factories = {} }
109
129
  function trialAllowed() {
110
130
  const c = context();
111
131
  return !entry.dead && doc.visibilityState === "visible" && doc.hasFocus?.() === true
112
- && c.enabled && c.show && c.trialReady && !c.safe && !c.publicLink && !c.reducedMotion && !c.saveData && !c.hidden && !c.modal;
132
+ && c.enabled && c.show && c.transportReady && !c.safe && !c.publicLink && !c.reducedMotion && !c.saveData && !c.hidden && !c.modal;
113
133
  }
114
134
  function mountTrial(handle) {
115
135
  const intent = trialIntent; if (!intent || !trialCurrent(handle.instance) || !handle.current() || !Number.isSafeInteger(++trialSerial)) throw Error("trial_unavailable");
@@ -144,47 +164,45 @@ export function startAppEffects({ store, window: host = window, factories = {} }
144
164
  function syncCursor() {
145
165
  if (entry.dead || !cursor) return;
146
166
  const c = context();
147
- cursor.update(entry.api?.cursorSelection?.() ?? null, c.enabled && c.show && c.previewReady && c.optIn && !c.safe && !c.publicLink && !c.reducedMotion && !c.saveData && !c.hidden && !c.modal && !cursorDragging());
167
+ cursor.update(entry.api?.cursorSelection?.() ?? null, c.enabled && c.show && c.transportReady && !c.safe && !c.publicLink && !c.reducedMotion && !c.saveData && !c.hidden && !c.modal && !cursorDragging());
148
168
  }
149
169
  try {
150
170
  if (appStore.get().connection === "ended") { entry.dispose(); return null; }
151
- preview = host.AgendPreview;
152
171
  media = host.matchMedia?.("(prefers-reduced-motion: reduce)") ?? { matches: true };
153
172
  coarse = host.matchMedia?.("(pointer: coarse)") ?? { matches: false };
154
173
  entry.api = (factories.runtime ?? createAppEffectRuntime)({ window: host, current: () => !entry.dead && host.document === doc,
155
- observe: () => ({ context: context(), ownerCss: ownerCssLifecycle.get().state, mode: resolvedMode() }), onDispose: () => entry.dispose(),
174
+ observe: () => ({ context: context(), ownerCss: ownerCssForPlayback(), mode: resolvedMode() }), onDispose: () => entry.dispose(),
156
175
  resolveOwnerTarget: (slot, manifest, proof) => !entry.dead ? regions?.resolve?.(slot, manifest.box, proof) ?? null : null,
157
176
  onChange: () => { syncCursor(); notify(); } });
158
177
  if (entry.dead || !entry.api.ok) { entry.dispose(); return null; }
159
- trialControl = (factories.trialController ?? createEffectTrialController)({ window: host, current: trialCurrent, allowed: trialAllowed, onStart: mountTrial, onChange: notify });
178
+ if (ownerIdentity) trialControl = (factories.trialController ?? createEffectTrialController)({ window: host, current: trialCurrent, allowed: trialAllowed, onStart: mountTrial, onChange: notify });
160
179
  hold(() => trialControl?.dispose());
161
- if (typeof host.AgendAuth.trialSession === "function") Promise.resolve().then(() => host.AgendAuth.trialSession()).then(value => {
180
+ if (ownerIdentity && typeof host.AgendAuth.trialSession === "function") Promise.resolve().then(() => host.AgendAuth.trialSession()).then(value => {
162
181
  if (entry.dead) return; trialHint = value?.localAdmin === true && value.epoch === chatEffectIdentity(host)?.epoch; notify();
163
182
  }).catch(() => { if (!entry.dead) { trialHint = false; notify(); } });
164
183
  hold(panelStore.subscribe(() => { trialControl?.sync(); notifyTrial(); }));
165
- const ownRegions = (factories.regions ?? createOwnerEffectRegions)({ window: host, current: () => !entry.dead && host.document === doc });
184
+ const ownRegions = ownerIdentity && (factories.regions ?? createOwnerEffectRegions)({ window: host, current: () => !entry.dead && host.document === doc });
166
185
  regions = ownRegions?.ok ? ownRegions : null; hold(() => ownRegions?.dispose?.());
167
186
  if (entry.dead) return null;
168
- const ownCursor = safe(() => (factories.cursor ?? createEffectCursor)({ window: host, current: () => !entry.dead && host.document === doc,
187
+ const ownCursor = ownerIdentity && safe(() => (factories.cursor ?? createEffectCursor)({ window: host, current: () => !entry.dead && host.document === doc,
169
188
  readImage: (selection, signal) => entry.api?.cursorImageSettled?.(selection, signal) ?? Promise.resolve(null),
170
189
  onChange(value) { const next = JSON.stringify(value); if (next !== cursorStamp) { cursorStamp = next; notify(); } },
171
190
  }));
172
191
  cursor = ownCursor?.ok ? ownCursor : null; hold(() => ownCursor?.dispose?.());
173
192
  if (entry.dead) return null;
174
193
  const sync = () => { if (!entry.dead) { trialControl?.sync(); entry.api.sync(); syncCursor(); notifyTrial(); } };
175
- let revision = appStore.get().themesRev, prefsRevision = appStore.get().prefsRev, navSeq = navStore.get().seq;
194
+ let revision = appStore.get().themesRev, prefsRevision = appStore.get().prefsRev, guestRevision = guestChatVersion(appStore.get()), navSeq = navStore.get().seq;
176
195
  hold(navStore.subscribe(value => { if (entry.dead || value.seq === navSeq) return; navSeq = value.seq; trialControl?.stop(); chatBinding = null; entry.api.detachChat(); syncCursor(); }));
177
196
  hold(appStore.subscribe(value => {
178
197
  if (entry.dead) return;
179
198
  if (value.connection === "ended") { entry.dispose(); return; }
180
- const chatChanged = value.themesRev !== revision, ownerChanged = value.prefsRev !== prefsRevision;
181
- revision = value.themesRev; prefsRevision = value.prefsRev;
199
+ const guestNext = guestChatVersion(value), chatChanged = value.themesRev !== revision || guestNext !== guestRevision, ownerChanged = value.prefsRev !== prefsRevision;
200
+ revision = value.themesRev; prefsRevision = value.prefsRev; guestRevision = guestNext;
182
201
  if (chatChanged) entry.api.changedChat();
183
202
  if (ownerChanged) entry.api.changedOwner();
184
203
  if (!chatChanged && !ownerChanged) sync();
185
204
  }));
186
205
  hold(ownerCssLifecycle.subscribe(sync)); hold(themesStore.subscribe(sync)); hold(onPrefsChanged(sync));
187
- if (typeof preview?.onChange === "function") hold(preview.onChange(sync));
188
206
  if (typeof host.AgendTheme?.onChange === "function") hold(host.AgendTheme.onChange(sync));
189
207
  for (const [target, type] of [[media, "change"], [coarse, "change"], [doc, "visibilitychange"], [host, "resize"], [host.navigator?.connection, "change"]]) {
190
208
  if (target?.addEventListener) { target.addEventListener(type, sync); hold(() => target.removeEventListener(type, sync)); }
@@ -0,0 +1,306 @@
1
+ // #2117: an agent's ambient effect on its own chat — falling petals, rising lanterns, twinkles, stars on a click. The
2
+ // agent sends data only (web-ambient.ts checks it; this checks the bounds again); the page draws it with plain nodes
3
+ // animated by the Web Animations API: transform and opacity only, positions through the CSSOM (no style attribute,
4
+ // #1300), every node aria-hidden and pointer-events: none. The looping layer sits behind the thread (.amb-layer, under
5
+ // the scroller and the dock); a burst shows over the messages for a moment (.amb-burst) and never takes the click.
6
+ //
7
+ // Its own gates (not the Canvas Worker runtime's, #2115): the viewer's "Show agents' themes, effects and widgets", safe
8
+ // mode, Web Animations support, and reduced motion only when this device chose to follow the system's setting
9
+ // (fx-motion.js; the user's direction: effects play by default). A hidden tab pauses. Nothing else gates it: it runs
10
+ // no agent code and fetches nothing but its own glyph pictures (fleet decision: no gate without a threat it stops).
11
+ // Caps: 24 looping particles, bursts of at most 16, at most 3 bursts alive — 72 nodes and animations per chat.
12
+ import { html, useEffect, useRef, useState } from "/assets/app-html.js";
13
+ import { t } from "/assets/app-i18n.js";
14
+ import { useStore } from "/assets/app-store.js";
15
+ import { onPrefsChanged } from "/assets/app-prefs.js";
16
+ import { safeMode } from "/assets/owner-css.js";
17
+ import { setThemesShown, themeImageUrl, themesShown, themesStore } from "/assets/app-themes.js";
18
+ import { markMotionNoteShown, motionBlocked, motionNoteDue, onMotionChange, setFollowsReducedMotion } from "/assets/fx-motion.js";
19
+ import "./chat-strings.js";
20
+
21
+ export const AMBIENT_CAPS = { loop: 24, burst: 16, bursts: 3 };
22
+ const LOOP_PRESETS = ["fall", "rise", "twinkle"];
23
+ const RULES = {
24
+ fall: { count: [1, 24], duration: [4, 30] }, rise: { count: [1, 24], duration: [4, 30] },
25
+ twinkle: { count: [1, 24], duration: [1, 10] }, "burst-on-click": { count: [4, 16], duration: [0.3, 2] },
26
+ };
27
+ const COLOR = /^#[0-9a-f]{6}$/;
28
+ const ASSET = /^[0-9a-f]{32}$/;
29
+ const own = (o, k) => !!o && typeof o === "object" && Object.prototype.hasOwnProperty.call(o, k);
30
+ const num = (v, lo, hi) => typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi;
31
+ const pair = (v, lo, hi) => Array.isArray(v) && v.length === 2 && num(v[0], lo, hi) && num(v[1], lo, hi) && v[0] <= v[1];
32
+
33
+ /** A spec as the host keeps it, checked again; null when anything is off (it is then not drawn at all). */
34
+ export function sanitizeAmbient(spec) {
35
+ if (!spec || typeof spec !== "object" || !own(RULES, spec.preset)) return null;
36
+ const r = RULES[spec.preset];
37
+ if (!Array.isArray(spec.glyphs) || spec.glyphs.length < 1 || spec.glyphs.length > 4) return null;
38
+ const glyphs = [];
39
+ for (const g of spec.glyphs) {
40
+ if (typeof g === "string" && g.length >= 1 && g.length <= 32) glyphs.push(g);
41
+ else if (g && typeof g === "object" && typeof g.asset === "string" && ASSET.test(g.asset)) glyphs.push({ asset: g.asset });
42
+ else return null;
43
+ }
44
+ const colors = Array.isArray(spec.colors) ? spec.colors.filter((c) => typeof c === "string" && COLOR.test(c)).slice(0, 5) : [];
45
+ if (!Number.isInteger(spec.count) || !num(spec.count, r.count[0], r.count[1])) return null;
46
+ if (!pair(spec.duration, r.duration[0], r.duration[1]) || !pair(spec.size, 10, 40) || !pair(spec.spread, 20, 120)) return null;
47
+ if (!num(spec.sway, 0, 80) || !num(spec.rotate, 0, 720)) return null;
48
+ if (!Array.isArray(spec.opacity) || spec.opacity.length !== 3 || !spec.opacity.every((o) => num(o, 0, 1))) return null;
49
+ return { preset: spec.preset, glyphs, colors, count: spec.count, duration: spec.duration, size: spec.size, sway: spec.sway, spread: spec.spread, rotate: spec.rotate, opacity: spec.opacity };
50
+ }
51
+
52
+ /** `name`'s ambients from GET /ui/themes, checked: { loop, click } (either null). Null when it has none. */
53
+ export function ambientFor(name) {
54
+ const a = themesStore.get().ambient;
55
+ if (!a || typeof a !== "object" || !own(a, name)) return null;
56
+ const mine = a[name];
57
+ const loop = own(mine, "loop") ? sanitizeAmbient(mine.loop && mine.loop.spec) : null;
58
+ const click = own(mine, "click") ? sanitizeAmbient(mine.click && mine.click.spec) : null;
59
+ if (loop && !LOOP_PRESETS.includes(loop.preset)) return null;
60
+ if (click && click.preset !== "burst-on-click") return null;
61
+ return loop || click ? { loop, click } : null;
62
+ }
63
+
64
+ /** Why this page does not draw ambients now, or null. */
65
+ export function ambientBlock(win = globalThis) {
66
+ if (safeMode()) return "safe";
67
+ if (!themesShown()) return "hidden";
68
+ if (motionBlocked(win)) return "reducedMotion";
69
+ const El = win.Element;
70
+ if (!El || !El.prototype || typeof El.prototype.animate !== "function") return "unsupported";
71
+ return null;
72
+ }
73
+
74
+ /** The gate as state: moves with the switches, the themes and the device's reduced-motion answer. */
75
+ function useAmbientBlock() {
76
+ useStore(themesStore);
77
+ const [, bump] = useState(0);
78
+ useEffect(() => {
79
+ const again = () => bump((n) => n + 1);
80
+ const offPrefs = onPrefsChanged((changed) => { if (changed.has("fx.show")) again(); });
81
+ const offMotion = onMotionChange(again);
82
+ return () => { if (typeof offPrefs === "function") offPrefs(); offMotion(); };
83
+ }, []);
84
+ return ambientBlock();
85
+ }
86
+
87
+ const rand = (lo, hi) => lo + Math.random() * (hi - lo);
88
+ const pick = (list) => list[Math.floor(Math.random() * list.length)];
89
+
90
+ /** One particle node: a text glyph (its colour, else the accent) or a glyph picture. */
91
+ function particle(doc, name, spec, cls) {
92
+ const g = pick(spec.glyphs), size = Math.round(rand(spec.size[0], spec.size[1]));
93
+ let el;
94
+ if (typeof g === "string") {
95
+ el = doc.createElement("span");
96
+ el.textContent = g;
97
+ el.style.setProperty("font-size", `${size}px`);
98
+ if (spec.colors.length) el.style.setProperty("color", pick(spec.colors));
99
+ } else {
100
+ el = doc.createElement("img");
101
+ el.src = themeImageUrl(name, g.asset);
102
+ el.alt = "";
103
+ el.decoding = "async";
104
+ el.draggable = false;
105
+ el.style.setProperty("width", `${size}px`);
106
+ el.style.setProperty("height", `${size}px`);
107
+ }
108
+ el.className = cls;
109
+ el.setAttribute("aria-hidden", "true");
110
+ return { el, size };
111
+ }
112
+
113
+ /**
114
+ * Draw `ambient` ({ loop, click }) for `name` into `layer` (behind) and `front` (bursts), listening on `surface` for
115
+ * clicks. Returns { stop() }: every node and animation it made goes, every listener it added is removed.
116
+ */
117
+ export function startAmbient({ name, ambient, layer, front, surface, doc = globalThis.document }) {
118
+ const anims = new Set();
119
+ const loopNodes = [];
120
+ let bursts = 0, stopped = false;
121
+ const track = (a) => { anims.add(a); if (doc.visibilityState === "hidden") a.pause(); return a; };
122
+
123
+ const drawLoop = () => {
124
+ for (const n of loopNodes.splice(0)) n.remove();
125
+ for (const a of [...anims]) if (a._amb === "loop") { a.cancel(); anims.delete(a); }
126
+ const spec = ambient.loop;
127
+ if (!spec) return;
128
+ const h = layer.clientHeight || 600, w = layer.clientWidth || 360;
129
+ const [o0, o1, o2] = spec.opacity;
130
+ for (let i = 0; i < Math.min(spec.count, AMBIENT_CAPS.loop); i++) {
131
+ const { el, size } = particle(doc, name, spec, "amb-p");
132
+ const dur = rand(spec.duration[0], spec.duration[1]) * 1000;
133
+ const turn = spec.rotate * (Math.random() < 0.5 ? -1 : 1);
134
+ let frames;
135
+ if (spec.preset === "twinkle") {
136
+ el.style.setProperty("left", `${rand(0, Math.max(0, w - size))}px`);
137
+ el.style.setProperty("top", `${rand(0, Math.max(0, h - size))}px`);
138
+ frames = [
139
+ { transform: "scale(0.6) rotate(0deg)", opacity: o0 },
140
+ { transform: `scale(1) rotate(${turn / 2}deg)`, opacity: o1, offset: 0.5 },
141
+ { transform: `scale(0.6) rotate(${turn}deg)`, opacity: o2 },
142
+ ];
143
+ } else {
144
+ el.style.setProperty("left", `${rand(0, Math.max(0, w - size))}px`);
145
+ el.style.setProperty("top", "0px");
146
+ const dx = rand(-spec.sway, spec.sway), down = spec.preset === "fall";
147
+ const from = down ? -size - 4 : h + 4, to = down ? h + 4 : -size - 4;
148
+ frames = [
149
+ { transform: `translate(0px, ${from}px) rotate(0deg)`, opacity: o0 },
150
+ { transform: `translate(${dx * 0.6}px, ${from + (to - from) * 0.15}px) rotate(${turn * 0.15}deg)`, opacity: o1, offset: 0.15 },
151
+ { transform: `translate(${-dx * 0.4}px, ${from + (to - from) * 0.6}px) rotate(${turn * 0.6}deg)`, opacity: (o1 + o2) / 2, offset: 0.6 },
152
+ { transform: `translate(${dx}px, ${to}px) rotate(${turn}deg)`, opacity: o2 },
153
+ ];
154
+ }
155
+ layer.appendChild(el);
156
+ loopNodes.push(el);
157
+ // A negative delay starts each one part-way through, so the layer is already full when it appears.
158
+ const a = el.animate(frames, { duration: dur, delay: -Math.random() * dur, iterations: Infinity, easing: "linear" });
159
+ a._amb = "loop";
160
+ track(a);
161
+ }
162
+ };
163
+
164
+ const burst = (x, y) => {
165
+ const spec = ambient.click;
166
+ if (!spec || bursts >= AMBIENT_CAPS.bursts || stopped) return;
167
+ bursts++;
168
+ const [, o1, o2] = spec.opacity;
169
+ let left = Math.min(spec.count, AMBIENT_CAPS.burst);
170
+ const done = (el, a) => { el.remove(); anims.delete(a); if (--left === 0) bursts--; };
171
+ for (let i = 0, n = left; i < n; i++) {
172
+ const { el, size } = particle(doc, name, spec, "amb-p");
173
+ el.style.setProperty("left", `${x - size / 2}px`);
174
+ el.style.setProperty("top", `${y - size / 2}px`);
175
+ const angle = (i / n) * Math.PI * 2 + rand(-0.3, 0.3), dist = rand(spec.spread[0], spec.spread[1]);
176
+ const turn = rand(-spec.rotate, spec.rotate) / 2;
177
+ front.appendChild(el);
178
+ const a = el.animate([
179
+ { transform: "translate(0px, 0px) scale(0.4) rotate(0deg)", opacity: o1 },
180
+ { transform: `translate(${Math.cos(angle) * dist}px, ${Math.sin(angle) * dist}px) scale(1) rotate(${turn}deg)`, opacity: Math.min(o2, o1) },
181
+ ], { duration: rand(spec.duration[0], spec.duration[1]) * 1000, easing: "cubic-bezier(.2,.7,.3,1)", fill: "forwards" });
182
+ a._amb = "burst";
183
+ track(a);
184
+ let gone = false;
185
+ const finish = () => { if (!gone) { gone = true; done(el, a); } };
186
+ a.onfinish = finish; a.oncancel = finish;
187
+ }
188
+ };
189
+
190
+ // Passive and never cancelling: the click still lands on what is under it. Only in the thread (not the composer),
191
+ // and not on a menu or dialog open over it: stars are for the conversation.
192
+ const onDown = (ev) => {
193
+ if (!ambient.click || ev.button > 0) return;
194
+ const target = ev.target;
195
+ if (!target || typeof target.closest !== "function" || !target.closest(".scroller") || target.closest("[role=menu], [role=dialog]")) return;
196
+ const box = front.getBoundingClientRect();
197
+ burst(ev.clientX - box.left, ev.clientY - box.top);
198
+ };
199
+ const onVisible = () => { for (const a of anims) { if (doc.visibilityState === "hidden") a.pause(); else a.play(); } };
200
+ // Size moves (a rotated phone, the preview panel opening): the loop is drawn again for the new box, a moment later.
201
+ let resizeTimer = null, lastW = layer.clientWidth, lastH = layer.clientHeight;
202
+ const ro = typeof globalThis.ResizeObserver === "function" ? new globalThis.ResizeObserver(() => {
203
+ if (layer.clientWidth === lastW && layer.clientHeight === lastH) return;
204
+ lastW = layer.clientWidth; lastH = layer.clientHeight;
205
+ clearTimeout(resizeTimer);
206
+ resizeTimer = setTimeout(() => { if (!stopped) drawLoop(); }, 200);
207
+ }) : null;
208
+
209
+ drawLoop();
210
+ if (ambient.click) surface.addEventListener("pointerdown", onDown, { passive: true });
211
+ doc.addEventListener("visibilitychange", onVisible);
212
+ if (ro && ambient.loop) ro.observe(layer);
213
+
214
+ return {
215
+ stop() {
216
+ stopped = true;
217
+ clearTimeout(resizeTimer);
218
+ if (ro) ro.disconnect();
219
+ surface.removeEventListener("pointerdown", onDown, { passive: true });
220
+ doc.removeEventListener("visibilitychange", onVisible);
221
+ for (const a of anims) a.cancel();
222
+ anims.clear();
223
+ for (const n of loopNodes.splice(0)) n.remove();
224
+ while (front.firstChild) front.firstChild.remove();
225
+ },
226
+ /** For tests and the smoke: what is alive now. */
227
+ stats() { return { nodes: layer.children.length + front.children.length, animations: anims.size, bursts }; },
228
+ };
229
+ }
230
+
231
+ /**
232
+ * The chat's ambient: renders its two layers inside .chat-main (`surface`) and runs them. `part` is "layer" (behind,
233
+ * right after the theme picture) or "front" (after the dock); the "layer" instance owns the run and finds its sibling.
234
+ */
235
+ export function ChatAmbient({ name, surface, part }) {
236
+ const block = useAmbientBlock();
237
+ const ambient = block ? null : ambientFor(name);
238
+ const key = ambient ? JSON.stringify(ambient) : "";
239
+ const ref = useRef(null);
240
+ useEffect(() => {
241
+ if (part !== "layer" || !ambient) return undefined;
242
+ const layer = ref.current, host = surface.current;
243
+ const front = host && host.querySelector(".amb-burst");
244
+ if (!layer || !host || !front) return undefined;
245
+ host.classList.add("has-amb");
246
+ const run = startAmbient({ name, ambient, layer, front, surface: host });
247
+ if (typeof globalThis !== "undefined") globalThis.__agendAmbient = run; // the smoke reads stats(); nothing else
248
+ return () => { run.stop(); host.classList.remove("has-amb"); if (globalThis.__agendAmbient === run) delete globalThis.__agendAmbient; };
249
+ }, [name, key, part]);
250
+ if (!ambient) return null;
251
+ return part === "layer" ? html`<div ref=${ref} class="amb-layer" aria-hidden="true"></div>` : html`<div class="amb-burst" aria-hidden="true"></div>`;
252
+ }
253
+
254
+ /**
255
+ * The one-time note (the user's direction): the system asks for reduced motion and this chat's ambient plays anyway —
256
+ * say so once on this device, and turn it off right here (this device's switch: the same on the public link, where
257
+ * Settings is not). Shown only where an ambient is actually drawn.
258
+ */
259
+ export function AmbientMotionNote({ name }) {
260
+ const block = useAmbientBlock();
261
+ const shown = !block && !!ambientFor(name);
262
+ const [open, setOpen] = useState(false);
263
+ useEffect(() => {
264
+ if (!shown || open || !motionNoteDue()) return;
265
+ markMotionNoteShown();
266
+ setOpen(true);
267
+ }, [shown]);
268
+ if (!open || !shown) return null;
269
+ const off = () => { setOpen(false); setFollowsReducedMotion(true); };
270
+ return html`<div class="amb-note" role="status">
271
+ <span>${t("chat.ambientMotionNote")}</span>
272
+ <button type="button" class="amb-note-off" onClick=${off}>${t("chat.ambientMotionNoteLink")}</button>
273
+ <button type="button" class="amb-note-x" aria-label=${t("chat.ambientMotionNoteClose")} onClick=${() => setOpen(false)}>×</button>
274
+ </div>`;
275
+ }
276
+
277
+ /**
278
+ * The fix for a block, done right here: both switches are this viewer's own (the show switch is a local choice on the
279
+ * public link; the reduced-motion one is per device), so it works on the public link too; safe mode is the URL. Both
280
+ * stay in Settings → Web appearance as well.
281
+ */
282
+ const FIX = {
283
+ hidden: () => setThemesShown(true),
284
+ reducedMotion: () => setFollowsReducedMotion(false),
285
+ safe: () => { const u = new URL(globalThis.location.href); u.searchParams.delete("safe"); globalThis.location.assign(u.toString()); },
286
+ };
287
+
288
+ /**
289
+ * The chat ⋯ entries for `name`'s ambient: why it is not showing here (a visible reason; choosing it fixes it when the
290
+ * page can), and — for an admin, never over the public link — Clear for each kind.
291
+ */
292
+ export function ambientMenuItems(name, { publicLink, clear }) {
293
+ const a = themesStore.get().ambient;
294
+ const mine = a && own(a, name) ? a[name] : null;
295
+ if (!mine || typeof mine !== "object" || !(own(mine, "loop") || own(mine, "click"))) return [];
296
+ const items = [];
297
+ const block = ambientBlock();
298
+ if (block) {
299
+ items.push({
300
+ key: "ambient-blocked", label: t(`chat.ambientBlocked_${block}`), icon: "info",
301
+ onSelect: () => { if (own(FIX, block)) FIX[block](); },
302
+ });
303
+ }
304
+ if (!publicLink) for (const k of ["loop", "click"]) if (own(mine, k)) items.push({ key: `ambient-clear-${k}`, label: t(k === "loop" ? "chat.ambientClearLoop" : "chat.ambientClearClick"), icon: "trash", onSelect: () => clear(name, k) });
305
+ return items;
306
+ }
@@ -1,10 +1,11 @@
1
1
  /** #1629: static Chat fallback. It requires no preview origin, device consent, Worker or personal identity. */
2
2
  import { useLayoutEffect } from "/assets/app-html.js";
3
3
  import { appStore } from "/assets/app-store.js";
4
- import { onPrefsChanged, prefInForce } from "/assets/app-prefs.js";
4
+ import { onPrefsChanged } from "/assets/app-prefs.js";
5
5
  import { themesShown, themesStore } from "/assets/app-themes.js";
6
6
  import { ownerCssLifecycle } from "/assets/owner-css.js";
7
- import { chatEffectIdentity } from "/assets/effect-chat-runtime.js";
7
+ import { effectPlaybackIdentity } from "/assets/effect-chat-runtime.js";
8
+ import { effectBootstrapReady } from "/assets/effect-frame.js";
8
9
  import { createChatPosters } from "/assets/effect-posters.js";
9
10
 
10
11
  export function ChatEffectPosters({ name, lease, element }) {
@@ -15,12 +16,11 @@ export function ChatEffectPosters({ name, lease, element }) {
15
16
  const cleanup = () => { if (!live) return; live = false; try { api?.dispose?.(); } catch { /* optional */ } for (const off of releases) { try { off?.(); } catch { /* release all */ } } };
16
17
  lease.hold(cleanup);
17
18
  try {
18
- const preview = host.AgendPreview, motion = host.matchMedia?.("(prefers-reduced-motion: reduce)") ?? { matches: true };
19
+ const motion = host.matchMedia?.("(prefers-reduced-motion: reduce)") ?? { matches: true };
19
20
  const observe = () => {
20
- let previewReady = false; try { previewReady = preview?.availability().ok === true; } catch { /* fallback */ }
21
21
  // This is a surface fallback, not a replacement for an explicit Pause, queued effect or failed live read.
22
- const playback = chatEffectIdentity(host) && typeof host.Worker === "function" && typeof host.OffscreenCanvas === "function" && previewReady &&
23
- prefInForce("preview.optIn")?.value === true && preview?.optedIn() === true && preview?.never() !== true && !motion.matches && host.navigator?.connection?.saveData !== true;
22
+ const playback = effectPlaybackIdentity(host) && typeof host.Worker === "function" && typeof host.OffscreenCanvas === "function" &&
23
+ effectBootstrapReady(host, host.location?.origin, host.document.body.dataset.effectBoot) && !motion.matches && host.navigator?.connection?.saveData !== true;
24
24
  return { shown: themesShown(), fallback: !playback, ownerCss: ownerCssLifecycle.get().state };
25
25
  };
26
26
  api = createChatPosters({ window: host, name, element: element.current, current, observe });
@@ -33,7 +33,6 @@ export function ChatEffectPosters({ name, lease, element }) {
33
33
  if (s.themesRev !== revision) { revision = s.themesRev; api.changed(); } else sync();
34
34
  }));
35
35
  releases.push(ownerCssLifecycle.subscribe(sync)); releases.push(themesStore.subscribe(sync)); releases.push(onPrefsChanged(sync));
36
- if (typeof preview?.onChange === "function") releases.push(preview.onChange(sync));
37
36
  for (const target of [motion, host.navigator?.connection]) if (target?.addEventListener) { target.addEventListener("change", sync); releases.push(() => target.removeEventListener("change", sync)); }
38
37
  sync(); return cleanup;
39
38
  } catch { cleanup(); return cleanup; }