@songsid/agend 2.3.3-beta.4 → 2.3.3-beta.6

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 (250) hide show
  1. package/dist/agent-cli.js +13 -112
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/agent-endpoint.d.ts +0 -1
  4. package/dist/agent-endpoint.js +1 -9
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/agent-whats-new.d.ts +3 -6
  7. package/dist/agent-whats-new.js +7 -18
  8. package/dist/agent-whats-new.js.map +1 -1
  9. package/dist/auth-api.d.ts +0 -7
  10. package/dist/auth-api.js +1 -15
  11. package/dist/auth-api.js.map +1 -1
  12. package/dist/backend/claude-code.js +4 -2
  13. package/dist/backend/claude-code.js.map +1 -1
  14. package/dist/backend/codex.d.ts +1 -0
  15. package/dist/backend/codex.js +106 -15
  16. package/dist/backend/codex.js.map +1 -1
  17. package/dist/backend/grok.js +57 -11
  18. package/dist/backend/grok.js.map +1 -1
  19. package/dist/backend/types.d.ts +35 -0
  20. package/dist/backend/types.js.map +1 -1
  21. package/dist/bundled-skills.d.ts +4 -6
  22. package/dist/bundled-skills.js +10 -14
  23. package/dist/bundled-skills.js.map +1 -1
  24. package/dist/channel/mcp-server.js +5 -5
  25. package/dist/channel/mcp-server.js.map +1 -1
  26. package/dist/channel/mcp-tools.d.ts +1 -3
  27. package/dist/channel/mcp-tools.js +4 -58
  28. package/dist/channel/mcp-tools.js.map +1 -1
  29. package/dist/cli.js +3 -1
  30. package/dist/cli.js.map +1 -1
  31. package/dist/config-validator.js +6 -3
  32. package/dist/config-validator.js.map +1 -1
  33. package/dist/daemon.d.ts +17 -3
  34. package/dist/daemon.js +76 -107
  35. package/dist/daemon.js.map +1 -1
  36. package/dist/dashboard-live.d.ts +1 -1
  37. package/dist/dashboard-live.js +3 -3
  38. package/dist/dashboard-live.js.map +1 -1
  39. package/dist/dashboard-permissions.d.ts +1 -1
  40. package/dist/dashboard-permissions.js +1 -3
  41. package/dist/dashboard-permissions.js.map +1 -1
  42. package/dist/fleet-manager.d.ts +31 -44
  43. package/dist/fleet-manager.js +94 -227
  44. package/dist/fleet-manager.js.map +1 -1
  45. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +11 -1
  46. package/dist/general-knowledge/skills/session-management/SKILL.md +6 -0
  47. package/dist/general-knowledge/skills/web-appearance/SKILL.md +13 -204
  48. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +26 -63
  49. package/dist/instance-lifecycle.d.ts +6 -0
  50. package/dist/instance-lifecycle.js +20 -4
  51. package/dist/instance-lifecycle.js.map +1 -1
  52. package/dist/locale.js +14 -4
  53. package/dist/locale.js.map +1 -1
  54. package/dist/outbound-handlers.js +19 -7
  55. package/dist/outbound-handlers.js.map +1 -1
  56. package/dist/outbound-schemas.d.ts +0 -62
  57. package/dist/outbound-schemas.js +3 -42
  58. package/dist/outbound-schemas.js.map +1 -1
  59. package/dist/public-web-gateway.js +0 -9
  60. package/dist/public-web-gateway.js.map +1 -1
  61. package/dist/public-web-link.d.ts +2 -0
  62. package/dist/public-web-link.js +5 -0
  63. package/dist/public-web-link.js.map +1 -1
  64. package/dist/search/search-docs.d.ts +1 -1
  65. package/dist/search/search-docs.js +2 -2
  66. package/dist/search/search-docs.js.map +1 -1
  67. package/dist/settings-api.d.ts +6 -0
  68. package/dist/settings-api.js +14 -2
  69. package/dist/settings-api.js.map +1 -1
  70. package/dist/settings-change.js +3 -1
  71. package/dist/settings-change.js.map +1 -1
  72. package/dist/settings-confirmation.d.ts +17 -3
  73. package/dist/settings-confirmation.js +8 -5
  74. package/dist/settings-confirmation.js.map +1 -1
  75. package/dist/settings-http-confirmation.js +31 -1
  76. package/dist/settings-http-confirmation.js.map +1 -1
  77. package/dist/tool-permissions.js +3 -10
  78. package/dist/tool-permissions.js.map +1 -1
  79. package/dist/topic-commands.d.ts +27 -7
  80. package/dist/topic-commands.js +94 -36
  81. package/dist/topic-commands.js.map +1 -1
  82. package/dist/types.d.ts +2 -1
  83. package/dist/ui/chat-ambient.js +4 -4
  84. package/dist/ui/chat-commands.js +3 -2
  85. package/dist/ui/chat-store.js +4 -1
  86. package/dist/ui/chat-strings.js +12 -46
  87. package/dist/ui/chat-thread.js +51 -59
  88. package/dist/ui/fleet-office-skin.js +37 -14
  89. package/dist/ui/fleet-office.js +31 -7
  90. package/dist/ui/html-attachment.js +22 -8
  91. package/dist/ui/panel-chat.js +21 -50
  92. package/dist/ui/preview-panel.js +12 -21
  93. package/dist/ui/preview.js +10 -3
  94. package/dist/ui/search-dialog.js +30 -14
  95. package/dist/ui/settings-appearance.js +1 -3
  96. package/dist/ui/settings-confirm.js +18 -0
  97. package/dist/ui/settings-guests.js +13 -4
  98. package/dist/ui/settings-learn.js +1 -1
  99. package/dist/ui/settings-mods.js +5 -12
  100. package/dist/ui/settings-registry.js +0 -1
  101. package/dist/ui/settings-strings.js +6 -6
  102. package/dist/ui/shared/app-i18n.js +13 -2
  103. package/dist/ui/shared/app-prefs.js +17 -17
  104. package/dist/ui/shared/app-shell.js +47 -9
  105. package/dist/ui/shared/app-store.js +6 -2
  106. package/dist/ui/shared/app.css +19 -36
  107. package/dist/ui/shared/app.js +5 -4
  108. package/dist/ui/shared/instance-nav.js +9 -9
  109. package/dist/ui/shared/instance-switch.js +8 -2
  110. package/dist/ui/shared/panel-view.js +22 -8
  111. package/dist/ui/shared/view-strings.js +6 -0
  112. package/dist/usage/usage-api.d.ts +3 -0
  113. package/dist/usage/usage-api.js +5 -1
  114. package/dist/usage/usage-api.js.map +1 -1
  115. package/dist/view-api.d.ts +2 -0
  116. package/dist/view-api.js +64 -0
  117. package/dist/view-api.js.map +1 -1
  118. package/dist/web-api.d.ts +9 -17
  119. package/dist/web-api.js +255 -371
  120. package/dist/web-api.js.map +1 -1
  121. package/dist/web-appearance-tools.d.ts +0 -2
  122. package/dist/web-appearance-tools.js +2 -5
  123. package/dist/web-appearance-tools.js.map +1 -1
  124. package/dist/web-appearance-view.d.ts +0 -6
  125. package/dist/web-appearance-view.js +0 -1
  126. package/dist/web-appearance-view.js.map +1 -1
  127. package/dist/web-appearance.d.ts +14 -7
  128. package/dist/web-appearance.js +5 -6
  129. package/dist/web-appearance.js.map +1 -1
  130. package/dist/web-asset-parts.d.ts +1 -17
  131. package/dist/web-asset-parts.js +1 -15
  132. package/dist/web-asset-parts.js.map +1 -1
  133. package/dist/web-auth.js +1 -7
  134. package/dist/web-auth.js.map +1 -1
  135. package/dist/web-chat-history.d.ts +3 -7
  136. package/dist/web-chat-history.js +10 -17
  137. package/dist/web-chat-history.js.map +1 -1
  138. package/dist/web-commands.d.ts +9 -1
  139. package/dist/web-commands.js +13 -0
  140. package/dist/web-commands.js.map +1 -1
  141. package/dist/web-experimental.d.ts +2 -11
  142. package/dist/web-experimental.js +8 -36
  143. package/dist/web-experimental.js.map +1 -1
  144. package/dist/web-guest-access.d.ts +40 -2
  145. package/dist/web-guest-access.js +50 -14
  146. package/dist/web-guest-access.js.map +1 -1
  147. package/dist/web-mod-bundle.d.ts +2 -45
  148. package/dist/web-mod-bundle.js +15 -63
  149. package/dist/web-mod-bundle.js.map +1 -1
  150. package/dist/web-mod-store.d.ts +3 -48
  151. package/dist/web-mod-store.js +30 -97
  152. package/dist/web-mod-store.js.map +1 -1
  153. package/dist/web-prefs.d.ts +13 -0
  154. package/dist/web-prefs.js +38 -0
  155. package/dist/web-prefs.js.map +1 -1
  156. package/dist/web-preview.d.ts +6 -13
  157. package/dist/web-preview.js +12 -23
  158. package/dist/web-preview.js.map +1 -1
  159. package/dist/web-session.d.ts +11 -3
  160. package/dist/web-session.js +22 -11
  161. package/dist/web-session.js.map +1 -1
  162. package/dist/web-settings-patch.d.ts +1 -0
  163. package/dist/web-settings-patch.js +6 -3
  164. package/dist/web-settings-patch.js.map +1 -1
  165. package/dist/web-settings-tools.d.ts +0 -13
  166. package/dist/web-settings-tools.js +12 -84
  167. package/dist/web-settings-tools.js.map +1 -1
  168. package/package.json +1 -1
  169. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +0 -376
  170. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +0 -672
  171. package/dist/ui/app-effects.js +0 -251
  172. package/dist/ui/chat-effect-posters.js +0 -42
  173. package/dist/ui/chat-effects.js +0 -167
  174. package/dist/ui/effect-trial-controller.js +0 -124
  175. package/dist/ui/effect-trial-offer.js +0 -2
  176. package/dist/ui/effect-trial-panel.js +0 -40
  177. package/dist/ui/effects-waker.js +0 -2
  178. package/dist/ui/settings-effects.js +0 -202
  179. package/dist/ui/settings-owner-effects.js +0 -82
  180. package/dist/ui/shared/effect-app-runtime.js +0 -299
  181. package/dist/ui/shared/effect-baseline.js +0 -64
  182. package/dist/ui/shared/effect-chat-runtime.js +0 -162
  183. package/dist/ui/shared/effect-cursor.js +0 -126
  184. package/dist/ui/shared/effect-display-metadata.js +0 -39
  185. package/dist/ui/shared/effect-frame.js +0 -146
  186. package/dist/ui/shared/effect-geometry.js +0 -137
  187. package/dist/ui/shared/effect-host-style.js +0 -10
  188. package/dist/ui/shared/effect-owner-regions.js +0 -74
  189. package/dist/ui/shared/effect-owner-source.js +0 -122
  190. package/dist/ui/shared/effect-policy.js +0 -151
  191. package/dist/ui/shared/effect-poster-reader.js +0 -98
  192. package/dist/ui/shared/effect-posters.js +0 -117
  193. package/dist/ui/shared/effect-profiler.js +0 -184
  194. package/dist/ui/shared/effect-profiles.js +0 -65
  195. package/dist/ui/shared/effect-registry.js +0 -545
  196. package/dist/ui/shared/effect-runtime-loader.js +0 -236
  197. package/dist/ui/shared/effect-safety.js +0 -153
  198. package/dist/ui/shared/effect-stock-snapshot.js +0 -43
  199. package/dist/ui/shared/effect-stock-trust.js +0 -217
  200. package/dist/ui/shared/effect-trial-shadow.js +0 -62
  201. package/dist/ui/shared/effect-trial-source.js +0 -103
  202. package/dist/ui/shared/effect-trial-stage.js +0 -170
  203. package/dist/ui/shared/effect-trial.css +0 -40
  204. package/dist/ui/shared/feature-flags.js +0 -127
  205. package/dist/ui/shared/owner-effect-prefs.js +0 -117
  206. package/dist/web-effect-bootstrap.d.ts +0 -12
  207. package/dist/web-effect-bootstrap.js +0 -49
  208. package/dist/web-effect-bootstrap.js.map +0 -1
  209. package/dist/web-effect-bundle.d.ts +0 -28
  210. package/dist/web-effect-bundle.js +0 -171
  211. package/dist/web-effect-bundle.js.map +0 -1
  212. package/dist/web-effect-canonical.d.ts +0 -8
  213. package/dist/web-effect-canonical.js +0 -27
  214. package/dist/web-effect-canonical.js.map +0 -1
  215. package/dist/web-effect-library.d.ts +0 -114
  216. package/dist/web-effect-library.js +0 -530
  217. package/dist/web-effect-library.js.map +0 -1
  218. package/dist/web-effect-playback.d.ts +0 -30
  219. package/dist/web-effect-playback.js +0 -71
  220. package/dist/web-effect-playback.js.map +0 -1
  221. package/dist/web-effect-poster.d.ts +0 -8
  222. package/dist/web-effect-poster.js +0 -52
  223. package/dist/web-effect-poster.js.map +0 -1
  224. package/dist/web-effect-profiles.d.ts +0 -118
  225. package/dist/web-effect-profiles.js +0 -181
  226. package/dist/web-effect-profiles.js.map +0 -1
  227. package/dist/web-effect-service.d.ts +0 -93
  228. package/dist/web-effect-service.js +0 -335
  229. package/dist/web-effect-service.js.map +0 -1
  230. package/dist/web-effect-tools.d.ts +0 -48
  231. package/dist/web-effect-tools.js +0 -129
  232. package/dist/web-effect-tools.js.map +0 -1
  233. package/dist/web-effect-trials.d.ts +0 -86
  234. package/dist/web-effect-trials.js +0 -204
  235. package/dist/web-effect-trials.js.map +0 -1
  236. package/dist/web-effect-upload.d.ts +0 -27
  237. package/dist/web-effect-upload.js +0 -185
  238. package/dist/web-effect-upload.js.map +0 -1
  239. package/dist/web-effect-worker.d.ts +0 -8
  240. package/dist/web-effect-worker.js +0 -170
  241. package/dist/web-effect-worker.js.map +0 -1
  242. package/dist/web-effect.d.ts +0 -103
  243. package/dist/web-effect.js +0 -153
  244. package/dist/web-effect.js.map +0 -1
  245. package/dist/web-experimental-http.d.ts +0 -2
  246. package/dist/web-experimental-http.js +0 -12
  247. package/dist/web-experimental-http.js.map +0 -1
  248. package/dist/web-mod-agent.d.ts +0 -50
  249. package/dist/web-mod-agent.js +0 -207
  250. package/dist/web-mod-agent.js.map +0 -1
