@songsid/agend 2.2.0 → 2.3.0-beta.1

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 (271) 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 +30 -0
  7. package/dist/agent-file-read.js +129 -0
  8. package/dist/agent-file-read.js.map +1 -0
  9. package/dist/agent-whats-new.js +13 -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/launch-effort.js +1 -1
  18. package/dist/backend/launch-effort.js.map +1 -1
  19. package/dist/backend/muse.js +10 -2
  20. package/dist/backend/muse.js.map +1 -1
  21. package/dist/channel/adapters/discord.d.ts +2 -0
  22. package/dist/channel/adapters/discord.js +17 -2
  23. package/dist/channel/adapters/discord.js.map +1 -1
  24. package/dist/channel/adapters/telegram.d.ts +8 -1
  25. package/dist/channel/adapters/telegram.js +74 -4
  26. package/dist/channel/adapters/telegram.js.map +1 -1
  27. package/dist/channel/mcp-tools.js +34 -0
  28. package/dist/channel/mcp-tools.js.map +1 -1
  29. package/dist/channel/types.d.ts +15 -0
  30. package/dist/config-validator.js +17 -1
  31. package/dist/config-validator.js.map +1 -1
  32. package/dist/daemon.js +244 -20
  33. package/dist/daemon.js.map +1 -1
  34. package/dist/fleet-context.d.ts +5 -0
  35. package/dist/fleet-manager.d.ts +89 -0
  36. package/dist/fleet-manager.js +401 -25
  37. package/dist/fleet-manager.js.map +1 -1
  38. package/dist/general-knowledge/skills/web-appearance/SKILL.md +258 -0
  39. package/dist/instance-avatar.js +3 -0
  40. package/dist/instance-avatar.js.map +1 -1
  41. package/dist/instance-config-impact.js +3 -0
  42. package/dist/instance-config-impact.js.map +1 -1
  43. package/dist/instance-lifecycle.js +9 -4
  44. package/dist/instance-lifecycle.js.map +1 -1
  45. package/dist/needs-you-hub.d.ts +4 -0
  46. package/dist/needs-you-hub.js +31 -6
  47. package/dist/needs-you-hub.js.map +1 -1
  48. package/dist/needs-you.d.ts +1 -13
  49. package/dist/needs-you.js +3 -37
  50. package/dist/needs-you.js.map +1 -1
  51. package/dist/outbound-handlers.d.ts +5 -0
  52. package/dist/outbound-handlers.js +30 -9
  53. package/dist/outbound-handlers.js.map +1 -1
  54. package/dist/outbound-schemas.d.ts +64 -0
  55. package/dist/outbound-schemas.js +50 -0
  56. package/dist/outbound-schemas.js.map +1 -1
  57. package/dist/public-web-gateway.js +14 -0
  58. package/dist/public-web-gateway.js.map +1 -1
  59. package/dist/repo-worktree-guard.d.ts +36 -0
  60. package/dist/repo-worktree-guard.js +143 -0
  61. package/dist/repo-worktree-guard.js.map +1 -0
  62. package/dist/scheduler/scheduler.d.ts +58 -1
  63. package/dist/scheduler/scheduler.js +176 -10
  64. package/dist/scheduler/scheduler.js.map +1 -1
  65. package/dist/settings-api.js +74 -1
  66. package/dist/settings-api.js.map +1 -1
  67. package/dist/settings-change.js +8 -0
  68. package/dist/settings-change.js.map +1 -1
  69. package/dist/settings-effect.js +16 -2
  70. package/dist/settings-effect.js.map +1 -1
  71. package/dist/settings-http-confirmation.js +5 -0
  72. package/dist/settings-http-confirmation.js.map +1 -1
  73. package/dist/tool-permissions.js +50 -0
  74. package/dist/tool-permissions.js.map +1 -1
  75. package/dist/topic-commands.d.ts +10 -6
  76. package/dist/topic-commands.js +21 -17
  77. package/dist/topic-commands.js.map +1 -1
  78. package/dist/topic-link.d.ts +128 -0
  79. package/dist/topic-link.js +269 -0
  80. package/dist/topic-link.js.map +1 -0
  81. package/dist/types.d.ts +13 -0
  82. package/dist/ui/app-effects.js +187 -0
  83. package/dist/ui/chat-effect-posters.js +42 -0
  84. package/dist/ui/chat-effects.js +72 -0
  85. package/dist/ui/chat-live-replies.js +58 -0
  86. package/dist/ui/chat-render.js +4 -4
  87. package/dist/ui/chat-store.js +9 -3
  88. package/dist/ui/chat-strings.js +15 -0
  89. package/dist/ui/chat-thread.js +47 -21
  90. package/dist/ui/chat-tour.js +1 -1
  91. package/dist/ui/effect-trial-controller.js +119 -0
  92. package/dist/ui/effect-trial-offer.js +31 -0
  93. package/dist/ui/effect-trial-panel.js +37 -0
  94. package/dist/ui/image-lightbox.js +1 -1
  95. package/dist/ui/panel-chat.js +85 -17
  96. package/dist/ui/panel-details.js +18 -5
  97. package/dist/ui/panel-fleet.js +3 -3
  98. package/dist/ui/panel-log.js +6 -6
  99. package/dist/ui/panel-needs.js +3 -3
  100. package/dist/ui/panel-settings.js +8 -5
  101. package/dist/ui/preview-panel.js +25 -9
  102. package/dist/ui/settings-appearance.js +325 -0
  103. package/dist/ui/settings-effects.js +201 -0
  104. package/dist/ui/settings-mods.js +257 -0
  105. package/dist/ui/settings-owner-effects.js +80 -0
  106. package/dist/ui/settings-strings.js +245 -0
  107. package/dist/ui/shared/agend-auth.js +27 -3
  108. package/dist/ui/shared/agent-widget.js +248 -0
  109. package/dist/ui/shared/app-i18n.js +2 -0
  110. package/dist/ui/shared/app-prefs.js +30 -10
  111. package/dist/ui/shared/app-route.js +23 -1
  112. package/dist/ui/shared/app-session.js +1 -1
  113. package/dist/ui/shared/app-shell.js +29 -16
  114. package/dist/ui/shared/app-store.js +2 -0
  115. package/dist/ui/shared/app-stream.js +12 -7
  116. package/dist/ui/shared/app-themes.js +188 -0
  117. package/dist/ui/shared/app.css +214 -1
  118. package/dist/ui/shared/app.js +6 -0
  119. package/dist/ui/shared/card-strings.js +28 -0
  120. package/dist/ui/shared/effect-app-runtime.js +248 -0
  121. package/dist/ui/shared/effect-baseline.js +64 -0
  122. package/dist/ui/shared/effect-chat-runtime.js +154 -0
  123. package/dist/ui/shared/effect-cursor.js +125 -0
  124. package/dist/ui/shared/effect-frame.js +127 -0
  125. package/dist/ui/shared/effect-geometry.js +122 -0
  126. package/dist/ui/shared/effect-host-style.js +10 -0
  127. package/dist/ui/shared/effect-owner-regions.js +74 -0
  128. package/dist/ui/shared/effect-owner-source.js +115 -0
  129. package/dist/ui/shared/effect-policy.js +137 -0
  130. package/dist/ui/shared/effect-poster-reader.js +98 -0
  131. package/dist/ui/shared/effect-posters.js +117 -0
  132. package/dist/ui/shared/effect-profiler.js +162 -0
  133. package/dist/ui/shared/effect-profiles.js +65 -0
  134. package/dist/ui/shared/effect-registry.js +496 -0
  135. package/dist/ui/shared/effect-runtime-loader.js +234 -0
  136. package/dist/ui/shared/effect-safety.js +153 -0
  137. package/dist/ui/shared/effect-stock-snapshot.js +34 -0
  138. package/dist/ui/shared/effect-stock-trust.js +165 -0
  139. package/dist/ui/shared/effect-trial-source.js +103 -0
  140. package/dist/ui/shared/effect-trial-stage.js +147 -0
  141. package/dist/ui/shared/header-tools.js +1 -1
  142. package/dist/ui/shared/identity-card.js +202 -0
  143. package/dist/ui/shared/identity-color.js +261 -0
  144. package/dist/ui/shared/instance-nav.js +22 -14
  145. package/dist/ui/shared/owner-css.js +221 -0
  146. package/dist/ui/shared/owner-effect-prefs.js +117 -0
  147. package/dist/ui/shared/panel-view.js +81 -8
  148. package/dist/ui/shared/signin.js +2 -0
  149. package/dist/ui/shared/ui-dialog.js +1 -1
  150. package/dist/ui/shared/ui-icons.js +2 -0
  151. package/dist/ui/shared/ui-menu.js +1 -1
  152. package/dist/ui/shared/ui-toast.js +1 -1
  153. package/dist/ui/shared/view-strings.js +30 -0
  154. package/dist/ui/signin.html +1 -0
  155. package/dist/view-api.d.ts +29 -0
  156. package/dist/view-api.js +188 -8
  157. package/dist/view-api.js.map +1 -1
  158. package/dist/web-api.d.ts +42 -0
  159. package/dist/web-api.js +896 -36
  160. package/dist/web-api.js.map +1 -1
  161. package/dist/web-appearance-limits.d.ts +82 -0
  162. package/dist/web-appearance-limits.js +83 -0
  163. package/dist/web-appearance-limits.js.map +1 -0
  164. package/dist/web-appearance-policy.d.ts +66 -0
  165. package/dist/web-appearance-policy.js +94 -0
  166. package/dist/web-appearance-policy.js.map +1 -0
  167. package/dist/web-appearance-tools.d.ts +50 -0
  168. package/dist/web-appearance-tools.js +105 -0
  169. package/dist/web-appearance-tools.js.map +1 -0
  170. package/dist/web-appearance-view.d.ts +99 -0
  171. package/dist/web-appearance-view.js +163 -0
  172. package/dist/web-appearance-view.js.map +1 -0
  173. package/dist/web-appearance.d.ts +79 -0
  174. package/dist/web-appearance.js +298 -0
  175. package/dist/web-appearance.js.map +1 -0
  176. package/dist/web-asset-parts.d.ts +98 -0
  177. package/dist/web-asset-parts.js +133 -0
  178. package/dist/web-asset-parts.js.map +1 -0
  179. package/dist/web-auth.js +17 -1
  180. package/dist/web-auth.js.map +1 -1
  181. package/dist/web-card.d.ts +96 -0
  182. package/dist/web-card.js +232 -0
  183. package/dist/web-card.js.map +1 -0
  184. package/dist/web-chat-history.d.ts +13 -0
  185. package/dist/web-chat-history.js +23 -2
  186. package/dist/web-chat-history.js.map +1 -1
  187. package/dist/web-effect-bundle.d.ts +28 -0
  188. package/dist/web-effect-bundle.js +126 -0
  189. package/dist/web-effect-bundle.js.map +1 -0
  190. package/dist/web-effect-canonical.d.ts +8 -0
  191. package/dist/web-effect-canonical.js +27 -0
  192. package/dist/web-effect-canonical.js.map +1 -0
  193. package/dist/web-effect-library.d.ts +114 -0
  194. package/dist/web-effect-library.js +530 -0
  195. package/dist/web-effect-library.js.map +1 -0
  196. package/dist/web-effect-poster.d.ts +8 -0
  197. package/dist/web-effect-poster.js +52 -0
  198. package/dist/web-effect-poster.js.map +1 -0
  199. package/dist/web-effect-profiles.d.ts +118 -0
  200. package/dist/web-effect-profiles.js +180 -0
  201. package/dist/web-effect-profiles.js.map +1 -0
  202. package/dist/web-effect-service.d.ts +86 -0
  203. package/dist/web-effect-service.js +312 -0
  204. package/dist/web-effect-service.js.map +1 -0
  205. package/dist/web-effect-tools.d.ts +47 -0
  206. package/dist/web-effect-tools.js +126 -0
  207. package/dist/web-effect-tools.js.map +1 -0
  208. package/dist/web-effect-trials.d.ts +86 -0
  209. package/dist/web-effect-trials.js +204 -0
  210. package/dist/web-effect-trials.js.map +1 -0
  211. package/dist/web-effect-upload.d.ts +29 -0
  212. package/dist/web-effect-upload.js +190 -0
  213. package/dist/web-effect-upload.js.map +1 -0
  214. package/dist/web-effect-worker.d.ts +8 -0
  215. package/dist/web-effect-worker.js +170 -0
  216. package/dist/web-effect-worker.js.map +1 -0
  217. package/dist/web-effect.d.ts +89 -0
  218. package/dist/web-effect.js +132 -0
  219. package/dist/web-effect.js.map +1 -0
  220. package/dist/web-identity-color.d.ts +87 -0
  221. package/dist/web-identity-color.js +209 -0
  222. package/dist/web-identity-color.js.map +1 -0
  223. package/dist/web-identity-palette.d.ts +113 -0
  224. package/dist/web-identity-palette.js +84 -0
  225. package/dist/web-identity-palette.js.map +1 -0
  226. package/dist/web-image-meta.d.ts +25 -0
  227. package/dist/web-image-meta.js +200 -0
  228. package/dist/web-image-meta.js.map +1 -0
  229. package/dist/web-mod-agent.d.ts +50 -0
  230. package/dist/web-mod-agent.js +203 -0
  231. package/dist/web-mod-agent.js.map +1 -0
  232. package/dist/web-mod-bundle.d.ts +144 -0
  233. package/dist/web-mod-bundle.js +464 -0
  234. package/dist/web-mod-bundle.js.map +1 -0
  235. package/dist/web-mod-export.d.ts +59 -0
  236. package/dist/web-mod-export.js +79 -0
  237. package/dist/web-mod-export.js.map +1 -0
  238. package/dist/web-mod-store.d.ts +283 -0
  239. package/dist/web-mod-store.js +624 -0
  240. package/dist/web-mod-store.js.map +1 -0
  241. package/dist/web-org.d.ts +3 -3
  242. package/dist/web-org.js +11 -17
  243. package/dist/web-org.js.map +1 -1
  244. package/dist/web-owner-css-store.d.ts +180 -0
  245. package/dist/web-owner-css-store.js +384 -0
  246. package/dist/web-owner-css-store.js.map +1 -0
  247. package/dist/web-owner-css.d.ts +58 -0
  248. package/dist/web-owner-css.js +552 -0
  249. package/dist/web-owner-css.js.map +1 -0
  250. package/dist/web-prefs.d.ts +48 -0
  251. package/dist/web-prefs.js +209 -33
  252. package/dist/web-prefs.js.map +1 -1
  253. package/dist/web-preview.d.ts +9 -1
  254. package/dist/web-preview.js +24 -7
  255. package/dist/web-preview.js.map +1 -1
  256. package/dist/web-settings-patch.d.ts +18 -0
  257. package/dist/web-settings-patch.js +51 -0
  258. package/dist/web-settings-patch.js.map +1 -1
  259. package/dist/web-shell-routes.d.ts +1 -1
  260. package/dist/web-shell-routes.js +1 -1
  261. package/dist/web-shell-routes.js.map +1 -1
  262. package/dist/web-theme-store.d.ts +218 -0
  263. package/dist/web-theme-store.js +516 -0
  264. package/dist/web-theme-store.js.map +1 -0
  265. package/dist/web-theme.d.ts +121 -0
  266. package/dist/web-theme.js +407 -0
  267. package/dist/web-theme.js.map +1 -0
  268. package/dist/web-widget.d.ts +72 -0
  269. package/dist/web-widget.js +245 -0
  270. package/dist/web-widget.js.map +1 -0
  271. package/package.json +1 -1
