@hyperframes/studio 0.7.106 → 0.7.107

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 (66) hide show
  1. package/dist/assets/{hyperframes-player-CvVcx_CV.js → hyperframes-player-BLrfwq5o.js} +1 -1
  2. package/dist/assets/{index-CRzCCuPH.js → index-BA9yhzfR.js} +1 -1
  3. package/dist/assets/{index-BbYg_isc.js → index-BSBk5srs.js} +1 -1
  4. package/dist/assets/{index-DerI0ikN.js → index-dF-CmLZu.js} +218 -218
  5. package/dist/assets/index-zQ4JFwwB.css +1 -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 +1 -1
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +5243 -3933
  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/editor/DomEditOverlay.tsx +36 -18
  19. package/src/components/editor/DomEditSelectionChrome.test.tsx +85 -0
  20. package/src/components/editor/DomEditSelectionChrome.tsx +29 -3
  21. package/src/components/editor/InlineTextToolbar.test.tsx +296 -0
  22. package/src/components/editor/InlineTextToolbar.tsx +281 -0
  23. package/src/components/editor/OffCanvasIndicators.tsx +38 -0
  24. package/src/components/editor/domEditInlineText.test.ts +86 -0
  25. package/src/components/editor/domEditInlineText.ts +99 -0
  26. package/src/components/editor/domEditInlineTextElement.test.ts +59 -0
  27. package/src/components/editor/domEditing.ts +1 -0
  28. package/src/components/editor/domEditingLayers.ts +7 -6
  29. package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
  30. package/src/components/editor/inlineTextStyleRange.ts +593 -0
  31. package/src/components/editor/inlineTextStyleRead.ts +47 -0
  32. package/src/components/editor/useDomEditNudge.ts +2 -2
  33. package/src/components/editor/useInlineTextEditing.tsx +119 -0
  34. package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
  35. package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
  36. package/src/components/feedback/feedbackTrigger.test.ts +168 -0
  37. package/src/components/feedback/feedbackTrigger.ts +269 -0
  38. package/src/components/feedback/projectProvenance.test.ts +120 -0
  39. package/src/components/feedback/projectProvenance.ts +83 -0
  40. package/src/components/renders/useRenderQueue.ts +62 -4
  41. package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
  42. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
  43. package/src/contexts/DomEditContext.tsx +4 -0
  44. package/src/hooks/domEditPersistFailure.ts +0 -7
  45. package/src/hooks/useAppHotkeys.ts +4 -4
  46. package/src/hooks/useDomEditCommits.test.tsx +103 -21
  47. package/src/hooks/useDomEditCommits.ts +2 -0
  48. package/src/hooks/useDomEditSession.ts +2 -0
  49. package/src/hooks/useDomEditTextCommits.ts +110 -11
  50. package/src/hooks/useFileTree.ts +8 -3
  51. package/src/hooks/useInlineTextEdit.test.tsx +555 -0
  52. package/src/hooks/useInlineTextEdit.ts +320 -0
  53. package/src/player/lib/playbackShortcuts.ts +5 -4
  54. package/src/telemetry/breadcrumbs.test.ts +68 -0
  55. package/src/telemetry/breadcrumbs.ts +70 -0
  56. package/src/telemetry/client.ts +5 -0
  57. package/src/telemetry/events.ts +84 -3
  58. package/src/utils/sourcePatcher.ts +5 -1
  59. package/src/utils/studioHelpers.ts +2 -5
  60. package/src/utils/timelineDiscovery.ts +3 -24
  61. package/src/utils/typingTarget.test.ts +54 -0
  62. package/src/utils/typingTarget.ts +43 -0
  63. package/dist/assets/index-tBPidglp.css +0 -1
  64. package/dist/chunk-OBAG3GWK.js.map +0 -1
  65. package/src/components/StudioFeedbackBar.tsx +0 -217
  66. /package/dist/{domEditingLayers-AT7G6F4L.js.map → domEditingLayers-7AMZ7GFI.js.map} +0 -0