@@ -8,8 +8,7 @@
8
8
  // Unrelated updates (a new message, another message's tick) stop nothing (#1306 §6.2, #1408 §4).
9
9
  import "./chat-render.js";
10
10
  import "./preview.js";
11
- import { canvasWorkerEnabled, onCanvasWorkerChange, onCanvasMetadataChange, readCanvasTrialMessage } from "/assets/feature-flags.js";
12
- import { loadHtmlAttachment } from "./html-attachment.js";
11
+ import { HTML_PREVIEW_MAX, loadHtmlAttachment, sizeLabel } from "./html-attachment.js";
13
12
  import { chatTime, chatTimeFull, onDayChange } from "./chat-time.js";
14
13
  import { textLinkSegments } from "/assets/app-route.js";
15
14
 
@@ -24,6 +23,12 @@ export const TEXT_PREVIEW_LINES = 200;
24
23
  export const TEXT_PREVIEW_NODES = 4000;
25
24
  /** A csv/tsv wider than this is shown as text, as Markdown tables are capped at 20 columns. */
26
25
  export const TEXT_PREVIEW_COLUMNS = 50;
26
+ /**
27
+ * #2863: the most a text card reads (bytes). It stays 1 MiB while an HTML preview goes to 8 MiB (and 50 MiB on
28
+ * "Preview anyway"): a text card reads, parses and formats the file on this page's own thread (Markdown, highlighting,
29
+ * a CSV table; Show all draws every line), where an HTML preview runs in its own frame. A bigger file is a download.
30
+ */
31
+ export const TEXT_CARD_MAX = 1024 * 1024;
27
32
  const P = () => globalThis.AgendPreview;
