Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -308,6 +308,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",
// sourcePatcher.ts: pre-existing internal clones between the inline-style
// and attribute tag-patchers; only the PatchOperation type gained two
// optional fields here, but the line shift makes fallow re-flag them.
Expand Down Expand Up @@ -562,6 +566,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
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
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