@songsid/agend 2.2.0 → 2.3.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/dist/agent-cli.js +248 -2
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/agent-endpoint.d.ts +2 -0
  4. package/dist/agent-endpoint.js +25 -0
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/agent-file-read.d.ts +35 -0
  7. package/dist/agent-file-read.js +144 -0
  8. package/dist/agent-file-read.js.map +1 -0
  9. package/dist/agent-whats-new.js +8 -0
  10. package/dist/agent-whats-new.js.map +1 -1
  11. package/dist/atomic-write.js +9 -1
  12. package/dist/atomic-write.js.map +1 -1
  13. package/dist/auth-api.js +17 -0
  14. package/dist/auth-api.js.map +1 -1
  15. package/dist/backend/antigravity.js +3 -3
  16. package/dist/backend/antigravity.js.map +1 -1
  17. package/dist/backend/claude-code.js +5 -1
  18. package/dist/backend/claude-code.js.map +1 -1
  19. package/dist/backend/launch-effort.js +1 -1
  20. package/dist/backend/launch-effort.js.map +1 -1
  21. package/dist/backend/muse.js +10 -2
  22. package/dist/backend/muse.js.map +1 -1
  23. package/dist/backend/types.d.ts +8 -0
  24. package/dist/backend/types.js +10 -0
  25. package/dist/backend/types.js.map +1 -1
  26. package/dist/cache-analysis.d.ts +47 -1
  27. package/dist/cache-analysis.js +68 -8
  28. package/dist/cache-analysis.js.map +1 -1
  29. package/dist/cache-report-tool.d.ts +17 -0
  30. package/dist/cache-report-tool.js +40 -0
  31. package/dist/cache-report-tool.js.map +1 -0
  32. package/dist/cache-service.d.ts +15 -0
  33. package/dist/cache-service.js +26 -1
  34. package/dist/cache-service.js.map +1 -1
  35. package/dist/channel/adapters/discord.d.ts +2 -0
  36. package/dist/channel/adapters/discord.js +17 -2
  37. package/dist/channel/adapters/discord.js.map +1 -1
  38. package/dist/channel/adapters/telegram.d.ts +8 -1
  39. package/dist/channel/adapters/telegram.js +74 -4
  40. package/dist/channel/adapters/telegram.js.map +1 -1
  41. package/dist/channel/mcp-tools.js +36 -0
  42. package/dist/channel/mcp-tools.js.map +1 -1
  43. package/dist/channel/types.d.ts +15 -0
  44. package/dist/cli.js +7 -3
  45. package/dist/cli.js.map +1 -1
  46. package/dist/config-validator.js +22 -2
  47. package/dist/config-validator.js.map +1 -1
  48. package/dist/daemon.js +244 -20
  49. package/dist/daemon.js.map +1 -1
  50. package/dist/fleet-context.d.ts +5 -0
  51. package/dist/fleet-manager.d.ts +92 -0
  52. package/dist/fleet-manager.js +410 -29
  53. package/dist/fleet-manager.js.map +1 -1
  54. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +7 -0
  55. package/dist/general-knowledge/skills/web-appearance/SKILL.md +367 -0
  56. package/dist/instance-avatar.js +3 -0
  57. package/dist/instance-avatar.js.map +1 -1
  58. package/dist/instance-config-impact.js +3 -0
  59. package/dist/instance-config-impact.js.map +1 -1
  60. package/dist/instance-lifecycle.js +9 -4
  61. package/dist/instance-lifecycle.js.map +1 -1
  62. package/dist/needs-you-hub.d.ts +4 -0
  63. package/dist/needs-you-hub.js +31 -6
  64. package/dist/needs-you-hub.js.map +1 -1
  65. package/dist/needs-you.d.ts +1 -13
  66. package/dist/needs-you.js +3 -37
  67. package/dist/needs-you.js.map +1 -1
  68. package/dist/outbound-handlers.d.ts +7 -0
  69. package/dist/outbound-handlers.js +65 -10
  70. package/dist/outbound-handlers.js.map +1 -1
  71. package/dist/outbound-schemas.d.ts +74 -0
  72. package/dist/outbound-schemas.js +56 -0
  73. package/dist/outbound-schemas.js.map +1 -1
  74. package/dist/public-web-gateway.js +14 -0
  75. package/dist/public-web-gateway.js.map +1 -1
  76. package/dist/repo-worktree-guard.d.ts +36 -0
  77. package/dist/repo-worktree-guard.js +143 -0
  78. package/dist/repo-worktree-guard.js.map +1 -0
  79. package/dist/scheduler/scheduler.d.ts +70 -1
  80. package/dist/scheduler/scheduler.js +256 -17
  81. package/dist/scheduler/scheduler.js.map +1 -1
  82. package/dist/service-installer.d.ts +2 -1
  83. package/dist/service-installer.js +18 -3
  84. package/dist/service-installer.js.map +1 -1
  85. package/dist/settings-api.js +74 -1
  86. package/dist/settings-api.js.map +1 -1
  87. package/dist/settings-change.js +8 -0
  88. package/dist/settings-change.js.map +1 -1
  89. package/dist/settings-effect.js +16 -2
  90. package/dist/settings-effect.js.map +1 -1
  91. package/dist/settings-http-confirmation.js +5 -0
  92. package/dist/settings-http-confirmation.js.map +1 -1
  93. package/dist/tool-permissions.d.ts +5 -0
  94. package/dist/tool-permissions.js +61 -2
  95. package/dist/tool-permissions.js.map +1 -1
  96. package/dist/topic-commands.d.ts +10 -6
  97. package/dist/topic-commands.js +21 -17
  98. package/dist/topic-commands.js.map +1 -1
  99. package/dist/topic-link.d.ts +128 -0
  100. package/dist/topic-link.js +269 -0
  101. package/dist/topic-link.js.map +1 -0
  102. package/dist/types.d.ts +13 -0
  103. package/dist/ui/app-effects.js +187 -0
  104. package/dist/ui/chat-effect-posters.js +42 -0
  105. package/dist/ui/chat-effects.js +72 -0
  106. package/dist/ui/chat-live-replies.js +58 -0
  107. package/dist/ui/chat-render.js +4 -4
  108. package/dist/ui/chat-store.js +9 -3
  109. package/dist/ui/chat-strings.js +15 -0
  110. package/dist/ui/chat-thread.js +47 -21
  111. package/dist/ui/chat-tour.js +1 -1
  112. package/dist/ui/effect-trial-controller.js +119 -0
  113. package/dist/ui/effect-trial-offer.js +31 -0
  114. package/dist/ui/effect-trial-panel.js +37 -0
  115. package/dist/ui/fleet-cache.js +23 -1
  116. package/dist/ui/image-lightbox.js +1 -1
  117. package/dist/ui/panel-chat.js +85 -17
  118. package/dist/ui/panel-details.js +18 -5
  119. package/dist/ui/panel-fleet.js +3 -3
  120. package/dist/ui/panel-log.js +6 -6
  121. package/dist/ui/panel-needs.js +3 -3
  122. package/dist/ui/panel-settings.js +8 -5
  123. package/dist/ui/preview-panel.js +25 -9
  124. package/dist/ui/settings-appearance.js +325 -0
  125. package/dist/ui/settings-effects.js +201 -0
  126. package/dist/ui/settings-mods.js +257 -0
  127. package/dist/ui/settings-owner-effects.js +80 -0
  128. package/dist/ui/settings-strings.js +245 -0
  129. package/dist/ui/shared/agend-auth.js +27 -3
  130. package/dist/ui/shared/agent-widget.js +248 -0
  131. package/dist/ui/shared/app-i18n.js +2 -0
  132. package/dist/ui/shared/app-prefs.js +30 -10
  133. package/dist/ui/shared/app-route.js +23 -1
  134. package/dist/ui/shared/app-session.js +1 -1
  135. package/dist/ui/shared/app-shell.js +29 -16
  136. package/dist/ui/shared/app-store.js +2 -0
  137. package/dist/ui/shared/app-stream.js +12 -7
  138. package/dist/ui/shared/app-themes.js +188 -0
  139. package/dist/ui/shared/app.css +225 -3
  140. package/dist/ui/shared/app.js +6 -0
  141. package/dist/ui/shared/card-strings.js +28 -0
  142. package/dist/ui/shared/effect-app-runtime.js +248 -0
  143. package/dist/ui/shared/effect-baseline.js +64 -0
  144. package/dist/ui/shared/effect-chat-runtime.js +154 -0
  145. package/dist/ui/shared/effect-cursor.js +125 -0
  146. package/dist/ui/shared/effect-frame.js +127 -0
  147. package/dist/ui/shared/effect-geometry.js +122 -0
  148. package/dist/ui/shared/effect-host-style.js +10 -0
  149. package/dist/ui/shared/effect-owner-regions.js +74 -0
  150. package/dist/ui/shared/effect-owner-source.js +115 -0
  151. package/dist/ui/shared/effect-policy.js +137 -0
  152. package/dist/ui/shared/effect-poster-reader.js +98 -0
  153. package/dist/ui/shared/effect-posters.js +117 -0
  154. package/dist/ui/shared/effect-profiler.js +162 -0
  155. package/dist/ui/shared/effect-profiles.js +65 -0
  156. package/dist/ui/shared/effect-registry.js +496 -0
  157. package/dist/ui/shared/effect-runtime-loader.js +234 -0
  158. package/dist/ui/shared/effect-safety.js +153 -0
  159. package/dist/ui/shared/effect-stock-snapshot.js +34 -0
  160. package/dist/ui/shared/effect-stock-trust.js +165 -0
  161. package/dist/ui/shared/effect-trial-source.js +103 -0
  162. package/dist/ui/shared/effect-trial-stage.js +147 -0
  163. package/dist/ui/shared/header-tools.js +1 -1
  164. package/dist/ui/shared/identity-card.js +202 -0
  165. package/dist/ui/shared/identity-color.js +261 -0
  166. package/dist/ui/shared/instance-nav.js +37 -19
  167. package/dist/ui/shared/owner-css.js +221 -0
  168. package/dist/ui/shared/owner-effect-prefs.js +117 -0
  169. package/dist/ui/shared/panel-view.js +81 -8
  170. package/dist/ui/shared/signin.js +2 -0
  171. package/dist/ui/shared/ui-dialog.js +1 -1
  172. package/dist/ui/shared/ui-icons.js +2 -0
  173. package/dist/ui/shared/ui-menu.js +1 -1
  174. package/dist/ui/shared/ui-toast.js +1 -1
  175. package/dist/ui/shared/usage-dialog.js +14 -2
  176. package/dist/ui/shared/view-strings.js +36 -2
  177. package/dist/ui/signin.html +1 -0
  178. package/dist/usage/providers.d.ts +3 -0
  179. package/dist/usage/providers.js +4 -3
  180. package/dist/usage/providers.js.map +1 -1
  181. package/dist/view-api.d.ts +29 -0
  182. package/dist/view-api.js +188 -8
  183. package/dist/view-api.js.map +1 -1
  184. package/dist/web-api.d.ts +42 -0
  185. package/dist/web-api.js +898 -36
  186. package/dist/web-api.js.map +1 -1
  187. package/dist/web-appearance-limits.d.ts +82 -0
  188. package/dist/web-appearance-limits.js +83 -0
  189. package/dist/web-appearance-limits.js.map +1 -0
  190. package/dist/web-appearance-policy.d.ts +66 -0
  191. package/dist/web-appearance-policy.js +94 -0
  192. package/dist/web-appearance-policy.js.map +1 -0
  193. package/dist/web-appearance-tools.d.ts +50 -0
  194. package/dist/web-appearance-tools.js +105 -0
  195. package/dist/web-appearance-tools.js.map +1 -0
  196. package/dist/web-appearance-view.d.ts +99 -0
  197. package/dist/web-appearance-view.js +163 -0
  198. package/dist/web-appearance-view.js.map +1 -0
  199. package/dist/web-appearance.d.ts +79 -0
  200. package/dist/web-appearance.js +298 -0
  201. package/dist/web-appearance.js.map +1 -0
  202. package/dist/web-asset-parts.d.ts +93 -0
  203. package/dist/web-asset-parts.js +133 -0
  204. package/dist/web-asset-parts.js.map +1 -0
  205. package/dist/web-auth.js +17 -1
  206. package/dist/web-auth.js.map +1 -1
  207. package/dist/web-card.d.ts +96 -0
  208. package/dist/web-card.js +232 -0
  209. package/dist/web-card.js.map +1 -0
  210. package/dist/web-chat-history.d.ts +13 -0
  211. package/dist/web-chat-history.js +23 -2
  212. package/dist/web-chat-history.js.map +1 -1
  213. package/dist/web-effect-bundle.d.ts +28 -0
  214. package/dist/web-effect-bundle.js +126 -0
  215. package/dist/web-effect-bundle.js.map +1 -0
  216. package/dist/web-effect-canonical.d.ts +8 -0
  217. package/dist/web-effect-canonical.js +27 -0
  218. package/dist/web-effect-canonical.js.map +1 -0
  219. package/dist/web-effect-library.d.ts +114 -0
  220. package/dist/web-effect-library.js +530 -0
  221. package/dist/web-effect-library.js.map +1 -0
  222. package/dist/web-effect-poster.d.ts +8 -0
  223. package/dist/web-effect-poster.js +52 -0
  224. package/dist/web-effect-poster.js.map +1 -0
  225. package/dist/web-effect-profiles.d.ts +118 -0
  226. package/dist/web-effect-profiles.js +180 -0
  227. package/dist/web-effect-profiles.js.map +1 -0
  228. package/dist/web-effect-service.d.ts +86 -0
  229. package/dist/web-effect-service.js +314 -0
  230. package/dist/web-effect-service.js.map +1 -0
  231. package/dist/web-effect-tools.d.ts +47 -0
  232. package/dist/web-effect-tools.js +129 -0
  233. package/dist/web-effect-tools.js.map +1 -0
  234. package/dist/web-effect-trials.d.ts +86 -0
  235. package/dist/web-effect-trials.js +204 -0
  236. package/dist/web-effect-trials.js.map +1 -0
  237. package/dist/web-effect-upload.d.ts +27 -0
  238. package/dist/web-effect-upload.js +185 -0
  239. package/dist/web-effect-upload.js.map +1 -0
  240. package/dist/web-effect-worker.d.ts +8 -0
  241. package/dist/web-effect-worker.js +170 -0
  242. package/dist/web-effect-worker.js.map +1 -0
  243. package/dist/web-effect.d.ts +103 -0
  244. package/dist/web-effect.js +153 -0
  245. package/dist/web-effect.js.map +1 -0
  246. package/dist/web-identity-color.d.ts +87 -0
  247. package/dist/web-identity-color.js +209 -0
  248. package/dist/web-identity-color.js.map +1 -0
  249. package/dist/web-identity-palette.d.ts +113 -0
  250. package/dist/web-identity-palette.js +84 -0
  251. package/dist/web-identity-palette.js.map +1 -0
  252. package/dist/web-image-meta.d.ts +25 -0
  253. package/dist/web-image-meta.js +200 -0
  254. package/dist/web-image-meta.js.map +1 -0
  255. package/dist/web-mod-agent.d.ts +50 -0
  256. package/dist/web-mod-agent.js +205 -0
  257. package/dist/web-mod-agent.js.map +1 -0
  258. package/dist/web-mod-bundle.d.ts +149 -0
  259. package/dist/web-mod-bundle.js +470 -0
  260. package/dist/web-mod-bundle.js.map +1 -0
  261. package/dist/web-mod-export.d.ts +59 -0
  262. package/dist/web-mod-export.js +79 -0
  263. package/dist/web-mod-export.js.map +1 -0
  264. package/dist/web-mod-store.d.ts +283 -0
  265. package/dist/web-mod-store.js +625 -0
  266. package/dist/web-mod-store.js.map +1 -0
  267. package/dist/web-org.d.ts +3 -3
  268. package/dist/web-org.js +11 -17
  269. package/dist/web-org.js.map +1 -1
  270. package/dist/web-owner-css-store.d.ts +180 -0
  271. package/dist/web-owner-css-store.js +384 -0
  272. package/dist/web-owner-css-store.js.map +1 -0
  273. package/dist/web-owner-css.d.ts +58 -0
  274. package/dist/web-owner-css.js +552 -0
  275. package/dist/web-owner-css.js.map +1 -0
  276. package/dist/web-prefs.d.ts +48 -0
  277. package/dist/web-prefs.js +209 -33
  278. package/dist/web-prefs.js.map +1 -1
  279. package/dist/web-preview.d.ts +9 -1
  280. package/dist/web-preview.js +24 -7
  281. package/dist/web-preview.js.map +1 -1
  282. package/dist/web-settings-patch.d.ts +18 -0
  283. package/dist/web-settings-patch.js +51 -0
  284. package/dist/web-settings-patch.js.map +1 -1
  285. package/dist/web-shell-routes.d.ts +1 -1
  286. package/dist/web-shell-routes.js +1 -1
  287. package/dist/web-shell-routes.js.map +1 -1
  288. package/dist/web-theme-store.d.ts +218 -0
  289. package/dist/web-theme-store.js +516 -0
  290. package/dist/web-theme-store.js.map +1 -0
  291. package/dist/web-theme.d.ts +121 -0
  292. package/dist/web-theme.js +407 -0
  293. package/dist/web-theme.js.map +1 -0
  294. package/dist/web-widget.d.ts +72 -0
  295. package/dist/web-widget.js +245 -0
  296. package/dist/web-widget.js.map +1 -0
  297. package/package.json +1 -1
  298. package/templates/systemd.service.ejs +8 -0
