cozyclay 3.1.4 → 3.1.5

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 (124) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/INSTALL_FOR_AGENTS.md +3 -0
  3. package/README.md +5 -0
  4. package/bin/agent/agent-failure-codes.mjs +31 -0
  5. package/bin/agent/agent-routes.mjs +5 -10
  6. package/bin/agent/agent-runner.mjs +5 -3
  7. package/bin/cozyclay.mjs +16 -64
  8. package/bin/studio-routes.mjs +69 -0
  9. package/bin/update-check.mjs +53 -5
  10. package/bin/update-install.mjs +136 -0
  11. package/bin/update-routes.mjs +80 -0
  12. package/dist/ai-camera-control/index.html +1 -1
  13. package/dist/app/index.html +5 -6
  14. package/dist/assets/{TaskBoard-U1Yl-4F1.js → TaskBoard-DwXhsJXz.js} +1 -1
  15. package/dist/assets/app-CEP7jzXB.css +1 -0
  16. package/dist/assets/app-CTKcyPM-.js +5144 -0
  17. package/dist/assets/compile-worker-or7djI2v.js +6 -0
  18. package/dist/assets/{motion3-compile-DDG3wklD.js → motion3-compile-Du7UXrWn.js} +1 -1
  19. package/dist/assets/scene-asset-sync-t45pWCyq.js +13 -0
  20. package/dist/assets/task-board-CkLZGPkv.js +15 -0
  21. package/dist/assets/workflow-DJwOUzqJ.js +179 -0
  22. package/dist/cozyclay-package.json +1 -1
  23. package/dist/greybox-to-video/index.html +3 -3
  24. package/dist/index.html +533 -264
  25. package/dist/media/cozyclay-mark.png +0 -0
  26. package/dist/media/cozyclay-truck-demo-poster.jpg +0 -0
  27. package/dist/media/step-move-poster.jpg +0 -0
  28. package/dist/media/step-shoot-poster.jpg +0 -0
  29. package/dist/media/step-stage-poster.jpg +0 -0
  30. package/dist/media/truck-greybox-poster.jpg +0 -0
  31. package/dist/media/truck-seedance-poster.jpg +0 -0
  32. package/dist/previs-software/index.html +4 -4
  33. package/dist/privacy/index.html +46 -19
  34. package/dist/seedance-camera-control/index.html +3 -3
  35. package/dist/sitemap.xml +6 -6
  36. package/dist/workflow/index.html +3 -4
  37. package/package.json +1 -1
  38. package/src/App.jsx +59 -70
  39. package/src/analytics-controls.js +17 -0
  40. package/src/analytics-friction.js +124 -0
  41. package/src/analytics-strict.js +139 -0
  42. package/src/analytics-values.js +49 -0
  43. package/src/analytics.js +37 -133
  44. package/src/asset-pane.jsx +4 -5
  45. package/src/click-friction.js +101 -0
  46. package/src/command-bus-telemetry.js +155 -0
  47. package/src/command-bus.js +10 -8
  48. package/src/commands/motion3.js +7 -2
  49. package/src/controls.jsx +3 -2
  50. package/src/crash-telemetry.js +45 -0
  51. package/src/domains/cast.js +2 -11
  52. package/src/domains/motion.js +49 -38
  53. package/src/domains/objects.js +3 -0
  54. package/src/domains/scene-undo.js +27 -0
  55. package/src/domains/scenes.js +1 -1
  56. package/src/error-boundary.jsx +2 -0
  57. package/src/export-attempt.js +100 -0
  58. package/src/export-workspace.js +12 -1
  59. package/src/first-success-guide.jsx +11 -7
  60. package/src/guide-telemetry.js +60 -0
  61. package/src/hierarchy-panel.jsx +2 -1
  62. package/src/live-control.js +2 -9
  63. package/src/locale-zh/domains.js +1 -0
  64. package/src/main.jsx +5 -0
  65. package/src/motion/timeline.jsx +9 -5
  66. package/src/motion-draft-telemetry.js +62 -0
  67. package/src/motion3/compiler/channels.js +7 -5
  68. package/src/motion3/compiler/compile.js +6 -2
  69. package/src/motion3/compiler/finger-close.js +84 -7
  70. package/src/motion3/compiler/head-lead.js +70 -0
  71. package/src/motion3/compiler/head-steady.js +1 -1
  72. package/src/motion3/compiler/limbs.js +10 -3
  73. package/src/motion3/compiler/pose.js +1 -1
  74. package/src/motion3/compiler/validate.js +5 -3
  75. package/src/motion3/pipeline.js +4 -0
  76. package/src/motion3/qa/explain.js +2 -1
  77. package/src/motion3/qa/finger-checks.js +80 -0
  78. package/src/motion3/qa/gait-observe.js +88 -0
  79. package/src/motion3/qa/grasp-checks.js +3 -0
  80. package/src/motion3/qa/hints-grasp.js +8 -0
  81. package/src/motion3/qa/index.js +2 -1
  82. package/src/motion3/qa/motion-life.js +26 -3
  83. package/src/motion3/schema/topics.js +1 -1
  84. package/src/playback-telemetry.js +107 -0
  85. package/src/post-turn-edits.js +62 -0
  86. package/src/search-friction.js +49 -0
  87. package/src/semantic-edit.js +19 -6
  88. package/src/shell/BoardSlot.jsx +3 -3
  89. package/src/shell/BottomDock.jsx +1 -1
  90. package/src/shell/DetailsSlot.jsx +1 -1
  91. package/src/shell/MenuBar.jsx +13 -13
  92. package/src/shell/StudioShell.jsx +7 -1
  93. package/src/shell/TopBar.jsx +2 -0
  94. package/src/shell/ViewportToolbar.jsx +2 -2
  95. package/src/shortcut-friction.js +122 -0
  96. package/src/stage-canvas-rigs.jsx +3 -4
  97. package/src/studio-telemetry.js +97 -0
  98. package/src/thrash-friction.js +90 -0
  99. package/src/update/UpdateChip.jsx +86 -0
  100. package/src/update/UpdateDialog.jsx +147 -0
  101. package/src/update/update-client.js +188 -0
  102. package/src/update/update-telemetry.js +78 -0
  103. package/src/update/update.css +94 -0
  104. package/src/use-webgl-available.js +35 -0
  105. package/src/workflow/AgentPanel.jsx +29 -30
  106. package/src/workflow/CozySceneNode.jsx +1 -1
  107. package/src/workflow/LoginPanel.jsx +15 -5
  108. package/src/workflow/agent-client.js +10 -91
  109. package/src/workflow/agent-failure-codes.js +47 -0
  110. package/src/workflow/agent-turn-telemetry.js +222 -0
  111. package/src/workflow/connect-telemetry.js +86 -0
  112. package/src/workflow/prompt-telemetry.js +105 -0
  113. package/tools/dev/pages/privacy.html +44 -17
  114. package/tools/loc-baseline.json +10 -10
  115. package/tools/run-tests.mjs +8 -8
  116. package/dist/assets/app-BdRlMf4J.css +0 -1
  117. package/dist/assets/app-KXdgBC-S.js +0 -5144
  118. package/dist/assets/compile-worker-oy_P9HHh.js +0 -6
  119. package/dist/assets/landing-y3EqDgSZ.js +0 -1
  120. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +0 -1
  121. package/dist/assets/scene-asset-sync-Bz59Ghdz.js +0 -13
  122. package/dist/assets/task-board-DFfNa0xg.js +0 -15
  123. package/dist/assets/workflow-BMJGd_gZ.js +0 -179
  124. package/dist/media/playground-poster.jpg +0 -0