28
33
  /** #1672: a status line longer than this (lines, or characters) is folded until it is opened. */
29
34
  export const STATUS_FOLD_LINES = 6;
@@ -120,7 +125,7 @@ export function createThread(list, scroller, opts) {
120
125
  if (el.textContent !== v) el.textContent = v;
121
126
  }
122
127
  // #1770: "today" ends at the fleet's midnight; the labels are rewritten then (and on every render), while there are any.
123
- let offDay = null, trialTimer = null;
128
+ let offDay = null;
124
129
  function armDay() {
125
130
  if (nodes.size && !offDay) offDay = onDayChange(() => { for (const v of nodes.values()) timeLabel(v.node); });
126
131
  else if (!nodes.size && offDay) { offDay(); offDay = null; }
@@ -133,8 +138,6 @@ export function createThread(list, scroller, opts) {
133
138
  // is kept by: it is written into the node (timeLabels) — crossing midnight relabels in place and reloads no preview.
134
139
  const time = x.ts ? `<span class="time" data-ts="${escAttr(x.ts)}" title="${escAttr(chatTimeFull(x.ts))}"></span>` : `<span class="time"></span>`;
135
140
  if (x.outgoing) return outgoingHtml(x);
136
- const trial = readCanvasTrialMessage(x);
137
- if (trial) return canvasWorkerEnabled() ? trialHtml(trial, time, x) : "";
138
141
  if (x.role === "status") return statusHtml(x, time);
139
142
  const u = opts.isUser(x);
140
143
  // renderMarkdown escapes the whole text before it adds any tag of its own (chat-render.js).
@@ -213,26 +216,7 @@ export function createThread(list, scroller, opts) {
213
216
  ? `<button type="button" class="click-link" data-act="showButtons" data-arg="${escAttr(of)}" title="${escAttr(tr("chat.clickShow"))}">${inner}</button>`
214
217
  : `<span class="click-text">${inner}</span>`}</div>`;
215
218
  }
216
- function trialNow() { try { const t = opts.serverNow ? opts.serverNow() : Date.now(); return Number.isFinite(t) ? t : Infinity; } catch { return Infinity; } }
217
- const trialReasons = new Set(["checking", "localAdmin", "chatChanged", "busy", "safe", "effectsOff", "reducedMotion", "saveData", "unsupported", "transport", "inactive", "dialog", "expired"]);
218
- function trialStatus(message) {
219
- const reason = opts.effectTrialStatus?.(message);
220
- return reason === "ready" || trialReasons.has(reason) ? reason : opts.canTryEffect?.(message) === true ? "ready" : "localAdmin";
221
- }
222
- function trialHtml(offer, time, message) {
223
- const reason = trialNow() >= offer.expiresAt ? "expired" : PUBLIC_LINK() ? "localAdmin" : trialStatus(message);
224
- const rawFailure = opts.effectTrialFailure?.(message), failure = typeof rawFailure === "string" ? rawFailure.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 80) : null;
225
- const available = reason === "ready", fix = !PUBLIC_LINK() && !available && reason !== "expired" && reason !== "checking" && typeof opts.fixEffectTrial === "function";
226
- const label = available ? "chat.effectTrialStart" : reason === "busy" ? "chat.effectTrialStopOther" : reason === "localAdmin" ? "chat.effectTrialSignIn" : reason === "reducedMotion" ? "chat.effectTrialPlayMotion" : "chat.effectTrialSettings";
227
- const button = available || fix ? `<button type="button" class="btn btn-sm effect-trial-start" data-act="effectTrial" data-arg="${escAttr(msgKey(message))}">${escAttr(tr(label))}</button>` : "";
228
- return `<div class="msg status-line"><div class="meta">${time}</div><div class="body"><div class="effect-trial-offer" data-trust="thread-control"><strong>${escAttr(tr("chat.effectTrial"))}: ${escAttr(offer.title)}</strong><div>${escAttr(tr(available ? "chat.effectTrialLocal" : "chat.effectTrialReason_" + reason))}</div>${failure ? `<div class="effect-trial-failure" role="status">${escAttr(tr("chat.effectTrialStopped"))}: ${escAttr(failure)}</div>` : ""}${button}</div></div></div>`;
229
- }
230
- function armTrials() {
231
- if (trialTimer !== null) clearTimeout(trialTimer); trialTimer = null;
232
- if (!canvasWorkerEnabled()) return;
233
- const now = trialNow(), next = msgs.map(x => readCanvasTrialMessage(x)).filter(x => x && x.expiresAt > now).reduce((n, x) => Math.min(n, x.expiresAt), Infinity);
234
- if (Number.isFinite(next)) trialTimer = setTimeout(() => { trialTimer = null; render(msgs); }, Math.max(1, Math.min(2147483647, next - now)));
235
- }
219
+
236
220
  /**
237
221
  * #1672: a status line (another instance's message, a daemon notice) is muted, read-only plain text — never Markdown,
238
222
  * a preview card, a button or a person's bubble. Checked before isUser, which reads the sender: a fleet name need not
@@ -428,7 +412,7 @@ export function createThread(list, scroller, opts) {
428
412
  if (reading) return;
429
413
  if (text !== null) { body.hidden = !body.hidden; show.textContent = tr(body.hidden ? "chat.tcShow" : "chat.tcHide"); return; }
430
414
  const mine = reading = ++reads; note.textContent = tr("chat.pvLoading");
431
- const r = await loadHtmlAttachment({ id: att.id, name: att.name, size: att.size });
415
+ const r = await loadHtmlAttachment({ id: att.id, name: att.name, size: att.size }, undefined, { ask: TEXT_CARD_MAX, max: TEXT_CARD_MAX });
432
416
  if (reading !== mine) return; // dropped (its chat left meanwhile): a new click reads again
433
417
  reading = 0;
434
418
  if (!ph.isConnected) return; // the card left the page while it was read: nothing to draw
@@ -484,6 +468,8 @@ export function createThread(list, scroller, opts) {
484
468
  const head = el("div", "pv-head"); head.setAttribute("data-trust", "thread-control");
485
469
  const label = el("span", "pv-label", att ? `${tr("chat.pvHtml")} · ${att.name}` : tr("chat.pvHtml"));
486
470
  const run = el("button", "btn btn-sm pv-run", tr("chat.pvPreview")); run.type = "button";
471
+ // #2863: a large file (over 8 MB) is previewed only on this second click, in the card or the panel as first asked.
472
+ const anywayB = el("button", "btn btn-sm pv-anyway", tr("chat.pvAnyway")); anywayB.type = "button"; anywayB.hidden = true;
487
473
  const stopB = el("button", "btn btn-sm pv-stop", tr("chat.pvStop")); stopB.type = "button"; stopB.hidden = true;
488
474
  // An attachment already has its download link (the file itself) right above the card.
489
475
  const dl = att ? null : el("button", "btn btn-sm btn-ghost pv-dl", tr("chat.pvDownload"));
@@ -496,7 +482,7 @@ export function createThread(list, scroller, opts) {
496
482
  const optB = el("button", "chip-btn pv-opt"); optB.type = "button";
497
483
  const neverB = el("button", "chip-btn pv-never"); neverB.type = "button";
498
484
  menu.append(sum, optB, neverB);
499
- head.append(label, run, stopB, ...(toPanel ? [toPanel] : []), ...(dl ? [dl] : []), menu);
485
+ head.append(label, run, anywayB, stopB, ...(toPanel ? [toPanel] : []), ...(dl ? [dl] : []), menu);
500
486
  const note = el("div", "pv-note"); note.setAttribute("data-trust", "thread-control");
501
487
  // #1568: the banner, with its "I understand" (hidden until the next sign-in; the frame's border stays).
502
488
  const banner = el("div", "pv-banner"); banner.setAttribute("data-trust", "thread-control"); banner.hidden = true;
@@ -508,45 +494,64 @@ export function createThread(list, scroller, opts) {
508
494
  // Reading an attachment: one read per card at a time (a second click while it reads does nothing), and a read
509
495
  // whose card was stopped, replaced or left meanwhile is dropped (`reading` moved on, or the card left the page).
510
496
  let reading = 0, reads = 0;
497
+ // #2863: where "Preview anyway" runs it ("card" | "panel") and what the card says meanwhile; null: not asked.
498
+ // `tooBig`: over the 50 MB ceiling (listed, or read) — the card says so and offers only the file's download.
499
+ let large = null, largeNote = "", tooBig = !!att && typeof att.size === "number" && att.size > HTML_PREVIEW_MAX;
511
500
  const refresh = (reason) => {
512
501
  const a = P().availability();
513
502
  const going = state === "starting" || state === "running";
514
503
  const inPanel = !!panel && panel.shown() === key; // #1481: the panel shows this block; the card points there
515
- run.hidden = !a.ok || going || inPanel || !!reading;
516
- if (toPanel) toPanel.hidden = inPanel || !!reading;
504
+ if (going || reading) large = null;
505
+ if (reason == null && large) reason = largeNote;
506
+ if (reason == null && tooBig) reason = tr("chat.pvAttOver");
507
+ run.hidden = !a.ok || going || inPanel || !!reading || !!large || tooBig;
508
+ anywayB.hidden = !a.ok || !large;
509
+ if (toPanel) toPanel.hidden = inPanel || !!reading || tooBig;
517
510
  stopB.hidden = !going && !reading;
518
511
  banner.hidden = !going || P().bannerAcked();
519
512
  note.textContent = reason != null ? reason : reading ? tr("chat.pvLoading") : going ? (state === "starting" ? tr("chat.pvStarting") : "") : inPanel ? tr("chat.pvInPanel") : a.ok ? "" : a.reason;
520
513
  optB.textContent = P().optedIn() ? tr("chat.pvDisallow") : tr("chat.pvAllow");
521
514
  neverB.textContent = P().never() ? tr("chat.pvNeverUndo") : tr("chat.pvNever");
522
515
  };
523
- /** The HTML, then `then(code)`: at once for a fence or an attachment already read; otherwise read it first. */
524
- const withCode = async (then) => {
516
+ /** Over 8 MB: the card asks, and "Preview anyway" runs it where `target` ("card" | "panel") says. */
517
+ const askLarge = (target, reason) => { large = target; largeNote = reason; refresh(reason); };
518
+ /**
519
+ * The HTML, then `then(code)`: at once for a fence or an attachment already read; otherwise read it first — a large
520
+ * one only once `anyway` (its listed size asks before anything is fetched).
521
+ */
522
+ const withCode = async (then, target = "card", anyway = false) => {
525
523
  if (code != null) { then(code); return; }
526
524
  if (reading) return;
527
525
  const mine = reading = ++reads;
528
526
  refresh();
529
- const r = await loadHtmlAttachment(att);
527
+ const r = await loadHtmlAttachment(att, undefined, { anyway });
530
528
  if (reading !== mine || !ph.isConnected) return;
531
529
  reading = 0;
530
+ if (!r.ok && r.reason === "large") { askLarge(target, trf("chat.pvLarge", sizeLabel(r.size))); return; }
531
+ if (!r.ok && r.reason === "over") { tooBig = true; refresh(); return; }
532
532
  if (!r.ok) { refresh(attReason(r.reason)); return; }
533
533
  code = r.code;
534
534
  refresh();
535
535
  then(code);
536
536
  };
537
- const ui = { state: (name, reason) => { state = name === "starting" || name === "running" ? name : "idle"; refresh(reason || null); } };
537
+ const ui = { state: (name, reason) => {
538
+ state = name === "starting" || name === "running" ? name : "idle";
539
+ if (name === "large") { askLarge("card", reason || ""); return; }
540
+ large = null; refresh(reason || null);
541
+ } };
538
542
  // Nothing is read for Preview on a device that does not allow previews: the button is not even shown there.
539
543
  // A Preview click is newer than any Open in panel still reading (#1704): it claims the panel's turn too — and, its
540
544
  // own HTML read, starts only if no newer click claimed meanwhile and no preview was started since (the panel's own
541
545
  // Preview): a late read never undoes a newer choice. A close is neither, so it does not drop this click.
542
- run.onclick = () => {
546
+ const preview = (anyway) => {
543
547
  if (!P().availability().ok || reading) return; // this card already reading: that click stays the one
544
548
  const mine = panel ? (panel.claim(), panel.claims()) : 0, started = P().startCount();
545
549
  withCode((c) => {
546
550
  if ((panel && panel.claims() !== mine) || P().startCount() !== started) { refresh(); return; }
547
- P().start(key, holder, c, ui);
548
- });
551
+ if (anyway) P().start(key, holder, c, ui, { anyway: true }); else P().start(key, holder, c, ui);
552
+ }, "card", anyway);
549
553
  };
554
+ run.onclick = () => preview(false);
550
555
  stopB.onclick = () => { if (reading) { reading = 0; refresh(); } else P().stop(key, "stopped", ""); };
551
556
  // #1704: this click runs it in the panel, as Preview there would — a running preview moves (it stops here and
552
557
  // starts there), an idle one starts there. Whether it can run is the panel's to say: a device that has not allowed
@@ -554,15 +559,18 @@ export function createThread(list, scroller, opts) {
554
559
  // An attachment's HTML is read first. The click claims the panel at once, so a newer click (even one still reading)
555
560
  // wins: if the panel changed (another click, block, close) or any preview started meanwhile, this one is dropped —
556
561
  // a late read never undoes a newer click.
557
- if (toPanel) toPanel.onclick = () => {
562
+ const openInPanel = (anyway) => {
558
563
  if (reading) return; // this card already reading: that click stays the one
559
564
  if (P().running(key)) P().stop(key, "stopped", "");
560
565
  const at = panel.claim(), started = P().startCount();
561
566
  withCode((c) => {
562
567
  if (panel.version() !== at || P().startCount() !== started) { refresh(); return; }
563
- panel.open({ key, instance: x.instance, msgKey: msgKey(x), ...(att ? { att } : { n: source.n }), code: c, sender: x.sender, ts: x.ts }, { run: true });
564
- });
568
+ large = null; refresh();
569
+ panel.open({ key, instance: x.instance, msgKey: msgKey(x), ...(att ? { att } : { n: source.n }), code: c, sender: x.sender, ts: x.ts, ...(anyway ? { anyway: true } : {}) }, { run: true });
570
+ }, "panel", anyway);
565
571
  };
572
+ if (toPanel) toPanel.onclick = () => openInPanel(false);
573
+ anywayB.onclick = () => { const where = large; large = null; if (where === "panel" && toPanel) openInPanel(true); else preview(true); };
566
574
  if (dl) dl.onclick = () => opts.download(code);
567
575
  ack.onclick = () => P().ackBanner(); // every card and the panel hear it (onChange)
568
576
  optB.onclick = () => { menu.open = false; opts.setPreviewOptIn(!P().optedIn()); };
@@ -668,7 +676,6 @@ export function createThread(list, scroller, opts) {
668
676
  function render(next, renderOpts = {}) {
669
677
  const stick = pinned || nearBottom();
670
678
  msgs = next || [];
671
- armTrials();
672
679
  if (!msgs.length) {
673
680
  for (const v of nodes.values()) stopIn(v.node);
674
681
  nodes.clear();
@@ -693,11 +700,7 @@ export function createThread(list, scroller, opts) {
693
700
  }
694
701
  /** Put the messages from startIndex() on in the list (nodes reused when unchanged). Returns how many are new. */
695
702
  function draw(drawOpts) {
696
- const shown = msgs.slice(startIndex()).filter(message => {
697
- // Volatile host offer records are hidden off and while their on-only parser is loading.
698
- const hostOffer = message?.role === "status" && message.sender === "agend" && Object.hasOwn(message, "effectTrial") && message.effectTrial != null;
699
- return !hostOffer || canvasWorkerEnabled() && readCanvasTrialMessage(message) !== undefined;
700
- });
703
+ const shown = msgs.slice(startIndex());
701
704
  const keep = new Set(shown.map(msgKey));
702
705
  for (const [k, v] of nodes) if (!keep.has(k)) { stopIn(v.node); v.node.remove(); nodes.delete(k); }
703
706
  let prev = null, added = 0;
@@ -711,7 +714,7 @@ export function createThread(list, scroller, opts) {
711
714
  const stopped = have ? stopIn(have.node) : [];
712
715
  const node = msgNode(html, x, stopped);
713
716
  if (have) have.node.replaceWith(node); else added++;
714
- have = { html, node, x, trialButton: node.querySelector(".effect-trial-offer > button.effect-trial-start") }; nodes.set(key, have);
717
+ have = { html, node, x }; nodes.set(key, have);
715
718
  } else { have.x = x; timeLabel(have.node); }
716
719
  const want = prev ? prev.nextSibling : list.firstChild;
717
720
  // A node already in the list is being moved: its preview stops first. A new node (still in its template) has none.
@@ -811,13 +814,6 @@ export function createThread(list, scroller, opts) {
811
814
  else if (act === "replyButton") {
812
815
  const m = /^([0-9a-f]{32}):(\d{1,2})$/.exec(el.dataset.arg || "");
813
816
  if (m && opts.clickReplyButton) opts.clickReplyButton(m[1], Number(m[2]));
814
- } else if (act === "effectTrial") {
815
- // Only the current host-produced keyed card and a real local click can request admission. No token is read from DOM.
816
- const key = el.dataset.arg, row = msgs.find(x => msgKey(x) === key), owner = nodes.get(key), node = owner?.node, offer = readCanvasTrialMessage(row);
817
- if (PUBLIC_LINK() || e.defaultPrevented || e.isTrusted !== true || owner?.trialButton !== el || el.className !== "btn btn-sm effect-trial-start" || !node?.contains(el) || el.closest(".effect-trial-offer")?.getAttribute("data-trust") !== "thread-control"
818
- || !offer || trialNow() >= offer.expiresAt) return;
819
- if (trialStatus(row) === "ready" && opts.canTryEffect?.(row) === true) opts.startEffectTrial?.(row);
820
- else opts.fixEffectTrial?.(row, trialStatus(row));
821
817
  } else if (act === "showButtons") { const x = msgs.find(y => y.buttons && y.buttons.id === el.dataset.arg); if (x) reveal(msgKey(x)); }
822
818
  else if (act === "reply") { const x = msgs.find(y => msgKey(y) === el.dataset.arg); if (x) opts.startReply?.(x); }
823
819
  else if (act === "jumpQuote") void jumpTo(el.dataset.arg);
@@ -841,7 +837,7 @@ export function createThread(list, scroller, opts) {
841
837
  /** The thread goes (the chat left, or another instance opened): every preview in it stops first. */
842
838
  /**
843
839
  * #2053: kept for a later visit — the chat left as it always did (every preview stops: "leave"), but its drawn nodes
844
- * stay. Nothing of it runs meanwhile: no timer (day labels, polls, effect trials, filling in), no size observer; the
840
+ * stay. Nothing of it runs meanwhile: no timer (day labels, polls, filling in), no size observer; the
845
841
  * caller drops its subscriptions. Shown again: attach, then render — only what changed is drawn.
846
842
  */
847
843
  function park() {
@@ -850,7 +846,6 @@ export function createThread(list, scroller, opts) {
850
846
  if (P()) P().stopAll("leave");
851
847
  for (const node of list.querySelectorAll(".html-card[data-att], .text-card[data-att]")) { const f = cardDrop.get(node); if (f) f(); }
852
848
  if (ro) { ro.disconnect(); ro = null; }
853
- if (trialTimer !== null) clearTimeout(trialTimer); trialTimer = null;
854
849
  if (growFrame !== null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(growFrame);
855
850
  if (growTimer !== null) clearTimeout(growTimer);
856
851
  growTimer = growFrame = null;
@@ -868,12 +863,9 @@ export function createThread(list, scroller, opts) {
868
863
  if (ro) { ro.observe(list); ro.observe(sc); }
869
864
  }
870
865
  /** `own`: only this thread's previews stop (a thread let go while another chat is in view); else every one, as leaving. */
871
- const redrawFeature = () => { if (!disposed && !parked) render(msgs); };
872
- const featureOff = onCanvasWorkerChange(redrawFeature), metadataOff = onCanvasMetadataChange(redrawFeature);
873
866
  function dispose(own = false) {
874
- disposed = true; featureOff(); metadataOff();
867
+ disposed = true;
875
868
  if (!parked) watchReader(scroller, false);
876
- if (trialTimer !== null) clearTimeout(trialTimer); trialTimer = null;
877
869
  if (ro) ro.disconnect();
878
870
  if (P()) { if (own) P().stopIn(list); else P().stopAll("leave"); }
879
871
  nodes.clear();
@@ -27,7 +27,7 @@
27
27
  // @property {(ctx, item, active, ms, env) => void} [drawFurniture] zones: one furniture item (active: activeWhen holds)
28
28
  // @property {(ctx, L, env) => void} [drawForeground] zones: a pack's foreground over the whole 1280×720 scene, above
29
29
  // the people, furniture and items in the air, below the crowd counts, name tags and bubbles
30
- // @property {(ctx, x, y, look, env) => void} [drawTag] zones: a name tag under feet at (x, y)
30
+ // @property {(ctx, x, y, look, env, compact?) => void} [drawTag] zones: a name tag under feet at (x, y) (compact: #2860)
31
31
  // @property {(ctx, area, n, env) => void} [drawCrowd] zones: an area's "+n" (who found no spot in it)
32
32
  // @property {(ctx, desk, look, ms, env) => void} drawCharacter the occupant in its pose at time `ms` (0: still); in
33
33
  // zones `desk` is the character's box at its current position (its feet at desk.seat); walking, `look.heading` is
@@ -293,21 +293,44 @@ function drawBubble(ctx, x, y, text, env, lift = 0) {
293
293
  /** A zones name tag's widest text (logical units): longer names are cut with an ellipsis. */
294
294
  export const TAG_TEXT_W = 84;
295
295
  const TAG_FONT = "600 10px system-ui, sans-serif";
296
+ /**
297
+ * #2860: the compact tag a crowded area keeps on (it used to show tags on hover only, #2513): a smaller font, a smaller
298
+ * plate, a shorter cut, the same level badge.
299
+ */
300
+ const COMPACT = { font: "500 9px system-ui, sans-serif", textW: 48, pad: 6, top: 2, h: 11 };
301
+ const NORMAL = { font: TAG_FONT, textW: TAG_TEXT_W, pad: 10, top: 3, h: 14 };
296
302
  /** The box a name tag under feet at (x, y) takes (what drawTag draws; bubbles keep clear of it, #2513). */
297
- export function tagBox(ctx, x, y, label) {
303
+ export function tagBox(ctx, x, y, label, compact = false) {
304
+ const k = compact ? COMPACT : NORMAL;
305
+ ctx.save();
306
+ ctx.font = k.font;
307
+ const t = fitText(ctx, label, k.textW), w = Math.ceil((ctx.measureText ? ctx.measureText(t).width : t.length * 6) + k.pad);
308
+ ctx.restore();
309
+ return { x: Math.round(x - w / 2), y: y + k.top, w, h: k.h, text: t };
310
+ }
311
+ /** #2860: what a tag covers with its level badge: on a full tag's top-left corner (half outside); left of a compact one. */
312
+ export function tagFootprint(ctx, box, badge, compact = false) {
313
+ if (!badge) return box;
314
+ if (compact) { const w = badgeWidth(ctx, badge) + 1; return { x: box.x - w, y: box.y, w: box.w + w, h: box.h }; }
315
+ return { x: box.x - 10, y: box.y - 7, w: box.w + 10, h: box.h + 7 };
316
+ }
317
+ function badgeWidth(ctx, badge) {
318
+ if (badge.mark) return 8;
298
319
  ctx.save();
299
- ctx.font = TAG_FONT;
300
- const t = fitText(ctx, label, TAG_TEXT_W), w = Math.ceil((ctx.measureText ? ctx.measureText(t).width : t.length * 6) + 10);
320
+ ctx.font = BADGE_FONT;
321
+ const w = Math.max(12, Math.ceil((ctx.measureText ? ctx.measureText(badge.text).width : badge.text.length * 5) + 6));
301
322
  ctx.restore();
302
- return { x: Math.round(x - w / 2), y: y + 3, w, h: 14, text: t };
323
+ return w;
303
324
  }
304
325
  /**
305
326
  * #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
327
  * badge — it takes none of the tag's width. `badge`: { text, tier } (fleet-office-levels.js badgeOf), or { mark } for a
307
328
  * pinned title that hides the number: a small diamond.
308
329
  */
309
- export function drawBadge(ctx, x, y, badge, pal) {
330
+ export function drawBadge(ctx, x, y, badge, pal, inline = false) {
310
331
  if (!badge) return;
332
+ // #2860 inline (a compact tag): (x, y) is the tag's left edge and middle; the badge sits just left of it, whole.
333
+ if (inline) { const w = badgeWidth(ctx, badge); x = x - 1 - w + (badge.mark ? w / 2 : w / 2 - 3); y = y + (badge.mark ? 0 : 1); }
311
334
  const c = tierColours(badge.tier, pal);
312
335
  ctx.save();
313
336
  ctx.globalAlpha = 1;
@@ -331,18 +354,18 @@ export function drawBadge(ctx, x, y, badge, pal) {
331
354
  }
332
355
  const BADGE_FONT = "700 8px system-ui, sans-serif";
333
356
 
334
- /** Zones: a name tag under someone's feet at (x, y). */
335
- function drawTag(ctx, x, y, look, env) {
357
+ /** Zones: a name tag under someone's feet at (x, y); `compact` (#2860): the smaller one a crowded area keeps on. */
358
+ function drawTag(ctx, x, y, look, env, compact = false) {
336
359
  const { pal } = env;
360
+ const b = tagBox(ctx, x, y, look.label, compact);
337
361
  ctx.save();
338
362
  if (look.dim) ctx.globalAlpha = 0.35;
339
- ctx.font = TAG_FONT;
340
- const t = fitText(ctx, look.label, TAG_TEXT_W), w = Math.ceil((ctx.measureText ? ctx.measureText(t).width : t.length * 6) + 10);
341
- ctx.fillStyle = pal.surface; ctx.fillRect(Math.round(x - w / 2), y + 3, w, 14);
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);
343
- ctx.fillStyle = pal.text; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(t, x, y + 10.5);
363
+ ctx.font = compact ? COMPACT.font : TAG_FONT;
364
+ ctx.fillStyle = pal.surface; ctx.fillRect(b.x, b.y, b.w, b.h);
365
+ ctx.strokeStyle = look.match ? pal.focus : pal.border; ctx.lineWidth = look.match ? 2 : 1; ctx.strokeRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1);
366
+ ctx.fillStyle = pal.text; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(b.text, x, b.y + b.h / 2 + 0.5);
344
367
  ctx.restore();
345
- if (look.badge) drawBadge(ctx, Math.round(x - w / 2), y + 3, look.badge, pal);
368
+ if (look.badge) compact ? drawBadge(ctx, b.x, b.y + b.h / 2, look.badge, pal, true) : drawBadge(ctx, b.x, b.y, look.badge, pal);
346
369
  }
347
370
 
348
371
  /**
@@ -33,8 +33,8 @@
33
33
  // any part — an office names it as its style; one this page cannot load is drawn as Desks, and says so.
34
34
  // - Crowds (#2513): an area with more people than seats packs the rest into a tighter grid, then counts who is left
35
35
  // ("+N", in its corner, never over its name); there idle and asleep people say nothing, at most a few speak (calls
36
- // first, then work), no bubble covers a name tag, and the tags show on hover or focus only. "Hide paused and stopped"
37
- // (per browser) leaves the sleepers out.
36
+ // first, then work), no bubble covers a name tag, and the tags are compact (#2860; one that would touch another shows
37
+ // on hover or focus only). "Hide paused and stopped" (per browser) leaves the sleepers out.
38
38
  // - Share… (#2636; an admin, on the dashboard reached directly or over the public link): a link anyone can open to watch an office live, read-only,
39
39
  // until it ends (office-share-dialog.js; the page it opens is office-share-page.js).
40
40
  // - Edit layout (an admin, a stored office on a zones skin): fleet-office-layout.js, saved as the office's overrides.
@@ -64,7 +64,7 @@ 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
- import { BUILT_IN_SKINS, CHAR_DEPTH, DEFAULT_PAL, bubbleBox, loadCozySkin, loadPackSkin, modeOf, skinFor, tagBox } from "./fleet-office-skin.js";
67
+ import { BUILT_IN_SKINS, CHAR_DEPTH, DEFAULT_PAL, bubbleBox, loadCozySkin, loadPackSkin, modeOf, skinFor, tagBox, tagFootprint } 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
70
  import { badgeOf, fixedOf } from "./fleet-office-levels.js";
@@ -131,6 +131,8 @@ const deviceRatio = () => Math.max(0.5, Math.min(4, Number(globalThis.devicePixe
131
131
  const SIZE0 = { w: 960, h: 600 };
132
132
  /** #2554: All offices' Fit keeps this many CSS px clear round the tiles (the zoom controls' column and a margin). */
133
133
  const FIT_PAD = 60;
134
+ /** #2860: how far below its place a crowded area's compact tag drops when a neighbour's is in the way (a tag's height + 1). */
135
+ const COMPACT_ROW = 12;
134
136
  /** #2554: in All offices a tile's name tags show (always) from this many CSS px a unit up; below it on hover only. */
135
137
  export const TAG_MIN_K = 0.75;
136
138
  const PAN_KEYS = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
@@ -418,13 +420,19 @@ export function drawPart(ctx, p, a, ox, oy, ms, still, hot, env, tagsOff = false
418
420
  if (sk.drawForeground) sk.drawForeground(ctx, p.L, env);
419
421
  // Who found no spot in an area: a count in its corner (never over its name).
420
422
  for (const [ar, n] of p.L.crowd || []) if (p.L.areas[ar] && sk.drawCrowd) sk.drawCrowd(ctx, p.L.areas[ar], n, env);
421
- // In an area over capacity the name tags show on hover or focus only (#2513: a crowd's tags covered each other).
423
+ // In an area over capacity (#2513: a crowd's full tags covered each other) everyone keeps a compact tag (#2860:
424
+ // in Cozy, the default, most of a fleet is in a crowd, and hover-only hid names and levels); one that would touch
425
+ // a tag already drawn, its badge included, is left to hover or focus. The full tags go first.
422
426
  const over = p.L.over || new Set();
427
+ const crowded = (q) => over.has(q.d.area) && q.d.key !== hot && !q.look.match;
423
428
  const tagged = (q) => !((tagsOff || over.has(q.d.area)) && q.d.key !== hot && !q.look.match);
429
+ const taken = [];
430
+ const touches = (b) => taken.some((o) => b.x < o.x + o.w && o.x < b.x + b.w && b.y < o.y + o.h && o.y < b.y + b.h);
424
431
  for (const q of people) {
425
432
  if (live.hidden.has(q.look.name) || !tagged(q)) continue;
426
433
  sk.drawTag(ctx, q.at.x, q.at.y, q.look, env);
427
- tags.push(tagBox(ctx, q.at.x, q.at.y, q.look.label));
434
+ const box = tagBox(ctx, q.at.x, q.at.y, q.look.label);
435
+ tags.push(box); taken.push(tagFootprint(ctx, box, q.look.badge));
428
436
  }
429
437
  // A furniture item's own name (#2574: an NPC drawn as furniture), tagged like a person; none on a tile too small to read.
430
438
  // #2698: an item hiding its people carries their tags too, in one row at its tag point (its own first; each keeps
@@ -434,7 +442,10 @@ export function drawPart(ctx, p, a, ox, oy, ms, still, hot, env, tagsOff = false
434
442
  for (const f of furniture) {
435
443
  const on = people.filter((q) => live.hidden.get(q.look.name) === f.id);
436
444
  if (!on.length) {
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)); }
445
+ if (!tagsOff && f.label) {
446
+ const nl = npcLook(p, f), box = tagBox(ctx, f.x, f.y, nl.label);
447
+ sk.drawTag(ctx, f.x, f.y, nl, env); tags.push(box); taken.push(tagFootprint(ctx, box, nl.badge));
448
+ }
438
449
  continue;
439
450
  }
440
451
  const order = new Map((f.activeFor || []).map((n, i) => [n, i]));
@@ -447,11 +458,24 @@ export function drawPart(ctx, p, a, ox, oy, ms, still, hot, env, tagsOff = false
447
458
  if (q ? !tagged(q) : tagsOff) continue;
448
459
  sk.drawTag(ctx, r.x, f.y, r.look, env);
449
460
  const box = tagBox(ctx, r.x, f.y, r.look.label);
450
- tags.push(box);
461
+ tags.push(box); taken.push(tagFootprint(ctx, box, r.look.badge));
451
462
  if (q) held.push({ name: q.look.name, id: f.id, box });
452
463
  }
453
464
  }
454
465
  p.heldTags = held;
466
+ // The crowd's compact tags go last, clear of every full tag above: people's, an item's own name and the row it
467
+ // carries (#2862 review). A crowd's grid is tighter than a compact tag is wide (OVERFLOW.STEPS down to 32): a
468
+ // neighbour's tag goes a row lower (COMPACT_ROW) rather than out, so a row of people reads as two staggered rows.
469
+ if (!tagsOff) for (const q of people) {
470
+ if (live.hidden.has(q.look.name) || !crowded(q)) continue;
471
+ for (const dy of [0, COMPACT_ROW]) {
472
+ const box = tagBox(ctx, q.at.x, q.at.y + dy, q.look.label, true), foot = tagFootprint(ctx, box, q.look.badge, true);
473
+ if (touches(foot)) continue;
474
+ sk.drawTag(ctx, q.at.x, q.at.y + dy, q.look, env, true);
475
+ tags.push(box); taken.push(foot);
476
+ break;
477
+ }
478
+ }
455
479
  } else {
456
480
  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 }); }
457
481
  blit(p.layers.front);
@@ -3,24 +3,38 @@
3
3
  // card. What runs is then handed to the preview frame exactly like a block's (AgendPreview.start): the file URL itself
4
4
  // is never opened or framed on the dashboard's origin.
5
5
 
6
- /** The same cap as a block's (preview.js LIMITS.maxBytes): UTF-8 bytes of the HTML. */
7
- export const HTML_ATTACHMENT_MAX = 1024 * 1024;
6
+ /**
7
+ * #2863: the sizes a preview takes (UTF-8 bytes of the HTML), the same as a block's (preview.js LIMITS): up to ASK it
8
+ * runs at once; above it the person is asked first ("Preview anyway?"); above MAX there is no preview, only the
9
+ * download. MAX is the frame's own ceiling (web-preview.ts PREVIEW_HTML_MAX_BYTES).
10
+ */
11
+ export const HTML_PREVIEW_ASK = 8 * 1024 * 1024;
12
+ export const HTML_PREVIEW_MAX = 50 * 1024 * 1024;
8
13
 
9
14
  /**
10
- * Read an attachment's HTML: { ok: true, code } or { ok: false, reason } with reason "over" (the file is over the
11
- * cap: its listed size is checked before anything is fetched, the bytes again after), "gone" (the fleet no longer
12
- * serves that id — the file changed, or the fleet restarted), or "failed".
15
+ * Read an attachment's HTML: { ok: true, code } or { ok: false, reason } with reason "large" (above `ask` and not
16
+ * `anyway`: nothing is fetched when its listed size says so; `size` is what it is), "over" (above `max`: its listed size
17
+ * is checked before anything is fetched, the bytes again after), "gone" (the fleet no longer serves that id — the file
18
+ * changed, or the fleet restarted), or "failed". The text card reads with its own, single cap (ask = max).
13
19
  */
14
- export async function loadHtmlAttachment(att, fetchImpl = globalThis.fetch) {
20
+ export async function loadHtmlAttachment(att, fetchImpl = globalThis.fetch, { ask = HTML_PREVIEW_ASK, max = HTML_PREVIEW_MAX, anyway = false } = {}) {
15
21
  if (!att || typeof att.id !== "string" || !/^[0-9a-f]{32}$/.test(att.id)) return { ok: false, reason: "failed" };
16
- if (typeof att.size === "number" && att.size > HTML_ATTACHMENT_MAX) return { ok: false, reason: "over" };
22
+ if (typeof att.size === "number" && att.size > max) return { ok: false, reason: "over" };
23
+ if (typeof att.size === "number" && att.size > ask && !anyway) return { ok: false, reason: "large", size: att.size };
17
24
  let res;
18
25
  try { res = await fetchImpl(`/ui/file/${att.id}`, { credentials: "same-origin", cache: "no-store" }); }
19
26
  catch { return { ok: false, reason: "failed" }; }
20
27
  if (!res || !res.ok) return { ok: false, reason: res && res.status === 404 ? "gone" : "failed" };
21
28
  let bytes;
22
29
  try { bytes = await res.arrayBuffer(); } catch { return { ok: false, reason: "failed" }; }
23
- if (bytes.byteLength > HTML_ATTACHMENT_MAX) return { ok: false, reason: "over" };
30
+ if (bytes.byteLength > max) return { ok: false, reason: "over" };
31
+ if (bytes.byteLength > ask && !anyway) return { ok: false, reason: "large", size: bytes.byteLength };
24
32
  // UTF-8, as a block's text is; a BOM is dropped.
25
33
  return { ok: true, code: new TextDecoder("utf-8").decode(bytes) };
26
34
  }
35
+
36
+ /** #2863: a size as the person reads it: "12.3 MB" (1 MB = 1024 × 1024 bytes, as the caps are). */
37
+ export function sizeLabel(bytes) {
38
+ const mb = bytes / (1024 * 1024);
39
+ return `${mb >= 10 ? Math.round(mb) : Math.round(mb * 10) / 10} MB`;
40
+ }