@songsid/agend 2.3.0-beta.12 → 2.3.0-beta.14

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 (89) hide show
  1. package/README.md +6 -0
  2. package/README.zh-TW.md +6 -0
  3. package/dist/backend/codex-metadata.d.ts +8 -0
  4. package/dist/backend/codex-metadata.js +74 -17
  5. package/dist/backend/codex-metadata.js.map +1 -1
  6. package/dist/config.d.ts +13 -1
  7. package/dist/config.js +14 -5
  8. package/dist/config.js.map +1 -1
  9. package/dist/fleet-manager.d.ts +36 -1
  10. package/dist/fleet-manager.js +135 -43
  11. package/dist/fleet-manager.js.map +1 -1
  12. package/dist/general-knowledge/skills/fleet-config/SKILL.md +2 -0
  13. package/dist/general-knowledge/skills/web-appearance/SKILL.md +21 -3
  14. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +23 -5
  15. package/dist/outbound-schemas.d.ts +14 -1
  16. package/dist/outbound-schemas.js +8 -2
  17. package/dist/outbound-schemas.js.map +1 -1
  18. package/dist/public-web-gateway.js +4 -0
  19. package/dist/public-web-gateway.js.map +1 -1
  20. package/dist/restart-progress.d.ts +16 -1
  21. package/dist/restart-progress.js +74 -15
  22. package/dist/restart-progress.js.map +1 -1
  23. package/dist/settings-api.js +4 -1
  24. package/dist/settings-api.js.map +1 -1
  25. package/dist/settings-confirmation.d.ts +3 -1
  26. package/dist/settings-confirmation.js +2 -0
  27. package/dist/settings-confirmation.js.map +1 -1
  28. package/dist/settings-effect.js +2 -1
  29. package/dist/settings-effect.js.map +1 -1
  30. package/dist/settings-http-confirmation.d.ts +11 -0
  31. package/dist/settings-http-confirmation.js +10 -0
  32. package/dist/settings-http-confirmation.js.map +1 -1
  33. package/dist/topic-commands.d.ts +10 -0
  34. package/dist/topic-commands.js +27 -15
  35. package/dist/topic-commands.js.map +1 -1
  36. package/dist/ui/chat-effects.js +10 -3
  37. package/dist/ui/chat-strings.js +2 -0
  38. package/dist/ui/effects-waker.js +70 -0
  39. package/dist/ui/fleet-canvas-layout.js +10 -4
  40. package/dist/ui/fleet-canvas.js +32 -2
  41. package/dist/ui/fleet-route-scene.js +207 -0
  42. package/dist/ui/fleet-route-worker.js +21 -0
  43. package/dist/ui/fleet-route.js +88 -0
  44. package/dist/ui/fleet-traffic.js +27 -11
  45. package/dist/ui/fleet-usage.js +35 -1
  46. package/dist/ui/panel-chat.js +51 -24
  47. package/dist/ui/panel-fleet.js +3 -3
  48. package/dist/ui/settings-appearance.js +2 -1
  49. package/dist/ui/settings-apply.js +3 -3
  50. package/dist/ui/settings-confirm.js +7 -2
  51. package/dist/ui/settings-owner-effects.js +6 -4
  52. package/dist/ui/shared/app-i18n.js +4 -0
  53. package/dist/ui/shared/app-prefs.js +12 -0
  54. package/dist/ui/shared/app-shell.js +38 -2
  55. package/dist/ui/shared/app-themes.js +116 -1
  56. package/dist/ui/shared/app.css +38 -3
  57. package/dist/ui/shared/card-strings.js +4 -0
  58. package/dist/ui/shared/identity-card.js +26 -8
  59. package/dist/ui/shared/identity-color.js +11 -7
  60. package/dist/ui/shared/ui-icons.js +1 -0
  61. package/dist/ui/shared/ui-menu.js +20 -7
  62. package/dist/ui/shared/web-authorize.js +47 -0
  63. package/dist/web-api.d.ts +0 -3
  64. package/dist/web-api.js +80 -14
  65. package/dist/web-api.js.map +1 -1
  66. package/dist/web-appearance.js +3 -1
  67. package/dist/web-appearance.js.map +1 -1
  68. package/dist/web-effect.d.ts +1 -1
  69. package/dist/web-host-guard.d.ts +11 -0
  70. package/dist/web-host-guard.js +11 -0
  71. package/dist/web-host-guard.js.map +1 -1
  72. package/dist/web-libavoid.d.ts +14 -0
  73. package/dist/web-libavoid.js +46 -0
  74. package/dist/web-libavoid.js.map +1 -0
  75. package/dist/web-owner-css-store.d.ts +3 -2
  76. package/dist/web-owner-css-store.js +9 -4
  77. package/dist/web-owner-css-store.js.map +1 -1
  78. package/dist/web-prefs.js +13 -0
  79. package/dist/web-prefs.js.map +1 -1
  80. package/dist/web-settings-tools.d.ts +5 -0
  81. package/dist/web-settings-tools.js +34 -6
  82. package/dist/web-settings-tools.js.map +1 -1
  83. package/dist/web-theme.d.ts +29 -0
  84. package/dist/web-theme.js +89 -1
  85. package/dist/web-theme.js.map +1 -1
  86. package/dist/yaml-shared-nodes.d.ts +25 -0
  87. package/dist/yaml-shared-nodes.js +185 -0
  88. package/dist/yaml-shared-nodes.js.map +1 -0
  89. package/package.json +2 -1
