@songsid/agend 2.3.2-beta.3 → 2.3.2-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/agent-cli.js +21 -2
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/agent-endpoint.js +5 -0
  4. package/dist/agent-endpoint.js.map +1 -1
  5. package/dist/auth-api.js +2 -0
  6. package/dist/auth-api.js.map +1 -1
  7. package/dist/backend/kiro.d.ts +21 -1
  8. package/dist/backend/kiro.js +89 -1
  9. package/dist/backend/kiro.js.map +1 -1
  10. package/dist/channel/adapters/discord.js +7 -4
  11. package/dist/channel/adapters/discord.js.map +1 -1
  12. package/dist/channel/mcp-tools.js +4 -0
  13. package/dist/channel/mcp-tools.js.map +1 -1
  14. package/dist/classic-channel-manager.d.ts +5 -0
  15. package/dist/classic-channel-manager.js +14 -1
  16. package/dist/classic-channel-manager.js.map +1 -1
  17. package/dist/config-validator.js +22 -5
  18. package/dist/config-validator.js.map +1 -1
  19. package/dist/config.js +6 -1
  20. package/dist/config.js.map +1 -1
  21. package/dist/daemon.js +16 -4
  22. package/dist/daemon.js.map +1 -1
  23. package/dist/dashboard-permissions.d.ts +20 -6
  24. package/dist/dashboard-permissions.js +58 -9
  25. package/dist/dashboard-permissions.js.map +1 -1
  26. package/dist/fleet-manager.d.ts +16 -1
  27. package/dist/fleet-manager.js +183 -21
  28. package/dist/fleet-manager.js.map +1 -1
  29. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +24 -1
  30. package/dist/general-knowledge/skills/web-appearance/SKILL.md +24 -1
  31. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +6 -2
  32. package/dist/instance-config-impact.js +10 -5
  33. package/dist/instance-config-impact.js.map +1 -1
  34. package/dist/instance-lifecycle.d.ts +8 -0
  35. package/dist/instance-lifecycle.js +5 -3
  36. package/dist/instance-lifecycle.js.map +1 -1
  37. package/dist/locale.js +2 -6
  38. package/dist/locale.js.map +1 -1
  39. package/dist/notice-delivery.d.ts +15 -0
  40. package/dist/notice-delivery.js +37 -0
  41. package/dist/notice-delivery.js.map +1 -0
  42. package/dist/outbound-schemas.d.ts +20 -0
  43. package/dist/outbound-schemas.js +17 -0
  44. package/dist/outbound-schemas.js.map +1 -1
  45. package/dist/public-web-gateway.js +1 -1
  46. package/dist/public-web-gateway.js.map +1 -1
  47. package/dist/quickstart.js +2 -0
  48. package/dist/quickstart.js.map +1 -1
  49. package/dist/reply-triggers.d.ts +2 -0
  50. package/dist/reply-triggers.js +3 -0
  51. package/dist/reply-triggers.js.map +1 -0
  52. package/dist/settings-api.js +27 -4
  53. package/dist/settings-api.js.map +1 -1
  54. package/dist/settings-change.js +17 -14
  55. package/dist/settings-change.js.map +1 -1
  56. package/dist/settings-effect.js +7 -1
  57. package/dist/settings-effect.js.map +1 -1
  58. package/dist/settings-instance-patch.js +4 -1
  59. package/dist/settings-instance-patch.js.map +1 -1
  60. package/dist/setup-host.js +2 -1
  61. package/dist/setup-host.js.map +1 -1
  62. package/dist/setup-wizard.js +2 -0
  63. package/dist/setup-wizard.js.map +1 -1
  64. package/dist/tool-permissions.js +10 -0
  65. package/dist/tool-permissions.js.map +1 -1
  66. package/dist/topic-commands.js +2 -1
  67. package/dist/topic-commands.js.map +1 -1
  68. package/dist/turn-reply-guard.d.ts +1 -1
  69. package/dist/turn-reply-guard.js +1 -1
  70. package/dist/turn-reply-guard.js.map +1 -1
  71. package/dist/types.d.ts +16 -6
  72. package/dist/ui/fleet-office-editor.js +10 -6
  73. package/dist/ui/fleet-office-layout.js +34 -28
  74. package/dist/ui/fleet-office-model.js +6 -3
  75. package/dist/ui/fleet-office-overview.js +2 -2
  76. package/dist/ui/fleet-office-pack.d.ts +3 -1
  77. package/dist/ui/fleet-office-pack.js +58 -7
  78. package/dist/ui/fleet-office-skin.js +13 -4
  79. package/dist/ui/fleet-office-store.js +27 -1
  80. package/dist/ui/fleet-office-view.js +5 -4
  81. package/dist/ui/fleet-office-zones.js +155 -6
  82. package/dist/ui/fleet-office.js +169 -41
  83. package/dist/ui/panel-settings.js +32 -10
  84. package/dist/ui/settings-dialogs.js +8 -1
  85. package/dist/ui/settings-model.js +14 -6
  86. package/dist/ui/settings-registry.js +4 -0
  87. package/dist/ui/settings-strings.js +14 -0
  88. package/dist/ui/shared/app-i18n.js +2 -0
  89. package/dist/ui/shared/app-shell.js +14 -6
  90. package/dist/ui/shared/app-stream.js +2 -1
  91. package/dist/ui/shared/app.css +17 -2
  92. package/dist/ui/shared/side-panel.js +95 -0
  93. package/dist/web-api.d.ts +3 -0
  94. package/dist/web-api.js +11 -0
  95. package/dist/web-api.js.map +1 -1
  96. package/dist/web-auth.js +1 -1
  97. package/dist/web-auth.js.map +1 -1
  98. package/dist/web-office-acts.d.ts +173 -0
  99. package/dist/web-office-acts.js +285 -0
  100. package/dist/web-office-acts.js.map +1 -0
  101. package/dist/web-office-skins.d.ts +1 -1
  102. package/dist/web-office-skins.js +1 -1
  103. package/dist/web-office-skins.js.map +1 -1
  104. package/dist/web-settings-patch.d.ts +2 -1
  105. package/dist/web-settings-patch.js +4 -1
  106. package/dist/web-settings-patch.js.map +1 -1
  107. package/package.json +1 -1
  108. package/templates/windows-shortcuts.ps1 +11 -2
