diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index b39d3b5b..c75ded66 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -556,10 +556,10 @@ of a full-width panel and nothing on the right.
around the selection with the two ends (`ui/RangeTrack`: each end a slider — arrows step a tenth,
Shift a second; drawn 4px, hit 1.5rem), Start / End fields with a tenth back / forward and "to the
playhead", the length, Play selection (loops), a name, Cancel / Cut clip. On the always-dark chrome the
- ends are white, a focused end's ring and the playhead amber (a light theme's accent all but vanished
- there). A typed time that cannot be read stays in its field, marked invalid; one past the end shows
- where it landed (decimal point or comma). The strip's window
- follows the selection when it settles, never under a dragging pointer. Plex seeks once a drag is
+ ends are white, a focused end's ring and the playhead the media amber `--media-mark` (the same in both
+ themes; a light theme's accent and warn all but vanished there). A typed time that cannot be read
+ stays in its field, marked invalid; one past the end shows where it landed (decimal point or comma).
+ The strip's window follows the selection when it settles, never under a dragging pointer. Plex seeks once a drag is
released (a seek may restart the transcode); YouTube follows the drag; a mark on the very end seeks a
moment short of it, and while a clip is marked the end of the item never closes the player or steps the
queue, and Plex's auto-skip waits. Escape closes the panel before the player, but after anything over
diff --git a/frontend/src/components/ClipPanel.tsx b/frontend/src/components/ClipPanel.tsx
index 1a10b008..9119a952 100644
--- a/frontend/src/components/ClipPanel.tsx
+++ b/frontend/src/components/ClipPanel.tsx
@@ -90,8 +90,9 @@ const LAST_MOMENT_S = 0.25;
// On the always-dark chrome the ends are white (a light theme's accent is a dark blue there, which
// all but vanished on the bar) and the playhead amber, so an end and the playhead are never confused.
// A focused end is ringed in amber too, as the crop frame is: the accent ring was under 3:1 there.
-const PLAYHEAD = "bg-warn";
-const END_RING = "focus-visible:outline-warn";
+// The media amber (`--media-mark`), not the theme's warn: a light theme's is dark on this bar.
+const PLAYHEAD = "bg-media-mark";
+const END_RING = "focus-visible:outline-media-mark";
export default function ClipPanel({
src,
diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx
index 3e70e41c..69e03af2 100644
--- a/frontend/src/components/PlexPlayer.tsx
+++ b/frontend/src/components/PlexPlayer.tsx
@@ -1533,7 +1533,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
{detail?.markers.map((m, i) => (
{
split();
const remove = screen.getByRole("button", { name: "editor.removeCut" });
expect(screen.getByRole("slider", { name: "editor.cutN" }).contains(remove)).toBe(false);
+ act(() => remove.focus());
fireEvent.click(remove);
expect(screen.queryByRole("slider", { name: "editor.cutN" })).toBeNull();
});
+ it("hands the focus on to a cut beside it, or to Split here, never to ", async () => {
+ split();
+ const video = document.querySelector("video")!;
+ Object.defineProperty(video, "currentTime", { value: 70, configurable: true, writable: true });
+ fireEvent.timeUpdate(video);
+ fireEvent.click(screen.getByText("editor.splitHere"));
+ const [second] = screen.getAllByRole("button", { name: "editor.removeCut" }).slice(1);
+ act(() => second!.focus());
+ fireEvent.click(second!);
+ await waitFor(() =>
+ expect(document.activeElement).toBe(screen.getByRole("slider", { name: "editor.cutN" })),
+ );
+ const last = screen.getByRole("button", { name: "editor.removeCut" });
+ act(() => last.focus());
+ fireEvent.click(last);
+ await waitFor(() =>
+ expect(document.activeElement).toBe(screen.getByText("editor.splitHere").closest("button")),
+ );
+ });
+
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" });
diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx
index 21ec2f07..f334909b 100644
--- a/frontend/src/components/VideoEditor.tsx
+++ b/frontend/src/components/VideoEditor.tsx
@@ -42,6 +42,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const qc = useQueryClient();
const videoRef = useRef(null);
const trackRef = useRef(null);
+ const splitRef = useRef(null);
const sid = useRef(1);
const nid = () => sid.current++;
@@ -132,6 +133,18 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// --- cut-list mutations ---
const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]);
+ // A cut removed by its button takes the button with it: once it is gone, the focus moves on to the
+ // cut before it (or the one now first), else to Split here — not to , out of the dialog.
+ const refocusAfterRemove = useRef(null);
+ useEffect(() => {
+ const bi = refocusAfterRemove.current;
+ if (bi == null) return;
+ refocusAfterRemove.current = null;
+ const cut = trackRef.current?.querySelector(
+ `[data-handle="cut-${Math.max(0, bi - 1)}"]`,
+ );
+ (cut ?? splitRef.current)?.focus();
+ }, [boundaries.length]);
const splitAtPlayhead = () => {
if (creating.current) return;
const time = current;
@@ -332,6 +345,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
}
>
@@ -420,8 +434,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
adornment: (
e.stopPropagation()}
- onClick={() => deleteBoundary(bi)}
- className="pointer-events-auto absolute bottom-0 -right-0.5 p-0.5 rounded-full bg-black/70 text-muted hover:text-danger"
+ onClick={() => {
+ refocusAfterRemove.current = bi;
+ deleteBoundary(bi);
+ }}
+ className="pointer-events-auto absolute bottom-0 -right-0.5 p-0.5 rounded-full bg-black/70 text-muted hover:text-danger focus-visible:-outline-offset-2"
aria-label={t("editor.removeCut")}
title={t("editor.removeCut")}
>
diff --git a/frontend/src/components/ui/CropFrame.tsx b/frontend/src/components/ui/CropFrame.tsx
index d96e867d..a5df16cc 100644
--- a/frontend/src/components/ui/CropFrame.tsx
+++ b/frontend/src/components/ui/CropFrame.tsx
@@ -79,8 +79,10 @@ const SMALL_FRAME_PX = 64;
// moves it (two origins at once made the frame jump between them).
type Drag = { handle: CropHandle | "move"; pointer: number; x: number; y: number; from: Frac };
// Not the app's accent ring: over a video it can be any colour, and a light theme's dark-blue accent
-// all but vanished on a dark picture. Amber, like the players' playhead, reads on both.
-const FOCUS = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-warn";
+// all but vanished on a dark picture. Amber, like the players' playhead, reads on both — the media
+// amber (`--media-mark`), the same in both themes: a light theme's warn is too dark over a picture.
+const FOCUS =
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-media-mark";
export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
const { t } = useTranslation();
diff --git a/frontend/src/index.css b/frontend/src/index.css
index a7083357..7029fba4 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -45,6 +45,7 @@
--color-live: var(--live);
--color-live-fg: var(--live-fg);
--color-media-chrome: var(--media-chrome);
+ --color-media-mark: var(--media-mark);
--font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
@@ -1154,6 +1155,9 @@ html[data-theme="light"] {
--live-fg: #ffffff;
--rating-media: #fcd34d; /* a score on a poster's black scrim */
--media-chrome: #171717; /* the always-dark player's menus and bars, under white text */
+ /* A place marked on that chrome or over a video (a playhead, a focused end's ring, the crop
+ frame's ring, an intro / credits marker): amber in both themes, as the chrome is dark in both. */
+ --media-mark: #fbbf24;
}
/* Toast auto-dismiss countdown bar (shrinks left-to-right to zero) */
diff --git a/frontend/src/lib/theme.contract.test.ts b/frontend/src/lib/theme.contract.test.ts
index 8260d392..710d1133 100644
--- a/frontend/src/lib/theme.contract.test.ts
+++ b/frontend/src/lib/theme.contract.test.ts
@@ -48,6 +48,13 @@ describe("the media colours", () => {
it("the player chrome's white text holds 4.5:1", () => {
expect(hexContrast("#ffffff", media()["media-chrome"]!)).toBeGreaterThanOrEqual(4.5);
});
+ // The amber that marks a place on the always-dark chrome and over a video — a playhead, a focused
+ // end's ring, the crop frame's ring — is the same in both themes: a light theme's warn is a dark
+ // amber, which fell under 3:1 on the clip strip (white 10% over the chrome). [WCAG 1.4.11]
+ it("a mark on the player chrome holds 3:1 on the clip strip", () => {
+ const strip = mixHex("#ffffff", media()["media-chrome"]!, 0.1);
+ expect(hexContrast(media()["media-mark"]!, strip)).toBeGreaterThanOrEqual(3);
+ });
});
it("styles exactly the schemes the app offers", () => {