Compare commits

..
12 Commits
Author SHA1 Message Date
peter 1c693f31ae release: 0.60.0 (r9-focus-a11y) 2026-08-08 13:10:19 +02:00
peter 237f99ebc3 chore(release): 0.60.0 — VERSION bump + public release notes
Cuts the v4 (Tailwind migration) + focus/a11y foundation work for release. User-facing note covers the
app-wide keyboard-focus ring, steadier menu highlights, the three previously-invisible focus inputs,
and keyboard-reachable saved-view row actions; the v4 migration and dead-style cleanup are listed as
chores.
2026-08-08 10:19:02 +02:00
peter 205f18a2e1 fix(a11y): address R9 code-review findings (glass shadow, focus dedup)
- #1: remove the 9 dead `shadow-*` from glass surfaces instead of letting them override the tuned glass
  box-shadow. The @layer components move made a JSX shadow-* win AND atomically drop the glass inset
  top-edge highlight; deleting the (always-dead) utilities keeps the intended glass depth incl. the
  inset sheen. Verified in-browser: a .glass box-shadow again includes its inset segment.
- #3: drop the per-element focus-visible outlines from the rail chrome buttons and SavedViews action
  buttons — the global button:focus-visible fallback already draws the identical 2px accent ring, so
  they were redundant. Verified: the pin button rings from the global rule alone.
- #5: unify the focus-within outline offset (offset-2) on the SearchBar and ShareDialog pills.
- #2 (double outline on the pill when an inner button is focused): NOT fixed — the clean fix
  (has-[input:focus] so only the input rings the pill) does not generate under Tailwind v4 (measured:
  no rule emitted). Kept the working focus-within, which is a11y-correct (every element still shows a
  focus indicator); the overlapping ring is an accepted cosmetic edge-case.
- #4 (weak ring on solid-fill selected items): deferred to R10 with the selected-paint tokenisation.

Gate green (tsc/eslint/prettier/knip/vitest 79).
2026-08-08 09:28:23 +02:00
peter c11e2822e9 feat(a11y): R9 focus foundation — app-wide focus-visible fallback + nav rail focus
- Global :focus-visible fallback (@layer base): one accent outline for keyboard users on every
  button/link/[role=button]/[role=tab]/summary that doesn't define its own. Non-input controls had no
  visible keyboard focus app-wide; this closes the void with a single rule. More specific focus styles
  (.selectable, an input's focus:ring, a menu item's data-active highlight) override it.
- PopoverItem now carries outline-hidden so menu items keep their data-active highlight without also
  drawing the global outline (no double ring).
- Nav rail rows adopt .selectable (via rowBase); rail chrome buttons (logo/pin/about/sign-out) get a
  focus-visible ring; the settings row already uses rowBase. Deduped the account trigger's selectable.

Deferred to R10: the chip/pill selected-PAINT unification (a .chip/.pill/.is-current repaint of the
~14 selection idioms) — the R10 token redefinition repaints those surfaces anyway, at less risk than a
hand migration now. R9's job (the focus/a11y foundation) is complete.

Verified: tsc/eslint/prettier/knip/vitest (79) green; browser computed-style confirms the global ring
renders (2px accent, offset 2px) on a plain button, .selectable overrides it with an inset ring, and
an outline-hidden menu button draws no global outline.
2026-08-08 04:51:02 +02:00
peter 51f9c8d0ed feat(a11y): R9 focus foundation — glass shadow fix, focus-visible ring, highlight/selected split
S1 — recipe foundation:
- Move .glass/.glass-card/.glass-menu into @layer components so a JSX shadow-* utility overrides the
  glass box-shadow again; the ~9 dead shadow-* on glass surfaces (modals, menus, save-bar, chat) now
  paint their intended drop-shadow. Perf-mode and :hover overrides stay unlayered so they still win.
- Add a .selectable recipe: the hover tint menu/list rows already use, PLUS the app-wide
  focus-visible ring that was missing on non-input controls (the biggest a11y gap).
- Split the popover roving highlight from the selected state (AnchoredPopover): a selected/checked
  PopoverItem now gets a ring ONLY, so its resting paint (bg-accent fill / text-accent / checkbox)
  survives being highlighted instead of downgrading to a 35% tint or being recoloured. Fixes the 4
  review findings deferred from R10-S0 generically; drops the leftover per-consumer data-active ring
  in tableHeader.

