From d35c59835897ddac879fc434c9c1509d52ca6a90 Mon Sep 17 00:00:00 2001 From: Ernis Date: Wed, 11 Nov 2026 03:26:31 +0600 Subject: [PATCH 1/4] Reapply "feat(studio): fit and polish promptwise's Timeline tab (#6)" (#7) This reverts commit 16041d3b28e58e4c92c6da2468bd06a3c5bcc690. --- .../StudioHeader.embed.dom.test.tsx | 79 +++++++++++++++++++ .../studio/src/components/StudioHeader.tsx | 61 ++++++++------ .../src/components/dock/dockLayout.test.ts | 37 +++++++++ .../studio/src/components/dock/dockLayout.ts | 27 ++++++- ...PropertyPanelEmptyState.embed.dom.test.tsx | 37 +++++++++ .../editor/PropertyPanelEmptyState.test.tsx | 2 +- .../editor/PropertyPanelEmptyState.tsx | 22 +++--- .../src/components/editor/SnapToolbar.tsx | 22 +++--- .../src/components/nle/NLEPreview.test.ts | 6 +- .../studio/src/components/nle/NLEPreview.tsx | 2 +- .../useStudioUrlState.opening.dom.test.tsx | 56 +++++++++++++ .../studio/src/hooks/useStudioUrlState.ts | 24 ++++-- .../components/TimelineShortcutHint.tsx | 2 +- packages/studio/src/promptwise/embed.ts | 5 ++ packages/studio/src/promptwise/theme.css | 29 ++++++- .../src/promptwise/usePromptwiseToolbar.ts | 44 +++++++++++ 16 files changed, 391 insertions(+), 64 deletions(-) create mode 100644 packages/studio/src/components/StudioHeader.embed.dom.test.tsx create mode 100644 packages/studio/src/components/editor/PropertyPanelEmptyState.embed.dom.test.tsx create mode 100644 packages/studio/src/hooks/useStudioUrlState.opening.dom.test.tsx create mode 100644 packages/studio/src/promptwise/usePromptwiseToolbar.ts diff --git a/packages/studio/src/components/StudioHeader.embed.dom.test.tsx b/packages/studio/src/components/StudioHeader.embed.dom.test.tsx new file mode 100644 index 00000000000..c6c30e47468 --- /dev/null +++ b/packages/studio/src/components/StudioHeader.embed.dom.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment happy-dom + +/** Embedded in promptwise: no header; the Timeline tab's toolbar drives Inspector and Export by message. */ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const layout = { rightCollapsed: true, setRightCollapsed: vi.fn(), setRightPanelTab: vi.fn() }; + +vi.mock("../promptwise/embed", () => ({ embeddedInPromptwise: true })); +vi.mock("../contexts/StudioContext", () => ({ + useStudioShellContext: () => ({ + projectId: "773d81ab-4ac0-4f9d-acd7-4cd8838312dd", + renderQueue: { isRendering: false, ffmpegMissing: false }, + }), +})); +vi.mock("../contexts/PanelLayoutContext", () => ({ usePanelLayoutContext: () => layout })); +vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() })); + +const { StudioHeader } = await import("./StudioHeader"); + +function command(name: string) { + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "promptwise:command", command: name }, + source: window.parent, + }), + ); + }); +} + +it("draws no header, runs the toolbar's presses and reports their state", () => { + const posted: unknown[] = []; + const postMessage = vi.spyOn(window.parent, "postMessage").mockImplementation((message) => { + posted.push(message); + }); + const onExport = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => + root.render( + , + ), + ); + + expect(host.innerHTML).toBe(""); + expect(posted).toEqual([{ type: "promptwise:state", inspectorOpen: false, rendering: false }]); + + command("inspector"); + expect(layout.setRightPanelTab).toHaveBeenLastCalledWith("design"); + expect(layout.setRightCollapsed).toHaveBeenLastCalledWith(false); + + command("export"); + expect(layout.setRightPanelTab).toHaveBeenLastCalledWith("renders"); + expect(onExport).toHaveBeenCalledOnce(); + + // Only the parent page may drive Studio, and only with the commands it knows. + window.dispatchEvent( + new MessageEvent("message", { data: { type: "promptwise:command", command: "export" } }), + ); + command("delete-everything"); + expect(onExport).toHaveBeenCalledOnce(); + + act(() => root.unmount()); + host.remove(); + postMessage.mockRestore(); +}); diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx index 87a3a019592..4347e015756 100644 --- a/packages/studio/src/components/StudioHeader.tsx +++ b/packages/studio/src/components/StudioHeader.tsx @@ -7,6 +7,8 @@ import { Button, cn, Tooltip } from "./ui"; import { ButtonGroup, ButtonGroupItem } from "../boardui/components/base/buttons/button-group"; import { Dock } from "./dock/Dock"; import { PromptwiseMark } from "../promptwise/PromptwiseMark"; +import { embeddedInPromptwise } from "../promptwise/embed"; +import { usePromptwiseToolbar } from "../promptwise/usePromptwiseToolbar"; import { ShowThemeToggle, ThemeToggle } from "./ThemeToggle"; interface StudioHeaderProps { @@ -55,6 +57,39 @@ export const StudioHeader = memo(function StudioHeader({ const isRendering = renderQueue.isRendering; const ffmpegMissing = renderQueue.ffmpegMissing; + const toggleInspector = () => { + if (shouldOpenInspector(rightCollapsed, inspectorPanelActive)) { + trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false }); + setRightPanelTab("design"); + setRightCollapsed(false); + return; + } + trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true }); + // Keep the current selection when collapsing the Inspector — closing + // the panel shouldn't deselect the element. + setRightCollapsed(true); + }; + + const exportVideo = () => { + if (isRendering) return; + setRightPanelTab("renders"); + setRightCollapsed(false); + // Without an encoder this render cannot finish, so the click + // delivers the user to the prompt that fixes it instead of + // queueing a job that exists only to fail. Disabling the button + // would leave them staring at a dead control with no route to + // the explanation. + if (ffmpegMissing) return; + onExport?.(); + }; + + usePromptwiseToolbar( + { inspectorOpen: inspectorButtonActive, rendering: isRendering }, + { inspector: toggleInspector, export: exportVideo }, + ); + // promptwise's Timeline tab has its own toolbar and tab name, so a second header would only repeat them. + if (embeddedInPromptwise) return null; + return (
@@ -104,18 +139,7 @@ export const StudioHeader = memo(function StudioHeader({ aria-pressed={inspectorButtonActive} selected={inspectorButtonActive} leadingIcon={RiLayoutRightLine} - onClick={() => { - if (shouldOpenInspector(rightCollapsed, inspectorPanelActive)) { - trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false }); - setRightPanelTab("design"); - setRightCollapsed(false); - return; - } - trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true }); - // Keep the current selection when collapsing the Inspector — closing - // the panel shouldn't deselect the element. - setRightCollapsed(true); - }} + onClick={toggleInspector} > Inspector @@ -137,18 +161,7 @@ export const StudioHeader = memo(function StudioHeader({ variant="primary" data-testid="header-export" disabled={isRendering} - onClick={() => { - if (isRendering) return; - setRightPanelTab("renders"); - setRightCollapsed(false); - // Without an encoder this render cannot finish, so the click - // delivers the user to the prompt that fixes it instead of - // queueing a job that exists only to fail. Disabling the button - // would leave them staring at a dead control with no route to - // the explanation. - if (ffmpegMissing) return; - onExport?.(); - }} + onClick={exportVideo} > {isRendering ? "Rendering…" : "Export"} diff --git a/packages/studio/src/components/dock/dockLayout.test.ts b/packages/studio/src/components/dock/dockLayout.test.ts index bddf62e8da8..40c3a1271c2 100644 --- a/packages/studio/src/components/dock/dockLayout.test.ts +++ b/packages/studio/src/components/dock/dockLayout.test.ts @@ -132,3 +132,40 @@ describe("applySideMinimums", () => { expect(timeline.group.minimumWidth).toBe(120); }); }); + +describe("buildEditLayout on a narrow dock", () => { + let api: DockviewApi; + beforeEach(() => { + ({ api } = mountBareDockview()); + }); + afterEach(() => { + api.dispose(); + document.body.innerHTML = ""; + }); + + it("puts the preview over the timeline and one hidden side group beside it, as in promptwise's tab", () => { + api.layout(640, 820); + buildEditLayout(api, 640); + const side = api.getPanel("design")!.group; + expect(side.panels.map((panel) => panel.id)).toEqual([ + "design", + "layers", + "renders", + "compositions", + "assets", + ]); + expect(side.api.isVisible).toBe(false); + expect(api.getPanel("preview")!.group.api.isVisible).toBe(true); + expect(api.getPanel("preview")!.group.width).toBe(640); + expect(api.getPanel("timeline")!.group.width).toBe(640); + expect(api.getPanel("timeline")!.group.height).toBe(260); + expect(api.getPanel("code")).toBeUndefined(); + }); + + it("keeps the three columns on a wide one", () => { + api.layout(1200, 700); + buildEditLayout(api, 1200); + expect(api.getPanel("compositions")!.group).not.toBe(api.getPanel("design")!.group); + expect(api.getPanel("design")!.group.api.isVisible).toBe(true); + }); +}); diff --git a/packages/studio/src/components/dock/dockLayout.ts b/packages/studio/src/components/dock/dockLayout.ts index 94886d93aea..acd9f8c1a35 100644 --- a/packages/studio/src/components/dock/dockLayout.ts +++ b/packages/studio/src/components/dock/dockLayout.ts @@ -10,6 +10,11 @@ const MIN_SIDE_W_FLOOR = 120; const DEFAULT_TIMELINE_H = 360; const DEFAULT_LEFT = ["compositions", "assets", "code", "catalog"] as const; const DEFAULT_RIGHT = ["design", "layers", "renders", "variables", "motionBlur"] as const; +/** Below this dock width (promptwise's Timeline tab beside its chat) two side columns would crush the preview. */ +const COMPACT_DOCK_W = 1000; +const COMPACT_SIDE = ["design", "layers", "renders", "compositions", "assets"] as const; +/** Room for three tracks under the toolbar and ruler; the preview gets the rest. */ +const COMPACT_TIMELINE_H = 260; /** Preferred side widths; when they overflow the preview's floor the right yields first, then the left. */ export function defaultSideWidths(viewportWidth: number) { @@ -99,12 +104,20 @@ function addSideColumn( return first; } -/** The default Edit layout: [library | preview | inspector] over a full-width timeline. */ +/** + * The default Edit layout: [library | preview | inspector] over a full-width timeline, or on a narrow + * dock the preview over the timeline with one side group beside the preview, shown when asked for + * (Inspector, a selection) since it would leave the preview too little room. + */ export function buildEditLayout( api: DockviewApi, viewportWidth: number, panels: readonly PanelId[] = PANEL_IDS, ) { + if (viewportWidth < COMPACT_DOCK_W) { + buildCompactLayout(api, viewportWidth, panels); + return; + } api.clear(); const widths = defaultSideWidths(viewportWidth); addRegisteredPanel(api, "preview"); @@ -116,3 +129,15 @@ export function buildEditLayout( if (right) api.getPanel(right)?.group.api.setSize({ width: widths.right }); api.getPanel("timeline")?.group.api.setSize({ height: DEFAULT_TIMELINE_H }); } + +function buildCompactLayout(api: DockviewApi, viewportWidth: number, panels: readonly PanelId[]) { + api.clear(); + addRegisteredPanel(api, "preview"); + addRegisteredPanel(api, "timeline", { referencePanel: "preview", direction: "below" }); + const side = addSideColumn(api, COMPACT_SIDE, panels, "right"); + applySideMinimums(api, viewportWidth); + api.getPanel("timeline")?.group.api.setSize({ height: COMPACT_TIMELINE_H }); + const group = side ? api.getPanel(side)?.group : undefined; + group?.api.setSize({ width: Math.max(MIN_SIDE_W, defaultSideWidths(viewportWidth).right) }); + group?.api.setVisible(false); +} diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.embed.dom.test.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.embed.dom.test.tsx new file mode 100644 index 00000000000..2b21a6f6328 --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.embed.dom.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment happy-dom + +/** In promptwise most changes go through the agent, so its button leads the empty Design panel. */ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock("../../promptwise/embed", async (original) => ({ + ...(await original()), + embeddedInPromptwise: true, +})); + +const { PropertyPanelEmptyState } = await import("./PropertyPanelEmptyState"); + +it("puts the agent first, as the panel's one primary button, and asks promptwise to open its chat", () => { + const posted: unknown[] = []; + const postMessage = vi.spyOn(window.parent, "postMessage").mockImplementation((message) => { + posted.push(message); + }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => root.render()); + + const text = host.textContent ?? ""; + expect(text.indexOf("Ask the agent")).toBeLessThan(text.indexOf("Record a gesture")); + const agent = host.querySelector("button")!; + expect(agent.textContent).toContain("Ask the agent"); + act(() => agent.click()); + expect(posted).toEqual([{ type: "promptwise:ask-agent" }]); + + act(() => root.unmount()); + host.remove(); + postMessage.mockRestore(); +}); diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx index 2848c1ee0e3..1578a77bb71 100644 --- a/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx @@ -28,7 +28,7 @@ describe("PropertyPanelEmptyState — flat empty", () => { expect(host.textContent).toContain("Nothing selected"); expect(host.textContent).toContain("Record a gesture"); // The agent chip only shows inside promptwise, whose chat it opens. - expect(host.textContent).not.toContain("Describe a change to the agent"); + expect(host.textContent).not.toContain("Ask the agent"); act(() => root.unmount()); }); }); diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx index 067d9068210..8385c190baa 100644 --- a/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx +++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx @@ -3,6 +3,7 @@ import type { DomEditSelection } from "./domEditingTypes"; import { canHideSelections } from "../../utils/timelineInspector"; import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; import { askPromptwiseAgent, embeddedInPromptwise } from "../../promptwise/embed"; +import { Button } from "../ui"; function FlatEmptyState() { return ( @@ -24,6 +25,17 @@ function FlatEmptyState() { Click any element on the canvas to edit it, or drag to select several.
+ {/* In promptwise the agent is the way most changes are made, so it comes first. */} + {embeddedInPromptwise && ( + + )} ● @@ -33,16 +45,6 @@ function FlatEmptyState() { R - {embeddedInPromptwise && ( - - )}
); diff --git a/packages/studio/src/components/editor/SnapToolbar.tsx b/packages/studio/src/components/editor/SnapToolbar.tsx index 8d5c72a2d55..63637d484b5 100644 --- a/packages/studio/src/components/editor/SnapToolbar.tsx +++ b/packages/studio/src/components/editor/SnapToolbar.tsx @@ -5,6 +5,10 @@ import { usePlayerStore } from "../../player/store/playerStore"; import { usePreviewOverlayContext } from "./PreviewOverlayProvider"; import { ownsPlainKeys } from "../../utils/typingTarget"; +/** A preview overlay button that's off: BoardUI's light surface with a hairline, readable over any frame. */ +const overlayButtonOff = + "bg-background-primary-default/90 text-foreground-icon-secondary shadow-xs ring-1 ring-border-button-default hover:bg-background-primary-hover hover:text-foreground-icon-primary"; + // fallow-ignore-next-line complexity export const SnapToolbar = memo(function SnapToolbar() { const [gridPopoverOpen, setGridPopoverOpen] = useState(false); @@ -87,9 +91,7 @@ export const SnapToolbar = memo(function SnapToolbar() { - - - - - {projectHasAudio && ( - - - - )} - {showKeyframes && ( + {allTools && ( <> - {/* With no keyframeable selection the button fades to disabled instead of - unmounting, so the toolbar layout never shifts (CapCut-style). */} - + + + {projectHasAudio && ( + + + + )} + {showKeyframes && ( + <> + {/* With no keyframeable selection the button fades to disabled instead of + unmounting, so the toolbar layout never shifts (CapCut-style). */} + + + + + + + + )} )} {onSplitElement && @@ -430,7 +444,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({ ); })()} {(() => { - if (!showAddBeat) return null; + if (!showAddBeat || !allTools) return null; // Rendered whenever shown (CapCut-style): before beat analysis loads (or when // the project has no analyzed music) the button fades to a disabled // state instead of unmounting, so the toolbar layout never shifts. @@ -471,53 +485,72 @@ export const TimelineToolbar = memo(function TimelineToolbar({ ); })()} + {embeddedInPromptwise && ( + + + + )}
{rightActions} - - - - - - + {allTools && ( + <> + + + + + + + + )}
diff --git a/packages/studio/src/main.tsx b/packages/studio/src/main.tsx index 7994ed12f84..a19b75cff26 100644 --- a/packages/studio/src/main.tsx +++ b/packages/studio/src/main.tsx @@ -111,6 +111,9 @@ window.addEventListener("unhandledrejection", (event) => { trackStudioEvent("unhandled_promise_rejection", props); }); +// The theme styles Studio for promptwise's tab: no tab on a lone panel. +if (embeddedInPromptwise) document.documentElement.dataset.embedded = ""; + createRoot(document.getElementById("root")!).render( {/* One icon size and weight for the whole app, taken from the theme file. diff --git a/packages/studio/src/player/components/TextClipContent.tsx b/packages/studio/src/player/components/TextClipContent.tsx index 1ca8d3c58de..b0c6c2cbd17 100644 --- a/packages/studio/src/player/components/TextClipContent.tsx +++ b/packages/studio/src/player/components/TextClipContent.tsx @@ -13,11 +13,11 @@ export function textClipBackground(text: TimelineText): string { export function TextClipContent({ text }: { text: TimelineText }) { return (
{text.value} diff --git a/packages/studio/src/promptwise/theme.css b/packages/studio/src/promptwise/theme.css index 2611f94da27..be78ceafe4f 100644 --- a/packages/studio/src/promptwise/theme.css +++ b/packages/studio/src/promptwise/theme.css @@ -76,3 +76,55 @@ .dv-groupview:hover .hf-timeline-shortcut-hint { opacity: 1; } + +/* Clips look like promptwise's own Timeline's: flat blocks with no title band or shadow, the name a small + * chip on pictures, and a text layer as its words in the interface's type on the tertiary surface. Effect + * badges show on the clip in hand. Audio clips keep Studio's fill, which their waveform is drawn for. */ +:root { + --timeline-clip-radius: 8px; + --timeline-clip-band-height: 0px; + --timeline-clip-shadow: none; + --timeline-clip-highlight: transparent; +} + +/* components.css comes after this file, so :root is what lets these win over its title band. */ +:root .timeline-clip:not(.is-audio) .timeline-clip__label { + top: 4px; + left: 6px; + width: fit-content; + max-width: calc(100% - 12px); + height: 16px; + padding: 0 4px; + border-radius: 4px; + background: var(--timeline-clip-chip-bg); + font-weight: 400; + line-height: 16px; + /* Over the filmstrip, which now starts at the clip's top. */ + z-index: 6; +} + +.timeline-clip:not(.is-hovered, .is-selected) .timeline-clip__label [data-testid="clip-badges"] { + display: none; +} + +.timeline-clip:has(.timeline-text-clip) + .timeline-clip__label:not(:has([data-testid="clip-badges"])), +.timeline-clip:has(.timeline-text-clip):not(.is-hovered, .is-selected) .timeline-clip__label { + display: none; +} + +.timeline-text-clip { + background: var(--color-background-tertiary-default) !important; +} + +.timeline-text-clip__words { + color: var(--color-text-primary) !important; + font-family: inherit !important; + font-size: 11px; + font-weight: 400 !important; +} + +/* Embedded in promptwise's tab, a panel alone in its group needs no tab to name it. */ +:root[data-embedded] .hf-dock .dv-tabs-and-actions-container.dv-single-tab { + display: none; +} From 061c9c179fb94488d9cef1e5ee89d57aa6a3030b Mon Sep 17 00:00:00 2001 From: Ernis Date: Wed, 11 Nov 2026 03:47:46 +0600 Subject: [PATCH 3/4] feat(studio): promptwise's resize grip on the dock's dividers, and a play button that shows - The dock's dividers no longer light up in the accent colour. Under the pointer or while dragged they show promptwise's sidebar grip, the white pill with three lines, turned on its side between the preview and the timeline. - The play and pause glyph had a white fill, which vanished on the light surface. It is now promptwise's own Timeline's filled play and pause in the button's ink, and the button's hover is the toolbar's. Co-Authored-By: Claude Fable 5.2 --- .../src/player/components/PlayerControls.tsx | 51 +++---------------- packages/studio/src/promptwise/theme.css | 32 ++++++++++++ packages/studio/src/styles/hex-baseline.json | 3 +- 3 files changed, 40 insertions(+), 46 deletions(-) diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index bab8820e331..59cf50167b7 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -1,6 +1,5 @@ import { useRef, useEffect, memo } from "react"; -import gsap from "gsap"; -import { MorphSVGPlugin } from "gsap/MorphSVGPlugin"; +import { RiPauseFill, RiPlayFill } from "@remixicon/react"; import { formatFrameTime, formatTime } from "../lib/time"; import { liveTime, usePlayerStore } from "../store/playerStore"; import { trackStudioEvent } from "../../utils/studioTelemetry"; @@ -15,46 +14,10 @@ import { PreviewSeekBar } from "./PreviewSeekBar"; /* ── Icon sub-components ─────────────────────────────────────────── */ -gsap.registerPlugin(MorphSVGPlugin); - -// Play glyph: the right-hand blade from the HyperFrames favicon (points right). -// Pause glyph: two bars centred in the same coordinate space so MorphSVG can -// tween one `d` into the other. Both shapes live in the favicon's 0-100 space -// and the svg viewBox frames the blade's bounding box. -const PLAY_BLADE_D = - "M87.5129 57.5141L56.9696 73.5433C52.8371 75.7098 48.7046 73.2553 49.6688 69.2104L58.9483 30.1391C59.9125 26.0942 65.2097 23.6397 68.3154 25.8062L91.2447 41.8354C96.4668 45.4796 94.4631 53.8699 87.5129 57.5141Z"; -const PAUSE_BARS_D = "M56 28H67V71H56Z M73 28H84V71H73Z"; - -// Morph the play blade <-> pause bars on toggle via GSAP MorphSVG. Both glyphs -// are one path whose `d` tweens; the initial render matches `playing` with no -// animation, and prefers-reduced-motion snaps instead of tweening. -function PlayPauseMorphIcon({ playing }: { playing: boolean }) { - const pathRef = useRef(null); - const isFirstRun = useRef(true); - useEffect(() => { - const el = pathRef.current; - if (!el) return; - const target = playing ? PAUSE_BARS_D : PLAY_BLADE_D; - const reduceMotion = - typeof window !== "undefined" && - window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; - if (isFirstRun.current || reduceMotion) { - isFirstRun.current = false; - gsap.set(el, { morphSVG: target }); - return; - } - const tween = gsap.to(el, { duration: 0.28, ease: "power2.inOut", morphSVG: target }); - return () => { - tween.kill(); - }; - }, [playing]); - return ( - - ); +// promptwise's own Timeline's play and pause (Remix's filled glyphs), in the button's ink. +function PlayPauseIcon({ playing }: { playing: boolean }) { + const Icon = playing ? RiPauseFill : RiPlayFill; + return