diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 81d4c054..0b08b8c6 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -113,9 +113,11 @@ style guide. 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). 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 + Plex marker) and the focus ring, and `--media-danger` an error, as a light theme's accent and danger are + dark on the chrome: `media-focus` re-points `--focus` on the player (the Plex player, the YouTube stage) + and on every surface painted `--media-chrome` (its menus, panels and bars), and a form there + (`media-scope`) re-points `--danger` too. `theme.contract.test.ts` measures the red on the chrome and + holds every class string that paints it to name one of the two. 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. @@ -561,8 +563,8 @@ of a full-width panel and nothing on the right. playhead", the length, Play selection (loops), a name, Cancel / Cut clip. On the always-dark chrome the 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 + read stays in its field once offered (Enter, leaving it), marked invalid with a line under the fields' + row saying how to type one (in the flow: the row below moves down rather than being covered); 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 @@ -765,4 +767,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 | +| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-focus` points the focus ring (`--focus`) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1 | diff --git a/frontend/src/components/ClipPanel.dom.test.tsx b/frontend/src/components/ClipPanel.dom.test.tsx index eabc570c..4f2a6ba0 100644 --- a/frontend/src/components/ClipPanel.dom.test.tsx +++ b/frontend/src/components/ClipPanel.dom.test.tsx @@ -129,6 +129,21 @@ describe("ClipPanel", () => { expect(range()).toBe("3500-3600"); }); + it("says an unreadable time on a line of its own under the fields, read out, over nothing", () => { + render(); + const out = () => screen.getAllByRole("textbox", { name: "clip.out" })[0] as HTMLInputElement; + fireEvent.change(out(), { target: { value: "abc" } }); + fireEvent.blur(out()); + const line = screen.getByRole("alert"); + expect(line.textContent).toBe("common.timecodeInvalidHours"); + expect(out().getAttribute("aria-describedby")).toBe(line.id); + // In the panel's flow, after the fields' row: it pushes the crop row down rather than lying over + // its buttons and taking their clicks. + expect(line.closest('[role="group"]')).toBeNull(); + expect(line.className).not.toMatch(/\babsolute\b/); + expect(line.parentElement?.className).toMatch(/\bmedia-scope\b/); + }); + it("takes a decimal comma, as a Hungarian hand types it", () => { render(); const field = screen.getAllByRole("textbox", { name: "clip.in" })[0]!; diff --git a/frontend/src/components/ClipPanel.tsx b/frontend/src/components/ClipPanel.tsx index ca202381..6dad8438 100644 --- a/frontend/src/components/ClipPanel.tsx +++ b/frontend/src/components/ClipPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useEffectEvent, useRef, useState, type SubmitEvent } from "react"; +import { useEffect, useEffectEvent, useId, useRef, useState, type SubmitEvent } from "react"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "lucide-react"; @@ -18,7 +18,8 @@ import SegmentedToggle from "./ui/SegmentedToggle"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; import { Input } from "./ui/Input"; -import { TimecodeField } from "./ui/TimecodeField"; +import { TimecodeError, TimecodeField } from "./ui/TimecodeField"; +import { useTimecodeField, type TimecodeDraft } from "./ui/useTimecodeField"; // The clip panel: marks a range of what is playing and cuts it into a download of its own. It lives // IN the player (the YouTube card under the video, the Plex player's control band), so the video @@ -164,6 +165,9 @@ export default function ClipPanel({ seek(next.end, final); }; + const inField = useTimecodeField(clip.start, hours, (v) => moveIn(v, true)); + const outField = useTimecodeField(clip.end, hours, (v) => moveOut(v, true)); + const timeErrorId = useId(); const body: ClipRequest = { source: src.source, ref: src.ref, @@ -291,7 +295,8 @@ export default function ClipPanel({ moveIn(v, true)} nudge={NUDGE_S} hereLabel={t("clip.inHere")} @@ -300,7 +305,8 @@ export default function ClipPanel({ moveOut(v, true)} nudge={NUDGE_S} hereLabel={t("clip.outHere")} @@ -322,6 +328,7 @@ export default function ClipPanel({ {t("clip.loop")} + {(inField.invalid || outField.invalid) && }
}); - render(); - const button = screen.getByRole("button", { name: "Remove" }); - expect(screen.getByRole("slider", { name: "In" }).contains(button)).toBe(false); - // The slider's arrows are not the button's. - fireEvent.keyDown(button, { key: "ArrowLeft" }); - expect(h.onChange).not.toHaveBeenCalled(); - }); - it("ends a drag whose handle leaves the window under it, so the track takes the next press", () => { const onChange = vi.fn(); const onSeek = vi.fn(); diff --git a/frontend/src/components/ui/RangeTrack.tsx b/frontend/src/components/ui/RangeTrack.tsx index 7ef6df27..4ffeeb27 100644 --- a/frontend/src/components/ui/RangeTrack.tsx +++ b/frontend/src/components/ui/RangeTrack.tsx @@ -9,6 +9,7 @@ import { type ReactNode, } from "react"; import clsx from "clsx"; +import { trackFrac, trackPct } from "./trackPosition"; // A horizontal time track: the shared body of every timeline that marks points in a piece of media // (the download editor's cut list, the players' clip panel). It draws what it is given — regions @@ -48,10 +49,6 @@ export interface TrackHandle { onChange: (time: number, final: boolean) => void; /** The drawn bar's look (its colour); the hit area around it is the track's. */ barClassName?: string; - /** Drawn at the handle, e.g. a remove button; it should stop its own pointer events. Beside the - * slider, not in it: a slider's content is not a control to assistive tech, and its keys are the - * slider's. */ - adornment?: ReactNode; } interface TrackRegion { @@ -108,9 +105,8 @@ export function RangeTrack({ playheadClassName = "bg-white", }: Props) { const win: RangeWindow = view ?? { from: 0, to: duration }; - const span = Math.max(win.to - win.from, 1e-6); - const frac = (t: number) => (t - win.from) / span; - const pct = (f: number) => `${(clamp(f, 0, 1) * 100).toFixed(3)}%`; + const frac = (t: number) => trackFrac(t, win); + const pct = trackPct; // 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. const [dragging, setDragging] = useState<{ @@ -239,7 +235,7 @@ export function RangeTrack({ e.stopPropagation(); move(next, true); }; - return [ + return (
-
, - h.adornment != null && ( -
- {h.adornment} -
- ), - ]; +
+ ); })}
); diff --git a/frontend/src/components/ui/TimecodeField.dom.test.tsx b/frontend/src/components/ui/TimecodeField.dom.test.tsx index f9f2a888..81bbed1d 100644 --- a/frontend/src/components/ui/TimecodeField.dom.test.tsx +++ b/frontend/src/components/ui/TimecodeField.dom.test.tsx @@ -1,10 +1,11 @@ // @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. +// The time field: invalid only once a time was offered, said in words on the caller's line, 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"; +import { TimecodeError, TimecodeField } from "./TimecodeField"; +import { useTimecodeField } from "./useTimecodeField"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); @@ -13,25 +14,39 @@ vi.mock("react-i18next", async (importOriginal) => { afterEach(cleanup); +function Harness({ value, onCommit }: { value: number; onCommit: (s: number) => void }) { + const field = useTimecodeField(value, false, onCommit); + return ( + <> + + {field.invalid && } + + ); +} + 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( {}} />); + render( {}} />); 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"); + // Once one was offered, the field re-checks as it is typed: the mark goes the moment it reads. + fireEvent.change(field(), { target: { value: "1:0" } }); + expect(field().getAttribute("aria-invalid")).toBeNull(); }); - it("says in words, under the field and linked to it, how a time is typed", () => { - render( {}} />); + it("says in words, read out and linked to the field, how a time is typed", () => { + render( {}} />); 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"); + expect(screen.getByRole("alert").id).toBe(id); fireEvent.change(field(), { target: { value: "0:12" } }); fireEvent.keyDown(field(), { key: "Enter" }); expect(field().getAttribute("aria-describedby")).toBeNull(); @@ -39,10 +54,10 @@ describe("TimecodeField", () => { 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(); + const { rerender } = render(); 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(); + rerender(); expect(field().value).toBe("0:40.0"); fireEvent.blur(field()); expect(onCommit).not.toHaveBeenCalled(); @@ -50,7 +65,7 @@ describe("TimecodeField", () => { fireEvent.change(field(), { target: { value: "x" } }); fireEvent.blur(field()); expect(field().getAttribute("aria-invalid")).toBe("true"); - rerender(); + rerender(); expect(field().value).toBe("0:41.0"); expect(field().getAttribute("aria-invalid")).toBeNull(); }); diff --git a/frontend/src/components/ui/TimecodeField.tsx b/frontend/src/components/ui/TimecodeField.tsx index 47548792..90244b7c 100644 --- a/frontend/src/components/ui/TimecodeField.tsx +++ b/frontend/src/components/ui/TimecodeField.tsx @@ -1,88 +1,67 @@ -import { useId, useState, type ComponentProps } from "react"; +import { type ComponentProps } from "react"; import { useTranslation } from "react-i18next"; -import { formatTimecode, parseTimecode } from "../../lib/timecode"; import { Input } from "./Input"; +import type { TimecodeDraft } from "./useTimecodeField"; // 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 // the value as it landed — the caller may clamp it (a time past the end) — and a time it cannot read -// stays for fixing rather than vanishing or seeming taken: marked invalid, with a line under the -// 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 +// stays for fixing rather than vanishing or seeming taken: marked invalid, with a line saying how to +// type one. Nothing is marked while a first time is being typed; once one was offered (Enter, +// leaving the field) and could not be read, the field re-checks as it is typed, so the mark goes the +// moment the time reads. One element throughout, so Enter leaves the focus in the field (a field // re-created per value dropped it on ). +// +// The draft lives in `useTimecodeField`, held by the caller, because the line is the caller's to +// place: in the flow under the fields' row (`TimecodeError`), never laid over the controls below it, +// and outside a field's