@hyperframes/studio 0.7.60 → 0.7.61

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 (47) hide show
  1. package/dist/assets/hyperframes-player-Xvx2hkrc.js +459 -0
  2. package/dist/assets/{index-cH6NfVV_.js → index-2mxh_HSy.js} +177 -177
  3. package/dist/assets/{index-D6etaey-.js → index-BCpoiv9S.js} +1 -1
  4. package/dist/assets/index-BhWig0mx.css +1 -0
  5. package/dist/assets/{index-Dh_WhagG.js → index-D-uFclFj.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1202 -673
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +3 -8
  11. package/src/components/DesignPanelPromoteProvider.tsx +27 -1
  12. package/src/components/StudioHeader.tsx +2 -3
  13. package/src/components/StudioRightPanel.tsx +34 -26
  14. package/src/components/editor/PromotableControl.tsx +4 -2
  15. package/src/components/editor/PropertyPanel.tsx +151 -149
  16. package/src/components/editor/PropertyPanelFlat.tsx +44 -42
  17. package/src/components/editor/manualEditingAvailability.test.ts +6 -6
  18. package/src/components/editor/manualEditingAvailability.ts +5 -3
  19. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +10 -0
  20. package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -0
  21. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
  22. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +21 -9
  23. package/src/components/editor/propertyPanelFlatStyleSections.tsx +15 -32
  24. package/src/components/editor/propertyPanelFlatTextSection.tsx +2 -2
  25. package/src/components/editor/propertyPanelInputCoverage.test.tsx +14 -0
  26. package/src/components/editor/propertyPanelPrimitives.tsx +9 -1
  27. package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
  28. package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
  29. package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
  30. package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
  31. package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
  32. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
  33. package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
  34. package/src/components/storyboard/storyboardReviewStage.ts +45 -0
  35. package/src/components/storyboard/useFrameComments.ts +22 -6
  36. package/src/contexts/ViewModeContext.tsx +60 -7
  37. package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
  38. package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
  39. package/src/hooks/useSlideshowTabState.test.ts +96 -0
  40. package/src/hooks/useSlideshowTabState.ts +61 -0
  41. package/src/player/lib/playbackTypes.ts +7 -0
  42. package/src/player/lib/runtimeProtocol.test.ts +6 -1
  43. package/src/player/lib/timelineDOM.ts +8 -11
  44. package/src/player/lib/timelineIframeHelpers.ts +153 -107
  45. package/dist/assets/hyperframes-player-3XTTaVNf.js +0 -459
  46. package/dist/assets/index-DXbu6IPT.css +0 -1
  47. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
@@ -388,48 +388,50 @@ export function PropertyPanelFlat({
388
388
  ),
389
389
  });
390
390
  }