S2 — worst offenders:
- Account-menu trigger + switch/add/sign-out rows adopt .selectable (visible keyboard focus).
- Saved-views rows: group-focus-within reveals the hover-only action buttons for keyboard users;
  aria-current on the active row; focus-visible ring on every button.
- The 3 invisible-focus inputs get an accent focus ring: SearchBar + ShareDialog via focus-within on
  the glass pill/row, AboutCell via focus-visible on the trigger.

Deferred to a later R9 sprint: the full unification of all ~14 selection idioms, a shared <NavItem>,
and .is-current/.chip recipes (no consumer yet in this pass).

Verified: tsc/eslint/prettier/knip/vitest (79) green; browser computed-style checks confirm the glass
shadow now wins, the focus outlines render (2px accent), and the split renders (selected ring-only,
unselected fill).
2026-08-08 04:27:51 +02:00
peter 280ba87f25 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.
2026-08-08 02:53:35 +02:00
peter d228865222 chore(localdev): bind plex_media to a local test dir instead of the nas.lan SMB share
The nas.lan CIFS share was too slow for dev playback, so localdev now binds a
small local Plex test tree (a few movies/shows mirroring the server's
/data/<lib>/… layout) at /plex-media. Driven by PLEX_MEDIA_HOST_PATH in .env
(gitignored) — default points at the Docker-Desktop host path for
D:\plex-test-media. The old CIFS/PLEX_SMB_* approach stays in git history.
2026-08-08 02:25:11 +02:00
peter f8e0ab1520 fix(popover): single hover-follows highlight without stealing input focus (UAT)
Second UAT finding on the tag popover: the keyboard-active row stayed put while
a fainter CSS :hover chased the pointer — two competing highlights. Root cause:
floating-ui's focusItemOnHover is (correctly) OFF for input-hosting menus because
setting its activeIndex on hover drags DOM focus onto the item and blurs the
create-tag input mid-typing; the only pointer feedback left was the item's own
`hover:bg-*`.

Fix, applied in the shared PopoverItem so it's general:
- Drive the visible highlight off a NEW `hoverIndex` state (highlightIndex =
  hoverIndex ?? activeIndex), decoupled from floating-ui's activeIndex — hovering
  sets only hoverIndex, never DOM focus, so one highlight follows the pointer AND
  the input keeps its caret. Keyboard nav (onNavigate/onMatch) clears hoverIndex
  so it takes the highlight back; leaving the menu clears it too.
- Split PopoverItem's `active` into focusActive (roving tabindex, keyboard) vs
  visualActive (data-active highlight).
- Remove the now-redundant `hover:bg-*` from the 5 consumers so data-active is the
  sole highlight (it was also a same-specificity tie with data-active).

Verified in-browser: hovering moves the single highlight; typing "testtag" in the
create field then hovering items keeps focus+caret on the input (caretPos intact);
mouse-leave returns the highlight to the keyboard row. Gate: tsc/eslint/prettier/
vitest 79 green.
2026-08-08 01:38:06 +02:00
peter 22e0d286c0 fix(popover): stronger, centralized active-row highlight (UAT finding)
The roving-focus row in every popover menu was barely distinguishable from the
menu surface — an accent fill at only 24%, and the recipe was copy-pasted across
5 consumers. Centralize it in PopoverItem (ACTIVE_HIGHLIGHT, applied to every
item) and strengthen it: accent/35 fill + a 1px inset accent ring for a defined
edge that reads across all 6 schemes in both themes. Consumers no longer carry
the recipe.

