@songsid/agend 2.3.0-beta.2 → 2.3.0-beta.4

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 (189) hide show
  1. package/dist/agent-whats-new.js +2 -0
  2. package/dist/agent-whats-new.js.map +1 -1
  3. package/dist/auth-api.d.ts +30 -1
  4. package/dist/auth-api.js +42 -12
  5. package/dist/auth-api.js.map +1 -1
  6. package/dist/backend/claude-code.d.ts +2 -2
  7. package/dist/backend/claude-code.js +5 -4
  8. package/dist/backend/claude-code.js.map +1 -1
  9. package/dist/backend/types.d.ts +6 -0
  10. package/dist/backend/types.js +15 -0
  11. package/dist/backend/types.js.map +1 -1
  12. package/dist/backend-availability.d.ts +120 -0
  13. package/dist/backend-availability.js +396 -0
  14. package/dist/backend-availability.js.map +1 -0
  15. package/dist/backend-command.d.ts +21 -3
  16. package/dist/backend-command.js +23 -4
  17. package/dist/backend-command.js.map +1 -1
  18. package/dist/channel/adapters/discord.d.ts +7 -0
  19. package/dist/channel/adapters/discord.js +35 -0
  20. package/dist/channel/adapters/discord.js.map +1 -1
  21. package/dist/channel/mcp-tools.js +8 -4
  22. package/dist/channel/mcp-tools.js.map +1 -1
  23. package/dist/channel/types.d.ts +6 -0
  24. package/dist/classic-channel-manager.d.ts +8 -3
  25. package/dist/classic-channel-manager.js +10 -3
  26. package/dist/classic-channel-manager.js.map +1 -1
  27. package/dist/config-validator.js +73 -2
  28. package/dist/config-validator.js.map +1 -1
  29. package/dist/delivery-outbox.js +5 -0
  30. package/dist/delivery-outbox.js.map +1 -1
  31. package/dist/fleet-context.d.ts +10 -0
  32. package/dist/fleet-manager.d.ts +238 -5
  33. package/dist/fleet-manager.js +878 -71
  34. package/dist/fleet-manager.js.map +1 -1
  35. package/dist/fleet-traffic.d.ts +38 -0
  36. package/dist/fleet-traffic.js +96 -0
  37. package/dist/fleet-traffic.js.map +1 -0
  38. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +13 -5
  39. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
  40. package/dist/general-knowledge/skills/web-appearance/SKILL.md +56 -11
  41. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +398 -0
  42. package/dist/human-delivery-receipts.js +2 -1
  43. package/dist/human-delivery-receipts.js.map +1 -1
  44. package/dist/locale.js +48 -8
  45. package/dist/locale.js.map +1 -1
  46. package/dist/memory-hogs.d.ts +27 -0
  47. package/dist/memory-hogs.js +124 -0
  48. package/dist/memory-hogs.js.map +1 -0
  49. package/dist/outbound-handlers.d.ts +2 -0
  50. package/dist/outbound-handlers.js +40 -1
  51. package/dist/outbound-handlers.js.map +1 -1
  52. package/dist/outbound-schemas.d.ts +92 -0
  53. package/dist/outbound-schemas.js +55 -1
  54. package/dist/outbound-schemas.js.map +1 -1
  55. package/dist/public-web-gateway.js +1 -1
  56. package/dist/public-web-gateway.js.map +1 -1
  57. package/dist/public-web-link.d.ts +9 -0
  58. package/dist/public-web-link.js +8 -0
  59. package/dist/public-web-link.js.map +1 -1
  60. package/dist/quickstart-api.d.ts +10 -1
  61. package/dist/quickstart-api.js +12 -4
  62. package/dist/quickstart-api.js.map +1 -1
  63. package/dist/reply-buttons-controller.d.ts +2 -0
  64. package/dist/reply-buttons-controller.js +1 -1
  65. package/dist/reply-buttons-controller.js.map +1 -1
  66. package/dist/tmux-manager.d.ts +5 -0
  67. package/dist/tmux-manager.js +16 -0
  68. package/dist/tmux-manager.js.map +1 -1
  69. package/dist/tool-permissions.js +4 -0
  70. package/dist/tool-permissions.js.map +1 -1
  71. package/dist/topic-commands.d.ts +5 -0
  72. package/dist/topic-commands.js +16 -0
  73. package/dist/topic-commands.js.map +1 -1
  74. package/dist/types.d.ts +25 -1
  75. package/dist/ui/app-effects.js +33 -9
  76. package/dist/ui/chat-effects.js +62 -4
  77. package/dist/ui/chat-strings.js +40 -4
  78. package/dist/ui/chat-thread.js +17 -6
  79. package/dist/ui/effect-trial-panel.js +19 -16
  80. package/dist/ui/fleet-canvas-layout.js +111 -0
  81. package/dist/ui/fleet-canvas.js +489 -0
  82. package/dist/ui/fleet-org.js +27 -12
  83. package/dist/ui/fleet-traffic.js +224 -0
  84. package/dist/ui/panel-chat.js +12 -5
  85. package/dist/ui/panel-details.js +2 -1
  86. package/dist/ui/panel-fleet.js +32 -11
  87. package/dist/ui/panel-settings.js +6 -4
  88. package/dist/ui/preview.js +3 -2
  89. package/dist/ui/settings-appearance.js +34 -18
  90. package/dist/ui/settings-catalog.js +89 -0
  91. package/dist/ui/settings-dialogs.js +40 -12
  92. package/dist/ui/settings-effects.js +4 -3
  93. package/dist/ui/settings-learn.js +25 -0
  94. package/dist/ui/settings-model.js +6 -2
  95. package/dist/ui/settings-mods.js +4 -3
  96. package/dist/ui/settings-owner-effects.js +8 -8
  97. package/dist/ui/settings-strings.js +40 -38
  98. package/dist/ui/shared/app-i18n.js +2 -2
  99. package/dist/ui/shared/app-prefs.js +1 -1
  100. package/dist/ui/shared/app-shell.js +23 -1
  101. package/dist/ui/shared/app-store.js +12 -1
  102. package/dist/ui/shared/app-stream.js +2 -1
  103. package/dist/ui/shared/app.css +186 -11
  104. package/dist/ui/shared/card-strings.js +4 -0
  105. package/dist/ui/shared/effect-app-runtime.js +1 -1
  106. package/dist/ui/shared/effect-frame.js +10 -5
  107. package/dist/ui/shared/effect-geometry.js +24 -9
  108. package/dist/ui/shared/effect-policy.js +2 -1
  109. package/dist/ui/shared/effect-registry.js +71 -17
  110. package/dist/ui/shared/effect-stock-trust.js +23 -2
  111. package/dist/ui/shared/effect-trial-shadow.js +62 -0
  112. package/dist/ui/shared/effect-trial-stage.js +15 -9
  113. package/dist/ui/shared/effect-trial.css +40 -0
  114. package/dist/ui/shared/identity-card.js +75 -4
  115. package/dist/ui/shared/identity-color.js +3 -3
  116. package/dist/ui/shared/instance-nav.js +20 -4
  117. package/dist/ui/shared/panel-view.js +24 -4
  118. package/dist/ui/shared/view-strings.js +22 -0
  119. package/dist/view-api.d.ts +32 -0
  120. package/dist/view-api.js +55 -4
  121. package/dist/view-api.js.map +1 -1
  122. package/dist/web-api.d.ts +19 -4
  123. package/dist/web-api.js +178 -20
  124. package/dist/web-api.js.map +1 -1
  125. package/dist/web-appearance-policy.d.ts +4 -6
  126. package/dist/web-appearance-policy.js +5 -12
  127. package/dist/web-appearance-policy.js.map +1 -1
  128. package/dist/web-appearance-tools.js +5 -1
  129. package/dist/web-appearance-tools.js.map +1 -1
  130. package/dist/web-appearance-view.d.ts +14 -1
  131. package/dist/web-appearance-view.js +98 -10
  132. package/dist/web-appearance-view.js.map +1 -1
  133. package/dist/web-appearance.d.ts +2 -2
  134. package/dist/web-appearance.js +5 -6
  135. package/dist/web-appearance.js.map +1 -1
  136. package/dist/web-auth.js +1 -1
  137. package/dist/web-auth.js.map +1 -1
  138. package/dist/web-card.d.ts +14 -0
  139. package/dist/web-card.js +40 -6
  140. package/dist/web-card.js.map +1 -1
  141. package/dist/web-channel-echo.d.ts +7 -1
  142. package/dist/web-channel-echo.js +6 -2
  143. package/dist/web-channel-echo.js.map +1 -1
  144. package/dist/web-chat-history.d.ts +40 -0
  145. package/dist/web-chat-history.js +66 -3
  146. package/dist/web-chat-history.js.map +1 -1
  147. package/dist/web-commands.d.ts +1 -0
  148. package/dist/web-effect-tools.js +2 -3
  149. package/dist/web-effect-tools.js.map +1 -1
  150. package/dist/web-effect-trials.d.ts +2 -2
  151. package/dist/web-effect-trials.js +3 -3
  152. package/dist/web-effect-trials.js.map +1 -1
  153. package/dist/web-guest-access.d.ts +125 -0
  154. package/dist/web-guest-access.js +232 -0
  155. package/dist/web-guest-access.js.map +1 -0
  156. package/dist/web-guest-rooms.d.ts +82 -0
  157. package/dist/web-guest-rooms.js +135 -0
  158. package/dist/web-guest-rooms.js.map +1 -0
  159. package/dist/web-identity-palette.d.ts +1 -1
  160. package/dist/web-identity-palette.js +2 -2
  161. package/dist/web-identity-palette.js.map +1 -1
  162. package/dist/web-login.d.ts +47 -0
  163. package/dist/web-login.js +115 -0
  164. package/dist/web-login.js.map +1 -1
  165. package/dist/web-mod-agent.d.ts +1 -1
  166. package/dist/web-mod-agent.js +2 -4
  167. package/dist/web-mod-agent.js.map +1 -1
  168. package/dist/web-mod-store.d.ts +5 -0
  169. package/dist/web-mod-store.js +6 -0
  170. package/dist/web-mod-store.js.map +1 -1
  171. package/dist/web-prefs.d.ts +8 -0
  172. package/dist/web-prefs.js +9 -0
  173. package/dist/web-prefs.js.map +1 -1
  174. package/dist/web-preview.d.ts +6 -2
  175. package/dist/web-preview.js +4 -2
  176. package/dist/web-preview.js.map +1 -1
  177. package/dist/web-returning-device.d.ts +39 -0
  178. package/dist/web-returning-device.js +46 -5
  179. package/dist/web-returning-device.js.map +1 -1
  180. package/dist/web-session.d.ts +58 -10
  181. package/dist/web-session.js +104 -22
  182. package/dist/web-session.js.map +1 -1
  183. package/dist/web-settings-tools.d.ts +81 -0
  184. package/dist/web-settings-tools.js +257 -0
  185. package/dist/web-settings-tools.js.map +1 -0
  186. package/dist/web-theme.d.ts +2 -0
  187. package/dist/web-theme.js +10 -1
  188. package/dist/web-theme.js.map +1 -1
  189. package/package.json +1 -1
