@songsid/agend 2.3.2-beta.15 → 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 (74) hide show
  1. package/dist/cache-service.d.ts +13 -1
  2. package/dist/cache-service.js +45 -2
  3. package/dist/cache-service.js.map +1 -1
  4. package/dist/channel/mcp-tools.js +2 -2
  5. package/dist/channel/mcp-tools.js.map +1 -1
  6. package/dist/config-validator.js +4 -1
  7. package/dist/config-validator.js.map +1 -1
  8. package/dist/fleet-manager.d.ts +14 -0
  9. package/dist/fleet-manager.js +33 -1
  10. package/dist/fleet-manager.js.map +1 -1
  11. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +10 -5
  12. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +10 -5
  13. package/dist/instance-config-impact.js +1 -0
  14. package/dist/instance-config-impact.js.map +1 -1
  15. package/dist/office-levels.d.ts +64 -0
  16. package/dist/office-levels.js +66 -0
  17. package/dist/office-levels.js.map +1 -0
  18. package/dist/office-share-page.js +1 -1
  19. package/dist/office-share-page.js.map +1 -1
  20. package/dist/office-share.d.ts +18 -2
  21. package/dist/office-share.js +22 -4
  22. package/dist/office-share.js.map +1 -1
  23. package/dist/public-web-gateway.js +1 -1
  24. package/dist/public-web-gateway.js.map +1 -1
  25. package/dist/scheduler/task-board.d.ts +28 -6
  26. package/dist/scheduler/task-board.js +66 -11
  27. package/dist/scheduler/task-board.js.map +1 -1
  28. package/dist/settings-api.js +4 -0
  29. package/dist/settings-api.js.map +1 -1
  30. package/dist/settings-change.js +2 -0
  31. package/dist/settings-change.js.map +1 -1
  32. package/dist/settings-effect.js +2 -0
  33. package/dist/settings-effect.js.map +1 -1
  34. package/dist/task-board-waker.js +7 -2
  35. package/dist/task-board-waker.js.map +1 -1
  36. package/dist/types.d.ts +5 -0
  37. package/dist/ui/fleet-office-card.js +114 -0
  38. package/dist/ui/fleet-office-editor.js +27 -7
  39. package/dist/ui/fleet-office-levels.js +105 -0
  40. package/dist/ui/fleet-office-model.js +9 -1
  41. package/dist/ui/fleet-office-pack.d.ts +2 -0
  42. package/dist/ui/fleet-office-pack.js +48 -2
  43. package/dist/ui/fleet-office-skin.js +39 -3
  44. package/dist/ui/fleet-office-store.js +13 -0
  45. package/dist/ui/fleet-office.js +172 -26
  46. package/dist/ui/fleet-org.js +2 -0
  47. package/dist/ui/fleet-task-card.js +2 -2
  48. package/dist/ui/fleet-task-common.js +30 -7
  49. package/dist/ui/fleet-task-work.js +2 -2
  50. package/dist/ui/fleet-tasks.js +10 -11
  51. package/dist/ui/office-share-page.js +1 -0
  52. package/dist/ui/panel-settings.js +7 -1
  53. package/dist/ui/settings-registry.js +1 -0
  54. package/dist/ui/settings-strings.js +2 -0
  55. package/dist/ui/shared/app.css +44 -0
  56. package/dist/usage-rollup.d.ts +34 -3
  57. package/dist/usage-rollup.js +56 -3
  58. package/dist/usage-rollup.js.map +1 -1
  59. package/dist/web-api.d.ts +5 -0
  60. package/dist/web-api.js +42 -10
  61. package/dist/web-api.js.map +1 -1
  62. package/dist/web-auth.js +1 -1
  63. package/dist/web-auth.js.map +1 -1
  64. package/dist/web-office-acts.d.ts +5 -2
  65. package/dist/web-office-acts.js +7 -2
  66. package/dist/web-office-acts.js.map +1 -1
  67. package/dist/web-office-store.d.ts +6 -1
  68. package/dist/web-office-store.js +5 -0
  69. package/dist/web-office-store.js.map +1 -1
  70. package/dist/web-settings-patch.d.ts +3 -1
  71. package/dist/web-settings-patch.js +4 -1
  72. package/dist/web-settings-patch.js.map +1 -1
  73. package/dist/workflow-templates/default.md +1 -1
  74. package/package.json +1 -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
  /**
@@ -9,10 +9,12 @@
9
9
  // - the skin packs an admin uploaded (#2513: GET /ui/office-skins, POST to upload one, DELETE to remove one;
10
10
  // src/web-office-skins.ts). The office editor and the layout editor call saveOffices() with the whole list and the rev
11
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).
12
13
  // - #2692: the agents' pack changes (GET / POST /ui/office-pack-changes, an admin's): which agents may change packs, the
13
14
  // fleet's approval mode, the changes waiting for a yes, and the newest log entries.
14
15
  import { AUTO_ID, filterOk, officesOk } from "./fleet-office-model.js";
15
16
  import { fitModeOk, savedOk } from "./fleet-office-view.js";
17
+ import { levelsOk } from "./fleet-office-levels.js";
16
18
 
17
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";
18
20
  /** #2554: "0" when All offices leaves the lobby tile out (default: shown). */
@@ -108,6 +110,17 @@ export function createOfficeStore({ fetch: get = globalThis.fetch, storage } = {
108
110
  return actsOk(d && d.acts, d && d.now);
109
111
  } catch { return null; }
110
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
+ },
111
124
  /** The skin packs the host keeps: { rev, packs: [{ id, name, sha }] }; null when they could not be read. */
