Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/studio/src/components/editor/AnimationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,8 @@ export const AnimationCard = memo(function AnimationCard({
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center gap-2 py-1.5"
aria-expanded={expanded}
className="flex w-full items-center gap-2 py-1.5 active:scale-[0.99]"
>
<span
className="rounded bg-panel-accent/10 px-1.5 py-0.5 text-[10px] font-semibold text-panel-accent"
Expand All @@ -176,7 +177,7 @@ export const AnimationCard = memo(function AnimationCard({
height="10"
viewBox="0 0 10 10"
fill="currentColor"
className={`flex-shrink-0 text-neutral-500 transition-transform ${expanded ? "" : "-rotate-90"}`}
className={`flex-shrink-0 text-neutral-500 transition-transform duration-150 ${expanded ? "" : "-rotate-90"}`}
>
<path d="M2 3l3 4 3-4z" />
</svg>
Expand Down
9 changes: 6 additions & 3 deletions packages/studio/src/components/editor/AnimationCardParts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ function RemoveButton({ onClick, title }: { onClick: () => void; title: string }
<button
type="button"
onClick={onClick}
className="flex-shrink-0 rounded p-0.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400"
className="relative flex-shrink-0 rounded p-1.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400 active:scale-[0.95]"
title={title}
aria-label={title}
>
<svg
width="12"
Expand Down Expand Up @@ -87,13 +88,15 @@ export function PropertyRow({
</span>
<button
type="button"
role="switch"
aria-checked={isVisible}
onClick={() => onCommit(isVisible ? "hidden" : "visible")}
className="flex-shrink-0 rounded-full transition-all duration-150 relative"
className="flex-shrink-0 rounded-full transition-colors duration-200 relative"
style={{ width: 28, height: 16, background: isVisible ? P.accent : P.borderInput }}
title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
>
<span
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
className="absolute top-[2px] left-0 rounded-full transition-transform duration-200"
style={{
width: 12,
height: 12,
Expand Down
15 changes: 11 additions & 4 deletions packages/studio/src/components/editor/ArcPathControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,17 @@ export const ArcPathControls = memo(function ArcPathControls({
<span className={LABEL}>Arc Motion</span>
<button
type="button"
role="switch"
aria-checked={Boolean(arcPath.enabled)}
aria-label="Arc motion"
onClick={handleToggle}
disabled={disabled}
className="relative rounded-full transition-all duration-150"
className="relative rounded-full transition-colors duration-200"
style={{ width: 28, height: 16, background: arcPath.enabled ? P.accent : P.borderInput }}
title={arcPath.enabled ? "Disable arc motion" : "Enable arc motion"}
>
<span
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
className="absolute top-[2px] left-0 rounded-full transition-transform duration-200"
style={{
width: 12,
height: 12,
Expand All @@ -69,9 +72,12 @@ export const ArcPathControls = memo(function ArcPathControls({
<span className={LABEL}>Auto-Rotate</span>
<button
type="button"
role="switch"
aria-checked={Boolean(arcPath.autoRotate)}
aria-label="Auto-rotate along path"
onClick={handleAutoRotate}
disabled={disabled}
className="relative rounded-full transition-all duration-150"
className="relative rounded-full transition-colors duration-200"
style={{
width: 28,
height: 16,
Expand All @@ -84,7 +90,7 @@ export const ArcPathControls = memo(function ArcPathControls({
}
>
<span
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
className="absolute top-[2px] left-0 rounded-full transition-transform duration-200"
style={{
width: 12,
height: 12,
Expand Down Expand Up @@ -113,6 +119,7 @@ export const ArcPathControls = memo(function ArcPathControls({
)}
</div>
<SliderControl
ariaLabel="Curviness"
value={seg.curviness}
min={0}
max={3}
Expand Down
153 changes: 153 additions & 0 deletions packages/studio/src/components/editor/BlockParamsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// @vitest-environment happy-dom

import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { BlockParam } from "@hyperframes/core/registry";
import { FileManagerProvider } from "../../contexts/FileManagerContext";
import type { useFileManager } from "../../hooks/useFileManager";
import { StudioPlaybackProvider, type StudioPlaybackValue } from "../../contexts/StudioContext";
import { BlockParamsPanel } from "./BlockParamsPanel";

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

vi.useFakeTimers();

const PARAMS: BlockParam[] = [
{ key: "--bg-color", label: "Background", type: "color", default: "#0c0c0c" },
{ key: "--text-color", label: "Text color", type: "color", default: "#fafafa" },
];

const playbackValue: StudioPlaybackValue = {
captionEditMode: false,
compositionLoading: false,
refreshKey: 0,
setRefreshKey: vi.fn(),
timelineElements: [],
isPlaying: false,
refreshPreviewDocumentVersion: vi.fn(),
};

function makeFileManager(content: { value: string }) {
const readProjectFile = vi.fn(async () => content.value);
const writeProjectFile = vi.fn(async (_path: string, next: string) => {
content.value = next;
});
// The panel only touches read/writeProjectFile; the rest of the context
// surface is irrelevant to these tests.
const value = { readProjectFile, writeProjectFile } as unknown as ReturnType<
typeof useFileManager
>;
return { value, readProjectFile, writeProjectFile };
}

let root: Root | null = null;

afterEach(() => {
act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
vi.clearAllTimers();
});

function renderPanel(fileManager: ReturnType<typeof makeFileManager>["value"]) {
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
act(() => {
root?.render(
<StudioPlaybackProvider value={playbackValue}>
<FileManagerProvider value={fileManager}>
<BlockParamsPanel
blockName="vfx-demo"
blockTitle="VFX Demo"
params={PARAMS}
compositionPath="compositions/vfx-demo.html"
onClose={vi.fn()}
/>
</FileManagerProvider>
</StudioPlaybackProvider>,
);
});
}

function changeParam(label: string, next: string) {
const input = document.querySelector<HTMLInputElement>(`input[aria-label="${label} value"]`);
if (!input) throw new Error(`no input for ${label}`);
act(() => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, next);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}

async function flushCommit() {
await act(async () => {
vi.advanceTimersByTime(350);
// Drain the read → guard → write promise chain.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}

describe("BlockParamsPanel commit safety", () => {
it("replaces duplicate occurrences of the param value with token boundaries", async () => {
// The default legitimately appears twice (two CSS rules) — both belong to
// the param, and the 8-digit hex must NOT be corrupted by substring match.
const content = {
value: ".a { background: #0c0c0c; } .b { border-color: #0c0c0c; } .c { color: #0c0c0cff; }",
};
const fm = makeFileManager(content);
renderPanel(fm.value);

changeParam("Background", "#123456");
await flushCommit();

expect(fm.writeProjectFile).toHaveBeenCalledTimes(1);
expect(content.value).toBe(
".a { background: #123456; } .b { border-color: #123456; } .c { color: #0c0c0cff; }",
);
});

it("refuses to write when the current value collides with unrelated content", async () => {
const content = {
value: ".a { background: #0c0c0c; } .b { color: #fafafa; }",
};
const fm = makeFileManager(content);
renderPanel(fm.value);

// First commit establishes the expected occurrence count (1).
changeParam("Background", "#fafafa");
await flushCommit();
expect(content.value).toBe(".a { background: #fafafa; } .b { color: #fafafa; }");

// Now "#fafafa" appears 2× (one ours, one unrelated) — a blind replace
// would rewrite the unrelated one, so the panel must refuse.
changeParam("Background", "#ff0000");
await flushCommit();

expect(fm.writeProjectFile).toHaveBeenCalledTimes(1); // no second write
expect(content.value).toBe(".a { background: #fafafa; } .b { color: #fafafa; }");
expect(document.body.textContent).toContain("could change unrelated content");
});

it("keeps a pending commit for one param when another param is edited", async () => {
const content = {
value: ".a { background: #0c0c0c; } .b { color: #fafafa; }",
};
const fm = makeFileManager(content);
renderPanel(fm.value);

// Edit both params back-to-back within the 300ms debounce window: the
// second edit must not cancel the first param's pending commit.
changeParam("Background", "#111111");
changeParam("Text color", "#222222");
await flushCommit();
await flushCommit();

expect(content.value).toContain("#111111");
expect(content.value).toContain("#222222");
});
});
Loading
Loading