@songsid/agend 2.3.1 → 2.3.2-beta.2

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 (160) hide show
  1. package/dist/authority/authority-state.d.ts +4 -0
  2. package/dist/authority/authority-state.js +16 -10
  3. package/dist/authority/authority-state.js.map +1 -1
  4. package/dist/authority/session-authorizations.d.ts +7 -1
  5. package/dist/authority/session-authorizations.js +6 -4
  6. package/dist/authority/session-authorizations.js.map +1 -1
  7. package/dist/authority/web-authority.d.ts +19 -6
  8. package/dist/authority/web-authority.js +22 -8
  9. package/dist/authority/web-authority.js.map +1 -1
  10. package/dist/channel/adapters/discord.d.ts +2 -0
  11. package/dist/channel/adapters/discord.js +33 -3
  12. package/dist/channel/adapters/discord.js.map +1 -1
  13. package/dist/channel/mcp-tools.js +5 -5
  14. package/dist/channel/mcp-tools.js.map +1 -1
  15. package/dist/channel/types.d.ts +11 -0
  16. package/dist/config-validator.js +40 -3
  17. package/dist/config-validator.js.map +1 -1
  18. package/dist/daemon.d.ts +34 -3
  19. package/dist/daemon.js +227 -46
  20. package/dist/daemon.js.map +1 -1
  21. package/dist/delivery-outbox.d.ts +12 -1
  22. package/dist/delivery-outbox.js +67 -15
  23. package/dist/delivery-outbox.js.map +1 -1
  24. package/dist/doctor.d.ts +3 -0
  25. package/dist/doctor.js +9 -0
  26. package/dist/doctor.js.map +1 -1
  27. package/dist/event-log.d.ts +5 -0
  28. package/dist/event-log.js +13 -0
  29. package/dist/event-log.js.map +1 -1
  30. package/dist/fleet-manager.d.ts +140 -0
  31. package/dist/fleet-manager.js +414 -29
  32. package/dist/fleet-manager.js.map +1 -1
  33. package/dist/fleet-process-handlers.d.ts +1 -0
  34. package/dist/fleet-process-handlers.js +9 -1
  35. package/dist/fleet-process-handlers.js.map +1 -1
  36. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +4 -4
  37. package/dist/general-knowledge/skills/web-appearance/SKILL.md +7 -7
  38. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +113 -55
  39. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +114 -56
  40. package/dist/guest-seen-people.d.ts +38 -0
  41. package/dist/guest-seen-people.js +78 -0
  42. package/dist/guest-seen-people.js.map +1 -0
  43. package/dist/instance-config-impact.js +4 -1
  44. package/dist/instance-config-impact.js.map +1 -1
  45. package/dist/instance-lifecycle.d.ts +1 -0
  46. package/dist/instance-lifecycle.js +16 -1
  47. package/dist/instance-lifecycle.js.map +1 -1
  48. package/dist/locale.js +38 -10
  49. package/dist/locale.js.map +1 -1
  50. package/dist/mac-auto-restore.d.ts +117 -0
  51. package/dist/mac-auto-restore.js +294 -0
  52. package/dist/mac-auto-restore.js.map +1 -0
  53. package/dist/outbound-schemas.d.ts +2 -0
  54. package/dist/outbound-schemas.js +2 -0
  55. package/dist/outbound-schemas.js.map +1 -1
  56. package/dist/public-web-gateway.js +9 -1
  57. package/dist/public-web-gateway.js.map +1 -1
  58. package/dist/settings-api.d.ts +26 -0
  59. package/dist/settings-api.js +107 -1
  60. package/dist/settings-api.js.map +1 -1
  61. package/dist/settings-change.js +37 -3
  62. package/dist/settings-change.js.map +1 -1
  63. package/dist/settings-effect.js +5 -2
  64. package/dist/settings-effect.js.map +1 -1
  65. package/dist/start-attempt.d.ts +9 -5
  66. package/dist/start-attempt.js +27 -8
  67. package/dist/start-attempt.js.map +1 -1
  68. package/dist/start-failure-chat.d.ts +4 -0
  69. package/dist/start-failure-chat.js +23 -6
  70. package/dist/start-failure-chat.js.map +1 -1
  71. package/dist/start-failure-notice.d.ts +4 -0
  72. package/dist/start-failure-notice.js +8 -0
  73. package/dist/start-failure-notice.js.map +1 -1
  74. package/dist/start-failure.d.ts +25 -0
  75. package/dist/start-failure.js +75 -2
  76. package/dist/start-failure.js.map +1 -1
  77. package/dist/turn-reply-guard.d.ts +25 -0
  78. package/dist/turn-reply-guard.js +111 -12
  79. package/dist/turn-reply-guard.js.map +1 -1
  80. package/dist/types.d.ts +18 -1
  81. package/dist/ui/backend-signin.js +5 -2
  82. package/dist/ui/chat-ambient.js +1 -1
  83. package/dist/ui/chat-effects.js +4 -4
  84. package/dist/ui/chat-render.js +5 -2
  85. package/dist/ui/chat-strings.js +2 -2
  86. package/dist/ui/chat-thread.js +2 -2
  87. package/dist/ui/first-run.js +2 -2
  88. package/dist/ui/fleet-canvas-view.js +114 -0
  89. package/dist/ui/fleet-canvas.js +15 -77
  90. package/dist/ui/fleet-office-editor.js +80 -5
  91. package/dist/ui/fleet-office-layout.js +197 -0
  92. package/dist/ui/fleet-office-model.js +6 -3
  93. package/dist/ui/fleet-office-overview.js +167 -0
  94. package/dist/ui/fleet-office-pack.d.ts +39 -0
  95. package/dist/ui/fleet-office-pack.js +240 -29
  96. package/dist/ui/fleet-office-skin.js +140 -29
  97. package/dist/ui/fleet-office-store.js +84 -5
  98. package/dist/ui/fleet-office-view.js +154 -0
  99. package/dist/ui/fleet-office-zones.js +95 -33
  100. package/dist/ui/fleet-office.js +779 -140
  101. package/dist/ui/fleet-usage.js +7 -6
  102. package/dist/ui/panel-chat.js +1 -1
  103. package/dist/ui/panel-details.js +2 -2
  104. package/dist/ui/panel-fleet.js +11 -77
  105. package/dist/ui/panel-log.js +2 -2
  106. package/dist/ui/panel-settings.js +578 -163
  107. package/dist/ui/search-dialog.js +26 -7
  108. package/dist/ui/settings-appearance.js +82 -29
  109. package/dist/ui/settings-apply.js +42 -7
  110. package/dist/ui/settings-dialogs.js +68 -22
  111. package/dist/ui/settings-draft.js +51 -0
  112. package/dist/ui/settings-guests.js +244 -78
  113. package/dist/ui/settings-learn.js +2 -2
  114. package/dist/ui/settings-model.js +2 -0
  115. package/dist/ui/settings-registry.js +142 -0
  116. package/dist/ui/settings-strings.js +102 -44
  117. package/dist/ui/settings-web-authority.js +34 -23
  118. package/dist/ui/shared/app-i18n.js +14 -0
  119. package/dist/ui/shared/app-nav.js +10 -5
  120. package/dist/ui/shared/app-prefs.js +80 -2
  121. package/dist/ui/shared/app-route.js +39 -5
  122. package/dist/ui/shared/app-shell.js +59 -4
  123. package/dist/ui/shared/app-stream.js +2 -1
  124. package/dist/ui/shared/app.css +254 -17
  125. package/dist/ui/shared/app.js +3 -0
  126. package/dist/ui/shared/effect-stock-trust.js +1 -1
  127. package/dist/ui/shared/identity-color.js +4 -4
  128. package/dist/ui/shared/signin.js +2 -2
  129. package/dist/ui/shared/ui-dialog.js +6 -1
  130. package/dist/ui/shared/ui-icons.js +2 -0
  131. package/dist/ui/shared/web-authorize.js +13 -12
  132. package/dist/web-api.d.ts +8 -2
  133. package/dist/web-api.js +160 -5
  134. package/dist/web-api.js.map +1 -1
  135. package/dist/web-auth.js +7 -1
  136. package/dist/web-auth.js.map +1 -1
  137. package/dist/web-experimental.d.ts +1 -1
  138. package/dist/web-experimental.js +1 -1
  139. package/dist/web-experimental.js.map +1 -1
  140. package/dist/web-mod-agent.js +2 -2
  141. package/dist/web-mod-agent.js.map +1 -1
  142. package/dist/web-office-skins.d.ts +108 -0
  143. package/dist/web-office-skins.js +298 -0
  144. package/dist/web-office-skins.js.map +1 -0
  145. package/dist/web-office-store.d.ts +8 -2
  146. package/dist/web-office-store.js +37 -7
  147. package/dist/web-office-store.js.map +1 -1
  148. package/dist/web-settings-patch.d.ts +6 -0
  149. package/dist/web-settings-patch.js +61 -4
  150. package/dist/web-settings-patch.js.map +1 -1
  151. package/dist/web-shell-routes.d.ts +4 -2
  152. package/dist/web-shell-routes.js +17 -7
  153. package/dist/web-shell-routes.js.map +1 -1
  154. package/dist/working-recovery.d.ts +104 -0
  155. package/dist/working-recovery.js +263 -0
  156. package/dist/working-recovery.js.map +1 -0
  157. package/dist/working-state-journal.d.ts +3 -1
  158. package/dist/working-state-journal.js +15 -2
  159. package/dist/working-state-journal.js.map +1 -1
  160. package/package.json +1 -1