112
125
  async skins() {
113
126
  try {
@@ -60,13 +60,15 @@ import { appStore } from "/assets/app-store.js";
60
60
  import { t, lang, register } from "/assets/app-i18n.js";
61
61
  import { identityOf } from "/assets/identity-color.js";
62
62
  import { Icon } from "/assets/ui-icons.js";
63
- import { nodeText } from "./fleet-org.js";
63
+ import { nodeText, stateWords } from "./fleet-org.js";
64
64
  import { hits, needle } from "./fleet-canvas-layout.js";
65
65
  import { AUTO_ID, LOBBY, autoLayout, backingScale, coatOf, createFlights, customLayout, deskAt, desksOf, filterOk, lampOf, layoutOk, lightOf, membersOf,
66
66
  officePose, officeRooms, signOf, workOn } from "./fleet-office-model.js";
67
67
  import { BUILT_IN_SKINS, CHAR_DEPTH, DEFAULT_PAL, bubbleBox, loadCozySkin, loadPackSkin, modeOf, skinFor, tagBox } from "./fleet-office-skin.js";
68
68
  import { CALLING, activeFurniture, bubbleAt, hiddenBy, bubbleRank, NIGHT, WANDER, createMail, createWalkers, isNight, linesFor, npcKey, personAt, placeBubbles, thinBubbles, walkTargets, zonesLayout } from "./fleet-office-zones.js";
69
69
  import { actsOk, createOfficeStore } from "./fleet-office-store.js";
70
+ import { badgeOf, fixedOf } from "./fleet-office-levels.js";
71
+ import { OfficeCard, cardModel } from "./fleet-office-card.js";
70
72
  import { POLL_MS } from "./fleet-traffic.js";
71
73
  import { OfficeEditor, officeForSave } from "./fleet-office-editor.js";
72
74
  import { LayoutEditor, handlesOf } from "./fleet-office-layout.js";
@@ -152,6 +154,14 @@ function sampledMatte(img) {
152
154
  return m;
153
155
  }
154
156
  const now = () => (typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now());
157
+ /** #2661: how often the levels are read again while the Office is shown (the host reuses them for a minute). */
158
+ const LEVELS_MS = 60_000;
159
+ /** #2661: how long a level-up's ring shows. */
160
+ const UP_MS = 1800;
161
+ /** #2661: Find in office zooms in to at least this (CSS px a logical unit). */
162
+ const FIND_K = 2;
163
+ /** #2661: below this stage width (CSS px) the card is a bottom sheet. */
164
+ const SHEET_W = 560;
155
165
  /** How often the picture can change when nothing is in the air (the poses loop at 4 fps; a bubble types faster). */
156
166
  const FPS = 12;
157
167
  const STYLES = BUILT_IN_SKINS;
@@ -171,6 +181,7 @@ function readPal(el) {
171
181
  bg: v("--bg", D.bg), surface: v("--surface", D.surface), surface2: v("--surface-2", D.surface2), border: v("--border", D.border),
172
182
  text: v("--text", D.text), text2: v("--text-2", D.text2), text3: v("--text-3", D.text3), ok: v("--ok", D.ok), warn: v("--warn", D.warn),
173
183
  danger: v("--danger", D.danger), focus: v("--focus", D.focus),
184
+ accent: v("--accent", D.accent), accentDim: v("--accent-dim", D.accentDim), okBg: v("--ok-bg", D.okBg), warnBg: v("--warn-bg", D.warnBg), dangerBg: v("--danger-bg", D.dangerBg),
174
185
  fx: { task: v("--fx-task", D.fx.task), send: v("--fx-send", D.fx.send), report: v("--fx-report", D.fx.report), broadcast: v("--fx-broadcast", D.fx.broadcast) },
175
186
  };
176
187
  }
@@ -183,15 +194,19 @@ function teamOptions(view) {
183
194
 
184
195
  // ── A scene: one office's layout, looks and skin, drawn by itself (the single view) or as a tile (All offices, #2554) ──
185
196
 
186
- /** What each desk shows: the pose, the light, the coat; dimmed when the Find box matches someone else. */
187
- function looksOf(L, work, q, mode, zones) {
197
+ /**
198
+ * What each desk shows: the pose, the light, the coat; dimmed when the Find box matches someone else; #2661: its level
199
+ * badge (`badge(name)`: fleet-office-levels.js badgeOf, or nothing).
200
+ */
201
+ function looksOf(L, work, q, mode, zones, badge = () => null) {
188
202
  return new Map(L.rooms.flatMap((r) => r.desks).map((d) => {
189
203
  const n = d.node;
190
204
  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 }];
191
205
  const match = !!q && hits(n, q);
192
206
  // At night (#2568) the idle are drawn asleep (their state stays idle: the tooltip and the list say so).
193
207
  return [d.key, { name: n.name, label: nodeText(n).primary, state: n.state, pose: officePose(d.night ? "paused" : n.state), night: !!d.night, light: lightOf(n.state), lamp: lampOf(n.state),
194
- 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 }];
208
+ 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,
209
+ badge: badge(n.name) }];
195
210
  }));
196
211
  }
197
212
  /** A stored office's own layout on a zones style (its overrides); `ignored`: they do not fit this style. */
