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

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.
@@ -274,6 +274,13 @@ saved the offices meanwhile, Save says so and keeps your layout — Save again p
274
274
  only calls and work get bubbles (a few at most) and name tags show on hover or focus. **Hide paused and stopped**
275
275
  (per browser, off by default) leaves the sleepers out. Now and then someone says a short line for their state in a
276
276
  bubble (never message content). Reduced motion: still poses, nothing in the air, bubbles shown whole.
277
+ **All offices** (in the switcher once two or more offices are stored, kept per device) shows every stored office as
278
+ a tile on one canvas, each exactly as its own view (its style, members, seats, bubbles, walking), labelled with its
279
+ name and head count, each as large as its office's content, packed to fill the panel; **Lobby tile** (on by
280
+ default; disabled when everyone is in an office) adds the instances in no office. Below 75% name tags show on hover
281
+ only. Hover and click work across
282
+ tiles; a tile's label, or a double-click on it, opens that office; traffic between offices flies straight across.
283
+ Edit offices and Edit layout wait for a single office.
277
284
  The Office fills the Fleet panel and moves like the Canvas (#2540): drag to pan, the wheel or a pinch to zoom,
278
285
  **+** / **−** / **Fit** / **Fill** at the top right (the zoom shown as a percentage above them) or the `+ - 0` keys
279
286
  (with the office focused), an overview at the bottom right to jump (not on a phone). It takes the whole panel: the
@@ -152,7 +152,8 @@ workspace `inbox/`; an unused upload is deleted after 30 minutes.
152
152
 
153
153
  **In replies.** Images inline; video and audio play inline; Markdown, CSV, JSON, code and text files get a **Show**
154
154
  card (200 lines, then "Show all … lines"); other files download. Code blocks have a language tag, **Wrap** and
155
- **Copy**, and fold after 30 lines. Each reply has **Copy this reply**. Markdown: headings, lists, quotes, bold/italic/
155
+ **Copy**, and fold after 30 lines. **⋯ → Full width** (desk only, off by default, this browser's choice) widens the
156
+ thread and the composer to the whole chat panel for wide tables and code; a person's bubbles keep their size. Each reply has **Copy this reply**. Markdown: headings, lists, quotes, bold/italic/
156
157
  strike, links, and tables (header row plus separator, alignment, up to 20 columns). Discord mentions show as names;
157
158
  a Discord server emoji (`<:name:id>`) of the fleet's servers shows as its picture, and any other (or one whose picture
158
159
  can't load) as `:name:`. Unicode emoji are drawn by the system's colour emoji font under every theme font (boxes on
@@ -284,6 +285,13 @@ saved the offices meanwhile, Save says so and keeps your layout — Save again p
284
285
  only calls and work get bubbles (a few at most) and name tags show on hover or focus. **Hide paused and stopped**
285
286
  (per browser, off by default) leaves the sleepers out. Now and then someone says a short line for their state in a
286
287
  bubble (never message content). Reduced motion: still poses, nothing in the air, bubbles shown whole.
288
+ **All offices** (in the switcher once two or more offices are stored, kept per device) shows every stored office as
289
+ a tile on one canvas, each exactly as its own view (its style, members, seats, bubbles, walking), labelled with its
290
+ name and head count, each as large as its office's content, packed to fill the panel; **Lobby tile** (on by
291
+ default; disabled when everyone is in an office) adds the instances in no office. Below 75% name tags show on hover
292
+ only. Hover and click work across
293
+ tiles; a tile's label, or a double-click on it, opens that office; traffic between offices flies straight across.
294
+ Edit offices and Edit layout wait for a single office.
287
295
  The Office fills the Fleet panel and moves like the Canvas (#2540): drag to pan, the wheel or a pinch to zoom,
288
296
  **+** / **−** / **Fit** / **Fill** at the top right (the zoom shown as a percentage above them) or the `+ - 0` keys
289
297
  (with the office focused), an overview at the bottom right to jump (not on a phone). It takes the whole panel: the
@@ -116,7 +116,7 @@ register("chat", {
116
116
  file_empty: "empty file", file_too_large: "over {0} MB", file_too_large_public: "over {0} MB — the most the public link carries in one upload", file_too_many: "at most {0} files per message", file_too_much: "the files together would be over {0} MB", removeFile: "Remove", putBack: "Put back",
117
117
  noMessages: "Type a task below.",
118
118
  pick: "Choose an agent to start chatting.", noAgents: "Create an agent to start chatting.", newAgent: "New agent", notFound: "No instance called “{0}”", notFoundHint: "It may have been deleted or renamed.", backToList: "Choose an agent",
119
- details: "Instance details", start: "Start instance", stopInstance: "Stop instance", restart: "Restart instance", delete: "Delete instance…",
119
+ details: "Instance details", fullWidth: "Full width", start: "Start instance", stopInstance: "Stop instance", restart: "Restart instance", delete: "Delete instance…",
120
120
  started: "{0} started", stoppedInst: "{0} stopped", restarted: "{0} restarted", instanceDeleted: "{0} was deleted",
121
121
  deleteTitle: "Delete {0}?", deleteHelp: "This removes the instance from the fleet and stops it. Its working directory is kept. Type “delete {0}” to confirm.", deleteGo: "Delete", cancel: "Cancel",
122
122
  loadFailed: "Failed to load", tour: "Chat tour", tourBtnTitle: "Show the chat tour on an agent's chat", tourNoAgent: "Choose an agent before opening the chat tour.",
@@ -255,7 +255,7 @@ register("chat", {
255
255
  file_empty: "空檔案", file_too_large: "超過 {0} MB", file_too_large_public: "超過 {0} MB,這是公開連結單次上傳的上限", file_too_many: "每則訊息最多 {0} 個檔案", file_too_much: "檔案合計會超過 {0} MB", removeFile: "移除", putBack: "放回輸入框",
256
256
  noMessages: "直接在下方輸入任務。",
257
257
  pick: "選一個 Agent 開始聊天。", noAgents: "先建立一個 Agent,再開始聊天。", newAgent: "新增 Agent", notFound: "沒有叫「{0}」的 instance", notFoundHint: "它可能已被刪除或改名。", backToList: "選擇 Agent",
258
- details: "Instance 詳細資料", start: "啟動 instance", stopInstance: "停止 instance", restart: "重新啟動 instance", delete: "刪除 instance…",
258
+ details: "Instance 詳細資料", fullWidth: "全寬", start: "啟動 instance", stopInstance: "停止 instance", restart: "重新啟動 instance", delete: "刪除 instance…",
259
259
  started: "{0} 已啟動", stoppedInst: "{0} 已停止", restarted: "{0} 已重新啟動", instanceDeleted: "{0} 已刪除",
260
260
  deleteTitle: "要刪除 {0} 嗎?", deleteHelp: "這會把 instance 從 fleet 移除並停止它,工作目錄會保留。請輸入「delete {0}」確認。", deleteGo: "刪除", cancel: "取消",
261
261
  loadFailed: "載入失敗", tour: "聊天導覽", tourBtnTitle: "在 Agent 的聊天上開啟聊天導覽", tourNoAgent: "請先選一個 Agent,再開啟聊天導覽。",
@@ -0,0 +1,167 @@
1
+ // #2554: the Office view's "All offices" — every stored office as a tile on one canvas, side by side, each drawn exactly
2
+ // as its own view would be (its style, its members, seats, poses, bubbles, walking), scaled into its tile. This module
3
+ // is the pure part (fleet-office.js draws it): who is in no office (the lobby tile), where the tiles go, what a point
4
+ // on the overview hits, which tiles are on screen, and the traffic that crosses between tiles.
5
+ //
6
+ // - Shown when at least two offices are stored (overviewOk); an id no stored office can have (ALL_ID: '*' is not an
7
+ // office id's character), kept per device as any office pick is.
8
+ // - The tiles: each as large as its office's drawn content — a zones scene its 1280×720, a desks office its rooms'
9
+ // bounds (cropToRooms: an office of three is small) — scaled down to at most CELL_W × CELL_H, never up; a LABEL band
10
+ // above each (the page's label sits there), GAP apart, M from the edge. Shelf packing in the stored order (the lobby
11
+ // last): a row takes tiles while they fit a row width, each row as tall as its tallest tile and centred across. The
12
+ // row width: whichever (of the widths a run of tiles makes) lets the whole overview show largest in the panel (Fit's
13
+ // contain); a tie keeps the narrower. The same input always gives the same picture.
14
+ // - Traffic between people in different tiles is a straight flight between their seats in overview coordinates
15
+ // (createCrossFlights); between two people in the same tile it is that tile's own (createFlights, along its corridors).
16
+ import { FLIGHT, along, desksOf, membersOf, pathLength } from "./fleet-office-model.js";
17
+ import { hue } from "./fleet-traffic.js";
18
+
19
+ /** The switcher's "All offices" value: '*' is never in a stored office's id (fleet-office-model.js officeOk). */
20
+ export const ALL_ID = "*all";
21
+ /** The lobby tile's key: never an office's id. */
22
+ export const LOBBY_TILE = "\u0000lobby";
23
+ /** The overview's geometry, in logical units. */
24
+ export const OV = { CELL_W: 1280, CELL_H: 720, LABEL: 56, GAP: 24, M: 24 };
25
+
26
+ /** Whether "All offices" is offered: at least two stored offices. */
27
+ export const overviewOk = (stored) => Array.isArray(stored) && stored.length >= 2;
28
+
29
+ /**
30
+ * The people in no stored office, in the org chart's order (General, the teams, the rest): the lobby tile's. An office
31
+ * with no members holds everyone (fleet-office-model.js membersOf), so then nobody is left for the lobby.
32
+ */
33
+ export function lobbyNodes(stored, view) {
34
+ const placed = new Set((stored || []).flatMap((o) => membersOf(o, view).map((n) => n.name)));
35
+ const out = new Map();
36
+ for (const n of [...view.top, ...view.teams.flatMap((tm) => tm.nodes), ...view.rest.nodes]) if (!placed.has(n.name) && !out.has(n.name)) out.set(n.name, n);
37
+ return [...out.values()];
38
+ }
39
+
40
+ /** A scene's scale in its tile: its own size, scaled down to fit a CELL_W × CELL_H cell (never up). */
41
+ export const tileScale = (w, h) => Math.min(1, OV.CELL_W / Math.max(1, w), OV.CELL_H / Math.max(1, h));
42
+
43
+ /**
44
+ * A desks layout cropped to its rooms: everything moved so the rooms' bounds start at (0, 0), its size theirs (the
45
+ * corridors outside the rooms and the margins are not part of a tile). Unchanged when it has no rooms.
46
+ */
47
+ export function cropToRooms(L) {
48
+ if (!L.rooms.length) return L;
49
+ const x0 = Math.min(...L.rooms.map((r) => r.x)), y0 = Math.min(...L.rooms.map((r) => r.y));
50
+ const x1 = Math.max(...L.rooms.map((r) => r.x + r.w)), y1 = Math.max(...L.rooms.map((r) => r.y + r.h));
51
+ const mv = (p) => ({ ...p, x: p.x - x0, y: p.y - y0 });
52
+ return { ...L, rooms: L.rooms.map((r) => ({ ...mv(r), desks: r.desks.map((d) => ({ ...mv(d), seat: mv(d.seat) })) })), waypoints: (L.waypoints || []).map(mv),
53
+ width: x1 - x0, height: y1 - y0 };
54
+ }
55
+
56
+ /**
57
+ * The tiles of `scenes` ([{ key, w, h }], in order) on shelves at most `rowW` wide (a tile wider than that has a row of
58
+ * its own): each { key, x, y, w, h, s (its scale), label: { x, y, w, h } (the band above it) }; and the overview's size.
59
+ * Each row is centred across the widest; its tiles stand at its top, under their labels.
60
+ */
61
+ export function arrangeTiles(scenes, rowW) {
62
+ const { LABEL, GAP, M } = OV;
63
+ const rows = [];
64
+ let row = null;
65
+ for (const sc of scenes) {
66
+ const s = tileScale(sc.w, sc.h), it = { key: sc.key, s, w: Math.max(1, sc.w) * s, h: Math.max(1, sc.h) * s };
67
+ if (!row || row.w + GAP + it.w > rowW + 1e-6) { row = { items: [], w: -GAP, h: 0 }; rows.push(row); }
68
+ row.items.push(it); row.w += GAP + it.w; row.h = Math.max(row.h, it.h);
69
+ }
70
+ const inner = rows.length ? Math.max(...rows.map((r) => r.w)) : 0, tiles = [];
71
+ let y = M;
72
+ for (const r of rows) {
73
+ let x = M + (inner - r.w) / 2;
74
+ for (const it of r.items) {
75
+ tiles.push({ key: it.key, x, y: y + LABEL, w: it.w, h: it.h, s: it.s, label: { x, y, w: it.w, h: LABEL } });
76
+ x += it.w + GAP;
77
+ }
78
+ y += LABEL + r.h + GAP;
79
+ }
80
+ return { rows: rows.length, rowW, tiles, width: 2 * M + inner, height: rows.length ? y - GAP + M : 2 * M };
81
+ }
82
+
83
+ /**
84
+ * The tiles packed for a `size` panel: of the row widths a run of consecutive tiles makes, the one whose overview shows
85
+ * largest there (min of the two fits); a tie keeps the narrower.
86
+ */
87
+ export function tileLayout(scenes, size) {
88
+ const ws = scenes.map((sc) => Math.max(1, sc.w) * tileScale(sc.w, sc.h)), cands = new Set();
89
+ for (let i = 0; i < ws.length; i++) { let run = -OV.GAP; for (let j = i; j < ws.length; j++) { run += OV.GAP + ws[j]; cands.add(run); } }
90
+ let best = arrangeTiles(scenes, 0), bestK = -1;
91
+ for (const rowW of [...cands].sort((a, b) => a - b)) {
92
+ const a = arrangeTiles(scenes, rowW), k = Math.min(size.w / a.width, size.h / a.height);
93
+ if (k > bestK + 1e-9) { best = a; bestK = k; }
94
+ }
95
+ return best;
96
+ }
97
+
98
+ /** The tile whose scene holds the overview point (x, y), or null. */
99
+ export const tileAt = (lay, x, y) => lay.tiles.find((t) => x >= t.x && x < t.x + t.w && y >= t.y && y < t.y + t.h) || null;
100
+ /** The tile whose label band holds the overview point, or null. */
101
+ export const labelAt = (lay, x, y) => lay.tiles.find((t) => x >= t.label.x && x < t.label.x + t.label.w && y >= t.label.y && y < t.label.y + t.label.h) || null;
102
+ /** Overview → the tile's own scene units, and back; a box in the scene → the overview. */
103
+ export const inTile = (t, x, y) => ({ x: (x - t.x) / t.s, y: (y - t.y) / t.s });
104
+ export const fromTile = (t, p) => ({ x: t.x + p.x * t.s, y: t.y + p.y * t.s });
105
+ export const tileBox = (t, b) => ({ x: t.x + b.x * t.s, y: t.y + b.y * t.s, w: b.w * t.s, h: b.h * t.s });
106
+ /** The tiles a view ({ k, x, y }) shows any of in a `size` viewport: the rest are not drawn. */
107
+ export function visibleTiles(lay, v, size) {
108
+ return lay.tiles.filter((t) => {
109
+ const x = v.x + t.x * v.k, y = v.y + t.y * v.k;
110
+ return x < size.w && x + t.w * v.k > 0 && y < size.h && y + t.h * v.k > 0;
111
+ });
112
+ }
113
+
114
+ /**
115
+ * A straight flight between two people in different tiles: [the sender's seat, the receiver's] in overview units (the
116
+ * first tile each is in), or null — when either is drawn nowhere, or some tile holds both (that tile carries it along
117
+ * its corridors). `parts`: [{ tile, L }] in the tiles' order.
118
+ */
119
+ export function crossPath(parts, from, to) {
120
+ let a = null, z = null;
121
+ for (const p of parts) {
122
+ const ds = desksOf(p.L), da = ds.find((d) => d.node.name === from), dz = ds.find((d) => d.node.name === to);
123
+ if (da && dz) return null;
124
+ if (da && !a) a = fromTile(p.tile, da.seat);
125
+ if (dz && !z) z = fromTile(p.tile, dz.seat);
126
+ }
127
+ return a && z ? [a, z] : null;
128
+ }
129
+
130
+ const MAX_SEEN = 2000;
131
+ /**
132
+ * The items in the air between tiles — fleet-office-model.js createFlights's lifecycle (each new item once; none for a
133
+ * stale delegation or with reduced motion), on crossPath's straight line: `frame(now, parts)` → { id, hue, kind, x, y,
134
+ * t } in overview units; a trip with no crossing (both in one tile, or either gone) is dropped.
135
+ */
136
+ export function createCrossFlights({ max = FLIGHT.MAX } = {}) {
137
+ const seen = new Set();
138
+ let trips = [];
139
+ return {
140
+ sync(items, now, still = false) {
141
+ let k = 0, started = 0;
142
+ for (const it of items || []) {
143
+ if (seen.has(it.id)) continue;
144
+ seen.add(it.id);
145
+ if (seen.size > MAX_SEEN) seen.delete(seen.values().next().value);
146
+ if (still || it.phase === "stale") continue;
147
+ trips.push({ id: it.id, from: it.from, to: it.to, kind: it.kind, hue: hue(it.kind), start: now + k++ * FLIGHT.STAGGER_MS, parts: null, pts: null, ms: 0 });
148
+ started++;
149
+ }
150
+ if (trips.length > max) trips = trips.slice(trips.length - max);
151
+ return started;
152
+ },
153
+ frame(now, parts) {
154
+ const out = [];
155
+ trips = trips.filter((f) => {
156
+ if (f.parts !== parts) { f.parts = parts; f.pts = crossPath(parts, f.from, f.to); f.ms = f.pts ? Math.min(FLIGHT.MAX_MS, Math.max(FLIGHT.MIN_MS, pathLength(f.pts) / FLIGHT.SPEED)) : 0; }
157
+ if (!f.pts) return false;
158
+ const t = (now - f.start) / f.ms;
159
+ if (t >= 1) return false;
160
+ if (t >= 0) { const p = along(f.pts, t); out.push({ id: f.id, hue: f.hue, kind: f.kind, x: p.x, y: p.y, t }); }
161
+ return true;
162
+ });
163
+ return out;
164
+ },
165
+ size: () => trips.length,
166
+ };
167
+ }
@@ -222,14 +222,16 @@ function drawDeskFront(ctx, d, look, env) {
222
222
  ctx.fillStyle = pal.text2; ctx.font = "600 8px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
223
223
  ctx.fillText(fitText(ctx, env.labels.closed, 32), d.x + 48, d.y + 63);
224
224
  }
225
- // The name tag.
226
- ctx.fillStyle = pal.surface;
227
- ctx.fillRect(d.x + 6, d.y + 78, d.w - 12, 16);
228
- ctx.strokeStyle = look.match ? pal.focus : pal.border;
229
- ctx.lineWidth = look.match ? 2 : 1;
230
- ctx.strokeRect(d.x + 6.5, d.y + 78.5, d.w - 13, 15);
231
- ctx.fillStyle = pal.text; ctx.font = "600 11px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
232
- 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
+ }
233
235
  ctx.restore();
234
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(); }
235
237
  }
@@ -3,7 +3,7 @@
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
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.
6
+ // fit mode (Fit or Fill) per office; #2554: whether All offices shows the lobby tile.
7
7
  // - the skin packs an admin uploaded (#2513: GET /ui/office-skins, POST to upload one, DELETE to remove one;
8
8
  // src/web-office-skins.ts). The office editor and the layout editor call saveOffices() with the whole list and the rev
9
9
  // they read (a change in between answers 409).
@@ -11,6 +11,8 @@ import { AUTO_ID, filterOk, officesOk } from "./fleet-office-model.js";
11
11
  import { fitModeOk, savedOk } from "./fleet-office-view.js";
12
12
 
13
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";
14
16
  /** #2540: { "<office>@<style>": [k, cx, cy] (and a 1 after them: a fit) } — the last VIEWS_MAX views, the most recent last. */
15
17
  const VIEW_KEY = "agend_org_office_view", VIEWS_MAX = 32;
16
18
  /** #2540: { "<office>@<style>": "cover" } — the offices whose fit fills the panel (the rest: "contain"), the last VIEWS_MAX. */
@@ -80,7 +82,10 @@ export function createOfficeStore({ fetch: get = globalThis.fetch, storage } = {
80
82
  return r.ok ? { ok: true } : { ok: false, status: r.status, error: d.error || "skin_failed" };
81
83
  } catch { return { ok: false, status: 0, error: "skin_failed" }; }
82
84
  },
83
- /** The office shown here: an id (one the host no longer has falls back to the auto office where it is used). */
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
+ */
84
89
  choice: () => { const v = read(CHOICE_KEY); return typeof v === "string" && v && v.length <= 40 ? v : AUTO_ID; },
85
90
  saveChoice: (id) => write(CHOICE_KEY, String(id)),
86
91
  filter: () => { const s = read(FILTER_KEY); try { return filterOk(s ? JSON.parse(s) : null); } catch { return filterOk(null); } },
@@ -91,6 +96,9 @@ export function createOfficeStore({ fetch: get = globalThis.fetch, storage } = {
91
96
  /** Whether paused and stopped people are left out of the Office here (default: shown). */
92
97
  hideAsleep: () => read(ASLEEP_KEY) === "1",
93
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"),
94
102
  /** The view kept for `key` (an office and its style): { k, cx, cy } (fleet-office-view.js savedOf), or null. */
95
103
  view: (key) => {
96
104
  try { const m = JSON.parse(read(VIEW_KEY) || "{}"); const a = m && Object.prototype.hasOwnProperty.call(m, key) ? m[key] : null;
@@ -44,6 +44,14 @@
44
44
  // (webkit's prefix too); where a page cannot take an element fullscreen (an iPhone) or the browser refuses, "immersive":
45
45
  // the container covers the window (CSS). Either way only the stage shows, with its floating controls (zoom, Fit, the
46
46
  // overview, a compact office switcher, Exit); the stage's resize handling re-fits it. Nothing about it is kept.
47
+ // - All offices (#2554; offered with two or more stored offices): every stored office as a tile on one canvas, each
48
+ // drawn as its own view would be — its style (a pack loaded once and shared), its members, seats, poses, bubbles,
49
+ // walking and in-office traffic — scaled into its tile (fleet-office-overview.js: the tiles, the lobby tile of whoever
50
+ // is in no office, hit-testing, culling, the flights between tiles). The same canvas, controls and loop; tiles off
51
+ // screen are not drawn. A tile's label (a button, a readable size whatever the zoom) or a double-click on it opens
52
+ // that office; a person opens their chat. A tile too small to read (under TAG_MIN_K) shows name tags on hover only;
53
+ // bubbles are drawn after every tile (never cut at a tile's edge). Fit keeps the tiles clear of the controls
54
+ // (FIT_PAD). Edit offices and Edit layout wait for a single office.
47
55
  import { html, useEffect, useLayoutEffect, useMemo, useRef, useState } from "/assets/app-html.js";
48
56
  import { appStore } from "/assets/app-store.js";
49
57
  import { t, lang, register } from "/assets/app-i18n.js";
@@ -59,6 +67,7 @@ import { createOfficeStore } from "./fleet-office-store.js";
59
67
  import { OfficeEditor, officeForSave } from "./fleet-office-editor.js";
60
68
  import { LayoutEditor, handlesOf } from "./fleet-office-layout.js";
61
69
  import { applyOverrides } from "./fleet-office-pack.js";
70
+ import { ALL_ID, LOBBY_TILE, OV, createCrossFlights, cropToRooms, fromTile, inTile, labelAt, lobbyNodes, overviewOk, tileAt, tileBox, tileLayout, visibleTiles } from "./fleet-office-overview.js";
62
71
  import { centreOn, gestureState, gestures, miniPoint, toScreen, toWorld, wheelFactor, zoomKey, zoomTo } from "./fleet-canvas-view.js";
63
72
  import { OK_MAX, OK_MIN, clampPan, desksK, edgeMatte, edgeStrips, fitScene, layerScale, miniBox, miniFrame, openingView, pinchOffice, savedOf, stepK, viewFromSaved,
64
73
  zoomLabel, zoomOffice } from "./fleet-office-view.js";
@@ -77,6 +86,8 @@ register("orgo", {
77
86
  hideAsleep: "Hide paused and stopped", layoutIgnored: "Its saved layout does not fit this style: drawn as the style's own.",
78
87
  fit: "Fit the office", overview: "Overview: click to move there", fullscreen: "Fullscreen", exitFullscreen: "Exit fullscreen",
79
88
  fill: "Fill", fillTitle: "Fill the panel (the scene's overflow is cropped; drag to see it)", zoomLevel: "Zoom {0}",
89
+ allOffices: "All offices", labelAll: "All offices: each office is a tile, drawn in its own style. Drag to pan; the wheel, a pinch, + and − zoom, 0 fits, F is fullscreen. Double-click a tile to open that office.",
90
+ tileLabel: "{0} ({1})", openOffice: "Open the office {0}", lobbyTile: "Lobby tile (in no office)", lobbyEveryone: "Everyone is in an office", overviewNoEdit: "Pick one office to edit offices or a layout",
80
91
  },
81
92
  "zh-TW": {
82
93
  label: "辦公室:每個 team 是一間房間,每個 instance 一張桌子;General 和未加入 team 的 instance 在大廳。拖曳平移;滾輪、雙指、+ 和 − 縮放,0 顯示整個場景,F 切換全螢幕。",
@@ -91,6 +102,8 @@ register("orgo", {
91
102
  hideAsleep: "隱藏暫停與停止的成員", layoutIgnored: "它儲存的配置不適用這個風格:以風格本身的配置顯示。",
92
103
  fit: "顯示整個辦公室", overview: "總覽:點一下移到那裡", fullscreen: "全螢幕", exitFullscreen: "結束全螢幕",
93
104
  fill: "填滿", fillTitle: "填滿整個面板(超出的部分會被裁切,拖曳即可看到)", zoomLevel: "縮放 {0}",
105
+ allOffices: "所有辦公室", labelAll: "所有辦公室:每間辦公室是一格,以它自己的風格顯示。拖曳平移;滾輪、雙指、+ 和 − 縮放,0 顯示全部,F 切換全螢幕。雙擊一格即可開啟那間辦公室。",
106
+ tileLabel: "{0}({1})", openOffice: "開啟辦公室「{0}」", lobbyTile: "大廳格(不在任何辦公室)", lobbyEveryone: "每個成員都已在某間辦公室", overviewNoEdit: "請先選一間辦公室,再編輯辦公室或配置",
94
107
  },
95
108
  });
96
109
  const to = (k, ...v) => t(`orgo.${k}`, ...v);
@@ -105,6 +118,10 @@ const reducedMotion = () => typeof matchMedia === "function" && matchMedia("(pre
105
118
  const deviceRatio = () => Math.max(0.5, Math.min(4, Number(globalThis.devicePixelRatio) || 1));
106
119
  /** The stage's size before it is measured (a test's page has none). */
107
120
  const SIZE0 = { w: 960, h: 600 };
121
+ /** #2554: All offices' Fit keeps this many CSS px clear round the tiles (the zoom controls' column and a margin). */
122
+ const FIT_PAD = 60;
123
+ /** #2554: in All offices a tile's name tags show (always) from this many CSS px a unit up; below it on hover only. */
124
+ export const TAG_MIN_K = 0.75;
108
125
  const PAN_KEYS = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
109
126
  /** Typing: no single-key shortcut (the `f` of fullscreen) there. */
110
127
  const typing = (el) => !!el && (el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA" || el.isContentEditable === true);
@@ -155,6 +172,149 @@ function teamOptions(view) {
155
172
  ...view.teams.map((tm, i) => ({ value: `t${i}`, team: tm.name, label: tm.name }))];
156
173
  }
157
174
 
175
+ // ── A scene: one office's layout, looks and skin, drawn by itself (the single view) or as a tile (All offices, #2554) ──
176
+
177
+ /** What each desk shows: the pose, the light, the coat; dimmed when the Find box matches someone else. */
178
+ function looksOf(L, work, q, mode, zones) {
179
+ return new Map(L.rooms.flatMap((r) => r.desks).map((d) => {
180
+ const n = d.node;
181
+ if (!n) return [d.key, { name: "", label: "", state: "missing", pose: "away", light: null, lamp: false, sign: null, coat: "", work: 0, dim: !!q, match: false, free: zones }];
182
+ const match = !!q && hits(n, q);
183
+ return [d.key, { name: n.name, label: nodeText(n).primary, state: n.state, pose: officePose(n.state), light: lightOf(n.state), lamp: lampOf(n.state),
184
+ sign: zones ? null : signOf(n.state), coat: coatOf(n.i, n.i ? identityOf(n.i) : null, mode), work: work.get(n.name) || 0, dim: !!q && !match, match, free: zones }];
185
+ }));
186
+ }
187
+ /** A stored office's own layout on a zones style (its overrides); `ignored`: they do not fit this style. */
188
+ function withOverrides(baseSkin, zones, office) {
189
+ const ov = zones && office ? office.overrides : null;
190
+ if (!ov || ov.pack !== baseSkin.id) return { skin: baseSkin, ignored: false };
191
+ const a = applyOverrides(baseSkin.pack, ov);
192
+ return a.ok ? { skin: { ...baseSkin, pack: a.pack }, ignored: false } : { skin: baseSkin, ignored: true };
193
+ }
194
+ /** A stored office's scene (or the lobby tile's): seats in its style's areas, its own seats, or one room wrapping at `deskW`. */
195
+ function officeScene(office, nodes, skin, zones, deskW, prevSeats) {
196
+ if (zones) return zonesLayout(skin.pack, nodes, prevSeats);
197
+ const lay = office.layout || layoutOk(skin.layout);
198
+ return lay ? customLayout({ ...office, layout: lay }, nodes) : autoLayout([{ key: office.id, name: office.name, lobby: !!office.lobby, nodes }], { width: deskW });
199
+ }
200
+ /** What drawPart needs of a scene; its layers are painted on first draw (and again at another resolution). */
201
+ function partOf(L, looks, skin, zones, walkers, flights) {
202
+ const all = L.rooms.flatMap((r) => r.desks);
203
+ const shown = new Set([...looks.values()].map((l) => l.state));
204
+ return { L, looks, skin, zones, walkers, flights, all, desks: all.filter((d) => d.node), layers: null,
205
+ active: zones ? activeFurniture(skin.pack, shown) : new Set(), lines: bubbleDefaults(), lang: lang() };
206
+ }
207
+ /** The back and front layers: pictures of the scene at `ls` layer px per logical unit (`tagsOff`: no name tags but the Find box's matches). */
208
+ function paintLayers(p, env, ls, tagsOff = false) {
209
+ const { L, skin, zones, looks, all } = p;
210
+ const lw = Math.max(1, Math.ceil(L.width * ls)), lh = Math.max(1, Math.ceil(L.height * ls));
211
+ const layer = (fn) => {
212
+ const c = document.createElement("canvas");
213
+ c.width = lw; c.height = lh;
214
+ const g = c.getContext && c.getContext("2d");
215
+ if (!g) return null;
216
+ g.setTransform(ls, 0, 0, ls, 0, 0);
217
+ g.imageSmoothingEnabled = false;
218
+ fn(g);
219
+ return c;
220
+ };
221
+ const back = layer((g) => {
222
+ g.fillStyle = env.pal.bg; g.fillRect(0, 0, L.width, L.height);
223
+ if (zones) {
224
+ skin.drawBackground(g, L, env);
225
+ return;
226
+ }
227
+ skin.drawFloor(g, L, env);
228
+ for (const r of L.rooms) skin.drawRoom(g, r, env);
229
+ for (const d of all) skin.drawDeskBack(g, d, looks.get(d.key), env);
230
+ });
231
+ const front = zones ? null : layer((g) => {
232
+ for (const d of all) { const lk = looks.get(d.key); skin.drawDeskFront(g, d, tagsOff && lk && !lk.match ? { ...lk, notag: true } : lk, env); }
233
+ });
234
+ return { ls, tagsOff, back, front };
235
+ }
236
+ /**
237
+ * One frame of a scene at `ms` (0: still), a logical unit `a` bitmap px, its origin at (ox, oy); `hot`: the desk key
238
+ * hovered (its name tag shows even in a crowd); `tagsOff` (#2554: a tile too small to read them): the name tags show
239
+ * only for the one hovered and the Find box's matches. Whether anything in it is moving (in the air, or someone walking).
240
+ */
241
+ function drawPart(ctx, p, a, ox, oy, ms, still, hot, env, tagsOff = false, defer = null) {
242
+ const sk = p.skin;
243
+ const ls = layerScale(a, p.L.width, p.L.height);
244
+ if (!p.layers || p.layers.ls !== ls || p.layers.tagsOff !== tagsOff) p.layers = paintLayers(p, env, ls, tagsOff);
245
+ const T = () => ctx.setTransform(a, 0, 0, a, ox, oy);
246
+ const blit = (c) => { if (!c) return; ctx.setTransform(a / ls, 0, 0, a / ls, ox, oy); ctx.drawImage(c, 0, 0); T(); };
247
+ blit(p.layers.back);
248
+ T();
249
+ const speakers = [], tags = [];
250
+ const up = still ? [] : p.flights.frame(ms, p.L);
251
+ let walking = false;
252
+ if (p.zones) {
253
+ // Furniture under the people; the people (nearest last) with the furniture whose depth is "y" among them, by base
254
+ // y (a tie: the item first, the person in front); furniture over them; things in the air; the pack's foreground
255
+ // (door frames, pillars, table fronts); then what must stay readable: crowd counts and name tags (bubbles below).
256
+ const t = ms > 0 ? ms : now();
257
+ const people = p.desks.map((d) => {
258
+ const at = p.walkers.at(d.node.name, t) || { ...d.seat, walking: false };
259
+ if (at.walking) walking = true;
260
+ return { d, at, look: p.looks.get(d.key) };
261
+ }).sort((x, y) => x.at.y - y.at.y);
262
+ const furniture = sk.pack.furniture;
263
+ const band = [...furniture.filter((f) => f.depth === "y").map((f) => ({ y: f.y, k: 0, f })), ...people.map((q) => ({ y: q.at.y, k: 1, p: q }))]
264
+ .sort((x, y) => x.y - y.y || x.k - y.k);
265
+ for (const f of furniture) if (f.depth !== "y" && f.depth < CHAR_DEPTH) sk.drawFurniture(ctx, f, p.active.has(f.id), ms, env);
266
+ for (const { f, p: q } of band) {
267
+ if (f) { sk.drawFurniture(ctx, f, p.active.has(f.id), ms, env); continue; }
268
+ const box = { ...q.d, x: q.at.x - 44, y: q.at.y - 53, w: 88, h: 53, seat: { x: q.at.x, y: q.at.y } };
269
+ sk.drawCharacter(ctx, box, q.at.walking ? { ...q.look, pose: "walk", heading: q.at.heading } : q.look, ms, env);
270
+ if (!q.at.walking) speakers.push({ look: q.look, x: q.at.x + 10, y: q.at.y - 50, area: q.d.area });
271
+ }
272
+ for (const f of furniture) if (f.depth !== "y" && f.depth >= CHAR_DEPTH) sk.drawFurniture(ctx, f, p.active.has(f.id), ms, env);
273
+ for (const it of up) sk.drawProp(ctx, it, ms, env);
274
+ if (sk.drawForeground) sk.drawForeground(ctx, p.L, env);
275
+ // Who found no spot in an area: a count in its corner (never over its name).
276
+ for (const [ar, n] of p.L.crowd || []) if (p.L.areas[ar] && sk.drawCrowd) sk.drawCrowd(ctx, p.L.areas[ar], n, env);
277
+ // In an area over capacity the name tags show on hover or focus only (#2513: a crowd's tags covered each other).
278
+ const over = p.L.over || new Set();
279
+ for (const q of people) {
280
+ if ((tagsOff || over.has(q.d.area)) && q.d.key !== hot && !q.look.match) continue;
281
+ sk.drawTag(ctx, q.at.x, q.at.y, q.look, env);
282
+ tags.push(tagBox(ctx, q.at.x, q.at.y, q.look.label));
283
+ }
284
+ } else {
285
+ for (const d of p.desks) { const look = p.looks.get(d.key); sk.drawCharacter(ctx, d, look, ms, env); speakers.push({ look, x: d.x + 58, y: d.y + 12 }); }
286
+ blit(p.layers.front);
287
+ // Tags left out of the layer: the hovered desk's front again, with its tag.
288
+ const hd = tagsOff && hot ? p.desks.find((d) => d.key === hot) : null;
289
+ if (hd) sk.drawDeskFront(ctx, hd, p.looks.get(hd.key), env);
290
+ for (const it of up) sk.drawProp(ctx, it, ms, env);
291
+ }
292
+ // Speech bubbles: a short line for the state, now and then (typed out; whole at once when still), placed so no two
293
+ // overlap (placeBubbles; #2431).
294
+ const bubbles = [];
295
+ for (const sp of speakers) {
296
+ if (sp.look.dim || sp.look.pose === "away") continue;
297
+ const b = bubbleAt(sp.look.name, sp.look.state, ms, linesFor(sp.look.state, p.lang, sk.pack, p.lines));
298
+ if (b) bubbles.push({ key: sp.look.name, area: sp.area, state: sp.look.state, calling: CALLING.has(sp.look.state), rank: bubbleRank(sp.look.state), shown: b.shown,
299
+ box: bubbleBox(ctx, sp.x, sp.y, b.text), tail: { x: sp.x, y: sp.y }, sp, b });
300
+ }
301
+ // A crowded area speaks less (thinBubbles: calls, then work, a few at most); no bubble covers a name tag. `defer`
302
+ // (All offices): drawn after every tile, unclipped, so a bubble at a tile's edge is whole.
303
+ const placed = placeBubbles(thinBubbles(bubbles, p.zones ? p.L.over : null), 22, tags);
304
+ const say = () => { T(); for (const { sp, b, dy } of placed) sk.drawBubble(ctx, sp.x, sp.y, [...b.text].slice(0, b.shown).join(""), env, -dy); };
305
+ if (defer) defer.push(say); else say();
306
+ return walking || up.length > 0 || p.flights.size() > 0;
307
+ }
308
+ /**
309
+ * The person at (x, y) in a scene's units, as it draws them: on zones where they walk, in the box the skin draws them
310
+ * in (an uploaded character's strip, anchor and size: #2513 review), nearest first; on desks, the desk.
311
+ */
312
+ function hitPart(p, x, y, t) {
313
+ if (!p.zones) return deskAt(p.L, x, y);
314
+ return personAt(p.all, x, y, (d) => p.walkers.at(d.node.name, t),
315
+ (d, at) => (p.skin.charBox && p.looks.get(d.key) ? p.skin.charBox(p.looks.get(d.key), at) : null));
316
+ }
317
+
158
318
  function People({ nodes, pinned, onChange }) {
159
319
  const [open, setOpen] = useState(false);
160
320
  const [find, setFind] = useState("");
@@ -196,6 +356,7 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
196
356
  const [filter, setFilter] = useState(() => store.filter());
197
357
  const [style, setStyle] = useState(() => store.skin());
198
358
  const [hideAsleep, setHideAsleep] = useState(() => store.hideAsleep());
359
+ const [lobbyOn, setLobbyOn] = useState(() => store.lobbyTile()); // #2554: All offices' lobby tile
199
360
  const [packs, setPacks] = useState([]); // the skin packs the host keeps: [{ id, name, sha }]
200
361
  const [packSkins, setPackSkins] = useState(() => new Map()); // id@sha → a loaded skin, or null (not a pack)
201
362
  // Edit layout's session while it is open: { id, officeId, skin, pack (the style's identity), base (the pack as kept),
@@ -216,6 +377,10 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
216
377
  const flights = useMemo(() => createFlights(), []);
217
378
  const walkers = useMemo(() => createWalkers(), []);
218
379
  const seatsRef = useRef(new Map()); // the zones layout's last seats: kept where they can be
380
+ // #2554: All offices' tiles keep their own: seats (by tile and style), walkers and flights (by tile); and the items
381
+ // flying between tiles.
382
+ const tileSeats = useRef(new Map()), tileWalkers = useRef(new Map()), tileFlights = useRef(new Map());
383
+ const cross = useMemo(() => createCrossFlights(), []);
219
384
  const still = useMemo(reducedMotion, []);
220
385
 
221
386
  // The host's offices: on opening, and again whenever the stream says they changed. Answers can arrive out of order
@@ -291,18 +456,26 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
291
456
  // snaps to whole numbers of these (fleet-office-view.js), so a pixel-level view maps a logical unit onto whole pixels.
292
457
  const ratio = backingScale(size.w, size.h, dpr);
293
458
 
294
- const custom = choice !== AUTO_ID ? stored.find((o) => o.id === choice) || null : null;
459
+ // #2554: All offices — chosen, and at least two offices stored (else the whole fleet, as an office gone is).
460
+ const overview = choice === ALL_ID && overviewOk(stored);
461
+ const custom = choice !== AUTO_ID && !overview ? stored.find((o) => o.id === choice) || null : null;
295
462
  // The style: a built-in one, or an uploaded pack once it is loaded (until then, and when it is not one, Desks).
296
463
  const wanted = custom ? custom.skin : style;
297
- const packInfo = STYLES.includes(wanted) ? null : packs.find((pk) => pk.id === wanted) || null;
464
+ const packOf = (id) => (STYLES.includes(id) ? null : packs.find((pk) => pk.id === id) || null);
465
+ const packInfo = packOf(wanted);
298
466
  const packKey = packInfo ? `${packInfo.id}@${packInfo.sha}` : null;
467
+ // The packs to load: this office's style; in All offices every tile's (each loaded once, shared by the tiles using it).
468
+ const needed = [...new Map((overview ? [...stored.map((o) => o.skin), ...(lobbyOn ? [style] : [])] : [wanted]).map(packOf).filter(Boolean)
469
+ .map((pk) => [`${pk.id}@${pk.sha}`, pk])).entries()];
470
+ const neededKey = needed.map(([k]) => k).join(",");
299
471
  useEffect(() => {
300
- if (!packInfo || packSkins.has(packKey)) return undefined;
472
+ const want = needed.filter(([k]) => !packSkins.has(k));
473
+ if (!want.length) return undefined;
301
474
  let live = true;
302
475
  const get = lease ? (path, o) => lease.fetch(path, o) : undefined;
303
- loadPackSkin(packInfo.id, packInfo.sha, get ? { fetch: get } : {}).then((sk) => { if (live) setPackSkins((m) => new Map(m).set(packKey, sk)); });
476
+ for (const [k, pk] of want) loadPackSkin(pk.id, pk.sha, get ? { fetch: get } : {}).then((sk) => { if (live) setPackSkins((m) => new Map(m).set(k, sk)); });
304
477
  return () => { live = false; };
305
- }, [packKey]);
478
+ }, [neededKey]);
306
479
  const loading = !!packInfo && !packSkins.has(packKey);
307
480
  const baseSkin = packInfo ? packSkins.get(packKey) || skinFor("pixel") : skinFor(STYLES.includes(wanted) ? wanted : custom ? wanted : "pixel");
308
481
  const zones = !!(baseSkin.pack && baseSkin.pack.stateZones);
@@ -312,42 +485,66 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
312
485
  const layoutStale = !!layout && (!custom || custom.id !== layout.officeId || custom.skin !== layout.skin || styleKey !== layout.pack || !zones || loading);
313
486
  // A stored office's own layout on a zones style (its overrides; while Edit layout is open, the draft).
314
487
  const placed = useMemo(() => {
315
- if (!zones) return { skin: baseSkin, ignored: false };
316
- if (layout && !layoutStale) return { skin: { ...baseSkin, pack: layout.draft }, ignored: false };
317
- const ov = custom && custom.overrides;
318
- if (!ov || ov.pack !== baseSkin.id) return { skin: baseSkin, ignored: false };
319
- const a = applyOverrides(baseSkin.pack, ov);
320
- return a.ok ? { skin: { ...baseSkin, pack: a.pack }, ignored: false } : { skin: baseSkin, ignored: true };
488
+ if (zones && layout && !layoutStale) return { skin: { ...baseSkin, pack: layout.draft }, ignored: false };
489
+ return withOverrides(baseSkin, zones, custom);
321
490
  }, [baseSkin, zones, custom, layout, layoutStale]);
322
491
  const skin = placed.skin;
323
- const L = useMemo(() => {
492
+ const keep = (n) => !(hideAsleep && ASLEEP.has(n.state));
493
+ const single = useMemo(() => {
494
+ if (overview) return { rooms: [], width: 1, height: 1 }; // All offices: the tiles below
324
495
  const all = custom ? membersOf(custom, view) : [...new Map(officeRooms(view, filter).flatMap((r) => r.nodes).map((n) => [n.name, n])).values()];
325
- const keep = (n) => !(hideAsleep && ASLEEP.has(n.state));
326
496
  const nodes = all.filter(keep);
327
497
  if (zones) { const z = zonesLayout(skin.pack, nodes, seatsRef.current); seatsRef.current = z.seats; return z; }
328
498
  // The desks wrap at the stage's width at the scale they open on (desksK: a pixel level near one CSS px a unit).
329
499
  const deskW = Math.max(240, Math.floor(width / desksK(ratio)));
330
500
  if (!custom) return autoLayout(officeRooms(view, filter).map((r) => (hideAsleep ? { ...r, nodes: r.nodes.filter(keep) } : r)), { width: deskW });
331
- const lay = custom.layout || layoutOk(skin.layout);
332
- return lay ? customLayout({ ...custom, layout: lay }, nodes) : autoLayout([{ key: custom.id, name: custom.name, lobby: false, nodes }], { width: deskW });
333
- }, [view, filter, custom, width, ratio, skin, hideAsleep]);
334
- const looks = useMemo(() => {
335
- const work = workOn(traffic);
336
- return new Map(L.rooms.flatMap((r) => r.desks).map((d) => {
337
- const n = d.node;
338
- if (!n) return [d.key, { name: "", label: "", state: "missing", pose: "away", light: null, lamp: false, sign: null, coat: "", work: 0, dim: !!q, match: false, free: zones }];
339
- const match = !!q && hits(n, q);
340
- return [d.key, { name: n.name, label: nodeText(n).primary, state: n.state, pose: officePose(n.state), light: lightOf(n.state), lamp: lampOf(n.state),
341
- sign: zones ? null : signOf(n.state), coat: coatOf(n.i, n.i ? identityOf(n.i) : null, mode), work: work.get(n.name) || 0, dim: !!q && !match, match, free: zones }];
342
- }));
343
- }, [L, traffic, q, mode, themeRev]);
501
+ return officeScene(custom, nodes, skin, false, deskW);
502
+ }, [overview, view, filter, custom, width, ratio, skin, hideAsleep]);
503
+ // ── #2554: All offices' tiles — each stored office (then the lobby: whoever is in no office) laid out as its own view
504
+ // lays it out, in its own style (desks wrap at a cell's width), with its own seats, walkers and flights ──
505
+ const lobbyAll = useMemo(() => (overview ? lobbyNodes(stored, view) : []), [overview, stored, view]);
506
+ const lobbyList = lobbyOn ? lobbyAll : [];
507
+ const tiles = useMemo(() => {
508
+ if (!overview) return [];
509
+ const mine = (m, k, make) => { if (!m.current.has(k)) m.current.set(k, make()); return m.current.get(k); };
510
+ const specs = stored.map((o) => ({ key: o.id, office: o, name: o.name, lobby: false, wanted: o.skin, nodes: membersOf(o, view) }));
511
+ if (lobbyList.length) specs.push({ key: LOBBY_TILE, office: { id: LOBBY_TILE, name: null, lobby: true, layout: null }, name: to("lobbyRoom"), lobby: true, wanted: style, nodes: lobbyList });
512
+ return specs.map((sp) => {
513
+ const info = packOf(sp.wanted), pk = info ? `${info.id}@${info.sha}` : null;
514
+ const base = info ? packSkins.get(pk) || skinFor("pixel") : skinFor(sp.wanted);
515
+ const z = !!(base.pack && base.pack.stateZones);
516
+ const sk = withOverrides(base, z, sp.lobby ? null : sp.office).skin;
517
+ const seatKey = `${sp.key}@${pk || base.id}`;
518
+ // A desks office is as large as its rooms (cropped: no margins, no corridors outside them).
519
+ const TL0 = officeScene(sp.office, sp.nodes.filter(keep), sk, z, OV.CELL_W, tileSeats.current.get(seatKey)), TL = z ? TL0 : cropToRooms(TL0);
520
+ if (z) tileSeats.current.set(seatKey, TL0.seats);
521
+ return { key: sp.key, name: sp.name, lobby: sp.lobby, count: sp.nodes.length, skin: sk, zones: z, L: TL, prefix: `${sp.key}\u0001`,
522
+ walkers: mine(tileWalkers, sp.key, createWalkers), flights: mine(tileFlights, sp.key, createFlights) };
523
+ });
524
+ }, [overview, stored, view, packs, packSkins, hideAsleep, lobbyOn, lobbyAll, style]);
525
+ const arranged = useMemo(() => tileLayout(tiles.map((tl) => ({ key: tl.key, w: tl.L.width, h: tl.L.height })), size), [tiles, size.w, size.h]);
526
+ const tileLooks = useMemo(() => { const work = workOn(traffic); return tiles.map((tl) => looksOf(tl.L, work, q, mode, tl.zones)); }, [tiles, traffic, q, mode, themeRev]);
527
+ // The scene the view pans and zooms: the office's, or the overview of every tile.
528
+ const L = useMemo(() => (overview ? { mode: "overview", rooms: [], width: arranged.width, height: arranged.height } : single), [overview, single, arranged]);
529
+ const looks = useMemo(() => looksOf(L, workOn(traffic), q, mode, zones), [L, traffic, q, mode, themeRev]);
344
530
  useEffect(() => { flights.sync(traffic || [], now(), still); }, [traffic]);
345
531
  // Zones: everyone's seat is where they walk to.
346
532
  useEffect(() => {
347
- if (!zones) return;
533
+ if (!zones || overview) return;
348
534
  walkers.sync(new Map(desksOf(L).map((d) => [d.node.name, d.seat])), now(), L, still);
349
535
  loop.current?.wake();
350
536
  }, [L]);
537
+ // …and in each tile on a zones style; the traffic within a tile is its own, between tiles the overview's.
538
+ useEffect(() => {
539
+ for (const tl of tiles) if (tl.zones) tl.walkers.sync(new Map(desksOf(tl.L).map((d) => [d.node.name, d.seat])), now(), tl.L, still);
540
+ loop.current?.wake();
541
+ }, [tiles]);
542
+ useEffect(() => {
543
+ if (!overview) return;
544
+ for (const tl of tiles) tl.flights.sync(traffic || [], now(), still);
545
+ cross.sync(traffic || [], now(), still);
546
+ loop.current?.wake();
547
+ }, [traffic, tiles]);
351
548
 
352
549
  // ── The view (#2540): { k, x, y } as the org chart canvas's (fleet-canvas-view.js) — logical units → the stage's CSS
353
550
  // px. Set without a render (a ref): the canvas redraws, the overview, the tooltip and Edit layout's overlay follow ──
@@ -355,7 +552,9 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
355
552
  // touched: a person moved the view (it is kept); fitted: the view is a fit (a resize fits it again, to the new size).
356
553
  const touched = useRef(false), fitted = useRef(false), drag = useRef(gestureState());
357
554
  const miniViewRef = useRef(null), miniRef = useRef(null), overlayEl = useRef(null), tipDeskRef = useRef(null), pctRef = useRef(null);
358
- const officeKey = `${custom ? custom.id : AUTO_ID}@${baseSkin.id}`; // what a view is kept for: the office and its style
555
+ const labelEls = useRef(new Map()); // #2554: the tiles' labels (tile key → element)
556
+ // What a view is kept for: the office and its style (All offices: its own key).
557
+ const officeKey = overview ? `${ALL_ID}@tiles` : `${custom ? custom.id : AUTO_ID}@${baseSkin.id}`;
359
558
  const keyRef = useRef(officeKey), saveTimer = useRef(0);
360
559
  // The fit mode (Fit: contain; Fill: cover), kept per office and style like the view.
361
560
  const [fitRev, setFitRev] = useState(0);
@@ -400,6 +599,21 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
400
599
  const pc = pctRef.current;
401
600
  if (pc) { const lb = zoomLabel(v.k); if (pc.textContent !== lb) pc.textContent = lb; pc.setAttribute("aria-label", to("zoomLevel", lb)); }
402
601
  if (overlayEl.current) placeOverlay(overlayEl.current);
602
+ // All offices: each tile's label just above it, as wide as the room up to the next label in its row (or the stage's
603
+ // edge); hidden while its tile is off screen (nothing past the stage's edge for a focus to scroll it to: the
604
+ // controls stay where they are).
605
+ const shownTiles = overview ? new Set(visibleTiles(arranged, v, size).map((tt) => tt.key)) : null;
606
+ for (const [i, tt] of (overview ? arranged.tiles : []).entries()) {
607
+ const el = labelEls.current.get(tt.key);
608
+ if (!el || !el.style) continue;
609
+ const lp = toScreen(v, tt.x, tt.y), next = arranged.tiles[i + 1];
610
+ const room = next && next.label.y === tt.label.y ? (next.x - tt.x) * v.k - 6 : size.w - lp.x - 8;
611
+ el.style.setProperty("left", `${lp.x}px`); el.style.setProperty("top", `${lp.y}px`);
612
+ el.style.setProperty("max-width", `${Math.max(40, room)}px`);
613
+ el.style.setProperty("visibility", shownTiles.has(tt.key) ? "visible" : "hidden");
614
+ }
615
+ const st = stageRef.current;
616
+ if (st && (st.scrollLeft || st.scrollTop)) { st.scrollLeft = 0; st.scrollTop = 0; }
403
617
  placeTip();
404
618
  };
405
619
  /** …and redraw. */
@@ -420,12 +634,18 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
420
634
  if (saveTimer.current) clearTimeout(saveTimer.current);
421
635
  saveTimer.current = setTimeout(flushSave, 400);
422
636
  };
637
+ /**
638
+ * The scene fitted (`mode`: contain or cover). All offices' contain keeps FIT_PAD px clear all round, so no tile sits
639
+ * under the zoom controls at the side.
640
+ */
641
+ const fitL = (mode) => fitScene(L.width, L.height, size, mode, overview && mode !== "cover" ? FIT_PAD : 0);
423
642
  /** The view an office opens on: its kept one (a kept fit: fitted to this size), else its opening; and whether a fit. */
424
643
  const openView = () => {
425
644
  const kept = store.view(officeKey);
426
- if (kept && kept.fit) return { v: fitScene(L.width, L.height, size, fitMode), fit: true };
645
+ if (kept && kept.fit) return { v: fitL(fitMode), fit: true };
427
646
  if (kept) return { v: viewFromSaved(kept, size), fit: false };
428
- return { v: openingView(L.width, L.height, size, ratio, zones, fitMode), fit: zones };
647
+ const fitted0 = zones || overview; // All offices opens whole, as a zones scene
648
+ return { v: overview ? fitL(fitMode) : openingView(L.width, L.height, size, ratio, fitted0, fitMode), fit: fitted0 };
429
649
  };
430
650
  // An office (or style) opened: its kept view, else its opening (zones fitted; desks from the top left). A resize fits
431
651
  // a fitted view again (and opens an untouched one again); a view a person moved keeps the same point in the middle.
@@ -440,16 +660,16 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
440
660
  opened.current = officeKey; keyRef.current = officeKey; touched.current = false;
441
661
  reopen();
442
662
  } else if (was && (was.w !== size.w || was.h !== size.h || was.ratio !== ratio)) {
443
- if (fitted.current) vp.current = fitScene(L.width, L.height, size, fitMode);
663
+ if (fitted.current) vp.current = fitL(fitMode);
444
664
  else if (!touched.current) reopen();
445
665
  else { const v = vp.current, cx = (was.w / 2 - v.x) / v.k, cy = (was.h / 2 - v.y) / v.k; vp.current = { k: v.k, x: size.w / 2 - cx * v.k, y: size.h / 2 - cy * v.k }; }
446
- } else if (fitted.current) vp.current = fitScene(L.width, L.height, size, fitMode); // the scene's size changed
666
+ } else if (fitted.current) vp.current = fitL(fitMode); // the scene's size changed
447
667
  vp.current = clampPan(view0(), L.width, L.height, size);
448
668
  raw.current = vp.current.k;
449
669
  }, [officeKey, size.w, size.h, ratio, L.width, L.height]);
450
670
  useEffect(() => () => flushSave(), []);
451
671
  /** Fit (the button, `0`): the office's mode — `mode` when it was just switched. */
452
- const fit = (mode = fitMode) => { const v = fitScene(L.width, L.height, size, mode); raw.current = v.k; setView(v, true, true); };
672
+ const fit = (mode = fitMode) => { const v = fitL(mode); raw.current = v.k; setView(v, true, true); };
453
673
  /** Fill on or off: kept for this office, and fitted at once. */
454
674
  const toggleFill = () => {
455
675
  const next = fitMode === "cover" ? "contain" : "cover";
@@ -538,8 +758,9 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
538
758
  // or the zoom changes, never on a pan); each frame draws the matte, the layers, the people and the items in the air ──
539
759
  const scene = useRef(null);
540
760
  const loop = useRef(null);
541
- // The matte around the scene: the pack's own, else its background's edge (sampled once), else the skin's (tokens).
542
- const matte = (skin.pack && skin.pack.matte) || sampledMatte(skin.backdrop) || (skin.matte ? skin.matte({ pal, mode }) : pal.bg);
761
+ // The matte around the scene: the pack's own, else its background's edge (sampled once), else the skin's (tokens);
762
+ // All offices: the dashboard's background (each tile is its own scene).
763
+ const matte = overview ? pal.bg : (skin.pack && skin.pack.matte) || sampledMatte(skin.backdrop) || (skin.matte ? skin.matte({ pal, mode }) : pal.bg);
543
764
  // …and behind the canvas too (the stage), so no page grey shows while it is (re)sized.
544
765
  useLayoutEffect(() => { const el = stageRef.current; if (el && el.style) el.style.setProperty("background-color", matte); }, [matte]);
545
766
  useLayoutEffect(() => {
@@ -552,108 +773,54 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
552
773
  cv.style.setProperty("width", `${BW / ratio}px`);
553
774
  cv.style.setProperty("height", `${BH / ratio}px`);
554
775
  const env = { pal, mode, labels: { lobby: to("lobbyRoom"), closed: to("closed"), areas: { desks: to("area_desks"), lounge: to("area_lounge"), alert: to("area_alert"), nap: to("area_nap") } } };
555
- const all = L.rooms.flatMap((r) => r.desks);
556
- /** The layers at `ls` layer px per logical unit. */
557
- const paint = (ls) => {
558
- const lw = Math.max(1, Math.ceil(L.width * ls)), lh = Math.max(1, Math.ceil(L.height * ls));
559
- const layer = (fn) => {
560
- const c = document.createElement("canvas");
561
- c.width = lw; c.height = lh;
562
- const g = c.getContext && c.getContext("2d");
563
- if (!g) return null;
564
- g.setTransform(ls, 0, 0, ls, 0, 0);
565
- g.imageSmoothingEnabled = false;
566
- fn(g);
567
- return c;
568
- };
569
- const back = layer((g) => {
570
- g.fillStyle = env.pal.bg; g.fillRect(0, 0, L.width, L.height);
571
- if (zones) {
572
- skin.drawBackground(g, L, env);
573
- return;
574
- }
575
- skin.drawFloor(g, L, env);
576
- for (const r of L.rooms) skin.drawRoom(g, r, env);
577
- for (const d of all) skin.drawDeskBack(g, d, looks.get(d.key), env);
578
- });
579
- const front = zones ? null : layer((g) => { for (const d of all) skin.drawDeskFront(g, d, looks.get(d.key), env); });
580
- return { ls, back, front };
581
- };
582
- const shown = new Set([...looks.values()].map((l) => l.state));
583
- scene.current = { ctx, ratio, BW, BH, paint, layers: null, matte, env, desks: all.filter((d) => d.node), looks, L, skin, zones, dirty: true,
584
- active: zones ? activeFurniture(skin.pack, shown) : new Set(), lines: bubbleDefaults(), lang: lang() };
776
+ const base = { ctx, ratio, BW, BH, matte, env, dirty: true };
777
+ if (overview) {
778
+ // Each tile a scene of its own, where the overview puts it (arranged.tiles, in the same order).
779
+ const parts = tiles.map((tl, i) => ({ ...partOf(tl.L, tileLooks[i], tl.skin, tl.zones, tl.walkers, tl.flights), key: tl.key, prefix: tl.prefix, tile: arranged.tiles[i] }));
780
+ scene.current = { ...base, overview: true, parts, lay: arranged };
781
+ } else {
782
+ scene.current = { ...base, overview: false, part: partOf(L, looks, skin, zones, walkers, flights) };
783
+ }
585
784
  if (still) draw(0); else loop.current?.wake();
586
- }, [L, looks, skin, pal, size.w, size.h, ratio, matte]);
785
+ }, [L, looks, skin, pal, size.w, size.h, ratio, matte, overview, tiles, tileLooks, arranged]);
587
786
 
588
787
  /** One frame at `ms` (0: still); whether anything is moving (in the air, or someone walking). */
589
788
  function draw(ms) {
590
789
  const s = scene.current;
591
790
  if (!s) return false;
592
- const { ctx, BW, BH, env, skin: sk } = s;
791
+ const { ctx, BW, BH, env } = s;
593
792
  // The view in bitmap px: a logical unit is `a` px (a whole number at a pixel level), the origin on a whole px.
594
793
  const v = view0(), a = v.k * s.ratio, ox = Math.round(v.x * s.ratio), oy = Math.round(v.y * s.ratio);
595
- const ls = layerScale(a, s.L.width, s.L.height);
596
- if (!s.layers || s.layers.ls !== ls) s.layers = s.paint(ls);
597
- const T = () => ctx.setTransform(a, 0, 0, a, ox, oy);
598
- const blit = (c) => { if (!c) return; ctx.setTransform(a / ls, 0, 0, a / ls, ox, oy); ctx.drawImage(c, 0, 0); T(); };
599
794
  ctx.setTransform(1, 0, 0, 1, 0, 0);
600
795
  ctx.clearRect(0, 0, BW, BH);
601
796
  ctx.fillStyle = s.matte; ctx.fillRect(0, 0, BW, BH);
602
797
  ctx.imageSmoothingEnabled = false;
603
- blit(s.layers.back);
604
- T();
605
- const speakers = [], tags = [];
606
- const up = still ? [] : flights.frame(ms, s.L);
607
- let walking = false;
608
- if (s.zones) {
609
- // Furniture under the people; the people (nearest last) with the furniture whose depth is "y" among them, by base
610
- // y (a tie: the item first, the person in front); furniture over them; things in the air; the pack's foreground
611
- // (door frames, pillars, table fronts); then what must stay readable: crowd counts and name tags (bubbles below).
612
- const t = ms > 0 ? ms : now();
613
- const people = s.desks.map((d) => {
614
- const at = walkers.at(d.node.name, t) || { ...d.seat, walking: false };
615
- if (at.walking) walking = true;
616
- return { d, at, look: s.looks.get(d.key) };
617
- }).sort((a, b) => a.at.y - b.at.y);
618
- const furniture = s.skin.pack.furniture;
619
- const band = [...furniture.filter((f) => f.depth === "y").map((f) => ({ y: f.y, k: 0, f })), ...people.map((p) => ({ y: p.at.y, k: 1, p }))]
620
- .sort((a, b) => a.y - b.y || a.k - b.k);
621
- for (const f of furniture) if (f.depth !== "y" && f.depth < CHAR_DEPTH) sk.drawFurniture(ctx, f, s.active.has(f.id), ms, env);
622
- for (const { f, p } of band) {
623
- if (f) { sk.drawFurniture(ctx, f, s.active.has(f.id), ms, env); continue; }
624
- const box = { ...p.d, x: p.at.x - 44, y: p.at.y - 53, w: 88, h: 53, seat: { x: p.at.x, y: p.at.y } };
625
- sk.drawCharacter(ctx, box, p.at.walking ? { ...p.look, pose: "walk", heading: p.at.heading } : p.look, ms, env);
626
- if (!p.at.walking) speakers.push({ look: p.look, x: p.at.x + 10, y: p.at.y - 50, area: p.d.area });
627
- }
628
- for (const f of furniture) if (f.depth !== "y" && f.depth >= CHAR_DEPTH) sk.drawFurniture(ctx, f, s.active.has(f.id), ms, env);
629
- for (const it of up) sk.drawProp(ctx, it, ms, env);
630
- if (sk.drawForeground) sk.drawForeground(ctx, s.L, env);
631
- // Who found no spot in an area: a count in its corner (never over its name).
632
- for (const [a, n] of s.L.crowd || []) if (s.L.areas[a] && sk.drawCrowd) sk.drawCrowd(ctx, s.L.areas[a], n, env);
633
- // In an area over capacity the name tags show on hover or focus only (#2513: a crowd's tags covered each other).
634
- const over = s.L.over || new Set(), hot = tipNow.current;
635
- for (const p of people) {
636
- if (over.has(p.d.area) && p.d.key !== hot && !p.look.match) continue;
637
- sk.drawTag(ctx, p.at.x, p.at.y, p.look, env);
638
- tags.push(tagBox(ctx, p.at.x, p.at.y, p.look.label));
639
- }
640
- } else {
641
- for (const d of s.desks) { const look = s.looks.get(d.key); sk.drawCharacter(ctx, d, look, ms, env); speakers.push({ look, x: d.x + 58, y: d.y + 12 }); }
642
- blit(s.layers.front);
643
- for (const it of up) sk.drawProp(ctx, it, ms, env);
798
+ if (!s.overview) return drawPart(ctx, s.part, a, ox, oy, ms, still, tipNow.current, env);
799
+ // All offices: each tile on screen drawn as its own view, scaled into it and clipped to it; one off screen is not
800
+ // drawn (its flights still land). Then a frame round each tile, and the items flying between tiles.
801
+ const hot = tipNow.current, on = new Set(visibleTiles(s.lay, v, sizeRef.current).map((tt) => tt.key)), said = [];
802
+ let moving = false;
803
+ for (const p of s.parts) {
804
+ const tt = p.tile;
805
+ if (!on.has(tt.key)) { if (!still) p.flights.frame(ms, p.L); continue; }
806
+ const tx = Math.round((v.x + tt.x * v.k) * s.ratio), ty = Math.round((v.y + tt.y * v.k) * s.ratio), tw = tt.w * v.k * s.ratio, th = tt.h * v.k * s.ratio;
807
+ ctx.save();
808
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
809
+ ctx.beginPath(); ctx.rect(tx, ty, tw, th); ctx.clip();
810
+ if (drawPart(ctx, p, a * tt.s, tx, ty, ms, still, hot && hot.startsWith(p.prefix) ? hot.slice(p.prefix.length) : null, env, v.k * tt.s < TAG_MIN_K, said)) moving = true;
811
+ ctx.restore();
812
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
813
+ ctx.strokeStyle = env.pal.border; ctx.lineWidth = 1;
814
+ ctx.strokeRect(tx - 0.5, ty - 0.5, tw + 1, th + 1);
644
815
  }
645
- // Speech bubbles: a short line for the state, now and then (typed out; whole at once when still), placed so no two
646
- // overlap (placeBubbles; #2431).
647
- const bubbles = [];
648
- for (const sp of speakers) {
649
- if (sp.look.dim || sp.look.pose === "away") continue;
650
- const b = bubbleAt(sp.look.name, sp.look.state, ms, linesFor(sp.look.state, s.lang, sk.pack, s.lines));
651
- if (b) bubbles.push({ key: sp.look.name, area: sp.area, state: sp.look.state, calling: CALLING.has(sp.look.state), rank: bubbleRank(sp.look.state), shown: b.shown,
652
- box: bubbleBox(ctx, sp.x, sp.y, b.text), tail: { x: sp.x, y: sp.y }, sp, b });
816
+ for (const say of said) say();
817
+ const up = still ? [] : cross.frame(ms, s.parts);
818
+ if (up.length) {
819
+ ctx.setTransform(a, 0, 0, a, ox, oy);
820
+ const prop = skinFor("pixel").drawProp;
821
+ for (const it of up) prop(ctx, it, ms, env);
653
822
  }
654
- // A crowded area speaks less (thinBubbles: calls, then work, a few at most); no bubble covers a name tag.
655
- for (const { sp, b, dy } of placeBubbles(thinBubbles(bubbles, s.zones ? s.L.over : null), 22, tags)) sk.drawBubble(ctx, sp.x, sp.y, [...b.text].slice(0, b.shown).join(""), env, -dy);
656
- return walking || up.length > 0 || flights.size() > 0;
823
+ return moving || up.length > 0 || cross.size() > 0;
657
824
  }
658
825
  // The loop: drawing only when the frame (FPS) changed or something is in the air; stopped while the tab is hidden,
659
826
  // gone with the view. Office review: while something walks or flies, one requestAnimationFrame per display frame (the
@@ -694,22 +861,38 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
694
861
  tipNow.current = tip;
695
862
  useEffect(() => { if (!scene.current) return; scene.current.dirty = true; if (still) draw(0); else loop.current?.wake(); }, [tip]);
696
863
 
864
+ // Everyone drawn: { key, node, box (the desk, in the view's units), dot (where the overview marks them), office (All
865
+ // offices: the tile's name) }. In All offices a key is the tile's prefix and the desk's key: one person in two
866
+ // offices is two people here.
867
+ const people = overview
868
+ ? tiles.flatMap((tl, i) => { const tt = arranged.tiles[i];
869
+ return tt ? desksOf(tl.L).map((d) => ({ key: tl.prefix + d.key, node: d.node, box: tileBox(tt, d), office: tl.name,
870
+ dot: fromTile(tt, tl.zones ? d.seat : { x: d.x + d.w / 2, y: d.y + d.h / 2 }) })) : []; })
871
+ : desksOf(L).map((d) => ({ key: d.key, node: d.node, box: d, office: null, dot: zones ? d.seat : { x: d.x + d.w / 2, y: d.y + d.h / 2 } }));
697
872
  // Preact owns the markup, not the view: put it back on the overview, the overlay and the tooltip after every render.
698
- const tipDesk = tip ? desksOf(L).find((d) => d.key === tip) || null : null;
699
- tipDeskRef.current = tipDesk;
873
+ const tipDesk = tip ? people.find((d) => d.key === tip) || null : null;
874
+ tipDeskRef.current = tipDesk ? tipDesk.box : null;
700
875
  useLayoutEffect(mark);
701
876
  // ── Pointing (#2540: through the view): hover (or a first tap) shows a desk's card, a click (or a second tap) opens
702
877
  // its chat; a drag on the scene pans it (never on Edit layout's handles: they move), the wheel and a pinch zoom ──
703
- const deskOf = (e) => {
878
+ /** The view's point under a pointer event (logical units), or null. */
879
+ const pointOf = (e) => {
704
880
  const r = canvasRef.current ? canvasRef.current.getBoundingClientRect() : null;
705
- if (!r) return null;
706
- const { x, y } = toWorld(view0(), e.clientX - r.left, e.clientY - r.top);
707
- // The zones scene draws people where they walk: they are hit there, not at the seat they are heading for, and in
708
- // the box the skin draws them in (an uploaded character's strip, anchor and size: #2513 review), nearest first.
709
- if (!zones) return deskAt(L, x, y);
710
- const t = now(), s = scene.current, sk = (s && s.skin) || skin, lk = s ? s.looks : looks;
711
- return personAt(L.rooms.flatMap((rm) => rm.desks), x, y, (d) => walkers.at(d.node.name, t),
712
- (d, at) => (sk.charBox && lk.get(d.key) ? sk.charBox(lk.get(d.key), at) : null));
881
+ return r ? toWorld(view0(), e.clientX - r.left, e.clientY - r.top) : null;
882
+ };
883
+ /** The person under a pointer event as drawn ({ key, node }), or null; All offices: in the tile there, in its own units. */
884
+ const deskOf = (e) => {
885
+ const w = pointOf(e), s = scene.current;
886
+ if (!w) return null;
887
+ // The zones scene draws people where they walk: they are hit there, not at the seat they are heading for (hitPart).
888
+ if (!s || !s.overview) {
889
+ if (overview) return null;
890
+ return hitPart(s && s.part ? s.part : { L, zones, walkers, skin, looks, all: L.rooms.flatMap((rm) => rm.desks) }, w.x, w.y, now());
891
+ }
892
+ const tt = tileAt(s.lay, w.x, w.y), p = tt ? s.parts.find((x) => x.tile.key === tt.key) : null;
893
+ if (!p) return null;
894
+ const at = inTile(tt, w.x, w.y), d = hitPart(p, at.x, at.y, now());
895
+ return d ? { key: p.prefix + d.key, node: d.node } : null;
713
896
  };
714
897
  const g = gestures(drag.current, { el: () => stageRef.current, pan: (dx, dy) => { const v = view0(); setView({ ...v, x: v.x + dx, y: v.y + dy }); },
715
898
  pinch: (from, to) => { raw.current = Math.min(OK_MAX, Math.max(OK_MIN, raw.current * (to.dist / from.dist))); setView(pinchOffice(view0(), from, to, raw.current)); } });
@@ -722,7 +905,7 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
722
905
  if (drag.current.pts.size === 2) raw.current = view0().k; // a pinch starts from the zoom shown
723
906
  };
724
907
  const onWheel = (e) => {
725
- if (!onScene(e.target) && !(typeof e.target?.closest === "function" && e.target.closest(".ol-handle"))) return;
908
+ if (!onScene(e.target) && !(typeof e.target?.closest === "function" && e.target.closest(".ol-handle, .oo-tile-label"))) return;
726
909
  e.preventDefault();
727
910
  const r = stageRef.current.getBoundingClientRect(), v = view0();
728
911
  raw.current = Math.min(OK_MAX, Math.max(OK_MIN, raw.current * wheelFactor(e)));
@@ -743,9 +926,9 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
743
926
  if (!el || typeof el.getAttribute !== "function") return;
744
927
  const hk = el.getAttribute("data-handle");
745
928
  if (hk && layout) { const hd = handlesOf(layout.draft).find((x) => x.key === hk); if (hd) show({ x: hd.x - 12, y: hd.y - 12, w: 24, h: 24 }); return; }
746
- const name = el.classList && el.classList.contains("oo-desk") ? el.getAttribute("data-name") : null;
747
- const d = name ? desksOf(L).find((x) => x.node.name === name) : null;
748
- if (d) show(d);
929
+ const key = el.classList && el.classList.contains("oo-desk") ? el.getAttribute("data-key") : null;
930
+ const d = key ? people.find((x) => x.key === key) : null;
931
+ if (d) show(d.box);
749
932
  };
750
933
  const onMove = (e) => { if (e.pointerType === "touch" || g.busy()) return; const d = deskOf(e); const key = d ? d.key : null; if (key !== tip) setTip(key); };
751
934
  const onClick = (e) => {
@@ -754,14 +937,21 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
754
937
  if (lastType.current === "touch" && tip !== d.key) { setTip(d.key); return; } // a first tap shows; a second opens
755
938
  open(d.node.name);
756
939
  };
940
+ // All offices: a double-click on a tile (off its people) opens that office; the lobby is no office.
941
+ const onDblClick = (e) => {
942
+ const s = scene.current, w = pointOf(e);
943
+ if (!overview || !s || !s.overview || !w || deskOf(e)) return;
944
+ const tt = tileAt(s.lay, w.x, w.y) || labelAt(s.lay, w.x, w.y), tl = tt ? tiles.find((x) => x.key === tt.key) : null;
945
+ if (tl && !tl.lobby) pick(tl.key);
946
+ };
757
947
  const onMini = (e) => {
758
948
  const w = miniPoint(miniRef.current ? miniRef.current.getBoundingClientRect() : null, { x: 0, y: 0, w: L.width, h: L.height }, e.clientX, e.clientY);
759
949
  if (w) setView(centreOn(view0(), size, w.x, w.y));
760
950
  };
761
951
 
762
- const auto = !custom;
952
+ const auto = !custom && !overview;
763
953
  // Edit layout: an admin, a stored office, a zones style (built in, or a pack loaded here); not while the offices'
764
- // editor is open, nor a second session.
954
+ // editor is open, nor a second session (nor in All offices: custom is null there).
765
955
  const canLayout = canEdit && storedRev !== null && !!custom && zones && !loading && baseSkin.id === custom.skin && !editing && !layout;
766
956
  const openLayout = () => {
767
957
  setLayout({ id: ++layoutSeq.current, officeId: custom.id, skin: custom.skin, pack: styleKey, base: baseSkin.pack, start: skin.pack, draft: skin.pack, rev: storedRev, list: stored });
@@ -795,14 +985,17 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
795
985
  // While Edit layout is open the office stays (its picker is disabled; this is the guard behind it).
796
986
  const pick = (id) => { if (layoutRef.current) return; store.saveChoice(id); setChoice(id); setTip(null); };
797
987
  const everyone = useMemo(() => { const m = new Map(); for (const n of [...view.top, ...view.teams.flatMap((tm) => tm.nodes), ...view.rest.nodes]) if (!m.has(n.name)) m.set(n.name, n); return [...m.values()]; }, [view]);
798
- const desks = desksOf(L);
799
988
  const tipText = tipDesk ? nodeText(tipDesk.node) : null;
800
- // The overview: the scene's rooms (or areas) and a dot for each person (a call in its colour), the view's frame on it.
801
- const miniRects = zones ? Object.values(L.areas || {}).map((a) => ({ key: a.name, ...a.rect })) : L.rooms.map((r) => ({ key: r.key, x: r.x, y: r.y, w: r.w, h: r.h }));
989
+ // The overview: the scene's rooms (or areas; All offices: the tiles) and a dot for each person (a call in its colour),
990
+ // the view's frame on it.
991
+ const miniRects = overview ? arranged.tiles.map((tt) => ({ key: tt.key, x: tt.x, y: tt.y, w: tt.w, h: tt.h }))
992
+ : zones ? Object.values(L.areas || {}).map((a) => ({ key: a.name, ...a.rect })) : L.rooms.map((r) => ({ key: r.key, x: r.x, y: r.y, w: r.w, h: r.h }));
802
993
  const dotR = Math.max(L.width, L.height) / 90;
803
- const officeSelect = (cls) => html`<select class=${`tb-select ${cls}`} value=${layout ? layout.officeId : custom ? custom.id : AUTO_ID} disabled=${!!layout}
994
+ // The switcher: the whole fleet, All offices (two or more stored), each stored office.
995
+ const officeSelect = (cls) => html`<select class=${`tb-select ${cls}`} value=${layout ? layout.officeId : overview ? ALL_ID : custom ? custom.id : AUTO_ID} disabled=${!!layout}
804
996
  aria-label=${to("office")} onChange=${(e) => pick(e.target.value)}>
805
997
  <option value=${AUTO_ID}>${to("allOffice")}</option>
998
+ ${overviewOk(stored) ? html`<option value=${ALL_ID}>${to("allOffices")}</option>` : null}
806
999
  ${stored.map((o) => html`<option key=${o.id} value=${o.id}>${o.name}</option>`)}
807
1000
  </select>`;
808
1001
  return html`<div class=${`oo${fs ? ` fs ${fs}` : ""}`} ref=${ooRef}>
@@ -819,32 +1012,43 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
819
1012
  </select></label>
820
1013
  <${People} nodes=${everyone} pinned=${f.pinned} onChange=${(pinned) => setF({ ...f, pinned })} />` : null}
821
1014
  <label class="check oo-hide-asleep"><input type="checkbox" checked=${hideAsleep} onChange=${() => { const v = !hideAsleep; store.saveHideAsleep(v); setHideAsleep(v); setTip(null); }} /><span>${to("hideAsleep")}</span></label>
1015
+ ${overview ? html`<label class="check oo-lobby-tile" title=${lobbyAll.length ? undefined : to("lobbyEveryone")}><input type="checkbox" checked=${lobbyOn && lobbyAll.length > 0}
1016
+ disabled=${!lobbyAll.length} onChange=${() => { const v = !lobbyOn; store.saveLobbyTile(v); setLobbyOn(v); setTip(null); }} /><span>${to("lobbyTile")}</span></label>
1017
+ ${lobbyAll.length ? null : html`<span class="muted oo-note oo-lobby-none">${to("lobbyEveryone")}</span>`}` : null}
822
1018
  ${custom && baseSkin.id !== custom.skin && !loading ? html`<span class="muted oo-note">${to("skinUnknown", styleName(custom.skin))}</span>` : null}
823
1019
  ${placed.ignored ? html`<span class="muted oo-note">${to("layoutIgnored")}</span>` : null}
824
1020
  ${L.unseated ? html`<span class="muted oo-note">${to("unseated", L.unseated)}</span>` : null}
825
- ${canEdit && storedRev !== null ? html`<button type="button" class="btn btn-sm oo-edit" onClick=${() => setEditing(true)} disabled=${!!layout}>${t("orge.edit")}</button>` : null}
1021
+ ${canEdit && storedRev !== null ? html`<button type="button" class="btn btn-sm oo-edit" onClick=${() => { if (!overview) setEditing(true); }} disabled=${!!layout || overview}
1022
+ title=${overview ? to("overviewNoEdit") : undefined}>${t("orge.edit")}</button>` : null}
826
1023
  ${canLayout ? html`<button type="button" class="btn btn-sm oo-layout" onClick=${openLayout}>${t("orgl.edit")}</button>` : null}
827
1024
  </div>
828
- ${editing ? html`<${OfficeEditor} offices=${stored} rev=${storedRev} view=${view} store=${store} packs=${packs}
1025
+ ${editing && !overview ? html`<${OfficeEditor} offices=${stored} rev=${storedRev} view=${view} store=${store} packs=${packs}
829
1026
  ask=${ask} current=${() => shown.current} onSaved=${adopt} onClose=${() => setEditing(false)} />` : null}
830
- ${desks.length || L.rooms.length ? null : html`<p class="note oo-empty">${to("nobody")} ${auto && (f.team || f.pinned.length) ? html`<button type="button" class="btn btn-sm" onClick=${() => setF({ team: "", pinned: [] })}>${to("everyone")}</button>` : null}</p>`}
1027
+ ${overview || people.length || L.rooms.length ? null : html`<p class="note oo-empty">${to("nobody")} ${auto && (f.team || f.pinned.length) ? html`<button type="button" class="btn btn-sm" onClick=${() => setF({ team: "", pinned: [] })}>${to("everyone")}</button>` : null}</p>`}
831
1028
  <div class=${`oo-stage${layout ? " editing" : ""}`} ref=${stageRef} onPointerDown=${onDown} onPointerMove=${g.move} onPointerUp=${g.up} onPointerCancel=${g.up}
832
1029
  onWheel=${onWheel} onKeyDown=${onKeyDown} onFocusIn=${onFocusIn} onClickCapture=${g.clickCapture}>
833
- <canvas ref=${canvasRef} class=${`oo-canvas${tipDesk && tipDesk.node.i ? " hot" : ""}`} role="application" tabindex="0" aria-label=${to("label")}
834
- onPointerMove=${onMove} onPointerLeave=${() => setTip(null)} onClick=${onClick}></canvas>
1030
+ <canvas ref=${canvasRef} class=${`oo-canvas${tipDesk && tipDesk.node.i ? " hot" : ""}`} role="application" tabindex="0" aria-label=${to(overview ? "labelAll" : "label")}
1031
+ onPointerMove=${onMove} onPointerLeave=${() => setTip(null)} onClick=${onClick} ondblclick=${onDblClick}></canvas>
1032
+ ${overview ? tiles.map((tl) => {
1033
+ const text = to("tileLabel", tl.name, tl.count), at = (el) => { if (el) labelEls.current.set(tl.key, el); else labelEls.current.delete(tl.key); };
1034
+ return tl.lobby ? html`<span key=${tl.key} ref=${at} class="oo-tile-label lobby">${text}</span>`
1035
+ : html`<button key=${tl.key} ref=${at} type="button" class="oo-tile-label" data-office=${tl.key} title=${to("openOffice", tl.name)} aria-label=${to("openOffice", tl.name)}
1036
+ onClick=${() => pick(tl.key)}>${text}</button>`;
1037
+ }) : null}
835
1038
  ${layout ? html`<${LayoutEditor} key=${layout.id} base=${layout.base} start=${layout.start} place=${placeOverlay}
836
1039
  stale=${layoutStale ? t("orgl.stale") : ""} onChange=${onDraft} onSave=${saveLayout} onClose=${closeLayout} />` : null}
837
1040
  ${tipText ? html`<div ref=${tipRef} class="oo-tip" role="tooltip">
838
1041
  <strong>${tipText.primary}</strong>${tipText.secondary ? html` <span class="muted mono">${tipText.secondary}</span>` : null}
839
1042
  <div>${tipText.sub}</div><div class="muted">${caption(officePose(tipDesk.node.state), tipDesk.node.state)}</div>
1043
+ ${tipDesk.office ? html`<div class="muted oo-tip-office">${tipDesk.office}</div>` : null}
840
1044
  ${tipDesk.node.i && lastType.current === "touch" ? html`<div class="muted">${to("tapAgain")}</div>` : null}
841
1045
  </div>` : null}
842
- <ul class="oo-desks" aria-label=${to("desks")}>${desks.map((d) => {
843
- const nt = nodeText(d.node), cap = caption(officePose(d.node.state), d.node.state);
1046
+ <ul class="oo-desks" aria-label=${to("desks")}>${people.map((d) => {
1047
+ const nt = nodeText(d.node), cap = caption(officePose(d.node.state), d.node.state), where = d.office ? ` · ${d.office}` : "";
844
1048
  return html`<li key=${d.key}>${d.node.i
845
- ? html`<button type="button" class="oo-desk" data-name=${d.node.name} aria-label=${`${nt.full} · ${cap}. ${t("orgc.openChat")}`}
1049
+ ? html`<button type="button" class="oo-desk" data-name=${d.node.name} data-key=${d.key} aria-label=${`${nt.full}${where} · ${cap}. ${t("orgc.openChat")}`}
846
1050
  onFocus=${() => setTip(d.key)} onBlur=${() => setTip(null)} onClick=${() => open(d.node.name)}>${nt.primary}</button>`
847
- : html`<span class="oo-desk" data-name=${d.node.name}>${nt.full}</span>`}</li>`;
1051
+ : html`<span class="oo-desk" data-name=${d.node.name} data-key=${d.key}>${nt.full}</span>`}</li>`;
848
1052
  })}</ul>
849
1053
  <div class="oc-zoom oo-zoom" role="group" aria-label=${t("orgc.view")}>
850
1054
  <output ref=${pctRef} class="oo-zoom-pct"></output>
@@ -861,8 +1065,7 @@ export function Office({ view, traffic, q = "", lease, onOpen }) {
861
1065
  <rect class="oo-mini-matte" x=${-L.width} y=${-L.height} width=${L.width * 3} height=${L.height * 3} fill=${matte} />
862
1066
  <rect class="oo-mini-scene" x="0" y="0" width=${L.width} height=${L.height} />
863
1067
  ${miniRects.map((r) => html`<rect key=${r.key} class="oo-mini-room" x=${r.x} y=${r.y} width=${r.w} height=${r.h} />`)}
864
- ${desks.map((d) => { const at = zones ? d.seat : { x: d.x + d.w / 2, y: d.y + d.h / 2 };
865
- return html`<circle key=${d.key} class=${`oo-mini-dot st-${d.node.state}${q && hits(d.node, q) ? " match" : ""}`} cx=${at.x} cy=${at.y} r=${dotR} />`; })}
1068
+ ${people.map((d) => html`<circle key=${d.key} class=${`oo-mini-dot st-${d.node.state}${q && hits(d.node, q) ? " match" : ""}`} cx=${d.dot.x} cy=${d.dot.y} r=${dotR} />`)}
866
1069
  <rect ref=${miniViewRef} class="oc-mini-view oo-mini-view" />
867
1070
  </svg>
868
1071
  </div>
@@ -44,6 +44,11 @@ const writeKey = (k, v) => { try { if (v) localStorage.setItem(k, v); else local
44
44
  export const chatCardPrefs = createStore({ open: readKey(CHAT_CARD_KEY, "open"), rowHidden: readKey(CHAT_CARD_ROW_KEY, "hidden") });
45
45
  export const setChatCardOpen = (open) => { writeKey(CHAT_CARD_KEY, open ? "open" : null); chatCardPrefs.set((s) => ({ ...s, open })); };
46
46
  export const setChatCardRowHidden = (rowHidden) => { writeKey(CHAT_CARD_ROW_KEY, rowHidden ? "hidden" : null); chatCardPrefs.set((s) => ({ ...s, rowHidden })); };
47
+ // Full width (the header's ⋯ menu): this browser's choice, like code wrap. The thread and the composer take the panel's
48
+ // width; a person's bubble keeps its cap (app.css). A phone's column is the screen already: no item there.
49
+ const CHAT_WIDE_KEY = "agend_chat_full_width";
50
+ export const chatWide = createStore({ on: readKey(CHAT_WIDE_KEY, "on") });
51
+ export const setChatWide = (on) => { writeKey(CHAT_WIDE_KEY, on ? "on" : null); chatWide.set({ on }); };
47
52
  function ChatCard({ name, card }) {
48
53
  const prefs = useStore(chatCardPrefs);
49
54
  if (!card || !prefs.open) return null;
@@ -260,6 +265,7 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
260
265
  const publishCanvasPlayback = useCallback(value => setCanvasPlayback(previous =>
261
266
  !previous && !value.playing || previous?.name === value.name && previous.playing === value.playing ? previous : value), []);
262
267
  const [wrap, setWrap] = useState(codeWrap);
268
+ const wide = useStore(chatWide).on;
263
269
  const view = useRef(null), split = useRef(null), thread = useRef(null), bg = useRef(null), effectSurface = useRef(null);
264
270
  useLayoutEffect(() => { applyTheme(view.current, theme, mode); }, [themeKey, mode]);
265
271
  useLayoutEffect(() => {
@@ -321,6 +327,7 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
321
327
  ...themeMenuItems(name),
322
328
  // #2117: its ambient — why it is not showing here (with the way to change that), and a person's Clear.
323
329
  ...ambientMenuItems(name, { publicLink: document.body.dataset.publicLink === "1", clear: clearAmbient }),
330
+ narrowScreen() ? null : { key: "fullWidth", checkbox: true, checked: wide, label: t("chat.fullWidth"), onSelect: () => setChatWide(!wide) },
324
331
  cwEnabled && canvasWorkerMenuAvailable(window) ? { key: "effects", label: t("cwControls.title"), onSelect: () => setDialog("effects") } : null,
325
332
  { key: "delete", label: t("chat.delete"), icon: "trash", danger: true, onSelect: () => setDialog("delete") },
326
333
  ];
@@ -333,7 +340,7 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
333
340
  const sub = html`<span class="status"><span class=${`dot ${cls}`} aria-hidden="true"></span>${statusLabel(inst, exec, awaiting)}</span>
334
341
  ${inst.model ? html`<button type="button" class="hd-chip" title=${t("chat.chipModel", modelText(inst))} aria-label=${t("chat.chipModel", modelText(inst))} onClick=${() => pick("model")}>${modelText(inst)}</button>` : null}
335
342
  ${inst.effort ? html`<button type="button" class="hd-chip" title=${t("chat.chipEffort", inst.effort)} aria-label=${t("chat.chipEffort", inst.effort)} onClick=${() => pick("effort")}>${inst.effort}</button>` : null}`;
336
- return html`<div class=${`panel p-chat${wrap ? " wrap-code" : ""}`} ref=${view}>
343
+ return html`<div class=${`panel p-chat${wrap ? " wrap-code" : ""}`} data-width=${wide ? "full" : undefined} ref=${view}>
337
344
  <${PanelHeader} title=${headerName(namesOf(inst || { name }, names.primary))} sub=${sub} nav=${guest ? null : html`<${InstanceSwitch} name=${name} current="chat" />`} notice=${html`<${ChatEffects} name=${name} lease=${lease} element=${effectSurface} store=${store} open=${dialog === "effects"} onPlayback=${publishCanvasPlayback} onClose=${() => setDialog(null)} active=${fxHere} />`}>${fxHere ? html`<${ChatEffectPosters} name=${name} lease=${lease} element=${effectSurface} />` : null}<${CardMenu} card=${card} /><${HeaderTools} navKey=${navKey} />${guest ? null : html`<${Menu} items=${items} label=${t("app.more")} />`}</${PanelHeader}>
338
345
  <${ChatCard} key=${name} name=${name} card=${card} />
339
346
  <${AgentWidget} slot="chat_header" widget=${widgetFor(name, "chat_header")} binds=${instanceBinds(inst, exec, awaiting)} />
@@ -573,6 +573,9 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
573
573
  the message's overflow-wrap: anywhere that is one character wide (the #1672 status line's time, one glyph per line). */
574
574
  .msg .time { font-family: var(--font-mono); white-space: nowrap; flex: none; }
575
575
  .msg.user { align-self: flex-end; max-width: min(80%, 620px); }
576
+ /* Full width (the header's ⋯ menu, this browser's choice): the thread and the composer take the panel, less their own
577
+ padding, for wide tables and code; a person's bubble keeps its cap (above). A phone's column is the screen already. */
578
+ @media (min-width: 900px) { .p-chat[data-width="full"] .thread, .p-chat[data-width="full"] .dock-col { max-width: none; } }
576
579
  .msg.user .meta { justify-content: flex-end; }
577
580
  /* #1773: the bubble hugs its text. The message is as wide as its widest child — usually the meta row (sender, a time
578
581
  such as 「昨天 下午03:58」, ticks) — and a block body would stretch to it; fit-content keeps a short message short, on
@@ -1762,6 +1765,8 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1762
1765
  .oo-people-list label { display: flex; align-items: center; gap: 8px; padding: 3px 2px; font-size: var(--fs-xs); color: var(--text); cursor: pointer; overflow-wrap: anywhere; }
1763
1766
  .oo-note { font-size: var(--fs-xs); }
1764
1767
  .oo-stage { position: relative; flex: 1 1 auto; min-height: 340px; overflow: hidden; background: var(--bg); touch-action: none; user-select: none; -webkit-user-select: none; }
1768
+ /* #2554: clip, not a scroll container — nothing past the edge (a tile's label) can scroll the stage and its controls. */
1769
+ .oo-stage { overflow: clip; }
1765
1770
  .oo-canvas { position: absolute; left: 0; top: 0; display: block; image-rendering: pixelated; cursor: grab; touch-action: none; }
1766
1771
  .oo-canvas:active { cursor: grabbing; }
1767
1772
  .oo-canvas:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
@@ -1785,6 +1790,16 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1785
1790
  .oo-mini-dot.match { fill: var(--focus); }
1786
1791
  .oo-tip { position: absolute; left: 0; top: 0; z-index: 5; max-width: 240px; pointer-events: none; padding: 6px 10px; font-size: var(--fs-xs); line-height: 1.4;
1787
1792
  color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md); box-shadow: var(--shadow-2); overflow-wrap: anywhere; }
1793
+ /* #2554: All offices — each tile's label (office name and head count) just above the tile, placed by the page (left, top,
1794
+ max-width through the CSSOM); a button that opens the office, the lobby's a plain chip. */
1795
+ .oo-tile-label { position: absolute; left: 0; top: 0; z-index: 3; transform: translateY(calc(-100% - 4px)); max-width: 240px; overflow: hidden; text-overflow: ellipsis;
1796
+ white-space: nowrap; padding: 2px 8px; font: inherit; font-size: var(--fs-xs); font-weight: var(--fw-semibold); line-height: 18px; color: var(--text);
1797
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-sm); }
1798
+ button.oo-tile-label { cursor: pointer; }
1799
+ button.oo-tile-label:hover { border-color: var(--accent); }
1800
+ button.oo-tile-label:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
1801
+ .oo-tile-label.lobby { color: var(--text-2); }
1802
+ .oo-lobby-tile { font-size: var(--fs-xs); color: var(--text-2); }
1788
1803
  /* The desks as a list: for a screen reader, and shown (over the stage's corner) while the keyboard is in it. */
1789
1804
  .oo-desks { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; list-style: none; }
1790
1805
  .oo-desks:focus-within { position: absolute; z-index: 7; left: var(--s-2); bottom: var(--s-2); width: auto; height: auto; max-height: 40%; margin: 0; padding: var(--s-1); clip: auto; overflow-y: auto;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@songsid/agend",
3
- "version": "2.3.2-beta.1",
3
+ "version": "2.3.2-beta.3",
4
4
  "description": "Multi-agent fleet daemon — run coding CLIs (Claude, Muse, Codex, OpenCode) from Discord or Telegram",
5
5
  "type": "module",
6
6
  "bin": {