@@ -4,7 +4,8 @@
4
4
  // "studio" (the zones layout: work desks, a lounge, an alert corner and a nap corner; people walk to the area for their
5
5
  // state). Every drawing call goes through the OfficeSkin interface below, so a skin pack (pictures from an artist,
6
6
  // format v1 in fleet-office-pack.js) can replace any part; a part a pack does not supply falls back to the built-in
7
- // drawing. No pack can be added yet (no upload): spriteSkin() and checkSkinPack() are the seam a later step plugs into.
7
+ // drawing. An admin uploads a pack (#2513, the host keeps it: src/web-office-skins.ts); loadPackSkin() reads it back —
8
+ // its manifest checked here again (checkSkinPack), its pictures by their content ids — and spriteSkin() draws it.
8
9
  //
9
10
  // ── The interface ──
10
11
  //
@@ -22,16 +23,25 @@
22
23
  // @property {(ctx, desk, look, env) => void} drawDeskFront desks: in front — the desk's front, its sign, folders, name tag
23
24
  // @property {(ctx, L, env) => void} [drawBackground] zones: the whole 1280×720 background, areas and their names
24
25
  // @property {(ctx, item, active, ms, env) => void} [drawFurniture] zones: one furniture item (active: activeWhen holds)
26
+ // @property {(ctx, L, env) => void} [drawForeground] zones: a pack's foreground over the whole 1280×720 scene, above
27
+ // the people, furniture and items in the air, below the crowd counts, name tags and bubbles
25
28
  // @property {(ctx, x, y, look, env) => void} [drawTag] zones: a name tag under feet at (x, y)
29
+ // @property {(ctx, area, n, env) => void} [drawCrowd] zones: an area's "+n" (who found no spot in it)
26
30
  // @property {(ctx, desk, look, ms, env) => void} drawCharacter the occupant in its pose at time `ms` (0: still); in
27
- // zones `desk` is the character's box at its current position (its feet at desk.seat)
31
+ // zones `desk` is the character's box at its current position (its feet at desk.seat); walking, `look.heading` is
32
+ // the current step's { dx, dy }
33
+ // @property {(look, at) => {x, y, w, h}} [charBox] zones: the box drawCharacter covers with the feet at `at`
34
+ // ({ x, y, walking, heading }): what hover and click hit (fleet-office-zones.js personAt)
28
35
  // @property {(ctx, prop, ms, env) => void} drawProp a traffic item in the air: { kind, hue, x, y, t }
29
36
  // @property {(ctx, x, y, text, env, lift?) => void} drawBubble a speech bubble whose tail points at (x, y), its box `lift` px higher
