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:
2026-10-10 21:29:55 +02:00
parent 89d18b46a6
commit 977ba244fa
22 changed files with 306 additions and 153 deletions
+8 -6
View File
@@ -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 |
@@ -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(<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", () => {
render(<Harness />);
const field = screen.getAllByRole("textbox", { name: "clip.in" })[0]!;
+17 -9
View File
@@ -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({
<MarkField
label={t("clip.in")}
value={clip.start}
hours={hours}
field={inField}
errorId={timeErrorId}
onSet={(v) => moveIn(v, true)}
nudge={NUDGE_S}
hereLabel={t("clip.inHere")}
@@ -300,7 +305,8 @@ export default function ClipPanel({
<MarkField
label={t("clip.out")}
value={clip.end}
hours={hours}
field={outField}
errorId={timeErrorId}
onSet={(v) => moveOut(v, true)}
nudge={NUDGE_S}
hereLabel={t("clip.outHere")}
@@ -322,6 +328,7 @@ export default function ClipPanel({
{t("clip.loop")}
</Button>
</div>
{(inField.invalid || outField.invalid) && <TimecodeError id={timeErrorId} hours={hours} />}
<div className="flex flex-wrap items-center gap-2">
<Button
@@ -389,7 +396,8 @@ export default function ClipPanel({
function MarkField({
label,
value,
hours,
field,
errorId,
onSet,
nudge,
hereLabel,
@@ -397,7 +405,8 @@ function MarkField({
}: {
label: string;
value: number;
hours: boolean;
field: TimecodeDraft;
errorId: string;
onSet: (v: number) => void;
nudge: number;
hereLabel: string;
@@ -418,10 +427,9 @@ function MarkField({
<TimecodeField
size="sm"
className="w-24 text-center tabular-nums"
value={value}
hours={hours}
field={field}
errorId={errorId}
aria-label={label}
onCommit={onSet}
/>
<IconButton
variant="overlay"
+2 -2
View File
@@ -936,7 +936,7 @@ export default function PlayerModal({
ref={stageRef}
// The wheel over the video is its volume, even inside a stacked card that scrolls.
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"
} ${maximized ? "player-stage--max" : ""}`}
>
@@ -1009,7 +1009,7 @@ export default function PlayerModal({
{(maximized || isFullscreen) && !clipDocked && (
<div
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 && (
<>
+3 -3
View File
@@ -113,7 +113,7 @@ const BACK_TO_PREV_S = 3;
// 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.
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";
// 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.
@@ -1191,7 +1191,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
return createPortal(
<div
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) => {
pointerRef.current = e.pointerType;
}}
@@ -1840,7 +1840,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
}}
>
<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()}
>
<Suspense fallback={null}>
@@ -65,7 +65,7 @@ export function PlexPlayerSettings(props: {
aria-label={t("plex.player.settings")}
onWheel={(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
tone="media"
+1 -1
View File
@@ -72,7 +72,7 @@ export function UpNextCard({
role="region"
aria-label={t("plex.player.upNext.title")}
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">
{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
// tenth short of the next cut, so no segment ever closes up.
describe("VideoEditor — a cut on the keyboard", () => {
@@ -235,11 +261,14 @@ describe("VideoEditor — a cut's remove button", () => {
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();
const remove = screen.getByRole("button", { name: "editor.removeCut" });
expect(remove.className).toMatch(/\bbottom-0\b/);
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 });
for (const toggle of toggles) expect(toggle.className).toMatch(/\bz-menu\b/);
});
+42 -16
View File
@@ -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 { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -15,9 +15,11 @@ import { markReported } from "../lib/api/core";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
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 { RangeTrack } from "./ui/RangeTrack";
import { trackLeft } from "./ui/trackPosition";
import { formatTimecode } from "../lib/timecode";
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 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 (
<Modal
@@ -429,11 +443,20 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
step: 0.1,
bigStep: 1,
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
onPointerDown={(e) => e.stopPropagation()}
onClick={() => {
// Refused while the clips are queued (the list holds still): then nothing is
// removed, and no focus is left waiting to be taken by a later change.
@@ -442,16 +465,16 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
deleteBoundary(bi);
}}
// 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 })}
title={t("editor.removeCut", { n: bi + 1 })}
>
<X className="w-3 h-3" />
</Pressable>
),
}))}
/>
</div>
))}
</div>
)}
{/* hover-scrub thumbnail */}
{strip && hoverTime != null && (
@@ -492,10 +515,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
<TimecodeField
className="w-24 tabular-nums"
size="sm"
value={sel.start}
hours={duration >= 3600}
field={inField}
errorId={timeErrorId}
disabled={selIdx === 0}
onCommit={(v) => selIdx > 0 && moveBoundary(selIdx - 1, v)}
/>
</label>
<label className="flex items-center gap-1 text-muted">
@@ -503,15 +525,19 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
<TimecodeField
className="w-24 tabular-nums"
size="sm"
value={sel.end}
hours={duration >= 3600}
field={outField}
errorId={timeErrorId}
disabled={selIdx === segments.length - 1}
onCommit={(v) => selIdx < segments.length - 1 && moveBoundary(selIdx, v)}
/>
</label>
<span className="text-xs text-muted tabular-nums w-16 text-right">
{tc(sel.end - sel.start)}
</span>
{(inField.invalid || outField.invalid) && (
<div className="basis-full">
<TimecodeError id={timeErrorId} hours={hours} />
</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";
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";
// 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.
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";
+2 -2
View File
@@ -36,9 +36,9 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here
| area | components |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<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) |
| 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` |
@@ -140,16 +140,6 @@ describe("RangeTrack", () => {
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", () => {
const onChange = vi.fn();
const onSeek = vi.fn();
+5 -18
View File
@@ -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 (
<div
key={h.id}
role="slider"
@@ -279,17 +275,8 @@ export function RangeTrack({
}}
>
<div className={clsx("h-full w-1 rounded-sm", h.barClassName ?? "bg-white/90")} />
</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>
);
@@ -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<typeof import("react-i18next")>();
@@ -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 (
<>
<TimecodeField aria-label="In" field={field} errorId="err" />
{field.invalid && <TimecodeError id="err" hours={false} />}
</>
);
}
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={() => {}} />);
render(<Harness 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");
// 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(<TimecodeField aria-label="In" value={10} onCommit={() => {}} />);
it("says in words, read out and linked to the field, how a time is typed", () => {
render(<Harness 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");
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(<TimecodeField aria-label="In" value={10} onCommit={onCommit} />);
const { rerender } = render(<Harness 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} />);
rerender(<Harness value={40} onCommit={onCommit} />);
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(<TimecodeField aria-label="In" value={41} onCommit={onCommit} />);
rerender(<Harness value={41} onCommit={onCommit} />);
expect(field().value).toBe("0:41.0");
expect(field().getAttribute("aria-invalid")).toBeNull();
});
+31 -52
View File
@@ -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 <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<
ComponentProps<typeof Input>,
"value" | "defaultValue" | "onChange" | "invalid"
> & {
/** Seconds. */
value: number;
/** Show hours (pass `duration >= 3600`, so one item's fields agree). */
hours?: boolean;
/** A readable time was typed (not called when it equals `value`). */
onCommit: (seconds: number) => void;
field: TimecodeDraft;
/** The id of the `TimecodeError` that speaks for this field while it is invalid. */
errorId: string;
};
export function TimecodeField({
value,
hours = false,
onCommit,
field,
errorId,
onBlur,
onKeyDown,
"aria-describedby": describedBy,
...rest
}: Props) {
const { t } = useTranslation();
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(" ");
const described = [describedBy, field.invalid ? errorId : null].filter(Boolean).join(" ");
return (
<span className="relative inline-flex">
<Input
{...rest}
value={draft?.text ?? formatTimecode(value, hours)}
invalid={invalid}
value={field.text}
invalid={field.invalid}
aria-describedby={described || undefined}
onChange={(e) => setDraft({ text: e.target.value, offered: draft?.offered ?? false })}
onChange={(e) => field.type(e.target.value)}
onBlur={(e) => {
commit();
field.offer();
onBlur?.(e);
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.key !== "Enter" || e.defaultPrevented) return;
e.preventDefault();
commit();
field.offer();
}}
/>
{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"
>
);
}
/** How a time is typed, said once one could not be read: rendered by the caller only while one of
* its fields is invalid, in the flow under them, and read out as it appears. */
export function TimecodeError({ id, hours }: { id: string; hours: boolean }) {
const { t } = useTranslation();
return (
<p id={id} role="alert" className="text-xs text-danger">
{t(hours ? "common.timecodeInvalidHours" : "common.timecodeInvalid")}
</span>
)}
</span>
</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",
"upload": {
"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."
}
},
@@ -243,6 +243,7 @@
"detailsToggle": "Technikai részletek",
"upload": {
"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."
}
},
+7
View File
@@ -145,6 +145,13 @@
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
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
+4
View File
@@ -81,6 +81,10 @@ describe("failTitleKey", () => {
expect(i18n.t(failCopyKeys(download, "body"))).toBe(
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"));
}
});
+27 -1
View File
@@ -68,7 +68,33 @@ describe("the media colours", () => {
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\)\);/);
// 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([]);
});
});