@songsid/agend 2.3.0-beta.3 → 2.3.0-beta.5

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 (149) hide show
  1. package/dist/agent-file-read.d.ts +4 -1
  2. package/dist/agent-file-read.js +129 -16
  3. package/dist/agent-file-read.js.map +1 -1
  4. package/dist/auth-api.d.ts +30 -1
  5. package/dist/auth-api.js +32 -7
  6. package/dist/auth-api.js.map +1 -1
  7. package/dist/authority/web-authority.d.ts +100 -0
  8. package/dist/authority/web-authority.js +130 -0
  9. package/dist/authority/web-authority.js.map +1 -0
  10. package/dist/backend/types.d.ts +1 -0
  11. package/dist/backend/types.js +15 -0
  12. package/dist/backend/types.js.map +1 -1
  13. package/dist/backend-availability.d.ts +120 -0
  14. package/dist/backend-availability.js +396 -0
  15. package/dist/backend-availability.js.map +1 -0
  16. package/dist/backend-command.d.ts +21 -3
  17. package/dist/backend-command.js +23 -4
  18. package/dist/backend-command.js.map +1 -1
  19. package/dist/channel/adapters/discord.d.ts +7 -0
  20. package/dist/channel/adapters/discord.js +35 -0
  21. package/dist/channel/adapters/discord.js.map +1 -1
  22. package/dist/channel/mcp-tools.js +5 -5
  23. package/dist/channel/mcp-tools.js.map +1 -1
  24. package/dist/channel/types.d.ts +6 -0
  25. package/dist/config-validator.js +91 -2
  26. package/dist/config-validator.js.map +1 -1
  27. package/dist/daemon.js +5 -5
  28. package/dist/daemon.js.map +1 -1
  29. package/dist/delivery-outbox.d.ts +2 -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 +230 -6
  33. package/dist/fleet-manager.js +719 -42
  34. package/dist/fleet-manager.js.map +1 -1
  35. package/dist/general-knowledge/skills/web-appearance/SKILL.md +45 -18
  36. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +7 -3
  37. package/dist/human-delivery-receipts.d.ts +10 -1
  38. package/dist/human-delivery-receipts.js +26 -9
  39. package/dist/human-delivery-receipts.js.map +1 -1
  40. package/dist/locale.js +42 -4
  41. package/dist/locale.js.map +1 -1
  42. package/dist/outbound-schemas.d.ts +7 -3
  43. package/dist/outbound-schemas.js +5 -1
  44. package/dist/outbound-schemas.js.map +1 -1
  45. package/dist/public-web-link.d.ts +9 -0
  46. package/dist/public-web-link.js +8 -0
  47. package/dist/public-web-link.js.map +1 -1
  48. package/dist/quickstart-api.d.ts +10 -1
  49. package/dist/quickstart-api.js +12 -4
  50. package/dist/quickstart-api.js.map +1 -1
  51. package/dist/reply-buttons-controller.d.ts +4 -0
  52. package/dist/reply-buttons-controller.js +2 -2
  53. package/dist/reply-buttons-controller.js.map +1 -1
  54. package/dist/settings-api.d.ts +9 -0
  55. package/dist/settings-api.js +76 -2
  56. package/dist/settings-api.js.map +1 -1
  57. package/dist/settings-change.js +66 -0
  58. package/dist/settings-change.js.map +1 -1
  59. package/dist/settings-effect.js +8 -1
  60. package/dist/settings-effect.js.map +1 -1
  61. package/dist/topic-commands.d.ts +5 -0
  62. package/dist/topic-commands.js +16 -0
  63. package/dist/topic-commands.js.map +1 -1
  64. package/dist/types.d.ts +36 -0
  65. package/dist/ui/chat-strings.js +4 -2
  66. package/dist/ui/chat-thread.js +4 -1
  67. package/dist/ui/panel-chat.js +19 -9
  68. package/dist/ui/panel-fleet.js +18 -6
  69. package/dist/ui/panel-settings.js +5 -2
  70. package/dist/ui/settings-catalog.js +92 -0
  71. package/dist/ui/settings-dialogs.js +37 -6
  72. package/dist/ui/settings-guests.js +117 -0
  73. package/dist/ui/settings-strings.js +28 -0
  74. package/dist/ui/shared/app-i18n.js +2 -0
  75. package/dist/ui/shared/app-prefs.js +3 -3
  76. package/dist/ui/shared/app-shell.js +36 -6
  77. package/dist/ui/shared/app-store.js +14 -0
  78. package/dist/ui/shared/app.css +54 -3
  79. package/dist/ui/shared/app.js +21 -14
  80. package/dist/ui/shared/card-strings.js +4 -0
  81. package/dist/ui/shared/effect-stock-trust.js +23 -2
  82. package/dist/ui/shared/identity-card.js +70 -2
  83. package/dist/ui/shared/instance-nav.js +8 -5
  84. package/dist/ui/shared/panel-view.js +22 -2
  85. package/dist/ui/shared/view-strings.js +6 -0
  86. package/dist/view-api.d.ts +11 -0
  87. package/dist/view-api.js +27 -4
  88. package/dist/view-api.js.map +1 -1
  89. package/dist/web-api.d.ts +32 -1
  90. package/dist/web-api.js +166 -28
  91. package/dist/web-api.js.map +1 -1
  92. package/dist/web-appearance-policy.d.ts +4 -6
  93. package/dist/web-appearance-policy.js +5 -12
  94. package/dist/web-appearance-policy.js.map +1 -1
  95. package/dist/web-appearance-tools.js +5 -1
  96. package/dist/web-appearance-tools.js.map +1 -1
  97. package/dist/web-appearance-view.d.ts +4 -1
  98. package/dist/web-appearance-view.js +21 -2
  99. package/dist/web-appearance-view.js.map +1 -1
  100. package/dist/web-appearance.d.ts +2 -2
  101. package/dist/web-appearance.js +5 -6
  102. package/dist/web-appearance.js.map +1 -1
  103. package/dist/web-card.d.ts +14 -0
  104. package/dist/web-card.js +40 -6
  105. package/dist/web-card.js.map +1 -1
  106. package/dist/web-channel-echo.d.ts +7 -1
  107. package/dist/web-channel-echo.js +6 -2
  108. package/dist/web-channel-echo.js.map +1 -1
  109. package/dist/web-chat-history.d.ts +40 -0
  110. package/dist/web-chat-history.js +66 -3
  111. package/dist/web-chat-history.js.map +1 -1
  112. package/dist/web-commands.d.ts +1 -0
  113. package/dist/web-effect-tools.js +2 -3
  114. package/dist/web-effect-tools.js.map +1 -1
  115. package/dist/web-effect-upload.js +1 -1
  116. package/dist/web-effect-upload.js.map +1 -1
  117. package/dist/web-guest-access.d.ts +65 -7
  118. package/dist/web-guest-access.js +124 -18
  119. package/dist/web-guest-access.js.map +1 -1
  120. package/dist/web-guest-rooms.d.ts +82 -0
  121. package/dist/web-guest-rooms.js +135 -0
  122. package/dist/web-guest-rooms.js.map +1 -0
  123. package/dist/web-identity-color.d.ts +1 -1
  124. package/dist/web-identity-color.js +2 -2
  125. package/dist/web-identity-color.js.map +1 -1
  126. package/dist/web-login.d.ts +47 -0
  127. package/dist/web-login.js +115 -0
  128. package/dist/web-login.js.map +1 -1
  129. package/dist/web-mod-agent.d.ts +1 -1
  130. package/dist/web-mod-agent.js +2 -4
  131. package/dist/web-mod-agent.js.map +1 -1
  132. package/dist/web-prefs.js +1 -1
  133. package/dist/web-prefs.js.map +1 -1
  134. package/dist/web-returning-device.d.ts +41 -0
  135. package/dist/web-returning-device.js +57 -5
  136. package/dist/web-returning-device.js.map +1 -1
  137. package/dist/web-session.d.ts +50 -3
  138. package/dist/web-session.js +96 -14
  139. package/dist/web-session.js.map +1 -1
  140. package/dist/web-settings-patch.d.ts +25 -2
  141. package/dist/web-settings-patch.js +96 -1
  142. package/dist/web-settings-patch.js.map +1 -1
  143. package/dist/web-settings-tools.d.ts +12 -0
  144. package/dist/web-settings-tools.js +10 -0
  145. package/dist/web-settings-tools.js.map +1 -1
  146. package/dist/web-theme.d.ts +2 -0
  147. package/dist/web-theme.js +10 -1
  148. package/dist/web-theme.js.map +1 -1
  149. package/package.json +1 -1
@@ -26,6 +26,20 @@ export function createStore(initial) {
26
26
  export const appStore = createStore({ ready: false, instances: [], uptime: 0, exec: {}, awaiting: {}, connection: "connecting", needs: [], connections: null,
27
27
  hydration: "none", retryHydration: null });
28
28
 
29
+ /**
30
+ * #1920 phase 3: this page is a guest's — the server says so on <body> (data-tier="guest"). A guest sees only its own
31
+ * agents' chats: no fleet, Needs you, View, Settings, filters, commands or new agent, and nothing it may not read is asked
32
+ * for (each would be refused). The server checks every request regardless; this only keeps the page to what works.
33
+ */
34
+ export function guestPage() {
35
+ return typeof document !== "undefined" && !!document.body && document.body.dataset.tier === "guest";
36
+ }
37
+ /** Who a guest's page is signed in as: its web name and its platform (the server's, on <body>). */
38
+ export function guestBadge() {
39
+ const d = typeof document !== "undefined" && document.body ? document.body.dataset : {};
40
+ return { name: d.guestName || "", platform: d.guestPlatform || "" };
41
+ }
42
+
29
43
  /** Fold one status frame (stream or poll) into the store. */
30
44
  export function applyStatus(d) {
31
45
  if (!d || !Array.isArray(d.instances)) return;
@@ -181,6 +181,13 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
181
181
  .side-foot > .session { position: static; }
182
182
  .side-foot .side-row span:not(.dot):not(.meta) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
183
183
  .side-foot .session .meta { display: none; }
184
+ /* #1920 phase 3: a guest's page says who it is signed in as, beside its session. */
185
+ .guest-chip { flex: 1; min-width: 0; display: flex; align-items: center; gap: var(--s-1); margin: 0; padding: 6px var(--s-2); border-radius: var(--r-sm);
186
+ background: var(--surface-2); color: var(--text-2); font-size: var(--fs-sm); }
187
+ .guest-chip .grow { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
188
+ /* Its own row: a name and a platform do not fit beside the session at sidebar widths. */
189
+ .guest .side-foot { flex-wrap: wrap; }
190
+ .guest .side-foot > .guest-chip { flex-basis: 100%; }
184
191
  .prefs-pop > summary { list-style: none; cursor: pointer; }
185
192
  .prefs-pop > summary::-webkit-details-marker { display: none; }
186
193
  .prefs.labelled { flex-direction: column; gap: var(--s-2); padding: 0; }
@@ -366,6 +373,9 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
366
373
  .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
374
  .field input:disabled { opacity: 0.6; }
368
375
  .field-row { display: flex; gap: var(--s-1); align-items: center; }
376
+ /* #2072: a model field — the list, and the text box "Other…" opens under it. */
377
+ .model-pick { display: flex; flex-direction: column; gap: var(--s-1); min-width: 0; }
378
+ .model-pick > select, .model-pick > input { width: 100%; }
369
379
  .note { font-size: var(--fs-xs); color: var(--text-3); }
370
380
  .checks { display: flex; flex-direction: column; gap: var(--s-1); max-height: 180px; overflow-y: auto; }
371
381
  .check { display: flex; gap: var(--s-2); align-items: center; font-size: var(--fs-sm); }
@@ -614,8 +624,12 @@ html.lb-open .scroller { overflow: hidden; }
614
624
  .pv-menu[open] { z-index: 6; background: var(--surface); border-radius: var(--r-sm); padding: var(--s-1); box-shadow: var(--shadow-2); }
615
625
  .pv-menu[open] > .chip-btn { display: block; text-align: left; width: max-content; max-width: 260px; white-space: normal; }
616
626
 
627
+ /* #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
628
+ hiding it 48 px from the bottom took that room away under a reader still scrolling — the wheel's last step was cut
629
+ short, or the view clamped back. Its dock is sticky and 0 px tall; the pill floats above it. */
630
+ .jump-dock { position: sticky; bottom: 0; height: 0; z-index: 5; }
617
631
  .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);
632
+ 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
633
  background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: var(--r-full); box-shadow: var(--shadow-2);
620
634
  padding: 6px var(--s-4); font-size: var(--fs-xs); font-weight: var(--fw-semibold); cursor: pointer;
621
635
  }
@@ -654,6 +668,17 @@ html.lb-open .scroller { overflow: hidden; }
654
668
  @media (max-width: 479px) { .btn-stop .stop-label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } }
655
669
  .btn-stop { background: var(--bg); }
656
670
  .hint { font-size: var(--fs-xs); color: var(--text-3); text-align: center; }
671
+ /* #1920 phase 3: Settings → Web → Guests. */
672
+ .s-guests .guest-names { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 2px var(--s-3); max-height: 220px; overflow: auto;
673
+ padding: var(--s-1) var(--s-2); border: 1px solid var(--border); border-radius: var(--r-sm); }
674
+ .s-guests .guest-name span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
675
+ .s-guests .guest-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s-2); }
676
+ .s-guests .guest-row { padding: var(--s-2) var(--s-3); border: 1px solid var(--border); border-radius: var(--r-sm); }
677
+ .s-guests .guest-actions { display: flex; flex-wrap: wrap; gap: var(--s-1); margin-top: var(--s-1); }
678
+ .s-guests .guest-narrow-edit { margin-top: var(--s-2); }
679
+ .s-guests .guest-away { color: var(--text-3); }
680
+ /* #1920: a guest's note under the composer — on a phone too, where the key hints are hidden. */
681
+ .guest-note { margin: 2px 0 0; font-size: var(--fs-xs); color: var(--text-3); text-align: center; }
657
682
  .pending-files { display: flex; flex-wrap: wrap; gap: var(--s-2); padding: var(--s-2) var(--s-2) 0; }