@@ -8,7 +8,7 @@
8
8
  // the public link or a read-only session (the host refuses them too).
9
9
  import { html, useMemo, useState } from "/assets/app-html.js";
10
10
  import { t, register } from "/assets/app-i18n.js";
11
- import { Dialog } from "/assets/ui-dialog.js";
11
+ import { SidePanel } from "/assets/side-panel.js";
12
12
  import { toast } from "/assets/ui-toast.js";
13
13
  import { nodeText } from "./fleet-org.js";
14
14
 
@@ -147,8 +147,11 @@ export function OfficeEditor({ offices, rev, view, store, packs = [], ask = () =
147
147
  // the rev under an older draft (a save would then overwrite what it never showed). After a 409 the person chooses
148
148
  // (#2431): keep their edits (on the newer rev: Save then replaces what was saved meanwhile, knowingly) or load the
149
149
  // newer list — never their draft thrown away unasked.
150
- const [base, setBase] = useState(() => ({ rev, draft: offices.map((o) => ({ ...o, members: [...o.members] })) }));
150
+ // `saved`: the list the draft is measured against (as read, or the newer one loaded) — the draft holds changes when
151
+ // it differs (#2585: leaving the page beside the docked editor then asks first).
152
+ const [base, setBase] = useState(() => ({ rev, saved: JSON.stringify(offices), draft: offices.map((o) => ({ ...o, members: [...o.members] })) }));
151
153
  const draft = base.draft;
154
+ const dirty = JSON.stringify(draft) !== base.saved;
152
155
  const setDraft = (next) => setBase((b) => ({ ...b, draft: typeof next === "function" ? next(b.draft) : next }));
153
156
  const [open, setOpen] = useState(null); // the office whose members are shown
154
157
  const [busy, setBusy] = useState(false);
@@ -157,8 +160,8 @@ export function OfficeEditor({ offices, rev, view, store, packs = [], ask = () =
157
160
  const copy = (list) => list.map((o) => ({ ...o, members: [...o.members] }));
158
161
  // The choice takes the snapshot in force when it is made: the stream may have read a newer one since the 409 (#2449).
159
162
  const inForce = () => current() || conflict;
160
- const keepMine = () => { const now = inForce(); setBase((b) => ({ rev: now.rev, draft: b.draft })); setConflict(null); setMsg(te("keptMine")); };
161
- const loadTheirs = () => { const now = inForce(); setBase({ rev: now.rev, draft: copy(now.offices) }); setOpen(null); setConflict(null); setMsg(""); };
163
+ const keepMine = () => { const now = inForce(); setBase((b) => ({ rev: now.rev, saved: JSON.stringify(now.offices), draft: b.draft })); setConflict(null); setMsg(te("keptMine")); };
164
+ const loadTheirs = () => { const now = inForce(); setBase({ rev: now.rev, saved: JSON.stringify(now.offices), draft: copy(now.offices) }); setOpen(null); setConflict(null); setMsg(""); };
162
165
  const everyone = useMemo(() => { const m = new Map(); for (const n of [...view.top, ...view.teams.flatMap((tm) => tm.nodes), ...view.rest.nodes]) if (!m.has(n.name)) m.set(n.name, n); return [...m.values()]; }, [view]);
163
166
  const set = (id, patch) => setDraft((d) => d.map((o) => (o.id === id ? { ...o, ...patch } : o)));
164
167
  const add = () => {
@@ -191,7 +194,8 @@ export function OfficeEditor({ offices, rev, view, store, packs = [], ask = () =
191
194
  };
192
195
  const actions = html`<button type="button" class="btn" onClick=${onClose} disabled=${busy}>${te("cancel")}</button>
193
196
  <button type="button" class="btn btn-primary" id="oeSave" onClick=${save} disabled=${busy || !!conflict}>${te("save")}</button>`;
194
- return html`<${Dialog} title=${te("title")} onClose=${onClose} actions=${actions} busy=${busy} wide=${true}>
197
+ // #2585: docked on the right on a wide desk (the page beside it in use); the centred dialog elsewhere.
198
+ return html`<${SidePanel} title=${te("title")} onClose=${onClose} actions=${actions} busy=${busy} dirty=${dirty}>
195
199
  <p class="note">${te("lead")}</p>
196
200
  <fieldset class="oe-form" disabled=${busy}>
197
201
  ${draft.length ? null : html`<p class="note" id="oeNone">${te("none")}</p>`}
@@ -220,5 +224,5 @@ export function OfficeEditor({ offices, rev, view, store, packs = [], ask = () =
220
224
  <button type="button" class="btn btn-sm" id="oeKeepMine" onClick=${keepMine} disabled=${busy}>${te("keepMine")}</button>
221
225
  <button type="button" class="btn btn-sm" id="oeLoadTheirs" onClick=${loadTheirs} disabled=${busy}>${te("loadTheirs")}</button>
222
226
  </div>` : null}
223
- </${Dialog}>`;
227
+ </${SidePanel}>`;
224
228
  }
@@ -9,8 +9,9 @@
9
9
  // the public link or a read-only session (the host refuses their saves too).
10
10
  //
11
11
  // The pure part (handles, moving, adding and removing) is exported for tests; LayoutEditor is the overlay.
12
- import { html, useEffect, useLayoutEffect, useRef, useState } from "/assets/app-html.js";
12
+ import { html, useEffect, useLayoutEffect, useMemo, useRef, useState } from "/assets/app-html.js";
13
13
  import { t, register } from "/assets/app-i18n.js";
14
+ import { SidePanel } from "/assets/side-panel.js";
14
15
  import { LOGICAL, overridesBetween, own } from "./fleet-office-pack.js";
15
16
 
16
17
  register("orgl", {
@@ -96,7 +97,7 @@ const KEY_STEP = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], Arr
96
97
  * moves (the canvas redraws it); `onSave(overrides | null)` → { ok } or { ok: false, message }; `onClose()`. `stale` (a message): the
97
98
  * office or its style changed underneath the session — no handles, nothing to save, only Cancel.
98
99
  */
99
- export function LayoutEditor({ base, start, place = null, stale = "", onChange, onSave, onClose }) {
100
+ export function LayoutEditor({ base, start, place = null, stale = "", dock = true, onChange, onSave, onClose }) {
100
101
  const [edited, setEdited] = useState(start);
101
102
  const [sel, setSel] = useState(null);
102
103
  const [coords, setCoords] = useState(true);
@@ -149,13 +150,38 @@ export function LayoutEditor({ base, start, place = null, stale = "", onChange,
149
150
  }
150
151
  const at = new Map(edited.waypoints.map((w) => [w.id, w]));
151
152
  const edgeKey = (e) => `${e[0]}\u0000${e[1]}`;
153
+ // #2585: the controls dock in the side panel on a wide desk (the canvas left whole for the handles), else they are
154
+ // the bar over the canvas as before. Esc (in the panel, or on the canvas's handles) is Cancel, never mid-save.
155
+ const esc = (e) => { if (e.key === "Escape" && !e.defaultPrevented && !busy) { e.preventDefault(); onClose(); } };
156
+ const dirty = useMemo(() => JSON.stringify(edited) !== JSON.stringify(start), [edited, start]);
157
+ const panel = (controls, actions, tail) => html`<${SidePanel} title=${tl("title")} dock=${dock} busy=${busy} dirty=${dirty} onClose=${onClose} actions=${actions}
158
+ fallback=${html`<div class="ol-bar">${controls}<span class="ol-actions">${actions}</span>${tail}</div>`}>
159
+ <div class="ol-side">${controls}${tail}</div>
160
+ </${SidePanel}>`;
152
161
  if (stale) {
153
- return html`<div class="ol" role="group" aria-label=${tl("title")}><div class="ol-bar">
154
- <p class="note ol-msg ol-stale" role="status">${stale}</p>
155
- <span class="ol-actions"><button type="button" class="btn btn-sm ol-cancel" onClick=${onClose} disabled=${busy}>${tl("cancel")}</button></span>
156
- </div></div>`;
162
+ return html`<div class="ol" role="group" aria-label=${tl("title")} onKeyDown=${esc}>
163
+ ${panel(null, html`<button type="button" class="btn btn-sm ol-cancel" onClick=${onClose} disabled=${busy}>${tl("cancel")}</button>`,
164
+ html`<p class="note ol-msg ol-stale" role="status">${stale}</p>`)}
165
+ </div>`;
157
166
  }
158
- return html`<div class="ol" role="group" aria-label=${tl("title")}>
167
+ const controls = html`<label class="check"><input type="checkbox" class="ol-coords" checked=${coords} onChange=${() => setCoords((v) => !v)} /><span>${tl("coords")}</span></label>
168
+ <button type="button" class="btn btn-sm ol-add" onClick=${add} disabled=${busy}>${tl("addWaypoint")}</button>
169
+ <span class="muted ol-sel" aria-live="polite">${selected ? tl("at", selected.label, selected.x, selected.y) : tl("none")}</span>
170
+ ${wpSel ? html`<button type="button" class="btn btn-sm btn-danger ol-remove" onClick=${() => { change(removeWaypoint(edited, wpSel)); setSel(null); }} disabled=${busy}>${tl("removeWaypoint")}</button>
171
+ <label class="oo-pick"><span class="oo-pick-label">${tl("joinTo")}</span>
172
+ <select class="tb-select ol-join-to" value=${joinTo} onChange=${(e) => setJoinTo(e.target.value)}>
173
+ <option value="">—</option>
174
+ ${edited.waypoints.filter((w) => w.id !== wpSel).map((w) => html`<option key=${w.id} value=${w.id}>${w.id}</option>`)}
175
+ </select></label>
176
+ <button type="button" class="btn btn-sm ol-join" disabled=${busy || !joinTo || joinTo === wpSel} onClick=${() => { change(toggleEdge(edited, wpSel, joinTo)); setJoinTo(""); }}>${tl("join")}</button>
177
+ <span class="ol-corridors">${edited.edges.filter((e) => e[0] === wpSel || e[1] === wpSel).map((e) => { const other = e[0] === wpSel ? e[1] : e[0];
178
+ return html`<button key=${edgeKey(e)} type="button" class="btn btn-sm ol-unjoin" data-other=${other} disabled=${busy} onClick=${() => change(toggleEdge(edited, wpSel, other))}>${tl("unjoin", other)}</button>`; })}</span>` : null}`;
179
+ const actions = html`<button type="button" class="btn btn-sm ol-reset" onClick=${reset} disabled=${busy}>${tl("reset")}</button>
180
+ <button type="button" class="btn btn-sm ol-cancel" onClick=${onClose} disabled=${busy}>${tl("cancel")}</button>
181
+ <button type="button" class="btn btn-sm btn-primary ol-save" onClick=${save} disabled=${busy}>${tl("save")}</button>`;
182
+ const tail = html`<span class="muted oo-note">${tl("hint")}</span>
183
+ ${msg ? html`<p class="note ol-msg" role="status">${msg}</p>` : null}`;
184
+ return html`<div class="ol" role="group" aria-label=${tl("title")} onKeyDown=${esc}>
159
185
  <div class="ol-overlay" ref=${overlay}>
160
186
  <svg ref=${svg} class="ol-svg" viewBox=${`0 0 ${LOGICAL.w} ${LOGICAL.h}`} width="100%" height="100%"
161
187
  onPointerMove=${move} onPointerUp=${up} onPointerCancel=${up} onPointerLeave=${up}>
@@ -172,26 +198,6 @@ export function LayoutEditor({ base, start, place = null, stale = "", onChange,
172
198
  </g>`)}
173
199
  </svg>
174
200
  </div>
175
- <div class="ol-bar">
176
- <label class="check"><input type="checkbox" class="ol-coords" checked=${coords} onChange=${() => setCoords((v) => !v)} /><span>${tl("coords")}</span></label>
177
- <button type="button" class="btn btn-sm ol-add" onClick=${add} disabled=${busy}>${tl("addWaypoint")}</button>
178
- <span class="muted ol-sel" aria-live="polite">${selected ? tl("at", selected.label, selected.x, selected.y) : tl("none")}</span>
179
- ${wpSel ? html`<button type="button" class="btn btn-sm btn-danger ol-remove" onClick=${() => { change(removeWaypoint(edited, wpSel)); setSel(null); }} disabled=${busy}>${tl("removeWaypoint")}</button>
180
- <label class="oo-pick"><span class="oo-pick-label">${tl("joinTo")}</span>
181
- <select class="tb-select ol-join-to" value=${joinTo} onChange=${(e) => setJoinTo(e.target.value)}>
182
- <option value="">—</option>
183
- ${edited.waypoints.filter((w) => w.id !== wpSel).map((w) => html`<option key=${w.id} value=${w.id}>${w.id}</option>`)}
184
- </select></label>
185
- <button type="button" class="btn btn-sm ol-join" disabled=${busy || !joinTo || joinTo === wpSel} onClick=${() => { change(toggleEdge(edited, wpSel, joinTo)); setJoinTo(""); }}>${tl("join")}</button>
186
- <span class="ol-corridors">${edited.edges.filter((e) => e[0] === wpSel || e[1] === wpSel).map((e) => { const other = e[0] === wpSel ? e[1] : e[0];
187
- return html`<button key=${edgeKey(e)} type="button" class="btn btn-sm ol-unjoin" data-other=${other} disabled=${busy} onClick=${() => change(toggleEdge(edited, wpSel, other))}>${tl("unjoin", other)}</button>`; })}</span>` : null}
188
- <span class="ol-actions">
189
- <button type="button" class="btn btn-sm ol-reset" onClick=${reset} disabled=${busy}>${tl("reset")}</button>
190
- <button type="button" class="btn btn-sm ol-cancel" onClick=${onClose} disabled=${busy}>${tl("cancel")}</button>
191
- <button type="button" class="btn btn-sm btn-primary ol-save" onClick=${save} disabled=${busy}>${tl("save")}</button>
192
- </span>
193
- <span class="muted oo-note">${tl("hint")}</span>
194
- ${msg ? html`<p class="note ol-msg" role="status">${msg}</p>` : null}
195
- </div>
201
+ ${panel(controls, actions, tail)}
196
202
  </div>`;
197
203
  }
@@ -199,7 +199,9 @@ export function autoLayout(rooms, { width = 960 } = {}) {
199
199
  }
200
200
  });
201
201
  const right = out.length ? Math.max(...out.map((r) => r.x + r.w)) : 0, bottom = out.length ? Math.max(...out.map((r) => r.y + r.h)) : 0;
202
- return { rooms: out, waypoints, edges, width: Math.max(width, right + M), height: bottom + GAP / 2 + M };
202
+ // As wide as its rooms (rows wrap at `width`): a scene narrower than the stage is centred in it (clampPan), never
203
+ // left at the stage's left edge with the rest of the stage empty (#2585 B).
204
+ return { rooms: out, waypoints, edges, width: out.length ? right + M : width, height: bottom + GAP / 2 + M };
203
205
  }
204
206
 
205
207
  /**
@@ -318,7 +320,8 @@ const MAX_SEEN = 2000;
318
320
  * The items in the air. `sync(items, now, still)` takes fleet-traffic.js's store items: each new one (a message, a
319
321
  * delegation's work, its report coming back) starts one trip from its sender's desk to its receiver's; a delegation with
320
322
  * no report (stale) and anything seen before start none; `still` (reduced motion) starts none. `frame(now, L)` the
321
- * trips under way on layout L: { id, hue, kind, x, y, t }, finished ones dropped; a trip whose desks are not drawn goes.
323
+ * trips under way on layout L: { id, hue, kind, from, x, y, t } (from: who sent it — a pack can draw it as theirs),
324
+ * finished ones dropped; a trip whose desks are not drawn goes.
322
325
  */
323
326
  export function createFlights({ max = FLIGHT.MAX } = {}) {
324
327
  const seen = new Set();
@@ -344,7 +347,7 @@ export function createFlights({ max = FLIGHT.MAX } = {}) {
344
347
  if (!f.pts) return false;
345
348
  const t = (now - f.start) / f.ms;
346
349
  if (t >= 1) return false;
347
- if (t >= 0) { const p = along(f.pts, t); out.push({ id: f.id, hue: f.hue, kind: f.kind, x: p.x, y: p.y, t }); }
350
+ if (t >= 0) { const p = along(f.pts, t); out.push({ id: f.id, hue: f.hue, kind: f.kind, from: f.from, x: p.x, y: p.y, t }); }
348
351
  return true;
349
352
  });
350
353
  return out;
@@ -130,7 +130,7 @@ export function crossPath(parts, from, to) {
130
130
  const MAX_SEEN = 2000;
131
131
  /**
132
132
  * The items in the air between tiles — fleet-office-model.js createFlights's lifecycle (each new item once; none for a
133
- * stale delegation or with reduced motion), on crossPath's straight line: `frame(now, parts)` → { id, hue, kind, x, y,
133
+ * stale delegation or with reduced motion), on crossPath's straight line: `frame(now, parts)` → { id, hue, kind, from, x, y,
134
134
  * t } in overview units; a trip with no crossing (both in one tile, or either gone) is dropped.
135
135
  */
136
136
  export function createCrossFlights({ max = FLIGHT.MAX } = {}) {
@@ -157,7 +157,7 @@ export function createCrossFlights({ max = FLIGHT.MAX } = {}) {
157
157
  if (!f.pts) return false;
158
158
  const t = (now - f.start) / f.ms;
159
159
  if (t >= 1) return false;
160
- if (t >= 0) { const p = along(f.pts, t); out.push({ id: f.id, hue: f.hue, kind: f.kind, x: p.x, y: p.y, t }); }
160
+ if (t >= 0) { const p = along(f.pts, t); out.push({ id: f.id, hue: f.hue, kind: f.kind, from: f.from, x: p.x, y: p.y, t }); }
161
161
  return true;
162
162
  });
163
163
  return out;
@@ -4,7 +4,8 @@
4
4
  export interface PackStrip { src: string; frames: number; w: number; h: number; fps: number; ax: number; ay: number; dw: number; dh: number }
5
5
  export interface PackPoint { x: number; y: number }
6
6
  export interface PackArea { name: string; rect: { x: number; y: number; w: number; h: number }; seats: PackPoint[] }
7
- export interface PackFurniture { id: string; sprite: PackStrip; x: number; y: number; depth: number | "y"; activeWhen: string[] | null; activeSprite: PackStrip | null }
7
+ /** `label` (#2574): its name tag, or null. */
8
+ export interface PackFurniture { id: string; sprite: PackStrip; x: number; y: number; depth: number | "y"; activeWhen: string[] | null; activeSprite: PackStrip | null; label: string | null }
8
9
  export interface CheckedPack {
9
10
  format: 1; id: string; scale: number; background?: string; foreground?: string; matte?: string;
10
11
  areas: Record<string, PackArea>; stateZones: Record<string, string> | null; door: PackPoint;
@@ -36,4 +37,5 @@ export function checkLayoutOverrides(x: unknown): { ok: true; overrides: LayoutO
36
37
  export function applyOverrides(pack: CheckedPack, ov: LayoutOverrides | null | undefined): { ok: true; pack: CheckedPack } | { ok: false; error: string };
37
38
  export function overridesBetween(pack: CheckedPack, edited: CheckedPack): LayoutOverrides | null;
38
39
  export function poseStrip(set: Record<string, PackStrip> | null | undefined, pose: string, heading?: { dx: number; dy: number } | null): { strip: PackStrip; mirror: boolean } | null;
40
+ export function walkStrip(f: { walk?: Record<string, PackStrip> | null; walking?: boolean; heading?: { dx: number; dy: number } | null } | null | undefined): { strip: PackStrip; mirror: boolean } | null;
39
41
  export function stripBox(st: PackStrip, x: number, y: number, mirror?: boolean): { x: number; y: number; w: number; h: number };
@@ -31,14 +31,27 @@
31
31
  // "waypoints": [{ "id": "hall", "x": 640, "y": 600 }, …], "edges": [["hall", "east"], …],
32
32
  // optional: the walkable graph (else people walk straight)
33
33
  // "furniture": [ { "id": "rack", "sprite": STRIP, "x": 1020, "y": 140, "depth": 2,
34
- // "activeWhen": "crashed", "activeSprite": STRIP } ],
34
+ // "activeWhen": "crashed", "activeSprite": STRIP, "activeFor": "atlas", "loop": true, "label": "Rack",
35
+ // "night": { "sprite": STRIP, "x": 300, "y": 420 },
36
+ // "walk": { "walk": STRIP, "walkDown": STRIP, "walkUp": STRIP, "walkSide": STRIP } } ],
37
+ // "label" (#2574): a name tag under it, like a person's
38
+ // (an NPC drawn as furniture). "night" (#2568), optional: at bedtime
39
+ // (when the viewer has it on: 00:00–06:00 their time) drawn as this
40
+ // strip, playing, at (x, y) — its own place for either left out.
41
+ // "walk" (#2596), optional: it strolls near its (x, y) and back
42
+ // like an idle person, drawn walking by these strips as a
43
+ // character set's walking poses ("walk" required); not at bedtime
44
+ // with a "night".
35
45
  // drawn by depth: below 1000 under the people, 1000 and up over
36
46
  // them; "depth": "y" (the string) sorts it among the people by base
37
47
  // y — its anchor's y against each person's feet: someone whose feet
38
48
  // are lower down (a greater y; a tie too) is drawn after it, in
39
49
  // front. (x, y) is where the strip's anchor goes. `activeWhen` (a
40
50
  // state, or a list): while anyone shown is in it, the item plays its
41
- // strip (or `activeSprite`); otherwise it holds the first frame
51
+ // strip (or `activeSprite`); otherwise it holds the first frame —
52
+ // or, with "loop": true (#2570), plays its strip all along.
53
+ // "activeFor" (an instance's name, or a list): only their state
54
+ // counts, not anyone's (a room several people take turns in)
42
55
  // "characters": { "default": { "working": STRIP, "idle": STRIP, "paused": STRIP, "needs": STRIP, "walk": STRIP,
43
56
  // "walkDown": STRIP, "walkUp": STRIP, "walkSide": STRIP },
44
57
  // "fox": { … } }, "default" with at least "idle" is required; a missing pose is idle.
@@ -47,6 +60,9 @@
47
60
  // walkSide, drawn as it is when moving right and mirrored when moving
48
61
  // left (so draw it facing right). A missing one is "walk", then idle
49
62
  // ("walk" itself is never mirrored)
63
+ // A set may also have "prop": STRIP (#2562) — what its people
64
+ // send: their messages, work and reports fly as it, in place of
65
+ // the pack's "props" (a set without one: the pack's props)
50
66
  // "byInstance": { "atlas": "fox" }, optional: an instance name → its character set
51
67
  // "props": { "task": STRIP, "send": STRIP, "report": STRIP, "broadcast": STRIP }, optional traffic items
52
68
  // "bubbles": { "en": { "working": ["On it."] }, "zh-TW": { … } } optional: short lines per state (≤ 12 a state,
@@ -96,6 +112,10 @@ export const LOGICAL = { w: 1280, h: 720 };
96
112
  export const CHAR_DEPTH = 1000;
97
113
  export const PACK_STATES = ["needs", "crashed", "stuck", "working", "idle", "paused", "stopped", "missing"];
98
114
  const POSES = ["working", "idle", "paused", "needs", "walk", "walkDown", "walkUp", "walkSide"], PROPS = ["task", "send", "report", "broadcast"];
115
+ /** A character set's strips: its poses, and what its people send (#2562). */
116
+ const SET_STRIPS = [...POSES, "prop"];
117
+ /** A furniture item's walking strips (#2596): a character set's walking poses. */
118
+ const WALKS = ["walk", "walkDown", "walkUp", "walkSide"];
99
119
  const BUILT_IN = new Set(["pixel", "studio"]);
100
120
  const ID = /^[A-Za-z0-9_-]{1,40}$/;
101
121
  const SRC = /^[A-Za-z0-9._/-]{1,200}$/;
@@ -138,6 +158,24 @@ export function checkStrip(v, at = "strip", scale = 2) {
138
158
  return { src: src(v.src, `${at}.src`), frames: int(v.frames, 1, 32, `${at}.frames`), w, h, fps: v.fps === undefined ? 4 : num(v.fps, 0, 30, `${at}.fps`),
139
159
  ax: v.ax === undefined ? w / 2 : num(v.ax, 0, w, `${at}.ax`), ay: v.ay === undefined ? h : num(v.ay, 0, h, `${at}.ay`), dw, dh };
140
160
  }
161
+ /** A furniture item's night (#2568): { sprite, x, y } — at bedtime it is drawn as `sprite` (playing) at (x, y), its own place when left out. */
162
+ function nightOf(v, at, scale) {
163
+ if (v === undefined) return null;
164
+ obj(v, at); keys(v, ["sprite", "x", "y"], at);
165
+ return { sprite: checkStrip(v.sprite, `${at}.sprite`, scale), x: v.x === undefined ? null : num(v.x, 0, LOGICAL.w, `${at}.x`), y: v.y === undefined ? null : num(v.y, 0, LOGICAL.h, `${at}.y`) };
166
+ }
167
+ /** A furniture item's walk (#2596): its walking strips, picked by heading as a character set's (poseStrip); "walk" required. */
168
+ function walkOf(v, at, scale) {
169
+ if (v === undefined) return null;
170
+ const w = strips(v, WALKS, at, scale);
171
+ if (!w.walk) throw new Refused(`${at}.walk`, "required");
172
+ return w;
173
+ }
174
+ /** Whose state an item's activeWhen watches (#2570): an instance's name, or a list of up to 8. */
175
+ function names(v, at) {
176
+ const one = (s, here) => { if (typeof s !== "string" || !s || s.length > 200 || inherited(s) || /[\u0000-\u001f\u007f]/.test(s)) throw new Refused(here, "an instance's name"); return s; };
177
+ return Array.isArray(v) ? list(v, 8, at).map((s, i) => one(s, `${at}[${i}]`)) : [one(v, at)];
178
+ }
141
179
  function strips(v, names, at, scale) {
142
180
  obj(v, at);
143
181
  keys(v, names, at);
@@ -204,20 +242,25 @@ export function checkSkinPack(x) {
204
242
  const fids = new Set();
205
243
  const furniture = list(x.furniture ?? [], LIMIT.furniture, "furniture").map((f, i) => {
206
244
  const at = `furniture[${i}]`;
207
- obj(f, at); keys(f, ["id", "sprite", "x", "y", "depth", "activeWhen", "activeSprite"], at);
245
+ obj(f, at); keys(f, ["id", "sprite", "x", "y", "depth", "activeWhen", "activeSprite", "activeFor", "loop", "label", "night", "walk"], at);
208
246
  if (!nameOk(f.id) || fids.has(f.id)) throw new Refused(`${at}.id`, "a new name of letters, digits, _ or - (not an inherited one)");
209
247
  fids.add(f.id);
210
248
  if (f.activeSprite !== undefined && f.activeWhen === undefined) throw new Refused(`${at}.activeSprite`, "only with activeWhen");
249
+ if (f.activeFor !== undefined && f.activeWhen === undefined) throw new Refused(`${at}.activeFor`, "only with activeWhen");
250
+ if (f.loop !== undefined && typeof f.loop !== "boolean") throw new Refused(`${at}.loop`, "true or false");
251
+ if (f.label !== undefined && (typeof f.label !== "string" || !f.label.trim() || [...f.label].length > LIMIT.line || /[\u0000-\u001f\u007f]/.test(f.label))) throw new Refused(`${at}.label`, `1–${LIMIT.line} characters`);
211
252
  return { id: f.id, sprite: checkStrip(f.sprite, `${at}.sprite`, scale), x: num(f.x, 0, LOGICAL.w, `${at}.x`), y: num(f.y, 0, LOGICAL.h, `${at}.y`),
212
253
  depth: f.depth === "y" ? "y" : depth(f.depth ?? 0, `${at}.depth`), activeWhen: f.activeWhen === undefined ? null : states(f.activeWhen, `${at}.activeWhen`),
213
- activeSprite: f.activeSprite === undefined ? null : checkStrip(f.activeSprite, `${at}.activeSprite`, scale) };
254
+ activeSprite: f.activeSprite === undefined ? null : checkStrip(f.activeSprite, `${at}.activeSprite`, scale),
255
+ activeFor: f.activeFor === undefined ? null : names(f.activeFor, `${at}.activeFor`), loop: f.loop === true, label: f.label === undefined ? null : f.label.trim(), night: nightOf(f.night, `${at}.night`, scale),
256
+ walk: walkOf(f.walk, `${at}.walk`, scale) };
214
257
  }).sort((a, b) => order(a) - order(b));
215
258
  const sets = obj(x.characters, "characters");
216
259
  if (Object.keys(sets).length > LIMIT.sets) throw new Refused("characters", `at most ${LIMIT.sets} sets`);
217
260
  const characters = {};
218
261
  for (const [k, v] of Object.entries(sets)) {
219
262
  if (!nameOk(k)) throw new Refused(`characters.${k}`, "a name of letters, digits, _ or - (not an inherited one)");
220
- characters[k] = strips(v, POSES, `characters.${k}`, scale);
263
+ characters[k] = strips(v, SET_STRIPS, `characters.${k}`, scale);
221
264
  if (!characters[k].idle) throw new Refused(`characters.${k}.idle`, "required");
222
265
  }
223
266
  if (!own(characters, "default")) throw new Refused("characters.default", "required");
@@ -261,13 +304,18 @@ export function checkSkinPack(x) {
261
304
  export const skinPackOk = (x) => { const r = checkSkinPack(x); return r.ok ? r.pack : null; };
262
305
  /** Every image a pack names (what a loader fetches before spriteSkin draws). */
263
306
  export const packImages = (pack) => [...new Set([pack.background, pack.foreground, pack.room.floor, pack.room.wall, pack.desk.back, pack.desk.front, pack.desk.lit,
264
- ...pack.furniture.flatMap((f) => [f.sprite.src, f.activeSprite && f.activeSprite.src]),
307
+ ...pack.furniture.flatMap((f) => [f.sprite.src, f.activeSprite && f.activeSprite.src, f.night && f.night.sprite.src, ...Object.values(f.walk || {}).map((st) => st.src)]),
265
308
  ...Object.values(pack.characters).flatMap((s) => Object.values(s).map((st) => st.src)), ...Object.values(pack.props).map((st) => st.src)].filter(Boolean))];
266
309
 
267
310
  /** Every frame strip a checked pack names, with where (the host checks each image is frames·w × h). */
268
311
  export function packStrips(pack) {
269
312
  const out = [];
270
- for (const f of pack.furniture) { out.push({ at: `furniture.${f.id}.sprite`, strip: f.sprite }); if (f.activeSprite) out.push({ at: `furniture.${f.id}.activeSprite`, strip: f.activeSprite }); }
313
+ for (const f of pack.furniture) {
314
+ out.push({ at: `furniture.${f.id}.sprite`, strip: f.sprite });
315
+ if (f.activeSprite) out.push({ at: `furniture.${f.id}.activeSprite`, strip: f.activeSprite });
316
+ if (f.night) out.push({ at: `furniture.${f.id}.night.sprite`, strip: f.night.sprite });
317
+ for (const [k, st] of Object.entries(f.walk || {})) out.push({ at: `furniture.${f.id}.walk.${k}`, strip: st });
318
+ }
271
319
  for (const [k, set] of Object.entries(pack.characters)) for (const [pose, st] of Object.entries(set)) out.push({ at: `characters.${k}.${pose}`, strip: st });
272
320
  for (const [k, st] of Object.entries(pack.props)) out.push({ at: `props.${k}`, strip: st });
273
321
  return out;
@@ -403,6 +451,9 @@ export function poseStrip(set, pose, heading = null) {
403
451
  const st = own(set, pose) || own(set, "idle");
404
452
  return st ? { strip: st, mirror: false } : null;
405
453
  }
454
+ /** The strip a furniture item shows while it walks (#2596: an NPC with `walk`, `walking` and `heading` set where it is
455
+ * drawn): its walk strip by heading, as a character's (poseStrip); null when it stands (its own sprite, as before). */
456
+ export const walkStrip = (f) => (f && f.walking && f.walk ? poseStrip(f.walk, "walk", f.heading) : null);
406
457
  /** The box (logical units) a strip covers with its anchor at (x, y): its displayed size; mirrored about the anchor. */
407
458
  export function stripBox(st, x, y, mirror = false) {
408
459
  const kx = st.dw / st.w, ky = st.dh / st.h;
@@ -41,7 +41,7 @@
41
41
  // along the pack's waypoints), tags, props and bubbles are drawn each frame, furniture by depth around the people (depth "y":
42
42
  // among them by base y), then the foreground, then the crowd counts, tags and bubbles.
43
43
  import { frameAt, nameHash } from "./fleet-office-model.js";
44
- import { CHAR_DEPTH, LOGICAL, checkSkinPack, own, packImages, poseStrip, stripBox } from "./fleet-office-pack.js";
44
+ import { CHAR_DEPTH, LOGICAL, checkSkinPack, own, packImages, poseStrip, stripBox, walkStrip } from "./fleet-office-pack.js";
45
45
 
46
46
  export { nameHash };
47
47
 
@@ -513,8 +513,12 @@ export function spriteSkin(pack, images, base = pixelSkin) {
513
513
  },
514
514
  drawFurniture(ctx, f, active, ms, env) {
515
515
  if (f.shape) { drawFurniture(ctx, f, active, ms, env); return; }
516
- // Active: its strip (or activeSprite) plays; otherwise its first frame holds.
516
+ // An NPC walking (#2596): its walk strip for its heading (walkSide mirrored going left), when its picture is loaded.
517
+ const ws = walkStrip(f);
518
+ if (ws && strip(ctx, ws.strip, ms, f.x, f.y, null, ws.mirror)) return;
519
+ // Active: its activeSprite (or its strip) plays; otherwise its strip loops ("loop", #2570) or its first frame holds.
517
520
  if (active) strip(ctx, f.activeSprite || f.sprite, ms, f.x, f.y);
521
+ else if (f.loop) strip(ctx, f.sprite, ms, f.x, f.y);
518
522
  else strip(ctx, f.sprite, 0, f.x, f.y, 0);
519
523
  },
520
524
  drawCharacter(ctx, d, look, ms, env) {
@@ -530,7 +534,11 @@ export function spriteSkin(pack, images, base = pixelSkin) {
530
534
  const ps = shown({ ...look, heading: at.heading }, !!at.walking);
531
535
  return ps ? stripBox(ps.strip, at.x, at.y, ps.mirror) : base.charBox(look, at);
532
536
  },
533
- drawProp(ctx, it, ms, env) { if (!strip(ctx, own(pack.props, it.hue), ms, it.x, it.y)) base.drawProp(ctx, it, ms, env); },
537
+ // An item in the air: its sender's own (their set's "prop", #2562), else the pack's for its kind, else the built-in one.
538
+ drawProp(ctx, it, ms, env) {
539
+ const mine = it.from ? own(setOf({ name: it.from }) || {}, "prop") : null;
540
+ if (!strip(ctx, mine || own(pack.props, it.hue), ms, it.x, it.y)) base.drawProp(ctx, it, ms, env);
541
+ },
534
542
  };
535
543
  }
536
544
 
@@ -541,7 +549,8 @@ export const BUILT_IN_SKINS = ["pixel", "studio"];
541
549
  export const skinFor = (id, packs = null) => SKINS.get(id) || (packs && packs.get(id)) || pixelSkin;
542
550
 
543
551
  const loaded = new Map(); // `${id}@${sha}` → Promise<OfficeSkin | null>
544
- const MAX_LOADED = 16;
552
+ // Room for every pack the host keeps (web-office-skins.ts SKIN_LIMITS.packs) and a few replaced ones (each a new sha).
553
+ const MAX_LOADED = 24;
545
554
  const defaultImage = (url) => new Promise((resolve) => {
546
555
  if (typeof Image !== "function") { resolve(null); return; }
547
556
  const im = new Image();
@@ -10,7 +10,7 @@
10
10
  import { AUTO_ID, filterOk, officesOk } from "./fleet-office-model.js";
11
11
  import { fitModeOk, savedOk } from "./fleet-office-view.js";
12
12
 
13
- const CHOICE_KEY = "agend_org_office", FILTER_KEY = "agend_org_office_filter", SKIN_KEY = "agend_org_office_skin", ASLEEP_KEY = "agend_org_office_hide_asleep";
13
+ const CHOICE_KEY = "agend_org_office", FILTER_KEY = "agend_org_office_filter", SKIN_KEY = "agend_org_office_skin", ASLEEP_KEY = "agend_org_office_hide_asleep", NIGHT_KEY = "agend_org_office_night";
14
14
  /** #2554: "0" when All offices leaves the lobby tile out (default: shown). */
15
15
  const LOBBY_KEY = "agend_org_office_lobby";
16
16
  /** #2540: { "<office>@<style>": [k, cx, cy] (and a 1 after them: a fit) } — the last VIEWS_MAX views, the most recent last. */
@@ -30,6 +30,20 @@ export function skinListOk(d) {
30
30
  }
31
31
 
32
32
  /** `fetch`: the caller's (a lease's, so an answer after the person moved on is dropped); `storage`: default localStorage. */
33
+ /**
34
+ * Agents' acts as the host sends them (GET /ui/office-acts, the `office_acts` event), checked: the well-formed ones.
35
+ * `now`: the host's clock when it sent them — each end is moved onto this page's clock (they may differ).
36
+ */
37
+ export function actsOk(list, now) {
38
+ const skew = typeof now === "number" && Number.isFinite(now) ? now - Date.now() : 0;
39
+ const str = (v) => typeof v === "string" && v.length > 0 && v.length <= 200, num = (v) => typeof v === "number" && Number.isFinite(v);
40
+ return (Array.isArray(list) ? list : []).filter((a) => a && typeof a === "object" && str(a.instance) && str(a.office) && num(a.until)
41
+ && (a.x === undefined || num(a.x)) && (a.y === undefined || num(a.y)) && (a.person === undefined || str(a.person))
42
+ && (a.say === undefined || (typeof a.say === "string" && a.say.length <= 40)) && (a.pose === undefined || ["idle", "working", "needs", "paused"].includes(a.pose)))
43
+ .map((a) => ({ instance: a.instance, office: a.office, until: a.until - skew, ...(num(a.since) ? { since: a.since } : {}), ...(str(a.state) ? { state: a.state } : {}),
44
+ ...(num(a.x) && num(a.y) ? { x: a.x, y: a.y } : {}),
45
+ ...(a.person ? { person: a.person } : {}), ...(a.say ? { say: a.say } : {}), ...(a.pose ? { pose: a.pose } : {}) }));
46
+ }
33
47
  export function createOfficeStore({ fetch: get = globalThis.fetch, storage } = {}) {
34
48
  const st = () => { try { return storage || globalThis.localStorage || null; } catch { return null; } };
35
49
  const read = (k) => { try { const s = st(); return s ? s.getItem(k) : null; } catch { return null; } };
@@ -57,6 +71,15 @@ export function createOfficeStore({ fetch: get = globalThis.fetch, storage } = {
57
71
  return r.ok ? { ok: true, rev: d.rev, offices: officesOk(d.offices) } : { ok: false, status: r.status, error: d.error || "offices_failed" };
58
72
  } catch { return { ok: false, status: 0, error: "offices_failed" }; }
59
73
  },
74
+ /** #2578: what agents are doing in the offices now (office_set): their acts, or null when they could not be read. */
75
+ async acts() {
76
+ try {
77
+ const r = await get("/ui/office-acts", { method: "GET", headers });
78
+ if (!r || !r.ok) return null;
79
+ const d = await r.json();
80
+ return actsOk(d && d.acts, d && d.now);
81
+ } catch { return null; }
82
+ },
60
83
  /** The skin packs the host keeps: { rev, packs: [{ id, name, sha }] }; null when they could not be read. */
61
84
  async skins() {
62
85
  try {
@@ -96,6 +119,9 @@ export function createOfficeStore({ fetch: get = globalThis.fetch, storage } = {
96
119
  /** Whether paused and stopped people are left out of the Office here (default: shown). */
97
120
  hideAsleep: () => read(ASLEEP_KEY) === "1",
98
121
  saveHideAsleep: (v) => write(ASLEEP_KEY, v ? "1" : "0"),
122
+ /** #2568: whether the idle go to bed at night (00:00–06:00 on this device's clock) here (default: off). */
123
+ nightOn: () => read(NIGHT_KEY) === "1",
124
+ saveNightOn: (v) => write(NIGHT_KEY, v ? "1" : "0"),
99
125
  /** #2554: whether All offices shows the lobby tile (the instances in no stored office) here (default: shown). */
100
126
  lobbyTile: () => read(LOBBY_KEY) !== "0",
101
127
  saveLobbyTile: (v) => write(LOBBY_KEY, v ? "1" : "0"),
@@ -9,7 +9,8 @@
9
9
  // whole scene shows — non-integer, so the scene fills the panel's width or height. "cover" (Fill): the smallest
10
10
  // scale that fills both; what overflows is cropped and stays a pan away. Either is centred.
11
11
  // - The view never loses the scene (clampPan): along an axis where the scene is larger than the panel it covers the
12
- // panel (no gap at an edge); where it is smaller it stays wholly inside.
12
+ // panel (no gap at an edge); where it is smaller it is centred on that axis (the user: the canvas's middle is the
13
+ // scene's), on opening, Fit, any zoom and a resize alike.
13
14
  //
14
15
  // Around the scene is a matte: the pack's `matte` (fleet-office-pack.js), else the colour of the background's edge
15
16
  // pixels (sampled once per style: edgeMatte), else the skin's own (Studio's hall floor; Desks': the dashboard's
@@ -65,13 +66,13 @@ export function fitScene(w, h, size, mode = "contain", margin = 0) {
65
66
  * 4/3 at 150 %, 0.8 at 125 %); the layout wraps at the panel's width over it, so it opens as wide as the panel.
66
67
  */
67
68
  export const desksK = (dpr = 1) => Math.max(1, Math.round(dpr)) / dpr;
68
- /** The view a scene opens on with nothing kept: zones fitted (the office's mode), desks from the top left at desksK. */
69
+ /** The view a scene opens on with nothing kept: zones fitted (the office's mode), desks from the top left at desksK (centred by clampPan on an axis where it is smaller). */
69
70
  export const openingView = (w, h, size, dpr, zones, mode = "contain") => (zones ? fitScene(w, h, size, mode) : { k: desksK(dpr), x: 0, y: 0 });
70
71
  /** One axis of clampPan: offset `o` of a scene `s` long in a viewport `n` long. */
71
- const clampAxis = (o, s, n) => (s >= n ? Math.min(0, Math.max(n - s, o)) : Math.min(n - s, Math.max(0, o)));
72
+ const clampAxis = (o, s, n) => (s >= n ? Math.min(0, Math.max(n - s, o)) : (n - s) / 2);
72
73
  /**
73
74
  * The view kept on the scene: along an axis where the scene is at least the viewport it covers the viewport (no gap at
74
- * either edge); where it is smaller it stays wholly inside. Nothing can be scrolled off-screen.
75
+ * either edge); where it is smaller it is centred on that axis. Nothing can be scrolled off-screen.
75
76
  */
76
77
  export function clampPan(v, w, h, size) {
77
78
  return { k: v.k, x: clampAxis(v.x, w * v.k, size.w), y: clampAxis(v.y, h * v.k, size.h) };