@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,261 @@
1
+ // #1958: an instance's identity colour on the page — the cue people scan the sidebar by — and where it shows (the
2
+ // name, a bar at the row's left edge, the avatar's ring). The server sends each instance's tiers in the status frame
3
+ // (`color`, `color_show`); this module resolves them as src/web-identity-color.ts does (a test pins the two equal),
4
+ // paints the sidebar row through the CSSOM (no style attribute, #1300), and holds the Color dialog.
5
+ // colour: a person's pick > the agent's (themes shown) > its own bot's role colour > automatic (a few soft colours)
6
+ // places: a person's choice > the agent's (themes shown) > this host's default (color.show) > ring + bar
7
+ // Settings → Web appearance can turn colours off for the host (color.off): nothing is painted, every pick is kept.
8
+ import { html, useEffect, useState } from "./app-html.js";
9
+ import { createStore, useStore } from "./app-store.js";
10
+ import { onPrefsChanged, prefInForce, savePref } from "./app-prefs.js";
11
+ import { register, t } from "./app-i18n.js";
12
+ import { themesShown, themesStore } from "./app-themes.js";
13
+ import { Dialog } from "./ui-dialog.js";
14
+ import { toast } from "./ui-toast.js";
15
+
16
+ // ── The palettes (src/web-identity-palette.ts: the same values, pinned by a test) ──
17
+
18
+ export const IDENTITY_PALETTE = [
19
+ { id: "red", light: "#cf222e", dark: "#ff7b72" }, { id: "orange", light: "#bc4c00", dark: "#ffa657" },
20
+ { id: "amber", light: "#8a5d00", dark: "#e3b341" }, { id: "lime", light: "#4d7c0f", dark: "#a3e635" },
21
+ { id: "green", light: "#1a7f37", dark: "#56d364" }, { id: "teal", light: "#0f766e", dark: "#2dd4bf" },
22
+ { id: "cyan", light: "#0e7490", dark: "#22d3ee" }, { id: "blue", light: "#0a62c2", dark: "#7ab7ff" },
23
+ { id: "indigo", light: "#4f46e5", dark: "#a5b4fc" }, { id: "violet", light: "#8250df", dark: "#d2a8ff" },
24
+ { id: "pink", light: "#bf3989", dark: "#f778ba" }, { id: "slate", light: "#57606a", dark: "#b1bac4" },
25
+ ];
26
+ export const AUTO_PALETTE = [
27
+ { id: "sand", light: "#8a6d3b", dark: "#d4b98a" }, { id: "clay", light: "#9c5a4a", dark: "#d9a294" },
28
+ { id: "rose", light: "#9b4f6a", dark: "#d99ab0" }, { id: "plum", light: "#7a5a8c", dark: "#c3a6d6" },
29
+ { id: "dusk", light: "#5a628f", dark: "#a9b0db" }, { id: "sea", light: "#3f7286", dark: "#92c2d4" },
30
+ { id: "moss", light: "#5b7340", dark: "#a9c48d" }, { id: "slate", light: "#5f6b78", dark: "#aeb8c4" },
31
+ ];
32
+ export const PLACEMENTS = ["name", "bar", "ring"];
33
+ export const DEFAULT_PLACEMENTS = ["bar", "ring"];
34
+ const HEX = /^#[0-9a-f]{6}$/;
35
+ const presetOf = (c) => (typeof c === "string" && c.startsWith("preset:") ? IDENTITY_PALETTE.find((p) => p.id === c.slice(7)) : undefined);
36
+ const autoOf = (c) => (typeof c === "string" && c.startsWith("auto:") ? AUTO_PALETTE.find((p) => p.id === c.slice(5)) : undefined);
37
+ /** A colour as the server sends it (preset:<id>, auto:<id>, #rrggbb), checked; null for anything else. */
38
+ export function colorOk(c) {
39
+ if (typeof c !== "string") return null;
40
+ if (presetOf(c) || autoOf(c) || HEX.test(c)) return c;
41
+ return null;
42
+ }
43
+ /** A place list, in PLACEMENTS order; null for anything that is not one. */
44
+ export function placesOk(v) {
45
+ if (!Array.isArray(v) || v.length > 3 || !v.every((p) => PLACEMENTS.includes(p)) || new Set(v).size !== v.length) return null;
46
+ return PLACEMENTS.filter((p) => v.includes(p));
47
+ }
48
+ /** The colour drawn for `c` on a page in `mode`. */
49
+ export function colorValue(c, mode) {
50
+ const p = presetOf(c) || autoOf(c);
51
+ return p ? p[mode] : c;
52
+ }
53
+
54
+ // ── Contrast: name text reads at 4.5:1 on the page's and the sidebar's background (tokens.css) ──
55
+
56
+ const BG = { light: ["#ffffff", "#f9f9f9"], dark: ["#212121", "#171717"] };
57
+ const TEXT = { light: "#0d0d0d", dark: "#ececec" };
58
+ function luminance(hex) {
59
+ const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((x) => (x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4));
60
+ return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
61
+ }
62
+ export function contrast(a, b) {
63
+ const [l1, l2] = [luminance(a), luminance(b)].sort((x, y) => y - x);
64
+ return (l1 + 0.05) / (l2 + 0.05);
65
+ }
66
+ /** The shade of `hex` name text uses: itself if readable, else the nearest step toward black (light) or white (dark). */
67
+ export function readableShade(hex, mode) {
68
+ if (!HEX.test(hex)) return TEXT[mode];
69
+ const toward = mode === "light" ? 0 : 255;
70
+ const ch = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
71
+ for (let step = 0; step <= 20; step++) {
72
+ const shade = `#${ch.map((c) => Math.round(c + (toward - c) * (step / 20)).toString(16).padStart(2, "0")).join("")}`;
73
+ if (BG[mode].every((bg) => contrast(shade, bg) >= 4.5)) return shade;
74
+ }
75
+ return TEXT[mode];
76
+ }
77
+
78
+ // ── Resolution ──
79
+
80
+ /** The colour in force and its source, from the frame's tiers. */
81
+ export function resolveColor(tiers, showThemes) {
82
+ const t2 = tiers && typeof tiers === "object" ? tiers : {};
83
+ const person = colorOk(t2.person);
84
+ if (person) return { color: person, source: "person" };
85
+ const agent = showThemes ? colorOk(t2.agent) : null;
86
+ if (agent && !autoOf(agent)) return { color: agent, source: "agent" };
87
+ const role = typeof t2.role === "string" && HEX.test(t2.role) ? t2.role : null;
88
+ if (role) return { color: role, source: "role" };
89
+ return { color: autoOf(t2.auto) ? t2.auto : "auto:sand", source: "auto" };
90
+ }
91
+ /** Where it shows, and whose choice that is. */
92
+ export function resolvePlaces(tiers, showThemes, hostDefault) {
93
+ const t2 = tiers && typeof tiers === "object" ? tiers : {};
94
+ const person = placesOk(t2.person);
95
+ if (person) return { show: person, source: "person" };
96
+ const agent = showThemes ? placesOk(t2.agent) : null;
97
+ if (agent) return { show: agent, source: "agent" };
98
+ return { show: placesOk(hostDefault) || [...DEFAULT_PLACEMENTS], source: "host" };
99
+ }
100
+
101
+ // ── This host's settings (web prefs color.show / color.off) ──
102
+
103
+ export const colorPrefsStore = createStore({ n: 0 });
104
+ const bump = () => colorPrefsStore.set((s) => ({ n: s.n + 1 }));
105
+ onPrefsChanged((changed) => { if (changed.has("color.show") || changed.has("color.off")) bump(); });
106
+ const readLocal = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
107
+ /** Where colours show by default on this host (Settings → Web appearance). */
108
+ export function defaultPlaces() {
109
+ const f = prefInForce("color.show");
110
+ let v = f ? f.value : undefined;
111
+ if (!f) { try { v = JSON.parse(readLocal("agend_color_show") || "null"); } catch { v = null; } }
112
+ return placesOk(v) || [...DEFAULT_PLACEMENTS];
113
+ }
114
+ export function colorsOff() {
115
+ const f = prefInForce("color.off");
116
+ return f ? f.value === true : readLocal("agend_color_off") === "on";
117
+ }
118
+ /** The default places (null: back to ring + bar). */
119
+ export function setDefaultPlaces(list) {
120
+ const v = list === null ? null : placesOk(list);
121
+ const same = v && v.length === DEFAULT_PLACEMENTS.length && v.every((p, i) => p === DEFAULT_PLACEMENTS[i]);
122
+ savePref("color.show", v === null || same ? null : v);
123
+ bump();
124
+ }
125
+ export function setColorsOff(off) {
126
+ savePref("color.off", off ? true : null);
127
+ bump();
128
+ }
129
+
130
+ /** What one instance's row shows: its colour per mode, name shades, places, and whether anything is painted at all. */
131
+ export function identityOf(it, opts = {}) {
132
+ const showThemes = opts.showThemes ?? themesShown();
133
+ const c = resolveColor(it && it.color, showThemes);
134
+ const p = resolvePlaces(it && it.color_show, showThemes, opts.hostDefault ?? defaultPlaces());
135
+ const light = colorValue(c.color, "light"), dark = colorValue(c.color, "dark");
136
+ return {
137
+ color: c.color, source: c.source, show: p.show, showSource: p.source, off: opts.off ?? colorsOff(),
138
+ light, dark, nameLight: readableShade(light, "light"), nameDark: readableShade(dark, "dark"),
139
+ };
140
+ }
141
+ /** The row's classes for its identity colour ("" when colours are off or shown nowhere). */
142
+ export function identityClasses(id) {
143
+ if (!id || id.off || !id.show.length) return "";
144
+ return ["idc", ...id.show.map((p) => `id-${p}`)].join(" ");
145
+ }
146
+ /** Paint an element (a callback ref): the colour's values for both modes as custom properties; the stylesheet picks. */
147
+ export function paintIdentity(el, id) {
148
+ if (!el || !el.style || typeof el.style.setProperty !== "function") return;
149
+ const on = id && !id.off && id.show.length;
150
+ for (const [k, v] of [["--id-light", id && id.light], ["--id-dark", id && id.dark], ["--id-name-light", id && id.nameLight], ["--id-name-dark", id && id.nameDark]]) {
151
+ if (on && v) el.style.setProperty(k, v); else el.style.removeProperty(k);
152
+ }
153
+ }
154
+ /** Redraw when this host's colour settings or the viewer's themes switch change. */
155
+ export function useIdentityColors() {
156
+ useStore(colorPrefsStore);
157
+ useStore(themesStore);
158
+ }
159
+
160
+ // ── The Color dialog (Chat ⋯, Details ⋯, View's Edit profile) ──
161
+
162
+ export const colorDialogStore = createStore({ name: null, it: null });
163
+ export function openColorDialog(name, it) { colorDialogStore.set({ name, it: it || null }); }
164
+ const closeColorDialog = () => colorDialogStore.set({ name: null, it: null });
165
+
166
+ /** The ⋯ entry: on the public link too (decision 3: colours change wherever the profile does). */
167
+ export function colorMenuItem(name, it) {
168
+ return { key: "identity-color", label: t("color.menu"), icon: "palette", onSelect: () => openColorDialog(name, it) };
169
+ }
170
+
171
+ export function ColorDialogHost() {
172
+ const s = useStore(colorDialogStore);
173
+ return s.name ? html`<${ColorDialog} key=${s.name} name=${s.name} it=${s.it} onClose=${closeColorDialog} />` : null;
174
+ }
175
+
176
+ function ColorDialog({ name, it, onClose }) {
177
+ useIdentityColors();
178
+ const [loaded, setLoaded] = useState(null); // the person's choice as stored: { color, color_show }
179
+ const [color, setColor] = useState(null); // the colour picked here (null: none — the next tier)
180
+ const [hex, setHex] = useState("");
181
+ const [places, setPlaces] = useState(null); // null: the default places
182
+ const [busy, setBusy] = useState(false);
183
+ const [error, setError] = useState("");
184
+ useEffect(() => {
185
+ let live = true;
186
+ fetch(`/api/profile/${encodeURIComponent(name)}`).then((r) => (r.ok ? r.json() : null)).then((row) => {
187
+ if (!live) return;
188
+ const c = row ? colorOk(row.color) : null, s = row ? placesOk(row.color_show) : null;
189
+ setLoaded({ color: c, show: s }); setColor(c); setHex(c && HEX.test(c) ? c : ""); setPlaces(s);
190
+ }).catch(() => { if (live) setLoaded({ color: null, show: null }); });
191
+ return () => { live = false; };
192
+ }, [name]);
193
+ // The preview: the instance as it would be drawn with the choice here.
194
+ const preview = identityOf({ ...(it || {}), color: { ...((it && it.color) || {}), person: color || undefined }, color_show: { ...((it && it.color_show) || {}), person: places || undefined } }, { off: false });
195
+ const pick = (c) => { setColor(c); setHex(c && HEX.test(c) ? c : ""); setError(""); };
196
+ const togglePlace = (p) => {
197
+ const base = places || preview.show;
198
+ setPlaces(base.includes(p) ? base.filter((x) => x !== p) : PLACEMENTS.filter((x) => x === p || base.includes(x)));
199
+ };
200
+ async function save(body) {
201
+ setBusy(true); setError("");
202
+ try {
203
+ const res = await fetch(`/api/profile/${encodeURIComponent(name)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
204
+ const r = await res.json().catch(() => ({}));
205
+ if (!res.ok) { setError(t("color.failed", (r && (r.detail || r.error)) || `HTTP ${res.status}`)); setBusy(false); return; }
206
+ toast(t("color.saved", name));
207
+ onClose();
208
+ } catch (err) { setError(t("color.failed", (err && err.message) || "network")); setBusy(false); }
209
+ }
210
+ const onHex = (v) => { setHex(v); const c = /^#[0-9a-fA-F]{6}$/.test(v) ? v.toLowerCase() : null; if (c) { setColor(c); setError(""); } };
211
+ const off = colorsOff();
212
+ return html`<${Dialog} title=${t("color.title", name)} onClose=${onClose} busy=${busy}
213
+ actions=${html`<button type="button" class="btn" disabled=${busy || !loaded} onClick=${() => save({ color: null, color_show: null })}>${t("color.reset")}</button>
214
+ <span class="dlg-spacer"></span>
215
+ <button type="button" class="btn" disabled=${busy} onClick=${onClose}>${t("color.cancel")}</button>
216
+ <button type="button" class="btn btn-primary" disabled=${busy || !loaded} onClick=${() => save({ color, color_show: places })}>${t("color.save")}</button>`}>
217
+ ${off ? html`<p class="note" role="status">${t("color.offNote")}</p>` : null}
218
+ <p class="sub">${t(`color.src_${preview.source}`)}</p>
219
+ <fieldset class="idc-swatches"><legend>${t("color.colour")}</legend>
220
+ ${IDENTITY_PALETTE.map((p) => html`<button type="button" key=${p.id} class=${`idc-swatch${color === `preset:${p.id}` ? " on" : ""}`}
221
+ aria-pressed=${color === `preset:${p.id}` ? "true" : "false"} aria-label=${t(`color.p_${p.id}`)} title=${t(`color.p_${p.id}`)}
222
+ ref=${(el) => { if (el && el.style) { el.style.setProperty("--sw-light", p.light); el.style.setProperty("--sw-dark", p.dark); } }}
223
+ onClick=${() => pick(`preset:${p.id}`)}></button>`)}
224
+ <label class="idc-custom">${t("color.custom")}
225
+ <input type="color" value=${HEX.test(hex) ? hex : "#808080"} onInput=${(e) => onHex(e.currentTarget.value)} aria-label=${t("color.custom")} />
226
+ <input type="text" class="idc-hex" inputmode="text" spellcheck=${false} maxlength="7" placeholder="#rrggbb" value=${hex} onInput=${(e) => onHex(e.currentTarget.value.trim())} aria-label=${t("color.hex")} /></label>
227
+ <button type="button" class="btn btn-sm" disabled=${!color} onClick=${() => pick(null)}>${t("color.noPick")}</button>
228
+ </fieldset>
229
+ <fieldset class="idc-places"><legend>${t("color.places")}</legend>
230
+ ${PLACEMENTS.map((p) => html`<label key=${p} class="check"><input type="checkbox" checked=${preview.show.includes(p)} onChange=${() => togglePlace(p)} /> ${t(`color.place_${p}`)}</label>`)}
231
+ <label class="check"><input type="checkbox" checked=${places === null} onChange=${() => setPlaces(places === null ? [...preview.show] : null)} /> ${t("color.useDefault")}</label>
232
+ </fieldset>
233
+ <div class="idc-preview" aria-hidden="true"><span class=${`inst v-inst ${identityClasses(preview)}`} ref=${(el) => paintIdentity(el, preview)}>
234
+ <span class="av"><span class="av-text">${(name || "?").slice(0, 1).toUpperCase()}</span></span>
235
+ <span class="inst-text"><span class="inst-name">${name}</span></span></span></div>
236
+ ${error ? html`<p class="err" role="alert">${error}</p>` : null}
237
+ </${Dialog}>`;
238
+ }
239
+
240
+ register("color", {
241
+ en: {
242
+ menu: "Colour…", title: "Colour for {0}", colour: "Colour", custom: "Custom", hex: "Hex colour", noPick: "Not chosen",
243
+ places: "Where it shows", place_name: "Name", place_bar: "Left bar", place_ring: "Ring", useDefault: "Use this host's default places",
244
+ src_person: "Shown now: picked by a person.", src_agent: "Shown now: the agent's own colour.", src_role: "Shown now: the colour of its bot's Discord role.",
245
+ src_auto: "Shown now: an automatic colour.", save: "Save", cancel: "Cancel", reset: "Reset",
246
+ saved: "Colour saved for {0}", failed: "Couldn't save the colour: {0}",
247
+ offNote: "Colours are turned off on this host (Settings → Web appearance). What you pick here is kept for when they're on.",
248
+ p_red: "Red", p_orange: "Orange", p_amber: "Amber", p_lime: "Lime", p_green: "Green", p_teal: "Teal", p_cyan: "Cyan", p_blue: "Blue",
249
+ p_indigo: "Indigo", p_violet: "Violet", p_pink: "Pink", p_slate: "Slate",
250
+ },
251
+ "zh-TW": {
252
+ menu: "顏色…", title: "{0} 的顏色", colour: "顏色", custom: "自訂", hex: "色碼", noPick: "不指定",
253
+ places: "顯示位置", place_name: "名稱", place_bar: "左側色條", place_ring: "頭像外框", useDefault: "使用這台主機的預設位置",
254
+ src_person: "目前顯示:有人挑選的顏色。", src_agent: "目前顯示:agent 自己的顏色。", src_role: "目前顯示:它的 bot 在 Discord 身分組的顏色。",
255
+ src_auto: "目前顯示:自動配色。", save: "儲存", cancel: "取消", reset: "重設",
256
+ saved: "已儲存 {0} 的顏色", failed: "無法儲存顏色:{0}",
257
+ offNote: "這台主機已關閉顏色(設定 → 網頁外觀)。在這裡挑的顏色會保留,重新開啟後就會出現。",
258
+ p_red: "紅", p_orange: "橙", p_amber: "琥珀", p_lime: "萊姆", p_green: "綠", p_teal: "青綠", p_cyan: "青", p_blue: "藍",
259
+ p_indigo: "靛", p_violet: "紫", p_pink: "粉紅", p_slate: "石板灰",
260
+ },
261
+ });
@@ -16,6 +16,8 @@ import { Skeleton } from "./ui-states.js";
16
16
  import { viewStore } from "./view-roster-store.js";
17
17
  import { onPrefsChanged, prefInForce, prefsWritable, savePref } from "./app-prefs.js";
18
18
  import "./view-strings.js";
19
+ import { themesShown, themesStore } from "./app-themes.js";
20
+ import { identityClasses, identityOf, paintIdentity, useIdentityColors } from "./identity-color.js";
19
21
 
20
22
  /** view.* text with named values. */
21
23
  function tn(key, values = {}) {
@@ -222,6 +224,8 @@ function writeFacetsOpen(open) { try { localStorage.setItem(FACETS_KEY, JSON.str
222
224
 
223
225
  /** The Favorites group's key among the tag groups: no tag can be it (a group name is never a control character). */
224
226
  const FAV_GROUP = "\u0000favorites";
227
+ /** #1936: the open instance, pinned on top while a filter is on. */
228
+ const PINNED_GROUP = "\u0000pinned";
225
229
  function persistOrder(groupNames, instByGroup) {
226
230
  const rows = [];
227
231
  groupNames.forEach((g, i) => rows.push({ item_type: "group", item_name: g, sort_index: i, group_name: null }));
@@ -280,9 +284,16 @@ const CLI_MONOGRAM = { "claude-code": "C", codex: "X", "kiro-cli": "K", grok: "G
280
284
  // One path segment under /ui/img/<kind>/ — no further slash or backslash, and not a dot segment ("." / ".." or their
281
285
  // %2e spellings, which a browser resolves too): the picture can only ever be one of those routes (#1642 review).
282
286
  const sameOrigin = (src) => typeof src === "string"
283
- && /^\/ui\/img\/(bot|avatar|emoji)\/(?!(?:\.|%2[eE]){1,2}(?:\?|$))[^/\\?#\s]+(\?v=[0-9A-Za-z%._-]*)?$/.test(src);
284
- /** The avatar to draw: the server's, if it is one the page may load; else the first character. */
287
+ && (/^\/ui\/img\/(bot|avatar|emoji)\/(?!(?:\.|%2[eE]){1,2}(?:\?|$))[^/\\?#\s]+(\?v=[0-9A-Za-z%._-]*)?$/.test(src)
288
+ // #1629: a theme's picture — one name segment, then its 32-hex content id.
289
+ || /^\/ui\/img\/theme\/(?!(?:\.|%2[eE]){1,2}\/)[^/\\?#\s]+\/[0-9a-f]{32}$/.test(src));
290
+ /**
291
+ * The avatar to draw: the server's, if it is one the page may load; else the first character. #1629: the agent's theme
292
+ * picture first, while this viewer shows agents' themes (fx.show, not safe mode).
293
+ */
285
294
  export function avatarOf(it) {
295
+ const th = it && themesShown() ? it.theme_avatar : null;
296
+ if (th && th.kind === "image" && sameOrigin(th.src)) return { kind: "image", src: th.src };
286
297
  const a = it && it.avatar;
287
298
  if (a && a.kind === "image" && sameOrigin(a.src)) return { kind: "image", src: a.src };
288
299
  if (a && a.kind === "emoji" && sameOrigin(a.src)) return { kind: "emoji", src: a.src, alt: typeof a.alt === "string" ? a.alt : "" };
@@ -290,7 +301,6 @@ export function avatarOf(it) {
290
301
  // #1722: the initial is the leading name's ("Names"), not the server's (which is always the nickname's).
291
302
  return { kind: "initial", text: firstCharacter(namesOf(it).primary) || (a && a.kind === "initial" && typeof a.text === "string" ? a.text : "") };
292
303
  }
293
- const ACCENT = /^#[0-9a-f]{6}$/i;
294
304
  /** The status dot on the avatar: needs you, crashed, paused, stopped, looks stuck, working, idle — colour AND a label. */
295
305
  export function dotState(it, exec, awaiting) {
296
306
  if (awaiting != null) return "needs";
@@ -306,22 +316,19 @@ const DOT = {
306
316
  };
307
317
  export function dotLabel(state) { return (DOT[state] || DOT.stopped)[1](); }
308
318
 
309
- /** An instance's avatar with its status dot. The ring (its bot's colour) is a CSS custom property set through the CSSOM. */
319
+ /**
320
+ * An instance's avatar with its status dot. #1958: its ring is the identity colour, painted by the row it sits in
321
+ * (identity-color.js: `.idc.id-ring`), for every face.
322
+ */
310
323
  export function InstanceAvatar({ it, state, cli = false }) {
324
+ useStore(themesStore); // #1629: redraw when the viewer turns agents' themes on or off
311
325
  const a = avatarOf(it);
312
326
  const [broken, setBroken] = useState("");
313
- const el = useRef(null);
314
- const accent = typeof it.accent === "string" && ACCENT.test(it.accent) && a.kind === "image" ? it.accent : "";
315
- useLayoutEffect(() => {
316
- const node = el.current;
317
- if (!node || !node.style) return;
318
- if (accent) node.style.setProperty("--ring", accent); else node.style.removeProperty("--ring");
319
- }, [accent]);
320
327
  const src = a.kind !== "initial" && a.src && broken !== a.src ? a.src : "";
321
328
  const text = a.kind === "emoji" && a.text ? a.text : a.kind === "initial" ? a.text : firstCharacter(namesOf(it).primary);
322
329
  const [cls] = DOT[state] || DOT.stopped;
323
330
  const label = dotLabel(state);
324
- return html`<span ref=${el} class=${`av${accent ? " ring" : ""}${a.kind === "emoji" ? " av-emoji" : ""}`}>
331
+ return html`<span class=${`av${a.kind === "emoji" ? " av-emoji" : ""}`}>
325
332
  ${src ? html`<img src=${src} alt="" decoding="async" onError=${() => setBroken(src)} />` : html`<span class="av-text" aria-hidden="true">${text}</span>`}
326
333
  <span class=${`av-dot dot ${cls}`} role="img" aria-label=${label} title=${label}></span>${cli
327
334
  ? html`<span class="av-cli" aria-hidden="true" title=${backendLabel(it.backend)}>${CLI_MONOGRAM[it.backend] || "·"}</span>` : null}</span>`;
@@ -341,6 +348,7 @@ const statusText = (facet) => (facet === "needs" ? t("app.needsYou") : facet ===
341
348
  export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting = {}, viewOnly = false, onPick }) {
342
349
  const nav = useStore(navStore);
343
350
  const names = useStore(namesStore); // #1722: which name leads
351
+ useIdentityColors(); // #1958: repaint when the host's colour settings or the themes switch change
344
352
  const filterRef = useRef(null);
345
353
  const list = useRef(null);
346
354
  const drag = useRef(null);
@@ -350,13 +358,12 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
350
358
  // #1664: "☆ Edit" — the stars on every row (on a phone the only way to them) and ↑ ↓ on the favorites — only on the
351
359
  // full list, as drag: in a filtered one the row above may not be the one above in the list (#1677 review).
352
360
  const [editing, setEditing] = useState(false);
353
- // Where a row goes: the view the page is in (#1523 §3.1). View → View; Details, or a Fleet tab (Q4 = A), → Details;
354
- // everywhere else → Chat. The anonymous reader only ever has View.
361
+ // Where a row goes: the view the page is in (#1523 §3.1). View → View; Details → Details; everywhere else, a Fleet tab
362
+ // included (#1917: the user's call, replacing Q4 = A), → Chat. The anonymous reader only ever has View.
355
363
  const rowPath = viewOnly || (route && route.panel === "view") ? viewPath
356
364
  : route && route.panel === "log" ? logPath // #1218: Log → Log
357
- : route && (route.panel === "details" || route.panel === "fleet") ? detailsPath : chatPath;
365
+ : route && route.panel === "details" ? detailsPath : chatPath;
358
366
  const active = route && (route.panel === "chat" || route.panel === "view" || route.panel === "log" || route.panel === "details") ? route.instance : null;
359
- useLayoutEffect(() => { keepActiveInView(list.current); }, [active, items.length, loaded]);
360
367
  const own = (map, name) => Object.prototype.hasOwnProperty.call(map, name);
361
368
  const byName = new Map(items.map((it) => [nameOf(it), it]));
362
369
  const facetOf = (name) => statusFacet(byName.get(name), own(exec, name) ? exec[name] : null, own(awaiting, name) ? awaiting[name] : null);
@@ -373,6 +380,12 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
373
380
  if (favs.length) shownInsts.set(FAV_GROUP, favs);
374
381
  const f = nav.filter;
375
382
  const shown = filterSidebar(shownGroups, shownInsts, byName, f.q, backendLabel, { status: f.status, cli: f.cli, statusOf: facetOf });
383
+ // #1936: while a filter is on, the instance you have open stays on top, pinned and marked "Current", whether or not
384
+ // it matches; a match is not repeated below. "N / M" still counts the filter's matches (the pinned row counts only
385
+ // when it is one). Clearing the filter puts it back in its place, and the list scrolls to it again.
386
+ const pinned = shown.active && active && byName.has(active) ? active : null;
387
+ const listed = pinned ? shown.groups.map((x) => ({ ...x, names: x.names.filter((n) => n !== pinned) })).filter((x) => x.names.length) : shown.groups;
388
+ useLayoutEffect(() => { keepActiveInView(list.current); }, [active, items.length, loaded, shown.active]);
376
389
  const backends = [...new Set(items.map((it) => it.backend).filter((b) => typeof b === "string" && b))].sort((a, b) => backendLabel(a).localeCompare(backendLabel(b)));
377
390
  const toggle = (g) => { const c = new Set(navStore.get().collapsed); if (c.has(g)) c.delete(g); else c.add(g); setCollapsed(c); };
378
391
  // From the store, not this render's copy: two clicks before the next render must both count.
@@ -431,8 +444,10 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
431
444
  const waiting = own(awaiting, name) ? awaiting[name] : null;
432
445
  const on = name === active;
433
446
  const fav = g === FAV_GROUP, starred = favSet.has(name);
447
+ // #1958: its identity colour — the ring, a left bar and/or the name, as resolved; painted through the CSSOM.
448
+ const id = identityOf(it);
434
449
  const full = !starred && favs.length >= MAX_FAVORITES;
435
- return html`<li key=${name} class="v-li"><a class=${`inst v-inst${on ? " active" : ""}`} href=${rowPath(name)} title=${instanceTooltip(it)}
450
+ return html`<li key=${name} class="v-li"><a class=${`inst v-inst${on ? " active" : ""} ${identityClasses(id)}`} ref=${(el) => paintIdentity(el, id)} href=${rowPath(name)} title=${instanceTooltip(it)}
436
451
  aria-current=${on ? "page" : undefined} onClick=${onPick} ...${fav ? {} : dragProps({ type: "instance", name, group: g })}>
437
452
  <${InstanceAvatar} it=${it} state=${dotState(it, own(exec, name) ? exec[name] : null, waiting)} cli=${true} />
438
453
  <span class="inst-text"><span class="inst-name" title=${label}>${label}</span>
@@ -450,12 +465,15 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
450
465
  };
451
466
  // The list scrolls; the filter under it stays in view (#999), so it sits outside the scrolling section.
452
467
  return html`<div class=${`view-roster inst-nav${editing && canStar ? " fav-editing" : ""}`}>
453
- <div class="side-section" id="instanceList" ref=${list}>
468
+ <div data-trust="navigation" class="side-section" id="instanceList" ref=${list}>
454
469
  <div class="inst-head"><h2 class="side-label">${tn("instancesLabel")}</h2>
455
470
  ${canStar && items.length ? html`<button type="button" class="chip-btn fav-edit" aria-pressed=${editing ? "true" : "false"} onClick=${() => setEditing(!editing)}>
456
471
  ${editing ? tn("favDone") : html`<${Icon} name="star" size=${12} />${tn("favEdit")}`}</button>` : null}</div>
457
472
  ${!loaded ? html`<${Skeleton} lines=${4} />` : !items.length ? html`<p class="side-empty">${t("app.noInstances")}</p>` : null}
458
- ${shown.groups.map(({ group: g, names }) => {
473
+ ${pinned ? html`<div key=${PINNED_GROUP} class="v-group v-pinned" role="group" aria-label=${tn("pinnedLabel")}>
474
+ <div class="v-group-head pinned-head"><span class="grow">${tn("pinnedCurrent")}</span></div>
475
+ <ul class="inst-list">${row(pinned, PINNED_GROUP, 0, 1)}</ul></div>` : null}
476
+ ${listed.map(({ group: g, names }) => {
459
477
  // #1664: Favorites — always open, not dragged (↑ ↓ in Edit), named in the page's language.
460
478
  if (g === FAV_GROUP) return html`<div key=${g} class="v-group v-fav" role="group" aria-label=${tn("favorites")}>
461
479
  <div class="v-group-head fav-head"><${Icon} name="star" size=${14} cls="fav-mark" /><span class="grow">${tn("favorites")}</span><span class="count">${names.length}</span></div>
@@ -0,0 +1,221 @@
1
+ // #1629 r4 §3: owner CSS on this page — the signed-in person's own stylesheet, linked last in <head> so it wins over
2
+ // app.css. It is a same-origin <link> (style-src 'self' admits it): no inline CSS, no nonce, no style attribute and no
3
+ // constructed stylesheet. Never linked:
4
+ // - on Settings (removed while it is open, put back on leaving: the way out is always rendered stock);
5
+ // - on the public link, for the anonymous View reader, and wherever the server named no version (data-owner-css);
6
+ // - on a device that turned it off (agend_owner_css_off), and for a load with ?safe=1 (safe mode).
7
+ // Sign-in, resend-code and setup are other pages: they never load this module.
8
+ import { navStore } from "./app-nav.js";
9
+ import { appStore, createStore, useStore } from "./app-store.js";
10
+ import { html, useEffect, useState } from "./app-html.js";
11
+ import { t } from "./app-i18n.js";
12
+
13
+ export const OWNER_CSS_OFF_KEY = "agend_owner_css_off";
14
+ const LINK_ID = "owner-css";
15
+
16
+ // Read when this module loads, so it holds for whatever asks first (themes start before installOwnerCss runs).
17
+ const readSafe = () => { try { return new URLSearchParams(location.search).get("safe") === "1"; } catch { return false; } };
18
+ let safe = readSafe();
19
+ let active = null;
20
+ let installed = false;
21
+ // B2: admission uses positive observed lifecycle, never active===null as proof of absence.
22
+ const lifeStore = createStore(Object.freeze({ state: "unknown", generation: 0, reason: "unverified" }));
23
+ export const ownerCssLifecycle = Object.freeze({ get: lifeStore.get, subscribe: lifeStore.subscribe });
24
+ let lifeGeneration = 0, linkGeneration = 0, ownedLink = null, linkState = "unknown", sourceState = "unknown";
25
+ function life(state, reason) { lifeStore.set(() => Object.freeze({ state, generation: ++lifeGeneration, reason })); }
26
+
27
+
28
+ /** This load is in safe mode (?safe=1): no owner CSS — and, as they come, no widgets or effects and default themes. */
29
+ export function safeMode() { return safe; }
30
+
31
+ /** This device turned owner CSS off (a per-device switch: not synced, so a broken phone layout is fixed on the phone). */
32
+ export function ownerCssOffHere() {
33
+ try { return localStorage.getItem(OWNER_CSS_OFF_KEY) === "1"; } catch { return false; }
34
+ }
35
+ export function setOwnerCssOffHere(off) {
36
+ try { if (off) localStorage.setItem(OWNER_CSS_OFF_KEY, "1"); else localStorage.removeItem(OWNER_CSS_OFF_KEY); } catch { /* this page only */ }
37
+ sync();
38
+ }
39
+
40
+ /** The person's version in force, as this page knows it (null: none). */
41
+ export function ownerCssActive() { return active; }
42
+
43
+ function eligiblePage() {
44
+ const b = document.body && document.body.dataset;
45
+ return !!b && b.mode === "full" && b.publicLink !== "1";
46
+ }
47
+ function wanted() {
48
+ if (!eligiblePage() || safe || !active || ownerCssOffHere()) return false;
49
+ const r = navStore.get().route;
50
+ return !(r && r.panel === "settings");
51
+ }
52
+
53
+ /** Revoke admission before a CSS/route mutation; a missing element alone is never an off verdict. */
54
+ function sync() {
55
+ life("unknown", "changing");
56
+ const el = document.getElementById(LINK_ID);
57
+ if (!wanted()) {
58
+ ++linkGeneration;
59
+ if (el) el.remove();
60
+ if (ownedLink && ownedLink !== el) ownedLink.remove();
61
+ ownedLink = null; linkState = "unknown";
62
+ const absent = document.querySelectorAll(`#${LINK_ID}`).length === 0;
63
+ const route = navStore.get().route;
64
+ const disabled = !eligiblePage() || safe || ownerCssOffHere() || route?.panel === "settings";
65
+ life(absent && (disabled || sourceState === "off") ? "off" : "unknown", absent ? disabled ? "disabled_here" : sourceState === "off" ? "host_off" : "unverified" : "removal_failed");
66
+ return;
67
+ }
68
+ const href = `/ui/owner-css.css?v=${encodeURIComponent(active)}`;
69
+ if (el && el !== ownedLink) { life("unknown", "unowned_link"); return; }
70
+ if (ownedLink && el === ownedLink && el.getAttribute("href") === href) { life(linkState, linkState === "on" ? "loaded" : linkState === "loading" ? "loading" : "load_failed"); return; }
71
+ // A new owned element fences late events from the previous version, including href changes.
72
+ const epoch = ++linkGeneration, sha = active;
73
+ if (ownedLink) ownedLink.remove();
74
+ const link = document.createElement("link"); ownedLink = link; linkState = "loading";
75
+ link.setAttribute("rel", "stylesheet"); link.setAttribute("id", LINK_ID); link.setAttribute("href", href);
76
+ const current = () => ownedLink === link && epoch === linkGeneration && active === sha && wanted()
77
+ && link.isConnected === true && document.getElementById(LINK_ID) === link && link.getAttribute("href") === href;
78
+ link.addEventListener("load", () => {
79
+ if (!current()) return;
80
+ let valid = false;
81
+ try { valid = link.disabled !== true && !!link.sheet && link.sheet.ownerNode === link && link.sheet.disabled !== true && link.sheet.href === new URL(href, location.href).href; } catch { /* unknown stylesheet */ }
82
+ linkState = valid ? "on" : "unknown"; life(linkState, valid ? "loaded" : "load_unverified");
83
+ });
84
+ link.addEventListener("error", () => { if (!current()) return; linkState = "unknown"; life("unknown", "load_failed"); });
85
+ life("loading", "loading");
86
+ document.head.appendChild(link); // last in head; no style text, inline attribute or constructed stylesheet
87
+ }
88
+
89
+ // #1883 r2: every change this page makes, and every read it starts, moves `gen`. A host answer is taken only if nothing
90
+ // newer happened after it was asked for — an older /state answer never overwrites a newer read, save, keep or revert.
91
+ let gen = 0;
92
+ const local = () => ++gen;
93
+
94
+ /** This page saved, restored or turned off a version: show it at once (null: none). */
95
+ export function setOwnerCssActive(sha) {
96
+ local();
97
+ showActive(sha, sha === null ? "off" : "on");
98
+ }
99
+ function showActive(sha, evidence = "unknown") {
100
+ active = typeof sha === "string" && /^[0-9a-f]{32}$/.test(sha) ? sha : null;
101
+ sourceState = evidence === "off" && sha === null ? "off" : evidence === "on" && active ? "on" : "unknown";
102
+ sync();
103
+ }
104
+
105
+ // ── Keep or revert (r4 §3): a saved version is on trial. "Keep" within 15 s keeps it; anything else reverts it — the
106
+ // page that saved it at 15 s, and the host at its own deadline (a closed tab, or CSS that hid this bar). The bar is the
107
+ // shell's, on every page and every device of the person's, so they can look at the result before answering.
108
+ // A trial has an id of its own (the same text saved twice is two trials): keep and revert name it, and the host acts
109
+ // only while that trial still is the one on trial — one kept, reverted or replaced meanwhile, anywhere, stays.
110
+ export const KEEP_SECONDS = 15;
111
+ /** The host's whole trial budget (20 s) and a second: the longest a page that did not save a trial waits to ask again. */
112
+ const HOST_TRIAL_SECONDS = 21;
113
+ /** { id, sha, previous, deadline (performance.now ms), local } while a version is on trial, else null. */
114
+ export const ownerCssTrial = createStore(null);
115
+ let trialTimer = null;
116
+ // set(fn) replaces the state; set(object) would merge into it (createStore), and null merges as nothing.
117
+ const dropTrial = () => { if (trialTimer) clearTimeout(trialTimer); trialTimer = null; ownerCssTrial.set(() => null); };
118
+
119
+ async function post(path, body) {
120
+ const res = await fetch(path, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
121
+ let json = null;
122
+ try { json = await res.json(); } catch { /* none */ }
123
+ return { ok: res.ok, status: res.status, body: json };
124
+ }
125
+
126
+ /**
127
+ * Trial `id` (of version `sha`) is in force: show it, and ask. `local`: this page saved it — at the end of `seconds` it
128
+ * reverts it (by its id). Otherwise (another device's trial, or this page loaded during one) `seconds` is the host's
129
+ * own remaining time (never the device's clock), and at its end the page asks the host again, which has settled it.
130
+ */
131
+ export function startOwnerCssTrial(id, sha, previous, seconds = KEEP_SECONDS, isLocal = true) {
132
+ local();
133
+ if (trialTimer) clearTimeout(trialTimer);
134
+ showActive(sha, "on");
135
+ const s = Math.max(0, Math.min(isLocal ? KEEP_SECONDS : HOST_TRIAL_SECONDS, Number.isFinite(seconds) ? seconds : 0));
136
+ const trial = { id, sha, previous: previous || null, deadline: performance.now() + s * 1000, local: isLocal };
137
+ ownerCssTrial.set(() => trial);
138
+ trialTimer = setTimeout(() => {
139
+ if (ownerCssTrial.get() !== trial) return;
140
+ if (isLocal) revertOwnerCss(); else { dropTrial(); reread(); }
141
+ }, s * 1000);
142
+ }
143
+ export async function keepOwnerCss() {
144
+ const trial = ownerCssTrial.get();
145
+ if (!trial) return false;
146
+ local();
147
+ dropTrial();
148
+ const r = await post("/ui/owner-css/keep", { trial: trial.id });
149
+ if (!r.ok) { await reread(); return false; } // too late: the host reverted it — show what is in force
150
+ return true;
151
+ }
152
+ export async function revertOwnerCss() {
153
+ const trial = ownerCssTrial.get();
154
+ if (!trial) return;
155
+ local();
156
+ dropTrial();
157
+ showActive(trial.previous); // at once; the host's answer below is what stays
158
+ await post("/ui/owner-css/revert", { trial: trial.id }).catch(() => null);
159
+ await reread();
160
+ }
161
+
162
+ /** The shell's bar: "Keep this CSS?", the seconds left, Keep and Revert. */
163
+ export function OwnerCssKeepBar() {
164
+ const trial = useStore(ownerCssTrial);
165
+ const [, tick] = useState(0);
166
+ useEffect(() => {
167
+ if (!trial) return undefined;
168
+ const id = setInterval(() => tick((n) => n + 1), 500);
169
+ return () => clearInterval(id);
170
+ }, [trial]);
171
+ if (!trial) return null;
172
+ const left = Math.max(0, Math.ceil((trial.deadline - performance.now()) / 1000));
173
+ return html`<div class="oc-keep" role="alertdialog" aria-live="assertive" aria-label=${t("app.ownerCssKeep")} data-trust="keep">
174
+ <span class="oc-keep-text"><span class="oc-keep-q">${t("app.ownerCssKeep")}</span><span class="oc-keep-left">${t("app.ownerCssKeepLeft", left)}</span></span>
175
+ <button type="button" class="btn btn-primary btn-sm" onClick=${() => { keepOwnerCss(); }}>${t("app.ownerCssKeepYes")}</button>
176
+ <button type="button" class="btn btn-sm" onClick=${() => { revertOwnerCss(); }}>${t("app.ownerCssKeepNo")}</button>
177
+ </div>`;
178
+ }
179
+
180
+ /** Ask the host which version is in force (another device changed it, or a trial ended), and whether one is on trial. */
181
+ async function reread() {
182
+ if (!eligiblePage()) return;
183
+ const mine = local();
184
+ sourceState = "unknown"; life("unknown", "refreshing");
185
+ try {
186
+ const res = await fetch("/ui/owner-css/state", { credentials: "same-origin" });
187
+ if (!res.ok) return;
188
+ const body = await res.json();
189
+ if (body && mine === gen) reconcile(body); // nothing newer happened since this was asked
190
+ } catch { /* the next change tries again */ }
191
+ }
192
+ /** Match the host: the version in force, and whether one is on trial (another device's included) — or none any more. */
193
+ function reconcile(state) {
194
+ const pending = state.pending && state.pending.sha === state.active && typeof state.pending.id === "string" ? state.pending : null;
195
+ const trial = ownerCssTrial.get();
196
+ if (!pending) { if (trial) dropTrial(); showActive(state.active, state.effectState); return; }
197
+ if (trial && trial.id === pending.id) return; // already asking about this one
198
+ // The host's own remaining time and a second (never this device's clock): it has settled the trial by then.
199
+ const remaining = typeof pending.remaining_ms === "number" && Number.isFinite(pending.remaining_ms) ? pending.remaining_ms : 0;
200
+ startOwnerCssTrial(pending.id, pending.sha, pending.previous, remaining / 1000 + 1, false);
201
+ }
202
+
203
+ /** Once per page load, before the first panel paints. */
204
+ export function installOwnerCss() {
205
+ if (installed) return;
206
+ installed = true;
207
+ safe = readSafe();
208
+ const b = document.body && document.body.dataset;
209
+ showActive(b?.ownerCss ?? null, b?.ownerCssState);
210
+ navStore.subscribe(sync);
211
+ // What the host says now: a trial in progress (this page reloaded during one, or another device's) shows the bar;
212
+ // one the host has settled since the page was served shows its outcome.
213
+ if (eligiblePage()) reread();
214
+ let rev = appStore.get().prefsRev;
215
+ appStore.subscribe((s) => {
216
+ if (s.prefsRev === rev) return;
217
+ const first = rev === null || rev === undefined;
218
+ rev = s.prefsRev;
219
+ if (!first) reread();
220
+ });
221
+ }