From 5bfcf5d83af7a43eb0ddb3d5359a40c1afccd07b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 5 Sep 2026 16:58:29 +0200 Subject: [PATCH 01/17] refactor(e10): move the platform components into components/ui/ and enforce the boundary The app HAS a platform layer -- AnchoredPopover (22 importers, with arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17), PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among ~160 feature components with nothing marking it as one. Several of those files describe themselves as 'the one place' or 'canonical' and were still bypassed by hand-rolled markup, because a layer nobody can see is a layer nobody uses; three separate inventory passes of this codebase misread it the same way. Moves the 33 domain-free modules into components/ui/, rewrites the imports in 89 files, and makes the boundary a lint rule rather than a convention: inside ui/ a one-level-up import (a feature component) and any lib/api slice are errors. The rule was verified to fire on both and to leave generic lib/ helpers alone. README.md states which side of the line a component belongs on and why. No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests all green; no backend file touched. --- frontend/eslint.config.js | 29 ++++++++++++ frontend/src/App.tsx | 8 ++-- frontend/src/components/About.tsx | 2 +- frontend/src/components/AboutCell.tsx | 2 +- frontend/src/components/AccountPanel.tsx | 4 +- frontend/src/components/ActiveFilterChips.tsx | 2 +- frontend/src/components/AddToPlaylist.tsx | 2 +- frontend/src/components/AdminUsers.tsx | 12 ++--- frontend/src/components/AuditLog.tsx | 4 +- frontend/src/components/ChannelDiscovery.tsx | 12 ++--- frontend/src/components/ChannelLayoutGrid.tsx | 4 +- frontend/src/components/ChannelLink.tsx | 4 +- frontend/src/components/ChannelPage.tsx | 8 ++-- frontend/src/components/ChannelPicker.tsx | 2 +- .../src/components/ChannelStatsPopover.tsx | 2 +- frontend/src/components/ChannelTags.tsx | 2 +- frontend/src/components/Channels.tsx | 20 ++++----- frontend/src/components/ChannelsProvider.tsx | 4 +- frontend/src/components/ChatDock.tsx | 4 +- frontend/src/components/ChatThread.tsx | 2 +- frontend/src/components/ConfigPanel.tsx | 10 ++--- frontend/src/components/ConfirmProvider.tsx | 2 +- frontend/src/components/DateFilterSelect.tsx | 2 +- frontend/src/components/DemoBanner.tsx | 2 +- frontend/src/components/DownloadCenter.tsx | 6 +-- frontend/src/components/DownloadDialog.tsx | 2 +- frontend/src/components/ErrorDialog.tsx | 2 +- frontend/src/components/Feed.tsx | 10 ++--- frontend/src/components/FeedScopeToggle.tsx | 2 +- frontend/src/components/FilterRail.tsx | 4 +- frontend/src/components/GlassTuner.tsx | 2 +- frontend/src/components/LanguageSwitcher.tsx | 2 +- frontend/src/components/LibraryPlayer.tsx | 2 +- frontend/src/components/Messages.tsx | 4 +- frontend/src/components/NavSidebar.tsx | 6 +-- .../src/components/NotificationsPanel.tsx | 6 +-- frontend/src/components/OnboardingWizard.tsx | 2 +- .../src/components/PersonalViewsModal.tsx | 4 +- frontend/src/components/PlayerModal.tsx | 4 +- frontend/src/components/Playlists.tsx | 6 +-- frontend/src/components/PlaylistsRail.tsx | 4 +- frontend/src/components/PlexBrowse.tsx | 10 ++--- .../src/components/PlexCollectionEditor.tsx | 2 +- .../src/components/PlexCollectionView.tsx | 2 +- frontend/src/components/PlexDiscover.tsx | 4 +- frontend/src/components/PlexInfo.tsx | 2 +- frontend/src/components/PlexLibraryPicker.tsx | 2 +- frontend/src/components/PlexPlaylistAdd.tsx | 2 +- frontend/src/components/PlexPlaylistView.tsx | 2 +- frontend/src/components/PlexScopeToggle.tsx | 2 +- frontend/src/components/PlexSearchBox.tsx | 2 +- frontend/src/components/PlexSidebar.tsx | 8 ++-- frontend/src/components/PlexViewToggle.tsx | 2 +- frontend/src/components/ProfileEditor.tsx | 2 +- frontend/src/components/ReleaseNotes.tsx | 2 +- frontend/src/components/SavedViewsPopover.tsx | 2 +- frontend/src/components/Scheduler.tsx | 4 +- frontend/src/components/SettingsPanel.tsx | 8 ++-- frontend/src/components/ShareDialog.tsx | 2 +- frontend/src/components/Sidebar.tsx | 4 +- frontend/src/components/Stats.tsx | 8 ++-- frontend/src/components/SyncStatus.tsx | 2 +- frontend/src/components/TagFilterChips.tsx | 4 +- frontend/src/components/TagManager.tsx | 4 +- frontend/src/components/UploadDialog.tsx | 2 +- frontend/src/components/VerifyBanner.tsx | 2 +- frontend/src/components/VersionBanner.tsx | 2 +- frontend/src/components/VideoCard.tsx | 2 +- frontend/src/components/VideoEditor.tsx | 2 +- frontend/src/components/VideoInfoCard.tsx | 4 +- frontend/src/components/ViewSwitcher.tsx | 2 +- frontend/src/components/VirtualFeed.tsx | 2 +- frontend/src/components/YearFilter.tsx | 6 +-- .../src/components/adminInvites.dom.test.tsx | 2 +- .../adminPendingAccess.dom.test.tsx | 2 +- frontend/src/components/auditColumns.tsx | 2 +- frontend/src/components/channelColumns.tsx | 6 +-- frontend/src/components/downloadAdmin.tsx | 2 +- frontend/src/components/downloadModals.tsx | 2 +- frontend/src/components/playlistColumns.tsx | 2 +- frontend/src/components/plexBrowseLists.tsx | 2 +- frontend/src/components/plexCards.tsx | 2 +- frontend/src/components/plexViews.tsx | 4 +- .../{ => ui}/ActionMenu.dom.test.tsx | 0 .../src/components/{ => ui}/ActionMenu.tsx | 0 .../{ => ui}/AnchoredPopover.dom.test.tsx | 0 .../components/{ => ui}/AnchoredPopover.tsx | 4 +- frontend/src/components/{ => ui}/Avatar.tsx | 0 .../src/components/{ => ui}/BackToTop.tsx | 0 frontend/src/components/{ => ui}/Banner.tsx | 0 .../components/{ => ui}/Carousel.dom.test.tsx | 0 frontend/src/components/{ => ui}/Carousel.tsx | 2 +- .../components/{ => ui}/ColumnSortControl.tsx | 4 +- .../components/{ => ui}/Combobox.dom.test.tsx | 0 frontend/src/components/{ => ui}/Combobox.tsx | 2 +- .../src/components/{ => ui}/CountBadge.tsx | 0 .../DataTable.fixedLayout.dom.test.tsx | 0 .../src/components/{ => ui}/DataTable.tsx | 2 +- .../{ => ui}/FilterChipGroup.dom.test.tsx | 0 .../components/{ => ui}/FilterChipGroup.tsx | 0 .../{ => ui}/FilterSelect.dom.test.tsx | 0 .../src/components/{ => ui}/FilterSelect.tsx | 0 .../src/components/{ => ui}/InlineNotice.tsx | 0 .../{ => ui}/ItemCount.dom.test.tsx | 2 +- .../src/components/{ => ui}/ItemCount.tsx | 0 .../components/{ => ui}/Modal.dom.test.tsx | 2 +- frontend/src/components/{ => ui}/Modal.tsx | 6 +-- frontend/src/components/{ => ui}/Overlay.tsx | 0 .../{ => ui}/PageScroller.dom.test.tsx | 2 +- .../src/components/{ => ui}/PageScroller.tsx | 4 +- .../{ => ui}/PageShell.dom.test.tsx | 0 .../src/components/{ => ui}/PageShell.tsx | 0 frontend/src/components/{ => ui}/Pager.tsx | 0 .../src/components/{ => ui}/PanelGroup.tsx | 0 .../src/components/{ => ui}/PanelGroups.tsx | 2 +- .../{ => ui}/PriorityStepper.dom.test.tsx | 0 .../components/{ => ui}/PriorityStepper.tsx | 0 .../src/components/{ => ui}/QueryState.tsx | 0 frontend/src/components/ui/README.md | 44 +++++++++++++++++++ .../{ => ui}/SegmentedToggle.dom.test.tsx | 0 .../components/{ => ui}/SegmentedToggle.tsx | 0 .../src/components/{ => ui}/SidePanel.tsx | 4 +- .../{ => ui}/SortControl.dom.test.tsx | 2 +- .../src/components/{ => ui}/SortControl.tsx | 2 +- frontend/src/components/{ => ui}/Tabs.tsx | 2 +- frontend/src/components/{ => ui}/Toaster.tsx | 2 +- frontend/src/components/{ => ui}/Tooltip.tsx | 2 +- .../src/components/{ => ui}/UndoToolbar.tsx | 0 .../src/components/{ => ui}/VirtualGrid.tsx | 0 frontend/src/components/ui/form.tsx | 2 +- .../src/components/{ => ui}/readAhead.test.ts | 0 frontend/src/components/{ => ui}/readAhead.ts | 0 .../src/components/{ => ui}/tableHeader.tsx | 4 +- frontend/src/lib/channelsUrlState.dom.test.ts | 2 +- frontend/src/lib/channelsUrlState.ts | 2 +- frontend/src/lib/playlistView.test.ts | 2 +- frontend/src/lib/sortOptions.ts | 2 +- frontend/src/lib/useFilterState.ts | 2 +- 138 files changed, 262 insertions(+), 189 deletions(-) rename frontend/src/components/{ => ui}/ActionMenu.dom.test.tsx (100%) rename frontend/src/components/{ => ui}/ActionMenu.tsx (100%) rename frontend/src/components/{ => ui}/AnchoredPopover.dom.test.tsx (100%) rename frontend/src/components/{ => ui}/AnchoredPopover.tsx (99%) rename frontend/src/components/{ => ui}/Avatar.tsx (100%) rename frontend/src/components/{ => ui}/BackToTop.tsx (100%) rename frontend/src/components/{ => ui}/Banner.tsx (100%) rename frontend/src/components/{ => ui}/Carousel.dom.test.tsx (100%) rename frontend/src/components/{ => ui}/Carousel.tsx (98%) rename frontend/src/components/{ => ui}/ColumnSortControl.tsx (90%) rename frontend/src/components/{ => ui}/Combobox.dom.test.tsx (100%) rename frontend/src/components/{ => ui}/Combobox.tsx (99%) rename frontend/src/components/{ => ui}/CountBadge.tsx (100%) rename frontend/src/components/{ => ui}/DataTable.fixedLayout.dom.test.tsx (100%) rename frontend/src/components/{ => ui}/DataTable.tsx (99%) rename frontend/src/components/{ => ui}/FilterChipGroup.dom.test.tsx (100%) rename frontend/src/components/{ => ui}/FilterChipGroup.tsx (100%) rename frontend/src/components/{ => ui}/FilterSelect.dom.test.tsx (100%) rename frontend/src/components/{ => ui}/FilterSelect.tsx (100%) rename frontend/src/components/{ => ui}/InlineNotice.tsx (100%) rename frontend/src/components/{ => ui}/ItemCount.dom.test.tsx (96%) rename frontend/src/components/{ => ui}/ItemCount.tsx (100%) rename frontend/src/components/{ => ui}/Modal.dom.test.tsx (98%) rename frontend/src/components/{ => ui}/Modal.tsx (97%) rename frontend/src/components/{ => ui}/Overlay.tsx (100%) rename frontend/src/components/{ => ui}/PageScroller.dom.test.tsx (98%) rename frontend/src/components/{ => ui}/PageScroller.tsx (98%) rename frontend/src/components/{ => ui}/PageShell.dom.test.tsx (100%) rename frontend/src/components/{ => ui}/PageShell.tsx (100%) rename frontend/src/components/{ => ui}/Pager.tsx (100%) rename frontend/src/components/{ => ui}/PanelGroup.tsx (100%) rename frontend/src/components/{ => ui}/PanelGroups.tsx (98%) rename frontend/src/components/{ => ui}/PriorityStepper.dom.test.tsx (100%) rename frontend/src/components/{ => ui}/PriorityStepper.tsx (100%) rename frontend/src/components/{ => ui}/QueryState.tsx (100%) create mode 100644 frontend/src/components/ui/README.md rename frontend/src/components/{ => ui}/SegmentedToggle.dom.test.tsx (100%) rename frontend/src/components/{ => ui}/SegmentedToggle.tsx (100%) rename frontend/src/components/{ => ui}/SidePanel.tsx (98%) rename frontend/src/components/{ => ui}/SortControl.dom.test.tsx (98%) rename frontend/src/components/{ => ui}/SortControl.tsx (98%) rename frontend/src/components/{ => ui}/Tabs.tsx (97%) rename frontend/src/components/{ => ui}/Toaster.tsx (99%) rename frontend/src/components/{ => ui}/Tooltip.tsx (97%) rename frontend/src/components/{ => ui}/UndoToolbar.tsx (100%) rename frontend/src/components/{ => ui}/VirtualGrid.tsx (100%) rename frontend/src/components/{ => ui}/readAhead.test.ts (100%) rename frontend/src/components/{ => ui}/readAhead.ts (100%) rename frontend/src/components/{ => ui}/tableHeader.tsx (99%) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index c8f32bc6..88a51850 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -67,5 +67,34 @@ export default tseslint.config( ], }, }, + { + // E10 S1: `components/ui/` is the PLATFORM layer, and the rule that makes it a layer rather than + // a folder is enforced here, not in prose (see components/ui/README.md). A platform component is + // domain-free: it may import sibling primitives and generic `lib/` helpers, never a feature + // component (`../Feed`) and never a domain API slice. Without this rule the boundary erodes on + // the first "just this once" import and the directory silently becomes another components/. + files: ["src/components/ui/**/*.{ts,tsx}"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + // `../X` (exactly one level up = a feature component). A `regex` rather than a glob: + // eslint matches `patterns` gitignore-style, where `../*` also swallows `../../lib/x`. + regex: "^\.\./(?!\.\./)", + message: + "components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.", + }, + { + regex: "^\.\./\.\./lib/api/", + message: + "components/ui/ is domain-free: a platform component may not call a domain API slice. Take the data as props.", + }, + ], + }, + ], + }, + }, prettier, ); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4534ef55..a61db11b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,12 +29,12 @@ import Welcome from "./components/Welcome"; import SetupWizard from "./components/SetupWizard"; import Header from "./components/Header"; import NavSidebar from "./components/NavSidebar"; -import BackToTop from "./components/BackToTop"; -import PageShell from "./components/PageShell"; +import BackToTop from "./components/ui/BackToTop"; +import PageShell from "./components/ui/PageShell"; import GlassTuner from "./components/GlassTuner"; import ChatDock from "./components/ChatDock"; -import Toaster from "./components/Toaster"; -import { StateMessage } from "./components/QueryState"; +import Toaster from "./components/ui/Toaster"; +import { StateMessage } from "./components/ui/QueryState"; import ErrorDialog from "./components/ErrorDialog"; import VersionBanner from "./components/VersionBanner"; import DemoBanner from "./components/DemoBanner"; diff --git a/frontend/src/components/About.tsx b/frontend/src/components/About.tsx index 46d2888c..1b28abeb 100644 --- a/frontend/src/components/About.tsx +++ b/frontend/src/components/About.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { Info, Sparkles } from "lucide-react"; import { api } from "../lib/api"; import { FRONTEND_VERSION, FRONTEND_SHA, FRONTEND_BUILD_DATE } from "../lib/version"; -import Modal from "./Modal"; +import Modal from "./ui/Modal"; function fmtDate(d?: string | null): string { if (!d) return "—"; diff --git a/frontend/src/components/AboutCell.tsx b/frontend/src/components/AboutCell.tsx index e26bc63d..86d9ddf4 100644 --- a/frontend/src/components/AboutCell.tsx +++ b/frontend/src/components/AboutCell.tsx @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { api } from "../lib/api"; import ChannelAboutContent from "./ChannelAbout"; -import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; +import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; /** * A channel-description cell: one truncated line in the table, revealing the FULL channel "About" diff --git a/frontend/src/components/AccountPanel.tsx b/frontend/src/components/AccountPanel.tsx index a7e25382..4b1377ca 100644 --- a/frontend/src/components/AccountPanel.tsx +++ b/frontend/src/components/AccountPanel.tsx @@ -2,8 +2,8 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Clock, Copy, Globe, KeyRound, Mail, Send, ShieldAlert } from "lucide-react"; -import Modal from "./Modal"; -import { LoadingState, StateMessage } from "./QueryState"; +import Modal from "./ui/Modal"; +import { LoadingState, StateMessage } from "./ui/QueryState"; import { api } from "../lib/api"; import { qk } from "../lib/queryKeys"; import { notify } from "../lib/notifications"; diff --git a/frontend/src/components/ActiveFilterChips.tsx b/frontend/src/components/ActiveFilterChips.tsx index 28e42875..e335dabf 100644 --- a/frontend/src/components/ActiveFilterChips.tsx +++ b/frontend/src/components/ActiveFilterChips.tsx @@ -4,7 +4,7 @@ import { Filter, X } from "lucide-react"; import type { ActiveFilter } from "../lib/useActiveFilters"; import { decideChipLayout } from "../lib/chipOverflow"; import { useChipOverflowFit } from "../lib/useChipOverflowFit"; -import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; +import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; // The applied-filter row above the feed: every narrowing currently in force, each removable on its // own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index b7fb452d..716279ef 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -6,7 +6,7 @@ import { Check, ListPlus, Plus, Youtube } from "lucide-react"; import { api, type Playlist } from "../lib/api"; import { useScrollFade } from "../lib/useScrollFade"; import { playlistName } from "../lib/playlistName"; -import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; +import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; // A small anchored menu for toggling a video's membership in the user's playlists, with inline // "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index d61b2737..ab70e155 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -26,16 +26,16 @@ import { import { notify } from "../lib/notifications"; import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers"; import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab"; -import Tooltip from "./Tooltip"; -import InlineNotice from "./InlineNotice"; +import Tooltip from "./ui/Tooltip"; +import InlineNotice from "./ui/InlineNotice"; import { Section } from "./ui/form"; -import SegmentedToggle from "./SegmentedToggle"; +import SegmentedToggle from "./ui/SegmentedToggle"; import { LANGUAGES, type LangCode } from "../i18n"; -import { LoadingState, StateMessage } from "./QueryState"; +import { LoadingState, StateMessage } from "./ui/QueryState"; import AccountPanel from "./AccountPanel"; import { useConfirm } from "./ConfirmProvider"; -import Tabs, { usePersistedTab } from "./Tabs"; -import { PageToolbar } from "./PageShell"; +import Tabs, { usePersistedTab } from "./ui/Tabs"; +import { PageToolbar } from "./ui/PageShell"; import { useMe } from "../lib/useMe"; // Admin-only user & access management: roles, access requests (the Invite whitelist), and the diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx index f19b0457..0631b678 100644 --- a/frontend/src/components/AuditLog.tsx +++ b/frontend/src/components/AuditLog.tsx @@ -8,8 +8,8 @@ import { notify } from "../lib/notifications"; import { LS } from "../lib/storage"; import { Section } from "./ui/form"; import { useConfirm } from "./ConfirmProvider"; -import DataTable from "./DataTable"; -import { LoadingState, StateMessage } from "./QueryState"; +import DataTable from "./ui/DataTable"; +import { LoadingState, StateMessage } from "./ui/QueryState"; import { auditColumns } from "./auditColumns"; // Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index caa64822..5798f0c1 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -12,15 +12,15 @@ import { useCardPager } from "../lib/useCardPager"; import { subsColumn } from "./channelColumns"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; -import Tooltip from "./Tooltip"; +import Tooltip from "./ui/Tooltip"; import ChannelLink from "./ChannelLink"; import AboutCell from "./AboutCell"; -import DataTable, { type Column } from "./DataTable"; +import DataTable, { type Column } from "./ui/DataTable"; import ChannelLayoutGrid from "./ChannelLayoutGrid"; -import { LoadingState, StateMessage } from "./QueryState"; -import ColumnSortControl from "./ColumnSortControl"; -import Pager from "./Pager"; -import { PageToolbar } from "./PageShell"; +import { LoadingState, StateMessage } from "./ui/QueryState"; +import ColumnSortControl from "./ui/ColumnSortControl"; +import Pager from "./ui/Pager"; +import { PageToolbar } from "./ui/PageShell"; import { useConfirm } from "./ConfirmProvider"; import { useChannelLayout } from "./ChannelsProvider"; diff --git a/frontend/src/components/ChannelLayoutGrid.tsx b/frontend/src/components/ChannelLayoutGrid.tsx index 76c988de..d15c6ffb 100644 --- a/frontend/src/components/ChannelLayoutGrid.tsx +++ b/frontend/src/components/ChannelLayoutGrid.tsx @@ -1,7 +1,7 @@ import { Fragment } from "react"; import { useTranslation } from "react-i18next"; -import { type Column } from "./DataTable"; -import VirtualGrid from "./VirtualGrid"; +import { type Column } from "./ui/DataTable"; +import VirtualGrid from "./ui/VirtualGrid"; import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout"; // The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column` render closures diff --git a/frontend/src/components/ChannelLink.tsx b/frontend/src/components/ChannelLink.tsx index 2fc67126..a422dbea 100644 --- a/frontend/src/components/ChannelLink.tsx +++ b/frontend/src/components/ChannelLink.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { ExternalLink } from "lucide-react"; -import Avatar from "./Avatar"; -import Tooltip from "./Tooltip"; +import Avatar from "./ui/Avatar"; +import Tooltip from "./ui/Tooltip"; import { channelYouTubeUrl } from "../lib/format"; // Avatar + channel name + "open on YouTube" link, shared by the Channel manager and the diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 682388d3..2932ca7d 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -5,7 +5,7 @@ import { useLiveQuery } from "../lib/useLiveQuery"; import { channelDetailPollMs } from "../lib/channelDetailPoll"; import { qk } from "../lib/queryKeys"; import { useNavigationActions } from "./NavigationProvider"; -import { PriorityStepper } from "./PriorityStepper"; +import { PriorityStepper } from "./ui/PriorityStepper"; import { useChannelPriority } from "../lib/useChannelPriority"; import { ArrowLeft, @@ -20,11 +20,11 @@ import { RefreshCw, SlidersHorizontal, } from "lucide-react"; -import Avatar from "./Avatar"; -import Tabs from "./Tabs"; +import Avatar from "./ui/Avatar"; +import Tabs from "./ui/Tabs"; import Feed from "./Feed"; import ChannelAboutContent from "./ChannelAbout"; -import { PageToolbar, PageToolbarSlot } from "./PageShell"; +import { PageToolbar, PageToolbarSlot } from "./ui/PageShell"; import { useFeedFilters } from "./FeedFiltersProvider"; import { useConfirm } from "./ConfirmProvider"; import { notify } from "../lib/notifications"; diff --git a/frontend/src/components/ChannelPicker.tsx b/frontend/src/components/ChannelPicker.tsx index 3c4386de..bebc16d2 100644 --- a/frontend/src/components/ChannelPicker.tsx +++ b/frontend/src/components/ChannelPicker.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { api, type FeedFilters } from "../lib/api"; -import Combobox, { type ComboboxOption } from "./Combobox"; +import Combobox, { type ComboboxOption } from "./ui/Combobox"; // Pick channels to narrow the feed to — several OR together, so every other filter then applies // across all of them. Until this existed the filter was settable only via a shared "?channel=" link: diff --git a/frontend/src/components/ChannelStatsPopover.tsx b/frontend/src/components/ChannelStatsPopover.tsx index 5c8a3b13..3d20d933 100644 --- a/frontend/src/components/ChannelStatsPopover.tsx +++ b/frontend/src/components/ChannelStatsPopover.tsx @@ -12,7 +12,7 @@ import { import { useTranslation } from "react-i18next"; import { type MyStatus } from "../lib/api"; import { formatEta } from "../lib/format"; -import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; +import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; // The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip // that reveals the numbers on hover (reuses the AnchoredPopover hover mode). One row per stat, each diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index f580d7a6..1a6e497d 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { Check, Plus, Sparkles, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { type Tag } from "../lib/api"; -import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover"; +import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./ui/AnchoredPopover"; import { useScrollFade } from "../lib/useScrollFade"; import { fitChipsWithPill } from "../lib/chipOverflow"; import { useChipOverflowFit } from "../lib/useChipOverflowFit"; diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index ca9c1426..2edc6817 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -10,26 +10,26 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { accountKey, LS } from "../lib/storage"; import { buildChannelColumns, applySyncFilter } from "./channelColumns"; import { notify } from "../lib/notifications"; -import Tooltip from "./Tooltip"; -import DataTable from "./DataTable"; -import Tabs, { type TabDef } from "./Tabs"; +import Tooltip from "./ui/Tooltip"; +import DataTable from "./ui/DataTable"; +import Tabs, { type TabDef } from "./ui/Tabs"; import ChannelLayoutGrid from "./ChannelLayoutGrid"; -import { LoadingState, StateMessage } from "./QueryState"; +import { LoadingState, StateMessage } from "./ui/QueryState"; import { useChannelTagActions } from "../lib/useChannelTagActions"; import { useTagSuggestions } from "./useTagSuggestions"; import { useChannelPriority } from "../lib/useChannelPriority"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; -import ColumnSortControl from "./ColumnSortControl"; -import FilterChipGroup from "./FilterChipGroup"; +import ColumnSortControl from "./ui/ColumnSortControl"; +import FilterChipGroup from "./ui/FilterChipGroup"; import TagFilterChips, { type FilterChip } from "./TagFilterChips"; -import ActionMenu, { type ActionMenuItem } from "./ActionMenu"; +import ActionMenu, { type ActionMenuItem } from "./ui/ActionMenu"; import ChannelStatsPopover from "./ChannelStatsPopover"; -import Pager from "./Pager"; +import Pager from "./ui/Pager"; import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout"; import { sortRows } from "../lib/columnSort"; -import { filterRows } from "./tableHeader"; +import { filterRows } from "./ui/tableHeader"; import { useCardPager } from "../lib/useCardPager"; -import { PageToolbar } from "./PageShell"; +import { PageToolbar } from "./ui/PageShell"; import ChannelDiscovery from "./ChannelDiscovery"; import TagManager from "./TagManager"; import { useConfirm } from "./ConfirmProvider"; diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx index 3504a49d..4111b803 100644 --- a/frontend/src/components/ChannelsProvider.tsx +++ b/frontend/src/components/ChannelsProvider.tsx @@ -22,8 +22,8 @@ import { hasChannelsParams, type ChannelsUrlState, } from "../lib/channelsUrlState"; -import { usePersistedTab } from "./Tabs"; -import type { FilterMap } from "./tableHeader"; +import { usePersistedTab } from "./ui/Tabs"; +import type { FilterMap } from "./ui/tableHeader"; import { useNavigation, useNavigationActions } from "./NavigationProvider"; import type { ChannelsView } from "./Channels"; diff --git a/frontend/src/components/ChatDock.tsx b/frontend/src/components/ChatDock.tsx index e638a306..1d3c1669 100644 --- a/frontend/src/components/ChatDock.tsx +++ b/frontend/src/components/ChatDock.tsx @@ -13,9 +13,9 @@ import { } from "../lib/chatDock"; import { onMessage, onUnread } from "../lib/messagesSocket"; import * as e2ee from "../lib/e2ee"; -import Avatar from "./Avatar"; +import Avatar from "./ui/Avatar"; import ChatThread from "./ChatThread"; -import Overlay from "./Overlay"; +import Overlay from "./ui/Overlay"; // The Messenger-style floating chat dock: open conversations live here, bottom-right, across // page navigation (state persisted per user). Always mounted (for human users) so it also owns diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx index 76689e30..da6d128d 100644 --- a/frontend/src/components/ChatThread.tsx +++ b/frontend/src/components/ChatThread.tsx @@ -6,7 +6,7 @@ import { Send } from "lucide-react"; import { api, HttpError, type Message, type MessageUser } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { useScrollFade } from "../lib/useScrollFade"; -import { LoadingState, StateMessage } from "./QueryState"; +import { LoadingState, StateMessage } from "./ui/QueryState"; import { relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging"; diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index f16f1631..0b8b35ee 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -5,12 +5,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Plug, RotateCcw, Send } from "lucide-react"; import { api, type ConfigItem, type PlexTestResult } from "../lib/api"; import { notify } from "../lib/notifications"; -import Tooltip from "./Tooltip"; -import InlineNotice from "./InlineNotice"; +import Tooltip from "./ui/Tooltip"; +import InlineNotice from "./ui/InlineNotice"; import { useMe } from "../lib/useMe"; -import Tabs, { usePersistedTab } from "./Tabs"; -import { LoadingState, StateMessage } from "./QueryState"; -import { PageToolbar } from "./PageShell"; +import Tabs, { usePersistedTab } from "./ui/Tabs"; +import { LoadingState, StateMessage } from "./ui/QueryState"; +import { PageToolbar } from "./ui/PageShell"; import { Switch } from "./ui/form"; import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar"; import { LS } from "../lib/storage"; diff --git a/frontend/src/components/ConfirmProvider.tsx b/frontend/src/components/ConfirmProvider.tsx index 47cc9dd3..191df296 100644 --- a/frontend/src/components/ConfirmProvider.tsx +++ b/frontend/src/components/ConfirmProvider.tsx @@ -1,6 +1,6 @@ import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import Modal from "./Modal"; +import Modal from "./ui/Modal"; import { Button } from "./ui/Button"; // App-styled, promise-based replacement for window.confirm. Wrap the tree in diff --git a/frontend/src/components/DateFilterSelect.tsx b/frontend/src/components/DateFilterSelect.tsx index f4fe6587..ff4a5d43 100644 --- a/frontend/src/components/DateFilterSelect.tsx +++ b/frontend/src/components/DateFilterSelect.tsx @@ -1,7 +1,7 @@ import { useLayoutEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { CalendarDays, Check, ChevronDown } from "lucide-react"; -import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; +import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; import { type FeedFilters } from "../lib/api"; import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter"; diff --git a/frontend/src/components/DemoBanner.tsx b/frontend/src/components/DemoBanner.tsx index d9b6cb94..e7837a92 100644 --- a/frontend/src/components/DemoBanner.tsx +++ b/frontend/src/components/DemoBanner.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { AlertTriangle } from "lucide-react"; -import Banner from "./Banner"; +import Banner from "./ui/Banner"; // Permanent, non-dismissible bar shown to the shared demo account so it's always clear the // state is communal. (Replaces the old login-time toast, which re-popped on every reload.) diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 819890dd..a4fa1787 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -19,9 +19,9 @@ import { Trash2, UploadCloud, } from "lucide-react"; -import Tabs from "./Tabs"; -import { PageToolbar } from "./PageShell"; -import { LoadingState, StateMessage } from "./QueryState"; +import Tabs from "./ui/Tabs"; +import { PageToolbar } from "./ui/PageShell"; +import { LoadingState, StateMessage } from "./ui/QueryState"; // Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until // used (the video editor in particular is heavy — filmstrip + scrubber). const DownloadDialog = lazy(() => import("./DownloadDialog")); diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/components/DownloadDialog.tsx index 1c6d3eb4..0ad44a56 100644 --- a/frontend/src/components/DownloadDialog.tsx +++ b/frontend/src/components/DownloadDialog.tsx @@ -2,7 +2,7 @@ import { lazy, Suspense, useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import Modal from "./Modal"; +import Modal from "./ui/Modal"; import { Button } from "./ui/Button"; import { api } from "../lib/api"; import { invalidateDownloads } from "../lib/downloads"; diff --git a/frontend/src/components/ErrorDialog.tsx b/frontend/src/components/ErrorDialog.tsx index 5b8968bb..34ba1805 100644 --- a/frontend/src/components/ErrorDialog.tsx +++ b/frontend/src/components/ErrorDialog.tsx @@ -1,7 +1,7 @@ import { useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { dismissError, getError, subscribeError } from "../lib/errorDialog"; -import Modal from "./Modal"; +import Modal from "./ui/Modal"; import { Button } from "./ui/Button"; // Renders the current app error (if any) as a modal the user must acknowledge. diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index a4253c7d..46fb5979 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -38,12 +38,12 @@ import { FEED_VIEWS, type FeedView } from "../lib/feedView"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; import ActiveFilterChips from "./ActiveFilterChips"; import SelectedViewChip from "./SelectedViewChip"; -import { LoadingState, StateMessage } from "./QueryState"; -import { PageToolbar } from "./PageShell"; +import { LoadingState, StateMessage } from "./ui/QueryState"; +import { PageToolbar } from "./ui/PageShell"; import { useWizard } from "./WizardProvider"; -import SortControl from "./SortControl"; -import FilterSelect from "./FilterSelect"; -import ItemCount from "./ItemCount"; +import SortControl from "./ui/SortControl"; +import FilterSelect from "./ui/FilterSelect"; +import ItemCount from "./ui/ItemCount"; import ViewSwitcher from "./ViewSwitcher"; import VirtualFeed from "./VirtualFeed"; // Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened. diff --git a/frontend/src/components/FeedScopeToggle.tsx b/frontend/src/components/FeedScopeToggle.tsx index b943f19c..e218bb31 100644 --- a/frontend/src/components/FeedScopeToggle.tsx +++ b/frontend/src/components/FeedScopeToggle.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { Library, User } from "lucide-react"; -import SegmentedToggle from "./SegmentedToggle"; +import SegmentedToggle from "./ui/SegmentedToggle"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; // The feed's Mine/Library scope, relocated from the filter sidebar to sit just right of the header diff --git a/frontend/src/components/FilterRail.tsx b/frontend/src/components/FilterRail.tsx index 420e705c..a0d6e241 100644 --- a/frontend/src/components/FilterRail.tsx +++ b/frontend/src/components/FilterRail.tsx @@ -1,8 +1,8 @@ import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { defaultLayout, type PanelId, type PanelLayout } from "../lib/panelLayout"; -import SidePanel from "./SidePanel"; -import PanelGroups, { type PanelItem } from "./PanelGroups"; +import SidePanel from "./ui/SidePanel"; +import PanelGroups, { type PanelItem } from "./ui/PanelGroups"; // The shared filter-rail shell, extracted from the three rails that all render the same SidePanel // chrome around a PanelGroups body: the feed Sidebar, the PlexSidebar, and the PlaylistsRail. It owns diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx index fed6ad06..ffada379 100644 --- a/frontend/src/components/GlassTuner.tsx +++ b/frontend/src/components/GlassTuner.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import Overlay from "./Overlay"; +import Overlay from "./ui/Overlay"; import { copyText } from "../lib/clipboard"; import { usePrefs } from "./PrefsProvider"; diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx index 66c59cc7..cb33aab5 100644 --- a/frontend/src/components/LanguageSwitcher.tsx +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { Check, Globe } from "lucide-react"; import { LANGUAGES, type LangCode } from "../i18n"; -import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; +import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; // Compact language picker (globe + current code). Presentational: the parent decides what // changing the language does (set it; persist server-side when signed in). diff --git a/frontend/src/components/LibraryPlayer.tsx b/frontend/src/components/LibraryPlayer.tsx index 0b8caa4c..39c7c79d 100644 --- a/frontend/src/components/LibraryPlayer.tsx +++ b/frontend/src/components/LibraryPlayer.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import Modal from "./Modal"; +import Modal from "./ui/Modal"; import { api, type DownloadJob } from "../lib/api"; import { jobTitle } from "./downloadRow"; diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx index 64559ff2..d7ed17a6 100644 --- a/frontend/src/components/Messages.tsx +++ b/frontend/src/components/Messages.tsx @@ -5,13 +5,13 @@ import { useQuery } from "@tanstack/react-query"; import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react"; import { api, type Conversation, type MessageUser } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; -import { LoadingState, StateMessage } from "./QueryState"; +import { LoadingState, StateMessage } from "./ui/QueryState"; import { relativeTime } from "../lib/format"; import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging"; import { useHistorySubview } from "../lib/history"; import { openChat } from "../lib/chatDock"; import * as e2ee from "../lib/e2ee"; -import Avatar from "./Avatar"; +import Avatar from "./ui/Avatar"; import KeyGate from "./KeyGate"; import { inputCls } from "./ui/form"; import ChatThread from "./ChatThread"; diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 9e84fee9..71d05ae7 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -12,10 +12,10 @@ import type { Page } from "../lib/urlState"; import { moduleDef, moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules"; import { useScrollFade } from "../lib/useScrollFade"; import { type LangCode } from "../i18n"; -import AvatarImg from "./Avatar"; -import Tooltip from "./Tooltip"; +import AvatarImg from "./ui/Avatar"; +import Tooltip from "./ui/Tooltip"; import LanguageSwitcher from "./LanguageSwitcher"; -import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; +import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; // Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar // dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 3189ec68..1be66ff3 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -37,9 +37,9 @@ import { type VideoWatchedMeta, } from "../lib/notifications"; import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format"; -import { LEVEL_STYLE } from "./Toaster"; -import { PageToolbar } from "./PageShell"; -import { LoadingState, StateMessage } from "./QueryState"; +import { LEVEL_STYLE } from "./ui/Toaster"; +import { PageToolbar } from "./ui/PageShell"; +import { LoadingState, StateMessage } from "./ui/QueryState"; import { useNavigationActions } from "./NavigationProvider"; import { useChannelsActions } from "./ChannelsProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; diff --git a/frontend/src/components/OnboardingWizard.tsx b/frontend/src/components/OnboardingWizard.tsx index c963bf0b..46c962af 100644 --- a/frontend/src/components/OnboardingWizard.tsx +++ b/frontend/src/components/OnboardingWizard.tsx @@ -5,7 +5,7 @@ import { qk } from "../lib/queryKeys"; import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react"; import { api, type Me } from "../lib/api"; import { beginGrant, endOnboarding } from "../lib/onboarding"; -import Overlay from "./Overlay"; +import Overlay from "./ui/Overlay"; // A first-login wizard that walks the user through granting YouTube access one scope at a // time, each with a plain explanation and an up-front heads-up about Google's "unverified diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx index 2a66c37e..ad5ea85e 100644 --- a/frontend/src/components/PersonalViewsModal.tsx +++ b/frontend/src/components/PersonalViewsModal.tsx @@ -17,13 +17,13 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api"; -import Modal from "./Modal"; +import Modal from "./ui/Modal"; import { Button } from "./ui/Button"; import { useSavedViews, asFilters } from "./SavedViewsProvider"; import { useActiveFilters } from "../lib/useActiveFilters"; import { buildPlexActiveFilters } from "./usePlexActiveFilters"; import { useUndoable } from "../lib/useUndoable"; -import UndoToolbar from "./UndoToolbar"; +import UndoToolbar from "./ui/UndoToolbar"; // Master–detail manager for saved ("personal") views: the left column lists the views (drag to // reorder) plus a "save the current filters" input; the right column shows the selected view's name diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 67ca30ad..73875a7d 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -28,9 +28,9 @@ import { VolumeX, X, } from "lucide-react"; -import Avatar from "./Avatar"; +import Avatar from "./ui/Avatar"; import AddToPlaylist from "./AddToPlaylist"; -import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; +import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack"; import DownloadButton from "./DownloadButton"; import { api, type Video, type VideoStatus } from "../lib/api"; diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index da772928..2d82d550 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -42,8 +42,8 @@ import { cycleSort, type SortState } from "../lib/columnSort"; import { formatRuntime } from "../lib/format"; import { applyView, sameOrder } from "../lib/playlistView"; const PlayerModal = lazy(() => import("./PlayerModal")); -import UndoToolbar from "./UndoToolbar"; -import { PageToolbar } from "./PageShell"; +import UndoToolbar from "./ui/UndoToolbar"; +import { PageToolbar } from "./ui/PageShell"; import { useChoice, useConfirm } from "./ConfirmProvider"; import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider"; import { useMe } from "../lib/useMe"; @@ -54,7 +54,7 @@ import { isActive, type Column, type FilterMap, -} from "./tableHeader"; +} from "./ui/tableHeader"; import { GRIP_KEY, playlistColumns } from "./playlistColumns"; // The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index 3944b966..a23aab7f 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -10,8 +10,8 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { LS, readAccountMerged, writeAccount } from "../lib/storage"; import { type PanelLayout } from "../lib/panelLayout"; import FilterRail from "./FilterRail"; -import { type PanelItem } from "./PanelGroups"; -import SortControl from "./SortControl"; +import { type PanelItem } from "./ui/PanelGroups"; +import SortControl from "./ui/SortControl"; import { type SortOption } from "../lib/columnSort"; import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider"; import { useMe } from "../lib/useMe"; diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index f6df040d..f4ed08ea 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -25,15 +25,15 @@ import { useDebounced } from "../lib/useDebounced"; import { useHistorySubview } from "../lib/history"; import { PLEX_SUB_URL, type Sub } from "./plexSubUrl"; import { toggleWatchState } from "../lib/plexWatch"; -import SortControl from "./SortControl"; -import FilterSelect from "./FilterSelect"; +import SortControl from "./ui/SortControl"; +import FilterSelect from "./ui/FilterSelect"; import PlexDiscover from "./PlexDiscover"; import { PlexCollectionsBrowse, PlexPlaylistsBrowse } from "./plexBrowseLists"; -import { PageToolbar } from "./PageShell"; +import { PageToolbar } from "./ui/PageShell"; import ActiveFilterChips from "./ActiveFilterChips"; import SelectedViewChip from "./SelectedViewChip"; -import ItemCount from "./ItemCount"; -import { StateMessage } from "./QueryState"; +import ItemCount from "./ui/ItemCount"; +import { StateMessage } from "./ui/QueryState"; import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters"; import { usePlex, usePlexActions } from "./PlexProvider"; import { usePublishPlexView } from "./PlexViewBridge"; diff --git a/frontend/src/components/PlexCollectionEditor.tsx b/frontend/src/components/PlexCollectionEditor.tsx index 17ba8b76..16855b9a 100644 --- a/frontend/src/components/PlexCollectionEditor.tsx +++ b/frontend/src/components/PlexCollectionEditor.tsx @@ -6,7 +6,7 @@ import { Check, Plus } from "lucide-react"; import { api } from "../lib/api"; import { notifyCollectionEditError } from "../lib/actionErrors"; import { useScrollFade } from "../lib/useScrollFade"; -import Modal from "./Modal"; +import Modal from "./ui/Modal"; import { useConfirm } from "./ConfirmProvider"; // Admin-only "manage this movie's collections" dialog (Collections P2). Lists the library's EDITABLE diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index fb6e7d6c..74efe5a8 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -13,7 +13,7 @@ import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi"; import { PlexPosterCard } from "./plexCards"; import PlexLibraryPicker from "./PlexLibraryPicker"; import { toggleWatchState } from "../lib/plexWatch"; -import { LoadingState, StateMessage } from "./QueryState"; +import { LoadingState, StateMessage } from "./ui/QueryState"; // The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the // ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external, diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx index 4b23af4f..858c126b 100644 --- a/frontend/src/components/PlexDiscover.tsx +++ b/frontend/src/components/PlexDiscover.tsx @@ -8,9 +8,9 @@ import PosterTile from "./PosterTile"; import { plexLang } from "../i18n"; import { toggleWatchState } from "../lib/plexWatch"; import { usePlex } from "./PlexProvider"; -import { Carousel } from "./Carousel"; +import { Carousel } from "./ui/Carousel"; import { PlexPosterCard } from "./plexCards"; -import { LoadingState, StateMessage } from "./QueryState"; +import { LoadingState, StateMessage } from "./ui/QueryState"; // The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE // /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index c4b07416..3174c666 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -16,7 +16,7 @@ import { } from "../lib/plexDetailUi"; import { usePref } from "../lib/usePref"; import i18n from "../i18n"; -import { Carousel } from "./Carousel"; +import { Carousel } from "./ui/Carousel"; import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexPlaylistAdd from "./PlexPlaylistAdd"; import { PLAYABLE_TINT, playableClass } from "./plexCards"; diff --git a/frontend/src/components/PlexLibraryPicker.tsx b/frontend/src/components/PlexLibraryPicker.tsx index a689417a..e5ed2233 100644 --- a/frontend/src/components/PlexLibraryPicker.tsx +++ b/frontend/src/components/PlexLibraryPicker.tsx @@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { api, type PlexCard } from "../lib/api"; import { useDebounced } from "../lib/useDebounced"; -import { LoadingState } from "./QueryState"; +import { LoadingState } from "./ui/QueryState"; // A debounced library search-and-pick list, shared by the "Add title" panel (PlexCollectionView) and // the "New collection" first-title panel (plexBrowseLists) — both search the library the same way and diff --git a/frontend/src/components/PlexPlaylistAdd.tsx b/frontend/src/components/PlexPlaylistAdd.tsx index 884f3385..7f7f9be2 100644 --- a/frontend/src/components/PlexPlaylistAdd.tsx +++ b/frontend/src/components/PlexPlaylistAdd.tsx @@ -5,7 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Minus, Plus } from "lucide-react"; import { api, type PlexPlaylist } from "../lib/api"; import { useScrollFade } from "../lib/useScrollFade"; -import Modal from "./Modal"; +import Modal from "./ui/Modal"; // What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a // set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist. diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index 0bb24816..4b42c3b4 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -35,7 +35,7 @@ import { api, type PlexCard } from "../lib/api"; import { formatRuntime } from "../lib/format"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { useConfirm } from "./ConfirmProvider"; -import { LoadingState, StateMessage } from "./QueryState"; +import { LoadingState, StateMessage } from "./ui/QueryState"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; // A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the diff --git a/frontend/src/components/PlexScopeToggle.tsx b/frontend/src/components/PlexScopeToggle.tsx index 6d436a69..d4a2ca15 100644 --- a/frontend/src/components/PlexScopeToggle.tsx +++ b/frontend/src/components/PlexScopeToggle.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import SegmentedToggle from "./SegmentedToggle"; +import SegmentedToggle from "./ui/SegmentedToggle"; import { usePlex, usePlexActions } from "./PlexProvider"; import { usePlexViewControls } from "./PlexViewBridge"; diff --git a/frontend/src/components/PlexSearchBox.tsx b/frontend/src/components/PlexSearchBox.tsx index 69905c6a..03b3dadd 100644 --- a/frontend/src/components/PlexSearchBox.tsx +++ b/frontend/src/components/PlexSearchBox.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { ChevronDown, Film, Tv } from "lucide-react"; import SearchBar from "./SearchBar"; -import ActionMenu from "./ActionMenu"; +import ActionMenu from "./ui/ActionMenu"; import { usePlex, usePlexActions } from "./PlexProvider"; import { api } from "../lib/api"; import { qk } from "../lib/queryKeys"; diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index 61e64dfd..fcdb92d2 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -9,10 +9,10 @@ import { type PanelLayout } from "../lib/panelLayout"; import { type MediaSourceKind } from "../lib/mediaSource"; import { useDebounced } from "../lib/useDebounced"; import FilterRail from "./FilterRail"; -import { type PanelItem } from "./PanelGroups"; -import SegmentedToggle from "./SegmentedToggle"; -import FilterChipGroup from "./FilterChipGroup"; -import Combobox, { type ComboboxOption } from "./Combobox"; +import { type PanelItem } from "./ui/PanelGroups"; +import SegmentedToggle from "./ui/SegmentedToggle"; +import FilterChipGroup from "./ui/FilterChipGroup"; +import Combobox, { type ComboboxOption } from "./ui/Combobox"; import SavedViewsPopover from "./SavedViewsPopover"; import PersonalViewsModal from "./PersonalViewsModal"; import { useSavedViews } from "./SavedViewsProvider"; diff --git a/frontend/src/components/PlexViewToggle.tsx b/frontend/src/components/PlexViewToggle.tsx index 61a60582..59fadede 100644 --- a/frontend/src/components/PlexViewToggle.tsx +++ b/frontend/src/components/PlexViewToggle.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import SegmentedToggle from "./SegmentedToggle"; +import SegmentedToggle from "./ui/SegmentedToggle"; import { usePlexViewControls } from "./PlexViewBridge"; // The Discover ⟷ Browse switch, hosted in the header next to the library-scope pill (All/Movies/Shows). diff --git a/frontend/src/components/ProfileEditor.tsx b/frontend/src/components/ProfileEditor.tsx index 7466b520..199b2689 100644 --- a/frontend/src/components/ProfileEditor.tsx +++ b/frontend/src/components/ProfileEditor.tsx @@ -3,7 +3,7 @@ import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Lock, Pencil, Plus, Trash2 } from "lucide-react"; -import Modal from "./Modal"; +import Modal from "./ui/Modal"; import { useConfirm } from "./ConfirmProvider"; import { api, type DownloadProfile, type DownloadSpec } from "../lib/api"; import { inputCls } from "./ui/form"; diff --git a/frontend/src/components/ReleaseNotes.tsx b/frontend/src/components/ReleaseNotes.tsx index 1ec3ba00..70e0631f 100644 --- a/frontend/src/components/ReleaseNotes.tsx +++ b/frontend/src/components/ReleaseNotes.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { Bug, Sparkles } from "lucide-react"; import { RELEASE_NOTES } from "../lib/releaseNotes"; -import Modal from "./Modal"; +import Modal from "./ui/Modal"; function fmtDate(d: string): string { const t = new Date(d); diff --git a/frontend/src/components/SavedViewsPopover.tsx b/frontend/src/components/SavedViewsPopover.tsx index fe2a3296..877fea69 100644 --- a/frontend/src/components/SavedViewsPopover.tsx +++ b/frontend/src/components/SavedViewsPopover.tsx @@ -1,7 +1,7 @@ import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Bookmark, Check, Settings2 } from "lucide-react"; -import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; +import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; import { useSavedViews } from "./SavedViewsProvider"; // The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index d0c2bc6d..6fae8a9c 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -19,11 +19,11 @@ import { } from "lucide-react"; import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; -import { LoadingState, StateMessage } from "./QueryState"; +import { LoadingState, StateMessage } from "./ui/QueryState"; import { useConfirm } from "./ConfirmProvider"; import { relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; -import Tooltip from "./Tooltip"; +import Tooltip from "./ui/Tooltip"; const POLL_MS = 4000; // While any job is running, poll faster so short scheduled runs aren't missed between ticks. diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 8645b5cb..0cea96a9 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -7,17 +7,17 @@ import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-r import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { api, HttpError, type Me } from "../lib/api"; import { LS, useAccountPersistedState } from "../lib/storage"; -import Avatar from "./Avatar"; +import Avatar from "./ui/Avatar"; import { notify, type NotifSettings } from "../lib/notifications"; -import Tooltip from "./Tooltip"; +import Tooltip from "./ui/Tooltip"; import { inputCls, Section, SettingRow, Switch } from "./ui/form"; import { Button } from "./ui/Button"; import { type SaveState } from "./ui/DraftSaveBar"; import { useConfirm } from "./ConfirmProvider"; -import { StateMessage } from "./QueryState"; +import { StateMessage } from "./ui/QueryState"; import { useWizard } from "./WizardProvider"; import { usePrefs } from "./PrefsProvider"; -import Tabs from "./Tabs"; +import Tabs from "./ui/Tabs"; import { useMe } from "../lib/useMe"; // The Settings page edits server-persisted preferences. Each change applies locally for instant diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index f8c72a27..5a44bf9d 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react"; import clsx from "clsx"; -import Modal from "./Modal"; +import Modal from "./ui/Modal"; import { useConfirm } from "./ConfirmProvider"; import { notify } from "../lib/notifications"; import { copyText } from "../lib/clipboard"; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index efe3baa2..c83c0e1c 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -11,12 +11,12 @@ import TagManager from "./TagManager"; import SavedViewsPopover from "./SavedViewsPopover"; import { useSavedViews } from "./SavedViewsProvider"; import FilterRail from "./FilterRail"; -import { type PanelItem } from "./PanelGroups"; +import { type PanelItem } from "./ui/PanelGroups"; import { type MediaSourceKind } from "../lib/mediaSource"; import ChannelPicker from "./ChannelPicker"; import DateFilterSelect from "./DateFilterSelect"; import YearFilter from "./YearFilter"; -import Combobox, { type ComboboxOption } from "./Combobox"; +import Combobox, { type ComboboxOption } from "./ui/Combobox"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { useChannelsActions } from "./ChannelsProvider"; import { useMe } from "../lib/useMe"; diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 734a17cd..7efc4342 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -9,10 +9,10 @@ import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { useWizard } from "./WizardProvider"; import { LS, useAccountPersistedState } from "../lib/storage"; -import Tooltip from "./Tooltip"; -import { LoadingState, StateMessage } from "./QueryState"; -import FilterChipGroup from "./FilterChipGroup"; -import Tabs from "./Tabs"; +import Tooltip from "./ui/Tooltip"; +import { LoadingState, StateMessage } from "./ui/QueryState"; +import FilterChipGroup from "./ui/FilterChipGroup"; +import Tabs from "./ui/Tabs"; import { Section, SettingRow } from "./ui/form"; import { useMe } from "../lib/useMe"; diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx index ab396906..5d04fa71 100644 --- a/frontend/src/components/SyncStatus.tsx +++ b/frontend/src/components/SyncStatus.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react"; import { api, type MyStatus } from "../lib/api"; import { formatViews } from "../lib/format"; -import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; +import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; // Per-user status (not the global catalog): shows the number of videos available to *this* // user, how many of *their* channels are still being fetched, and how many lack full diff --git a/frontend/src/components/TagFilterChips.tsx b/frontend/src/components/TagFilterChips.tsx index e2efd945..fb5590ed 100644 --- a/frontend/src/components/TagFilterChips.tsx +++ b/frontend/src/components/TagFilterChips.tsx @@ -2,8 +2,8 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { fitChipsWithPill } from "../lib/chipOverflow"; import { useChipOverflowFit } from "../lib/useChipOverflowFit"; -import { filterChipClass } from "./FilterChipGroup"; -import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover"; +import { filterChipClass } from "./ui/FilterChipGroup"; +import { useAnchoredPopover, PopoverSurface } from "./ui/AnchoredPopover"; // A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose // popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses diff --git a/frontend/src/components/TagManager.tsx b/frontend/src/components/TagManager.tsx index acb7527c..156e457e 100644 --- a/frontend/src/components/TagManager.tsx +++ b/frontend/src/components/TagManager.tsx @@ -6,9 +6,9 @@ import { Plus, Trash2 } from "lucide-react"; import { api, type Tag } from "../lib/api"; import { notify } from "../lib/notifications"; import { useScrollFade } from "../lib/useScrollFade"; -import Modal from "./Modal"; +import Modal from "./ui/Modal"; import { useConfirm } from "./ConfirmProvider"; -import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; +import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; // One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a // hover popover on the count listing the tagged channels (each a link that focuses it in the diff --git a/frontend/src/components/UploadDialog.tsx b/frontend/src/components/UploadDialog.tsx index c5eeebca..7b586b07 100644 --- a/frontend/src/components/UploadDialog.tsx +++ b/frontend/src/components/UploadDialog.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { UploadCloud, File as FileIcon } from "lucide-react"; -import Modal from "./Modal"; +import Modal from "./ui/Modal"; import { Button } from "./ui/Button"; import { api } from "../lib/api"; import { invalidateDownloads } from "../lib/downloads"; diff --git a/frontend/src/components/VerifyBanner.tsx b/frontend/src/components/VerifyBanner.tsx index b38fae61..77c1f45c 100644 --- a/frontend/src/components/VerifyBanner.tsx +++ b/frontend/src/components/VerifyBanner.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { CheckCircle2, AlertTriangle } from "lucide-react"; -import Banner from "./Banner"; +import Banner from "./ui/Banner"; import type { VerifyEntry } from "../lib/useVerifyEntry"; // The top-of-app outcome of an emailed verification link opened in a browser that ALREADY holds a diff --git a/frontend/src/components/VersionBanner.tsx b/frontend/src/components/VersionBanner.tsx index d486bb68..93843c5b 100644 --- a/frontend/src/components/VersionBanner.tsx +++ b/frontend/src/components/VersionBanner.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Sparkles } from "lucide-react"; import { FRONTEND_VERSION, SEEN_VERSION_KEY } from "../lib/version"; -import Banner from "./Banner"; +import Banner from "./ui/Banner"; // Eye-catching, dismissible bar shown once after the running build's version changes // (compares the baked frontend version to the last one the user acknowledged). diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index f50c573f..da9103da 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -14,7 +14,7 @@ import { // Aliased: `Video` is this app's own domain type (lib/api), and it wins the name. Video as VideoIcon, } from "lucide-react"; -import Avatar from "./Avatar"; +import Avatar from "./ui/Avatar"; import AddToPlaylist from "./AddToPlaylist"; import DownloadButton from "./DownloadButton"; import VideoInfoCard from "./VideoInfoCard"; diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx index 042956c9..8b5f563e 100644 --- a/frontend/src/components/VideoEditor.tsx +++ b/frontend/src/components/VideoEditor.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Crop, Eye, EyeOff, Pause, Play, Scissors, SplitSquareHorizontal, X } from "lucide-react"; import clsx from "clsx"; -import Modal from "./Modal"; +import Modal from "./ui/Modal"; import { notify } from "../lib/notifications"; import { api, type DownloadJob, type EditSpec } from "../lib/api"; import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry"; diff --git a/frontend/src/components/VideoInfoCard.tsx b/frontend/src/components/VideoInfoCard.tsx index bf95da9a..8acaad25 100644 --- a/frontend/src/components/VideoInfoCard.tsx +++ b/frontend/src/components/VideoInfoCard.tsx @@ -6,8 +6,8 @@ import { qk } from "../lib/queryKeys"; import { api, type Video } from "../lib/api"; import { formatDate, formatDuration, formatViews } from "../lib/format"; import { linkify } from "../lib/linkify"; -import Avatar from "./Avatar"; -import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; +import Avatar from "./ui/Avatar"; +import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; // The feed card's "more than the card face" hover infobox (E8·S3, decision D3): full (un-clamped) // title, channel, views·duration·date, a description snippet, and the channel's tags. It hangs off a diff --git a/frontend/src/components/ViewSwitcher.tsx b/frontend/src/components/ViewSwitcher.tsx index 836ae7cc..d1e0d730 100644 --- a/frontend/src/components/ViewSwitcher.tsx +++ b/frontend/src/components/ViewSwitcher.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { Check, ChevronDown, type LucideIcon } from "lucide-react"; import clsx from "clsx"; -import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; +import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; export interface ViewOption { id: T; diff --git a/frontend/src/components/VirtualFeed.tsx b/frontend/src/components/VirtualFeed.tsx index 5268ea39..22aa17bd 100644 --- a/frontend/src/components/VirtualFeed.tsx +++ b/frontend/src/components/VirtualFeed.tsx @@ -1,7 +1,7 @@ import type { Video, VideoStatus } from "../lib/api"; import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView"; import VideoCard from "./VideoCard"; -import VirtualGrid from "./VirtualGrid"; +import VirtualGrid from "./ui/VirtualGrid"; // Estimated row heights before measurement; real heights are measured per-row so these only affect // the very first paint and the scrollbar's initial guess. Taken from measuring each mode in the diff --git a/frontend/src/components/YearFilter.tsx b/frontend/src/components/YearFilter.tsx index 50eebe9e..12b8124e 100644 --- a/frontend/src/components/YearFilter.tsx +++ b/frontend/src/components/YearFilter.tsx @@ -4,9 +4,9 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { ChevronDown } from "lucide-react"; import { qk } from "../lib/queryKeys"; import { api, type FeedFilters } from "../lib/api"; -import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; -import { filterChipClass } from "./FilterChipGroup"; -import CountBadge from "./CountBadge"; +import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; +import { filterChipClass } from "./ui/FilterChipGroup"; +import CountBadge from "./ui/CountBadge"; import { useDebounced } from "../lib/useDebounced"; // The feed rail's "Year" control (item 8 / E8·S2): quick chips for the most recent years plus a diff --git a/frontend/src/components/adminInvites.dom.test.tsx b/frontend/src/components/adminInvites.dom.test.tsx index fb1dcbd9..12b7be7e 100644 --- a/frontend/src/components/adminInvites.dom.test.tsx +++ b/frontend/src/components/adminInvites.dom.test.tsx @@ -53,7 +53,7 @@ vi.mock("../lib/useMe", () => ({ useMe: () => meMock })); // The tab strip is rendered into an app-level toolbar slot that does not exist here, so pin the // active tab directly — this file is about the Access-requests panel, not the tab chrome. -vi.mock("./Tabs", () => ({ +vi.mock("./ui/Tabs", () => ({ default: () => null, usePersistedTab: () => ["access", () => {}], })); diff --git a/frontend/src/components/adminPendingAccess.dom.test.tsx b/frontend/src/components/adminPendingAccess.dom.test.tsx index bfedaa10..980eb7bd 100644 --- a/frontend/src/components/adminPendingAccess.dom.test.tsx +++ b/frontend/src/components/adminPendingAccess.dom.test.tsx @@ -46,7 +46,7 @@ const meMock = { vi.mock("../lib/useMe", () => ({ useMe: () => meMock })); // The tab strip renders into an app-level toolbar slot that does not exist here; pin the tab. -vi.mock("./Tabs", () => ({ +vi.mock("./ui/Tabs", () => ({ default: () => null, usePersistedTab: () => ["access", () => {}], })); diff --git a/frontend/src/components/auditColumns.tsx b/frontend/src/components/auditColumns.tsx index 25b1b3d7..40d406cc 100644 --- a/frontend/src/components/auditColumns.tsx +++ b/frontend/src/components/auditColumns.tsx @@ -2,7 +2,7 @@ import type { TFunction } from "i18next"; import { relativeTime, formatDate } from "../lib/format"; import i18n from "../i18n"; import type { AuditRow } from "../lib/api"; -import type { Column } from "./DataTable"; +import type { Column } from "./ui/DataTable"; function actionLabel(t: TFunction, action: string): string { // Canonical key → localized label; falls back to the raw key for any unmapped action. diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx index bf9082c7..b8b831db 100644 --- a/frontend/src/components/channelColumns.tsx +++ b/frontend/src/components/channelColumns.tsx @@ -4,9 +4,9 @@ import { useTranslation } from "react-i18next"; import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react"; import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api"; import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format"; -import type { Column } from "./DataTable"; -import Tooltip from "./Tooltip"; -import { PriorityStepper } from "./PriorityStepper"; +import type { Column } from "./ui/DataTable"; +import Tooltip from "./ui/Tooltip"; +import { PriorityStepper } from "./ui/PriorityStepper"; import ChannelLink from "./ChannelLink"; import AboutCell from "./AboutCell"; import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags"; diff --git a/frontend/src/components/downloadAdmin.tsx b/frontend/src/components/downloadAdmin.tsx index c3f71898..1a4a6870 100644 --- a/frontend/src/components/downloadAdmin.tsx +++ b/frontend/src/components/downloadAdmin.tsx @@ -3,7 +3,7 @@ import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Settings2 } from "lucide-react"; -import Modal from "./Modal"; +import Modal from "./ui/Modal"; import { useLiveQuery } from "../lib/useLiveQuery"; import { api } from "../lib/api"; import { formatBytes } from "../lib/format"; diff --git a/frontend/src/components/downloadModals.tsx b/frontend/src/components/downloadModals.tsx index 154d58da..0cf918e8 100644 --- a/frontend/src/components/downloadModals.tsx +++ b/frontend/src/components/downloadModals.tsx @@ -3,7 +3,7 @@ import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Plus, Trash2 } from "lucide-react"; -import Modal from "./Modal"; +import Modal from "./ui/Modal"; import { api, type DownloadJob } from "../lib/api"; import { inputCls } from "./ui/form"; import { Button } from "./ui/Button"; diff --git a/frontend/src/components/playlistColumns.tsx b/frontend/src/components/playlistColumns.tsx index 6862514a..5d01b2e3 100644 --- a/frontend/src/components/playlistColumns.tsx +++ b/frontend/src/components/playlistColumns.tsx @@ -2,7 +2,7 @@ import { Play, X } from "lucide-react"; import type { TFunction } from "i18next"; import type { Video } from "../lib/api"; import { formatDuration } from "../lib/format"; -import type { Column } from "./tableHeader"; +import type { Column } from "./ui/tableHeader"; // Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`. // diff --git a/frontend/src/components/plexBrowseLists.tsx b/frontend/src/components/plexBrowseLists.tsx index a487bd22..07187a74 100644 --- a/frontend/src/components/plexBrowseLists.tsx +++ b/frontend/src/components/plexBrowseLists.tsx @@ -8,7 +8,7 @@ import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollection } from ".. import { formatRuntime } from "../lib/format"; import { notifyCollectionEditError } from "../lib/actionErrors"; import { useDebounced } from "../lib/useDebounced"; -import { LoadingState, StateMessage } from "./QueryState"; +import { LoadingState, StateMessage } from "./ui/QueryState"; import PosterTile from "./PosterTile"; import PlexLibraryPicker from "./PlexLibraryPicker"; diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index 7608fe2e..d4d6ec73 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Star, Tv2 } from "lucide-react"; import { type CSSProperties } from "react"; import { formatRuntime } from "../lib/format"; -import { Carousel } from "./Carousel"; +import { Carousel } from "./ui/Carousel"; import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; // Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index 2aa2c5d4..45488292 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -22,10 +22,10 @@ import { WatchedToggle, } from "../lib/plexDetailUi"; import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards"; -import { Carousel } from "./Carousel"; +import { Carousel } from "./ui/Carousel"; import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; import PlexCollectionEditor from "./PlexCollectionEditor"; -import { LoadingState, StateMessage } from "./QueryState"; +import { LoadingState, StateMessage } from "./ui/QueryState"; // The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with // shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state, diff --git a/frontend/src/components/ActionMenu.dom.test.tsx b/frontend/src/components/ui/ActionMenu.dom.test.tsx similarity index 100% rename from frontend/src/components/ActionMenu.dom.test.tsx rename to frontend/src/components/ui/ActionMenu.dom.test.tsx diff --git a/frontend/src/components/ActionMenu.tsx b/frontend/src/components/ui/ActionMenu.tsx similarity index 100% rename from frontend/src/components/ActionMenu.tsx rename to frontend/src/components/ui/ActionMenu.tsx diff --git a/frontend/src/components/AnchoredPopover.dom.test.tsx b/frontend/src/components/ui/AnchoredPopover.dom.test.tsx similarity index 100% rename from frontend/src/components/AnchoredPopover.dom.test.tsx rename to frontend/src/components/ui/AnchoredPopover.dom.test.tsx diff --git a/frontend/src/components/AnchoredPopover.tsx b/frontend/src/components/ui/AnchoredPopover.tsx similarity index 99% rename from frontend/src/components/AnchoredPopover.tsx rename to frontend/src/components/ui/AnchoredPopover.tsx index 8f980402..a7a57b04 100644 --- a/frontend/src/components/AnchoredPopover.tsx +++ b/frontend/src/components/ui/AnchoredPopover.tsx @@ -36,8 +36,8 @@ import { type Placement, type UseRoleProps, } from "@floating-ui/react"; -import { useLayer } from "../lib/layerStack"; -import { useFullscreenElement } from "../lib/fullscreen"; +import { useLayer } from "../../lib/layerStack"; +import { useFullscreenElement } from "../../lib/fullscreen"; // R8 popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9 // hand-rolled positioning engines (measured flip / width clamp / rAF scroll-follow / scroller-relative diff --git a/frontend/src/components/Avatar.tsx b/frontend/src/components/ui/Avatar.tsx similarity index 100% rename from frontend/src/components/Avatar.tsx rename to frontend/src/components/ui/Avatar.tsx diff --git a/frontend/src/components/BackToTop.tsx b/frontend/src/components/ui/BackToTop.tsx similarity index 100% rename from frontend/src/components/BackToTop.tsx rename to frontend/src/components/ui/BackToTop.tsx diff --git a/frontend/src/components/Banner.tsx b/frontend/src/components/ui/Banner.tsx similarity index 100% rename from frontend/src/components/Banner.tsx rename to frontend/src/components/ui/Banner.tsx diff --git a/frontend/src/components/Carousel.dom.test.tsx b/frontend/src/components/ui/Carousel.dom.test.tsx similarity index 100% rename from frontend/src/components/Carousel.dom.test.tsx rename to frontend/src/components/ui/Carousel.dom.test.tsx diff --git a/frontend/src/components/Carousel.tsx b/frontend/src/components/ui/Carousel.tsx similarity index 98% rename from frontend/src/components/Carousel.tsx rename to frontend/src/components/ui/Carousel.tsx index 46f4affa..3ef705b4 100644 --- a/frontend/src/components/Carousel.tsx +++ b/frontend/src/components/ui/Carousel.tsx @@ -2,7 +2,7 @@ import { useCallback, type ReactNode } from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { useTranslation } from "react-i18next"; import clsx from "clsx"; -import { useScrollEdges } from "../lib/useScrollEdges"; +import { useScrollEdges } from "../../lib/useScrollEdges"; // Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing // the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future diff --git a/frontend/src/components/ColumnSortControl.tsx b/frontend/src/components/ui/ColumnSortControl.tsx similarity index 90% rename from frontend/src/components/ColumnSortControl.tsx rename to frontend/src/components/ui/ColumnSortControl.tsx index 2599ac1c..ca4c5f69 100644 --- a/frontend/src/components/ColumnSortControl.tsx +++ b/frontend/src/components/ui/ColumnSortControl.tsx @@ -1,6 +1,6 @@ import { type Column } from "./DataTable"; -import { type SortState } from "../lib/columnSort"; -import { optionsFromColumns } from "../lib/sortOptions"; +import { type SortState } from "../../lib/columnSort"; +import { optionsFromColumns } from "../../lib/sortOptions"; import SortControl from "./SortControl"; // The headerless sort control for views without clickable column headers (the channel manager's and diff --git a/frontend/src/components/Combobox.dom.test.tsx b/frontend/src/components/ui/Combobox.dom.test.tsx similarity index 100% rename from frontend/src/components/Combobox.dom.test.tsx rename to frontend/src/components/ui/Combobox.dom.test.tsx diff --git a/frontend/src/components/Combobox.tsx b/frontend/src/components/ui/Combobox.tsx similarity index 99% rename from frontend/src/components/Combobox.tsx rename to frontend/src/components/ui/Combobox.tsx index 9cf34c78..600bf6f2 100644 --- a/frontend/src/components/Combobox.tsx +++ b/frontend/src/components/ui/Combobox.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import { useTranslation } from "react-i18next"; import { Check, ChevronDown, X } from "lucide-react"; import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover"; -import { useScrollFade } from "../lib/useScrollFade"; +import { useScrollFade } from "../../lib/useScrollFade"; // The universal combobox: a react-select / Overseerr-style "combi input" — selected values are // removable chips that live INSIDE a text field you type into, and a checklist of options opens below. diff --git a/frontend/src/components/CountBadge.tsx b/frontend/src/components/ui/CountBadge.tsx similarity index 100% rename from frontend/src/components/CountBadge.tsx rename to frontend/src/components/ui/CountBadge.tsx diff --git a/frontend/src/components/DataTable.fixedLayout.dom.test.tsx b/frontend/src/components/ui/DataTable.fixedLayout.dom.test.tsx similarity index 100% rename from frontend/src/components/DataTable.fixedLayout.dom.test.tsx rename to frontend/src/components/ui/DataTable.fixedLayout.dom.test.tsx diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx similarity index 99% rename from frontend/src/components/DataTable.tsx rename to frontend/src/components/ui/DataTable.tsx index 8173ce6e..b8271eba 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/ui/DataTable.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { sortRows, cycleSort, type SortState } from "../lib/columnSort"; +import { sortRows, cycleSort, type SortState } from "../../lib/columnSort"; import Pager, { hasPagerContent } from "./Pager"; import { PageToolbar } from "./PageShell"; import { ColumnHead, alignClass, filterRows, type Column, type FilterMap } from "./tableHeader"; diff --git a/frontend/src/components/FilterChipGroup.dom.test.tsx b/frontend/src/components/ui/FilterChipGroup.dom.test.tsx similarity index 100% rename from frontend/src/components/FilterChipGroup.dom.test.tsx rename to frontend/src/components/ui/FilterChipGroup.dom.test.tsx diff --git a/frontend/src/components/FilterChipGroup.tsx b/frontend/src/components/ui/FilterChipGroup.tsx similarity index 100% rename from frontend/src/components/FilterChipGroup.tsx rename to frontend/src/components/ui/FilterChipGroup.tsx diff --git a/frontend/src/components/FilterSelect.dom.test.tsx b/frontend/src/components/ui/FilterSelect.dom.test.tsx similarity index 100% rename from frontend/src/components/FilterSelect.dom.test.tsx rename to frontend/src/components/ui/FilterSelect.dom.test.tsx diff --git a/frontend/src/components/FilterSelect.tsx b/frontend/src/components/ui/FilterSelect.tsx similarity index 100% rename from frontend/src/components/FilterSelect.tsx rename to frontend/src/components/ui/FilterSelect.tsx diff --git a/frontend/src/components/InlineNotice.tsx b/frontend/src/components/ui/InlineNotice.tsx similarity index 100% rename from frontend/src/components/InlineNotice.tsx rename to frontend/src/components/ui/InlineNotice.tsx diff --git a/frontend/src/components/ItemCount.dom.test.tsx b/frontend/src/components/ui/ItemCount.dom.test.tsx similarity index 96% rename from frontend/src/components/ItemCount.dom.test.tsx rename to frontend/src/components/ui/ItemCount.dom.test.tsx index 560adc93..5295edb4 100644 --- a/frontend/src/components/ItemCount.dom.test.tsx +++ b/frontend/src/components/ui/ItemCount.dom.test.tsx @@ -5,7 +5,7 @@ // those are the two branches worth guarding. English is forced so the singular/plural strings are stable. import { describe, it, expect, beforeAll, afterEach } from "vitest"; import { render, screen, cleanup } from "@testing-library/react"; -import "../i18n"; // side-effect: initialise the shared i18next instance +import "../../i18n"; // side-effect: initialise the shared i18next instance import i18n from "i18next"; import ItemCount from "./ItemCount"; diff --git a/frontend/src/components/ItemCount.tsx b/frontend/src/components/ui/ItemCount.tsx similarity index 100% rename from frontend/src/components/ItemCount.tsx rename to frontend/src/components/ui/ItemCount.tsx diff --git a/frontend/src/components/Modal.dom.test.tsx b/frontend/src/components/ui/Modal.dom.test.tsx similarity index 98% rename from frontend/src/components/Modal.dom.test.tsx rename to frontend/src/components/ui/Modal.dom.test.tsx index d622314c..ef886c69 100644 --- a/frontend/src/components/Modal.dom.test.tsx +++ b/frontend/src/components/ui/Modal.dom.test.tsx @@ -17,7 +17,7 @@ vi.mock("react-i18next", async (importOriginal) => { return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; }); // Isolate the focus trap from the Back-to-close history stack (tested in history.dom.test.ts). -vi.mock("../lib/history", () => ({ useBackToClose: () => {} })); +vi.mock("../../lib/history", () => ({ useBackToClose: () => {} })); import Modal from "./Modal"; diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/ui/Modal.tsx similarity index 97% rename from frontend/src/components/Modal.tsx rename to frontend/src/components/ui/Modal.tsx index 295986d1..fb819d8b 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -2,9 +2,9 @@ import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { X } from "lucide-react"; -import { useBackToClose } from "../lib/history"; -import { pushLayer, removeLayer, isTopmost } from "../lib/layerStack"; -import { useFullscreenElement } from "../lib/fullscreen"; +import { useBackToClose } from "../../lib/history"; +import { pushLayer, removeLayer, isTopmost } from "../../lib/layerStack"; +import { useFullscreenElement } from "../../lib/fullscreen"; // Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically // focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a diff --git a/frontend/src/components/Overlay.tsx b/frontend/src/components/ui/Overlay.tsx similarity index 100% rename from frontend/src/components/Overlay.tsx rename to frontend/src/components/ui/Overlay.tsx diff --git a/frontend/src/components/PageScroller.dom.test.tsx b/frontend/src/components/ui/PageScroller.dom.test.tsx similarity index 98% rename from frontend/src/components/PageScroller.dom.test.tsx rename to frontend/src/components/ui/PageScroller.dom.test.tsx index 872857be..94834eb9 100644 --- a/frontend/src/components/PageScroller.dom.test.tsx +++ b/frontend/src/components/ui/PageScroller.dom.test.tsx @@ -15,7 +15,7 @@ vi.mock("react-i18next", async (importOriginal) => { }); import PageScroller from "./PageScroller"; -import { notifyNav } from "../lib/history"; +import { notifyNav } from "../../lib/history"; beforeAll(() => { // useScrollFade wires a ResizeObserver (watch-the-children); jsdom ships none. diff --git a/frontend/src/components/PageScroller.tsx b/frontend/src/components/ui/PageScroller.tsx similarity index 98% rename from frontend/src/components/PageScroller.tsx rename to frontend/src/components/ui/PageScroller.tsx index 7733f3da..93d3749a 100644 --- a/frontend/src/components/PageScroller.tsx +++ b/frontend/src/components/ui/PageScroller.tsx @@ -1,6 +1,6 @@ import { useCallback, useLayoutEffect, useRef, type ReactNode } from "react"; -import { useScrollFade } from "../lib/useScrollFade"; -import { ensureNavId, peekNavId, subscribeNav } from "../lib/history"; +import { useScrollFade } from "../../lib/useScrollFade"; +import { ensureNavId, peekNavId, subscribeNav } from "../../lib/history"; // Saved scroll offsets, keyed by `${historyEntryId}:${scrollKey}`. The entry id ties an offset to the // exact history entry, so Back restores where you left it and a forward navigation (a new entry) opens diff --git a/frontend/src/components/PageShell.dom.test.tsx b/frontend/src/components/ui/PageShell.dom.test.tsx similarity index 100% rename from frontend/src/components/PageShell.dom.test.tsx rename to frontend/src/components/ui/PageShell.dom.test.tsx diff --git a/frontend/src/components/PageShell.tsx b/frontend/src/components/ui/PageShell.tsx similarity index 100% rename from frontend/src/components/PageShell.tsx rename to frontend/src/components/ui/PageShell.tsx diff --git a/frontend/src/components/Pager.tsx b/frontend/src/components/ui/Pager.tsx similarity index 100% rename from frontend/src/components/Pager.tsx rename to frontend/src/components/ui/Pager.tsx diff --git a/frontend/src/components/PanelGroup.tsx b/frontend/src/components/ui/PanelGroup.tsx similarity index 100% rename from frontend/src/components/PanelGroup.tsx rename to frontend/src/components/ui/PanelGroup.tsx diff --git a/frontend/src/components/PanelGroups.tsx b/frontend/src/components/ui/PanelGroups.tsx similarity index 98% rename from frontend/src/components/PanelGroups.tsx rename to frontend/src/components/ui/PanelGroups.tsx index 93892e77..82d494cb 100644 --- a/frontend/src/components/PanelGroups.tsx +++ b/frontend/src/components/ui/PanelGroups.tsx @@ -14,7 +14,7 @@ import { sortableKeyboardCoordinates, verticalListSortingStrategy, } from "@dnd-kit/sortable"; -import type { PanelLayout } from "../lib/panelLayout"; +import type { PanelLayout } from "../../lib/panelLayout"; import PanelGroup from "./PanelGroup"; export type PanelItem = { diff --git a/frontend/src/components/PriorityStepper.dom.test.tsx b/frontend/src/components/ui/PriorityStepper.dom.test.tsx similarity index 100% rename from frontend/src/components/PriorityStepper.dom.test.tsx rename to frontend/src/components/ui/PriorityStepper.dom.test.tsx diff --git a/frontend/src/components/PriorityStepper.tsx b/frontend/src/components/ui/PriorityStepper.tsx similarity index 100% rename from frontend/src/components/PriorityStepper.tsx rename to frontend/src/components/ui/PriorityStepper.tsx diff --git a/frontend/src/components/QueryState.tsx b/frontend/src/components/ui/QueryState.tsx similarity index 100% rename from frontend/src/components/QueryState.tsx rename to frontend/src/components/ui/QueryState.tsx diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md new file mode 100644 index 00000000..86cfa80d --- /dev/null +++ b/frontend/src/components/ui/README.md @@ -0,0 +1,44 @@ +# `components/ui/` — the platform layer + +Everything in this directory is a **platform component**: generic, reusable, and **domain-free**. +Everything in `components/` above it is a **module component**: it knows about videos, channels, +playlists, Plex or downloads. + +That boundary is the whole point of this directory. Before E10 S1 these 33 modules sat flat among +~160 feature components, and the platform was invisible — several of them literally describe +themselves as "the one place", "shared", "canonical", "app-wide", and were still routinely bypassed +by a hand-rolled ` + - + icon={ + u.is_suspended ? ( + + ) : ( + + ) + } + /> - + icon={} + /> ); @@ -435,25 +438,28 @@ function PendingAccessRowView({ {!row.email_verified && ( - + icon={} + /> )} {row.can_activate && ( - + )} @@ -673,12 +679,14 @@ function AdminInvites() { placeholder={t("settings.invites.addPlaceholder")} className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> - + {t("settings.invites.add")} + {decided.length > 0 && ( diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx index 0631b678..e382a24a 100644 --- a/frontend/src/components/AuditLog.tsx +++ b/frontend/src/components/AuditLog.tsx @@ -6,6 +6,7 @@ import { Trash2 } from "lucide-react"; import { api } from "../lib/api"; import { notify } from "../lib/notifications"; import { LS } from "../lib/storage"; +import { Button } from "./ui/Button"; import { Section } from "./ui/form"; import { useConfirm } from "./ConfirmProvider"; import DataTable from "./ui/DataTable"; @@ -72,14 +73,15 @@ export default function AuditLog() { persistKey={LS.auditTable} controlsPosition="top" controlsLeading={ - + } emptyText={t("audit.empty")} /> diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 2edc6817..1fff9b9c 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -10,6 +10,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { accountKey, LS } from "../lib/storage"; import { buildChannelColumns, applySyncFilter } from "./channelColumns"; import { notify } from "../lib/notifications"; +import { Button } from "./ui/Button"; import Tooltip from "./ui/Tooltip"; import DataTable from "./ui/DataTable"; import Tabs, { type TabDef } from "./ui/Tabs"; @@ -489,13 +490,15 @@ export default function Channels() { {/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate "Manage tags" button; the tooltip explains what tags are + that it's clickable. */} - + {tagFilter.length > 0 && ( @@ -518,12 +521,9 @@ export default function Channels() { {highConfidence.length > 0 && ( - + )} )} diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 2d82d550..2dc648a0 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -42,6 +42,7 @@ import { cycleSort, type SortState } from "../lib/columnSort"; import { formatRuntime } from "../lib/format"; import { applyView, sameOrder } from "../lib/playlistView"; const PlayerModal = lazy(() => import("./PlayerModal")); +import { Button } from "./ui/Button"; import UndoToolbar from "./ui/UndoToolbar"; import { PageToolbar } from "./ui/PageShell"; import { useChoice, useConfirm } from "./ConfirmProvider"; @@ -585,12 +586,9 @@ export default function Playlists() { {t("playlists.loadFailed")} - + ); } @@ -703,25 +701,26 @@ export default function Playlists() { )} {linked && detail.dirty && ( - + )} {editable && ( - + icon={} + /> )} @@ -755,12 +754,14 @@ export default function Playlists() { )} {canSaveOrder && ( - + {t("playlists.saveOrder")} + )} {/* ONE sentence that names the state. Three loose texts used to pile up here (reset link + save hint + "reordering is off"), and none of them answered the diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index a23aab7f..918ab859 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -10,6 +10,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { LS, readAccountMerged, writeAccount } from "../lib/storage"; import { type PanelLayout } from "../lib/panelLayout"; import FilterRail from "./FilterRail"; +import { Button } from "./ui/Button"; import { type PanelItem } from "./ui/PanelGroups"; import SortControl from "./ui/SortControl"; import { type SortOption } from "../lib/columnSort"; @@ -147,14 +148,10 @@ export default function PlaylistsRail({ title: t("playlists.manageGroup"), body: (
- + {playlists.length > 1 && ( ) : ( - + )}
@@ -676,7 +673,7 @@ export function DownloadAuth() {
{h.configured && ( +
diff --git a/frontend/src/components/ui/Button.dom.test.tsx b/frontend/src/components/ui/Button.dom.test.tsx index 6db53608..dc20d45a 100644 --- a/frontend/src/components/ui/Button.dom.test.tsx +++ b/frontend/src/components/ui/Button.dom.test.tsx @@ -71,6 +71,33 @@ describe("Button primitive (S5)", () => { expect(onClick).not.toHaveBeenCalled(); }); + it("a link gets no button padding, and an explicit size still wins", () => { + // The size default is variant-dependent (link → "none"). Locking it because the failure is + // silent: a link that quietly inherits px-3 py-2 reads as a button, and nothing errors. + const { rerender } = render(); + let btn = screen.getByRole("button", { name: "Show all" }); + expect(btn.className).toContain("text-accent"); + expect(btn.className).not.toContain("px-3"); + rerender( + , + ); + btn = screen.getByRole("button", { name: "Show all" }); + expect(btn.className).toContain("px-3"); + }); + + it("keeps one font-weight utility whatever the variant", () => { + // Two font-* utilities of equal specificity are decided by stylesheet order, not class order, + // so a variant must never add a second one (there is no tailwind-merge here). + for (const variant of ["primary", "link", "glass", "outline"] as const) { + const { unmount } = render(); + const cls = screen.getByRole("button", { name: "X" }).className; + expect(cls.split(/\s+/).filter((c) => c.startsWith("font-")).length).toBe(1); + unmount(); + } + }); + it("fullWidth adds the w-full utility", () => { render(); expect(screen.getByRole("button", { name: "Wide" }).className).toContain("w-full"); diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index 12b44960..31baf14b 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -17,25 +17,45 @@ type ButtonVariant = | "primary" // solid accent — the main affirmative action | "danger" // solid danger — destructive confirm | "outline" // bordered, quiet — secondary action (Full history, Subscribe, Cancel-with-border) + | "outline-accent" // bordered + accent-tinted — an affirmative secondary (Activate, Approve) + | "outline-danger" // neutral at rest, danger on hover — the row-level Delete icon + | "outline-warn" // bordered + warn-tinted — a reversible restriction that is currently ON | "ghost" // borderless, hover-fill — tertiary/dismiss (dialog Cancel, close) | "glass" // glass card — the Plex/overlay action buttons - | "danger-outline"; // bordered danger — a destructive action that shouldn't shout + | "danger-soft" // bordered AND danger-coloured at rest — a destructive action that shouldn't shout + | "link"; // inline text link — "Show all", "Learn more", a resend-mail prompt inside a sentence type ButtonSize = | "xs" // dense inline action | "sm" // the btnCls geometry (px-3 py-1.5) | "md" // default action button (px-3 py-2) | "lg" // prominent CTA - | "icon"; // square icon-only (w-9 h-9) + | "icon" // square icon-only (w-9 h-9) + | "icon-sm" // square icon-only, dense (w-8 h-8) — toolbars and table rows + | "none"; // no padding/radius of its own — the `link` variant's default const VARIANTS: Record = { // .btn-primary / .btn-danger own the fill + one hover-dim (index.css @layer components). primary: "btn-primary", danger: "btn-danger", - outline: "border border-border text-muted hover:text-fg hover:border-fg/40", - ghost: "text-muted hover:text-fg hover:bg-surface", + // E10 S1b: the neutral variants were RE-AIMED at the paint the app actually uses. Counting the + // un-migrated call sites showed the majority hovering to `accent` (outline) and filling with `card` + // (ghost), while this primitive hovered to `fg` and filled with `surface` — so the 37 migrated sites + // and the ~100 hand-rolled ones had drifted into two different looks. That split IS the + // inconsistency this epic exists to remove; the primitive follows the majority, not the reverse. + outline: "border border-border text-muted hover:text-accent hover:border-accent", + "outline-accent": "border border-accent/40 text-accent hover:bg-accent/10", + // Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference + // is whether the button should advertise the destruction before the pointer arrives. + "outline-danger": "border border-border text-muted hover:text-danger hover:border-danger/40", + "outline-warn": "border border-warn/40 text-warn hover:bg-warn/10", + ghost: "text-muted hover:text-fg hover:bg-card", glass: "glass-card glass-hover hover:text-accent", - "danger-outline": "border border-danger/60 text-danger hover:bg-danger/10 hover:border-danger", + "danger-soft": "border border-danger/60 text-danger hover:bg-danger/10 hover:border-danger", + // No font-weight here on purpose: BASE already sets font-medium, and a second font-* utility would + // be a coin-flip (both are single-class utilities of equal specificity, and without tailwind-merge + // the winner is decided by the generated stylesheet order, not by the class list order). + link: "text-accent hover:underline", }; const SIZES: Record = { @@ -44,6 +64,8 @@ const SIZES: Record = { md: "px-3 py-2 text-sm rounded-lg gap-1.5", lg: "px-4 py-2.5 text-base rounded-lg gap-2", icon: "w-9 h-9 rounded-lg justify-center", + "icon-sm": "w-8 h-8 rounded-lg justify-center", + none: "gap-1.5", }; const BASE = @@ -51,6 +73,7 @@ const BASE = export interface ButtonProps extends ButtonHTMLAttributes { variant?: ButtonVariant; + /** Defaults to "md", except for `variant="link"`, whose default is "none" (a link owns no padding). */ size?: ButtonSize; /** Leading icon (an lucide element). Replaced by a spinner while `loading`. */ icon?: ReactNode; @@ -67,7 +90,7 @@ export interface ButtonProps extends ButtonHTMLAttributes { export const Button = forwardRef(function Button( { variant = "primary", - size = "md", + size, icon, iconOnly = false, label, @@ -81,6 +104,10 @@ export const Button = forwardRef(function Button }, ref, ) { + // A link must not inherit the action-button padding/radius, so its size default differs. Resolved + // here rather than by asking every call site to pass `size="none"` alongside `variant="link"` — + // a pairing everyone would eventually forget, which is how a "link" grows a button's box. + const resolvedSize: ButtonSize = size ?? (variant === "link" ? "none" : "md"); const leading = loading ? : icon; return ( + icon={} + /> )} {ch?.subscribed && !me.is_demo && (
diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx index da6d128d..cfcd63b8 100644 --- a/frontend/src/components/ChatThread.tsx +++ b/frontend/src/components/ChatThread.tsx @@ -6,6 +6,7 @@ import { Send } from "lucide-react"; import { api, HttpError, type Message, type MessageUser } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { useScrollFade } from "../lib/useScrollFade"; +import { Button } from "./ui/Button"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; @@ -213,15 +214,16 @@ export default function ChatThread({ placeholder={t("messages.writePlaceholder")} className="flex-1 resize-none max-h-28 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> - + icon={} + />
)} diff --git a/frontend/src/components/KeyGate.tsx b/frontend/src/components/KeyGate.tsx index 1b7a7dc2..423a6933 100644 --- a/frontend/src/components/KeyGate.tsx +++ b/frontend/src/components/KeyGate.tsx @@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, Lock, ShieldCheck } from "lucide-react"; import { api, HttpError } from "../lib/api"; import * as e2ee from "../lib/e2ee"; +import { Button } from "./ui/Button"; import { inputCls } from "./ui/form"; // Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device. @@ -107,21 +108,17 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa )} {err &&
{err}
}
- - +
); diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx index d7ed17a6..b61695bc 100644 --- a/frontend/src/components/Messages.tsx +++ b/frontend/src/components/Messages.tsx @@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query"; import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react"; import { api, type Conversation, type MessageUser } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; +import { Button } from "./ui/Button"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { relativeTime } from "../lib/format"; import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging"; @@ -123,13 +124,10 @@ function ConversationList({ {t("messages.conversations")} {ready && ( - + )} {(keyState === "setup" || keyState === "unlock") && } @@ -213,12 +211,12 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac return (
- + icon={} + />
{t("messages.newTitle")}
- + icon={} + />
- - + icon={} + />
- +
)}
@@ -305,43 +308,45 @@ function NotificationRow({ )} {isScheduler && ( - + )} {isDownloadDone && ( - + )}
{!n.read && ( - + icon={} + /> )} - + icon={} + />
); @@ -396,47 +401,52 @@ function ClientActivityRow({ {hidden ? (
- - +
) : watched ? (
- - +
) : subscribed ? (
- +
) : access ? ( - + ) : ( n.action && !n.dismissed && ( - + ) )} - + icon={} + /> ); } diff --git a/frontend/src/components/PlexCollectionEditor.tsx b/frontend/src/components/PlexCollectionEditor.tsx index 16855b9a..6182d8b5 100644 --- a/frontend/src/components/PlexCollectionEditor.tsx +++ b/frontend/src/components/PlexCollectionEditor.tsx @@ -6,6 +6,7 @@ import { Check, Plus } from "lucide-react"; import { api } from "../lib/api"; import { notifyCollectionEditError } from "../lib/actionErrors"; import { useScrollFade } from "../lib/useScrollFade"; +import { Button } from "./ui/Button"; import Modal from "./ui/Modal"; import { useConfirm } from "./ConfirmProvider"; @@ -138,14 +139,16 @@ export default function PlexCollectionEditor({ placeholder={t("plex.collEditor.newPlaceholder")} className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> - + {/* Filter */} diff --git a/frontend/src/components/PlexPlaylistAdd.tsx b/frontend/src/components/PlexPlaylistAdd.tsx index 7f7f9be2..76d37f58 100644 --- a/frontend/src/components/PlexPlaylistAdd.tsx +++ b/frontend/src/components/PlexPlaylistAdd.tsx @@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Minus, Plus } from "lucide-react"; import { api, type PlexPlaylist } from "../lib/api"; import { useScrollFade } from "../lib/useScrollFade"; +import { Button } from "./ui/Button"; import Modal from "./ui/Modal"; // What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a @@ -102,14 +103,16 @@ export default function PlexPlaylistAdd({ placeholder={t("plex.playlistAdd.newPlaceholder")} className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> - +
- +
diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index 5a44bf9d..e95a0b29 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -138,13 +138,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void } value={fullUrl} className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-hidden" /> - + + )} {/* No resend CTA here, unlike the invalid banner below: the token was NOT spent — we simply @@ -564,9 +565,9 @@ function AuthCard({ verify }: { verify: VerifyEntry }) { {verifyOutcome === "invalid" && ( {t("welcome.auth.verifyInvalid")}{" "} - + )} {bounced === "requested" && @@ -679,15 +680,15 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
{mode === "signin" ? ( <> - + ) : ( - + )}
@@ -820,9 +821,9 @@ function StatePanel({

{children}
- + {cta && ( +
{q.isError && !q.data ? ( q.refetch()}> @@ -166,13 +164,10 @@ export function PlexPersonView({ return (
- +
{q.isError && !d ? ( q.refetch()}> diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index 31baf14b..e7b47d7a 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -36,26 +36,28 @@ type ButtonSize = const VARIANTS: Record = { // .btn-primary / .btn-danger own the fill + one hover-dim (index.css @layer components). - primary: "btn-primary", - danger: "btn-danger", + primary: "font-medium btn-primary", + danger: "font-medium btn-danger", // E10 S1b: the neutral variants were RE-AIMED at the paint the app actually uses. Counting the // un-migrated call sites showed the majority hovering to `accent` (outline) and filling with `card` // (ghost), while this primitive hovered to `fg` and filled with `surface` — so the 37 migrated sites // and the ~100 hand-rolled ones had drifted into two different looks. That split IS the // inconsistency this epic exists to remove; the primitive follows the majority, not the reverse. - outline: "border border-border text-muted hover:text-accent hover:border-accent", - "outline-accent": "border border-accent/40 text-accent hover:bg-accent/10", + outline: "font-medium border border-border text-muted hover:text-accent hover:border-accent", + "outline-accent": "font-medium border border-accent/40 text-accent hover:bg-accent/10", // Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference // is whether the button should advertise the destruction before the pointer arrives. - "outline-danger": "border border-border text-muted hover:text-danger hover:border-danger/40", - "outline-warn": "border border-warn/40 text-warn hover:bg-warn/10", - ghost: "text-muted hover:text-fg hover:bg-card", - glass: "glass-card glass-hover hover:text-accent", - "danger-soft": "border border-danger/60 text-danger hover:bg-danger/10 hover:border-danger", + "outline-danger": + "font-medium border border-border text-muted hover:text-danger hover:border-danger/40", + "outline-warn": "font-medium border border-warn/40 text-warn hover:bg-warn/10", + ghost: "font-medium text-muted hover:text-fg hover:bg-card", + glass: "font-medium glass-card glass-hover hover:text-accent", + "danger-soft": + "font-medium border border-danger/60 text-danger hover:bg-danger/10 hover:border-danger", // No font-weight here on purpose: BASE already sets font-medium, and a second font-* utility would // be a coin-flip (both are single-class utilities of equal specificity, and without tailwind-merge // the winner is decided by the generated stylesheet order, not by the class list order). - link: "text-accent hover:underline", + link: "font-semibold text-accent hover:underline", }; const SIZES: Record = { @@ -68,8 +70,11 @@ const SIZES: Record = { none: "gap-1.5", }; +// The font-weight is owned by each VARIANT, not by BASE: `link` wants semibold (that is what a text +// link looks like everywhere in this app) and the action buttons want medium. Two font-* utilities on +// one element is a coin flip without tailwind-merge, so exactly one has to emit it — see the test. const BASE = - "inline-flex items-center justify-center font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"; + "inline-flex items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed"; export interface ButtonProps extends ButtonHTMLAttributes { variant?: ButtonVariant; From 721467ef69982f4ee3f5a07fdce6c18cfb88cb95 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 5 Sep 2026 18:41:41 +0200 Subject: [PATCH 04/17] feat(e10): finish the mappable Button migration, including the ui/ layer itself Third sweep, 20 more sites: the rest of AdminUsers (the access-request and invite clusters), Scheduler, SettingsPanel, SetupWizard, About, Feed, KeyGate, OnboardingWizard -- and, importantly, four platform components that were still hand-rolling their own buttons: Modal's close, Banner's dismiss, SidePanel's two toggles and QueryState's retry. A platform layer that bypasses its own primitive is the same defect one level up. Hand-rolled buttons with their own geometry: 110 -> 39; Button call sites 37 -> 106. What is left is deliberate, and it is the list Button's own docstring excludes plus two families this pass refused to force: video overlay controls painted on white/N, drag handles, chip removers, accent-solid pills, the bg-card family on the signed-out screen, and sites whose paint is conditional (`suspended ? warn : neutral`) or otherwise has no variant yet. Forcing those through a className override would be unsafe without tailwind-merge, which is exactly the hazard this sprint documented. 3x tsc, eslint, knip, prettier, 689 vitest green; UAT on Settings and the About dialog (its close button and full-width CTA are both migrated), clean console. --- frontend/src/components/About.tsx | 8 ++- frontend/src/components/AdminUsers.tsx | 51 ++++++++++---------- frontend/src/components/Feed.tsx | 15 +++--- frontend/src/components/KeyGate.tsx | 8 +-- frontend/src/components/OnboardingWizard.tsx | 15 +++--- frontend/src/components/Scheduler.tsx | 9 ++-- frontend/src/components/SettingsPanel.tsx | 15 +++--- frontend/src/components/SetupWizard.tsx | 9 ++-- frontend/src/components/ui/Banner.tsx | 12 +++-- frontend/src/components/ui/Modal.tsx | 12 +++-- frontend/src/components/ui/QueryState.tsx | 9 ++-- frontend/src/components/ui/SidePanel.tsx | 20 +++++--- 12 files changed, 90 insertions(+), 93 deletions(-) diff --git a/frontend/src/components/About.tsx b/frontend/src/components/About.tsx index 1b28abeb..9da74868 100644 --- a/frontend/src/components/About.tsx +++ b/frontend/src/components/About.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { Info, Sparkles } from "lucide-react"; import { api } from "../lib/api"; import { FRONTEND_VERSION, FRONTEND_SHA, FRONTEND_BUILD_DATE } from "../lib/version"; +import { Button } from "./ui/Button"; import Modal from "./ui/Modal"; function fmtDate(d?: string | null): string { @@ -70,12 +71,9 @@ export default function About({ ))}
- + ); } diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 9d6b9b0b..55333a27 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -828,14 +828,15 @@ function AdminDemo() { )}
- + icon={} + />
))} @@ -856,12 +857,9 @@ function AdminDemo() { placeholder={t("settings.demo.addPlaceholder")} className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> - +
@@ -917,24 +915,26 @@ function InviteRow({ )}
- + icon={} + /> - + icon={} + /> {/* Deny records a decision and keeps the row; delete forgets the address. Once an account exists for it the row is that account's approval gate, so it is managed from Users & roles @@ -954,14 +954,15 @@ function InviteRow({ ) : ( - + icon={} + /> )}
diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 46fb5979..9d55c5bf 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -38,6 +38,7 @@ import { FEED_VIEWS, type FeedView } from "../lib/feedView"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; import ActiveFilterChips from "./ActiveFilterChips"; import SelectedViewChip from "./SelectedViewChip"; +import { Button } from "./ui/Button"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { PageToolbar } from "./ui/PageShell"; import { useWizard } from "./WizardProvider"; @@ -530,20 +531,18 @@ export default function Feed({ {isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}

{isDemo ? ( - + ) : ( <> - + + {configured && ( - + )} @@ -225,15 +223,16 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () > {t("onboarding.write.enableUnsubscribe")} - + )} diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 6fae8a9c..eb5cdd5b 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -19,6 +19,7 @@ import { } from "lucide-react"; import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; +import { Button } from "./ui/Button"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { useConfirm } from "./ConfirmProvider"; import { relativeTime } from "../lib/format"; @@ -283,13 +284,9 @@ function MaintenanceSettings({ aria-label={t("scheduler.maintenance.batchLabel")} className="w-28 bg-surface border border-border rounded-sm px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> - +
{t("scheduler.maintenance.batchNote", { diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 26c77128..e473d7b8 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -495,13 +495,15 @@ function SignInMethods({ me }: { me: Me }) { {t("settings.account.sessions.hint")}

- + @@ -830,13 +832,10 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {

{t("settings.account.deleteHint")}

- + )} diff --git a/frontend/src/components/SetupWizard.tsx b/frontend/src/components/SetupWizard.tsx index 9b53654d..705e53fe 100644 --- a/frontend/src/components/SetupWizard.tsx +++ b/frontend/src/components/SetupWizard.tsx @@ -4,6 +4,7 @@ import { Check, ChevronLeft, ChevronRight, Loader2, Send } from "lucide-react"; import { api, HttpError } from "../lib/api"; import { setLanguage, type LangCode } from "../i18n"; import LanguageSwitcher from "./LanguageSwitcher"; +import { Button } from "./ui/Button"; import { inputCls } from "./ui/form"; // First-run install wizard. Shown by App.tsx while the instance is unconfigured. The one-time @@ -239,15 +240,17 @@ export default function SetupWizard() { placeholder={t("setup.smtp.testTo")} className={inputCls} /> - + )} {testState === "ok" && ( diff --git a/frontend/src/components/ui/Banner.tsx b/frontend/src/components/ui/Banner.tsx index e175ab9b..afcba0a1 100644 --- a/frontend/src/components/ui/Banner.tsx +++ b/frontend/src/components/ui/Banner.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import type { LucideIcon } from "lucide-react"; import { X } from "lucide-react"; +import { Button } from "./Button"; type BannerTone = "accent" | "warning"; @@ -56,13 +57,14 @@ export default function Banner({ )} {onDismiss && ( - + icon={} + /> )} ); diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index fb819d8b..20db87a0 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -5,6 +5,7 @@ import { X } from "lucide-react"; import { useBackToClose } from "../../lib/history"; import { pushLayer, removeLayer, isTopmost } from "../../lib/layerStack"; import { useFullscreenElement } from "../../lib/fullscreen"; +import { Button } from "./Button"; // Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically // focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a @@ -141,14 +142,15 @@ export default function Modal({

{title}

- + icon={} + />
{children}
diff --git a/frontend/src/components/ui/QueryState.tsx b/frontend/src/components/ui/QueryState.tsx index fa397957..2aa85523 100644 --- a/frontend/src/components/ui/QueryState.tsx +++ b/frontend/src/components/ui/QueryState.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { AlertTriangle, Loader2 } from "lucide-react"; +import { Button } from "./Button"; // Centered loading state: a spinning accent ring + an optional label. The single shared loading // visual for a page/section's first load, so all of them read the same (and can be swapped for @@ -48,13 +49,9 @@ export function StateMessage({ {children} {onRetry && ( - + )} ); diff --git a/frontend/src/components/ui/SidePanel.tsx b/frontend/src/components/ui/SidePanel.tsx index 91cdbdee..99169f37 100644 --- a/frontend/src/components/ui/SidePanel.tsx +++ b/frontend/src/components/ui/SidePanel.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react"; import { useScrollFade } from "../../lib/useScrollFade"; import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin"; +import { Button } from "./Button"; // The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read as // one system, and the same peek-open model as the nav rail: unpinned it sits as a small tab docked @@ -101,14 +102,16 @@ export default function SidePanel({ {/* Row 1 — identity only: pin, icon, title (flexes + truncates last), count, customize. Module actions live on their own row below so the title always has room. */}
- + {iconExpanded ?? {icon}} {onTitleClick ? ( + icon={} + /> )} {onToggleEditing && ( ); + expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target"); + unmount(); + render(); + expect(screen.getByRole("button", { name: "Read more" }).className).not.toContain( + "touch-target", + ); + }); + it("fullWidth adds the w-full utility", () => { render(); expect(screen.getByRole("button", { name: "Wide" }).className).toContain("w-full"); diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index e7b47d7a..15b2bc79 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -60,6 +60,11 @@ const VARIANTS: Record = { link: "font-semibold text-accent hover:underline", }; +// The geometry ladder, and the whole of it — a call site picks a rung, it does not invent one. The +// rungs were derived from what the app actually used (counted during E10 S1b): `px-3 py-2 text-sm` +// (22 sites), `px-2 py-1 text-xs` (12), `px-3 py-1.5 text-sm` (11). Every value is rem-derived, so +// the Settings text-size slider (--font-scale, applied to the root font-size) scales the padding and +// the box with the label instead of leaving a shrunken button around bigger text. const SIZES: Record = { xs: "px-2 py-1 text-xs rounded-md gap-1", sm: "px-3 py-1.5 text-sm rounded-lg gap-1.5", @@ -113,6 +118,11 @@ export const Button = forwardRef(function Button // here rather than by asking every call site to pass `size="none"` alongside `variant="link"` — // a pairing everyone would eventually forget, which is how a "link" grows a button's box. const resolvedSize: ButtonSize = size ?? (variant === "link" ? "none" : "md"); + // The 1024x768 comfort contract lives HERE, not in the call sites: on a coarse pointer every + // button-shaped control gets at least a 2.75rem box (see .touch-target in index.css). `link` is + // excluded on purpose — it is a run of text inside a sentence, and a 44px-tall inline link would + // wreck the paragraph it sits in rather than make anything easier to hit. + const touchTarget = variant !== "link"; const leading = loading ? : icon; return ( - ))} + ) : ( + + ), + )}
+ ), }, diff --git a/frontend/src/components/ui/ActionMenu.tsx b/frontend/src/components/ui/ActionMenu.tsx index b14bb497..dd441fb5 100644 --- a/frontend/src/components/ui/ActionMenu.tsx +++ b/frontend/src/components/ui/ActionMenu.tsx @@ -1,19 +1,166 @@ -import { useState, type ReactNode } from "react"; -import { MoreHorizontal } from "lucide-react"; +import { Fragment, useState, type ReactNode } from "react"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; // A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions. // Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher // and LanguageSwitcher. Use it to keep occasional actions out of a crowded toolbar. The items are // fire-and-close (`onSelect`); a `disabled` item (e.g. a mutation already in flight) is inert. +// +// E10 S2 split the ITEM LIST out of the trigger (`MenuItemList`) so the ⋯ menu and the right-click +// context menu (`ContextMenu`) render the same rows from the same item type. That is the whole point +// of the split: a context menu that drifts from the overflow menu beside it is two menus, and the +// user has to learn both. -export interface ActionMenuItem { +/** Shared surface paint for every menu built on this file — the ⋯ menu, its submenus, and + * `ContextMenu`. One constant so a context menu can never look like a different component. */ +export const MENU_SURFACE = "glass-menu min-w-52 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"; +const ROW = + "flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition disabled:opacity-50"; + +interface MenuItemBase { id: string; label: string; icon?: ReactNode; // an element (so a caller can swap in a spinning icon while pending) - onSelect: () => void; disabled?: boolean; title?: string; // optional native tooltip (carries a longer hint the label alone can't) + /** Draw a divider above this item. Ignored on the first item — a menu never opens with a rule. */ + separatorBefore?: boolean; +} + +interface ActionItem extends MenuItemBase { + onSelect: () => void; + /** Destructive action: painted in the danger colour, like the outline-danger buttons. */ + danger?: boolean; + items?: never; +} + +interface SubmenuItem extends MenuItemBase { + /** Non-empty → this row opens a submenu instead of firing. */ + items: readonly ActionMenuItem[]; + onSelect?: never; +} + +// A discriminated union rather than "onSelect and items are both optional": the invariant is +// "a row either fires or it opens a submenu, never both and never neither", and this codebase's +// house rule is that such a rule lives in the type, not in a comment above it. +export type ActionMenuItem = ActionItem | SubmenuItem; + +const isSubmenu = (it: ActionMenuItem): it is SubmenuItem => Array.isArray(it.items); + +/** The rows of a menu: leaf actions, submenus and separators, from one item array. Rendered inside a + * `PopoverSurface` (which supplies the list registry) — see `ActionMenu` and `ContextMenu`. + * `onClose` closes the ROOT menu, so selecting deep inside a submenu dismisses the whole stack. */ +export function MenuItemList({ + items, + onClose, +}: { + items: readonly ActionMenuItem[]; + onClose: () => void; +}) { + return ( + <> + {items.map((it, i) => ( + + {it.separatorBefore && i > 0 && ( + - {/* The app-wide keyboard cheat sheet + the global `h` that opens it. Mounted once, outside - the page area, so every surface answers "what can I press here" the same way. */} + {/* The two app-wide overlays: the keyboard cheat sheet (`h`) and the jump-to palette (Mod+K). + Mounted once, outside the page area, so every surface answers "what can I press here" and + "take me to X" the same way. Their keys are bound by useGlobalKeymap above. */} + {meQuery.data && !meQuery.data.is_demo && } {aboutOpen && ( diff --git a/frontend/src/components/AppCommandPalette.tsx b/frontend/src/components/AppCommandPalette.tsx new file mode 100644 index 00000000..500ef172 --- /dev/null +++ b/frontend/src/components/AppCommandPalette.tsx @@ -0,0 +1,158 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { Keyboard, ListVideo, Palette, Tv } from "lucide-react"; +import CommandPalette, { type CommandGroup } from "./ui/CommandPalette"; +import { api } from "../lib/api"; +import { qk } from "../lib/queryKeys"; +import { useMe } from "../lib/useMe"; +import { moduleDef, moduleOrder } from "../lib/modules"; +import { SCHEMES, type Scheme } from "../lib/theme"; +import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap"; +import { useNavigationActions } from "./NavigationProvider"; +import { usePlaylistsActions } from "./PlaylistsProvider"; +import { usePrefs } from "./PrefsProvider"; +import { useSavedViews } from "./SavedViewsProvider"; + +// What Ctrl/Cmd+K can reach. The generic list/filter/keyboard shell is `ui/CommandPalette`; this is +// the domain half — the layer boundary says a primitive may not import `lib/api` or a module. +// +// Scope decided with the product owner (2026-09-05): navigation + app-wide ACTIONS. The actions here +// are the reversible, local ones — the shortcuts sheet and the theme. Deliberately NOT here: "Sync +// from YouTube". It is a long-running job against a quota, it wants the progress and error surface +// the Channels page already gives it, and one mistyped Enter is a worse failure than the walk it +// would have saved. +// +// Saved views come from whichever module owns the current SavedViewsProvider source (feed on the +// feed, Plex on Plex), i.e. exactly the views that are applicable where you stand. +export default function AppCommandPalette() { + const { t } = useTranslation(); + const open = usePaletteOpen(); + const me = useMe(); + const { setPage, openChannel } = useNavigationActions(); + const { setSelectedId } = usePlaylistsActions(); + const { theme, setTheme } = usePrefs(); + const savedViews = useSavedViews(); + + // Only fetch the lists while the palette is actually open — it is a rarely-opened surface and + // these are otherwise the feed's and the playlist rail's own queries, shared through the cache. + const channelsQ = useQuery({ + queryKey: qk.channels(), + queryFn: () => api.channels(), + enabled: open, + }); + const playlistsQ = useQuery({ + queryKey: qk.playlists(), + queryFn: () => api.playlists(), + enabled: open, + }); + + const groups = useMemo(() => { + const out: CommandGroup[] = []; + + // Modules, in nav-rail order and gated by the account — the registry decides, not this file. + if (me) { + const pages = [...moduleOrder(me), "settings" as const]; + out.push({ + id: "modules", + label: t("palette.groups.modules"), + items: pages.map((page) => { + const def = moduleDef(page); + const Icon = def.icon; + return { + id: `page:${page}`, + label: t(def.labelKey), + icon: , + // The longer tab title is a second name for the same place ("Channel manager" for + // "Channels"), so match on it even though the row shows the short one. + keywords: def.titleKey ? t(def.titleKey) : "", + run: () => setPage(page), + }; + }), + }); + } + + const channels = channelsQ.data ?? []; + if (channels.length > 0) { + out.push({ + id: "channels", + label: t("palette.groups.channels"), + items: channels + .filter((c) => !c.hidden) + .map((c) => ({ + id: `channel:${c.id}`, + label: c.title ?? c.id, + hint: c.handle ?? undefined, + icon: , + keywords: c.handle ?? "", + run: () => openChannel(c.id, c.title ?? undefined), + })), + }); + } + + const playlists = playlistsQ.data ?? []; + if (playlists.length > 0) { + out.push({ + id: "playlists", + label: t("palette.groups.playlists"), + items: playlists.map((p) => ({ + id: `playlist:${p.id}`, + label: p.name, + hint: t("palette.videoCount", { count: p.item_count }), + icon: , + run: () => { + setSelectedId(p.id); + setPage("playlists"); + }, + })), + }); + } + + if (savedViews.views.length > 0) { + out.push({ + id: "views", + label: t("palette.groups.views"), + items: savedViews.views.map((v) => ({ + id: `view:${v.id}`, + label: v.name, + run: () => savedViews.apply(v), + })), + }); + } + + out.push({ + id: "actions", + label: t("palette.groups.actions"), + items: [ + { + id: "action:shortcuts", + label: t("keys.title"), + icon: , + run: toggleKeyboardHelp, + }, + ...SCHEMES.map((s) => ({ + id: `action:theme:${s.id}`, + label: t("palette.theme", { name: s.name }), + icon: , + keywords: s.id, + run: () => setTheme({ ...theme, scheme: s.id as Scheme }), + })), + ], + }); + + return out; + }, [ + t, + me, + channelsQ.data, + playlistsQ.data, + savedViews, + theme, + setTheme, + setPage, + openChannel, + setSelectedId, + ]); + + return ; +} diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 9f738691..c9666d11 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -45,7 +45,7 @@ import { import { renderDescription } from "../lib/descriptionLinks"; import { fullscreenElement } from "../lib/fullscreen"; import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap"; -import { useBackToClose } from "../lib/history"; +import { closeThenNavigate, useBackToClose } from "../lib/history"; import { useNativeControlsYield, useYtBigView, @@ -756,16 +756,11 @@ export default function PlayerModal({ onClick={() => { const cid = active.channel_id; const cname = active.channel_title ?? undefined; - // Closing the player pops its own history entry (useBackToClose → history.back()). - // Open the channel only AFTER that pop's popstate — pushing the channel entry - // before it would let the back remove it again (the bug: clicking the name only - // closed the player). One-shot listener fires after the popstate handler. - const open = () => { - window.removeEventListener("popstate", open); - openChannel(cid, cname); - }; - window.addEventListener("popstate", open); - onClose(); + // Close first, open the channel only once this player's own Back-to-close + // entry has been reclaimed — otherwise that reclaim undoes the push and + // clicking the name merely closes the player. The rule lives in history.ts + // now; the command palette needs exactly the same one. + closeThenNavigate(onClose, () => openChannel(cid, cname)); }} className="font-medium hover:text-accent truncate text-left" > diff --git a/frontend/src/components/ui/CommandPalette.dom.test.tsx b/frontend/src/components/ui/CommandPalette.dom.test.tsx new file mode 100644 index 00000000..9a2e6244 --- /dev/null +++ b/frontend/src/components/ui/CommandPalette.dom.test.tsx @@ -0,0 +1,171 @@ +// @vitest-environment jsdom +// +// Contract of the jump-to palette: filtering (word-wise and accent-insensitive), arrow/Enter +// selection over the FLAT result list, and closing before the command runs. Items are plain props +// (no i18n, no domain), so this asserts the shell only. i18n is key-passthrough. +import { useState } from "react"; +import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import CommandPalette, { type CommandGroup } from "./CommandPalette"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; +}); + +beforeAll(() => { + // jsdom has no layout, so scrollIntoView is missing on the elements the active-row effect touches. + Element.prototype.scrollIntoView ??= () => {}; +}); + +afterEach(cleanup); + +// `choose` closes and waits for the dialog's own Back-to-close history entry to be reclaimed before +// running the command (see closeThenNavigate), so the command lands a tick later — hence the waits. +const ran: string[] = []; +const groups = (): CommandGroup[] => [ + { + id: "modules", + label: "Modules", + items: [ + { id: "page:feed", label: "Feed", run: () => ran.push("page:feed") }, + { id: "page:plex", label: "Plex", keywords: "media library", run: () => ran.push("plex") }, + ], + }, + { + id: "channels", + label: "Channels", + items: [ + { id: "ch:1", label: "TheVR Podcast", hint: "@thevr", run: () => ran.push("ch:1") }, + { id: "ch:2", label: "Pénzügyi Fitnesz", run: () => ran.push("ch:2") }, + ], + }, +]; + +// The palette must REALLY close, not just report it: `choose` waits for the Back-to-close entry +// that only a genuine unmount reclaims, so a harness that keeps it mounted would never run anything. +function Harness() { + const [isOpen, setIsOpen] = useState(true); + return ( + { + ran.push("closed"); + setIsOpen(false); + }} + groups={groups()} + /> + ); +} + +const open = () => { + ran.length = 0; + return render(); +}; + +const rows = () => + [...document.querySelectorAll("[data-cmd-id]")].map((el) => el.getAttribute("data-cmd-id")); + +/** The virtually-selected row, read the way a screen reader would. */ +const selected = () => + document.querySelector("[aria-selected='true']")?.getAttribute("data-cmd-id") ?? null; + +const field = () => screen.getByRole("combobox", { name: "palette.placeholder" }); +const type = (value: string) => fireEvent.change(field(), { target: { value } }); +const press = (key: string) => fireEvent.keyDown(field(), { key }); + +describe("CommandPalette", () => { + it("renders nothing while closed", () => { + render( {}} groups={groups()} />); + expect(screen.queryByText("palette.title")).toBeNull(); + }); + + it("lists every group until you type", () => { + open(); + expect(rows()).toEqual(["page:feed", "page:plex", "ch:1", "ch:2"]); + }); + + // You half-remember a name; you should not have to remember the word order or the spacing. + it("matches on each word, in any order", () => { + open(); + type("pod thevr"); + expect(rows()).toEqual(["ch:1"]); + }); + + // Bilingual app, hurried typing: nobody reaches for the accent keys. + it("matches without accents", () => { + open(); + type("penzugyi"); + expect(rows()).toEqual(["ch:2"]); + }); + + it("matches hidden keywords and the group name", () => { + open(); + type("media"); + expect(rows()).toEqual(["page:plex"]); + type("channels"); + expect(rows()).toEqual(["ch:1", "ch:2"]); + }); + + it("says so when nothing matches", () => { + open(); + type("zzzz"); + expect(rows()).toEqual([]); + expect(screen.getByText("palette.empty")).toBeTruthy(); + }); + + it("selects the first row and points the input at it", () => { + open(); + expect(selected()).toBe("page:feed"); + expect( + screen + .getByRole("combobox", { name: "palette.placeholder" }) + .getAttribute("aria-activedescendant"), + ).toContain("page:feed"); + }); + + // The arrows walk the flat result list, not the sections — crossing a group heading is invisible. + it("walks results with the arrows, across group boundaries", () => { + open(); + press("ArrowDown"); + press("ArrowDown"); + expect(selected()).toBe("ch:1"); + }); + + it("wraps in both directions", () => { + open(); + press("ArrowUp"); + expect(selected()).toBe("ch:2"); + press("ArrowDown"); + expect(selected()).toBe("page:feed"); + }); + + it("keeps the selection valid when the filter shrinks the list", () => { + open(); + press("ArrowDown"); + press("ArrowDown"); + press("ArrowDown"); // ch:2 + type("thevr"); // one row left; the old index is out of range + expect(selected()).toBe("ch:1"); + }); + + // The ORDER is the point (closeThenNavigate): the dialog closes and gives its Back-to-close + // history entry back BEFORE the command runs, because a command that navigates would otherwise + // have its pushState undone by that reclaim. + it("closes, and runs the command only once the close has landed", async () => { + open(); + fireEvent.click(screen.getByText("TheVR Podcast")); + expect(ran).toEqual(["closed"]); + await waitFor(() => expect(ran).toEqual(["closed", "ch:1"])); + }); + + // Only ONE test here can go through the full close→popstate→run cycle: jsdom gives a file a single + // session-history entry, so the first back() consumes it and later ones fire no popstate. Which + // command Enter picks is covered by the selection assertions above; this checks it chooses at all. + it("chooses the selected row on Enter", () => { + open(); + press("ArrowDown"); + press("Enter"); + expect(ran).toEqual(["closed"]); + }); +}); diff --git a/frontend/src/components/ui/CommandPalette.tsx b/frontend/src/components/ui/CommandPalette.tsx new file mode 100644 index 00000000..98860c76 --- /dev/null +++ b/frontend/src/components/ui/CommandPalette.tsx @@ -0,0 +1,178 @@ +import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Search } from "lucide-react"; +import Modal from "./Modal"; +import { closeThenNavigate } from "../../lib/history"; + +// The app's jump-to bar: one text field over every place you can go and every app-wide thing you can +// do, opened with Ctrl/Cmd+K. It is the "one-layer operation" half of E10 — reaching a channel three +// modules away should cost a keystroke and a word, not a rail, a page and a scroll. +// +// Domain-free on purpose: the items arrive as a prop, because `components/ui` may not import a +// module or an API slice. `AppCommandPalette` (one level up) is what knows about modules, channels +// and playlists. On the shell it reuses `Modal` in its `top` placement — same layer stack, Escape, +// focus restore and Back-to-close as every other dialog. + +export interface CommandItem { + /** Unique across the whole palette; also the React key. */ + id: string; + label: string; + /** Right-aligned secondary text — usually the group, so a result stays legible once filtered. */ + hint?: string; + icon?: ReactNode; + /** Extra text the filter matches but the row does not show (a channel handle, an alias). */ + keywords?: string; + run: () => void; +} + +export interface CommandGroup { + id: string; + label: string; + items: CommandItem[]; +} + +/** Case- and accent-insensitive, so "audio" finds "Audió" and "penzugy" finds "pénzügy" — this is a + * bilingual app and nobody types the accents when they are in a hurry. */ +function fold(s: string): string { + return s + .toLowerCase() + .normalize("NFD") + .replace(/\p{Diacritic}/gu, ""); +} + +/** Every query word must appear somewhere in the item's text. Word-wise rather than substring so + * "thevr pod" finds "TheVR Podcast" — the way you actually half-remember a name. */ +function matchesQuery(item: CommandItem, groupLabel: string, words: string[]): boolean { + if (words.length === 0) return true; + const hay = fold(`${item.label} ${item.hint ?? ""} ${item.keywords ?? ""} ${groupLabel}`); + return words.every((w) => hay.includes(w)); +} + +export default function CommandPalette({ + open, + onClose, + groups, +}: { + open: boolean; + onClose: () => void; + groups: CommandGroup[]; +}) { + const { t } = useTranslation(); + const listId = useId(); + const [query, setQuery] = useState(""); + const [active, setActive] = useState(0); + // Ref on the highlighted row rather than a selector lookup: item ids come from domain data + // (playlist names, channel handles) and would need escaping to be safe in a query string. + const activeRef = useRef(null); + + // A fresh open starts from a blank field: the palette is for "take me somewhere", and a leftover + // query from last time hides everything you might want now. + useEffect(() => { + if (open) { + setQuery(""); + setActive(0); + } + }, [open]); + + const visible = useMemo(() => { + const words = fold(query.trim()).split(/\s+/).filter(Boolean); + return groups + .map((g) => ({ ...g, items: g.items.filter((i) => matchesQuery(i, g.label, words)) })) + .filter((g) => g.items.length > 0); + }, [groups, query]); + + // One flat list behind the grouped rendering — arrow keys walk results, not sections. + const flat = useMemo(() => visible.flatMap((g) => g.items), [visible]); + const activeId = flat[Math.min(active, flat.length - 1)]?.id; + + // Keep the highlighted row in view when the arrows walk past the fold. + useEffect(() => { + activeRef.current?.scrollIntoView({ block: "nearest" }); + }, [activeId]); + + if (!open) return null; + + // Close, wait for this dialog's own Back-to-close entry to be reclaimed, THEN run. Most commands + // navigate, and a pushState issued before that reclaim is undone by it — see closeThenNavigate. + const choose = (item: CommandItem) => closeThenNavigate(onClose, item.run); + + // These live on the input rather than in the shared keymap: they are the field's own editing keys + // (like a { + setQuery(e.target.value); + setActive(0); + }} + onKeyDown={onInputKey} + placeholder={t("palette.placeholder")} + aria-label={t("palette.placeholder")} + // A minimal combobox tree: the arrows move a VIRTUAL selection while the caret stays in + // the field, so a screen reader has to be told which row is current. + role="combobox" + aria-expanded + aria-controls={listId} + aria-activedescendant={activeId ? `${listId}-${activeId}` : undefined} + className="w-full bg-transparent text-sm outline-hidden placeholder:text-muted" + /> + + +
+ {flat.length === 0 ? ( +

{t("palette.empty")}

+ ) : ( + visible.map((g) => ( +
+
+ {g.label} +
+ {g.items.map((item) => ( + + ))} +
+ )) + )} +
+ + ); +} diff --git a/frontend/src/components/ui/KeyboardHelp.dom.test.tsx b/frontend/src/components/ui/KeyboardHelp.dom.test.tsx index 21e33233..bf6ae9dc 100644 --- a/frontend/src/components/ui/KeyboardHelp.dom.test.tsx +++ b/frontend/src/components/ui/KeyboardHelp.dom.test.tsx @@ -6,7 +6,12 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; import KeyboardHelp from "./KeyboardHelp"; -import { closeKeyboardHelp, useHelpConditions, useKeyScope } from "../../lib/keymap"; +import { + closeKeyboardHelp, + useGlobalKeymap, + useHelpConditions, + useKeyScope, +} from "../../lib/keymap"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); @@ -20,6 +25,17 @@ afterEach(() => { closeKeyboardHelp(); }); +/** App's job: it binds the `global` scope (h / Mod+K) once for the whole tree. */ +function Host({ children }: { children?: React.ReactNode }) { + useGlobalKeymap(); + return ( + <> + {children} + + + ); +} + /** A stand-in for a surface that registers a scope while mounted, the way the players do. */ function Surface({ episode }: { episode?: boolean }) { useKeyScope("plexPlayer"); @@ -36,7 +52,7 @@ const descriptions = () => screen.getAllByRole("definition").map((el) => el.text describe("KeyboardHelp", () => { it("opens and closes on the global h", () => { - render(); + render(); expect(screen.queryByText("keys.title")).toBeNull(); pressH(); expect(screen.getByText("keys.title")).toBeTruthy(); @@ -45,7 +61,7 @@ describe("KeyboardHelp", () => { }); it("does not open while a field has focus", () => { - render(); + render(); const input = document.createElement("input"); document.body.append(input); act(() => { @@ -57,31 +73,29 @@ describe("KeyboardHelp", () => { it("leads with the live surface and trails with the app-wide keys", () => { render( - <> + - - , + , ); pressH(); const rows = descriptions(); // A plexPlayer row first, the app-wide rows last — the reading order the reader wants. expect(rows[0]).toBe("keys.playPause"); - expect(rows.slice(-2)).toEqual(["keys.help", "keys.contextMenu"]); + expect(rows.slice(-3)).toEqual(["keys.palette", "keys.help", "keys.contextMenu"]); }); it("lists only the app-wide keys when no surface is registered", () => { - render(); + render(); pressH(); - expect(descriptions()).toEqual(["keys.help", "keys.contextMenu"]); + expect(descriptions()).toEqual(["keys.palette", "keys.help", "keys.contextMenu"]); }); // "Previous / next episode" inside a movie would be a promise the player does not keep. it("hides a conditional row until its surface says it applies", () => { const { unmount } = render( - <> + - - , + , ); pressH(); expect(descriptions()).not.toContain("keys.episode"); @@ -89,10 +103,9 @@ describe("KeyboardHelp", () => { closeKeyboardHelp(); // the sheet outlives the tree it was opened from render( - <> + - - , + , ); pressH(); expect(descriptions()).toContain("keys.episode"); diff --git a/frontend/src/components/ui/KeyboardHelp.tsx b/frontend/src/components/ui/KeyboardHelp.tsx index ad90085b..497ada8b 100644 --- a/frontend/src/components/ui/KeyboardHelp.tsx +++ b/frontend/src/components/ui/KeyboardHelp.tsx @@ -1,18 +1,17 @@ -import { Fragment, useEffect } from "react"; +import { Fragment } from "react"; import { useTranslation } from "react-i18next"; import Modal from "./Modal"; import { closeKeyboardHelp, - dispatch, formatChord, helpLines, - toggleKeyboardHelp, useActiveScopes, useHelpConditionValues, useKeyboardHelpOpen, } from "../../lib/keymap"; -// The app's one keyboard cheat sheet, plus the global `h` that opens it. Mounted once by App. +// The app's one keyboard cheat sheet. Mounted once by App; the global `h` that opens it lives in +// `useGlobalKeymap`, beside the palette's Mod+K, so one listener owns the whole `global` scope. // // It renders the shared SHORTCUTS table filtered to the scopes that are live right now — the Plex // player's keys while you are in the player, the app-wide ones underneath — so there is a single @@ -31,19 +30,6 @@ export default function KeyboardHelp() { const scopes = useActiveScopes(); const conditions = useHelpConditionValues(); - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - dispatch(e, "global", { - help: (ev) => { - ev.preventDefault(); - toggleKeyboardHelp(); - }, - }); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, []); - if (!open) return null; // A `when` row is listed only where it applies — "Previous / next episode" inside a movie would be // a promise the player does not keep. diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index 20db87a0..a38857bf 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -32,11 +32,13 @@ export default function Modal({ onClose: () => void; children: ReactNode; maxWidth?: string; - /** `drawer` slides in from the right edge and runs full height; everything else about the shell — - * the layer stack, ESC, the focus trap, Back-to-close, the scroll lock — is identical. A drawer - * is a placement, not a second dialog implementation: duplicating that behaviour is exactly how - * two shells drift apart. */ - placement?: "center" | "drawer"; + /** `drawer` slides in from the right edge and runs full height; `top` pins the panel near the top + * of the viewport (where a search-first dialog like the command palette belongs, so the list grows + * downwards instead of the whole thing jumping as results change). Everything else about the shell + * — the layer stack, ESC, the focus trap, Back-to-close, the scroll lock — is identical. A + * placement is not a second dialog implementation: duplicating that behaviour is exactly how two + * shells drift apart. */ + placement?: "center" | "drawer" | "top"; }) { const { t } = useTranslation(); // Browser/mouse Back closes the topmost modal instead of navigating away. @@ -114,10 +116,15 @@ export default function Modal({ }, [restoreTo]); const drawer = placement === "drawer"; + const top = placement === "top"; return createPortal(
{ pressedOnBackdrop.current = e.target === e.currentTarget; diff --git a/frontend/src/i18n/locales/en/keys.json b/frontend/src/i18n/locales/en/keys.json index 2df9013e..5c255351 100644 --- a/frontend/src/i18n/locales/en/keys.json +++ b/frontend/src/i18n/locales/en/keys.json @@ -19,5 +19,6 @@ "stats": "Stats for nerds", "back": "Stop & back to feed", "browseBack": "Back one level", - "closePlayer": "Close the player" + "closePlayer": "Close the player", + "palette": "Open the jump-to palette" } diff --git a/frontend/src/i18n/locales/en/palette.json b/frontend/src/i18n/locales/en/palette.json new file mode 100644 index 00000000..75dda89d --- /dev/null +++ b/frontend/src/i18n/locales/en/palette.json @@ -0,0 +1,15 @@ +{ + "title": "Jump to…", + "placeholder": "Search modules, channels, playlists…", + "empty": "Nothing matches.", + "theme": "Theme: {{name}}", + "videoCount": "{{count}} video", + "videoCount_other": "{{count}} videos", + "groups": { + "modules": "Modules", + "channels": "Channels", + "playlists": "Playlists", + "views": "Saved views", + "actions": "Actions" + } +} diff --git a/frontend/src/i18n/locales/hu/keys.json b/frontend/src/i18n/locales/hu/keys.json index 8de1dd82..c8cfca4f 100644 --- a/frontend/src/i18n/locales/hu/keys.json +++ b/frontend/src/i18n/locales/hu/keys.json @@ -19,5 +19,6 @@ "stats": "Statisztika (nerdöknek)", "back": "Leállítás és vissza a feedre", "browseBack": "Vissza egy szintet", - "closePlayer": "Lejátszó bezárása" + "closePlayer": "Lejátszó bezárása", + "palette": "Ugrás-paletta megnyitása" } diff --git a/frontend/src/i18n/locales/hu/palette.json b/frontend/src/i18n/locales/hu/palette.json new file mode 100644 index 00000000..88e80ca2 --- /dev/null +++ b/frontend/src/i18n/locales/hu/palette.json @@ -0,0 +1,15 @@ +{ + "title": "Ugrás…", + "placeholder": "Modul, csatorna, lista keresése…", + "empty": "Nincs találat.", + "theme": "Téma: {{name}}", + "videoCount": "{{count}} videó", + "videoCount_other": "{{count}} videó", + "groups": { + "modules": "Modulok", + "channels": "Csatornák", + "playlists": "Listák", + "views": "Mentett nézetek", + "actions": "Műveletek" + } +} diff --git a/frontend/src/lib/history.dom.test.ts b/frontend/src/lib/history.dom.test.ts index 9f9be78c..f2b7e208 100644 --- a/frontend/src/lib/history.dom.test.ts +++ b/frontend/src/lib/history.dom.test.ts @@ -11,7 +11,7 @@ // no reconcile-driven history.back() runs and the shared module state stays clean between tests. import { describe, it, expect, vi, afterEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; -import { useBackToClose, useBackToExit } from "./history"; +import { closeThenNavigate, useBackToClose, useBackToExit } from "./history"; // Let the mount effect's coalesced reconcile microtask settle. const settle = () => @@ -87,3 +87,28 @@ describe("useBackToExit back-ladder", () => { rg.unmount(); }); }); + +describe("closeThenNavigate", () => { + // The bug it exists for: an overlay's unmount reclaims its Back-to-close entry with a programmatic + // history.back(). A pushState made before that lands is undone by it, so a dialog command that + // navigates does nothing but close. PlayerModal hit it once; the command palette hit it again. + it("closes first and navigates only after the reclaim lands", () => { + const order: string[] = []; + closeThenNavigate( + () => order.push("close"), + () => order.push("navigate"), + ); + expect(order).toEqual(["close"]); + + window.dispatchEvent(new PopStateEvent("popstate")); + expect(order).toEqual(["close", "navigate"]); + }); + + it("navigates exactly once, however many pops follow", () => { + const navigate = vi.fn(); + closeThenNavigate(() => {}, navigate); + window.dispatchEvent(new PopStateEvent("popstate")); + window.dispatchEvent(new PopStateEvent("popstate")); + expect(navigate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index 1134c58d..ec0c24b1 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -187,6 +187,27 @@ function ensureOverlayListener(): void { }); } +/** Close an overlay that holds a `useBackToClose` entry and THEN navigate — in that order, waiting + * for the browser to confirm it. + * + * The trap: closing unmounts the overlay, whose cleanup reconciles the history by issuing a + * programmatic `history.back()`. A `pushState` made before that lands (i.e. anything `setPage` / + * `openChannel` does synchronously) is simply undone by it, so the navigation silently does + * nothing. PlayerModal hit this first — clicking a video's channel name only closed the player — + * and the command palette hit it again the day it was written, which is why the fix lives here + * instead of being re-derived at each call site. + * + * Only call this from INSIDE an overlay that uses `useBackToClose`: the popstate we wait for is + * that overlay's own entry being reclaimed, and without one it never arrives. */ +export function closeThenNavigate(close: () => void, navigate: () => void): void { + const once = () => { + window.removeEventListener("popstate", once); + navigate(); + }; + window.addEventListener("popstate", once); + close(); +} + /** While the calling component is mounted, Back (or the browser/mouse back button) closes it via * `onClose` instead of navigating away. Mount the component only while the overlay is open. */ export function useBackToClose(onClose: () => void): void { diff --git a/frontend/src/lib/keymap.test.ts b/frontend/src/lib/keymap.test.ts index 0cf903c5..44b37a84 100644 --- a/frontend/src/lib/keymap.test.ts +++ b/frontend/src/lib/keymap.test.ts @@ -143,11 +143,15 @@ describe("dispatch", () => { const calls: string[] = []; // The handler map is the global scope's routable ids only — `contextMenu` is absent from it, // which is the compile-time half of the same claim; tsc rejects passing one. - const handlers = { help: () => calls.push("help") }; + const handlers = { + help: () => void calls.push("help"), + palette: () => void calls.push("palette"), + }; expect(dispatch(press({ key: "F10", shiftKey: true }), "global", handlers)).toBe(false); expect(dispatch(press({ key: "ContextMenu" }), "global", handlers)).toBe(false); expect(dispatch(press({ key: "h" }), "global", handlers)).toBe(true); - expect(calls).toEqual(["help"]); + expect(dispatch(press({ key: "k", ctrlKey: true }), "global", handlers)).toBe(true); + expect(calls).toEqual(["help", "palette"]); }); it("ignores keys belonging to another scope", () => { @@ -203,7 +207,7 @@ describe("helpLines", () => { it("orders scopes as asked, not as tabled", () => { const first = (scopes: Parameters[0]) => helpLines(scopes)[0]?.labelKey; expect(first(["plexBrowse", "global"])).toBe("keys.browseBack"); - expect(first(["global", "plexBrowse"])).toBe("keys.help"); + expect(first(["global", "plexBrowse"])).toBe("keys.palette"); }); }); diff --git a/frontend/src/lib/keymap.ts b/frontend/src/lib/keymap.ts index 32380324..69c7ef55 100644 --- a/frontend/src/lib/keymap.ts +++ b/frontend/src/lib/keymap.ts @@ -51,6 +51,7 @@ export const SHORTCUTS = [ // --- global --------------------------------------------------------------------------------- // `h`, not `?`: on a Hungarian layout `?` is a Shift chord, and the one help key people already // have in their fingers here is the Plex player's `h`. Making it app-wide costs no relearning. + { id: "palette", scope: "global", keys: ["Mod+k"], labelKey: "keys.palette" }, { id: "help", scope: "global", keys: ["h"], labelKey: "keys.help" }, // Matched by `useContextMenu` on the focused row, not by a window-level dispatch: which row the // menu belongs to is a property of focus, not of the app. @@ -242,6 +243,36 @@ export const toggleKeyboardHelp = (): void => helpOpen.set((v) => !v); /** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */ export const useKeyboardHelpOpen = (): boolean => helpOpen.use(); +// ── The command palette ─────────────────────────────────────────────────────────────────────── +const paletteOpen = createStore(false); + +export const closePalette = (): void => paletteOpen.set(false); +export const usePaletteOpen = (): boolean => paletteOpen.use(); + +/** Bind the `global` scope, once, for the whole app. It lives here rather than inside either + * overlay because both are opened by it: a listener that sat in one of them would have to route a + * key belonging to the other, and `dispatch`'s exhaustiveness would stop meaning anything. App + * calls this; the overlays are pure renderers of their store. */ +export function useGlobalKeymap(): void { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + dispatch(e, "global", { + help: (ev) => { + ev.preventDefault(); + helpOpen.set((v) => !v); + }, + palette: (ev) => { + // Mod+K is the browser's "focus the search bar" in a few builds; we want it. + ev.preventDefault(); + paletteOpen.set((v) => !v); + }, + }); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); +} + /** Answers for the `when` conditions, published by the surface that knows them. */ export type HelpConditions = { readonly [K in ShortcutWhen]?: boolean }; const helpConditions = createStore({}); From f99240d8ca5f45d1e72f7d884c6d247f0a885eef Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 5 Sep 2026 22:22:14 +0200 Subject: [PATCH 11/17] fix(e10): stop the hidden chip measurer scrolling the whole app sideways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four chip lanes render an intentionally unbounded hidden "mirror" so the shared fit core can read each chip's natural width. An absolutely positioned box still contributes to its ancestors' SCROLLABLE overflow, and `visibility: hidden` does not exempt it — and nothing between the toolbar band and clipped it. At the 1024x768 floor that meant the channel manager's document scroll width was 1258px: the whole app scrolled 249px sideways into dead space and dragged the fixed header to x=-177. The same mirror was also 228px wide inside the Tags column's 144px cell, which pushed the table to 990px inside its 906px container — i.e. it broke DataTable's "the table never exceeds its container" contract and truncated channel names to "The...". One cause, two symptoms. The lane now carries a shared `chipLaneClass` = `relative overflow-clip`, so the contract lives in one constant instead of four hand-rolled class strings. `clip` rather than `hidden` because it creates no scrollport, so nothing can ever scroll the mirror into view. Measurement is untouched: offsetLeft/offsetWidth are layout values and a clip does not change layout. Measured after: document 1258 -> 1024, sideways scroll 249 -> 0, table 990 -> 921 with no cell overflow, and the "+N" collapse still fits exactly as before. A source-level test asserts every component rendering a mirror uses the lane class — the drift a fifth hand-rolled lane would introduce, which no DOM test can catch since jsdom has no layout engine to overflow. --- frontend/src/components/ActiveFilterChips.tsx | 4 +- frontend/src/components/ChannelTags.tsx | 6 +-- frontend/src/components/TagFilterChips.tsx | 4 +- frontend/src/lib/chipLane.contract.test.ts | 48 +++++++++++++++++++ frontend/src/lib/useChipOverflowFit.ts | 19 ++++++++ 5 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/chipLane.contract.test.ts diff --git a/frontend/src/components/ActiveFilterChips.tsx b/frontend/src/components/ActiveFilterChips.tsx index e335dabf..47253798 100644 --- a/frontend/src/components/ActiveFilterChips.tsx +++ b/frontend/src/components/ActiveFilterChips.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { Filter, X } from "lucide-react"; import type { ActiveFilter } from "../lib/useActiveFilters"; import { decideChipLayout } from "../lib/chipOverflow"; -import { useChipOverflowFit } from "../lib/useChipOverflowFit"; +import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit"; import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; // The applied-filter row above the feed: every narrowing currently in force, each removable on its @@ -172,7 +172,7 @@ export default function ActiveFilterChips({
{/* Hidden mirror of the full set, used only to measure natural widths. Kept out of the a11y tree diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index 1a6e497d..01050987 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -5,7 +5,7 @@ import { type Tag } from "../lib/api"; import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./ui/AnchoredPopover"; import { useScrollFade } from "../lib/useScrollFade"; import { fitChipsWithPill } from "../lib/chipOverflow"; -import { useChipOverflowFit } from "../lib/useChipOverflowFit"; +import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit"; // The channel tag surface, shared by the channel manager (table + cards) and the channel detail // page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell: @@ -129,7 +129,7 @@ function SuggestedChipsLane({ }, [hidden, overflowOpen]); return ( -
+
{/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */}
{/* The add trigger LEADS the row — a stable left anchor that never moves as chips are added (they grow to its right), so its editor popover can't drift. Compact icon in the width- diff --git a/frontend/src/components/TagFilterChips.tsx b/frontend/src/components/TagFilterChips.tsx index fb5590ed..550c4df9 100644 --- a/frontend/src/components/TagFilterChips.tsx +++ b/frontend/src/components/TagFilterChips.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { fitChipsWithPill } from "../lib/chipOverflow"; -import { useChipOverflowFit } from "../lib/useChipOverflowFit"; +import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit"; import { filterChipClass } from "./ui/FilterChipGroup"; import { useAnchoredPopover, PopoverSurface } from "./ui/AnchoredPopover"; @@ -74,7 +74,7 @@ export default function TagFilterChips({ const moreLabel = t("channels.tags.moreCount", { count: hidden }); return ( -
+
{/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can read each chip's right edge, the rem gap and the pill width off real boxes. */}
diff --git a/frontend/src/components/ui/ActionMenu.tsx b/frontend/src/components/ui/ActionMenu.tsx index 23e4cc68..c65f020b 100644 --- a/frontend/src/components/ui/ActionMenu.tsx +++ b/frontend/src/components/ui/ActionMenu.tsx @@ -22,16 +22,17 @@ interface MenuItemBase { id: string; label: string; icon?: ReactNode; // an element (so a caller can swap in a spinning icon while pending) - /** Off because it is BUSY (a mutation in flight): a native `disabled` button, skipped by roving - * focus. Use it when there is nothing to explain — the row will be back in a moment. */ disabled?: boolean; - /** Off because it does not APPLY here (no other list to move to, a built-in list you cannot - * rename): reachable but inert — `aria-disabled`, still focusable, still tooltip-capable, and it - * no-ops on activation. `disabled` would hide the very explanation the row exists to give, since - * a disabled button fires no pointer events (no `title`) and roving focus skips it, so neither a - * mouse nor a keyboard could ever read the `title` (E10 review round 1). */ - inert?: boolean; - title?: string; // optional native tooltip (carries a longer hint the label alone can't) + /** Optional native tooltip (carries a longer hint the label alone can't). + * + * KNOWN GAP, deliberately still open: on a `disabled` row this reaches nobody — a disabled button + * fires no pointer events, so no tooltip, and floating-ui's roving focus skips it. E10 review + * round 1 tried an `aria-disabled` "inert" row to fix that; round 2 found `useListNavigation` + * skips `aria-disabled` identically (`isListIndexDisabled`), which AnchoredPopover's own comment + * already said — so the swap fixed nothing and made the mouse worse (the row took the active + * highlight, then no-opped on click). Reverted. A real fix has to put the reason somewhere a + * disabled row is not: a visible hint line in the menu, or an `aria-describedby` note. */ + title?: string; /** Draw a divider above this item. Ignored on the first item — a menu never opens with a rule. */ separatorBefore?: boolean; } @@ -79,16 +80,13 @@ export function MenuItemList({ { - // An inert row is reachable on purpose (so its `title` can be read) but must not act. - if (it.inert) return; it.onSelect(); onClose(); }} - className={`${ROW}${it.inert ? " opacity-50" : ""}${it.danger ? " text-danger" : ""}`} + className={`${ROW}${it.danger ? " text-danger" : ""}`} > {it.icon && ( diff --git a/frontend/src/components/ui/AnchoredPopover.tsx b/frontend/src/components/ui/AnchoredPopover.tsx index b538a5d2..2e0daabb 100644 --- a/frontend/src/components/ui/AnchoredPopover.tsx +++ b/frontend/src/components/ui/AnchoredPopover.tsx @@ -118,13 +118,6 @@ export interface AnchoredPopoverOptions { * scrolls under it while its actions still target the row you opened it on (E10 review round 1). * This is also what a native context menu does. */ dismissOnScroll?: boolean; - /** Hand the focus back to the trigger on close. On by default, and right whenever there IS a - * trigger element. Turn it off when the anchor is a virtual point: floating-ui then has no - * `domReference` to return to and falls back to a module-global "previously focused" stack, so it - * restores some unrelated element from an earlier popover — which, in the rail's inline rename, - * stole focus back from the field that had just opened and tore it down again (E10 review round - * 1). A caller that turns this off owns the restore. */ - returnFocus?: boolean; } // Internal: call sites use the inferred hook return, they don't name the type. @@ -301,7 +294,6 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { roves, open: opts.open, selectedIndex: opts.selectedIndex ?? null, - returnFocus: opts.returnFocus ?? true, refs, floatingStyles, context, @@ -526,7 +518,6 @@ function PopoverSurfaceInner({ isNav, isCombobox, roves, - returnFocus, selectedIndex, activeIndex, highlightIndex, @@ -624,7 +615,7 @@ function PopoverSurfaceInner({ // — in an item-less menu (e.g. a first-run tag editor with only a create input) — the // input, so such a menu still has a keyboard entry point instead of stranding focus. initialFocus={initialFocus ?? (isNav && selectedIndex != null ? -1 : undefined)} - returnFocus={returnFocus} + returnFocus > {positioned} diff --git a/frontend/src/components/ui/Button.dom.test.tsx b/frontend/src/components/ui/Button.dom.test.tsx index 77bd7c2e..535ff326 100644 --- a/frontend/src/components/ui/Button.dom.test.tsx +++ b/frontend/src/components/ui/Button.dom.test.tsx @@ -115,3 +115,32 @@ describe("Button primitive (S5)", () => { expect(screen.getByRole("button", { name: "Wide" }).className).toContain("w-full"); }); }); + +// --- the accessible-name contract, pinned at the TYPE level ------------------------------------ +// A button that renders no text must carry a name, and that rule is a union in Button.tsx rather +// than a doc comment because the comment version let two nameless icon buttons ship (E10 review +// round 1). But a type with no test is a type someone widens to silence a stubborn call site: +// replacing `ButtonProps` with the permissive shape left tsc, eslint, knip and vitest all green +// (round 2). Each `@ts-expect-error` below FAILS THE BUILD if the error it expects stops happening, +// so weakening the union turns the whole typecheck lane red. +// +// Rendered, not just declared, so eslint sees them used and the file stays one test suite. +describe("Button accessible-name contract (type level)", () => { + it("rejects a text-less button with no name, and accepts every named spelling", () => { + render( + <> + {/* @ts-expect-error size="icon" with no label/aria-label/title has no accessible name */} + + , + ); + expect(screen.getByRole("button", { name: "by label" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "by aria-label" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "by title" })).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/ui/CommandPalette.tsx b/frontend/src/components/ui/CommandPalette.tsx index 62e81a39..d0f00fca 100644 --- a/frontend/src/components/ui/CommandPalette.tsx +++ b/frontend/src/components/ui/CommandPalette.tsx @@ -64,8 +64,6 @@ export default function CommandPalette({ // Ref on the highlighted row rather than a selector lookup: item ids come from domain data // (playlist names, channel handles) and would need escaping to be safe in a query string. const activeRef = useRef(null); - // Last pointer position, so a mousemove the BROWSER synthesised can be told from one the user made. - const lastPointer = useRef<{ x: number; y: number } | null>(null); // A fresh open starts from a blank field: the palette is for "take me somewhere", and a leftover // query from last time hides everything you might want now. @@ -170,10 +168,11 @@ export default function CommandPalette({ // Only a pointer that actually MOVED may take the selection. After the arrow keys // scroll a row past the fold, Blink re-dispatches a mousemove at the unchanged // cursor position to refresh hover — which handed the highlight straight back to - // whatever row slid under a resting cursor (E10 review round 1). - const prev = lastPointer.current; - lastPointer.current = { x: e.clientX, y: e.clientY }; - if (prev && prev.x === e.clientX && prev.y === e.clientY) return; + // whatever row slid under a resting cursor (E10 review round 1). The browser + // reports zero movement for exactly that synthetic event, so ask it directly: + // round 1 remembered the last position instead, which could not judge the FIRST + // move of a session and went stale across a close (round 2). + if (e.movementX === 0 && e.movementY === 0) return; setActive(flat.indexOf(item)); }} className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm ${ diff --git a/frontend/src/components/ui/KeyboardHelp.dom.test.tsx b/frontend/src/components/ui/KeyboardHelp.dom.test.tsx index 6780648f..cf99270c 100644 --- a/frontend/src/components/ui/KeyboardHelp.dom.test.tsx +++ b/frontend/src/components/ui/KeyboardHelp.dom.test.tsx @@ -6,6 +6,7 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; import KeyboardHelp from "./KeyboardHelp"; +import { useContextMenu } from "./useContextMenu"; import { closeKeyboardHelp, useGlobalKeymap, @@ -95,14 +96,45 @@ describe("KeyboardHelp", () => { // sheet only offers Shift+F10 where something actually answers it. Advertising it globally was a // promise the feed, Plex, Downloads and Settings did not keep (E10 review round 1) — the test above // pins the other half, that a bare app lists the two global keys and nothing else. - it("offers the context-menu key on a surface that has one", () => { - render( + // Through the REAL hook, not a scope stub: round 2 showed that commenting out + // `useKeyScope("contextMenu")` inside `useContextMenu` left the whole suite green, because the + // only coverage registered the scope by hand. This binds the sheet to the thing that owns it. + it("offers the context-menu key on a surface that has one, and not otherwise", () => { + function WithMenu() { + useContextMenu(); + return null; + } + const { unmount } = render( - + , ); pressH(); expect(descriptions()).toContain("keys.contextMenu"); + + unmount(); + cleanup(); + closeKeyboardHelp(); + render(); + pressH(); + expect(descriptions()).not.toContain("keys.contextMenu"); + }); + + // Two live context menus (the Playlists page mounts one, its rail another) must not double the row. + it("lists the context-menu chords once however many surfaces register", () => { + function WithMenu() { + useContextMenu(); + return null; + } + render( + + + + , + ); + pressH(); + expect(descriptions().filter((d) => d === "keys.contextMenu")).toHaveLength(1); + expect(screen.getAllByText("⇧ F10")).toHaveLength(1); }); // "Previous / next episode" inside a movie would be a promise the player does not keep. diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index c1d1a9ea..eae01500 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -48,9 +48,12 @@ somewhere: the app actually used. Pick a rung; do not hand-roll padding on a primitive. - **Type, padding and box geometry are rem-derived**, so the Settings text-size slider (`--font-scale` on the root font-size) scales boxes and padding with the label. A `text-[Npx]` font size is an - eslint error for exactly this reason. The deliberate exception is `VirtualGrid`'s `minCol` / - `colMaxWidth` / `GRID_GAP`, which are px: those are layout-engine measurements compared against a - container's own pixel width, not text boxes. + eslint error for exactly this reason. The deliberate exceptions are the layout-engine measurements + — `VirtualGrid`'s `minCol` / `colMaxWidth` and `gridColumns`' `GRID_GAP` — which are compared + against a container's own pixel width rather than sized against text. Two px box values are NOT + deliberate and are worth converting when touched: `Tooltip`'s `max-w-[240px]` (it does not widen + with the slider while its text does, so the tooltip re-wraps at large text sizes) and `form.tsx`'s + `left-[18px]` toggle knob. - **Minimum target: 2.75rem, on coarse pointers only** (`.touch-target`, index.css). A 32px icon button is fine with a mouse and not with a thumb, so the box grows where there is no precise pointer. It grows the real box rather than painting an invisible hit area: two expanded hit areas diff --git a/frontend/src/components/ui/useContextMenu.ts b/frontend/src/components/ui/useContextMenu.ts index 698e85a4..1342176c 100644 --- a/frontend/src/components/ui/useContextMenu.ts +++ b/frontend/src/components/ui/useContextMenu.ts @@ -64,8 +64,6 @@ export interface ContextMenuController { export function useContextMenu(): ContextMenuController { // Register the scope HERE, so the cheat sheet lists Shift+F10 exactly on the surfaces that answer it. useKeyScope("contextMenu"); - // `onOpenChange` is built before `restoreFocus` exists; a ref keeps the order from mattering. - const restoreFocusRef = useRef<() => void>(() => {}); const [target, setTarget] = useState(null); const [open, setOpen] = useState(false); const pop = useAnchoredPopover({ @@ -73,10 +71,7 @@ export function useContextMenu(): ContextMenuController { onOpenChange: (next) => { setOpen(next); // Drop the target on close so a stale row can't drive a re-open's first paint. - if (!next) { - setTarget(null); - restoreFocusRef.current(); - } + if (!next) setTarget(null); }, // A native context menu drops down-and-right from the pointer; flip()/shift() take it the other // way near an edge. @@ -86,44 +81,18 @@ export function useContextMenu(): ContextMenuController { // The anchor is a fixed POINT, not an element, so scrolling cannot move the panel with its row — // it would sit over a different row than the one its actions target. Native menus close; so do we. dismissOnScroll: true, - // Same reason: with no `domReference`, floating-ui's own returnFocus restores an unrelated - // element off its module-global stack. We know the actual row, so we hand focus back ourselves. - returnFocus: false, }); - // The row the open menu belongs to, so focus can go back where it came from on close. - const triggerEl = useRef(null); - const { setPositionReference } = pop.refs; const openAt = useCallback( (x: number, y: number, payload: T, contextElement?: Element) => { setPositionReference({ getBoundingClientRect: () => pointRect(x, y), contextElement }); - triggerEl.current = contextElement ?? null; setTarget(payload); setOpen(true); }, [setPositionReference], ); - /** Hand focus back to the row — but only if that is still the right thing to do. - * - * After the commit, because both conditions are about the world the close produced. A row that is - * simply still there gets its focus back (the keyboard user is where they were). A row that has - * been REPLACED keeps whatever replaced it: the rail swaps in an inline rename field on this very - * path, and blindly restoring is what used to blur that field into its own commit-and-close (E10 - * review round 1). Likewise, an action that deliberately moved focus somewhere else keeps it. */ - const restoreFocus = useCallback(() => { - const el = triggerEl.current; - triggerEl.current = null; - if (!el) return; - queueMicrotask(() => { - if (!el.isConnected) return; - const active = document.activeElement; - if (active && active !== document.body && !el.contains(active)) return; - if (el instanceof HTMLElement) el.focus(); - }); - }, []); - // Long-press bookkeeping. Refs, not state: none of it paints, and a re-render per pointermove // during a scroll would be a real cost on the device this path exists for. const timer = useRef(null); @@ -197,7 +166,7 @@ export function useContextMenu(): ContextMenuController { // pointer that isn't there. // Through the shared parser, against the table's own row: the chords live in ONE place, and // `matches` is exact on modifiers (the hand-rolled version answered Ctrl+Shift+F10 too). - if (!matchesShortcut(e.nativeEvent, "contextMenu")) return; + if (!matchesShortcut(e.nativeEvent, "contextMenu", "contextMenu")) return; e.preventDefault(); const r = e.currentTarget.getBoundingClientRect(); openAt(r.left, r.bottom, payload, e.currentTarget); @@ -206,17 +175,5 @@ export function useContextMenu(): ContextMenuController { [openAt, cancelPress], ); - restoreFocusRef.current = restoreFocus; - - return { - target, - open, - close: () => { - setOpen(false); - setTarget(null); - restoreFocus(); - }, - triggerProps, - popover: pop, - }; + return { target, open, close: () => setOpen(false), triggerProps, popover: pop }; } diff --git a/frontend/src/index.css b/frontend/src/index.css index 4d4cc533..ba7ded13 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -576,7 +576,10 @@ body { } } } -.glass-hover:hover { +/* `:not(:disabled)` for the same reason ui/Button prefixes every variant hover with `enabled:` — a + disabled control must not advertise a click that does nothing. The Tailwind half was gated in E10 + review round 1 and this half was missed, so a disabled glass button still lifted (round 2). */ +.glass-hover:not(:disabled):hover { border-color: color-mix(in srgb, var(--accent) 55%, transparent); box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 12%, transparent), diff --git a/frontend/src/lib/chipLane.contract.test.ts b/frontend/src/lib/chipLane.contract.test.ts index 8f4e3ee9..4d9c4ee3 100644 --- a/frontend/src/lib/chipLane.contract.test.ts +++ b/frontend/src/lib/chipLane.contract.test.ts @@ -25,6 +25,14 @@ function count(haystack: string, needle: string): number { return haystack.split(needle).length - 1; } +/** Occurrences of `chipLaneClass` that are actually a LANE, i.e. not the import that brings it in. + * Counting raw occurrences gave every file one free mirror — the import — so a second, unprotected + * lane in a file that already had a compliant one still passed, which is the exact hole this test + * was rewritten to close (E10 review round 2, caught by mutation). */ +function laneCount(src: string): number { + return count(src.replace(/^\s*import[\s\S]*?from\s+"[^"]*";$/gm, ""), "chipLaneClass"); +} + /** Every source file under src/, not just src/components/ — App.tsx and main.tsx sit above it. */ function sources(): Array<[string, string]> { const walk = (u: URL, prefix: string): Array<[string, string]> => @@ -58,13 +66,15 @@ describe("chip lane containment contract", () => { .map(([name, src]) => ({ name, mirrors: count(src, MIRROR), - lanes: count(src, "chipLaneClass"), + lanes: laneCount(src), })) .filter((f) => f.lanes < f.mirrors); expect(offenders).toEqual([]); }); it("finds the measurers it claims to guard (the marker has not been renamed away)", () => { - expect(withMirror().length).toBeGreaterThanOrEqual(3); + // The MIRROR alone, not the hook: `lib/chipOverflow.ts` names the hook in a prose comment and was + // padding this canary, so it stayed satisfied with one real measurer fewer (round 2). + expect(sources().filter(([, src]) => src.includes(MIRROR)).length).toBeGreaterThanOrEqual(3); }); }); diff --git a/frontend/src/lib/globalKeymap.dom.test.tsx b/frontend/src/lib/globalKeymap.dom.test.tsx new file mode 100644 index 00000000..2df7ec95 --- /dev/null +++ b/frontend/src/lib/globalKeymap.dom.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +// +// The `global` scope's LIVE behaviour, as opposed to `matches`/`dispatch` in isolation (keymap.test.ts +// covers those against a handler map the test supplies itself, which cannot see the real handlers). +// +// Every assertion here pins a fix that E10 review round 2 proved was un-pinned: reverting the guard +// each one covers left the whole suite green, which is how a fix round quietly becomes the next +// round's source of regressions. +import { useEffect } from "react"; +import { describe, it, expect, afterEach, vi } from "vitest"; +import { render, cleanup, act } from "@testing-library/react"; + +import { + closeKeyboardHelp, + closePalette, + useGlobalKeymap, + useKeyboardHelpOpen, + usePaletteOpen, +} from "./keymap"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; +}); + +afterEach(() => { + cleanup(); + closeKeyboardHelp(); + closePalette(); +}); + +/** Renders the live stores so an assertion reads what a real consumer would render. */ +function Host({ enabled = true, onState }: { enabled?: boolean; onState: (s: string) => void }) { + useGlobalKeymap(enabled); + const help = useKeyboardHelpOpen(); + const palette = usePaletteOpen(); + useEffect(() => { + onState(`${help ? "help" : ""}${palette ? "palette" : ""}` || "none"); + }, [help, palette, onState]); + return null; +} + +const key = (init: KeyboardEventInit) => + act(() => { + window.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }), + ); + }); + +describe("useGlobalKeymap", () => { + it("opens the cheat sheet on h and the palette on Mod+K", () => { + let state = ""; + render( (state = s)} />); + key({ key: "h" }); + expect(state).toBe("help"); + key({ key: "h" }); + expect(state).toBe("none"); + key({ key: "k", ctrlKey: true }); + expect(state).toBe("palette"); + }); + + // Mod+K is written as a toggle, and the palette autofocuses its own search field — so without + // `allowInInput` on the row the second press was eaten by the typing guard and Chrome took the + // chord instead (round 1). The FIRST press must still be routed from a field, too. + it("toggles the palette shut from inside a text field", () => { + let state = ""; + render( (state = s)} />); + const field = document.createElement("input"); + document.body.appendChild(field); + field.focus(); + + act(() => { + field.dispatchEvent( + new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true, cancelable: true }), + ); + }); + expect(state).toBe("palette"); + act(() => { + field.dispatchEvent( + new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true, cancelable: true }), + ); + }); + expect(state).toBe("none"); + field.remove(); + }); + + // `h` still belongs to whatever is being typed. + it("leaves a plain letter to the text field it was typed into", () => { + let state = ""; + render( (state = s)} />); + const field = document.createElement("input"); + document.body.appendChild(field); + field.focus(); + act(() => { + field.dispatchEvent( + new KeyboardEvent("keydown", { key: "h", bubbles: true, cancelable: true }), + ); + }); + expect(state).toBe("none"); + field.remove(); + }); + + // Each toggle mounts/unmounts an overlay that costs a real history entry, so ~30 of them a second + // desyncs the overlay bookkeeping from the browser's actual stack. Holding a key is not a press. + it("ignores auto-repeat", () => { + let state = ""; + render( (state = s)} />); + key({ key: "h", repeat: true }); + expect(state).toBe("none"); + key({ key: "k", ctrlKey: true, repeat: true }); + expect(state).toBe("none"); + }); + + // Bound above the branch that mounts the overlays, the keys were swallowed on the loading/Welcome + // screens and the flipped store then sprang the overlay open by itself once the shell mounted. + it("binds nothing, and clears a stale store, while disabled", () => { + let state = ""; + const { rerender } = render( (state = s)} />); + key({ key: "h" }); + expect(state).toBe("help"); + + rerender( (state = s)} />); + expect(state).toBe("none"); // the stale `true` is cleared, not carried into the next mount + key({ key: "h" }); + expect(state).toBe("none"); // and the listener is gone + }); +}); diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index 7989ba9a..1f33789b 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -146,6 +146,10 @@ let overlayCounter = 0; let historyDepth = 0; // Programmatic history.back() calls still awaiting their popstate — each must be ignored. let pendingProgrammatic = 0; +// Overlay entries a navigation has PUSHED PAST. They are still real history entries and their +// overlays may still be mounted, but they are no longer on top, so reclaiming one with history.back() +// would pop the navigation instead. Reconcile consumes them silently. See closeThenNavigate. +let buriedEntries = 0; let reconcileScheduled = false; let listenerInstalled = false; @@ -153,10 +157,17 @@ let listenerInstalled = false; function reconcileHistory(): void { reconcileScheduled = false; const want = overlayStack.length; + // A genuine user Back decrements historyDepth on its own, so a burial can outlive the entries it + // described; never claim more buried entries than there are entries. + if (buriedEntries > historyDepth) buriedEntries = historyDepth; while (historyDepth < want) { historyDepth++; window.history.pushState({ ...window.history.state, _ov: ++overlayCounter }, ""); } + while (historyDepth > want && buriedEntries > 0) { + historyDepth--; // buried: the browser entry stays, but it is not ours to reclaim + buriedEntries--; + } while (historyDepth > want) { historyDepth--; pendingProgrammatic++; @@ -212,14 +223,15 @@ export function closeThenNavigate(close: () => void, navigate: () => void): void window.removeEventListener("popstate", onPop); clearTimeout(fallback); navigate(); - // Any overlay entry still counted in `historyDepth` is now BURIED under the entry the navigation - // just pushed, so a later unmount must NOT try to reclaim it with history.back() — that pops the - // navigation instead and silently bounces the user back where they started. Found with a palette - // opened over the player (E10 review round 1): closing the palette landed its own popstate, the - // navigation then unmounted the player too, and the player's reconcile ate the new page entry. - // Adopting the entries (rather than backing over them) leaves Back meaning "return to where I - // was", which is what a navigation should mean anyway. - historyDepth = 0; + // Every overlay entry still counted is now BURIED under the entry the navigation just pushed, so + // a later unmount must NOT reclaim it with history.back() — that pops the navigation instead and + // silently bounces the user back where they started (a palette opened over the player: closing + // the palette landed its popstate, the navigation unmounted the player too, and the player's + // reconcile ate the new page entry). MARK them rather than zeroing the depth: round 1 set + // `historyDepth = 0` outright, which also desynced the counter from overlays the navigation does + // NOT unmount — About/ReleaseNotes live outside the page column — so one Back both navigated and + // closed them (round 2). + buriedEntries = historyDepth; }; const onPop = () => { // The same reconcile can issue MORE than one back(); a pushState made between them would be diff --git a/frontend/src/lib/keymap.ts b/frontend/src/lib/keymap.ts index 6b85ee4f..d907e692 100644 --- a/frontend/src/lib/keymap.ts +++ b/frontend/src/lib/keymap.ts @@ -192,9 +192,17 @@ export function matches(e: KeyboardEvent, chord: string): boolean { * matched at the element level (Shift+F10 belongs to whichever row has focus, not to the window), * but it must still go through `matches` or the table's `keys` become decoration. They had: the * context menu hand-rolled `e.key === "F10" && e.shiftKey`, so Ctrl+Shift+F10 opened it too and - * editing the row changed the help sheet and nothing else (E10 review round 1). */ -export function matchesShortcut(e: KeyboardEvent, id: string): boolean { - const row = ROWS.find((s) => s.id === id); + * editing the row changed the help sheet and nothing else (E10 review round 1). + * + * Keyed on scope AND id, both as literal types: ids are only unique WITHIN a scope (`back` is on + * both players), so a bare `ROWS.find(s => s.id === id)` would answer for whichever came first, and + * a typo'd string would silently never match (round 2). */ +export function matchesShortcut( + e: KeyboardEvent, + scope: S, + id: Extract["id"], +): boolean { + const row = ROWS.find((s) => s.scope === scope && s.id === id); return !!row?.keys.some((k) => matches(e, k)); } @@ -245,7 +253,11 @@ const tokens: { scope: KeyScope }[] = []; /** The live scopes, innermost first, always ending in `global`. Re-renders on change. */ export function useActiveScopes(): KeyScope[] { const stack = scopeStack.use(); - return [...stack].reverse().concat("global"); + // DEDUPED: a scope can be registered by more than one live component (the Playlists page and its + // rail each mount a `useContextMenu`), and `helpLines` appends a row's chords once per occurrence — + // so without this the sheet rendered "⇧ F10 Menu ⇧ F10 Menu" under a duplicated React key + // (E10 review round 2). Innermost registration wins the ordering. + return [...new Set([...stack].reverse())].concat("global"); } // ── The keyboard help sheet ─────────────────────────────────────────────────────────────────── From dac391a89016ec0e255825e5323196f61d544963 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 6 Sep 2026 02:22:23 +0200 Subject: [PATCH 16/17] =?UTF-8?q?fix(e10):=20review=20round=203=20?= =?UTF-8?q?=E2=80=94=20settle=20the=20focus=20race=20in=20a=20browser=20in?= =?UTF-8?q?stead=20of=20guessing=20again?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 reviewed round 2's diff. Trend across the loop: 32 findings over the whole epic, 15 over round 1's fixes, 11 over round 2's. What it caught is mostly the same shape as before — a guard narrowed until it was wrong in the other direction — plus one item this branch had now got wrong three different ways. Backspace ownership over the player, third and final answer. Round 1 deferred to nothing (the cheat sheet took the key and the player stopped underneath it). Round 1's fix deferred to `anyLayerAbove` (the player's own gear/tracks popovers push layers too, so it stopped closing them). Round 2's fix named the sheet and palette (the app-wide ErrorDialog is neither, so the player tore down under it). The rule that actually holds is not a list of overlays: the player's own panels answer first, because handleBack's cascade is what closes them, and anything else layered above takes the key. The rail's inline rename is settled by measurement rather than argument. Two rounds reasoned about whether a React passive effect lands before or after floating-ui's return-focus microtask and reached opposite conclusions; a jsdom harness built to decide it reproduced no failure for ANY shape, because focus never enters the menu there — so it was deleted rather than kept as a test that discriminates nothing. Checked in a real browser instead: the field opens and holds focus. The shipped fix works, and the comment now says what was measured rather than what was inferred. The same check found that round 1's OTHER rail fix never worked at all. Escape does run its own handler (typing then Escape leaves the name unchanged, so it cancels rather than blur-commits) and the row button is focusable when focused from outside — yet focus still lands on . Three explanations, none of which survived. So the refocus machinery is reverted to the shape that shipped before this epic and the gap is documented where the next person will hit it, with the evidence and an instruction to instrument before theorising. A fourth guess would have been worse than a known bug. Also: the two comments in Playlists.tsx that still claimed the disabled menu rows explain themselves — untrue since round 2 reverted `inert`, and exactly the comment-vs-code drift CLAUDE.md calls the source of the worst bugs here. And the move-failure toast printed HttpError.detail verbatim, which for the reachable 404 is the backend's bare English "Unknown playlist"; only a structured detail has been through localizeDetail, so only a structured detail reaches the toast now. --- frontend/src/components/Playlists.tsx | 10 ++-- frontend/src/components/PlaylistsRail.tsx | 65 +++++++++++------------ frontend/src/components/PlexPlayer.tsx | 24 ++++++--- 3 files changed, 55 insertions(+), 44 deletions(-) diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 8f25f8ab..fa03016d 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -634,7 +634,10 @@ export default function Playlists() { })), } : // Kept visible and disabled rather than dropped: an action that vanishes teaches - // nobody why. With one playlist there is nowhere to move to, and the title says so. + // nobody why. KNOWN GAP: the `title` that carries the why currently reaches nobody + // either — see ActionMenu's `title` prop, where the two failed attempts at fixing + // that are recorded. The row is honest about being off; it is not yet honest about + // the reason. { id: "move", label: t("playlists.moveTo"), @@ -656,8 +659,9 @@ export default function Playlists() { label, icon, separatorBefore: i === 0, - // Same gate as the drag handle, and it explains itself the same way instead of - // silently doing nothing. + // Same gate as the drag handle. NOTE the drag handle explains itself and this row + // does not: the handle is `aria-disabled` (focusable, tooltip-capable) while a menu + // row cannot be — see ActionMenu's `title` prop for why. disabled: !storedOrderEditable, title: storedOrderEditable ? undefined : dragHint, onSelect: () => moveInOrder(ctxRow, to), diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index 4446efe0..3442f3c8 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -163,38 +163,42 @@ export default function PlaylistsRail({ }, }); - // Which row to hand focus back to once the inline editor closes. Enter and Escape both unmount the - // focused and re-render the row as a