@@ -211,7 +226,9 @@ function officeScene(office, nodes, skin, zones, deskW, prevSeats, night = false
211
226
  export function partOf(L, looks, skin, zones, walkers, flights) {
212
227
  const all = L.rooms.flatMap((r) => r.desks);
213
228
  const shown = new Set([...looks.values()].map((l) => l.state)), byName = new Map([...looks.values()].filter((l) => l.name).map((l) => [l.name, l.state]));
214
- return { L, looks, skin, zones, walkers, flights, all, desks: all.filter((d) => d.node), layers: null,
229
+ // #2661: each desk's room (Desks: who is in the same area as a level-up, drawPart).
230
+ const roomOf = new Map(L.rooms.flatMap((r) => r.desks.map((d) => [d.key, r.key])));
231
+ return { L, looks, skin, zones, walkers, flights, all, desks: all.filter((d) => d.node), layers: null, roomOf,
215
232
  active: zones ? activeFurniture(skin.pack, shown, byName) : new Set(), lines: bubbleDefaults(), lang: lang() };
216
233
  }
217
234
  /**
@@ -417,12 +434,12 @@ export function drawPart(ctx, p, a, ox, oy, ms, still, hot, env, tagsOff = false
417
434
  for (const f of furniture) {
418
435
  const on = people.filter((q) => live.hidden.get(q.look.name) === f.id);
419
436
  if (!on.length) {
420
- if (!tagsOff && f.label) { sk.drawTag(ctx, f.x, f.y, { label: f.label }, env); tags.push(tagBox(ctx, f.x, f.y, f.label)); }
437
+ if (!tagsOff && f.label) { const nl = npcLook(p, f); sk.drawTag(ctx, f.x, f.y, nl, env); tags.push(tagBox(ctx, f.x, f.y, nl.label)); }
421
438
  continue;
422
439
  }
423
440
  const order = new Map((f.activeFor || []).map((n, i) => [n, i]));
424
441
  on.sort((x, y) => (order.get(x.look.name) ?? 0) - (order.get(y.look.name) ?? 0));
425
- const row = tagRow(ctx, f.x, f.y, [...(f.label ? [{ label: f.label }] : []), ...on.map((q) => q.look)]);
442
+ const row = tagRow(ctx, f.x, f.y, [...(f.label ? [npcLook(p, f)] : []), ...on.map((q) => q.look)]);
426
443
  const top = f.sprite ? Math.min(f.y - 50, stripBox(f.activeSprite || f.sprite, f.x, f.y).y + 2) : f.y - 50;
427
444
  for (const [i, r] of row.entries()) {
428
445
  const q = f.label ? on[i - 1] : on[i];
@@ -436,13 +453,29 @@ export function drawPart(ctx, p, a, ox, oy, ms, still, hot, env, tagsOff = false
436
453
  }
437
454
  p.heldTags = held;
438
455
  } else {
439
- for (const d of p.desks) { const look = p.looks.get(d.key); sk.drawCharacter(ctx, d, actPosed(p, look), ms, env); speakers.push({ look, x: d.x + 58, y: d.y + 12 }); }
456
+ for (const d of p.desks) { const look = p.looks.get(d.key); sk.drawCharacter(ctx, d, actPosed(p, look), ms, env); speakers.push({ look, x: d.x + 58, y: d.y + 12, area: p.roomOf.get(d.key) ?? null }); }
440
457
  blit(p.layers.front);
441
458
  // Tags left out of the layer: the hovered desk's front again, with its tag.
442
459
  const hd = tagsOff && hot ? p.desks.find((d) => d.key === hot) : null;
443
460
  if (hd) sk.drawDeskFront(ctx, hd, p.looks.get(hd.key), env);
444
461
  for (const it of up) sk.drawProp(ctx, it, ms, env);
445
462
  }
463
+ // #2661: a level up — a short ring over the head (UP_MS), and the people in the same area (a room's on Desks) glance
464
+ // over: a small "!" over theirs. Never with reduced motion (no loop: ms 0).
465
+ const rising = ms && p.ups && p.ups.size ? speakers.filter((sp) => { const t0 = p.ups.get(sp.look.name); return t0 !== undefined && now() - t0 < UP_MS; }) : [];
466
+ for (const sp of rising) {
467
+ const f = (now() - p.ups.get(sp.look.name)) / UP_MS;
468
+ ctx.save();
469
+ ctx.globalAlpha = 1 - f;
470
+ ctx.strokeStyle = env.pal.warn; ctx.lineWidth = 2;
471
+ ctx.beginPath(); ctx.arc(sp.x, sp.y - 8, 6 + f * 14, 0, Math.PI * 2); ctx.stroke();
472
+ ctx.fillStyle = env.pal.text; ctx.font = "700 9px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "bottom";
473
+ for (const o of speakers) {
474
+ if (o === sp || o.look.dim || o.look.pose === "away" || (o.area ?? null) !== (sp.area ?? null)) continue;
475
+ ctx.fillText("!", o.x, o.y - 4);
476
+ }
477
+ ctx.restore();
478
+ }
446
479
  // Speech bubbles: a short line for the state, now and then (typed out; whole at once when still), placed so no two
447
480
  // overlap (placeBubbles; #2431).
448
481
  const bubbles = [];
@@ -460,7 +493,15 @@ export function drawPart(ctx, p, a, ox, oy, ms, still, hot, env, tagsOff = false
460
493
  const placed = placeBubbles(thinBubbles(bubbles, p.zones ? p.L.over : null), 22, tags);
461
494
  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); };
462
495
  if (defer) defer.push(say); else say();
463
- return walking || up.length > 0 || p.flights.size() > 0 || post.some((it) => it.t > 0);
496
+ return walking || up.length > 0 || p.flights.size() > 0 || post.some((it) => it.t > 0) || rising.length > 0;
497
+ }
498
+ /**
499
+ * #2661: an NPC's name tag (a furniture item's label) — with the title a pack pins on it (`levels.fixed` by its id),
500
+ * while levels are on: "label · title", and a mark for a badge (an NPC has no level).
501
+ */
502
+ function npcLook(p, f) {
503
+ const fx = p.levelsOn ? fixedOf(f.id, p.skin.pack && p.skin.pack.levels) : null;
504
+ return fx ? { label: `${f.label} · ${fx.title}`, badge: { mark: true, tier: 0 } } : { label: f.label };
464
505
  }
465
506
  /**
466
507
  * 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
@@ -562,6 +603,10 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
562
603
  const [themeRev, setThemeRev] = useState(0);
563
604
  const [pal, setPal] = useState(DEFAULT_PAL); // the tokens in force (read once the stage is there)
564
605
  const [tip, setTip] = useState(null); // the desk key the tooltip is for
606
+ // #2661: the person whose card is open (their key, as `tip`), and the desk-list entry it was opened from (focus goes
607
+ // back there when it closes); the card's element and where it points (placed every frame: it follows them).
608
+ const [card, setCard] = useState(null);
609
+ const cardFrom = useRef(null), cardEl = useRef(null), cardDesk = useRef(null);
565
610
  const stageRef = useRef(null), canvasRef = useRef(null), tipRef = useRef(null), lastType = useRef("mouse");
566
611
  // Fullscreen: null, "native" (the Fullscreen API) or "immersive" (the CSS cover); per visit, never kept.
567
612
  const ooRef = useRef(null), [fs, setFs] = useState(null), fsRef = useRef(null), fsAsked = useRef(false), fsTarget = useRef(null);
@@ -621,6 +666,27 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
621
666
  // A read a newer one or the stream overtook is dropped (it is older: review — an opening read answered after the one
622
667
  // the stream's return began brought a cleared act back); read again when the stream comes back, and — with no live
623
668
  // stream (the public link, or a stream polling) — every POLL_MS while the page is shown.
669
+ // #2661: everyone's level (fleet-office-levels.js levelsOk): read on opening and every LEVELS_MS while shown — the
670
+ // host works them out at most once a minute, so nothing here asks more often.
671
+ const [lv, setLv] = useState(null);
672
+ // Who went up a level since the last read (name → when it was seen): a ring over them for UP_MS (drawPart).
673
+ const ups = useRef(new Map()), lastLevels = useRef(null);
674
+ useEffect(() => {
675
+ if (!lease) return undefined;
676
+ const read = async () => {
677
+ const d = await store.levels();
678
+ if (!lease.current() || !d) return;
679
+ const before = lastLevels.current, t = now();
680
+ if (before) for (const [name, e] of d.levels) { const b = before.get(name); if (b && e.level > b.level) ups.current.set(name, t); }
681
+ for (const [name, t0] of ups.current) if (t - t0 >= UP_MS) ups.current.delete(name);
682
+ lastLevels.current = d.levels;
683
+ setLv(d);
684
+ loop.current?.wake();
685
+ };
686
+ void read();
687
+ const h = lease.interval(() => { if (!globalThis.document?.hidden) void read(); }, LEVELS_MS);
688
+ return () => lease.clear(h);
689
+ }, [lease, store]);
624
690
  const [acts, setActs] = useState([]);
625
691
  const actSeen = useRef(new Set());
626
692
  useEffect(() => {
@@ -767,10 +833,10 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
767
833
  });
768
834
  }, [overview, stored, view, packs, packSkins, cozyRev, hideAsleep, lobbyOn, lobbyAll, style, night]);
769
835
  const arranged = useMemo(() => tileLayout(tiles.map((tl) => ({ key: tl.key, w: tl.L.width, h: tl.L.height })), size), [tiles, size.w, size.h]);
770
- const tileLooks = useMemo(() => { const work = workOn(traffic); return tiles.map((tl) => looksOf(tl.L, work, q, mode, tl.zones)); }, [tiles, traffic, q, mode, themeRev]);
836
+ const tileLooks = useMemo(() => { const work = workOn(traffic); return tiles.map((tl) => looksOf(tl.L, work, q, mode, tl.zones, (n) => badgeOf(n, lv, tl.skin.pack && tl.skin.pack.levels))); }, [tiles, traffic, q, mode, themeRev, lv]);
771
837
  // The scene the view pans and zooms: the office's, or the overview of every tile.
772
838
  const L = useMemo(() => (overview ? { mode: "overview", rooms: [], width: arranged.width, height: arranged.height } : single), [overview, single, arranged]);
773
- const looks = useMemo(() => looksOf(L, workOn(traffic), q, mode, zones), [L, traffic, q, mode, themeRev]);
839
+ const looks = useMemo(() => looksOf(L, workOn(traffic), q, mode, zones, (n) => badgeOf(n, lv, skin.pack && skin.pack.levels)), [L, traffic, q, mode, themeRev, lv, skin]);
774
840
  // #2564: what comes in from other offices — one office's and style's own: a letter's door and drop are that layout's,
775
841
  // so another office (or style) starts with none (one shown meanwhile is not carried over to a different room).
776
842
  // #2578: the acts of the people in this stored office (by name); none elsewhere. On a desks style (review: accepted
@@ -846,6 +912,23 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
846
912
  const v = view0(), p = toScreen(v, d.x, d.y), x = Math.max(4, Math.min(p.x, size.w - 244)), above = p.y > 90;
847
913
  el.style.setProperty("transform", above ? `translate(${x}px, ${p.y + 6}px) translateY(-100%)` : `translate(${x}px, ${p.y + d.h * v.k}px)`);
848
914
  };
915
+ /**
916
+ * #2661: the card beside its person — to their right (left where it would not fit), level with their head, inside the
917
+ * stage — following them as they walk (a walker's position now, else their seat or desk). A bottom sheet is not placed.
918
+ */
919
+ const placeCard = () => {
920
+ const el = cardEl.current, c = cardDesk.current;
921
+ if (!el || !c || !el.style || el.classList?.contains("sheet")) return;
922
+ const v = view0(), sz = sizeRef.current;
923
+ const w = c.walks ? walkers.at(c.name, now()) : null;
924
+ const pt = w ? { x: w.x, y: w.y - 52, r: 16 } : { x: c.box.x + c.box.w / 2, y: c.box.y, r: (c.box.w / 2) * v.k };
925
+ const p = toScreen(v, pt.x, pt.y), cw = 320, ch = el.offsetHeight || 260, gap = 12 + (w ? pt.r : pt.r);
926
+ let x = p.x + gap;
927
+ if (x + cw > sz.w - 8) x = p.x - gap - cw;
928
+ x = Math.max(8, Math.min(x, sz.w - cw - 8));
929
+ const y = Math.max(8, Math.min(p.y - 24, sz.h - ch - 8));
930
+ el.style.setProperty("transform", `translate(${Math.round(x)}px, ${Math.round(y)}px)`);
931
+ };
849
932
  /**
850
933
  * Put the view on everything that shows it beside the canvas: the overview's frame (the part of the scene in view;
851
934
  * the whole view on its data-k/x/y), the zoom's percentage, the overlay, the tooltip.
@@ -1044,18 +1127,19 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
1044
1127
  const base = { ctx, ratio, BW, BH, matte, env, dirty: true };
1045
1128
  if (overview) {
1046
1129
  // Each tile a scene of its own, where the overview puts it (arranged.tiles, in the same order).
1047
- const parts = tiles.map((tl, i) => ({ ...partOf(tl.L, tileLooks[i], tl.skin, tl.zones, tl.walkers, tl.flights), night, key: tl.key, prefix: tl.prefix, tile: arranged.tiles[i] }));
1130
+ const parts = tiles.map((tl, i) => ({ ...partOf(tl.L, tileLooks[i], tl.skin, tl.zones, tl.walkers, tl.flights), night, key: tl.key, prefix: tl.prefix, tile: arranged.tiles[i], ups: ups.current, levelsOn: !!(lv && lv.enabled) }));
1048
1131
  scene.current = { ...base, overview: true, parts, lay: arranged };
1049
1132
  } else {
1050
- scene.current = { ...base, overview: false, part: { ...partOf(L, looks, skin, zones, walkers, flights), ghosts, night, mail, acts: actsHere, actSeen: actSeen.current } };
1133
+ scene.current = { ...base, overview: false, part: { ...partOf(L, looks, skin, zones, walkers, flights), ghosts, night, mail, acts: actsHere, actSeen: actSeen.current, ups: ups.current, levelsOn: !!(lv && lv.enabled) } };
1051
1134
  }
1052
1135
  if (still) draw(0); else loop.current?.wake();
1053
- }, [L, looks, skin, pal, size.w, size.h, ratio, matte, overview, tiles, tileLooks, arranged, ghosts, night, mail, actsHere]);
1136
+ }, [L, looks, skin, pal, size.w, size.h, ratio, matte, overview, tiles, tileLooks, arranged, ghosts, night, mail, actsHere, lv]);
1054
1137
 
1055
1138
  /** One frame at `ms` (0: still); whether anything is moving (in the air, or someone walking). */
1056
1139
  function draw(ms) {
1057
1140
  const s = scene.current;
1058
1141
  if (!s) return false;
1142
+ placeCard();
1059
1143
  const { ctx, BW, BH, env } = s;
1060
1144
  // The view in bitmap px: a logical unit is `a` px (a whole number at a pixel level), the origin on a whole px.
1061
1145
  const v = view0(), a = v.k * s.ratio, ox = Math.round(v.x * s.ratio), oy = Math.round(v.y * s.ratio);
@@ -1134,11 +1218,16 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
1134
1218
  // Everyone drawn: { key, node, box (the desk, in the view's units), dot (where the overview marks them), office (All
1135
1219
  // offices: the tile's name) }. In All offices a key is the tile's prefix and the desk's key: one person in two
1136
1220
  // offices is two people here.
1221
+ // #2661: and for the card, where they are (`area`: the zone's or the room's name), the style that draws them, their look.
1222
+ const roomOf = (L0, d) => { const r = L0.rooms.find((rm) => rm.desks.includes(d)); return r ? (r.lobby ? to("lobbyRoom") : r.name) : null; };
1223
+ const areaName = (a) => (a ? ({ desks: to("area_desks"), lounge: to("area_lounge"), alert: to("area_alert"), nap: to("area_nap") })[a] || a : null);
1137
1224
  const people = overview
1138
- ? tiles.flatMap((tl, i) => { const tt = arranged.tiles[i];
1225
+ ? tiles.flatMap((tl, i) => { const tt = arranged.tiles[i], lk = tileLooks[i];
1139
1226
  return tt ? desksOf(tl.L).map((d) => ({ key: tl.prefix + d.key, node: d.node, box: tileBox(tt, d), office: tl.name,
1140
- dot: fromTile(tt, tl.zones ? d.seat : { x: d.x + d.w / 2, y: d.y + d.h / 2 }) })) : []; })
1141
- : 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 } }));
1227
+ dot: fromTile(tt, tl.zones ? d.seat : { x: d.x + d.w / 2, y: d.y + d.h / 2 }),
1228
+ area: tl.zones ? areaName(d.area) : roomOf(tl.L, d), skin: tl.skin, look: lk && lk.get(d.key) })) : []; })
1229
+ : 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 },
1230
+ area: zones ? areaName(d.area) : roomOf(L, d), skin, look: looks.get(d.key) }));
1142
1231
  // Preact owns the markup, not the view: put it back on the overview, the overlay and the tooltip after every render.
