From 4086f800664e590b9085ff0974c87a25b2cff87c Mon Sep 17 00:00:00 2001
From: Richard R
Date: Mon, 1 Jun 2026 13:40:47 -0600
Subject: [PATCH] refactor(ui): consolidate button, menu, popover, and range
primitives for unified usage
Remove legacy UI harness and dev/demo files. Replace scattered button, menu, popover, and range input utilities with shared, composable primitives: Button, ButtonLink, ButtonAnchor, MenuActionItem, MenuItemsSurface, PopoverSurface, PopoverTrigger, and RangeInput. Update all usages across app, admin, player, and document components to use these new primitives, eliminating duplicated class logic and improving consistency. Remove obsolete utility files and class exports. This change streamlines UI code, centralizes styling, and reduces maintenance overhead.
---
src/app/(app)/dev/ui/page.tsx | 5 -
src/app/(app)/epub/[id]/page.tsx | 18 +-
src/app/(app)/html/[id]/page.tsx | 18 +-
src/app/(app)/pdf/[id]/page.tsx | 27 +--
src/app/(public)/layout.tsx | 23 +--
src/app/(public)/page.tsx | 39 +---
src/app/(public)/privacy/page.tsx | 24 +--
src/components/AudiobookExportModal.tsx | 94 ++++-----
src/components/CodeBlock.tsx | 14 +-
src/components/ColorPicker.tsx | 13 +-
src/components/CookieConsentBanner.tsx | 26 +--
src/components/ProgressCard.tsx | 14 +-
src/components/SettingsModal.tsx | 37 ++--
src/components/admin/AdminFeaturesPanel.tsx | 21 +-
src/components/admin/AdminProvidersPanel.tsx | 105 ++++------
src/components/auth/UserMenu.tsx | 10 +-
src/components/dev/UiHarness.tsx | 188 ------------------
src/components/doclist/views/GalleryView.tsx | 17 +-
.../doclist/window/FinderSidebar.tsx | 64 +++---
.../documents/DocumentHeaderMenu.tsx | 53 ++---
src/components/documents/DocumentSettings.tsx | 15 +-
src/components/documents/ZoomControl.tsx | 26 +--
src/components/player/Navigator.tsx | 15 +-
src/components/player/SpeedControl.tsx | 20 +-
src/components/player/VoicesControlBase.tsx | 6 +-
src/components/reader/SegmentsSidebar.tsx | 10 +-
src/components/ui/button.tsx | 41 +++-
src/components/ui/menu.tsx | 47 ++++-
src/components/ui/popover.ts | 9 -
src/components/ui/popover.tsx | 35 ++++
src/components/ui/{range.ts => range.tsx} | 12 +-
src/components/ui/select.tsx | 21 +-
32 files changed, 398 insertions(+), 669 deletions(-)
delete mode 100644 src/app/(app)/dev/ui/page.tsx
delete mode 100644 src/components/dev/UiHarness.tsx
delete mode 100644 src/components/ui/popover.ts
create mode 100644 src/components/ui/popover.tsx
rename src/components/ui/{range.ts => range.tsx} (71%)
diff --git a/src/app/(app)/dev/ui/page.tsx b/src/app/(app)/dev/ui/page.tsx
deleted file mode 100644
index 25c2f3c..0000000
--- a/src/app/(app)/dev/ui/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { UiHarness } from '@/components/dev/UiHarness';
-
-export default function UiDevPage() {
- return ;
-}
diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx
index b2df05d..4a25c2e 100644
--- a/src/app/(app)/epub/[id]/page.tsx
+++ b/src/app/(app)/epub/[id]/page.tsx
@@ -1,7 +1,6 @@
'use client';
import { useParams, useRouter } from "next/navigation";
-import Link from 'next/link';
import { useCallback, useEffect, useRef, useState } from 'react';
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
import { EPUBViewer } from '@/components/views/EPUBViewer';
@@ -20,7 +19,7 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef';
-import { buttonClass } from '@/components/ui';
+import { ButtonLink } from '@/components/ui';
import { useEpubDocument } from './useEpubDocument';
export default function EPUBPage() {
@@ -172,15 +171,12 @@ export default function EPUBPage() {
return (
{error}
-
+
Back to Documents
-
+
);
}
@@ -189,16 +185,12 @@ export default function EPUBPage() {
<>
+
Documents
-
+
}
title={isLoading ? 'Loading…' : (currDocName || '')}
right={
diff --git a/src/app/(app)/html/[id]/page.tsx b/src/app/(app)/html/[id]/page.tsx
index d5ec371..f0193a3 100644
--- a/src/app/(app)/html/[id]/page.tsx
+++ b/src/app/(app)/html/[id]/page.tsx
@@ -1,7 +1,6 @@
'use client';
import { useParams, useRouter } from "next/navigation";
-import Link from 'next/link';
import { useCallback, useEffect, useRef, useState } from 'react';
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
import { HTMLViewer } from '@/components/views/HTMLViewer';
@@ -18,7 +17,7 @@ import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef';
-import { buttonClass } from '@/components/ui';
+import { ButtonLink } from '@/components/ui';
import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client';
import { useHtmlDocument } from './useHtmlDocument';
@@ -159,15 +158,12 @@ export default function HTMLPage() {
return (
{error}
-
+
Back to Documents
-
+
);
}
@@ -176,16 +172,12 @@ export default function HTMLPage() {
<>
+
Documents
-
+
}
title={isLoading ? 'Loading…' : (currDocName || '')}
right={
diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx
index bd9f17b..0d1b3b9 100644
--- a/src/app/(app)/pdf/[id]/page.tsx
+++ b/src/app/(app)/pdf/[id]/page.tsx
@@ -2,7 +2,6 @@
import dynamic from 'next/dynamic';
import { useParams, useRouter } from 'next/navigation';
-import Link from 'next/link';
import { useCallback, useEffect, useRef, useState, type MouseEvent } from 'react';
import { useTTS } from '@/contexts/TTSContext';
import { DocumentSettings } from '@/components/documents/DocumentSettings';
@@ -20,7 +19,7 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { LoadingSpinner } from '@/components/Spinner';
-import { buttonClass } from '@/components/ui';
+import { Button, ButtonLink } from '@/components/ui';
import {
FORCE_REPARSE_CONFIRM_MESSAGE,
FORCE_REPARSE_CONFIRM_TEXT,
@@ -253,16 +252,12 @@ export default function PDFViewerPage() {
return (
{error}
-
+
Back to Documents
-
+
);
}
@@ -343,13 +338,14 @@ export default function PDFViewerPage() {
{!isLoading && parseUiState === 'failed' ? (
-
+
) : null}
@@ -382,17 +378,12 @@ export default function PDFViewerPage() {
<>
+
Documents
-
+
}
title={isLoading ? 'Loading…' : (currDocName || '')}
right={
diff --git a/src/app/(public)/layout.tsx b/src/app/(public)/layout.tsx
index 4b08f30..f850899 100644
--- a/src/app/(public)/layout.tsx
+++ b/src/app/(public)/layout.tsx
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import Link from 'next/link';
import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc';
-import { buttonClass } from '@/components/ui';
+import { ButtonAnchor, ButtonLink } from '@/components/ui';
import './public.css';
export default async function PublicLayout({ children }: { children: ReactNode }) {
@@ -42,12 +42,8 @@ export default async function PublicLayout({ children }: { children: ReactNode }
GitHub
-
- Sign in
-
-
- Open app
-
+ Sign in
+ Open app
@@ -70,18 +66,11 @@ export default async function PublicLayout({ children }: { children: ReactNode }
{enableUserSignups ? (
-
- Create account
-
+
Create account
) : null}
-
+
Star on GitHub
-
+
diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx
index 49ead03..069ca95 100644
--- a/src/app/(public)/page.tsx
+++ b/src/app/(public)/page.tsx
@@ -2,7 +2,7 @@ import type { CSSProperties } from 'react';
import type { Metadata } from 'next';
import Link from 'next/link';
import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc';
-import { buttonClass } from '@/components/ui';
+import { ButtonAnchor, ButtonLink } from '@/components/ui';
export const metadata: Metadata = {
title: 'Open Source Read-Along Workspace',
@@ -98,24 +98,13 @@ export default async function LandingPage() {
-
- Open the reader
-
+ Open the reader
{enableUserSignups ? (
-
- Create account
-
+ Create account
) : (
-
- Sign in
-
+ Sign in
)}
-
- Read the docs →
-
+ Read the docs →
@@ -316,22 +305,12 @@ export default async function LandingPage() {
external compute worker. Every piece is yours to host.
diff --git a/src/app/(public)/privacy/page.tsx b/src/app/(public)/privacy/page.tsx
index 30818b5..96ac56b 100644
--- a/src/app/(public)/privacy/page.tsx
+++ b/src/app/(public)/privacy/page.tsx
@@ -1,7 +1,7 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { headers } from 'next/headers';
-import { buttonClass } from '@/components/ui';
+import { ButtonAnchor, ButtonLink } from '@/components/ui';
export const metadata: Metadata = {
title: 'Privacy & Data Usage',
@@ -181,25 +181,13 @@ export default async function PrivacyPage() {
want full infrastructure control.
diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx
index ca378e7..1f9e7e3 100644
--- a/src/components/AudiobookExportModal.tsx
+++ b/src/components/AudiobookExportModal.tsx
@@ -1,7 +1,7 @@
'use client';
import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react';
-import { Transition, Listbox, Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react';
+import { Transition, Listbox, Menu, MenuButton } from '@headlessui/react';
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
import { ProgressPopup } from '@/components/ProgressPopup';
import { ProgressCard } from '@/components/ProgressCard';
@@ -13,7 +13,7 @@ import { VoicesControlBase } from '@/components/player/VoicesControlBase';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
-import { Button, Card, IconButton, MenuItemClass, SharedListboxButton, SharedListboxOption, SharedListboxOptions, cn, menuPanelClass, rangeInputClass } from '@/components/ui';
+import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, RangeInput, SharedListboxButton, SharedListboxOption, SharedListboxOptions, cn } from '@/components/ui';
import {
getAudiobookStatus,
deleteAudiobookChapter,
@@ -621,14 +621,12 @@ export function AudiobookExportModal({
{formatSpeed(nativeSpeed)}x
- setNativeSpeed(parseFloat(e.target.value))}
- className={rangeInputClass}
/>
0.5x
@@ -645,14 +643,12 @@ export function AudiobookExportModal({
{formatSpeed(postSpeed)}x
- setPostSpeed(parseFloat(e.target.value))}
- className={rangeInputClass}
/>
0.5x
@@ -794,68 +790,48 @@ export function AudiobookExportModal({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
-
{chapter.status === 'completed' && (
<>
-
-
+ setPendingDeleteChapter(chapter)}
+ title="Delete this chapter"
+ >
+
+ Delete
+
+ handleDownloadChapter(chapter)}>
+
+ Download
+
>
)}
{regeneratingChapter === chapter.index && (
-
+
+
+ Cancel
+
)}
{onRegenerateChapter && !isGenerating && (
-
+ handleRegenerateChapter(chapter)}
+ title="Regenerate this chapter"
+ >
+
+ {regeneratingChapter === chapter.index ? 'Regenerating...' : 'Regenerate'}
+
)}
-
+
{/* end of menu items */}
diff --git a/src/components/CodeBlock.tsx b/src/components/CodeBlock.tsx
index f2d327c..3217389 100644
--- a/src/components/CodeBlock.tsx
+++ b/src/components/CodeBlock.tsx
@@ -2,7 +2,7 @@
import { useState } from 'react';
import { CopyIcon, CheckIcon } from '@/components/icons/Icons';
-import { buttonClass } from '@/components/ui';
+import { Button } from '@/components/ui';
export function CodeBlock({ children }: { children: string }) {
const [copied, setCopied] = useState(false);
@@ -19,17 +19,15 @@ export function CodeBlock({ children }: { children: string }) {
font-mono text-foreground border border-line">
{children}
-
+
);
}
diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx
index e203595..374ec34 100644
--- a/src/components/ColorPicker.tsx
+++ b/src/components/ColorPicker.tsx
@@ -1,10 +1,10 @@
'use client';
import { useRef, useState, useEffect, useCallback } from 'react';
-import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
+import { Popover, PopoverButton } from '@headlessui/react';
import { isLightColor, type CustomThemeColors } from '@/contexts/ThemeContext';
import { PaletteIcon } from '@/components/icons/Icons';
-import { IconButton, Input, cn, popoverPanelClass } from '@/components/ui';
+import { IconButton, Input, PopoverSurface } from '@/components/ui';
/**
* Curated swatch palettes per color role, sourced from existing themes
@@ -89,13 +89,10 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
/>
-
{/* Label */}
@@ -173,7 +170,7 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
className="flex-1 font-mono"
/>
-
+
);
}
diff --git a/src/components/CookieConsentBanner.tsx b/src/components/CookieConsentBanner.tsx
index 3e1a278..de748e0 100644
--- a/src/components/CookieConsentBanner.tsx
+++ b/src/components/CookieConsentBanner.tsx
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import Link from 'next/link';
import { getConsentState, setConsentState } from '@/lib/client/analytics';
import { Transition } from '@headlessui/react';
-import { buttonClass } from '@/components/ui';
+import { Button } from '@/components/ui';
export function CookieConsentBanner() {
const [show, setShow] = useState(false);
@@ -55,26 +55,22 @@ export function CookieConsentBanner() {
-
-
diff --git a/src/components/ProgressCard.tsx b/src/components/ProgressCard.tsx
index 380a45a..f8341d0 100644
--- a/src/components/ProgressCard.tsx
+++ b/src/components/ProgressCard.tsx
@@ -1,4 +1,4 @@
-import { buttonClass } from '@/components/ui';
+import { Button } from '@/components/ui';
interface ProgressCardProps {
progress: number;
@@ -52,17 +52,15 @@ export function ProgressCard({
)}
- onCancel(e)}
>
{cancelText}
-
+
{/* Progress bar */}
diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx
index 7d71964..3114e6b 100644
--- a/src/components/SettingsModal.tsx
+++ b/src/components/SettingsModal.tsx
@@ -4,9 +4,6 @@ import { Fragment, useState, useEffect, useCallback, useMemo } from 'react';
import {
Transition,
Listbox,
- ListboxButton,
- ListboxOptions,
- ListboxOption,
} from '@headlessui/react';
import Link from 'next/link';
import { useTheme } from '@/contexts/ThemeContext';
@@ -56,9 +53,9 @@ import {
ModalFrame,
ModalTitle,
inputClass,
- listboxButtonClass,
- listboxOptionClass,
- listboxOptionsClass,
+ SharedListboxButton,
+ SharedListboxOption,
+ SharedListboxOptions,
} from '@/components/ui';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -692,28 +689,26 @@ export function SettingsModal({
setCustomModelInput('');
}}
>
-
+
{selectedProviderOption?.name || 'Select Provider'}
-
+
-
{ttsProviders.map((provider) => (
- listboxOptionClass(active)}
value={provider}
>
{({ selected }) => (
@@ -728,9 +723,9 @@ export function SettingsModal({
)}
>
)}
-
+
))}
-
+
)}
@@ -797,7 +792,7 @@ export function SettingsModal({
}
}}
>
-
+
{selectedModel ? (
@@ -815,21 +810,19 @@ export function SettingsModal({
-
+
-
{ttsModels.map((model) => (
- listboxOptionClass(active)}
value={model}
>
{({ selected }) => (
@@ -849,9 +842,9 @@ export function SettingsModal({
)}
>
)}
-
+
))}
-
+
diff --git a/src/components/admin/AdminFeaturesPanel.tsx b/src/components/admin/AdminFeaturesPanel.tsx
index af55edc..162e4a5 100644
--- a/src/components/admin/AdminFeaturesPanel.tsx
+++ b/src/components/admin/AdminFeaturesPanel.tsx
@@ -1,7 +1,7 @@
'use client';
import { Fragment, useEffect, useMemo, useState } from 'react';
-import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react';
+import { Listbox, Transition } from '@headlessui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
@@ -10,9 +10,9 @@ import {
Section,
ToggleRow,
inputClass,
- listboxButtonClass,
- listboxOptionClass,
- listboxOptionsClass,
+ SharedListboxButton,
+ SharedListboxOption,
+ SharedListboxOptions,
Button,
} from '@/components/ui';
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
@@ -205,24 +205,23 @@ export function AdminFeaturesPanel() {
{providerOptions.length > 0 ? (
-
+
{selectedProviderOption?.name ?? 'Select provider'}
-
+
-
+
{providerOptions.map((opt) => (
- listboxOptionClass(active)}
>
{({ selected }) => (
<>
@@ -236,9 +235,9 @@ export function AdminFeaturesPanel() {
)}
>
)}
-
+
))}
-
+
) : (
diff --git a/src/components/admin/AdminProvidersPanel.tsx b/src/components/admin/AdminProvidersPanel.tsx
index 2f61ba3..8960fe9 100644
--- a/src/components/admin/AdminProvidersPanel.tsx
+++ b/src/components/admin/AdminProvidersPanel.tsx
@@ -1,7 +1,7 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
-import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
+import { Listbox, Menu, MenuButton, Transition } from '@headlessui/react';
import { Fragment } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
@@ -14,15 +14,14 @@ import {
Section,
ToggleRow,
inputClass,
- listboxButtonClass,
- listboxOptionClass,
- listboxOptionsClass,
+ SharedListboxButton,
+ SharedListboxOption,
+ SharedListboxOptions,
Button,
IconButton,
Input,
- MenuItemClass,
- cn,
- menuPanelClass,
+ MenuItemsSurface,
+ MenuActionItem,
} from '@/components/ui';
type ProviderType = TtsProviderId;
@@ -426,24 +425,23 @@ export function AdminProvidersPanel() {
setCustomModelInput('');
}}
>
-
+
{selectedProviderType.label}
-
+
-
+
{PROVIDER_TYPE_OPTIONS.map((opt) => (
- listboxOptionClass(active)}
>
{({ selected }) => (
<>
@@ -457,9 +455,9 @@ export function AdminProvidersPanel() {
)}
>
)}
-
+
))}
-
+
@@ -485,26 +483,25 @@ export function AdminProvidersPanel() {
setCustomModelInput('');
}}
>
-
+
{selectedModelDefinition?.name ?? 'Select model'}
-
+
-
+
{modelDefinitions.map((model) => (
- listboxOptionClass(active)}
>
{({ selected }) => (
<>
@@ -523,9 +520,9 @@ export function AdminProvidersPanel() {
)}
>
)}
-
+
))}
-
+
{supportsCustomModel && selectedModelId === 'custom' && (
@@ -663,56 +660,26 @@ export function AdminProvidersPanel() {
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
-
-
-
-
-
-
+ startEdit(p)}>
+ Edit
+
+ setDefault(p.slug)}
+ disabled={!p.enabled || defaultProviderSlug === p.slug}
+ >
+ Set as default
+
+ toggleEnabled(p)}>
+ {p.enabled ? 'Disable' : 'Enable'}
+
+ remove(p.id)}>
+ Delete
+
+
diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx
index c459eb1..e780d2a 100644
--- a/src/components/auth/UserMenu.tsx
+++ b/src/components/auth/UserMenu.tsx
@@ -7,7 +7,7 @@ import { useAuthSession } from '@/hooks/useAuthSession';
import { getAuthClient } from '@/lib/client/auth-client';
import { useRouter } from 'next/navigation';
import { UserIcon } from '@/components/icons/Icons';
-import { IconButton, SidebarNavItem, SidebarNavLink, buttonClass } from '@/components/ui';
+import { ButtonLink, IconButton, SidebarNavItem, SidebarNavLink } from '@/components/ui';
type UserMenuVariant = 'toolbar' | 'sidebar';
@@ -57,13 +57,13 @@ export function UserMenu({
return (
-
+
Connect
-
+
{enableUserSignups && (
-
+
Create account
-
+
)}
);
diff --git a/src/components/dev/UiHarness.tsx b/src/components/dev/UiHarness.tsx
deleted file mode 100644
index 11b4764..0000000
--- a/src/components/dev/UiHarness.tsx
+++ /dev/null
@@ -1,188 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { THEMES, useTheme } from '@/contexts/ThemeContext';
-import {
- Badge,
- Button,
- Card,
- CheckItem,
- Divider,
- Field,
- IconButton,
- Input,
- Panel,
- Section,
- Select,
- Sidebar,
- Surface,
- Switch,
- Textarea,
- ToggleRow,
- Toolbar,
- ToolbarButton,
- ToolbarGroup,
- ToolbarSegment,
- buttonClass,
- dropzoneSurfaceClass,
- popoverPanelClass,
- rangeInputClass,
-} from '@/components/ui';
-
-const THEME_OPTIONS = THEMES.filter((theme) => theme !== 'custom');
-
-export function UiHarness() {
- const { theme, setTheme } = useTheme();
- const [enabled, setEnabled] = useState(true);
- const [checked, setChecked] = useState(false);
- const [selectValue, setSelectValue] = useState('library');
-
- return (
-
-
-
-
-
-
- {THEME_OPTIONS.map((option) => (
- setTheme(option)}
- className={buttonClass({
- variant: theme === option ? 'primary' : 'outline',
- size: 'sm',
- className: 'capitalize',
- })}
- >
- {option}
-
- ))}
-
-
-
-
-
-
- Primary
- Secondary
- Outline
- Ghost
- Danger
- Disabled
- ↻
- ×
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Direct switch sample
-
-
-
-
-
-
-
- Range control
- 1.2x
-
-
-
-
-
-
-
- Sidebar
- Nav
-
-
- {['Library', 'Reader', 'Audio', 'Settings'].map((item, index) => (
-
- {item}
-
- ))}
-
-
-
-
-
-
-
- List
- Grid
- Gallery
-
- Sort by name
- Recently opened
-
-
-
-
Dropzone idle
-
Border and wash states live in `dropzoneStyles`.
-
-
-
Dropzone active
-
Drag feedback uses tokenized state colors.
-
-
-
Popover panel
-
Shared shell for player and reader controls.
-
-
-
-
-
- );
-}
diff --git a/src/components/doclist/views/GalleryView.tsx b/src/components/doclist/views/GalleryView.tsx
index 2941072..41245ad 100644
--- a/src/components/doclist/views/GalleryView.tsx
+++ b/src/components/doclist/views/GalleryView.tsx
@@ -7,7 +7,7 @@ import type { DocumentListDocument } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import { DocumentPreview } from '@/components/doclist/DocumentPreview';
import { formatDocumentSize } from '@/components/doclist/formatSize';
-import { buttonClass } from '@/components/ui';
+import { Button, ButtonLink } from '@/components/ui';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';
@@ -198,20 +198,17 @@ export function GalleryView({
-
+
Open
-
-
+ onDeleteDoc(activeDoc)}
- className={buttonClass({ variant: 'secondary', size: 'sm' })}
+ variant="secondary"
+ size="sm"
>
Delete
-
+
diff --git a/src/components/doclist/window/FinderSidebar.tsx b/src/components/doclist/window/FinderSidebar.tsx
index 573dcb0..20ee1c3 100644
--- a/src/components/doclist/window/FinderSidebar.tsx
+++ b/src/components/doclist/window/FinderSidebar.tsx
@@ -1,13 +1,13 @@
'use client';
-import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
+import { Menu, MenuButton, Transition } from '@headlessui/react';
import { Fragment, useRef, type CSSProperties, type ReactNode } from 'react';
import { useDrop } from 'react-dnd';
import type { Folder, SidebarFilter } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons';
import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
-import { IconButton, MenuItemClass, Sidebar as SidebarShell, SidebarNav, SidebarNavGroup, SidebarNavItem, cn, menuPanelClass } from '@/components/ui';
+import { IconButton, MenuActionItem, MenuItemsSurface, Sidebar as SidebarShell, SidebarNav, SidebarNavGroup, SidebarNavItem } from '@/components/ui';
interface FinderSidebarProps {
filter: SidebarFilter;
@@ -220,44 +220,32 @@ export function FinderSidebar({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
-
-
-
-
+ {
+ onNewFolder();
+ onRowAction?.();
+ }}
+ >
+
+ New Folder
+
+ {
+ onClearFolders();
+ onRowAction?.();
+ }}
+ >
+
+ Remove All Folders
+
+
)}
diff --git a/src/components/documents/DocumentHeaderMenu.tsx b/src/components/documents/DocumentHeaderMenu.tsx
index c6a671f..7581b9c 100644
--- a/src/components/documents/DocumentHeaderMenu.tsx
+++ b/src/components/documents/DocumentHeaderMenu.tsx
@@ -1,11 +1,11 @@
'use client';
-import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
+import { Menu, MenuButton, Transition } from '@headlessui/react';
import { Fragment } from 'react';
import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon, ListIcon } from '@/components/icons/Icons';
import { ZoomControl } from '@/components/documents/ZoomControl';
import { UserMenu } from '@/components/auth/UserMenu';
-import { IconButton, MenuItemClass, ToolbarButton, menuPanelClass } from '@/components/ui';
+import { IconButton, MenuActionItem, MenuItemsSurface, ToolbarButton } from '@/components/ui';
interface DocumentHeaderMenuProps {
zoomLevel: number;
@@ -100,7 +100,7 @@ export function DocumentHeaderMenu({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
-
+
{/* Zoom Controls Section */}
Zoom / Padding
@@ -125,49 +125,28 @@ export function DocumentHeaderMenu({
{/* Actions Section */}
{onOpenSegments && (
-
+
+
+ {isSegmentsOpen ? 'Hide Segments' : 'Segments'}
+
)}
{showAudiobookExport && onOpenAudiobook && (
-
+
+
+ {isAudiobookOpen ? 'Hide Audiobook' : 'Export Audiobook'}
+
)}
-
+
+
+ {isSettingsOpen ? 'Hide Settings' : 'Settings'}
+
{/* Auth Section */}
-
+
diff --git a/src/components/documents/DocumentSettings.tsx b/src/components/documents/DocumentSettings.tsx
index 553db7e..6632e09 100644
--- a/src/components/documents/DocumentSettings.tsx
+++ b/src/components/documents/DocumentSettings.tsx
@@ -15,7 +15,7 @@ import {
clampSegmentPreloadSentenceLookahead,
clampTtsSegmentMaxBlockLength,
} from '@/types/config';
-import { Section, ToggleRow, CheckItem, SegmentedControl, buttonClass, rangeInputClass } from '@/components/ui';
+import { IconButton, RangeInput, Section, ToggleRow, CheckItem, SegmentedControl } from '@/components/ui';
import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons';
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse';
@@ -77,14 +77,13 @@ function RangeSetting({
}
diff --git a/src/components/documents/ZoomControl.tsx b/src/components/documents/ZoomControl.tsx
index 0d88f0b..5a65840 100644
--- a/src/components/documents/ZoomControl.tsx
+++ b/src/components/documents/ZoomControl.tsx
@@ -1,4 +1,4 @@
-import { buttonClass } from '@/components/ui';
+import { IconButton } from '@/components/ui';
export function ZoomControl({
value,
@@ -15,33 +15,25 @@ export function ZoomControl({
}) {
return (
-
−
-
+
{value}%
- = max}
- className={buttonClass({
- variant: 'ghost',
- size: 'icon',
- className: 'h-6 w-6 text-sm leading-none',
- })}
+ size="sm"
+ className="h-6 w-6 text-sm leading-none"
aria-label="Zoom in"
>
+
-
+
);
}
diff --git a/src/components/player/Navigator.tsx b/src/components/player/Navigator.tsx
index c43b295..6a7a074 100644
--- a/src/components/player/Navigator.tsx
+++ b/src/components/player/Navigator.tsx
@@ -1,8 +1,8 @@
'use client';
-import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
+import { Popover } from '@headlessui/react';
import { useState, useEffect, useRef } from 'react';
-import { IconButton, Input, cn, popoverPanelClass, popoverTriggerClass } from '@/components/ui';
+import { IconButton, Input, PopoverSurface, PopoverTrigger } from '@/components/ui';
export const Navigator = ({ currentPage, numPages, skipToLocation }: {
currentPage: number;
@@ -67,15 +67,12 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
{/* Page number popup */}
-
+
{currentPage} / {numPages || 1}
-
-
+
+
-
+
{/* Page forward */}
diff --git a/src/components/player/SpeedControl.tsx b/src/components/player/SpeedControl.tsx
index eae7f04..516d001 100644
--- a/src/components/player/SpeedControl.tsx
+++ b/src/components/player/SpeedControl.tsx
@@ -1,11 +1,11 @@
'use client';
-import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
+import { Popover } from '@headlessui/react';
import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons';
import { useConfig } from '@/contexts/ConfigContext';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
-import { popoverPanelClass, popoverTriggerClass, rangeInputClass } from '@/components/ui';
+import { PopoverSurface, PopoverTrigger, RangeInput } from '@/components/ui';
export const SpeedControl = ({
setSpeedAndRestart,
@@ -89,13 +89,13 @@ export const SpeedControl = ({
return (
-
+
{compactTriggerLabel}
{triggerLabel}
-
-
+
+
{!nativeSpeedSupported && (
@@ -113,8 +113,7 @@ export const SpeedControl = ({
{max.toFixed(1)}x
-
)}
@@ -137,8 +135,7 @@ export const SpeedControl = ({
{max.toFixed(1)}x
-
-
+
);
};
diff --git a/src/components/player/VoicesControlBase.tsx b/src/components/player/VoicesControlBase.tsx
index b415744..fd2cad9 100644
--- a/src/components/player/VoicesControlBase.tsx
+++ b/src/components/player/VoicesControlBase.tsx
@@ -8,7 +8,7 @@ import { useEffect, useMemo, useState } from 'react';
import { buildKokoroVoiceString, parseKokoroVoiceNames } from '@/lib/shared/kokoro';
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
-import { SharedListboxButton, SharedListboxOption, SharedListboxOptions, cn, popoverTriggerClass } from '@/components/ui';
+import { SharedListboxButton, SharedListboxOption, SharedListboxOptions, cn } from '@/components/ui';
export function VoicesControlBase({
availableVoices,
@@ -33,8 +33,8 @@ export function VoicesControlBase({
const buttonClass = variant === 'field'
? 'bg-surface pr-10'
- : cn(popoverTriggerClass, 'space-x-0.5 px-1.5 py-0.5 text-xs sm:space-x-1 sm:px-2 sm:py-1 sm:text-sm');
- const buttonTone = variant === 'field' ? 'default' : 'unstyled';
+ : 'space-x-0.5 px-1.5 py-0.5 text-xs sm:space-x-1 sm:px-2 sm:py-1 sm:text-sm';
+ const buttonTone = variant === 'field' ? 'default' : 'popover';
const iconClass = variant === 'field'
? 'h-3.5 w-3.5 shrink-0'
diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx
index 961c6dd..d9ff378 100644
--- a/src/components/reader/SegmentsSidebar.tsx
+++ b/src/components/reader/SegmentsSidebar.tsx
@@ -1,14 +1,14 @@
'use client';
import { Fragment, type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react';
+import { Popover, PopoverButton, Transition } from '@headlessui/react';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { Book } from 'epubjs';
import toast from 'react-hot-toast';
import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext';
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
-import { Button, IconButton, popoverPanelClass } from '@/components/ui';
+import { Button, IconButton, PopoverSurface } from '@/components/ui';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';
@@ -903,9 +903,9 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
-
@@ -958,7 +958,7 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
))}
-
+
);
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index 2db2af3..b1ca111 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -1,4 +1,5 @@
-import type { ButtonHTMLAttributes, ReactNode } from 'react';
+import Link, { type LinkProps } from 'next/link';
+import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
import { variants } from './variants';
import { focusRing, motionColors } from './tokens';
@@ -45,7 +46,7 @@ export const btnOutline = buttonStyles({ variant: 'outline', size: 'md' });
export const btnDanger = buttonStyles({ variant: 'danger', size: 'md' });
export const btnGhost = buttonStyles({ variant: 'ghost', size: 'md' });
-export function buttonClass({
+function buttonClass({
variant = 'secondary',
size = 'md',
className = '',
@@ -75,3 +76,39 @@ export function Button({
);
}
+
+export function ButtonLink({
+ variant = 'secondary',
+ size = 'md',
+ className,
+ children,
+ ...props
+}: LinkProps & AnchorHTMLAttributes & {
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function ButtonAnchor({
+ variant = 'secondary',
+ size = 'md',
+ className,
+ children,
+ ...props
+}: AnchorHTMLAttributes & {
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/ui/menu.tsx b/src/components/ui/menu.tsx
index 26ae858..583fe4b 100644
--- a/src/components/ui/menu.tsx
+++ b/src/components/ui/menu.tsx
@@ -1,7 +1,9 @@
+import { MenuItem, MenuItems } from '@headlessui/react';
+import type { ComponentProps } from 'react';
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
-export const menuPanelClass = 'rounded-md border border-line bg-surface p-1 shadow-elev-2 ring-1 ring-line-soft';
+const menuPanelClass = 'rounded-md border border-line bg-surface p-1 shadow-elev-2 ring-1 ring-line-soft';
export function Menu({
children,
@@ -15,9 +17,50 @@ export function Menu({
);
}
-export function MenuItemClass(active: boolean, tone: 'default' | 'danger' = 'default') {
+function menuItemClass(active: boolean, tone: 'default' | 'danger' = 'default') {
if (tone === 'danger') {
return cn('flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs text-danger', active && 'bg-danger-wash');
}
return cn('flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs', active ? 'bg-accent-wash text-accent' : 'text-foreground');
}
+
+export function MenuItemsSurface({
+ className,
+ children,
+ ...props
+}: ComponentProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function MenuActionItem({
+ tone = 'default',
+ activeOverride = false,
+ disabled = false,
+ className,
+ children,
+ ...props
+}: Omit, 'className' | 'children'> & {
+ tone?: 'default' | 'danger';
+ activeOverride?: boolean;
+ className?: string;
+ children: ReactNode;
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/popover.ts b/src/components/ui/popover.ts
deleted file mode 100644
index 9f219d6..0000000
--- a/src/components/ui/popover.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { cn } from './cn';
-
-export const popoverPanelClass = cn(
- 'z-50 rounded-md border border-line bg-surface p-3 shadow-elev-2 focus:outline-none',
-);
-
-export const popoverTriggerClass = cn(
- 'inline-flex items-center rounded-md text-foreground hover:bg-accent-wash hover:text-accent focus:outline-none transition-colors duration-fast ease-standard',
-);
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
new file mode 100644
index 0000000..f18cfba
--- /dev/null
+++ b/src/components/ui/popover.tsx
@@ -0,0 +1,35 @@
+import { PopoverButton, PopoverPanel } from '@headlessui/react';
+import type { ComponentProps } from 'react';
+import { cn } from './cn';
+
+const popoverPanelClass = cn(
+ 'z-50 rounded-md border border-line bg-surface p-3 shadow-elev-2 focus:outline-none',
+);
+
+export const popoverTriggerClass = cn(
+ 'inline-flex items-center rounded-md text-foreground hover:bg-accent-wash hover:text-accent focus:outline-none transition-colors duration-fast ease-standard',
+);
+
+export function PopoverTrigger({
+ className,
+ children,
+ ...props
+}: ComponentProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function PopoverSurface({
+ className,
+ children,
+ ...props
+}: ComponentProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/ui/range.ts b/src/components/ui/range.tsx
similarity index 71%
rename from src/components/ui/range.ts
rename to src/components/ui/range.tsx
index a7b75e7..a2f6f25 100644
--- a/src/components/ui/range.ts
+++ b/src/components/ui/range.tsx
@@ -1,6 +1,7 @@
+import type { InputHTMLAttributes } from 'react';
import { cn } from './cn';
-export const rangeInputClass = cn(
+const rangeInputClass = cn(
'w-full cursor-pointer appearance-none rounded-lg bg-surface-sunken accent-accent',
'[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-runnable-track]:bg-surface-sunken',
'[&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent',
@@ -8,6 +9,13 @@ export const rangeInputClass = cn(
'[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-accent',
);
-export function rangeInputClassName(className?: string) {
+function rangeInputClassName(className?: string) {
return cn(rangeInputClass, className);
}
+
+export function RangeInput({
+ className,
+ ...props
+}: InputHTMLAttributes) {
+ return ;
+}
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
index fb9e8ec..5123278 100644
--- a/src/components/ui/select.tsx
+++ b/src/components/ui/select.tsx
@@ -5,29 +5,32 @@ import type { ComponentProps } from 'react';
import { cn } from './cn';
export { segmentedButtonClass, segmentedGroupClass } from './segmented-control';
-export const listboxButtonClass =
+const listboxButtonClass =
'relative w-full cursor-pointer rounded-md bg-surface-sunken border border-line py-1.5 pl-2.5 pr-9 text-left text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent-line hover:bg-accent-wash transition-colors duration-fast ease-standard';
-export const listboxToolbarButtonClass =
+const listboxToolbarButtonClass =
'inline-flex items-center rounded-md border border-line bg-surface px-2 py-1 text-xs text-foreground hover:border-accent-line hover:bg-accent-wash hover:text-accent transition-colors duration-fast ease-standard';
-export const listboxPanelClass =
+const listboxPopoverButtonClass =
+ 'inline-flex items-center rounded-md text-foreground hover:bg-accent-wash hover:text-accent focus:outline-none transition-colors duration-fast ease-standard';
+
+const listboxPanelClass =
'z-50 max-h-60 overflow-y-auto overscroll-contain rounded-md bg-surface p-1 shadow-elev-2 ring-1 ring-line focus:outline-none';
-export const listboxOptionsClass =
+const listboxOptionsClass =
cn(listboxPanelClass, 'w-[var(--button-width)] [--anchor-gap:0.25rem]');
-export const listboxCompactOptionsClass =
+const listboxCompactOptionsClass =
'z-50 min-w-[8rem] rounded-md bg-surface p-1 shadow-elev-2 ring-1 ring-line focus:outline-none [--anchor-gap:0.25rem]';
-export const listboxOptionClass = (active: boolean, selected = false, inset: 'check' | 'none' = 'check') =>
+const listboxOptionClass = (active: boolean, selected = false, inset: 'check' | 'none' = 'check') =>
cn(
'relative cursor-pointer select-none rounded-sm py-1.5 text-sm',
inset === 'check' ? 'pl-9 pr-3' : 'px-2.5',
selected ? 'bg-accent text-background font-medium' : active ? 'bg-accent-wash text-foreground' : 'text-foreground',
);
-export const listboxCompactOptionClass = (active: boolean, selected = false) =>
+const listboxCompactOptionClass = (active: boolean, selected = false) =>
cn(
'relative cursor-pointer select-none rounded-sm px-2 py-1 text-xs',
active
@@ -43,10 +46,12 @@ export function SharedListboxButton({
children,
...props
}: ComponentProps & {
- tone?: 'default' | 'toolbar' | 'unstyled';
+ tone?: 'default' | 'toolbar' | 'popover' | 'unstyled';
}) {
const baseClass = tone === 'toolbar'
? listboxToolbarButtonClass
+ : tone === 'popover'
+ ? listboxPopoverButtonClass
: tone === 'unstyled'
? ''
: listboxButtonClass;