@@ -41,9 +41,10 @@ import { takeAgentRequest } from "./settings-request.js";
41
41
  import { setPreviewOptIn } from "./panel-chat.js";
42
42
  import { startTour } from "./chat-tour.js";
43
43
  import "./chat-strings.js";
44
+ import { AppearanceSection } from "./settings-appearance.js";
44
45
 
45
46
  const tn = (k, ...v) => t(`settings.${k}`, ...v);
46
- const ICONS = { device: "user", agents: "bot", bots: "plug", classic: "room", general: "sliders", web: "globe", advanced: "code" };
47
+ const ICONS = { device: "user", appearance: "palette", agents: "bot", bots: "plug", classic: "room", general: "sliders", web: "globe", advanced: "code" };
47
48
  const RELEASES = "https://github.com/songsid/AgEnD/releases";
48
49
 
49
50
  /** GET under the panel's lease → { ok, status, body }. */
@@ -224,6 +225,8 @@ export function SettingsPanel({ route, navKey }) {
224
225
  }, [!!ctx, navKey]);
225
226
  // #1604: This device needs no configuration: shown at once, whatever the configuration read does.
226
227
  const sectionBody = section === "device" ? html`<${DeviceSection} />`
228
+ // #1629 r4 §3: Appearance is the person's own (no configuration read): shown at once, like Preferences.
229
+ : section === "appearance" ? html`<${AppearanceSection} />`
227
230
  : !data ? html`<${Skeleton} lines=${6} />`