@@ -0,0 +1,49 @@
1
+ // Closed property values for the export lifecycle and the session-end beacon.
2
+ // Kept out of analytics.js so these vocabularies can grow without growing the
3
+ // core sanitizer; nothing here imports, so analytics.js can depend on it.
4
+
5
+ export const EXPORT_FAILURE_CODES = new Set(["unsupported_codec", "encode_failed", "render_failed", "aborted", "unknown"]);
6
+ const COUNT_BUCKETS = new Set(["0", "1-3", "4-10", "gte11"]);
7
+ const EXPORT_PROPERTY_VALUES = Object.freeze({
8
+ export_kind: new Set(["video", "depth_video", "frame", "keyframe_pack", "workflow_send", "animation_project", "contact_sheet", "panel_pack", "animatic", "storyboard", "render_pass", "otio"]),
9
+ format: new Set(["mp4", "png", "zip", "cclayproject", "otio"]),
10
+ surface: new Set(["studio", "workflow", "embed"]),
11
+ duration_bucket: new Set(["lt1s", "1-3s", "3-10s", "10-30s", "gte30s"]),
12
+ failure_code: EXPORT_FAILURE_CODES,
13
+ objects: COUNT_BUCKETS,
14
+ // Where a failed or cancelled export stood: the step and its frame share.
15
+ stage: new Set(["preparing", "encoding", "finalizing", "handoff"]),
16
+ progress: new Set(["0", "1-25", "26-50", "51-75", "76-99"]),
17
+ // export:saved: where the file went, and why it fell back to a download.
18
+ method: new Set(["workspace", "download"]),
19
+ reason: new Set(["none", "unsupported", "no_workspace", "permission", "write_failed"]),
20
+ });
21
+ const ATTEMPT_ID_PATTERN = /^[a-f0-9]{32}$/;
22
+
23
+ /** One export:attempt_* or export:saved property, checked against its closed set. */
24
+ export function exportPropAllowed(key, value) {
25
+ if (key === "attempt_id" || key === "retry_of") return typeof value === "string" && ATTEMPT_ID_PATTERN.test(value);
26
+ if (key === "is_retry") return typeof value === "boolean";
27
+ return EXPORT_PROPERTY_VALUES[key]?.has(value) ?? false;
28
+ }
29
+
30
+ // The thrown-value type labels app:error and app:render_crash share (analytics.js
31
+ // errorType): built-in Error and DOMException names, else "non_error" or "other".
32
+ export const ERROR_TYPE_VALUES = new Set([
33
+ "Error", "TypeError", "RangeError", "ReferenceError", "SyntaxError", "EvalError", "URIError",
34
+ "AggregateError", "AbortError", "TimeoutError", "NotAllowedError", "NotSupportedError", "NotFoundError",
35
+ "NotReadableError", "InvalidStateError", "QuotaExceededError", "SecurityError", "NetworkError",
36
+ "DataCloneError", "OperationError", "EncodingError", "UnknownError", "DOMException", "non_error", "other",
37
+ ]);
38
+ export const SESSION_DURATION_BUCKETS = new Set(["lt1m", "1-5m", "5-15m", "15-30m", "gte30m"]);
39
+ // The SDK's session id is a random UUID; nothing else may ride in that slot.
40
+ const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
41
+ const SCENES_TOUCHED_MAX = 20;
42
+
43
+ /** One app:session_ended property: code-made buckets, a capped count, the SDK session id. */
44
+ export function sessionEndedPropAllowed(key, value) {
45
+ if (key === "duration_bucket") return SESSION_DURATION_BUCKETS.has(value);
46
+ if (key === "scenes_touched") return Number.isInteger(value) && value >= 0 && value <= SCENES_TOUCHED_MAX;
47
+ if (key === "session_id") return typeof value === "string" && SESSION_ID_PATTERN.test(value);
48
+ return key.endsWith("_bucket") && COUNT_BUCKETS.has(value);
49
+ }
package/src/analytics.js CHANGED
@@ -1,4 +1,7 @@
1
1
  import { FIRST_EDIT_KINDS, FIRST_EDIT_VERSION } from "./semantic-edit.js";