658
683
  .file-chip { display: flex; align-items: center; gap: 6px; font-size: var(--fs-xs); padding: 4px var(--s-2); border-radius: var(--r-md); background: var(--bg); max-width: 320px; }
659
684
  .file-chip img { width: 28px; height: 28px; object-fit: cover; border-radius: 4px; }
@@ -1334,13 +1359,39 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1334
1359
  #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
1360
  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
1361
  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; }
1362
+ .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
1363
  .id-card.card-s { --id-h: 160px; --id-ar: 4 / 1; --id-bmin: 100px; --id-bmax: 160px; }
1339
1364
  .id-card.card-l { --id-h: 360px; --id-ar: 9 / 4; --id-bmin: 200px; --id-bmax: 360px; }
1340
1365
  .id-card.card-c { --id-h: 96px; --id-bmin: 120px; --id-bmax: 200px; margin: 0; border: 0; border-radius: 0; background: none; }
1341
1366
  .id-card.card-c.bs-s { --id-ar: 4 / 1; --id-bmin: 96px; --id-bmax: 150px; }
1342
1367
  .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%; }
1368
+ /* #2086: the banner at its focus (--id-focus, set per card), never wider than 5 : 1 — its width is capped at five times its
1369
+ size's tallest height, so the band shown everywhere is (picture aspect ÷ 5) of its height. Beyond that it is centred
1370
+ over a blurred copy of itself. */
1371
+ .id-banner-box { position: relative; overflow: hidden; container: idbanner / inline-size; }
1372
+ /* #2089 review: and never narrower than 9 : 4 — its height never more than its box's width ÷ 2.25 (a size's floor yields
1373
+ to it on a narrow column: a wide sidebar, the usage rail), so the promised safe area holds on every layout. */
1374
+ .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);
1375
+ 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%); }
1376
+ /* #2086: Edit profile's banner focus — the picture whole, the band every screen shows (dashed, outside it shaded) and a phone's. */
1377
+ .bf-wrap { position: relative; max-width: 440px; overflow: hidden; border-radius: var(--r-sm); }
1378
+ .bf-pic { display: block; width: 100%; height: auto; }
1379
+ .bf-band { position: absolute; pointer-events: none; box-sizing: border-box; }
1380
+ .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; }
1381
+ .bf-safe { border: 2px dashed #fff; box-shadow: 0 0 0 999px rgba(0, 0, 0, .45); }
1382
+ /* The phone's band over the shade, in its own colour (its label at its own top-left, never in the shaded part). */
1383
+ .bf-phone { z-index: 1; border: 2px solid #ffd54a; }
1384
+ .bf-phone span { right: auto; left: 8px; top: 5px; color: #ffd54a; }
1385
+ .bf-field input[type=range] { width: 100%; max-width: 440px; }
1386
+ .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; }
1387
+ /* Wider than the banner's cap (5 × the size's tallest height): its sides feather into the fill, so the sharp centre
1388
+ leads without a seam. One threshold per size (a container query cannot read a custom property). */
1389
+ @container idbanner (min-width: 750px) { .id-card.card-c.bs-s .id-banner { -webkit-mask-image: var(--id-feather); mask-image: var(--id-feather); } }
1390
+ @container idbanner (min-width: 800px) { .id-card.card-s .id-banner { -webkit-mask-image: var(--id-feather); mask-image: var(--id-feather); } }
1391
+ @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); } }
1392
+ @container idbanner (min-width: 1100px) { .id-card.card-c.bs-l .id-banner { -webkit-mask-image: var(--id-feather); mask-image: var(--id-feather); } }
1393
+ @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); } }
1394
+ @container idbanner (min-width: 1800px) { .id-card.card-l .id-banner { -webkit-mask-image: var(--id-feather); mask-image: var(--id-feather); } }
1344
1395
  .id-row { display: flex; align-items: flex-start; gap: var(--s-3); padding: var(--s-3) var(--s-4); }
