@songsid/agend 2.3.2-beta.14 → 2.3.2-beta.16

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 (145) hide show
  1. package/dist/backend/codex-session-lookup.d.ts +15 -0
  2. package/dist/backend/codex-session-lookup.js +58 -0
  3. package/dist/backend/codex-session-lookup.js.map +1 -1
  4. package/dist/backend/codex.js +15 -4
  5. package/dist/backend/codex.js.map +1 -1
  6. package/dist/backend/kiro-classic-archive.d.ts +5 -0
  7. package/dist/backend/kiro-classic-archive.js +132 -7
  8. package/dist/backend/kiro-classic-archive.js.map +1 -1
  9. package/dist/backend/kiro-identity.d.ts +2 -0
  10. package/dist/backend/kiro-identity.js +4 -0
  11. package/dist/backend/kiro-identity.js.map +1 -1
  12. package/dist/backend/kiro-reconcile-store.d.ts +27 -0
  13. package/dist/backend/kiro-reconcile-store.js +344 -0
  14. package/dist/backend/kiro-reconcile-store.js.map +1 -0
  15. package/dist/backend/kiro-v2-store-worker.js +12 -3
  16. package/dist/backend/kiro-v2-store-worker.js.map +1 -1
  17. package/dist/backend/kiro-v2-store.d.ts +11 -0
  18. package/dist/backend/kiro-v2-store.js.map +1 -1
  19. package/dist/cache-service.d.ts +13 -1
  20. package/dist/cache-service.js +45 -2
  21. package/dist/cache-service.js.map +1 -1
  22. package/dist/channel/mcp-tools.js +5 -3
  23. package/dist/channel/mcp-tools.js.map +1 -1
  24. package/dist/cli.js +17 -4
  25. package/dist/cli.js.map +1 -1
  26. package/dist/config-validator.js +4 -1
  27. package/dist/config-validator.js.map +1 -1
  28. package/dist/fleet-manager.d.ts +23 -1
  29. package/dist/fleet-manager.js +127 -9
  30. package/dist/fleet-manager.js.map +1 -1
  31. package/dist/general-instance.d.ts +24 -0
  32. package/dist/general-instance.js +53 -0
  33. package/dist/general-instance.js.map +1 -1
  34. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +2 -0
  35. package/dist/general-knowledge/skills/web-appearance/SKILL.md +2 -0
  36. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +16 -9
  37. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +16 -9
  38. package/dist/instance-config-impact.js +1 -0
  39. package/dist/instance-config-impact.js.map +1 -1
  40. package/dist/kiro-reconcile.d.ts +21 -0
  41. package/dist/kiro-reconcile.js +80 -0
  42. package/dist/kiro-reconcile.js.map +1 -0
  43. package/dist/kiro-tui-forensic.d.ts +7 -0
  44. package/dist/kiro-tui-forensic.js +17 -3
  45. package/dist/kiro-tui-forensic.js.map +1 -1
  46. package/dist/locale.js +20 -0
  47. package/dist/locale.js.map +1 -1
  48. package/dist/log-service.js +1 -1
  49. package/dist/log-service.js.map +1 -1
  50. package/dist/office-levels.d.ts +64 -0
  51. package/dist/office-levels.js +66 -0
  52. package/dist/office-levels.js.map +1 -0
  53. package/dist/office-share-page.d.ts +3 -1
  54. package/dist/office-share-page.js +8 -3
  55. package/dist/office-share-page.js.map +1 -1
  56. package/dist/office-share-service.d.ts +4 -3
  57. package/dist/office-share-service.js +6 -6
  58. package/dist/office-share-service.js.map +1 -1
  59. package/dist/office-share.d.ts +35 -8
  60. package/dist/office-share.js +50 -15
  61. package/dist/office-share.js.map +1 -1
  62. package/dist/outbound-handlers.js +20 -1
  63. package/dist/outbound-handlers.js.map +1 -1
  64. package/dist/outbound-schemas.d.ts +6 -0
  65. package/dist/outbound-schemas.js +6 -0
  66. package/dist/outbound-schemas.js.map +1 -1
  67. package/dist/public-web-gateway.js +1 -1
  68. package/dist/public-web-gateway.js.map +1 -1
  69. package/dist/quickstart-api.d.ts +25 -1
  70. package/dist/quickstart-api.js +53 -6
  71. package/dist/quickstart-api.js.map +1 -1
  72. package/dist/scheduler/task-board.d.ts +28 -6
  73. package/dist/scheduler/task-board.js +66 -11
  74. package/dist/scheduler/task-board.js.map +1 -1
  75. package/dist/service-installer.d.ts +13 -0
  76. package/dist/service-installer.js +15 -0
  77. package/dist/service-installer.js.map +1 -1
  78. package/dist/service-owner.d.ts +25 -0
  79. package/dist/service-owner.js +43 -2
  80. package/dist/service-owner.js.map +1 -1
  81. package/dist/settings-api.js +4 -0
  82. package/dist/settings-api.js.map +1 -1
  83. package/dist/settings-change.js +2 -0
  84. package/dist/settings-change.js.map +1 -1
  85. package/dist/settings-effect.js +2 -0
  86. package/dist/settings-effect.js.map +1 -1
  87. package/dist/setup-form.js +17 -3
  88. package/dist/setup-form.js.map +1 -1
  89. package/dist/setup-i18n.js +2 -0
  90. package/dist/setup-i18n.js.map +1 -1
  91. package/dist/task-board-waker.js +7 -2
  92. package/dist/task-board-waker.js.map +1 -1
  93. package/dist/tool-permissions.js +1 -0
  94. package/dist/tool-permissions.js.map +1 -1
  95. package/dist/types.d.ts +5 -0
  96. package/dist/ui/fleet-office-card.js +114 -0
  97. package/dist/ui/fleet-office-editor.js +27 -7
  98. package/dist/ui/fleet-office-layout.js +14 -6
  99. package/dist/ui/fleet-office-levels.js +105 -0
  100. package/dist/ui/fleet-office-model.js +9 -1
  101. package/dist/ui/fleet-office-pack.d.ts +6 -0
  102. package/dist/ui/fleet-office-pack.js +62 -6
  103. package/dist/ui/fleet-office-skin.js +39 -3
  104. package/dist/ui/fleet-office-store.js +31 -3
  105. package/dist/ui/fleet-office-view.js +8 -0
  106. package/dist/ui/fleet-office-zones.js +10 -2
  107. package/dist/ui/fleet-office.js +208 -30
  108. package/dist/ui/fleet-org.js +2 -0
  109. package/dist/ui/fleet-task-card.js +2 -2
  110. package/dist/ui/fleet-task-common.js +30 -7
  111. package/dist/ui/fleet-task-work.js +2 -2
  112. package/dist/ui/fleet-tasks.js +10 -11
  113. package/dist/ui/office-share-dialog.js +31 -7
  114. package/dist/ui/office-share-page.js +1 -0
  115. package/dist/ui/panel-log.js +2 -2
  116. package/dist/ui/panel-settings.js +7 -1
  117. package/dist/ui/settings-registry.js +1 -0
  118. package/dist/ui/settings-strings.js +4 -0
  119. package/dist/ui/settings-wizard.js +15 -4
  120. package/dist/ui/shared/app.css +50 -0
  121. package/dist/ui/shared/setup-host-text.js +2 -0
  122. package/dist/usage-rollup.d.ts +34 -3
  123. package/dist/usage-rollup.js +56 -3
  124. package/dist/usage-rollup.js.map +1 -1
  125. package/dist/web-api.d.ts +5 -0
  126. package/dist/web-api.js +116 -14
  127. package/dist/web-api.js.map +1 -1
  128. package/dist/web-auth.js +1 -1
  129. package/dist/web-auth.js.map +1 -1
  130. package/dist/web-office-acts.d.ts +5 -2
  131. package/dist/web-office-acts.js +7 -2
  132. package/dist/web-office-acts.js.map +1 -1
  133. package/dist/web-office-pack-tools.d.ts +10 -1
  134. package/dist/web-office-pack-tools.js +36 -5
  135. package/dist/web-office-pack-tools.js.map +1 -1
  136. package/dist/web-office-skins.js +6 -0
  137. package/dist/web-office-skins.js.map +1 -1
  138. package/dist/web-office-store.d.ts +28 -4
  139. package/dist/web-office-store.js +49 -8
  140. package/dist/web-office-store.js.map +1 -1
  141. package/dist/web-settings-patch.d.ts +3 -1
  142. package/dist/web-settings-patch.js +4 -1
  143. package/dist/web-settings-patch.js.map +1 -1
  144. package/dist/workflow-templates/default.md +1 -1
  145. package/package.json +1 -1