@@ -350,6 +350,10 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
350
350
  .menu-item:hover:not(:disabled), .menu-item:focus-visible { background: var(--surface-2); outline: none; }
351
351
  .menu-item.danger { color: var(--danger); }
352
352
  .menu-item:disabled { opacity: 0.45; }
353
+ /* #2196: a group's heading in a menu (Page colours) and its radio items (the chosen one ticked). */
354
+ .menu-heading { padding: var(--s-2) var(--s-3) var(--s-1); font-size: var(--fs-xs); font-weight: var(--fw-semibold); color: var(--text-2); border-top: 1px solid var(--border); margin-top: var(--s-1); }
355
+ .menu-heading:first-child { border-top: 0; margin-top: 0; }
356
+ .menu-radio .menu-tick { width: 16px; display: inline-flex; color: var(--accent); }
353
357
 
354
358
  /* ── Dialog: centred on a desktop, a bottom sheet on a phone ── */
355
359
  .dlg { border: 0; padding: 0; border-radius: var(--r-lg); background: var(--bg); color: var(--text); box-shadow: var(--shadow-2); width: min(480px, calc(100vw - 32px)); max-height: 85vh; }
@@ -560,6 +564,14 @@ html.lb-open .scroller { overflow: hidden; }
560
564
  .chat-main { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
561
565
  /* #1629: an agent's theme on its own chat surface — custom properties set on .p-chat through the CSSOM, choices as
562
566
  these tokens. The surface paints its own background (the page's body is not its). */
567
+ /*
568
+ * #2196: an agent's colours on the whole page (app-themes.js applyPageTheme, on .shell): the shell paints its own
569
+ * background then, under the sidebar, the header, the usage rail and a phone's tabs. The header follows the page, not
570
+ * the chat: it takes the shell's colours back (--chrome-*: resolved on .shell, so the page's theme or the page's own).
571
+ */
572
+ .shell.th-page { background: var(--bg); color: var(--text); }
573
+ .shell { --chrome-bg: var(--bg); --chrome-surface: var(--surface); --chrome-text: var(--text); --chrome-text-2: var(--text-2); --chrome-accent: var(--accent); }
574
+ .p-chat > .panel-head { --bg: var(--chrome-bg); --surface: var(--chrome-surface); --text: var(--chrome-text); --text-2: var(--chrome-text-2); --accent: var(--chrome-accent); background: var(--bg); color: var(--text); }
563
575
  .p-chat.th { background: var(--bg); color: var(--text); }
564
576
  .p-chat.th .msg.user .body { border-radius: var(--bubble-radius, var(--r-xl)); }
565
577
  .p-chat.th-font-system .thread, .p-chat.th-font-system .composer textarea { font-family: system-ui, -apple-system, "Segoe UI", sans-serif, var(--font-emoji); }
@@ -684,7 +696,30 @@ img.amb-p { object-fit: contain; }
684
696
  /* A narrow phone: Stop reply beside Send leaves the message box no room — the label is for screen readers there. */
685
697
  @media (max-width: 479px) { .btn-stop .stop-label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } }
686
698
  .btn-stop { background: var(--bg); }
