@songsid/agend 2.3.0-beta.3 → 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 (112) hide show
  1. package/dist/auth-api.d.ts +30 -1
  2. package/dist/auth-api.js +32 -7
  3. package/dist/auth-api.js.map +1 -1
  4. package/dist/backend/types.d.ts +1 -0
  5. package/dist/backend/types.js +15 -0
  6. package/dist/backend/types.js.map +1 -1
  7. package/dist/backend-availability.d.ts +120 -0
  8. package/dist/backend-availability.js +396 -0
  9. package/dist/backend-availability.js.map +1 -0
  10. package/dist/backend-command.d.ts +21 -3
  11. package/dist/backend-command.js +23 -4
  12. package/dist/backend-command.js.map +1 -1
  13. package/dist/channel/adapters/discord.d.ts +7 -0
  14. package/dist/channel/adapters/discord.js +35 -0
  15. package/dist/channel/adapters/discord.js.map +1 -1
  16. package/dist/channel/mcp-tools.js +4 -4
  17. package/dist/channel/mcp-tools.js.map +1 -1
  18. package/dist/channel/types.d.ts +6 -0
  19. package/dist/config-validator.js +73 -2
  20. package/dist/config-validator.js.map +1 -1
  21. package/dist/fleet-context.d.ts +10 -0
  22. package/dist/fleet-manager.d.ts +174 -6
  23. package/dist/fleet-manager.js +642 -41
  24. package/dist/fleet-manager.js.map +1 -1
  25. package/dist/general-knowledge/skills/web-appearance/SKILL.md +17 -7
  26. package/dist/human-delivery-receipts.js +2 -1
  27. package/dist/human-delivery-receipts.js.map +1 -1
  28. package/dist/locale.js +42 -4
  29. package/dist/locale.js.map +1 -1
  30. package/dist/outbound-schemas.d.ts +4 -0
  31. package/dist/outbound-schemas.js +5 -1
  32. package/dist/outbound-schemas.js.map +1 -1
  33. package/dist/public-web-link.d.ts +9 -0
  34. package/dist/public-web-link.js +8 -0
  35. package/dist/public-web-link.js.map +1 -1
  36. package/dist/quickstart-api.d.ts +10 -1
  37. package/dist/quickstart-api.js +12 -4
  38. package/dist/quickstart-api.js.map +1 -1
  39. package/dist/reply-buttons-controller.d.ts +2 -0
  40. package/dist/reply-buttons-controller.js +1 -1
  41. package/dist/reply-buttons-controller.js.map +1 -1
  42. package/dist/topic-commands.d.ts +5 -0
  43. package/dist/topic-commands.js +16 -0
  44. package/dist/topic-commands.js.map +1 -1
  45. package/dist/types.d.ts +24 -0
  46. package/dist/ui/chat-thread.js +4 -1
  47. package/dist/ui/panel-chat.js +4 -3
  48. package/dist/ui/panel-fleet.js +15 -6
  49. package/dist/ui/panel-settings.js +3 -2
  50. package/dist/ui/settings-catalog.js +89 -0
  51. package/dist/ui/settings-dialogs.js +37 -6
  52. package/dist/ui/settings-strings.js +6 -0
  53. package/dist/ui/shared/app.css +36 -3
  54. package/dist/ui/shared/card-strings.js +4 -0
  55. package/dist/ui/shared/effect-stock-trust.js +23 -2
  56. package/dist/ui/shared/identity-card.js +70 -2
  57. package/dist/ui/shared/panel-view.js +22 -2
  58. package/dist/ui/shared/view-strings.js +6 -0
  59. package/dist/view-api.d.ts +11 -0
  60. package/dist/view-api.js +27 -4
  61. package/dist/view-api.js.map +1 -1
  62. package/dist/web-api.d.ts +16 -0
  63. package/dist/web-api.js +133 -22
  64. package/dist/web-api.js.map +1 -1
  65. package/dist/web-appearance-policy.d.ts +4 -6
  66. package/dist/web-appearance-policy.js +5 -12
  67. package/dist/web-appearance-policy.js.map +1 -1
  68. package/dist/web-appearance-tools.js +5 -1
  69. package/dist/web-appearance-tools.js.map +1 -1
  70. package/dist/web-appearance-view.d.ts +4 -1
  71. package/dist/web-appearance-view.js +21 -2
  72. package/dist/web-appearance-view.js.map +1 -1
  73. package/dist/web-appearance.d.ts +2 -2
  74. package/dist/web-appearance.js +5 -6
  75. package/dist/web-appearance.js.map +1 -1
  76. package/dist/web-card.d.ts +14 -0
  77. package/dist/web-card.js +40 -6
  78. package/dist/web-card.js.map +1 -1
  79. package/dist/web-channel-echo.d.ts +7 -1
  80. package/dist/web-channel-echo.js +6 -2
  81. package/dist/web-channel-echo.js.map +1 -1
  82. package/dist/web-chat-history.d.ts +40 -0
  83. package/dist/web-chat-history.js +66 -3
  84. package/dist/web-chat-history.js.map +1 -1
  85. package/dist/web-commands.d.ts +1 -0
  86. package/dist/web-effect-tools.js +2 -3
  87. package/dist/web-effect-tools.js.map +1 -1
  88. package/dist/web-guest-access.d.ts +65 -7
  89. package/dist/web-guest-access.js +124 -18
  90. package/dist/web-guest-access.js.map +1 -1
  91. package/dist/web-guest-rooms.d.ts +82 -0
  92. package/dist/web-guest-rooms.js +135 -0
  93. package/dist/web-guest-rooms.js.map +1 -0
  94. package/dist/web-login.d.ts +47 -0
  95. package/dist/web-login.js +115 -0
  96. package/dist/web-login.js.map +1 -1
  97. package/dist/web-mod-agent.d.ts +1 -1
  98. package/dist/web-mod-agent.js +2 -4
  99. package/dist/web-mod-agent.js.map +1 -1
  100. package/dist/web-returning-device.d.ts +39 -0
  101. package/dist/web-returning-device.js +46 -5
  102. package/dist/web-returning-device.js.map +1 -1
  103. package/dist/web-session.d.ts +43 -3
  104. package/dist/web-session.js +79 -14
  105. package/dist/web-session.js.map +1 -1
  106. package/dist/web-settings-tools.d.ts +12 -0
  107. package/dist/web-settings-tools.js +10 -0
  108. package/dist/web-settings-tools.js.map +1 -1
  109. package/dist/web-theme.d.ts +2 -0
  110. package/dist/web-theme.js +10 -1
  111. package/dist/web-theme.js.map +1 -1
  112. package/package.json +1 -1
