feat(design): the button families on the decided ladder, first half of the sweep (E28 S3b1)
Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`, `media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held by theme.contract.test.ts. Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor, Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are 153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play).
This commit is contained in:
@@ -8,11 +8,12 @@ import Modal from "./ui/Modal";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
||||
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
|
||||
import { inputCls, btnCls } from "./ui/form";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
import { failureReason, notifyActionError } from "../lib/actionErrors";
|
||||
import { markReported } from "../lib/api/core";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// mm:ss.d timecode <-> seconds.
|
||||
function tc(s: number): string {
|
||||
@@ -431,23 +432,21 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
|
||||
{/* transport */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={playPause}
|
||||
className="p-2 rounded-lg bg-surface hover:bg-card transition"
|
||||
title={t("editor.playPause")}
|
||||
>
|
||||
{playing ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
</button>
|
||||
<IconButton variant="outline" size="md" onClick={playPause} label={t("editor.playPause")}>
|
||||
{playing ? <Pause /> : <Play />}
|
||||
</IconButton>
|
||||
<div className="text-xs text-muted tabular-nums">
|
||||
{tc(current)} / {tc(duration)}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={splitAtPlayhead}
|
||||
className={clsx(btnCls, "bg-surface hover:bg-card inline-flex items-center gap-1.5")}
|
||||
icon={<SplitSquareHorizontal />}
|
||||
>
|
||||
<SplitSquareHorizontal className="w-4 h-4" /> {t("editor.splitHere")}
|
||||
</button>
|
||||
{t("editor.splitHere")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* timeline: filmstrip + segments (keep/drop tint) + markers + playhead + hover preview */}
|
||||
@@ -499,7 +498,8 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
title={s.keep ? t("editor.keptSeg") : t("editor.droppedSeg")}
|
||||
>
|
||||
{/* keep/drop toggle */}
|
||||
<button
|
||||
<Pressable
|
||||
aria-pressed={s.keep}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleKeep(s.id);
|
||||
@@ -511,7 +511,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
title={s.keep ? t("editor.drop") : t("editor.keep")}
|
||||
>
|
||||
{s.keep ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</Pressable>
|
||||
</div>
|
||||
))}
|
||||
{/* draggable boundary markers */}
|
||||
@@ -528,7 +528,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
setDrag({ type: "boundary", bi });
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
<Pressable
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteBoundary(bi);
|
||||
@@ -537,7 +537,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
title={t("editor.removeCut")}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Pressable>
|
||||
</div>
|
||||
))}
|
||||
{/* playhead */}
|
||||
@@ -571,16 +571,15 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
{sel && (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm rounded-lg glass-card px-3 py-2">
|
||||
<span className="font-medium">{t("editor.segment", { n: selIdx + 1 })}</span>
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
pressed={sel.keep}
|
||||
onClick={() => toggleKeep(sel.id)}
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium",
|
||||
sel.keep ? "bg-accent/15 text-accent" : "bg-surface text-muted",
|
||||
)}
|
||||
icon={sel.keep ? <Eye /> : <EyeOff />}
|
||||
>
|
||||
{sel.keep ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||
{sel.keep ? t("editor.kept") : t("editor.dropped")}
|
||||
</button>
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<label className="flex items-center gap-1 text-muted">
|
||||
{t("editor.in")}
|
||||
@@ -618,19 +617,16 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-muted">{t("editor.output.label")}:</span>
|
||||
{(["separate", "join"] as const).map((m) => (
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
key={m}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
pressed={output === m}
|
||||
onClick={() => setOutput(m)}
|
||||
disabled={m === "join" && kept.length < 2}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-lg border transition disabled:opacity-40",
|
||||
output === m
|
||||
? "border-accent bg-accent/10 text-accent"
|
||||
: "border-border text-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
{t(`editor.output.${m}`)}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-muted">
|
||||
@@ -640,35 +636,30 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
|
||||
{/* crop + precision */}
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
pressed={cropOn}
|
||||
onClick={() => setCropOn((v) => !v)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition",
|
||||
cropOn
|
||||
? "border-accent text-accent bg-accent/10"
|
||||
: "border-border text-muted hover:text-fg",
|
||||
)}
|
||||
icon={<Crop />}
|
||||
className="justify-start"
|
||||
>
|
||||
<Crop className="w-4 h-4" /> {cropOn ? t("editor.cropOn") : t("editor.cropOff")}
|
||||
</button>
|
||||
{cropOn ? t("editor.cropOn") : t("editor.cropOff")}
|
||||
</Button>
|
||||
{!cropOn ? (
|
||||
<div className="flex gap-2">
|
||||
{(["accurate", "fast"] as const).map((m) => {
|
||||
const on = (m === "accurate") === accurate;
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={m}
|
||||
variant="outline"
|
||||
pressed={on}
|
||||
onClick={() => setAccurate(m === "accurate")}
|
||||
className={clsx(
|
||||
"flex-1 px-3 py-2 rounded-lg border text-left transition",
|
||||
on ? "border-accent bg-accent/10" : "border-border hover:border-muted",
|
||||
)}
|
||||
title={t(`editor.${m}.hint`)}
|
||||
className="flex-1"
|
||||
>
|
||||
<div className={clsx("text-sm font-medium", on ? "text-accent" : "text-fg")}>
|
||||
{t(`editor.${m}.label`)}
|
||||
</div>
|
||||
</button>
|
||||
{t(`editor.${m}.label`)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user