build(deps): migrate Tailwind CSS v3.4 → v4

Root-fixes the opacity-modifier seed bug behind R10 S0: the theme colors were
bare `var(--x)` hex with no <alpha-value>, so `bg-accent/40`, `ring-accent/40`,
`border-border/60`, `text-muted/70`, … (~99 sites across 45 files) emitted no
CSS rule at all — latent no-ops. v4 implements the opacity modifier via
`color-mix`, so the modifier now works with the existing hex vars, no per-scheme
token duplication needed.

- `npx @tailwindcss/upgrade`: `@tailwind` directives → `@import "tailwindcss"`,
  JS config → CSS `@theme` (colors, --font-sans, the z-index tokens preserved),
  classname renames (rounded/shadow/blur scale, outline-none→outline-hidden,
  ring→ring-2) across 56 files; PostCSS plugin → @tailwindcss/postcss,
  autoprefixer removed (v4 prefixes for you).
- `@theme` → `@theme inline` (load-bearing): the color vars are redefined at
  runtime under html[data-scheme]/[data-theme]; inline resolves them at the use
  site so per-scheme opacity tints track the active scheme (the shadcn v4 pattern).
- Revert one codemod false-positive: it rewrote the English word "rounded" →
  "rounded-sm" inside a releaseNotes.ts prose sentence.