@@ -8,6 +8,9 @@
8
8
  // seats' figures, #2558), a moon, and a thin line to its day place. One that sleeps in place shows it on its day place
9
9
  // (and it follows the item); dragged away, the item has its own night place; its Reset puts back the pack's.
10
10
  //
11
+ // #2736: the mail drop — where letters from other offices land — is a handle too (an envelope). On a pack without
12
+ // one it stands, dashed, where letters land today (fleet-office-zones.js dropPoint); dragged, the office has its own.
13
+ //
11
14
  // #2691: a selected seat lists the office's members under "Reserved for" (several may be picked) and "Lend when
12
15
  // away": whoever it is for takes it first, and nobody else does unless it is lent while they are all elsewhere
13
16
  // (fleet-office-zones.js assignSeats). Saved as the overrides' `seatFor` by area and slot; a reserved seat's handle
@@ -22,13 +25,14 @@ import { html, useEffect, useLayoutEffect, useMemo, useRef, useState } from "/as
22
25
  import { t, register } from "/assets/app-i18n.js";
23
26
  import { SidePanel } from "/assets/side-panel.js";
24
27
  import { LOGICAL, overridesBetween, own } from "./fleet-office-pack.js";
28
+ import { dropPoint } from "./fleet-office-zones.js";
25
29
 