@@ -0,0 +1,257 @@
1
+ // #1629 r4 §4 (r4-5): Settings → Appearance → Mods. A mod is one file that carries a look: an agent's theme with its
2
+ // pictures, its card text, widgets, effects, and a person's own CSS. Importing is a person's action in two steps:
3
+ // the file is checked whole and its plan shown in full (every part, the custom CSS's whole text) — then only the
4
+ // parts the person ticks are applied, all or nothing. Custom CSS from a file is never ticked by default. Installed
5
+ // mods can be downloaded again or uninstalled (what they applied stays: Reset and the CSS controls work on it).
6
+ // Export builds a file from what is in force now.
7
+ import { html, useEffect, useLayoutEffect, useRef, useState } from "/assets/app-html.js";
8
+ import { t } from "/assets/app-i18n.js";
9
+ import { appStore, useStore } from "/assets/app-store.js";
10
+ import { Icon } from "/assets/ui-icons.js";
11
+ import { toast } from "/assets/ui-toast.js";
12
+ import { confirmDialog } from "/assets/ui-confirm.js";
13
+ import { startOwnerCssTrial } from "/assets/owner-css.js";
14
+ import { AgentWidget, checkWidget, fleetBinds, instanceBinds } from "/assets/agent-widget.js";
15
+ import "./settings-strings.js";
16
+
17
+ const tn = (k, ...v) => t(`settings.${k}`, ...v);
18
+ const kib = (n) => (n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MiB` : `${Math.max(1, Math.round(n / 1024))} KiB`);
19
+
20
+ async function call(path, init) {
21
+ let res;
22
+ try { res = await fetch(path, { credentials: "same-origin", ...init }); } catch { return { ok: false, status: 0, body: null }; }
23
+ let body = null;
24
+ try { body = await res.json(); } catch { /* not JSON */ }
25
+ return { ok: res.ok, status: res.status, body, res };
26
+ }
27
+ const postJson = (path, body) => call(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
28
+ function download(text, name) {
29
+ const url = URL.createObjectURL(new Blob([text], { type: "application/octet-stream" }));
30
+ const a = document.createElement("a"); a.href = url; a.download = name; a.className = "offscreen";
31
+ document.body.appendChild(a); a.click(); a.remove();
32
+ URL.revokeObjectURL(url);
33
+ }
34
+ /** Why the host refused, in words (the first problems of a bundle, each with where it is). */
35
+ function why(body) {
36
+ if (!body) return tn("modFailed", "?");
37
+ if (Array.isArray(body.problems) && body.problems.length) return body.problems.slice(0, 5).map((p) => `${p.path || "file"}: ${p.problem}`).join("\n");
38
+ return tn("modFailed", body.detail ? `${body.error}: ${body.detail}` : body.error || "?");
39
+ }
40
+
41
+ /** The parts a plan offers, each with its default: everything but custom CSS. */
42
+ export function defaultChoice(plan) {
43
+ const c = { theme: false, identity: false, effects: false, ownerCss: false, widgets: {}, replace: false };
44
+ for (const it of plan.items) {
45
+ if (it.part === "theme" || it.part === "identity") c[it.part] = true;
46
+ else if (it.part === "widget") c.widgets[it.slot] = true;
47
+ else if (it.part === "effect") c.effects = true;
48
+ // ownerCss stays false: "read it before turning it on" (r4 §4).
49
+ }
50
+ return c;
51
+ }
52
+
53
+ const COLOR = /^#[0-9a-f]{6}$/;
54
+ /** A colour as a swatch (its fill set through the CSSOM — never a style attribute) and its hex code. */
55
+ function Swatch({ color }) {
56
+ const ref = useRef(null);
57
+ useLayoutEffect(() => { if (ref.current && ref.current.style && COLOR.test(color)) ref.current.style.setProperty("--sw", color); }, [color]);
58
+ return html`<span class="s-mod-sw"><span class="s-mod-sw-chip" ref=${ref} aria-hidden="true"></span><code>${color}</code></span>`;
59
+ }
60
+ /** Every field of the checked theme, as it will be applied — nothing left unseen (#1897 review). */
61
+ export function ThemeDetail({ theme, pictures, effects }) {
62
+ const th = theme || {};
63
+ const row = (k, v) => html`<div class="s-mod-kv"><span class="k">${k}</span><span class="v">${v}</span></div>`;
64
+ const pal = (mode) => {
65
+ const p = th.colors && th.colors[mode];
66
+ return p && Object.keys(p).length ? row(tn(mode === "dark" ? "modColorsDark" : "modColorsLight"), html`<span class="s-mod-sws">${Object.keys(p).map((k) => html`<span key=${k} class="s-mod-sw-item">${k} <${Swatch} color=${p[k]} /></span>`)}</span>`) : null;
67
+ };
68
+ const picOf = (id) => (pictures || []).find((p) => p.id === id);
69
+ const pic = (id, label) => {
70
+ const p = picOf(id);
71
+ return p ? row(label, html`<span class="s-mod-pic"><img src=${`/ui/mods/staged/${p.id}`} alt=${label} loading="lazy" /><span class="muted">${p.path} · ${kib(p.bytes)}</span></span>`) : null;
72
+ };
73
+ return html`<div class="s-mod-theme">
74
+ ${th.accent ? row(tn("modAccent"), html`<${Swatch} color=${th.accent} />`) : null}
75
+ ${pal("light")}${pal("dark")}
76
+ ${th.bubble ? row(tn("modBubble"), `${th.bubble.radius !== undefined ? `${th.bubble.radius}px` : ""}${th.bubble.radius !== undefined && th.bubble.border ? " · " : ""}${th.bubble.border || ""}`) : null}
77
+ ${th.font ? row(tn("modFont"), th.font) : null}
78
+ ${th.workingLabel ? row(tn("modWorkingLabel"), html`<span class="s-mod-text">${th.workingLabel}</span>`) : null}
79
+ ${th.background ? pic(th.background.image, tn("modBackground")) : null}
80
+ ${th.background ? row(tn("modBackgroundFit"), `${th.background.fit} · ${Math.round(th.background.opacity * 100)}%`) : null}
81
+ ${th.avatar ? pic(th.avatar, tn("modAvatar")) : null}
82
+ ${(effects || []).map((e, i) => html`<div key=${i}>${row(tn("modPlacesEffect"), html`<span><code>${e.effect}</code>${e.id ? html` <span class="muted">${e.id.slice(0, 8)}</span>` : null}${Object.keys(e.params || {}).length ? html` · <code>${JSON.stringify(e.params)}</code>` : null}</span>`)}</div>`)}
83
+ <details class="s-mod-raw"><summary>${tn("modAllFields")}</summary><pre class="s-mod-css-text">${JSON.stringify(th, null, 2)}</pre></details>
84
+ </div>`;
85
+ }
86
+
87
+ function PlanItem({ it, choice, setChoice, binds, fleet }) {
88
+ const tick = (label, on, set) => html`<label class="check s-mod-tick"><input type="checkbox" checked=${on} onChange=${(e) => set(e.target.checked)} /><span>${label}</span></label>`;
89
+ if (it.part === "theme") {
90
+ return html`<li class="s-mod-item">${tick(tn("modPartTheme"), choice.theme, (v) => setChoice({ ...choice, theme: v }))}
91
+ <div class="s-mod-detail"><${ThemeDetail} theme=${it.theme} pictures=${it.pictures} effects=${it.effects} /></div></li>`;
92
+ }
93
+ if (it.part === "identity") {
94
+ return html`<li class="s-mod-item">${tick(tn("modPartIdentity"), choice.identity, (v) => setChoice({ ...choice, identity: v }))}
95
+ <div class="s-mod-detail">${it.tagline ? html`<span class="s-mod-text">${it.tagline}</span>` : null}${it.bio ? html`<span class="s-mod-text">${it.bio}</span>` : null}</div></li>`;
96
+ }
97
+ if (it.part === "widget") {
98
+ const w = checkWidget(it.slot, { spec: it.spec, data: it.data });
99
+ 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 } }))}
100
+ <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>`;
101
+ }
102
+ if (it.part === "effect") {
103
+ return html`<li class="s-mod-item">${tick(tn("modPartEffect", it.title), choice.effects, (v) => setChoice({ ...choice, effects: v }))}
104
+ <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>`;
105
+ }
106
+ if (it.part === "ownerCss") {
107
+ return html`<li class="s-mod-item s-mod-css">${tick(tn("modPartCss"), choice.ownerCss, (v) => setChoice({ ...choice, ownerCss: v }))}
108
+ <p class="s-mod-warn">${tn("modCssWarn")}</p>
109
+ <pre class="s-mod-css-text">${it.text}</pre>
110
+ ${it.assets.length ? html`<p class="sub">${tn("modCssAssets", it.assets.length, kib(it.assets.reduce((n, a) => n + a.bytes, 0)))}</p>` : null}</li>`;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ function Plan({ staged, onDone }) {
116
+ const app = useStore(appStore);
117
+ const plan = staged.plan;
118
+ const [choice, setChoice] = useState(() => defaultChoice(plan));
119
+ const [target, setTarget] = useState("");
120
+ const [busy, setBusy] = useState(false);
121
+ const [error, setError] = useState("");
122
+ const toInstance = choice.theme || choice.identity || !!choice.widgets.chat_header || !!choice.widgets.details;
123
+ const names = (app.instances || []).map((i) => i.name);
124
+ const install = async () => {
125
+ setBusy(true); setError("");
126
+ const r = await postJson("/ui/mods/commit", { stage: staged.stage, plan_hash: staged.plan_hash, include: choice, ...(toInstance ? { target } : {}) });
127
+ setBusy(false);
128
+ // The stage this plan shows is gone or no longer this plan (replaced on another device, expired): read again —
129
+ // the page then shows whatever is staged now, with its own, fresh choices (keyed by stage).
130
+ if (!r.ok) { setError(why(r.body)); if (r.status === 404 || (r.body && r.body.error === "plan_changed")) onDone(); return; }
131
+ if (r.body.css) { startOwnerCssTrial(r.body.css.trial, r.body.css.sha, r.body.css.previous); toast(tn("cssTrying")); }
132
+ toast(tn("modInstalled", plan.mod.title));
133
+ onDone();
134
+ };
135
+ const cancel = async () => {
136
+ const r = await postJson("/ui/mods/abort", { stage: staged.stage });
137
+ // Not this stage any more (another device staged a file meanwhile): say so, then show what is staged now.
138
+ if (!r.ok) toast(r.status === 404 ? tn("modStageGone") : why(r.body), false);
139
+ onDone();
140
+ };
141
+ const inst = (app.instances || []).find((i) => i.name === target) || null;
142
+ const binds = inst ? instanceBinds(inst, app.exec && app.exec[inst.name], app.awaiting && Object.prototype.hasOwnProperty.call(app.awaiting, inst.name) ? app.awaiting[inst.name] : null) : {};
143
+ const m = plan.mod;
144
+ return html`<div class="s-mod-plan" role="group" aria-label=${tn("modPlan")}>
145
+ <h4>${m.title} <span class="muted">${m.name} ${m.version}</span></h4>
146
+ ${m.author ? html`<p class="sub">${tn("modAuthor", m.author)}</p>` : null}
147
+ ${m.description ? html`<p class="sub s-mod-text">${m.description}</p>` : null}
148
+ <p class="sub">${tn("modSizeId", kib(m.bytes), m.id.slice(0, 8))}${m.installed ? ` · ${tn("modAlready")}` : ""}</p>
149
+ ${m.replaces ? html`<label class="check"><input type="checkbox" checked=${choice.replace} onChange=${(e) => setChoice({ ...choice, replace: e.target.checked })} /><span>${tn("modReplace", m.replaces.version)}</span></label>` : null}
150
+ <ul class="s-mod-items">${plan.items.map((it, i) => html`<${PlanItem} key=${i} it=${it} choice=${choice} setChoice=${setChoice} binds=${binds} fleet=${fleetBinds(app.instances, app.exec, app.awaiting)} />`)}</ul>
151
+ ${toInstance ? html`<label class="field"><span>${tn("modTarget")}</span>
152
+ <select value=${target} onChange=${(e) => setTarget(e.target.value)}><option value="">${tn("modTargetPick")}</option>${names.map((n) => html`<option key=${n} value=${n}>${n}</option>`)}</select></label>` : null}
153
+ ${toInstance && !target ? html`<p class="sub">${tn("modTargetNeeded")}</p>` : null}
154
+ ${error ? html`<p class="err s-mod-err" role="alert">${error}</p>` : null}
155
+ <div class="s-widget-actions">
156
+ <button type="button" class="btn btn-primary" disabled=${busy || (toInstance && !target) || (m.replaces && !choice.replace)} onClick=${install}><${Icon} name="check" size=${16} />${tn("modInstall")}</button>
157
+ <button type="button" class="btn" disabled=${busy} onClick=${cancel}>${tn("modCancel")}</button>
158
+ </div>
159
+ </div>`;
160
+ }
161
+
162
+ function ExportForm() {
163
+ const app = useStore(appStore);
164
+ const [f, setF] = useState({ instance: "", name: "", version: "1.0.0", title: "", theme: true, identity: true, header: true, details: true, sidebar: false, ownerCss: false });
165
+ const [error, setError] = useState("");
166
+ const set = (k) => (e) => setF({ ...f, [k]: e.target.type === "checkbox" ? e.target.checked : e.target.value });
167
+ const go = async () => {
168
+ setError("");
169
+ const body = { instance: f.instance || undefined, name: f.name, version: f.version, title: f.title,
170
+ include: { theme: f.theme, identity: f.identity, ownerCss: f.ownerCss, widgets: { chat_header: f.header, details: f.details, sidebar: f.sidebar } } };
171
+ let res;
172
+ try { res = await fetch("/ui/mods/export", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); }
173
+ catch { setError(tn("modFailed", "network")); return; }
174
+ if (!res.ok) { let b = null; try { b = await res.json(); } catch { /* none */ } setError(why(b)); return; }
175
+ download(await res.text(), `${f.name}-${f.version}.agendmod.json`);
176
+ // Effects that aren't this person's to carry stay out of the file (their own claims and shared ones travel).
177
+ const omitted = Number(res.headers && res.headers.get ? res.headers.get("X-AgEnD-Effects-Omitted") : 0);
178
+ if (omitted > 0) toast(tn("modExportOmitted", omitted), false);
179
+ };
180
+ const box = (k, label) => html`<label class="check"><input type="checkbox" checked=${f[k]} onChange=${set(k)} /><span>${label}</span></label>`;
181
+ return html`<details class="s-mod-export"><summary>${tn("modExport")}</summary>
182
+ <p class="sub">${tn("modExportLead")}</p>
183
+ <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>
184
+ <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>
185
+ <label class="field"><span>${tn("modExportName")}</span><input value=${f.name} placeholder="my-look" onInput=${set("name")} /></label>
186
+ <label class="field"><span>${tn("modExportVersion")}</span><input value=${f.version} onInput=${set("version")} /></label>
187
+ <label class="field"><span>${tn("modExportTitle")}</span><input value=${f.title} onInput=${set("title")} /></label>
188
+ ${error ? html`<p class="err s-mod-err" role="alert">${error}</p>` : null}
189
+ <div class="s-widget-actions"><button type="button" class="btn" disabled=${!f.name || !f.title} onClick=${go}><${Icon} name="download" size=${16} />${tn("modExportGo")}</button></div>
190
+ </details>`;
191
+ }
192
+
193
+ export function ModsCard() {
194
+ const [state, setState] = useState(null);
195
+ const [busy, setBusy] = useState(false);
196
+ const [error, setError] = useState("");
197
+ const file = useRef(null);
198
+ // Only an answer of the expected shape is shown (#1889's lesson): anything else is "couldn't load", never a crash.
199
+ const load = async () => {
200
+ const r = await call("/ui/mods");
201
+ const b = r.ok && r.body && typeof r.body === "object" ? r.body : null;
202
+ const staged = b && b.staged && typeof b.staged === "object" && typeof b.staged.stage === "string" && b.staged.plan && Array.isArray(b.staged.plan.items) ? b.staged : null;
203
+ const proposals = b && Array.isArray(b.proposals) ? b.proposals.filter((p) => p && typeof p.instance === "string" && typeof p.title === "string") : [];
204
+ setState(b && Array.isArray(b.mods) ? { mods: b.mods.filter((m) => m && typeof m.id === "string"), staged, proposals } : { failed: true });
205
+ };
206
+ useEffect(() => { load(); }, []);
207
+ if (!state) return html`<section class="card s-appearance s-mods"><h3>${tn("modsTitle")}</h3><p class="sub">…</p></section>`;
208
+ if (state.failed) return html`<section class="card s-appearance s-mods"><h3>${tn("modsTitle")}</h3><p class="err">${tn("modsLoadFailed")}</p></section>`;
209
+ const stage = async () => {
210
+ const f = file.current && file.current.files && file.current.files[0];
211
+ if (!f) return;
212
+ setBusy(true); setError("");
213
+ const r = await call("/ui/mods/stage", { method: "POST", headers: { "Content-Type": "application/json" }, body: f });
214
+ setBusy(false);
215
+ if (file.current) file.current.value = "";
216
+ if (!r.ok) { setError(why(r.body)); return; }
217
+ setState({ ...state, staged: r.body });
218
+ };
219
+ const review = async (p) => {
220
+ const r = await postJson("/ui/mods/proposal/stage", { instance: p.instance });
221
+ if (!r.ok) { setError(why(r.body)); return; }
222
+ setState({ ...state, staged: r.body });
223
+ };
224
+ const dismiss = async (p) => {
225
+ const r = await postJson("/ui/mods/proposal/dismiss", { instance: p.instance });
226
+ if (!r.ok) { toast(why(r.body), false); return; }
227
+ load();
228
+ };
229
+ const uninstall = async (m) => {
230
+ if (!(await confirmDialog({ message: tn("modUninstallConfirm", m.title), confirmLabel: tn("modUninstall"), danger: true }))) return;
231
+ const r = await postJson("/ui/mods/uninstall", { id: m.id });
232
+ if (!r.ok) { toast(why(r.body), false); return; }
233
+ toast(tn("modUninstalled", m.title));
234
+ load();
235
+ };
236
+ const fetchFile = async (m) => {
237
+ const r = await fetch(`/ui/mods/file/${m.id}`, { credentials: "same-origin" }).catch(() => null);
238
+ if (!r || !r.ok) { toast(tn("modFailed", r ? r.status : "network"), false); return; }
239
+ download(await r.text(), `${m.name}-${m.version}.agendmod.json`);
240
+ };
241
+ return html`<section class="card s-appearance s-mods"><h3>${tn("modsTitle")}</h3>
242
+ <p class="sub">${tn("modsLead")}</p>
243
+ ${state.staged ? html`<${Plan} key=${state.staged.stage} staged=${state.staged} onDone=${load} />` : html`<div class="s-mod-import">
244
+ <label class="btn"><${Icon} name="attach" size=${16} /><span>${tn("modChoose")}</span>
245
+ <input ref=${file} type="file" accept=".json,application/json" class="sr-only" disabled=${busy} onChange=${stage} /></label></div>`}
246
+ ${error ? html`<p class="err s-mod-err" role="alert">${error}</p>` : null}
247
+ ${!state.staged && (state.proposals || []).length ? html`<div class="s-mod-proposals"><h4>${tn("modProposals")}</h4><ul class="s-mod-list">${state.proposals.map((p) => html`<li key=${p.instance}>
248
+ <span class="grow">${tn("modProposedBy", p.instance)} <strong>${p.title}</strong> <span class="muted">${p.name} ${p.version}</span></span>
249
+ <button type="button" class="btn btn-sm" onClick=${() => review(p)}>${tn("modReview")}</button>
250
+ <button type="button" class="btn btn-sm" onClick=${() => dismiss(p)}>${tn("modDismiss")}</button></li>`)}</ul></div>` : null}
251
+ ${state.mods.length ? html`<ul class="s-mod-list">${state.mods.map((m) => html`<li key=${m.id}>
252
+ <span class="grow"><strong>${m.title}</strong> <span class="muted">${m.name} ${m.version}${m.target ? ` · ${tn("modAppliedTo", m.target)}` : ""}</span></span>
253
+ <button type="button" class="btn btn-sm" onClick=${() => fetchFile(m)}>${tn("modDownload")}</button>
254
+ <button type="button" class="btn btn-sm danger" onClick=${() => uninstall(m)}>${tn("modUninstall")}</button></li>`)}</ul>` : html`<p class="sub">${tn("modsNone")}</p>`}
255
+ <${ExportForm} />
256
+ </section>`;
257
+ }
@@ -0,0 +1,80 @@
1
+ // #1629: authoritative selectors and local slot controls; the app owns all playback and resource reads.
2
+ import { html, useEffect, useRef, useState } from "/assets/app-html.js";
3
+ import { register, t } from "/assets/app-i18n.js";
4
+ import { useStore } from "/assets/app-store.js";
5
+ import { OWNER_EFFECT_PREF_KEYS } from "/assets/app-prefs.js";
6
+ import { getAppEffects } from "./app-effects.js";
7
+ import { ownerEffectsStore, watchOwnerEffects, refreshOwnerEffects, saveOwnerEffects } from "/assets/owner-effect-prefs.js";
8
+ register("ownerEffects", {
9
+ en: {
10
+ title: "Your effect choices", operatorTitle: "Host-wide effect choices", person: "These choices follow this platform sign-in. Discord and Telegram accounts stay separate.",
11
+ operator: "Host-wide choices: everyone signed in as the host operator shares these slots.",
12
+ sidebar: "Sidebar", header: "Header", page: "Page", box: "Box", cursor: "Cursor", none: "None", savedChoice: "Saved choice",
13
+ unknown: "Choices are unavailable. Reload to try again.", changed: "This page can no longer access these choices. Reload and sign in again.",
14
+ unavailable: "No longer available", clear: "Clear", reload: "Reload choices", saved: "Saved.", failed: "Couldn't save. Reload choices and try again.",
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: "Playback requires Effects and preview consent. The cursor follows the same consent and falls back to stock cursors when unavailable.",
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 protect page responsiveness. Resume keeps the three-pauses safety limit.", resumeFailed: "This effect cannot resume: the page or its safety history is not ready. Reload the page or choose another effect.",
18
+ },
19
+ "zh-TW": {
20
+ title: "你的效果選擇", operatorTitle: "主機共用效果選擇", person: "選擇會跟隨這個平台的登入身分;Discord 與 Telegram 帳號各自獨立。",
21
+ operator: "主機共用選擇:所有以主機操作者身分登入的人,都會共用這些欄位。",
22
+ sidebar: "側邊欄", header: "頁首", page: "頁面", box: "區塊", cursor: "游標", none: "無", savedChoice: "已儲存的選擇",
23
+ unknown: "目前無法取得選擇,請重新載入再試。", changed: "這個頁面已無法存取選擇,請重新載入並登入。",
24
+ unavailable: "已無法使用", clear: "清除", reload: "重新載入選擇", saved: "已儲存。", failed: "無法儲存,請重新載入選擇再試。",
25
+ savedRefresh: "已儲存,但無法更新畫面;請重新載入確認。", readOnly: "請以管理員身分登入才能修改選擇。",
26
+ playback: "開啟效果並允許預覽後才會播放;游標也遵循相同的同意設定;無法使用時會還原一般游標。",
27
+ pause: "暫停", resume: "恢復", running: "播放中", cursorFailed: "這裡無法套用游標。可清除或重新載入選擇後再試。", starting: "啟動中", queued: "等待空位", paused: "已暫停", off: "這裡已關閉播放,或無法播放效果。", budget: "為維持頁面順暢,已暫停效果。恢復仍保留三次暫停的安全上限。", resumeFailed: "頁面或安全紀錄尚未就緒,無法恢復此效果。請重新載入頁面,或選擇其他效果。",
28
+ },
29
+ });
30
+ const text = key => t(`ownerEffects.${key}`);
31
+ export function ownerEffectFits(manifest, key) {
32
+ if (!manifest || typeof manifest !== "object" || !OWNER_EFFECT_PREF_KEYS.includes(key)) return false;
33
+ if (key === "fx.cursor") return !!manifest.cursor;
34
+ const region = key.slice(3);
35
+ return Array.isArray(manifest.regions) && manifest.regions.includes(region) && (region !== "box" || !!manifest.box);
36
+ }
37
+ export function OwnerEffects({ catalog, busy = false }) {
38
+ const state = useStore(ownerEffectsStore), [message, setMessage] = useState(null), life = useRef({ alive: false, seq: 0 }), [runtime, setRuntime] = useState(null);
39
+ useEffect(() => { life.current.alive = true; const stop = watchOwnerEffects(); return () => { life.current.alive = false; life.current.seq++; stop(); }; }, []);
40
+ useEffect(() => {
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 }); };
43
+ const off = entry?.subscribe(pull), end = entry?.onEnd(pull); pull();
44
+ return () => { live = false; off?.(); end?.(); };
45
+ }, []);
46
+ const reload = () => { getAppEffects()?.api?.changedOwner?.(); return refreshOwnerEffects(); };
47
+ // Library reloads/own share changes can alter availability without changing the preference revision.
48
+ useEffect(() => { if (catalog) reload(); }, [catalog]);
49
+ if (state.scope === "none") return null;
50
+ const disabled = busy || state.busy || state.status !== "known" || state.revoked || !catalog?.canWrite;
51
+ const save = async (key, id) => {
52
+ if (disabled || !life.current.alive) return;
53
+ if (id !== null && !(catalog.effects || []).some(e => e.id === id && ownerEffectFits(e.manifest, key))) return;
54
+ const seq = ++life.current.seq; setMessage(null);
55
+ const result = await saveOwnerEffects({ [key]: id });
56
+ if (!life.current.alive || seq !== life.current.seq) return;
57
+ if (result.ok || result.saved) getAppEffects()?.api?.changedOwner?.();
58
+ setMessage(text(result.ok ? "saved" : result.saved ? "savedRefresh" : "failed"));
59
+ };
60
+ return html`<section class="s-fx-owner" aria-label=${text(state.scope === "operator" ? "operatorTitle" : "title")}><h4>${text(state.scope === "operator" ? "operatorTitle" : "title")}</h4><p class="sub">${text(state.scope === "operator" ? "operator" : "person")}</p>
61
+ <p class="sub">${text("playback")}</p>${catalog && !catalog.canWrite ? html`<p class="sub">${text("readOnly")}</p>` : null}
62
+ <button class="btn" type="button" disabled=${busy || state.busy || state.revoked} onClick=${reload}>${text("reload")}</button>
63
+ ${state.status !== "known" ? html`<p class="sub" role="status">${text(state.revoked ? "changed" : "unknown")}</p>` : OWNER_EFFECT_PREF_KEYS.map(key => {
64
+ const row = state.slots[key], options = (catalog?.effects || []).filter(e => ownerEffectFits(e.manifest, key));
65
+ const absent = !!row.id && !options.some(e => e.id === row.id);
66
+ const play = runtime?.snapshot?.rows?.find(r => r.slot === key && r.id === row.id);
67
+ const unavailable = row.status === "unavailable" || runtime?.snapshot?.slots?.[key]?.id === row.id && runtime.snapshot.slots[key].status === "unavailable";
68
+ const phase = !runtime?.available ? "off" : play?.reason === "paused" || play?.reason === "host_budget" ? "paused" : play?.phase === "running" ? "running" : play?.phase ? "starting" : play?.verdict === "queued" ? "queued" : "off";
69
+ return html`<div class="s-fx-owner-row" key=${key}><label>${text(key.slice(3))}<select class="s-fx-target" data-owner-slot=${key} disabled=${disabled} value=${row.id || ""} onChange=${e => save(key, e.target.value || null)}>
70
+ <option value="">${text("none")}</option>${absent ? html`<option value=${row.id} disabled>${text(row.status === "unavailable" ? "unavailable" : "savedChoice")}</option>` : null}
71
+ ${options.map(e => html`<option key=${e.id} value=${e.id}>${e.manifest.title}</option>`)}</select></label>
72
+ ${unavailable ? html`<span class="sub" role="status">${text("unavailable")}</span>` : null}
73
+ ${key === "fx.cursor" && row.status === "available" && row.id && !unavailable ? html`<span class="sub" role="status" data-owner-runtime=${key}>${text(!runtime?.available ? "off" : runtime.cursor?.id === row.id && runtime.cursor.status === "running" ? "running" : runtime.cursor?.id === row.id && runtime.cursor.status === "loading" ? "starting" : runtime.cursor?.id === row.id && runtime.cursor.status === "unavailable" ? "cursorFailed" : "off")}</span>` : null}
74
+ ${key !== "fx.cursor" && row.status === "available" && row.id && !unavailable ? html`<span class="sub" role="status" data-owner-runtime=${key}>${text(phase)}</span>
75
+ ${play?.reason === "host_budget" ? html`<span class="sub">${text("budget")}</span>` : null}
76
+ <button class="btn" type="button" data-owner-pause=${key} disabled=${!runtime?.available || !play} onClick=${() => runtime?.entry?.api?.pauseOwner?.(key)}>${text("pause")}</button>
77
+ <button class="btn" type="button" data-owner-resume=${key} disabled=${!runtime?.available || !play} onClick=${() => { if (!runtime?.entry?.api?.resumeOwner?.(key)) setMessage(text("resumeFailed")); }}>${text("resume")}</button>` : null}
78
+ ${row.id ? html`<button class="btn" type="button" data-owner-clear=${key} disabled=${disabled} onClick=${() => save(key, null)}>${text("clear")}</button>` : null}</div>`;
79
+ })}${message ? html`<p class="sub" role="status">${message}</p>` : null}</section>`;
80
+ }
@@ -288,6 +288,129 @@ register("settings", {
288
288
  accessTitle: "Access",
289
289
  webChat: "Web chat",
290
290
  sec_web: "Web",
291
+ // #1629 r4 §3: Settings → Appearance (owner CSS).
292
+ sec_appearance: "Web appearance",
293
+ colorTitle: "Identity colours", colorLead: "Each agent's colour in the sidebar: a person's pick, the agent's own, its bot's Discord role colour, or an automatic soft colour. Change one agent's from its ⋯ menu → Colour….",
294
+ colorPlaces: "Where colours show, unless an agent's own setting says otherwise", colorOff: "Turn off colours",
295
+ colorOffLead: "Hides every identity colour on this host. The colours people picked are kept, and come back when you turn colours on again.",
296
+ restoreTitle: "Restore defaults", restoreLead: "Puts this host's appearance settings back to their defaults. It asks first and says what it resets.",
297
+ restoreConfirm: "This resets, for this host: showing agents' themes and effects, the card size and each card's own size, your sidebar widget, where colours show, and whether colours are off. It also turns your custom CSS off (its versions are kept) and its switch on this device. The colours people picked for agents, installed mods, uploaded effects and the card limits stay as they are.",
298
+ restoreGo: "Restore defaults", restoreDone: "Appearance settings are back to their defaults.", restoreCssFailed: "The settings are back to their defaults, but your custom CSS could not be turned off: {0}",
299
+ cardSizeTitle: "Identity cards",
300
+ cardSizeLead: "How large identity cards are drawn on all your devices. A card's own S / M / L sets that card apart.",
301
+ cardLimitsTitle: "Identity card upload limits",
302
+ cardLimitsLead: "The largest portrait and banner anyone may upload, and how many pictures each agent may keep. Empty = the default (shown greyed). A lower limit applies at once; a higher one waits for a fleet admin's confirmation. Pictures already stored stay.",
303
+ cardLimitsUnavailable: "Only a signed-in admin can see and change these here (or edit web.appearance in fleet.yaml).",
304
+ cardLimit_portrait_max_width: "Portrait width (px)", cardLimit_portrait_max_height: "Portrait height (px)", cardLimit_portrait_max_kib: "Portrait size (KiB)",
305
+ cardLimit_banner_max_width: "Banner width (px)", cardLimit_banner_max_height: "Banner height (px)", cardLimit_banner_max_kib: "Banner size (KiB)",
306
+ cardLimit_instance_quota_files: "Pictures per agent", cardLimit_instance_quota_mib: "Picture space per agent (MiB)",
307
+ cardLimitInForce: "In force: {0} (allowed {1}–{2})",
308
+ cardLimitsWhole: "Each limit is a whole number, or empty for the default.",
309
+ cardLimitsNoChange: "Nothing changed.",
310
+ cardLimitsSave: "Save limits",
311
+ cardLimitsSaved: "Saved.",
312
+ cardLimitsFailed: "Not saved: {0}",
313
+ themesTitle: "Agents' themes",
314
+ themesShow: "Show agents' themes, effects and widgets",
315
+ themesShowLead: "On all your devices. When it's off, every chat looks the same, and agents' pictures and ring colours are their bots' again.",
316
+ themesSafeMode: "This page was opened with ?safe=1: agents' themes are not shown on it.",
317
+ widgetTitle: "Your sidebar widget",
318
+ widgetLead: "A small panel under the instance list, on all your devices: the dashboard draws it from a spec and values you write here as JSON — no HTML, script or CSS. Its live values are your fleet's counts (fleet.working, fleet.needs_you, fleet.idle, fleet.stopped). Agents can't change it.",
319
+ widgetNotJson: "Not JSON.",
320
+ widgetSave: "Save widget",
321
+ widgetSaved: "Sidebar widget saved.",
322
+ widgetExample: "Start from an example",
323
+ widgetRemove: "Remove widget",
324
+ widgetRemoved: "Sidebar widget removed.",
325
+ modsTitle: "Mods",
326
+ modsLead: "A mod is one file (.agendmod.json) that carries a look: an agent's theme and pictures, its card text, widgets, effects, and custom CSS. Importing shows everything in it first; only what you tick is applied, all at once. Uninstalling removes the mod from this list; what it applied stays until you reset it.",
327
+ modsLoadFailed: "Couldn't load the installed mods. Reload the page to try again.",
328
+ modsNone: "No mods installed.",
329
+ modChoose: "Import a mod file…",
330
+ modPlan: "What this mod would change",
331
+ modAuthor: "By {0} (unverified)",
332
+ modSizeId: "{0} · id {1}",
333
+ modAlready: "already installed",
334
+ modReplace: "Replace the installed version {0}",
335
+ modPartTheme: "Theme (colours, font, pictures)",
336
+ modPartIdentity: "Card text (tagline and bio)",
337
+ modPartWidget_chat_header: "Chat header widget",
338
+ modPartWidget_details: "Details page widget",
339
+ modPartWidget_sidebar: "Your sidebar widget",
340
+ modPartEffect: "Effect: {0}",
341
+ modPartCss: "Custom CSS (on your screens only)",
342
+ modCssWarn: "Custom CSS from this file — read it before turning it on.",
343
+ modCssAssets: "{0} files for it ({1})",
344
+ modAccent: "Accent", modFont: "Font", modPictures: "{0} pictures ({1})", modPlaces: "places {0} effects",
345
+ modColorsLight: "Light colours", modColorsDark: "Dark colours", modBubble: "Bubbles", modWorkingLabel: "Working label",
346
+ modBackground: "Background picture", modBackgroundFit: "Background fit and opacity", modAvatar: "Picture (face)",
347
+ modPlacesEffect: "Places effect", modAllFields: "Every field, as it will be applied",
348
+ modStageGone: "That import was replaced or ran out of time. Showing what is staged now.",
349
+ modRegions: "draws on: {0}", modWants: "hears: {0}",
350
+ modTarget: "Apply to",
351
+ modTargetPick: "Choose an agent…",
352
+ modTargetNeeded: "Choose the agent this look goes to.",
353
+ modAppliedTo: "applied to {0}",
354
+ modInstall: "Install",
355
+ modCancel: "Cancel",
356
+ modInstalled: "Installed {0}.",
357
+ modFailed: "Not done: {0}",
358
+ modDownload: "Download",
359
+ modUninstall: "Uninstall",
360
+ modUninstallConfirm: "Uninstall {0}? Its effects leave your library unless something still uses them. What it applied stays.",
361
+ modUninstalled: "Uninstalled {0}.",
362
+ modExport: "Export a mod",
363
+ modExportLead: "Make a mod file from what is in force now: an agent's theme, card text and widgets, your sidebar widget, your custom CSS.",
364
+ modExportInstance: "Agent",
365
+ modExportName: "Name (a-z, 0-9, -)",
366
+ modExportVersion: "Version",
367
+ modExportTitle: "Title",
368
+ modExportGo: "Download the file",
369
+ modExportOmitted: "{0} effect(s) placed there are someone else's and were left out: only your own and shared effects travel.",
370
+ modProposals: "Proposed by agents",
371
+ modProposedBy: "{0} proposes",
372
+ modReview: "Review",
373
+ modDismiss: "Dismiss",
374
+ cssTitle: "Custom CSS (yours only)",
375
+ cssLead: "Your own stylesheet, applied to your screens only: every device where you are signed in, never anyone else's, and never on Settings. It can't load anything from the internet. Put pictures and fonts under Assets below and name them url(\"asset:<id>\").",
376
+ cssLoadFailed: "Couldn't load your custom CSS. Reload the page to try again.",
377
+ cssPublicLink: "Custom CSS isn't available on a temporary public link.",
378
+ cssInForce: "In force: {0}",
379
+ cssOnTrial: "On trial: {0}. Choose Keep, or it reverts.",
380
+ cssNone: "No custom CSS in force.",
381
+ cssSave: "Save and try",
382
+ cssTrying: "Custom CSS saved. Look around, then choose Keep within 15 seconds.",
383
+ cssOff: "Turn off (all devices)",
384
+ cssVersions: "Earlier versions ({0})",
385
+ cssCurrent: "in force",
386
+ cssRestore: "Use this",
387
+ cssAssets: "Assets",
388
+ cssAssetsLead: "PNG, WebP, JPEG, GIF or WOFF2. {0} of {1} files, {2} in total (at most 1 MiB). Metadata such as GPS is removed.",
389
+ cssAssetAdd: "Add a picture or font",
390
+ cssAssetCopy: "Copy reference",
391
+ cssCopied: "Copied {0}",
392
+ cssAssetRemove: "Remove",
393
+ cssDeviceTitle: "On this device",
394
+ cssDeviceOff: "Turn off custom CSS on this device",
395
+ cssDeviceOffLead: "Only this browser. Your other devices keep it.",
396
+ cssSafe: "Is something hidden or broken? Add ?safe=1 to the end of any page's address and it loads without custom CSS.",
397
+ cssError_at_rule: "Not saved: {0} isn't allowed.",
398
+ cssError_url: "Not saved: the only url() allowed is url(\"asset:<id>\") for a file in Assets.",
399
+ cssError_unknown_asset: "Not saved: there's no asset {0}. Add it under Assets first.",
400
+ cssError_function: "Not saved: {0} isn't allowed.",
401
+ cssError_property: "Not saved: {0} isn't allowed.",
402
+ cssError_script_scheme: "Not saved: javascript: and vbscript: aren't allowed.",
403
+ cssError_too_large: "Not saved: custom CSS can be at most 64 KiB.",
404
+ cssError_malformed: "Not saved: a string or url() isn't closed.",
405
+ cssError_nul: "Not saved: the text contains a NUL character.",
406
+ cssError_unserialisable: "Not saved: the CSS couldn't be read back safely.",
407
+ cssError_asset_type: "Not added: only PNG, WebP, JPEG, GIF or WOFF2 files are allowed.",
408
+ cssError_asset_unreadable: "Not added: the file couldn't be read.",
409
+ cssError_asset_quota: "Not added: Assets hold at most 16 files and 1 MiB in total.",
410
+ cssError_asset_in_use: "Not removed: a saved version still uses it.",
411
+ cssError_owner_css_read_only: "This sign-in can view but not change custom CSS.",
412
+ cssError_owner_css_needs_a_signed_in_person: "Sign in in the browser to change custom CSS.",
413
+ cssError_other: "Not saved ({0}).",
291
414
  webLead: "How the web dashboard behaves. A change that loosens access (longer sessions, the browser terminal back on or for longer, the public link) waits for a fleet admin to confirm it in chat (General topic) or on the host; one that tightens it applies at once.",
292
415
  webPublicRefused: "Web settings can't be changed over a temporary public link. Open the dashboard at its own address to change them.",
293
416
  webSessionTitle: "Sign-in sessions",
@@ -661,6 +784,128 @@ register("settings", {
661
784
  accessTitle: "存取",
662
785
  webChat: "網頁聊天",
663
786
  sec_web: "網頁",
787
+ sec_appearance: "網頁外觀",
788
+ colorTitle: "識別色", colorLead: "每個 agent 在側欄的顏色:有人挑選的、agent 自己的、它的 bot 在 Discord 身分組的顏色,或自動配的柔和色。要改某個 agent 的顏色,從它的 ⋯ 選單 → 顏色…。",
789
+ colorPlaces: "顏色顯示的位置(agent 自己另有設定時除外)", colorOff: "關閉顏色",
790
+ colorOffLead: "在這台主機隱藏所有識別色。有人挑過的顏色會保留,重新開啟顏色後就會回來。",
791
+ restoreTitle: "還原預設值", restoreLead: "把這台主機的外觀設定改回預設值。執行前會先詢問,並說明會重設哪些項目。",
792
+ restoreConfirm: "會重設這台主機的:是否顯示 agent 的主題與效果、卡片大小與各張卡片自己的大小、你的側欄小工具、顏色顯示的位置,以及是否關閉顏色;也會關閉你的自訂 CSS(版本會保留)以及這台裝置上的 CSS 開關。有人替 agent 挑的顏色、已安裝的 mod、上傳的效果與卡片上限都不會變。",
793
+ restoreGo: "還原預設值", restoreDone: "外觀設定已改回預設值。", restoreCssFailed: "設定已改回預設值,但無法關閉你的自訂 CSS:{0}",
794
+ cardSizeTitle: "身分卡",
795
+ cardSizeLead: "身分卡在你所有裝置上的顯示大小。卡片上自己的 小/中/大 可以單獨設定那張卡片。",
796
+ cardLimitsTitle: "身分卡上傳限制",
797
+ cardLimitsLead: "任何人可上傳的肖像與橫幅上限,以及每個 agent 可保留的圖片數量。留空=預設值(以灰字顯示)。調低立即生效;調高需 fleet admin 確認。已存的圖片不受影響。",
798
+ cardLimitsUnavailable: "只有已登入的管理者能在這裡查看與修改(或在 fleet.yaml 編輯 web.appearance)。",
799
+ cardLimit_portrait_max_width: "肖像寬度(px)", cardLimit_portrait_max_height: "肖像高度(px)", cardLimit_portrait_max_kib: "肖像大小(KiB)",
800
+ cardLimit_banner_max_width: "橫幅寬度(px)", cardLimit_banner_max_height: "橫幅高度(px)", cardLimit_banner_max_kib: "橫幅大小(KiB)",
801
+ cardLimit_instance_quota_files: "每個 agent 的圖片數", cardLimit_instance_quota_mib: "每個 agent 的圖片空間(MiB)",
802
+ cardLimitInForce: "實際生效:{0}(允許 {1}–{2})",
803
+ cardLimitsWhole: "每個限制須為整數,或留空使用預設值。",
804
+ cardLimitsNoChange: "沒有變更。",
805
+ cardLimitsSave: "儲存限制",
806
+ cardLimitsSaved: "已儲存。",
807
+ cardLimitsFailed: "未儲存:{0}",
808
+ themesTitle: "Agent 的主題",
809
+ themesShow: "顯示 agent 的主題、效果與小工具",
810
+ themesShowLead: "套用到你所有的裝置。關閉時每個聊天看起來都一樣,agent 的圖片與外框顏色也會換回各自 bot 的。",
811
+ themesSafeMode: "這個頁面是用 ?safe=1 開啟的:不顯示 agent 的主題。",
812
+ widgetTitle: "你的側邊欄小工具",
813
+ widgetLead: "實例清單下方的一個小面板,套用到你所有的裝置:儀表板依你在這裡用 JSON 寫的規格與數值繪製——沒有 HTML、腳本或 CSS。即時數值是你 fleet 的計數(fleet.working、fleet.needs_you、fleet.idle、fleet.stopped)。agent 無法變更它。",
814
+ widgetNotJson: "不是 JSON。",
815
+ widgetSave: "儲存小工具",
816
+ widgetSaved: "已儲存側邊欄小工具。",
817
+ widgetExample: "從範例開始",
818
+ widgetRemove: "移除小工具",
819
+ widgetRemoved: "已移除側邊欄小工具。",
820
+ modsTitle: "Mod",
821
+ modsLead: "Mod 是一個檔案(.agendmod.json),帶著一套外觀:agent 的主題與圖片、身分卡文字、小工具、效果,以及自訂 CSS。匯入時會先完整列出內容,只有你勾選的部分會一次套用。解除安裝會把 mod 從這個清單移除;它已套用的外觀會保留,直到你重設。",
822
+ modsLoadFailed: "無法載入已安裝的 mod。請重新載入頁面再試一次。",
823
+ modsNone: "尚未安裝任何 mod。",
824
+ modChoose: "匯入 mod 檔案…",
825
+ modPlan: "這個 mod 會變更的內容",
826
+ modAuthor: "作者:{0}(未經驗證)",
827
+ modSizeId: "{0} · id {1}",
828
+ modAlready: "已安裝",
829
+ modReplace: "取代已安裝的 {0} 版",
830
+ modPartTheme: "主題(顏色、字型、圖片)",
831
+ modPartIdentity: "身分卡文字(標語與簡介)",
832
+ modPartWidget_chat_header: "聊天標頭小工具",
833
+ modPartWidget_details: "詳細資料頁小工具",
834
+ modPartWidget_sidebar: "你的側邊欄小工具",
835
+ modPartEffect: "效果:{0}",
836
+ modPartCss: "自訂 CSS(只套用在你的畫面)",
837
+ modCssWarn: "這是檔案裡的自訂 CSS——開啟前請先讀過。",
838
+ modCssAssets: "附帶 {0} 個檔案({1})",
839
+ modAccent: "強調色", modFont: "字型", modPictures: "{0} 張圖片({1})", modPlaces: "放置 {0} 個效果",
840
+ modColorsLight: "淺色模式顏色", modColorsDark: "深色模式顏色", modBubble: "對話泡泡", modWorkingLabel: "工作中文字",
841
+ modBackground: "背景圖片", modBackgroundFit: "背景填滿方式與不透明度", modAvatar: "圖片(頭像)",
842
+ modPlacesEffect: "放置效果", modAllFields: "所有欄位(套用時的樣子)",
843
+ modStageGone: "那次匯入已被取代或已逾時。以下顯示目前暫存的內容。",
844
+ modRegions: "繪製於:{0}", modWants: "接收:{0}",
845
+ modTarget: "套用到",
846
+ modTargetPick: "選擇一個 agent…",
847
+ modTargetNeeded: "請選擇這套外觀要套用到哪個 agent。",
848
+ modAppliedTo: "套用於 {0}",
849
+ modInstall: "安裝",
850
+ modCancel: "取消",
851
+ modInstalled: "已安裝 {0}。",
852
+ modFailed: "未完成:{0}",
853
+ modDownload: "下載",
854
+ modUninstall: "解除安裝",
855
+ modUninstallConfirm: "要解除安裝 {0} 嗎?它的效果會離開你的效果庫,除非還有東西在使用。它已套用的外觀會保留。",
856
+ modUninstalled: "已解除安裝 {0}。",
857
+ modExport: "匯出 mod",
858
+ modExportLead: "用目前生效的外觀做成 mod 檔案:某個 agent 的主題、身分卡文字與小工具、你的側邊欄小工具、你的自訂 CSS。",
859
+ modExportInstance: "Agent",
860
+ modExportName: "名稱(a-z、0-9、-)",
861
+ modExportVersion: "版本",
862
+ modExportTitle: "標題",
863
+ modExportGo: "下載檔案",
864
+ modExportOmitted: "有 {0} 個放在那裡的效果屬於別人,已略過:只有你自己的和共用的效果會被帶走。",
865
+ modProposals: "Agent 提議的 mod",
866
+ modProposedBy: "{0} 提議",
867
+ modReview: "檢視",
868
+ modDismiss: "略過",
869
+ cssTitle: "自訂 CSS(只套用在你自己)",
870
+ cssLead: "你自己的樣式表,只套用在你自己的畫面:你登入的每一台裝置都會套用,不會影響別人,也不會套用在設定頁。它無法從網路載入任何東西;圖片和字型請放在下方的「資源」,再用 url(\"asset:<id>\") 引用。",
871
+ cssLoadFailed: "無法載入你的自訂 CSS。請重新整理頁面再試一次。",
872
+ cssPublicLink: "臨時公開連結無法使用自訂 CSS。",
873
+ cssInForce: "使用中:{0}",
874
+ cssOnTrial: "試用中:{0}。請按「保留」,否則會還原。",
875
+ cssNone: "目前沒有使用自訂 CSS。",
876
+ cssSave: "儲存並試用",
877
+ cssTrying: "已儲存自訂 CSS。請到各頁看看,15 秒內按「保留」。",
878
+ cssOff: "關閉(所有裝置)",
879
+ cssVersions: "先前的版本({0})",
880
+ cssCurrent: "使用中",
881
+ cssRestore: "改用這版",
882
+ cssAssets: "資源",
883
+ cssAssetsLead: "PNG、WebP、JPEG、GIF 或 WOFF2。目前 {0} / {1} 個檔案,共 {2}(上限 1 MiB)。會移除 GPS 等中繼資料。",
884
+ cssAssetAdd: "加入圖片或字型",
885
+ cssAssetCopy: "複製引用",
886
+ cssCopied: "已複製 {0}",
887
+ cssAssetRemove: "移除",
888
+ cssDeviceTitle: "這台裝置",
889
+ cssDeviceOff: "在這台裝置關閉自訂 CSS",
890
+ cssDeviceOffLead: "只影響這個瀏覽器,你的其他裝置照常套用。",
891
+ cssSafe: "畫面被藏起來或壞掉了嗎?在任何頁面網址的最後加上 ?safe=1,就會不套用自訂 CSS 地載入。",
892
+ cssError_at_rule: "未儲存:不允許使用 {0}。",
893
+ cssError_url: "未儲存:url() 只能用 url(\"asset:<id>\") 引用「資源」裡的檔案。",
894
+ cssError_unknown_asset: "未儲存:找不到資源 {0}。請先在「資源」加入它。",
895
+ cssError_function: "未儲存:不允許使用 {0}。",
896
+ cssError_property: "未儲存:不允許使用 {0}。",
897
+ cssError_script_scheme: "未儲存:不允許使用 javascript: 和 vbscript:。",
898
+ cssError_too_large: "未儲存:自訂 CSS 最多 64 KiB。",
899
+ cssError_malformed: "未儲存:有字串或 url() 沒有結束。",
900
+ cssError_nul: "未儲存:內容含有 NUL 字元。",
901
+ cssError_unserialisable: "未儲存:這份 CSS 無法安全地重新輸出。",
902
+ cssError_asset_type: "未加入:只接受 PNG、WebP、JPEG、GIF 或 WOFF2 檔案。",
903
+ cssError_asset_unreadable: "未加入:無法讀取這個檔案。",
904
+ cssError_asset_quota: "未加入:資源最多 16 個檔案、合計 1 MiB。",
905
+ cssError_asset_in_use: "未移除:仍有已儲存的版本在使用它。",
906
+ cssError_owner_css_read_only: "這個登入只能檢視、不能變更自訂 CSS。",
907
+ cssError_owner_css_needs_a_signed_in_person: "請在瀏覽器登入後再變更自訂 CSS。",
908
+ cssError_other: "未儲存({0})。",
664
909
  webLead: "網頁儀表板的行為。放寬存取的變更(延長 session、重新開啟或延長瀏覽器終端機、公開連結)需要 fleet admin 在聊天(General topic)或主機上確認;收緊的變更會立即套用。",
665
910
  webPublicRefused: "無法透過臨時公開連結變更網頁設定。請從儀表板本身的網址開啟後再變更。",
666
911
  webSessionTitle: "登入 session",