228
231
  : data.error ? html`<${ErrorState} message=${tn("configLoadFailed")} onRetry=${reload} />`
229
232
  : section === "agents" ? html`<${Agents} ctx=${ctx} search=${search} openDialog=${(d) => setDialog({ ...d, key: navKey })} />`
@@ -236,14 +239,14 @@ export function SettingsPanel({ route, navKey }) {
236
239
  const open = dialog && dialog.key === navKey ? dialog : null;
237
240
  const close = () => setDialog(null);
238
241
  const searchable = section === "agents" || section === "bots" || section === "classic";
239
- return html`<div class="panel p-settings">
242
+ return html`<div data-effect-panel="settings" class="panel p-settings">
240
243
  <${PanelHeader} title=${tn("title")} sub=${data && data.version ? html`<a class="pill ver" href=${data.version.includes("-") ? `${RELEASES}/latest` : `${RELEASES}/tag/v${data.version}`} target="_blank" rel="noopener">v${data.version}</a>` : null}>
241
244
  <button type="button" class="btn btn-ghost btn-sm" disabled=${!ctx} onClick=${() => setDialog({ kind: "wizard", key: navKey })}><${Icon} name="wand" size=${16} /><span class="hide-narrow">${tn("wizardButton")}</span></button>
242
245
  <button type="button" class="icon-btn" aria-label=${tn("helpButton")} title=${tn("helpButton")} onClick=${() => setDialog({ kind: "help", key: navKey })}><${Icon} name="info" /></button>
243
246
  </${PanelHeader}>
244
- <nav class="seg" aria-label=${tn("title")}>${SETTINGS_SECTIONS.map((k) => html`<a key=${k} href=${settingsPath(k)} class=${`seg-item${k === section ? " active" : ""}`}
247
+ <nav data-trust="panel-navigation" class="seg" aria-label=${tn("title")}>${SETTINGS_SECTIONS.map((k) => html`<a key=${k} href=${settingsPath(k)} class=${`seg-item${k === section ? " active" : ""}`}
245
248
  aria-current=${k === section ? "page" : undefined}><${Icon} name=${ICONS[k]} size=${16} /><span>${tn(`sec_${k}`)}</span></a>`)}</nav>
246
- <div class="panel-body"><div class="col">
249
+ <div data-trust="panel-content" class="panel-body"><div class="col">
247
250
  ${searchable ? html`<div class="s-toolbar"><label class="s-search"><${Icon} name="search" size=${14} /><span class="sr-only">${tn("searchAll")}</span>
248
251
  <input type="search" value=${search} placeholder=${tn("searchAll")} onInput=${(e) => setSearch(e.target.value)} /></label></div>` : null}
249
252
  ${op ? html`<${OperationCard} op=${op} schema=${data && data.schema} />` : null}
@@ -915,7 +918,7 @@ function Developer({ ctx }) {
915
918
  /** Staged, not applied: the count, what it will cost, Discard and Apply. */
916
919
  function PendingBar({ staged, busy, onApply, onDiscard }) {
917
920
  const kinds = [...new Set([...staged.values()].map((c) => c.impact))];
918
- return html`<div class="s-pending" role="region" aria-label=${tn("pendingRegion")}>
921
+ return html`<div data-trust="panel-controls" class="s-pending" role="region" aria-label=${tn("pendingRegion")}>
919
922
  <strong>${tn("changesPending", staged.size)}</strong>
920
923
  <span class="note">${kinds.map(impactText).join(" · ")}</span>
921
924
  <span class="grow"></span>
@@ -16,6 +16,7 @@ import { Icon } from "/assets/ui-icons.js";
16
16
  import "./chat-render.js";
17
17
  import "./preview.js";
18
18
  import { loadHtmlAttachment } from "./html-attachment.js";
19
+ import { EffectTrialPanel } from "./effect-trial-panel.js";
19
20
  import { chatTime, onDayChange } from "./chat-time.js";
20
21
 
21
22
  const R = () => globalThis.AgendChatRender;
@@ -43,14 +44,25 @@ export const panelClaims = () => claims;
43
44
  /** The frame's key in preview.js: its own, so the card that opened it keeps its own state. */
44
45
  export const frameKey = (key) => `panel:${key}`;
45
46
  /** Show `spec` in the panel; `run`: start it there (the card's "Open in panel" click). The block shown before stops. */
47
+ function retire(open) {
48
+ if (!open) return;
49
+ if (open.kind === "trial") { try { open.handle.stop(); } catch { /* host controller owns cleanup */ } }
50
+ else if (P()) P().stop(frameKey(open.key), "stopped", "");
51
+ }
46
52
  export function openPanel(spec, { run = false } = {}) {
47
- if (state.open && P()) P().stop(frameKey(state.open.key), "stopped", "");
48
- set({ open: { ...spec }, run: run ? ++runs : 0 });
53
+ const old = state.open;
54
+ set({ open: { ...spec, kind: "html" }, run: run ? ++runs : 0 });
55
+ retire(old); // State changes first: cleanup reentry cannot close a newer panel.
56
+ }
57
+ /** A checked controller handle enters the existing component slot; no HTML run token is issued. */
58
+ export function openTrialPanel(spec) {
59
+ const old = state.open;
60
+ set({ open: { ...spec, kind: "trial" }, run: 0 }); retire(old);
49
61
  }
50
- /** Close the panel: its frame stops first. */
62
+ /** Close publishes the empty slot before old-owner callbacks can reenter. */
51
63
  export function closePanel() {
52
- if (state.open && P()) P().stop(frameKey(state.open.key), "stopped", "");
53
- if (state.open) set({ open: null, run: 0 });
64
+ const old = state.open; if (!old) return;
65
+ set({ open: null, run: 0 }); retire(old);
54
66
  }
55
67
  /** The card key the panel shows, or null. */
56
68
  export const shownKey = () => (state.open ? state.open.key : null);
@@ -67,7 +79,7 @@ export function htmlTitle(code) {
67
79
  * without one, the newest block does. `msgs` in order; `keyOf` makes a message's key.
68
80
  */
69
81
  export function newerVersion(open, msgs, keyOf) {
70
- if (!open || !Array.isArray(msgs)) return null;
82
+ if (!open || open.kind === "trial" || !Array.isArray(msgs)) return null;
71
83
  const at = msgs.findIndex(x => keyOf(x) === open.msgKey);
72
84
  if (at < 0) return null;
73
85
  // An attached file: a later agent reply attaching a file of the same name is its newer version. Its HTML is read
@@ -132,7 +144,8 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
132
144
  const pending = useRef(0); // #1704: the card's click, waiting for this device's Allow (the st.run it came with)
133
145
  const ownAllow = useRef(0); // while this panel's own Allow commits its choice: the click it answers (not cancelled by it)
134
146
  const holder = useRef(null), divider = useRef(null), panel = useRef(null);
135
- const open = st.open && st.open.instance === name ? st.open : null;
147
+ const trial = st.open?.kind === "trial" && st.open.instance === name ? st.open : null;
148
+ const open = st.open?.kind !== "trial" && st.open?.instance === name ? st.open : null;
136
149
 
137
150
  useEffect(() => panelStore.subscribe(setSt), []);
138
151
  // This device's choice (here or in another tab) decides whether Preview is offered: re-read it when it changes.
@@ -231,6 +244,9 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
231
244
  // Esc in the panel closes it (and is not the chat's "stop the reply").
232
245
  const onPanelKey = (e) => { if (e.key === "Escape" && !e.isComposing) { e.preventDefault(); closePanel(); } };
233
246
 
247
+ if (trial) return html`<${EffectTrialPanel} binding=${trial.binding} title=${trial.handle.reply.offer.title}
248
+ current=${trial.handle.current} ownerCss=${trial.ownerCss} onStage=${trial.onStage}
249
+ onStop=${trial.handle.stop} onClose=${() => { if (panelStore.get().open === trial) closePanel(); }} />`;
234
250
  if (!open) return null;
235
251
  const going = run.state === "starting" || run.state === "running";
236
252
  const a = P().availability();
@@ -250,9 +266,9 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
250
266
  if (want && pending.current === want && panelStore.get().run === want) start();
251
267
  };
252
268
  const source = t("chat.pvSource", open.sender || name, timeOf(open.ts));
253
- return html`<div class="pv-divider" ref=${divider} role="separator" aria-orientation="vertical" tabindex="0" aria-label=${t("chat.pvResize")}
269
+ return html`<div data-trust="preview" class="pv-divider" ref=${divider} role="separator" aria-orientation="vertical" tabindex="0" aria-label=${t("chat.pvResize")}
254
270
  aria-valuemin=${String(MIN_PANEL)} onKeyDown=${onKey} onPointerDown=${onDown}></div>
255
- <aside class="pv-panel" ref=${panel} aria-label=${t("chat.pvPanel")} onKeyDown=${onPanelKey}>
271
+ <aside data-trust="preview" class="pv-panel" ref=${panel} aria-label=${t("chat.pvPanel")} onKeyDown=${onPanelKey}>
256
272
  <div class="pv-panel-head">
257
273
  <button type="button" class="icon-btn only-narrow pv-panel-close" title=${t("chat.pvBack")} aria-label=${t("chat.pvBack")} onClick=${closePanel}><${Icon} name="back" size=${18} /></button>
258
274
  <div class="pv-panel-title"><strong>${t("chat.pvPanel")}</strong>
@@ -0,0 +1,325 @@
1
+ // #1629 r4 §3: Settings → Appearance — the person's own custom CSS (owner CSS): the editor, keep-or-revert, earlier
2
+ // versions, the pictures and fonts it may name, and this device's off switch. Settings itself is always drawn stock
3
+ // (owner-css.js removes the link here), so a stylesheet that hides things can always be fixed from this page.
4
+ import { html, useEffect, useRef, useState } from "/assets/app-html.js";
5
+ import { t } from "/assets/app-i18n.js";
6
+ import { appStore, useStore } from "/assets/app-store.js";
7
+ import { ModsCard } from "./settings-mods.js";
8
+ import { EffectsCard } from "./settings-effects.js";
9
+ import { Icon } from "/assets/ui-icons.js";
10
+ import { toast } from "/assets/ui-toast.js";
11
+ import { ownerCssOffHere, ownerCssTrial, safeMode, setOwnerCssActive, setOwnerCssOffHere, startOwnerCssTrial } from "/assets/owner-css.js";
12
+ import { setThemesShown, themesStore } from "/assets/app-themes.js";
13
+ import { CARD_SIZES, cardSizeDefault, cardSizeStore, setCardSizeDefault } from "/assets/identity-card.js";
14
+ import { AgentWidget, checkWidget, fleetBinds, setSidebarWidget, sidebarWidgetRaw, sidebarWidgetStore, widgetProblem } from "/assets/agent-widget.js";
15
+ import { confirmedWrite } from "./settings-confirm.js";
16
+ import { savePref } from "/assets/app-prefs.js";
17
+ import { confirmDialog } from "/assets/ui-confirm.js";
18
+ import { colorsOff, defaultPlaces, PLACEMENTS, setColorsOff, setDefaultPlaces, useIdentityColors } from "/assets/identity-color.js";
19
+ import "./settings-strings.js";
20
+
21
+ const tn = (k, ...v) => t(`settings.${k}`, ...v);
22
+ const short = (sha) => (sha ? sha.slice(0, 8) : "");
23
+ const kib = (n) => `${Math.max(1, Math.round(n / 1024))} KiB`;
24
+
25
+ async function call(path, init) {
26
+ let res;
27
+ try { res = await fetch(path, { credentials: "same-origin", ...init }); } catch { return { ok: false, status: 0, body: null }; }
28
+ let body = null;
29
+ try { body = await res.json(); } catch { /* not JSON */ }
30
+ return { ok: res.ok, status: res.status, body };
31
+ }
32
+ const postJson = (path, body) => call(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
33
+
34
+ /** Why the host refused a text, in the person's words. */
35
+ function refusal(body) {
36
+ const problem = body && body.error, detail = body && body.detail;
37
+ const known = ["at_rule", "url", "unknown_asset", "function", "property", "script_scheme", "too_large", "malformed", "nul", "unserialisable",
38
+ "asset_type", "asset_unreadable", "asset_quota", "asset_in_use", "owner_css_read_only", "owner_css_needs_a_signed_in_person"];
39
+ return known.includes(problem) ? tn(`cssError_${problem}`, detail || "") : tn("cssError_other", problem || "?");
40
+ }
41
+
42
+ export function AppearanceSection() {
43
+ const publicLink = typeof document !== "undefined" && document.body && document.body.dataset.publicLink === "1";
44
+ if (publicLink) return html`<section class="card s-appearance"><h3>${tn("cssTitle")}</h3><p class="sub">${tn("cssPublicLink")}</p></section>`;
45
+ return html`<${ThemesCard} /><${IdentityColorCard} /><${CardSizeCard} /><${SidebarWidgetCard} /><${OwnerCssCard} /><${ModsCard} /><${EffectsCard} /><${DeviceCard} /><${CardLimitsCard} /><${RestoreDefaultsCard} />`;
46
+ }
47
+
48
+ /**
49
+ * #1958: identity colours on this host — where they show for an instance nobody chose for (ring and bar unless
50
+ * changed), and a switch that turns them off everywhere while every pick is kept.
51
+ */
52
+ function IdentityColorCard() {
53
+ useIdentityColors();
54
+ const places = defaultPlaces(), off = colorsOff();
55
+ const toggle = (p) => setDefaultPlaces(places.includes(p) ? places.filter((x) => x !== p) : [...places, p]);
56
+ return html`<section class="card s-appearance s-synced s-colors"><h3>${tn("colorTitle")}</h3>
57
+ <p class="sub">${tn("colorLead")}</p>
58
+ <fieldset class="idc-places" disabled=${off}><legend>${tn("colorPlaces")}</legend>
59
+ ${PLACEMENTS.map((p) => html`<label key=${p} class="check"><input type="checkbox" checked=${places.includes(p)} onChange=${() => toggle(p)} /> ${t(`color.place_${p}`)}</label>`)}
60
+ </fieldset>
61
+ <label class="check"><input type="checkbox" class="s-colors-off" checked=${off} onChange=${(e) => setColorsOff(e.target.checked)} /> <span>${tn("colorOff")}</span></label>
62
+ <p class="sub">${tn("colorOffLead")}</p>
63
+ </section>`;
64
+ }
65
+
66
+ /**
67
+ * #1958 decision 5: Restore defaults — the host's appearance settings back to their defaults after a confirm that says
68
+ * what it resets; this person's custom CSS turned off (its versions stay). Not settings, so never touched: the colours
69
+ * people picked per instance, installed mods, uploaded effects and the fleet's card limits.
70
+ */
71
+ export async function restoreAppearanceDefaults() {
72
+ setThemesShown(true);
73
+ setCardSizeDefault("m");
74
+ savePref("card.sizeByInstance", null);
75
+ setSidebarWidget(null);
76
+ setDefaultPlaces(null);
77
+ setColorsOff(false);
78
+ setOwnerCssOffHere(false);
79
+ const r = await postJson("/ui/owner-css/restore", { sha: null });
80
+ return r && r.error ? { ok: false, error: r.error } : { ok: true };
81
+ }
82
+ function RestoreDefaultsCard() {
83
+ const [busy, setBusy] = useState(false);
84
+ async function go() {
85
+ if (!(await confirmDialog({ title: tn("restoreTitle"), message: tn("restoreConfirm"), confirmLabel: tn("restoreGo"), danger: true }))) return;
86
+ setBusy(true);
87
+ const r = await restoreAppearanceDefaults();
88
+ setBusy(false);
89
+ if (r.ok) toast(tn("restoreDone")); else toast(tn("restoreCssFailed", r.error), false);
90
+ }
91
+ return html`<section class="card s-appearance s-restore"><h3>${tn("restoreTitle")}</h3>
92
+ <p class="sub">${tn("restoreLead")}</p>
93
+ <button type="button" class="btn" disabled=${busy} onClick=${go}>${tn("restoreTitle")}</button>
94
+ </section>`;
95
+ }
96
+
97
+ /** #1629 r4.1 Q3 (a): every identity card's size on this person's screens (a card's own control sets it apart). */
98
+ function CardSizeCard() {
99
+ useStore(cardSizeStore);
100
+ const size = cardSizeDefault();
101
+ return html`<section class="card s-appearance s-synced"><h3>${tn("cardSizeTitle")}</h3>
102
+ <div class="card-size" role="group" aria-label=${tn("cardSizeTitle")}>
103
+ ${CARD_SIZES.map((s) => html`<button key=${s} type="button" class=${`seg-item${s === size ? " active" : ""}`} aria-pressed=${s === size ? "true" : "false"}
104
+ onClick=${() => setCardSizeDefault(s)}>${t(`card.size${s.toUpperCase()}Name`)}</button>`)}
105
+ </div>
106
+ <p class="sub">${tn("cardSizeLead")}</p>
107
+ </section>`;
108
+ }
109
+
110
+ const LIMIT_FIELDS = [["portrait", "max_width"], ["portrait", "max_height"], ["portrait", "max_kib"], ["banner", "max_width"], ["banner", "max_height"], ["banner", "max_kib"],
111
+ ["instance_quota", "files"], ["instance_quota", "mib"]];
112
+ /**
113
+ * #1629 r4.1 Q3 (b): the identity card's upload limits (fleet.yaml web.appearance). Each field shows what is in force
114
+ * when it differs from what is written (a value beyond its bound is clamped). A lower limit applies at once; a higher
115
+ * one waits for a fleet admin's confirmation (the server decides). Stored pictures are never touched.
116
+ */
117
+ function CardLimitsCard() {
118
+ const [view, setView] = useState(null);
119
+ const [draft, setDraft] = useState({});
120
+ const [busy, setBusy] = useState(false);
121
+ const [msg, setMsg] = useState(null);
122
+ const load = async () => {
123
+ const r = await call("/api/settings/fleet/appearance");
124
+ // Only an answer of the expected shape is drawn: every group's in-force values, defaults and bounds.
125
+ const ok = r.ok && r.body && ["portrait", "banner", "instance_quota"].every((g) => r.body.effective && r.body.effective[g] && r.body.defaults && r.body.defaults[g] && r.body.bounds && r.body.bounds[g]);
126
+ if (!ok) { setView({ error: true }); return; }
127
+ setView(r.body);
128
+ const d = {};
129
+ for (const [g, k] of LIMIT_FIELDS) { const v = r.body.configured && r.body.configured[g] && r.body.configured[g][k]; d[`${g}.${k}`] = typeof v === "number" ? String(v) : ""; }
130
+ setDraft(d);
131
+ };
132
+ useEffect(() => { load(); }, []);
133
+ if (!view) return null;
134
+ if (view.error) return html`<section class="card s-appearance"><h3>${tn("cardLimitsTitle")}</h3><p class="sub">${tn("cardLimitsUnavailable")}</p></section>`;
135
+ const configured = (g, k) => (view.configured && view.configured[g] && typeof view.configured[g][k] === "number" ? view.configured[g][k] : null);
136
+ const save = async () => {
137
+ const body = {};
138
+ for (const [g, k] of LIMIT_FIELDS) {
139
+ const raw = (draft[`${g}.${k}`] || "").trim();
140
+ const next = raw === "" ? null : Number(raw);
141
+ if (next !== null && !(Number.isInteger(next) && next > 0)) { setMsg(tn("cardLimitsWhole")); return; }
142
+ if (next !== configured(g, k)) (body[g] ??= {})[k] = next;
143
+ }
144
+ if (!Object.keys(body).length) { setMsg(tn("cardLimitsNoChange")); return; }
145
+ setBusy(true); setMsg(null);
146
+ const r = await confirmedWrite("/api/settings/fleet/appearance", { method: "PUT", body, label: tn("cardLimitsTitle") });
147
+ setBusy(false);
148
+ if (r.ok) { setMsg(tn("cardLimitsSaved")); load(); } else setMsg(tn("cardLimitsFailed", (r.body && r.body.error) || r.status));
149
+ };
150
+ const warnings = Array.isArray(view.warnings) ? view.warnings : [];
151
+ return html`<section class="card s-appearance"><h3>${tn("cardLimitsTitle")}</h3>
152
+ <p class="sub">${tn("cardLimitsLead")}</p>
153
+ <fieldset class="form" disabled=${busy}>
154
+ ${["portrait", "banner", "instance_quota"].map((g) => html`<div key=${g} class="grid3">
155
+ ${LIMIT_FIELDS.filter(([gg]) => gg === g).map(([, k]) => {
156
+ const id = `cardLimit-${g}-${k}`, eff = view.effective[g][k], conf = configured(g, k);
157
+ return html`<div key=${k} class="field"><label for=${id}>${tn(`cardLimit_${g}_${k}`)}</label>
158
+ <input id=${id} type="number" min="1" step="1" placeholder=${String(view.defaults[g][k])} value=${draft[`${g}.${k}`] || ""}
159
+ onInput=${(e) => setDraft({ ...draft, [`${g}.${k}`]: e.target.value })} />
160
+ ${conf !== null && conf !== eff ? html`<span class="note">${tn("cardLimitInForce", eff, view.bounds[g][k][0], view.bounds[g][k][1])}</span>` : null}</div>`;
161
+ })}
162
+ </div>`)}
163
+ </fieldset>
164
+ ${warnings.length ? html`<ul class="note s-limit-warnings">${warnings.map((w, i) => html`<li key=${i}><code>${w.path}</code>: ${w.message}</li>`)}</ul>` : null}
165
+ <div class="save-row"><button type="button" class="btn btn-primary" disabled=${busy} onClick=${save}>${tn("cardLimitsSave")}</button>
166
+ ${msg ? html`<span class="note" role="status">${msg}</span>` : null}</div>
167
+ </section>`;
168
+ }
169
+
170
+ /** #1629 r2 (d): whether this person's screens show agents' themes (and, as they come, effects) — synced. */
171
+ function ThemesCard() {
172
+ const s = useStore(themesStore);
173
+ return html`<section class="card s-appearance s-synced"><h3>${tn("themesTitle")}</h3>
174
+ <label class="check"><input type="checkbox" checked=${s.shown} disabled=${safeMode()} onChange=${(e) => setThemesShown(e.target.checked)} />
175
+ <span>${tn("themesShow")}</span></label>
176
+ <p class="sub">${safeMode() ? tn("themesSafeMode") : tn("themesShowLead")}</p>
177
+ </section>`;
178
+ }
179
+
180
+ /** An example to start from: the fleet's counts, as a person's sidebar widget may show them. */
181
+ export const SIDEBAR_WIDGET_EXAMPLE = {
182
+ spec: { v: 1, title: "Fleet", items: [
183
+ { type: "stat", label: "Working", bind: "fleet.working" },
184
+ { type: "stat", label: "Needs you", bind: "fleet.needs_you" },
185
+ { type: "stat", label: "Idle", bind: "fleet.idle" },
186
+ { type: "stat", label: "Stopped", bind: "fleet.stopped" },
187
+ ] },
188
+ data: {},
189
+ };
190
+ /**
191
+ * #1629 r4-4: the person's own sidebar widget — a declarative spec and its values, as JSON; drawn under the instance
192
+ * list by the page's own components, with the fleet's counts as the only live values. Synced like the other choices.
193
+ */
194
+ function SidebarWidgetCard() {
195
+ useStore(sidebarWidgetStore);
196
+ const app = useStore(appStore);
197
+ const saved = sidebarWidgetRaw();
198
+ const [text, setText] = useState(() => (saved ? JSON.stringify(saved, null, 2) : ""));
199
+ const [error, setError] = useState("");
200
+ let parsed = null, problem = "";
201
+ if (text.trim()) {
202
+ try { parsed = JSON.parse(text); problem = widgetProblem("sidebar", parsed) || ""; } catch { problem = tn("widgetNotJson"); }
203
+ }
204
+ const preview = parsed && !problem ? checkWidget("sidebar", parsed) : null;
205
+ const save = () => {
206
+ if (!parsed || problem) { setError(problem || tn("widgetNotJson")); return; }
207
+ setError("");
208
+ setSidebarWidget(parsed);
209
+ toast(tn("widgetSaved"));
210
+ };
211
+ const remove = () => { setSidebarWidget(null); setText(""); setError(""); toast(tn("widgetRemoved")); };
212
+ return html`<section class="card s-appearance s-synced s-widget"><h3>${tn("widgetTitle")}</h3>
213
+ <p class="sub">${tn("widgetLead")}</p>
214
+ <label class="field"><span class="sr-only">${tn("widgetTitle")}</span>
215
+ <textarea class="s-widget-text" rows="10" spellcheck=${false} autocapitalize="off" autocomplete="off" value=${text}
216
+ onInput=${(e) => { setText(e.target.value); setError(""); }}></textarea></label>
217
+ ${error || problem ? html`<p class="err" role="alert">${error || problem}</p>` : null}
218
+ ${preview ? html`<div class="s-widget-preview"><${AgentWidget} slot="sidebar" widget=${preview} binds=${fleetBinds(app.instances, app.exec, app.awaiting)} /></div>` : null}
219
+ <div class="s-widget-actions">
220
+ <button type="button" class="btn btn-primary" disabled=${!parsed || !!problem} onClick=${save}><${Icon} name="check" size=${16} />${tn("widgetSave")}</button>
221
+ ${!text.trim() ? html`<button type="button" class="btn" onClick=${() => setText(JSON.stringify(SIDEBAR_WIDGET_EXAMPLE, null, 2))}>${tn("widgetExample")}</button>` : null}
222
+ ${saved ? html`<button type="button" class="btn" onClick=${remove}>${tn("widgetRemove")}</button>` : null}
223
+ </div>
224
+ </section>`;
225
+ }
226
+
227
+ function OwnerCssCard() {
228
+ const [state, setState] = useState(null);
229
+ const [text, setText] = useState("");
230
+ const [busy, setBusy] = useState(false);
231
+ const [error, setError] = useState("");
232
+ const trial = useStore(ownerCssTrial);
233
+ const file = useRef(null);
234
+ const load = async (opts = {}) => {
235
+ const r = await call("/ui/owner-css/state");
236
+ if (!r.ok || !r.body) { setState({ failed: true }); return; }
237
+ setState(r.body);
238
+ if (opts.text !== false) setText(r.body.text || "");
239
+ };
240
+ useEffect(() => { load(); }, []);
241
+ // A trial answered (kept or reverted) changes what is in force: read it again, keeping the editor's text.
242
+ useEffect(() => { if (state) load({ text: false }); }, [trial && trial.sha]);
243
+ if (!state) return html`<section class="card s-appearance"><h3>${tn("cssTitle")}</h3><p class="sub">…</p></section>`;
244
+ if (state.failed) return html`<section class="card s-appearance"><h3>${tn("cssTitle")}</h3><p class="err">${tn("cssLoadFailed")}</p></section>`;
245
+
246
+ const save = async () => {
247
+ setBusy(true); setError("");
248
+ const r = await postJson("/ui/owner-css", { text });
249
+ setBusy(false);
250
+ if (!r.ok) { setError(refusal(r.body)); return; }
251
+ startOwnerCssTrial(r.body.trial, r.body.sha, r.body.previous);
252
+ toast(tn("cssTrying"));
253
+ load({ text: false });
254
+ };
255
+ const restore = async (sha) => {
256
+ setBusy(true); setError("");
257
+ const r = await postJson("/ui/owner-css/restore", { sha });
258
+ setBusy(false);
259
+ if (!r.ok) { setError(refusal(r.body)); return; }
260
+ setOwnerCssActive(r.body.active);
261
+ load();
262
+ };
263
+ const addAsset = async () => {
264
+ const f = file.current && file.current.files && file.current.files[0];
265
+ if (!f) return;
266
+ setBusy(true); setError("");
267
+ const r = await call("/ui/owner-css/asset", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: f });
268
+ setBusy(false);
269
+ if (file.current) file.current.value = "";
270
+ if (!r.ok) { setError(refusal(r.body)); return; }
271
+ load({ text: false });
272
+ };
273
+ const removeAsset = async (id) => {
274
+ setBusy(true); setError("");
275
+ const r = await call(`/ui/owner-css/asset/${id}`, { method: "DELETE" });
276
+ setBusy(false);
277
+ if (!r.ok) { setError(refusal(r.body)); return; }
278
+ load({ text: false });
279
+ };
280
+ const copyRef = (id) => {
281
+ const ref = `url("asset:${id}")`;
282
+ try { navigator.clipboard.writeText(ref).then(() => toast(tn("cssCopied", ref)), () => toast(ref)); } catch { toast(ref); }
283
+ };
284
+ const inForce = state.active
285
+ ? (state.pending ? tn("cssOnTrial", short(state.active)) : tn("cssInForce", short(state.active)))
286
+ : tn("cssNone");
287
+ const assetBytes = state.assets.reduce((n, a) => n + a.bytes, 0);
288
+ return html`<section class="card s-appearance s-owner-css"><h3>${tn("cssTitle")}</h3>
289
+ <p class="sub">${tn("cssLead")}</p>
290
+ <p class="s-css-status" role="status">${inForce}</p>
291
+ <label class="field"><span class="sr-only">${tn("cssTitle")}</span>
292
+ <textarea class="s-css-text" rows="14" spellcheck=${false} autocapitalize="off" autocomplete="off" value=${text} disabled=${busy}
293
+ onInput=${(e) => setText(e.target.value)}></textarea></label>
294
+ ${error ? html`<p class="err" role="alert">${error}</p>` : null}
295
+ <div class="s-css-actions">
296
+ <button type="button" class="btn btn-primary" disabled=${busy || !text.trim()} onClick=${save}><${Icon} name="check" size=${16} />${tn("cssSave")}</button>
297
+ ${state.active ? html`<button type="button" class="btn" disabled=${busy} onClick=${() => restore(null)}>${tn("cssOff")}</button>` : null}
298
+ </div>
299
+ ${state.versions.length ? html`<details class="s-css-versions"><summary>${tn("cssVersions", state.versions.length)}</summary>
300
+ <ul class="s-css-list">${state.versions.map((v) => html`<li key=${v.sha}>
301
+ <code>${short(v.sha)}</code><span class="muted">${new Date(v.at).toLocaleString()} · ${kib(v.bytes)}</span>
302
+ ${v.sha === state.active ? html`<span class="pill">${tn("cssCurrent")}</span>`
303
+ : html`<button type="button" class="btn btn-sm" disabled=${busy} onClick=${() => restore(v.sha)}>${tn("cssRestore")}</button>`}
304
+ </li>`)}</ul></details>` : null}
305
+ <h4>${tn("cssAssets")}</h4>
306
+ <p class="sub">${tn("cssAssetsLead", state.assets.length, state.limits.maxAssets, kib(assetBytes))}</p>
307
+ ${state.assets.length ? html`<ul class="s-css-list">${state.assets.map((a) => html`<li key=${a.id}>
308
+ <code>${a.id}</code><span class="muted">${a.kind} · ${kib(a.bytes)}</span>
309
+ <button type="button" class="btn btn-sm" onClick=${() => copyRef(a.id)}>${tn("cssAssetCopy")}</button>
310
+ <button type="button" class="btn btn-sm btn-ghost" disabled=${busy} onClick=${() => removeAsset(a.id)}>${tn("cssAssetRemove")}</button>
311
+ </li>`)}</ul>` : null}
312
+ <label class="field"><span>${tn("cssAssetAdd")}</span>
313
+ <input ref=${file} type="file" accept="image/png,image/webp,image/jpeg,image/gif,font/woff2,.woff2" disabled=${busy} onChange=${addAsset} /></label>
314
+ </section>`;
315
+ }
316
+
317
+ function DeviceCard() {
318
+ const [off, setOff] = useState(ownerCssOffHere());
319
+ return html`<section class="card s-appearance s-local"><h3>${tn("cssDeviceTitle")}</h3>
320
+ <label class="check"><input type="checkbox" checked=${off} onChange=${(e) => { setOwnerCssOffHere(e.target.checked); setOff(ownerCssOffHere()); }} />
321
+ <span>${tn("cssDeviceOff")}</span></label>
322
+ <p class="sub">${tn("cssDeviceOffLead")}</p>
323
+ <p class="sub">${tn("cssSafe")}</p>
324
+ </section>`;
325
+ }