fix(clip): a time's error line sits in the flow, the remove button off the cut, and every player surface rings amber
- useTimecodeField holds a time field's draft for its caller, which places TimecodeError (role alert) in its own flow under the fields' row: it no longer lies over the crop row taking its clicks, runs out of the editor's card, or joins the field's <label> name. - The editor's remove-cut buttons sit in a row under the track at their cut (trackLeft), off the cut's grab area, where a press drags it; RangeTrack's adornment slot is gone. - media-focus re-points --focus to the media amber on the Plex player, the YouTube stage and every bg-media-chrome surface; a contract test holds each such class string to media-focus or media-scope, the field's focus edge to the focus token, and --color-focus to the inline theme. - An upload's quota headline says the converted file does not fit, not "Storage full". - DESIGN.md: the tokens paragraph says what is re-pointed where; the decision row measures all three colours on the chrome.
This commit is contained in:
+8
-6
@@ -113,9 +113,11 @@ style guide.
|
|||||||
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). On that chrome `--media-mark` is a marked place (the playhead, the crop frame, a
|
(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
|
Plex marker) and the focus ring, and `--media-danger` an error, as a light theme's accent and danger are
|
||||||
`--focus` and `--danger` to them, as a light theme's accent and danger are dark on the chrome
|
dark on the chrome: `media-focus` re-points `--focus` on the player (the Plex player, the YouTube stage)
|
||||||
(measured in `theme.contract.test.ts`). Black and white are the media scrims and their text. Five schemes × dark
|
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
|
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.
|
||||||
@@ -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
|
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
|
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
|
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
|
read stays in its field once offered (Enter, leaving it), marked invalid with a line under the fields'
|
||||||
how to type one; a time typed over a mark that then moves (a nudge, another segment) is dropped; one
|
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).
|
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
|
||||||
@@ -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-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 |
|
| 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 |
|
||||||
|
|||||||
@@ -129,6 +129,21 @@ describe("ClipPanel", () => {
|
|||||||
expect(range()).toBe("3500-3600");
|
expect(range()).toBe("3500-3600");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("says an unreadable time on a line of its own under the fields, read out, over nothing", () => {
|
||||||
|
render(<Harness />);
|
||||||
|
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", () => {
|
it("takes a decimal comma, as a Hungarian hand types it", () => {
|
||||||
render(<Harness />);
|
render(<Harness />);
|
||||||
const field = screen.getAllByRole("textbox", { name: "clip.in" })[0]!;
|
const field = screen.getAllByRole("textbox", { name: "clip.in" })[0]!;
|
||||||
|
|||||||
@@ -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 { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "lucide-react";
|
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 { Button } from "./ui/Button";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import { Input } from "./ui/Input";
|
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
|
// 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
|
// 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);
|
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 = {
|
const body: ClipRequest = {
|
||||||
source: src.source,
|
source: src.source,
|
||||||
ref: src.ref,
|
ref: src.ref,
|
||||||
@@ -291,7 +295,8 @@ export default function ClipPanel({
|
|||||||
<MarkField
|
<MarkField
|
||||||
label={t("clip.in")}
|
label={t("clip.in")}
|
||||||
value={clip.start}
|
value={clip.start}
|
||||||
hours={hours}
|
field={inField}
|
||||||
|
errorId={timeErrorId}
|
||||||
onSet={(v) => moveIn(v, true)}
|
onSet={(v) => moveIn(v, true)}
|
||||||
nudge={NUDGE_S}
|
nudge={NUDGE_S}
|
||||||
hereLabel={t("clip.inHere")}
|
hereLabel={t("clip.inHere")}
|
||||||
@@ -300,7 +305,8 @@ export default function ClipPanel({
|
|||||||
<MarkField
|
<MarkField
|
||||||
label={t("clip.out")}
|
label={t("clip.out")}
|
||||||
value={clip.end}
|
value={clip.end}
|
||||||
hours={hours}
|
field={outField}
|
||||||
|
errorId={timeErrorId}
|
||||||
onSet={(v) => moveOut(v, true)}
|
onSet={(v) => moveOut(v, true)}
|
||||||
nudge={NUDGE_S}
|
nudge={NUDGE_S}
|
||||||
hereLabel={t("clip.outHere")}
|
hereLabel={t("clip.outHere")}
|
||||||
@@ -322,6 +328,7 @@ export default function ClipPanel({
|
|||||||
{t("clip.loop")}
|
{t("clip.loop")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{(inField.invalid || outField.invalid) && <TimecodeError id={timeErrorId} hours={hours} />}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -389,7 +396,8 @@ export default function ClipPanel({
|
|||||||
function MarkField({
|
function MarkField({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
hours,
|
field,
|
||||||
|
errorId,
|
||||||
onSet,
|
onSet,
|
||||||
nudge,
|
nudge,
|
||||||
hereLabel,
|
hereLabel,
|
||||||
@@ -397,7 +405,8 @@ function MarkField({
|
|||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
hours: boolean;
|
field: TimecodeDraft;
|
||||||
|
errorId: string;
|
||||||
onSet: (v: number) => void;
|
onSet: (v: number) => void;
|
||||||
nudge: number;
|
nudge: number;
|
||||||
hereLabel: string;
|
hereLabel: string;
|
||||||
@@ -418,10 +427,9 @@ function MarkField({
|
|||||||
<TimecodeField
|
<TimecodeField
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-24 text-center tabular-nums"
|
className="w-24 text-center tabular-nums"
|
||||||
value={value}
|
field={field}
|
||||||
hours={hours}
|
errorId={errorId}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onCommit={onSet}
|
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="overlay"
|
variant="overlay"
|
||||||
|
|||||||
@@ -936,7 +936,7 @@ export default function PlayerModal({
|
|||||||
ref={stageRef}
|
ref={stageRef}
|
||||||
// The wheel over the video is its volume, even inside a stacked card that scrolls.
|
// The wheel over the video is its volume, even inside a stacked card that scrolls.
|
||||||
data-wheel-volume=""
|
data-wheel-volume=""
|
||||||
className={`player-stage relative aspect-video w-full bg-black overflow-hidden ${
|
className={`player-stage media-focus relative aspect-video w-full bg-black overflow-hidden ${
|
||||||
wide ? (clipOpen ? "rounded-tl-2xl" : "rounded-l-2xl") : "rounded-t-2xl"
|
wide ? (clipOpen ? "rounded-tl-2xl" : "rounded-l-2xl") : "rounded-t-2xl"
|
||||||
} ${maximized ? "player-stage--max" : ""}`}
|
} ${maximized ? "player-stage--max" : ""}`}
|
||||||
>
|
>
|
||||||
@@ -1009,7 +1009,7 @@ export default function PlayerModal({
|
|||||||
{(maximized || isFullscreen) && !clipDocked && (
|
{(maximized || isFullscreen) && !clipDocked && (
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-start gap-x-3 overflow-x-auto border-t border-white/10 bg-media-chrome/95 px-3 text-sm text-white"
|
className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-start gap-x-3 overflow-x-auto border-t border-white/10 bg-media-chrome/95 media-focus px-3 text-sm text-white"
|
||||||
>
|
>
|
||||||
{hasQueue && (
|
{hasQueue && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ const BACK_TO_PREV_S = 3;
|
|||||||
// inside the window skips again (the keyboard keeps ← / → at the viewer's own step).
|
// inside the window skips again (the keyboard keeps ← / → at the viewer's own step).
|
||||||
// The player's menus sit on the always-dark media chrome, under white text, in every theme.
|
// The player's menus sit on the always-dark media chrome, under white text, in every theme.
|
||||||
const PLAYER_MENU =
|
const PLAYER_MENU =
|
||||||
"rounded-xl border border-white/15 bg-media-chrome/95 text-sm text-white shadow-2xl";
|
"rounded-xl border border-white/15 bg-media-chrome/95 media-focus text-sm text-white shadow-2xl";
|
||||||
const PLAYER_ROW = "block w-full rounded-lg px-2 py-1.5 text-left hover:bg-white/10";
|
const PLAYER_ROW = "block w-full rounded-lg px-2 py-1.5 text-left hover:bg-white/10";
|
||||||
// A track choice: the chosen one wears the tick in the leading slot (DESIGN "one of several"), which
|
// A track choice: the chosen one wears the tick in the leading slot (DESIGN "one of several"), which
|
||||||
// the highlight's white text cannot wash out the way it did the accent-coloured label.
|
// the highlight's white text cannot wash out the way it did the accent-coloured label.
|
||||||
@@ -1191,7 +1191,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={wrapRef}
|
ref={wrapRef}
|
||||||
className="fixed left-0 top-0 z-overlay bg-black flex items-center justify-center select-none"
|
className="media-focus fixed left-0 top-0 z-overlay bg-black flex items-center justify-center select-none"
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
pointerRef.current = e.pointerType;
|
pointerRef.current = e.pointerType;
|
||||||
}}
|
}}
|
||||||
@@ -1840,7 +1840,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="my-auto w-full max-w-3xl rounded-2xl border border-white/15 bg-media-chrome/95 shadow-2xl"
|
className="my-auto w-full max-w-3xl rounded-2xl border border-white/15 bg-media-chrome/95 media-focus shadow-2xl"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function PlexPlayerSettings(props: {
|
|||||||
aria-label={t("plex.player.settings")}
|
aria-label={t("plex.player.settings")}
|
||||||
onWheel={(e) => e.stopPropagation()}
|
onWheel={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="border border-white/15 bg-media-chrome/95 absolute bottom-full right-0 z-menu mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
|
className="border border-white/15 bg-media-chrome/95 media-focus absolute bottom-full right-0 z-menu mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
|
||||||
>
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
tone="media"
|
tone="media"
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function UpNextCard({
|
|||||||
role="region"
|
role="region"
|
||||||
aria-label={t("plex.player.upNext.title")}
|
aria-label={t("plex.player.upNext.title")}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="absolute bottom-24 right-6 z-menu flex w-[22rem] max-w-[calc(100vw-3rem)] gap-3 rounded-2xl border border-white/15 bg-media-chrome/95 p-3.5 text-white shadow-2xl"
|
className="absolute bottom-24 right-6 z-menu flex w-[22rem] max-w-[calc(100vw-3rem)] gap-3 rounded-2xl border border-white/15 bg-media-chrome/95 media-focus p-3.5 text-white shadow-2xl"
|
||||||
>
|
>
|
||||||
<div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
|
<div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
|
||||||
{next && (
|
{next && (
|
||||||
|
|||||||
@@ -137,6 +137,32 @@ describe("VideoEditor — a typed time and another segment", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// An unreadable time is said on a line under the segment's row: outside the field's label, so the
|
||||||
|
// field's name stays "Out" and the line is read once, as its description.
|
||||||
|
describe("VideoEditor — an unreadable time", () => {
|
||||||
|
it("is said under the row, outside the field's label", () => {
|
||||||
|
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<VideoEditor job={job} onClose={() => {}} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
const video = document.querySelector("video")!;
|
||||||
|
Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true });
|
||||||
|
fireEvent.timeUpdate(video);
|
||||||
|
fireEvent.click(screen.getByText("editor.splitHere"));
|
||||||
|
fireEvent.pointerDown(screen.getAllByTitle("editor.keptSeg")[0]!);
|
||||||
|
const out = screen.getByRole("textbox", { name: "editor.out" });
|
||||||
|
fireEvent.change(out, { target: { value: "abc" } });
|
||||||
|
fireEvent.keyDown(out, { key: "Enter" });
|
||||||
|
const line = screen.getByRole("alert");
|
||||||
|
expect(line.textContent).toBe("common.timecodeInvalid");
|
||||||
|
expect(line.closest("label")).toBeNull();
|
||||||
|
expect(screen.getByRole("textbox", { name: "editor.out" })).toBe(out);
|
||||||
|
expect(out.getAttribute("aria-describedby")).toBe(line.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 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", () => {
|
||||||
@@ -235,11 +261,14 @@ describe("VideoEditor — a cut's remove button", () => {
|
|||||||
expect(document.activeElement).toBe(splitButton);
|
expect(document.activeElement).toBe(splitButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sits at the cut's foot, under the next segment's keep toggle at its head", () => {
|
it("sits under the track at its cut, off the cut's grab area; the keep toggle sits over it", () => {
|
||||||
split();
|
split();
|
||||||
const remove = screen.getByRole("button", { name: "editor.removeCut" });
|
const remove = screen.getByRole("button", { name: "editor.removeCut" });
|
||||||
expect(remove.className).toMatch(/\bbottom-0\b/);
|
|
||||||
const track = screen.getByRole("group", { name: "editor.timeline" });
|
const track = screen.getByRole("group", { name: "editor.timeline" });
|
||||||
|
// A press anywhere on the cut drags it; the X that deletes it (no undo) is below the track.
|
||||||
|
expect(track.contains(remove)).toBe(false);
|
||||||
|
const cut = screen.getByRole("slider", { name: "editor.cutN" });
|
||||||
|
expect(remove.closest<HTMLElement>("[style]")?.style.left).toBe(cut.style.left);
|
||||||
const toggles = within(track).getAllByRole("button", { pressed: true });
|
const toggles = within(track).getAllByRole("button", { pressed: true });
|
||||||
for (const toggle of toggles) expect(toggle.className).toMatch(/\bz-menu\b/);
|
for (const toggle of toggles) expect(toggle.className).toMatch(/\bz-menu\b/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -15,9 +15,11 @@ import { markReported } from "../lib/api/core";
|
|||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "./ui/Pressable";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "./ui/Input";
|
||||||
import { TimecodeField } from "./ui/TimecodeField";
|
import { TimecodeError, TimecodeField } from "./ui/TimecodeField";
|
||||||
|
import { useTimecodeField } from "./ui/useTimecodeField";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
import { RangeTrack } from "./ui/RangeTrack";
|
import { RangeTrack } from "./ui/RangeTrack";
|
||||||
|
import { trackLeft } from "./ui/trackPosition";
|
||||||
import { formatTimecode } from "../lib/timecode";
|
import { formatTimecode } from "../lib/timecode";
|
||||||
|
|
||||||
type Seg = { id: number; start: number; end: number; keep: boolean };
|
type Seg = { id: number; start: number; end: number; keep: boolean };
|
||||||
@@ -309,6 +311,18 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
|
|
||||||
const sel = segments.find((s) => s.id === selId) ?? segments[0];
|
const sel = segments.find((s) => s.id === selId) ?? segments[0];
|
||||||
const selIdx = segments.findIndex((s) => s.id === sel?.id);
|
const selIdx = segments.findIndex((s) => s.id === sel?.id);
|
||||||
|
const hours = duration >= 3600;
|
||||||
|
const inField = useTimecodeField(
|
||||||
|
sel?.start ?? 0,
|
||||||
|
hours,
|
||||||
|
(v) => selIdx > 0 && moveBoundary(selIdx - 1, v),
|
||||||
|
);
|
||||||
|
const outField = useTimecodeField(
|
||||||
|
sel?.end ?? 0,
|
||||||
|
hours,
|
||||||
|
(v) => selIdx < segments.length - 1 && moveBoundary(selIdx, v),
|
||||||
|
);
|
||||||
|
const timeErrorId = useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -429,11 +443,20 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
step: 0.1,
|
step: 0.1,
|
||||||
bigStep: 1,
|
bigStep: 1,
|
||||||
onChange: (time: number) => moveBoundary(bi, time),
|
onChange: (time: number) => moveBoundary(bi, time),
|
||||||
// At the cut's foot, clear of the next segment's eye at its head. A real button: a
|
}))}
|
||||||
// click or Enter removes the cut; its press is not a seek of the track under it.
|
/>
|
||||||
adornment: (
|
</div>
|
||||||
|
{/* Each cut's remove button, under the track at the cut: a real button a click or Enter
|
||||||
|
works, off the cut's grab area, where a press drags it (a removal has no undo). */}
|
||||||
|
{boundaries.length > 0 && (
|
||||||
|
<div className="relative mt-1 h-6">
|
||||||
|
{boundaries.map((b, bi) => (
|
||||||
|
<div
|
||||||
|
key={`cut-${bi}`}
|
||||||
|
className="absolute top-0 -translate-x-1/2"
|
||||||
|
style={{ left: trackLeft(b, { from: 0, to: duration }) }}
|
||||||
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Refused while the clips are queued (the list holds still): then nothing is
|
// 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.
|
// removed, and no focus is left waiting to be taken by a later change.
|
||||||
@@ -442,16 +465,16 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
deleteBoundary(bi);
|
deleteBoundary(bi);
|
||||||
}}
|
}}
|
||||||
// 24px, the target floor; white on its black disc in either theme.
|
// 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"
|
className="flex p-1.5 rounded-full bg-black/70 text-white/80 hover:text-white"
|
||||||
aria-label={t("editor.removeCut", { n: bi + 1 })}
|
aria-label={t("editor.removeCut", { n: bi + 1 })}
|
||||||
title={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>
|
||||||
),
|
</div>
|
||||||
}))}
|
))}
|
||||||
/>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* hover-scrub thumbnail */}
|
{/* hover-scrub thumbnail */}
|
||||||
{strip && hoverTime != null && (
|
{strip && hoverTime != null && (
|
||||||
@@ -492,10 +515,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
<TimecodeField
|
<TimecodeField
|
||||||
className="w-24 tabular-nums"
|
className="w-24 tabular-nums"
|
||||||
size="sm"
|
size="sm"
|
||||||
value={sel.start}
|
field={inField}
|
||||||
hours={duration >= 3600}
|
errorId={timeErrorId}
|
||||||
disabled={selIdx === 0}
|
disabled={selIdx === 0}
|
||||||
onCommit={(v) => selIdx > 0 && moveBoundary(selIdx - 1, v)}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-1 text-muted">
|
<label className="flex items-center gap-1 text-muted">
|
||||||
@@ -503,15 +525,19 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
<TimecodeField
|
<TimecodeField
|
||||||
className="w-24 tabular-nums"
|
className="w-24 tabular-nums"
|
||||||
size="sm"
|
size="sm"
|
||||||
value={sel.end}
|
field={outField}
|
||||||
hours={duration >= 3600}
|
errorId={timeErrorId}
|
||||||
disabled={selIdx === segments.length - 1}
|
disabled={selIdx === segments.length - 1}
|
||||||
onCommit={(v) => selIdx < segments.length - 1 && moveBoundary(selIdx, v)}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<span className="text-xs text-muted tabular-nums w-16 text-right">
|
<span className="text-xs text-muted tabular-nums w-16 text-right">
|
||||||
{tc(sel.end - sel.start)}
|
{tc(sel.end - sel.start)}
|
||||||
</span>
|
</span>
|
||||||
|
{(inField.invalid || outField.invalid) && (
|
||||||
|
<div className="basis-full">
|
||||||
|
<TimecodeError id={timeErrorId} hours={hours} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export const ROVING_HIGHLIGHT =
|
|||||||
"data-active:bg-accent/35 data-active:text-fg data-active:ring-1 data-active:ring-inset data-active:ring-accent/60";
|
"data-active:bg-accent/35 data-active:text-fg data-active:ring-1 data-active:ring-inset data-active:ring-accent/60";
|
||||||
const HIGHLIGHT_SELECTED =
|
const HIGHLIGHT_SELECTED =
|
||||||
"data-active:bg-accent/35 data-active:text-fg data-active:ring-2 data-active:ring-inset data-active:ring-accent/80";
|
"data-active:bg-accent/35 data-active:text-fg data-active:ring-2 data-active:ring-inset data-active:ring-accent/80";
|
||||||
// On the always-dark media chrome (a player's menus, `bg-media-chrome` under white text) the theme's
|
// On the always-dark media chrome (a player's menus, `bg-media-chrome media-focus` under white text) the theme's
|
||||||
// `text-fg` is dark in the light theme: the highlighted row would go dark-on-dark. White instead.
|
// `text-fg` is dark in the light theme: the highlighted row would go dark-on-dark. White instead.
|
||||||
const MEDIA_HIGHLIGHT =
|
const MEDIA_HIGHLIGHT =
|
||||||
"data-active:bg-white/15 data-active:text-white data-active:ring-1 data-active:ring-inset data-active:ring-white/40";
|
"data-active:bg-white/15 data-active:text-white data-active:ring-1 data-active:ring-inset data-active:ring-white/40";
|
||||||
|
|||||||
@@ -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 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` |
|
| 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` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; 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)
|
||||||
|
|
||||||
|
|||||||
@@ -140,16 +140,6 @@ describe("RangeTrack", () => {
|
|||||||
expect(a.onChange).toHaveBeenLastCalledWith(40, true);
|
expect(a.onChange).toHaveBeenLastCalledWith(40, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("puts a handle's adornment beside its slider, not inside it, where a button stays a button", () => {
|
|
||||||
const h = handle({ adornment: <button>Remove</button> });
|
|
||||||
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
|
|
||||||
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", () => {
|
it("ends a drag whose handle leaves the window under it, so the track takes the next press", () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const onSeek = vi.fn();
|
const onSeek = vi.fn();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import clsx from "clsx";
|
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
|
// 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
|
// (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;
|
onChange: (time: number, final: boolean) => void;
|
||||||
/** The drawn bar's look (its colour); the hit area around it is the track's. */
|
/** The drawn bar's look (its colour); the hit area around it is the track's. */
|
||||||
barClassName?: string;
|
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 {
|
interface TrackRegion {
|
||||||
@@ -108,9 +105,8 @@ export function RangeTrack({
|
|||||||
playheadClassName = "bg-white",
|
playheadClassName = "bg-white",
|
||||||
}: 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 frac = (t: number) => trackFrac(t, win);
|
||||||
const frac = (t: number) => (t - win.from) / span;
|
const pct = trackPct;
|
||||||
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<{
|
const [dragging, setDragging] = useState<{
|
||||||
@@ -239,7 +235,7 @@ export function RangeTrack({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
move(next, true);
|
move(next, true);
|
||||||
};
|
};
|
||||||
return [
|
return (
|
||||||
<div
|
<div
|
||||||
key={h.id}
|
key={h.id}
|
||||||
role="slider"
|
role="slider"
|
||||||
@@ -279,17 +275,8 @@ export function RangeTrack({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={clsx("h-full w-1 rounded-sm", h.barClassName ?? "bg-white/90")} />
|
<div className={clsx("h-full w-1 rounded-sm", h.barClassName ?? "bg-white/90")} />
|
||||||
</div>,
|
</div>
|
||||||
h.adornment != null && (
|
);
|
||||||
<div
|
|
||||||
key={`${h.id}-adornment`}
|
|
||||||
className="pointer-events-none absolute inset-y-0 z-base w-6 -translate-x-1/2"
|
|
||||||
style={{ left: pct(f) }}
|
|
||||||
>
|
|
||||||
{h.adornment}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
];
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
//
|
//
|
||||||
// The time field: invalid only once a time was offered, said in words under the field, and a draft
|
// The time field: invalid only once a time was offered, said in words on the caller's line, and a
|
||||||
// that never outlives the value it was typed over.
|
// draft that never outlives the value it was typed over.
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
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) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
@@ -13,25 +14,39 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function Harness({ value, onCommit }: { value: number; onCommit: (s: number) => void }) {
|
||||||
|
const field = useTimecodeField(value, false, onCommit);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TimecodeField aria-label="In" field={field} errorId="err" />
|
||||||
|
{field.invalid && <TimecodeError id="err" hours={false} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const field = () => screen.getByRole("textbox") as HTMLInputElement;
|
const field = () => screen.getByRole("textbox") as HTMLInputElement;
|
||||||
|
|
||||||
describe("TimecodeField", () => {
|
describe("TimecodeField", () => {
|
||||||
it("is not invalid while a time is still being typed, only once it is offered", () => {
|
it("is not invalid while a time is still being typed, only once it is offered", () => {
|
||||||
render(<TimecodeField aria-label="In" value={10} onCommit={() => {}} />);
|
render(<Harness value={10} onCommit={() => {}} />);
|
||||||
fireEvent.change(field(), { target: { value: "" } });
|
fireEvent.change(field(), { target: { value: "" } });
|
||||||
fireEvent.change(field(), { target: { value: "1:" } });
|
fireEvent.change(field(), { target: { value: "1:" } });
|
||||||
expect(field().getAttribute("aria-invalid")).toBeNull();
|
expect(field().getAttribute("aria-invalid")).toBeNull();
|
||||||
fireEvent.blur(field());
|
fireEvent.blur(field());
|
||||||
expect(field().getAttribute("aria-invalid")).toBe("true");
|
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", () => {
|
it("says in words, read out and linked to the field, how a time is typed", () => {
|
||||||
render(<TimecodeField aria-label="In" value={10} onCommit={() => {}} />);
|
render(<Harness value={10} onCommit={() => {}} />);
|
||||||
fireEvent.change(field(), { target: { value: "abc" } });
|
fireEvent.change(field(), { target: { value: "abc" } });
|
||||||
fireEvent.keyDown(field(), { key: "Enter" });
|
fireEvent.keyDown(field(), { key: "Enter" });
|
||||||
const id = field().getAttribute("aria-describedby");
|
const id = field().getAttribute("aria-describedby");
|
||||||
expect(id).toBeTruthy();
|
expect(id).toBeTruthy();
|
||||||
expect(document.getElementById(id!)?.textContent).toBe("common.timecodeInvalid");
|
expect(document.getElementById(id!)?.textContent).toBe("common.timecodeInvalid");
|
||||||
|
expect(screen.getByRole("alert").id).toBe(id);
|
||||||
fireEvent.change(field(), { target: { value: "0:12" } });
|
fireEvent.change(field(), { target: { value: "0:12" } });
|
||||||
fireEvent.keyDown(field(), { key: "Enter" });
|
fireEvent.keyDown(field(), { key: "Enter" });
|
||||||
expect(field().getAttribute("aria-describedby")).toBeNull();
|
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", () => {
|
it("drops a draft when the value moves under it, and never commits it to the new value", () => {
|
||||||
const onCommit = vi.fn();
|
const onCommit = vi.fn();
|
||||||
const { rerender } = render(<TimecodeField aria-label="In" value={10} onCommit={onCommit} />);
|
const { rerender } = render(<Harness value={10} onCommit={onCommit} />);
|
||||||
fireEvent.change(field(), { target: { value: "0:05" } });
|
fireEvent.change(field(), { target: { value: "0:05" } });
|
||||||
// Another segment selected under the same field (or a nudge): the field edits a new value now.
|
// 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} />);
|
rerender(<Harness value={40} onCommit={onCommit} />);
|
||||||
expect(field().value).toBe("0:40.0");
|
expect(field().value).toBe("0:40.0");
|
||||||
fireEvent.blur(field());
|
fireEvent.blur(field());
|
||||||
expect(onCommit).not.toHaveBeenCalled();
|
expect(onCommit).not.toHaveBeenCalled();
|
||||||
@@ -50,7 +65,7 @@ describe("TimecodeField", () => {
|
|||||||
fireEvent.change(field(), { target: { value: "x" } });
|
fireEvent.change(field(), { target: { value: "x" } });
|
||||||
fireEvent.blur(field());
|
fireEvent.blur(field());
|
||||||
expect(field().getAttribute("aria-invalid")).toBe("true");
|
expect(field().getAttribute("aria-invalid")).toBe("true");
|
||||||
rerender(<TimecodeField aria-label="In" value={41} onCommit={onCommit} />);
|
rerender(<Harness value={41} onCommit={onCommit} />);
|
||||||
expect(field().value).toBe("0:41.0");
|
expect(field().value).toBe("0:41.0");
|
||||||
expect(field().getAttribute("aria-invalid")).toBeNull();
|
expect(field().getAttribute("aria-invalid")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,88 +1,67 @@
|
|||||||
import { useId, useState, type ComponentProps } from "react";
|
import { type ComponentProps } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatTimecode, parseTimecode } from "../../lib/timecode";
|
|
||||||
import { Input } from "./Input";
|
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 /
|
// 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 for fixing rather than vanishing or seeming taken: marked invalid, with a line under the
|
// stays for fixing rather than vanishing or seeming taken: marked invalid, with a line saying how to
|
||||||
// field saying how to type one. Invalid is said of a time offered (Enter, leaving the field), never
|
// type one. Nothing is marked while a first time is being typed; once one was offered (Enter,
|
||||||
// of one still being typed. One element throughout, so Enter leaves the focus in the field (a field
|
// 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 <body>).
|
// re-created per value dropped it on <body>).
|
||||||
|
//
|
||||||
|
// 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 <label>, where it would become part of the field's name.
|
||||||
|
|
||||||
type Props = Omit<
|
type Props = Omit<
|
||||||
ComponentProps<typeof Input>,
|
ComponentProps<typeof Input>,
|
||||||
"value" | "defaultValue" | "onChange" | "invalid"
|
"value" | "defaultValue" | "onChange" | "invalid"
|
||||||
> & {
|
> & {
|
||||||
/** Seconds. */
|
field: TimecodeDraft;
|
||||||
value: number;
|
/** The id of the `TimecodeError` that speaks for this field while it is invalid. */
|
||||||
/** Show hours (pass `duration >= 3600`, so one item's fields agree). */
|
errorId: string;
|
||||||
hours?: boolean;
|
|
||||||
/** A readable time was typed (not called when it equals `value`). */
|
|
||||||
onCommit: (seconds: number) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TimecodeField({
|
export function TimecodeField({
|
||||||
value,
|
field,
|
||||||
hours = false,
|
errorId,
|
||||||
onCommit,
|
|
||||||
onBlur,
|
onBlur,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
"aria-describedby": describedBy,
|
"aria-describedby": describedBy,
|
||||||
...rest
|
...rest
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const described = [describedBy, field.invalid ? errorId : null].filter(Boolean).join(" ");
|
||||||
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 = () => {
|
|
||||||
if (draft == null) return;
|
|
||||||
const v = parseTimecode(draft.text);
|
|
||||||
if (v == null) {
|
|
||||||
if (!draft.offered) setDraft({ text: draft.text, offered: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDraft(null);
|
|
||||||
if (Math.abs(v - value) > 1e-3) onCommit(v);
|
|
||||||
};
|
|
||||||
const described = [describedBy, invalid ? errorId : null].filter(Boolean).join(" ");
|
|
||||||
return (
|
return (
|
||||||
<span className="relative inline-flex">
|
<Input
|
||||||
<Input
|
{...rest}
|
||||||
{...rest}
|
value={field.text}
|
||||||
value={draft?.text ?? formatTimecode(value, hours)}
|
invalid={field.invalid}
|
||||||
invalid={invalid}
|
aria-describedby={described || undefined}
|
||||||
aria-describedby={described || undefined}
|
onChange={(e) => field.type(e.target.value)}
|
||||||
onChange={(e) => setDraft({ text: e.target.value, offered: draft?.offered ?? false })}
|
onBlur={(e) => {
|
||||||
onBlur={(e) => {
|
field.offer();
|
||||||
commit();
|
onBlur?.(e);
|
||||||
onBlur?.(e);
|
}}
|
||||||
}}
|
onKeyDown={(e) => {
|
||||||
onKeyDown={(e) => {
|
onKeyDown?.(e);
|
||||||
onKeyDown?.(e);
|
if (e.key !== "Enter" || e.defaultPrevented) return;
|
||||||
if (e.key !== "Enter" || e.defaultPrevented) return;
|
e.preventDefault();
|
||||||
e.preventDefault();
|
field.offer();
|
||||||
commit();
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
);
|
||||||
{invalid && (
|
}
|
||||||
<span
|
|
||||||
id={errorId}
|
/** How a time is typed, said once one could not be read: rendered by the caller only while one of
|
||||||
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"
|
* its fields is invalid, in the flow under them, and read out as it appears. */
|
||||||
>
|
export function TimecodeError({ id, hours }: { id: string; hours: boolean }) {
|
||||||
{t(hours ? "common.timecodeInvalidHours" : "common.timecodeInvalid")}
|
const { t } = useTranslation();
|
||||||
</span>
|
return (
|
||||||
)}
|
<p id={id} role="alert" className="text-xs text-danger">
|
||||||
</span>
|
{t(hours ? "common.timecodeInvalidHours" : "common.timecodeInvalid")}
|
||||||
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { RangeWindow } from "./RangeTrack";
|
||||||
|
|
||||||
|
// Where a time sits along a `RangeTrack` over a window: the track draws its regions, playhead and
|
||||||
|
// handles by these, and a control laid beside the track (a cut's remove button under it) lines up
|
||||||
|
// with the handle drawn at the same time.
|
||||||
|
|
||||||
|
export const trackFrac = (t: number, win: RangeWindow) =>
|
||||||
|
(t - win.from) / Math.max(win.to - win.from, 1e-6);
|
||||||
|
|
||||||
|
/** A fraction of the track as a CSS length, held to the track. */
|
||||||
|
export const trackPct = (f: number) => `${(Math.max(0, Math.min(1, f)) * 100).toFixed(3)}%`;
|
||||||
|
|
||||||
|
/** A time's place along the track, as its CSS `left`. */
|
||||||
|
export const trackLeft = (t: number, win: RangeWindow) => trackPct(trackFrac(t, win));
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { formatTimecode, parseTimecode } from "../../lib/timecode";
|
||||||
|
|
||||||
|
// The draft of a time field (`TimecodeField`), held by the caller so it can place the line saying an
|
||||||
|
// unreadable time (`TimecodeError`) in its own flow.
|
||||||
|
|
||||||
|
export interface TimecodeDraft {
|
||||||
|
/** The text the field shows. */
|
||||||
|
text: string;
|
||||||
|
/** A time was offered and cannot be read. */
|
||||||
|
invalid: boolean;
|
||||||
|
type: (text: string) => void;
|
||||||
|
/** Enter or leaving the field: take the draft if it reads, else mark it. */
|
||||||
|
offer: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The draft of one time field. `value` in seconds; `hours` as `formatTimecode` takes it. */
|
||||||
|
export function useTimecodeField(
|
||||||
|
value: number,
|
||||||
|
hours: boolean,
|
||||||
|
/** A readable time was typed (not called when it equals `value`). */
|
||||||
|
onCommit: (seconds: number) => void,
|
||||||
|
): TimecodeDraft {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text: draft?.text ?? formatTimecode(value, hours),
|
||||||
|
invalid: draft != null && draft.offered && parseTimecode(draft.text) == null,
|
||||||
|
type: (text) => setDraft({ text, offered: draft?.offered ?? false }),
|
||||||
|
offer: () => {
|
||||||
|
if (draft == null) return;
|
||||||
|
const v = parseTimecode(draft.text);
|
||||||
|
if (v == null) {
|
||||||
|
if (!draft.offered) setDraft({ text: draft.text, offered: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraft(null);
|
||||||
|
if (Math.abs(v - value) > 1e-3) onCommit(v);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -243,6 +243,7 @@
|
|||||||
"detailsToggle": "Technical details",
|
"detailsToggle": "Technical details",
|
||||||
"upload": {
|
"upload": {
|
||||||
"quota_exceeded": {
|
"quota_exceeded": {
|
||||||
|
"short": "Not enough storage for the converted file",
|
||||||
"body": "The phone-ready version of this upload would not fit in your storage quota. Your original is kept – delete something from your library, then try again."
|
"body": "The phone-ready version of this upload would not fit in your storage quota. Your original is kept – delete something from your library, then try again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -243,6 +243,7 @@
|
|||||||
"detailsToggle": "Technikai részletek",
|
"detailsToggle": "Technikai részletek",
|
||||||
"upload": {
|
"upload": {
|
||||||
"quota_exceeded": {
|
"quota_exceeded": {
|
||||||
|
"short": "Nincs elég hely az átalakított fájlnak",
|
||||||
"body": "A feltöltés telefonra alakított változata nem fér bele a tárhelykeretedbe. Az eredeti megmaradt – törölj valamit a könyvtáradból, majd próbáld újra."
|
"body": "A feltöltés telefonra alakított változata nem fér bele a tárhelykeretedbe. Az eredeti megmaradt – törölj valamit a könyvtáradból, majd próbáld újra."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -145,6 +145,13 @@
|
|||||||
border-color: var(--edge-strong);
|
border-color: var(--edge-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The focus ring on the always-dark player chrome (a player, its menus and panels): the media amber,
|
||||||
|
as a light theme's accent is dark (under 3:1 on the chrome). Every class string that paints
|
||||||
|
bg-media-chrome names it or media-scope (theme.contract.test.ts). */
|
||||||
|
@utility media-focus {
|
||||||
|
--focus: var(--media-mark);
|
||||||
|
}
|
||||||
|
|
||||||
/* 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 for what is chosen; what must be seen
|
never lands on the dark bar. The accent stays the theme's for what is chosen; what must be seen
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ describe("failTitleKey", () => {
|
|||||||
expect(i18n.t(failCopyKeys(download, "body"))).toBe(
|
expect(i18n.t(failCopyKeys(download, "body"))).toBe(
|
||||||
i18n.t("downloads.errors.fail.cat.quota_exceeded.body"),
|
i18n.t("downloads.errors.fail.cat.quota_exceeded.body"),
|
||||||
);
|
);
|
||||||
|
// Its headline too: space is left, so not "Storage full".
|
||||||
|
const short = i18n.t(failCopyKeys(upload, "short"));
|
||||||
|
expect(short).not.toMatch(/^downloads\./);
|
||||||
|
expect(short).not.toBe(i18n.t("downloads.errors.fail.cat.quota_exceeded.short"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,33 @@ describe("the media colours", () => {
|
|||||||
expect(scope).toMatch(/--danger:\s*var\(--media-danger\);/);
|
expect(scope).toMatch(/--danger:\s*var\(--media-danger\);/);
|
||||||
expect(scope).toMatch(/--focus:\s*var\(--media-mark\);/);
|
expect(scope).toMatch(/--focus:\s*var\(--media-mark\);/);
|
||||||
expect(css).toMatch(/outline: 2px solid var\(--focus, var\(--accent\)\);/);
|
expect(css).toMatch(/outline: 2px solid var\(--focus, var\(--accent\)\);/);
|
||||||
expect(css).toMatch(/--color-focus: var\(--focus, var\(--accent\)\);/);
|
// In the `inline` theme block, so `focus-*` utilities read --focus where they are used — a plain
|
||||||
|
// @theme resolves it once on :root, and a scope's own --focus never reaches them.
|
||||||
|
const at2 = css.indexOf("@theme inline {");
|
||||||
|
expect(css.slice(at2, css.indexOf("\n}", at2))).toMatch(
|
||||||
|
/--color-focus: var\(--focus, var\(--accent\)\);/,
|
||||||
|
);
|
||||||
|
// A field's focus edge is that colour too, not the accent's.
|
||||||
|
const field = readFileSync(new URL("../components/ui/fieldStyles.ts", import.meta.url), "utf8");
|
||||||
|
expect(field).toMatch(/\bfocus-within:border-focus\b/);
|
||||||
|
expect(field).toMatch(/\bfocus-within:ring-focus\b/);
|
||||||
|
expect(field).not.toMatch(/\bfocus-within:(border|ring)-accent\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every surface painted the player chrome names its focus colour", () => {
|
||||||
|
const at = css.indexOf("@utility media-focus {");
|
||||||
|
expect(css.slice(at, css.indexOf("\n}", at))).toMatch(/--focus:\s*var\(--media-mark\);/);
|
||||||
|
// A class string that paints the chrome carries the media focus (media-scope re-points it too):
|
||||||
|
// the theme's accent ring fell under 3:1 on it in a light scheme.
|
||||||
|
const root = new URL("../", import.meta.url);
|
||||||
|
const offenders: string[] = [];
|
||||||
|
for (const f of readdirSync(root, { recursive: true, encoding: "utf8" })) {
|
||||||
|
if (!/\.tsx?$/.test(f) || /\.test\.tsx?$/.test(f)) continue;
|
||||||
|
const src = readFileSync(new URL(f.replace(/\\/g, "/"), root), "utf8");
|
||||||
|
for (const m of src.matchAll(/["'`]([^"'`]*\bbg-media-chrome\b[^"'`]*)["'`]/g))
|
||||||
|
if (!/\bmedia-(scope|focus)\b/.test(m[1]!)) offenders.push(`${f}: ${m[1]}`);
|
||||||
|
}
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user