diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index c75ded66..81d4c054 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -112,7 +112,10 @@ style guide.
own chip), never `--warn`: a score is not a warning. What sits on a picture looks the same in both themes,
because the picture does: the broadcast red `--live` under `--live-fg` (never `--danger`: "live" is not an
error), `--rating-media` on a poster's scrim, and `--media-chrome` for the always-dark player's menus
- (owner decision F16). Black and white are the media scrims and their text. Five schemes × dark
+ (owner decision F16). On that chrome `--media-mark` is a marked place (the playhead, the crop frame, a
+ Plex marker) and every focus ring, and `--media-danger` an error: a form there (`media-scope`) re-points
+ `--focus` and `--danger` to them, as a light theme's accent and danger are dark on the chrome
+ (measured in `theme.contract.test.ts`). Black and white are the media scrims and their text. Five schemes × dark
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss) each earn their
place with their own hue family; **Fjord** (Midnight's navy with a calm steel-blue accent) is the one kept
exception, the calm twin of the vivid Midnight. A new scheme still needs its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode.
@@ -556,9 +559,11 @@ 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 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).
+ ends are white, every focus ring in the panel 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 once offered (Enter, leaving it), marked invalid with a line under it saying
+ how to type one; a time typed over a mark that then moves (a nudge, another segment) is dropped; 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
@@ -760,3 +765,4 @@ of a full-width panel and nothing on the right.
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays |
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
+| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-scope` points its focus rings (`--focus`) and errors at them | The theme's accent ring, warn and danger | On the always-dark chrome a light theme's accent ring (Slate 2.8:1), warn (2.3:1) and danger (2.8:1) all fell under 3:1 |
diff --git a/frontend/src/components/ClipPanel.dom.test.tsx b/frontend/src/components/ClipPanel.dom.test.tsx
index 30154b48..eabc570c 100644
--- a/frontend/src/components/ClipPanel.dom.test.tsx
+++ b/frontend/src/components/ClipPanel.dom.test.tsx
@@ -137,7 +137,7 @@ describe("ClipPanel", () => {
expect(range()).toBe("102.5-130");
});
- it("never seeks the player onto the item's very end (that would end it, and the clip with it)", () => {
+ it("never seeks the player onto the item's very end (that would finish the item)", () => {
const onSeek = vi.fn();
render();
fireEvent.keyDown(screen.getByRole("slider", { name: "clip.out" }), { key: "End" });
diff --git a/frontend/src/components/ClipPanel.tsx b/frontend/src/components/ClipPanel.tsx
index 9119a952..ca202381 100644
--- a/frontend/src/components/ClipPanel.tsx
+++ b/frontend/src/components/ClipPanel.tsx
@@ -84,15 +84,17 @@ interface Props {
const NUDGE_S = 0.1;
// How far short of the item's end a seek stops: a mark on the very end moves the player to its last
-// moment, not past it — a player put at its end ENDS (a Plex film closes, a YouTube queue steps on)
-// and takes the clip with it.
+// moment, not past it. A player put at its end ENDS, and the item counts as finished — YouTube marks
+// it watched and shows its end screen, a Plex position reads as the whole — while a mark there is only
+// a place. (The players keep themselves and the clip open at an end while one is marked; this keeps
+// the item unfinished.)
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.
-// The media amber (`--media-mark`), not the theme's warn: a light theme's is dark on this bar.
+// The media amber (`--media-mark`), not the theme's warn: a light theme's is dark on this bar. Every
+// focus ring in the panel is that amber as well (media-scope re-points --focus), as the crop frame's
+// is: the theme accent's ring was under 3:1 on the chrome.
const PLAYHEAD = "bg-media-mark";
-const END_RING = "focus-visible:outline-media-mark";
export default function ClipPanel({
src,
@@ -247,7 +249,6 @@ export default function ClipPanel({
playhead={current}
playheadClassName={PLAYHEAD}
onSeek={seek}
- handleRingClassName={END_RING}
className="h-14 cursor-pointer rounded-md bg-white/10 text-white/45"
background={
formatTimecode(s, hours).replace(/\.\d$/, "")} />
diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx
index 69e03af2..e797d4e0 100644
--- a/frontend/src/components/PlexPlayer.tsx
+++ b/frontend/src/components/PlexPlayer.tsx
@@ -950,7 +950,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// (`useHistorySubview`'s back).
// - The on-screen arrow is a click on "leave", not a cancel, so it does not stop a countdown.
// - Escape in fullscreen leaves fullscreen (the browser's own Escape) and not the player.
- // - The arrow leaves past a clip as it does past a panel; the keys step out of it.
+ // - The arrow still closes an open panel or the stats first, but it leaves past a clip, as past a
+ // countdown: only the keys step out of a clip.
const back = useCallback(
(from: "arrow" | "key" | "escape") => {
if (from !== "arrow" && skipProgressRef.current != null) cancelAutoSkipRef.current();
diff --git a/frontend/src/components/VideoEditor.partial.dom.test.tsx b/frontend/src/components/VideoEditor.partial.dom.test.tsx
index ee6fb873..96459b7a 100644
--- a/frontend/src/components/VideoEditor.partial.dom.test.tsx
+++ b/frontend/src/components/VideoEditor.partial.dom.test.tsx
@@ -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(
+
+ {}} />
+ ,
+ );
+ 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" });
diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx
index f334909b..74648050 100644
--- a/frontend/src/components/VideoEditor.tsx
+++ b/frontend/src/components/VideoEditor.tsx
@@ -435,12 +435,16 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
e.stopPropagation()}
onClick={() => {
+ // Refused while the clips are queued (the list holds still): then nothing is
+ // removed, and no focus is left waiting to be taken by a later change.
+ if (creating.current) return;
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")}
+ // 24px, the target floor; white on its black disc in either theme.
+ className="pointer-events-auto absolute bottom-0 -right-0.5 p-1.5 rounded-full bg-black/70 text-white/80 hover:text-white focus-visible:-outline-offset-2"
+ aria-label={t("editor.removeCut", { n: bi + 1 })}
+ title={t("editor.removeCut", { n: bi + 1 })}
>
diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md
index a1024716..a8779ca3 100644
--- a/frontend/src/components/ui/README.md
+++ b/frontend/src/components/ui/README.md
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here
-| area | components |
-| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
-| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `