diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx
index 29af8ee..dfeb216 100644
--- a/web/src/app/App.tsx
+++ b/web/src/app/App.tsx
@@ -1,9 +1,9 @@
import { useEffect, type ReactNode } from 'react';
-import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
+import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../shared/stores/auth.store.ts';
import { useRuntime } from './providers/runtime-context.tsx';
import { LoginPage } from '../features/auth/pages/LoginPage.tsx';
-import { MediaChannelEntry } from '../features/media/components/channel-navigation.tsx';
+import { AppNav, navMode } from './AppNav.tsx';
import { MediaThemeController } from '../features/media/services/theme.ts';
import { FloatingNotice } from '../ui/floating-notice.tsx';
@@ -24,11 +24,23 @@ function AuthenticatedShell() {
return () => runtime.player.removeEventListener('open-controls', openControls);
}, [runtime, navigate]);
useEffect(() => runtime.player.setVisible(location.pathname.startsWith('/media/')), [runtime, location.pathname]);
- // Reading-adjacent pages own their header and discovery actions. The migrated
- // shell used to inject a second, generic navigation bar above every page, which
- // changed the vertical rhythm and made shelf/library diverge from the legacy UI.
- // Media keeps its own channel navigation through MediaLayout.
- return ;
+ const mode = navMode(location.pathname);
+ // Every page except the book itself wears the chosen theme: the reader keeps its own
+ // reading themes, everything around it shares one palette.
+ const themed = !location.pathname.startsWith('/book/');
+ useEffect(() => {
+ if (!themed) return undefined;
+ const theme = new MediaThemeController(runtime.mediaApi.preferenceScope(), () => undefined);
+ return () => theme.dispose();
+ }, [runtime, themed]);
+ useEffect(() => {
+ if (mode === 'hidden') delete document.body.dataset.appNav; else document.body.dataset.appNav = mode;
+ return () => { delete document.body.dataset.appNav; };
+ }, [mode]);
+ return
+
+ {mode !== 'hidden' &&
}
+
;
}
export function AuthBoundary({ children }: { children?: ReactNode }) {
@@ -37,30 +49,8 @@ export function AuthBoundary({ children }: { children?: ReactNode }) {
return <>{children}>;
}
-export function MediaLayout() {
- const runtime = useRuntime();
- const location = useLocation();
- const current = location.pathname === '/me' || location.pathname.startsWith('/me/')
- ? 'me'
- : location.pathname.startsWith('/media/music')
- ? 'music'
- : location.pathname.startsWith('/media/audiobook')
- ? 'audiobook'
- : 'video';
- // The legacy media shell applies its own palette while any media route is
- // mounted. Keep this scoped to MediaLayout so reading pages continue to use
- // the reader palette, and let React CSS consume the injected theme tokens.
- useEffect(() => {
- const theme = new MediaThemeController(runtime.mediaApi.preferenceScope(), () => undefined);
- return () => theme.dispose();
- }, [runtime]);
- // Keep the channel rail after the page content in the DOM, matching the legacy
- // shell. Its presentation stylesheet may pin it to the viewport, while the
- // route-owned page remains the first and primary content surface.
- return <>>;
-}
-
-export function HomeRedirect() { return ; }
+/** Media routes: the page itself. Theme and navigation belong to AuthenticatedShell. */
+export function MediaLayout() { return ; }
export function LoadingPage() { return ; }
diff --git a/web/src/app/AppNav.tsx b/web/src/app/AppNav.tsx
new file mode 100644
index 0000000..e68f8f9
--- /dev/null
+++ b/web/src/app/AppNav.tsx
@@ -0,0 +1,73 @@
+import { Link, useLocation } from 'react-router-dom';
+import { BookOpen, Clapperboard, Headphones, House, Music2, UserRound, type LucideIcon } from 'lucide-react';
+import { useAuthStore } from '../shared/stores/auth.store.ts';
+import { useRuntime } from './providers/runtime-context.tsx';
+
+/**
+ * hidden: the book and the player own the whole screen.
+ * top: a top-level destination; the bar shows everywhere.
+ * push: a page reached from a top-level one (detail, settings, library files):
+ * on a phone it replaces the bar with its own back arrow, on desktop the
+ * sidebar stays.
+ */
+export type NavMode = 'hidden' | 'top' | 'push';
+
+const mediaTop = /^\/media\/(video|music|audiobook)(?:\/([^/]+))?$/;
+const pushed = new Set(['favorites', 'queue', 'settings', 'history', 'folders', 'player', 'narrators']);
+
+export function navMode(pathname: string): NavMode {
+ if (pathname.startsWith('/book/') || /^\/media\/[^/]+\/player(\/|$)/.test(pathname)) return 'hidden';
+ if (pathname === '/' || pathname === '/shelf' || pathname === '/me') return 'top';
+ const media = mediaTop.exec(pathname);
+ if (media) return media[2] && pushed.has(media[2]) ? 'push' : 'top';
+ return 'push';
+}
+
+interface Destination { to: string; label: string; icon: LucideIcon; current(path: string): boolean }
+const destinations: Destination[] = [
+ { to: '/', label: '首页', icon: House, current: path => path === '/' },
+ { to: '/shelf', label: '书架', icon: BookOpen, current: path => /^\/(shelf|library|sources)(\/|$)/.test(path) },
+ { to: '/media/video', label: '影音', icon: Clapperboard, current: path => path.startsWith('/media') },
+ { to: '/me', label: '我的', icon: UserRound, current: path => path === '/me' || path.startsWith('/me/') },
+];
+const channels: Array<{ id: string; to: string; label: string; icon: LucideIcon }> = [
+ { id: 'video', to: '/media/video', label: '影视', icon: Clapperboard },
+ { id: 'music', to: '/media/music', label: '音乐', icon: Music2 },
+ { id: 'audiobook', to: '/media/audiobook', label: '有声书', icon: Headphones },
+];
+
+/** The one navigation: a tab bar on a phone, a sidebar from 900px up. */
+export function AppNav({ mode }: { mode: Exclude }) {
+ const { pathname } = useLocation();
+ const runtime = useRuntime();
+ const user = useAuthStore(state => state.verifiedUser ?? state.session?.user ?? null);
+ const channel = /^\/media\/(video|music|audiobook)/.exec(pathname)?.[1];
+ const name = user?.displayName || user?.username || '';
+ return ;
+}
+
+function host(baseUrl: string): string { try { return new URL(baseUrl).host; } catch { return baseUrl; } }
diff --git a/web/src/app/router/routes.tsx b/web/src/app/router/routes.tsx
index 96d4c2a..4b7e814 100644
--- a/web/src/app/router/routes.tsx
+++ b/web/src/app/router/routes.tsx
@@ -1,5 +1,5 @@
import { Navigate, type RouteObject } from 'react-router-dom';
-import { AppShell, HomeRedirect, MediaLayout } from '../App.tsx';
+import { AppShell, MediaLayout } from '../App.tsx';
import { MediaCatalogPage } from '../../features/media/pages/MediaCatalogPage.tsx';
import { MediaSearchPage } from '../../features/media/pages/MediaSearchPage.tsx';
import { MediaFavoritesPage } from '../../features/media/pages/MediaFavoritesPage.tsx';
@@ -14,6 +14,7 @@ import { LibraryPage, LibraryBrowsePage, LibraryFilesPage } from '../../features
import { SourcesPage } from '../../features/sources/pages/SourcesPage.tsx';
import { ReaderPage } from '../../features/reader/pages/ReaderPage.tsx';
import { MinePage } from '../../features/account/pages/MinePage.tsx';
+import { HomePage } from '../../features/home/pages/HomePage.tsx';
import { MediaLibrariesPage, MediaLibraryCreatePage } from '../../features/media/pages/MediaLibrariesPage.tsx';
import { MediaLibraryEditPage, MediaLibraryPermissionsPage } from '../../features/media/pages/MediaLibraryEditorPages.tsx';
import { MediaTasksPage } from '../../features/media/pages/MediaTasksPage.tsx';
@@ -34,7 +35,7 @@ import { PluginManagementPage } from '../../features/sources/pages/PluginManagem
export const routes = [{
path: '/', element: , errorElement: , children: [
- { index: true, element: },
+ { index: true, element: },
{ path: 'shelf', element: },
{ path: 'library', element: },
{ path: 'library/books', element: },
diff --git a/web/src/app/shell.css b/web/src/app/shell.css
new file mode 100644
index 0000000..a632245
--- /dev/null
+++ b/web/src/app/shell.css
@@ -0,0 +1,101 @@
+/* V3 shell: one navigation for reading and media.
+ * A tab bar below 900px, a sidebar from 900px up. Colours come from the theme
+ * tokens that AuthenticatedShell applies to , so every theme restyles it. */
+:root { --app-nav-width: 232px; --app-nav-bar: 64px; }
+
+.app-shell { display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; }
+/* Pages were direct children of #app (a flex column that clips); keep exactly that
+ * contract for them one level down. */
+.app-shell-body { display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; overflow: hidden; }
+
+.app-nav {
+ --nav-ink: var(--media-theme-text, var(--reader-ink));
+ --nav-muted: var(--media-theme-muted, var(--reader-muted));
+ --nav-line: var(--media-theme-border, var(--reader-line));
+ --nav-surface: var(--media-theme-paper, var(--reader-surface));
+ --nav-accent: var(--media-theme-accent, var(--reader-accent));
+ --nav-soft: var(--media-theme-selection, var(--reader-paper));
+ flex: none;
+ box-sizing: border-box;
+ background: var(--nav-surface);
+ color: var(--nav-ink);
+ border-color: var(--nav-line);
+}
+.app-nav ul { list-style: none; margin: 0; padding: 0; }
+.app-nav a { color: inherit; text-decoration: none; }
+.app-nav a:focus-visible { outline: 2px solid var(--nav-accent); outline-offset: 2px; }
+.app-nav-icon { display: grid; place-items: center; }
+
+/* ---- phone: tab bar ---- */
+@media (max-width: 899px) {
+ .app-nav { order: 2; display: flex; height: calc(var(--app-nav-bar) + env(safe-area-inset-bottom)); padding: 6px 12px env(safe-area-inset-bottom); border-top: 1px solid var(--nav-line); }
+ .app-nav[data-mode='push'] { display: none; }
+ .app-nav-brand, .app-nav-sub, .app-nav-user { display: none; }
+ .app-nav-list { display: flex; flex: 1; }
+ .app-nav-list > li { flex: 1; min-width: 0; }
+ .app-nav-item { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; height: 52px; font-size: 11px; font-weight: 500; color: var(--nav-muted); }
+ .app-nav-item[aria-current='page'] { color: var(--nav-accent); font-weight: 600; }
+ .app-nav-item .app-nav-icon { width: 52px; height: 30px; border-radius: 15px; }
+ .app-nav-item[aria-current='page'] .app-nav-icon { background: var(--nav-soft); }
+}
+
+/* ---- desktop: sidebar ---- */
+@media (min-width: 900px) {
+ .app-shell { flex-direction: row; }
+ /* After the page in the DOM (so the phone bar follows the content), before it on screen. */
+ .app-nav { order: -1; display: flex; flex-direction: column; width: var(--app-nav-width); padding: 24px 14px; border-right: 1px solid var(--nav-line); overflow-y: auto; }
+ .app-nav-brand { padding: 0 14px 22px; font-family: 'Songti SC', 'Noto Serif SC', 'Source Han Serif SC', serif; font-size: 26px; font-weight: 700; color: var(--nav-accent); }
+ .app-nav-item { display: flex; align-items: center; gap: 12px; height: 44px; padding: 0 14px; border-radius: 12px; font-size: 15px; color: var(--nav-ink); }
+ .app-nav-item:hover { background: var(--media-theme-surface, var(--reader-paper)); }
+ .app-nav-item[aria-current='page'] { background: var(--nav-soft); color: var(--nav-accent); font-weight: 600; }
+ .app-nav-sub { margin-top: 2px; }
+ .app-nav-subitem { display: flex; align-items: center; gap: 12px; height: 38px; padding: 0 14px 0 46px; border-radius: 10px; font-size: 14px; color: var(--nav-muted); }
+ .app-nav-subitem i { width: 8px; height: 8px; border-radius: 50%; background: var(--media-theme-ch-video, var(--nav-accent)); }
+ .app-nav-subitem[data-channel='music'] i { background: var(--media-theme-ch-music, var(--nav-accent)); }
+ .app-nav-subitem[data-channel='audiobook'] i { background: var(--media-theme-ch-audio, var(--nav-accent)); }
+ .app-nav-subitem[aria-current='page'] { background: var(--media-theme-surface, var(--reader-paper)); color: var(--nav-ink); font-weight: 600; }
+ .app-nav-user { display: flex; align-items: center; gap: 10px; margin-top: auto; padding: 10px 12px; border-radius: 14px; background: var(--media-theme-background, var(--reader-paper)); }
+ .app-nav-avatar { display: grid; place-items: center; flex: none; width: 34px; height: 34px; border-radius: 50%; background: var(--nav-accent); color: var(--media-theme-on-accent, #fff); font-weight: 600; }
+ .app-nav-user-text { min-width: 0; }
+ .app-nav-user-text strong, .app-nav-user-text small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .app-nav-user-text strong { font-size: 14px; }
+ .app-nav-user-text small { font-size: 11px; color: var(--nav-muted); }
+}
+
+/* ---- the persistent mini player sits clear of the bar / sidebar ---- */
+@media (max-width: 899px) {
+ body[data-app-nav='top'] .media-player:is(.media-player-audio, .media-player-native-video) { bottom: calc(var(--app-nav-bar) + env(safe-area-inset-bottom) + 10px); }
+}
+@media (min-width: 900px) {
+ body[data-app-nav] .media-player:is(.media-player-audio, .media-player-native-video) { left: calc(var(--app-nav-width) + 24px); }
+}
+
+/* ---- reading pages wear the same theme as media ---- */
+body[data-media-theme] :is(.shelf-screen, .library-screen, .sources-screen, .home-page) {
+ --reader-ink: var(--media-theme-text);
+ --reader-paper: var(--media-theme-background);
+ --reader-surface: var(--media-theme-paper);
+ --reader-muted: var(--media-theme-muted);
+ --reader-line: var(--media-theme-border);
+ --reader-accent: var(--media-theme-accent);
+ background: var(--media-theme-background);
+ color: var(--media-theme-text);
+}
+
+/* The old fixed channel rail is gone; its top gutter goes with it. */
+@media (min-width: 701px) {
+ /* Only pages whose header scrolls with them; pages with a fixed header keep their 68px. */
+ body[data-app-nav] .media-screen:is(.media-catalog-page, .mine-page, .home-page) { padding-top: 24px; }
+ /* Search and the ⋯ menu used to hang from the rail with a negative offset. */
+ body[data-app-nav] .media-heading:has(.media-mobile-title) .media-heading-actions { position: fixed; z-index: 81; top: 12px; right: 32px; }
+}
+
+/* Fixed page headers were placed to clear the old rail's brand; they now clear the sidebar. */
+@media (min-width: 900px) {
+ body[data-app-nav] .media-screen .media-mobile-title,
+ body[data-app-nav] .media-screen.media-secondary-page > .media-heading,
+ body[data-app-nav] .media-narrators:not([data-view=directory]) > .media-narrator-heading,
+ body[data-app-nav] .media-utility-workspace .media-settings-heading,
+ body[data-app-nav] .media-manager-workspace > .media-heading { left: calc(var(--app-nav-width) + 24px); right: 32px; }
+ body[data-app-nav] .media-screen.media-detail-shell > .media-heading { left: var(--app-nav-width); right: 0; }
+}
diff --git a/web/src/features/home/pages/HomePage.tsx b/web/src/features/home/pages/HomePage.tsx
new file mode 100644
index 0000000..85cc140
--- /dev/null
+++ b/web/src/features/home/pages/HomePage.tsx
@@ -0,0 +1,87 @@
+import { Link } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { ChevronRight, Clapperboard, Headphones, Play, type LucideIcon } from 'lucide-react';
+import { useRuntime } from '../../../app/providers/runtime-context.tsx';
+import { useAuthStore } from '../../../shared/stores/auth.store.ts';
+import { BookCard, useCover } from '../../shelf/pages/ShelfPage.tsx';
+import { useShelf } from '../../shelf/queries/shelf.queries.ts';
+import { historyQuery, type Activity } from '../../media/queries/media.queries.ts';
+import type { ContinueReadingItem } from '../../../api/types.ts';
+import '../styles/home.css';
+
+export function greeting(hour: number): string {
+ return hour < 6 ? '夜深了' : hour < 12 ? '早上好' : hour < 18 ? '下午好' : '晚上好';
+}
+
+function clock(seconds: number): string {
+ const total = Math.max(0, Math.floor(seconds));
+ const h = Math.floor(total / 3600), m = Math.floor((total % 3600) / 60), s = total % 60;
+ return (h ? `${h}:${String(m).padStart(2, '0')}` : String(m)) + `:${String(s).padStart(2, '0')}`;
+}
+
+/** Progress through the unfinished activity, 0..1; unknown length reads as 0 rather than a guess. */
+export function activityProgress(activity: Activity): number {
+ const length = activity.end === null ? 0 : activity.end - activity.start;
+ return length > 0 && activity.position !== undefined ? Math.max(0, Math.min(1, (activity.position - activity.start) / length)) : 0;
+}
+
+function Resume({ book }: { book: ContinueReadingItem }) {
+ const runtime = useRuntime();
+ const src = useCover(runtime.api, book.coverUrl);
+ const percent = Math.round(Math.max(0, Math.min(1, book.percentage)) * 100);
+ return
+ {src ?
: {book.title}}
+
+ 继续阅读
+ {book.title || '未命名'}
+ {book.chapterTitle}
+
+ 继续 · {percent}%
+
+ ;
+}
+
+function ResumeMedia({ channel, label, icon: Icon, activity }: { channel: 'video' | 'audiobook'; label: string; icon: LucideIcon; activity: Activity }) {
+ const progress = activityProgress(activity);
+ const meta = activity.end !== null && activity.position !== undefined
+ ? `${channel === 'video' ? '已看' : '已听'} ${clock(activity.position - activity.start)} / ${clock(activity.end - activity.start)}`
+ : activity.partTitle;
+ return
+ {label}
+ {activity.title}{activity.partTitle && activity.partTitle !== activity.title ? ` · ${activity.partTitle}` : ''}
+ {meta}
+
+ ;
+}
+
+/** 首页: where you left off, across reading and media, then the shelf. */
+export function HomePage() {
+ const runtime = useRuntime();
+ const user = useAuthStore(state => state.verifiedUser ?? state.session?.user ?? null);
+ const reading = useQuery({
+ queryKey: ['home', 'reading', runtime.api.baseUrl, user?.id],
+ queryFn: ({ signal }) => runtime.api.continueReading(1, { signal }),
+ });
+ const shelf = useShelf(1, 'recent');
+ const video = useQuery(historyQuery(runtime, 'video', new URLSearchParams()));
+ const audio = useQuery(historyQuery(runtime, 'audiobook', new URLSearchParams()));
+ const unfinished = (items?: Activity[]) => items?.find(item => !item.completed);
+ const book = reading.data?.[0];
+ const videoActivity = unfinished(video.data?.items), audioActivity = unfinished(audio.data?.items);
+ const books = (shelf.data?.items ?? []).slice(0, 8);
+ const name = user?.displayName || user?.username || '';
+ return
+
{greeting(new Date().getHours())}{name ? `,${name}` : ''}
从上次停下的地方继续
+ {book
+ ?
+ :
}
+ {(videoActivity || audioActivity) &&
+ {videoActivity && }
+ {audioActivity && }
+
}
+ {books.length > 0 &&
+ 我的书架
全部
+ {books.map(item => )}
+ }
+
;
+}
diff --git a/web/src/features/home/styles/home.css b/web/src/features/home/styles/home.css
new file mode 100644
index 0000000..0a4004d
--- /dev/null
+++ b/web/src/features/home/styles/home.css
@@ -0,0 +1,46 @@
+/* 首页: continue where you left off. Uses the shared theme tokens. */
+.home-page { max-width: 1000px; }
+.media-screen.home-page { padding-top: 20px; }
+.home-header h1 { margin: 6px 0 0; font-family: 'Songti SC', 'Noto Serif SC', 'Source Han Serif SC', serif; font-size: 28px; line-height: 1.2; }
+.home-header p { margin: 4px 0 0; font-size: 14px; color: var(--muted); }
+
+.home-pill { align-self: flex-start; display: inline-flex; align-items: center; height: 22px; padding: 0 10px; border-radius: 11px; background: rgb(255 255 255 / 0.18); font-size: 12px; }
+.home-progress { display: block; height: 4px; border-radius: 2px; background: var(--border); overflow: hidden; }
+.home-progress > i { display: block; height: 100%; border-radius: 2px; background: currentColor; }
+
+.home-hero, .home-empty {
+ display: flex;
+ gap: 16px;
+ margin-top: 16px;
+ padding: 18px;
+ border-radius: 24px;
+ background: var(--media-accent);
+ color: var(--media-on-accent);
+ text-decoration: none;
+}
+.home-hero:focus-visible, .home-card:focus-visible { outline: 2px solid var(--media-accent); outline-offset: 3px; }
+.home-hero-cover { display: grid; place-items: center; flex: none; width: 84px; height: 120px; overflow: hidden; border-radius: 12px; background: #e9dfc9; color: #1b1a17; box-shadow: 0 6px 16px rgb(27 26 23 / 0.25); }
+.home-hero-cover img { width: 100%; height: 100%; object-fit: cover; }
+.home-hero-cover b { padding: 10px; font-family: 'Songti SC', 'Noto Serif SC', serif; font-size: 15px; line-height: 1.3; letter-spacing: 0.05em; overflow-wrap: anywhere; }
+.home-hero-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
+.home-hero-text strong { margin-top: 8px; font-family: 'Songti SC', 'Noto Serif SC', serif; font-size: 21px; overflow-wrap: anywhere; }
+.home-hero-text small { font-size: 13px; opacity: 0.85; }
+.home-hero-text .home-progress { margin-top: auto; background: rgb(255 255 255 / 0.25); color: var(--media-on-accent); }
+.home-hero-action { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; height: 40px; margin-top: 12px; padding: 0 18px; border-radius: 20px; background: var(--media-on-accent); color: var(--media-accent); font-size: 14px; font-weight: 600; }
+
+.home-empty { flex-direction: column; align-items: flex-start; gap: 8px; }
+.home-empty a { color: inherit; font-weight: 600; }
+
+.home-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin-top: 12px; }
+.home-card { display: flex; flex-direction: column; gap: 2px; padding: 14px; border-radius: 20px; text-decoration: none; color: var(--text); background: var(--media-theme-ch-video-soft, var(--surface)); --ch: var(--media-theme-ch-video, var(--media-accent)); }
+.home-card[data-channel='audiobook'] { background: var(--media-theme-ch-audio-soft, var(--surface)); --ch: var(--media-theme-ch-audio, var(--media-accent)); }
+.home-card-label { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--ch); }
+.home-card strong { margin-top: 8px; font-size: 15px; overflow-wrap: anywhere; }
+.home-card small { margin-bottom: 10px; font-size: 12px; color: var(--muted); }
+.home-card .home-progress { background: rgb(0 0 0 / 0.1); color: var(--ch); }
+
+.home-section { display: flex; align-items: center; margin: 24px 0 10px; }
+.home-section h2 { flex: 1; margin: 0; font-family: 'Songti SC', 'Noto Serif SC', serif; font-size: 19px; }
+.home-section a { display: inline-flex; align-items: center; color: var(--muted); font-size: 13px; text-decoration: none; }
+.home-shelf-row { display: flex; gap: 14px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: thin; }
+.home-shelf-row .book-card { flex: none; width: 108px; }
diff --git a/web/src/features/media/components/channel-navigation.tsx b/web/src/features/media/components/channel-navigation.tsx
index e47cfa7..d92e66f 100644
--- a/web/src/features/media/components/channel-navigation.tsx
+++ b/web/src/features/media/components/channel-navigation.tsx
@@ -17,7 +17,7 @@ export function ChannelSwitcher({current,label}:{current:string;label?:string})
useEffect(()=>{const close=(event:PointerEvent)=>{if(root.current&&!root.current.contains(event.target as Node))root.current.open=false;};document.addEventListener('pointerdown',close);return()=>document.removeEventListener('pointerdown',close);},[]);
return {if(event.key==='Escape'){event.preventDefault();event.stopPropagation();root.current!.open=false;root.current!.querySelector('summary')?.focus();}}}>
{label&&{label}}
-
+
;
}
export function MediaChannelEntry({ current }: { current: string }) {
diff --git a/web/src/features/media/services/theme.ts b/web/src/features/media/services/theme.ts
index 03a2ea4..b61a1bd 100644
--- a/web/src/features/media/services/theme.ts
+++ b/web/src/features/media/services/theme.ts
@@ -22,6 +22,12 @@ export function saveMediaTheme(scope:string,value:MediaThemeId){
export function mediaThemePalette(id:MediaThemeId,dark=false){return mediaThemes.find(theme=>theme.id===(id==='system'?(dark?'midnight':'sand'):id))!;}
/** Only prefixed media variables are placed on the body; reading tokens remain untouched. */
+// [ink, soft background] per channel. Ink is used as text on its own soft background, so
+// every pair is held to 4.5:1 by test/media-theme-contrast.test.ts.
+const lightChannels={read:['#935825','#f3e6d8'],video:['#4b4fd6','#e4e5fa'],music:['#b23656','#f6e0e6'],audio:['#0c7272','#d9eeee']} as const;
+const darkChannels={read:['#e0a36a','#33291f'],video:['#9a9dff','#25264a'],music:['#f28ba6','#3c2229'],audio:['#5fd0cb','#17363a']} as const;
+export const channelColors={light:lightChannels,dark:darkChannels};
+
export class MediaThemeController {
current:MediaThemeId;
private readonly system=typeof window.matchMedia==='function'?window.matchMedia('(prefers-color-scheme: dark)'):undefined;
@@ -45,11 +51,17 @@ export class MediaThemeController {
document.body.style.setProperty('--media-theme-scheme',theme.dark?'dark':'light');
document.body.style.setProperty('--media-theme-error-ink',theme.dark?'#f4a89d':'#943c32');
document.body.style.setProperty('--media-theme-error-background',theme.dark?'#f49a8c18':'#a4504515');
+ // Channel colours: each content type keeps one hue everywhere (icons, progress, labels).
+ const channel=theme.dark?darkChannels:lightChannels;
+ for(const [name,[ink,soft]] of Object.entries(channel)){document.body.style.setProperty('--media-theme-ch-'+name,ink);document.body.style.setProperty('--media-theme-ch-'+name+'-soft',soft);}
+ document.body.style.setProperty('--media-theme-on-ch',theme.dark?'#0b1a33':'#ffffff');
}
dispose(){
this.system?.removeEventListener('change',this.onSystem);window.removeEventListener('storage',this.onStorage);window.removeEventListener(themeChangeEvent,this.onThemeChange);
delete document.body.dataset.mediaTheme;
tokens.forEach(token=>document.body.style.removeProperty('--media-theme-'+token));
for(const token of ['scheme','error-ink','error-background'])document.body.style.removeProperty('--media-theme-'+token);
+ for(const name of Object.keys(lightChannels)){document.body.style.removeProperty('--media-theme-ch-'+name);document.body.style.removeProperty('--media-theme-ch-'+name+'-soft');}
+ document.body.style.removeProperty('--media-theme-on-ch');
}
}
diff --git a/web/src/features/shelf/pages/ShelfPage.tsx b/web/src/features/shelf/pages/ShelfPage.tsx
index acf87a0..f779964 100644
--- a/web/src/features/shelf/pages/ShelfPage.tsx
+++ b/web/src/features/shelf/pages/ShelfPage.tsx
@@ -2,7 +2,7 @@ import { Alert } from '@mantine/core';
import { useQueryClient } from '@tanstack/react-query';
import { Link, useSearchParams } from 'react-router-dom';
import { useEffect, useMemo, useState, type FormEvent } from 'react';
-import { BookOpen, Headphones, LibraryBig, MoreHorizontal, Search, Settings, SlidersVertical, UserRound } from 'lucide-react';
+import { BookOpen, Headphones, LibraryBig, MoreHorizontal, Search, Settings, SlidersVertical } from 'lucide-react';
import type { Book, ContinueReadingItem } from '../../../api/types.ts';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
@@ -15,7 +15,7 @@ import { DialogView, type Dialog } from '../../../ui/dialog.tsx';
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
const coverCache = new WeakMap