30
- // The desks layout's back and front layers change only when the fleet does: the renderer keeps them as pictures and
31
- // draws the characters, props and bubbles each frame. In zones the background is kept; furniture, people (walking
32
- // along the pack's waypoints), tags, props and bubbles are drawn each frame, furniture by depth around the people.
37
+ // @property {(env) => string} matte #2540: the colour around the scene (the Office fills the panel; the scene is 16:9)
38
+ // @property {object|null} [backdrop] a pack's loaded background picture (the page samples its edges for the matte)
39
+ // The desks layout's back and front layers change only when the fleet (or the zoom) does: the renderer keeps them as
40
+ // pictures and draws the characters, props and bubbles each frame. In zones the background is kept; furniture, people (walking
41
+ // along the pack's waypoints), tags, props and bubbles are drawn each frame, furniture by depth around the people (depth "y":
42
+ // among them by base y), then the foreground, then the crowd counts, tags and bubbles.
33
43
  import { frameAt, nameHash } from "./fleet-office-model.js";
34
- import { CHAR_DEPTH, LOGICAL } from "./fleet-office-pack.js";
44
+ import { CHAR_DEPTH, LOGICAL, checkSkinPack, own, packImages, poseStrip, stripBox } from "./fleet-office-pack.js";
35
45
 
36
46
  export { nameHash };
37
47
 
@@ -212,14 +222,16 @@ function drawDeskFront(ctx, d, look, env) {
212
222
  ctx.fillStyle = pal.text2; ctx.font = "600 8px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
213
223
  ctx.fillText(fitText(ctx, env.labels.closed, 32), d.x + 48, d.y + 63);
214
224
  }
215
- // The name tag.
216
- ctx.fillStyle = pal.surface;
217
- ctx.fillRect(d.x + 6, d.y + 78, d.w - 12, 16);
218
- ctx.strokeStyle = look.match ? pal.focus : pal.border;
219
- ctx.lineWidth = look.match ? 2 : 1;
220
- ctx.strokeRect(d.x + 6.5, d.y + 78.5, d.w - 13, 15);
221
- ctx.fillStyle = pal.text; ctx.font = "600 11px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
222
- ctx.fillText(fitText(ctx, look.label, d.w - 18), d.x + d.w / 2, d.y + 86.5);
225
+ // The name tag (#2554: none when `notag` — a tile too small to read it; the page draws it again on hover).
226
+ if (!look.notag) {
227
+ ctx.fillStyle = pal.surface;
228
+ ctx.fillRect(d.x + 6, d.y + 78, d.w - 12, 16);
229
+ ctx.strokeStyle = look.match ? pal.focus : pal.border;
230
+ ctx.lineWidth = look.match ? 2 : 1;
231
+ ctx.strokeRect(d.x + 6.5, d.y + 78.5, d.w - 13, 15);
232
+ ctx.fillStyle = pal.text; ctx.font = "600 11px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
233
+ ctx.fillText(fitText(ctx, look.label, d.w - 18), d.x + d.w / 2, d.y + 86.5);
234
+ }
223
235
  ctx.restore();
224
236
  if (look.match) { ctx.save(); ctx.strokeStyle = pal.focus; ctx.lineWidth = 2; ctx.strokeRect(d.x + 2, d.y + 2, d.w - 4, d.h - 6); ctx.restore(); }
225
237
  }
@@ -271,21 +283,58 @@ function drawBubble(ctx, x, y, text, env, lift = 0) {
271
283
  ctx.restore();
272
284
  }
273
285
 
286
+ /** A zones name tag's widest text (logical units): longer names are cut with an ellipsis. */
287
+ export const TAG_TEXT_W = 84;
288
+ const TAG_FONT = "600 10px system-ui, sans-serif";
289
+ /** The box a name tag under feet at (x, y) takes (what drawTag draws; bubbles keep clear of it, #2513). */
290
+ export function tagBox(ctx, x, y, label) {
291
+ ctx.save();
292
+ ctx.font = TAG_FONT;
293
+ const t = fitText(ctx, label, TAG_TEXT_W), w = Math.ceil((ctx.measureText ? ctx.measureText(t).width : t.length * 6) + 10);
294
+ ctx.restore();
295
+ return { x: Math.round(x - w / 2), y: y + 3, w, h: 14, text: t };
296
+ }
274
297
  /** Zones: a name tag under someone's feet at (x, y). */
275
298
  function drawTag(ctx, x, y, look, env) {
276
299
  const { pal } = env;
277
300
  ctx.save();
278
301
  if (look.dim) ctx.globalAlpha = 0.35;
279
- ctx.font = "600 10px system-ui, sans-serif";
280
- const t = fitText(ctx, look.label, 84), w = Math.ceil((ctx.measureText ? ctx.measureText(t).width : t.length * 6) + 10);
302
+ ctx.font = TAG_FONT;
303
+ const t = fitText(ctx, look.label, TAG_TEXT_W), w = Math.ceil((ctx.measureText ? ctx.measureText(t).width : t.length * 6) + 10);
281
304
  ctx.fillStyle = pal.surface; ctx.fillRect(Math.round(x - w / 2), y + 3, w, 14);
282
305
  ctx.strokeStyle = look.match ? pal.focus : pal.border; ctx.lineWidth = look.match ? 2 : 1; ctx.strokeRect(Math.round(x - w / 2) + 0.5, y + 3.5, w - 1, 13);
283
306
  ctx.fillStyle = pal.text; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(t, x, y + 10.5);
284
307
  ctx.restore();
285
308
  }
286
309
 
310
+ /**
311
+ * Zones: how many more want an area than it has room for — a chip in its bottom-right corner, inside its rect, so it
312
+ * never covers the area's name (#2513: "+42" over the nap corner's title).
313
+ */
314
+ export function crowdChip(ctx, area, n) {
315
+ ctx.save();
316
+ ctx.font = "700 12px system-ui, sans-serif";
317
+ const text = `+${n}`, w = Math.ceil((ctx.measureText ? ctx.measureText(text).width : text.length * 7) + 12), h = 18, r = area.rect;
318
+ ctx.restore();
319
+ return { x: Math.round(r.x + r.w - w - 6), y: Math.round(r.y + r.h - h - 6), w, h, text };
320
+ }
321
+ function drawCrowd(ctx, area, n, env) {
322
+ if (!n) return;
323
+ const { pal } = env, b = crowdChip(ctx, area, n);
324
+ ctx.save();
325
+ ctx.fillStyle = pal.surface; ctx.fillRect(b.x, b.y, b.w, b.h);
326
+ ctx.strokeStyle = pal.border; ctx.lineWidth = 1; ctx.strokeRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1);
327
+ ctx.fillStyle = pal.text; ctx.font = "700 12px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
328
+ ctx.fillText(b.text, b.x + b.w / 2, b.y + b.h / 2 + 0.5);
329
+ ctx.restore();
330
+ }
331
+
332
+ /** The built-in character in zones covers this box around its feet (as zonesLayout's desk box). */
333
+ const charBox = (look, at) => ({ x: at.x - 24, y: at.y - 52, w: 48, h: 66 });
334
+ /** #2540: Desks' matte (around the scene): the page's background token, which the desks' floor is too (Studio has its own). */
335
+ const matte = (env) => env.pal.bg;
287
336
  /** @type {OfficeSkin} */