1143
1232
  const tipDesk = tip ? people.find((d) => d.key === tip) || null : null;
1144
1233
  tipDeskRef.current = tipDesk ? tipDesk.box : null;
@@ -1201,16 +1290,20 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
1201
1290
  if (d) show(d.box);
1202
1291
  };
1203
1292
  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); };
1293
+ // #2661: a click (or a tap) on a person opens their card; on the same person again, or off everyone, closes it; on
1294
+ // someone else, switches to theirs. A double-click skips the card and opens the chat.
1204
1295
  const onClick = (e) => {
1205
1296
  const d = deskOf(e);
1206
- if (!d || !d.node.i) { setTip(null); return; }
1207
- if (lastType.current === "touch" && tip !== d.key) { setTip(d.key); return; } // a first tap shows; a second opens
1208
- open(d.node.name);
1297
+ if (!d || !d.node.i) { setTip(null); setCard(null); return; }
1298
+ if (lastType.current === "touch") setTip(null);
1299
+ cardFrom.current = null;
1300
+ setCard((cur) => (cur === d.key ? null : d.key));
1209
1301
  };
1210
1302
  // All offices: a double-click on a tile (off its people) opens that office; the lobby is no office.
1211
1303
  const onDblClick = (e) => {
1212
- const s = scene.current, w = pointOf(e);
1213
- if (!overview || !s || !s.overview || !w || deskOf(e)) return;
1304
+ const s = scene.current, w = pointOf(e), hit = deskOf(e);
1305
+ if (hit && hit.node.i) { setCard(null); open(hit.node.name); return; } // #2661: a person's chat, past the card
1306
+ if (!overview || !s || !s.overview || !w || hit) return;
1214
1307
  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;
1215
1308
  if (tl && !tl.lobby) pick(tl.key);
1216
1309
  };