Gate green: tsc(app+node) / eslint 0-err / prettier / vitest 79 / knip.
Verified in-browser: bg-accent/15 now computes a scheme-tracking color at 0.15
alpha across all 6 schemes × 2 themes; the hand-rolled glass border/shadow/
radius/backdrop-filter render unchanged under v4.
This commit is contained in:
2026-08-08 00:50:05 +02:00
parent 1199bb0eaf
commit 70bda98868
60 changed files with 822 additions and 809 deletions
+604 -591
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -37,12 +37,12 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "9.39.1", "@eslint/js": "9.39.1",
"@playwright/test": "^1.61.1", "@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"eslint": "9.39.1", "eslint": "9.39.1",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-plugin-react-hooks": "5.2.0", "eslint-plugin-react-hooks": "5.2.0",
@@ -52,7 +52,7 @@
"knip": "6.27.0", "knip": "6.27.0",
"postcss": "^8.4.39", "postcss": "^8.4.39",
"prettier": "3.6.2", "prettier": "3.6.2",
"tailwindcss": "^3.4.6", "tailwindcss": "^4.3.3",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "8.46.0", "typescript-eslint": "8.46.0",
"vite": "^5.3.4", "vite": "^5.3.4",
+1 -2
View File
@@ -1,6 +1,5 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {}, "@tailwindcss/postcss": {},
autoprefixer: {},
}, },
}; };
+3 -3
View File
@@ -45,19 +45,19 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
ref={pop.refs.setReference} ref={pop.refs.setReference}
{...pop.getReferenceProps()} {...pop.getReferenceProps()}
tabIndex={0} tabIndex={0}
className="block max-w-[22rem] truncate text-muted cursor-help outline-none" className="block max-w-88 truncate text-muted cursor-help outline-hidden"
> >
{preview} {preview}
</span> </span>
<PopoverSurface <PopoverSurface
popover={pop} popover={pop}
className="glass w-[480px] max-h-[28rem] overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]" className="glass w-[480px] max-h-112 overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
> >
{/* Full About once the detail loads; until then the list's truncated description. */} {/* Full About once the detail loads; until then the list's truncated description. */}
{ch ? ( {ch ? (
<ChannelAboutContent ch={ch} /> <ChannelAboutContent ch={ch} />
) : ( ) : (
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{preview}</p> <p className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">{preview}</p>
)} )}
</PopoverSurface> </PopoverSurface>
</> </>
@@ -34,7 +34,7 @@ export default function ActiveFilterChips({
aria-label={t("feed.chips.remove", { name: f.label })} aria-label={t("feed.chips.remove", { name: f.label })}
className="group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 rounded-full border border-accent/40 bg-accent/10 text-accent text-xs hover:border-accent hover:bg-accent/20 transition" className="group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 rounded-full border border-accent/40 bg-accent/10 text-accent text-xs hover:border-accent hover:bg-accent/20 transition"
> >
<span className="max-w-[14rem] truncate">{f.label}</span> <span className="max-w-56 truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" /> <X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
</button> </button>
))} ))}
+2 -2
View File
@@ -124,7 +124,7 @@ export default function AddToPlaylist({
// the floating element only). `toggle()` already no-ops while busy, so re-entrancy is // the floating element only). `toggle()` already no-ops while busy, so re-entrancy is
// guarded without touching focus; `aria-busy` on the list conveys the pending state. // guarded without touching focus; `aria-busy` on the list conveys the pending state.
label={playlistName(pl, t)} label={playlistName(pl, t)}
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-none transition hover:bg-card/60 data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)]" className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition hover:bg-card/60 data-active:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)]"
> >
<span <span
className={`grid place-items-center w-4 h-4 rounded border ${ className={`grid place-items-center w-4 h-4 rounded border ${
@@ -156,7 +156,7 @@ export default function AddToPlaylist({
} }
}} }}
placeholder={t("playlists.newPlaylist")} placeholder={t("playlists.newPlaylist")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-hidden focus:border-accent"
/> />
</div> </div>
</div> </div>
+2 -2
View File
@@ -349,7 +349,7 @@ function AdminInvites() {
value={newEmail} value={newEmail}
onChange={(e) => setNewEmail(e.target.value)} onChange={(e) => setNewEmail(e.target.value)}
placeholder={t("settings.invites.addPlaceholder")} placeholder={t("settings.invites.addPlaceholder")}
className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-hidden focus:border-accent"
/> />
<button <button
type="submit" type="submit"
@@ -468,7 +468,7 @@ function AdminDemo() {
value={newEmail} value={newEmail}
onChange={(e) => setNewEmail(e.target.value)} onChange={(e) => setNewEmail(e.target.value)}
placeholder={t("settings.demo.addPlaceholder")} placeholder={t("settings.demo.addPlaceholder")}
className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-hidden focus:border-accent"
/> />
<button <button
type="submit" type="submit"
+1 -1
View File
@@ -229,7 +229,7 @@ type ItemRole = "menuitem" | "menuitemradio" | "menuitemcheckbox" | "option";
* key. The recommended recipe is an accent tint — and note `bg-accent/NN` does NOT work here (the * key. The recommended recipe is an accent tint — and note `bg-accent/NN` does NOT work here (the
* theme's `--accent` is a hex var, and Tailwind v3 can't inject alpha into it), so use a color-mix * theme's `--accent` is a hex var, and Tailwind v3 can't inject alpha into it), so use a color-mix
* arbitrary value like the app's own glass idiom: * arbitrary value like the app's own glass idiom:
* `data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg`. */ * `data-active:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-active:text-fg`. */
export function PopoverItem({ export function PopoverItem({
children, children,
onSelect, onSelect,
+2 -2
View File
@@ -50,7 +50,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
return ( return (
<> <>
{ch.description ? ( {ch.description ? (
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed"> <p className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
{linkify(ch.description)} {linkify(ch.description)}
</p> </p>
) : ( ) : (
@@ -81,7 +81,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
{/^[A-Za-z]{2}$/.test(ch.country) && ( {/^[A-Za-z]{2}$/.test(ch.country) && (
// Real SVG flag (flag-icons) — Windows/Chrome don't render flag emoji. // Real SVG flag (flag-icons) — Windows/Chrome don't render flag emoji.
<span <span
className={`fi fi-${ch.country.toLowerCase()} rounded-sm shrink-0`} className={`fi fi-${ch.country.toLowerCase()} rounded-xs shrink-0`}
aria-hidden aria-hidden
/> />
)} )}
+3 -3
View File
@@ -205,7 +205,7 @@ export default function ChannelDiscovery({
<> <>
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */} {/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
<PageToolbar> <PageToolbar>
<div className="px-4 pt-3 max-w-[96rem] mx-auto flex items-start justify-between gap-4"> <div className="px-4 pt-3 max-w-384 mx-auto flex items-start justify-between gap-4">
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p> <p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */} {/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
{layout === "cards" && ( {layout === "cards" && (
@@ -224,7 +224,7 @@ export default function ChannelDiscovery({
)} )}
</div> </div>
</PageToolbar> </PageToolbar>
<div className="px-4 pb-4 max-w-[96rem] mx-auto"> <div className="px-4 pb-4 max-w-384 mx-auto">
{query.isError && !query.data ? ( {query.isError && !query.data ? (
<StateMessage tone="error" onRetry={() => query.refetch()}> <StateMessage tone="error" onRetry={() => query.refetch()}>
{t("common.loadError")} {t("common.loadError")}
@@ -239,7 +239,7 @@ export default function ChannelDiscovery({
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined} persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
controlsPosition="both" controlsPosition="both"
controlsInBand controlsInBand
controlsBandClassName="px-4 max-w-[96rem] mx-auto" controlsBandClassName="px-4 max-w-384 mx-auto"
sort={sort} sort={sort}
onSortChange={setSort} onSortChange={setSort}
emptyText={t("channels.discovery.empty")} emptyText={t("channels.discovery.empty")}
+1 -1
View File
@@ -297,7 +297,7 @@ export default function ChannelPage({
<button <button
data-testid="channel-back" data-testid="channel-back"
onClick={closeChannel} onClick={closeChannel}
className="absolute top-5 left-7 sm:left-9 z-base inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-sm" className="absolute top-5 left-7 sm:left-9 z-base inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-xs"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
{t("channel.back")} {t("channel.back")}
+1 -1
View File
@@ -52,7 +52,7 @@ export default function ChannelPicker({
key={id} key={id}
onClick={() => toggle(id)} onClick={() => toggle(id)}
title={t("sidebar.channelRemove", { name: nameOf(id) })} title={t("sidebar.channelRemove", { name: nameOf(id) })}
className="inline-flex items-center gap-1 max-w-full pl-2.5 pr-1.5 py-1 rounded-full bg-accent text-accent-fg text-xs shadow-sm hover:opacity-90 transition" className="inline-flex items-center gap-1 max-w-full pl-2.5 pr-1.5 py-1 rounded-full bg-accent text-accent-fg text-xs shadow-xs hover:opacity-90 transition"
> >
<span className="truncate">{nameOf(id)}</span> <span className="truncate">{nameOf(id)}</span>
<X className="w-3 h-3 shrink-0" /> <X className="w-3 h-3 shrink-0" />
+4 -4
View File
@@ -23,7 +23,7 @@ interface TagChipProps {
function TagChip({ tag, onClick, title }: TagChipProps) { function TagChip({ tag, onClick, title }: TagChipProps) {
const cls = const cls =
"shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent max-w-[9rem] truncate"; "shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent max-w-36 truncate";
return onClick ? ( return onClick ? (
<button <button
type="button" type="button"
@@ -75,7 +75,7 @@ function TagEditorMenu({
checked={on} checked={on}
onSelect={() => onToggle(tg.id)} onSelect={() => onToggle(tg.id)}
label={tg.name} label={tg.name}
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-none transition hover:bg-card data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg ${ className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition hover:bg-card data-active:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-active:text-fg ${
on ? "text-fg" : "text-muted" on ? "text-fg" : "text-muted"
}`} }`}
> >
@@ -111,7 +111,7 @@ function TagEditorMenu({
}} }}
maxLength={64} maxLength={64}
placeholder={t("channels.tags.createPlaceholder")} placeholder={t("channels.tags.createPlaceholder")}
className="flex-1 min-w-0 bg-transparent text-xs px-2 py-1 rounded-md border border-border focus:border-accent outline-none" className="flex-1 min-w-0 bg-transparent text-xs px-2 py-1 rounded-md border border-border focus:border-accent outline-hidden"
/> />
<button <button
type="button" type="button"
@@ -247,7 +247,7 @@ export function ChannelTagsControl({
// collapses the rest into "+N". (max-w-full lets a narrower card shrink it.) Unbounded, the row // collapses the rest into "+N". (max-w-full lets a narrower card shrink it.) Unbounded, the row
// takes its container's width, so a wide header shows the tags inline instead of collapsing them. // takes its container's width, so a wide header shows the tags inline instead of collapsing them.
<div <div
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-[12rem] max-w-full" : "w-full"}`} className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
> >
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. */} {/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. */}
<div <div
+7 -7
View File
@@ -616,7 +616,7 @@ export default function Channels() {
const cardPager = <Pager {...pagerProps} />; const cardPager = <Pager {...pagerProps} />;
const tabs = ( const tabs = (
<div className="px-4 pt-4 max-w-[96rem] mx-auto flex items-center justify-between gap-4 flex-wrap"> <div className="px-4 pt-4 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => ( {(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
<button <button
@@ -648,7 +648,7 @@ export default function Channels() {
<PageToolbar> <PageToolbar>
{tabs} {tabs}
{view === "subscribed" && ( {view === "subscribed" && (
<div className="px-4 pt-2 pb-2 max-w-[96rem] mx-auto"> <div className="px-4 pt-2 pb-2 max-w-384 mx-auto">
{/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name {/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name
search lives in the shared top SearchBar, tag create/delete in the manager. */} search lives in the shared top SearchBar, tag create/delete in the manager. */}
<div className="flex items-center justify-between gap-4 flex-wrap"> <div className="flex items-center justify-between gap-4 flex-wrap">
@@ -700,7 +700,7 @@ export default function Channels() {
{/* In table mode the status chips + pager ride in DataTable's own in-band controls row. The card {/* In table mode the status chips + pager ride in DataTable's own in-band controls row. The card
layout has no such row, so surface the same status chips (left) and its pager (right) here. */} layout has no such row, so surface the same status chips (left) and its pager (right) here. */}
{view === "subscribed" && layout === "cards" && ( {view === "subscribed" && layout === "cards" && (
<div className="px-4 pb-2 max-w-[96rem] mx-auto flex items-center justify-between gap-3 flex-wrap"> <div className="px-4 pb-2 max-w-384 mx-auto flex items-center justify-between gap-3 flex-wrap">
{statusChips} {statusChips}
{cardPager} {cardPager}
</div> </div>
@@ -719,7 +719,7 @@ export default function Channels() {
/> />
) : view === "blocked" ? ( ) : view === "blocked" ? (
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */ /* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
<div className="px-4 pb-4 pt-3 max-w-[96rem] mx-auto"> <div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
{(blockedQuery.data?.length ?? 0) === 0 ? ( {(blockedQuery.data?.length ?? 0) === 0 ? (
<p className="text-sm text-muted py-8">{t("channels.blocked.empty")}</p> <p className="text-sm text-muted py-8">{t("channels.blocked.empty")}</p>
) : ( ) : (
@@ -756,7 +756,7 @@ export default function Channels() {
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when /* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
`position: sticky` engages on the first scroll (the band's controls row above gives the `position: sticky` engages on the first scroll (the band's controls row above gives the
visual gap). */ visual gap). */
<div className="px-4 pb-4 max-w-[96rem] mx-auto"> <div className="px-4 pb-4 max-w-384 mx-auto">
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row {/* Channel table — only the rows scroll; the header row is sticky, and the controls row
(status chips + pager) rides in the fixed band right above it (controlsInBand). The (status chips + pager) rides in the fixed band right above it (controlsInBand). The
card layout swaps the table for a virtualized grid over the same rows (its pager and card layout swaps the table for a virtualized grid over the same rows (its pager and
@@ -776,7 +776,7 @@ export default function Channels() {
controlsPosition="both" controlsPosition="both"
controlsLeading={statusChips} controlsLeading={statusChips}
controlsInBand controlsInBand
controlsBandClassName="px-4 max-w-[96rem] mx-auto" controlsBandClassName="px-4 max-w-384 mx-auto"
sort={sort} sort={sort}
onSortChange={setSort} onSortChange={setSort}
rowClassName={(c) => (c.hidden ? "opacity-60" : "")} rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
@@ -953,7 +953,7 @@ function PriorityCell({
else if (e.key === "Escape") setEditing(false); else if (e.key === "Escape") setEditing(false);
}} }}
aria-label={t("channels.row.priorityHint")} aria-label={t("channels.row.priorityHint")}
className="absolute top-1/2 right-0 -translate-y-1/2 w-14 h-6 text-sm font-medium tabular-nums text-right bg-card border border-accent rounded px-1 outline-none z-popover [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" className="absolute top-1/2 right-0 -translate-y-1/2 w-14 h-6 text-sm font-medium tabular-nums text-right bg-card border border-accent rounded-sm px-1 outline-hidden z-popover [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
/> />
)} )}
</span> </span>
+2 -2
View File
@@ -178,7 +178,7 @@ export default function ChatThread({
return ( return (
<div key={m.id} className={`flex ${mine ? "justify-end" : "justify-start"}`}> <div key={m.id} className={`flex ${mine ? "justify-end" : "justify-start"}`}>
<div <div
className={`max-w-[80%] rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap break-words ${ className={`max-w-[80%] rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap wrap-break-word ${
mine ? "bg-accent text-accent-fg rounded-br-sm" : "bg-card rounded-bl-sm" mine ? "bg-accent text-accent-fg rounded-br-sm" : "bg-card rounded-bl-sm"
}`} }`}
> >
@@ -211,7 +211,7 @@ export default function ChatThread({
}} }}
rows={1} rows={1}
placeholder={t("messages.writePlaceholder")} placeholder={t("messages.writePlaceholder")}
className="flex-1 resize-none max-h-28 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" className="flex-1 resize-none max-h-28 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-hidden focus:border-accent"
/> />
<button <button
onClick={submit} onClick={submit}
@@ -40,7 +40,7 @@ export default function ColumnSortControl<T>({
onChange(key ? { key, dir: active?.key === key ? active.dir : "asc" } : null); onChange(key ? { key, dir: active?.key === key ? active.dir : "asc" } : null);
}} }}
aria-label={label} aria-label={label}
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent" className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-hidden focus:border-accent"
> >
<option value="">{defaultLabel}</option> <option value="">{defaultLabel}</option>
{sortable.map((c) => ( {sortable.map((c) => (
+1 -1
View File
@@ -370,7 +370,7 @@ function Field({
max={isNum && item.max != null ? item.max : undefined} max={isNum && item.max != null ? item.max : undefined}
placeholder={item.secret ? "••••••••" : String(item.default ?? "")} placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
aria-label={t(`config.fields.${item.key}.label`, item.key)} aria-label={t(`config.fields.${item.key}.label`, item.key)}
className="w-52 bg-card border border-border rounded-xl px-3 py-1.5 text-sm outline-none focus:border-accent disabled:opacity-50" className="w-52 bg-card border border-border rounded-xl px-3 py-1.5 text-sm outline-hidden focus:border-accent disabled:opacity-50"
/> />
)} )}
{showReset && !secretDisabled && ( {showReset && !secretDisabled && (
+4 -4
View File
@@ -181,7 +181,7 @@ function SourceRef({ url }: { url: string }) {
onClick={copy} onClick={copy}
aria-label={t("downloads.source.copy")} aria-label={t("downloads.source.copy")}
title={t("downloads.source.copy")} title={t("downloads.source.copy")}
className="shrink-0 p-0.5 rounded hover:bg-surface hover:text-fg transition" className="shrink-0 p-0.5 rounded-sm hover:bg-surface hover:text-fg transition"
> >
<Copy className="w-3 h-3" /> <Copy className="w-3 h-3" />
</button> </button>
@@ -227,7 +227,7 @@ function DownloadRow({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5"> <div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
{job.job_kind === "edit" && ( {job.job_kind === "edit" && (
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-accent/15 text-accent"> <span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded-sm text-[10px] font-semibold uppercase tracking-wide bg-accent/15 text-accent">
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")} <Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
</span> </span>
)} )}
@@ -435,7 +435,7 @@ function ErrorModal({
{t("downloads.errors.fail.detailsToggle")} {t("downloads.errors.fail.detailsToggle")}
</button> </button>
{showRaw && ( {showRaw && (
<pre className="mt-1.5 text-xs bg-surface rounded-lg p-2.5 overflow-x-auto whitespace-pre-wrap break-words max-h-40 text-muted"> <pre className="mt-1.5 text-xs bg-surface rounded-lg p-2.5 overflow-x-auto whitespace-pre-wrap wrap-break-word max-h-40 text-muted">
{job.error} {job.error}
</pre> </pre>
)} )}
@@ -713,7 +713,7 @@ function AdminSystem() {
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span> <span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
<button <button
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })} onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
className="p-1 rounded hover:bg-surface text-muted hover:text-fg" className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg"
title={t("downloads.admin.editQuota")} title={t("downloads.admin.editQuota")}
> >
<Settings2 className="w-4 h-4" /> <Settings2 className="w-4 h-4" />
+3 -3
View File
@@ -410,7 +410,7 @@ export default function Feed({
value={ytCount} value={ytCount}
onChange={(e) => setYtCount(Number(e.target.value))} onChange={(e) => setYtCount(Number(e.target.value))}
title={t("feed.yt.showHint")} title={t("feed.yt.showHint")}
className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent" className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-hidden focus:border-accent"
> >
{[20, 40, 60, 100].map((n) => ( {[20, 40, 60, 100].map((n) => (
<option key={n} value={n}> <option key={n} value={n}>
@@ -603,7 +603,7 @@ export default function Feed({
}) })
} }
title={t("feed.source.tip")} title={t("feed.source.tip")}
className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent" className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-hidden focus:border-accent"
> >
<option value="organic">{t("feed.source.organic")}</option> <option value="organic">{t("feed.source.organic")}</option>
<option value="all">{t("feed.source.all")}</option> <option value="all">{t("feed.source.all")}</option>
@@ -637,7 +637,7 @@ export default function Feed({
}); });
}} }}
aria-label={t("feed.sortLabel")} aria-label={t("feed.sortLabel")}
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent" className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-hidden focus:border-accent"
> >
{/* Relevance only ranks meaningfully when there's a search term — offer it then. */} {/* Relevance only ranks meaningfully when there's a search term — offer it then. */}
{(filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys).map((k) => ( {(filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys).map((k) => (
+5 -5
View File
@@ -327,7 +327,7 @@ export default function GlassTuner() {
onClick={() => setTab(t)} onClick={() => setTab(t)}
className={`flex-1 rounded-lg px-2 py-1 text-xs capitalize ${ className={`flex-1 rounded-lg px-2 py-1 text-xs capitalize ${
tab === t tab === t
? "bg-accent text-[color:var(--accent-fg)] font-semibold" ? "bg-accent text-(--accent-fg) font-semibold"
: "glass-card text-muted" : "glass-card text-muted"
}`} }`}
> >
@@ -361,7 +361,7 @@ export default function GlassTuner() {
type="color" type="color"
value={swatches[p.k] || "#000000"} value={swatches[p.k] || "#000000"}
onChange={(e) => setColor(p.k, e.target.value)} onChange={(e) => setColor(p.k, e.target.value)}
className="w-8 h-6 rounded border border-border bg-transparent cursor-pointer" className="w-8 h-6 rounded-sm border border-border bg-transparent cursor-pointer"
/> />
<span className="flex-1">{p.label}</span> <span className="flex-1">{p.label}</span>
<code className="text-[10px] text-muted">{swatches[p.k]}</code> <code className="text-[10px] text-muted">{swatches[p.k]}</code>
@@ -385,7 +385,7 @@ export default function GlassTuner() {
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<button <button
onClick={copy} onClick={copy}
className="flex-1 rounded-lg px-2 py-1.5 text-xs font-semibold bg-accent text-[color:var(--accent-fg)]" className="flex-1 rounded-lg px-2 py-1.5 text-xs font-semibold bg-accent text-(--accent-fg)"
> >
{copied ? "Copied ✓" : "Copy CSS"} {copied ? "Copied ✓" : "Copy CSS"}
</button> </button>
@@ -396,7 +396,7 @@ export default function GlassTuner() {
Reset Reset
</button> </button>
</div> </div>
<pre className="text-[9.5px] leading-relaxed bg-[color:var(--bg)] border border-border/60 rounded-lg p-2 overflow-x-auto text-muted whitespace-pre"> <pre className="text-[9.5px] leading-relaxed bg-(--bg) border border-border/60 rounded-lg p-2 overflow-x-auto text-muted whitespace-pre">
{cssOut} {cssOut}
</pre> </pre>
</div> </div>
@@ -419,7 +419,7 @@ function Ctl({ s, value, onChange }: { s: Slider; value: number; onChange: (v: n
step={s.step} step={s.step}
value={value} value={value}
onChange={(e) => onChange(Number(e.target.value))} onChange={(e) => onChange(Number(e.target.value))}
className="w-16 text-right text-[11px] tabular-nums rounded bg-card border border-border px-1.5 py-0.5 text-accent" className="w-16 text-right text-[11px] tabular-nums rounded-sm bg-card border border-border px-1.5 py-0.5 text-accent"
/> />
<input <input
type="range" type="range"
+1 -1
View File
@@ -7,7 +7,7 @@ import { api, HttpError } from "../lib/api";
import * as e2ee from "../lib/e2ee"; import * as e2ee from "../lib/e2ee";
const inputCls = const inputCls =
"w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"; "w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-hidden focus:border-accent";
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device. // Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
// Self-contained — on success it updates the shared e2ee unlock state, so any observing view // Self-contained — on success it updates the shared e2ee unlock state, so any observing view
+2 -2
View File
@@ -56,7 +56,7 @@ export default function LanguageSwitcher({
{rail ? ( {rail ? (
/* Current-language badge (HU/EN) so the active language reads at a glance without /* Current-language badge (HU/EN) so the active language reads at a glance without
opening the menu. The ring matches the page background to detach it from the icon. */ opening the menu. The ring matches the page background to detach it from the icon. */
<span className="absolute -bottom-1 -right-1 text-[8px] font-bold leading-none px-1 py-[1px] rounded bg-accent text-accent-fg ring-2 ring-bg"> <span className="absolute -bottom-1 -right-1 text-[8px] font-bold leading-none px-1 py-px rounded-sm bg-accent text-accent-fg ring-2 ring-bg">
{current.code.toUpperCase()} {current.code.toUpperCase()}
</span> </span>
) : ( ) : (
@@ -78,7 +78,7 @@ export default function LanguageSwitcher({
setOpen(false); setOpen(false);
}} }}
label={l.label} label={l.label}
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted outline-none transition hover:text-fg hover:bg-card data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg" className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted outline-hidden transition hover:text-fg hover:bg-card data-active:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-active:text-fg"
> >
<span>{l.label}</span> <span>{l.label}</span>
{l.code === value && <Check className="w-4 h-4 text-accent" />} {l.code === value && <Check className="w-4 h-4 text-accent" />}
+1 -1
View File
@@ -224,7 +224,7 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
value={filter} value={filter}
onChange={(e) => setFilter(e.target.value)} onChange={(e) => setFilter(e.target.value)}
placeholder={t("messages.searchPeople")} placeholder={t("messages.searchPeople")}
className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-hidden focus:border-accent"
/> />
{q.isError && !q.data ? ( {q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}> <StateMessage tone="error" onRetry={() => q.refetch()}>
+1 -1
View File
@@ -56,7 +56,7 @@ export default function Modal({
return createPortal( return createPortal(
<div <div
className="fixed inset-0 z-overlay flex items-center justify-center p-4 sm:p-6 bg-black/70 backdrop-blur-sm" className="fixed inset-0 z-overlay flex items-center justify-center p-4 sm:p-6 bg-black/70 backdrop-blur-xs"
onMouseDown={(e) => { onMouseDown={(e) => {
pressedOnBackdrop.current = e.target === e.currentTarget; pressedOnBackdrop.current = e.target === e.currentTarget;
}} }}
@@ -345,7 +345,7 @@ function ClientActivityRow({
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} /> <Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{n.title && <div className="text-sm font-semibold">{n.title}</div>} {n.title && <div className="text-sm font-semibold">{n.title}</div>}
<div className="text-sm break-words"> <div className="text-sm wrap-break-word">
{n.message} {n.message}
{n.repeat > 1 && ( {n.repeat > 1 && (
<span className="ml-1.5 align-middle text-[11px] font-semibold text-muted tabular-nums"> <span className="ml-1.5 align-middle text-[11px] font-semibold text-muted tabular-nums">
+1 -1
View File
@@ -107,7 +107,7 @@ export default function PageScroller({
<main <main
ref={setRef} ref={setRef}
style={fade.style} style={fade.style}
className="flex-1 min-w-0 overflow-y-auto [scrollbar-gutter:stable]" className="flex-1 min-w-0 overflow-y-auto scrollbar-gutter-stable"
> >
{children} {children}
</main> </main>
+1 -1
View File
@@ -55,7 +55,7 @@ export default function PageShell({
return ( return (
<PageToolbarSlot.Provider value={toolbarSlot}> <PageToolbarSlot.Provider value={toolbarSlot}>
{header} {header}
<div className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-[var(--hdr-h)]" : ""}`}> <div className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}>
{banners} {banners}
<div ref={setToolbarSlot} className="shrink-0" /> <div ref={setToolbarSlot} className="shrink-0" />
<PageScroller scrollKey={scrollKey} fadeTop={fadeTop}> <PageScroller scrollKey={scrollKey} fadeTop={fadeTop}>
+2 -2
View File
@@ -87,7 +87,7 @@ export default function Pager({
onBlur={commitPageInput} onBlur={commitPageInput}
onKeyDown={(e) => e.key === "Enter" && commitPageInput()} onKeyDown={(e) => e.key === "Enter" && commitPageInput()}
aria-label={t("datatable.pager.pageLabel")} aria-label={t("datatable.pager.pageLabel")}
className="w-14 bg-card border border-border rounded-md px-1.5 py-1 text-xs text-center tabular-nums outline-none focus:border-accent" className="w-14 bg-card border border-border rounded-md px-1.5 py-1 text-xs text-center tabular-nums outline-hidden focus:border-accent"
/> />
{t("datatable.pager.ofTotal", { total: totalPages })} {t("datatable.pager.ofTotal", { total: totalPages })}
</span> </span>
@@ -110,7 +110,7 @@ export default function Pager({
onSizeChange(Number(e.target.value)); onSizeChange(Number(e.target.value));
scrollListToTop(); scrollListToTop();
}} }}
className="bg-card border border-border rounded-md px-1.5 py-1 text-xs outline-none focus:border-accent" className="bg-card border border-border rounded-md px-1.5 py-1 text-xs outline-hidden focus:border-accent"
> >
{pageSizeOptions.map((n) => ( {pageSizeOptions.map((n) => (
<option key={n} value={n}> <option key={n} value={n}>
+9 -9
View File
@@ -726,7 +726,7 @@ export default function PlayerModal({
window.addEventListener("focus", rearm); window.addEventListener("focus", rearm);
stage?.addEventListener("mouseleave", rearm); stage?.addEventListener("mouseleave", rearm);
return () => { return () => {
window.removeEventListener("blur", onBlur); window.removeEventListener("blur-sm", onBlur);
window.removeEventListener("focus", rearm); window.removeEventListener("focus", rearm);
stage?.removeEventListener("mouseleave", rearm); stage?.removeEventListener("mouseleave", rearm);
}; };
@@ -889,7 +889,7 @@ export default function PlayerModal({
<div <div
ref={dialogRef} ref={dialogRef}
data-testid="player-modal" data-testid="player-modal"
className="fixed inset-0 z-overlay flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-sm" className="fixed inset-0 z-overlay flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-xs"
onClick={onClose} onClick={onClose}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@@ -908,7 +908,7 @@ export default function PlayerModal({
disabled={index === 0} disabled={index === 0}
aria-label={t("player.previous")} aria-label={t("player.previous")}
title={`${t("player.previous")} · Shift+←`} title={`${t("player.previous")} · Shift+←`}
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-sm transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid" 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" /> <ChevronLeft className="h-8 w-8" />
</button> </button>
@@ -916,7 +916,7 @@ export default function PlayerModal({
<div <div
ref={cardRef} ref={cardRef}
tabIndex={-1} tabIndex={-1}
className="glass-card relative flex-1 min-w-0 max-h-full overflow-y-auto rounded-2xl shadow-2xl outline-none" className="glass-card relative flex-1 min-w-0 max-h-full overflow-y-auto rounded-2xl shadow-2xl outline-hidden"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div <div
@@ -1056,7 +1056,7 @@ export default function PlayerModal({
)} )}
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */} {/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
{playerError == null && nativeControls && ( {playerError == null && nativeControls && (
<div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-white/90 shadow-lg backdrop-blur-sm"> <div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-white/90 shadow-lg backdrop-blur-xs">
<Settings className="h-3.5 w-3.5" /> <Settings className="h-3.5 w-3.5" />
{t("player.nativeControls")} {t("player.nativeControls")}
</div> </div>
@@ -1150,7 +1150,7 @@ export default function PlayerModal({
<div <div
ref={descFade.ref} ref={descFade.ref}
style={descFade.style} style={descFade.style}
className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto no-scrollbar leading-relaxed" className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
> >
{renderDescription(detail.data.description, { {renderDescription(detail.data.description, {
currentId: currentVideoId, currentId: currentVideoId,
@@ -1250,7 +1250,7 @@ export default function PlayerModal({
<span>· {formatDuration(active.duration_seconds)}</span> <span>· {formatDuration(active.duration_seconds)}</span>
)} )}
{active.live_status === "was_live" && ( {active.live_status === "was_live" && (
<span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded"> <span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
{t("player.stream")} {t("player.stream")}
</span> </span>
)} )}
@@ -1314,7 +1314,7 @@ export default function PlayerModal({
className={ className={
"shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " + "shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
(watched (watched
? "bg-accent text-accent-fg shadow-sm hover:opacity-90" ? "bg-accent text-accent-fg shadow-xs hover:opacity-90"
: "text-muted hover:text-fg hover:bg-surface border border-border") : "text-muted hover:text-fg hover:bg-surface border border-border")
} }
> >
@@ -1334,7 +1334,7 @@ export default function PlayerModal({
disabled={index >= queue!.length - 1} disabled={index >= queue!.length - 1}
aria-label={t("player.next")} aria-label={t("player.next")}
title={`${t("player.next")} · Shift+→`} title={`${t("player.next")} · Shift+→`}
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-sm transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid" 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" /> <ChevronRight className="h-8 w-8" />
</button> </button>
+1 -1
View File
@@ -590,7 +590,7 @@ export default function Playlists() {
if (e.key === "Escape") setRenaming(false); if (e.key === "Escape") setRenaming(false);
}} }}
aria-label={t("playlists.rename")} aria-label={t("playlists.rename")}
className="text-lg font-semibold bg-card border border-border rounded-md px-2 py-0.5 outline-none focus:border-accent" className="text-lg font-semibold bg-card border border-border rounded-md px-2 py-0.5 outline-hidden focus:border-accent"
/> />
) : ( ) : (
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
+2 -2
View File
@@ -154,7 +154,7 @@ export default function PlaylistsRail({
value={plSort.key} value={plSort.key}
onChange={(e) => setPlSort({ ...plSort, key: e.target.value as PlSort["key"] })} onChange={(e) => setPlSort({ ...plSort, key: e.target.value as PlSort["key"] })}
aria-label={t("playlists.sortLabel")} aria-label={t("playlists.sortLabel")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-1.5 py-1 text-[11px] outline-none focus:border-accent" className="flex-1 min-w-0 bg-card border border-border rounded-md px-1.5 py-1 text-[11px] outline-hidden focus:border-accent"
> >
<option value="custom">{t("playlists.railSortCustom")}</option> <option value="custom">{t("playlists.railSortCustom")}</option>
<option value="name">{t("playlists.railSortName")}</option> <option value="name">{t("playlists.railSortName")}</option>
@@ -200,7 +200,7 @@ export default function PlaylistsRail({
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
placeholder={t("playlists.newPlaylist")} placeholder={t("playlists.newPlaylist")}
disabled={!canWrite} disabled={!canWrite}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent disabled:opacity-50" className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-hidden focus:border-accent disabled:opacity-50"
/> />
</form> </form>
</div> </div>
+15 -15
View File
@@ -470,7 +470,7 @@ function PlexPosterCard({
onOpen(); onOpen();
} }
}} }}
className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border cursor-pointer" className="relative aspect-2/3 rounded-xl overflow-hidden bg-card border border-border cursor-pointer"
> >
<img <img
src={card.thumb} src={card.thumb}
@@ -522,18 +522,18 @@ function PlexPosterCard({
)} )}
{/* Watched badge when not hovering (the toggle replaces it on hover). */} {/* Watched badge when not hovering (the toggle replaces it on hover). */}
{card.status === "watched" && ( {card.status === "watched" && (
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded group-hover:opacity-0"> <span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
{t("plex.watched")} {t("plex.watched")}
</span> </span>
)} )}
{card.type === "show" && card.season_count != null && ( {card.type === "show" && card.season_count != null && (
<span className="absolute bottom-1.5 left-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded"> <span className="absolute bottom-1.5 left-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
{t("plex.seasons", { count: card.season_count })} {t("plex.seasons", { count: card.season_count })}
</span> </span>
)} )}
{/* Aggregate in-progress badge for a partially-watched show. */} {/* Aggregate in-progress badge for a partially-watched show. */}
{card.type === "show" && card.status === "in_progress" && ( {card.type === "show" && card.status === "in_progress" && (
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/75 text-accent px-1.5 py-0.5 rounded group-hover:opacity-0"> <span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/75 text-accent px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
{t("plex.inProgress")} {t("plex.inProgress")}
</span> </span>
)} )}
@@ -545,7 +545,7 @@ function PlexPosterCard({
)} )}
{/* Type tag — tell a movie card from a show card at a glance (unified feed). */} {/* Type tag — tell a movie card from a show card at a glance (unified feed). */}
<span <span
className="absolute bottom-1.5 right-1.5 rounded bg-black/65 p-0.5 text-white/90" className="absolute bottom-1.5 right-1.5 rounded-sm bg-black/65 p-0.5 text-white/90"
title={t( title={t(
card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie", card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
)} )}
@@ -670,7 +670,7 @@ function ActionBtn({
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:opacity-50 ${ className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:opacity-50 ${
primary ? "bg-accent text-accent-fg shadow-sm hover:opacity-90" : "glass-card glass-hover" primary ? "bg-accent text-accent-fg shadow-xs hover:opacity-90" : "glass-card glass-hover"
}`} }`}
> >
<Icon className="w-4 h-4 shrink-0" /> <Icon className="w-4 h-4 shrink-0" />
@@ -707,7 +707,7 @@ function SeasonCard({
onOpen(); onOpen();
} }
}} }}
className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border cursor-pointer" className="relative aspect-2/3 rounded-xl overflow-hidden bg-card border border-border cursor-pointer"
> >
<img <img
src={se.thumb} src={se.thumb}
@@ -750,16 +750,16 @@ function SeasonCard({
</button> </button>
)} )}
{watched && ( {watched && (
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded group-hover:opacity-0"> <span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
{t("plex.watched")} {t("plex.watched")}
</span> </span>
)} )}
{se.status === "in_progress" && ( {se.status === "in_progress" && (
<span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/75 text-accent px-1.5 py-0.5 rounded group-hover:opacity-0"> <span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/75 text-accent px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
{t("plex.inProgress")} {t("plex.inProgress")}
</span> </span>
)} )}
<span className="absolute bottom-1.5 left-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded"> <span className="absolute bottom-1.5 left-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
{t("plex.series.episodeCount", { count: se.episode_count })} {t("plex.series.episodeCount", { count: se.episode_count })}
</span> </span>
</div> </div>
@@ -846,7 +846,7 @@ function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: s
onClick={() => onOpen(r.id)} onClick={() => onOpen(r.id)}
className="group w-[130px] shrink-0 text-left" className="group w-[130px] shrink-0 text-left"
> >
<div className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border"> <div className="relative aspect-2/3 rounded-xl overflow-hidden bg-card border border-border">
<img <img
src={r.thumb} src={r.thumb}
alt="" alt=""
@@ -854,7 +854,7 @@ function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: s
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/> />
{r.status === "watched" && ( {r.status === "watched" && (
<span className="absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded"> <span className="absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
{t("plex.watched")} {t("plex.watched")}
</span> </span>
)} )}
@@ -963,7 +963,7 @@ function EpisodeCard({
onClick={onAdd} onClick={onAdd}
title={t("plex.playlist.addEpisode")} title={t("plex.playlist.addEpisode")}
aria-label={t("plex.playlist.addEpisode")} aria-label={t("plex.playlist.addEpisode")}
className="shrink-0 rounded p-1 text-muted transition hover:text-accent" className="shrink-0 rounded-sm p-1 text-muted transition hover:text-accent"
> >
<ListPlus className="w-4 h-4" /> <ListPlus className="w-4 h-4" />
</button> </button>
@@ -1054,7 +1054,7 @@ function PlexShowView({
<img <img
src={show.thumb} src={show.thumb}
alt="" alt=""
className="w-full aspect-[2/3] object-cover rounded-xl border border-border" className="w-full aspect-2/3 object-cover rounded-xl border border-border"
/> />
{show.resume && ( {show.resume && (
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center"> <div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
@@ -1286,7 +1286,7 @@ function PlexSeasonView({
<img <img
src={se.thumb} src={se.thumb}
alt="" alt=""
className="w-full aspect-[2/3] object-cover rounded-xl border border-border" className="w-full aspect-2/3 object-cover rounded-xl border border-border"
/> />
{se.resume && ( {se.resume && (
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center"> <div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
@@ -131,7 +131,7 @@ export default function PlexCollectionEditor({
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createNew()} onKeyDown={(e) => e.key === "Enter" && createNew()}
placeholder={t("plex.collEditor.newPlaceholder")} placeholder={t("plex.collEditor.newPlaceholder")}
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm outline-none focus:border-accent" className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm outline-hidden focus:border-accent"
/> />
<button <button
onClick={createNew} onClick={createNew}
@@ -149,7 +149,7 @@ export default function PlexCollectionEditor({
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
placeholder={t("plex.collEditor.search")} placeholder={t("plex.collEditor.search")}
className="glass-card mb-2 w-full rounded-lg px-3 py-1.5 text-sm outline-none focus:border-accent" className="glass-card mb-2 w-full rounded-lg px-3 py-1.5 text-sm outline-hidden focus:border-accent"
/> />
)} )}
@@ -193,7 +193,7 @@ export default function PlexCollectionEditor({
<button <button
onClick={() => removeCollection(c.id, c.title)} onClick={() => removeCollection(c.id, c.title)}
disabled={!!busy} disabled={!!busy}
className="shrink-0 rounded px-1.5 py-0.5 text-xs text-muted hover:text-red-400 disabled:opacity-50" className="shrink-0 rounded-sm px-1.5 py-0.5 text-xs text-muted hover:text-red-400 disabled:opacity-50"
> >
{t("plex.collEditor.delete")} {t("plex.collEditor.delete")}
</button> </button>
@@ -226,7 +226,7 @@ export default function PlexCollectionEditor({
<button <button
onClick={() => markEditable(c.id)} onClick={() => markEditable(c.id)}
disabled={!!busy} disabled={!!busy}
className="glass-card glass-hover shrink-0 rounded px-2 py-0.5 text-xs disabled:opacity-50" className="glass-card glass-hover shrink-0 rounded-sm px-2 py-0.5 text-xs disabled:opacity-50"
> >
{t("plex.collEditor.makeEditable")} {t("plex.collEditor.makeEditable")}
</button> </button>
+3 -3
View File
@@ -145,7 +145,7 @@ export default function PlexInfo({
<img <img
src={detail.thumb} src={detail.thumb}
alt="" alt=""
className={`shrink-0 rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : "w-32 sm:w-44"} aspect-[2/3]`} className={`shrink-0 rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : "w-32 sm:w-44"} aspect-2/3`}
/> />
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}> <div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
@@ -412,14 +412,14 @@ export default function PlexInfo({
disabled={!onPlayItem} disabled={!onPlayItem}
className="group/col w-28 shrink-0 text-left" className="group/col w-28 shrink-0 text-left"
> >
<div className="relative aspect-[2/3] overflow-hidden rounded-lg border border-border bg-surface"> <div className="relative aspect-2/3 overflow-hidden rounded-lg border border-border bg-surface">
<img <img
src={m.thumb} src={m.thumb}
alt="" alt=""
className="h-full w-full object-cover transition group-hover/col:scale-105" className="h-full w-full object-cover transition group-hover/col:scale-105"
/> />
{m.status === "watched" && ( {m.status === "watched" && (
<span className="absolute right-1 top-1 rounded bg-black/70 px-1 text-[9px] text-white"> <span className="absolute right-1 top-1 rounded-sm bg-black/70 px-1 text-[9px] text-white">
✓ ✓
</span> </span>
)} )}
+11 -11
View File
@@ -1110,7 +1110,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* Top bar */} {/* Top bar */}
<div <div
className={`absolute top-0 inset-x-0 p-4 bg-gradient-to-b from-black/70 to-transparent transition-opacity ${ className={`absolute top-0 inset-x-0 p-4 bg-linear-to-b from-black/70 to-transparent transition-opacity ${
uiVisible ? "opacity-100" : "opacity-0" uiVisible ? "opacity-100" : "opacity-0"
}`} }`}
> >
@@ -1184,7 +1184,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* Bottom controls */} {/* Bottom controls */}
<div <div
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-8 bg-gradient-to-t from-black/80 to-transparent transition-opacity ${ className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-8 bg-linear-to-t from-black/80 to-transparent transition-opacity ${
uiVisible ? "opacity-100" : "opacity-0 pointer-events-none" uiVisible ? "opacity-100" : "opacity-0 pointer-events-none"
}`} }`}
> >
@@ -1221,7 +1221,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* Hover timestamp tooltip — follows the cursor along the bar. */} {/* Hover timestamp tooltip — follows the cursor along the bar. */}
{hover && duration > 0 && ( {hover && duration > 0 && (
<div <div
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded bg-black/90 px-1.5 py-0.5 text-[11px] tabular-nums text-white" className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-[11px] tabular-nums text-white"
style={{ left: `${hover.frac * 100}%` }} style={{ left: `${hover.frac * 100}%` }}
> >
{fmt(hover.t)} {fmt(hover.t)}
@@ -1311,13 +1311,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
/> />
{/* thumb */} {/* thumb */}
<div <div
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-accent shadow" className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-accent shadow-sm"
style={{ left: `${(muted ? 0 : volume) * 100}%` }} style={{ left: `${(muted ? 0 : volume) * 100}%` }}
/> />
{/* hover value tooltip (0–100), like the seek bar */} {/* hover value tooltip (0–100), like the seek bar */}
{volHover != null && ( {volHover != null && (
<div <div
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded bg-black/90 px-1.5 py-0.5 text-[11px] tabular-nums text-white" className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-[11px] tabular-nums text-white"
style={{ left: `${volHover * 100}%` }} style={{ left: `${volHover * 100}%` }}
> >
{Math.round(volHover * 100)} {Math.round(volHover * 100)}
@@ -1544,7 +1544,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
type="color" type="color"
value={prefs.subColor} value={prefs.subColor}
onChange={(e) => patchPrefs({ subColor: e.target.value })} onChange={(e) => patchPrefs({ subColor: e.target.value })}
className="h-6 w-10 cursor-pointer rounded bg-transparent" className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
/> />
</div> </div>
<div className="flex items-center justify-between px-1 py-1.5"> <div className="flex items-center justify-between px-1 py-1.5">
@@ -1570,7 +1570,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
type="color" type="color"
value={prefs.subBg} value={prefs.subBg}
onChange={(e) => patchPrefs({ subBg: e.target.value })} onChange={(e) => patchPrefs({ subBg: e.target.value })}
className="h-6 w-10 cursor-pointer rounded bg-transparent" className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
/> />
)} )}
</div> </div>
@@ -1593,7 +1593,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
type="color" type="color"
value={prefs.subShadowColor} value={prefs.subShadowColor}
onChange={(e) => patchPrefs({ subShadowColor: e.target.value })} onChange={(e) => patchPrefs({ subShadowColor: e.target.value })}
className="h-6 w-10 cursor-pointer rounded bg-transparent" className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
/> />
</div> </div>
)} )}
@@ -1629,7 +1629,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
type="color" type="color"
value={prefs.clockColor} value={prefs.clockColor}
onChange={(e) => patchPrefs({ clockColor: e.target.value })} onChange={(e) => patchPrefs({ clockColor: e.target.value })}
className="h-6 w-10 cursor-pointer rounded bg-transparent" className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
/> />
</div> </div>
<Toggle <Toggle
@@ -1728,7 +1728,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{keys.map((k) => ( {keys.map((k) => (
<kbd <kbd
key={k} key={k}
className="rounded bg-white/15 px-1.5 py-0.5 text-xs font-mono whitespace-nowrap" className="rounded-sm bg-white/15 px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
> >
{k} {k}
</kbd> </kbd>
@@ -1879,7 +1879,7 @@ function Ctrl({
{children} {children}
</button> </button>
<span <span
className={`pointer-events-none absolute bottom-full ${pos} mb-2 whitespace-nowrap rounded bg-black/90 px-2 py-1 text-[11px] text-white opacity-0 transition group-hover/ctrl:opacity-100`} className={`pointer-events-none absolute bottom-full ${pos} mb-2 whitespace-nowrap rounded-sm bg-black/90 px-2 py-1 text-[11px] text-white opacity-0 transition group-hover/ctrl:opacity-100`}
> >
{label} {label}
</span> </span>
+1 -1
View File
@@ -100,7 +100,7 @@ export default function PlexPlaylistAdd({
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createNew()} onKeyDown={(e) => e.key === "Enter" && createNew()}
placeholder={t("plex.playlistAdd.newPlaceholder")} placeholder={t("plex.playlistAdd.newPlaceholder")}
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm outline-none focus:border-accent" className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm outline-hidden focus:border-accent"
/> />
<button <button
onClick={createNew} onClick={createNew}
+12 -12
View File
@@ -116,7 +116,7 @@ type Ctx = {
function Poster({ src, className }: { src: string; className: string }) { function Poster({ src, className }: { src: string; className: string }) {
return ( return (
<div <div
className={`relative shrink-0 overflow-hidden rounded border border-border bg-surface ${className}`} className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${className}`}
> >
<img src={src} alt="" loading="lazy" className="h-full w-full object-cover" /> <img src={src} alt="" loading="lazy" className="h-full w-full object-cover" />
</div> </div>
@@ -161,11 +161,11 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
className="group flex min-w-0 flex-1 items-center gap-2.5 text-left" className="group flex min-w-0 flex-1 items-center gap-2.5 text-left"
> >
<div <div
className={`relative shrink-0 overflow-hidden rounded border border-border bg-surface ${dense ? "aspect-video w-14" : "aspect-video w-20"}`} className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${dense ? "aspect-video w-14" : "aspect-video w-20"}`}
> >
<img src={ep.thumb} alt="" loading="lazy" className="h-full w-full object-cover" /> <img src={ep.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
{ep.status === "watched" && ( {ep.status === "watched" && (
<span className="absolute right-0.5 top-0.5 rounded bg-black/70 px-1 text-[9px] text-white"> <span className="absolute right-0.5 top-0.5 rounded-sm bg-black/70 px-1 text-[9px] text-white">
✓ ✓
</span> </span>
)} )}
@@ -179,7 +179,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
<button <button
onClick={() => ctx.onRemove([ep.id])} onClick={() => ctx.onRemove([ep.id])}
disabled={ctx.busy} disabled={ctx.busy}
className="shrink-0 rounded p-1 text-muted hover:text-red-400 disabled:opacity-30" className="shrink-0 rounded-sm p-1 text-muted hover:text-red-400 disabled:opacity-30"
aria-label="remove" aria-label="remove"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@@ -210,7 +210,7 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
onClick={() => ctx.onPlay(item.id)} onClick={() => ctx.onPlay(item.id)}
className="group flex min-w-0 flex-1 items-center gap-3 text-left" className="group flex min-w-0 flex-1 items-center gap-3 text-left"
> >
<Poster src={item.thumb} className={dense ? "aspect-[2/3] w-10" : "aspect-[2/3] w-14"} /> <Poster src={item.thumb} className={dense ? "aspect-2/3 w-10" : "aspect-2/3 w-14"} />
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-medium">{item.title}</div> <div className="truncate text-sm font-medium">{item.title}</div>
<div className="text-xs text-muted">{item.year}</div> <div className="text-xs text-muted">{item.year}</div>
@@ -222,7 +222,7 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
<button <button
onClick={() => ctx.onRemove([item.id])} onClick={() => ctx.onRemove([item.id])}
disabled={ctx.busy} disabled={ctx.busy}
className="shrink-0 rounded p-1 text-muted hover:text-red-400 disabled:opacity-30" className="shrink-0 rounded-sm p-1 text-muted hover:text-red-400 disabled:opacity-30"
aria-label="remove" aria-label="remove"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@@ -256,7 +256,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
<DragHandle {...attributes} {...listeners} /> <DragHandle {...attributes} {...listeners} />
<button <button
onClick={() => ctx.toggleShow(block.showKey)} onClick={() => ctx.toggleShow(block.showKey)}
className="grid h-6 w-6 shrink-0 place-items-center rounded text-muted hover:text-fg" className="grid h-6 w-6 shrink-0 place-items-center rounded-sm text-muted hover:text-fg"
aria-expanded={open} aria-expanded={open}
> >
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />} {open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
@@ -269,7 +269,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
src={ep.thumb} src={ep.thumb}
alt="" alt=""
loading="lazy" loading="lazy"
className="aspect-[2/3] w-8 rounded border-2 border-surface object-cover" className="aspect-2/3 w-8 rounded-sm border-2 border-surface object-cover"
style={{ marginLeft: i ? -14 : 0 }} style={{ marginLeft: i ? -14 : 0 }}
/> />
))} ))}
@@ -285,7 +285,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
onClick={() => ctx.onRemove(block.items.map((i) => i.id))} onClick={() => ctx.onRemove(block.items.map((i) => i.id))}
disabled={ctx.busy} disabled={ctx.busy}
title={t("plex.playlist.removeShow")} title={t("plex.playlist.removeShow")}
className="shrink-0 rounded p-1 text-muted hover:text-red-400 disabled:opacity-30" className="shrink-0 rounded-sm p-1 text-muted hover:text-red-400 disabled:opacity-30"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
@@ -306,7 +306,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
{dense && ( {dense && (
<button <button
onClick={() => ctx.toggleSeason(sKey)} onClick={() => ctx.toggleSeason(sKey)}
className="grid h-5 w-5 shrink-0 place-items-center rounded text-muted hover:text-fg" className="grid h-5 w-5 shrink-0 place-items-center rounded-sm text-muted hover:text-fg"
aria-expanded={sOpen} aria-expanded={sOpen}
> >
{sOpen ? ( {sOpen ? (
@@ -323,7 +323,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
onClick={() => ctx.onRemove(s.items.map((i) => i.id))} onClick={() => ctx.onRemove(s.items.map((i) => i.id))}
disabled={ctx.busy} disabled={ctx.busy}
title={t("plex.playlist.removeSeason")} title={t("plex.playlist.removeSeason")}
className="ml-1 rounded p-0.5 text-muted hover:text-red-400 disabled:opacity-30" className="ml-1 rounded-sm p-0.5 text-muted hover:text-red-400 disabled:opacity-30"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
@@ -532,7 +532,7 @@ export default function PlexPlaylistView({
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && rename()} onKeyDown={(e) => e.key === "Enter" && rename()}
onBlur={rename} onBlur={rename}
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-1.5 text-lg font-bold outline-none focus:border-accent" className="glass-card min-w-0 flex-1 rounded-lg px-3 py-1.5 text-lg font-bold outline-hidden focus:border-accent"
/> />
) : ( ) : (
<h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1> <h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
+5 -5
View File
@@ -171,7 +171,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
onChange={(e) => setNewPlaylist(e.target.value)} onChange={(e) => setNewPlaylist(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createPlaylist()} onKeyDown={(e) => e.key === "Enter" && createPlaylist()}
placeholder={t("plex.playlist.newPlaceholder")} placeholder={t("plex.playlist.newPlaceholder")}
className="glass-card min-w-0 flex-1 rounded-lg px-2 py-1 text-xs outline-none focus:border-accent" className="glass-card min-w-0 flex-1 rounded-lg px-2 py-1 text-xs outline-hidden focus:border-accent"
/> />
<button <button
onClick={createPlaylist} onClick={createPlaylist}
@@ -190,7 +190,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
<button <button
key={p.id} key={p.id}
onClick={() => onOpenPlaylist(p.id)} onClick={() => onOpenPlaylist(p.id)}
className="glass-hover flex w-full items-center gap-2 rounded px-1.5 py-1 text-left text-sm" className="glass-hover flex w-full items-center gap-2 rounded-sm px-1.5 py-1 text-left text-sm"
> >
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" /> <ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="min-w-0 flex-1 truncate">{p.title}</span> <span className="min-w-0 flex-1 truncate">{p.title}</span>
@@ -298,7 +298,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
value={collSearch} value={collSearch}
onChange={(e) => setCollSearch(e.target.value)} onChange={(e) => setCollSearch(e.target.value)}
placeholder={t("plex.filter.collectionSearch")} placeholder={t("plex.filter.collectionSearch")}
className="mb-1.5 w-full rounded-lg border border-border bg-card px-2 py-1 text-sm focus:border-accent focus:outline-none" className="mb-1.5 w-full rounded-lg border border-border bg-card px-2 py-1 text-sm focus:border-accent focus:outline-hidden"
/> />
<div <div
ref={collFade.ref} ref={collFade.ref}
@@ -519,7 +519,7 @@ function Chip({
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) { function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
return ( return (
<span className="inline-flex items-center gap-1 rounded-full bg-accent/15 text-accent px-2 py-1 text-xs"> <span className="inline-flex items-center gap-1 rounded-full bg-accent/15 text-accent px-2 py-1 text-xs">
<span className="truncate max-w-[9rem]">{label}</span> <span className="truncate max-w-36">{label}</span>
<button onClick={onRemove} className="hover:opacity-70" aria-label="remove"> <button onClick={onRemove} className="hover:opacity-70" aria-label="remove">
<X className="w-3 h-3" /> <X className="w-3 h-3" />
</button> </button>
@@ -546,7 +546,7 @@ function NumberInput({
const n = e.target.value === "" ? null : Number(e.target.value); const n = e.target.value === "" ? null : Number(e.target.value);
onChange(n != null && Number.isFinite(n) ? n : null); onChange(n != null && Number.isFinite(n) ? n : null);
}} }}
className="w-20 rounded-lg border border-border bg-card px-2 py-1 text-sm tabular-nums focus:border-accent focus:outline-none" className="w-20 rounded-lg border border-border bg-card px-2 py-1 text-sm tabular-nums focus:border-accent focus:outline-hidden"
/> />
); );
} }
+2 -2
View File
@@ -129,14 +129,14 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
<button <button
onClick={() => startEdit(p)} onClick={() => startEdit(p)}
title={t("downloads.actions.rename")} title={t("downloads.actions.rename")}
className="p-1 rounded hover:bg-surface text-muted hover:text-fg" className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg"
> >
<Pencil className="w-3.5 h-3.5" /> <Pencil className="w-3.5 h-3.5" />
</button> </button>
<button <button
onClick={() => onDelete(p)} onClick={() => onDelete(p)}
title={t("downloads.profiles.delete")} title={t("downloads.profiles.delete")}
className="p-1 rounded hover:bg-surface text-muted hover:text-red-400" className="p-1 rounded-sm hover:bg-surface text-muted hover:text-red-400"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
+1 -1
View File
@@ -38,7 +38,7 @@ export default function ReleaseNotes({
<h3 className="text-base font-semibold">v{r.version}</h3> <h3 className="text-base font-semibold">v{r.version}</h3>
<span className="text-xs text-muted">{fmtDate(r.date)}</span> <span className="text-xs text-muted">{fmtDate(r.date)}</span>
{r.sha && ( {r.sha && (
<span className="text-[11px] font-mono text-muted bg-surface px-1.5 py-0.5 rounded"> <span className="text-[11px] font-mono text-muted bg-surface px-1.5 py-0.5 rounded-sm">
{r.sha} {r.sha}
</span> </span>
)} )}
+5 -5
View File
@@ -172,7 +172,7 @@ export default function SavedViewsWidget({
}} }}
placeholder={t("views.namePlaceholder")} placeholder={t("views.namePlaceholder")}
maxLength={80} maxLength={80}
className="flex-1 min-w-0 bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent" className="flex-1 min-w-0 bg-card border border-border rounded-lg px-2 py-1 text-xs outline-hidden focus:border-accent"
/> />
<button <button
onClick={() => void save()} onClick={() => void save()}
@@ -256,7 +256,7 @@ function ViewRow({
if (e.key === "Escape") onCancelRename(); if (e.key === "Escape") onCancelRename();
}} }}
maxLength={80} maxLength={80}
className="flex-1 min-w-0 bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent" className="flex-1 min-w-0 bg-card border border-border rounded-lg px-2 py-1 text-xs outline-hidden focus:border-accent"
/> />
<button <button
onClick={() => onRename(draft)} onClick={() => onRename(draft)}
@@ -316,21 +316,21 @@ function ViewRow({
<button <button
onClick={onShare} onClick={onShare}
title={t("views.share")} title={t("views.share")}
className="p-1 rounded text-muted opacity-0 group-hover:opacity-100 hover:text-fg transition" className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 hover:text-fg transition"
> >
<Share2 className="w-3.5 h-3.5" /> <Share2 className="w-3.5 h-3.5" />
</button> </button>
<button <button
onClick={onStartRename} onClick={onStartRename}
title={t("views.rename")} title={t("views.rename")}
className="p-1 rounded text-muted opacity-0 group-hover:opacity-100 hover:text-fg transition" className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 hover:text-fg transition"
> >
<Pencil className="w-3.5 h-3.5" /> <Pencil className="w-3.5 h-3.5" />
</button> </button>
<button <button
onClick={onDelete} onClick={onDelete}
title={t("views.delete")} title={t("views.delete")}
className="p-1 rounded text-muted opacity-0 group-hover:opacity-100 hover:text-red-400 transition" className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 hover:text-red-400 transition"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
+2 -2
View File
@@ -172,7 +172,7 @@ function JobRow({
if (e.key === "Enter") save(); if (e.key === "Enter") save();
if (e.key === "Escape") setEditing(false); if (e.key === "Escape") setEditing(false);
}} }}
className="w-16 bg-card border border-border rounded px-1.5 py-0.5 text-xs outline-none focus:border-accent" className="w-16 bg-card border border-border rounded-sm px-1.5 py-0.5 text-xs outline-hidden focus:border-accent"
/> />
<span className="text-muted text-xs">{t("scheduler.minutes")}</span> <span className="text-muted text-xs">{t("scheduler.minutes")}</span>
<button <button
@@ -281,7 +281,7 @@ function MaintenanceSettings({
onChange={(e) => setVal(e.target.value)} onChange={(e) => setVal(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && save()} onKeyDown={(e) => e.key === "Enter" && save()}
aria-label={t("scheduler.maintenance.batchLabel")} aria-label={t("scheduler.maintenance.batchLabel")}
className="w-28 bg-card border border-border rounded px-2 py-1 text-sm tabular-nums outline-none focus:border-accent" className="w-28 bg-card border border-border rounded-sm px-2 py-1 text-sm tabular-nums outline-hidden focus:border-accent"
/> />
<button <button
onClick={save} onClick={save}
+1 -1
View File
@@ -40,7 +40,7 @@ export default function SearchBar({
if (e.key === "Enter") fire(); if (e.key === "Enter") fire();
}} }}
placeholder={placeholder} placeholder={placeholder}
className="flex-1 min-w-0 bg-transparent outline-none text-sm placeholder:text-muted" className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
/> />
{value && ( {value && (
<button <button
+1 -1
View File
@@ -403,7 +403,7 @@ function SignInMethods({ me }: { me: Me }) {
}; };
const inputCls = const inputCls =
"w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"; "w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-hidden focus:border-accent";
// Offer Google only when this instance has Google OAuth configured (or the account is already // Offer Google only when this instance has Google OAuth configured (or the account is already
// linked — then we still show its "Connected" status even if the instance creds were removed). // linked — then we still show its "Connected" status even if the instance creds were removed).
+1 -1
View File
@@ -130,7 +130,7 @@ export default function SetupWizard() {
} }
const inputCls = const inputCls =
"w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"; "w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-hidden focus:border-accent";
return ( return (
<Centered> <Centered>
+6 -3
View File
@@ -135,7 +135,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
<input <input
readOnly readOnly
value={fullUrl} value={fullUrl}
className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-none" className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-hidden"
/> />
<button <button
onClick={copy} onClick={copy}
@@ -184,7 +184,10 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
<Eye className="w-3 h-3" /> {t("downloads.share.views", { n: link.view_count })} <Eye className="w-3 h-3" /> {t("downloads.share.views", { n: link.view_count })}
</span> </span>
{badges.map((b) => ( {badges.map((b) => (
<span key={b} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-surface"> <span
key={b}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-sm bg-surface"
>
{b === t("downloads.share.hasPassword") && <Lock className="w-3 h-3" />} {b === t("downloads.share.hasPassword") && <Lock className="w-3 h-3" />}
{b} {b}
</span> </span>
@@ -208,7 +211,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
placeholder={t( placeholder={t(
link.has_password ? "downloads.share.newPassword" : "downloads.share.setPassword", link.has_password ? "downloads.share.newPassword" : "downloads.share.setPassword",
)} )}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-hidden focus:border-accent"
/> />
<button <button
onClick={() => savePass.mutate(newPass.trim())} onClick={() => savePass.mutate(newPass.trim())}
+5 -5
View File
@@ -46,7 +46,7 @@ function TagChip({
data-tag={tag.name} data-tag={tag.name}
onClick={onClick} onClick={onClick}
title={t("sidebar.channelCount", { count })} title={t("sidebar.channelCount", { count })}
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${ className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-xs hover:shadow-sm active:translate-y-px transition ${
active active
? "bg-accent text-accent-fg border-accent" ? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-fg hover:border-accent" : "bg-card border-border text-fg hover:border-accent"
@@ -169,7 +169,7 @@ export default function Sidebar({
dateTo: undefined, dateTo: undefined,
}) })
} }
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm active:translate-y-px transition ${ className={`text-xs px-2.5 py-1 rounded-full border shadow-xs active:translate-y-px transition ${
activePreset activePreset
? "bg-accent text-accent-fg border-accent" ? "bg-accent text-accent-fg border-accent"
: "bg-card border-border hover:border-accent" : "bg-card border-border hover:border-accent"
@@ -185,7 +185,7 @@ export default function Sidebar({
setCustomDates(next); setCustomDates(next);
if (next) setFilters({ ...filters, maxAgeDays: undefined }); if (next) setFilters({ ...filters, maxAgeDays: undefined });
}} }}
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm active:translate-y-px transition ${ className={`text-xs px-2.5 py-1 rounded-full border shadow-xs active:translate-y-px transition ${
customDates || filters.dateFrom || filters.dateTo customDates || filters.dateFrom || filters.dateTo
? "bg-accent text-accent-fg border-accent" ? "bg-accent text-accent-fg border-accent"
: "bg-card border-border hover:border-accent" : "bg-card border-border hover:border-accent"
@@ -209,7 +209,7 @@ export default function Sidebar({
maxAgeDays: undefined, maxAgeDays: undefined,
}) })
} }
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-hidden focus:border-accent"
/> />
</label> </label>
<label className="flex items-center justify-between gap-2 text-xs"> <label className="flex items-center justify-between gap-2 text-xs">
@@ -224,7 +224,7 @@ export default function Sidebar({
maxAgeDays: undefined, maxAgeDays: undefined,
}) })
} }
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-hidden focus:border-accent"
/> />
</label> </label>
{(filters.dateFrom || filters.dateTo) && ( {(filters.dateFrom || filters.dateTo) && (
+3 -3
View File
@@ -51,7 +51,7 @@ function TagRow({
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
onBlur={commit} onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()} onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1.5 text-sm outline-none focus:border-accent" className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1.5 text-sm outline-hidden focus:border-accent"
/> />
<span <span
ref={pop.refs.setReference} ref={pop.refs.setReference}
@@ -160,7 +160,7 @@ export default function TagManager({
<div <div
ref={listFade.ref} ref={listFade.ref}
style={listFade.style} style={listFade.style}
className="flex flex-col gap-2 max-h-[22rem] overflow-y-auto no-scrollbar pr-1" className="flex flex-col gap-2 max-h-88 overflow-y-auto no-scrollbar pr-1"
> >
{userTags.map((tag) => ( {userTags.map((tag) => (
<TagRow <TagRow
@@ -201,7 +201,7 @@ export default function TagManager({
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
placeholder={t("tagManager.newPlaceholder")} placeholder={t("tagManager.newPlaceholder")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1.5 text-sm outline-none focus:border-accent" className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1.5 text-sm outline-hidden focus:border-accent"
/> />
<button <button
type="submit" type="submit"
+1 -1
View File
@@ -38,7 +38,7 @@ export default function Toaster() {
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} /> <Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>} {toast.title && <div className="text-sm font-semibold">{toast.title}</div>}
<div className="text-sm break-words"> <div className="text-sm wrap-break-word">
{toast.message} {toast.message}
{toast.repeat > 1 && ( {toast.repeat > 1 && (
<span className="ml-1.5 align-middle text-[11px] font-semibold text-muted tabular-nums"> <span className="ml-1.5 align-middle text-[11px] font-semibold text-muted tabular-nums">
+4 -4
View File
@@ -244,7 +244,7 @@ function Thumb({
<div className="w-full h-full" /> <div className="w-full h-full" />
)} )}
{video.duration_seconds != null ? ( {video.duration_seconds != null ? (
<span className="absolute bottom-1.5 right-1.5 bg-black/80 text-white text-[11px] font-medium px-1.5 py-0.5 rounded"> <span className="absolute bottom-1.5 right-1.5 bg-black/80 text-white text-[11px] font-medium px-1.5 py-0.5 rounded-sm">
{formatDuration(video.duration_seconds)} {formatDuration(video.duration_seconds)}
</span> </span>
) : video.live_status === "live" || video.live_status === "upcoming" ? ( ) : video.live_status === "live" || video.live_status === "upcoming" ? (
@@ -257,7 +257,7 @@ function Thumb({
</span> </span>
) : null} ) : null}
{video.live_status === "was_live" && ( {video.live_status === "was_live" && (
<span className="absolute top-1.5 left-1.5 bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded"> <span className="absolute top-1.5 left-1.5 bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
{t("card.stream")} {t("card.stream")}
</span> </span>
)} )}
@@ -288,7 +288,7 @@ function Thumb({
title={t("card.restartTitle")} title={t("card.restartTitle")}
aria-label={t("card.restart")} aria-label={t("card.restart")}
className={clsx( className={clsx(
"inline-flex items-center justify-center gap-1.5 font-medium bg-white/15 text-white backdrop-blur-sm hover:bg-white/25 transition", "inline-flex items-center justify-center gap-1.5 font-medium bg-white/15 text-white backdrop-blur-xs hover:bg-white/25 transition",
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm", small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm",
)} )}
> >
@@ -307,7 +307,7 @@ function Thumb({
)} )}
> >
<Play <Play
className={clsx("fill-current translate-x-[1px]", small ? "w-4 h-4" : "w-5 h-5")} className={clsx("fill-current translate-x-px", small ? "w-4 h-4" : "w-5 h-5")}
/> />
</button> </button>
)} )}
+5 -5
View File
@@ -44,7 +44,7 @@ type Frac = { x: number; y: number; w: number; h: number };
type Drag = { type: "seek" } | { type: "boundary"; bi: number }; type Drag = { type: "seek" } | { type: "boundary"; bi: number };
const numCls = const numCls =
"w-24 rounded-md bg-surface border border-border px-2 py-1 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-accent/40"; "w-24 rounded-md bg-surface border border-border px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40";
const MIN_SEG = 0.1; const MIN_SEG = 0.1;
export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClose: () => void }) { export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
@@ -348,7 +348,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
onPointerDown={(e) => onCropDown(e, "move")} onPointerDown={(e) => onCropDown(e, "move")}
> >
<div <div
className="absolute -right-1.5 -bottom-1.5 w-4 h-4 rounded-sm bg-accent cursor-nwse-resize" className="absolute -right-1.5 -bottom-1.5 w-4 h-4 rounded-xs bg-accent cursor-nwse-resize"
onPointerDown={(e) => onCropDown(e, "resize")} onPointerDown={(e) => onCropDown(e, "resize")}
/> />
</div> </div>
@@ -432,7 +432,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
toggleKeep(s.id); toggleKeep(s.id);
}} }}
className={clsx( className={clsx(
"absolute top-1 left-1 p-0.5 rounded bg-black/50 backdrop-blur-sm", "absolute top-1 left-1 p-0.5 rounded-sm bg-black/50 backdrop-blur-xs",
s.keep ? "text-accent" : "text-muted", s.keep ? "text-accent" : "text-muted",
)} )}
title={s.keep ? t("editor.drop") : t("editor.keep")} title={s.keep ? t("editor.drop") : t("editor.keep")}
@@ -449,7 +449,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
style={{ left: pct(b) }} style={{ left: pct(b) }}
> >
<div <div
className="w-1 h-full bg-white/90 cursor-ew-resize rounded" className="w-1 h-full bg-white/90 cursor-ew-resize rounded-sm"
onPointerDown={(e) => { onPointerDown={(e) => {
e.stopPropagation(); e.stopPropagation();
setDrag({ type: "boundary", bi }); setDrag({ type: "boundary", bi });
@@ -460,7 +460,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
e.stopPropagation(); e.stopPropagation();
deleteBoundary(bi); deleteBoundary(bi);
}} }}
className="absolute -top-0 -right-2 p-0.5 rounded-full bg-black/70 text-muted hover:text-red-400" className="absolute top-0 -right-2 p-0.5 rounded-full bg-black/70 text-muted hover:text-red-400"
title={t("editor.removeCut")} title={t("editor.removeCut")}
> >
<X className="w-3 h-3" /> <X className="w-3 h-3" />
+1 -1
View File
@@ -94,7 +94,7 @@ export default function ViewSwitcher<T extends string>({
setOpen(false); setOpen(false);
}} }}
label={o.label} label={o.label}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left outline-none transition data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] ${ className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left outline-hidden transition data-active:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] ${
on ? "text-accent" : "text-fg hover:bg-card" on ? "text-accent" : "text-fg hover:bg-card"
}`} }`}
> >
+1 -1
View File
@@ -190,7 +190,7 @@ export default function WatchPage() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && password && unlock()} onKeyDown={(e) => e.key === "Enter" && password && unlock()}
placeholder={T.passwordPlaceholder} placeholder={T.passwordPlaceholder}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-accent/40" className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm outline-hidden focus:ring-2 focus:ring-accent/40"
/> />
{error && <div className="text-sm text-red-400 mt-2">{error}</div>} {error && <div className="text-sm text-red-400 mt-2">{error}</div>}
<button <button
+4 -4
View File
@@ -90,7 +90,7 @@ export default function Welcome() {
href={REPO_URL} href={REPO_URL}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="group mt-8 flex flex-col items-center gap-x-4 gap-y-2 rounded-2xl border border-accent/25 bg-accent/[0.06] px-5 py-4 text-center sm:flex-row sm:text-left hover:border-accent/50 transition" className="group mt-8 flex flex-col items-center gap-x-4 gap-y-2 rounded-2xl border border-accent/25 bg-accent/6 px-5 py-4 text-center sm:flex-row sm:text-left hover:border-accent/50 transition"
> >
<span className="grid place-items-center w-10 h-10 rounded-xl bg-accent/15 text-accent shrink-0"> <span className="grid place-items-center w-10 h-10 rounded-xl bg-accent/15 text-accent shrink-0">
<Code2 className="w-5 h-5" /> <Code2 className="w-5 h-5" />
@@ -213,7 +213,7 @@ function Preview({
if (failed) { if (failed) {
return ( return (
<div className={`glass-card rounded-2xl overflow-hidden ${className ?? ""}`}> <div className={`glass-card rounded-2xl overflow-hidden ${className ?? ""}`}>
<div className="w-full h-full grid place-items-center bg-gradient-to-br from-accent/10 to-card text-muted text-sm p-6 text-center"> <div className="w-full h-full grid place-items-center bg-linear-to-br from-accent/10 to-card text-muted text-sm p-6 text-center">
{label} {label}
</div> </div>
</div> </div>
@@ -258,7 +258,7 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
}, [onClose]); }, [onClose]);
return ( return (
<div <div
className="fixed inset-0 z-popover grid place-items-center bg-black/80 backdrop-blur-sm p-4 sm:p-8" className="fixed inset-0 z-popover grid place-items-center bg-black/80 backdrop-blur-xs p-4 sm:p-8"
onClick={onClose} onClick={onClose}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@@ -431,7 +431,7 @@ function AuthCard() {
} }
const inputCls = const inputCls =
"w-full bg-card border border-border rounded-xl px-3 py-2.5 text-sm outline-none focus:border-accent"; "w-full bg-card border border-border rounded-xl px-3 py-2.5 text-sm outline-hidden focus:border-accent";
const titleKey = TITLE_KEY[mode]; const titleKey = TITLE_KEY[mode];
return ( return (
+3 -3
View File
@@ -81,12 +81,12 @@ export function auditColumns(
render: (r) => { render: (r) => {
const change = changeText(r); const change = changeText(r);
return ( return (
// break-words (wrap), NOT truncate: in the auto-layout table a `truncate` (nowrap) cell // wrap-break-word (wrap), NOT truncate: in the auto-layout table a `truncate` (nowrap) cell
// sizes to its longest row, so one long summary/change stretched the whole table past the // sizes to its longest row, so one long summary/change stretched the whole table past the
// card (the row rules bled into the page). Wrapping keeps every row inside the container. // card (the row rules bled into the page). Wrapping keeps every row inside the container.
<div className="min-w-0"> <div className="min-w-0">
{r.summary && <div className="break-words">{r.summary}</div>} {r.summary && <div className="wrap-break-word">{r.summary}</div>}
{change && <div className="text-[11px] text-muted break-words">{change}</div>} {change && <div className="text-[11px] text-muted wrap-break-word">{change}</div>}
</div> </div>
); );
}, },
+1 -1
View File
@@ -59,7 +59,7 @@ export function playlistColumns({
<button <button
onClick={() => onPlay(v)} onClick={() => onPlay(v)}
aria-label={`${t("card.play")} — ${v.title}`} aria-label={`${t("card.play")} — ${v.title}`}
className="relative w-[62px] h-[35px] rounded overflow-hidden bg-surface shrink-0 group" className="relative w-[62px] h-[35px] rounded-sm overflow-hidden bg-surface shrink-0 group"
> >
{v.thumbnail_url && ( {v.thumbnail_url && (
<img src={v.thumbnail_url} alt="" className="w-full h-full object-cover" /> <img src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
+4 -4
View File
@@ -148,7 +148,7 @@ function FilterFunnel<T>({
value={(value as string) ?? ""} value={(value as string) ?? ""}
onChange={(e) => onApply(col.key, e.target.value)} onChange={(e) => onApply(col.key, e.target.value)}
placeholder={col.header} placeholder={col.header}
className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-hidden focus:border-accent"
/> />
)} )}
{f.kind === "select" && ( {f.kind === "select" && (
@@ -158,7 +158,7 @@ function FilterFunnel<T>({
checked={!filterOn} checked={!filterOn}
onSelect={() => onApply(col.key, "")} onSelect={() => onApply(col.key, "")}
label={t("datatable.all")} label={t("datatable.all")}
className={`text-left text-xs px-2 py-1.5 rounded-md outline-none transition data-[active]:ring-1 data-[active]:ring-accent ${ className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition data-active:ring-1 data-active:ring-accent ${
!filterOn ? "bg-accent text-accent-fg" : "text-muted hover:bg-card" !filterOn ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
}`} }`}
> >
@@ -171,7 +171,7 @@ function FilterFunnel<T>({
checked={value === o.value} checked={value === o.value}
onSelect={() => onApply(col.key, o.value)} onSelect={() => onApply(col.key, o.value)}
label={o.label} label={o.label}
className={`text-left text-xs px-2 py-1.5 rounded-md outline-none transition data-[active]:ring-1 data-[active]:ring-accent ${ className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition data-active:ring-1 data-active:ring-accent ${
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card" value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
}`} }`}
> >
@@ -197,7 +197,7 @@ function FilterFunnel<T>({
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value]) onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value])
} }
label={o.label} label={o.label}
className={`flex w-full items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-none transition data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] ${ className={`flex w-full items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition data-active:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] ${
on ? "text-fg" : "text-muted hover:bg-card" on ? "text-fg" : "text-muted hover:bg-card"
}`} }`}
> >
+1 -1
View File
@@ -10,7 +10,7 @@ import Tooltip from "../Tooltip";
* `bg-card rounded-xl focus:border-accent` style; unifying the two is a design call left to the * `bg-card rounded-xl focus:border-accent` style; unifying the two is a design call left to the
* glass-consistency epic, not this DRY extract. */ * glass-consistency epic, not this DRY extract. */
export const inputCls = export const inputCls =
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40"; "w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40";
/** The standard text button style (used verbatim by ShareDialog + VideoEditor). */ /** The standard text button style (used verbatim by ShareDialog + VideoEditor). */
export const btnCls = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50"; export const btnCls = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50";
+48 -3
View File
@@ -1,6 +1,51 @@
@tailwind base; @import "tailwindcss";
@tailwind components;
@tailwind utilities; /* `inline` is load-bearing: the color vars (--bg/--accent/… below) are redefined at
runtime per html[data-scheme]/[data-theme], so utilities must inline the value and
resolve it at the USE site, not reference a once-computed --color-* token. This also
makes the v4 opacity modifier (bg-accent/40 → color-mix(… var(--accent) 40% …)) pick
up the active scheme's color. Same pattern as shadcn's v4 theme setup. */
@theme inline {
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-card: var(--card);
--color-border: var(--border);
--color-fg: var(--fg);
--color-muted: var(--muted);
--color-accent: var(--accent);
--color-accent-fg: var(--accent-fg);
--font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
--z-index-base: 10;
--z-index-menu: 20;
--z-index-chrome: 30;
--z-index-paneltab: 34;
--z-index-panel: 35;
--z-index-rail: 40;
--z-index-overlay: 50;
--z-index-popover: 60;
--z-index-tooltip: 100;
--z-index-tuner: 9999;
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
:root { :root {
--font-scale: 1.06; --font-scale: 1.06;
-47
View File
@@ -1,47 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
bg: "var(--bg)",
surface: "var(--surface)",
card: "var(--card)",
border: "var(--border)",
fg: "var(--fg)",
muted: "var(--muted)",
accent: "var(--accent)",
"accent-fg": "var(--accent-fg)",
},
fontFamily: {
sans: ["Inter", "system-ui", "-apple-system", "Segoe UI", "Roboto", "sans-serif"],
},
// Named z-index layers — the single source of truth for stacking order. Values are
// deliberately UNCHANGED from the magic numbers they replaced (this was a rename, not a
// renumber), so the visual layering is identical; the names just make the stack legible and
// give future changes one place to edit. Low → high:
// base(10) in-content decorations: sticky table head, card badges, in-flow dropdowns
// menu(20) menus/overlays sitting just over content
// chrome(30) floating chrome: the header band, back-to-top, content filter/sort menus
// paneltab / panel (34/35) the collapsed tab vs the expanded side panel (aside over tab)
// rail(40) full-height rails + docked bars: nav rail, chat dock, floating save bar
// overlay(50) full-screen modals/players, toaster, onboarding, popovers over content
// popover(60) a popover that must sit over an overlay (player scrub, tag menu) + the login gate
// tooltip(100) tooltips, above everything interactive
// tuner(9999) the dev/lab glass tuner, deliberately on top of all
zIndex: {
base: "10",
menu: "20",
chrome: "30",
paneltab: "34",
panel: "35",
rail: "40",
overlay: "50",
popover: "60",
tooltip: "100",
tuner: "9999",
},
},
},
plugins: [],
};