Merge r10-tailwind-v4: Tailwind v4 migration + popover highlight fixes

R10 S0 foundation: migrate Tailwind v3.4→v4 (roots-fixes the ~99 opacity-modifier
no-op sites via v4's color-mix opacity), drop 2 dead keyframes, and centralize +
strengthen the popover active-row highlight (single hover-follows highlight that
doesn't steal the create-input's caret). Gate green; browser-verified across 6
schemes × 2 themes. A user /code-review high surfaced 4 minor findings (the
centralized highlight collides with consumers' own selected states) — deliberately
deferred to R9, where the selected-vs-highlighted separation is solved generically.
This commit is contained in:
2026-08-08 02:53:35 +02:00
60 changed files with 884 additions and 841 deletions
+603 -590
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -37,12 +37,12 @@
"devDependencies": {
"@eslint/js": "9.39.1",
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/react": "^16.3.2",
"@types/node": "^26.1.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react-hooks": "5.2.0",
@@ -52,7 +52,7 @@
"knip": "6.27.0",
"postcss": "^8.4.39",
"prettier": "3.6.2",
"tailwindcss": "^3.4.6",
"tailwindcss": "^4.3.3",
"typescript": "^5.5.3",
"typescript-eslint": "8.46.0",
"vite": "^5.3.4",
+1 -2
View File
@@ -1,6 +1,5 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
"@tailwindcss/postcss": {},
},
};
+3 -3
View File
@@ -45,19 +45,19 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
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}
</span>
<PopoverSurface
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. */}
{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>
</>
@@ -34,7 +34,7 @@ export default function ActiveFilterChips({
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"
>
<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" />
</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
// guarded without touching focus; `aria-busy` on the list conveys the pending state.
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"
>
<span
className={`grid place-items-center w-4 h-4 rounded border ${
@@ -156,7 +156,7 @@ export default function AddToPlaylist({
}
}}
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>
+2 -2
View File
@@ -349,7 +349,7 @@ function AdminInvites() {
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
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
type="submit"
@@ -468,7 +468,7 @@ function AdminDemo() {
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
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
type="submit"
+61 -15
View File
@@ -109,6 +109,12 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
const elementsRef = useRef<Array<HTMLElement | null>>([]);
const labelsRef = useRef<Array<string | null>>([]);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
// Pointer-hover highlight, kept SEPARATE from floating-ui's activeIndex on purpose: useListNavigation
// syncs DOM focus to activeIndex, so setting activeIndex on hover would blur a menu's free-text input
// (the create-tag field) mid-typing. hoverIndex drives only the VISUAL highlight; DOM focus never
// follows it. The highlighted row = hoverIndex ?? activeIndex, so there is exactly one highlight that
// tracks the pointer when hovering and the keyboard otherwise.
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const { refs, floatingStyles, context, middlewareData, isPositioned } = useFloating({
nodeId,
@@ -137,7 +143,10 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
// A stale active item would highlight (and steal the next open's initial focus) the second time a
// menu opens; clear it whenever the surface closes.
useEffect(() => {
if (!opts.open) setActiveIndex(null);
if (!opts.open) {
setActiveIndex(null);
setHoverIndex(null);
}
}, [opts.open]);
const click = useClick(context, { enabled: !isHover });
@@ -159,7 +168,12 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
enabled: isNav,
listRef: elementsRef,
activeIndex,
onNavigate: setActiveIndex,
// Keyboard (and pointer, when floating-ui owns it) navigation takes the visual highlight back from
// a stale pointer-hover position.
onNavigate: (i) => {
setActiveIndex(i);
setHoverIndex(null);
},
selectedIndex: opts.selectedIndex ?? null,
loop: true,
// Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens,
@@ -174,7 +188,10 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput,
listRef: labelsRef,
activeIndex,
onMatch: setActiveIndex,
onMatch: (i) => {
setActiveIndex(i);
setHoverIndex(null);
},
});
const interactions = useInteractions([click, hover, focus, dismiss, role, listNav, typeahead]);
@@ -204,6 +221,8 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
elementsRef,
labelsRef,
activeIndex,
highlightIndex: hoverIndex ?? activeIndex,
setHoverIndex,
...interactions,
};
}
@@ -212,6 +231,8 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
// separately owns the elementsRef/labelsRef registration; this context is the active-state channel.
interface PopoverListContextValue {
activeIndex: number | null;
highlightIndex: number | null;
setHoverIndex: (index: number | null) => void;
getItemProps: AnchoredPopover["getItemProps"];
}
const PopoverListContext = createContext<PopoverListContextValue | null>(null);
@@ -223,13 +244,18 @@ type ItemRole = "menuitem" | "menuitemradio" | "menuitemcheckbox" | "option";
* one exclusive choice, `menuitemcheckbox`+`checked` when several can be on at once,
* `option`+`selected` for a listbox choice, plain `menuitem` for an action.
*
* The active (roving) item carries `data-active` — style the highlight off THAT, not `:focus-visible`:
* The active (roving) item carries `data-active`, and the highlight is applied HERE for every item
* (see ACTIVE_HIGHLIGHT) — consumers don't add it. It's driven off `data-active`, not `:focus-visible`:
* a click-opened menu focuses the first item programmatically, which does NOT reliably trip the
* focus-visible heuristic, so a `:focus-visible` highlight can stay invisible until the first arrow
* 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
* arbitrary value like the app's own glass idiom:
* `data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg`. */
* key. (Since the Tailwind v4 migration `bg-accent/NN` works — v4 does the opacity modifier via
* color-mix — so the recipe no longer needs a hand-written color-mix arbitrary value.) */
// Applied to EVERY PopoverItem so the roving-focus row is clearly visible across all schemes: an
// accent fill plus a 1px inset accent ring for a defined edge. (The previous per-consumer 24% fill
// alone read as barely-there — a real UAT finding, 2026-08-08.)
const ACTIVE_HIGHLIGHT =
"data-active:bg-accent/35 data-active:text-fg data-active:ring-1 data-active:ring-inset data-active:ring-accent/60";
export function PopoverItem({
children,
onSelect,
@@ -254,7 +280,10 @@ export function PopoverItem({
const ctx = useContext(PopoverListContext);
const textLabel = label ?? (typeof children === "string" ? children : undefined);
const { ref, index } = useListItem({ label: textLabel });
const active = ctx?.activeIndex === index;
// Roving tabindex / DOM focus follow the KEYBOARD position (activeIndex); the visible highlight
// follows the pointer when hovering, else the keyboard (highlightIndex = hoverIndex ?? activeIndex).
const focusActive = ctx?.activeIndex === index;
const visualActive = ctx?.highlightIndex === index;
const ariaProps =
role === "menuitemradio" || role === "menuitemcheckbox"
@@ -271,8 +300,16 @@ export function PopoverItem({
rest.onClick?.(e);
if (!disabled) onSelect?.();
};
// Move the single visual highlight to the hovered item via hoverIndex — NOT floating-ui's activeIndex,
// which would drag DOM focus onto the item and blur a menu's free-text input (the create-tag field)
// mid-typing. This is what makes one highlight follow the pointer while the input keeps its caret.
// (The guard keeps it a no-op when the pointer hasn't crossed into a new row.)
const mergedMouseMove = (e: React.MouseEvent<HTMLButtonElement>) => {
rest.onMouseMove?.(e);
if (ctx && !disabled && index != null && ctx.highlightIndex !== index) ctx.setHoverIndex(index);
};
const handlers = ctx
? ctx.getItemProps({ ...rest, onClick: mergedClick })
? ctx.getItemProps({ ...rest, onClick: mergedClick, onMouseMove: mergedMouseMove })
: { ...rest, onClick: mergedClick };
return (
@@ -283,10 +320,10 @@ export function PopoverItem({
{...handlers}
// These win over anything in `handlers` — the roving tabindex must not be a caller override,
// and disabled/className/data-active are the platform's to set.
tabIndex={active ? 0 : -1}
data-active={active ? "true" : undefined}
tabIndex={focusActive ? 0 : -1}
data-active={visualActive ? "true" : undefined}
disabled={disabled}
className={className}
className={`${ACTIVE_HIGHLIGHT}${className ? ` ${className}` : ""}`}
>
{children}
</button>
@@ -344,6 +381,8 @@ function PopoverSurfaceInner({
isNav,
selectedIndex,
activeIndex,
highlightIndex,
setHoverIndex,
elementsRef,
labelsRef,
} = popover;
@@ -397,7 +436,12 @@ function PopoverSurfaceInner({
positioned element is essential: the popIn keyframe animates `transform`, which would
otherwise override Floating UI's `transform: translate(x,y)` and park the popover at (0,0)
for the animation's duration — the "flash in the top-left corner" bug. */}
<div className={className} style={style}>
<div
className={className}
style={style}
// Pointer left the menu → drop the hover highlight so it returns to the keyboard position.
onMouseLeave={() => setHoverIndex(null)}
>
{children}
</div>
</div>
@@ -406,7 +450,9 @@ function PopoverSurfaceInner({
// Menu/listbox surfaces host the item registry (FloatingList) + active-state context so nested
// <PopoverItem>s can self-register and rove.
const positioned = isNav ? (
<PopoverListContext.Provider value={{ activeIndex, getItemProps }}>
<PopoverListContext.Provider
value={{ activeIndex, highlightIndex, setHoverIndex, getItemProps }}
>
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
{surface}
</FloatingList>
+2 -2
View File
@@ -50,7 +50,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
return (
<>
{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)}
</p>
) : (
@@ -81,7 +81,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
{/^[A-Za-z]{2}$/.test(ch.country) && (
// Real SVG flag (flag-icons) — Windows/Chrome don't render flag emoji.
<span
className={`fi fi-${ch.country.toLowerCase()} rounded-sm shrink-0`}
className={`fi fi-${ch.country.toLowerCase()} rounded-xs shrink-0`}
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). */}
<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>
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
{layout === "cards" && (
@@ -224,7 +224,7 @@ export default function ChannelDiscovery({
)}
</div>
</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 ? (
<StateMessage tone="error" onRetry={() => query.refetch()}>
{t("common.loadError")}
@@ -239,7 +239,7 @@ export default function ChannelDiscovery({
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
controlsPosition="both"
controlsInBand
controlsBandClassName="px-4 max-w-[96rem] mx-auto"
controlsBandClassName="px-4 max-w-384 mx-auto"
sort={sort}
onSortChange={setSort}
emptyText={t("channels.discovery.empty")}
+1 -1
View File
@@ -297,7 +297,7 @@ export default function ChannelPage({
<button
data-testid="channel-back"
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" />
{t("channel.back")}
+1 -1
View File
@@ -52,7 +52,7 @@ export default function ChannelPicker({
key={id}
onClick={() => toggle(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>
<X className="w-3 h-3 shrink-0" />
+4 -4
View File
@@ -23,7 +23,7 @@ interface TagChipProps {
function TagChip({ tag, onClick, title }: TagChipProps) {
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 ? (
<button
type="button"
@@ -75,7 +75,7 @@ function TagEditorMenu({
checked={on}
onSelect={() => onToggle(tg.id)}
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 ${
on ? "text-fg" : "text-muted"
}`}
>
@@ -111,7 +111,7 @@ function TagEditorMenu({
}}
maxLength={64}
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
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
// takes its container's width, so a wide header shows the tags inline instead of collapsing them.
<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. */}
<div
+7 -7
View File
@@ -616,7 +616,7 @@ export default function Channels() {
const cardPager = <Pager {...pagerProps} />;
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">
{(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
<button
@@ -648,7 +648,7 @@ export default function Channels() {
<PageToolbar>
{tabs}
{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
search lives in the shared top SearchBar, tag create/delete in the manager. */}
<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
layout has no such row, so surface the same status chips (left) and its pager (right) here. */}
{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}
{cardPager}
</div>
@@ -719,7 +719,7 @@ export default function Channels() {
/>
) : view === "blocked" ? (
/* 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 ? (
<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
`position: sticky` engages on the first scroll (the band's controls row above gives the
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
(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
@@ -776,7 +776,7 @@ export default function Channels() {
controlsPosition="both"
controlsLeading={statusChips}
controlsInBand
controlsBandClassName="px-4 max-w-[96rem] mx-auto"
controlsBandClassName="px-4 max-w-384 mx-auto"
sort={sort}
onSortChange={setSort}
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
@@ -953,7 +953,7 @@ function PriorityCell({
else if (e.key === "Escape") setEditing(false);
}}
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>
+2 -2
View File
@@ -178,7 +178,7 @@ export default function ChatThread({
return (
<div key={m.id} className={`flex ${mine ? "justify-end" : "justify-start"}`}>
<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"
}`}
>
@@ -211,7 +211,7 @@ export default function ChatThread({
}}
rows={1}
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
onClick={submit}
@@ -40,7 +40,7 @@ export default function ColumnSortControl<T>({
onChange(key ? { key, dir: active?.key === key ? active.dir : "asc" } : null);
}}
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>
{sortable.map((c) => (
+1 -1
View File
@@ -370,7 +370,7 @@ function Field({
max={isNum && item.max != null ? item.max : undefined}
placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
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 && (
+4 -4
View File
@@ -181,7 +181,7 @@ function SourceRef({ url }: { url: string }) {
onClick={copy}
aria-label={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" />
</button>
@@ -227,7 +227,7 @@ function DownloadRow({
<div className="min-w-0 flex-1">
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
{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")}
</span>
)}
@@ -435,7 +435,7 @@ function ErrorModal({
{t("downloads.errors.fail.detailsToggle")}
</button>
{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}
</pre>
)}
@@ -713,7 +713,7 @@ function AdminSystem() {
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
<button
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")}
>
<Settings2 className="w-4 h-4" />
+3 -3
View File
@@ -410,7 +410,7 @@ export default function Feed({
value={ytCount}
onChange={(e) => setYtCount(Number(e.target.value))}
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) => (
<option key={n} value={n}>
@@ -603,7 +603,7 @@ export default function Feed({
})
}
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="all">{t("feed.source.all")}</option>
@@ -637,7 +637,7 @@ export default function Feed({
});
}}
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. */}
{(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)}
className={`flex-1 rounded-lg px-2 py-1 text-xs capitalize ${
tab === t
? "bg-accent text-[color:var(--accent-fg)] font-semibold"
? "bg-accent text-(--accent-fg) font-semibold"
: "glass-card text-muted"
}`}
>
@@ -361,7 +361,7 @@ export default function GlassTuner() {
type="color"
value={swatches[p.k] || "#000000"}
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>
<code className="text-[10px] text-muted">{swatches[p.k]}</code>
@@ -385,7 +385,7 @@ export default function GlassTuner() {
<div className="flex gap-1.5">
<button
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"}
</button>
@@ -396,7 +396,7 @@ export default function GlassTuner() {
Reset
</button>
</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}
</pre>
</div>
@@ -419,7 +419,7 @@ function Ctl({ s, value, onChange }: { s: Slider; value: number; onChange: (v: n
step={s.step}
value={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
type="range"
+1 -1
View File
@@ -7,7 +7,7 @@ import { api, HttpError } from "../lib/api";
import * as e2ee from "../lib/e2ee";
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.
// 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 ? (
/* 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. */
<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()}
</span>
) : (
@@ -78,7 +78,7 @@ export default function LanguageSwitcher({
setOpen(false);
}}
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"
>
<span>{l.label}</span>
{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}
onChange={(e) => setFilter(e.target.value)}
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 ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
+1 -1
View File
@@ -56,7 +56,7 @@ export default function Modal({
return createPortal(
<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) => {
pressedOnBackdrop.current = e.target === e.currentTarget;
}}
@@ -345,7 +345,7 @@ function ClientActivityRow({
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
{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.repeat > 1 && (
<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
ref={setRef}
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}
</main>
+1 -1
View File
@@ -55,7 +55,7 @@ export default function PageShell({
return (
<PageToolbarSlot.Provider value={toolbarSlot}>
{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}
<div ref={setToolbarSlot} className="shrink-0" />
<PageScroller scrollKey={scrollKey} fadeTop={fadeTop}>
+2 -2
View File
@@ -87,7 +87,7 @@ export default function Pager({
onBlur={commitPageInput}
onKeyDown={(e) => e.key === "Enter" && commitPageInput()}
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 })}
</span>
@@ -110,7 +110,7 @@ export default function Pager({
onSizeChange(Number(e.target.value));
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) => (
<option key={n} value={n}>
+9 -9
View File
@@ -726,7 +726,7 @@ export default function PlayerModal({
window.addEventListener("focus", rearm);
stage?.addEventListener("mouseleave", rearm);
return () => {
window.removeEventListener("blur", onBlur);
window.removeEventListener("blur-sm", onBlur);
window.removeEventListener("focus", rearm);
stage?.removeEventListener("mouseleave", rearm);
};
@@ -889,7 +889,7 @@ export default function PlayerModal({
<div
ref={dialogRef}
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}
role="dialog"
aria-modal="true"
@@ -908,7 +908,7 @@ export default function PlayerModal({
disabled={index === 0}
aria-label={t("player.previous")}
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" />
</button>
@@ -916,7 +916,7 @@ export default function PlayerModal({
<div
ref={cardRef}
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()}
>
<div
@@ -1056,7 +1056,7 @@ export default function PlayerModal({
)}
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
{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" />
{t("player.nativeControls")}
</div>
@@ -1150,7 +1150,7 @@ export default function PlayerModal({
<div
ref={descFade.ref}
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, {
currentId: currentVideoId,
@@ -1250,7 +1250,7 @@ export default function PlayerModal({
<span>· {formatDuration(active.duration_seconds)}</span>
)}
{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")}
</span>
)}
@@ -1314,7 +1314,7 @@ export default function PlayerModal({
className={
"shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
(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")
}
>
@@ -1334,7 +1334,7 @@ export default function PlayerModal({
disabled={index >= queue!.length - 1}
aria-label={t("player.next")}
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" />
</button>
+1 -1
View File
@@ -590,7 +590,7 @@ export default function Playlists() {
if (e.key === "Escape") setRenaming(false);
}}
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">
+2 -2
View File
@@ -154,7 +154,7 @@ export default function PlaylistsRail({
value={plSort.key}
onChange={(e) => setPlSort({ ...plSort, key: e.target.value as PlSort["key"] })}
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="name">{t("playlists.railSortName")}</option>
@@ -200,7 +200,7 @@ export default function PlaylistsRail({
onChange={(e) => setNewName(e.target.value)}
placeholder={t("playlists.newPlaylist")}
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>
</div>
+15 -15
View File
@@ -470,7 +470,7 @@ function PlexPosterCard({
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
src={card.thumb}
@@ -522,18 +522,18 @@ function PlexPosterCard({
)}
{/* Watched badge when not hovering (the toggle replaces it on hover). */}
{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")}
</span>
)}
{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 })}
</span>
)}
{/* Aggregate in-progress badge for a partially-watched show. */}
{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")}
</span>
)}
@@ -545,7 +545,7 @@ function PlexPosterCard({
)}
{/* Type tag — tell a movie card from a show card at a glance (unified feed). */}
<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(
card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
)}
@@ -670,7 +670,7 @@ function ActionBtn({
onClick={onClick}
disabled={disabled}
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" />
@@ -707,7 +707,7 @@ function SeasonCard({
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
src={se.thumb}
@@ -750,16 +750,16 @@ function SeasonCard({
</button>
)}
{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")}
</span>
)}
{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")}
</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 })}
</span>
</div>
@@ -846,7 +846,7 @@ function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: s
onClick={() => onOpen(r.id)}
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
src={r.thumb}
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"
/>
{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")}
</span>
)}
@@ -963,7 +963,7 @@ function EpisodeCard({
onClick={onAdd}
title={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" />
</button>
@@ -1054,7 +1054,7 @@ function PlexShowView({
<img
src={show.thumb}
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 && (
<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
src={se.thumb}
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 && (
<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)}
onKeyDown={(e) => e.key === "Enter" && createNew()}
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
onClick={createNew}
@@ -149,7 +149,7 @@ export default function PlexCollectionEditor({
value={q}
onChange={(e) => setQ(e.target.value)}
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
onClick={() => removeCollection(c.id, c.title)}
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")}
</button>
@@ -226,7 +226,7 @@ export default function PlexCollectionEditor({
<button
onClick={() => markEditable(c.id)}
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")}
</button>
+3 -3
View File
@@ -145,7 +145,7 @@ export default function PlexInfo({
<img
src={detail.thumb}
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" : ""}`}>
@@ -412,14 +412,14 @@ export default function PlexInfo({
disabled={!onPlayItem}
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
src={m.thumb}
alt=""
className="h-full w-full object-cover transition group-hover/col:scale-105"
/>
{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>
)}
+11 -11
View File
@@ -1110,7 +1110,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* Top bar */}
<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"
}`}
>
@@ -1184,7 +1184,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* Bottom controls */}
<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"
}`}
>
@@ -1221,7 +1221,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* Hover timestamp tooltip — follows the cursor along the bar. */}
{hover && duration > 0 && (
<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}%` }}
>
{fmt(hover.t)}
@@ -1311,13 +1311,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
/>
{/* thumb */}
<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}%` }}
/>
{/* hover value tooltip (0–100), like the seek bar */}
{volHover != null && (
<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}%` }}
>
{Math.round(volHover * 100)}
@@ -1544,7 +1544,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
type="color"
value={prefs.subColor}
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 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"
value={prefs.subBg}
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>
@@ -1593,7 +1593,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
type="color"
value={prefs.subShadowColor}
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>
)}
@@ -1629,7 +1629,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
type="color"
value={prefs.clockColor}
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>
<Toggle
@@ -1728,7 +1728,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{keys.map((k) => (
<kbd
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}
</kbd>
@@ -1879,7 +1879,7 @@ function Ctrl({
{children}
</button>
<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}
</span>
+1 -1
View File
@@ -100,7 +100,7 @@ export default function PlexPlaylistAdd({
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createNew()}
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
onClick={createNew}
+12 -12
View File
@@ -116,7 +116,7 @@ type Ctx = {
function Poster({ src, className }: { src: string; className: string }) {
return (
<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" />
</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"
>
<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" />
{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>
)}
@@ -179,7 +179,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
<button
onClick={() => ctx.onRemove([ep.id])}
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"
>
<X className="h-4 w-4" />
@@ -210,7 +210,7 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
onClick={() => ctx.onPlay(item.id)}
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="truncate text-sm font-medium">{item.title}</div>
<div className="text-xs text-muted">{item.year}</div>
@@ -222,7 +222,7 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
<button
onClick={() => ctx.onRemove([item.id])}
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"
>
<X className="h-4 w-4" />
@@ -256,7 +256,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
<DragHandle {...attributes} {...listeners} />
<button
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}
>
{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}
alt=""
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 }}
/>
))}
@@ -285,7 +285,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
onClick={() => ctx.onRemove(block.items.map((i) => i.id))}
disabled={ctx.busy}
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" />
</button>
@@ -306,7 +306,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
{dense && (
<button
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}
>
{sOpen ? (
@@ -323,7 +323,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
onClick={() => ctx.onRemove(s.items.map((i) => i.id))}
disabled={ctx.busy}
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" />
</button>
@@ -532,7 +532,7 @@ export default function PlexPlaylistView({
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && 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>
+5 -5
View File
@@ -171,7 +171,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
onChange={(e) => setNewPlaylist(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createPlaylist()}
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
onClick={createPlaylist}
@@ -190,7 +190,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
<button
key={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" />
<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}
onChange={(e) => setCollSearch(e.target.value)}
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
ref={collFade.ref}
@@ -519,7 +519,7 @@ function Chip({
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
return (
<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">
<X className="w-3 h-3" />
</button>
@@ -546,7 +546,7 @@ function NumberInput({
const n = e.target.value === "" ? null : Number(e.target.value);
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
onClick={() => startEdit(p)}
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" />
</button>
<button
onClick={() => onDelete(p)}
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" />
</button>
+1 -1
View File
@@ -38,7 +38,7 @@ export default function ReleaseNotes({
<h3 className="text-base font-semibold">v{r.version}</h3>
<span className="text-xs text-muted">{fmtDate(r.date)}</span>
{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}
</span>
)}
+5 -5
View File
@@ -172,7 +172,7 @@ export default function SavedViewsWidget({
}}
placeholder={t("views.namePlaceholder")}
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
onClick={() => void save()}
@@ -256,7 +256,7 @@ function ViewRow({
if (e.key === "Escape") onCancelRename();
}}
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
onClick={() => onRename(draft)}
@@ -316,21 +316,21 @@ function ViewRow({
<button
onClick={onShare}
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" />
</button>
<button
onClick={onStartRename}
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" />
</button>
<button
onClick={onDelete}
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" />
</button>
+2 -2
View File
@@ -172,7 +172,7 @@ function JobRow({
if (e.key === "Enter") save();
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>
<button
@@ -281,7 +281,7 @@ function MaintenanceSettings({
onChange={(e) => setVal(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && save()}
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
onClick={save}
+1 -1
View File
@@ -40,7 +40,7 @@ export default function SearchBar({
if (e.key === "Enter") fire();
}}
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 && (
<button
+1 -1
View File
@@ -403,7 +403,7 @@ function SignInMethods({ me }: { me: Me }) {
};
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
// 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 =
"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 (
<Centered>
+6 -3
View File
@@ -135,7 +135,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
<input
readOnly
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
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 })}
</span>
{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}
</span>
@@ -208,7 +211,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
placeholder={t(
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
onClick={() => savePass.mutate(newPass.trim())}
+5 -5
View File
@@ -46,7 +46,7 @@ function TagChip({
data-tag={tag.name}
onClick={onClick}
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
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-fg hover:border-accent"
@@ -169,7 +169,7 @@ export default function Sidebar({
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
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border hover:border-accent"
@@ -185,7 +185,7 @@ export default function Sidebar({
setCustomDates(next);
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
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border hover:border-accent"
@@ -209,7 +209,7 @@ export default function Sidebar({
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 className="flex items-center justify-between gap-2 text-xs">
@@ -224,7 +224,7 @@ export default function Sidebar({
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>
{(filters.dateFrom || filters.dateTo) && (
+3 -3
View File
@@ -51,7 +51,7 @@ function TagRow({
onChange={(e) => setName(e.target.value)}
onBlur={commit}
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
ref={pop.refs.setReference}
@@ -160,7 +160,7 @@ export default function TagManager({
<div
ref={listFade.ref}
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) => (
<TagRow
@@ -201,7 +201,7 @@ export default function TagManager({
value={newName}
onChange={(e) => setNewName(e.target.value)}
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
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}`} />
<div className="min-w-0 flex-1">
{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.repeat > 1 && (
<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" />
)}
{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)}
</span>
) : video.live_status === "live" || video.live_status === "upcoming" ? (
@@ -257,7 +257,7 @@ function Thumb({
</span>
) : null}
{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")}
</span>
)}
@@ -288,7 +288,7 @@ function Thumb({
title={t("card.restartTitle")}
aria-label={t("card.restart")}
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",
)}
>
@@ -307,7 +307,7 @@ function Thumb({
)}
>
<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>
)}
+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 };
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;
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")}
>
<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")}
/>
</div>
@@ -432,7 +432,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
toggleKeep(s.id);
}}
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",
)}
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) }}
>
<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) => {
e.stopPropagation();
setDrag({ type: "boundary", bi });
@@ -460,7 +460,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
e.stopPropagation();
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")}
>
<X className="w-3 h-3" />
+2 -2
View File
@@ -94,8 +94,8 @@ export default function ViewSwitcher<T extends string>({
setOpen(false);
}}
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)] ${
on ? "text-accent" : "text-fg hover:bg-card"
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left outline-hidden transition ${
on ? "text-accent" : "text-fg"
}`}
>
<Icon className="w-4 h-4 shrink-0" />
+1 -1
View File
@@ -190,7 +190,7 @@ export default function WatchPage() {
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && password && unlock()}
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>}
<button
+4 -4
View File
@@ -90,7 +90,7 @@ export default function Welcome() {
href={REPO_URL}
target="_blank"
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">
<Code2 className="w-5 h-5" />
@@ -213,7 +213,7 @@ function Preview({
if (failed) {
return (
<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}
</div>
</div>
@@ -258,7 +258,7 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
}, [onClose]);
return (
<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}
role="dialog"
aria-modal="true"
@@ -431,7 +431,7 @@ function AuthCard() {
}
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];
return (
+3 -3
View File
@@ -81,12 +81,12 @@ export function auditColumns(
render: (r) => {
const change = changeText(r);
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
// card (the row rules bled into the page). Wrapping keeps every row inside the container.
<div className="min-w-0">
{r.summary && <div className="break-words">{r.summary}</div>}
{change && <div className="text-[11px] text-muted break-words">{change}</div>}
{r.summary && <div className="wrap-break-word">{r.summary}</div>}
{change && <div className="text-[11px] text-muted wrap-break-word">{change}</div>}
</div>
);
},
+1 -1
View File
@@ -59,7 +59,7 @@ export function playlistColumns({
<button
onClick={() => onPlay(v)}
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 && (
<img src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
+5 -5
View File
@@ -148,7 +148,7 @@ function FilterFunnel<T>({
value={(value as string) ?? ""}
onChange={(e) => onApply(col.key, e.target.value)}
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" && (
@@ -158,7 +158,7 @@ function FilterFunnel<T>({
checked={!filterOn}
onSelect={() => onApply(col.key, "")}
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"
}`}
>
@@ -171,7 +171,7 @@ function FilterFunnel<T>({
checked={value === o.value}
onSelect={() => onApply(col.key, o.value)}
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"
}`}
>
@@ -197,8 +197,8 @@ function FilterFunnel<T>({
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value])
}
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)] ${
on ? "text-fg" : "text-muted hover:bg-card"
className={`flex w-full items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
on ? "text-fg" : "text-muted"
}`}
>
<span
+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
* glass-consistency epic, not this DRY extract. */
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). */
export const btnCls = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50";
+48 -19
View File
@@ -1,6 +1,51 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
/* `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 {
--font-scale: 1.06;
@@ -374,14 +419,6 @@ html[data-perf="1"] body {
opacity: 1;
}
}
@keyframes overlayOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes panelIn {
from {
transform: translateX(100%);
@@ -390,14 +427,6 @@ html[data-perf="1"] body {
transform: translateX(0);
}
}
@keyframes panelOut {
from {
transform: translateX(0);
}
to {
transform: translateX(100%);
}
}
@keyframes popIn {
from {
opacity: 0;
-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: [],
};