288
- export const pixelSkin = { id: "pixel", drawFloor, drawRoom, drawDeskBack, drawCharacter, drawDeskFront, drawProp, drawBubble, drawTag };
337
+ export const pixelSkin = { id: "pixel", drawFloor, drawRoom, drawDeskBack, drawCharacter, drawDeskFront, drawProp, drawBubble, drawTag, drawCrowd, charBox, matte };
289
338
 
290
339
  // ── The built-in studio: the zones layout, drawn procedurally ──
291
340
 
@@ -346,8 +395,6 @@ function drawBackground(ctx, L, env) {
346
395
  ctx.strokeStyle = pal.border; ctx.lineWidth = 2; ctx.strokeRect(r.x + 1, r.y + 1, r.w - 2, r.h - 2);
347
396
  ctx.fillStyle = pal.text; ctx.font = "600 16px system-ui, sans-serif"; ctx.textBaseline = "top"; ctx.textAlign = "left";
348
397
  ctx.fillText(fitText(ctx, env.labels.areas[a.name] || a.name, r.w - 60), r.x + 12, r.y + 10);
349
- const crowd = L.crowd && L.crowd.get(a.name);
350
- if (crowd) { ctx.fillStyle = pal.text2; ctx.textAlign = "right"; ctx.fillText(`+${crowd}`, r.x + r.w - 12, r.y + 10); }
351
398
  ctx.restore();
352
399
  }
353
400
  }
@@ -396,9 +443,11 @@ function drawFurniture(ctx, f, active, ms, env) {
396
443
  }
397
444
  }
398
445
  /** @type {OfficeSkin} */
399
- export const studioSkin = { ...pixelSkin, id: "studio", pack: STUDIO_PACK, drawBackground, drawFurniture };
446
+ /** #2540: the studio's matte is its hall floor (the scene's edge), so the panel around the scene reads as more of it. */
447
+ const studioMatte = (env) => (ZONE_FLOOR[env.mode] || ZONE_FLOOR.dark).hall;
448
+ export const studioSkin = { ...pixelSkin, id: "studio", pack: STUDIO_PACK, drawBackground, drawFurniture, matte: studioMatte };
400
449
 
401
- // ── Skin packs (the seam; nothing registers one yet) ──
450
+ // ── Skin packs ──
402
451
 
403
452
  /**
404
453
  * An OfficeSkin from a checked pack (fleet-office-pack.js checkSkinPack) and its loaded pictures (`images`: src → a
@@ -406,20 +455,29 @@ export const studioSkin = { ...pixelSkin, id: "studio", pack: STUDIO_PACK, drawB
406
455
  */
407
456
  export function spriteSkin(pack, images, base = pixelSkin) {
408
457
  const img = (src) => (src && images.get(src)) || null;
409
- const strip = (ctx, st, ms, x, y, frame = null) => {
458
+ // A strip's frame at its displayed size (dw × dh, unsmoothed), its anchor on (x, y); mirrored about the anchor.
459
+ const strip = (ctx, st, ms, x, y, frame = null, mirror = false) => {
410
460
  const im = img(st && st.src);
411
461
  if (!im) return false;
412
- const f = frame ?? frameAt(ms, st.frames, st.fps), s = pack.scale;
462
+ const f = frame ?? frameAt(ms, st.frames, st.fps), b = stripBox(st, x, y, mirror);
413
463
  ctx.save();
414
464
  ctx.imageSmoothingEnabled = false;
415
- ctx.drawImage(im, f * st.w, 0, st.w, st.h, Math.round(x - st.ax * s), Math.round(y - st.ay * s), st.w * s, st.h * s);
465
+ if (mirror) { ctx.translate(b.x + b.w, b.y); ctx.scale(-1, 1); ctx.drawImage(im, f * st.w, 0, st.w, st.h, 0, 0, b.w, b.h); }
466
+ else ctx.drawImage(im, f * st.w, 0, st.w, st.h, b.x, b.y, b.w, b.h);
416
467
  ctx.restore();
417
468
  return true;
418
469
  };
470
+ const setOf = (look) => own(pack.characters, own(pack.byInstance, look.name)) || own(pack.characters, "default");
471
+ /** The strip a look shows (its pose; walking, by its heading), when its picture is loaded. */
472
+ const shown = (look, walking = look.pose === "walk") => {
473
+ const ps = poseStrip(setOf(look), walking ? "walk" : look.pose, look.heading);
474
+ return ps && img(ps.strip.src) ? ps : null;
475
+ };
419
476
  return {
420
477
  ...base,
421
478
  id: pack.id,
422
479
  pack,
480
+ backdrop: img(pack.background),
423
481
  drawRoom(ctx, room, env) {
424
482
  const floor = img(pack.room.floor), wall = img(pack.room.wall);
425
483
  if (!floor) { base.drawRoom(ctx, room, env); return; }
@@ -448,6 +506,11 @@ export function spriteSkin(pack, images, base = pixelSkin) {
448
506
  if (!im) { drawBackground(ctx, L, env); return; }
449
507
  ctx.save(); ctx.imageSmoothingEnabled = false; ctx.drawImage(im, 0, 0, LOGICAL.w, LOGICAL.h); ctx.restore();
450
508
  },
509
+ drawForeground(ctx, L, env) {
510
+ const im = img(pack.foreground);
511
+ if (!im) return;
512
+ ctx.save(); ctx.imageSmoothingEnabled = false; ctx.drawImage(im, 0, 0, LOGICAL.w, LOGICAL.h); ctx.restore();
513
+ },
451
514
  drawFurniture(ctx, f, active, ms, env) {
452
515
  if (f.shape) { drawFurniture(ctx, f, active, ms, env); return; }
453
516
  // Active: its strip (or activeSprite) plays; otherwise its first frame holds.
@@ -456,19 +519,67 @@ export function spriteSkin(pack, images, base = pixelSkin) {
456
519
  },
457
520
  drawCharacter(ctx, d, look, ms, env) {
458
521
  if (look.pose === "away") return;
459
- const set = pack.characters[pack.byInstance[look.name]] || pack.characters.default;
522
+ const ps = shown(look);
460
523
  ctx.save();
461
524
  if (look.dim) ctx.globalAlpha = 0.35;
462
- const done = !!set && strip(ctx, set[look.pose] || set.idle, ms, d.seat.x, d.seat.y);
525
+ const done = !!ps && strip(ctx, ps.strip, ms, d.seat.x, d.seat.y, null, ps.mirror);
463
526
  ctx.restore();
464
527
  if (!done) base.drawCharacter(ctx, d, look, ms, env);
465
528
  },
466
- drawProp(ctx, it, ms, env) { if (!strip(ctx, pack.props[it.hue], ms, it.x, it.y)) base.drawProp(ctx, it, ms, env); },
529
+ charBox(look, at) {
530
+ const ps = shown({ ...look, heading: at.heading }, !!at.walking);
531
+ return ps ? stripBox(ps.strip, at.x, at.y, ps.mirror) : base.charBox(look, at);
532
+ },
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); },
467
534
  };
468
535
  }
469
536
 
470
- /** The skins this page has: the built-in ones (a pack would be added here once loaded). */
537
+ /** The skins built into this page; an uploaded pack is loaded by loadPackSkin. */
471
538
  export const SKINS = new Map([["pixel", pixelSkin], ["studio", studioSkin]]);
472
- export const skinFor = (id) => SKINS.get(id) || pixelSkin;
539
+ export const BUILT_IN_SKINS = ["pixel", "studio"];
540
+ /** A skin by id: a built-in one, else a pack loaded here (`packs`: id → skin), else the default (pixel). */
541
+ export const skinFor = (id, packs = null) => SKINS.get(id) || (packs && packs.get(id)) || pixelSkin;
542
+
543
+ const loaded = new Map(); // `${id}@${sha}` → Promise<OfficeSkin | null>
544
+ const MAX_LOADED = 16;
545
+ const defaultImage = (url) => new Promise((resolve) => {
546
+ if (typeof Image !== "function") { resolve(null); return; }
547
+ const im = new Image();
548
+ im.onload = () => resolve(im);
549
+ im.onerror = () => resolve(null);
550
+ im.src = url;
551
+ });
552
+ /**
553
+ * An uploaded pack (GET /ui/office-skins/<id>?v=<sha>) as an OfficeSkin: its manifest checked (null when it is not a
554
+ * pack: the page then draws a built-in style and says so), its pictures loaded from their content ids (one that does
555
+ * not load is drawn by the built-in skin). Kept per id and sha, so a pack is fetched once.
556
+ */
557
+ export function loadPackSkin(id, sha, { fetch: get = globalThis.fetch, image = defaultImage } = {}) {
558
+ const key = `${id}@${sha}`;
559
+ if (loaded.has(key)) return loaded.get(key);
560
+ const base = `/ui/office-skins/${encodeURIComponent(id)}`;
561
+ const job = (async () => {
562
+ try {
563
+ const r = await get(`${base}?v=${encodeURIComponent(sha)}`, { method: "GET", headers: { "Content-Type": "application/json" } });
564
+ if (!r || !r.ok) return null;
565
+ const d = await r.json();
566
+ const c = checkSkinPack(d && d.manifest);
567
+ if (!c.ok || c.pack.id !== id) return null;
568
+ const files = d.files && typeof d.files === "object" ? d.files : {};
569
+ const images = new Map();
570
+ await Promise.all(packImages(c.pack).map(async (src) => {
571
+ const fid = own(files, src);
572
+ if (typeof fid !== "string" || !/^[0-9a-f]{32}$/.test(fid)) return;
573
+ const im = await image(`${base}/img/${fid}.png`);
574
+ if (im) images.set(src, im);
575
+ }));
576
+ return spriteSkin(c.pack, images);
577
+ } catch { return null; }
578
+ })();
579
+ loaded.set(key, job);
580
+ if (loaded.size > MAX_LOADED) loaded.delete(loaded.keys().next().value);
581
+ job.then((skin) => { if (!skin) loaded.delete(key); }); // a failed read is tried again next time
582
+ return job;
583
+ }
473
584
  /** The people's depth in zones (fleet-office-pack.js). */
474
585
  export { CHAR_DEPTH };
@@ -2,12 +2,32 @@
2
2
  // - the offices people made: the host's (GET / PUT /ui/offices, src/web-office-store.ts — one list for every viewer);
3
3
  // what comes back is checked again here (fleet-office-model.js officesOk), never trusted;
4
4
  // - this browser's own choices (localStorage; blocked storage: this page only, as the canvas's Canvas/List): which
5
- // office is shown, the auto office's member filter, and its style (skin).
6
- // TODO(office editor): adding and renaming offices, assigning people, dragging seats and drawing paths call
7
- // saveOffices() with the whole list and the rev they read (a change in between answers 409).
5
+ // office is shown, the auto office's member filter, and its style (skin); #2540: the view (zoom and centre) and the
6
+ // fit mode (Fit or Fill) per office; #2554: whether All offices shows the lobby tile.
7
+ // - the skin packs an admin uploaded (#2513: GET /ui/office-skins, POST to upload one, DELETE to remove one;
8
+ // src/web-office-skins.ts). The office editor and the layout editor call saveOffices() with the whole list and the rev
9
+ // they read (a change in between answers 409).
8
10
  import { AUTO_ID, filterOk, officesOk } from "./fleet-office-model.js";
11
+ import { fitModeOk, savedOk } from "./fleet-office-view.js";
9
12
 
10
- const CHOICE_KEY = "agend_org_office", FILTER_KEY = "agend_org_office_filter", SKIN_KEY = "agend_org_office_skin";
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";
14
+ /** #2554: "0" when All offices leaves the lobby tile out (default: shown). */
15
+ const LOBBY_KEY = "agend_org_office_lobby";
16
+ /** #2540: { "<office>@<style>": [k, cx, cy] (and a 1 after them: a fit) } — the last VIEWS_MAX views, the most recent last. */
17
+ const VIEW_KEY = "agend_org_office_view", VIEWS_MAX = 32;
18
+ /** #2540: { "<office>@<style>": "cover" } — the offices whose fit fills the panel (the rest: "contain"), the last VIEWS_MAX. */
19
+ const FIT_KEY = "agend_org_office_fit";
20
+ const PACK_ID = /^[A-Za-z0-9_-]{1,40}$/;
21
+ /** The host's list of skin packs, checked: [{ id, name, sha }] (anything odd dropped). */
22
+ export function skinListOk(d) {
23
+ const out = [], ids = new Set();
24
+ for (const p of Array.isArray(d && d.packs) ? d.packs.slice(0, 64) : []) {
25
+ if (!p || typeof p.id !== "string" || !PACK_ID.test(p.id) || ids.has(p.id) || typeof p.sha !== "string" || !/^[0-9a-f]{32}$/.test(p.sha)) continue;
26
+ ids.add(p.id);
27
+ out.push({ id: p.id, name: typeof p.name === "string" && p.name.trim() ? p.name.trim().slice(0, 60) : p.id, sha: p.sha });
28
+ }
29
+ return out;
30
+ }
11
31
 
12
32
  /** `fetch`: the caller's (a lease's, so an answer after the person moved on is dropped); `storage`: default localStorage. */
13
33
  export function createOfficeStore({ fetch: get = globalThis.fetch, storage } = {}) {
@@ -37,7 +57,35 @@ export function createOfficeStore({ fetch: get = globalThis.fetch, storage } = {
37
57
  return r.ok ? { ok: true, rev: d.rev, offices: officesOk(d.offices) } : { ok: false, status: r.status, error: d.error || "offices_failed" };
38
58
  } catch { return { ok: false, status: 0, error: "offices_failed" }; }
39
59
  },
40
- /** The office shown here: an id (one the host no longer has falls back to the auto office where it is used). */
60
+ /** The skin packs the host keeps: { rev, packs: [{ id, name, sha }] }; null when they could not be read. */
61
+ async skins() {
62
+ try {
63
+ const r = await get("/ui/office-skins", { method: "GET", headers });
64
+ if (!r || !r.ok) return null;
65
+ const d = await r.json();
66
+ return { rev: typeof (d && d.rev) === "string" ? d.rev : "", packs: skinListOk(d) };
67
+ } catch { return null; }
68
+ },
69
+ /** Upload a pack (an admin session): { manifest, files: { name: base64 }, name? } → { ok, pack } or { ok: false, status, error, detail }. */
70
+ async uploadSkin(body) {
71
+ try {
72
+ const r = await get("/ui/office-skins", { method: "POST", headers, body: JSON.stringify(body) });
73
+ const d = await r.json().catch(() => ({}));
74
+ return r.ok ? { ok: true, pack: d.pack, rev: d.rev } : { ok: false, status: r.status, error: d.error || "skin_failed", detail: d.detail || "" };
75
+ } catch { return { ok: false, status: 0, error: "skin_failed", detail: "" }; }
76
+ },
77
+ /** Remove a pack (an admin session). */
78
+ async deleteSkin(id) {
79
+ try {
80
+ const r = await get(`/ui/office-skins/${encodeURIComponent(id)}`, { method: "DELETE", headers });
81
+ const d = await r.json().catch(() => ({}));
82
+ return r.ok ? { ok: true } : { ok: false, status: r.status, error: d.error || "skin_failed" };
83
+ } catch { return { ok: false, status: 0, error: "skin_failed" }; }
84
+ },
85
+ /**
86
+ * The office shown here: an id (one the host no longer has falls back to the auto office where it is used), or
87
+ * #2554's All offices (fleet-office-overview.js ALL_ID; with fewer than two offices stored: the auto office).
88
+ */
41
89
  choice: () => { const v = read(CHOICE_KEY); return typeof v === "string" && v && v.length <= 40 ? v : AUTO_ID; },
42
90
  saveChoice: (id) => write(CHOICE_KEY, String(id)),
43
91
  filter: () => { const s = read(FILTER_KEY); try { return filterOk(s ? JSON.parse(s) : null); } catch { return filterOk(null); } },
@@ -45,5 +93,36 @@ export function createOfficeStore({ fetch: get = globalThis.fetch, storage } = {
45
93
  /** The whole fleet's style here: a skin id ("pixel": desks, "studio": zones); an unknown one is the default where used. */
46
94
  skin: () => { const v = read(SKIN_KEY); return typeof v === "string" && /^[A-Za-z0-9_-]{1,40}$/.test(v) ? v : "pixel"; },
47
95
  saveSkin: (id) => write(SKIN_KEY, String(id)),
96
+ /** Whether paused and stopped people are left out of the Office here (default: shown). */
97
+ hideAsleep: () => read(ASLEEP_KEY) === "1",
98
+ saveHideAsleep: (v) => write(ASLEEP_KEY, v ? "1" : "0"),
99
+ /** #2554: whether All offices shows the lobby tile (the instances in no stored office) here (default: shown). */
100
+ lobbyTile: () => read(LOBBY_KEY) !== "0",
101
+ saveLobbyTile: (v) => write(LOBBY_KEY, v ? "1" : "0"),
102
+ /** The view kept for `key` (an office and its style): { k, cx, cy } (fleet-office-view.js savedOf), or null. */
103
+ view: (key) => {
104
+ try { const m = JSON.parse(read(VIEW_KEY) || "{}"); const a = m && Object.prototype.hasOwnProperty.call(m, key) ? m[key] : null;
105
+ return Array.isArray(a) ? savedOk({ k: a[0], cx: a[1], cy: a[2], fit: a[3] === 1 }) : null; } catch { return null; }
106
+ },
107
+ saveView: (key, v) => {
108
+ const ok = savedOk(v);
109
+ if (!ok || typeof key !== "string" || key.length > 100) return;
110
+ let m = {};
111
+ try { const o = JSON.parse(read(VIEW_KEY) || "{}"); if (o && typeof o === "object" && !Array.isArray(o)) m = o; } catch { m = {}; }
112
+ const r = (n) => Math.round(n * 1000) / 1000;
113
+ const next = Object.entries(m).filter(([k2, a]) => k2 !== key && Array.isArray(a)).slice(-(VIEWS_MAX - 1));
114
+ write(VIEW_KEY, JSON.stringify(Object.fromEntries([...next, [key, [r(ok.k), r(ok.cx), r(ok.cy), ...(ok.fit ? [1] : [])]]])));
115
+ },
116
+ /** The fit mode kept for `key`: "contain" (the default) or "cover" (Fill). */
117
+ fitMode: (key) => {
118
+ try { const m = JSON.parse(read(FIT_KEY) || "{}"); return fitModeOk(m && Object.prototype.hasOwnProperty.call(m, key) ? m[key] : null); } catch { return "contain"; }
119
+ },
120
+ saveFitMode: (key, mode) => {
121
+ if (typeof key !== "string" || key.length > 100) return;
122
+ let m = {};
123
+ try { const o = JSON.parse(read(FIT_KEY) || "{}"); if (o && typeof o === "object" && !Array.isArray(o)) m = o; } catch { m = {}; }
124
+ const next = Object.entries(m).filter(([k2, v]) => k2 !== key && v === "cover").slice(-(VIEWS_MAX - 1));
125
+ write(FIT_KEY, JSON.stringify(Object.fromEntries(fitModeOk(mode) === "cover" ? [...next, [key, "cover"]] : next)));
126
+ },
48
127
  };
49
128
  }
@@ -0,0 +1,154 @@
1
+ // #2540: the Office view's canvas controls — the pure part (fleet-office.js applies it). The office fills the Fleet
2
+ // panel; its scene (the zones' 1280×720 logical canvas, or the desks layout) is panned and zoomed like the org chart's
3
+ // canvas (fleet-canvas-view.js: the same view { k, x, y }, gestures, wheel and keys), with these of its own:
4
+ //
5
+ // - Pixel levels for the steps. + and − step to the next pixel level (one logical unit a whole number of device px:
6
+ // k·dpr = 1, 2, 3, …; below one device px a unit by 1.25); the canvas always draws unsmoothed (nearest neighbour).
7
+ // The wheel and a pinch zoom continuously, and Fit takes whatever scale fills the panel.
8
+ // - Fit: two modes, kept per office (fleet-office-store.js). "contain" (the default): the largest scale at which the
9
+ // whole scene shows — non-integer, so the scene fills the panel's width or height. "cover" (Fill): the smallest
10
+ // scale that fills both; what overflows is cropped and stays a pan away. Either is centred.
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.
13
+ //
14
+ // Around the scene is a matte: the pack's `matte` (fleet-office-pack.js), else the colour of the background's edge
15
+ // pixels (sampled once per style: edgeMatte), else the skin's own (Studio's hall floor; Desks': the dashboard's
16
+ // background). It paints the whole canvas outside the scene (and the stage behind it), never the page's grey.
17
+ import { clampK, pinchTo, zoomTo } from "./fleet-canvas-view.js";
18
+ import { CANVAS_MAX } from "./fleet-office-model.js";
19
+
20
+ /** The zoom's range, in CSS px per logical unit. */
21
+ export const OK_MIN = 0.1, OK_MAX = 8;
22
+ const EPS = 1e-6;
23
+ const clampO = (k) => clampK(k, OK_MIN, OK_MAX);
24
+ /** The highest pixel level within the range at this ratio (at least 1). */
25
+ const topLevel = (dpr) => Math.max(1, Math.floor(OK_MAX * dpr + EPS));
26
+
27
+ /** `k` on its pixel level: k·dpr rounded to a whole number when it is 1 or more; as it is (in range) below that. */
28
+ export function snapK(k, dpr = 1) {
29
+ const c = clampO(k), n = c * dpr;
30
+ if (n < 1 - EPS) return c;
31
+ return Math.min(topLevel(dpr), Math.max(1, Math.round(n))) / dpr;
32
+ }
33
+ /** Whether `k` is a pixel level (a whole number of device px per logical unit). */
34
+ export const isPixelK = (k, dpr = 1) => k * dpr >= 1 - EPS && Math.abs(k * dpr - Math.round(k * dpr)) < 1e-6;
35
+ /** One step in (`dir` > 0) or out: the next pixel level; below one device pixel a unit, by 1.25. */
36
+ export function stepK(k, dir, dpr = 1) {
37
+ const n = k * dpr;
38
+ if (dir > 0) {
39
+ if (n >= 1 - EPS) return Math.min(topLevel(dpr), Math.floor(n + EPS) + 1) / dpr;
40
+ return n * 1.25 >= 1 - EPS ? 1 / dpr : clampO(k * 1.25);
41
+ }
42
+ if (n > 1 + EPS) return Math.max(1, Math.ceil(n - EPS) - 1) / dpr;
43
+ return clampO(k * 0.8);
44
+ }
45
+ /** The view at scale `k` (in range, continuous: the wheel's) about the viewport point (cx, cy). */
46
+ export const zoomOffice = (v, k, cx, cy) => zoomTo(v, clampO(k), cx, cy);
47
+ /** A pinch step: `raw` the zoom the pinch has reached (continuous); the world point under the old midpoint follows. */
48
+ export const pinchOffice = (v, from, to, raw) => pinchTo(v, from, to, clampO(raw));
49
+
50
+ /** The fit modes: "contain" (the whole scene shows) and "cover" (the panel is filled, the overflow cropped). */
51
+ export const FIT_MODES = ["contain", "cover"];
52
+ export const fitModeOk = (m) => (m === "cover" ? "cover" : "contain");
53
+ /** The scale at which a `w`×`h` scene fits a `size` viewport: contain (the whole scene) or cover (the whole panel). */
54
+ export function fitK(w, h, size, mode = "contain", margin = 0) {
55
+ const sx = (size.w - 2 * margin) / Math.max(1, w), sy = (size.h - 2 * margin) / Math.max(1, h);
56
+ return clampO(fitModeOk(mode) === "cover" ? Math.max(sx, sy) : Math.min(sx, sy));
57
+ }
58
+ /** The scene at fitK, centred. */
59
+ export function fitScene(w, h, size, mode = "contain", margin = 0) {
60
+ const k = fitK(w, h, size, mode, margin);
61
+ return { k, x: (size.w - w * k) / 2, y: (size.h - h * k) / 2 };
62
+ }
63
+ /**
64
+ * The desks layout's scale when it opens: the pixel level nearest one CSS px a unit (1 on an ordinary or a 2× screen,
65
+ * 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
+ 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
+ export const openingView = (w, h, size, dpr, zones, mode = "contain") => (zones ? fitScene(w, h, size, mode) : { k: desksK(dpr), x: 0, y: 0 });
70
+ /** 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
+ /**
73
+ * 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
+ */
76
+ export function clampPan(v, w, h, size) {
77
+ return { k: v.k, x: clampAxis(v.x, w * v.k, size.w), y: clampAxis(v.y, h * v.k, size.h) };
78
+ }
79
+ /** The zoom as a person reads it: "150%". */
80
+ export const zoomLabel = (k) => `${Math.round(k * 100)}%`;
81
+ /**
82
+ * The overview's box in a `size` stage: the scene's aspect, about a seventh of the stage's width (120–280 px, at most
83
+ * a third of its height), `margin` px in from the bottom right corner — always inside the stage.
84
+ */
85
+ export function miniBox(size, w, h, margin = 12) {
86
+ const ar = Math.max(1, w) / Math.max(1, h), roomW = Math.max(0, size.w - 2 * margin), roomH = Math.max(0, size.h - 2 * margin);
87
+ let bw = Math.min(280, Math.max(120, Math.round(size.w / 7)), roomW), bh = bw / ar;
88
+ const maxH = Math.min(roomH, Math.max(60, size.h / 3));
89
+ if (bh > maxH) { bh = maxH; bw = bh * ar; }
90
+ bw = Math.floor(bw); bh = Math.floor(bh);
91
+ return { x: size.w - margin - bw, y: size.h - margin - bh, w: bw, h: bh };
92
+ }
93
+ /** The view's frame on the overview (logical units): the part of the scene in view (never outside it). */
94
+ export function miniFrame(v, size, w, h) {
95
+ const x0 = Math.max(0, -v.x / v.k), y0 = Math.max(0, -v.y / v.k);
96
+ const x1 = Math.min(w, (size.w - v.x) / v.k), y1 = Math.min(h, (size.h - v.y) / v.k);
97
+ return { x: x0, y: y0, width: Math.max(0, x1 - x0), height: Math.max(0, y1 - y0) };
98
+ }
99
+
100
+ // ── Kept per device, per office: the zoom and the logical point in the middle (the viewport's size may differ) ──
101
+
102
+ /** What is kept of a view: { k, cx, cy }, and `fit: true` when it is a fit (it is fitted again, to the size then). */
103
+ export const savedOf = (v, size, fit = false) => ({ k: v.k, cx: (size.w / 2 - v.x) / v.k, cy: (size.h / 2 - v.y) / v.k, ...(fit ? { fit: true } : {}) });
104
+ /** A kept view, checked: { k, cx, cy } with finite numbers (k in range), else null. */
105
+ export function savedOk(x) {
106
+ if (!x || typeof x !== "object") return null;
107
+ const { k, cx, cy } = x;
108
+ if (![k, cx, cy].every((n) => typeof n === "number" && Number.isFinite(n)) || k <= 0) return null;
109
+ if (Math.abs(cx) > 1e6 || Math.abs(cy) > 1e6) return null;
110
+ return { k: clampO(k), cx, cy, ...(x.fit === true ? { fit: true } : {}) };
111
+ }
112
+ /** The view a kept one gives in a `size` viewport: the same zoom, the same logical point in the middle. */
113
+ export function viewFromSaved(s, size) {
114
+ const k = clampO(s.k);
115
+ return { k, x: size.w / 2 - s.cx * k, y: size.h / 2 - s.cy * k };
116
+ }
117
+
118
+ // ── Drawing ──
119
+
120
+ /** The cached layers' budget (device px): beyond it they are drawn coarser and scaled up by a whole number. */
121
+ export const LAYER_MAX = { side: CANVAS_MAX.side, area: 8 * 1024 * 1024 };
122
+ /**
123
+ * The layers' resolution (layer px per logical unit) for a view at `a` device px a unit and a `w`×`h` scene: `a`
124
+ * itself (the layer is blitted 1:1), or — over the budget — `a` divided by the smallest whole number that fits it,
125
+ * so the blit is still an integer scale-up (nearest neighbour: crisp).
126
+ */
127
+ export function layerScale(a, w, h, max = LAYER_MAX) {
128
+ const cap = Math.min(max.side / Math.max(1, w), max.side / Math.max(1, h), Math.sqrt(max.area / Math.max(1, w * h)));
129
+ if (a <= cap) return a;
130
+ return a / Math.ceil(a / cap - EPS);
131
+ }
132
+ /**
133
+ * The matte from a background's edge pixels: `strips` are RGBA pixel arrays (the top and bottom rows, the left and
134
+ * right columns); the mean of their opaque pixels as #rrggbb, or null when none is opaque.
135
+ */
136
+ export function edgeMatte(strips) {
137
+ let r = 0, g = 0, b = 0, n = 0;
138
+ for (const s of strips) {
139
+ if (!s || typeof s.length !== "number") continue;
140
+ for (let i = 0; i + 3 < s.length; i += 4) { if (s[i + 3] < 128) continue; r += s[i]; g += s[i + 1]; b += s[i + 2]; n++; }
141
+ }
142
+ if (!n) return null;
143
+ const hx = (v) => Math.round(v / n).toString(16).padStart(2, "0");
144
+ return `#${hx(r)}${hx(g)}${hx(b)}`;
145
+ }
146
+ /** The edge strips of a canvas (its 2d context's pixels), for edgeMatte; null when they cannot be read. */
147
+ export function edgeStrips(c) {
148
+ try {
149
+ const g = c && typeof c.getContext === "function" ? c.getContext("2d") : null;
150
+ if (!g || typeof g.getImageData !== "function" || !(c.width > 0) || !(c.height > 0)) return null;
151
+ const w = c.width, h = c.height;
152
+ return [g.getImageData(0, 0, w, 1).data, g.getImageData(0, h - 1, w, 1).data, g.getImageData(0, 0, 1, h).data, g.getImageData(w - 1, 0, 1, h).data];
153
+ } catch { return null; } // a tainted or lost canvas: the skin's matte
154
+ }