@songsid/agend 2.3.0-beta.7 → 2.3.0-beta.9

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 (88) hide show
  1. package/dist/agent-cli-instructions.md +2 -1
  2. package/dist/agent-cli.js +3 -0
  3. package/dist/agent-cli.js.map +1 -1
  4. package/dist/agent-whats-new.js +2 -0
  5. package/dist/agent-whats-new.js.map +1 -1
  6. package/dist/channel/mcp-tools.js +3 -3
  7. package/dist/channel/mcp-tools.js.map +1 -1
  8. package/dist/daemon.d.ts +2 -0
  9. package/dist/daemon.js +29 -9
  10. package/dist/daemon.js.map +1 -1
  11. package/dist/decision-list.d.ts +25 -0
  12. package/dist/decision-list.js +30 -0
  13. package/dist/decision-list.js.map +1 -0
  14. package/dist/fleet-manager.d.ts +9 -2
  15. package/dist/fleet-manager.js +44 -12
  16. package/dist/fleet-manager.js.map +1 -1
  17. package/dist/fleet-traffic.d.ts +36 -0
  18. package/dist/fleet-traffic.js +100 -0
  19. package/dist/fleet-traffic.js.map +1 -1
  20. package/dist/general-knowledge/skills/web-appearance/SKILL.md +17 -10
  21. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +32 -13
  22. package/dist/instance-config-impact.js +1 -0
  23. package/dist/instance-config-impact.js.map +1 -1
  24. package/dist/outbound-handlers.js +4 -1
  25. package/dist/outbound-handlers.js.map +1 -1
  26. package/dist/outbound-schemas.d.ts +6 -4
  27. package/dist/outbound-schemas.js +7 -3
  28. package/dist/outbound-schemas.js.map +1 -1
  29. package/dist/settings-api.js +4 -0
  30. package/dist/settings-api.js.map +1 -1
  31. package/dist/settings-change.js +4 -0
  32. package/dist/settings-change.js.map +1 -1
  33. package/dist/settings-effect.js +2 -0
  34. package/dist/settings-effect.js.map +1 -1
  35. package/dist/tool-permissions.js +9 -42
  36. package/dist/tool-permissions.js.map +1 -1
  37. package/dist/ui/app-effects.js +9 -5
  38. package/dist/ui/chat-ambient.js +8 -7
  39. package/dist/ui/chat-effect-posters.js +3 -2
  40. package/dist/ui/chat-effects.js +22 -15
  41. package/dist/ui/chat-render.js +5 -2
  42. package/dist/ui/chat-strings.js +6 -4
  43. package/dist/ui/chat-thread.js +134 -17
  44. package/dist/ui/effect-trial-controller.js +13 -8
  45. package/dist/ui/fleet-canvas.js +54 -18
  46. package/dist/ui/fleet-org.js +3 -3
  47. package/dist/ui/fleet-traffic.js +156 -37
  48. package/dist/ui/fleet-usage.js +401 -0
  49. package/dist/ui/panel-chat.js +101 -47
  50. package/dist/ui/panel-fleet.js +6 -5
  51. package/dist/ui/panel-settings.js +6 -1
  52. package/dist/ui/settings-owner-effects.js +1 -1
  53. package/dist/ui/settings-strings.js +2 -0
  54. package/dist/ui/shared/agend-auth.js +1 -1
  55. package/dist/ui/shared/app-route.js +1 -1
  56. package/dist/ui/shared/app-stream.js +3 -2
  57. package/dist/ui/shared/app.css +89 -9
  58. package/dist/ui/shared/effect-app-runtime.js +3 -3
  59. package/dist/ui/shared/effect-chat-runtime.js +1 -1
  60. package/dist/ui/shared/effect-policy.js +5 -6
  61. package/dist/ui/shared/effect-profiler.js +31 -9
  62. package/dist/ui/shared/effect-registry.js +23 -31
  63. package/dist/ui/shared/effect-stock-snapshot.js +9 -0
  64. package/dist/ui/shared/effect-stock-trust.js +37 -6
  65. package/dist/ui/shared/effect-trial-source.js +1 -1
  66. package/dist/ui/shared/effect-trial-stage.js +27 -21
  67. package/dist/ui/shared/effect-trial.css +1 -1
  68. package/dist/ui/shared/fx-motion.js +12 -3
  69. package/dist/ui/shared/tokens.css +9 -3
  70. package/dist/ui/shared/usage-dialog.js +8 -0
  71. package/dist/ui/shared/view-strings.js +4 -0
  72. package/dist/ui/web-terminal/terminal.css +4 -4
  73. package/dist/usage-report.d.ts +12 -1
  74. package/dist/usage-report.js +39 -8
  75. package/dist/usage-report.js.map +1 -1
  76. package/dist/web-ambient.js +9 -2
  77. package/dist/web-ambient.js.map +1 -1
  78. package/dist/web-api.d.ts +2 -0
  79. package/dist/web-api.js +8 -3
  80. package/dist/web-api.js.map +1 -1
  81. package/dist/web-settings-patch.d.ts +3 -1
  82. package/dist/web-settings-patch.js +4 -1
  83. package/dist/web-settings-patch.js.map +1 -1
  84. package/dist/web-shell-routes.d.ts +1 -1
  85. package/dist/web-shell-routes.js +1 -1
  86. package/dist/web-shell-routes.js.map +1 -1
  87. package/dist/workflow-templates/default.md +1 -1
  88. package/package.json +1 -1
@@ -21,6 +21,7 @@ import { confirmedWrite } from "./settings-confirm.js";
21
21
  import { OrgCanvas } from "./fleet-canvas.js";
22
22
  import { labelOf } from "/assets/instance-nav.js";
23
23
  import { CacheReport } from "./fleet-cache.js";
24
+ import { UsageTab } from "./fleet-usage.js";
24
25
  import { BackendSelect, ModelSelect } from "./settings-dialogs.js";
25
26
 
26
27
  /** #2015: one of the largest processes as text: "python3 18.1 GB (dev-a)". */