1345
1396
  .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
1397
  .id-text { flex: 1; min-width: 0; }
@@ -12,7 +12,7 @@ import { Shell, handleKey, showDialog, closeDrawer, openDrawer, shellStore, retr
12
12
  import { installSwipe } from "./app-swipe.js";
13
13
  import { startRouter, navStore, navigate } from "./app-nav.js";
14
14
  import { createStream } from "./app-stream.js";
15
- import { appStore, applyStatus, applyActivity } from "./app-store.js";
15
+ import { appStore, applyStatus, applyActivity, guestPage } from "./app-store.js";
16
16
  import { chatPath } from "./app-route.js";
17
17
  import { startNeedsNotifier } from "./app-needs.js";
18
18
  import { toast } from "./ui-toast.js";
@@ -29,6 +29,9 @@ initUsageRail();
29
29
  // session-only panel, nothing under /ui/ is ever imported or read (#1408 §3).
30
30
  const mode = boot.mode === "view-only" ? "view-only" : "full";
31
31
  appStore.set({ viewOnly: mode === "view-only" });
32
+ // #1920 phase 3: a guest's page has its chats and nothing else — no panel, notifier, theme list or owner CSS a guest
33
+ // would be refused.
34
+ const guest = guestPage();
32
35
 
33
36
  // A failed load can be retried (the panel's Retry): each retry asks for a fresh URL, since a browser may keep a failed
34
37
  // module import cached under the first one. boot() runs once, whichever attempt succeeds.
@@ -80,19 +83,22 @@ if (mode === "full") {
80
83
  // a boot import that failed before the Outlet first asked would be retried at once, depending on timing.)
81
84
  let chatLoads = 0;
82
85
  panels.set("chat", { load: () => (chatLoads++ === 0 ? chatBoot : loadChat()).then((m) => m.ChatPanel) });
83
- panels.set("fleet", { load: () => loadFleet().then((m) => m.FleetPanel) });
84
- panels.set("settings", { load: () => loadSettings().then((m) => m.SettingsPanel) });
85
- panels.set("needs", { load: () => loadNeeds().then((m) => m.NeedsPanel) });
86
- // #1523 N2: one instance's Details (/ui/fleet/agent/<name>).
87
- panels.set("details", { load: () => loadDetails().then((m) => m.DetailsPanel) });
88
- // #1218: one instance's Log (/ui/log/<name>).
89
- panels.set("log", { load: () => loadLog().then((m) => m.LogPanel) });
90
- // #1386 §6.3: desktop notifications for new "Needs you" items, in any panel, while this signed-in page is open.
91
- startNeedsNotifier({ open: (instance) => navigate(chatPath(instance)) });
86
+ if (!guest) {
87
+ panels.set("fleet", { load: () => loadFleet().then((m) => m.FleetPanel) });
88
+ panels.set("settings", { load: () => loadSettings().then((m) => m.SettingsPanel) });
89
+ panels.set("needs", { load: () => loadNeeds().then((m) => m.NeedsPanel) });
90
+ // #1523 N2: one instance's Details (/ui/fleet/agent/<name>).
91
+ panels.set("details", { load: () => loadDetails().then((m) => m.DetailsPanel) });
92
+ // #1218: one instance's Log (/ui/log/<name>).
93
+ panels.set("log", { load: () => loadLog().then((m) => m.LogPanel) });
94
+ // #1386 §6.3: desktop notifications for new "Needs you" items, in any panel, while this signed-in page is open.
95
+ startNeedsNotifier({ open: (instance) => navigate(chatPath(instance)) });
96
+ }
92
97
  // #1665: this device's first upload of its own choices (once), then another device's changes at each status frame.
98
+ // A guest's page only reads them (prefsWritable is false there).
93
99
  bootPrefs();
94
- // #1629: agents' themes, read when the status frame's themes_rev moves.
95
- startThemes();
100
+ // #1629: agents' themes, read when the status frame's themes_rev moves (a guest's frames never carry it).
101
+ if (!guest) startThemes();
96
102
  onNewInstance = async () => {
97
103
  closeDrawer(); // on a phone the dialog opens from the drawer: the drawer goes first
98
104
  let m;
@@ -103,8 +109,9 @@ if (mode === "full") {
103
109
  } else appStore.set({ connection: "none" });
104
110
 
105
111
  startRouter(window);
106
- // #1629 r4 §3: the person's owner CSS (full app only; never on Settings, in safe mode or where this device turned it off).
107
- installOwnerCss();
112
+ // #1629 r4 §3: the person's owner CSS (full app only; never on Settings, in safe mode or where this device turned it off;
113
+ // never a guest's — owner CSS is an admin's).
114
+ if (!guest) installOwnerCss();
108
115
  // /ui on its own opens the chat this browser had open last (an unknown name shows the panel's "not found").
109
116
  {
110
117
  const r = navStore.get().route;
@@ -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
@@ -9,7 +9,7 @@
9
9
  // Public (/assets/): an anonymous View reader gets the same list, fed by View's roster read instead of status frames.
10
10
  import { html, useLayoutEffect, useRef, useState } from "./app-html.js";
11
11
  import { t } from "./app-i18n.js";
12
- import { appStore, createStore, useStore } from "./app-store.js";
12
+ import { appStore, createStore, guestPage, useStore } from "./app-store.js";
13
13
  import { chatPath, detailsPath, logPath, viewPath } from "./app-route.js";
14
14
  import { Icon } from "./ui-icons.js";
15
15
  import { Skeleton } from "./ui-states.js";
@@ -391,10 +391,13 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
391
391
  const favs = nav.favorites.filter((n) => known.has(n));
392
392
  const favSet = new Set(favs);
393
393
  const canStar = !viewOnly && prefsWritable();
394
+ // #1920: a guest's list is to pick from — no reordering and no filters (one this device kept would hide rows with no
395
+ // way to clear it).
396
+ const guest = guestPage();
394
397
  const shownGroups = favs.length ? [FAV_GROUP, ...groupNames] : groupNames;
395
398
  const shownInsts = new Map([...instByGroup].map(([g, names]) => [g, names.filter((n) => !favSet.has(n))]));
396
399
  if (favs.length) shownInsts.set(FAV_GROUP, favs);
397
- const f = nav.filter;
400
+ const f = guest ? { q: "", status: [], cli: [] } : nav.filter;
398
401
  const shown = filterSidebar(shownGroups, shownInsts, byName, f.q, backendLabel, { status: f.status, cli: f.cli, statusOf: facetOf });
399
402
  // #1936: while a filter is on, the instance you have open stays on top, pinned and marked "Current", whether or not
400
403
  // it matches; a match is not repeated below. "N / M" still counts the filter's matches (the pinned row counts only
@@ -408,7 +411,7 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
408
411
  const flip = (key, value) => { const cur = new Set(navStore.get().filter[key]); if (cur.has(value)) cur.delete(value); else cur.add(value); setNavFilter({ [key]: [...cur] }); };
409
412
  // Drag to reorder: only the full list (a filtered subset would be ambiguous), groups among groups, instances within
410
413
  // their group.
411
- const dragProps = (item) => shown.active ? {} : {
414
+ const dragProps = (item) => shown.active || guest ? {} : {
412
415
  draggable: true,
413
416
  onDragStart: (e) => { drag.current = item; e.currentTarget.classList.add("dragging"); try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", item.name); } catch { /* Firefox needs data */ } },
414
417
  onDragEnd: (e) => { e.currentTarget.classList.remove("dragging"); drag.current = null; },
@@ -504,7 +507,7 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
504
507
  })}
505
508
  ${loaded && shown.active && !shown.groups.length ? html`<p class="side-empty">${tn("filterNone")}</p>` : null}
506
509
  </div>
507
- <div class="v-filter">
510
+ ${guest ? null : html`<div class="v-filter">
508
511
  <label class="v-filter-box"><${Icon} name="search" size=${14} /><span class="sr-only">${tn("filterPh")}</span>
509
512
  <input ref=${filterRef} id="filterInput" type="text" autocomplete="off" spellcheck=${false} placeholder=${tn("filterPh")} value=${f.q}
510
513
  onInput=${(e) => setNavFilter({ q: e.target.value })}
@@ -520,7 +523,7 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
520
523
  </div>
521
524
  <p class="note nav-shown" aria-live="polite">${shown.shown} / ${shown.total}${shown.active ? ` ${tn("filterShown")}` : ""}</p>
522
525
  </div>
523
- </div>
526
+ </div>`}
524
527
  </div>`;
525
528
  }
526
529
 
@@ -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) {