@hyperframes/studio 0.7.106 → 0.7.108

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 (175) hide show
  1. package/dist/assets/{hyperframes-player-CvVcx_CV.js → hyperframes-player-QqavTWRK.js} +1 -1
  2. package/dist/assets/{index-CRzCCuPH.js → index-BQjXA1pz.js} +1 -1
  3. package/dist/assets/{index-BbYg_isc.js → index-BhHHMprP.js} +1 -1
  4. package/dist/assets/index-DNkh9mbV.css +1 -0
  5. package/dist/assets/index-GqONLcOl.js +428 -0
  6. package/dist/{chunk-OBAG3GWK.js → chunk-AZYHQC6V.js} +6 -7
  7. package/dist/chunk-AZYHQC6V.js.map +1 -0
  8. package/dist/{domEditingLayers-AT7G6F4L.js → domEditingLayers-7AMZ7GFI.js} +4 -2
  9. package/dist/index.d.ts +86 -3
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +24325 -17831
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/components/EditorShell.tsx +0 -2
  15. package/src/components/StudioErrorBoundary.test.tsx +97 -0
  16. package/src/components/StudioErrorBoundary.tsx +7 -0
  17. package/src/components/StudioOverlays.tsx +15 -13
  18. package/src/components/StudioRightPanel.tsx +18 -1
  19. package/src/components/TimelineToolbar.test.tsx +46 -0
  20. package/src/components/TimelineToolbar.tsx +17 -0
  21. package/src/components/editor/DomEditOverlay.tsx +36 -18
  22. package/src/components/editor/DomEditSelectionChrome.test.tsx +85 -0
  23. package/src/components/editor/DomEditSelectionChrome.tsx +29 -3
  24. package/src/components/editor/InlineTextToolbar.test.tsx +296 -0
  25. package/src/components/editor/InlineTextToolbar.tsx +281 -0
  26. package/src/components/editor/OffCanvasIndicators.tsx +38 -0
  27. package/src/components/editor/PropertyPanel.test.tsx +5 -1
  28. package/src/components/editor/PropertyPanel.tsx +7 -23
  29. package/src/components/editor/PropertyPanelFlat.tsx +25 -0
  30. package/src/components/editor/audioFxSummary.test.ts +75 -0
  31. package/src/components/editor/audioFxSummary.ts +36 -0
  32. package/src/components/editor/audioFxTelemetry.test.ts +129 -0
  33. package/src/components/editor/audioFxTelemetry.ts +230 -0
  34. package/src/components/editor/domEditInlineText.test.ts +86 -0
  35. package/src/components/editor/domEditInlineText.ts +99 -0
  36. package/src/components/editor/domEditInlineTextElement.test.ts +59 -0
  37. package/src/components/editor/domEditing.ts +1 -0
  38. package/src/components/editor/domEditingLayers.ts +7 -6
  39. package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
  40. package/src/components/editor/inlineTextStyleRange.ts +593 -0
  41. package/src/components/editor/inlineTextStyleRead.ts +47 -0
  42. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
  43. package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
  44. package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
  45. package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
  46. package/src/components/editor/propertyPanelAutomation.ts +97 -0
  47. package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
  48. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  49. package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
  50. package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
  51. package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
  52. package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
  53. package/src/components/editor/propertyPanelFxControls.tsx +356 -0
  54. package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
  55. package/src/components/editor/propertyPanelFxFamily.ts +83 -0
  56. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
  57. package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
  58. package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
  59. package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
  60. package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
  61. package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
  62. package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
  63. package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
  64. package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
  65. package/src/components/editor/propertyPanelFxSection.tsx +550 -0
  66. package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
  67. package/src/components/editor/propertyPanelTypes.ts +5 -0
  68. package/src/components/editor/useDomEditNudge.ts +2 -2
  69. package/src/components/editor/useFxAudition.ts +95 -0
  70. package/src/components/editor/useFxCarve.ts +0 -0
  71. package/src/components/editor/useFxChainObserved.ts +83 -0
  72. package/src/components/editor/useFxLevelling.ts +235 -0
  73. package/src/components/editor/useInlineTextEditing.tsx +119 -0
  74. package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
  75. package/src/components/editor/useVolumeAutomation.ts +54 -0
  76. package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
  77. package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
  78. package/src/components/feedback/feedbackTrigger.test.ts +168 -0
  79. package/src/components/feedback/feedbackTrigger.ts +269 -0
  80. package/src/components/feedback/projectProvenance.test.ts +120 -0
  81. package/src/components/feedback/projectProvenance.ts +83 -0
  82. package/src/components/renders/useRenderQueue.ts +62 -4
  83. package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
  84. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
  85. package/src/contexts/DomEditContext.tsx +15 -0
  86. package/src/hooks/domEditPersistFailure.ts +0 -7
  87. package/src/hooks/useAppHotkeys.test.ts +198 -0
  88. package/src/hooks/useAppHotkeys.ts +31 -4
  89. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
  90. package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
  91. package/src/hooks/useDomEditAttributeCommits.ts +90 -10
  92. package/src/hooks/useDomEditCommits.test.tsx +141 -21
  93. package/src/hooks/useDomEditCommits.ts +4 -0
  94. package/src/hooks/useDomEditSession.ts +4 -0
  95. package/src/hooks/useDomEditTextCommits.ts +112 -11
  96. package/src/hooks/useElementLifecycleOps.ts +3 -0
  97. package/src/hooks/useFileTree.ts +8 -3
  98. package/src/hooks/useInlineTextEdit.test.tsx +555 -0
  99. package/src/hooks/useInlineTextEdit.ts +320 -0
  100. package/src/hooks/useLivePlayheadTime.ts +49 -0
  101. package/src/hooks/usePreviewPersistence.ts +7 -0
  102. package/src/hooks/useTimelineEditing.ts +3 -0
  103. package/src/player/components/AutomationSelectionMenu.tsx +76 -0
  104. package/src/player/components/AutomationValueInput.tsx +51 -0
  105. package/src/player/components/LayerDisclosureRow.tsx +5 -4
  106. package/src/player/components/Timeline.test.ts +84 -0
  107. package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
  108. package/src/player/components/TimelineAutomationLane.tsx +674 -0
  109. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
  110. package/src/player/components/TimelineLanes.test.tsx +24 -5
  111. package/src/player/components/TimelineLanes.tsx +142 -154
  112. package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
  113. package/src/player/components/TimelineTrackHeader.tsx +166 -6
  114. package/src/player/components/TimelineTrackRow.tsx +8 -1
  115. package/src/player/components/automationClipboard.test.ts +94 -0
  116. package/src/player/components/automationClipboard.ts +136 -0
  117. package/src/player/components/automationGestureKeys.test.ts +49 -0
  118. package/src/player/components/automationGestureKeys.ts +49 -0
  119. package/src/player/components/automationLaneData.test.ts +270 -0
  120. package/src/player/components/automationLaneData.ts +258 -0
  121. package/src/player/components/automationLaneDragMath.ts +194 -0
  122. package/src/player/components/automationLaneGeometry.test.ts +239 -0
  123. package/src/player/components/automationLaneGeometry.ts +287 -0
  124. package/src/player/components/automationLaneHeight.ts +15 -0
  125. package/src/player/components/automationLanePointer.ts +20 -0
  126. package/src/player/components/automationLaneSelection.test.ts +183 -0
  127. package/src/player/components/automationLaneSelection.ts +135 -0
  128. package/src/player/components/automationShapes.test.ts +82 -0
  129. package/src/player/components/automationShapes.ts +70 -0
  130. package/src/player/components/automationSimplify.test.ts +48 -0
  131. package/src/player/components/automationSimplify.ts +51 -0
  132. package/src/player/components/timelineClipGestureHandlers.ts +223 -0
  133. package/src/player/components/timelineLaneProps.ts +19 -0
  134. package/src/player/components/timelineLayout.ts +11 -5
  135. package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
  136. package/src/player/components/useAutomationEdgeStretch.ts +269 -0
  137. package/src/player/components/useAutomationLaneGestures.ts +537 -0
  138. package/src/player/components/useAutomationLanes.test.tsx +112 -0
  139. package/src/player/components/useAutomationLanes.ts +170 -0
  140. package/src/player/components/useAutomationRangeDrag.ts +114 -0
  141. package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
  142. package/src/player/components/useTimelineTrackLayout.ts +83 -7
  143. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  144. package/src/player/hooks/useTimelinePlayer.ts +13 -1
  145. package/src/player/lib/automationStoreSync.test.ts +83 -0
  146. package/src/player/lib/automationStoreSync.ts +56 -0
  147. package/src/player/lib/playbackShortcuts.ts +5 -4
  148. package/src/player/lib/timelineDOM.test.ts +33 -0
  149. package/src/player/lib/timelineDOM.ts +12 -0
  150. package/src/player/store/automationSelectionSlice.test.ts +30 -0
  151. package/src/player/store/automationSelectionSlice.ts +54 -0
  152. package/src/player/store/playerStore.test.ts +15 -0
  153. package/src/player/store/playerStore.ts +54 -65
  154. package/src/player/store/timelineElement.ts +78 -0
  155. package/src/styles/studio.css +89 -0
  156. package/src/telemetry/agentRuntime.test.ts +45 -0
  157. package/src/telemetry/agentRuntime.ts +62 -0
  158. package/src/telemetry/breadcrumbs.test.ts +68 -0
  159. package/src/telemetry/breadcrumbs.ts +70 -0
  160. package/src/telemetry/client.ts +5 -0
  161. package/src/telemetry/events.ts +84 -3
  162. package/src/telemetry/system.ts +8 -0
  163. package/src/utils/sourcePatcher.ts +5 -1
  164. package/src/utils/studioFileHistory.test.ts +49 -0
  165. package/src/utils/studioFileHistory.ts +17 -1
  166. package/src/utils/studioHelpers.ts +2 -5
  167. package/src/utils/studioTelemetry.ts +16 -10
  168. package/src/utils/timelineDiscovery.ts +3 -24
  169. package/src/utils/typingTarget.test.ts +54 -0
  170. package/src/utils/typingTarget.ts +43 -0
  171. package/dist/assets/index-DerI0ikN.js +0 -428
  172. package/dist/assets/index-tBPidglp.css +0 -1
  173. package/dist/chunk-OBAG3GWK.js.map +0 -1
  174. package/src/components/StudioFeedbackBar.tsx +0 -217
  175. /package/dist/{domEditingLayers-AT7G6F4L.js.map → domEditingLayers-7AMZ7GFI.js.map} +0 -0
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Which coding agent, if any, is driving this Studio.
3
+ *
4
+ * Studio cannot detect this. The signal lives entirely in the environment of
5
+ * the CLI process — `CLAUDECODE`, `CURSOR_TRACE_ID` and friends — which the
6
+ * browser has no access to. So the CLI classifies it once
7
+ * (`cli/src/telemetry/agent_runtime.ts`) and publishes the resulting category
8
+ * into the served page as `window.__HF_CLI_AGENT_RUNTIME`, alongside the
9
+ * distinct id and the canary decisions.
10
+ *
11
+ * Null is the common, correct answer: a Studio opened by hand, or served by
12
+ * Vite in development, has no CLI to ask. Reading it as "no agent" rather than
13
+ * "unknown" is the honest default — every agent we can name sets a marker, and
14
+ * an unnamed one is indistinguishable from a person either way.
15
+ *
16
+ * Memoized, matching `distinctId.ts`: the value is fixed for the life of the
17
+ * page, and a caller that reads it per event should not pay for a global lookup
18
+ * and a type check every time.
19
+ */
20
+
21
+ declare global {
22
+ interface Window {
23
+ __HF_CLI_AGENT_RUNTIME?: string;
24
+ }
25
+ }
26
+
27
+ let resolved: string | null | undefined;
28
+
29
+ export function resolveAgentRuntime(): string | null {
30
+ if (resolved !== undefined) return resolved;
31
+ if (typeof window === "undefined") {
32
+ resolved = null;
33
+ return resolved;
34
+ }
35
+ const raw = window.__HF_CLI_AGENT_RUNTIME;
36
+ // A non-empty string only. An empty string means the CLI had nothing to say,
37
+ // and sending `""` would land in PostHog as a distinct value from absent —
38
+ // two encodings of the same fact, which is exactly what makes a breakdown
39
+ // read as a gap that is not there.
40
+ resolved = typeof raw === "string" && raw.length > 0 ? raw : null;
41
+ return resolved;
42
+ }
43
+
44
+ /**
45
+ * The same answer as a telemetry property: never null, never absent.
46
+ *
47
+ * "No agent" is a real, common finding — most sessions are people — so it needs
48
+ * a value that survives a breakdown. Encoding it as an omitted property would
49
+ * make "a person used this" indistinguishable from "this event predates the
50
+ * property", and the two studio transports type their payloads differently
51
+ * (one permits null, one does not), so left alone they would encode the same
52
+ * fact two ways. That mismatch has already produced one wrong conclusion in
53
+ * this project's telemetry; a shared sentinel is what stops it.
54
+ */
55
+ export function agentRuntimeProperty(): string {
56
+ return resolveAgentRuntime() ?? "none";
57
+ }
58
+
59
+ /** Test seam: the memo would otherwise leak between cases in one module load. */
60
+ export function resetAgentRuntimeForTests(): void {
61
+ resolved = undefined;
62
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { recordBreadcrumb, breadcrumbTrail, resetBreadcrumbs } from "./breadcrumbs";
3
+
4
+ beforeEach(() => resetBreadcrumbs());
5
+
6
+ describe("breadcrumbTrail", () => {
7
+ it("is empty before anything happens", () => {
8
+ expect(breadcrumbTrail()).toBe("");
9
+ });
10
+
11
+ it("records events oldest first with a seconds stamp", () => {
12
+ recordBreadcrumb("studio_session_start", {});
13
+ recordBreadcrumb("studio_render_start", {});
14
+ const trail = breadcrumbTrail();
15
+ expect(trail).toMatch(/^\d+\.\d session_start > \d+\.\d render_start$/);
16
+ });
17
+
18
+ it("strips the studio prefix from both event naming styles", () => {
19
+ recordBreadcrumb("studio_render_start", {});
20
+ recordBreadcrumb("studio:save_failure", {});
21
+ expect(breadcrumbTrail()).toContain("render_start");
22
+ expect(breadcrumbTrail()).toContain("save_failure");
23
+ expect(breadcrumbTrail()).not.toContain("studio_");
24
+ expect(breadcrumbTrail()).not.toContain("studio:");
25
+ });
26
+
27
+ it("keeps a short discriminator so an event says where it happened", () => {
28
+ recordBreadcrumb("studio:tab_switch", { tab: "renders" });
29
+ expect(breadcrumbTrail()).toContain("tab_switch:renders");
30
+ });
31
+
32
+ it("rolls, keeping the most recent run-up rather than the oldest history", () => {
33
+ for (let i = 0; i < 40; i++) recordBreadcrumb(`studio_step_${i}`, {});
34
+ const trail = breadcrumbTrail();
35
+ expect(trail).toContain("step_39");
36
+ expect(trail).not.toContain("step_0 ");
37
+ expect(trail.split(" > ")).toHaveLength(14);
38
+ });
39
+
40
+ describe("privacy", () => {
41
+ it("never copies free text, however it is spelled", () => {
42
+ recordBreadcrumb("studio_feedback", {
43
+ comment: "my secret unreleased product name",
44
+ error_message: "/Users/someone/private/path.html",
45
+ stack_trace: "at Object.<anonymous> (/Users/someone/project.ts:1:1)",
46
+ });
47
+ const trail = breadcrumbTrail();
48
+ expect(trail).not.toContain("secret");
49
+ expect(trail).not.toContain("/Users/");
50
+ expect(trail).toContain("feedback");
51
+ });
52
+
53
+ it("drops an allowlisted value that is long enough to be prose", () => {
54
+ recordBreadcrumb("studio_thing", {
55
+ action: "a".repeat(200),
56
+ });
57
+ expect(breadcrumbTrail()).toContain("thing");
58
+ expect(breadcrumbTrail()).not.toContain("aaaa");
59
+ });
60
+
61
+ it("takes numbers and booleans from allowlisted keys", () => {
62
+ recordBreadcrumb("studio_a", { status: 500 });
63
+ recordBreadcrumb("studio_b", { mode: false });
64
+ expect(breadcrumbTrail()).toContain("a:500");
65
+ expect(breadcrumbTrail()).toContain("b:false");
66
+ });
67
+ });
68
+ });
@@ -0,0 +1,70 @@
1
+ // ---------------------------------------------------------------------------
2
+ // A short trail of what the user did before they reported something.
3
+ //
4
+ // A feedback comment says what went wrong; it almost never says how to get
5
+ // there. This is the missing half. Every `studio_*` event already flows through
6
+ // one funnel (`trackEvent`), so recording the trail costs one call there and no
7
+ // new instrumentation anywhere else — and it stays correct as events are added.
8
+ //
9
+ // PRIVACY: names and numbers only. Values are copied from a fixed allowlist of
10
+ // short, low-cardinality keys, so free text (comments, file paths, composition
11
+ // content) can never reach the trail even if some future event carries it.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /** Enough to cover the run-up to a failure without bloating the payload. */
15
+ const LIMIT = 14;
16
+
17
+ /**
18
+ * Keys worth keeping alongside an event name. Each is a short enum-ish
19
+ * discriminator: `tab_switch` alone says little, `tab_switch:renders` says
20
+ * where they were. Anything not on this list is dropped, not truncated.
21
+ */
22
+ const DETAIL_KEYS = [
23
+ "action",
24
+ "tab",
25
+ "panel",
26
+ "status",
27
+ "mode",
28
+ "reason",
29
+ "format",
30
+ "via",
31
+ ] as const;
32
+
33
+ interface Crumb {
34
+ at: number;
35
+ label: string;
36
+ }
37
+
38
+ const trail: Crumb[] = [];
39
+ const startedAt = Date.now();
40
+
41
+ function detailFor(properties: Record<string, unknown>): string {
42
+ for (const key of DETAIL_KEYS) {
43
+ const value = properties[key];
44
+ if (typeof value === "string" && value.length > 0 && value.length <= 24) return `:${value}`;
45
+ if (typeof value === "number" || typeof value === "boolean") return `:${String(value)}`;
46
+ }
47
+ return "";
48
+ }
49
+
50
+ export function recordBreadcrumb(event: string, properties: Record<string, unknown>): void {
51
+ // `studio:` and `studio_` prefixes are noise in a 14-item trail.
52
+ const name = event.replace(/^studio[:_]/, "");
53
+ trail.push({ at: Date.now() - startedAt, label: `${name}${detailFor(properties)}` });
54
+ if (trail.length > LIMIT) trail.shift();
55
+ }
56
+
57
+ /**
58
+ * The trail as one line, oldest first, each entry stamped with seconds since
59
+ * the tab opened: `2.1 session_start > 48.7 render_start > 71.2 save_failure`.
60
+ * A single string rather than an array so it stays readable in a PostHog cell
61
+ * and in whatever the report is pasted into.
62
+ */
63
+ export function breadcrumbTrail(): string {
64
+ return trail.map((c) => `${(c.at / 1000).toFixed(1)} ${c.label}`).join(" > ");
65
+ }
66
+
67
+ /** Test seam. Production never clears the trail; it rolls. */
68
+ export function resetBreadcrumbs(): void {
69
+ trail.length = 0;
70
+ }
@@ -8,6 +8,7 @@ import { getAnonymousId, hasShownNotice, markNoticeShown } from "./config";
8
8
  import { browserTelemetryAllowed } from "./policy";
9
9
  import { getBrowserSystemMeta } from "./system";
10
10
  import { canaryEventProperties } from "./canary";
11
+ import { recordBreadcrumb } from "./breadcrumbs";
11
12
 
12
13
  // Write-only PostHog project key, safe to embed in client code.
13
14
  const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
@@ -37,6 +38,10 @@ export function shouldTrack(): boolean {
37
38
  export function trackEvent(event: string, properties: EventProperties = {}): void {
38
39
  if (!shouldTrack()) return;
39
40
 
41
+ // Every studio event passes through here, so this is the one place that can
42
+ // build a repro trail without asking each call site to opt in.
43
+ recordBreadcrumb(event, properties);
44
+
40
45
  const sys = getBrowserSystemMeta();
41
46
  eventQueue.push({
42
47
  event,
@@ -1,4 +1,5 @@
1
1
  import { trackEvent } from "./client";
2
+ import { breadcrumbTrail } from "./breadcrumbs";
2
3
 
3
4
  // Studio frontend events. The corresponding `render_complete` / `render_error`
4
5
  // events are emitted server-side by `packages/cli/src/server/studioServer.ts`
@@ -94,14 +95,94 @@ export function trackStudioSegmentEaseEdit(props: {
94
95
  trackEvent("studio_segment_ease_edit", { action: props.action, ease: props.ease });
95
96
  }
96
97
 
97
- export function trackStudioFeedback(props: { rating: number; comment?: string }): void {
98
+ /**
99
+ * Context shared by every event in the feedback funnel, so `shown` →
100
+ * `dismissed` / `studio_feedback` can be read as one funnel broken down by the
101
+ * moment that triggered it. Without `shown` there is no way to tell a prompt
102
+ * nobody answers from a prompt that never renders.
103
+ */
104
+ interface StudioFeedbackContext {
105
+ /** What the prompt is about: "render_complete" | "render_failed". */
106
+ reason: string;
107
+ /** Render job the prompt followed — joins the response to that render. */
108
+ render_id?: string;
109
+ }
110
+
111
+ export function trackStudioFeedbackShown(ctx: StudioFeedbackContext): void {
112
+ trackEvent("studio_feedback_shown", {
113
+ reason: ctx.reason,
114
+ render_id: ctx.render_id,
115
+ source: "studio",
116
+ });
117
+ }
118
+
119
+ export function trackStudioFeedbackDismissed(
120
+ ctx: StudioFeedbackContext & {
121
+ /** "close" | "escape" | "timeout" — separates rejection from inattention. */
122
+ via: string;
123
+ /** A dismiss after picking a rating is an abandon, not a refusal. */
124
+ had_rating: boolean;
125
+ },
126
+ ): void {
127
+ trackEvent("studio_feedback_dismissed", {
128
+ reason: ctx.reason,
129
+ render_id: ctx.render_id,
130
+ via: ctx.via,
131
+ had_rating: ctx.had_rating,
132
+ source: "studio",
133
+ });
134
+ }
135
+
136
+ /**
137
+ * The booking link offered after a response. Its own event because the thing
138
+ * worth measuring is the click, and a link is otherwise invisible to us.
139
+ */
140
+ export function trackStudioFeedbackInterviewClick(ctx: { reason: string }): void {
141
+ trackEvent("studio_feedback_interview_click", {
142
+ reason: ctx.reason,
143
+ source: "studio",
144
+ });
145
+ }
146
+
147
+ export function trackStudioFeedback(
148
+ props: StudioFeedbackContext & {
149
+ /**
150
+ * Absent on the failure prompt, which asks what broke instead of scoring a
151
+ * render the user never got. A fabricated rating would poison the average.
152
+ */
153
+ rating?: number;
154
+ comment?: string;
155
+ /**
156
+ * Which follow-up the comment answers ("remove" | "borrow" | "fix" |
157
+ * "detractor" | "failure"). One card asks one question, rotated across
158
+ * users, so this is what makes the free text separable.
159
+ */
160
+ question: string;
161
+ /** "preset" (a tapped chip) or "typed". Never mix them when counting. */
162
+ answer_kind: string;
163
+ /**
164
+ * Reproduction context from whatever produced the prompt: render settings,
165
+ * outcome, counts. Flattened onto the event so each key is filterable in
166
+ * PostHog rather than buried in a JSON blob nobody can group by.
167
+ */
168
+ context?: Record<string, string | number | boolean | undefined>;
169
+ },
170
+ ): void {
98
171
  // Plain product event, not a PostHog survey response: nothing here is served
99
172
  // by the surveys product (no survey definition, no targeting, no popover).
100
173
  trackEvent("studio_feedback", {
101
- rating: props.rating,
102
- rating_scale: 10,
174
+ ...(props.rating === undefined ? {} : { rating: props.rating, rating_scale: 10 }),
103
175
  ...(props.comment ? { comment: props.comment } : {}),
176
+ ...props.context,
177
+ reason: props.reason,
178
+ render_id: props.render_id,
179
+ question: props.question,
180
+ answer_kind: props.answer_kind,
104
181
  doctor_summary: getBrowserDoctorSummary(),
182
+ // What the user did in the run-up. A comment says what broke; this says
183
+ // how to get there, which is the half a bug report is usually missing.
184
+ // Read AFTER the context spread so no caller can shadow it.
185
+ breadcrumbs: breadcrumbTrail(),
105
186
  source: "studio",
106
187
  });
107
188
  }
@@ -4,6 +4,8 @@
4
4
  // No PII — only environment characteristics useful for product analytics.
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
+ import { agentRuntimeProperty } from "./agentRuntime";
8
+
7
9
  export interface BrowserSystemMeta {
8
10
  user_agent: string;
9
11
  language: string;
@@ -13,6 +15,8 @@ export interface BrowserSystemMeta {
13
15
  timezone_offset_minutes: number;
14
16
  is_mobile: boolean;
15
17
  studio_version: string;
18
+ /** Which coding agent is driving this Studio, or null when a person is. */
19
+ agent_runtime: string;
16
20
  }
17
21
 
18
22
  const EMPTY_META: BrowserSystemMeta = {
@@ -24,6 +28,7 @@ const EMPTY_META: BrowserSystemMeta = {
24
28
  timezone_offset_minutes: 0,
25
29
  is_mobile: false,
26
30
  studio_version: "dev",
31
+ agent_runtime: "none",
27
32
  };
28
33
 
29
34
  let cached: BrowserSystemMeta | null = null;
@@ -46,6 +51,9 @@ export function getBrowserSystemMeta(): BrowserSystemMeta {
46
51
  timezone_offset_minutes: new Date().getTimezoneOffset(),
47
52
  is_mobile: /Android|iPhone|iPad/i.test(ua),
48
53
  studio_version: typeof __STUDIO_VERSION__ !== "undefined" ? __STUDIO_VERSION__ : "dev",
54
+ // Same value the `studio:*` transport attaches, from the same accessor —
55
+ // two families that disagreed here would split every agent breakdown.
56
+ agent_runtime: agentRuntimeProperty(),
49
57
  };
50
58
  return cached;
51
59
  }
@@ -87,7 +87,11 @@ function splitInlineStyleDeclarations(style: string): string[] {
87
87
  }
88
88
 
89
89
  export interface PatchOperation {
90
- type: "inline-style" | "attribute" | "text-content" | "html-attribute";
90
+ // `rich-text` is the only member that carries markup. It is deliberately
91
+ // separate from `text-content`, whose contract is "this value is text": the
92
+ // design panel and every other caller rely on that, and widening it would
93
+ // have turned all of them into markup sinks at once.
94
+ type: "inline-style" | "attribute" | "text-content" | "html-attribute" | "rich-text";
91
95
  property: string;
92
96
  value: string | null;
93
97
  childSelector?: string;
@@ -29,6 +29,55 @@ describe("saveProjectFilesWithHistory", () => {
29
29
  });
30
30
  });
31
31
 
32
+ /**
33
+ * Deleting a clip POSTs `remove-element`, which rewrites the file server-side,
34
+ * and only then saves the duration shrink. Expecting the content read before
35
+ * the mutation made the server refuse that write as a conflict: the save queue
36
+ * paused on the 409 and the clip stayed on the timeline until a reload.
37
+ */
38
+ it("expects what is on disk, not the undo baseline, when they differ", async () => {
39
+ const expectations: Record<string, string | undefined> = {};
40
+ const recordEdit = vi.fn();
41
+
42
+ await saveProjectFilesWithHistory({
43
+ projectId: "project-1",
44
+ label: "Delete timeline clip",
45
+ kind: "timeline",
46
+ files: { "index.html": "removed+shrunk" },
47
+ readFile: async () => "original",
48
+ diskContent: { "index.html": "removed" },
49
+ writeFile: async (path, _content, expectedContent) => {
50
+ expectations[path] = expectedContent;
51
+ },
52
+ recordEdit,
53
+ });
54
+
55
+ expect(expectations["index.html"]).toBe("removed");
56
+ // Undo still goes all the way back, which is the whole reason the two are
57
+ // allowed to differ.
58
+ expect(recordEdit).toHaveBeenCalledWith(
59
+ expect.objectContaining({
60
+ files: { "index.html": { before: "original", after: "removed+shrunk" } },
61
+ }),
62
+ );
63
+ });
64
+
65
+ it("still expects the undo baseline when nothing says otherwise", async () => {
66
+ const expectations: Record<string, string | undefined> = {};
67
+ await saveProjectFilesWithHistory({
68
+ projectId: "project-1",
69
+ label: "Move layer",
70
+ kind: "manual",
71
+ files: { "index.html": "after" },
72
+ readFile: async () => "before",
73
+ writeFile: async (path, _content, expectedContent) => {
74
+ expectations[path] = expectedContent;
75
+ },
76
+ recordEdit: vi.fn(),
77
+ });
78
+ expect(expectations["index.html"]).toBe("before");
79
+ });
80
+
32
81
  it("skips writes and history for unchanged content", async () => {
33
82
  const writeFile = vi.fn();
34
83
  const recordEdit = vi.fn();
@@ -34,6 +34,21 @@ interface SaveProjectFilesWithHistoryInput {
34
34
  readFile: (path: string) => Promise<string>;
35
35
  writeFile: ProjectFileWriter;
36
36
  recordEdit: (entry: RecordEditInput) => Promise<void>;
37
+ /**
38
+ * What a path holds ON DISK right now, when that is not the same as the
39
+ * history's "before".
40
+ *
41
+ * The two are normally one value, so the write's optimistic-concurrency
42
+ * expectation was taken straight from the undo baseline. They come apart when
43
+ * a server-side mutation has already written part of the edit: deleting a clip
44
+ * POSTs `remove-element`, which rewrites the file, and only then saves the
45
+ * duration shrink — expecting the pre-delete content it read at the start. The
46
+ * server had moved the file on, so the write was refused as a conflict, the
47
+ * save queue paused, and the clip stayed on the timeline until a reload.
48
+ *
49
+ * Undo still restores `before`; this only says what to expect on disk.
50
+ */
51
+ diskContent?: Record<string, string>;
37
52
  }
38
53
 
39
54
  export async function readProjectFileContent(pid: string, path: string): Promise<string> {
@@ -57,6 +72,7 @@ export async function saveProjectFilesWithHistory({
57
72
  readFile,
58
73
  writeFile,
59
74
  recordEdit,
75
+ diskContent,
60
76
  }: SaveProjectFilesWithHistoryInput): Promise<string[]> {
61
77
  return serializeStudioFileMutations(writeFile, Object.keys(files), async () => {
62
78
  const snapshots: Record<string, { before: string; after: string }> = {};
@@ -73,7 +89,7 @@ export async function saveProjectFilesWithHistory({
73
89
  const writtenPaths: string[] = [];
74
90
  try {
75
91
  for (const path of changedPaths) {
76
- await writeFile(path, snapshots[path].after, snapshots[path].before);
92
+ await writeFile(path, snapshots[path].after, diskContent?.[path] ?? snapshots[path].before);
77
93
  writtenPaths.push(path);
78
94
  }
79
95
 
@@ -1,3 +1,4 @@
1
+ import { isTypingTarget } from "./typingTarget";
1
2
  import type { TimelineElement } from "../player/store/playerStore";
2
3
  import type { DomEditSelection } from "../components/editor/domEditing";
3
4
  import type { TimelineAssetKind } from "./timelineAssetDrop";
@@ -114,11 +115,7 @@ export function getEventTargetElement(target: EventTarget | null): HTMLElement |
114
115
  }
115
116
 
116
117
  export function shouldIgnoreHistoryShortcut(target: EventTarget | null): boolean {
117
- const el = getEventTargetElement(target);
118
- if (!el) return false;
119
- return Boolean(
120
- el.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor"),
121
- );
118
+ return isTypingTarget(target);
122
119
  }
123
120
 
124
121
  export function getHistoryShortcutLabel(action: "undo" | "redo"): string {
@@ -1,6 +1,7 @@
1
1
  import { resolveStudioDistinctId } from "../telemetry/distinctId";
2
2
  import { browserTelemetryAllowed } from "../telemetry/policy";
3
3
  import { canaryEventProperties } from "../telemetry/canary";
4
+ import { agentRuntimeProperty } from "../telemetry/agentRuntime";
4
5
 
5
6
  // PostHog public ingest key — write-only, safe to ship in the client bundle
6
7
  const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
@@ -42,6 +43,11 @@ function isEnabled(): boolean {
42
43
  function getSessionProperties(): EventProperties {
43
44
  return {
44
45
  studio_version: typeof __STUDIO_VERSION__ !== "undefined" ? __STUDIO_VERSION__ : "dev",
46
+ // On EVERY event, not just the audio ones. "Which of these sessions was a
47
+ // person and which was an agent" is a question worth asking of any feature,
48
+ // and a property that only some events carry cannot answer it — the
49
+ // breakdown silently reads as though the agent never used the rest.
50
+ agent_runtime: agentRuntimeProperty(),
45
51
  screen_width: window.screen?.width,
46
52
  screen_height: window.screen?.height,
47
53
  viewport_width: window.innerWidth,
@@ -73,9 +79,8 @@ export function trackStudioEvent(event: string, properties: EventProperties = {}
73
79
  }
74
80
  }
75
81
 
76
- async function flushEvents(): Promise<void> {
77
- if (queue.length === 0) return;
78
-
82
+ /** The queue, shaped for PostHog's batch endpoint — shared by both drain paths. */
83
+ function drainBatch() {
79
84
  const batch = queue.map((e) => ({
80
85
  event: e.event,
81
86
  properties: { ...e.properties, $ip: null },
@@ -83,6 +88,13 @@ async function flushEvents(): Promise<void> {
83
88
  timestamp: e.timestamp,
84
89
  }));
85
90
  queue = [];
91
+ return batch;
92
+ }
93
+
94
+ async function flushEvents(): Promise<void> {
95
+ if (queue.length === 0) return;
96
+
97
+ const batch = drainBatch();
86
98
 
87
99
  const controller = new AbortController();
88
100
  const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
@@ -112,13 +124,7 @@ export function flushViaBeacon(): void {
112
124
  flushTimer = null;
113
125
  }
114
126
  if (queue.length === 0) return;
115
- const batch = queue.map((e) => ({
116
- event: e.event,
117
- properties: { ...e.properties, $ip: null },
118
- distinct_id: getDistinctId(),
119
- timestamp: e.timestamp,
120
- }));
121
- queue = [];
127
+ const batch = drainBatch();
122
128
  const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
123
129
  try {
124
130
  navigator.sendBeacon(`${POSTHOG_HOST}/batch/`, body);
@@ -1,24 +1,3 @@
1
- interface EditableTargetLike {
2
- tagName?: string;
3
- isContentEditable?: boolean;
4
- closest?: (selector: string) => unknown;
5
- getAttribute?: (name: string) => string | null;
6
- }
7
-
8
- export function isEditableTarget(target: EventTarget | null): boolean {
9
- if (!target || typeof target !== "object") return false;
10
-
11
- const element = target as EditableTargetLike;
12
- const tagName = element.tagName?.toLowerCase();
13
- if (tagName === "input" || tagName === "textarea" || tagName === "select") return true;
14
- if (element.isContentEditable) return true;
15
-
16
- const role = element.getAttribute?.("role");
17
- if (role === "textbox" || role === "searchbox" || role === "combobox") return true;
18
-
19
- return Boolean(
20
- element.closest?.(
21
- "input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor",
22
- ),
23
- );
24
- }
1
+ // Compatibility name for downstream imports. The typing decision itself has
2
+ // one owner; new Studio callers import `isTypingTarget` directly.
3
+ export { isTypingTarget as isEditableTarget } from "./typingTarget";
@@ -0,0 +1,54 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { isTypingTarget } from "./typingTarget";
5
+ import { isEditableTarget } from "./timelineDiscovery";
6
+
7
+ afterEach(() => {
8
+ document.body.innerHTML = "";
9
+ });
10
+
11
+ function mount(html: string): HTMLElement {
12
+ document.body.innerHTML = html;
13
+ return document.body.firstElementChild as HTMLElement;
14
+ }
15
+
16
+ describe("isTypingTarget", () => {
17
+ it("keeps the legacy import on the same canonical decision", () => {
18
+ expect(isEditableTarget).toBe(isTypingTarget);
19
+ });
20
+
21
+ // The bug this exists for: inline text editing uses plaintext-only, an
22
+ // attribute selector for 'true' missed it, and the playback shortcuts ate
23
+ // every letter typed into the composition.
24
+ it("recognises a plaintext-only editable, not just contenteditable=true", () => {
25
+ expect(isTypingTarget(mount('<h1 contenteditable="plaintext-only">Hi</h1>'))).toBe(true);
26
+ expect(isTypingTarget(mount('<h1 contenteditable="true">Hi</h1>'))).toBe(true);
27
+ expect(isTypingTarget(mount("<h1 contenteditable>Hi</h1>"))).toBe(true);
28
+ });
29
+
30
+ it("recognises a child of an editable, which a selector on the target alone misses", () => {
31
+ const host = mount('<div contenteditable="true"><span>inner</span></div>');
32
+ expect(isTypingTarget(host.querySelector("span"))).toBe(true);
33
+ });
34
+
35
+ it("recognises the ordinary fields too", () => {
36
+ expect(isTypingTarget(mount("<input />"))).toBe(true);
37
+ expect(isTypingTarget(mount("<textarea></textarea>"))).toBe(true);
38
+ expect(isTypingTarget(mount("<select></select>"))).toBe(true);
39
+ expect(isTypingTarget(mount('<div role="textbox"></div>'))).toBe(true);
40
+ expect(isTypingTarget(mount('<div role="searchbox"></div>'))).toBe(true);
41
+ expect(isTypingTarget(mount('<div role="combobox"></div>'))).toBe(true);
42
+ });
43
+
44
+ it("leaves the keys alone for anything that is not being typed into", () => {
45
+ expect(isTypingTarget(mount("<div>plain</div>"))).toBe(false);
46
+ expect(isTypingTarget(mount("<button>press</button>"))).toBe(false);
47
+ expect(isTypingTarget(mount('<h1 contenteditable="false">Hi</h1>'))).toBe(false);
48
+ });
49
+
50
+ it("says no to nothing at all", () => {
51
+ expect(isTypingTarget(null)).toBe(false);
52
+ expect(isTypingTarget({} as EventTarget)).toBe(false);
53
+ });
54
+ });