687
- .hint { font-size: var(--fs-xs); color: var(--text-3); text-align: center; }
699
+ .hint { font-size: var(--fs-xs); color: var(--text-3); }
700
+ /* #2215 (the user's request, 2026-10-19): one row under the composer, at the bottom left — this browser's web authority, then
701
+ the key hint. Its height is kept even before the authority is read (#2184: switching chats never moves the composer). */
702
+ .composer-row { position: relative; display: flex; align-items: center; gap: var(--s-2); min-height: 20px; margin-top: calc(-1 * var(--s-1));
703
+ font-size: var(--fs-xs); line-height: 1.4; color: var(--text-3); white-space: nowrap; }
704
+ .composer-row > * { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
705
+ .composer-row > .hint { flex-shrink: 1000; }
706
+ .composer-row > :not(.hint) + .hint::before { content: "·"; margin-right: var(--s-2); }
707
+ /* A guest's note and the public link's sentence are kept whole: on a narrow screen they wrap rather than lose words. */
708
+ .composer-row .guest-note, .composer-row .webauth-row[data-state="public"] { margin: 0; text-align: left; white-space: normal; flex-shrink: 1; }
709
+ .webauth-row { display: inline-flex; align-items: center; gap: var(--s-1); }
710
+ .webauth-row .webauth-ok, .webauth-row .webauth-ask { font: inherit; padding: 0 2px; border: 0; background: none; cursor: pointer; border-radius: var(--r-sm); }
711
+ .webauth-row .webauth-ok { color: var(--ok); }
712
+ .webauth-row .webauth-ask { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
713
+ .webauth-row .webauth-ok:focus-visible, .webauth-row .webauth-ask:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
714
+ /* What pressing Authorize answered is said in the row, in the hint's place: nothing is laid over the composer. */
715
+ .webauth-row .webauth-notice { white-space: normal; color: var(--text-2); }
716
+ .webauth-row .webauth-notice::before { content: "·"; margin-right: var(--s-2); color: var(--text-3); }
717
+ .composer-row:has(.webauth-notice) > .hint { display: none; }
718
+ /* When "✓ Verified" ends, on a tap (touch has no hover): a bubble above the row while it has focus. */
719
+ .webauth-tip { position: absolute; left: 0; bottom: calc(100% + 4px); z-index: 5; max-width: min(420px, calc(100vw - 32px)); white-space: normal;
720
+ padding: var(--s-1) var(--s-2); border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface); color: var(--text);
721
+ box-shadow: var(--shadow-2); }
722
+ .webauth-tip[hidden] { display: none; }
688
723
  /* #1920 phase 3: Settings → Web → Guests. */
689
724
  .s-guests .guest-names { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 2px var(--s-3); max-height: 220px; overflow: auto;
690
725
  padding: var(--s-1) var(--s-2); border: 1px solid var(--border); border-radius: var(--r-sm); }
@@ -1581,8 +1616,8 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1581
1616
  .side-widget { padding: 0 var(--s-3) var(--s-2); }
1582
1617
  .aw-sidebar { max-height: 240px; overflow: auto; }
1583
1618
 
1584
- .chat-card-toggle { display: flex; align-items: center; gap: var(--s-2); width: 100%; min-height: 32px; padding: 0 var(--s-4); border: 0; background: none; color: var(--text-2); font: inherit; font-size: var(--fs-xs); text-align: left; cursor: pointer; }
1585
- .chat-card-peek { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-3); }
1619
+ /* The chat header's card menu button (the user, 2026-10-20: no control on the card): pressed-looking while the card shows. */
1620
+ .chat-card-menu > .icon-btn.active { background: var(--accent-dim); color: var(--accent); }
1586
1621
  /* A phone: the portrait stays on the text's row, smaller (the user's ask: never a portrait alone on a line with the text
1587
1622
  under it); the S / M / L control takes the line below. The chat header's card is trimmed further: a smaller portrait
1588
1623
  and banner, and its tagline and bio cut to a few lines (Details and View show them whole). */
