+
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;