@@ -1258,6 +1351,54 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
1258
1351
  const pick = (id) => { if (layoutRef.current) return; store.saveChoice(id); setChoice(id); setTip(null); };
1259
1352
  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]);
1260
1353
  const tipText = tipDesk ? nodeText(tipDesk.node) : null;
1354
+ // #2661: the card's person (gone from the office, filtered out or the office changed: it closes).
1355
+ const cardOf = card ? people.find((d) => d.key === card && d.node.i) || null : null;
1356
+ cardDesk.current = cardOf ? { name: cardOf.node.name, box: cardOf.box, walks: zones && !overview } : null;
1357
+ useEffect(() => { if (card && !cardOf) setCard(null); }, [card, !!cardOf]);
1358
+ const closeCard = () => {
1359
+ setCard(null);
1360
+ const from = cardFrom.current; cardFrom.current = null;
1361
+ if (from) [...(ooRef.current?.querySelectorAll?.(".oo-desk") || [])].find((el) => el.getAttribute("data-key") === from)?.focus?.();
1362
+ };
1363
+ // Esc, or a press anywhere off the card and the scene (the scene's own click decides there), closes it.
1364
+ useEffect(() => {
1365
+ if (!card) return undefined;
1366
+ const doc = globalThis.document;
1367
+ const key = (e) => { if (e.key === "Escape" && !e.defaultPrevented) { e.preventDefault(); closeCard(); } };
1368
+ // A desk-list entry is the scene's too: its own click decides (the same person again closes, another switches) —
1369
+ // closing here first would let that click open it again (#2761 review).
1370
+ const press = (e) => {
1371
+ const el = e.target;
1372
+ if (cardEl.current?.contains?.(el) || el === canvasRef.current) return;
1373
+ if (typeof el?.closest === "function" && el.closest(".oo-desk") && ooRef.current?.contains?.(el)) return;
1374
+ closeCard();
1375
+ };
1376
+ doc?.addEventListener?.("keydown", key, true); doc?.addEventListener?.("pointerdown", press, true);
1377
+ return () => { doc?.removeEventListener?.("keydown", key, true); doc?.removeEventListener?.("pointerdown", press, true); };
1378
+ }, [card]);
1379
+ /** Find in office: zoomed in (to at least TWO px a unit) and centred on them. */
1380
+ const findCard = () => {
1381
+ const c = cardDesk.current;
1382
+ if (!c) return;
1383
+ const w = c.walks ? walkers.at(c.name, now()) : null, at = w ? { x: w.x, y: w.y - 26 } : { x: c.box.x + c.box.w / 2, y: c.box.y + c.box.h / 2 };
1384
+ const v = view0(), k = Math.min(OK_MAX, Math.max(v.k, FIND_K));
1385
+ touched.current = true; fitted.current = false;
1386
+ setView(centreOn({ ...v, k }, size, at.x, at.y));
1387
+ };
1388
+ /** The card's picture: the person's idle frame in the office's style (pack strip or built-in figure). */
1389
+ const drawAvatar = (cv) => {
1390
+ const s = scene.current, c = cardOf;
1391
+ const g = cv && cv.getContext ? cv.getContext("2d") : null;
1392
+ if (!g || !c || !s || !c.skin || !c.look) return;
1393
+ const r = deviceRatio(), W = 56;
1394
+ if (cv.width !== W * r) { cv.width = W * r; cv.height = W * r; }
1395
+ g.setTransform(1, 0, 0, 1, 0, 0); g.clearRect(0, 0, cv.width, cv.height); g.imageSmoothingEnabled = false;
1396
+ const look = { ...c.look, pose: "idle", dim: false, match: false, badge: null, notag: true, free: true, work: 0, sign: null };
1397
+ const at = { x: 0, y: 0 }, b = c.skin.charBox ? c.skin.charBox(look, at) : null, box = b || { x: -16, y: -48, w: 32, h: 48 };
1398
+ const k = Math.min((W - 8) / box.w, (W - 8) / box.h) * r;
1399
+ g.setTransform(k, 0, 0, k, (W * r) / 2 - (box.x + box.w / 2) * k, (W * r) / 2 - (box.y + box.h / 2) * k);
1400
+ try { c.skin.drawCharacter(g, { x: at.x - 44, y: at.y - 53, w: 88, h: 53, seat: at }, look, 0, s.env); } catch { /* a picture not loaded yet: blank */ }
1401
+ };
1261
1402
  // The overview: the scene's rooms (or areas; All offices: the tiles) and a dot for each person (a call in its colour),
