fix(clip): typed times belong to their value, and every mark on the player chrome can be seen

TimecodeField drops a draft when the value moves under it (another segment selected, a nudge), so
leaving the field never commits it to another cut; it is invalid only once offered, with a line
under it saying how to type a time. On the always-dark chrome media-scope points every focus ring
at --media-mark and errors at the new --media-danger (a light theme's accent and danger were under
3:1 there). A track whose dragged handle unmounts ends the drag on the pointer's release instead of
locking up; a remove refused while clips are queued leaves no focus to take later; each remove-cut
button names its cut and is a 24px target. DESIGN.md records the media tokens; stale comments fixed.
This commit is contained in:
2026-10-10 20:28:52 +02:00
parent a867145bcb
commit c27cfdcb9c
18 changed files with 303 additions and 71 deletions
@@ -102,6 +102,41 @@ describe("VideoEditor — a split export that fails part-way", () => {
});
});
// A time typed into the selected segment's field belongs to that segment: selecting another one
// drops it, and never commits it to the new selection's cut.
describe("VideoEditor — a typed time and another segment", () => {
it("does not move the newly selected segment's cut with a time typed for the old one", () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
for (const at of [30, 60]) {
Object.defineProperty(video, "currentTime", {
value: at,
configurable: true,
writable: true,
});
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
}
const segs = () => screen.getAllByTitle("editor.keptSeg");
fireEvent.pointerDown(segs()[1]!);
const inField = screen.getByRole("textbox", { name: "editor.in" });
act(() => inField.focus());
fireEvent.change(inField, { target: { value: "0:40" } });
// Another segment is selected before the field loses the focus (a press on it).
fireEvent.pointerDown(segs()[2]!);
fireEvent.blur(inField);
const cuts = screen
.getAllByRole("slider", { name: "editor.cutN" })
.map((c) => c.getAttribute("aria-valuenow"));
expect(cuts).toEqual(["30", "60"]);
});
});
// The cuts sit on the shared time track: each is a slider the keyboard steps, and a step stops a
// tenth short of the next cut, so no segment ever closes up.
describe("VideoEditor — a cut on the keyboard", () => {
@@ -172,6 +207,34 @@ describe("VideoEditor — a cut's remove button", () => {
);
});
it("a remove refused while the clips are queued leaves no focus to take later", async () => {
let refuse: (e: unknown) => void = () => {};
vi.spyOn(api, "enqueueEdit").mockImplementation(
() =>
new Promise((_, reject) => {
refuse = reject;
}) as never,
);
split();
fireEvent.click(screen.getByText("editor.createN"));
await waitFor(() => expect(api.enqueueEdit).toHaveBeenCalled());
// The cut list holds still while the clips are queued: the remove is refused.
const remove = screen.getByRole("button", { name: "editor.removeCut" });
act(() => remove.focus());
fireEvent.click(remove);
expect(screen.getByRole("slider", { name: "editor.cutN" })).toBeTruthy();
await act(async () => refuse(new Error("refused")));
// The create failed and the editor stays; a later split keeps the focus where it was.
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 70, configurable: true, writable: true });
fireEvent.timeUpdate(video);
const splitButton = screen.getByText("editor.splitHere").closest("button")!;
act(() => splitButton.focus());
fireEvent.click(splitButton);
expect(screen.getAllByRole("slider", { name: "editor.cutN" })).toHaveLength(2);
expect(document.activeElement).toBe(splitButton);
});
it("sits at the cut's foot, under the next segment's keep toggle at its head", () => {
split();
const remove = screen.getByRole("button", { name: "editor.removeCut" });