@@ -36,7 +37,7 @@ register("fleet", {
36
37
  hostMemory_elevated: "Host memory pressure: elevated — new agents start one at a time, at least 5 s apart",
37
38
  hostMemory_critical: "Host memory pressure: critical — new agents wait for memory to recover",
38
39
  hostMemory_top: "Largest: {0}", hostMemory_ownerFleet: "the fleet", hostMemory_ownerTmux: "tmux", hostMemory_ownerOther: "not an instance",
39
- title: "Fleet", tasks: "Tasks", schedules: "Schedules", teams: "Teams", org: "Org chart", cache: "Cache", config: "Config",
40
+ title: "Fleet", tasks: "Tasks", schedules: "Schedules", teams: "Teams", org: "Org chart", cache: "Cache", usage: "Usage", config: "Config",
40
41
  docs: "Docs", docsNewTab: "Docs (opens in a new tab)",
41
42
  newTask: "New task", newSchedule: "New schedule", newTeam: "New team",
42
43
  noTasks: "No tasks yet", noTasksHint: "Create one to coordinate fleet work.", noSchedules: "No schedules yet", noSchedulesHint: "Automate recurring work with cron.",
@@ -68,7 +69,7 @@ register("fleet", {
68
69
  hostMemory_elevated: "主機記憶體壓力偏高:新的 agent 一次啟動一個,至少間隔 5 秒",
69
70
  hostMemory_critical: "主機記憶體壓力嚴重:新的 agent 會等記憶體恢復再啟動",
70
71
  hostMemory_top: "最大:{0}", hostMemory_ownerFleet: "fleet 本身", hostMemory_ownerTmux: "tmux", hostMemory_ownerOther: "不屬於任何 instance",
71
- title: "Fleet", tasks: "Tasks", schedules: "排程", teams: "Teams", org: "組織圖", cache: "快取", config: "設定",
72
+ title: "Fleet", tasks: "Tasks", schedules: "排程", teams: "Teams", org: "組織圖", cache: "快取", usage: "用量", config: "設定",
72
73
  docs: "文件", docsNewTab: "文件(在新分頁開啟)",
73
74
  newTask: "新增 task", newSchedule: "新增排程", newTeam: "新增 team",
74
75
  noTasks: "還沒有 task", noTasksHint: "建立一個來協調 fleet 的工作。", noSchedules: "還沒有排程", noSchedulesHint: "用 cron 自動執行例行工作。",
@@ -102,7 +103,7 @@ const DOCS_SITE = "https://songsid.github.io/AgEnD";
102
103
  export function docsUrl(language = lang()) {
103
104
  return `${DOCS_SITE}${language === "zh-TW" ? "/zh-tw" : ""}/docs/web-guide/`;
104
105
  }
105
- const ICONS = { tasks: "tasks", schedules: "clock", teams: "team", org: "org", cache: "chart", config: "sliders" };
106
+ const ICONS = { tasks: "tasks", schedules: "clock", teams: "team", org: "org", cache: "chart", usage: "chart", config: "sliders" };
106
107
  const BACKENDS = ["claude-code", "codex", "opencode", "kiro-cli", "antigravity", "grok", "muse"];
107
108
 
108
109
  async function api(method, path, body, lease) {
@@ -141,7 +142,7 @@ export function FleetPanel({ route, navKey }) {
141
142
  const lease = useLease(navKey);
142
143
  const tab = route.tab;
143
144
  useEffect(() => { setTitle(`${t("fleet.title")} · ${t(`fleet.${tab}`)}`); }, [tab, navKey]);
144
- const Body = { tasks: Tasks, schedules: Schedules, teams: Teams, org: Org, cache: Cache, config: Config }[tab] || Tasks;
145
+ const Body = { tasks: Tasks, schedules: Schedules, teams: Teams, org: Org, cache: Cache, usage: UsageTab, config: Config }[tab] || Tasks;
145
146
  // #1685: host memory pressure slowing or holding new agents — shown here, where macOS's routine "elevated" is not a chat notice.
146
147
  const { hostMemory, hostMemoryTop } = useStore(appStore);
147
148
  // #2015: and which processes hold the most, and whose — text only (never chat, #1712).
@@ -151,7 +152,7 @@ export function FleetPanel({ route, navKey }) {
151
152
  <${PanelHeader} title=${t("fleet.title")} sub=${sub}><${DocsLink} /><${HeaderTools} navKey=${navKey} /></${PanelHeader}>
152
153
  <nav data-trust="panel-navigation" class="seg" aria-label=${t("fleet.title")}>${FLEET_TABS.map(k => html`<a key=${k} href=${fleetPath(k)} class=${`seg-item${k === tab ? " active" : ""}`}
153
154
  aria-current=${k === tab ? "page" : undefined}><${Icon} name=${ICONS[k]} size=${16} /><span>${t(`fleet.${k}`)}</span></a>`)}</nav>
154
- <div data-trust="panel-content" class="panel-body"><div class=${`col${tab === "org" || tab === "cache" ? " col-wide" : ""}`}><${Body} lease=${lease} /></div></div>
155
+ <div data-trust="panel-content" class="panel-body"><div class=${`col${tab === "org" || tab === "cache" || tab === "usage" ? " col-wide" : ""}`}><${Body} lease=${lease} /></div></div>
155
156
  </div>`;
156
157
  }
157
158
 
@@ -779,13 +779,14 @@ function WebSection({ ctx, staged }) {
779
779
  const original = useMemo(() => ({
780
780
  echo: web.echo_to_channel ?? true,
781
781
  log: web.log_tab === true, // #1218: off by default
782
+ usagePub: web.usage_report_on_public_link === true, // #2122: off by default
782
783
  pub: { allow_public: (web.public_link && web.public_link.allow_public) ?? true, ttl_minutes: (web.public_link && web.public_link.ttl_minutes) ?? 120, protocol: (web.public_link && web.public_link.protocol) ?? "http2" },
783
784
  session: { absolute_hours: local.absolute_hours ?? null, idle_hours: local.idle_hours ?? null },
784
785
  terminal: { enabled: term.enabled ?? null, ttl_minutes: term.ttl_minutes ?? null },
785
786
  }), []);
786
787
  const text = (v) => (v === null || v === undefined ? "" : String(v));
787
788
  const [f, setF] = useState(() => ({
788
- echo: original.echo, log: original.log, pub: original.pub.allow_public, ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol,
789
+ echo: original.echo, log: original.log, usagePub: original.usagePub, pub: original.pub.allow_public, ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol,
789
790
  abs: text(original.session.absolute_hours), idle: text(original.session.idle_hours),
790
791
  termOn: original.terminal.enabled !== false, termTtl: text(original.terminal.ttl_minutes),
791
792
  }));
@@ -814,6 +815,7 @@ function WebSection({ ctx, staged }) {
814
815
  const body = {
815
816
  ...(f.echo !== original.echo ? { echo_to_channel: f.echo } : {}),
816
817
  ...(f.log !== original.log ? { log_tab: f.log } : {}),
818
+ ...(f.usagePub !== original.usagePub ? { usage_report_on_public_link: f.usagePub } : {}),
817
819
  ...(Object.keys(publicPatch).length ? { public_link: publicPatch } : {}),
818
820
  ...(Object.keys(sessionPatch).length ? { session: { local: sessionPatch } } : {}),
819
821
  ...(Object.keys(terminalPatch).length ? { web_terminal: terminalPatch } : {}),
@@ -821,6 +823,7 @@ function WebSection({ ctx, staged }) {
821
823
  const fields = [
822
824
  ...(body.echo_to_channel !== undefined ? ["web.echo_to_channel"] : []),
823
825
  ...(body.log_tab !== undefined ? ["web.log_tab"] : []),
826
+ ...(body.usage_report_on_public_link !== undefined ? ["web.usage_report_on_public_link"] : []),
824
827
  ...Object.keys(publicPatch).map((k) => `web.public_link.${k}`),
825
828
  ...Object.keys(sessionPatch).map((k) => `web.session.local.${k}`),
826
829
  ...Object.keys(terminalPatch).map((k) => `web_terminal.${k}`),
@@ -862,6 +865,8 @@ function WebSection({ ctx, staged }) {
862
865
  <p class="note">${tn("webEchoHint")}</p>
863
866
  <label class="check"><input id="webLogTab" type="checkbox" checked=${f.log} onChange=${(e) => set("log")(e.target.checked)} /><span>${tn("webLogTab")}</span><${Impact} schema=${schema} field="web.log_tab" /></label>
864
867
  <p class="note">${tn("webLogTabHint")}</p>
868
+ <label class="check"><input id="webUsagePublic" type="checkbox" checked=${f.usagePub} onChange=${(e) => set("usagePub")(e.target.checked)} /><span>${tn("webUsagePublic")}</span><${Impact} schema=${schema} field="web.usage_report_on_public_link" /></label>
869
+ <p class="note">${tn("webUsagePublicHint")}</p>
865
870
  <label class="check"><input id="publicWebLink" type="checkbox" checked=${f.pub} onChange=${(e) => set("pub")(e.target.checked)} /><span>${tn("publicLink")}</span><${Impact} schema=${schema} field="web.public_link.allow_public" /></label>
866
871
  <p class="note">${tn("publicLinkHint")}</p>
867
872
  <div class="grid2">
@@ -39,7 +39,7 @@ export function OwnerEffects({ catalog, busy = false }) {
39
39
  useEffect(() => { life.current.alive = true; const stop = watchOwnerEffects(); return () => { life.current.alive = false; life.current.seq++; stop(); }; }, []);
40
40
  useEffect(() => {
41
41
  let live = true; const entry = getAppEffects();
42
- const pull = () => { if (!live) return; const gates = entry?.gates(); setRuntime({ entry, snapshot: entry?.api?.ownerSnapshot?.(), cursor: entry?.cursorSnapshot?.(), available: !!entry && !entry.dead && gates.enabled && gates.show && gates.transportReady && !gates.safe && !gates.publicLink && !gates.reducedMotion && !gates.saveData }); };
42
+ const pull = () => { if (!live) return; const gates = entry?.gates(); setRuntime({ entry, snapshot: entry?.api?.ownerSnapshot?.(), cursor: entry?.cursorSnapshot?.(), available: !!entry && !entry.dead && gates.enabled && gates.show && gates.transportReady && !gates.safe && !gates.publicLink && !gates.reducedMotion }); };
43
43
  const off = entry?.subscribe(pull), end = entry?.onEnd(pull); pull();
44
44
  return () => { live = false; off?.(); end?.(); };
45
45
  }, []);
@@ -15,6 +15,7 @@ register("settings", {
15
15
  publicProtocol: "Tunnel protocol",
16
16
  webEcho: "Web echo",
17
17
  webEchoHint: "Copies web messages to the bound Telegram or Discord topic. ClassicBot rooms follow it unless a room sets its own.",
18
+ webUsagePublic: "Usage report over the public link", webUsagePublicHint: "Fleet → Usage (up to 400 days of each agent's tokens, requests, messages and API-equivalent amounts) is offered over the temporary public link too. Off by default; guests never see it. Applies at once.",
18
19
  webLogTab: "Log tab: each agent's steps", webLogTabHint: "Shows each instance's prompts, replies, tool calls and results, and fleet events in its Log tab, read from its own transcript only while the tab is open. Secret values are masked. Also over the public link. Turning it on waits for a fleet admin's confirmation.",
19
20
  general: "General Settings",
20
21
  advanced: "Advanced",
@@ -562,6 +563,7 @@ register("settings", {
562
563
  publicProtocol: "Tunnel 協定",
563
564
  webEcho: "Web 同步",
564
565
  webEchoHint: "將網頁訊息複製到綁定的 Telegram 或 Discord 主題。ClassicBot 聊天室也照這個設定,除非該聊天室另外設定。",
566
+ webUsagePublic: "透過公開連結提供用量報告", webUsagePublicHint: "Fleet → 用量(最多 400 天、每個 agent 的 token、請求、訊息與依牌價換算的 API 等值)也會透過臨時公開連結提供。預設關閉;訪客一律看不到。變更立即生效。",
565
567
  webLogTab: "紀錄分頁:每個 agent 的步驟", webLogTabHint: "在每個 instance 的「紀錄」分頁顯示提示、回覆、工具呼叫與結果,以及 fleet 事件;只在分頁開著時從它自己的對話紀錄讀取。機密值會遮蔽。公開連結也看得到。開啟需 fleet admin 確認。",
566
568
  general: "一般設定",
567
569
  advanced: "進階",
@@ -67,7 +67,7 @@
67
67
  bar.setAttribute("role", "alert");
68
68
  bar.setAttribute("data-trust", "session-ended");
69
69
  bar.className = "agend-session-ended";
70
- bar.style.cssText = "position:fixed;top:0;left:0;right:0;z-index:2147483647;padding:10px 16px;background:#f85149;color:#fff;font:14px system-ui,sans-serif;text-align:center";
70
+ bar.style.cssText = "position:fixed;top:0;left:0;right:0;z-index:2147483647;padding:10px 16px;background:#f85149;color:#fff;font:14px system-ui,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\",\"Segoe UI Symbol\",\"Noto Color Emoji\",emoji;text-align:center";
71
71
  const zh = /^zh/i.test(navigator.language || "");
72
72
  bar.append(document.createTextNode(zh ? "登入已結束。" : "Your session has ended. "));
73
73
  const link = document.createElement("a");
@@ -1,7 +1,7 @@
1
1
  // #1408 §3: the app's routes on the client. The same table as the server's classifier (src/web-shell-routes.ts;
2
2
  // a test runs both over one list of cases). Pure: no DOM, no history.
3
3
 
4
- export const FLEET_TABS = ["tasks", "schedules", "teams", "org", "cache", "config"];
4
+ export const FLEET_TABS = ["tasks", "schedules", "teams", "org", "cache", "usage", "config"];
5
5
  export const SETTINGS_SECTIONS = ["device", "appearance", "agents", "bots", "classic", "general", "web", "advanced"]; // #1604: device first
6
6
  const MAX_INSTANCE_NAME = 128;
7
7
  const PATH_SEPARATOR_OR_CONTROL = /[/\\\u0000-\u001f\u007f]/;
@@ -30,8 +30,9 @@
30
30
  // - suspend()/resume(): the page is leaving (pagehide) / came back from the back-forward cache.
31
31
 
32
32
  // #1218: steps_tick — an instance's transcript moved while a Log watches it (SSE only: a poll page asks the Log itself).
33
- // #1918: traffic — a message between instances moved (SSE only: the org canvas reads /ui/traffic when it has no stream).
34
- const EVENTS = ["status", "message", "agent_reply_live", "delivery", "deliveries", "prompt", "prompts", "prompt_resolved", "activity", "needs", "reply_buttons", "steps_tick", "clock", "traffic"];
33
+ // #1918: traffic — a message between instances moved; delegation — a task opened, or its report closed it (SSE only: the
34
+ // org canvas reads /ui/traffic when it has no stream).
35
+ const EVENTS = ["status", "message", "agent_reply_live", "delivery", "deliveries", "prompt", "prompts", "prompt_resolved", "activity", "needs", "reply_buttons", "steps_tick", "clock", "traffic", "delegation"];
35
36
  const MAX_HELD = 1000; // live events held during one catch-up read; more → read again afterwards
36
37
  const RETRY_DELAYS = [1000, 2000, 4000]; // after the first failed catch-up read; then it waits for Retry
37
38
  const POLL_LIMIT_MS = 10000; // a poll that has not answered by then counts as failed
@@ -453,6 +453,9 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
453
453
  .msg.status-line .st-more { border: 0; background: none; color: var(--text-2); cursor: pointer; font-size: var(--fs-xs); padding: var(--s-1) 0 0 var(--s-3); }
454
454
  .msg.status-line .st-more:hover { color: var(--text); }
455
455
  .msg-tools { display: flex; gap: var(--s-1); margin-top: var(--s-1); opacity: 0; transition: opacity var(--t-fast); }
456
+ /* #2147: bounded stock toolbar paint, including fixed descendants. Padding preserves focus outlines. */
457
+ .shell .thread .msg-tools { contain: paint; overflow: clip; overflow-clip-margin: 0px; padding: 4px; margin: 0 -4px -4px; }
458
+ .shell .thread .codeblock > .cb-head { contain: paint; overflow: clip; overflow-clip-margin: 0px; }
456
459
  .msg:hover .msg-tools, .msg:focus-within .msg-tools { opacity: 1; }
457
460
  @media (hover: none) { .msg-tools { opacity: 1; } }
458
461
  /* Delivery ticks: always visible, in colour, with their label on hover and for screen readers (rough edge 10). */
@@ -559,7 +562,7 @@ html.lb-open .scroller { overflow: hidden; }
559
562
  these tokens. The surface paints its own background (the page's body is not its). */
560
563
  .p-chat.th { background: var(--bg); color: var(--text); }
561
564
  .p-chat.th .msg.user .body { border-radius: var(--bubble-radius, var(--r-xl)); }
562
- .p-chat.th-font-system .thread, .p-chat.th-font-system .composer textarea { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
565
+ .p-chat.th-font-system .thread, .p-chat.th-font-system .composer textarea { font-family: system-ui, -apple-system, "Segoe UI", sans-serif, var(--font-emoji); }
563
566
  .p-chat.th-font-mono .thread, .p-chat.th-font-mono .composer textarea { font-family: var(--font-mono); }
564
567
  .p-chat.th-bubble-thin .msg.user .body { box-shadow: inset 0 0 0 1px var(--border); }
565
568
  .p-chat.th-bubble-accent .msg.user .body { box-shadow: inset 0 0 0 1px var(--accent); }
@@ -1217,7 +1220,7 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1217
1220
  .oc-team-name { fill: var(--text); font-size: 13px; font-weight: 600; }
1218
1221
  .oc-team-meta { fill: var(--text-3); font-size: 11px; }
1219
1222
  .oc-team-sum { fill: var(--text-2); font-size: 11.5px; }
1220
- .oc-team.dim { opacity: 0.55; }
1223
+ .oc-team.dim, .oc-team-head.dim { opacity: 0.55; }
1221
1224
  .oc-node.dim { opacity: 0.4; }
1222
1225
  .oc-node { cursor: pointer; }
1223
1226
  .oc-node[role="img"] { cursor: default; }
@@ -1263,7 +1266,11 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1263
1266
  .oc-flow.h-broadcast { stroke: var(--fx-broadcast); } .oc-flow.h-team { stroke: var(--fx-team); stroke-width: 3.5; }
1264
1267
  .oc-flow.ph-live { stroke-dasharray: 7 6; animation: oc-dash 0.9s linear infinite; }
1265
1268
  .oc-flow.ph-queued { stroke-dasharray: 2 5; opacity: 0.8; }
1266
- .oc-flow.ph-fading { stroke: var(--text-3); animation: oc-fade 3s ease-out forwards; }
1269
+ .oc-flow.ph-fading { stroke: var(--text-3); animation: oc-fade 12s ease-out forwards; }
1270
+ /* #1918 review: the work — a delegation in progress (a slow dash, kept until its report), its report coming back, none. */
1271
+ .oc-flow.ph-work { stroke-dasharray: 10 7; stroke-width: 3; animation: oc-dash 2.4s linear infinite; }
1272
+ .oc-flow.ph-return { stroke-width: 3; animation: oc-fade 2.4s ease-out forwards; }
1273
+ .oc-flow.ph-stale { stroke: var(--text-3); stroke-dasharray: 3 6; animation: oc-fade 12s ease-out forwards; }
1267
1274
  .oc-ah.h-task { fill: var(--fx-task); } .oc-ah.h-send { fill: var(--fx-send); } .oc-ah.h-report { fill: var(--fx-report); }
1268
1275
  .oc-ah.h-broadcast { fill: var(--fx-broadcast); } .oc-ah.h-team { fill: var(--fx-team); } .oc-ah.h-fading { fill: var(--text-3); }
1269
1276
  .oc-pulse.h-task { fill: var(--fx-task); } .oc-pulse.h-send { fill: var(--fx-send); } .oc-pulse.h-report { fill: var(--fx-report); }
@@ -1272,16 +1279,19 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1272
1279
  .oc-badge rect { fill: var(--surface-2); stroke: var(--border); } .oc-badge text { fill: var(--text-2); font-size: 10.5px; }
1273
1280
  @keyframes oc-dash { to { stroke-dashoffset: -26; } }
1274
1281
  @keyframes oc-fade { from { opacity: 0.8; } to { opacity: 0.15; } }
1275
- .oc-legend { position: absolute; left: var(--s-3); bottom: var(--s-3); max-width: calc(100% - 220px);
1276
- font-size: var(--fs-xs); color: var(--text-2); background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md); padding: 6px 10px; }
1277
- .oc-legend summary { cursor: pointer; font-weight: var(--fw-semibold); color: var(--text); }
1278
- .oc-legend[open] summary { margin-bottom: 4px; }
1282
+ /* #2052: the key is a strip under the toolbar (its toggle is in the toolbar), never over the canvas. */
1283
+ .oc-legend { margin: 0 0 var(--s-2); font-size: var(--fs-xs); color: var(--text-2); background: var(--surface); border: 1px solid var(--border);
1284
+ border-radius: var(--r-md); padding: 6px 10px; }
1285
+ .oc-key.on { border-color: var(--accent); }
1279
1286
  .oc-lg-rows { display: flex; flex-wrap: wrap; gap: var(--s-1) var(--s-3); }
1280
1287
  .oc-lg { display: inline-flex; align-items: center; gap: 6px; }
1281
1288
  .oc-sw { width: 18px; height: 3px; border-radius: 2px; display: inline-block; background: var(--text-3); }
1282
1289
  .oc-sw.h-task { background: var(--fx-task); } .oc-sw.h-send { background: var(--fx-send); } .oc-sw.h-report { background: var(--fx-report); } .oc-sw.h-broadcast { background: var(--fx-broadcast); }
1283
1290
  .oc-sw.ph-queued { background: repeating-linear-gradient(90deg, var(--text-2) 0 3px, transparent 3px 7px); }
1284
1291
  .oc-sw.ph-fading { opacity: 0.5; }
1292
+ .oc-sw.ph-work { background: repeating-linear-gradient(90deg, var(--fx-task) 0 6px, transparent 6px 9px); height: 4px; }
1293
+ .oc-sw.ph-return { height: 4px; }
1294
+ .oc-sw.ph-stale { background: repeating-linear-gradient(90deg, var(--text-3) 0 2px, transparent 2px 6px); }
1285
1295
  .oc-live { gap: 6px; }
1286
1296
  .oc-live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-3); }
1287
1297
  .oc-live.on .oc-live-dot { background: var(--ok); }
@@ -1291,14 +1301,16 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1291
1301
  @media (prefers-reduced-motion: reduce) {
1292
1302
  .oc-flow.ph-live { animation: none; stroke-dasharray: none; }
1293
1303
  .oc-flow.ph-fading { animation: none; opacity: 0.4; }
1304
+ .oc-flow.ph-work { animation: none; }
1305
+ .oc-flow.ph-return, .oc-flow.ph-stale { animation: none; opacity: 0.5; }
1294
1306
  .oc-pulse { display: none; }
1295
1307
  }
1296
1308
  @media (max-width: 640px) {
1297
1309
  .oc-stage { height: calc(100dvh - 248px); min-height: 340px; }
1298
1310
  .oc-find { flex: 1 1 150px; }
1299
- .oc-tools { margin-left: 0; width: 100%; flex-wrap: nowrap; }
1311
+ /* #2052: Find, Key, Live and Canvas/List are wider than a phone together: the row wraps (nothing is cut off). */
1312
+ .oc-tools { margin-left: 0; width: 100%; flex-wrap: wrap; row-gap: var(--s-2); }
1300
1313
  .oc-mini { display: none; }
1301
- .oc-legend { max-width: calc(100% - 24px); gap: 2px var(--s-2); }
1302
1314
  }
1303
1315
  /* A phone: Fleet's five tabs fit when only the open one shows its label (the others keep theirs for screen readers). */
1304
1316
  @media (max-width: 479px) {
@@ -1341,6 +1353,73 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1341
1353
  .ch-bar .btn { min-height: var(--tap); }
1342
1354
  }
1343
1355
 
1356
+ /* ── #2122: Fleet → Usage. A categorical palette kept apart from the state colours (Okabe–Ito hues, tuned per mode for
1357
+ contrast); every series also has a text label, so colour is never the only cue. ── */
1358
+ .us { --us-0: #56b4e9; --us-1: #e69f00; --us-2: #2bc48a; --us-3: #f0e442; --us-4: #e78ac3; --us-5: #94a3b8; --us-heat: #56b4e9;
1359
+ display: flex; flex-direction: column; gap: var(--s-3); min-width: 0; }
1360
+ @media (prefers-color-scheme: light) { :root:not([data-theme="dark"]) .us { --us-0: #0072b2; --us-1: #c25400; --us-2: #007a5a; --us-3: #8a6d00; --us-4: #b0457a; --us-5: #64748b; --us-heat: #0072b2; } }
1361
+ :root[data-theme="light"] .us { --us-0: #0072b2; --us-1: #c25400; --us-2: #007a5a; --us-3: #8a6d00; --us-4: #b0457a; --us-5: #64748b; --us-heat: #0072b2; }
1362
+ .us-bar-top { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-2) var(--s-3); margin-top: var(--s-2); }
1363
+ .us-group { display: inline-flex; align-items: center; gap: var(--s-2); font-size: var(--fs-sm); color: var(--text-2); }
1364
+ .us-group select, .us-custom input { min-height: 32px; padding: 0 var(--s-2); border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--bg); color: var(--text); }
1365
+ .us-group select:focus, .us-custom input:focus { border-color: var(--focus); box-shadow: 0 0 0 3px var(--accent-dim); outline: none; }
1366
+ .us-csv { margin-left: auto; }
1367
+ .us-custom { display: flex; flex-wrap: wrap; align-items: end; gap: var(--s-2) var(--s-3); font-size: var(--fs-sm); }
1368
+ .us-custom label { display: inline-flex; flex-direction: column; gap: 2px; color: var(--text-2); }
1369
+ .us-basis { font-size: var(--fs-sm); color: var(--text); padding: var(--s-2) var(--s-3); border-left: 3px solid var(--accent); background: var(--surface-2); border-radius: var(--r-sm); }
1370
+ .us-fresh { font-size: var(--fs-xs); }
1371
+ .us-partial { font-size: var(--fs-sm); padding: var(--s-2) var(--s-3); border-left: 3px solid var(--warn); background: var(--warn-bg); border-radius: var(--r-sm); }
1372
+ .us-unpriced { font-size: var(--fs-xs); }
1373
+ .us-scan { display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--s-2); }
1374
+ .us-totals { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--s-3); margin: 0; }
1375
+ .us-totals .ch-metric dd { font-size: var(--fs-lg); }
1376
+ .us-table { width: 100%; border-collapse: collapse; font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
1377
+ .us-table th, .us-table td { padding: var(--s-2); border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
1378
+ .us-table thead th { font-size: 11px; font-weight: var(--fw-regular); color: var(--text-3); }
1379
+ .us-table .num { text-align: right; white-space: nowrap; }
1380
+ .us-name { min-width: 0; max-width: 360px; font-weight: var(--fw-regular); }
1381
+ .us-name .mono { display: block; font-weight: var(--fw-semibold); overflow-wrap: anywhere; }
1382
+ .us-bar { display: block; width: 100%; height: 6px; margin: 4px 0; }
1383
+ .us-bar-bg { fill: var(--surface-2); } .us-bar-fg { fill: var(--us-0); }
1384
+ .us-meta { display: block; font-size: var(--fs-xs); color: var(--text-3); overflow-wrap: anywhere; }
1385
+ .us-flags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
1386
+ .us-trend, .us-heat { display: flex; flex-direction: column; gap: var(--s-2); min-width: 0; }
1387
+ .us-trend h3, .us-heat h3, .us-rest h3 { font-size: var(--fs-sm); font-weight: var(--fw-semibold); }
1388
+ .us-scroll { overflow-x: auto; max-width: 100%; }
1389
+ .us-chart, .us-heatmap { display: block; }
1390
+ .us-axis { font-size: 9px; fill: var(--text-3); }
1391
+ .us-s0 { fill: var(--us-0); background: var(--us-0); } .us-s1 { fill: var(--us-1); background: var(--us-1); } .us-s2 { fill: var(--us-2); background: var(--us-2); }
1392
+ .us-s3 { fill: var(--us-3); background: var(--us-3); } .us-s4 { fill: var(--us-4); background: var(--us-4); } .us-s5 { fill: var(--us-5); background: var(--us-5); }
1393
+ .us-legend { display: flex; flex-wrap: wrap; gap: var(--s-1) var(--s-4); list-style: none; padding: 0; margin: 0; font-size: var(--fs-xs); }
1394
+ .us-legend li { display: inline-flex; align-items: center; gap: var(--s-1); min-width: 0; overflow-wrap: anywhere; }
1395
+ .us-sw, .us-h-sw { display: inline-block; width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
1396
+ .us-h { fill: var(--us-heat); }
1397
+ .us-h.l1, .us-h-sw.l1 { opacity: 0.45; } .us-h.l2 { opacity: 0.65; } .us-h.l3 { opacity: 0.85; } .us-h.l4, .us-h-sw.l4 { opacity: 1; }
1398
+ .us-h-sw.l4 { background: var(--us-heat); }
1399
+ /* Read with nothing used: a solid grey cell. Not collected: an empty dashed outline. */
1400
+ .us-h.idle { fill: var(--text-3); opacity: 0.35; }
1401
+ .us-h-sw.idle { background: var(--text-3); opacity: 0.35; }
1402
+ .us-h.gap { fill: none; stroke: var(--text-3); stroke-width: 1; stroke-dasharray: 2 2; }
1403
+ .us-h-sw.gap { border: 1px dashed var(--text-3); }
1404
+ .us-grid, .us-base { stroke: var(--border); stroke-width: 1; }
1405
+ .us-grid { stroke-dasharray: 3 3; }
1406
+ .us-scale, .us-earlier { font-size: var(--fs-xs); }
1407
+ .us-heat-key { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-1); font-size: var(--fs-xs); }
1408
+ .us-rest .row-item { font-size: var(--fs-xs); }
1409
+ .u-by-agent { margin-left: auto; }
1410
+ @media (max-width: 699px) {
1411
+ .us-totals { grid-template-columns: repeat(2, minmax(0, 1fr)); }
1412
+ .us-csv { margin-left: 0; }
1413
+ .us-bar-top .btn { min-height: var(--tap); }
1414
+ /* Rows become cards: each value with its label. */
1415
+ .us-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
1416
+ .us-table, .us-table tbody, .us-table tr, .us-table th, .us-table td { display: block; width: 100%; }
1417
+ .us-table tr { margin-bottom: var(--s-3); padding: var(--s-3); border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--bg); }
1418
+ .us-table th, .us-table td { border: 0; padding: 2px 0; max-width: none; }
1419
+ .us-table td { display: flex; justify-content: space-between; gap: var(--s-3); }
1420
+ .us-table td::before { content: attr(data-label); color: var(--text-3); text-align: left; }
1421
+ }
1422
+
1344
1423
  /* ── Chat commands and quick actions (#1269) ── */
1345
1424
  .hd-chip { margin-left: var(--s-2); padding: 0 var(--s-2); border: 1px solid var(--border); border-radius: var(--r-full); background: transparent; color: var(--text-2);
1346
1425
  font-size: var(--fs-xs); line-height: 20px; cursor: pointer; max-width: 16em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; }
@@ -1373,6 +1452,7 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1373
1452
  }
1374
1453
  /* #1630: a Discord server emoji in a chat message, the size of the text around it. */
1375
1454
  .md img.emoji { display: inline-block; width: 1.375em; height: 1.375em; object-fit: contain; vertical-align: -0.3em; margin: 0 0.05em; }
1455
+ .md .emoji-text { color: var(--text-2); }
1376
1456
  /* #1682: a mention, as Discord draws one — accent text on a tint of it; the id (or the Telegram handle) on hover. */
1377
1457
  .md .mention { display: inline; padding: 0 0.2em; border-radius: var(--r-sm); color: var(--accent); background: var(--accent-dim);
1378
1458
  font-weight: var(--fw-medium); white-space: nowrap; }
@@ -31,7 +31,7 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
31
31
  const effects = s?.metadata?.effects ?? [], known = registry?.snapshot?.() ?? [];
32
32
  const rows = effects.map(p => {
33
33
  const b = s.bindings.get(p.id), row = known.find(r => b && r.key === b.key && r.id === p.id);
34
- if (row && !s.error && !playbackUnavailable) return row.reason == null && row.phase == null ? { ...row, reason: "calibrating" } : row;
34
+ if (row && !s.error && !playbackUnavailable) return row.reason == null && row.phase == null ? { ...row, phase: "loading" } : row;
35
35
  return { id: p.id, key: b?.key ?? null, verdict: "off", phase: null, quality: null,
36
36
  reason: s.error ?? playbackUnavailable ?? effectPlaybackReason(lastContext) ?? (!p.manifest.regions.includes("chat") ? "region_unavailable" : "runtime_unavailable") };
37
37
  });
@@ -141,7 +141,7 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
141
141
  if (identity.scope === "owner") trial = make("trialSource", createEffectTrialSource)({ window: host, current: () => {
142
142
  if (!alive()) return false; const v = observe(), c = v.context;
143
143
  return alive() && c.enabled === true && c.show === true && c.transportReady === true
144
- && c.safe === false && c.publicLink === false && c.reducedMotion === false && c.saveData === false && c.hidden === false;
144
+ && c.safe === false && c.publicLink === false && c.reducedMotion === false && c.hidden === false;
145
145
  }, ownerCurrent: (s, name) => sourceAlive(s) && s.name === name,
146
146
  stock: () => registry.frameRoot ? make("stock", stockEffectTrust)(host, { frameRoot: registry.frameRoot, ownerCss: observe().ownerCss }) : null,
147
147
  readResource: (spec, signal) => typeof loader.trialResourceSettled === "function" ? loader.trialResourceSettled(spec, signal) : Promise.resolve(null),
@@ -210,7 +210,7 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
210
210
  // Metadata describes placements, not executable resources. Its existing session/epoch/current and
211
211
  // shared settled-lane fences apply even when this viewer has declined playback. No code is fetched here.
212
212
  if (!pending) {
213
- const ownerNeeds = identity.scope === "owner" && !c.publicLink && c.enabled && c.show && c.transportReady && !c.safe && !c.reducedMotion && !c.saveData && !c.hidden && personal?.needsMetadata() === true, chatNeeds = sourceAlive(s) && !s.error && !s.metadata;
213
+ const ownerNeeds = identity.scope === "owner" && !c.publicLink && c.enabled && c.show && c.transportReady && !c.safe && !c.reducedMotion && !c.hidden && personal?.needsMetadata() === true, chatNeeds = sourceAlive(s) && !s.error && !s.metadata;
214
214
  if (!alive() || revision !== before || s !== source) { if (alive()) queueSync(); return; }
215
215
  if (ownerNeeds && (!chatNeeds || lastMetadata !== "owner")) void readPersonal();
216
216
  else if (chatNeeds) void read(s);
@@ -138,7 +138,7 @@ export function createChatEffectRuntime({ window: host, name, element, current,
138
138
  const stamp = JSON.stringify(ctx);
139
139
  if (stamp !== lastContext) { lastContext = stamp; registry.update(descriptors(), ctx); }
140
140
  emit();
141
- if (!metadata && !active && ctx.enabled && ctx.show && ctx.transportReady && !ctx.safe && !ctx.publicLink && !ctx.reducedMotion && !ctx.saveData && !ctx.hidden) void read();
141
+ if (!metadata && !active && ctx.enabled && ctx.show && ctx.transportReady && !ctx.safe && !ctx.publicLink && !ctx.reducedMotion && !ctx.hidden) void read();
142
142
  }
143
143
  async function read() {
144
144
  if (!alive() || error || active) return;
@@ -66,8 +66,8 @@ export const EFFECT_BLOCK_REASONS = Object.freeze([
66
66
 
67
67
  /** Diagnostic names only; the admission policy below still validates every required flag. */
68
68
  export function effectPlaybackReason(c) {
69
- if (!c || ["safe", "publicLink", "reducedMotion", "saveData", "hidden", "enabled", "show", "transportReady"].some(k => typeof c[k] !== "boolean")) return "gates_unknown";
70
- return c.safe ? "safe_mode" : c.reducedMotion ? "reduced_motion" : c.saveData ? "save_data"
69
+ if (!c || ["safe", "publicLink", "reducedMotion", "hidden", "enabled", "show", "transportReady"].some(k => typeof c[k] !== "boolean")) return "gates_unknown";
70
+ return c.safe ? "safe_mode" : c.reducedMotion ? "reduced_motion"
71
71
  : c.hidden ? "hidden" : !c.enabled ? "unsupported_host" : !c.show ? "disabled" : !c.transportReady ? "transport_unavailable" : null;
72
72
  }
73
73
 
@@ -77,13 +77,12 @@ export function allocateEffectFrames(descriptors, context) {
77
77
  if (!Array.isArray(descriptors) || descriptors.length > 128 || !plain(context)) return [];
78
78
  const keys = new Set(), duplicate = new Set();
79
79
  for (const d of descriptors) if (d && typeof d.key === "string") { if (keys.has(d.key)) duplicate.add(d.key); keys.add(d.key); }
80
- const result = descriptors.map(d => ({ key: d?.key, verdict: "off", reason: "unknown", queuePos: null }));
81
- const gates = ["safe", "publicLink", "reducedMotion", "saveData", "hidden", "enabled", "show", "transportReady", "constrained", "modal"];
82
- if (gates.some(k => typeof context[k] !== "boolean") || context.openInstance !== null && !instanceName(context.openInstance)) return result;
80
+ const result = descriptors.map(d => ({ key: d?.key, verdict: "off", reason: "descriptor_invalid", queuePos: null }));
81
+ const gates = ["safe", "publicLink", "reducedMotion", "hidden", "enabled", "show", "transportReady", "constrained", "modal"];
82
+ if (gates.some(k => typeof context[k] !== "boolean") || context.openInstance !== null && !instanceName(context.openInstance)) return result.map(r => ({ ...r, reason: "gates_unknown" }));
83
83
  let global = null;
84
84
  if (context.safe === true) global = "safe_mode";
85
85
  else if (context.reducedMotion === true) global = "reduced_motion";
86
- else if (context.saveData === true) global = "save_data";
87
86
  else if (context.hidden === true) global = "hidden";
88
87
  else if (context.enabled !== true) global = "unsupported_host";
89
88
  else if (context.show !== true) global = "disabled";
@@ -12,10 +12,14 @@ const id = v => typeof v === "string" && /^[0-9a-f]{32}$/.test(v);
12
12
  /** One host-owned mount. The registry must remove the frame when remove is true, never merely hide it. */
13
13
  export class EffectBudget {
14
14
  constructor({ device, baselineFps, clock = () => performance.now(), longTasksSupported = false } = {}) {
15
- if (!deviceClass(device) || !finite(baselineFps) || baselineFps <= 0 || baselineFps > 1000 || typeof clock !== "function" || typeof longTasksSupported !== "boolean") throw new TypeError("effect budget options");
15
+ if (!deviceClass(device) || baselineFps !== undefined && (!finite(baselineFps) || baselineFps <= 0 || baselineFps > 1000) || typeof clock !== "function" || typeof longTasksSupported !== "boolean") throw new TypeError("effect budget options");
16
16
  this.device = device;
17
- // Measured by the host while NO effects are mounted; never author-supplied or learned from a running effect.
18
- this.fpsFloor = Math.min(EFFECT_BUDGETS[device].fps, baselineFps * 0.9);
17
+ // Legacy host measurements may still be supplied by pure probes. Product playback starts
18
+ // immediately and learns cadence concurrently from host rAF, never Worker timing.
19
+ // Freeze before the first report: a mount's profile floor must remain coherent.
20
+ this.fpsFloor = baselineFps === undefined ? null : Math.min(EFFECT_BUDGETS[device].fps, baselineFps * 0.9);
21
+ this.cadence = [];
22
+ this.cadenceAt = null;
19
23
  this.clock = clock;
20
24
  this.longTasksSupported = longTasksSupported;
21
25
  this.last = null;
@@ -58,7 +62,16 @@ export class EffectBudget {
58
62
  frame() {
59
63
  const now = this.time();
60
64
  if (now === null) return this.stop("clock_unavailable");
61
- if (this.verdict === "paused" || this.hiddenAt !== null || now < this.epoch + 1000) return this.snapshot();
65
+ if (this.verdict === "paused" || this.hiddenAt !== null) return this.snapshot();
66
+ if (this.fpsFloor === null) {
67
+ const interval = this.cadenceAt === null ? null : now - this.cadenceAt;
68
+ this.cadenceAt = now;
69
+ if (interval !== null && interval >= 1 && interval <= 1000) {
70
+ if (this.cadence.length === 256) this.cadence.shift();
71
+ this.cadence.push(interval);
72
+ }
73
+ }
74
+ if (now < this.epoch + 1000) return this.snapshot();
62
75
  this.trim(now);
63
76
  if (this.frames.at(-1) !== now) {
64
77
  if (this.frames.length >= 2048) this.overflow = true;
@@ -88,10 +101,10 @@ export class EffectBudget {
88
101
  const now = this.time();
89
102
  if (now === null) return this.stop("clock_unavailable");
90
103
  if (!plain(raw)) return this.stop("gates_unknown");
91
- const { hidden, reducedMotion, saveData, show } = raw;
92
- if ([hidden, reducedMotion, saveData, show].some(v => typeof v !== "boolean")) return this.stop("gates_unknown");
104
+ const { hidden, reducedMotion, show } = raw;
105
+ if ([hidden, reducedMotion, show].some(v => typeof v !== "boolean")) return this.stop("gates_unknown");
93
106
  if (this.verdict === "paused") return this.snapshot();
94
- if (reducedMotion || saveData || !show) return this.stop(reducedMotion ? "reduced_motion" : saveData ? "save_data" : "viewer_off");
107
+ if (reducedMotion || !show) return this.stop(reducedMotion ? "reduced_motion" : "viewer_off");
95
108
  if (hidden && this.hiddenAt === null) this.hiddenAt = now;
96
109
  if (hidden && now - this.hiddenAt >= 10000) return this.stop("hidden");
97
110
  if (!hidden && this.hiddenAt !== null) {
@@ -105,6 +118,7 @@ export class EffectBudget {
105
118
  this.overflow = false;
106
119
  this.badWindows = 0;
107
120
  this.metrics = null;
121
+ this.cadence = []; this.cadenceAt = null;
108
122
  }
109
123
  return this.snapshot();
110
124
  }
@@ -123,8 +137,16 @@ export class EffectBudget {
123
137
  const hostMs = this.longTasksSupported ? sum(null) : null;
124
138
  const ownMs = this.longTasksSupported && this.tasks.some(t => t.attributed) ? sum(true) : null;
125
139
  const limits = EFFECT_BUDGETS[this.device];
126
- const bad = this.overflow || fps < this.fpsFloor || hostMs !== null && hostMs > limits.longTaskMsPerSecond;
127
- this.metrics = { at: now, hostFps: fps, hostLongTaskMsPerSecond: hostMs, attributedLongTaskMsPerSecond: ownMs,
140
+ if (this.fpsFloor === null && this.cadence.length >= 3) {
141
+ // Faster observed intervals distinguish intermittent jank from a genuinely slower
142
+ // display. This is a live reference, not an empty-page health certification.
143
+ const sorted = [...this.cadence].sort((a, b) => a - b);
144
+ const interval = sorted[Math.ceil(sorted.length * (sorted.length < 8 ? 0.5 : 0.2)) - 1];
145
+ this.fpsFloor = Math.min(limits.fps, 900 / interval);
146
+ this.cadence = []; this.cadenceAt = null;
147
+ }
148
+ const bad = this.overflow || this.fpsFloor === null || fps < this.fpsFloor || hostMs !== null && hostMs > limits.longTaskMsPerSecond;
149
+ this.metrics = this.fpsFloor === null ? null : { at: now, hostFps: fps, hostLongTaskMsPerSecond: hostMs, attributedLongTaskMsPerSecond: ownMs,
128
150
  attribution: ownMs === null ? "unknown" : "observed", fpsFloor: this.fpsFloor, windowMs: 2000 };
129
151
  this.overflow = false;
130
152
  if (bad) {