@@ -13,6 +13,8 @@ import { api, confirmedWrite, newKey } from "./settings-confirm.js";
13
13
  import { adminUserIdError, adminUserIdPattern, botHandle, TokenEnvNote, TokenField, verifyBotToken } from "./settings-token.js";
14
14
  import { DiscordServerPicker } from "./settings-discord.js";
15
15
  import { startOperation } from "./settings-apply.js";
16
+ import "./settings-strings.js"; // its words, wherever a picker of this file is used (#2072: Fleet's create dialog too)
17
+ import { backendChoices, modelChoices, OTHER_MODEL, useBackendRows, useModelCatalog } from "./settings-catalog.js";
16
18
  import {
17
19
  ACCESS_MODES, AGENT_FIELDS, AGENT_MODES, BOT_FIELDS, CH_TYPES, CLASSIC_FIELDS, HANG_TIMEOUT_DEFAULT_MINUTES, LOG_LEVELS, TOOL_PROGRESS, TOOL_SETS,
18
20
  VISIBILITY_MODES, accessConfirmations, agentForm, agentPatch, backendOptions, changedFields, channelId, channelsOf,
@@ -32,9 +34,36 @@ export function Select({ value, options, onChange, disabled, id, label }) {
32
34
  return html`<select id=${id} value=${value} disabled=${disabled} aria-label=${label} onChange=${(e) => onChange(e.target.value)}>
33
35
  ${opts.map((o) => html`<option key=${o.value} value=${o.value}>${o.label}</option>`)}</select>`;
34
36
  }
37
+ /**
38
+ * #2072: a backend field shows every backend with what this host says about it — one it cannot run is disabled with
39
+ * why (not installed, not signed in, too old); one not checked yet stays selectable; the current value always can be.
40
+ */
35
41
  export function BackendSelect({ value, onChange, disabled, id }) {
36
- return html`<${Select} id=${id} value=${value} disabled=${disabled} onChange=${onChange}
37
- options=${backendOptions(value).map((o) => (o.unavailable ? { value: o.value, label: tn("backendUnavailable", o.value) } : o))} />`;
42
+ const rows = useBackendRows();
43
+ const known = backendOptions(value).filter((o) => !o.unavailable).map((o) => o.value);
44
+ const opts = backendChoices(known, rows || [], value, {
45
+ notInstalled: tn("backendNotInstalled"), notSignedIn: tn("backendNotSignedIn"), tooOld: (v) => tn("backendTooOld", v),
46
+ unchecked: tn("backendUnchecked"), removed: (v) => tn("backendUnavailable", v),
47
+ });
48
+ return html`<select id=${id} value=${value} disabled=${disabled} onChange=${(e) => onChange(e.target.value)}>
49
+ ${opts.map((o) => html`<option key=${o.value} value=${o.value} disabled=${o.disabled}>${o.label}</option>`)}</select>`;
50
+ }
51
+ /**
52
+ * #2072: a model field is a list — that backend's models for this account (cached; never a probe), the current one
53
+ * first and marked when the CLI does not list it, and "Other…" for a name it does not list (a text box opens with
54
+ * the current value in it: choosing "Other…" alone changes nothing — emptying the box is what inherits, #2098 review).
55
+ * `inherit`: the label of the empty choice (inherit / none), or omit it when the field has none.
56
+ */
57
+ export function ModelSelect({ value, onChange, disabled, id, backend, instance, inherit }) {
58
+ const catalog = useModelCatalog(backend, instance);
59
+ const [typing, setTyping] = useState(false);
60
+ const opts = modelChoices(catalog || [], value, { notListed: tn("modelNotListed"), other: tn("modelOther") }, inherit);
61
+ return html`<div class="model-pick">
62
+ <select id=${id} value=${typing ? OTHER_MODEL : value} disabled=${disabled}
63
+ onChange=${(e) => { const v = e.target.value; if (v === OTHER_MODEL) setTyping(true); else { setTyping(false); onChange(v); } }}>
64
+ ${opts.map((o) => html`<option key=${o.value} value=${o.value}>${o.label}</option>`)}</select>
65
+ ${typing ? html`<input type="text" aria-label=${tn("modelOtherLabel")} placeholder=${tn("modelOtherPlaceholder")} disabled=${disabled}
66
+ value=${value} onInput=${(e) => onChange(e.target.value)} />` : null}</div>`;
38
67
  }
39
68
  export function Impact({ schema, field }) { return html`<span class="impact">${impactText(impactOf(schema, field))}</span>`; }
40
69
  export function Label({ text, schema, field, htmlFor }) {
@@ -228,7 +257,8 @@ export function AgentDialog({ name, inst, ctx, onClose }) {
228
257
  <input id="ag-dn" type="text" placeholder=${name} disabled=${f.inherit.display_name} value=${shown("display_name", f.display_name, defaults.display_name || "")} onInput=${(e) => set("display_name")(e.target.value)} /></${Override}></div>
229
258
  <div class="field">${L(tn("description"), "instance.description", "ag-desc")}<input id="ag-desc" type="text" value=${f.description} onInput=${(e) => set("description")(e.target.value)} /></div>
230
259
  <div class="field">${L(tn("backend"), "instance.backend", "ag-be")}<${BackendSelect} id="ag-be" value=${f.backend} onChange=${set("backend")} /></div>
231
- <div class="field">${L(tn("model"), "instance.model", "ag-model")}<input id="ag-model" type="text" placeholder=${tn("inheritPlaceholder")} value=${f.model} onInput=${(e) => set("model")(e.target.value)} /></div>
260
+ <div class="field">${L(tn("model"), "instance.model", "ag-model")}<${ModelSelect} id="ag-model" value=${f.model} onChange=${set("model")}
261
+ backend=${f.backend || defaults.backend || "claude-code"} instance=${name} inherit=${tn("inheritPlaceholder")} /></div>
232
262
  </div>
233
263
  <div class="field">${L(tn("workingDir"), "instance.working_directory", "ag-wd")}<input id="ag-wd" type="text" value=${f.working_directory} onInput=${(e) => set("working_directory")(e.target.value)} /></div>
234
264
  <div class="field">${L(`${tn("autoPause")} (${tn("minutes")})`, "instance.auto_pause_after", "ag-auto")}
@@ -264,8 +294,8 @@ export function AgentDialog({ name, inst, ctx, onClose }) {
264
294
  onChange=${(e) => set("lightweight")(e.target.checked)} /><span>${tn("lightweight")}</span></label></${Override}></div>
265
295
  <div class="field">${L(tn("modelFailover"), "instance.model_failover", "ag-fo")}
266
296
  <${Override} inherit=${f.inherit.model_failover} onInherit=${setInherit("model_failover")}>
267
- <input id="ag-fo" type="text" placeholder="e.g. sonnet" disabled=${f.inherit.model_failover}
268
- value=${shown("model_failover", f.model_failover, (defaults.model_failover && defaults.model_failover[0]) || "")} onInput=${(e) => set("model_failover")(e.target.value)} /></${Override}>
297
+ <${ModelSelect} id="ag-fo" disabled=${f.inherit.model_failover} backend=${f.backend || defaults.backend || "claude-code"} instance=${name} inherit=${tn("modelNone")}
298
+ value=${shown("model_failover", f.model_failover, (defaults.model_failover && defaults.model_failover[0]) || "")} onChange=${set("model_failover")} /></${Override}>
269
299
  <${Feedback} warning=${failoverWarn} /></div>
270
300
  </div>
271
301
  <div class="field">${L(tn("systemPrompt"), "instance.systemPrompt", "ag-sys")}<input id="ag-sys" type="text" placeholder="e.g. file:prompts/dev.md" value=${f.systemPrompt} onInput=${(e) => set("systemPrompt")(e.target.value)} /></div>
@@ -591,7 +621,8 @@ export function ClassicDialog({ room, ctx, onClose }) {
591
621
  return html`<${EditDialog} title=${c.name || c.instanceName} subtitle=${`ID: ${c.instanceName} · ${tn(`exec_${runtime.execution}`)}`} onClose=${onClose} onStage=${stage} ready=${ready} impacts=${CLASSIC_FIELDS} schema=${schema}>
592
622
  <div class="grid2">
593
623
  <div class="field">${L(tn("backend"), "classic.backend", "cl-be")}<${BackendSelect} id="cl-be" value=${f.backend} onChange=${set("backend")} /></div>
594
- <div class="field">${L(tn("model"), "classic.model", "cl-model")}<input id="cl-model" type="text" placeholder=${inherited.model || tn("inheritPlaceholder")} value=${f.model} onInput=${(e) => set("model")(e.target.value)} /></div>
624
+ <div class="field">${L(tn("model"), "classic.model", "cl-model")}<${ModelSelect} id="cl-model" value=${f.model} onChange=${set("model")}
625
+ backend=${f.backend} instance=${c.instanceName} inherit=${inherited.model ? tn("inheritValue", inherited.model) : tn("inheritPlaceholder")} /></div>
595
626
  <div class="field">${L(`${tn("autoPause")} (${tn("minutes")})`, "classic.auto_pause_after", "cl-auto")}
596
627
  <${Override} inherit=${f.inherit.auto_pause_after} onInherit=${setInherit("auto_pause_after")}>
597
628
  <input id="cl-auto" type="number" min="0" step="1" class=${errors.auto ? "invalid" : ""} disabled=${f.inherit.auto_pause_after}
@@ -203,6 +203,9 @@ register("settings", {
203
203
  changesApplied: "Changes applied. AgEnD is reconciling affected Agents.",
204
204
  reviewChanges: "Review changes",
205
205
  backendUnavailable: "{0} (removed — choose another backend)",
206
+ backendNotInstalled: "not installed", backendNotSignedIn: "not signed in (use /login)", backendTooOld: "needs {0} or newer", backendUnchecked: "not checked yet",
207
+ modelNotListed: "current — not in this CLI's list", modelOther: "Other…", modelOtherLabel: "Model name", modelOtherPlaceholder: "a model name this CLI accepts",
208
+ modelNone: "— none —", inheritValue: "inherit ({0})", modelCliDefault: "the CLI's own default",
206
209
  readyApply: "Ready to apply",
207
210
  copied: "Copied",
208
211
  expectedMapping: "expected a mapping",
@@ -697,6 +700,9 @@ register("settings", {
697
700
  changesApplied: "變更已套用,AgEnD 正在協調受影響的 Agent。",
698
701
  reviewChanges: "檢查變更",
699
702
  backendUnavailable: "{0}(已移除,請選擇其他 backend)",
703
+ backendNotInstalled: "未安裝", backendNotSignedIn: "未登入(請用 /login)", backendTooOld: "需要 {0} 以上版本", backendUnchecked: "尚未檢查",
704
+ modelNotListed: "目前使用中 — 不在這個 CLI 的清單裡", modelOther: "其他…", modelOtherLabel: "模型名稱", modelOtherPlaceholder: "這個 CLI 接受的模型名稱",
705
+ modelNone: "— 無 —", inheritValue: "沿用({0})", modelCliDefault: "CLI 自己的預設",
700
706
  readyApply: "可套用變更",
701
707
  copied: "已複製",
702
708
  expectedMapping: "預期為 mapping",
@@ -366,6 +366,9 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
366
366
  .field input:focus, .field select:focus, .field textarea:focus, .field-row input:focus { border-color: var(--focus); box-shadow: 0 0 0 3px var(--accent-dim); }
367
367
  .field input:disabled { opacity: 0.6; }
368
368
  .field-row { display: flex; gap: var(--s-1); align-items: center; }
369
+ /* #2072: a model field — the list, and the text box "Other…" opens under it. */
370
+ .model-pick { display: flex; flex-direction: column; gap: var(--s-1); min-width: 0; }
371
+ .model-pick > select, .model-pick > input { width: 100%; }
369
372
  .note { font-size: var(--fs-xs); color: var(--text-3); }
370
373
  .checks { display: flex; flex-direction: column; gap: var(--s-1); max-height: 180px; overflow-y: auto; }
371
374
  .check { display: flex; gap: var(--s-2); align-items: center; font-size: var(--fs-sm); }
@@ -614,8 +617,12 @@ html.lb-open .scroller { overflow: hidden; }
614
617
  .pv-menu[open] { z-index: 6; background: var(--surface); border-radius: var(--r-sm); padding: var(--s-1); box-shadow: var(--shadow-2); }
615
618
  .pv-menu[open] > .chip-btn { display: block; text-align: left; width: max-content; max-width: 260px; white-space: normal; }
616
619
 
620
+ /* #2087: the pill takes no room in the thread — it was a sticky box in the flow, 38 px at the end of the scroll, so
621
+ hiding it 48 px from the bottom took that room away under a reader still scrolling — the wheel's last step was cut
622
+ short, or the view clamped back. Its dock is sticky and 0 px tall; the pill floats above it. */
623
+ .jump-dock { position: sticky; bottom: 0; height: 0; z-index: 5; }
617
624
  .jump-latest {
618
- position: sticky; bottom: var(--s-3); left: 50%; transform: translateX(-50%); z-index: 5; display: inline-flex; align-items: center; gap: var(--s-1);
625
+ position: absolute; bottom: var(--s-3); left: 50%; transform: translateX(-50%); white-space: nowrap; display: inline-flex; align-items: center; gap: var(--s-1);
619
626
  background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: var(--r-full); box-shadow: var(--shadow-2);
620
627
  padding: 6px var(--s-4); font-size: var(--fs-xs); font-weight: var(--fw-semibold); cursor: pointer;
621
628
  }
@@ -1334,13 +1341,39 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1334
1341
  #2038: the banner's height follows the card's width — S 4:1, M 3:1, L 2.25:1 — between a floor and a ceiling per size,
1335
1342
  so it is never a strip across a wide chat and never a wall on a phone; the picture fills it, cropped about its middle.
1336
1343
  The chat header's card (card-c) follows the same size choice (card-c bs-<size>). */
1337
- .id-card { --id-h: 240px; --id-ar: 3 / 1; --id-bmin: 140px; --id-bmax: 240px; margin: 0 0 var(--s-4); border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface); overflow: hidden; }
1344
+ .id-card { --id-feather: linear-gradient(to right, transparent 0, #000 7%, #000 93%, transparent 100%); --id-h: 240px; --id-ar: 3 / 1; --id-bmin: 140px; --id-bmax: 240px; margin: 0 0 var(--s-4); border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface); overflow: hidden; }
1338
1345
  .id-card.card-s { --id-h: 160px; --id-ar: 4 / 1; --id-bmin: 100px; --id-bmax: 160px; }
1339
1346
  .id-card.card-l { --id-h: 360px; --id-ar: 9 / 4; --id-bmin: 200px; --id-bmax: 360px; }
1340
1347
  .id-card.card-c { --id-h: 96px; --id-bmin: 120px; --id-bmax: 200px; margin: 0; border: 0; border-radius: 0; background: none; }
1341
1348
  .id-card.card-c.bs-s { --id-ar: 4 / 1; --id-bmin: 96px; --id-bmax: 150px; }
1342
1349
  .id-card.card-c.bs-l { --id-ar: 9 / 4; --id-bmin: 150px; --id-bmax: 220px; }
1343
- .id-banner { display: block; width: 100%; height: auto; aspect-ratio: var(--id-ar); min-height: var(--id-bmin); max-height: var(--id-bmax); object-fit: cover; object-position: 50% 50%; }
1350
+ /* #2086: the banner at its focus (--id-focus, set per card), never wider than 5 : 1 — its width is capped at five times its
1351
+ size's tallest height, so the band shown everywhere is (picture aspect ÷ 5) of its height. Beyond that it is centred
1352
+ over a blurred copy of itself. */
1353
+ .id-banner-box { position: relative; overflow: hidden; container: idbanner / inline-size; }
1354
+ /* #2089 review: and never narrower than 9 : 4 — its height never more than its box's width ÷ 2.25 (a size's floor yields
1355
+ to it on a narrow column: a wide sidebar, the usage rail), so the promised safe area holds on every layout. */
1356
+ .id-banner { position: relative; display: block; width: 100%; max-width: calc(var(--id-bmax) * 5); margin-inline: auto; height: auto; aspect-ratio: var(--id-ar);
1357
+ min-height: min(var(--id-bmin), calc(100cqw / 2.25)); max-height: min(var(--id-bmax), calc(100cqw / 2.25)); object-fit: cover; object-position: 50% var(--id-focus, 50%); }
1358
+ /* #2086: Edit profile's banner focus — the picture whole, the band every screen shows (dashed, outside it shaded) and a phone's. */
1359
+ .bf-wrap { position: relative; max-width: 440px; overflow: hidden; border-radius: var(--r-sm); }
1360
+ .bf-pic { display: block; width: 100%; height: auto; }
1361
+ .bf-band { position: absolute; pointer-events: none; box-sizing: border-box; }
1362
+ .bf-band span { position: absolute; right: 4px; top: 2px; font-size: var(--fs-xs); font-weight: var(--fw-semibold); color: #fff; text-shadow: 0 1px 2px #000, 0 0 4px #000; }
1363
+ .bf-safe { border: 2px dashed #fff; box-shadow: 0 0 0 999px rgba(0, 0, 0, .45); }
1364
+ /* The phone's band over the shade, in its own colour (its label at its own top-left, never in the shaded part). */
1365
+ .bf-phone { z-index: 1; border: 2px solid #ffd54a; }
1366
+ .bf-phone span { right: auto; left: 8px; top: 5px; color: #ffd54a; }
1367
+ .bf-field input[type=range] { width: 100%; max-width: 440px; }
1368
+ .id-banner-fill { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: 50% var(--id-focus, 50%); filter: blur(24px) saturate(0.75) brightness(0.7); transform: scale(1.4); opacity: .9; pointer-events: none; }
1369
+ /* Wider than the banner's cap (5 × the size's tallest height): its sides feather into the fill, so the sharp centre
1370
+ leads without a seam. One threshold per size (a container query cannot read a custom property). */
1371
+ @container idbanner (min-width: 750px) { .id-card.card-c.bs-s .id-banner { -webkit-mask-image: var(--id-feather); mask-image: var(--id-feather); } }
1372
+ @container idbanner (min-width: 800px) { .id-card.card-s .id-banner { -webkit-mask-image: var(--id-feather); mask-image: var(--id-feather); } }
1373
+ @container idbanner (min-width: 1000px) { .id-card.card-c:not(.bs-s):not(.bs-l) .id-banner { -webkit-mask-image: var(--id-feather); mask-image: var(--id-feather); } }
1374
+ @container idbanner (min-width: 1100px) { .id-card.card-c.bs-l .id-banner { -webkit-mask-image: var(--id-feather); mask-image: var(--id-feather); } }
1375
+ @container idbanner (min-width: 1200px) { .id-card:not(.card-s):not(.card-l):not(.card-c) .id-banner { -webkit-mask-image: var(--id-feather); mask-image: var(--id-feather); } }
1376
+ @container idbanner (min-width: 1800px) { .id-card.card-l .id-banner { -webkit-mask-image: var(--id-feather); mask-image: var(--id-feather); } }
1344
1377
  .id-row { display: flex; align-items: flex-start; gap: var(--s-3); padding: var(--s-3) var(--s-4); }
1345
1378
  .id-portrait { display: block; flex-shrink: 0; height: var(--id-h); width: auto; max-width: min(calc(var(--id-h) * 0.75), 100%); object-fit: cover; border-radius: var(--r-sm); }
1346
1379
  .id-text { flex: 1; min-width: 0; }
@@ -13,6 +13,8 @@ register("card", {
13
13
  byAgent: "set by the agent",
14
14
  show: "Show card",
15
15
  hide: "Hide card",
16
+ bannerSafe: "Every screen",
17
+ bannerPhone: "Phone · M card",
16
18
  },
17
19
  "zh-TW": {
18
20
  label: "{0} 的身分卡",
@@ -24,5 +26,7 @@ register("card", {
24
26
  byAgent: "由 agent 設定",
25
27
  show: "顯示卡片",
26
28
  hide: "隱藏卡片",
29
+ bannerSafe: "所有螢幕",
30
+ bannerPhone: "手機 · 中卡片",
27
31
  },
28
32
  });
@@ -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 }; }
@@ -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).
@@ -149,8 +175,12 @@ export function IdentityCard({ name, card, compact = false, sizeControl = !compa
149
175
  const whose = (f) => t(f.by === "human" ? "card.byHuman" : "card.byAgent");
150
176
  const pic = (slot, cls, alt) => card[slot] && broken[slot] !== card[slot].src
151
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;
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", "");
152
182
  return html`<section class=${`id-card card-${size}${bannerSize}`} aria-label=${t("card.label", name)}>
153
- ${pic("banner", "id-banner", "")}
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}
154
184
  <div class="id-row">
155
185
  ${pic("portrait", "id-portrait", t("card.portraitAlt", name))}
156
186
  <div class="id-text">
@@ -162,6 +192,44 @@ export function IdentityCard({ name, card, compact = false, sizeControl = !compa
162
192
  </section>`;
163
193
  }
164
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
+
165
233
  // ── A person's picture for the card (View's Edit profile), fitted before it is sent (r4.1 Q3 (b)) ──
166
234
  /**
167
235
  * `file` fitted to `limit` ({ max_width, max_height, max_bytes }): as it is when it already fits; otherwise, once the
@@ -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
 
@@ -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>
@@ -170,6 +170,9 @@ register("view", {
170
170
  "cardBio": "Bio (up to 280 characters, 3 line breaks)",
171
171
  "cardPortrait": "Portrait",
172
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",
173
176
  "cardRemove": "Remove yours",
174
177
  "cardKeep": "Keep yours",
175
178
  "clearCard": "Clear card",
@@ -375,6 +378,9 @@ register("view", {
375
378
  "cardBio": "簡介(最多 280 個字、3 次換行)",
376
379
  "cardPortrait": "肖像",
377
380
  "cardBanner": "橫幅",
381
+ "cardBannerFocus": "橫幅焦點",
382
+ "cardBannerFocusNote": "橫幅被裁切時保留在畫面中的部分:虛線框在所有螢幕與卡片大小都看得到;黃色框是手機上中卡片的範圍。",
383
+ "cardFocusFollow": "跟隨 agent 的設定",
378
384
  "cardRemove": "移除你的",
379
385
  "cardKeep": "保留你的",
380
386
  "clearCard": "清除卡片",
@@ -105,6 +105,17 @@ export declare function setPersonIdentityColour(dataDir: string, name: string, i
105
105
  error: string;
106
106
  detail?: string;
107
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
+ };
108
119
  /** Every person-picked colour, for a status read (never creates profiles.db or its table). */
109
120
  export declare function identityColors(dataDir: string): ReturnType<typeof IdentityColorStore.all>;
110
121
  /** The card picture drawn for `slot`: the person's, else the agent's (the same rule as the card's JSON). */
package/dist/view-api.js CHANGED
@@ -41,7 +41,7 @@ import Database from "better-sqlite3";
41
41
  import { getTmuxSession } from "./config.js";
42
42
  import { getTmuxSocketName } from "./paths.js";
43
43
  import { evaluateWebRequest } from "./web-auth.js";
44
- import { HumanCardStore, resolveCard } from "./web-card.js";
44
+ import { bannerFocusOk, HumanCardStore, resolveCard } from "./web-card.js";
45
45
  import { IdentityColorStore } from "./web-identity-color.js";
46
46
  import { effectiveAppearanceLimits } from "./web-appearance-limits.js";
47
47
  const execFileP = promisify(execFile);
@@ -114,6 +114,14 @@ export function setPersonIdentityColour(dataDir, name, identity) {
114
114
  }
115
115
  return { ok: true };
116
116
  }
117
+ /** #2086: the banner focus a person set for one instance (null: none; the agent's, else the centre, is drawn). */
118
+ export function personBannerFocus(dataDir, name) {
119
+ return bannerFocusOk(cardStore({ dataDir }).get(name).banner_focus);
120
+ }
121
+ /** #2086: a person's banner focus — exactly POST /api/profile's card_banner_focus (the store's check; its own write). */
122
+ export function setPersonBannerFocus(dataDir, name, focus) {
123
+ return cardStore({ dataDir }).setBannerFocus(name, focus);
124
+ }
117
125
  /** Every person-picked colour, for a status read (never creates profiles.db or its table). */
118
126
  export function identityColors(dataDir) {
119
127
  return IdentityColorStore.all(dataDir, () => profileDb(dataDir));
@@ -474,6 +482,12 @@ export function handleViewRequest(req, res, url, ctx) {
474
482
  cardText.tagline = body.card_tagline;
475
483
  if (Object.hasOwn(body, "card_bio"))
476
484
  cardText.bio = body.card_bio;
485
+ // #2086: the banner's focus (0 top … 100 bottom; null follows the agent's, else the centre) — checked first too.
486
+ const focus = Object.hasOwn(body, "card_banner_focus") ? body.card_banner_focus : undefined;
487
+ if (focus !== undefined && focus !== null && bannerFocusOk(focus) === null) {
488
+ json(res, 400, { error: "card_banner_focus", detail: "a whole number from 0 (top) to 100 (bottom), or null" });
489
+ return;
490
+ }
477
491
  // #1958: the identity colour (a preset or #rrggbb; null or "" resets it) and where it shows (color_show: a list
478
492
  // of name / bar / ring, [] for nowhere; null resets it) — checked before anything is written.
479
493
  const identity = {};
@@ -488,7 +502,7 @@ export function handleViewRequest(req, res, url, ctx) {
488
502
  }
489
503
  const texts = cardText.tagline !== undefined || cardText.bio !== undefined;
490
504
  const ids = identity.color !== undefined || identity.show !== undefined;
491
- if (!texts && !ids && !set.length) {
505
+ if (!texts && !ids && focus === undefined && !set.length) {
492
506
  json(res, 200, { ok: true });
493
507
  return;
494
508
  }
@@ -497,7 +511,7 @@ export function handleViewRequest(req, res, url, ctx) {
497
511
  const db = profileDb(ctx.dataDir);
498
512
  // Prism #2003 P2-2: the stores' tables are made before the transaction, so a rollback never leaves this cached
499
513
  // handle believing in tables it does not have.
500
- if (texts)
514
+ if (texts || focus !== undefined)
501
515
  cardStore(ctx).get(name);
502
516
  if (ids)
503
517
  colorStore(ctx).get(name);
@@ -512,6 +526,13 @@ export function handleViewRequest(req, res, url, ctx) {
512
526
  throw rollback;
513
527
  }
514
528
  }
529
+ if (focus !== undefined) {
530
+ const fw = cardStore(ctx).setBannerFocus(name, focus);
531
+ if (!fw.ok) {
532
+ refused = fw;
533
+ throw rollback;
534
+ }
535
+ }
515
536
  if (ids) {
516
537
  const w = colorStore(ctx).set(name, identity);
517
538
  if (!w.ok) {
@@ -569,7 +590,9 @@ export function handleViewRequest(req, res, url, ctx) {
569
590
  // The limits in force ride along, so View's editor can fit a picture before it is sent (r4.1 Q3 (b)).
570
591
  const limits = effectiveAppearanceLimits(ctx.fleetConfig?.web?.appearance).limits;
571
592
  const slot = (l) => ({ max_width: l.maxWidth, max_height: l.maxHeight, max_bytes: l.maxBytes });
572
- json(res, 200, { ...resolveCard(name, store.get(name), ctx.themeStore?.get(name).theme.card), limits: { portrait: slot(limits.portrait), banner: slot(limits.banner) } });
593
+ // #2089 review: the agent's own banner focus too (not only the one drawn), so Edit profile's "Follow the agent's" shows it.
594
+ const agentCard = ctx.themeStore?.get(name).theme.card, agentFocus = bannerFocusOk(agentCard?.bannerFocus);
595
+ json(res, 200, { ...resolveCard(name, store.get(name), agentCard), ...(agentFocus !== null ? { banner_focus_agent: agentFocus } : {}), limits: { portrait: slot(limits.portrait), banner: slot(limits.banner) } });
573
596
  return true;
574
597
  }
575
598
  if (method === "GET" && slot) {