2
+ import { ERROR_TYPE_VALUES, EXPORT_FAILURE_CODES, exportPropAllowed } from "./analytics-values.js";
3
+ import { bump as bumpFriction, observeLongTasks, snapshot as frictionSnapshot } from "./analytics-friction.js";
4
+ import { STRICT_EVENT_KEYS, strictPropValidator } from "./analytics-strict.js";
2
5
  export const MCP_TOOL_CATEGORIES = Object.freeze([
3
6
  "read",
4
7
  "camera",
@@ -94,10 +97,12 @@ const DEFAULT_ALLOWED_ORIGINS = Object.freeze([
94
97
  "https://cozyclay.org",
95
98
  "https://www.cozyclay.org",
96
99
  ]);
100
+ const EXPORT_ATTEMPT_KEYS = ["attempt_id", "export_kind", "format", "surface", "is_retry", "retry_of", "objects"];
97
101
  const EVENT_PROPERTIES = Object.freeze({
98
102
  "install:first_launch": ["heard_from"],
99
103
  "app:session_started": [],
100
- "app:session_ended": ["duration_bucket", "action_count_bucket", "scenes_touched", ...EDIT_BUCKET_KEYS],
104
+ "app:session_ended": ["duration_bucket", "action_count_bucket", "scenes_touched", ...EDIT_BUCKET_KEYS, "session_id"],
105
+ ...STRICT_EVENT_KEYS,
101
106
  "app:error": ["error_kind", "error_type", "error_source", "error_file", "error_line", "error_col"],
102
107
  "device:profile": ["gpu_vendor", "gpu_class", "webgl", "cpu_bucket", "memory_bucket", "frame_rate_bucket"],
103
108
  "survey:use_case": ["use_case", "team"],
@@ -116,21 +121,14 @@ const EVENT_PROPERTIES = Object.freeze({
116
121
  "project:opened": ["age_bucket"],
117
122
  "craft:first_action": ["action_kind"],
118
123
  "craft:first_edit": ["edit_kind", "definition_version"],
119
- "motion:generate_requested": ["surface", "input_mode", "request_id"],
120
- "motion:preflight_blocked": ["reason", "surface", "request_id"],
121
- "motion:preflight_passed": ["backend", "surface", "request_id"],
122
- "motion:job_started": ["backend", "input_mode", "request_id"],
123
- "motion:job_succeeded": ["backend", "duration_bucket", "input_mode", "request_id"],
124
- "motion:job_failed": ["backend", "duration_bucket", "input_mode", "error_code", "request_id"],
125
- "motion:result_applied": ["request_id", "backend"],
126
124
  "export:blocking_frame_succeeded": ["format"],
127
125
  "export:video_succeeded": ["format"],
128
126
  "export:keyframe_pack": ["entries", "source"],
129
- "export:attempt_started": ["attempt_id", "export_kind", "format", "surface"],
130
- "export:attempt_succeeded": ["attempt_id", "export_kind", "format", "surface", "duration_bucket"],
131
- "export:attempt_failed": ["attempt_id", "export_kind", "format", "surface", "duration_bucket", "failure_code"],
132
- "export:attempt_cancelled": ["attempt_id", "export_kind", "format", "surface", "duration_bucket", "failure_code"],
133
- "sample:played": ["from"],
127
+ "export:attempt_started": EXPORT_ATTEMPT_KEYS,
128
+ "export:attempt_succeeded": [...EXPORT_ATTEMPT_KEYS, "duration_bucket"],
129
+ "export:attempt_failed": [...EXPORT_ATTEMPT_KEYS, "duration_bucket", "failure_code", "stage", "progress"],
130
+ "export:attempt_cancelled": [...EXPORT_ATTEMPT_KEYS, "duration_bucket", "failure_code", "stage", "progress"],
131
+ "export:saved": ["method", "reason"],
134
132
  "playground:opened": [],
135
133
  "playground:first_action": ["action_kind"],
136
134
  "playground:first_edit": ["edit_kind", "definition_version"],
@@ -140,35 +138,15 @@ const EVENT_PROPERTIES = Object.freeze({
140
138
  });
141
139
  const FEATURE_NAMES = new Set([
142
140
  "pose_edit", "pose_save", "camera_fly", "orbit", "dolly_rail", "crane_graph", "timeline_scrub",
143
- "prompt_block_add", "shot_add", "shot_cut", "export_pose", "export_frame", "export_video",
144
- "export_depth_video", "export_keyframe_pack",
145
- "mcp_connected", "auto_color", "plan_view",
141
+ "export_frame", "export_video",
142
+ "export_depth_video", "export_keyframe_pack", "export_render_pass", "export_storyboard",
143
+ "auto_color",
146
144
  ]);
147
145
  // `site` and `playground` arrive through `npx cozyclay --via <source>` from the
148
146
  // landing page's copy commands; the others are the first-launch prompt answers.
149
147
  export const HEARD_FROM_SOURCES = Object.freeze(["x", "hn", "reddit", "github", "friend", "other", "site", "playground"]);
150
148
  const HEARD_FROM_VALUES = new Set(HEARD_FROM_SOURCES);
151
149
  const DENIED_PROPERTY_KEYS = new Set(["prompt", "text", "url", "path", "file"]);
152
- const MOTION_ERROR_CODES = new Set(["aborted", "unsupported_route", "generation_failed", "quota", "unknown"]);
153
- const MOTION_PROPERTY_VALUES = Object.freeze({
154
- // `fal` is the Studio's AI video route (H3 Max Turbo through api.cozyclay.org).
155
- backend: new Set(["none", "local_kimodo", "hosted", "fal"]),
156
- surface: new Set(["timeline", "line_edit", "trail", "mcp", "fal_card", "agent"]),
157
- input_mode: new Set(["prompt", "pose", "edit", "a_to_b", "still"]),
158
- // `locked`: the AI video route is not enabled for this account yet.
159
- // `missing_input`: A/B stills, segmentation or a shared camera are missing.
160
- reason: new Set(["unconfigured", "unreachable", "unsupported_route", "locked", "missing_input"]),
161
- error_code: MOTION_ERROR_CODES,
162
- duration_bucket: new Set(["lt1s", "1-3s", "3-10s", "10-30s", "gte30s"]),
163
- });
164
- const EXPORT_FAILURE_CODES = new Set(["unsupported_codec", "encode_failed", "render_failed", "aborted", "unknown"]);
165
- const EXPORT_PROPERTY_VALUES = Object.freeze({
166
- export_kind: new Set(["video", "depth_video", "frame", "keyframe_pack", "workflow_send", "animation_project", "contact_sheet", "panel_pack", "animatic"]),
167
- format: new Set(["mp4", "png", "zip", "cclayproject"]),
168
- surface: new Set(["studio", "workflow", "embed"]),
169
- duration_bucket: new Set(["lt1s", "1-3s", "3-10s", "10-30s", "gte30s"]),
170
- failure_code: EXPORT_FAILURE_CODES,
171
- });
172
150
  // Sculpt mode (#739): one sculpt_run per finished run. Numbers only: no
173
151
  // reference image, recipe, part name or sculpt id ever rides along.
174
152
  export const SCULPT_RUN_OUTCOMES = Object.freeze(["done", "cancelled", "failed"]);
@@ -176,18 +154,13 @@ const SCULPT_RUN_MAX_ROUNDS = 10;
176
154
  const SCULPT_RUN_MAX_DURATION_MS = 86_400_000;
177
155
  const EXECUTION_FAILURE_CODES = Object.freeze({
178
156
  workflow: new Set(["aborted", "capture_failed", "generation_failed", "unknown"]),
179
- agent: new Set(["aborted", "auth", "rate_limited", "tool_failed", "upstream", "unknown"]),
157
+ agent: new Set(["aborted", "auth", "rate_limited", "tool_failed", "upstream", "unknown", "key_refused", "stale_scene", "capability_missing", "busy", "invalid_request"]),
180
158
  });
181
159
  // Only a type label and a same-origin script location cross the boundary; the
182
160
  // error message, stack text, URLs and anything a user typed never do.
183
161
  const ERROR_PROPERTY_VALUES = Object.freeze({
184
162
  error_kind: new Set(["error", "unhandled_rejection"]),
185
- error_type: new Set([
186
- "Error", "TypeError", "RangeError", "ReferenceError", "SyntaxError", "EvalError", "URIError",
187
- "AggregateError", "AbortError", "TimeoutError", "NotAllowedError", "NotSupportedError", "NotFoundError",
188
- "NotReadableError", "InvalidStateError", "QuotaExceededError", "SecurityError", "NetworkError",
189
- "DataCloneError", "OperationError", "EncodingError", "UnknownError", "DOMException", "non_error", "other",
190
- ]),
163
+ error_type: ERROR_TYPE_VALUES,
191
164
  error_source: new Set(["app", "extension", "external", "unknown"]),
192
165
  });
193
166
  const ERROR_FILE_PATTERN = /^[A-Za-z0-9_.-]{1,80}\.(?:m?js|jsx|ts|tsx)$/;
@@ -227,8 +200,8 @@ let deviceProfileStarted = false;
227
200
  // Passive signals describe the session; they are not user actions and never
228
201
  // move action_count_bucket.
229
202
  const PASSIVE_EVENTS = new Set([
230
- "app:session_started", "app:session_ended", "install:first_launch",
231
- "app:error", "device:profile",
203
+ "app:session_started", "app:session_ended", "install:first_launch", ...Object.keys(STRICT_EVENT_KEYS),
204
+ "app:error", "device:profile", "app:render_crash", "app:gl_lost",
232
205
  ]);
233
206
 
234
207
  function storage() {
@@ -283,16 +256,13 @@ const isSafePropertyValue = (value) => {
283
256
  export function sanitizeProps(event, props) {
284
257
  const allowedKeys = EVENT_PROPERTIES[event] ?? [];
285
258
  if (!props || typeof props !== "object" || Array.isArray(props)) return {};
286
- const sanitized = {};
259
+ const sanitized = {}, strict = strictPropValidator(event);
287
260
  for (const key of allowedKeys) {
288
261
  if (DENIED_PROPERTY_KEYS.has(key) || !Object.hasOwn(props, key)) continue;
289
262
  if (event === "feature:used" && (key !== "name" || !FEATURE_NAMES.has(props[key]))) continue;
290
263
  if (event === "install:first_launch" && (key !== "heard_from" || !HEARD_FROM_VALUES.has(props[key]))) continue;
291
- if (event.startsWith("export:attempt_")) {
292
- if (key === "attempt_id") {
293
- if (typeof props[key] !== "string" || !/^[a-f0-9]{32}$/.test(props[key])) continue;
294
- } else if (!EXPORT_PROPERTY_VALUES[key]?.has(props[key])) continue;
295
- }
264
+ if ((event.startsWith("export:attempt_") || event === "export:saved") && !exportPropAllowed(key, props[key])) continue;
265
+ if (strict) { if (strict(key, props[key])) sanitized[key] = props[key]; continue; }
296
266
  if (event === "export:keyframe_pack") {
297
267
  if (key === "source" && props[key] !== "workflow") continue;
298
268
  if (key === "entries" && (!Number.isFinite(props[key]) || props[key] < 0)) continue;
@@ -301,11 +271,6 @@ export function sanitizeProps(event, props) {
301
271
  if (key === "edit_kind" && !FIRST_EDIT_KINDS.includes(props[key])) continue;
302
272
  if (key === "definition_version" && props[key] !== FIRST_EDIT_VERSION) continue;
303
273
  }
304
- if (event.startsWith("motion:")) {
305
- if (key === "request_id") {
306
- if (typeof props[key] !== "string" || !/^[a-f0-9]{32}$/.test(props[key])) continue;
307
- } else if (!MOTION_PROPERTY_VALUES[key]?.has(props[key])) continue;
308
- }
309
274
  if (event.startsWith("workflow:") || event.startsWith("agent:") || event.startsWith("mcp:")) {
310
275
  if (key === "run_id" || key === "turn_id" || key === "request_id") {
311
276
  if (typeof props[key] !== "string" || !/^[a-f0-9]{32}$/.test(props[key])) continue;
@@ -547,63 +512,6 @@ export function exportFailureCode(error, fallbackCode = "unknown") {
547
512
  return EXPORT_FAILURE_CODES.has(fallbackCode) ? fallbackCode : "unknown";
548
513
  }
549
514
 
550
- /**
551
- * One user-initiated export, ending at pipeline completion/download handoff.
552
- * Optional { now, capture } dependencies keep fixtures deterministic. Telemetry
553
- * failures (including unavailable randomness) must not change export behavior.
554
- */
555
- export function startExportAttempt(metadata, dependencies = {}) {
556
- let terminal = false;
557
- let props = null;
558
- let startedAt = NaN;
559
- let now = () => performance.now();
560
- let capture = track;
561
- const readClock = () => {
562
- try { return now(); } catch { return NaN; }
563
- };
564
- const emit = (event, payload) => {
565
- try {
566
- Promise.resolve(capture(event, sanitizeProps(event, payload))).catch(() => {
567
- // A rejected transport is as non-fatal as a synchronous failure.
568
- });
569
- } catch {
570
- // Analytics must never affect the export or its error handling.
571
- }
572
- };
573
- try {
574
- now = dependencies.now ?? now;
575
- capture = dependencies.capture ?? capture;
576
- const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
577
- const attempt_id = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
578
- props = { ...sanitizeProps("export:attempt_started", metadata), attempt_id };
579
- startedAt = readClock();
580
- emit("export:attempt_started", props);
581
- } catch {
582
- // Without a secure random ID, omit the attempt rather than inventing
583
- // a persistent/content-derived identifier or blocking the export.
584
- props = null;
585
- }
586
- const finish = (result, error, fallbackCode) => {
587
- if (terminal) return;
588
- terminal = true;
589
- if (!props) return;
590
- try {
591
- const payload = { ...props, duration_bucket: bucketMs(readClock() - startedAt) };
592
- if (result === "failed") {
593
- payload.failure_code = exportFailureCode(error, fallbackCode);
594
- if (payload.failure_code === "aborted") result = "cancelled";
595
- }
596
- emit(`export:attempt_${result}`, payload);
597
- } catch {
598
- // Clock/payload failures are isolated from the actual export too.
599
- }
600
- };
601
- return {
602
- succeed() { finish("succeeded"); },
603
- fail(error, fallbackCode = "unknown") { finish("failed", error, fallbackCode); },
604
- };
605
- }
606
-
607
515
  const URL_PROPERTY_KEYS = [
608
516
  "$current_url",
609
517
  "$initial_current_url",
@@ -903,6 +811,7 @@ export async function initAnalytics() {
903
811
  sessionStartedAt = Date.now();
904
812
  installSessionEndListeners(resolved, globalProperties);
905
813
  installErrorListeners();
814
+ observeLongTasks();
906
815
  void recordDeviceProfile();
907
816
  if (resolved.distribution === "npm") {
908
817
  track("app:session_started");
@@ -919,7 +828,8 @@ export async function initAnalytics() {
919
828
  }
920
829
 
921
830
  export function track(event, props = {}) {
922
- if (!initialized || !enabled || !posthog) return;
831
+ // The allowlist is the gate: an event it does not name is never sent.
832
+ if (!initialized || !enabled || !posthog || !Object.hasOwn(EVENT_PROPERTIES, event)) return;
923
833
  try {
924
834
  if (getAnalyticsOptOut()) return;
925
835
  const sanitized = sanitizeProps(event, props);
@@ -983,7 +893,7 @@ function installErrorListeners() {
983
893
  errorListenersInstalled = true;
984
894
  const report = (payload) => {
985
895
  try {
986
- const props = describeError(payload);
896
+ const props = describeError(payload); bumpFriction("errors");
987
897
  const signature = [props.error_kind, props.error_type, props.error_source, props.error_file, props.error_line, props.error_col].join("|");
988
898
  if (errorSignaturesSeen.has(signature) || errorSignaturesSeen.size >= ERROR_SIGNATURE_LIMIT) return;
989
899
  errorSignaturesSeen.add(signature);
@@ -1113,25 +1023,19 @@ function installSessionEndListeners(resolved, globalProperties) {
1113
1023
  // This transport bypasses the SDK, so explicitly apply the same
1114
1024
  // current opt-out and browser DNT policy before serializing an ID.
1115
1025
  if (!enabled || !posthog || getAnalyticsOptOut() || posthog.has_opted_out_capturing()) return;
1116
- const payload = {
1117
- api_key: resolved.apiKey,
1118
- event: "app:session_ended",
1119
- properties: {
1120
- ...globalProperties,
1121
- distinct_id: posthog.get_distinct_id(),
1122
- duration_bucket: bucketSessionDuration(Date.now() - sessionStartedAt),
1123
- action_count_bucket: bucketCount(sessionActionCount),
1124
- scenes_touched: Math.min(20, sessionScenesTouched),
1125
- ...sessionEditBuckets(),
1126
- },
1127
- };
1128
- const body = JSON.stringify(payload);
1129
1026
  const endpoint = `${resolved.apiHost.replace(/\/$/, "")}/e/`;
1130
- if (typeof globalThis.navigator?.sendBeacon === "function") {
1131
- globalThis.navigator.sendBeacon(endpoint, new Blob([body], { type: "application/json" }));
1132
- } else {
1133
- void fetch(endpoint, { method: "POST", body, keepalive: true, headers: { "content-type": "application/json" } });
1134
- }
1027
+ const identity = { ...globalProperties, distinct_id: posthog.get_distinct_id() };
1028
+ // The same validator as every SDK event, one request per event.
1029
+ const send = (event, props) => {
1030
+ const body = JSON.stringify({ api_key: resolved.apiKey, event, properties: { ...identity, ...sanitizeProps(event, props) } });
1031
+ if (typeof globalThis.navigator?.sendBeacon === "function") globalThis.navigator.sendBeacon(endpoint, new Blob([body], { type: "application/json" }));
1032
+ else void fetch(endpoint, { method: "POST", body, keepalive: true, headers: { "content-type": "application/json" } });
1033
+ };
1034
+ const duration = bucketSessionDuration(Date.now() - sessionStartedAt);
1035
+ // session_id joins the last events of the session.
1036
+ send("app:session_ended", { session_id: posthog.get_session_id?.(), duration_bucket: duration, action_count_bucket: bucketCount(sessionActionCount),
1037
+ scenes_touched: Math.min(20, sessionScenesTouched), ...sessionEditBuckets() });
1038
+ send("app:friction_summary", { active_time: duration, ...frictionSnapshot() });
1135
1039
  } catch {
1136
1040
  // Unload telemetry is best effort.
1137
1041
  }
@@ -8,6 +8,7 @@ import { assetKind, formatAssetBytes } from "./asset-shelf.js";
8
8
  import { assetRecord } from "./scene-asset-cache.js";
9
9
  import ResourceStatus from "./resource-status.jsx";
10
10
  import { logStore } from "./shell/log-store.js";
11
+ import { SearchEmpty } from "./search-friction.js";
11
12
  import "./asset-pane.css";
12
13
 
13
14
  /** Casting assets offered in the Content browser's Characters folder. A rig's `id` doubles as
@@ -396,9 +397,7 @@ function catalogueTile(entry, props) {
396
397
  );
397
398
  }
398
399
 
399
- function EmptyNote({ children }) {
400
- return <p className="content-empty">{children}</p>;
401
- }
400
+ const EmptyNote = ({ children }) => <p className="content-empty">{children}</p>;
402
401
 
403
402
  export function FolderGrid({ folder, query, ...props }) {
404
403
  const { imageAssetIds, meshAssetIds, shots = [], takeVersions = [], poses = [], onShotOpen, onTakeOpen, selectedKey, onSelect } = props;
@@ -504,7 +503,7 @@ export function FolderGrid({ folder, query, ...props }) {
504
503
  if (!poses.length) empty = ko("No saved poses yet. Save one from the pose studio.", "저장된 포즈가 아직 없어요. 포즈 스튜디오에서 저장하세요.");
505
504
  }
506
505
  if (empty) return <EmptyNote>{empty}</EmptyNote>;
507
- if (query && folder !== "props" && !tiles.length) return <EmptyNote>{ko("Nothing matches this search.", "검색 결과가 없어요.")}</EmptyNote>;
506
+ if (query && folder !== "props" && !tiles.length) return <SearchEmpty surface="assets" query={query}><EmptyNote>{ko("Nothing matches this search.", "검색 결과가 없어요.")}</EmptyNote></SearchEmpty>;
508
507
  return <div className="content-grid" role="list">{tiles}</div>;
509
508
  }
510
509
 
@@ -531,7 +530,7 @@ function LogView({ query }) {
531
530
  if (list) list.scrollTop = list.scrollHeight;
532
531
  }, [visible.length, visible[visible.length - 1]?.text]);
533
532
  if (!entries.length) return <EmptyNote>{ko("No events yet this session. Notices, generation jobs and exports appear here.", "이번 세션의 기록이 아직 없어요. 알림, 생성 작업, 내보내기가 여기에 표시됩니다.")}</EmptyNote>;
534
- if (!visible.length) return <EmptyNote>{ko("Nothing matches this search.", "검색 결과가 없어요.")}</EmptyNote>;
533
+ if (!visible.length) return <SearchEmpty surface="assets" query={query}><EmptyNote>{ko("Nothing matches this search.", "검색 결과가 없어요.")}</EmptyNote></SearchEmpty>;
535
534
  return (
536
535
  <ol className="content-log" ref={listRef} data-testid="content-log" aria-live="polite">
537
536
  {visible.map((entry) => (
@@ -0,0 +1,101 @@
1
+ // ui:rage_click and ui:dead_click from one capture-phase pointerdown listener the
2
+ // Studio shell installs (StudioShell.jsx). Real pointer input on a natively disabled
3
+ // button still dispatches pointerdown to a document capture listener (Chrome 155,
4
+ // measured; mousedown and click do not), so no control needs the aria-disabled
5
+ // pattern for its dead clicks to be seen.
6
+ //
7
+ // Only a closed control id leaves (analytics-controls.js), never coordinates, text
8
+ // or element ids. Typing targets and IME composition are ignored outright.
9
+ import { track } from "./analytics.js";
10
+ import { admit, bump } from "./analytics-friction.js";
11
+ import { controlOf } from "./analytics-controls.js";
12
+
13
+ const RAGE_WINDOW_MS = 1000, RAGE_RADIUS_PX = 24, RAGE_MIN = 3, RAGE_COOLDOWN_MS = 10_000, EFFECT_MS = 500;
14
+ const RAGE_PER_CONTROL = 2, DEAD_PER_CONTROL = 2;
15
+ const CONTROL = "[data-fid], button, [role='button'], [role='tab'], [role='menuitem'], a[href]";
16
+
17
+ let send = track, now = () => performance.now();
18
+ let burst = null, burstTimer = null;
19
+ const lastRage = new Map();
20
+ let lastReceiptAt = -Infinity, lastRefusalAt = -Infinity, lastMutationAt = -Infinity;
21
+
22
+ /** The command bus recorded a receipt for a UI-origin command (command-bus-telemetry.js); `ok: false` is a refusal. */
23
+ export function noteBusReceipt(ok) {
24
+ const at = now();
25
+ lastReceiptAt = at;
26
+ if (!ok) lastRefusalAt = at;
27
+ }
28
+
29
+ const typing = (target, event) => event?.isComposing || Boolean(target?.closest?.("input, textarea, select, [contenteditable=''], [contenteditable='true']"));
30
+ const disabledOf = (control) => Boolean(control?.closest?.("[aria-disabled='true'], :disabled"));
31
+ const bucketClicks = (n) => (n >= 6 ? "gte6" : n >= 4 ? "4-5" : "3");
32
+ const emit = (event, props) => { try { send(event, props); } catch { /* telemetry is advisory */ } };
33
+
34
+ function finishBurst() {
35
+ clearTimeout(burstTimer);
36
+ const ended = burst;
37
+ burst = null;
38
+ if (!ended || ended.count < RAGE_MIN) return;
39
+ bump("rage_clicks");
40
+ const at = now(), last = lastRage.get(ended.control) ?? -Infinity;
41
+ if (at - last < RAGE_COOLDOWN_MS) return;
42
+ lastRage.set(ended.control, at);
43
+ if (admit(`ui:rage_click:${ended.control}`, RAGE_PER_CONTROL)) emit("ui:rage_click", { control: ended.control, clicks: bucketClicks(ended.count), changed: ended.changed() });
44
+ }
45
+
46
+ function dead(control, reason) {
47
+ bump("dead_clicks");
48
+ if (admit(`ui:dead_click:${control}`, DEAD_PER_CONTROL)) emit("ui:dead_click", { control, reason });
49
+ }
50
+
51
+ /** One pointerdown, as the shell's capture listener sees it. `x`, `y` stay local (the rage radius). */
52
+ export function notePointerDown({ target, x, y, event, observe = () => () => {} }) {
53
+ // A right or middle click runs no control's action: only the primary button (mouse, pen, touch: 0) counts.
54
+ if ((event?.button ?? 0) !== 0 || typing(target, event)) return;
55
+ const element = target?.closest?.(CONTROL);
56
+ if (!element) return;
57
+ const fid = element.closest("[data-fid]")?.getAttribute("data-fid");
58
+ const control = controlOf(fid);
59
+ const at = now();
60
+ // A rage burst: the same control, within 1 s of its first click and 24 px of where it began.
61
+ if (burst && burst.control === control && burst.element === element && at - burst.at <= RAGE_WINDOW_MS && Math.hypot(x - burst.x, y - burst.y) <= RAGE_RADIUS_PX) {
62
+ burst.count += 1;
63
+ } else {
64
+ finishBurst();
65
+ const since = at;
66
+ burst = { control, element, at, x, y, count: 1, changed: () => lastReceiptAt >= since || lastMutationAt >= since };
67
+ burstTimer = setTimeout(finishBurst, RAGE_WINDOW_MS);
68
+ }
69
+ if (disabledOf(element)) { dead(control, "aria_disabled"); return; }
70
+ // refused / no_effect: watched only on the closed controls, whose clicks are meant to do something.
71
+ if (control === "other") return;
72
+ const stop = observe(() => { lastMutationAt = now(); });
73
+ setTimeout(() => {
74
+ try { stop(); } catch { /* the observer is advisory */ }
75
+ if (lastRefusalAt >= at) dead(control, "refused");
76
+ else if (lastReceiptAt < at && lastMutationAt < at) dead(control, "no_effect");
77
+ }, EFFECT_MS);
78
+ }
79
+
80
+ /** The shell's listener: one capture-phase pointerdown on `root`. Returns its removal. */
81
+ export function installClickFriction(root = globalThis.document) {
82
+ if (!root?.addEventListener) return () => {};
83
+ const observe = (onChange) => {
84
+ if (typeof MutationObserver !== "function") return () => {};
85
+ const observer = new MutationObserver(onChange);
86
+ observer.observe(root.body ?? root, { subtree: true, childList: true, attributes: true, characterData: true });
87
+ return () => observer.disconnect();
88
+ };
89
+ const listener = (event) => { try { notePointerDown({ target: event.target, x: event.clientX, y: event.clientY, event, observe }); } catch { /* never affect a click */ } };
90
+ root.addEventListener("pointerdown", listener, true);
91
+ return () => root.removeEventListener("pointerdown", listener, true);
92
+ }
93
+
94
+ /** Tests only: capture what is sent and drive the clock; returns the restore. */
95
+ export function clickFrictionSeam({ capture = track, clock = () => performance.now() } = {}) {
96
+ const previous = [send, now];
97
+ send = capture; now = clock;
98
+ const reset = () => { clearTimeout(burstTimer); burst = null; lastRage.clear(); lastReceiptAt = lastRefusalAt = lastMutationAt = -Infinity; };
99
+ reset();
100
+ return () => { [send, now] = previous; reset(); };
101
+ }
@@ -0,0 +1,155 @@
1
+ // The command bus's friction signals: studio:refused (one per refusal, capped per
2
+ // key), the history receipts an undo can name, and edit:undone for the user's own
3
+ // undos (domains/scene-undo.js). Only closed values leave: a refusal code, a
4
+ // command group, an edit kind, an age bucket. Never ids, args or messages.
5
+ import { EDIT_GROUPS, recordSemanticEdit, track } from './analytics.js';
6
+ import { createFirstEditRecorder } from './semantic-edit.js';
7
+ import { admit, bump } from './analytics-friction.js';
8
+ import { bucketAge, refusalCodeOf } from './analytics-strict.js';
9
+ import { noteEditGesture } from './post-turn-edits.js';
10
+ import { noteBusReceipt } from './click-friction.js';
11
+
12
+ let send = track, clock = () => Date.now();
13
+ const REFUSALS_PER_KEY = 3, UNDOS_PER_SESSION = 20, STAMPS_KEPT = 500;
14
+
15
+ // Command id prefix to studio:refused group; anything else is "other".
16
+ const GROUPS = [
17
+ [/^objects?\./, 'object'], [/^shot\./, 'shot'], [/^(characters?|cast)\./, 'cast'], [/^scene\./, 'scene'],
18
+ [/^(?:motion|ik)\./, 'motion'], [/^edit\./, 'edit'], [/^task\./, 'task'], [/^agent\./, 'agent'],
19
+ ];
20
+ export const commandGroup = id => GROUPS.find(([pattern]) => pattern.test(String(id)))?.[1] ?? 'other';
21
+
22
+ // The one table from a committed command to its closed edit kind (FIRST_EDIT_KINDS,
23
+ // semantic-edit.js); its group (EDIT_GROUPS, analytics.js) follows from the kind.
24
+ // Recording a camera key is camera_key_record; drawing or clearing a rail (or a
25
+ // setCamera patch naming a rail field) is rail_edit; retiming or removing keys and
26
+ // other camera-block changes are shot_edit, as they were in version 1. Capturing the
27
+ // viewing camera into the shot (after an orbit or fly) is navigation, not an edit.
28
+ // Bone and IK keys are pose; placing or adding a character counts as an object, as it
29
+ // does for object_insert. Everything else is no edit kind.
30
+ const EDIT_KINDS = [
31
+ [/^shot\.captureCamera$/, null],
32
+ [/^shot\.(create|createStill|duplicate|split)$/, 'shot_add'],
33
+ [/^shot\.(setCameraRail|clearCameraRail)$/, 'rail_edit'],
34
+ [/^shot\.addKey$/, 'camera_key_record'],
35
+ [/^shot\./, 'shot_edit'],
36
+ [/^(character\.(setPose|setIkKey|removeIkKey|clearIkKeys)|ik\.|motion\.rangePin\.)/, 'pose_edit'],
37
+ [/^(object\.(add|duplicate|sculpt|createKit)|character\.add)$/, 'object_insert'],
38
+ [/^(objects?\.|character\.(set|remove|update|move)$|characters\.arrange$|cast\.replace$)/, 'object_transform'],
39
+ // The take's fields carry its prompt (motion.take.prompt).
40
+ [/^motion\.set$/, 'prompt_block_edit'],
41
+ ];
42
+ // Built on first use: this module loads inside an import cycle (analytics.js -> semantic-edit.js ->
43
+ // command-bus.js -> here), where EDIT_GROUPS is not initialized yet.
44
+ let groups = null;
45
+ const groupOf = kind => (groups ??= new Map(Object.entries(EDIT_GROUPS).flatMap(([group, kinds]) => kinds.map(entry => [entry, group])))).get(kind);
46
+ /** A command's closed edit kind, or null. `hint`: asset.import's closed `placeAs`, and `rail` when a
47
+ * shot.setCamera patch names a rail field (key names only; values are never read). */
48
+ export function firstEditKindOf(action, { placeAs, rail = false } = {}) {
49
+ if (action === 'asset.import') return placeAs === 'cutout' ? 'cutout_insert' : 'object_insert';
50
+ if (action === 'shot.setCamera' && rail) return 'rail_edit';
51
+ return EDIT_KINDS.find(([pattern]) => pattern.test(String(action)))?.[1] ?? null;
52
+ }
53
+ // The camera block's rail fields (semantic-edit.js rail_edit): a setCamera patch naming one edits the rail.
54
+ const RAIL_FIELDS = ['cameraRail', 'craneHeight', 'railFollow', 'dollyTiming'];
55
+ const railPatch = args => Object.keys(args?.patch ?? {}).some(key => RAIL_FIELDS.includes(key));
56
+ // A drag's begin/update patches, remembered by txId until its commit names the edit.
57
+ const railTransactions = new Set();
58
+ function hintOf(id, args, recorded) {
59
+ if (id === 'run.begin' || id === 'run.update') {
60
+ if (recorded.txId && railPatch(args?.args)) railTransactions.add(recorded.txId);
61
+ while (railTransactions.size > 50) railTransactions.delete(railTransactions.values().next().value);
62
+ return {};
63
+ }
64
+ return { placeAs: args?.placeAs, rail: id === 'run.commit' ? railTransactions.delete(recorded.txId) : railPatch(args) };
65
+ }
66
+ const AGENT_WORK = new Set(['agent.turn.finish', 'task.finish']);
67
+ /** The edit:undone / post-turn group of a command: pose, camera, object, shot, prompt, agent_turn or other. */
68
+ export function editKind(action, origin = 'ui', hint) {
69
+ // What the agent made (its turn, a task, or an edit it ran on its own) is undone as agent work.
70
+ if (AGENT_WORK.has(action) || origin !== 'ui') return 'agent_turn';
71
+ return groupOf(firstEditKindOf(action, hint)) ?? 'other';
72
+ }
73
+
74
+ // The user's own edits feed the session edit buckets and the surface's first edit
75
+ // (definition_version 2). App starts a new mount boundary with startUserEdits.
76
+ let editSurface = 'craft', firstEdit = createFirstEditRecorder((event, props) => send(event, props));
77
+ export function startUserEdits(surface) {
78
+ editSurface = surface;
79
+ firstEdit = createFirstEditRecorder((event, props) => send(event, props));
80
+ }
81
+
82
+ const emit = (event, props) => {
83
+ try { send(event, props); } catch { /* telemetry is advisory */ }
84
+ };
85
+
86
+ /** A refusal receipt: the session counter always moves; the event, at most 3 times per (code, group, origin). */
87
+ export function noteRefusal(value, origin, id) {
88
+ // A cancel the caller asked for is not a refusal. A UI one tells a click it was refused (click-friction.js);
89
+ // an agent's or MCP client's refusal at the same moment is not the user's click.
90
+ if (value.code === 'CANCELLED') return;
91
+ if (origin === 'ui') noteBusReceipt(false);
92
+ const by = origin === 'ui' ? 'ui' : 'agent', code = refusalCodeOf(value.code), group = commandGroup(id);
93
+ bump(by === 'ui' ? 'refusals_ui' : 'refusals_agent');
94
+ if (admit(`studio:refused:${code}:${group}:${by}`, REFUSALS_PER_KEY)) emit('studio:refused', { code, origin: by, phase: value.phase, group });
95
+ }
96
+
97
+ // receiptId -> { at, kind }: when a history receipt was recorded and what it did.
98
+ const stamps = new Map();
99
+
100
+ /**
101
+ * The bus's history receipts by history entry id (what edit.undo restores), each
102
+ * stamped when it was recorded. Gesture rule: a drag is one transaction whose
103
+ * receipt is recorded at run.commit, and any later receipt naming the same entry
104
+ * replaces the earlier one, so an entry's age runs from its LAST receipt: the
105
+ * time since the user finished that edit, not since they started it.
106
+ */
107
+ export function createHistoryReceipts(ports) {
108
+ const receipts = new Map();
109
+ return {
110
+ get: historyEntryId => receipts.get(historyEntryId),
111
+ /** `id` the command, `action` the command a transaction ran (run.commit of a drag). */
112
+ remember(recorded, { id, origin, action, args }) {
113
+ const hint = hintOf(id, args, recorded);
114
+ if (origin === 'ui' && recorded.ok !== false) noteBusReceipt(true);
115
+ // Session friction: an undo the user made (agent undos are the agent's own correction).
116
+ if (id === 'edit.undo' && origin === 'ui' && recorded.status === 'undone') bump('undos');
117
+ // The user's edit that changed the scene, once per receipt (a drag commits once): the session edit
118
+ // buckets, the first edit and agent:post_turn_edit. Agent, MCP and undo/redo receipts never count.
119
+ if (origin === 'ui' && recorded.authored === true && !id.startsWith('edit.')) noteUserEdit(firstEditKindOf(action ?? id, hint));
120
+ if (recorded.undo?.canUndoDirect && !id.startsWith('edit.')) {
121
+ receipts.set(recorded.undo.historyEntryId, recorded);
122
+ stamps.set(recorded.receiptId, { at: clock(), kind: editKind(action ?? id, origin, hint) });
123
+ while (stamps.size > STAMPS_KEPT) stamps.delete(stamps.keys().next().value);
124
+ }
125
+ for (const [key, receipt] of receipts) if (ports.isRetained?.(receipt) === false) { receipts.delete(key); stamps.delete(receipt.receiptId); }
126
+ },
127
+ };
128
+ }
129
+
130
+ let userEditAt = -Infinity;
131
+ /** When the user's own last edit receipt was recorded (bus clock), or -Infinity: the thrash detectors' "no edit in between". */
132
+ export const lastUserEditAt = () => userEditAt;
133
+ function noteUserEdit(kind) {
134
+ if (!kind) return;
135
+ userEditAt = clock();
136
+ try { recordSemanticEdit(kind); firstEdit(editSurface, kind); } catch { /* telemetry is advisory */ }
137
+ noteEditGesture(groupOf(kind));
138
+ }
139
+
140
+ const RESULTS = { undone: 'done', noop: 'noop' };
141
+ /** The user's undo (`via` key, menu or button): its receipt, or null for an IK drag preview backed out. */
142
+ export function noteUndone(via, receipt) {
143
+ const result = !receipt ? 'done' : receipt.ok === false ? 'refused' : RESULTS[receipt.status];
144
+ if (!result || !admit('edit:undone', UNDOS_PER_SESSION)) return;
145
+ // A native history entry no receipt names (or nothing undone at all) is "other", with no age.
146
+ const stamp = result === 'done' && receipt ? stamps.get(receipt.undoneReceiptId) : null;
147
+ emit('edit:undone', { kind: receipt ? stamp?.kind ?? 'other' : 'ik_preview', via, result, ...(stamp ? { age: bucketAge(clock() - stamp.at) } : {}) });
148
+ }
149
+
150
+ /** Tests only: capture what is sent and drive the clock; returns the restore. */
151
+ export function telemetrySeam({ track: capture = track, now = () => Date.now() } = {}) {
152
+ const previous = [send, clock];
153
+ send = capture; clock = now;
154
+ return () => { [send, clock] = previous; stamps.clear(); };
155
+ }