@hyperframes/studio 0.7.105 → 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 (71) hide show
  1. package/dist/assets/{hyperframes-player-Jx07OQgw.js → hyperframes-player-BLrfwq5o.js} +1 -1
  2. package/dist/assets/{index-CryZ0PGi.js → index-BA9yhzfR.js} +1 -1
  3. package/dist/assets/{index-Dqi2GmOp.js → index-BSBk5srs.js} +1 -1
  4. package/dist/assets/{index-DuxKGsHZ.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 +5335 -3954
  12. package/dist/index.js.map +1 -1
  13. package/package.json +8 -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 +119 -31
  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/domEditOverlayCrop.test.ts +136 -2
  28. package/src/components/editor/domEditOverlayCrop.ts +99 -15
  29. package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
  30. package/src/components/editor/domEditOverlayGeometry.ts +13 -10
  31. package/src/components/editor/domEditOverlayTransform.ts +88 -0
  32. package/src/components/editor/domEditing.ts +1 -0
  33. package/src/components/editor/domEditingLayers.ts +7 -6
  34. package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
  35. package/src/components/editor/inlineTextStyleRange.ts +593 -0
  36. package/src/components/editor/inlineTextStyleRead.ts +47 -0
  37. package/src/components/editor/useDomEditNudge.ts +2 -2
  38. package/src/components/editor/useInlineTextEditing.tsx +119 -0
  39. package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
  40. package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
  41. package/src/components/feedback/feedbackTrigger.test.ts +168 -0
  42. package/src/components/feedback/feedbackTrigger.ts +269 -0
  43. package/src/components/feedback/projectProvenance.test.ts +120 -0
  44. package/src/components/feedback/projectProvenance.ts +83 -0
  45. package/src/components/renders/useRenderQueue.ts +62 -4
  46. package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
  47. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
  48. package/src/contexts/DomEditContext.tsx +4 -0
  49. package/src/hooks/domEditPersistFailure.ts +0 -7
  50. package/src/hooks/useAppHotkeys.ts +4 -4
  51. package/src/hooks/useDomEditCommits.test.tsx +103 -21
  52. package/src/hooks/useDomEditCommits.ts +2 -0
  53. package/src/hooks/useDomEditSession.ts +2 -0
  54. package/src/hooks/useDomEditTextCommits.ts +110 -11
  55. package/src/hooks/useFileTree.ts +8 -3
  56. package/src/hooks/useInlineTextEdit.test.tsx +555 -0
  57. package/src/hooks/useInlineTextEdit.ts +320 -0
  58. package/src/player/lib/playbackShortcuts.ts +5 -4
  59. package/src/telemetry/breadcrumbs.test.ts +68 -0
  60. package/src/telemetry/breadcrumbs.ts +70 -0
  61. package/src/telemetry/client.ts +5 -0
  62. package/src/telemetry/events.ts +84 -3
  63. package/src/utils/sourcePatcher.ts +5 -1
  64. package/src/utils/studioHelpers.ts +2 -5
  65. package/src/utils/timelineDiscovery.ts +3 -24
  66. package/src/utils/typingTarget.test.ts +54 -0
  67. package/src/utils/typingTarget.ts +43 -0
  68. package/dist/assets/index-tBPidglp.css +0 -1
  69. package/dist/chunk-OBAG3GWK.js.map +0 -1
  70. package/src/components/StudioFeedbackBar.tsx +0 -217
  71. /package/dist/{domEditingLayers-AT7G6F4L.js.map → domEditingLayers-7AMZ7GFI.js.map} +0 -0
@@ -0,0 +1,119 @@
1
+ import { useRef, type ReactNode, type RefObject } from "react";
2
+ import { InlineTextToolbar } from "./InlineTextToolbar";
3
+ import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
4
+ import { useInlineTextEdit } from "../../hooks/useInlineTextEdit";
5
+ import { usePlayerStore } from "../../player/store/playerStore";
6
+ import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
7
+ import {
8
+ canEditElementTextInline,
9
+ canEditTextInline,
10
+ isDoublePress,
11
+ type PressMark,
12
+ } from "./domEditInlineText";
13
+ import type { DomEditSelection } from "./domEditingTypes";
14
+
15
+ /**
16
+ * The canvas' side of editing text where it sits.
17
+ *
18
+ * Holds the session, decides which presses open one, and knows the two things
19
+ * the canvas has to do differently while one is open: stand aside so the caret
20
+ * underneath can be reached, and stop taking focus back.
21
+ *
22
+ * The actions context is read here rather than threaded through the overlay's
23
+ * props, the same way the agent surfaces in that overlay read it, and it is
24
+ * absent in standalone player mounts, which have no project to edit.
25
+ */
26
+ export function useInlineTextEditing(selectionRef: RefObject<DomEditSelection | null>): {
27
+ editing: boolean;
28
+ /** Open an edit when this press pairs with the last one. */
29
+ startFromPress: (event: { clientX: number; clientY: number }) => boolean;
30
+ /** Handle a key on the canvas. Returns true when it opened an edit. */
31
+ handleKeyDown: (event: {
32
+ key: string;
33
+ shiftKey: boolean;
34
+ ctrlKey: boolean;
35
+ metaKey: boolean;
36
+ altKey: boolean;
37
+ }) => boolean;
38
+ /**
39
+ * The styling controls for the current selection, for the caller to render.
40
+ *
41
+ * Handed back rather than mounted somewhere central because it belongs to the
42
+ * session this hook owns, and appears and disappears with it.
43
+ */
44
+ toolbar: ReactNode;
45
+ } {
46
+ const actions = useDomEditActionsContextOptional();
47
+ const inlineText = useInlineTextEdit({
48
+ onCommit: (commit) => void actions?.handleDomRichTextCommit(commit),
49
+ onPause: () => usePlayerStore.getState().setIsPlaying(false),
50
+ });
51
+ const lastPressRef = useRef<PressMark | null>(null);
52
+
53
+ /**
54
+ * The element under this press, hit-tested now rather than read from state.
55
+ *
56
+ * Every cached answer in the overlay is React state that has not caught up
57
+ * with the press happening now: the hover is documented as an async cache,
58
+ * and the selection from the first press has not re-rendered. Reading either
59
+ * meant the first double press on any element opened nothing, which is every
60
+ * double press that matters.
61
+ */
62
+ const elementUnderPress = (event: { clientX: number; clientY: number }) => {
63
+ const iframe = actions?.previewIframeRef?.current;
64
+ if (!iframe) return null;
65
+ // Studio suppresses pointer events inside the composition so the canvas
66
+ // overlay can own input, which means a plain elementFromPoint only ever
67
+ // finds wrappers. This helper lifts that for the length of the hit test,
68
+ // and is the same one the canvas uses to decide what was clicked.
69
+ return getPreviewTargetFromPointer(
70
+ iframe,
71
+ event.clientX,
72
+ event.clientY,
73
+ selectionRef.current?.compositionPath ?? null,
74
+ );
75
+ };
76
+
77
+ /** A point on Studio's canvas, in the scaled composition's coordinates. */
78
+ const compositionPoint = (event: { clientX: number; clientY: number }) => {
79
+ const iframe = actions?.previewIframeRef?.current;
80
+ const view = iframe?.contentWindow;
81
+ if (!iframe || !view?.innerWidth) return undefined;
82
+ const box = iframe.getBoundingClientRect();
83
+ const scale = box.width / view.innerWidth || 1;
84
+ return { x: (event.clientX - box.left) / scale, y: (event.clientY - box.top) / scale };
85
+ };
86
+
87
+ return {
88
+ editing: inlineText.session !== null,
89
+ toolbar: (
90
+ <InlineTextToolbar
91
+ session={inlineText.session}
92
+ iframe={actions?.previewIframeRef?.current ?? null}
93
+ />
94
+ ),
95
+ // Enter opens the selected element's text, the way every design tool does,
96
+ // and is the dependable way in: a double press has to survive the canvas'
97
+ // gesture machinery, while this is one key on a selection that has settled.
98
+ handleKeyDown: (event) => {
99
+ if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey)
100
+ return false;
101
+ const target = selectionRef.current;
102
+ if (inlineText.session || !target || !canEditTextInline(target)) return false;
103
+ return inlineText.start(target.element);
104
+ },
105
+ startFromPress: (event) => {
106
+ if (inlineText.session) return false;
107
+ const element = elementUnderPress(event);
108
+ const press = { x: event.clientX, y: event.clientY, at: Date.now(), element };
109
+ const paired = isDoublePress(lastPressRef.current, press);
110
+ lastPressRef.current = press;
111
+
112
+ if (!paired) return false;
113
+ if (!element || !canEditElementTextInline(element)) return false;
114
+ // The caret opens where the press landed, which means mapping the point
115
+ // out of Studio's coordinates and into the composition's own.
116
+ return inlineText.start(element, compositionPoint(event));
117
+ },
118
+ };
119
+ }
@@ -0,0 +1,27 @@
1
+ import { useEffect } from "react";
2
+ import { StudioFeedbackCard } from "./StudioFeedbackCard";
3
+ import { requestStudioFeedback } from "./feedbackTrigger";
4
+
5
+ /**
6
+ * The feedback prompt for the crash screen.
7
+ *
8
+ * A crash unmounts the whole editor, and with it the card that normally lives
9
+ * in the overlay stack, so the request cannot come from there. The error
10
+ * boundary's fallback renders this instead: the card mounts and subscribes
11
+ * first (child effects run before the parent's), then the effect below asks.
12
+ *
13
+ * The crash screen is the one place a user is guaranteed to have an opinion
14
+ * and nothing else to do with it. Everything else about the ask is unchanged —
15
+ * same eligibility, same cooldowns — so a crash cannot be used to talk to
16
+ * someone who already said their piece this session.
17
+ */
18
+ export function CrashFeedbackPrompt() {
19
+ useEffect(() => {
20
+ // No `detail`: the crash screen already prints the error message directly
21
+ // above this card, and the crash event already carries it. Passing it here
22
+ // would quote the same sentence back to the user twice.
23
+ requestStudioFeedback({ reason: "crash" });
24
+ }, []);
25
+
26
+ return <StudioFeedbackCard />;
27
+ }
@@ -0,0 +1,373 @@
1
+ import { memo, useState, useCallback, useRef, useEffect } from "react";
2
+ import {
3
+ trackStudioFeedback,
4
+ trackStudioFeedbackShown,
5
+ trackStudioFeedbackDismissed,
6
+ trackStudioFeedbackInterviewClick,
7
+ } from "../../telemetry/events";
8
+ import {
9
+ subscribeToFeedbackRequests,
10
+ markFeedbackAnswered,
11
+ markFeedbackDismissed,
12
+ followUpFor,
13
+ isProblemReason,
14
+ type FeedbackRequest,
15
+ type FollowUpQuestion,
16
+ } from "./feedbackTrigger";
17
+ import { projectProvenance } from "./projectProvenance";
18
+
19
+ /** A failed render is its own question; nothing is rotated in front of it. */
20
+ const FAILURE_FOLLOW_UP: FollowUpQuestion = {
21
+ id: "failure",
22
+ prompt: "That export failed.",
23
+ placeholder: "What were you exporting?",
24
+ presets: [
25
+ { label: "Happens every time", hint: "Every export of this project fails this way" },
26
+ { label: "First time", hint: "Exports normally work for me" },
27
+ { label: "Long composition", hint: "Over a minute, or a lot of scenes" },
28
+ { label: "Big media files", hint: "Large video or image assets in the project" },
29
+ { label: "Has audio", hint: "The composition includes voiceover, music or sound" },
30
+ { label: "Embedded video", hint: "One or more video clips inside the composition" },
31
+ ],
32
+ };
33
+
34
+ /**
35
+ * The crash screen ask. "What were you doing" beats "what went wrong": the
36
+ * user cannot see the stack trace, but they know exactly what they touched,
37
+ * and that is the half the crash report is missing.
38
+ */
39
+ const CRASH_FOLLOW_UP: FollowUpQuestion = {
40
+ id: "crash",
41
+ prompt: "Studio crashed. What were you doing?",
42
+ placeholder: "The last thing you touched",
43
+ presets: [
44
+ { label: "Editing the timeline", hint: "Dragging, trimming or splitting clips" },
45
+ { label: "Playing the preview", hint: "Scrubbing or playing back the composition" },
46
+ { label: "Adding media", hint: "Importing video, images or audio" },
47
+ { label: "Editing properties", hint: "Changing styles, transforms or keyframes" },
48
+ { label: "Exporting", hint: "Starting or watching a render" },
49
+ { label: "Just opened it", hint: "It broke before I did anything" },
50
+ ],
51
+ };
52
+
53
+ /** Long enough to read the render result first; short enough to stay polite. */
54
+ const AUTO_DISMISS_MS = 30_000;
55
+ /** Matches the .hf-toast-exit animation so the node leaves as it finishes. */
56
+ const EXIT_MS = 160;
57
+ /**
58
+ * The thanks state carries the interview link, so it has to outlast a glance.
59
+ * A 1.4s confirmation would flash a booking link nobody could reach.
60
+ */
61
+ const THANKS_MS = 10_000;
62
+
63
+ /**
64
+ * Someone who just answered is the likeliest person in the product to say yes
65
+ * to a call, and this is the only moment we have their attention with goodwill.
66
+ */
67
+ const INTERVIEW_URL = "https://calendar.app.google/yRHT7oPsHWcqFfFv5";
68
+
69
+ const RATINGS = Array.from({ length: 11 }, (_, n) => n);
70
+
71
+ type Step = "rating" | "comment" | "thanks";
72
+
73
+ /**
74
+ * The Studio feedback prompt. Shown by `feedbackTrigger` after a render
75
+ * finishes or fails — never on a timer, so the user always knows what they are
76
+ * being asked about.
77
+ *
78
+ * A failed render skips the 0-10 scale: scoring an export you never got is a
79
+ * question with no useful answer, and the free-text reply is the whole point.
80
+ */
81
+ // fallow-ignore-next-line complexity
82
+ export const StudioFeedbackCard = memo(function StudioFeedbackCard() {
83
+ const [request, setRequest] = useState<FeedbackRequest | null>(null);
84
+ const [step, setStep] = useState<Step>("rating");
85
+ const [rating, setRating] = useState<number | null>(null);
86
+ const [followUp, setFollowUp] = useState<FollowUpQuestion>(FAILURE_FOLLOW_UP);
87
+ const [comment, setComment] = useState("");
88
+ /** Explanation of the chip under the pointer, shown on the reserved line. */
89
+ const [hint, setHint] = useState<string | null>(null);
90
+ const [exiting, setExiting] = useState(false);
91
+ const inputRef = useRef<HTMLInputElement>(null);
92
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
93
+ // Read inside close(), which must not re-subscribe every keystroke.
94
+ const stateRef = useRef({ request, rating, step });
95
+ stateRef.current = { request, rating, step };
96
+
97
+ const clearTimer = useCallback(() => {
98
+ if (timerRef.current) {
99
+ clearTimeout(timerRef.current);
100
+ timerRef.current = null;
101
+ }
102
+ }, []);
103
+
104
+ const close = useCallback(
105
+ (via: "close" | "escape" | "timeout" | "sent") => {
106
+ const { request: req, rating: picked, step: at } = stateRef.current;
107
+ if (!req) return;
108
+ clearTimer();
109
+ if (via === "sent") {
110
+ markFeedbackAnswered();
111
+ } else {
112
+ markFeedbackDismissed();
113
+ trackStudioFeedbackDismissed({
114
+ reason: req.reason,
115
+ render_id: req.renderId,
116
+ via,
117
+ had_rating: picked !== null || at === "comment",
118
+ });
119
+ }
120
+ setExiting(true);
121
+ setTimeout(() => {
122
+ setRequest(null);
123
+ setExiting(false);
124
+ setRating(null);
125
+ setComment("");
126
+ setStep("rating");
127
+ }, EXIT_MS);
128
+ },
129
+ [clearTimer],
130
+ );
131
+
132
+ useEffect(
133
+ () =>
134
+ subscribeToFeedbackRequests((next) => {
135
+ setRequest(next);
136
+ setRating(null);
137
+ setComment("");
138
+ setHint(null);
139
+ setFollowUp(next.reason === "crash" ? CRASH_FOLLOW_UP : FAILURE_FOLLOW_UP);
140
+ // A crash or a failed render has nothing to score, so those open
141
+ // straight at the ask instead of demanding a number first.
142
+ setStep(isProblemReason(next.reason) ? "comment" : "rating");
143
+ trackStudioFeedbackShown({ reason: next.reason, render_id: next.renderId });
144
+ }),
145
+ [],
146
+ );
147
+
148
+ // Auto-dismiss only while the prompt is still untouched — once the user
149
+ // starts typing or picks a score, the card is theirs until they close it.
150
+ useEffect(() => {
151
+ if (!request || step === "thanks" || rating !== null || comment !== "") return;
152
+ timerRef.current = setTimeout(() => close("timeout"), AUTO_DISMISS_MS);
153
+ return clearTimer;
154
+ }, [request, step, rating, comment, close, clearTimer]);
155
+
156
+ useEffect(() => {
157
+ if (step === "comment") inputRef.current?.focus();
158
+ }, [step]);
159
+
160
+ /** `preset` is the tapped chip's text; absent means the field was typed in. */
161
+ const submit = useCallback(
162
+ (preset?: string) => {
163
+ const req = stateRef.current.request;
164
+ if (!req) return;
165
+ const text = (preset ?? comment).trim();
166
+ // Nothing to send yet: a failure prompt with no words is just a dismiss.
167
+ if (rating === null && text === "") return close("close");
168
+ trackStudioFeedback({
169
+ reason: req.reason,
170
+ render_id: req.renderId,
171
+ // Which follow-up this comment answers. Without it, "nothing" against
172
+ // "what would you cut?" and "nothing" against "what should we fix?" are
173
+ // the same row, and neither means anything.
174
+ question: followUp.id,
175
+ // Tapped chips cluster into countable buckets; typed answers do not.
176
+ // Mixing them would make a chip's popularity look like a written theme.
177
+ answer_kind: preset === undefined ? "typed" : "preset",
178
+ // Provenance first so a producer's own context wins on a key clash.
179
+ context: { ...projectProvenance(), ...req.context },
180
+ ...(rating === null ? {} : { rating }),
181
+ ...(text ? { comment: text } : {}),
182
+ });
183
+ clearTimer();
184
+ setStep("thanks");
185
+ setTimeout(() => close("sent"), THANKS_MS);
186
+ },
187
+ [rating, comment, followUp.id, close, clearTimer],
188
+ );
189
+
190
+ const pickRating = useCallback((n: number) => {
191
+ setRating(n);
192
+ setFollowUp(followUpFor(n));
193
+ setStep("comment");
194
+ }, []);
195
+
196
+ if (!request) return null;
197
+
198
+ const failed = isProblemReason(request.reason);
199
+ // Deliberately the CLI's question, word for word. Studio and CLI ratings only
200
+ // sit on one axis if they asked the same thing; what makes them comparable is
201
+ // `reason`, which records the moment, not a reworded prompt.
202
+ const title =
203
+ step === "rating" ? "How likely are you to recommend HyperFrames?" : followUp.prompt;
204
+
205
+ return (
206
+ <div
207
+ role="dialog"
208
+ aria-label="Send feedback to the HyperFrames team"
209
+ className={`motion-reduce:animate-none ${exiting ? "hf-toast-exit" : "hf-toast-enter"}`}
210
+ onKeyDown={(e) => {
211
+ if (e.key === "Escape") close("escape");
212
+ }}
213
+ >
214
+ <div
215
+ className="flex w-[min(340px,calc(100vw-48px))] flex-col gap-2.5 overflow-hidden rounded-2xl px-3.5 py-3"
216
+ style={{
217
+ background: failed
218
+ ? "linear-gradient(135deg, rgba(127,29,29,0.55), rgba(80,10,10,0.45))"
219
+ : "linear-gradient(135deg, rgba(38,38,38,0.55), rgba(23,23,23,0.45))",
220
+ backdropFilter: "blur(16px) saturate(1.6)",
221
+ WebkitBackdropFilter: "blur(16px) saturate(1.6)",
222
+ border: `1px solid ${failed ? "rgba(239,68,68,0.18)" : "rgba(255,255,255,0.08)"}`,
223
+ boxShadow: [
224
+ "0 8px 32px rgba(0,0,0,0.35)",
225
+ `inset 0 1px 0 ${failed ? "rgba(239,68,68,0.12)" : "rgba(255,255,255,0.06)"}`,
226
+ "inset 0 -1px 0 rgba(0,0,0,0.15)",
227
+ ].join(", "),
228
+ }}
229
+ >
230
+ <div className="flex items-start gap-3">
231
+ <p
232
+ className={`min-w-0 flex-1 text-[12px] leading-5 ${failed ? "text-red-100" : "text-neutral-100"}`}
233
+ >
234
+ {step === "thanks" ? "Thanks, that helps." : title}
235
+ </p>
236
+ {step === "thanks" && (
237
+ <a
238
+ href={INTERVIEW_URL}
239
+ target="_blank"
240
+ rel="noopener noreferrer"
241
+ onClick={() => {
242
+ const req = stateRef.current.request;
243
+ if (req) trackStudioFeedbackInterviewClick({ reason: req.reason });
244
+ close("sent");
245
+ }}
246
+ className="h-6 flex-shrink-0 whitespace-nowrap rounded-full border border-white/20 bg-white/[0.08] px-2.5 text-[11px] leading-6 text-neutral-100 transition-[background-color,border-color,transform] duration-150 ease-out hover:border-white/35 hover:bg-white/[0.16] active:scale-[0.97] motion-reduce:transition-none"
247
+ >
248
+ Talk to us, 30 min
249
+ </a>
250
+ )}
251
+ <button
252
+ type="button"
253
+ onClick={() => close("close")}
254
+ className="-mr-1 -mt-0.5 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors duration-150 hover:bg-white/10 hover:text-neutral-200"
255
+ aria-label="Dismiss"
256
+ >
257
+ <svg
258
+ width="10"
259
+ height="10"
260
+ viewBox="0 0 10 10"
261
+ fill="none"
262
+ stroke="currentColor"
263
+ strokeWidth="1.5"
264
+ >
265
+ <path d="M2 2l6 6M8 2l-6 6" />
266
+ </svg>
267
+ </button>
268
+ </div>
269
+
270
+ {/* The failure text the user already saw, quoted back so the report
271
+ carries it without asking them to retype the error. */}
272
+ {failed && request.detail && step !== "thanks" && (
273
+ <p className="line-clamp-2 text-[11px] leading-4 text-red-200/70">{request.detail}</p>
274
+ )}
275
+
276
+ {step === "rating" && (
277
+ <>
278
+ {/* Native radios: free arrow-key navigation, grouping and labels. */}
279
+ <fieldset className="grid grid-cols-11 gap-[3px]">
280
+ <legend className="sr-only">Rate this export from 0 to 10</legend>
281
+ {RATINGS.map((n) => (
282
+ <label
283
+ key={n}
284
+ // A resting fill, so the row reads as eleven controls rather
285
+ // than as a line of text that happens to be clickable.
286
+ className="flex h-7 cursor-pointer items-center justify-center rounded-md bg-white/[0.04] text-[11px] tabular-nums text-neutral-400 transition-[background-color,color,transform] duration-150 ease-out hover:bg-white/[0.14] hover:text-neutral-100 active:scale-[0.97] has-[:checked]:bg-white/90 has-[:checked]:text-neutral-900 has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-white/50 motion-reduce:transition-none"
287
+ >
288
+ <input
289
+ type="radio"
290
+ name="hf-studio-feedback-rating"
291
+ value={n}
292
+ checked={rating === n}
293
+ onChange={() => pickRating(n)}
294
+ className="sr-only"
295
+ />
296
+ {n}
297
+ </label>
298
+ ))}
299
+ </fieldset>
300
+ <div className="flex justify-between text-[10px] leading-none text-neutral-500">
301
+ <span>Not likely</span>
302
+ <span>Extremely likely</span>
303
+ </div>
304
+ </>
305
+ )}
306
+
307
+ {step === "comment" && (
308
+ <>
309
+ {/* One tap is the whole answer. Sends immediately: making someone
310
+ tap a chip and then hunt for Send throws away the reason chips
311
+ exist. The field below stays for anything the chips miss. */}
312
+ <div className="flex flex-wrap gap-1.5">
313
+ {followUp.presets.map((preset) => (
314
+ <button
315
+ key={preset.label}
316
+ type="button"
317
+ onClick={() => submit(preset.label)}
318
+ onPointerEnter={() => setHint(preset.hint)}
319
+ onPointerLeave={() => setHint(null)}
320
+ onFocus={() => setHint(preset.hint)}
321
+ onBlur={() => setHint(null)}
322
+ // The hint reaches a screen reader through the label, since
323
+ // it never gets the hover that reveals it visually.
324
+ aria-label={`${preset.label}. ${preset.hint}`}
325
+ // Hover has to be unmistakable, not a shade brighter: the
326
+ // chip under the pointer is the one the hint line is talking
327
+ // about, so if you cannot tell which is hovered, the hint is
328
+ // attached to nothing.
329
+ className={`h-6 rounded-full border px-2.5 text-[11px] transition-[background-color,border-color,color,transform] duration-150 ease-out active:scale-[0.97] motion-reduce:transition-none ${
330
+ failed
331
+ ? "border-red-300/20 bg-red-100/[0.06] text-red-100/80 hover:border-red-200/70 hover:bg-red-100/25 hover:text-white"
332
+ : "border-white/10 bg-white/[0.04] text-neutral-300 hover:border-white/60 hover:bg-white/25 hover:text-white"
333
+ }`}
334
+ >
335
+ {preset.label}
336
+ </button>
337
+ ))}
338
+ </div>
339
+ {/* Inline, not a floating tooltip. The card is 340px in a corner,
340
+ so a bubble above the chips lands on top of the question and a
341
+ bubble below lands on the input. One reserved line occludes
342
+ nothing and needs no hover delay to be readable. */}
343
+ <p className="h-3.5 truncate text-[10.5px] leading-[14px] text-neutral-500">
344
+ {hint ?? "Tap one, or write your own."}
345
+ </p>
346
+ <div className="flex items-center gap-2">
347
+ <input
348
+ ref={inputRef}
349
+ type="text"
350
+ value={comment}
351
+ onChange={(e) => setComment(e.target.value)}
352
+ onKeyDown={(e) => {
353
+ if (e.key === "Enter") submit();
354
+ }}
355
+ placeholder={followUp.placeholder}
356
+ aria-label={followUp.prompt}
357
+ maxLength={500}
358
+ className="h-7 min-w-0 flex-1 rounded-md border border-white/10 bg-black/20 px-2 text-[11px] text-neutral-100 outline-none transition-colors duration-150 placeholder:text-neutral-500 focus:border-white/25"
359
+ />
360
+ <button
361
+ type="button"
362
+ onClick={() => submit()}
363
+ className="h-7 flex-shrink-0 rounded-md bg-white/10 px-2.5 text-[11px] text-neutral-100 transition-[background-color,transform] duration-150 ease-out hover:bg-white/[0.16] active:scale-[0.97] motion-reduce:transition-none"
364
+ >
365
+ Send
366
+ </button>
367
+ </div>
368
+ </>
369
+ )}
370
+ </div>
371
+ </div>
372
+ );
373
+ });
@@ -0,0 +1,168 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
4
+
5
+ // The policy module decides whether this profile may be measured at all; the
6
+ // trigger refuses to ask anyone it cannot hear, so every test here has to say
7
+ // which of those two worlds it is in.
8
+ vi.mock("../../telemetry/policy", () => ({
9
+ browserTelemetryAllowed: () => telemetryAllowed,
10
+ }));
11
+
12
+ let telemetryAllowed = true;
13
+
14
+ const {
15
+ requestStudioFeedback,
16
+ subscribeToFeedbackRequests,
17
+ markFeedbackAnswered,
18
+ markFeedbackDismissed,
19
+ followUpFor,
20
+ isProblemReason,
21
+ } = await import("./feedbackTrigger");
22
+
23
+ const DAY_MS = 86_400_000;
24
+
25
+ function collect() {
26
+ const seen: unknown[] = [];
27
+ const unsubscribe = subscribeToFeedbackRequests((r) => seen.push(r));
28
+ return { seen, unsubscribe };
29
+ }
30
+
31
+ beforeEach(() => {
32
+ telemetryAllowed = true;
33
+ localStorage.clear();
34
+ sessionStorage.clear();
35
+ vi.useRealTimers();
36
+ });
37
+
38
+ afterEach(() => {
39
+ vi.useRealTimers();
40
+ });
41
+
42
+ describe("requestStudioFeedback eligibility", () => {
43
+ it("asks on the first eligible request", () => {
44
+ const { seen, unsubscribe } = collect();
45
+ requestStudioFeedback({ reason: "render_complete", renderId: "r1" });
46
+ expect(seen).toEqual([{ reason: "render_complete", renderId: "r1" }]);
47
+ unsubscribe();
48
+ });
49
+
50
+ it("asks only once per tab session, however many renders finish", () => {
51
+ const { seen, unsubscribe } = collect();
52
+ requestStudioFeedback({ reason: "render_complete", renderId: "r1" });
53
+ requestStudioFeedback({ reason: "render_complete", renderId: "r2" });
54
+ requestStudioFeedback({ reason: "render_failed", renderId: "r3" });
55
+ expect(seen).toHaveLength(1);
56
+ unsubscribe();
57
+ });
58
+
59
+ it("stays silent for 30 days after an answer", () => {
60
+ markFeedbackAnswered();
61
+ const { seen, unsubscribe } = collect();
62
+ requestStudioFeedback({ reason: "render_complete" });
63
+ expect(seen).toHaveLength(0);
64
+ unsubscribe();
65
+ });
66
+
67
+ it("asks again once the 30 day answer cooldown has passed", () => {
68
+ localStorage.setItem("hyperframes-studio:feedbackAnsweredAt", String(Date.now() - 31 * DAY_MS));
69
+ const { seen, unsubscribe } = collect();
70
+ requestStudioFeedback({ reason: "render_complete" });
71
+ expect(seen).toHaveLength(1);
72
+ unsubscribe();
73
+ });
74
+
75
+ it("stays silent for 7 days after a dismissal, then asks again", () => {
76
+ markFeedbackDismissed();
77
+ const first = collect();
78
+ requestStudioFeedback({ reason: "render_complete" });
79
+ expect(first.seen).toHaveLength(0);
80
+ first.unsubscribe();
81
+
82
+ sessionStorage.clear();
83
+ localStorage.setItem("hyperframes-studio:feedbackDismissedAt", String(Date.now() - 8 * DAY_MS));
84
+ const second = collect();
85
+ requestStudioFeedback({ reason: "render_complete" });
86
+ expect(second.seen).toHaveLength(1);
87
+ second.unsubscribe();
88
+ });
89
+
90
+ it("never asks a profile that opted out of telemetry", () => {
91
+ telemetryAllowed = false;
92
+ const { seen, unsubscribe } = collect();
93
+ requestStudioFeedback({ reason: "crash" });
94
+ expect(seen).toHaveLength(0);
95
+ unsubscribe();
96
+ });
97
+
98
+ it("does not burn the session slot on an ineligible request", () => {
99
+ telemetryAllowed = false;
100
+ const blocked = collect();
101
+ requestStudioFeedback({ reason: "render_complete" });
102
+ blocked.unsubscribe();
103
+
104
+ // Opting back in mid-session must not find the one ask already spent.
105
+ telemetryAllowed = true;
106
+ const { seen, unsubscribe } = collect();
107
+ requestStudioFeedback({ reason: "render_complete" });
108
+ expect(seen).toHaveLength(1);
109
+ unsubscribe();
110
+ });
111
+
112
+ it("drops the request when nothing is listening", () => {
113
+ expect(() => requestStudioFeedback({ reason: "crash" })).not.toThrow();
114
+ // The ask was not spent, so the next real subscriber still gets asked.
115
+ const { seen, unsubscribe } = collect();
116
+ requestStudioFeedback({ reason: "crash" });
117
+ expect(seen).toHaveLength(1);
118
+ unsubscribe();
119
+ });
120
+ });
121
+
122
+ describe("follow-up selection", () => {
123
+ it("gives every detractor the complaint question, never a rotated one", () => {
124
+ for (let rating = 0; rating <= 6; rating++) {
125
+ expect(followUpFor(rating).id).toBe("detractor");
126
+ }
127
+ });
128
+
129
+ it("rotates among the three research questions above 6", () => {
130
+ const ids = new Set<string>();
131
+ for (let i = 0; i < 200; i++) ids.add(followUpFor(9).id);
132
+ expect([...ids].sort()).toEqual(["fix", "remove", "workaround"]);
133
+ });
134
+
135
+ it("gives every question scannable, distinct one-tap answers with explanations", () => {
136
+ for (const rating of [0, 7, 8, 9, 10]) {
137
+ const q = followUpFor(rating);
138
+ // Four is the floor for a useful spread; past seven the chips stop being
139
+ // scannable in a 340px card and start being a form.
140
+ expect(q.presets.length).toBeGreaterThanOrEqual(4);
141
+ expect(q.presets.length).toBeLessThanOrEqual(7);
142
+ expect(new Set(q.presets.map((p) => p.label)).size).toBe(q.presets.length);
143
+ for (const p of q.presets) {
144
+ expect(p.hint.length).toBeGreaterThan(0);
145
+ // A label that wraps to two lines in a chip row is not scannable.
146
+ expect(p.label.length).toBeLessThanOrEqual(24);
147
+ }
148
+ }
149
+ });
150
+
151
+ it("offers no brand names, which are a popularity vote and not an instruction", () => {
152
+ const brands = /capcut|premiere|resolve|after effects|final cut|remotion|canva|descript/i;
153
+ for (const rating of [0, 7, 8, 9, 10]) {
154
+ for (const p of followUpFor(rating).presets) {
155
+ expect(p.label).not.toMatch(brands);
156
+ expect(p.hint).not.toMatch(brands);
157
+ }
158
+ }
159
+ });
160
+ });
161
+
162
+ describe("isProblemReason", () => {
163
+ it("treats everything except a finished render as a problem report", () => {
164
+ expect(isProblemReason("render_complete")).toBe(false);
165
+ expect(isProblemReason("render_failed")).toBe(true);
166
+ expect(isProblemReason("crash")).toBe(true);
167
+ });
168
+ });