@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,269 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Single owner of "should Studio ask for feedback right now, and about what".
3
+ //
4
+ // The prompt used to fire on a session counter (every 10th tab). That collected
5
+ // 5 responses in three months, because it asked at an arbitrary moment with no
6
+ // subject. It now fires on a render outcome — the moment the user has just
7
+ // formed an opinion — which is the same moment the CLI asks.
8
+ //
9
+ // Producers call `requestStudioFeedback`; the card subscribes. Eligibility
10
+ // lives here and nowhere else, so no caller has to know the cooldown rules.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import { browserTelemetryAllowed } from "../../telemetry/policy";
14
+
15
+ export type FeedbackReason = "render_complete" | "render_failed" | "crash";
16
+
17
+ /**
18
+ * Everything except a finished render is a problem report: no 0-10 score (you
19
+ * cannot rate an export you never got, and a fabricated rating poisons the
20
+ * average) and the card wears its error styling.
21
+ */
22
+ export function isProblemReason(reason: FeedbackReason): boolean {
23
+ return reason !== "render_complete";
24
+ }
25
+
26
+ /**
27
+ * A one-tap answer. `label` has to stand on its own — a chip that only makes
28
+ * sense once you hover it is a badly named chip, and most people never hover.
29
+ * `hint` carries the nuance a two-word label cannot, for the people who do.
30
+ */
31
+ export interface FollowUpPreset {
32
+ label: string;
33
+ hint: string;
34
+ }
35
+
36
+ /**
37
+ * The follow-up asked after a score. One question per person, rotated across
38
+ * people: a corner card that asks three things gets answered by nobody, but the
39
+ * same card asking one thing of every third user covers the same ground.
40
+ */
41
+ export interface FollowUpQuestion {
42
+ /** Sent with the response, so answers stay separable instead of collapsing
43
+ * into one undifferentiated comment field. */
44
+ id: string;
45
+ prompt: string;
46
+ placeholder: string;
47
+ /**
48
+ * Typing into a corner card is a cost most people decline, so the common
49
+ * answers are buttons and the field is the escape hatch. Keep these mutually
50
+ * distinct: two chips a user could reasonably read the same way collect a
51
+ * number that means nothing.
52
+ */
53
+ presets: readonly FollowUpPreset[];
54
+ }
55
+
56
+ // The options below are not invented, and none of them is a brand.
57
+ //
58
+ // `fix` mirrors the themes that actually dominate written feedback: audio and
59
+ // encoding first by a wide margin, then media and fonts, then export-versus-
60
+ // preview mismatches, then render time. `workaround` mirrors the steps people
61
+ // report finishing in other tools. `remove` offers the surfaces that see the
62
+ // least real use, so the question contains genuine deletion candidates instead
63
+ // of a list nobody would pick from. `detractor` mirrors the failures Studio
64
+ // actually emits.
65
+ //
66
+ // Seven is the ceiling, not a target: past that the chips stop being scannable
67
+ // in a corner card and start being a form. Re-derive all of these when the
68
+ // shape of the feedback changes, because a stale option list quietly steers
69
+ // every answer it collects.
70
+ const FOLLOW_UPS: readonly FollowUpQuestion[] = [
71
+ {
72
+ id: "remove",
73
+ prompt: "What would you cut from Studio?",
74
+ placeholder: "The part you never use",
75
+ presets: [
76
+ { label: "Layers panel", hint: "The layer tree on the right" },
77
+ { label: "Variables panel", hint: "Composition variables on the right" },
78
+ { label: "Blocks browser", hint: "The block library in the sidebar" },
79
+ { label: "Storyboard mode", hint: "The storyboard view next to Preview" },
80
+ { label: "Caption editing", hint: "Editing caption words and presets in Studio" },
81
+ { label: "Render history", hint: "The list of past exports" },
82
+ { label: "Nothing", hint: "It all earns its place" },
83
+ ],
84
+ },
85
+ {
86
+ // Replaces an earlier "which editor gets this right?". Brand names are a
87
+ // popularity vote, not an instruction: "CapCut" does not say what to build.
88
+ // What someone had to leave HyperFrames to do names the missing feature
89
+ // exactly, and written feedback is full of these workarounds already.
90
+ //
91
+ // Every option here is a step Studio genuinely cannot do today. Offering
92
+ // one it CAN do (trimming, voiceover, ducking, compositing, grading) would
93
+ // collect taps that mean "I could not find it", and there is no way to tell
94
+ // those apart from "it does not exist" afterwards. Check before adding.
95
+ id: "workaround",
96
+ prompt: "What did you have to do outside Studio?",
97
+ placeholder: "The step you finished somewhere else",
98
+ presets: [
99
+ { label: "Fix the exported file", hint: "Remuxing or re-encoding the output by hand" },
100
+ { label: "Convert source media", hint: "Re-encoding footage before it would import cleanly" },
101
+ { label: "Match loudness", hint: "Normalising levels across the finished audio" },
102
+ { label: "Make images or graphics", hint: "Creating visual assets in another tool" },
103
+ { label: "Record footage", hint: "Capturing screen or camera video" },
104
+ { label: "Nothing, it was all here", hint: "No outside tools needed" },
105
+ ],
106
+ },
107
+ {
108
+ id: "fix",
109
+ prompt: "What should we fix first?",
110
+ placeholder: "The thing that slowed you down",
111
+ presets: [
112
+ { label: "Audio mixing", hint: "Levels, ducking or the balance in the exported mix" },
113
+ { label: "Export encoding", hint: "The render fails, or the finished file is wrong" },
114
+ { label: "Export matches preview", hint: "The exported video differs from the preview" },
115
+ { label: "Media and fonts", hint: "Adding video, images or fonts to a project" },
116
+ { label: "Render speed", hint: "Exports take too long to finish" },
117
+ { label: "Re-rendering everything", hint: "A small change means rendering the whole thing" },
118
+ {
119
+ label: "Checks and warnings",
120
+ hint: "Lint flags things that are fine, or misses real ones",
121
+ },
122
+ ],
123
+ },
124
+ ];
125
+
126
+ /** Detractors are not asked a rotated question; the complaint is the answer. */
127
+ const DETRACTOR_FOLLOW_UP: FollowUpQuestion = {
128
+ id: "detractor",
129
+ prompt: "What went wrong?",
130
+ placeholder: "The thing that made that a low score",
131
+ presets: [
132
+ { label: "Export failed", hint: "The render did not finish" },
133
+ { label: "Output looked wrong", hint: "It rendered, but not the way I built it" },
134
+ { label: "Crashed or froze", hint: "The editor stopped responding" },
135
+ { label: "Lost my edits", hint: "Changes did not save, or came back changed" },
136
+ { label: "Too slow", hint: "Waiting on renders, or the editor itself lagging" },
137
+ { label: "Could not figure it out", hint: "Could not find or understand something" },
138
+ ],
139
+ };
140
+
141
+ const DETRACTOR_MAX_RATING = 6;
142
+
143
+ export function followUpFor(rating: number): FollowUpQuestion {
144
+ if (rating <= DETRACTOR_MAX_RATING) return DETRACTOR_FOLLOW_UP;
145
+ // Rotation only has to spread across users, not be unguessable, so any
146
+ // per-prompt jitter works. Studio's determinism rules cover rendered
147
+ // compositions, not the editor chrome.
148
+ return FOLLOW_UPS[Math.floor(Math.random() * FOLLOW_UPS.length)];
149
+ }
150
+
151
+ /**
152
+ * Facts about the moment being reported on, sent with the response so a
153
+ * complaint arrives with the conditions that produced it instead of needing a
154
+ * round trip to ask. Producers fill this; the trigger never inspects it.
155
+ *
156
+ * Names and enums only, no free text: this rides to the same place the comment
157
+ * does, and the user consented to a comment, not to their project's contents.
158
+ */
159
+ export interface FeedbackContext {
160
+ [key: string]: string | number | boolean | undefined;
161
+ }
162
+
163
+ export interface FeedbackRequest {
164
+ reason: FeedbackReason;
165
+ /** Render job the prompt is about — joins the response to the render event. */
166
+ renderId?: string;
167
+ /** Failure text shown to the user, sent verbatim so reports are actionable. */
168
+ detail?: string;
169
+ /** Reproduction context: settings, counts, outcomes. See FeedbackContext. */
170
+ context?: FeedbackContext;
171
+ }
172
+
173
+ const STORAGE_KEYS = {
174
+ /** Epoch ms of the last prompt the user dismissed or ignored. */
175
+ dismissedAt: "hyperframes-studio:feedbackDismissedAt",
176
+ /** Epoch ms of the last prompt the user actually answered. */
177
+ answeredAt: "hyperframes-studio:feedbackAnsweredAt",
178
+ } as const;
179
+
180
+ /** One prompt per tab, so a batch of renders can't turn into a batch of asks. */
181
+ const SESSION_ASKED_KEY = "hyperframes-studio:feedbackAskedThisSession";
182
+
183
+ const DAY_MS = 86_400_000;
184
+ const ANSWERED_COOLDOWN_MS = 30 * DAY_MS;
185
+ const DISMISSED_COOLDOWN_MS = 7 * DAY_MS;
186
+
187
+ type Listener = (request: FeedbackRequest) => void;
188
+
189
+ let listener: Listener | null = null;
190
+
191
+ function isDisabled(): boolean {
192
+ try {
193
+ return import.meta.env.VITE_HYPERFRAMES_NO_FEEDBACK === "1";
194
+ } catch {
195
+ return false;
196
+ }
197
+ }
198
+
199
+ function readTimestamp(key: string): number {
200
+ try {
201
+ return parseInt(window.localStorage.getItem(key) || "0", 10) || 0;
202
+ } catch {
203
+ return 0;
204
+ }
205
+ }
206
+
207
+ function writeTimestamp(key: string, now: number): void {
208
+ try {
209
+ window.localStorage.setItem(key, String(now));
210
+ } catch {
211
+ /* localStorage may be unavailable or full */
212
+ }
213
+ }
214
+
215
+ function askedThisSession(): boolean {
216
+ try {
217
+ return window.sessionStorage.getItem(SESSION_ASKED_KEY) === "1";
218
+ } catch {
219
+ // Without sessionStorage there is no way to bound the asks, so don't ask.
220
+ return true;
221
+ }
222
+ }
223
+
224
+ function markAskedThisSession(): void {
225
+ try {
226
+ window.sessionStorage.setItem(SESSION_ASKED_KEY, "1");
227
+ } catch {
228
+ /* sessionStorage may be unavailable */
229
+ }
230
+ }
231
+
232
+ function isEligible(now: number): boolean {
233
+ if (isDisabled()) return false;
234
+ // Prompting a user whose telemetry is off would collect a response we then
235
+ // drop on the floor. Ask only the people we can actually hear.
236
+ if (!browserTelemetryAllowed()) return false;
237
+ if (askedThisSession()) return false;
238
+ if (now - readTimestamp(STORAGE_KEYS.answeredAt) < ANSWERED_COOLDOWN_MS) return false;
239
+ if (now - readTimestamp(STORAGE_KEYS.dismissedAt) < DISMISSED_COOLDOWN_MS) return false;
240
+ return true;
241
+ }
242
+
243
+ /**
244
+ * Ask for feedback about `request` if the user is due. Safe to call on every
245
+ * render outcome: ineligible calls are dropped without touching any state, so
246
+ * a user who just answered stays quiet rather than burning their next window.
247
+ */
248
+ export function requestStudioFeedback(request: FeedbackRequest): void {
249
+ if (!listener) return;
250
+ if (!isEligible(Date.now())) return;
251
+ markAskedThisSession();
252
+ listener(request);
253
+ }
254
+
255
+ /** Subscribe the card. Single-subscriber by design — there is one card. */
256
+ export function subscribeToFeedbackRequests(next: Listener): () => void {
257
+ listener = next;
258
+ return () => {
259
+ if (listener === next) listener = null;
260
+ };
261
+ }
262
+
263
+ export function markFeedbackAnswered(): void {
264
+ writeTimestamp(STORAGE_KEYS.answeredAt, Date.now());
265
+ }
266
+
267
+ export function markFeedbackDismissed(): void {
268
+ writeTimestamp(STORAGE_KEYS.dismissedAt, Date.now());
269
+ }
@@ -0,0 +1,120 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, it, expect, beforeEach, vi } from "vitest";
4
+ import {
5
+ captureProjectProvenance,
6
+ projectProvenance,
7
+ resetProjectProvenance,
8
+ } from "./projectProvenance";
9
+
10
+ const CONFIG = "hyperframes.json";
11
+
12
+ /**
13
+ * The real `/files/*` route answers with an envelope, not the file. Mocking the
14
+ * raw config here instead let a broken parse pass the suite and fail live, so
15
+ * this helper is deliberately shaped like the server's actual response.
16
+ */
17
+ function mockConfigResponse(fileContent: string, ok = true) {
18
+ vi.stubGlobal(
19
+ "fetch",
20
+ vi.fn(async () => ({
21
+ ok,
22
+ json: async () => ({ filename: CONFIG, content: fileContent, version: "abc" }),
23
+ })),
24
+ );
25
+ }
26
+
27
+ beforeEach(() => {
28
+ resetProjectProvenance();
29
+ vi.unstubAllGlobals();
30
+ });
31
+
32
+ describe("captureProjectProvenance", () => {
33
+ it("reads the authoring workflow that built the project", async () => {
34
+ mockConfigResponse(JSON.stringify({ authoringSkill: "faceless-explainer" }));
35
+ await captureProjectProvenance("p1", [CONFIG, "index.html"], ["index.html"]);
36
+ expect(projectProvenance()).toMatchObject({
37
+ project_scaffolded: true,
38
+ project_authoring_skill: "faceless-explainer",
39
+ });
40
+ });
41
+
42
+ it("counts compositions and media, which is what actually reproduces a bug", async () => {
43
+ mockConfigResponse("{}");
44
+ await captureProjectProvenance(
45
+ "p1",
46
+ [CONFIG, "index.html", "a.html", "assets/clip.mp4", "assets/vo.wav", "assets/logo.png"],
47
+ ["index.html", "a.html"],
48
+ );
49
+ expect(projectProvenance()).toMatchObject({
50
+ project_composition_count: 2,
51
+ project_media_count: 3,
52
+ project_file_count: 6,
53
+ });
54
+ });
55
+
56
+ it("flags a project that init never scaffolded, and skips the config fetch", async () => {
57
+ const fetchSpy = vi.fn();
58
+ vi.stubGlobal("fetch", fetchSpy);
59
+ await captureProjectProvenance("p1", ["index.html"], ["index.html"]);
60
+ expect(projectProvenance().project_scaffolded).toBe(false);
61
+ expect(projectProvenance().project_authoring_skill).toBeUndefined();
62
+ expect(fetchSpy).not.toHaveBeenCalled();
63
+ });
64
+
65
+ // Every way the config can fail to yield a skill must still leave the counts
66
+ // behind: a partial report beats no report.
67
+ it.each([
68
+ ["the config is corrupt", () => mockConfigResponse("{ not json")],
69
+ [
70
+ "the envelope has no content field",
71
+ () =>
72
+ vi.stubGlobal(
73
+ "fetch",
74
+ vi.fn(async () => ({ ok: true, json: async () => ({ error: "not found" }) })),
75
+ ),
76
+ ],
77
+ [
78
+ "the fetch fails outright",
79
+ () =>
80
+ vi.stubGlobal(
81
+ "fetch",
82
+ vi.fn(async () => {
83
+ throw new Error("offline");
84
+ }),
85
+ ),
86
+ ],
87
+ ["the route answers non-ok", () => mockConfigResponse("{}", false)],
88
+ ])("keeps the counts when %s", async (_case, arrange) => {
89
+ arrange();
90
+ await expect(
91
+ captureProjectProvenance("p1", [CONFIG, "index.html"], ["index.html"]),
92
+ ).resolves.toBeUndefined();
93
+ expect(projectProvenance().project_scaffolded).toBe(true);
94
+ expect(projectProvenance().project_composition_count).toBe(1);
95
+ expect(projectProvenance().project_authoring_skill).toBeUndefined();
96
+ });
97
+
98
+ describe("privacy", () => {
99
+ it("drops a hand-edited skill that is not a slug, so no free text escapes", async () => {
100
+ mockConfigResponse(
101
+ JSON.stringify({ authoringSkill: "my client's secret campaign /Users/me/x" }),
102
+ );
103
+ await captureProjectProvenance("p1", [CONFIG], []);
104
+ expect(projectProvenance().project_authoring_skill).toBeUndefined();
105
+ });
106
+
107
+ it("never copies file names, paths or the project title", async () => {
108
+ mockConfigResponse(JSON.stringify({ authoringSkill: "slideshow", title: "Secret Launch" }));
109
+ await captureProjectProvenance(
110
+ "p1",
111
+ [CONFIG, "assets/unreleased-product-hero.mp4"],
112
+ ["index.html"],
113
+ );
114
+ const flat = JSON.stringify(projectProvenance());
115
+ expect(flat).not.toContain("unreleased");
116
+ expect(flat).not.toContain("Secret");
117
+ expect(flat).not.toContain("assets/");
118
+ });
119
+ });
120
+ });
@@ -0,0 +1,83 @@
1
+ // ---------------------------------------------------------------------------
2
+ // How this project came to exist, and roughly what shape it is.
3
+ //
4
+ // A crash report says what broke. It does not say what the user was working ON,
5
+ // and "a project made by /faceless-explainer with 14 compositions and 60 assets"
6
+ // reproduces a crash that "Cannot read properties of undefined" never will.
7
+ //
8
+ // Held at module scope on purpose: a crash unmounts the React tree, so anything
9
+ // living in component state is gone by the time the crash prompt renders. This
10
+ // survives, because it was captured when the project loaded.
11
+ //
12
+ // PRIVACY: counts, a skill slug, and booleans. No file names, no paths, no
13
+ // project title. The user consented to a comment, not to an inventory of their
14
+ // work.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ import type { FeedbackContext } from "./feedbackTrigger";
18
+
19
+ /** Written by `hyperframes init`; absent in a hand-made or copied project. */
20
+ const CONFIG_FILE = "hyperframes.json";
21
+
22
+ /** Matches the CLI's own slug gate, so a hand-edited value cannot leak text. */
23
+ const SKILL_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
24
+
25
+ const MEDIA_EXTENSIONS = /\.(mp4|mov|webm|m4v|mp3|wav|m4a|aac|ogg|png|jpe?g|gif|webp|svg|avif)$/i;
26
+
27
+ let snapshot: FeedbackContext = {};
28
+
29
+ function countMedia(files: string[]): number {
30
+ return files.filter((f) => MEDIA_EXTENSIONS.test(f)).length;
31
+ }
32
+
33
+ /**
34
+ * Record what we can see from the project listing, then fill in the authoring
35
+ * skill from `hyperframes.json` if the project has one. Failure is silent and
36
+ * partial: a report with the counts but no skill still beats no report.
37
+ */
38
+ export async function captureProjectProvenance(
39
+ projectId: string,
40
+ files: string[],
41
+ compositions: string[],
42
+ ): Promise<void> {
43
+ const scaffolded = files.includes(CONFIG_FILE);
44
+ snapshot = {
45
+ // False means the project was copied, hand-written, or predates init.
46
+ // Those reproduce differently from a scaffolded one, so the flag matters
47
+ // even when the skill below is missing.
48
+ project_scaffolded: scaffolded,
49
+ project_composition_count: compositions.length,
50
+ project_file_count: files.length,
51
+ project_media_count: countMedia(files),
52
+ };
53
+ if (!scaffolded) return;
54
+
55
+ try {
56
+ const res = await fetch(`/api/projects/${projectId}/files/${encodeURIComponent(CONFIG_FILE)}`);
57
+ if (!res.ok) return;
58
+ // The route answers with an envelope, not the file: {filename, content,
59
+ // version}. The config is the `content` string inside it.
60
+ const envelope: unknown = await res.json();
61
+ const raw = (envelope as { content?: unknown }).content;
62
+ if (typeof raw !== "string") return;
63
+ const parsed: unknown = JSON.parse(raw);
64
+ if (typeof parsed !== "object" || parsed === null) return;
65
+ const skill = (parsed as { authoringSkill?: unknown }).authoringSkill;
66
+ // The workflow that built this project: which of the creation skills the
67
+ // agent ran. This is the closest thing to "how was it made" we can get.
68
+ if (typeof skill === "string" && SKILL_SLUG.test(skill)) {
69
+ snapshot.project_authoring_skill = skill;
70
+ }
71
+ } catch {
72
+ // Missing, unreadable or corrupt config: keep the counts, drop the skill.
73
+ }
74
+ }
75
+
76
+ export function projectProvenance(): FeedbackContext {
77
+ return snapshot;
78
+ }
79
+
80
+ /** Test seam. */
81
+ export function resetProjectProvenance(): void {
82
+ snapshot = {};
83
+ }
@@ -4,6 +4,7 @@ import { trackStudioRenderStart } from "../../telemetry/events";
4
4
  import { getAnonymousId } from "../../telemetry/config";