26
30
  register("orgl", {
27
31
  en: {
28
32
  edit: "Edit layout", title: "Edit layout", coords: "Show coordinates", addWaypoint: "Add waypoint", removeWaypoint: "Remove waypoint",
29
- joinTo: "Join to", join: "Join", unjoin: "Unjoin {0}", corridors: "Corridors", none: "Nothing selected: pick a seat, an item, the door or a waypoint.",
33
+ joinTo: "Join to", join: "Join", unjoin: "Unjoin {0}", corridors: "Corridors", none: "Nothing selected: pick a seat, an item, the door, the mail drop or a waypoint.",
30
34
  reset: "Reset to the pack's layout", save: "Save layout", cancel: "Cancel", saved: "Layout saved.", hint: "Drag, or use the arrow keys (Shift: by 10).",
31
- seat: "Seat {0} in {1}", furniture: "Item {0}", night: "Item {0} (night)", door: "Door", waypoint: "Waypoint {0}", at: "{0} at {1}, {2}",
35
+ seat: "Seat {0} in {1}", furniture: "Item {0}", night: "Item {0} (night)", door: "Door", mail: "Mail drop", mailAuto: "Mail drop (where letters land now; drag to place it)", waypoint: "Waypoint {0}", at: "{0} at {1}, {2}",
32
36
  nightReset: "Back to the pack's night place",
33
37
  seatFor: "Seat {0} in {1}, reserved for {2}", reservedFor: "Reserved for", lend: "Lend when away", nobody: "No members to pick.",
34
38
  changed: "Someone changed the offices meanwhile. Your layout is kept: Save again puts it on the current list.",
@@ -37,9 +41,9 @@ register("orgl", {
37
41
  },
38
42
  "zh-TW": {
39
43
  edit: "編輯配置", title: "編輯配置", coords: "顯示座標", addWaypoint: "新增路徑點", removeWaypoint: "移除路徑點",
40
- joinTo: "連到", join: "連接", unjoin: "取消連接 {0}", corridors: "走道", none: "尚未選取:請選座位、家具、門或路徑點。",
44
+ joinTo: "連到", join: "連接", unjoin: "取消連接 {0}", corridors: "走道", none: "尚未選取:請選座位、家具、門、信件落點或路徑點。",
41
45
  reset: "還原成 pack 的配置", save: "儲存配置", cancel: "取消", saved: "配置已儲存。", hint: "拖曳,或用方向鍵移動(Shift:一次 10)。",
42
- seat: "{1} 的座位 {0}", furniture: "家具 {0}", night: "家具 {0}(夜間)", door: "門", waypoint: "路徑點 {0}", at: "{0},位置 {1}, {2}",
46
+ seat: "{1} 的座位 {0}", furniture: "家具 {0}", night: "家具 {0}(夜間)", door: "門", mail: "信件落點", mailAuto: "信件落點(目前信件落下的位置;拖曳即可指定)", waypoint: "路徑點 {0}", at: "{0},位置 {1}, {2}",
43
47
  nightReset: "回到 pack 的夜間位置",
44
48
  seatFor: "{1} 的座位 {0},保留給 {2}", reservedFor: "保留給", lend: "不在時借給別人", nobody: "沒有可選的成員。",
45
49
  changed: "有人在這期間修改了辦公室。你的配置已保留:再按一次儲存,會套用到目前的清單。",
@@ -57,7 +61,7 @@ const nightAt = (f) => (f.night.x !== null && f.night.x !== undefined && f.night
57
61
  /**
58
62
  * Every handle on a pack: { key, kind, label, x, y } — seats by area (#2691: `for` and `lend` when reserved),
59
63
  * furniture, each item's night place (#2665: an item with a `night`; `day` its day place, `inPlace` while it sleeps
60
- * there), the door, the waypoints.
64
+ * there), the door, the mail drop (#2736: `auto` where the pack has none — where letters land now), the waypoints.
61
65
  */
62
66
  export function handlesOf(pack) {
63
67
  const out = [];
@@ -71,6 +75,8 @@ export function handlesOf(pack) {
71
75
  out.push({ key: `night:${f.id}`, kind: "night", label: tl("night", f.id), x: n.x, y: n.y, day: { x: f.x, y: f.y }, inPlace: !n.own });
72
76
  }
73
77
  out.push({ key: "door", kind: "door", label: tl("door"), x: pack.door.x, y: pack.door.y });
78
+ const md = pack.mailDrop || dropPoint({ areas: pack.areas, waypoints: pack.waypoints, width: LOGICAL.w, height: LOGICAL.h });
79
+ if (md) out.push({ key: "mail", kind: "mail", label: tl(pack.mailDrop ? "mail" : "mailAuto"), x: md.x, y: md.y, ...(pack.mailDrop ? {} : { auto: true }) });
74
80
  for (const w of pack.waypoints) out.push({ key: `wp:${w.id}`, kind: "waypoint", label: tl("waypoint", w.id), x: w.x, y: w.y });
75
81
  return out;
76
82
  }
@@ -87,6 +93,7 @@ export function moveHandle(pack, key, x, y) {
87
93
  if (kind === "furn") return { ...pack, furniture: pack.furniture.map((f) => (f.id === a ? { ...f, ...inCanvas() } : f)) };
88
94
  if (kind === "night") return { ...pack, furniture: pack.furniture.map((f) => (f.id === a && f.night ? { ...f, night: { ...f.night, ...inCanvas() } } : f)) };
89
95
  if (kind === "door") return { ...pack, door: inCanvas() };
96
+ if (kind === "mail") return { ...pack, mailDrop: inCanvas() };
90
97
  if (kind === "wp") return { ...pack, waypoints: pack.waypoints.map((w) => (w.id === a ? { ...w, ...inCanvas() } : w)) };
91
98
  return pack;
92
99
  }
@@ -255,10 +262,11 @@ export function LayoutEditor({ base, start, place = null, stale = "", dock = tru
255
262
  ${Object.values(edited.areas).map((a) => html`<rect key=${`a:${a.name}`} class="ol-area" x=${a.rect.x} y=${a.rect.y} width=${a.rect.w} height=${a.rect.h} />`)}
256
263
  ${edited.edges.map((e) => { const p = at.get(e[0]), q = at.get(e[1]); return p && q ? html`<line key=${`e:${edgeKey(e)}`} class="ol-edge" x1=${p.x} y1=${p.y} x2=${q.x} y2=${q.y} />` : null; })}
257
264
  ${handles.filter((h) => h.kind === "night" && !h.inPlace).map((h) => html`<line key=${`nl:${h.key}`} class="ol-night-line" x1=${h.day.x} y1=${h.day.y} x2=${h.x} y2=${h.y} />`)}
258
- ${[...handles.filter((h) => h.kind === "night"), ...handles.filter((h) => h.kind !== "night")].map((h) => html`<g key=${h.key} class=${`ol-handle ol-${h.kind}${h.for ? " reserved" : ""}${sel === h.key ? " on" : ""}`} data-handle=${h.key} tabindex="0" role="button"
265
+ ${[...handles.filter((h) => h.kind === "night"), ...handles.filter((h) => h.kind !== "night")].map((h) => html`<g key=${h.key} class=${`ol-handle ol-${h.kind}${h.for ? " reserved" : ""}${h.auto ? " auto" : ""}${sel === h.key ? " on" : ""}`} data-handle=${h.key} tabindex="0" role="button"
259
266
  aria-label=${tl("at", h.label, h.x, h.y)} aria-pressed=${sel === h.key ? "true" : "false"}
260
267
  onPointerDown=${down(h.key)} onKeyDown=${keyOn(h)} onFocus=${() => setSel(h.key)}>
261
268
  ${h.kind === "door" ? html`<rect x=${h.x - 9} y=${h.y - 9} width="18" height="18" transform=${`rotate(45 ${h.x} ${h.y})`} />`
269
+ : h.kind === "mail" ? html`<rect x=${h.x - 10} y=${h.y - 7} width="20" height="14" /><path class="ol-flap" d=${`M${h.x - 10} ${h.y - 7}L${h.x} ${h.y + 1}L${h.x + 10} ${h.y - 7}`} />`
262
270
  : h.kind === "furniture" ? html`<rect x=${h.x - 8} y=${h.y - 8} width="16" height="16" />`
263
271
  : h.kind === "night" ? html`<circle cx=${h.x} cy=${h.y} r=${h.inPlace ? 15 : 8} /><path class="ol-moon" d=${h.inPlace ? moonPath(h.x + 10, h.y - 10) : moonPath(h.x, h.y)} />`
264
272
  : h.kind === "seat" && h.for ? html`<circle class="ol-for-ring" cx=${h.x} cy=${h.y} r="13" /><circle cx=${h.x} cy=${h.y} r="9" />`
@@ -0,0 +1,105 @@
1
+ // #2661: the Office levels on the page — what the host serves (GET /ui/office-levels, src/office-levels.ts: the level from
2
+ // each instance's lifetime work, and where the viewer may see them, the numbers behind it) put into words and colours:
3
+ // the tier a level is in, its title (the page's generic ones, or a skin pack's `levels.titles`), a title a pack pins on
4
+ // someone (`levels.fixed`, which may hide the number), the badge's text and the XP bar. Pure: no DOM, no fetch.
5
+ import { t, register } from "/assets/app-i18n.js";
6
+
7
+ register("olv", {
8
+ en: { t0: "Novice", t1: "Apprentice", t2: "Adept", t3: "Expert", t4: "Master", t5: "Legend" },
9
+ "zh-TW": { t0: "新手", t1: "學徒", t2: "熟手", t3: "專家", t4: "大師", t5: "傳奇" },
10
+ });
11
+
12
+ /** The tiers by the level each starts at: grey, green, blue, the filled blue, gold, red (#2661). */
13
+ export const TIER_FROM = [0, 10, 20, 35, 50, 100];
14
+ /** The tier (0–5) a level is in. */
15
+ export const tierOf = (level) => { let i = 0; while (i + 1 < TIER_FROM.length && level >= TIER_FROM[i + 1]) i++; return i; };
16
+
17
+ /**
18
+ * A tier's badge colours from the dashboard's tokens (no palette of its own: the status tokens and their tints, as the
19
+ * renderer reads them, readPal). { bg, fg, line }.
20
+ */
21
+ export function tierColours(tier, pal) {
22
+ switch (tier) {
23
+ case 1: return { bg: pal.okBg, fg: pal.ok, line: pal.ok };
24
+ case 2: return { bg: pal.accentDim, fg: pal.accent, line: pal.accent };
25
+ case 3: return { bg: pal.accent, fg: pal.surface, line: pal.accent };
26
+ case 4: return { bg: pal.warnBg, fg: pal.warn, line: pal.warn };
27
+ case 5: return { bg: pal.dangerBg, fg: pal.danger, line: pal.danger };
28
+ default: return { bg: pal.surface2, fg: pal.text2, line: pal.border };
29
+ }
30
+ }
31
+
32
+ /** A level as the badge writes it: up to 999 whole, then 1.1k, 12k (never wider than four characters). */
33
+ export function badgeText(level) {
34
+ if (level < 1000) return String(level);
35
+ const k = level / 1000;
36
+ return k < 10 ? `${Math.floor(k * 10) / 10}k` : `${Math.floor(k)}k`;
37
+ }
38
+
39
+ /** The title for a level: the pack's table (the last entry it has reached), else the page's tier name. */
40
+ export function titleOf(level, titles) {
41
+ if (Array.isArray(titles) && titles.length) {
42
+ let name = null;
43
+ for (const e of titles) if (level >= e.from) name = e.name;
44
+ if (name) return name;
45
+ }
46
+ return t(`olv.t${tierOf(level)}`);
47
+ }
48
+
49
+ /** What a pack pins on `name` (an instance or an NPC's id): { title, hideLevel } or null. */
50
+ export function fixedOf(name, levels) {
51
+ const f = levels && levels.fixed;
52
+ return f && Object.prototype.hasOwnProperty.call(f, name) ? f[name] : null;
53
+ }
54
+
55
+ /** Work tokens to reach a level (the host's curve: level = floor(√(work ÷ unit))). */
56
+ export const workFor = (level, unit) => level * level * unit;
57
+
58
+ /** The XP bar: { next, toNext, pct (0–1) } — the next level, the work it still needs, how far along this level is. */
59
+ export function xpOf(level, work, unit) {
60
+ const from = workFor(level, unit), to = workFor(level + 1, unit);
61
+ return { next: level + 1, toNext: Math.max(0, to - work), pct: to > from ? Math.min(1, Math.max(0, (work - from) / (to - from))) : 0 };
62
+ }
63
+
64
+ /** Tokens in short words: 950, 12.3K, 260M, 1.2B. */
65
+ export function tokens(n) {
66
+ const v = Math.max(0, n);
67
+ if (v < 1000) return String(Math.round(v));
68
+ const [d, s] = v >= 1e9 ? [1e9, "B"] : v >= 1e6 ? [1e6, "M"] : [1e3, "K"];
69
+ const x = v / d;
70
+ return `${x < 100 ? Math.floor(x * 10) / 10 : Math.floor(x)}${s}`;
71
+ }
72
+
73
+ /**
74
+ * What the host answered (GET /ui/office-levels), checked: { enabled, unit, detail, levels: name → entry } with every
75
+ * entry's numbers sane (anything odd: no levels at all).
76
+ */
77
+ export function levelsOk(v) {
78
+ const none = { enabled: false, unit: 100000, detail: false, levels: new Map() };
79
+ if (!v || typeof v !== "object" || v.enabled !== true || typeof v.levels !== "object" || !v.levels) return none;
80
+ const unit = Number.isFinite(v.unit) && v.unit > 0 ? v.unit : 100000;
81
+ const n = (x) => Number.isFinite(x) && x >= 0;
82
+ const out = new Map();
83
+ for (const [name, e] of Object.entries(v.levels)) {
84
+ if (!e || !Number.isInteger(e.level) || e.level < 0) continue;
85
+ const detail = v.detail === true && n(e.workTokens) && e.today && n(e.today.workTokens) && n(e.today.requests);
86
+ out.set(name, detail
87
+ ? { level: e.level, workTokens: e.workTokens, estimated: e.estimated === true, today: { workTokens: e.today.workTokens, requests: e.today.requests },
88
+ firstSeen: typeof e.firstSeen === "string" && /^\d{4}-\d{2}-\d{2}$/.test(e.firstSeen) ? e.firstSeen : null }
89
+ : { level: e.level });
90
+ }
91
+ return { enabled: true, unit, detail: v.detail === true, levels: out };
92
+ }
93
+
94
+ /**
95
+ * The badge a person's name tag carries: { text, tier } — or { mark: true } for a pinned title that hides the level —
96
+ * or null (levels off, or none for them).
97
+ */
98
+ export function badgeOf(name, lv, levels) {
99
+ if (!lv || !lv.enabled) return null;
100
+ const fx = fixedOf(name, levels);
101
+ if (fx && fx.hideLevel) return { mark: true, tier: 0 };
102
+ const e = lv && lv.levels.get(name);
103
+ if (!e) return null;
104
+ return { text: badgeText(e.level), tier: tierOf(e.level) };
105
+ }
@@ -74,13 +74,21 @@ export function officeRooms(view, filter) {
74
74
  return picked.map((r) => ({ ...r, nodes: r.nodes.filter((n) => pinned.has(n.name)) })).filter((r) => r.nodes.length);
75
75
  }
76
76
 
77
- /** A stored office's members: instance names, or `team:<name>` for a team's; none means everyone. */
77
+ /**
78
+ * #2752: an office kept for nobody (its style's NPCs only) stores this one member: a team with no name, which no team
79
+ * has, so a version before it finds nobody too. `[]` still means everyone.
80
+ */
81
+ export const NOBODY = "team:";
82
+ /** Who an office's members pick: "everyone" ([]), "nobody" (only NOBODY), or "picked". */
83
+ export const membersMode = (members) => (!members.length ? "everyone" : members.every((m) => m === NOBODY) ? "nobody" : "picked");
84
+ /** A stored office's members: instance names, or `team:<name>` for a team's; none means everyone, NOBODY nobody. */
78
85
  export function membersOf(office, view) {
79
86
  const every = [...view.top, ...view.teams.flatMap((tm) => tm.nodes), ...view.rest.nodes];
80
87
  const byName = new Map(every.map((n) => [n.name, n]));
81
88
  if (!office.members.length) return [...byName.values()];
82
89
  const out = new Map();
83
90
  for (const m of office.members) {
91
+ if (m === NOBODY) continue;
84
92
  const team = m.startsWith("team:") ? view.teams.find((tm) => tm.name === m.slice(5)) : null;
85
93
  for (const n of team ? team.nodes : byName.has(m) ? [byName.get(m)] : []) if (!out.has(n.name)) out.set(n.name, n);
86
94
  }
@@ -12,10 +12,14 @@ export interface PackFurniture { id: string; sprite: PackStrip; x: number; y: nu
12
12
  export interface CheckedPack {
13
13
  format: 1; id: string; scale: number; background?: string; foreground?: string; matte?: string;
14
14
  areas: Record<string, PackArea>; stateZones: Record<string, string> | null; door: PackPoint;
15
+ /** #2736: where a letter from another office lands, or null (the waypoint nearest the middle). */
16
+ mailDrop: PackPoint | null;
15
17
  waypoints: Array<{ id: string; x: number; y: number }>; edges: Array<[string, string]>;
16
18
  furniture: PackFurniture[]; characters: Record<string, Record<string, PackStrip>>; byInstance: Record<string, string>;
17
19
  props: Record<string, PackStrip>; bubbles: Record<string, Record<string, string[]>>;
18
20
  room: { floor?: string; wall?: string }; desk: { back?: string; front?: string; lit?: string };
21
+ /** #2661: the Office levels' words — titles from each level up, and titles pinned on instances or NPCs. */
22
+ levels?: { titles?: Array<{ from: number; name: string }>; fixed?: Record<string, { title: string; hideLevel: boolean }> };
19
23
  }
20
24
  export interface LayoutOverrides {
21
25
  pack: string;
@@ -26,6 +30,8 @@ export interface LayoutOverrides {
26
30
  /** #2691: a seat's reservation by area and slot ("0", "1", …), in place of the pack's; `for: []` is none. */
27
31
  seatFor?: Record<string, Record<string, { for: string[]; lend: boolean }>>;
28
32
  door?: [number, number];
33
+ /** #2736: where a letter from another office lands. */
34
+ mailDrop?: [number, number];
29
35
  waypoints?: Array<{ id: string; x: number; y: number }>;
30
36
  edges?: Array<[string, string]>;
31
37
  }
@@ -35,6 +35,10 @@
35
35
  // the org chart's states (fleet-org.js ORG_STATES) → an area; a
36
36
  // state it does not name goes to "default", else the first area
37
37
  // "door": [640, 700], optional: where someone new walks in from (default: bottom middle)
38
+ // "mailDrop": [640, 420], optional (#2736): where a letter from another office lands (more
39
+ // waiting step GAP px right of it, past any seat); default: the
40
+ // waypoint nearest the middle off every seat (fleet-office-zones.js
41
+ // dropPoint)
38
42
  // "waypoints": [{ "id": "hall", "x": 640, "y": 600 }, …], "edges": [["hall", "east"], …],
39
43
  // optional: the walkable graph (else people walk straight)
40
44
  // "straight": true, optional (#2634): every walk goes across, then up or down — each
@@ -87,6 +91,13 @@
87
91
  // ≤ 40 characters each), in place of the page's own
88
92
  // "room": { "floor": "floor.png", "wall": "wall.png" }, "desk": { "back": …, "front": …, "lit": … }
89
93
  // optional: for the desks layout (a pack without stateZones)
94
+ // "levels": { optional (#2661): the Office levels' words
95
+ // "titles": [{ "from": 1, "name": "Intern" }, …], the title from each level up (≤ 32, `from` 0–100000
96
+ // rising, a name 1–24 characters), in place of the page's own
97
+ // "fixed": { "atlas": { "title": "Founder", "hideLevel": true } } }
98
+ // a title pinned on an instance (or an NPC: a furniture item's
99
+ // id) whatever its level (≤ 500); hideLevel: its badge is a mark,
100
+ // its card has no "Lv n" or XP bar
90
101
  // }
91
102
  //
92
103
  // A STRIP is { "src": "idle.png", "frames": 4, "w": 32, "h": 48, "fps": 4, "ax": 16, "ay": 44 }: `frames` frames of
@@ -120,7 +131,7 @@
120
131
  // "seatFor": { "desks": { "0": { "for": ["atlas"], "lend": false } } },
121
132
  // a seat's reservation by area and slot (#2691), in place of the
122
133
  // pack's own for that seat; "for": [] is none (lends nothing)
123
- // "door": [640, 700],
134
+ // "door": [640, 700], "mailDrop": [640, 420], (#2736: kept beside the offices on disk, as furnitureNight)
124
135
  // "waypoints": [{ "id": "hall", "x": 640, "y": 600 }, …], "edges": [["hall", "east"], …] }
125
136
  // the walkable graph, replaced whole (edges need waypoints)
126
137
  //
@@ -150,7 +161,7 @@ const RESERVED = new Set(["cozy"]);
150
161
  const ID = /^[A-Za-z0-9_-]{1,40}$/;
151
162
  const SRC = /^[A-Za-z0-9._/-]{1,200}$/;
152
163
  const HEX = /^#[0-9a-f]{6}$/i;
153
- const LIMIT = { areas: 32, seats: 64, furniture: 128, waypoints: 512, edges: 2048, sets: 64, byInstance: 500, lines: 12, line: 40, seatFor: 16 };
164
+ const LIMIT = { areas: 32, seats: 64, furniture: 128, waypoints: 512, edges: 2048, sets: 64, byInstance: 500, lines: 12, line: 40, seatFor: 16, titles: 32, title: 24, fixed: 500 };
154
165
 
155
166
  class Refused extends Error { constructor(at, why) { super(`${at}: ${why}`); } }
156
167
  /** `o[k]` when it is o's own property, else undefined: a name from a pack or a page never reaches Object.prototype. */
@@ -239,6 +250,43 @@ function states(v, at) {
239
250
  return Array.isArray(v) ? list(v, 8, at).map((s, i) => one(s, `${at}[${i}]`)) : [one(v, at)];
240
251
  }
241
252
 
253
+ /** #2661: a title's words — 1–LIMIT.title characters on one line. */
254
+ function titleText(v, at) {
255
+ if (typeof v !== "string" || !v.trim() || [...v.trim()].length > LIMIT.title || /[\u0000-\u001f\u007f]/.test(v)) throw new Refused(at, `1–${LIMIT.title} characters`);
256
+ return v.trim();
257
+ }
258
+ /** #2661: a pack's `levels` (titles, fixed), checked; null when it has none. */
259
+ function levelsOf(v) {
260
+ if (v === undefined) return null;
261
+ obj(v, "levels");
262
+ keys(v, ["titles", "fixed"], "levels");
263
+ const out = {};
264
+ if (v.titles !== undefined) {
265
+ let last = -1;
266
+ out.titles = list(v.titles, LIMIT.titles, "levels.titles").map((t, i) => {
267
+ const at = `levels.titles[${i}]`;
268
+ obj(t, at); keys(t, ["from", "name"], at);
269
+ const from = int(t.from, 0, 100000, `${at}.from`);
270
+ if (from <= last) throw new Refused(`${at}.from`, "higher than the title before it");
271
+ last = from;
272
+ return { from, name: titleText(t.name, `${at}.name`) };
273
+ });
274
+ }
275
+ if (v.fixed !== undefined) {
276
+ obj(v.fixed, "levels.fixed");
277
+ if (Object.keys(v.fixed).length > LIMIT.fixed) throw new Refused("levels.fixed", `at most ${LIMIT.fixed}`);
278
+ out.fixed = {};
279
+ for (const [k, f] of Object.entries(v.fixed)) {
280
+ const at = `levels.fixed.${k}`;
281
+ if (inherited(k)) throw new Refused(at, "an instance's name (not an inherited one)");
282
+ obj(f, at); keys(f, ["title", "hideLevel"], at);
283
+ if (f.hideLevel !== undefined && typeof f.hideLevel !== "boolean") throw new Refused(`${at}.hideLevel`, "true or false");
284
+ out.fixed[k] = { title: titleText(f.title, `${at}.title`), hideLevel: f.hideLevel === true };
285
+ }
286
+ }
287
+ return out;
288
+ }
289
+
242
290
  /**
243
291
  * A pack's manifest, strictly: { ok: true, pack } or { ok: false, error: "<where>: <why>" }. `kept`: a pack the host
244
292
  * keeps already (read back, not uploaded), which may hold a RESERVED id.
@@ -246,7 +294,7 @@ function states(v, at) {
246
294
  export function checkSkinPack(x, { kept = false } = {}) {
247
295
  try {
248
296
  obj(x, "pack");
249
- keys(x, ["format", "id", "scale", "background", "foreground", "matte", "areas", "stateZones", "door", "waypoints", "edges", "straight", "furniture", "characters", "byInstance", "props", "bubbles", "room", "desk"], "pack");
297
+ keys(x, ["format", "id", "scale", "background", "foreground", "matte", "areas", "stateZones", "door", "mailDrop", "waypoints", "edges", "straight", "furniture", "characters", "byInstance", "props", "bubbles", "room", "desk", "levels"], "pack");
250
298
  if (x.format !== 1) throw new Refused("format", "1");
251
299
  if (typeof x.id !== "string" || !ID.test(x.id)) throw new Refused("id", "1–40 letters, digits, _ or -");
252
300
  if (BUILT_IN.has(x.id) || (!kept && RESERVED.has(x.id))) throw new Refused("id", "a built-in skin's id");
@@ -344,14 +392,17 @@ export function checkSkinPack(x, { kept = false } = {}) {
344
392
  });
345
393
  }
346
394
  }
395
+ const levels = levelsOf(x.levels);
347
396
  const room = x.room === undefined ? {} : obj(x.room, "room"), desk = x.desk === undefined ? {} : obj(x.desk, "desk");
348
397
  keys(room, ["floor", "wall"], "room"); keys(desk, ["back", "front", "lit"], "desk");
349
398
  const opt = (v, at) => (v === undefined ? undefined : src(v, at));
350
399
  return { ok: true, pack: {
351
400
  format: 1, id: x.id, scale, background: opt(x.background, "background"), foreground: opt(x.foreground, "foreground"), matte: x.matte === undefined ? undefined : x.matte.toLowerCase(), areas, stateZones,
352
- door: x.door === undefined ? { x: LOGICAL.w / 2, y: LOGICAL.h - 10 } : point(x.door, "door"), waypoints, edges, straight: x.straight === true, furniture, characters, byInstance,
401
+ door: x.door === undefined ? { x: LOGICAL.w / 2, y: LOGICAL.h - 10 } : point(x.door, "door"),
402
+ mailDrop: x.mailDrop === undefined ? null : point(x.mailDrop, "mailDrop"), waypoints, edges, straight: x.straight === true, furniture, characters, byInstance,
353
403
  props: x.props === undefined ? {} : strips(x.props, PROPS, "props", scale), bubbles,
354
404
  room: { floor: opt(room.floor, "room.floor"), wall: opt(room.wall, "room.wall") }, desk: { back: opt(desk.back, "desk.back"), front: opt(desk.front, "desk.front"), lit: opt(desk.lit, "desk.lit") },
405
+ ...(levels ? { levels } : {}),
355
406
  } };
356
407
  } catch (err) {
357
408
  if (err instanceof Refused) return { ok: false, error: err.message };
@@ -392,7 +443,7 @@ const reserve = (s, r) => { const { for: _f, lend: _l, ...p } = s; return r && r
392
443
  export function checkLayoutOverrides(x) {
393
444
  try {
394
445
  obj(x, "overrides");
395
- keys(x, ["pack", "seats", "furniture", "furnitureNight", "seatFor", "door", "waypoints", "edges"], "overrides");
446
+ keys(x, ["pack", "seats", "furniture", "furnitureNight", "seatFor", "door", "mailDrop", "waypoints", "edges"], "overrides");
396
447
  if (typeof x.pack !== "string" || !ID.test(x.pack)) throw new Refused("overrides.pack", "1–40 letters, digits, _ or -");
397
448
  const out = { pack: x.pack };
398
449
  if (x.seats !== undefined) {
@@ -448,6 +499,7 @@ export function checkLayoutOverrides(x) {
448
499
  }
449
500
  }
450
501
  if (x.door !== undefined) { const q = point(x.door, "overrides.door"); out.door = [q.x, q.y]; }
502
+ if (x.mailDrop !== undefined) { const q = point(x.mailDrop, "overrides.mailDrop"); out.mailDrop = [q.x, q.y]; }
451
503
  if (x.edges !== undefined && x.waypoints === undefined) throw new Refused("overrides.edges", "only with waypoints");
452
504
  if (x.waypoints !== undefined) {
453
505
  const ids = new Set();
@@ -508,8 +560,9 @@ export function applyOverrides(pack, ov) {
508
560
  return { ...f, ...(q ? { x: q[0], y: q[1] } : {}), ...(n ? { night: { ...f.night, x: n[0], y: n[1] } } : {}) };
509
561
  });
510
562
  const door = ov.door ? { x: ov.door[0], y: ov.door[1] } : pack.door;
563
+ const mailDrop = ov.mailDrop ? { x: ov.mailDrop[0], y: ov.mailDrop[1] } : pack.mailDrop ?? null;
511
564
  const graph = ov.waypoints ? { waypoints: ov.waypoints.map((w) => ({ ...w })), edges: (ov.edges || []).map((e) => [e[0], e[1]]) } : { waypoints: pack.waypoints, edges: pack.edges };
512
- return { ok: true, pack: { ...pack, areas, furniture, door, ...graph } };
565
+ return { ok: true, pack: { ...pack, areas, furniture, door, mailDrop, ...graph } };
513
566
  } catch (err) {
514
567
  if (err instanceof Refused) return { ok: false, error: err.message };
515
568
  throw err;
@@ -575,6 +628,9 @@ export function overridesBetween(pack, edited) {
575
628
  if (o.night.x !== f.night.x || o.night.y !== f.night.y) (ov.furnitureNight ||= {})[f.id] = [f.night.x, f.night.y];
576
629
  }
577
630
  if (pack.door.x !== edited.door.x || pack.door.y !== edited.door.y) ov.door = [edited.door.x, edited.door.y];
631
+ // #2736: a mail drop placed (the pack has none) or moved from the pack's.
632
+ const md = pack.mailDrop ?? null, ed = edited.mailDrop ?? null;
633
+ if (ed && (!md || md.x !== ed.x || md.y !== ed.y)) ov.mailDrop = [ed.x, ed.y];
578
634
  if (!same(pack.waypoints, edited.waypoints) || !same(pack.edges, edited.edges)) {
579
635
  ov.waypoints = edited.waypoints.map((w) => ({ id: w.id, x: w.x, y: w.y }));
580
636
  ov.edges = edited.edges.map((e) => [e[0], e[1]]);
@@ -13,8 +13,9 @@
13
13
  // @typedef {"working"|"idle"|"paused"|"needs"|"walk"} OfficePose (fleet-office-model.js POSES; "away" draws no one)
14
14
  // @typedef {{ pal: OfficePalette, mode: "dark"|"light", labels: { lobby, closed, areas: { [area]: string } } }} OfficeEnv
15
15
  // @typedef {{ name: string, label: string, state: string, pose: OfficePose|"away", light: "warn"|"danger"|null,
16
- // lamp: boolean, sign: "closed"|null, coat: string, work: number, dim: boolean, match: boolean, free: boolean }} OfficeLook
17
- // (one occupant; `free`: standing in a zone, not behind a desk cell)
16
+ // lamp: boolean, sign: "closed"|null, coat: string, work: number, dim: boolean, match: boolean, free: boolean,
17
+ // badge?: { text, tier } | { mark: true } }} OfficeLook
18
+ // (one occupant; `free`: standing in a zone, not behind a desk cell; `badge`: its level on its name tag, #2661)
18
19
  // @typedef {object} OfficeSkin
19
20
  // @property {string} id
20
21
  // @property {object} [pack] a checked pack (fleet-office-pack.js); with `pack.stateZones` the zones layout is used
@@ -44,15 +45,18 @@
44
45
  import { t } from "/assets/app-i18n.js";
45
46
  import { frameAt, nameHash } from "./fleet-office-model.js";
46
47
  import { CHAR_DEPTH, LOGICAL, checkSkinPack, own, packImages, poseStrip, stripBox, walkStrip } from "./fleet-office-pack.js";
48
+ import { tierColours } from "./fleet-office-levels.js";
47
49
 
48
50
  export { nameHash };
49
51
 
50
52
  // ── The palette: the dashboard's tokens, read by the renderer; furniture keeps its own wood and metal ──
51
53
 
52
- /** @typedef {{ bg, surface, surface2, border, text, text2, text3, ok, warn, danger, focus, fx: { task, send, report, broadcast } }} OfficePalette */
54
+ /** @typedef {{ bg, surface, surface2, border, text, text2, text3, ok, warn, danger, focus, accent, accentDim, okBg, warnBg, dangerBg, fx: { task, send, report, broadcast } }} OfficePalette */
53
55
  export const DEFAULT_PAL = {
54
56
  bg: "#212121", surface: "#2f2f2f", surface2: "#3a3a3a", border: "#3d3d3d", text: "#ececec", text2: "#b4b4b4", text3: "#a3a3a3",
55
57
  ok: "#4cc38a", warn: "#e3b341", danger: "#ff8a80", focus: "#7ab7ff", fx: { task: "#b794f6", send: "#2dd4bf", report: "#f472b6", broadcast: "#fb923c" },
58
+ // #2661: the level badges' tiers (the status tokens and their tints).
59
+ accent: "#7ab7ff", accentDim: "rgba(122, 183, 255, 0.14)", okBg: "rgba(76, 195, 138, 0.14)", warnBg: "rgba(227, 179, 65, 0.14)", dangerBg: "rgba(255, 138, 128, 0.14)",
56
60
  };
57
61
  /** Light or dark, from the page's own background token (whatever chose it: the person, the system, a theme). */
58
62
  export function modeOf(pal) {
@@ -235,6 +239,7 @@ function drawDeskFront(ctx, d, look, env) {
235
239
  ctx.fillText(fitText(ctx, look.label, d.w - 18), d.x + d.w / 2, d.y + 86.5);
236
240
  }
237
241
  ctx.restore();
242
+ if (!look.notag && look.badge) drawBadge(ctx, d.x + 6, d.y + 78, look.badge, pal);
238
243
  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(); }
239
244
  }
240
245
 
@@ -296,6 +301,36 @@ export function tagBox(ctx, x, y, label) {
296
301
  ctx.restore();
297
302
  return { x: Math.round(x - w / 2), y: y + 3, w, h: 14, text: t };
298
303
  }
304
+ /**
305
+ * #2661: a level badge on a name tag's top-left corner (x, y: the tag's corner), overlapping its edge like an app's
306
+ * badge — it takes none of the tag's width. `badge`: { text, tier } (fleet-office-levels.js badgeOf), or { mark } for a
307
+ * pinned title that hides the number: a small diamond.
308
+ */
309
+ export function drawBadge(ctx, x, y, badge, pal) {
310
+ if (!badge) return;
311
+ const c = tierColours(badge.tier, pal);
312
+ ctx.save();
313
+ ctx.globalAlpha = 1;
314
+ if (badge.mark) {
315
+ ctx.translate(x, y); ctx.rotate(Math.PI / 4);
316
+ ctx.fillStyle = pal.surface; ctx.fillRect(-4, -4, 8, 8);
317
+ ctx.fillStyle = c.bg; ctx.fillRect(-4, -4, 8, 8);
318
+ ctx.strokeStyle = c.line; ctx.lineWidth = 1; ctx.strokeRect(-3.5, -3.5, 7, 7);
319
+ ctx.restore();
320
+ return;
321
+ }
322
+ ctx.font = BADGE_FONT;
323
+ const w = Math.max(12, Math.ceil((ctx.measureText ? ctx.measureText(badge.text).width : badge.text.length * 5) + 6)), h = 11;
324
+ const bx = Math.round(x - w / 2 + 3), by = Math.round(y - h / 2 - 1);
325
+ // An opaque surface first, so a tint reads the same over any picture.
326
+ ctx.fillStyle = pal.surface; ctx.fillRect(bx, by, w, h);
327
+ ctx.fillStyle = c.bg; ctx.fillRect(bx, by, w, h);
328
+ ctx.strokeStyle = c.line; ctx.lineWidth = 1; ctx.strokeRect(bx + 0.5, by + 0.5, w - 1, h - 1);
329
+ ctx.fillStyle = c.fg; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(badge.text, bx + w / 2, by + h / 2 + 0.5);
330
+ ctx.restore();
331
+ }
332
+ const BADGE_FONT = "700 8px system-ui, sans-serif";
333
+
299
334
  /** Zones: a name tag under someone's feet at (x, y). */
300
335
  function drawTag(ctx, x, y, look, env) {
301
336
  const { pal } = env;
@@ -307,6 +342,7 @@ function drawTag(ctx, x, y, look, env) {
307
342
  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);
308
343
  ctx.fillStyle = pal.text; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(t, x, y + 10.5);
309
344
  ctx.restore();
345
+ if (look.badge) drawBadge(ctx, Math.round(x - w / 2), y + 3, look.badge, pal);
310
346
  }
311
347
 
312
348
  /**
@@ -1,16 +1,20 @@
1
1
  // The Office view's data, behind one small seam (fleet-office.js uses only these calls):
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
+ // - #2716: the whole-fleet office's style, kept on the host for everyone (GET /ui/offices's autoSkin; PUT
5
+ // /ui/office-style, an admin's) — this browser's own pick (skin(): an admin's copy of it, anyone else's own choice);
4
6
  // - 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); #2540: the view (zoom and centre) and the
7
+ // office is shown, the auto office's member filter; #2540: the view (zoom and centre) and the
6
8
  // fit mode (Fit or Fill) per office; #2554: whether All offices shows the lobby tile.
7
9
  // - the skin packs an admin uploaded (#2513: GET /ui/office-skins, POST to upload one, DELETE to remove one;
8
10
  // src/web-office-skins.ts). The office editor and the layout editor call saveOffices() with the whole list and the rev
9
11
  // they read (a change in between answers 409).
12
+ // - #2661: everyone's level (GET /ui/office-levels, src/office-levels.ts; fleet-office-levels.js levelsOk checks it).
10
13
  // - #2692: the agents' pack changes (GET / POST /ui/office-pack-changes, an admin's): which agents may change packs, the
11
14
  // fleet's approval mode, the changes waiting for a yes, and the newest log entries.
12
15
  import { AUTO_ID, filterOk, officesOk } from "./fleet-office-model.js";
13
16
  import { fitModeOk, savedOk } from "./fleet-office-view.js";
17
+ import { levelsOk } from "./fleet-office-levels.js";
14
18
 
15
19
  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";
16
20
  /** #2554: "0" when All offices leaves the lobby tile out (default: shown). */
@@ -65,15 +69,26 @@ export function createOfficeStore({ fetch: get = globalThis.fetch, storage } = {
65
69
  const write = (k, v) => { try { const s = st(); if (s) s.setItem(k, v); } catch { /* this page only */ } };
66
70
  const headers = { "Content-Type": "application/json" };
67
71
  return {
68
- /** { rev, offices } from the host; null when it could not be read (no offices shown but the auto one). */
72
+ /**
73
+ * { rev, offices, autoSkin } from the host (#2716 autoSkin: the whole-fleet office's style, or null: none kept); null
74
+ * when it could not be read (no offices shown but the auto one).
75
+ */
69
76
  async offices() {
70
77
  try {
71
78
  const r = await get("/ui/offices", { method: "GET", headers });
72
79
  if (!r || !r.ok) return null;
73
80
  const d = await r.json();
74
- return { rev: Number.isSafeInteger(d && d.rev) ? d.rev : 0, offices: officesOk(d && d.offices) };
81
+ return { rev: Number.isSafeInteger(d && d.rev) ? d.rev : 0, offices: officesOk(d && d.offices), autoSkin: d && typeof d.autoSkin === "string" && PACK_ID.test(d.autoSkin) ? d.autoSkin : null };
75
82
  } catch { return null; }
76
83
  },
84
+ /** #2716: the whole-fleet office's style for everyone (an admin session): { ok, autoSkin } or { ok: false, status, error }. */
85
+ async saveAutoSkin(skin) {
86
+ try {
87
+ const r = await get("/ui/office-style", { method: "PUT", headers, body: JSON.stringify({ skin }) });
88
+ const d = await r.json().catch(() => ({}));
89
+ return r.ok ? { ok: true, autoSkin: typeof d.autoSkin === "string" ? d.autoSkin : null } : { ok: false, status: r.status, error: d.error || "office_style_failed" };
90
+ } catch { return { ok: false, status: 0, error: "office_style_failed" }; }
91
+ },
77
92
  /**
78
93
  * Replace the host's list (an admin session; `rev`: the one read — required: the host refuses a save without it,
79
94
  * so two editors never overwrite each other unseen). { ok, rev, offices } or { ok: false, status, error }.
@@ -95,6 +110,17 @@ export function createOfficeStore({ fetch: get = globalThis.fetch, storage } = {
95
110
  return actsOk(d && d.acts, d && d.now);
96
111
  } catch { return null; }
97
112
  },
113
+ /**
114
+ * #2661: everyone's level (GET /ui/office-levels; a share link's own, through its lease), checked (levelsOk); null
115
+ * when it could not be read (the levels shown stay).
116
+ */
117
+ async levels() {
118
+ try {
119
+ const r = await get("/ui/office-levels", { method: "GET", headers });
120
+ if (!r || !r.ok) return null;
121
+ return levelsOk(await r.json());
122
+ } catch { return null; }
123
+ },
98
124
  /** The skin packs the host keeps: { rev, packs: [{ id, name, sha }] }; null when they could not be read. */
99
125
  async skins() {
100
126
  try {
@@ -158,6 +184,8 @@ export function createOfficeStore({ fetch: get = globalThis.fetch, storage } = {
158
184
  saveFilter: (f) => write(FILTER_KEY, JSON.stringify(filterOk(f))),
159
185
  /** The whole fleet's style here: a skin id ("pixel": desks, "studio" and "cozy": zones); none chosen yet: cozy (#2631); an unknown one is the default where used. */
160
186
  skin: () => { const v = read(SKIN_KEY); return typeof v === "string" && /^[A-Za-z0-9_-]{1,40}$/.test(v) ? v : "cozy"; },
187
+ /** #2716: this browser's own pick of it, or null (none made here). */
188
+ skinChosen: () => { const v = read(SKIN_KEY); return typeof v === "string" && /^[A-Za-z0-9_-]{1,40}$/.test(v) ? v : null; },
161
189
  saveSkin: (id) => write(SKIN_KEY, String(id)),
162
190
  /** Whether paused and stopped people are left out of the Office here (default: shown). */
163
191
  hideAsleep: () => read(ASLEEP_KEY) === "1",
@@ -97,6 +97,14 @@ export function miniFrame(v, size, w, h) {
97
97
  const x1 = Math.min(w, (size.w - v.x) / v.k), y1 = Math.min(h, (size.h - v.y) / v.k);
98
98
  return { x: x0, y: y0, width: Math.max(0, x1 - x0), height: Math.max(0, y1 - y0) };
99
99
  }
100
+ /**
101
+ * #2737: the whole scene is in view (the overview's frame covers all of it, to half a unit), so the overview would only
102
+ * repeat the panel — and sit over the one corner of it a person cannot otherwise see.
103
+ */
104
+ export function wholeInView(v, size, w, h) {
105
+ const f = miniFrame(v, size, w, h);
106
+ return f.x <= 0.5 && f.y <= 0.5 && f.x + f.width >= w - 0.5 && f.y + f.height >= h - 0.5;
107
+ }
100
108
 
101
109
  // ── Kept per device, per office: the zoom and the logical point in the middle (the viewport's size may differ) ──
102
110
 
@@ -140,7 +140,7 @@ export function zonesLayout(pack, nodes, prev = new Map(), { night = false } = {
140
140
  return { key: `zone/${n.name}`, node: n, x: s.x - 24, y: s.y - 52, w: 48, h: 66, seat: { x: s.x, y: s.y }, wp: null, area: s.area, slot: s.slot, night: sleepers.has(n.name) };
141
141
  });
142
142
  return { mode: "zones", rooms: [{ key: "\u0000zones", name: null, lobby: false, x: 0, y: 0, w: LOGICAL.w, h: LOGICAL.h, desks }], areas: pack.areas,
143
- waypoints: pack.waypoints || [], edges: pack.edges || [], straight: pack.straight === true, door: pack.door, width: LOGICAL.w, height: LOGICAL.h, seats, crowd, over };
143
+ waypoints: pack.waypoints || [], edges: pack.edges || [], straight: pack.straight === true, door: pack.door, mailDrop: pack.mailDrop || null, width: LOGICAL.w, height: LOGICAL.h, seats, crowd, over };
144
144
  }
145
145
 
146
146
  // ── Walking ──
@@ -227,7 +227,9 @@ export function walkTargets(desks, graph, t, wander = true, furniture = [], nigh
227
227
  */
228
228
  export const MAIL = { MAX_WAIT: 60000, GAP: 16, SEARCH: 320, ASLEEP: new Set(["paused", "stopped"]), MAX: 20 };
229
229
  /**
230
- * Where the `n`th waiting letter lands: `n` GAPs along from the waypoint nearest the room's middle — the nearest whose
230
+ * Where the `n`th waiting letter lands. #2736: the layout's `mailDrop` when it has one — the `n`th of the slots GAP
231
+ * apart rightwards from it that no seat is on (never beyond the room's right edge); fewer than that, today's rule. Otherwise `n`
232
+ * GAPs along from the waypoint nearest the room's middle — the nearest whose
231
233
  * spot (offset included) no seat of the office is on, taken or free (someone may sit there any moment; #2561 likewise);
232
234
  * with none, the nearest point round the middle (rings GAP·2 apart, inside the room) that no seat is on; with none of
233
235
  * those either, null — the letter is not delivered here (never on a seat).
@@ -236,6 +238,12 @@ export function dropPoint(L, n = 0) {
236
238
  const mid = { x: L.width / 2, y: L.height * 0.55 }, d = (p, q) => Math.hypot(p.x - q.x, p.y - q.y);
237
239
  const seats = [...Object.values(L.areas || {}).flatMap((a) => a.seats || []), ...desksIn(L).map((q) => q.seat)];
238
240
  const onSeat = (p) => seats.some((q) => d(p, q) < 4);
241
+ // #2751 review: the nth off-seat slot (slots GAP apart from the mailDrop), so two letters never share one.
242
+ const md = L.mailDrop;
243
+ if (md) for (let i = 0, free = 0; md.x + i * MAIL.GAP <= L.width; i++) {
244
+ const p = { x: Math.round(md.x + i * MAIL.GAP), y: Math.round(md.y) };
245
+ if (!onSeat(p) && free++ === n) return p;
246
+ }
239
247
  const spots = (L.waypoints || []).map((w) => ({ x: Math.round(w.x + n * MAIL.GAP), y: Math.round(w.y), from: w }))
240
248
  .filter((p) => !onSeat(p.from) && !onSeat(p)).sort((p, q) => d(p.from, mid) - d(q.from, mid));
241
249
  if (spots.length) return { x: spots[0].x, y: spots[0].y };