@@ -95,14 +95,35 @@ function outset(style) {
95
95
  }
96
96
  return pad <= 256 ? Math.ceil(pad) : null;
97
97
  }
98
+ /**
99
+ * #2086: the identity card's banner fill (a blurred copy beside a centred banner) — the one filter stock admits. Only
100
+ * this exact value, on exactly `.id-banner-box > img.id-banner-fill`, absolutely positioned in that box (so the box is
101
+ * its containing block — #2089 review: a fixed fill is the viewport's, and no box clips it), and the box positioned and
102
+ * clipping on both axes; its paint is then that box's rectangle (the blur and its scale never leave it). The box must
103
+ * also be a real block box that the browser reports as the fill's offset parent (#2089 review r3: `display: contents`
104
+ * keeps the computed position and overflow but generates no box, so an outer ancestor would place the fill, and an inline
105
+ * box clips differently). Anything else with a filter refuses.
106
+ */
107
+ export const STOCK_BANNER_FILL_FILTER = "blur(24px) saturate(0.75) brightness(0.7)";
108
+ function stockBannerFill(host, el) {
109
+ const box = el.parentElement;
110
+ if (typeof el.matches !== "function" || !el.matches(".id-banner-box > img.id-banner-fill") || !box) return null;
111
+ const s = host.getComputedStyle(el), b = host.getComputedStyle(box);
112
+ if (s.filter !== STOCK_BANNER_FILL_FILTER || !none(s.backdropFilter) || !none(b.filter) || !none(b.backdropFilter)) return null;
113
+ if (!["hidden", "clip"].includes(b.overflowX) || !["hidden", "clip"].includes(b.overflowY)) return null;
114
+ if (s.position !== "absolute" || !["relative", "absolute"].includes(b.position)) return null;
115
+ if (b.display !== "block" || el.offsetParent !== box) return null;
116
+ return rect(box);
117
+ }
98
118
  function protectedPaint(host, root, budget) {
99
119
  const nodes = [root, ...root.querySelectorAll("*")]; budget.n += nodes.length;
100
120
  if (budget.n > 4096) return null;
101
121
  let union = null;
102
122
  for (const el of nodes) {
103
123
  if (!visible(host, el)) continue;
104
- const style = host.getComputedStyle(el), r = rect(el), pad = outset(style);
105
- if (!r || pad === null || !none(style.filter) || !none(style.backdropFilter)) return null;
124
+ const style = host.getComputedStyle(el), pad = outset(style);
125
+ const r = none(style.filter) ? rect(el) : stockBannerFill(host, el);
126
+ if (!r || pad === null || !none(style.backdropFilter)) return null;
106
127
  const a = { x: r.x - pad, y: r.y - pad, width: r.width + pad * 2, height: r.height + pad * 2 };
107
128
  if (!union) union = a;
108
129
  else { const x = Math.min(union.x, a.x), y = Math.min(union.y, a.y); union = { x, y, width: Math.max(union.x + union.width, a.x + a.width) - x, height: Math.max(union.y + union.height, a.y + a.height) - y }; }
@@ -0,0 +1,62 @@
1
+ /** #2039: fixed host-only closed shadow shell. Owner CSS has no exported parts or slots into it. */
2
+ const roots = new WeakMap();
3
+ const clear = root => { while (root.firstChild) root.removeChild(root.firstChild); };
4
+ function sheetOf(host, record) {
5
+ try {
6
+ const link = record.link, sheet = link?.sheet;
7
+ return record.ready && !record.dead && roots.get(record.panel)?.current === record && record.root.mode === "closed" && record.root.host === record.panel
8
+ && link.getRootNode() === record.root && link.isConnected && link.disabled === false && sheet?.ownerNode === link && sheet.disabled === false
9
+ && sheet.href === new URL("/assets/effect-trial.css", host.location.origin).href && typeof sheet.insertRule === "function" && typeof sheet.deleteRule === "function" && sheet.cssRules ? sheet : null;
10
+ } catch { return null; }
11
+ }
12
+ /** Reflect only a shell issued here, after its fixed same-origin stylesheet loaded. Never a serialized capability. */
13
+ export function effectTrialShadow(host, panel) {
14
+ const record = roots.get(panel)?.current;
15
+ return record && sheetOf(host, record) === record.proof?.sheet ? record.proof : null;
16
+ }
17
+ export function createEffectTrialShadow({ window: host, panel, title, labels, onStop, onClose, onReady, onError } = {}) {
18
+ if (!host?.document || panel?.ownerDocument !== host.document || typeof panel.attachShadow !== "function" || typeof onReady !== "function" || typeof onError !== "function") return null;
19
+ let slot = roots.get(panel), cleanup = null;
20
+ const clock = () => (host.performance ?? globalThis.performance).now(), later = host.setTimeout?.bind(host) ?? globalThis.setTimeout, cancel = host.clearTimeout?.bind(host) ?? globalThis.clearTimeout;
21
+ try {
22
+ if (slot?.current && !slot.current.dead) return null;
23
+ if (!slot) { slot = { root: panel.attachShadow({ mode: "closed" }), current: null }; roots.set(panel, slot); }
24
+ const root = slot.root; clear(root);
25
+ const doc = host.document, element = (tag, cls) => { const el = doc.createElement(tag); el.className = cls; return el; };
26
+ const link = element("link", "effect-trial-style"); link.setAttribute("rel", "stylesheet"); link.setAttribute("href", "/assets/effect-trial.css");
27
+ const head = element("header", "effect-trial-head"), label = element("strong", "effect-trial-label"), name = element("span", "effect-trial-title"), close = element("button", "effect-trial-close"), controls = element("div", "effect-trial-controls"), stop = element("button", "effect-trial-stop"), body = element("div", "effect-trial-body"), stage = element("div", "effect-trial-stage"), note = element("p", "effect-trial-note");
28
+ close.setAttribute("type", "button"); stop.setAttribute("type", "button"); stage.setAttribute("aria-hidden", "true");
29
+ head.append(label, name, close); controls.append(stop); body.append(stage);
30
+ const nodes = [head, controls, body, note]; for (const el of nodes) el.setAttribute("hidden", "");
31
+ const began = clock(); if (!Number.isFinite(began) || began < 0) return null;
32
+ let timer = null;
33
+ const record = { panel, root, link, stage, head, controls, body, note, close, stop, ready: false, dead: false, proof: null }; slot.current = record;
34
+ const text = (nextTitle, nextLabels) => {
35
+ if (record.dead || !nextLabels) return;
36
+ label.textContent = nextLabels.label; name.textContent = typeof nextTitle === "string" ? nextTitle : "";
37
+ close.textContent = nextLabels.close; stop.textContent = nextLabels.stop; note.textContent = nextLabels.note;
38
+ panel.setAttribute("data-effect-trial-theme", doc.documentElement.dataset.theme === "dark" ? "dark" : "light");
39
+ };
40
+ const failed = () => { if (!record.dead) { dispose(); try { onError("stage_unavailable"); } catch { /* cleanup already owns failure */ } } };
41
+ const loaded = () => {
42
+ if (record.dead || record.ready || slot.current !== record) return;
43
+ const at = clock(); if (!Number.isFinite(at) || at < began || at - began >= 10000) { failed(); return; }
44
+ record.ready = true; const sheet = sheetOf(host, record);
45
+ if (!sheet) { failed(); return; }
46
+ cancel(timer); timer = null;
47
+ record.proof = Object.freeze({ panel, root, link, sheet, stage, head, controls, body, note, close, stop });
48
+ for (const el of nodes) el.removeAttribute("hidden");
49
+ try { onReady(record.proof); } catch { failed(); }
50
+ };
51
+ const stopClick = () => { if (!record.dead) onStop?.(); }, closeClick = () => { if (!record.dead) onClose?.(); };
52
+ function dispose() {
53
+ if (record.dead) return; record.dead = true; cancel(timer); timer = null;
54
+ link.removeEventListener("load", loaded); link.removeEventListener("error", failed); stop.removeEventListener("click", stopClick); close.removeEventListener("click", closeClick);
55
+ if (slot.current === record) { slot.current = null; clear(root); }
56
+ }
57
+ cleanup = dispose;
58
+ link.addEventListener("load", loaded); link.addEventListener("error", failed); stop.addEventListener("click", stopClick); close.addEventListener("click", closeClick);
59
+ text(title, labels); timer = later(failed, 10000); root.append(link, ...nodes);
60
+ return Object.freeze({ dispose, text });
61
+ } catch { cleanup?.(); return null; }
62
+ }
@@ -1,4 +1,5 @@
1
1
  /** #1629: host-issued LOCAL trial paint receipt. No authority, loader, frame factory or pool is created here. */
2
+ import { effectTrialShadow } from "./effect-trial-shadow.js";
2
3
  import { effectHostStyle } from "./effect-host-style.js";
3
4
  import { effectStockSnapshot } from "./effect-stock-snapshot.js";
4
5
  import { STOCK_EFFECT_BARRIERS } from "./effect-stock-trust.js";
@@ -58,10 +59,11 @@ export function effectTrialStage(receipt, expected, stock) {
58
59
  }
59
60
 
60
61
  /** Only the fixed host TrialPanel supplies these elements and a current server-run lease. Not an agent manifest. */
61
- export function createEffectTrialStage({ window: host, panel, stage, binding: raw, current, ownerCss, onStop = () => {} } = {}) {
62
+ export function createEffectTrialStage({ window: host, panel, stage, binding: raw, current, onStop = () => {} } = {}) {
62
63
  const own = binding(raw);
63
- if (!own || typeof current !== "function" || typeof ownerCss !== "function" || typeof onStop !== "function" || !host?.document || !panel || !stage || owners.has(stage)) return null;
64
- let sheet; try { sheet = effectHostStyle(host); if (!sheet || host.CSS?.supports("contain", "paint") !== true || host.CSS.supports("overflow", "clip") !== true) return null; } catch { return null; }
64
+ if (!own || typeof current !== "function" || typeof onStop !== "function" || !host?.document || !panel || !stage || owners.has(stage)) return null;
65
+ const shell = effectTrialShadow(host, panel), hostSheet = effectHostStyle(host);
66
+ let sheet; try { sheet = shell?.sheet; if (!shell || shell.stage !== stage || !hostSheet || !sheet || host.CSS?.supports("contain", "paint") !== true || host.CSS.supports("overflow", "clip") !== true) return null; } catch { return null; }
65
67
  const receipt = Object.freeze(Object.create(null)), frames = new Set(), listeners = new Set(); let dead = false, constraint = null;
66
68
  const identity = Object.freeze({ key: own.key, id: own.id, instance: own.instance, generation: own.generation, serial: ++serial });
67
69
  const invalidate = reason => {
@@ -76,23 +78,27 @@ export function createEffectTrialStage({ window: host, panel, stage, binding: ra
76
78
  function check(stock) {
77
79
  try {
78
80
  // This callback may reenter disposal or replace the owner. Recheck identity after it returns.
79
- if (dead || current(own) !== true || constraint !== null && constraint() !== true || dead || owners.get(stage) !== receipt || ownerCss() !== "off" || dead) return null;
81
+ if (dead || current(own) !== true || constraint !== null && constraint() !== true || dead || owners.get(stage) !== receipt || dead) return null;
80
82
  const proof = effectStockSnapshot(stock), doc = host.document;
81
- if (effectHostStyle(host) !== sheet) return null;
83
+ if (effectHostStyle(host) !== hostSheet || effectTrialShadow(host, panel) !== shell) return null;
82
84
  // The own panel MUST still suppress ordinary foreground effects. Never synthesize front:true.
83
85
  if (!proof || proof.front !== false || doc.visibilityState !== "visible" || doc.hasFocus?.() !== true || doc.fullscreenElement || doc.pictureInPictureElement) return null;
84
86
  const split = doc.querySelector(".main > .p-chat > .chat-split");
85
87
  if (!split || doc.querySelectorAll(".main > .p-chat").length !== 1 || doc.querySelectorAll(".chat-split > .pv-panel").length !== 1
86
88
  || panel.parentElement !== split || panel.ownerDocument !== doc || panel.isConnected !== true || panel.className !== "pv-panel effect-trial-panel" || panel.getAttribute("data-trust") !== "preview") return null;
87
- const children = [...panel.children], names = ["effect-trial-head", "effect-trial-controls", "effect-trial-body", "effect-trial-note"];
88
- if (children.length !== names.length || children.some((el, i) => el.className !== names[i]) || children[2].children.length !== 1 || children[2].firstElementChild !== stage
89
+ const children = [...shell.root.children], names = ["effect-trial-style", "effect-trial-head", "effect-trial-controls", "effect-trial-body", "effect-trial-note"];
90
+ if (panel.children.length !== 0 || children.length !== names.length || children.some((el, i) => el.className !== names[i]) || children[3].children.length !== 1 || children[3].firstElementChild !== stage
89
91
  || stage.className !== "effect-trial-stage" || stage.ownerDocument !== doc || stage.isConnected !== true || stage.hasAttribute("tabindex") || stage.hasAttribute("contenteditable")) return null;
92
+ for (const control of [shell.head, shell.controls, shell.close, shell.stop, shell.note]) {
93
+ const style = host.getComputedStyle(control);
94
+ if (style.display === "none" || style.visibility !== "visible" || style.opacity !== "1" || !rect(control)) return null;
95
+ }
90
96
  if ([...doc.querySelectorAll(STOCK_EFFECT_BARRIERS)].some(el => el !== panel && visible(host, el))
91
97
  || [...doc.querySelectorAll(".pv-panel")].some(el => el !== panel && visible(host, el))) return null;
92
98
  const active = doc.activeElement;
93
99
  if (active && active !== doc.body && active !== doc.documentElement && (active.matches("input,select,textarea,[contenteditable]") || active.isContentEditable)) return null;
94
100
  let el = stage, depth = 0;
95
- while (el) { const s = host.getComputedStyle(el); if (++depth > 128 || el.ownerDocument !== doc || !neutral(s) || s.opacity !== "1" || s.visibility !== "visible" || s.display === "none" || s.contain !== (el === stage ? "paint" : "none")) return null; if (el === doc.documentElement) break; el = el.parentElement; }
101
+ while (el) { const s = host.getComputedStyle(el); if (++depth > 128 || el.ownerDocument !== doc || !neutral(s) || s.opacity !== "1" || s.visibility !== "visible" || s.display === "none" || s.contain !== (el === stage ? "paint" : "none")) return null; if (el === doc.documentElement) break; el = el.parentElement ?? (el.getRootNode?.() === shell.root ? panel : null); }
96
102
  if (el !== doc.documentElement) return null;
97
103
  const s = host.getComputedStyle(stage), r = rect(stage), p = rect(panel);
98
104
  if (!r || !p || s.position !== "relative" || s.zIndex !== "0" || s.overflowX !== "clip" || s.overflowY !== "clip"
@@ -103,7 +109,7 @@ export function createEffectTrialStage({ window: host, panel, stage, binding: ra
103
109
  if ([...stage.children].some(child => ![...frames].some(f => f.element === child))) return null;
104
110
  const width = Math.floor(r.width), height = Math.floor(r.height);
105
111
  if (width < 1 || height < 1 || width > 4096 || height > 4096 || width * height > 8388608 || dead || owners.get(stage) !== receipt) return null;
106
- if (current(own) !== true || constraint !== null && constraint() !== true || dead || owners.get(stage) !== receipt || ownerCss() !== "off" || dead || stage.isConnected !== true || panel.parentElement !== split) return null;
112
+ if (current(own) !== true || constraint !== null && constraint() !== true || dead || owners.get(stage) !== receipt || dead || stage.isConnected !== true || panel.parentElement !== split) return null;
107
113
  return { x: r.x, y: r.y, width, height };
108
114
  } catch { return null; }
109
115
  }
@@ -0,0 +1,40 @@
1
+ /* #2039: fixed closed-shadow Trial chrome; no inherited owner-CSS typography, parts or slots. */
2
+ :host {
3
+ all: initial !important;
4
+ box-sizing: border-box !important;
5
+ display: flex !important;
6
+ flex: 0 0 45% !important;
7
+ flex-direction: column !important;
8
+ align-self: stretch !important;
9
+ width: 45% !important;
10
+ min-width: 320px !important;
11
+ max-width: calc(100% - 368px) !important;
12
+ min-height: 0 !important;
13
+ height: auto !important;
14
+ position: relative !important;
15
+ z-index: 1 !important;
16
+ overflow: clip !important;
17
+ background: #ffffff !important;
18
+ color: #1b1b1f !important;
19
+ font: 400 16px/1.4 system-ui, sans-serif !important;
20
+ direction: ltr !important;
21
+ unicode-bidi: isolate !important;
22
+ text-align: start !important;
23
+ visibility: visible !important;
24
+ opacity: 1 !important;
25
+ }
26
+ :host([data-effect-trial-theme="dark"]) { background: #202024 !important; color: #f0f0f4 !important; }
27
+ :host::before, :host::after { all: initial !important; content: none !important; display: none !important; }
28
+ *, *::before, *::after { box-sizing: border-box; }
29
+ [hidden] { display: none !important; }
30
+ .effect-trial-head, .effect-trial-controls, .effect-trial-note { position: relative; z-index: 1; padding: 12px; margin: 0; }
31
+ .effect-trial-head { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid currentColor; }
32
+ .effect-trial-title { flex: 1; min-width: 0; overflow-wrap: anywhere; }
33
+ button { appearance: auto; display: inline-block; font: inherit; color: inherit; background: transparent; border: 1px solid currentColor; border-radius: 6px; padding: 8px 12px; cursor: pointer; }
34
+ button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
35
+ .effect-trial-body { flex: 1; min-height: 0; display: flex; padding: 12px; }
36
+ .effect-trial-stage { position: relative; z-index: 0; flex: 1; min-width: 0; min-height: 0; contain: paint; overflow: clip; padding: 0; border: 0; outline: none; box-shadow: none; }
37
+ .effect-trial-note { font-size: 14px; }
38
+ @media (max-width: 899px) {
39
+ :host { position: fixed !important; inset: 0 !important; z-index: 37 !important; width: auto !important; min-width: 0 !important; max-width: none !important; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left) !important; }
40
+ }
@@ -4,7 +4,8 @@
4
4
  //
5
5
  // Nothing the agent wrote is markup: the tagline and bio are text nodes (never linkified), the pictures are same-origin
6
6
  // <img> elements whose URLs are checked here again, and sizes are class tokens only (card-s | card-m | card-l, heights in
7
- // app.css) — no style attribute, no CSSOM size (#1300). The size is a viewer's choice: every card's default
7
+ // app.css) — no style attribute, no CSSOM size (#1300). #2086: the banner's focus is a position, not a size: one custom
8
+ // property set through the CSSOM (`--id-focus`), and the banner is never drawn wider than BANNER_MAX_ASPECT : 1. The size is a viewer's choice: every card's default
8
9
  // (`card.size`) and the cards set apart from it (`card.sizeByInstance`, at most 200), synced like the other preferences.
9
10
  import { html, useEffect, useState } from "./app-html.js";
10
11
  import { t } from "./app-i18n.js";
@@ -13,6 +14,28 @@ import { onPrefsChanged, prefInForce, savePref } from "./app-prefs.js";
13
14
  import "./card-strings.js";
14
15
 
15
16
  export const CARD_SIZES = ["s", "m", "l"];
17
+ /**
18
+ * #2086: the widest a banner is ever drawn (width : height) — app.css caps its width at this many times its size's
19
+ * tallest height. So the band of the picture shown everywhere is (picture's aspect ÷ this) of its height, at the focus.
20
+ */
21
+ export const BANNER_MAX_ASPECT = 5;
22
+ /**
23
+ * #2086 (#2089 review): the narrowest a card draws its banner — 9 : 4 (app.css caps a banner's height at its box's width ÷ 2.25, on
24
+ * every layout) — which crops a wide picture's sides. Every card is between this and BANNER_MAX_ASPECT, so the area shown on every screen is
25
+ * the narrowest box's width (centred) × the widest box's height (at the focus).
26
+ */
27
+ export const BANNER_MIN_ASPECT = 9 / 4;
28
+ /** The share of a picture's height a box of `boxAspect` shows (1: all of it; a narrower box crops the sides instead). */
29
+ export const bannerShownHeight = (pictureAspect, boxAspect) => Math.min(1, pictureAspect / boxAspect);
30
+ /** The share of a picture's width a box of `boxAspect` shows, centred (1: all of it). */
31
+ export const bannerShownWidth = (pictureAspect, boxAspect) => Math.min(1, boxAspect / pictureAspect);
32
+ /** The area every screen shows, as shares of the picture: { left, top, width, height } at focus `f`. */
33
+ export function bannerSafeArea(pictureAspect, f) {
34
+ const width = bannerShownWidth(pictureAspect, BANNER_MIN_ASPECT), height = bannerShownHeight(pictureAspect, BANNER_MAX_ASPECT);
35
+ return { left: (1 - width) / 2, top: bannerBandTop(height, f), width, height };
36
+ }
37
+ /** That band's top, as a share of the picture's height, at focus `f` (0–100) — object-position's own rule. */
38
+ export const bannerBandTop = (shown, f) => (1 - shown) * (f / 100);
16
39
  export const MAX_CARD_SIZES = 200;
17
40
  const own = (o, k) => !!o && Object.prototype.hasOwnProperty.call(o, k);
18
41
  const readLocal = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
@@ -82,6 +105,9 @@ export function checkCard(name, body) {
82
105
  const v = body[k];
83
106
  if (v && typeof v === "object" && typeof v.text === "string" && v.text.length && v.text.length <= MAX[k] && (v.by === "human" || v.by === "agent")) out[k] = { text: v.text, by: v.by };
84
107
  }
108
+ // #2086: the banner's focus, a whole number 0 (top) … 100 (bottom).
109
+ const f = body.banner_focus;
110
+ if (f && typeof f === "object" && Number.isInteger(f.value) && f.value >= 0 && f.value <= 100 && (f.by === "human" || f.by === "agent")) out.banner_focus = { value: f.value, by: f.by };
85
111
  return Object.keys(out).length ? out : null;
86
112
  }
87
113
  // #1889 review: per instance, an epoch that moves whenever the version this page wants changes (a clear included).
@@ -135,7 +161,8 @@ function SizeControl({ name, size }) {
135
161
  }
136
162
 
137
163
  /**
138
- * The card. `compact`: the chat header's own small size (no size control). Nothing when the card is empty. The
164
+ * The card. `compact`: the chat header's own small size (no size control; the banner still follows the size choice,
165
+ * #2038). Nothing when the card is empty. The
139
166
  * tagline and bio say whose they are (title), since a person's field wins over the agent's.
140
167
  */
141
168
  export function IdentityCard({ name, card, compact = false, sizeControl = !compact }) {
@@ -143,11 +170,17 @@ export function IdentityCard({ name, card, compact = false, sizeControl = !compa
143
170
  const [broken, setBroken] = useState({});
144
171
  if (!card) return null;
145
172
  const size = compact ? "c" : cardSizeFor(name);
173
+ // #2038: the chat header's card is its own small size, but its banner follows the person's size choice too.
174
+ const bannerSize = compact ? ` bs-${cardSizeFor(name)}` : "";
146
175
  const whose = (f) => t(f.by === "human" ? "card.byHuman" : "card.byAgent");
147
176
  const pic = (slot, cls, alt) => card[slot] && broken[slot] !== card[slot].src
148
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;
149
- return html`<section class=${`id-card card-${size}`} aria-label=${t("card.label", name)}>
150
- ${pic("banner", "id-banner", "")}
178
+ // #2086: the banner at its focus; on a screen wider than BANNER_MAX_ASPECT it is centred over a blurred copy of itself.
179
+ const focus = card.banner_focus ? card.banner_focus.value : 50;
180
+ const atFocus = (el) => { if (el && el.style) el.style.setProperty("--id-focus", `${focus}%`); };
181
+ const banner = pic("banner", "id-banner", "");
182
+ return html`<section class=${`id-card card-${size}${bannerSize}`} aria-label=${t("card.label", name)}>
183
+ ${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}
151
184
  <div class="id-row">
152
185
  ${pic("portrait", "id-portrait", t("card.portraitAlt", name))}
153
186
  <div class="id-text">
@@ -159,6 +192,44 @@ export function IdentityCard({ name, card, compact = false, sizeControl = !compa
159
192
  </section>`;
160
193
  }
161
194
 
195
+ // ── #2086: the banner's focus, set in View's Edit profile — the whole picture, what each screen shows of it ──
196
+ /**
197
+ * #2089 review: the picture the focus preview shows — a file just chosen (a blob URL this owns: released when another is
198
+ * chosen and when the dialog closes) or the banner drawn now (a server URL, never released).
199
+ */
200
+ export function useBannerPreview() {
201
+ const [preview, setPreview] = useState(null);
202
+ useEffect(() => () => { if (preview && preview.blob) { try { URL.revokeObjectURL(preview.blob); } catch { /* already gone */ } } }, [preview]);
203
+ return [preview, {
204
+ fromFile(file) { let url = null; try { url = URL.createObjectURL(file); } catch { return; } setPreview({ src: url, blob: url }); },
205
+ fromSrc(src) { setPreview({ src }); },
206
+ }];
207
+ }
208
+ /** A phone draws an M card's banner at about 3 : 1 (app.css, ≤479 px) — a guide for that size, not every phone card. */
209
+ export const BANNER_PHONE_ASPECT = 3;
210
+ /**
211
+ * The banner whole, with the band every screen shows at this focus (dashed; outside it shaded) and the band a phone
212
+ * shows (solid) — measured from the picture itself once it has loaded. Positions go through the CSSOM (#1300).
213
+ */
214
+ export function BannerFocusPreview({ src, focus }) {
215
+ const [size, setSize] = useState(null);
216
+ if (!src) return null;
217
+ const ar = size ? size.w / size.h : null;
218
+ const put = (el, a) => { for (const k of ["left", "top", "width", "height"]) el.style.setProperty(k, `${(a[k] * 100).toFixed(2)}%`); };
219
+ // Every screen: the safe area (both crops). The phone guide: an M card on a phone, as drawn there (both crops too).
220
+ const safe = (el) => { if (el && el.style && ar) put(el, bannerSafeArea(ar, focus)); };
221
+ const phone = (el) => {
222
+ if (!el || !el.style || !ar) return;
223
+ const width = bannerShownWidth(ar, BANNER_PHONE_ASPECT), height = bannerShownHeight(ar, BANNER_PHONE_ASPECT);
224
+ put(el, { left: (1 - width) / 2, top: bannerBandTop(height, focus), width, height });
225
+ };
226
+ return html`<div class="bf-wrap">
227
+ <img class="bf-pic" src=${src} alt="" onLoad=${(e) => { const i = e.currentTarget; if (i.naturalWidth && i.naturalHeight) setSize({ w: i.naturalWidth, h: i.naturalHeight }); }} />
228
+ ${ar ? html`<div class="bf-band bf-phone" ref=${phone}><span>${t("card.bannerPhone")}</span></div>
229
+ <div class="bf-band bf-safe" ref=${safe}><span>${t("card.bannerSafe")}</span></div>` : null}
230
+ </div>`;
231
+ }
232
+
162
233
  // ── A person's picture for the card (View's Edit profile), fitted before it is sent (r4.1 Q3 (b)) ──
163
234
  /**
164
235
  * `file` fitted to `limit` ({ max_width, max_height, max_bytes }): as it is when it already fits; otherwise, once the
@@ -3,7 +3,7 @@
3
3
  // (`color`, `color_show`); this module resolves them as src/web-identity-color.ts does (a test pins the two equal),
4
4
  // paints the sidebar row through the CSSOM (no style attribute, #1300), and holds the Color dialog.
5
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
6
+ // places: a person's choice > the agent's (themes shown) > this host's default (color.show) > all three (#2056)
7
7
  // Settings → Web appearance can turn colours off for the host (color.off): nothing is painted, every pick is kept.
8
8
  import { html, useEffect, useState } from "./app-html.js";
9
9
  import { createStore, useStore } from "./app-store.js";
@@ -30,7 +30,7 @@ export const AUTO_PALETTE = [
30
30
  { id: "moss", light: "#5b7340", dark: "#a9c48d" }, { id: "slate", light: "#5f6b78", dark: "#aeb8c4" },
31
31
  ];
32
32
  export const PLACEMENTS = ["name", "bar", "ring"];
33
- export const DEFAULT_PLACEMENTS = ["bar", "ring"];
33
+ export const DEFAULT_PLACEMENTS = ["name", "bar", "ring"]; // #2056: all three where nobody chose (was ring + bar)
34
34
  const HEX = /^#[0-9a-f]{6}$/;
35
35
  const presetOf = (c) => (typeof c === "string" && c.startsWith("preset:") ? IDENTITY_PALETTE.find((p) => p.id === c.slice(7)) : undefined);
36
36
  const autoOf = (c) => (typeof c === "string" && c.startsWith("auto:") ? AUTO_PALETTE.find((p) => p.id === c.slice(5)) : undefined);
@@ -115,7 +115,7 @@ export function colorsOff() {
115
115
  const f = prefInForce("color.off");
116
116
  return f ? f.value === true : readLocal("agend_color_off") === "on";
117
117
  }
118
- /** The default places (null: back to ring + bar). */
118
+ /** The default places (null: back to all three, #2056). */
119
119
  export function setDefaultPlaces(list) {
120
120
  const v = list === null ? null : placesOk(list);
121
121
  const same = v && v.length === DEFAULT_PLACEMENTS.length && v.every((p, i) => p === DEFAULT_PLACEMENTS[i]);
@@ -36,6 +36,22 @@ export function groupOf(it) {
36
36
  if (tags.length) return tags[0];
37
37
  return "Other";
38
38
  }
39
+ /** A group's heading: the fallback group ("Other") is a key, shown in the page's language; a tag is shown as it is. */
40
+ export function groupLabel(g) { return g === "Other" ? tn("groupOther") : g; }
41
+ /**
42
+ * #2050: the model as a person reads it. The server's resolver writes English for a model it has not resolved yet
43
+ * ("default (detected when it starts)") and for a CLI's own default ("x (default)"); those are shown in the page's language.
44
+ */
45
+ export function modelText(it, raw = it && it.model) {
46
+ if (!raw || typeof raw !== "string") return raw;
47
+ if (it.model_source === "unresolved") {
48
+ const why = /^default \((.*)\)$/.exec(raw)?.[1];
49
+ const reason = why === "detected when it starts" ? tn("modelWhyStart") : why === "this CLI does not report a default" ? tn("modelWhyCli") : why;
50
+ return reason ? tn("modelDefaultWhy", { reason }) : raw === "default" ? tn("modelDefault") : raw;
51
+ }
52
+ if (it.model_source === "cli-default") { const m = /^(.*) \(default\)$/.exec(raw); if (m) return tn("modelCliDefault", { model: m[1] }); }
53
+ return raw;
54
+ }
39
55
  const BACKEND_LABELS = { "claude-code": "Claude Code", "kiro-cli": "Kiro CLI", codex: "Codex", grok: "Grok Build", antigravity: "Antigravity", opencode: "OpenCode" };
40
56
  export function backendLabel(backend) { return BACKEND_LABELS[backend] || backend || "Unknown"; }
41
57
  export const KNOWN_BACKENDS = new Set(Object.keys(BACKEND_LABELS));
@@ -93,8 +109,8 @@ export function instanceTooltip(it) {
93
109
  if (it.display_name && it.display_name !== name) lines.push(it.display_name, `(${name})`);
94
110
  else lines.push(name);
95
111
  lines.push(line("tooltipBackend", backendLabel(it.backend)));
96
- const src = (s) => (s === "instance" || s === "classic" ? " (configured)" : s === "fleet-default" ? " (fleet default)" : "");
97
- lines.push(line("tooltipModel", (it.model || tn("tooltipUnavailable")) + src(it.model_source)));
112
+ const src = (s) => (s === "instance" || s === "classic" ? tn("srcConfigured") : s === "fleet-default" ? tn("srcFleetDefault") : "");
113
+ lines.push(line("tooltipModel", (modelText(it) || tn("tooltipUnavailable")) + src(it.model_source)));
98
114
  lines.push(line("tooltipStatus", status));
99
115
  lines.push(line("tooltipContext", context));
100
116
  if (it.effort) lines.push(line("tooltipEffort", it.effort + src(it.effort_source)));
@@ -481,8 +497,8 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
481
497
  const folded = nav.collapsed.has(g);
482
498
  return html`<div key=${g} class="v-group">
483
499
  <button type="button" class="v-group-head" aria-expanded=${folded ? "false" : "true"} onClick=${() => toggle(g)}
484
- title=${tn(folded ? "expandGroup" : "collapseGroup", { group: g })} ...${dragProps({ type: "group", name: g })}>
485
- <${Icon} name="chevron" size=${14} cls=${folded ? "caret folded" : "caret"} /><span class="grow">${g}</span><span class="count">${names.length}</span></button>
500
+ title=${tn(folded ? "expandGroup" : "collapseGroup", { group: groupLabel(g) })} ...${dragProps({ type: "group", name: g })}>
501
+ <${Icon} name="chevron" size=${14} cls=${folded ? "caret folded" : "caret"} /><span class="grow">${groupLabel(g)}</span><span class="count">${names.length}</span></button>
486
502
  ${folded ? null : html`<ul class="inst-list">${names.map((name, i) => row(name, g, i, names.length))}</ul>`}
487
503
  </div>`;
488
504
  })}
@@ -16,7 +16,7 @@ import { HeaderTools, VIEW_SCALE, textSizeStore } from "./header-tools.js";
16
16
  import { readStream } from "./read-stream.js";
17
17
  // The usage dialog's metric row lives with the dialog now (usage-dialog.js); re-exported for what imported it here.
18
18
  export { UsageMetric } from "./usage-dialog.js";
19
- import { backendLabel, STATUS_KEYS } from "./instance-nav.js";
19
+ import { backendLabel, modelText, STATUS_KEYS } from "./instance-nav.js";
20
20
  import { viewStore } from "./view-roster-store.js";
21
21
 
22
22
  /** #1580: the app's stream says the fleet is not answering (a restart): the View's polls skip their turn until it is. */
@@ -27,7 +27,7 @@ import { Dialog } from "./ui-dialog.js";
27
27
  import { Empty, ErrorState, Skeleton } from "./ui-states.js";
28
28
  import { Icon } from "./ui-icons.js";
29
29
  import "./view-strings.js";
30
- import { checkCard, fitPicture, IdentityCard, useCard } from "./identity-card.js";
30
+ import { BannerFocusPreview, checkCard, fitPicture, IdentityCard, useBannerPreview, useCard } from "./identity-card.js";
31
31
  import { confirmDialog } from "./ui-confirm.js";
32
32
  import { openColorDialog } from "./identity-color.js";
33
33
 
@@ -305,7 +305,7 @@ function Card({ it, viewOnly, onEdit }) {
305
305
  <div class="v-avatar">${it.has_avatar ? html`<img ref=${avatar} alt="" />` : html`<span class="v-initial" aria-hidden="true">${initial}</span>`}</div>
306
306
  <div class="v-card-body">
307
307
  <div class="v-card-line"><strong>${it.display_name || it.instance_name}</strong>${it.role ? html`<span class="v-role">${it.role}</span>` : null}</div>
308
- <div class="v-card-meta">${it.instance_name} · ${backendLabel(it.backend)}${it.model ? ` · ${it.model}` : ""}${ctx}</div>
308
+ <div class="v-card-meta">${it.instance_name} · ${backendLabel(it.backend)}${it.model ? ` · ${modelText(it)}` : ""}${ctx}</div>
309
309
  ${open && it.description ? html`<p class="v-card-desc">${it.description}</p>` : null}
310
310
  </div>
311
311
  <button type="button" class="icon-btn" onClick=${toggle} aria-expanded=${open ? "true" : "false"} aria-label=${tn(open ? "collapseProfile" : "expandProfile")} title=${tn(open ? "collapseProfile" : "expandProfile")}>
@@ -332,6 +332,11 @@ function EditProfile({ it, onClose }) {
332
332
  const [removeCard, setRemoveCard] = useState({ portrait: false, banner: false });
333
333
  const [clearCard, setClearCard] = useState(false);
334
334
  const pics = { portrait: useRef(null), banner: useRef(null) };
335
+ // #2086: the banner's focus — this person's (null: follow the agent's, else the centre), and the picture to preview:
336
+ // a newly chosen file, else the banner drawn now.
337
+ // `agent`: the agent's own focus (what "Follow the agent's" shows; else the centre).
338
+ const [focus, setFocus] = useState({ value: 50, mine: null, start: null, agent: null });
339
+ const [bannerPreview, preview] = useBannerPreview();
335
340
  useEffect(() => {
336
341
  let live = true;
337
342
  // The card's fields stay disabled until this read is in (#1889 review: a draft typed meanwhile was overwritten);
@@ -343,6 +348,11 @@ function EditProfile({ it, onClose }) {
343
348
  const resolved = checkCard(it.instance_name, body);
344
349
  const mine = (k) => (resolved && resolved[k] && resolved[k].by === "human" ? resolved[k] : null);
345
350
  setCard({ mine: { tagline: mine("tagline"), bio: mine("bio"), portrait: mine("portrait"), banner: mine("banner") }, limits: body.limits || null });
351
+ const bf = resolved && resolved.banner_focus;
352
+ const agent = Number.isInteger(body.banner_focus_agent) && body.banner_focus_agent >= 0 && body.banner_focus_agent <= 100 ? body.banner_focus_agent : null;
353
+ const mineFocus = bf && bf.by === "human" ? bf.value : null;
354
+ setFocus({ value: mineFocus ?? agent ?? 50, mine: mineFocus, start: mineFocus, agent });
355
+ if (resolved && resolved.banner) preview.fromSrc(resolved.banner.src);
346
356
  setCardText({ tagline: mine("tagline") ? mine("tagline").text : "", bio: mine("bio") ? mine("bio").text : "" });
347
357
  }).catch(() => { if (live && lease.current()) setCard(none); });
348
358
  return () => { live = false; };
@@ -360,6 +370,7 @@ function EditProfile({ it, onClose }) {
360
370
  const clearing = clearCard;
361
371
  const before = card && card.mine ? { tagline: card.mine.tagline ? card.mine.tagline.text : "", bio: card.mine.bio ? card.mine.bio.text : "" } : { tagline: "", bio: "" };
362
372
  if (!clearing) for (const k of ["tagline", "bio"]) if (cardText[k] !== before[k]) draft[`card_${k}`] = cardText[k];
373
+ if (!clearing && focus.mine !== focus.start) draft.card_banner_focus = focus.mine; // #2086
363
374
  const cardPics = {};
364
375
  try {
365
376
  // A picture too large is fitted first (after asking) — before anything is written, so declining writes nothing.
@@ -422,10 +433,19 @@ function EditProfile({ it, onClose }) {
422
433
  <label class="field"><span>${tn("cardTagline")}</span><input value=${cardText.tagline} onInput=${(e) => setCardText({ ...cardText, tagline: e.target.value })} /></label>
423
434
  <label class="field"><span>${tn("cardBio")}</span><textarea rows="4" value=${cardText.bio} onInput=${(e) => setCardText({ ...cardText, bio: e.target.value })}></textarea></label>
424
435
  ${["portrait", "banner"].map((slot) => html`<div key=${slot} class="form">
425
- <label class="field"><span>${tn(slot === "portrait" ? "cardPortrait" : "cardBanner")}</span><input ref=${pics[slot]} type="file" accept="image/png,image/jpeg,image/webp" /></label>
436
+ <label class="field"><span>${tn(slot === "portrait" ? "cardPortrait" : "cardBanner")}</span><input ref=${pics[slot]} type="file" accept="image/png,image/jpeg,image/webp"
437
+ onChange=${slot === "banner" ? (e) => { const fl = e.currentTarget.files && e.currentTarget.files[0]; if (fl) preview.fromFile(fl); } : undefined} /></label>
426
438
  ${card && card.mine && card.mine[slot] ? html`<div class="field-row"><button type="button" class="btn btn-sm" aria-pressed=${removeCard[slot] ? "true" : "false"}
427
439
  onClick=${() => setRemoveCard({ ...removeCard, [slot]: !removeCard[slot] })}>${tn(removeCard[slot] ? "cardKeep" : "cardRemove")}</button></div>` : null}
428
440
  </div>`)}
441
+ ${bannerPreview ? html`<div class="field bf-field">
442
+ <span>${tn("cardBannerFocus")}</span>
443
+ <${BannerFocusPreview} src=${bannerPreview.src} focus=${focus.value} />
444
+ <input type="range" min="0" max="100" step="1" value=${focus.value} aria-label=${tn("cardBannerFocus")}
445
+ onInput=${(e) => { const v = Number(e.currentTarget.value); setFocus({ ...focus, value: v, mine: v }); }} />
446
+ <span class="note">${tn("cardBannerFocusNote")}</span>
447
+ ${focus.mine !== null ? html`<div class="field-row"><button type="button" class="btn btn-sm" onClick=${() => setFocus({ ...focus, value: focus.agent ?? 50, mine: null })}>${tn("cardFocusFollow")}</button></div>` : null}
448
+ </div>` : null}
429
449
  </fieldset>
430
450
  <div class="field-row"><button type="button" class="btn btn-sm" aria-pressed=${clearCard ? "true" : "false"} onClick=${() => setClearCard(!clearCard)}>${tn(clearCard ? "keepCard" : "clearCard")}</button>
431
451
  ${clearCard ? html`<span class="muted">${tn("clearCardNote")}</span>` : null}</div>
@@ -51,6 +51,14 @@ register("view", {
51
51
  "tooltipEffort": "Effort: {value}",
52
52
  "tooltipCost": "Cost: {value}",
53
53
  "tooltipUnavailable": "Unavailable",
54
+ "srcConfigured": " (configured)",
55
+ "srcFleetDefault": " (fleet default)",
56
+ "modelDefault": "default",
57
+ "modelDefaultWhy": "default ({reason})",
58
+ "modelCliDefault": "{model} (default)",
59
+ "modelWhyStart": "detected when it starts",
60
+ "modelWhyCli": "this CLI does not report a default",
61
+ "groupOther": "Other",
54
62
  "statusRunning": "Running",
55
63
  "statusPaused": "Paused",
56
64
  "statusStopped": "Stopped",
@@ -162,6 +170,9 @@ register("view", {
162
170
  "cardBio": "Bio (up to 280 characters, 3 line breaks)",
163
171
  "cardPortrait": "Portrait",
164
172
  "cardBanner": "Banner",
173
+ "cardBannerFocus": "Banner focus",
174
+ "cardBannerFocusNote": "What stays in view when the banner is cropped: the dashed area shows on every screen and card size; the yellow one is a phone's medium card.",
175
+ "cardFocusFollow": "Follow the agent's",
165
176
  "cardRemove": "Remove yours",
166
177
  "cardKeep": "Keep yours",
167
178
  "clearCard": "Clear card",
@@ -248,6 +259,14 @@ register("view", {
248
259
  "tooltipEffort": "推理強度:{value}",
249
260
  "tooltipCost": "費用:{value}",
250
261
  "tooltipUnavailable": "無資料",
262
+ "srcConfigured": "(設定值)",
263
+ "srcFleetDefault": "(fleet 預設)",
264
+ "modelDefault": "預設",
265
+ "modelDefaultWhy": "預設({reason})",
266
+ "modelCliDefault": "{model}(預設)",
267
+ "modelWhyStart": "啟動時偵測",
268
+ "modelWhyCli": "這個 CLI 不提供預設值",
269
+ "groupOther": "其他",
251
270
  "statusRunning": "運行中",
252
271
  "statusPaused": "已暫停",
253
272
  "statusStopped": "已停止",
@@ -359,6 +378,9 @@ register("view", {
359
378
  "cardBio": "簡介(最多 280 個字、3 次換行)",
360
379
  "cardPortrait": "肖像",
361
380
  "cardBanner": "橫幅",
381
+ "cardBannerFocus": "橫幅焦點",
382
+ "cardBannerFocusNote": "橫幅被裁切時保留在畫面中的部分:虛線框在所有螢幕與卡片大小都看得到;黃色框是手機上中卡片的範圍。",
383
+ "cardFocusFollow": "跟隨 agent 的設定",
362
384
  "cardRemove": "移除你的",
363
385
  "cardKeep": "保留你的",
364
386
  "clearCard": "清除卡片",
@@ -84,6 +84,38 @@ export declare function resolveInstanceIdentity(input: {
84
84
  }): InstanceIdentity;
85
85
  /** Profiles by instance, for a status read: only when profiles.db already exists — a status read never creates it. */
86
86
  export declare function profileIdentities(dataDir: string): Map<string, Pick<ProfileRow, "display_name" | "description" | "role" | "avatar_path">>;
87
+ /** #2056: one instance's person-picked colour and places, as the Colour dialog (GET /api/profile) reads them. */
88
+ export declare function personIdentityColour(dataDir: string, name: string): {
89
+ color: string | null;
90
+ show: string[] | null;
91
+ };
92
+ /**
93
+ * #2056: a person's colour for one instance — exactly POST /api/profile's colour fields: the store's own check (in set(),
94
+ * before anything is written), the table made first, the write in a profiles.db transaction. The caller announces
95
+ * (identityChanged).
96
+ */
97
+ export declare function setPersonIdentityColour(dataDir: string, name: string, identity: {
98
+ color?: unknown;
99
+ show?: unknown;
100
+ }): {
101
+ ok: true;
102
+ } | {
103
+ ok: false;
104
+ status: number;
105
+ error: string;
106
+ detail?: string;
107
+ };
108
+ /** #2086: the banner focus a person set for one instance (null: none; the agent's, else the centre, is drawn). */
109
+ export declare function personBannerFocus(dataDir: string, name: string): number | null;
110
+ /** #2086: a person's banner focus — exactly POST /api/profile's card_banner_focus (the store's check; its own write). */
111
+ export declare function setPersonBannerFocus(dataDir: string, name: string, focus: unknown): {
112
+ ok: true;
113
+ } | {
114
+ ok: false;
115
+ status: number;
116
+ error: string;
117
+ detail?: string;
118
+ };
87
119
  /** Every person-picked colour, for a status read (never creates profiles.db or its table). */
88
120
  export declare function identityColors(dataDir: string): ReturnType<typeof IdentityColorStore.all>;
89
121
  /** The card picture drawn for `slot`: the person's, else the agent's (the same rule as the card's JSON). */