@@ -11,8 +11,10 @@ register("card", {
11
11
  sizeSName: "Small", sizeMName: "Medium", sizeLName: "Large",
12
12
  byHuman: "set by a person",
13
13
  byAgent: "set by the agent",
14
+ menu: "Identity card",
14
15
  show: "Show card",
15
16
  hide: "Hide card",
17
+ showRow: "Show picture and bio",
16
18
  bannerSafe: "Every screen",
17
19
  bannerPhone: "Phone · M card",
18
20
  },
@@ -24,8 +26,10 @@ register("card", {
24
26
  sizeSName: "小", sizeMName: "中", sizeLName: "大",
25
27
  byHuman: "由人設定",
26
28
  byAgent: "由 agent 設定",
29
+ menu: "身分卡",
27
30
  show: "顯示卡片",
28
31
  hide: "隱藏卡片",
32
+ showRow: "顯示小圖和介紹",
29
33
  bannerSafe: "所有螢幕",
30
34
  bannerPhone: "手機 · 中卡片",
31
35
  },
@@ -160,35 +160,53 @@ function SizeControl({ name, size }) {
160
160
  </div>`;
161
161
  }
162
162
 
163
+ /**
164
+ * #2217 hotfix (Prism): pictures that failed to load on this page, by src — shared, so the chat's card menu judges a card
165
+ * as drawn (a broken banner is not drawn, and then the card cannot fold), not by its metadata alone.
166
+ */
167
+ export const brokenPictures = createStore({});
168
+ const markBroken = (src) => { if (src && !brokenPictures.get()[src]) brokenPictures.set((s) => ({ ...s, [src]: true })); };
169
+ const drawn = (card, slot) => !!(card && card[slot] && !brokenPictures.get()[card[slot].src]);
170
+ /**
171
+ * #2217: whether a card can fold its row away and keep the banner — a banner drawn, and something on its row (a picture
172
+ * drawn, a tagline or a bio). The chat's card menu enables Show picture and bio only then.
173
+ */
174
+ export const cardFoldable = (card) => !!(card && drawn(card, "banner") && (drawn(card, "portrait") || card.tagline || card.bio));
175
+
163
176
  /**
164
177
  * The card. `compact`: the chat header's own small size (no size control; the banner still follows the size choice,
165
178
  * #2038). Nothing when the card is empty. The
166
179
  * tagline and bio say whose they are (title), since a person's field wins over the agent's.
180
+ * #2217: `rowHidden` — the chat's picture and bio folded away, the banner kept. The user (2026-10-20): no control on the
181
+ * card at all — the chat header's two buttons show / hide the card and fold / unfold its row. Only a card with a banner
182
+ * and something on its row folds (cardFoldable; else it would be the card hidden).
167
183
  */
168
- export function IdentityCard({ name, card, compact = false, sizeControl = !compact }) {
184
+ export function IdentityCard({ name, card, compact = false, sizeControl = !compact, rowHidden = false }) {
169
185
  useStore(cardSizeStore);
170
- const [broken, setBroken] = useState({});
186
+ const broken = useStore(brokenPictures);
171
187
  if (!card) return null;
172
188
  const size = compact ? "c" : cardSizeFor(name);
173
189
  // #2038: the chat header's card is its own small size, but its banner follows the person's size choice too.
174
190
  const bannerSize = compact ? ` bs-${cardSizeFor(name)}` : "";
175
191
  const whose = (f) => t(f.by === "human" ? "card.byHuman" : "card.byAgent");
176
- const pic = (slot, cls, alt) => card[slot] && broken[slot] !== card[slot].src
177
- ? html`<img class=${cls} src=${card[slot].src} alt=${alt} title=${whose(card[slot])} decoding="async" onError=${() => setBroken({ ...broken, [slot]: card[slot].src })} />` : null;
192
+ const pic = (slot, cls, alt) => card[slot] && !broken[card[slot].src]
193
+ ? html`<img class=${cls} src=${card[slot].src} alt=${alt} title=${whose(card[slot])} decoding="async" onError=${() => markBroken(card[slot].src)} />` : null;
178
194
  // #2086: the banner at its focus; on a screen wider than BANNER_MAX_ASPECT it is centred over a blurred copy of itself.
179
195
  const focus = card.banner_focus ? card.banner_focus.value : 50;
180
196
  const atFocus = (el) => { if (el && el.style) el.style.setProperty("--id-focus", `${focus}%`); };
181
197
  const banner = pic("banner", "id-banner", "");
182
- return html`<section class=${`id-card card-${size}${bannerSize}`} aria-label=${t("card.label", name)}>
198
+ const portrait = pic("portrait", "id-portrait", t("card.portraitAlt", name));
199
+ const rowShown = !(rowHidden && banner && cardFoldable(card));
200
+ return html`<section class=${`id-card card-${size}${bannerSize}${rowShown ? "" : " row-hidden"}`} aria-label=${t("card.label", name)}>
183
201
  ${banner ? html`<div class="id-banner-box" ref=${atFocus}><img class="id-banner-fill" src=${card.banner.src} alt="" aria-hidden="true" decoding="async" />${banner}</div>` : null}
184
- <div class="id-row">
185
- ${pic("portrait", "id-portrait", t("card.portraitAlt", name))}
202
+ ${rowShown ? html`<div class="id-row">
203
+ ${portrait}
186
204
  <div class="id-text">
187
205
  ${card.tagline ? html`<p class="id-tagline" title=${whose(card.tagline)}>${card.tagline.text}</p>` : null}
188
206
  ${card.bio ? html`<p class="id-bio" title=${whose(card.bio)}>${card.bio.text}</p>` : null}
189
207
  </div>
190
208
  ${sizeControl ? html`<${SizeControl} name=${name} size=${size} />` : null}
191
- </div>
209
+ </div>` : null}
192
210
  </section>`;
193
211
  }
194
212
 
@@ -9,7 +9,7 @@ import { html, useEffect, useState } from "./app-html.js";
9
9
  import { createStore, useStore } from "./app-store.js";
10
10
  import { onPrefsChanged, prefInForce, savePref } from "./app-prefs.js";
11
11
  import { register, t } from "./app-i18n.js";
12
- import { themesShown, themesStore } from "./app-themes.js";
12
+ import { pageNow, themesShown, themesStore } from "./app-themes.js";
13
13
  import { Dialog } from "./ui-dialog.js";
14
14
  import { toast } from "./ui-toast.js";
15
15
 
@@ -63,16 +63,20 @@ export function contrast(a, b) {
63
63
  const [l1, l2] = [luminance(a), luminance(b)].sort((x, y) => y - x);
64
64
  return (l1 + 0.05) / (l2 + 0.05);
65
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];
66
+ /**
67
+ * The shade of `hex` name text uses: itself if readable, else the nearest step toward black (light) or white (dark).
68
+ * `page` (#2196): an agent's colours on the page now — names must read on its background and sidebar instead.
69
+ */
70
+ export function readableShade(hex, mode, page = null) {
71
+ const backgrounds = page ? [page.bg, page.sidebar] : BG[mode], text = page ? page.text : TEXT[mode];
72
+ if (!HEX.test(hex)) return text;
69
73
  const toward = mode === "light" ? 0 : 255;
70
74
  const ch = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
71
75
  for (let step = 0; step <= 20; step++) {
72
76
  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;
77
+ if (backgrounds.every((bg) => contrast(shade, bg) >= 4.5)) return shade;
74
78
  }
75
- return TEXT[mode];
79
+ return text;
76
80
  }
77
81
 
78
82
  // ── Resolution ──
@@ -135,7 +139,7 @@ export function identityOf(it, opts = {}) {
135
139
  const light = colorValue(c.color, "light"), dark = colorValue(c.color, "dark");
136
140
  return {
137
141
  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"),
142
+ light, dark, nameLight: readableShade(light, "light", pageNow("light")), nameDark: readableShade(dark, "dark", pageNow("dark")),
139
143
  };
140
144
  }
141
145
  /** The row's classes for its identity colour ("" when colours are off or shown nowhere). */
@@ -40,6 +40,7 @@ const P = {
40
40
  sort: () => html`<path d="M7 4v16M3 16l4 4 4-4M17 20V4M13 8l4-4 4 4"/>`, // #2179: the sidebar's sort
41
41
  star: () => html`<path d="M12 2.5l2.94 5.96 6.56.95-4.75 4.63 1.12 6.54L12 17.5l-5.87 3.08 1.12-6.54L2.5 9.41l6.56-.95L12 2.5z"/>`,
42
42
  eye: () => html`<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>`,
43
+ idcard: () => html`<rect x="2" y="5" width="20" height="14" rx="2"/><circle cx="8" cy="11" r="2"/><path d="M5 16a3 3 0 0 1 6 0M14 10h5M14 14h3"/>`,
43
44
  check: () => html`<path d="M20 6L9 17l-5-5"/>`,
44
45
  inbox: () => html`<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>`,
45
46
  bell: () => html`<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>`,
@@ -4,21 +4,25 @@ import { html, useEffect, useRef, useState } from "./app-html.js";
4
4
  import { Icon } from "./ui-icons.js";
5
5
  import { t } from "./app-i18n.js";
6
6
 
7
- /** items: [{ key, label, icon?, onSelect, danger?, disabled? }] (falsy entries are skipped). */
8
- export function Menu({ items, label, icon = "more", cls = "" }) {
7
+ /**
8
+ * items: [{ key, label, icon?, onSelect, danger?, disabled? }] (falsy entries are skipped). `checked` makes a radio item;
9
+ * `checkbox: true` with `checked` a checkbox item (#2217 hotfix: the chat's card menu), which toggles and keeps the menu
10
+ * open. `active`: the button shows a pressed look (what it controls is on), without changing its role.
11
+ */
12
+ export function Menu({ items, label, icon = "more", cls = "", active = false }) {
9
13
  const [open, setOpen] = useState(false);
10
14
  const btn = useRef(null), list = useRef(null);
11
15
  const shown = (items || []).filter(Boolean);
12
16
  useEffect(() => {
13
17
  if (!open) return undefined;
14
- const first = list.current && list.current.querySelector("[role=menuitem]:not([disabled])");
18
+ const first = list.current && list.current.querySelector("[role=menuitem]:not([disabled]), [role=menuitemradio]:not([disabled]), [role=menuitemcheckbox]:not([disabled])");
15
19
  if (first) first.focus();
16
20
  const outside = (e) => { if (list.current && !list.current.contains(e.target) && btn.current && !btn.current.contains(e.target)) setOpen(false); };
17
21
  document.addEventListener("pointerdown", outside, true);
18
22
  return () => document.removeEventListener("pointerdown", outside, true);
19
23
  }, [open]);
20
24
  function onKey(e) {
21
- const all = [...list.current.querySelectorAll("[role=menuitem]:not([disabled])")];
25
+ const all = [...list.current.querySelectorAll("[role=menuitem]:not([disabled]), [role=menuitemradio]:not([disabled]), [role=menuitemcheckbox]:not([disabled])")];
22
26
  const i = all.indexOf(document.activeElement);
23
27
  if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setOpen(false); btn.current && btn.current.focus(); }
24
28
  else if (e.key === "ArrowDown") { e.preventDefault(); (all[(i + 1) % all.length] || all[0])?.focus(); }
@@ -29,11 +33,20 @@ export function Menu({ items, label, icon = "more", cls = "" }) {
29
33
  }
30
34
  function choose(item) { setOpen(false); btn.current && btn.current.focus(); item.onSelect(); }
31
35
  return html`<div class=${`menu ${cls}`}>
32
- <button ref=${btn} type="button" class="icon-btn" aria-haspopup="menu" aria-expanded=${open ? "true" : "false"}
36
+ <button ref=${btn} type="button" class=${`icon-btn${active ? " active" : ""}`} aria-haspopup="menu" aria-expanded=${open ? "true" : "false"}
33
37
  aria-label=${label || t("app.more")} title=${label || t("app.more")} onClick=${() => setOpen(!open)}><${Icon} name=${icon} /></button>
34
38
  ${open ? html`<div data-trust="menu" ref=${list} class="menu-list" role="menu" aria-label=${label || t("app.more")} onKeyDown=${onKey}>
35
- ${shown.map(item => html`<button key=${item.key} type="button" role="menuitem" class=${`menu-item${item.danger ? " danger" : ""}`}
36
- disabled=${!!item.disabled} onClick=${() => choose(item)}>${item.icon ? html`<${Icon} name=${item.icon} size=${16} />` : null}<span>${item.label}</span></button>`)}
39
+ ${shown.map(item => item.heading
40
+ ? html`<div key=${item.key} class="menu-heading" role="presentation">${item.label}</div>`
41
+ : item.checkbox
42
+ ? html`<button key=${item.key} type="button" role="menuitemcheckbox" aria-checked=${item.checked ? "true" : "false"} class="menu-item menu-radio"
43
+ disabled=${!!item.disabled} onClick=${() => item.onSelect()}><span class="menu-tick" aria-hidden="true">${item.checked ? html`<${Icon} name="check" size=${16} />` : null}</span><span>${item.label}</span></button>`
44
+ : item.checked !== undefined
45
+ // #2196: one of a group of choices (Page colours): a radio item, the chosen one ticked.
46
+ ? html`<button key=${item.key} type="button" role="menuitemradio" aria-checked=${item.checked ? "true" : "false"} class="menu-item menu-radio"
47
+ disabled=${!!item.disabled} onClick=${() => choose(item)}><span class="menu-tick" aria-hidden="true">${item.checked ? html`<${Icon} name="check" size=${16} />` : null}</span><span>${item.label}</span></button>`
48
+ : html`<button key=${item.key} type="button" role="menuitem" class=${`menu-item${item.danger ? " danger" : ""}`}
49
+ disabled=${!!item.disabled} onClick=${() => choose(item)}>${item.icon ? html`<${Icon} name=${item.icon} size=${16} />` : null}<span>${item.label}</span></button>`)}
37
50
  </div>` : null}
38
51
  </div>`;
39
52
  }
@@ -27,6 +27,10 @@ register("webauth", {
27
27
  refusal_dm_unavailable: "Couldn't send you a DM. Open your DMs with the bot, then try again.",
28
28
  refusal_other: "That didn't work: {0}",
29
29
  settings: "Settings",
30
+ verifiedShort: "Verified",
31
+ authorizeShort: "Authorize",
32
+ authorizeWhy: "Not verified: authorize this browser to speak as you",
33
+ waitingShort: "Waiting for your Confirm ({0})",
30
34
  },
31
35
  "zh-TW": {
32
36
  verified: "已驗證 · 以你的身分發言到 {0}",
@@ -49,6 +53,10 @@ register("webauth", {
49
53
  refusal_dm_unavailable: "無法私訊給你。請開放與 bot 的私訊後再試一次。",
50
54
  refusal_other: "沒有成功:{0}",
51
55
  settings: "設定",
56
+ verifiedShort: "已驗證",
57
+ authorizeShort: "授權",
58
+ authorizeWhy: "未驗證:授權這個瀏覽器以你的身分發言",
59
+ waitingShort: "等待確認({0})",
52
60
  },
53
61
  });
54
62
  const tn = (k, ...v) => t(`webauth.${k}`, ...v);
@@ -139,6 +147,45 @@ export function AuthorizeChip() {
139
147
  return html`<div class="webauth-chip" data-state=${a.st.state}>${body(a)}${a.notice ? html` <span class="note" role="status">${a.notice}</span>` : null}</div>`;
140
148
  }
141
149
 
150
+ /**
151
+ * The composer's row (#2215, the user's request, 2026-10-19): the same states as AuthorizeChip, each kept to a few words so
152
+ * they share one row with the key hint. What a state would have said at length is in its tooltip and its accessible
153
+ * name: when "Verified" ends, why it can't be authorized, the countdown's ask. A tap on "✓ Verified" shows when it
154
+ * ends (touch has no hover) in a bubble above the row, until the next tap, a tap elsewhere or Escape. What pressing
155
+ * Authorize answered (sent, refused) is said in the row itself, in the hint's place (#2216 review: a bubble left over the
156
+ * composer covered Attach and what was being typed, with nothing to close it).
157
+ */
158
+ export function ComposerAuth() {
159
+ const a = useWebAuthorization();
160
+ const [open, setOpen] = useState(false);
161
+ const tip = useRef("wa-tip-" + Math.random().toString(36).slice(2, 8));
162
+ useEffect(() => { setOpen(false); }, [a.st && a.st.state]); // a new state closes the bubble
163
+ if (guestPage()) return null;
164
+ if (publicLink()) return html`<span class="webauth-row note" data-state="public" title=${tn("publicLink")}>${tn("publicLink")}</span>`;
165
+ if (!a.st) return null;
166
+ const st = a.st, id = tip.current;
167
+ const said = a.notice ? html`<span class="webauth-notice" role="status">${a.notice}</span>` : null;
168
+ if (st.state === "verified") {
169
+ const until = tn("verified", clock(st.until));
170
+ return html`<span class="webauth-row" data-state="verified">
171
+ <button type="button" class="webauth-ok" title=${until} aria-label=${`✓ ${until}`} aria-expanded=${open ? "true" : "false"}
172
+ aria-controls=${id} onClick=${() => setOpen((v) => !v)} onBlur=${() => setOpen(false)}
173
+ onKeyDown=${(e) => { if (e.key === "Escape" && open) { e.stopPropagation(); setOpen(false); } }}>✓ ${tn("verifiedShort")}</button><span
174
+ id=${id} class="webauth-tip" role="tooltip" hidden=${!open}>${until}</span>${said}</span>`;
175
+ }
176
+ if (st.state === "pending") {
177
+ const asks = tn("waiting", mmss(a.left));
178
+ return html`<span class="webauth-row" data-state="pending"><span class="note" title=${a.notice ? `${asks} — ${a.notice}` : asks}>${tn("waitingShort", mmss(a.left))}</span>${said}</span>`;
179
+ }
180
+ if (st.reason === "not_authorized" || st.reason === "authorization_expired") {
181
+ return html`<span class="webauth-row" data-state="none"><span class="note" aria-hidden="true">${tn("notVerified")} ·</span><button type="button" class="webauth-ask"
182
+ title=${tn("authorizeWhy")} aria-label=${tn("authorizeWhy")} onClick=${a.ask}>${tn("authorizeShort")}</button>${said}</span>`;
183
+ }
184
+ const why = REASONS.includes(st.reason) ? tn(`reason_${st.reason}`) : tn("reason_other");
185
+ return html`<span class="webauth-row" data-state="none"><span class="note" title=${why}>${tn("notVerified")}<span class="sr-only">: ${why}</span></span>${st.reason === "disabled" || st.reason === "loopback_only"
186
+ ? html` · <a href="/settings/web" title=${why}>${tn("settings")}</a>` : null}</span>`;
187
+ }
188
+
142
189
  function body(a) {
143
190
  const st = a.st;
144
191
  if (st.state === "verified") return html`<span class="webauth-ok">✓ ${tn("verified", clock(st.until))}</span>`;
package/dist/web-api.d.ts CHANGED
@@ -371,9 +371,6 @@ export interface WebPerson {
371
371
  /** Their account's name at sign-in, made safe (bound only). */
372
372
  readonly platformName?: string;
373
373
  }
374
- /**
375
- * Handle a Web UI request. Returns true if handled, false to pass through.
376
- */
377
374
  export declare function handleWebRequest(req: IncomingMessage, res: ServerResponse, url: URL, ctx: WebApiContext): boolean;
378
375
  /**
379
376
  * #1723: a public-link page's preview: where it may frame from (`frameSrc` — the quick-tunnel wildcard while its own
package/dist/web-api.js CHANGED
@@ -21,7 +21,8 @@ import { formatWebChannelEcho } from "./web-channel-echo.js";
21
21
  import { t } from "./locale.js";
22
22
  import { isSafeInstanceName } from "./web-shell-routes.js";
23
23
  import { MAX_COMMAND_ARGS } from "./web-commands.js";
24
- import { sendPanelHtml } from "./web-host-guard.js";
24
+ import { ROUTE_WORKER_CSP, ROUTE_WORKER_PATH, sendPanelHtml } from "./web-host-guard.js";
25
+ import { libavoidFile } from "./web-libavoid.js";
25
26
  import { shellRoute } from "./web-shell-routes.js";
26
27
  import { createReadStream, createWriteStream, readFileSync } from "node:fs";
27
28
  import { join, dirname } from "node:path";
@@ -968,9 +969,54 @@ function profileBody(p) {
968
969
  }
969
970
  /** #1823: an SSE frame with the server's clock (no id: it is not a message, and a reconnect gets a fresh one). */
970
971
  function sseClock() { return `event: clock\ndata: ${JSON.stringify({ now: Date.now() })}\n\n`; }
971
- /**
972
- * Handle a Web UI request. Returns true if handled, false to pass through.
973
- */
972
+ const statusTicks = new WeakMap();
973
+ function subscribeStatusTick(ctx, stream) {
974
+ let tick = statusTicks.get(ctx);
975
+ if (!tick)
976
+ statusTicks.set(ctx, tick = { streams: new Set(), timer: null });
977
+ const t = tick;
978
+ t.streams.add(stream);
979
+ t.timer ??= setInterval(() => statusTick(ctx, t), ctx.sseHeartbeatMs ?? SSE_HEARTBEAT_MS);
980
+ return () => {
981
+ t.streams.delete(stream);
982
+ if (!t.streams.size && t.timer) {
983
+ clearInterval(t.timer);
984
+ t.timer = null;
985
+ }
986
+ };
987
+ }
988
+ function statusTick(ctx, t) {
989
+ const live = [...t.streams].filter((s) => s.alive());
990
+ if (!live.length)
991
+ return;
992
+ let status, admin = null;
993
+ const guests = new Map();
994
+ try {
995
+ status = measureSyncWork("web.sseStatus", () => ctx.getUiStatus());
996
+ }
997
+ catch {
998
+ return;
999
+ } // nothing to send this tick; the next one tries again
1000
+ const frameFor = (s) => {
1001
+ if (!s.scope)
1002
+ return admin ??= `event: status\ndata: ${JSON.stringify(status)}\n\n`;
1003
+ const scope = s.scope(), key = [...scope].sort().join("\u0000");
1004
+ let frame = guests.get(key);
1005
+ if (frame === undefined)
1006
+ guests.set(key, frame = `event: status\ndata: ${JSON.stringify(guestStatus(status, scope))}\n\n`);
1007
+ return frame;
1008
+ };
1009
+ for (const s of live) {
1010
+ let frame;
1011
+ try {
1012
+ frame = frameFor(s);
1013
+ }
1014
+ catch {
1015
+ continue;
1016
+ }
1017
+ s.write(frame);
1018
+ }
1019
+ }
974
1020
  export function handleWebRequest(req, res, url, ctx) {
975
1021
  const path = url.pathname;
976
1022
  const method = req.method ?? "GET";
@@ -1021,6 +1067,9 @@ export function handleWebRequest(req, res, url, ctx) {
1021
1067
  try {
1022
1068
  const js = readFileSync(join(__dirname, "ui", fileName), "utf-8");
1023
1069
  res.setHeader("Content-Type", "application/javascript; charset=utf-8");
1070
+ // #2182: the route worker's own, narrower-reaching policy (ROUTE_WORKER_CSP); every other file keeps the page's.
1071
+ if (path === ROUTE_WORKER_PATH)
1072
+ res.setHeader("Content-Security-Policy", ROUTE_WORKER_CSP);
1024
1073
  res.writeHead(200);
1025
1074
  res.end(js);
1026
1075
  }
@@ -1029,6 +1078,19 @@ export function handleWebRequest(req, res, url, ctx) {
1029
1078
  }
1030
1079
  return true;
1031
1080
  }
1081
+ // #2182: libavoid (libavoid-js, LGPL-2.1-or-later) — the npm package's own two files, unmodified, at a versioned path.
1082
+ const vendor = method === "GET" ? libavoidFile(path) : null;
1083
+ if (vendor) {
1084
+ try {
1085
+ const body = readFileSync(vendor.file);
1086
+ res.writeHead(200, { "Content-Type": vendor.type, "Cache-Control": "private, max-age=31536000, immutable" });
1087
+ res.end(body);
1088
+ }
1089
+ catch {
1090
+ json(res, 404, { error: "File not found" });
1091
+ }
1092
+ return true;
1093
+ }
1032
1094
  // ── Backend detection ─────────────────────────────────
1033
1095
  // #2072: the model a Settings field offers — the cached catalog of that backend (that instance's account's). Read only.
1034
1096
  if (method === "GET" && path === "/ui/models") {
@@ -1171,13 +1233,17 @@ export function handleWebRequest(req, res, url, ctx) {
1171
1233
  if (!guest && ctx.needsYouItems)
1172
1234
  res.write(`event: needs\ndata: ${JSON.stringify({ items: ctx.needsYouItems() })}\n\n`);
1173
1235
  ctx.sseClients.add(res);
1174
- const interval = setInterval(() => {
1236
+ // #1956: the heartbeat's status comes from the one build every open stream shares each tick (statusTick), with
1237
+ // this stream's own projection; this stream's checks stay its own.
1238
+ const unsubscribe = subscribeStatusTick(ctx, {
1175
1239
  // A stream authorized once must not outlive the authorization. Re-checked
1176
1240
  // without counting as activity, or an open tab would keep an idle session
1177
1241
  // alive forever; a revoked, expired or rotated-away session ends here.
1178
1242
  // #2049 review: a guest's stream also needs the exact session that opened it — another cookie on the same request
1179
1243
  // still authenticating does not keep a revoked device's stream (with its scope) alive.
1180
- if (!isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false }) || (guest && !guest.current())) {
1244
+ alive: () => {
1245
+ if (isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false }) && (!guest || guest.current()))
1246
+ return true;
1181
1247
  // Close the connection too, not just the response: an ended response leaves the socket idle in
1182
1248
  // keep-alive, still held open by whoever's session just ended.
1183
1249
  const socket = req.socket;
@@ -1186,23 +1252,23 @@ export function handleWebRequest(req, res, url, ctx) {
1186
1252
  }
1187
1253
  catch { /* already closed */ }
1188
1254
  cleanup();
1189
- return;
1190
- }
1191
- try {
1192
- // Every open dashboard tab, every 10 s: the status of every instance, serialized on the fleet's loop (#1235).
1193
- res.write(measureSyncWork("web.sseStatus", statusFrame) + sseClock());
1255
+ return false;
1256
+ },
1257
+ scope: guest ? () => guest.scope() : null,
1258
+ write: (frame) => { try {
1259
+ res.write(frame + sseClock());
1194
1260
  }
1195
1261
  catch {
1196
1262
  cleanup();
1197
- }
1198
- }, ctx.sseHeartbeatMs ?? SSE_HEARTBEAT_MS);
1263
+ } },
1264
+ });
1199
1265
  let cleanedUp = false;
1200
1266
  const cleanup = () => {
1201
1267
  if (cleanedUp)
1202
1268
  return;
1203
1269
  cleanedUp = true;
1204
1270
  ctx.sseClients.delete(res);
1205
- clearInterval(interval);
1271
+ unsubscribe();
1206
1272
  };
1207
1273
  // The *response's* `close` is the one that means the stream is over: it fires when the connection
1208
1274
  // goes away or the response is finished. The request's `close` means "the request has been read"