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
+10 -4
View File
@@ -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, 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 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 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 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 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. 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, 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 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 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 ends are white, every focus ring in the panel and the playhead the media amber `--media-mark` (the same
themes; a light theme's accent and warn all but vanished there). A typed time that cannot be read in both themes; a light theme's accent and warn all but vanished there). A typed time that cannot be
stays in its field, marked invalid; one past the end shows where it landed (decimal point or comma). 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 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 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 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-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 | 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 | 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 |
@@ -137,7 +137,7 @@ describe("ClipPanel", () => {
expect(range()).toBe("102.5-130"); 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(); const onSeek = vi.fn();
render(<Harness onSeek={onSeek} duration={600} start={{ start: 590, end: 595 }} />); render(<Harness onSeek={onSeek} duration={600} start={{ start: 590, end: 595 }} />);
fireEvent.keyDown(screen.getByRole("slider", { name: "clip.out" }), { key: "End" }); fireEvent.keyDown(screen.getByRole("slider", { name: "clip.out" }), { key: "End" });
+7 -6
View File
@@ -84,15 +84,17 @@ interface Props {
const NUDGE_S = 0.1; 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 // 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) // moment, not past it. A player put at its end ENDS, and the item counts as finished — YouTube marks
// and takes the clip with it. // 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; 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 // 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. // 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. Every
// The media amber (`--media-mark`), not the theme's warn: a light theme's is dark on this bar. // 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 PLAYHEAD = "bg-media-mark";
const END_RING = "focus-visible:outline-media-mark";
export default function ClipPanel({ export default function ClipPanel({
src, src,
@@ -247,7 +249,6 @@ export default function ClipPanel({
playhead={current} playhead={current}
playheadClassName={PLAYHEAD} playheadClassName={PLAYHEAD}
onSeek={seek} onSeek={seek}
handleRingClassName={END_RING}
className="h-14 cursor-pointer rounded-md bg-white/10 text-white/45" className="h-14 cursor-pointer rounded-md bg-white/10 text-white/45"
background={ background={
<RulerTicks view={view} format={(s) => formatTimecode(s, hours).replace(/\.\d$/, "")} /> <RulerTicks view={view} format={(s) => formatTimecode(s, hours).replace(/\.\d$/, "")} />
+2 -1
View File
@@ -950,7 +950,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// (`useHistorySubview`'s back). // (`useHistorySubview`'s back).
// - The on-screen arrow is a click on "leave", not a cancel, so it does not stop a countdown. // - 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. // - 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( const back = useCallback(
(from: "arrow" | "key" | "escape") => { (from: "arrow" | "key" | "escape") => {
if (from !== "arrow" && skipProgressRef.current != null) cancelAutoSkipRef.current(); if (from !== "arrow" && skipProgressRef.current != null) cancelAutoSkipRef.current();
@@ -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 // 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. // tenth short of the next cut, so no segment ever closes up.
describe("VideoEditor — a cut on the keyboard", () => { 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", () => { it("sits at the cut's foot, under the next segment's keep toggle at its head", () => {
split(); split();
const remove = screen.getByRole("button", { name: "editor.removeCut" }); const remove = screen.getByRole("button", { name: "editor.removeCut" });
+7 -3
View File
@@ -435,12 +435,16 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
<Pressable <Pressable
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onClick={() => { 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; refocusAfterRemove.current = bi;
deleteBoundary(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" // 24px, the target floor; white on its black disc in either theme.
aria-label={t("editor.removeCut")} 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"
title={t("editor.removeCut")} aria-label={t("editor.removeCut", { n: bi + 1 })}
title={t("editor.removeCut", { n: bi + 1 })}
> >
<X className="w-3 h-3" /> <X className="w-3 h-3" />
</Pressable> </Pressable>
+7 -7
View File
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here ## What lives here
| area | components | | 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` | | 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 `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `TimecodeField` (a cut's time typed in: the draft until Enter or blur takes it, then the value as it landed; unreadable stays, marked invalid), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` | | 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 `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `TimecodeField` (a cut's time typed in: the draft until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid with a line saying how to type one; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) | | data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` | | page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` | | state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
## The size contract (E10 S1c) ## The size contract (E10 S1c)
@@ -150,18 +150,35 @@ describe("RangeTrack", () => {
expect(h.onChange).not.toHaveBeenCalled(); expect(h.onChange).not.toHaveBeenCalled();
}); });
it("rings a focused handle in the caller's colour, for a track on the dark media chrome", () => { it("ends a drag whose handle leaves the window under it, so the track takes the next press", () => {
render( const onChange = vi.fn();
const onSeek = vi.fn();
const { rerender } = render(
<RangeTrack duration={200} label="Clip" handles={[handle({ onChange })]} onSeek={onSeek} />,
);
const track = screen.getByRole("group", { name: "Clip" });
box(track);
fireEvent.pointerDown(screen.getByRole("slider", { name: "In" }), {
button: 0,
clientX: 250,
pointerId: 7,
});
// A key moves the mark out of the window while the finger is still down: the handle unmounts.
rerender(
<RangeTrack <RangeTrack
duration={100} duration={200}
label="Clip" label="Clip"
handles={[handle()]} handles={[handle({ onChange, time: 20 })]}
handleRingClassName="focus-visible:outline-warn" view={{ from: 100, to: 200 }}
onSeek={onSeek}
/>, />,
); );
expect(screen.getByRole("slider", { name: "In" }).className).toContain( expect(screen.queryByRole("slider", { name: "In" })).toBeNull();
"focus-visible:outline-warn", // Released where the handle no longer is: the drag ends, as final, where the mark now is.
); fireEvent.pointerUp(document.body, { pointerId: 7 });
expect(onChange).toHaveBeenLastCalledWith(20, true);
fireEvent.pointerDown(track, { button: 0, clientX: 500, pointerId: 8 });
expect(onSeek).toHaveBeenCalledWith(150, false);
}); });
it("seeks on a press of the bare track, following the drag, final on release", () => { it("seeks on a press of the bare track, following the drag, final on release", () => {
+35 -11
View File
@@ -1,4 +1,6 @@
import { import {
useEffect,
useEffectEvent,
useRef, useRef,
useState, useState,
type CSSProperties, type CSSProperties,
@@ -80,9 +82,6 @@ interface Props {
/** Accessible name of the whole track. */ /** Accessible name of the whole track. */
label: string; label: string;
playheadClassName?: string; playheadClassName?: string;
/** A focused handle's ring colour (a `focus-visible:outline-*` class) where the theme accent does
* not read on the track, e.g. on the dark media chrome. */
handleRingClassName?: string;
} }
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
@@ -107,7 +106,6 @@ export function RangeTrack({
className, className,
label, label,
playheadClassName = "bg-white", playheadClassName = "bg-white",
handleRingClassName,
}: Props) { }: Props) {
const win: RangeWindow = view ?? { from: 0, to: duration }; const win: RangeWindow = view ?? { from: 0, to: duration };
const span = Math.max(win.to - win.from, 1e-6); const span = Math.max(win.to - win.from, 1e-6);
@@ -115,12 +113,40 @@ export function RangeTrack({
const pct = (f: number) => `${(clamp(f, 0, 1) * 100).toFixed(3)}%`; const pct = (f: number) => `${(clamp(f, 0, 1) * 100).toFixed(3)}%`;
// The handle a pointer is dragging, and the press on the bare track (a scrub), so a move is only // The handle a pointer is dragging, and the press on the bare track (a scrub), so a move is only
// acted on between its own down and up. // acted on between its own down and up.
const [dragging, setDragging] = useState<{ id: string; pointer: number } | null>(null); const [dragging, setDragging] = useState<{
id: string;
pointer: number;
el: HTMLElement;
} | null>(null);
const [scrubbing, setScrubbing] = useState<number | null>(null); const [scrubbing, setScrubbing] = useState<number | null>(null);
// Where the drag last put its handle, for a cancel to report as final. // Where the drag last put its handle, for a cancel to report as final.
const lastRef = useRef(0); const lastRef = useRef(0);
const busy = dragging != null || scrubbing != null; const busy = dragging != null || scrubbing != null;
// A handle can go while its pointer is still down: its mark moved out of the window under it (a
// key marked the other end and carried this one along). Its own up / cancel then never come — the
// capture went with the node — and the drag would hold the track `busy` for good (a finger never
// comes back with the same pointer). So the pointer's release anywhere ends it, reported final
// where the mark now is: the caller's final-only work (a seek, a frozen view let go) still runs.
const endLostDrag = useEffectEvent((id: string) => {
setDragging(null);
const h = handles.find((x) => x.id === id);
if (h) h.onChange(clamp(h.time, h.min, h.max), true);
});
useEffect(() => {
if (!dragging) return;
const { id, pointer, el } = dragging;
const end = (e: globalThis.PointerEvent) => {
if (e.pointerId === pointer && !el.isConnected) endLostDrag(id);
};
document.addEventListener("pointerup", end);
document.addEventListener("pointercancel", end);
return () => {
document.removeEventListener("pointerup", end);
document.removeEventListener("pointercancel", end);
};
}, [dragging]);
const capture = (e: PointerEvent<HTMLElement>) => { const capture = (e: PointerEvent<HTMLElement>) => {
try { try {
e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.setPointerCapture(e.pointerId);
@@ -224,11 +250,9 @@ export function RangeTrack({
aria-valuenow={Math.round(h.time * 10) / 10} aria-valuenow={Math.round(h.time * 10) / 10}
aria-valuetext={h.valueText} aria-valuetext={h.valueText}
data-handle={h.id} data-handle={h.id}
// The ring sits inside the hit area: the track clips its overflow. // The ring sits inside the hit area: the track clips its overflow. Its colour is the base
className={clsx( // focus ring's, which a scope over another ground re-points (media-scope: the media amber).
"absolute inset-y-0 z-base flex w-6 -translate-x-1/2 cursor-ew-resize touch-none items-center justify-center rounded-sm focus-visible:-outline-offset-2", className="absolute inset-y-0 z-base flex w-6 -translate-x-1/2 cursor-ew-resize touch-none items-center justify-center rounded-sm focus-visible:-outline-offset-2"
handleRingClassName,
)}
style={{ left: pct(f) }} style={{ left: pct(f) }}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onPointerDown={(e) => { onPointerDown={(e) => {
@@ -237,7 +261,7 @@ export function RangeTrack({
if (busy) return; if (busy) return;
capture(e); capture(e);
lastRef.current = h.time; lastRef.current = h.time;
setDragging({ id: h.id, pointer: e.pointerId }); setDragging({ id: h.id, pointer: e.pointerId, el: e.currentTarget });
}} }}
onPointerMove={(e) => { onPointerMove={(e) => {
if (mine(e)) move(timeAtPointer(e.currentTarget, e.clientX, win), false); if (mine(e)) move(timeAtPointer(e.currentTarget, e.clientX, win), false);
@@ -0,0 +1,57 @@
// @vitest-environment jsdom
//
// The time field: invalid only once a time was offered, said in words under the field, and a draft
// that never outlives the value it was typed over.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { TimecodeField } from "./TimecodeField";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
});
afterEach(cleanup);
const field = () => screen.getByRole("textbox") as HTMLInputElement;
describe("TimecodeField", () => {
it("is not invalid while a time is still being typed, only once it is offered", () => {
render(<TimecodeField aria-label="In" value={10} onCommit={() => {}} />);
fireEvent.change(field(), { target: { value: "" } });
fireEvent.change(field(), { target: { value: "1:" } });
expect(field().getAttribute("aria-invalid")).toBeNull();
fireEvent.blur(field());
expect(field().getAttribute("aria-invalid")).toBe("true");
});
it("says in words, under the field and linked to it, how a time is typed", () => {
render(<TimecodeField aria-label="In" value={10} onCommit={() => {}} />);
fireEvent.change(field(), { target: { value: "abc" } });
fireEvent.keyDown(field(), { key: "Enter" });
const id = field().getAttribute("aria-describedby");
expect(id).toBeTruthy();
expect(document.getElementById(id!)?.textContent).toBe("common.timecodeInvalid");
fireEvent.change(field(), { target: { value: "0:12" } });
fireEvent.keyDown(field(), { key: "Enter" });
expect(field().getAttribute("aria-describedby")).toBeNull();
});
it("drops a draft when the value moves under it, and never commits it to the new value", () => {
const onCommit = vi.fn();
const { rerender } = render(<TimecodeField aria-label="In" value={10} onCommit={onCommit} />);
fireEvent.change(field(), { target: { value: "0:05" } });
// Another segment selected under the same field (or a nudge): the field edits a new value now.
rerender(<TimecodeField aria-label="In" value={40} onCommit={onCommit} />);
expect(field().value).toBe("0:40.0");
fireEvent.blur(field());
expect(onCommit).not.toHaveBeenCalled();
// An unreadable one goes too, and its mark with it.
fireEvent.change(field(), { target: { value: "x" } });
fireEvent.blur(field());
expect(field().getAttribute("aria-invalid")).toBe("true");
rerender(<TimecodeField aria-label="In" value={41} onCommit={onCommit} />);
expect(field().value).toBe("0:41.0");
expect(field().getAttribute("aria-invalid")).toBeNull();
});
});
+53 -23
View File
@@ -1,12 +1,15 @@
import { useState, type ComponentProps } from "react"; import { useId, useState, type ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { formatTimecode, parseTimecode } from "../../lib/timecode"; import { formatTimecode, parseTimecode } from "../../lib/timecode";
import { Input } from "./Input"; import { Input } from "./Input";
// A field a cut's time is typed into (lib/timecode): the editor's segment ends, the clip panel's In / // A field a cut's time is typed into (lib/timecode): the editor's segment ends, the clip panel's In /
// Out. A typed draft is the field's until Enter or leaving the field takes it; then the field shows // Out. A typed draft is the field's until Enter or leaving the field takes it; then the field shows
// the value as it landed — the caller may clamp it (a time past the end) — and a time it cannot read // the value as it landed — the caller may clamp it (a time past the end) — and a time it cannot read
// stays, marked invalid, for fixing rather than vanishing or seeming taken. One element throughout, // stays for fixing rather than vanishing or seeming taken: marked invalid, with a line under the
// so Enter leaves the focus in the field (a field re-created per value dropped it on <body>). // field saying how to type one. Invalid is said of a time offered (Enter, leaving the field), never
// of one still being typed. One element throughout, so Enter leaves the focus in the field (a field
// re-created per value dropped it on <body>).
type Props = Omit< type Props = Omit<
ComponentProps<typeof Input>, ComponentProps<typeof Input>,
@@ -26,33 +29,60 @@ export function TimecodeField({
onCommit, onCommit,
onBlur, onBlur,
onKeyDown, onKeyDown,
"aria-describedby": describedBy,
...rest ...rest
}: Props) { }: Props) {
const [draft, setDraft] = useState<string | null>(null); const { t } = useTranslation();
const invalid = draft != null && parseTimecode(draft) == null; const errorId = useId();
const [draft, setDraft] = useState<{ text: string; offered: boolean } | null>(null);
// A draft belongs to the value it was typed over. When the value moves without it (a nudge, "to
// the playhead", another segment selected under the same field), the draft is dropped: kept, it
// would show over the new value, and leaving the field would commit it to whatever the field
// edits now — another cut.
const [typedOver, setTypedOver] = useState(value);
if (typedOver !== value) {
setTypedOver(value);
setDraft(null);
}
const invalid = draft != null && draft.offered && parseTimecode(draft.text) == null;
const commit = () => { const commit = () => {
if (draft == null) return; if (draft == null) return;
const v = parseTimecode(draft); const v = parseTimecode(draft.text);
if (v == null) return; if (v == null) {
if (!draft.offered) setDraft({ text: draft.text, offered: true });
return;
}
setDraft(null); setDraft(null);
if (Math.abs(v - value) > 1e-3) onCommit(v); if (Math.abs(v - value) > 1e-3) onCommit(v);
}; };
const described = [describedBy, invalid ? errorId : null].filter(Boolean).join(" ");
return ( return (
<Input <span className="relative inline-flex">
{...rest} <Input
value={draft ?? formatTimecode(value, hours)} {...rest}
invalid={invalid} value={draft?.text ?? formatTimecode(value, hours)}
onChange={(e) => setDraft(e.target.value)} invalid={invalid}
onBlur={(e) => { aria-describedby={described || undefined}
commit(); onChange={(e) => setDraft({ text: e.target.value, offered: draft?.offered ?? false })}
onBlur?.(e); onBlur={(e) => {
}} commit();
onKeyDown={(e) => { onBlur?.(e);
onKeyDown?.(e); }}
if (e.key !== "Enter" || e.defaultPrevented) return; onKeyDown={(e) => {
e.preventDefault(); onKeyDown?.(e);
commit(); if (e.key !== "Enter" || e.defaultPrevented) return;
}} e.preventDefault();
/> commit();
}}
/>
{invalid && (
<span
id={errorId}
className="absolute left-0 top-full z-menu mt-1 whitespace-nowrap rounded-md border border-danger bg-bg px-2 py-0.5 text-xs text-danger"
>
{t(hours ? "common.timecodeInvalidHours" : "common.timecodeInvalid")}
</span>
)}
</span>
); );
} }
+3 -2
View File
@@ -25,13 +25,14 @@ const SIZES: Record<FieldSize, string> = {
}; };
// The edge is --edge-strong (the fg mixed 55% into the bg, ≥ 3:1 on every scheme — WCAG 1.4.11); focus // The edge is --edge-strong (the fg mixed 55% into the bg, ≥ 3:1 on every scheme — WCAG 1.4.11); focus
// turns it into a 2px accent edge (the border plus a 1px ring), the outlined-field convention. Invalid // turns it into a 2px accent edge (the border plus a 1px ring; `focus`, so a scope over another ground
// can name its own, see media-scope), the outlined-field convention. Invalid
// is the danger edge, focused or not. A wrapper frame keys both states to its own INPUT, not to any // is the danger edge, focused or not. A wrapper frame keys both states to its own INPUT, not to any
// descendant: the header search's Go button is disabled while the query is empty, and a bare // descendant: the header search's Go button is disabled while the query is empty, and a bare
// `has-disabled:` faded the whole box. On a coarse pointer the box grows to the 44 touch target. // `has-disabled:` faded the whole box. On a coarse pointer the box grows to the 44 touch target.
const BASE = const BASE =
"min-w-0 border edge-strong text-fg placeholder:text-muted transition-colors " + "min-w-0 border edge-strong text-fg placeholder:text-muted transition-colors " +
"focus-within:border-accent focus-within:ring-1 focus-within:ring-accent focus-within:outline-hidden " + "focus-within:border-focus focus-within:ring-1 focus-within:ring-focus focus-within:outline-hidden " +
"aria-invalid:border-danger aria-invalid:ring-danger " + "aria-invalid:border-danger aria-invalid:ring-danger " +
"has-[input[aria-invalid=true]]:border-danger has-[input[aria-invalid=true]]:ring-danger " + "has-[input[aria-invalid=true]]:border-danger has-[input[aria-invalid=true]]:ring-danger " +
"disabled:cursor-not-allowed disabled:opacity-50 " + "disabled:cursor-not-allowed disabled:opacity-50 " +
+2
View File
@@ -2,6 +2,8 @@
"privacyPolicy": "Privacy policy", "privacyPolicy": "Privacy policy",
"termsOfService": "Terms of service", "termsOfService": "Terms of service",
"sourceCode": "Source code", "sourceCode": "Source code",
"timecodeInvalid": "Not a time — type it like 1:05.5",
"timecodeInvalidHours": "Not a time — type it like 1:02:05.5",
"close": "Close", "close": "Close",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm", "confirm": "Confirm",
+1 -1
View File
@@ -37,7 +37,7 @@
"dropped": "Dropped", "dropped": "Dropped",
"keptSeg": "Kept segment", "keptSeg": "Kept segment",
"droppedSeg": "Dropped segment", "droppedSeg": "Dropped segment",
"removeCut": "Remove cut", "removeCut": "Remove cut {{n}}",
"keptSummary": "{{n}} kept · {{dur}}", "keptSummary": "{{n}} kept · {{dur}}",
"createJoin": "Create joined clip", "createJoin": "Create joined clip",
"timeline": "Timeline", "timeline": "Timeline",
+2
View File
@@ -2,6 +2,8 @@
"privacyPolicy": "Adatvédelmi irányelvek", "privacyPolicy": "Adatvédelmi irányelvek",
"termsOfService": "Felhasználási feltételek", "termsOfService": "Felhasználási feltételek",
"sourceCode": "Forráskód", "sourceCode": "Forráskód",
"timecodeInvalid": "Ez nem időpont — így írd: 1:05,5",
"timecodeInvalidHours": "Ez nem időpont — így írd: 1:02:05,5",
"close": "Bezárás", "close": "Bezárás",
"cancel": "Mégse", "cancel": "Mégse",
"confirm": "Megerősítés", "confirm": "Megerősítés",
+1 -1
View File
@@ -37,7 +37,7 @@
"dropped": "Eldobva", "dropped": "Eldobva",
"keptSeg": "Megtartott szegmens", "keptSeg": "Megtartott szegmens",
"droppedSeg": "Eldobott szegmens", "droppedSeg": "Eldobott szegmens",
"removeCut": "Vágás törlése", "removeCut": "{{n}}. vágás törlése",
"keptSummary": "{{n}} megtartva · {{dur}}", "keptSummary": "{{n}} megtartva · {{dur}}",
"createJoin": "Összefűzött klip", "createJoin": "Összefűzött klip",
"timeline": "Idővonal", "timeline": "Idővonal",
+12 -3
View File
@@ -31,6 +31,8 @@
--color-fg: var(--fg); --color-fg: var(--fg);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-accent: var(--accent); --color-accent: var(--accent);
/* A focus ring's colour: the accent, unless a scope over another ground names its own. */
--color-focus: var(--focus, var(--accent));
--color-accent-fg: var(--accent-fg); --color-accent-fg: var(--accent-fg);
--color-info: var(--info); --color-info: var(--info);
--color-info-fg: var(--info-fg); --color-info-fg: var(--info-fg);
@@ -145,7 +147,9 @@
/* A form on the always-dark player chrome (the clip panel's fields): the neutral tokens the field /* A form on the always-dark player chrome (the clip panel's fields): the neutral tokens the field
primitives read are re-pointed to white-on-chrome for this subtree, so a light theme's dark text primitives read are re-pointed to white-on-chrome for this subtree, so a light theme's dark text
never lands on the dark bar. The accent stays the theme's. */ never lands on the dark bar. The accent stays the theme's for what is chosen; what must be seen
there is not left to it: a focus ring is the media amber and an error the media red, as a light
theme's accent and danger are dark (under 3:1 on the chrome). */
@utility media-scope { @utility media-scope {
--fg: #ffffff; --fg: #ffffff;
--muted: rgb(255 255 255 / 0.62); --muted: rgb(255 255 255 / 0.62);
@@ -155,6 +159,8 @@
/* A card on the chrome (a segmented control's track): a veil of it, never the theme's card — a /* A card on the chrome (a segmented control's track): a veil of it, never the theme's card — a
light theme's white under this scope's white text left the unchosen options unreadable. */ light theme's white under this scope's white text left the unchosen options unreadable. */
--card: rgb(255 255 255 / 0.08); --card: rgb(255 255 255 / 0.08);
--danger: var(--media-danger);
--focus: var(--media-mark);
color: var(--fg); color: var(--fg);
} }
@@ -298,8 +304,9 @@
[role="tab"]:focus-visible, [role="tab"]:focus-visible,
[role="slider"]:focus-visible, [role="slider"]:focus-visible,
summary:focus-visible { summary:focus-visible {
/* Solid accent: the earlier 60% mix all but vanished on the dark glass. */ /* Solid accent: the earlier 60% mix all but vanished on the dark glass. A scope over another
outline: 2px solid var(--accent); ground (media-scope) may name its own ring colour in --focus. */
outline: 2px solid var(--focus, var(--accent));
outline-offset: 2px; outline-offset: 2px;
} }
@@ -1158,6 +1165,8 @@ html[data-theme="light"] {
/* A place marked on that chrome or over a video (a playhead, a focused end's ring, the crop /* 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. */ frame's ring, an intro / credits marker): amber in both themes, as the chrome is dark in both. */
--media-mark: #fbbf24; --media-mark: #fbbf24;
/* An error said on that chrome (a time field the clip panel cannot read): its edge and its line. */
--media-danger: #f87171;
} }
/* Toast auto-dismiss countdown bar (shrinks left-to-right to zero) */ /* Toast auto-dismiss countdown bar (shrinks left-to-right to zero) */
+15
View File
@@ -55,6 +55,21 @@ describe("the media colours", () => {
const strip = mixHex("#ffffff", media()["media-chrome"]!, 0.1); const strip = mixHex("#ffffff", media()["media-chrome"]!, 0.1);
expect(hexContrast(media()["media-mark"]!, strip)).toBeGreaterThanOrEqual(3); expect(hexContrast(media()["media-mark"]!, strip)).toBeGreaterThanOrEqual(3);
}); });
// A form on that chrome (the clip panel, `media-scope`) keeps neither the theme's danger nor its
// accent for what must be seen: a light theme's are dark (#b91c1c 2.8:1, Slate's accent 2.8:1 on
// the chrome). Its error edge and text take the media red, and every focus ring in it — the base
// ring and a field's focus edge both read --focus — the media amber. [WCAG 1.4.11, 1.4.3]
it("a form on the player chrome says an error and a focus in colours that hold there", () => {
expect(hexContrast(media()["media-danger"]!, media()["media-chrome"]!)).toBeGreaterThanOrEqual(
4.5,
);
const at = css.indexOf("@utility media-scope {");
const scope = css.slice(at, css.indexOf("\n}", at));
expect(scope).toMatch(/--danger:\s*var\(--media-danger\);/);
expect(scope).toMatch(/--focus:\s*var\(--media-mark\);/);
expect(css).toMatch(/outline: 2px solid var\(--focus, var\(--accent\)\);/);
expect(css).toMatch(/--color-focus: var\(--focus, var\(--accent\)\);/);
});
}); });
it("styles exactly the schemes the app offers", () => { it("styles exactly the schemes the app offers", () => {