Uses the now-working `bg-accent/NN` / `ring-accent/NN` utilities (v4 does the
opacity modifier via color-mix) instead of the old hand-written color-mix
arbitrary value the v3 no-op forced. Verified in-browser: fill + inset ring
render on the active item, dark and light.
2026-08-08 01:16:21 +02:00
peter 97c584733a fix(seed): mark instance configured in e2e_seed so a prod→dev reseed skips setup
`siftlode seed-dev-from-prod` TRUNCATEs app_state (excluded from the data
allow-list as local/operational state), which dropped localdev back into the
first-run "Setup needed" gate and blocked login/UAT. e2e_seed runs at the end
of both `seed` and `seed-dev-from-prod` and always leaves a loginable admin, so
it now also flips app_state.configured=true (idempotent; the running api re-reads
and promotes its one-way cache, no restart needed).
2026-08-08 01:01:16 +02:00
peter e592d800a1 style(css): drop 2 dead @keyframes (overlayOut, panelOut)
R10 S0 audit finding: both are unreferenced — overlays and side panels
unmount with no exit animation, only their `…In` counterparts are used
(0 `animate-[…]` / `animation:` consumers). Dead code, removed.
2026-08-08 00:51:11 +02:00
peter 70bda98868 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.
2026-08-08 00:50:05 +02:00
68 changed files with 1051 additions and 926 deletions
+1 -1
View File
@@ -1 +1 @@
0.59.1
0.60.0
+13 -10
View File
@@ -107,16 +107,19 @@ services:
volumes:
pgdata:
# OPTIONAL (Plex integration dev testing): the Plex media, mounted READ-ONLY from the SMB share
# so the container can play the physical file locally (this dev box isn't the Plex host). Uses a
# dedicated read-only samba user via PLEX_SMB_USER/PLEX_SMB_PASS in .env. Only referenced when the
# Plex module is enabled; harmless otherwise (Docker mounts it lazily on first container use). On
# prod (LXC on the Plex host) this is a plain host bind-mount instead — no SMB.
# OPTIONAL (Plex integration dev testing): the Plex media, mounted READ-ONLY so the container can
# play the physical file locally (this dev box isn't the Plex host). Only referenced when the Plex
# module is enabled; harmless otherwise (Docker mounts it lazily on first container use). On prod
# (LXC on the Plex host) this is a plain host bind-mount.
#
# Local bind to a few test movies/shows kept on this box — the full nas.lan SMB share was too slow.
# Point PLEX_MEDIA_HOST_PATH (.env, gitignored, so no site path lives in the tracked compose) at the
# Docker-Desktop host path (e.g. /run/desktop/mnt/host/d/plex-test-media for D:\plex-test-media),
# mirroring the Plex server's /data/<lib>/… layout so plex_path_map (/data=/plex-media) resolves.
# (The prior CIFS/PLEX_SMB_* approach for the whole nas.lan share lives in git history.)
plex_media:
driver: local
driver_opts:
type: cifs
# Host/share + credentials come from .env (gitignored) so no site-specific values live in
# the tracked compose: PLEX_SMB_HOST, PLEX_SMB_SHARE, PLEX_SMB_USER, PLEX_SMB_PASS.
device: "//${PLEX_SMB_HOST:-localhost}/${PLEX_SMB_SHARE:-data}"
o: "username=${PLEX_SMB_USER:-},password=${PLEX_SMB_PASS:-},ro,vers=3.0,uid=1000,gid=1000,file_mode=0444,dir_mode=0555"
type: none
o: bind
device: "${PLEX_MEDIA_HOST_PATH:-/run/desktop/mnt/host/d/plex-test-media}"
+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 rounded-sm outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60 focus-visible:outline-offset-2"
>
{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>
))}
+3 -3
View File
@@ -98,7 +98,7 @@ export default function AddToPlaylist({
<ListPlus className="w-4 h-4" />
</button>
<PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl p-2 shadow-2xl">
<PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl p-2">
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
would otherwise reach the feed card that hosts the trigger and start playback. */}
<div onClick={(e) => e.stopPropagation()}>
@@ -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"
+65 -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,22 @@ 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.) */
// The roving-focus highlight, SPLIT by selection state (R9). A NON-selected row gets the familiar
// accent fill + inset ring. A SELECTED/checked row gets ring-ONLY, so the consumer's own resting
// selected paint (a bg-accent fill, text-accent, a checkbox) SURVIVES being highlighted instead of
// being downgraded to a 35% tint or having its text recoloured (the R10-S0 review findings). The two
// can't be merged onto one recipe: the fill and the selected paint both drive background/color on the
// same element, so the highlight must yield to the selected state rather than overwrite it.
const HIGHLIGHT_UNSELECTED =
"data-active:bg-accent/35 data-active:text-fg data-active:ring-1 data-active:ring-inset data-active:ring-accent/60";
const HIGHLIGHT_SELECTED = "data-active:ring-2 data-active:ring-inset data-active:ring-accent/70";
export function PopoverItem({
children,
onSelect,
@@ -254,7 +284,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 +304,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 +324,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={`outline-hidden ${checked || selected ? HIGHLIGHT_SELECTED : HIGHLIGHT_UNSELECTED}${className ? ` ${className}` : ""}`}
>
{children}
</button>
@@ -344,6 +385,8 @@ function PopoverSurfaceInner({
isNav,
selectedIndex,
activeIndex,
highlightIndex,
setHoverIndex,
elementsRef,
labelsRef,
} = popover;
@@ -397,7 +440,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 +454,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>
+1 -1
View File
@@ -51,7 +51,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
return createPortal(
<button
onClick={toTop}
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center w-16 h-16 rounded-full glass-card glass-hover text-fg shadow-lg hover:text-accent transition-all duration-200 ${
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center w-16 h-16 rounded-full glass-card glass-hover text-fg hover:text-accent transition-all duration-200 ${
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
}`}
>
+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>
+1 -1
View File
@@ -73,7 +73,7 @@ export default function ChatDock({ meId }: { meId: number }) {
function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
const { t } = useTranslation();
return (
<div className="pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl shadow-2xl flex flex-col overflow-hidden">
<div className="pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl flex flex-col overflow-hidden">
{/* One-shot attention flash on a new message; keyed by the counter so it replays each time. */}
{chat.flash > 0 && (
<div
+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 && (
+5 -5
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>
)}
@@ -649,7 +649,7 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
className={inputCls}
/>
{open && filtered.length > 0 && (
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu shadow-xl overflow-hidden">
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
{filtered.map((u) => (
<button
key={u.id}
@@ -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) => (
+6 -6
View File
@@ -284,7 +284,7 @@ export default function GlassTuner() {
{open && (
<div
style={{ pointerEvents: "auto" }}
className="glass-menu rounded-l-2xl rounded-r-none w-[300px] max-h-[82vh] flex flex-col overflow-hidden shadow-2xl"
className="glass-menu rounded-l-2xl rounded-r-none w-[300px] max-h-[82vh] flex flex-col overflow-hidden"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
<span className="text-sm font-semibold text-fg">◐ Glass Tuner</span>
@@ -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()}>
+2 -4
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;
}}
@@ -66,9 +66,7 @@ export default function Modal({
role="dialog"
aria-modal="true"
>
<div
className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl shadow-2xl`}
>
<div className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl`}>
<div className="flex items-start justify-between gap-3 p-5 pb-3">
<h2 className="text-lg font-semibold leading-snug">{title}</h2>
<button
+5 -4
View File
@@ -168,7 +168,8 @@ export default function NavSidebar({
.filter((p) => sys.has(p))
.map((p) => ({ page: p, ...META(p) }));
const rowBase = "w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
const rowBase =
"selectable w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
const name = me.display_name ?? me.email.split("@")[0];
// Role is surfaced as a plain title tooltip on the account row (no visual badge). Demo isn't a
// real role (it's a flag on a `user` row), so surface it first.
@@ -367,7 +368,7 @@ export default function NavSidebar({
key={a.id}
onClick={() => switchTo(a.id)}
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
className="selectable w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
>
<AvatarImg
src={a.avatar_url}
@@ -393,14 +394,14 @@ export default function NavSidebar({
clearActiveAccount();
window.location.href = "/auth/login";
}}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<UserPlus className="w-4 h-4" />
{t("header.account.addAccount")}
</button>
<button
onClick={logout}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<LogOut className="w-4 h-4" />
{t("header.account.signOut")}
@@ -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 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>
)}
+7 -6
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)}
@@ -295,6 +295,7 @@ function ViewRow({
<button
onClick={onApply}
title={t("views.apply")}
aria-current={active ? "true" : undefined}
className={`flex-1 min-w-0 text-left text-xs py-1 truncate ${
active ? "text-accent font-medium" : "text-fg"
}`}
@@ -308,7 +309,7 @@ function ViewRow({
className={`p-1 rounded transition ${
view.is_default
? "text-accent"
: "text-muted opacity-0 group-hover:opacity-100 hover:text-accent"
: "text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-accent"
}`}
>
<Star className={`w-3.5 h-3.5 ${view.is_default ? "fill-current" : ""}`} />
@@ -316,21 +317,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 group-focus-within: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 group-focus-within: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 group-focus-within: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}
+2 -2
View File
@@ -31,7 +31,7 @@ export default function SearchBar({
if (onGo && !goDisabled) onGo();
};
return (
<div className="pointer-events-auto flex-1 min-w-0 max-w-xl h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2">
<div className="pointer-events-auto flex-1 min-w-0 max-w-xl h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2 focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
<Search className="w-4 h-4 shrink-0 text-muted" />
<input
value={value}
@@ -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>
+8 -5
View File
@@ -61,7 +61,7 @@ function UserShare({ job }: { job: DownloadJob }) {
className={inputCls}
/>
{open && filtered.length > 0 && (
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu shadow-xl overflow-hidden">
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
{filtered.map((u) => (
<button
key={u.id}
@@ -130,12 +130,12 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
return (
<div className="rounded-lg glass-card p-2.5 space-y-2">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 rounded-md focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
<Link2 className="w-4 h-4 text-muted shrink-0" />
<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 ${
!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 ${
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
@@ -37,7 +37,7 @@ export function DraftSaveBar({
const saving = state === "saving";
const floating = variant === "floating";
const wrap = floating
? "fixed bottom-4 left-1/2 -translate-x-1/2 z-rail glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]"
? "fixed bottom-4 left-1/2 -translate-x-1/2 z-rail glass rounded-2xl flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]"
: "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40";
// The floating variant is fixed to the viewport but renders from a page inside the page
// scroller, so it goes to <body> — otherwise the scroller's stacking context (its edge-fade
+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";
+146 -76
View File
@@ -1,6 +1,66 @@
@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);
}
}
/* R9 focus foundation: one accent focus ring for keyboard users on every interactive element that
doesn't define its own. In @layer base so any component/utility focus style (.selectable, an input's
focus:ring, a menu item's data-active highlight + outline-hidden) overrides it. Non-input controls
app-wide had no visible keyboard focus before R9; this is the single biggest a11y win. */
@layer base {
button:focus-visible,
a:focus-visible,
[role="button"]:focus-visible,
[role="tab"]:focus-visible,
summary:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
outline-offset: 2px;
}
}
:root {
--font-scale: 1.06;
@@ -220,69 +280,95 @@ body {
}
/* ===== Liquid-glass surface system (theme-aware, GPU-light) ===== */
.glass {
/* Frosted overlay surface: translucent enough that the blurred backdrop shows
/* In @layer components so the recipe classes below (.selectable) compose with utilities instead of
silently beating them the way these rules did while unlayered. (The ~9 dead `shadow-*` that used to
sit on glass surfaces were REMOVED in R9, not activated: the tuned glass box-shadow — including its
inset top-edge highlight — is the intended depth, and a shadow-* utility would atomically replace it
and drop that highlight.) The perf-mode and :hover overrides below stay UNLAYERED on purpose. */
@layer components {
.glass {
/* Frosted overlay surface: translucent enough that the blurred backdrop shows
through (the glassy look), opaque enough that the background isn't distracting.
The strong blur turns whatever is behind into soft colour, not a sharp image.
Layer 1 (top) = the optional scrim; layer 2 = the translucent surface tint. */
background:
linear-gradient(
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
),
color-mix(in srgb, var(--surface) var(--glass-surface-alpha), transparent);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent);
border-top-color: color-mix(
in srgb,
#fff var(--glass-edge),
color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent)
);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff var(--glass-inset), transparent),
0 18px 44px -16px rgba(0, 0, 0, 0.6);
}
.glass-card {
/* No backdrop-filter here: cards render in bulk (feed grid) over a solid background
background:
linear-gradient(
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
),
color-mix(in srgb, var(--surface) var(--glass-surface-alpha), transparent);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent);
border-top-color: color-mix(
in srgb,
#fff var(--glass-edge),
color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent)
);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff var(--glass-inset), transparent),
0 18px 44px -16px rgba(0, 0, 0, 0.6);
}
.glass-card {
/* No backdrop-filter here: cards render in bulk (feed grid) over a solid background
where blur adds almost nothing visually but is GPU-expensive and triggers reflow.
Translucency + border + depth keep the look; blur is reserved for .glass overlays. */
background:
linear-gradient(
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
),
color-mix(in srgb, var(--card) var(--glass-card-alpha), transparent);
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
border-top-color: color-mix(
in srgb,
#fff var(--glass-edge),
color-mix(in srgb, var(--border) 65%, transparent)
);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) / 2), transparent),
0 8px 22px -14px rgba(0, 0, 0, 0.45);
}
.glass-menu {
/* Floating menus/popovers hover over undimmed content (no backdrop scrim like dialogs),
background:
linear-gradient(
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
),
color-mix(in srgb, var(--card) var(--glass-card-alpha), transparent);
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
border-top-color: color-mix(
in srgb,
#fff var(--glass-edge),
color-mix(in srgb, var(--border) 65%, transparent)
);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) / 2), transparent),
0 8px 22px -14px rgba(0, 0, 0, 0.45);
}
.glass-menu {
/* Floating menus/popovers hover over undimmed content (no backdrop scrim like dialogs),
so they must be near-opaque to stay readable — only a hint of translucency + the blur. */
background:
linear-gradient(
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
),
color-mix(in srgb, var(--surface) var(--glass-menu-alpha), transparent);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-top-color: color-mix(
in srgb,
#fff var(--glass-edge),
color-mix(in srgb, var(--border) 80%, transparent)
);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) - 2%), transparent),
0 18px 44px -16px rgba(0, 0, 0, 0.6);
background:
linear-gradient(
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
),
color-mix(in srgb, var(--surface) var(--glass-menu-alpha), transparent);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-top-color: color-mix(
in srgb,
#fff var(--glass-edge),
color-mix(in srgb, var(--border) 80%, transparent)
);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) - 2%), transparent),
0 18px 44px -16px rgba(0, 0, 0, 0.6);
}
/* R9: standard interactive-row recipe. The hover tint menu/list rows already use, PLUS the
app-wide focus-visible ring that was missing on non-input controls (buttons, rows, menu items) —
the biggest single a11y win. In @layer components so a consumer's own utilities still win; the
accent ring renders correctly under v4 (color-mix), matching the input ring in ui/form.tsx. */
.selectable {
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.selectable:hover {
background-color: var(--card);
}
.selectable:focus-visible {
/* outline (not a box-shadow ring) so it never fights a glass surface's box-shadow and never gets
clipped by an overflow-hidden ancestor; inset offset keeps it inside tightly-packed rows. */
outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
outline-offset: -2px;
}
}
.glass-hover:hover {
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
@@ -374,14 +460,6 @@ html[data-perf="1"] body {
opacity: 1;
}
}
@keyframes overlayOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes panelIn {
from {
transform: translateX(100%);
@@ -390,14 +468,6 @@ html[data-perf="1"] body {
transform: translateX(0);
}
}
@keyframes panelOut {
from {
transform: translateX(0);
}
to {
transform: translateX(100%);
}
}
@keyframes popIn {
from {
opacity: 0;
+18
View File
@@ -14,6 +14,24 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.60.0",
date: "2026-08-08",
summary:
"Keyboard focus is now clearly visible across the whole app, menu highlights are steadier, and the styling engine was modernised for consistent theming.",
features: [
"Keyboard focus is now visible everywhere — buttons, links, menu items and inputs all show a clear accent outline when you move to them with Tab.",
],
fixes: [
"A selected item in a menu keeps its highlight when you hover or arrow onto it, instead of fading to a faint tint.",
"The main search box, the About tooltip and the share-link field now show a focus ring when reached by keyboard.",
"A saved view's row actions (rename, share, delete) are now reachable by keyboard, not only on hover.",
],
chores: [
"Migrated the styling engine to Tailwind v4, so theme colours and their opacities render consistently across every scheme and in both light and dark.",
"Removed dead styles (unused shadows and animations) and consolidated focus-ring and menu-highlight styling into shared recipes.",
],
},
{
version: "0.59.1",
date: "2026-08-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: [],
};
+16
View File
@@ -24,6 +24,7 @@ from sqlalchemy import delete, func, select # noqa: E402
from app.db import SessionLocal # noqa: E402
from app.models import ( # noqa: E402
AppState,
Channel,
Playlist,
PlaylistItem,
@@ -46,6 +47,21 @@ def _now() -> datetime:
def main() -> None:
db = SessionLocal()
try:
# 0. Mark the instance configured so the first-run setup wizard is skipped. A prod→dev
# reseed (siftlode seed-dev-from-prod) TRUNCATEs app_state — it's excluded from the
# data allow-list, being local/operational state — which would otherwise drop the box
# back into "Setup needed" and block login/UAT. Since e2e_seed always leaves a loginable
# admin below, the instance is legitimately set up. Idempotent (mirrors state._row +
# state.mark_configured). The running api process caches `configured` one-way from False,
# so it re-reads the DB and promotes on the next request — no restart needed.
app_state = db.get(AppState, 1)
if app_state is None:
app_state = AppState(id=1, sync_paused=False)
db.add(app_state)
app_state.configured = True
app_state.setup_token_hash = None
db.commit()
# 1. Upsert the account and always reset it to a clean, loginable baseline
# (email_verified + is_active + not suspended + not demo + password set).
user = db.scalar(select(User).where(User.email == EMAIL))