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:
@@ -61,7 +61,9 @@ import {
|
||||
useYtVolume,
|
||||
} from "../lib/playerModalHooks";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// Experiment (branch experiment/inline-player): play the video in-app via the
|
||||
// YouTube IFrame Player API (not a bare embed) so we can read playback position
|
||||
@@ -574,7 +576,7 @@ export default function PlayerModal({
|
||||
where there's no room beside the card. */}
|
||||
<div className="relative flex w-full max-w-4xl max-h-full">
|
||||
{hasQueue && (
|
||||
<FocusSafeButton
|
||||
<Pressable
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goPrev();
|
||||
@@ -585,7 +587,7 @@ export default function PlayerModal({
|
||||
className="absolute right-full top-0 bottom-0 mr-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
||||
>
|
||||
<ChevronLeft className="h-8 w-8" />
|
||||
</FocusSafeButton>
|
||||
</Pressable>
|
||||
)}
|
||||
<div
|
||||
ref={cardRef}
|
||||
@@ -670,62 +672,46 @@ export default function PlayerModal({
|
||||
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
|
||||
portal into the fullscreen element rather than <body> (a body-portaled node is
|
||||
painted underneath the element holding the lock, i.e. invisible). */}
|
||||
{!navigated && <AddToPlaylist videoId={active.id} variant="overlay" />}
|
||||
{!navigated && (
|
||||
<AddToPlaylist
|
||||
videoId={active.id}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition"
|
||||
/>
|
||||
<DownloadButton videoId={active.id} title={active.title} variant="overlay" />
|
||||
)}
|
||||
{!navigated && (
|
||||
<DownloadButton
|
||||
videoId={active.id}
|
||||
title={active.title}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition"
|
||||
/>
|
||||
)}
|
||||
{!navigated && (
|
||||
<button
|
||||
<Button
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
pressed={watched}
|
||||
onClick={() => setWatched(!watched)}
|
||||
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||||
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
|
||||
watched
|
||||
? "bg-white/25 text-white"
|
||||
: "text-white/70 hover:bg-white/10 hover:text-white"
|
||||
}`}
|
||||
label={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||||
icon={watched ? <CheckCheck /> : <Check />}
|
||||
>
|
||||
{watched ? <CheckCheck className="h-4 w-4" /> : <Check className="h-4 w-4" />}
|
||||
<span className="hidden md:inline">
|
||||
{watched ? t("player.watched") : t("player.markWatched")}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<span className="h-4 w-px bg-white/20" />
|
||||
<button
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
onClick={toggleTrueFullscreen}
|
||||
title={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
|
||||
aria-label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition"
|
||||
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize className="h-4 w-4" />
|
||||
) : (
|
||||
<Maximize className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
{isFullscreen ? <Minimize /> : <Maximize />}
|
||||
</IconButton>
|
||||
<Button
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
exitBigView();
|
||||
setNativeControls(false);
|
||||
focusModal();
|
||||
}}
|
||||
title={t("player.exitMaximize")}
|
||||
aria-label={t("player.exitMaximize")}
|
||||
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-white/80 hover:bg-white/10 hover:text-white transition"
|
||||
label={t("player.exitMaximize")}
|
||||
icon={<Minimize2 />}
|
||||
>
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("player.exitMaximize")}</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
|
||||
@@ -802,14 +788,15 @@ export default function PlayerModal({
|
||||
</span>
|
||||
</h2>
|
||||
{navigated && (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => loadVideo(active.id, null)}
|
||||
title={t("player.backToOriginal")}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
|
||||
icon={<ArrowLeft />}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("player.back")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<PopoverSurface
|
||||
popover={descPop}
|
||||
@@ -837,15 +824,16 @@ export default function PlayerModal({
|
||||
<div className="text-sm text-muted">{t("player.noDescription")}</div>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid="player-close"
|
||||
onClick={onClose}
|
||||
title={t("player.closeEsc")}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
|
||||
>
|
||||
{t("player.close")}
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Channel + meta on one line, with the watched toggle pushed to the right.
|
||||
@@ -952,60 +940,56 @@ export default function PlayerModal({
|
||||
own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there
|
||||
the docked bar offers OUR true fullscreen. */}
|
||||
{!navigated && (
|
||||
<button
|
||||
<IconButton
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={toggleFullscreen}
|
||||
label={t("player.fullscreen")}
|
||||
title={`${t("player.fullscreen")} · F`}
|
||||
aria-label={t("player.fullscreen")}
|
||||
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
|
||||
className="ml-auto"
|
||||
>
|
||||
<Maximize className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{!navigated && (
|
||||
<AddToPlaylist
|
||||
videoId={active.id}
|
||||
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
|
||||
/>
|
||||
<Maximize />
|
||||
</IconButton>
|
||||
)}
|
||||
{!navigated && <AddToPlaylist videoId={active.id} variant="outline" size="md" />}
|
||||
{!navigated && (
|
||||
<DownloadButton
|
||||
videoId={active.id}
|
||||
title={active.title}
|
||||
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
|
||||
variant="outline"
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
{!navigated && onRemoveFromList && (
|
||||
<button
|
||||
<IconButton
|
||||
variant="outline-danger"
|
||||
size="md"
|
||||
onClick={() => void removeFromList()}
|
||||
title={t("playlists.removeItem")}
|
||||
// Named with its object, like the row control: a bare "Remove" among Fullscreen /
|
||||
// Download / Mark watched reads as "delete this video".
|
||||
aria-label={t("playlists.removeItemOf", { title: active.title })}
|
||||
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-danger hover:bg-surface border border-border transition"
|
||||
label={t("playlists.removeItemOf", { title: active.title })}
|
||||
title={t("playlists.removeItem")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<Trash2 />
|
||||
</IconButton>
|
||||
)}
|
||||
{!navigated && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
pressed={watched}
|
||||
onClick={() => setWatched(!watched)}
|
||||
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||||
className={
|
||||
"shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
|
||||
(watched
|
||||
? "btn-primary shadow-xs"
|
||||
: "text-muted hover:text-fg hover:bg-surface border border-border")
|
||||
}
|
||||
icon={watched ? <CheckCheck /> : <Check />}
|
||||
>
|
||||
{watched ? <CheckCheck className="w-4 h-4" /> : <Check className="w-4 h-4" />}
|
||||
{watched ? t("player.watched") : t("player.markWatched")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasQueue && (
|
||||
<FocusSafeButton
|
||||
<Pressable
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goNext();
|
||||
@@ -1016,7 +1000,7 @@ export default function PlayerModal({
|
||||
className="absolute left-full top-0 bottom-0 ml-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
|
||||
>
|
||||
<ChevronRight className="h-8 w-8" />
|
||||
</FocusSafeButton>
|
||||
</Pressable>
|
||||
)}
|
||||
</div>
|
||||
{/* Toasts raised while the player is open (a failed Remove, a lost connection) — the app's own
|
||||
|
||||
Reference in New Issue
Block a user