@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.
- package/dist/auth-api.d.ts +30 -1
- package/dist/auth-api.js +32 -7
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/types.d.ts +1 -0
- package/dist/backend/types.js +15 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/backend-availability.d.ts +120 -0
- package/dist/backend-availability.js +396 -0
- package/dist/backend-availability.js.map +1 -0
- package/dist/backend-command.d.ts +21 -3
- package/dist/backend-command.js +23 -4
- package/dist/backend-command.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +7 -0
- package/dist/channel/adapters/discord.js +35 -0
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +4 -4
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/types.d.ts +6 -0
- package/dist/config-validator.js +73 -2
- package/dist/config-validator.js.map +1 -1
- package/dist/fleet-context.d.ts +10 -0
- package/dist/fleet-manager.d.ts +174 -6
- package/dist/fleet-manager.js +642 -41
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +17 -7
- package/dist/human-delivery-receipts.js +2 -1
- package/dist/human-delivery-receipts.js.map +1 -1
- package/dist/locale.js +42 -4
- package/dist/locale.js.map +1 -1
- package/dist/outbound-schemas.d.ts +4 -0
- package/dist/outbound-schemas.js +5 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-link.d.ts +9 -0
- package/dist/public-web-link.js +8 -0
- package/dist/public-web-link.js.map +1 -1
- package/dist/quickstart-api.d.ts +10 -1
- package/dist/quickstart-api.js +12 -4
- package/dist/quickstart-api.js.map +1 -1
- package/dist/reply-buttons-controller.d.ts +2 -0
- package/dist/reply-buttons-controller.js +1 -1
- package/dist/reply-buttons-controller.js.map +1 -1
- package/dist/topic-commands.d.ts +5 -0
- package/dist/topic-commands.js +16 -0
- package/dist/topic-commands.js.map +1 -1
- package/dist/types.d.ts +24 -0
- package/dist/ui/chat-thread.js +4 -1
- package/dist/ui/panel-chat.js +4 -3
- package/dist/ui/panel-fleet.js +15 -6
- package/dist/ui/panel-settings.js +3 -2
- package/dist/ui/settings-catalog.js +89 -0
- package/dist/ui/settings-dialogs.js +37 -6
- package/dist/ui/settings-strings.js +6 -0
- package/dist/ui/shared/app.css +36 -3
- package/dist/ui/shared/card-strings.js +4 -0
- package/dist/ui/shared/effect-stock-trust.js +23 -2
- package/dist/ui/shared/identity-card.js +70 -2
- package/dist/ui/shared/panel-view.js +22 -2
- package/dist/ui/shared/view-strings.js +6 -0
- package/dist/view-api.d.ts +11 -0
- package/dist/view-api.js +27 -4
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +16 -0
- package/dist/web-api.js +133 -22
- package/dist/web-api.js.map +1 -1
- package/dist/web-appearance-policy.d.ts +4 -6
- package/dist/web-appearance-policy.js +5 -12
- package/dist/web-appearance-policy.js.map +1 -1
- package/dist/web-appearance-tools.js +5 -1
- package/dist/web-appearance-tools.js.map +1 -1
- package/dist/web-appearance-view.d.ts +4 -1
- package/dist/web-appearance-view.js +21 -2
- package/dist/web-appearance-view.js.map +1 -1
- package/dist/web-appearance.d.ts +2 -2
- package/dist/web-appearance.js +5 -6
- package/dist/web-appearance.js.map +1 -1
- package/dist/web-card.d.ts +14 -0
- package/dist/web-card.js +40 -6
- package/dist/web-card.js.map +1 -1
- package/dist/web-channel-echo.d.ts +7 -1
- package/dist/web-channel-echo.js +6 -2
- package/dist/web-channel-echo.js.map +1 -1
- package/dist/web-chat-history.d.ts +40 -0
- package/dist/web-chat-history.js +66 -3
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-commands.d.ts +1 -0
- package/dist/web-effect-tools.js +2 -3
- package/dist/web-effect-tools.js.map +1 -1
- package/dist/web-guest-access.d.ts +65 -7
- package/dist/web-guest-access.js +124 -18
- package/dist/web-guest-access.js.map +1 -1
- package/dist/web-guest-rooms.d.ts +82 -0
- package/dist/web-guest-rooms.js +135 -0
- package/dist/web-guest-rooms.js.map +1 -0
- package/dist/web-login.d.ts +47 -0
- package/dist/web-login.js +115 -0
- package/dist/web-login.js.map +1 -1
- package/dist/web-mod-agent.d.ts +1 -1
- package/dist/web-mod-agent.js +2 -4
- package/dist/web-mod-agent.js.map +1 -1
- package/dist/web-returning-device.d.ts +39 -0
- package/dist/web-returning-device.js +46 -5
- package/dist/web-returning-device.js.map +1 -1
- package/dist/web-session.d.ts +43 -3
- package/dist/web-session.js +79 -14
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-tools.d.ts +12 -0
- package/dist/web-settings-tools.js +10 -0
- package/dist/web-settings-tools.js.map +1 -1
- package/dist/web-theme.d.ts +2 -0
- package/dist/web-theme.js +10 -1
- package/dist/web-theme.js.map +1 -1
- 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
|
-
|
|
37
|
-
|
|
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")}
|
|
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
|
-
|
|
268
|
-
value=${shown("model_failover", f.model_failover, (defaults.model_failover && defaults.model_failover[0]) || "")}
|
|
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")}
|
|
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",
|
package/dist/ui/shared/app.css
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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),
|
|
105
|
-
|
|
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).
|
|
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
|
-
${
|
|
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"
|
|
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": "清除卡片",
|
package/dist/view-api.d.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|