1262
1403
  // the view's frame on it.
1263
1404
  const miniRects = overview ? arranged.tiles.map((tt) => ({ key: tt.key, x: tt.x, y: tt.y, w: tt.w, h: tt.h }))
@@ -1313,17 +1454,22 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
1313
1454
  }) : null}
1314
1455
  ${layout ? html`<${LayoutEditor} key=${layout.id} base=${layout.base} start=${layout.start} place=${placeOverlay} dock=${!fs} members=${layoutMembers}
1315
1456
  stale=${layoutStale ? t("orgl.stale") : ""} onChange=${onDraft} onSave=${saveLayout} onClose=${closeLayout} />` : null}
1316
- ${tipText ? html`<div ref=${tipRef} class="oo-tip" role="tooltip">
1457
+ ${tipText && tip !== card ? html`<div ref=${tipRef} class="oo-tip" role="tooltip">
1317
1458
  <strong>${tipText.primary}</strong>${tipText.secondary ? html` <span class="muted mono">${tipText.secondary}</span>` : null}
1318
1459
  <div>${tipText.sub}</div><div class="muted">${caption(officePose(tipDesk.node.state), tipDesk.node.state)}</div>
1319
1460
  ${tipDesk.office ? html`<div class="muted oo-tip-office">${tipDesk.office}</div>` : null}
1320
- ${tipDesk.node.i && onOpen && lastType.current === "touch" ? html`<div class="muted">${to("tapAgain")}</div>` : null}
1321
1461
  </div>` : null}
1462
+ ${cardOf ? html`<${OfficeCard} key=${cardOf.key}
1463
+ model=${cardModel({ node: cardOf.node, lv, levels: cardOf.skin && cardOf.skin.pack ? cardOf.skin.pack.levels : null, share: !onOpen,
1464
+ state: stateWords(cardOf.node), now: caption(officePose(cardOf.node.state), cardOf.node.state),
1465
+ where: [cardOf.office || (custom ? custom.name : to("allOffice")), cardOf.area].filter(Boolean).join(" · ") })}
1466
+ avatar=${drawAvatar} sheet=${size.w < SHEET_W} place=${(el) => { cardEl.current = el; placeCard(); }}
1467
+ onChat=${onOpen ? () => { const n = cardOf.node.name; setCard(null); open(n); } : null} onFind=${findCard} onClose=${closeCard} />` : null}
1322
1468
  <ul class="oo-desks" aria-label=${to("desks")}>${people.map((d) => {
1323
1469
  const nt = nodeText(d.node), cap = caption(officePose(d.node.state), d.node.state), where = d.office ? ` · ${d.office}` : "";
1324
1470
  return html`<li key=${d.key}>${d.node.i
1325
- ? html`<button type="button" class="oo-desk" data-name=${d.node.name} data-key=${d.key} aria-label=${`${nt.full}${where} · ${cap}.${onOpen ? ` ${t("orgc.openChat")}` : ""}`}
1326
- onFocus=${() => setTip(d.key)} onBlur=${() => setTip(null)} onClick=${() => open(d.node.name)}>${nt.primary}</button>`
1471
+ ? html`<button type="button" class="oo-desk" data-name=${d.node.name} data-key=${d.key} aria-label=${`${nt.full}${where} · ${cap}.`} aria-haspopup="dialog"
1472
+ onFocus=${() => setTip(d.key)} onBlur=${() => setTip(null)} onClick=${() => { cardFrom.current = d.key; setCard((cur) => (cur === d.key ? null : d.key)); }}>${nt.primary}</button>`
1327
1473
  : html`<span class="oo-desk" data-name=${d.node.name} data-key=${d.key}>${nt.full}</span>`}</li>`;
1328
1474
  })}</ul>
1329
1475
  <div class="oc-zoom oo-zoom" role="group" aria-label=${t("orgc.view")}>
@@ -60,6 +60,8 @@ export function orgState(i, exec, awaiting, needs) {
60
60
  * What a node's card says (fleet-canvas.js; the Office view's desk too, fleet-office.js): its names, its state and
61
61
  * backend (`sub`), and all of it in one line (`full`, the card's title and label).
62
62
  */
63
+ /** #2661: a node's state in words (as its card and tooltip say it). */
64
+ export const stateWords = (n) => to(n.state);
63
65
  export function nodeText(n) {
64
66
  const it = n.i;
65
67
  const names = it ? namesOf(it) : { primary: n.name, secondary: "" };
@@ -175,7 +175,7 @@ export function CardDialog({ id, tick, projects, writable = true, place = null,
175
175
  </div>
176
176
 
177
177
  <div class="tc-actions">
178
- <${StatusPill} status=${card.status} />
178
+ <${StatusPill} status=${card.status} card=${card} />
179
179
  ${writable ? html`
180
180
  ${card.status === "review" ? html`
181
181
  <button type="button" class="btn btn-primary btn-sm" disabled=${busy} onClick=${() => moved(() => moveCard(card, "done"))}><${Icon} name="check" size=${14} />${isMain ? t("taskcard.confirmMain") : t("tasks.confirm")}</button>
@@ -260,7 +260,7 @@ export function CardDialog({ id, tick, projects, writable = true, place = null,
260
260
 
261
261
  ${isMain ? html`<${Field} title=${t("taskcard.subs", children.length)}>
262
262
  ${children.length ? html`<ul class="tc-children">${children.map((c) => html`<li key=${c.id}><button type="button" onClick=${() => onOpen(c.id)}>
263
- <${StatusPill} status=${c.status} /><span class="tb-code">${c.id}</span><span class="tc-child-title">${c.title}</span><span class="tb-who">${c.assignee || ""}</span></button></li>`)}</ul>` : null}
263
+ <${StatusPill} status=${c.status} card=${c} /><span class="tb-code">${c.id}</span><span class="tc-child-title">${c.title}</span><span class="tb-who">${c.assignee || ""}</span></button></li>`)}</ul>` : null}
264
264
  ${finished || !writable ? null : html`<${QuickAdd} placeholder=${t("taskcard.quickAdd")} onAdd=${(title) => run(() => boardApi.create({ parent: card.id, title }))} />`}
265
265
  </${Field}>` : null}
266
266
 
@@ -10,7 +10,8 @@ import { toast } from "/assets/ui-toast.js";
10
10
 
11
11
  register("tasks", {
12
12
  en: {
13
- open: "To do", claimed: "In progress", blocked: "Blocked", review: "Waiting for you", done: "Done", cancelled: "Cancelled",
13
+ open: "To do", claimed: "In progress", blocked: "Blocked", review: "To confirm", done: "Done", cancelled: "Cancelled",
14
+ reviewYou: "Waiting for you", reviewBy: "Waiting for {0}", reviewByTip: "{0} confirms it, not you",
14
15
  main: "Main task", sub: "Sub task", urgent: "Urgent", high: "High", normal: "Normal", low: "Low",
15
16
  sizeS: "Small", sizeM: "Medium", sizeL: "Large", sizeNone: "Not sized",
16
17
  projects: "Projects", noProject: "No project", noProjectDesc: "Tasks the fleet opened outside any project",
@@ -53,7 +54,6 @@ register("tasks", {
53
54
  nowDoing: "Working on", nowBlocked: "Blocked", nowNext: "Next", ownerTip: "Main task owner: {0}", moreCards: "+{0}",
54
55
  wrapTag: "To fill in", wrapLine: "Not wrapped up yet: {0}", gapUnchecked: "{0} checklist items unchecked", gapUncheckedOne: "1 checklist item unchecked",
55
56
  gapNoResult: "no result", confirmGapTitle: "Confirm {0}?", confirmAnyway: "Confirm anyway",
56
- askedFirst: "{0} handed it out and has been asked; you can answer it here too",
57
57
  confirmGapLabel: "{0}. You can send it back to have it filled in — or confirm anyway, with a note if you like",
58
58
  "err.too_long": "{0} is too long: {1} characters, at most {2}", "err.state_lines": "State is at most {0} lines (it has {1})",
59
59
  "err.acceptance_empty": "An acceptance item cannot be empty", "err.acceptance_max": "At most {0} acceptance items",
@@ -76,7 +76,7 @@ register("tasks", {
76
76
  "note.deps_unmet": "Note: {0} not done yet",
77
77
  // #2246 step 3: who works on what (fleet-task-work.js)
78
78
  instances: "Instances", instancesTip: "The fleet's instances and what each works on", instancesHead: "{0} instance(s) · {1} working",
79
- workSentBack: "Sent back", workDoing: "In progress", workBlocked: "Blocked", workReview: "Waiting for you", workMain: "Owns",
79
+ workSentBack: "Sent back", workDoing: "In progress", workBlocked: "Blocked", workReview: "To confirm", workMain: "Owns",
80
80
  workReady: "Can start", workWaiting: "Waiting on", workSubs: "sub tasks {0}/{1}", workClaimable: "nobody's: can be taken",
81
81
  workWaitsFor: "waits for {0}", workNone: "Nothing on the board", workNewComments: "Comments it has not read yet",
82
82
  workOutside: "Not in the fleet", workFailed: "Could not read who works on what: {0}", workJoin: ", ",
@@ -84,6 +84,7 @@ register("tasks", {
84
84
  },
85
85
  "zh-TW": {
86
86
  open: "待辦", claimed: "進行中", blocked: "卡住", review: "待確認", done: "完成", cancelled: "取消",
87
+ reviewYou: "等你確認", reviewBy: "等 {0} 確認", reviewByTip: "由 {0} 確認,不是你",
87
88
  main: "主任務", sub: "子任務", urgent: "緊急", high: "高", normal: "一般", low: "低",
88
89
  sizeS: "小", sizeM: "中", sizeL: "大", sizeNone: "未定",
89
90
  projects: "專案", noProject: "未分類", noProjectDesc: "fleet 在專案以外開的 task",
@@ -126,7 +127,6 @@ register("tasks", {
126
127
  nowDoing: "執行中", nowBlocked: "卡住", nowNext: "下一步", ownerTip: "主任務負責人:{0}", moreCards: "+{0}",
127
128
  wrapTag: "待補齊", wrapLine: "負責人還沒補齊:{0}", gapUnchecked: "還有 {0} 項沒勾", gapUncheckedOne: "還有 1 項沒勾",
128
129
  gapNoResult: "沒有產出總結", confirmGapTitle: "確認 {0}?", confirmAnyway: "還是確認",
129
- askedFirst: "已先通知派工者 {0};你也可以直接在這裡回覆",
130
130
  confirmGapLabel: "{0}。可以先退回請負責人補齊;還是要確認的話,可以留一句確認意見",
131
131
  "err.too_long": "{0}太長:{1} 字,上限 {2} 字", "err.state_lines": "目前狀態最多 {0} 行(現在 {1} 行)",
132
132
  "err.acceptance_empty": "驗收條件不能是空的", "err.acceptance_max": "驗收條件最多 {0} 項",
@@ -190,6 +190,17 @@ export const percentDone = (units) => (units.total ? Math.round((units.done / un
190
190
  /** The key a card's project is filed under: its id, or NO_PROJECT. */
191
191
  export const projectKey = (card) => (card.projectId === null || card.projectId === undefined ? NO_PROJECT : card.projectId);
192
192
 
193
+ /**
194
+ * #2080: whether a card waits for the person — Waiting for you, and its count: the host's word (`waitsForPerson`, the
195
+ * same rule as Needs you: a card the person handed out, in review needing their confirmation or blocked on a question,
196
+ * or one whose dispatcher was deleted); a card read without it, by the person's own hand-out alone. An agent's card in
197
+ * review is its confirmer's (`confirmer`), never the person's.
198
+ */
199
+ export function waitsForYou(card) {
200
+ if (typeof card.waitsForPerson === "boolean") return card.waitsForPerson;
201
+ return !!card.dispatcher && card.dispatcher.kind === "person" && ((card.status === "review" && card.needsReview) || card.status === "blocked");
202
+ }
203
+
193
204
  /**
194
205
  * One project's numbers for its card on Home and its header, from the cards at hand (`key`: a project id or
195
206
  * NO_PROJECT). The board's read carries the server's (over every card, finished ones too); this is the fallback.
@@ -201,7 +212,7 @@ export function projectStats(key, cards) {
201
212
  if (projectKey(c) !== key) continue;
202
213
  if (!lastActivity || c.updatedAt > lastActivity) lastActivity = c.updatedAt;
203
214
  if (c.status === "cancelled") continue;
204
- if (c.status === "review") waiting++;
215
+ if (c.status === "review" && waitsForYou(c)) waiting++;
205
216
  if (c.status === "blocked") blocked++;
206
217
  if (c.kind !== "main") continue;
207
218
  mains++;
@@ -454,8 +465,20 @@ export function ProgressBar({ progress, wide = false }) {
454
465
  ${barSegments(progress).filter((s) => s.k === "done").map((s) => html`<rect key=${s.k} class=${`tb-seg st-${s.k}`} x=${s.x} width=${s.w} height="6" />`)}</svg>`;
455
466
  }