@@ -0,0 +1,320 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { sanitizeRichTextChildren } from "@hyperframes/core/rich-text-sanitize";
3
+
4
+ /**
5
+ * Editing an element's text where it sits, in the composition itself.
6
+ *
7
+ * The alternative is an input positioned over the element, which has to
8
+ * reproduce its font, size, weight, spacing, colour, alignment and wrapping to
9
+ * look right, and is subtly wrong the moment any of those is missed. The
10
+ * preview is a same-origin document holding the real element, and the commit
11
+ * path already mutates that exact node, so the element is both the most
12
+ * accurate surface to type into and the one the rest of the code understands.
13
+ *
14
+ * The session owns the element's editable state for its whole life, and tears
15
+ * down the same way whichever way it ends. A session that failed to close
16
+ * would leave the canvas unable to select anything.
17
+ */
18
+
19
+ /**
20
+ * `true`, not `plaintext-only`.
21
+ *
22
+ * `plaintext-only` was what kept a text edit from becoming a structural one,
23
+ * and it also made it impossible to give three characters a colour, which is
24
+ * the point of editing in the composition rather than in a field. The guard it
25
+ * was providing is rebuilt as two narrower ones that do not cost the feature:
26
+ * paste arrives as plain text, and what leaves the element goes through the
27
+ * sanitiser before anyone writes it to a file.
28
+ */
29
+ const EDITABLE = "true";
30
+ /** Studio's accent, so the mark belongs to Studio rather than to the design. */
31
+ const EDITING_OUTLINE = "2px solid #3CE6AC";
32
+
33
+ export interface InlineTextEditSession {
34
+ element: HTMLElement;
35
+ /**
36
+ * The element's markup when editing started, for putting back on cancel.
37
+ * Markup rather than text: cancelling an edit that recoloured a word has to
38
+ * restore the colours it replaced, not just the letters.
39
+ */
40
+ original: string;
41
+ /** The element's own outline, to put back when the session ends. */
42
+ outline: string;
43
+ /** The element's own outline offset, restored with the outline. */
44
+ outlineOffset: string;
45
+ }
46
+
47
+ /** The live markup to persist and the session snapshot to restore on failure. */
48
+ export interface InlineTextEditCommit {
49
+ /** The exact preview node that owned the edit; a reload must not retarget it. */
50
+ element: HTMLElement;
51
+ html: string;
52
+ previousHtml: string;
53
+ }
54
+
55
+ export interface InlineTextEditControls {
56
+ session: InlineTextEditSession | null;
57
+ /**
58
+ * Begin editing this element. `caretAt` is a point in the element's own
59
+ * document, so the caret can open where the user pointed rather than at a
60
+ * fixed end. Returns false when a session is already open.
61
+ */
62
+ start: (element: HTMLElement, caretAt?: { x: number; y: number }) => boolean;
63
+ /** Hand the current text to the commit function and close. */
64
+ commit: () => void;
65
+ /** Put the original text back and close, persisting nothing. */
66
+ cancel: () => void;
67
+ }
68
+
69
+ /**
70
+ * Drop the text selection, but only when it lives inside this element.
71
+ *
72
+ * A selection somewhere else in the preview belongs to whatever put it there
73
+ * and is not this session's to clear.
74
+ */
75
+ function clearSelectionWithin(element: HTMLElement): void {
76
+ const selection = element.ownerDocument.defaultView?.getSelection();
77
+ if (!selection || selection.rangeCount === 0) return;
78
+ const range = selection.getRangeAt(0);
79
+ if (!element.contains(range.startContainer) && !element.contains(range.endContainer)) return;
80
+ selection.removeAllRanges();
81
+ }
82
+
83
+ export function useInlineTextEdit({
84
+ onCommit,
85
+ onPause,
86
+ }: {
87
+ /** Where the edited text goes. The caller owns persistence. */
88
+ onCommit: (commit: InlineTextEditCommit) => void;
89
+ /** Stop playback, so the element is not animating under the caret. */
90
+ onPause?: () => void;
91
+ }): InlineTextEditControls {
92
+ const [session, setSession] = useState<InlineTextEditSession | null>(null);
93
+ // The teardown reads this rather than the state, so an exit path that runs
94
+ // before React re-renders still sees the element it has to clean up.
95
+ const openRef = useRef<InlineTextEditSession | null>(null);
96
+ /** The pending caret placement, so a session that closes first can drop it. */
97
+ const framesRef = useRef<number | null>(null);
98
+
99
+ const teardown = useCallback((): InlineTextEditSession | null => {
100
+ const open = openRef.current;
101
+ if (!open) return null;
102
+ if (framesRef.current !== null) {
103
+ open.element.ownerDocument.defaultView?.cancelAnimationFrame(framesRef.current);
104
+ framesRef.current = null;
105
+ }
106
+ openRef.current = null;
107
+ setSession(null);
108
+ // An element removed from the document mid-session is not an error, it is
109
+ // just nothing left to clean up.
110
+ if (open.element.isConnected) {
111
+ open.element.removeAttribute("contenteditable");
112
+ // Restored rather than cleared: the composition may have authored one.
113
+ open.element.style.outline = open.outline;
114
+ open.element.style.outlineOffset = open.outlineOffset;
115
+ // Drop the highlight too. Removing contenteditable and blurring leaves a
116
+ // selection made inside the element painted on screen, so a word picked
117
+ // with a double press stayed grey after the click that closed the edit.
118
+ clearSelectionWithin(open.element);
119
+ open.element.blur();
120
+ }
121
+ return open;
122
+ }, []);
123
+
124
+ const start = useCallback(
125
+ (element: HTMLElement, caretAt?: { x: number; y: number }): boolean => {
126
+ if (openRef.current) return false;
127
+
128
+ const open = {
129
+ element,
130
+ original: element.innerHTML,
131
+ outline: element.style.outline,
132
+ outlineOffset: element.style.outlineOffset,
133
+ };
134
+ // Drawn on the element itself, not in Studio's overlay above it. This is
135
+ // the only mark that says the caret is in the TEXT rather than the
136
+ // element being selected, and it has to sit in the same document as the
137
+ // caret to read that way.
138
+ element.style.outline = EDITING_OUTLINE;
139
+ element.style.outlineOffset = "2px";
140
+ openRef.current = open;
141
+ setSession(open);
142
+ onPause?.();
143
+
144
+ element.setAttribute("contenteditable", EDITABLE);
145
+ // Focused and selected on the next frame, not now. The press that opened
146
+ // this is still in flight: the canvas overlay takes focus on its own
147
+ // pointer-down, and the click that follows puts a caret in the element
148
+ // and collapses any selection. Doing it after all of that is what lands.
149
+ const view = element.ownerDocument.defaultView;
150
+ const raf = view?.requestAnimationFrame(() => {
151
+ framesRef.current = null;
152
+ if (openRef.current?.element !== element) return;
153
+ element.focus({ preventScroll: true });
154
+ placeCaret(element, caretAt);
155
+ });
156
+ framesRef.current = raf ?? null;
157
+ return true;
158
+ },
159
+ [onPause],
160
+ );
161
+
162
+ const commit = useCallback(() => {
163
+ const open = openRef.current;
164
+ if (!open) return;
165
+ // Sanitised here, in the element, so the preview shows exactly what will be
166
+ // saved rather than something the server will quietly cut down.
167
+ sanitizeRichTextChildren(open.element);
168
+ const html = open.element.innerHTML;
169
+ teardown();
170
+ // After teardown, so the commit path's own resync does not fight an
171
+ // element that is still editable.
172
+ onCommit({ element: open.element, html, previousHtml: open.original });
173
+ }, [onCommit, teardown]);
174
+
175
+ const cancel = useCallback(() => {
176
+ const open = openRef.current;
177
+ if (!open) return;
178
+ if (open.element.isConnected) open.element.innerHTML = open.original;
179
+ teardown();
180
+ }, [teardown]);
181
+
182
+ // The keys belong to the element, not to the document: the element lives in
183
+ // the preview's own document, so a listener on Studio's would never see them.
184
+ useEffect(() => {
185
+ const element = session?.element;
186
+ if (!element) return;
187
+
188
+ const onKeyDown = (event: KeyboardEvent) => {
189
+ // Shift+Enter is a line break in a multi-line element, and is left alone.
190
+ if (event.key === "Enter" && !event.shiftKey) {
191
+ event.preventDefault();
192
+ commit();
193
+ return;
194
+ }
195
+ if (event.key === "Escape") {
196
+ event.preventDefault();
197
+ cancel();
198
+ }
199
+ };
200
+ // Clicking away keeps the work, which is what every other field in Studio
201
+ // does and what a user who has just typed something expects.
202
+ const onBlur = () => commit();
203
+ // Nothing here for double or triple click: the browser already takes the
204
+ // word on two and the whole text on three, which is what a text field does
205
+ // everywhere else. Overriding the double click to take everything cost the
206
+ // word selection and gained nothing the triple click did not already do.
207
+ // Dropping `plaintext-only` means the browser would otherwise paste a whole
208
+ // web page's markup straight in. What arrives is the words.
209
+ const insertPlainText = (text: string) => {
210
+ if (text) element.ownerDocument.execCommand("insertText", false, text);
211
+ };
212
+ const onPaste = (event: ClipboardEvent) => {
213
+ event.preventDefault();
214
+ insertPlainText(event.clipboardData?.getData("text/plain") ?? "");
215
+ };
216
+ // `contenteditable="true"` accepts dragged HTML as eagerly as pasted HTML.
217
+ // Keep the drop path on the same plain-text rail as paste so markup cannot
218
+ // execute in the same-origin preview before commit-time sanitisation.
219
+ const onDragOver = (event: DragEvent) => event.preventDefault();
220
+ const onDrop = (event: DragEvent) => {
221
+ event.preventDefault();
222
+ insertPlainText(event.dataTransfer?.getData("text/plain") ?? "");
223
+ };
224
+
225
+ element.addEventListener("keydown", onKeyDown);
226
+ element.addEventListener("blur", onBlur);
227
+ element.addEventListener("paste", onPaste);
228
+ element.addEventListener("dragover", onDragOver);
229
+ element.addEventListener("drop", onDrop);
230
+ return () => {
231
+ element.removeEventListener("keydown", onKeyDown);
232
+ element.removeEventListener("blur", onBlur);
233
+ element.removeEventListener("paste", onPaste);
234
+ element.removeEventListener("dragover", onDragOver);
235
+ element.removeEventListener("drop", onDrop);
236
+ };
237
+ }, [session, commit, cancel]);
238
+
239
+ // Navigation can remove the overlay while the opening frame is pending.
240
+ // Teardown is the single owner of cancelling that frame and restoring the
241
+ // composition node, so unmount closes through the same path as every exit.
242
+ useEffect(
243
+ () => () => {
244
+ teardown();
245
+ },
246
+ [teardown],
247
+ );
248
+
249
+ return { session, start, commit, cancel };
250
+ }
251
+
252
+ /**
253
+ * Put the caret where the user pointed, or after the last character.
254
+ *
255
+ * Opening on a point is what makes this feel like text rather than a dialog:
256
+ * the caret lands between the two letters that were clicked, exactly as it
257
+ * would in any other editor.
258
+ */
259
+ function placeCaret(element: HTMLElement, at?: { x: number; y: number }): void {
260
+ const doc = element.ownerDocument;
261
+ const selection = doc.defaultView?.getSelection();
262
+ if (!selection) return;
263
+
264
+ const range = at ? caretRangeAt(doc, at) : null;
265
+ if (range && element.contains(range.startContainer)) {
266
+ selection.removeAllRanges();
267
+ selection.addRange(range);
268
+ return;
269
+ }
270
+ placeCaretAtEnd(element);
271
+ }
272
+
273
+ /** The caret position under a point, across the two APIs browsers expose. */
274
+ function caretRangeAt(doc: Document, at: { x: number; y: number }): Range | null {
275
+ const legacy = doc as Document & {
276
+ caretRangeFromPoint?: (x: number, y: number) => Range | null;
277
+ };
278
+ if (typeof legacy.caretRangeFromPoint === "function") {
279
+ return legacy.caretRangeFromPoint(at.x, at.y);
280
+ }
281
+ const standard = doc as Document & {
282
+ caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null;
283
+ };
284
+ const position = standard.caretPositionFromPoint?.(at.x, at.y);
285
+ if (!position) return null;
286
+ const range = doc.createRange();
287
+ range.setStart(position.offsetNode, position.offset);
288
+ range.collapse(true);
289
+ return range;
290
+ }
291
+
292
+ /**
293
+ * Put the caret after the last character, with nothing selected.
294
+ *
295
+ * Selecting the whole text would mean the next keystroke silently destroys it,
296
+ * which is a bad thing to do to someone who double-clicked to fix a typo. A
297
+ * caret at the end is where a person who wants to keep typing expects to be,
298
+ * and everything else stays available: click anywhere to move it, drag to
299
+ * select, Cmd+A to take the lot.
300
+ */
301
+ function placeCaretAtEnd(element: HTMLElement): void {
302
+ const doc = element.ownerDocument;
303
+ const selection = doc.defaultView?.getSelection();
304
+ if (!selection) return;
305
+ const range = doc.createRange();
306
+ // Into the text node, not just past the last child: collapsing the element's
307
+ // contents leaves the caret at a node boundary, which types in the right
308
+ // place but reports itself as "after child 0" and is a different position
309
+ // from the one the user sees at the end of the word.
310
+ const last = element.lastChild;
311
+ if (last && last.nodeType === 3) {
312
+ range.setStart(last, last.textContent?.length ?? 0);
313
+ range.collapse(true);
314
+ } else {
315
+ range.selectNodeContents(element);
316
+ range.collapse(false);
317
+ }
318
+ selection.removeAllRanges();
319
+ selection.addRange(range);
320
+ }
@@ -6,15 +6,13 @@
6
6
  * is active and the user is navigating caption segments).
7
7
  */
8
8
 
9
+ import { isTypingTarget } from "../../utils/typingTarget";
10
+
9
11
  const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
10
12
 
11
13
  const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
12
- "input",
13
- "textarea",
14
- "select",
15
14
  "button",
16
15
  "a[href]",
17
- "[contenteditable='true']",
18
16
  "[role='button']",
19
17
  "[role='checkbox']",
20
18
  "[role='combobox']",
@@ -27,6 +25,9 @@ const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
27
25
  ].join(",");
28
26
 
29
27
  export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
28
+ // Anything the user is typing into owns its keys outright, editable elements
29
+ // included: a letter claimed here never reaches the text.
30
+ if (isTypingTarget(target)) return true;
30
31
  if (!target || typeof target !== "object") return false;
31
32
  const candidate = target as { closest?: unknown };
32
33
  if (typeof candidate.closest !== "function") return false;
@@ -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
  }
@@ -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;
@@ -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,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";