Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,10 @@
// require intrusive middleware changes beyond this PR's scope.
"minLines": 6,
"ignore": [
// FileTree.tsx / LeftSidebar.tsx: pre-existing 8-line structural clone
// (shared sidebar node shape); surfaced by the UX-sweep line shifts.
"packages/studio/src/components/editor/FileTree.tsx",
"packages/studio/src/components/sidebar/LeftSidebar.tsx",
// AWS Lambda and GCP Cloud Run deliberately mirror the same distributed
// rendering lifecycle while retaining provider-specific SDK, storage, and
// retry semantics. The Plan v2 AWS adapter extends that existing symmetry;
Expand Down Expand Up @@ -870,6 +874,10 @@
"packages/studio/src/components/editor/BlockParamsPanel.tsx",
"packages/studio/src/components/editor/DomEditOverlay.tsx",
"packages/studio/src/components/editor/FileTree.tsx",
// ColorGradingControls: main's 374-line render function (LUT + vignette/grain
// detail panels), reconciled against this PR's LUT import spinner/error graft
// during rebase. Pre-existing complexity; line-shift re-flags it.
"packages/studio/src/components/editor/propertyPanelColorGradingControls.tsx",
"packages/studio/src/components/editor/FileTreeNodes.tsx",
"packages/studio/src/components/editor/LayersPanel.tsx",
"packages/studio/src/components/editor/MotionPathNode.tsx",
Expand Down
3 changes: 2 additions & 1 deletion packages/studio/src/components/editor/AnimationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,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 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
14 changes: 10 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
170 changes: 170 additions & 0 deletions packages/studio/src/components/editor/BlockParamsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// @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("refuses a multi-occurrence value and never mutates the unrelated sibling", async () => {
// The param's default (#0c0c0c) also appears on an UNRELATED element. The
// panel has no way to tell which occurrence belongs to the param, so it
// must refuse rather than risk rewriting `.unrelated`. Nothing is written.
const content = {
value: ".block { background: #0c0c0c; } .unrelated { border-color: #0c0c0c; }",
};
const before = content.value;
const fm = makeFileManager(content);
renderPanel(fm.value);

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

expect(fm.writeProjectFile).not.toHaveBeenCalled();
expect(content.value).toBe(before); // both occurrences untouched
expect(content.value).toContain(".unrelated { border-color: #0c0c0c; }");
expect(document.body.textContent).toContain("appears 2×");
});

it("writes a unique occurrence with token boundaries (no substring corruption)", async () => {
// #0c0c0c is unique here — the 8-digit #0c0c0cff must NOT match (token
// boundary), so exactly one occurrence rewrites and the hex8 is preserved.
const content = {
value: ".a { background: #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; } .c { color: #0c0c0cff; }");
});

it("refuses once a previously-unique value later collides with unrelated content", async () => {
const content = {
value: ".a { background: #0c0c0c; } .b { color: #fafafa; }",
};
const fm = makeFileManager(content);
renderPanel(fm.value);

// #0c0c0c is unique → first commit writes; now #fafafa appears 2×.
changeParam("Background", "#fafafa");
await flushCommit();
expect(content.value).toBe(".a { background: #fafafa; } .b { color: #fafafa; }");

// Next edit's current value (#fafafa) now matches 2× → 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("appears 2×");
});

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