456
467
 
457
- export function StatusPill({ status }) {
458
- return html`<span class=${`tb-pill st-${status}`}>${t(`tasks.${status}`)}</span>`;
468
+ /** #2080: whom a card in review waits for, in words: you, the agent that confirms it, or (neither known) "To confirm". */
469
+ export function reviewLabel(card) {
470
+ if (waitsForYou(card)) return t("tasks.reviewYou");
471
+ return card.confirmer ? t("tasks.reviewBy", card.confirmer) : t("tasks.review");
472
+ }
473
+
474
+ /**
475
+ * A card's status. #2080: with the card, one in review says whom it waits for — the person ("Waiting for you") or the
476
+ * agent that confirms it ("Waiting for atlas").
477
+ */
478
+ export function StatusPill({ status, card = null }) {
479
+ const by = card && status === "review" && !waitsForYou(card) && card.confirmer ? card.confirmer : null;
480
+ const label = !card || status !== "review" ? t(`tasks.${status}`) : reviewLabel(card);
481
+ return html`<span class=${`tb-pill st-${status}${by ? " by-agent" : ""}`} title=${by ? t("tasks.reviewByTip", by) : null}>${label}</span>`;
459
482
  }
460
483
 
461
484
  /**
@@ -8,7 +8,7 @@ import { Dialog } from "/assets/ui-dialog.js";
8
8
  import { Icon } from "/assets/ui-icons.js";
9
9
  import { Skeleton } from "/assets/ui-states.js";
10
10
  import { GroupMark, InstanceAvatar, dotLabel, dotState, groupKind, groupLabel, groupOf, namesOf, namesStore } from "/assets/instance-nav.js";
11
- import { Who, boardApi, errorText } from "./fleet-task-common.js";
11
+ import { Who, boardApi, errorText, reviewLabel } from "./fleet-task-common.js";
12
12
 
13
13
  const LABEL = { back: "tasks.workSentBack", doing: "tasks.workDoing", blocked: "tasks.workBlocked", review: "tasks.workReview", main: "tasks.workMain", ready: "tasks.workReady", waiting: "tasks.workWaiting" };
14
14
 
@@ -33,7 +33,7 @@ export function WorkLines({ lines, onOpen }) {
33
33
  if (!lines.length) return html`<p class="muted tw-none">${t("tasks.workNone")}</p>`;
34
34
  return html`<ul class="tw-lines">${lines.map((l) => html`<li key=${`${l.kind}:${l.card.id}`}>
35
35
  <button type="button" class="tw-line" title=${`${l.card.id} ${l.card.title}`} onClick=${() => onOpen(l.card.id)}>
36
- <span class=${`tw-kind k-${l.kind}`}>${t(LABEL[l.kind])}</span><span class="tb-code">${l.card.id}</span><span class="tw-title">${l.card.title}</span>
36
+ <span class=${`tw-kind k-${l.kind}`}>${l.kind === "review" ? reviewLabel(l.card) : t(LABEL[l.kind])}</span><span class="tb-code">${l.card.id}</span><span class="tw-title">${l.card.title}</span>
37
37
  ${l.note ? html`<span class="muted tw-note">${l.note}</span>` : null}
38
38
  ${l.unread ? html`<span class="tw-unread" title=${t("tasks.workNewComments")} aria-label=${t("tasks.workNewComments")}><${Icon} name="chat" size=${13} /></span>` : null}
39
39
  </button></li>`)}</ul>`;