feat(studio): add Vignette and Grain rows with tuned-settings sub-panel

Also hoists the pointerdown-drag/reset-click test scaffold shared by the
new Roundness tests and the existing Contrast/Exposure tests into helpers
(findRowByText/dragSliderTrack/clickSliderReset), and exempts pre-existing,
branch-inherited fallow findings unrelated to this task (TextFieldEditor
complexity from earlier Text-inspector commits; test-scaffold duplication
across four flat-inspector-series test files from Plans 2-4) via
.fallowrc.jsonc, per this repo's established convention for line-shift/
inherited findings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-14 15:51:51 -07:00
co-authored by Claude Sonnet 5
parent 82cb7b8979
commit 93f3bf5945
3 changed files with 210 additions and 36 deletions
@@ -31,6 +31,36 @@ function neutralGrading() {
return grading;
}
function findRowByText(
host: HTMLElement,
selector: string,
text: string,
match: "includes" | "startsWith" = "includes",
) {
const row = Array.from(host.querySelectorAll(selector)).find((el) =>
el.textContent?.[match](text),
);
if (!row) throw new Error(`expected a ${text} row`);
return row;
}
function dragSliderTrack(row: Element, clientX: number, trackWidth: number) {
const track = row.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
if (!track) throw new Error("expected a slider track");
Object.defineProperty(track, "getBoundingClientRect", {
value: () => ({ left: 0, width: trackWidth, top: 0, height: 2, right: trackWidth, bottom: 2 }),
});
act(() => {
track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX }));
});
}
function clickSliderReset(row: Element) {
const resetButton = row.querySelector<HTMLButtonElement>('[data-flat-slider-reset="true"]');
expect(resetButton).not.toBeNull();
act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
}
describe("FlatColorGradingAccessory", () => {
it("shows a 5px status dot colored by runtime status, with the message as its title", () => {
const { host, root } = renderInto(
@@ -206,15 +236,8 @@ describe("FlatColorGradingSection — Adjust sliders", () => {
onCommitColorGrading={onCommitColorGrading}
/>,
);
const contrastRow = Array.from(host.querySelectorAll('[data-flat-grade-adjust="true"]')).find(
(row) => row.textContent?.includes("Contrast"),
);
if (!contrastRow) throw new Error("expected a Contrast row");
const resetButton = contrastRow.querySelector<HTMLButtonElement>(
'[data-flat-slider-reset="true"]',
);
expect(resetButton).not.toBeNull();
act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const contrastRow = findRowByText(host, '[data-flat-grade-adjust="true"]', "Contrast");
clickSliderReset(contrastRow);
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
expect(onCommitColorGrading.mock.calls[0][0].adjust.contrast).toBe(0);
act(() => root.unmount());
@@ -228,19 +251,9 @@ describe("FlatColorGradingSection — Adjust sliders", () => {
onCommitColorGrading={onCommitColorGrading}
/>,
);
const contrastRow = Array.from(host.querySelectorAll('[data-flat-grade-adjust="true"]')).find(
(row) => row.textContent?.includes("Contrast"),
);
if (!contrastRow) throw new Error("expected a Contrast row");
const track = contrastRow.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
if (!track) throw new Error("expected a slider track");
Object.defineProperty(track, "getBoundingClientRect", {
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
});
act(() => {
// min=-100, max=100, step=1, ratio=0.75 -> raw=50 -> commit(50) -> adjust.contrast = 50/100 = 0.5
track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 75 }));
});
const contrastRow = findRowByText(host, '[data-flat-grade-adjust="true"]', "Contrast");
// min=-100, max=100, step=1, ratio=0.75 -> raw=50 -> commit(50) -> adjust.contrast = 50/100 = 0.5
dragSliderTrack(contrastRow, 75, 100);
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
expect(onCommitColorGrading.mock.calls[0][0].adjust.contrast).toBe(0.5);
act(() => root.unmount());
@@ -259,22 +272,75 @@ describe("FlatColorGradingSection — Adjust sliders", () => {
onCommitColorGrading={onCommitColorGrading}
/>,
);
const exposureRow = Array.from(host.querySelectorAll('[data-flat-grade-adjust="true"]')).find(
(row) => row.textContent?.includes("Exposure"),
);
if (!exposureRow) throw new Error("expected an Exposure row");
const track = exposureRow.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
if (!track) throw new Error("expected a slider track");
Object.defineProperty(track, "getBoundingClientRect", {
value: () => ({ left: 0, width: 200, top: 0, height: 2, right: 200, bottom: 2 }),
});
act(() => {
// min=-200, max=200, step=5, ratio=1.0 -> raw=200 -> commit(200) -> adjust.exposure = 200/100 = 2
track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 200 }));
});
const exposureRow = findRowByText(host, '[data-flat-grade-adjust="true"]', "Exposure");
// min=-200, max=200, step=5, ratio=1.0 -> raw=200 -> commit(200) -> adjust.exposure = 200/100 = 2
dragSliderTrack(exposureRow, 200, 200);
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
expect(onCommitColorGrading.mock.calls[0][0].adjust.exposure).toBe(2);
expect(onCommitColorGrading.mock.calls[0][0].adjust.saturation).toBe(0.2);
act(() => root.unmount());
});
});
describe("FlatColorGradingSection — Vignette and Grain", () => {
it("renders Vignette and Grain amount rows with a settings gear, expanding tuned sliders on click", () => {
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
const vignetteGear = host.querySelector<HTMLButtonElement>(
'[data-flat-grade-settings="vignette"]',
);
expect(vignetteGear).not.toBeNull();
act(() => vignetteGear?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(host.textContent).toContain("Midpoint");
expect(host.textContent).toContain("Feather");
act(() => root.unmount());
});
it("shows tuned Midpoint at its 50% default with no reset until moved from default", () => {
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
const gear = host.querySelector<HTMLButtonElement>('[data-flat-grade-settings="vignette"]');
act(() => gear?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const midpointRow = findRowByText(host, "div", "Midpoint", "startsWith");
expect(midpointRow.querySelector('[data-flat-slider-reset="true"]')).toBeNull();
act(() => root.unmount());
});
it("commits a dragged Roundness value on slider track pointerdown, scaled from percent back into the -1..1 detail range", () => {
const onCommitColorGrading = vi.fn();
const { host, root } = renderInto(
<FlatColorGradingSection
{...neutralPropsBase()}
onCommitColorGrading={onCommitColorGrading}
/>,
);
const gear = host.querySelector<HTMLButtonElement>('[data-flat-grade-settings="vignette"]');
act(() => gear?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const roundnessRow = findRowByText(host, "div", "Roundness", "startsWith");
// min=-100, max=100, step=1, ratio=0.75 -> raw=50 -> commit(50) -> details.vignetteRoundness = 50/100 = 0.5
dragSliderTrack(roundnessRow, 75, 100);
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
expect(onCommitColorGrading.mock.calls[0][0].details.vignetteRoundness).toBe(0.5);
act(() => root.unmount());
});
it("resets a non-default Roundness back to its 0 default via the tuned slider's reset button", () => {
const onCommitColorGrading = vi.fn();
const grading = {
...neutralGrading(),
details: { ...neutralGrading().details, vignetteRoundness: 0.4 },
};
const { host, root } = renderInto(
<FlatColorGradingSection
{...neutralPropsBase()}
grading={grading}
onCommitColorGrading={onCommitColorGrading}
/>,
);
const gear = host.querySelector<HTMLButtonElement>('[data-flat-grade-settings="vignette"]');
act(() => gear?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const roundnessRow = findRowByText(host, "div", "Roundness", "startsWith");
clickSliderReset(roundnessRow);
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
expect(onCommitColorGrading.mock.calls[0][0].details.vignetteRoundness).toBe(0);
act(() => root.unmount());
});
});
@@ -4,9 +4,10 @@ import {
isHfColorGradingActive,
normalizeHfColorGrading,
type HfColorGradingAdjustKey,
type HfColorGradingDetailKey,
type NormalizedHfColorGrading,
} from "@hyperframes/core/color-grading";
import { Compare, Plus, RotateCcw } from "../../icons/SystemIcons";
import { Compare, Plus, RotateCcw, Settings } from "../../icons/SystemIcons";
import { LUT_EXT } from "../../utils/mediaTypes";
import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
import { resolveValueTier } from "./propertyPanelValueTier";
@@ -106,6 +107,32 @@ function formatAdjustValue(key: HfColorGradingAdjustKey, rawPercent: number): st
return `${Math.round(rawPercent)}%`;
}
const DETAIL_SLIDERS: Array<{
key: HfColorGradingDetailKey;
label: string;
defaultValue: number;
}> = [
{ key: "vignette", label: "Vignette", defaultValue: 0 },
{ key: "vignetteMidpoint", label: "Midpoint", defaultValue: 0.5 },
{ key: "vignetteRoundness", label: "Roundness", defaultValue: 0 },
{ key: "vignetteFeather", label: "Feather", defaultValue: 0.65 },
{ key: "grain", label: "Grain", defaultValue: 0 },
{ key: "grainSize", label: "Grain Size", defaultValue: 0.25 },
{ key: "grainRoughness", label: "Roughness", defaultValue: 0.5 },
];
const detailByKey = (key: HfColorGradingDetailKey) => {
const spec = DETAIL_SLIDERS.find((d) => d.key === key);
if (!spec) throw new Error(`Unknown color grading detail key: ${key}`);
return spec;
};
const VIGNETTE_TUNE_KEYS: HfColorGradingDetailKey[] = [
"vignetteMidpoint",
"vignetteRoundness",
"vignetteFeather",
];
const GRAIN_TUNE_KEYS: HfColorGradingDetailKey[] = ["grainSize", "grainRoughness"];
// fallow-ignore-next-line complexity
export function FlatColorGradingSection({
grading,
assets,
@@ -131,6 +158,7 @@ export function FlatColorGradingSection({
}) {
const lutInputRef = useRef<HTMLInputElement>(null);
const [lutOpen, setLutOpen] = useState(false);
const [detailSettingsOpen, setDetailSettingsOpen] = useState<"vignette" | "grain" | null>(null);
const lutAssets = useMemo(
() => assets.filter((asset) => LUT_EXT.test(asset)).sort((a, b) => a.localeCompare(b)),
[assets],
@@ -155,6 +183,33 @@ export function FlatColorGradingSection({
if (firstLut) applyLut(firstLut, 1);
};
const renderDetailSlider = (key: HfColorGradingDetailKey) => {
const spec = detailByKey(key);
const value = grading.details[key];
const isSet = Math.abs(value - spec.defaultValue) > 1e-4;
return (
<FlatSlider
key={key}
label={spec.label}
value={Math.round(value * 100)}
min={key === "vignetteRoundness" ? -100 : 0}
max={100}
tier={isSet ? "explicitCustom" : "default"}
displayValue={`${Math.round(value * 100)}%`}
centerTick={key === "vignetteRoundness"}
onCommit={(next) =>
onCommitColorGrading({ ...grading, details: { ...grading.details, [key]: next / 100 } })
}
onReset={() =>
onCommitColorGrading({
...grading,
details: { ...grading.details, [key]: spec.defaultValue },
})
}
/>
);
};
return (
<div className="space-y-1.5">
<div data-flat-grade-preset="true" className="flex min-h-[30px] items-center justify-between">
@@ -292,6 +347,43 @@ export function FlatColorGradingSection({
);
})}
</div>
<div className="space-y-1.5 border-t border-panel-hairline pt-1.5">
<div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
Finishing
</div>
<div className="flex items-center gap-1.5">
<div className="flex-1">{renderDetailSlider("vignette")}</div>
<button
type="button"
data-flat-grade-settings="vignette"
title="Vignette settings"
onClick={() => setDetailSettingsOpen((c) => (c === "vignette" ? null : "vignette"))}
className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1"
>
<Settings size={12} />
</button>
</div>
<div className="flex items-center gap-1.5">
<div className="flex-1">{renderDetailSlider("grain")}</div>
<button
type="button"
data-flat-grade-settings="grain"
title="Grain settings"
onClick={() => setDetailSettingsOpen((c) => (c === "grain" ? null : "grain"))}
className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1"
>
<Settings size={12} />
</button>
</div>
{detailSettingsOpen && (
<div className="space-y-0.5 border-l-2 border-panel-border-input pl-2.5">
{(detailSettingsOpen === "vignette" ? VIGNETTE_TUNE_KEYS : GRAIN_TUNE_KEYS).map(
renderDetailSlider,
)}
</div>
)}
</div>
</div>
);
}