391
- groups.push({
392
- id: "layout",
393
- title: "Layout",
394
- // No scrub accessory: FlatRow/CommitField has no pointer-drag scrubbing
395
- // (wheel/arrow keys only) advertising "drag values to scrub" here lies.
396
- summary: `${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`,
397
- content: (
398
- <FlatLayoutSection
399
- element={element}
400
- styles={styles}
401
- onSetStyle={onSetStyle}
402
- disabled={!element.capabilities.canEditStyles}
403
- displayX={displayX}
404
- displayY={displayY}
405
- displayW={displayW}
406
- displayH={displayH}
407
- displayR={displayR}
408
- manualOffsetEditingDisabled={manualOffsetEditingDisabled}
409
- manualSizeEditingDisabled={manualSizeEditingDisabled}
410
- manualRotationEditingDisabled={manualRotationEditingDisabled}
411
- commitManualOffset={commitManualOffset}
412
- commitManualSize={commitManualSize}
413
- commitManualRotation={commitManualRotation}
414
- gsapAnimId={gsapAnimId}
415
- navKeyframes={navKeyframes}
416
- currentPct={currentPct}
417
- seekFromKfPct={seekFromKfPct}
418
- animIdForProp={animIdForProp}
419
- resolveAnimIdForProp={animIdForProp}
420
- gsapRuntimeValues={gsapRuntimeValues}
421
- gsapKeyframes={navKeyframes}
422
- elStart={elStart}
423
- elDuration={elDuration}
424
- onCommitAnimatedProperty={onCommitAnimatedProperty}
425
- onCommitAnimatedProperties={onCommitAnimatedProperties}
426
- onSeekToTime={onSeekToTime}
427
- onRemoveKeyframe={onRemoveKeyframe}
428
- onConvertToKeyframes={onConvertToKeyframes}
429
- onLivePreviewProps={createGsapLivePreview(previewIframeRef ?? { current: null })}
430
- />
431
- ),
432
- });
391
+ if (sections.layout) {
392
+ groups.push({
393
+ id: "layout",
394
+ title: "Layout",
395
+ // No scrub accessory: FlatRow/CommitField has no pointer-drag scrubbing
396
+ // (wheel/arrow keys only) advertising "drag values to scrub" here lies.
397
+ summary: `${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`,
398
+ content: (
399
+ <FlatLayoutSection
400
+ element={element}
401
+ styles={styles}
402
+ onSetStyle={onSetStyle}
403
+ disabled={!element.capabilities.canEditStyles}
404
+ displayX={displayX}
405
+ displayY={displayY}
406
+ displayW={displayW}
407
+ displayH={displayH}
408
+ displayR={displayR}
409
+ manualOffsetEditingDisabled={manualOffsetEditingDisabled}
410
+ manualSizeEditingDisabled={manualSizeEditingDisabled}
411
+ manualRotationEditingDisabled={manualRotationEditingDisabled}
412
+ commitManualOffset={commitManualOffset}
413
+ commitManualSize={commitManualSize}
414
+ commitManualRotation={commitManualRotation}
415
+ gsapAnimId={gsapAnimId}
416
+ navKeyframes={navKeyframes}
417
+ currentPct={currentPct}
418
+ seekFromKfPct={seekFromKfPct}
419
+ animIdForProp={animIdForProp}
420
+ resolveAnimIdForProp={animIdForProp}
421
+ gsapRuntimeValues={gsapRuntimeValues}
422
+ gsapKeyframes={navKeyframes}
423
+ elStart={elStart}
424
+ elDuration={elDuration}
425
+ onCommitAnimatedProperty={onCommitAnimatedProperty}
426
+ onCommitAnimatedProperties={onCommitAnimatedProperties}
427
+ onSeekToTime={onSeekToTime}
428
+ onRemoveKeyframe={onRemoveKeyframe}
429
+ onConvertToKeyframes={onConvertToKeyframes}
430
+ onLivePreviewProps={createGsapLivePreview(previewIframeRef ?? { current: null })}
431
+ />
432
+ ),
433
+ });
434
+ }
433
435
  if (showMotionGroup) {
434
436
  groups.push({
435
437
  id: "motion",
@@ -106,13 +106,13 @@ describe("manual editing availability", () => {
106
106
  expect(resolveStudioBooleanEnvFlag({ UNKNOWN: "maybe" }, ["UNKNOWN"], false)).toBe(false);
107
107
  });
108
108
 
109
- it("defaults the flat inspector flag to false and honors an explicit override", async () => {
110
- const off = await loadAvailabilityWithEnv({});
111
- expect(off.STUDIO_FLAT_INSPECTOR_ENABLED).toBe(false);
109
+ it("defaults the flat inspector flag to true and honors an explicit override", async () => {
110
+ const on = await loadAvailabilityWithEnv({});
111
+ expect(on.STUDIO_FLAT_INSPECTOR_ENABLED).toBe(true);
112
112
 
113
- const on = await loadAvailabilityWithEnv({
114
- VITE_STUDIO_FLAT_INSPECTOR_ENABLED: "true",
113
+ const off = await loadAvailabilityWithEnv({
114
+ VITE_STUDIO_FLAT_INSPECTOR_ENABLED: "false",
115
115
  });
116
- expect(on.STUDIO_FLAT_INSPECTOR_ENABLED).toBe(true);
116
+ expect(off.STUDIO_FLAT_INSPECTOR_ENABLED).toBe(false);
117
117
  });
118
118
  });
@@ -98,12 +98,14 @@ export const STUDIO_SDK_RESOLVER_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
98
98
  );
99
99
 
100
100
  // Studio inspector redesign ("Ledger, flat" — design_handoff_studio_inspector):
101
- // flat identity header/footer/groups behind a flag for incremental review.
102
- // Default false; enable via VITE_STUDIO_FLAT_INSPECTOR_ENABLED=true.
101
+ // flat identity header/footer/groups. Default true as of v0.7.59+ bug-fix pass
102
+ // (right-aligned values, Stroke select-only, promote-badge overlap, Layout/
103
+ // Style section gating); disable via VITE_STUDIO_FLAT_INSPECTOR_ENABLED=false
104
+ // to fall back to the legacy panel.
103
105
  export const STUDIO_FLAT_INSPECTOR_ENABLED = resolveStudioBooleanEnvFlag(
104
106
  env,
105
107
  ["VITE_STUDIO_ENABLE_FLAT_INSPECTOR", "VITE_STUDIO_FLAT_INSPECTOR_ENABLED"],
106
- false,
108
+ true,
107
109
  );
108
110
 
109
111
  export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
@@ -38,6 +38,16 @@ describe("FlatRow", () => {
38
38
  act(() => root.unmount());
39
39
  });
40
40
 
41
+ it("right-aligns the value input — the flat inspector's justify-between row layout leaves a left-aligned value looking stranded at the edge of its own box", () => {
42
+ const { host, root } = renderInto(
43
+ <FlatRow label="Size" value="72px" tier="explicitCustom" onCommit={vi.fn()} />,
44
+ );
45
+ const input = host.querySelector("input");
46
+ expect(input?.className).toContain("text-right");
47
+ expect(input?.className).not.toContain("text-left");
48
+ act(() => root.unmount());
49
+ });
50
+
41
51
  it("renders the explicitCustom tier with a mint value and a reset button", () => {
42
52
  const onReset = vi.fn();
43
53
  const { host, root } = renderInto(
@@ -51,6 +51,7 @@ export function FlatRow({
51
51
  value={value}
52
52
  disabled={disabled}
53
53
  liveCommit={liveCommit}
54
+ align="right"
54
55
  onCommit={(nextValue) => {
55
56
  track("metric", label);
56
57
  onCommit(nextValue);
@@ -12,16 +12,3 @@ export const STROKE_STYLE_OPTIONS: string[] = [
12
12
  "inset",
13
13
  "outset",
14
14
  ];
15
-
16
- export function formatStrokeSummary(widthPx: number, style: string): string {
17
- return `${widthPx}px ${style}`;
18
- }
19
-
20
- export function parseStrokeSummary(text: string): { widthPx: number; style: string } | null {
21
- const match = /^\s*(-?\d+(?:\.\d+)?)px\s+(\S+)\s*$/.exec(text);
22
- if (!match) return null;
23
- const widthPx = Number.parseFloat(match[1]);
24
- const style = match[2];
25
- if (!Number.isFinite(widthPx) || !style) return null;
26
- return { widthPx, style };
27
- }
@@ -175,31 +175,43 @@ function setInputValue(input: HTMLInputElement, nextValue: string) {
175
175
  }
176
176
 
177
177
  describe("FlatStyleSection — Stroke and Radius", () => {
178
- it("renders the combined stroke row and commits width+style together on blur", () => {
178
+ it("renders a width-only Stroke width row style is only ever set through the Stroke style select below it", () => {
179
179
  const { host, root } = renderSection(STROKE_STYLES);
180
- expect(host.textContent).toContain("Stroke");
181
- expect(getFlatRowInput(host, "Stroke").value).toBe("1px solid");
180
+ expect(host.textContent).toContain("Stroke width");
181
+ expect(host.textContent).toContain("Stroke style");
182
+ expect(getFlatRowInput(host, "Stroke width").value).toBe("1px");
182
183
  act(() => root.unmount());
183
184
  });
184
185
 
185
- it("commits the stroke row's new width and style together on blur", async () => {
186
+ it("commits a new stroke width without touching the existing style", async () => {
186
187
  const { host, root, onSetStyle } = renderSection(STROKE_STYLES);
187
- await commitFlatRowInput(host, "Stroke", "2px dashed");
188
+ await commitFlatRowInput(host, "Stroke width", "2px");
188
189
  expect(onSetStyle).toHaveBeenCalledWith("border-width", "2px");
189
- expect(onSetStyle).toHaveBeenCalledWith("border-style", "dashed");
190
+ expect(onSetStyle).not.toHaveBeenCalledWith("border-style", expect.anything());
191
+ act(() => root.unmount());
192
+ });
193
+
194
+ it("defaults a from-zero width commit to solid, since a width with no style renders no visible border", async () => {
195
+ const { host, root, onSetStyle } = renderSection({
196
+ "border-width": "0px",
197
+ "border-style": "none",
198
+ "border-color": "rgba(255,255,255,.12)",
199
+ });
200
+ await commitFlatRowInput(host, "Stroke width", "3px");
201
+ expect(onSetStyle).toHaveBeenCalledWith("border-style", "solid");
190
202
  act(() => root.unmount());
191
203
  });
192
204
 
193
205
  it("clamps an out-of-range stroke width commit to 200px (fix 2)", async () => {
194
206
  const { host, root, onSetStyle } = renderSection(STROKE_STYLES);
195
- await commitFlatRowInput(host, "Stroke", "9999px solid");
207
+ await commitFlatRowInput(host, "Stroke width", "9999px");
196
208
  expect(onSetStyle).toHaveBeenCalledWith("border-width", "200px");
197
209
  act(() => root.unmount());
198
210
  });
199
211
 
200
- it("rejects a stroke commit whose style token is not a valid border-style (fix 2)", async () => {
212
+ it("ignores an unparseable stroke width commit", async () => {
201
213
  const { host, root, onSetStyle } = renderSection(STROKE_STYLES);
202
- await commitFlatRowInput(host, "Stroke", "12px bogus");
214
+ await commitFlatRowInput(host, "Stroke width", "bogus");
203
215
  expect(onSetStyle).not.toHaveBeenCalled();
204
216
  act(() => root.unmount());
205
217
  });
@@ -3,11 +3,7 @@ import { useEffect, useState } from "react";
3
3
  import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
4
4
  import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
5
5
  import { BorderRadiusEditor } from "./BorderRadiusEditor";
6
- import {
7
- formatStrokeSummary,
8
- parseStrokeSummary,
9
- STROKE_STYLE_OPTIONS,
10
- } from "./propertyPanelFlatStyleHelpers";
6
+ import { STROKE_STYLE_OPTIONS } from "./propertyPanelFlatStyleHelpers";
11
7
  import {
12
8
  buildBoxShadowPresetValue,
13
9
  buildClipPathValue,
@@ -147,10 +143,9 @@ function FlatFillFields({
147
143
  }
148
144
 
149
145
  /* ------------------------------------------------------------------ */
150
- /* Flat Stroke row — combined width+style+color */
146
+ /* Flat Stroke row — width (numeric), style (select), color */
151
147
  /* ------------------------------------------------------------------ */
152
148
 
153
- // fallow-ignore-next-line complexity
154
149
  function FlatStrokeRow({
155
150
  styles,
156
151
  disabled,
@@ -167,52 +162,40 @@ function FlatStrokeRow({
167
162
  const borderStyleValue = styles["border-style"] || styles["border-top-style"] || "none";
168
163
  const borderColorValue =
169
164
  styles["border-color"] || styles["border-top-color"] || "rgba(255, 255, 255, 0.18)";
170
- const summary = formatStrokeSummary(borderWidthValue, borderStyleValue);
171
- const tier = resolveValueTier(
172
- styles["border-width"] != null || styles["border-style"] != null ? summary : undefined,
173
- formatStrokeSummary(0, "none"),
174
- );
165
+ const widthDisplay = formatPxMetricValue(borderWidthValue);
175
166
 
176
167
  return (
177
168
  <>
178
169
  <FlatRow
179
- label="Stroke"
180
- value={summary}
181
- tier={tier}
170
+ label="Stroke width"
171
+ value={widthDisplay}
172
+ tier={resolveValueTier(styles["border-width"], "0px")}
182
173
  disabled={disabled}
183
174
  onCommit={async (next) => {
184
- const parsed = parseStrokeSummary(next);
185
- if (!parsed) return;
186
- if (!STROKE_STYLE_OPTIONS.includes(parsed.style)) return;
187
- const normalizedWidth = normalizePanelPxValue(`${parsed.widthPx}px`, {
175
+ const normalizedWidth = normalizePanelPxValue(next, {
188
176
  min: 0,
189
177
  max: 200,
190
178
  fallback: borderWidthValue,
191
179
  });
192
180
  if (!normalizedWidth) return;
181
+ // buildStrokeWidthStyleUpdates already covers "a width typed in
182
+ // from 0 needs a style to actually render a visible border" —
183
+ // it only defaults to solid when the current style is none/hidden,
184
+ // never clobbering an already-chosen style (dashed, dotted, …).
193
185
  for (const [property, value] of buildStrokeWidthStyleUpdates(
194
186
  normalizedWidth,
195
- parsed.style,
187
+ borderStyleValue,
196
188
  )) {
197
189
  await onSetStyle(property, value);
198
190
  }
199
- for (const [property, value] of buildStrokeStyleUpdates(parsed.style, normalizedWidth)) {
200
- await onSetStyle(property, value);
201
- }
202
191
  }}
203
- suffix={
204
- <>
205
- <span
206
- className="h-4 w-4 flex-shrink-0 rounded border border-panel-border-input"
207
- style={{ backgroundColor: borderColorValue }}
208
- />
209
- <span className="font-mono text-[10px] text-panel-text-3">{borderColorValue}</span>
210
- </>
211
- }
212
192
  />
213
193
  <FlatSelectRow
214
194
  label="Stroke style"
215
195
  value={borderStyleValue}
196
+ // Valid border-style keywords — the ONLY way to set this, since a
197
+ // free-text field here would require typing an exact CSS keyword
198
+ // (e.g. "dashed") with no indication of which ones are valid.
216
199
  options={STROKE_STYLE_OPTIONS}
217
200
  tier={resolveValueTier(styles["border-style"], "none")}
218
201
  disabled={disabled}
@@ -267,7 +267,7 @@ export function FlatTextSection({
267
267
 
268
268
  if (textFields.length > 1) {
269
269
  return (
270
- <div className="space-y-1.5">
270
+ <div className="space-y-2.5">
271
271
  <FlatTextLayerList
272
272
  fields={textFields}
273
273
  activeFieldKey={activeField.key}
@@ -295,7 +295,7 @@ export function FlatTextSection({
295
295
  }
296
296
 
297
297
  return (
298
- <div className="space-y-1.5">
298
+ <div className="space-y-2.5">
299
299
  <FlatTextFieldEditor
300
300
  field={activeField}
301
301
  styles={styles}
@@ -134,6 +134,13 @@ describe("classic property-panel primitive telemetry", () => {
134
134
  const input = host.querySelector("input");
135
135
  if (!input) throw new Error("expected metric input");
136
136
 
137
+ // Regression guard for CommitField's shared `align` default: the classic
138
+ // panel lays out label-then-value inline, where left-aligned reads
139
+ // naturally — this must stay left even though the flat inspector's
140
+ // FlatRow now opts into `align="right"` for its own justify-between rows.
141
+ expect(input.className).toContain("text-left");
142
+ expect(input.className).not.toContain("text-right");
143
+
137
144
  act(() => blurInput(input));
138
145
  expect(trackStudioEvent).not.toHaveBeenCalled();
139
146
 
@@ -448,6 +455,13 @@ function representativeElement() {
448
455
 
449
456
  describe("classic PropertyPanel input coverage", () => {
450
457
  it("emits only named, known-section events across body inputs and header/footer chrome", async () => {
458
+ vi.resetModules();
459
+ vi.doMock("./manualEditingAvailability", async () => {
460
+ const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
461
+ "./manualEditingAvailability",
462
+ );
463
+ return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: false };
464
+ });
451
465
  const { PropertyPanel } = await import("./PropertyPanel");
452
466
  const host = render(
453
467
  <PropertyPanel
@@ -9,11 +9,17 @@ export function CommitField({
9
9
  value,
10
10
  disabled,
11
11
  liveCommit,
12
+ align = "left",
12
13
  onCommit,
13
14
  }: {
14
15
  value: string;
15
16
  disabled?: boolean;
16
17
  liveCommit?: boolean;
18
+ /** The legacy panel lays out label-then-value inline (left reads naturally);
19
+ * the flat inspector lays out label…value across a `justify-between` row,
20
+ * where a left-aligned value looks stranded at the edge of its own
21
+ * right-hand box instead of lining up with every other row's value. */
22
+ align?: "left" | "right";
17
23
  onCommit: (nextValue: string) => void;
18
24
  }) {
19
25
  const [draft, setDraft] = useState(value);
@@ -92,7 +98,9 @@ export function CommitField({
92
98
  scheduleCommit(nextDraft);
93
99
  }}
94
100
  title={parseNumericToken(value) ? "Scroll or use Arrow keys to adjust" : undefined}
95
- className="min-w-0 w-full bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
101
+ className={`min-w-0 w-full bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600 ${
102
+ align === "right" ? "text-right" : "text-left"
103
+ }`}
96
104
  />
97
105
  );
98
106
  }
@@ -0,0 +1,47 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { AgentChatMessageButton } from "./AgentChatMessageButton";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ afterEach(() => {
11
+ document.body.innerHTML = "";
12
+ vi.useRealTimers();
13
+ vi.restoreAllMocks();
14
+ });
15
+
16
+ describe("AgentChatMessageButton", () => {
17
+ it("reports a successful copy and resets to an explicit re-copy action", async () => {
18
+ vi.useFakeTimers();
19
+ const writeText = vi.fn().mockResolvedValue(undefined);
20
+ Object.defineProperty(navigator, "clipboard", {
21
+ configurable: true,
22
+ value: { writeText },
23
+ });
24
+ const onCopied = vi.fn();
25
+ const host = document.createElement("div");
26
+ document.body.append(host);
27
+ const root = createRoot(host);
28
+ act(() => {
29
+ root.render(<AgentChatMessageButton message="handoff" onCopied={onCopied} />);
30
+ });
31
+
32
+ const button = host.querySelector("button");
33
+ if (!button) throw new Error("copy button not rendered");
34
+ await act(async () => {
35
+ button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
36
+ await Promise.resolve();
37
+ });
38
+
39
+ expect(writeText).toHaveBeenCalledWith("handoff");
40
+ expect(onCopied).toHaveBeenCalledOnce();
41
+ expect(button.textContent).toBe("Copied — paste in your agent chat");
42
+
43
+ act(() => vi.advanceTimersByTime(3000));
44
+ expect(button.textContent).toBe("Copy again");
45
+ act(() => root.unmount());
46
+ });
47
+ });
@@ -0,0 +1,45 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Button } from "../ui/Button";
3
+
4
+ export const APPLY_STORYBOARD_FEEDBACK_MESSAGE =
5
+ "Read the storyboard feedback I saved in .hyperframes/frame-comments.json and revise the frames.";
6
+
7
+ export function AgentChatMessageButton({
8
+ message,
9
+ label = "Copy prompt for agent",
10
+ onCopied,
11
+ }: {
12
+ message: string;
13
+ label?: string;
14
+ onCopied?: () => void;
15
+ }) {
16
+ const [copyState, setCopyState] = useState<"idle" | "copied" | "again" | "failed">("idle");
17
+
18
+ useEffect(() => {
19
+ if (copyState !== "copied") return;
20
+ const timeout = window.setTimeout(() => setCopyState("again"), 3000);
21
+ return () => window.clearTimeout(timeout);
22
+ }, [copyState]);
23
+
24
+ const copyMessage = async () => {
25
+ try {
26
+ await navigator.clipboard.writeText(message);
27
+ setCopyState("copied");
28
+ onCopied?.();
29
+ } catch {
30
+ setCopyState("failed");
31
+ }
32
+ };
33
+
34
+ return (
35
+ <Button size="sm" variant="secondary" onClick={() => void copyMessage()}>
36
+ {copyState === "copied"
37
+ ? "Copied — paste in your agent chat"
38
+ : copyState === "again"
39
+ ? "Copy again"
40
+ : copyState === "failed"
41
+ ? "Copy failed"
42
+ : label}
43
+ </Button>
44
+ );
45
+ }