5
5
  import { browserTelemetryAllowed } from "../../telemetry/policy";
6
6
  import { generateId } from "../../utils/generateId";
7
+ import { requestStudioFeedback, type FeedbackContext } from "../feedback/feedbackTrigger";
7
8
 
8
9
  export interface RenderJob {
9
10
  id: string;
@@ -72,6 +73,23 @@ export function useRenderQueue(projectId: string | null) {
72
73
  const [actionError, setActionError] = useState<string | null>(null);
73
74
  const eventSourceRef = useRef<EventSource | null>(null);
74
75
  const activeJobRef = useRef<string | null>(null);
76
+ // Renders started in THIS tab, mapped to the settings they ran with.
77
+ // `loadRenders` also injects finished jobs from disk history, and those must
78
+ // never trigger a feedback prompt — the user did not just watch them happen.
79
+ const sessionJobs = useRef(new Map<string, FeedbackContext>());
80
+ const promptedJobIds = useRef(new Set<string>());
81
+
82
+ /**
83
+ * The one way a render started here enters the list. Every start path — the
84
+ * happy one and all three failure shortcuts — goes through here, so both
85
+ * "this render belongs to this session" and "these are the settings it ran
86
+ * with" have a single owner. A report about a render is only actionable if
87
+ * it arrives with the settings that produced it.
88
+ */
89
+ const addSessionJob = useCallback((job: RenderJob, settings: FeedbackContext) => {
90
+ sessionJobs.current.set(job.id, settings);
91
+ setJobs((prev) => [...prev, job]);
92
+ }, []);
75
93
 
76
94
  const closeActiveEventSource = useCallback((jobId?: string) => {
77
95
  if (jobId && activeJobRef.current !== jobId) return;
@@ -148,6 +166,16 @@ export function useRenderQueue(projectId: string | null) {
148
166
  });
149
167
 
150
168
  const startTime = Date.now();
169
+ // Travels with any feedback about this render. Settings only: the
170
+ // composition path is a name the user chose, not file contents.
171
+ const settings: FeedbackContext = {
172
+ render_format: format,
173
+ render_quality: quality,
174
+ render_fps: fps,
175
+ render_resolution: resolution ?? "auto",
176
+ render_composition: composition ?? "index.html",
177
+ render_has_variables: Boolean(opts.variables && Object.keys(opts.variables).length > 0),
178
+ };
151
179
  // "auto" / undefined means "render at the composition's authored size".
152
180
  // Omit the field entirely — sending "auto" would trip the route's
153
181
  // enum validation set.
@@ -201,7 +229,7 @@ export function useRenderQueue(projectId: string | null) {
201
229
  filename: "Export failed",
202
230
  createdAt: startTime,
203
231
  };
204
- setJobs((prev) => [...prev, failedJob]);
232
+ addSessionJob(failedJob, settings);
205
233
  return;
206
234
  }
207
235
  if (!res.ok) {
@@ -213,7 +241,7 @@ export function useRenderQueue(projectId: string | null) {
213
241
  filename: "Export failed",
214
242
  createdAt: startTime,
215
243
  };
216
- setJobs((prev) => [...prev, failedJob]);
244
+ addSessionJob(failedJob, settings);
217
245
  return;
218
246
  }
219
247
  const { jobId } = await res.json();
@@ -227,7 +255,7 @@ export function useRenderQueue(projectId: string | null) {
227
255
  filename: `${jobId}${ext}`,
228
256
  createdAt: startTime,
229
257
  };
230
- setJobs((prev) => [...prev, job]);
258
+ addSessionJob(job, settings);
231
259
  activeJobRef.current = jobId;
232
260
 
233
261
  // Track progress via SSE
@@ -279,7 +307,7 @@ export function useRenderQueue(projectId: string | null) {
279
307
 
280
308
  return jobId;
281
309
  },
282
- [projectId, closeActiveEventSource],
310
+ [projectId, closeActiveEventSource, addSessionJob],
283
311
  );
284
312
 
285
313
  // Cancel an in-flight render. The job row stays (as "cancelled") so the
@@ -352,6 +380,36 @@ export function useRenderQueue(projectId: string | null) {
352
380
 
353
381
  const dismissActionError = useCallback(() => setActionError(null), []);
354
382
 
383
+ // Ask for feedback the moment a render this tab started reaches its outcome.
384
+ // Watching the list (rather than each of the four places a job can finish)
385
+ // keeps one trigger for every path, including SSE drops and cancels-that-
386
+ // finished-anyway. `requestStudioFeedback` decides whether to actually ask.
387
+ useEffect(() => {
388
+ for (const job of jobs) {
389
+ if (job.status === "rendering" || job.status === "cancelled") continue;
390
+ const settings = sessionJobs.current.get(job.id);
391
+ if (!settings || promptedJobIds.current.has(job.id)) continue;
392
+ promptedJobIds.current.add(job.id);
393
+ requestStudioFeedback({
394
+ reason: job.status === "complete" ? "render_complete" : "render_failed",
395
+ renderId: job.id,
396
+ detail: job.error,
397
+ context: {
398
+ ...settings,
399
+ // How far it got and how long it took separate "died on frame one"
400
+ // from "died during encode", which need different fixes.
401
+ render_progress: job.progress,
402
+ render_duration_ms: job.durationMs ?? Date.now() - job.createdAt,
403
+ render_stage: job.stage,
404
+ render_error: job.error,
405
+ // Earlier renders this session: a first-render failure and a
406
+ // failure after nine successes are different bugs.
407
+ renders_this_session: sessionJobs.current.size,
408
+ },
409
+ });
410
+ }
411
+ }, [jobs]);
412
+
355
413
  // Clean up EventSource on unmount or projectId change
356
414
  useEffect(() => {
357
415
  return () => {
@@ -11,6 +11,7 @@ import {
11
11
  } from "./AgentChatMessageButton";
12
12
  import { FRAME_STATUS_META } from "./frameStatus";
13
13
  import type { CommentsSubmitState } from "./useFrameComments";
14
+ import { isTypingTarget } from "../../utils/typingTarget";
14
15
 
15
16
  export interface StoryboardFrameFocusProps {
16
17
  projectId: string;
@@ -140,8 +141,7 @@ export function StoryboardFrameFocus({
140
141
  useEffect(() => {
141
142
  // fallow-ignore-next-line complexity
142
143
  const onKey = (e: KeyboardEvent) => {
143
- const el = document.activeElement;
144
- if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement) return;
144
+ if (isTypingTarget(document.activeElement)) return;
145
145
  if (e.key === "Escape") handleBack();
146
146
  else if (e.key === "ArrowLeft" && frame.index > 1) handleNavigate(-1);
147
147
  else if (e.key === "ArrowRight" && frame.index < frameCount) handleNavigate(1);
@@ -26,6 +26,7 @@ vi.mock("./FramePoster", () => ({
26
26
  }));
27
27
 
28
28
  const onSelectComposition = vi.fn();
29
+ const onNavigate = vi.fn();
29
30
 
30
31
  function TestApp() {
31
32
  const viewMode = useViewModeState();
@@ -48,9 +49,9 @@ function TestApp() {
48
49
  narrative: "",
49
50
  extra: {},
50
51
  }}
51
- frameCount={1}
52
+ frameCount={2}
52
53
  onBack={vi.fn()}
53
- onNavigate={vi.fn()}
54
+ onNavigate={onNavigate}
54
55
  onSaved={vi.fn()}
55
56
  onSelectComposition={onSelectComposition}
56
57
  scriptExists={false}
@@ -109,6 +110,7 @@ beforeEach(() => {
109
110
  window.history.replaceState({ entry: "timeline" }, "", "/");
110
111
  window.history.pushState({ entry: "storyboard" }, "", "/?view=storyboard");
111
112
  onSelectComposition.mockReset();
113
+ onNavigate.mockReset();
112
114
  });
113
115
 
114
116
  afterEach(() => {
@@ -168,3 +170,18 @@ describe("dirty storyboard voiceover view-mode guard", () => {
168
170
  act(() => root.unmount());
169
171
  });
170
172
  });
173
+
174
+ describe("storyboard frame keyboard navigation", () => {
175
+ it("leaves arrow keys with contenteditable text", () => {
176
+ const { host, root } = renderApp();
177
+ const editor = document.createElement("div");
178
+ editor.contentEditable = "true";
179
+ host.append(editor);
180
+ editor.focus();
181
+
182
+ act(() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" })));
183
+
184
+ expect(onNavigate).not.toHaveBeenCalled();
185
+ act(() => root.unmount());
186
+ });
187
+ });