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.
This commit is contained in:
Richard R 2026-06-01 13:40:47 -06:00
parent 5d0d3d313d
commit 4086f80066
32 changed files with 398 additions and 669 deletions

View file

@ -1,5 +0,0 @@
import { UiHarness } from '@/components/dev/UiHarness';
export default function UiDevPage() {
return <UiHarness />;
}

View file

@ -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 (
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-danger mb-4">{error}</p>
<Link
href="/app"
className={buttonClass({ variant: 'secondary', size: 'md', className: 'gap-2' })}
>
<ButtonLink href="/app" variant="secondary" size="md" className="gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Documents
</Link>
</ButtonLink>
</div>
);
}
@ -189,16 +185,12 @@ export default function EPUBPage() {
<>
<Header
left={
<Link
href="/app"
className={buttonClass({ variant: 'secondary', size: 'sm', className: 'gap-2' })}
aria-label="Back to documents"
>
<ButtonLink href="/app" variant="secondary" size="sm" className="gap-2" aria-label="Back to documents">
<svg className="w-3 h-3" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Documents
</Link>
</ButtonLink>
}
title={isLoading ? 'Loading…' : (currDocName || '')}
right={

View file

@ -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 (
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-danger mb-4">{error}</p>
<Link
href="/app"
className={buttonClass({ variant: 'secondary', size: 'md', className: 'gap-2' })}
>
<ButtonLink href="/app" variant="secondary" size="md" className="gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Documents
</Link>
</ButtonLink>
</div>
);
}
@ -176,16 +172,12 @@ export default function HTMLPage() {
<>
<Header
left={
<Link
href="/app"
className={buttonClass({ variant: 'secondary', size: 'sm', className: 'gap-2' })}
aria-label="Back to documents"
>
<ButtonLink href="/app" variant="secondary" size="sm" className="gap-2" aria-label="Back to documents">
<svg className="w-3 h-3" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Documents
</Link>
</ButtonLink>
}
title={isLoading ? 'Loading…' : (currDocName || '')}
right={

View file

@ -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 (
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-danger mb-4">{error}</p>
<Link
href="/app"
onClick={handleBackToDocuments}
className={buttonClass({ variant: 'secondary', size: 'md', className: 'gap-2' })}
>
<ButtonLink href="/app" onClick={handleBackToDocuments} variant="secondary" size="md" className="gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Documents
</Link>
</ButtonLink>
</div>
);
}
@ -343,13 +338,14 @@ export default function PDFViewerPage() {
{!isLoading && parseUiState === 'failed' ? (
<div className="mt-3 flex justify-start">
<button
<Button
type="button"
onClick={requestForceReparse}
className={buttonClass({ variant: 'secondary', size: 'sm' })}
variant="secondary"
size="sm"
>
Retry Parse
</button>
</Button>
</div>
) : null}
</div>
@ -382,17 +378,12 @@ export default function PDFViewerPage() {
<>
<Header
left={
<Link
href="/app"
onClick={handleBackToDocuments}
className={buttonClass({ variant: 'secondary', size: 'sm', className: 'gap-2' })}
aria-label="Back to documents"
>
<ButtonLink href="/app" onClick={handleBackToDocuments} variant="secondary" size="sm" className="gap-2" aria-label="Back to documents">
<svg className="w-3 h-3" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Documents
</Link>
</ButtonLink>
}
title={isLoading ? 'Loading…' : (currDocName || '')}
right={

View file

@ -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
</a>
<span className="public-nav-divider" aria-hidden="true" />
<Link href="/signin" className={buttonClass({ variant: 'ghost', size: 'sm' })}>
Sign in
</Link>
<Link href="/app" className={buttonClass({ variant: 'primary', size: 'sm' })}>
Open app
</Link>
<ButtonLink href="/signin" variant="ghost" size="sm">Sign in</ButtonLink>
<ButtonLink href="/app" variant="primary" size="sm">Open app</ButtonLink>
</nav>
</header>
</div>
@ -70,18 +66,11 @@ export default async function PublicLayout({ children }: { children: ReactNode }
</p>
<div className="public-footer-cta">
{enableUserSignups ? (
<Link href="/signup" className={buttonClass({ variant: 'outline', size: 'sm' })}>
Create account
</Link>
<ButtonLink href="/signup" variant="outline" size="sm">Create account</ButtonLink>
) : null}
<a
href="https://github.com/richardr1126/openreader"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'ghost', size: 'sm' })}
>
<ButtonAnchor href="https://github.com/richardr1126/openreader" target="_blank" rel="noopener noreferrer" variant="ghost" size="sm">
Star on GitHub
</a>
</ButtonAnchor>
</div>
</div>

View file

@ -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() {
</p>
<div className="public-actions">
<Link href="/app" className={buttonClass({ variant: 'primary', size: 'lg' })}>
Open the reader
</Link>
<ButtonLink href="/app" variant="primary" size="lg">Open the reader</ButtonLink>
{enableUserSignups ? (
<Link href="/signup" className={buttonClass({ variant: 'outline', size: 'lg' })}>
Create account
</Link>
<ButtonLink href="/signup" variant="outline" size="lg">Create account</ButtonLink>
) : (
<Link href="/signin" className={buttonClass({ variant: 'outline', size: 'lg' })}>
Sign in
</Link>
<ButtonLink href="/signin" variant="outline" size="lg">Sign in</ButtonLink>
)}
<Link
href="https://docs.openreader.richardr.dev/"
className={buttonClass({ variant: 'ghost', size: 'lg' })}
>
Read the docs
</Link>
<ButtonLink href="https://docs.openreader.richardr.dev/" variant="ghost" size="lg">Read the docs </ButtonLink>
</div>
<div className="public-formats" aria-label="Supported formats">
@ -316,22 +305,12 @@ export default async function LandingPage() {
external compute worker. Every piece is yours to host.
</p>
<div className="public-actions">
<a
href="https://github.com/richardr1126/openreader#readme"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'primary', size: 'lg' })}
>
<ButtonAnchor href="https://github.com/richardr1126/openreader#readme" target="_blank" rel="noopener noreferrer" variant="primary" size="lg">
View the repository
</a>
<a
href="https://docs.openreader.richardr.dev/docker-quick-start"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'outline', size: 'lg' })}
>
</ButtonAnchor>
<ButtonAnchor href="https://docs.openreader.richardr.dev/docker-quick-start" target="_blank" rel="noopener noreferrer" variant="outline" size="lg">
Deployment guides
</a>
</ButtonAnchor>
</div>
</div>

View file

@ -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.
</p>
<div className="policy-actions">
<a
href="https://github.com/richardr1126/openreader/issues"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'primary', size: 'md' })}
>
<ButtonAnchor href="https://github.com/richardr1126/openreader/issues" target="_blank" rel="noopener noreferrer" variant="primary" size="md">
Open an Issue
</a>
<a
href="https://github.com/richardr1126/openreader#readme"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'outline', size: 'md' })}
>
</ButtonAnchor>
<ButtonAnchor href="https://github.com/richardr1126/openreader#readme" target="_blank" rel="noopener noreferrer" variant="outline" size="md">
Self-Hosting Guide
</a>
<Link href="/?redirect=false" className={buttonClass({ variant: 'ghost', size: 'md' })}>
Back to landing
</Link>
</ButtonAnchor>
<ButtonLink href="/?redirect=false" variant="ghost" size="md">Back to landing</ButtonLink>
</div>
</section>
</div>

View file

@ -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({
<label className="text-[11px] uppercase tracking-wider font-medium text-soft">Native model speed</label>
<span className="text-xs font-medium text-accent tabular-nums">{formatSpeed(nativeSpeed)}x</span>
</div>
<input
type="range"
<RangeInput
min="0.5"
max="3"
step="0.1"
value={nativeSpeed}
onChange={(e) => setNativeSpeed(parseFloat(e.target.value))}
className={rangeInputClass}
/>
<div className="flex justify-between text-[10px] text-soft">
<span>0.5x</span>
@ -645,14 +643,12 @@ export function AudiobookExportModal({
<label className="text-[11px] uppercase tracking-wider font-medium text-soft">Post-generation speed</label>
<span className="text-xs font-medium text-accent tabular-nums">{formatSpeed(postSpeed)}x</span>
</div>
<input
type="range"
<RangeInput
min="0.5"
max="3"
step="0.1"
value={postSpeed}
onChange={(e) => setPostSpeed(parseFloat(e.target.value))}
className={rangeInputClass}
/>
<div className="flex justify-between text-[10px] text-soft">
<span>0.5x</span>
@ -794,68 +790,48 @@ export function AudiobookExportModal({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
<MenuItemsSurface
anchor={{ to: 'bottom end', gap: '8px', padding: '12px' }}
portal
className={cn(menuPanelClass, 'z-[70] w-44 origin-top-right bg-background focus:outline-none')}
className="z-[70] w-44 origin-top-right bg-background focus:outline-none"
>
{chapter.status === 'completed' && (
<>
<MenuItem>
{({ active }) => (
<button
onClick={() => setPendingDeleteChapter(chapter)}
className={MenuItemClass(active, 'danger')}
title="Delete this chapter"
>
<XCircleIcon className="h-4 w-4" />
<span>Delete</span>
</button>
)}
</MenuItem>
<MenuItem>
{({ active }) => (
<button
onClick={() => handleDownloadChapter(chapter)}
className={MenuItemClass(active)}
>
<DownloadIcon className="h-4 w-4" />
<span>Download</span>
</button>
)}
</MenuItem>
<MenuActionItem
tone="danger"
onClick={() => setPendingDeleteChapter(chapter)}
title="Delete this chapter"
>
<XCircleIcon className="h-4 w-4" />
<span>Delete</span>
</MenuActionItem>
<MenuActionItem onClick={() => handleDownloadChapter(chapter)}>
<DownloadIcon className="h-4 w-4" />
<span>Download</span>
</MenuActionItem>
</>
)}
{regeneratingChapter === chapter.index && (
<MenuItem>
{({ active }) => (
<button
onClick={handleCancel}
className={MenuItemClass(active, 'danger')}
title="Cancel this chapter regeneration"
>
<XCircleIcon className="h-4 w-4" />
<span>Cancel</span>
</button>
)}
</MenuItem>
<MenuActionItem
tone="danger"
onClick={handleCancel}
title="Cancel this chapter regeneration"
>
<XCircleIcon className="h-4 w-4" />
<span>Cancel</span>
</MenuActionItem>
)}
{onRegenerateChapter && !isGenerating && (
<MenuItem disabled={regeneratingChapter !== null}>
{({ active, disabled }) => (
<button
onClick={() => handleRegenerateChapter(chapter)}
disabled={disabled}
className={cn(MenuItemClass(active), 'disabled:cursor-not-allowed disabled:opacity-50')}
title="Regenerate this chapter"
>
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index ? 'animate-spin' : ''}`} />
<span>{regeneratingChapter === chapter.index ? 'Regenerating...' : 'Regenerate'}</span>
</button>
)}
</MenuItem>
<MenuActionItem
disabled={regeneratingChapter !== null}
onClick={() => handleRegenerateChapter(chapter)}
title="Regenerate this chapter"
>
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index ? 'animate-spin' : ''}`} />
<span>{regeneratingChapter === chapter.index ? 'Regenerating...' : 'Regenerate'}</span>
</MenuActionItem>
)}
</MenuItems>
</MenuItemsSurface>
{/* end of menu items */}
</Transition>
</Menu>

View file

@ -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">
<code>{children}</code>
</pre>
<button
<Button
onClick={handleCopy}
className={buttonClass({
variant: 'secondary',
size: 'icon',
className: 'absolute top-2 right-2 h-7 w-7 opacity-0 group-hover:opacity-100 focus:opacity-100',
})}
variant="secondary"
size="icon"
className="absolute top-2 right-2 h-7 w-7 opacity-0 group-hover:opacity-100 focus:opacity-100"
title="Copy to clipboard"
>
{copied ? <CheckIcon className="w-4 h-4" /> : <CopyIcon className="w-4 h-4" />}
</button>
</Button>
</div>
);
}

View file

@ -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)
/>
</PopoverButton>
<PopoverPanel
<PopoverSurface
anchor="bottom start"
transition
className={cn(
popoverPanelClass,
'z-[60] mt-2 w-56 bg-background space-y-3 transition duration-fast ease-standard data-[closed]:opacity-0 data-[closed]:scale-95',
)}
className="z-[60] mt-2 w-56 bg-background space-y-3 transition duration-fast ease-standard data-[closed]:opacity-0 data-[closed]:scale-95"
>
{/* Label */}
<div className="flex items-center justify-between">
@ -173,7 +170,7 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
className="flex-1 font-mono"
/>
</div>
</PopoverPanel>
</PopoverSurface>
</Popover>
);
}

View file

@ -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() {
</div>
<div className="flex flex-col gap-3 min-w-fit sm:flex-row">
<button
<Button
onClick={handleDecline}
className={buttonClass({
variant: 'ghost',
size: 'md',
className: 'whitespace-nowrap',
})}
variant="ghost"
size="md"
className="whitespace-nowrap"
>
Decline Non-Essential
</button>
<button
</Button>
<Button
onClick={handleAccept}
className={buttonClass({
variant: 'primary',
size: 'md',
className: 'whitespace-nowrap font-bold shadow-elev-1',
})}
variant="primary"
size="md"
className="whitespace-nowrap font-bold shadow-elev-1"
>
Accept All
</button>
</Button>
</div>
</div>
</Transition>

View file

@ -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({
</div>
)}
</div>
<button
<Button
type="button"
className={buttonClass({
variant: 'ghost',
size: 'xs',
className: 'shrink-0 hover:bg-accent-wash',
})}
variant="ghost"
size="xs"
className="shrink-0"
onClick={(e) => onCancel(e)}
>
<span>{cancelText}</span>
</button>
</Button>
</div>
{/* Progress bar */}

View file

@ -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('');
}}
>
<ListboxButton className={listboxButtonClass}>
<SharedListboxButton>
<span className="block truncate">
{selectedProviderOption?.name || 'Select Provider'}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-soft" />
</span>
</ListboxButton>
</SharedListboxButton>
<Transition
as={Fragment}
leave="transition ease-standard duration-fast"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions
<SharedListboxOptions
anchor="bottom start"
className={listboxOptionsClass}
>
{ttsProviders.map((provider) => (
<ListboxOption
<SharedListboxOption
key={provider.id}
className={({ active }) => listboxOptionClass(active)}
value={provider}
>
{({ selected }) => (
@ -728,9 +723,9 @@ export function SettingsModal({
)}
</>
)}
</ListboxOption>
</SharedListboxOption>
))}
</ListboxOptions>
</SharedListboxOptions>
</Transition>
</Listbox>
)}
@ -797,7 +792,7 @@ export function SettingsModal({
}
}}
>
<ListboxButton className={listboxButtonClass}>
<SharedListboxButton>
{selectedModel ? (
<span className="block">
<span className="block truncate">
@ -815,21 +810,19 @@ export function SettingsModal({
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-soft" />
</span>
</ListboxButton>
</SharedListboxButton>
<Transition
as={Fragment}
leave="transition ease-standard duration-fast"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions
<SharedListboxOptions
anchor="bottom start"
className={listboxOptionsClass}
>
{ttsModels.map((model) => (
<ListboxOption
<SharedListboxOption
key={model.id}
className={({ active }) => listboxOptionClass(active)}
value={model}
>
{({ selected }) => (
@ -849,9 +842,9 @@ export function SettingsModal({
)}
</>
)}
</ListboxOption>
</SharedListboxOption>
))}
</ListboxOptions>
</SharedListboxOptions>
</Transition>
</Listbox>

View file

@ -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() {
</div>
{providerOptions.length > 0 ? (
<Listbox value={selectedProviderOption} onChange={handleProviderChange}>
<ListboxButton className={listboxButtonClass}>
<SharedListboxButton>
<span className="block truncate">{selectedProviderOption?.name ?? 'Select provider'}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span>
</ListboxButton>
</SharedListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions anchor="bottom start" className={listboxOptionsClass}>
<SharedListboxOptions anchor="bottom start">
{providerOptions.map((opt) => (
<ListboxOption
<SharedListboxOption
key={opt.id}
value={opt}
className={({ active }) => listboxOptionClass(active)}
>
{({ selected }) => (
<>
@ -236,9 +235,9 @@ export function AdminFeaturesPanel() {
)}
</>
)}
</ListboxOption>
</SharedListboxOption>
))}
</ListboxOptions>
</SharedListboxOptions>
</Transition>
</Listbox>
) : (

View file

@ -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('');
}}
>
<ListboxButton className={listboxButtonClass}>
<SharedListboxButton>
<span className="block truncate">{selectedProviderType.label}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span>
</ListboxButton>
</SharedListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions anchor="bottom start" className={listboxOptionsClass}>
<SharedListboxOptions anchor="bottom start">
{PROVIDER_TYPE_OPTIONS.map((opt) => (
<ListboxOption
<SharedListboxOption
key={opt.value}
value={opt}
className={({ active }) => listboxOptionClass(active)}
>
{({ selected }) => (
<>
@ -457,9 +455,9 @@ export function AdminProvidersPanel() {
)}
</>
)}
</ListboxOption>
</SharedListboxOption>
))}
</ListboxOptions>
</SharedListboxOptions>
</Transition>
</Listbox>
</Field>
@ -485,26 +483,25 @@ export function AdminProvidersPanel() {
setCustomModelInput('');
}}
>
<ListboxButton className={listboxButtonClass}>
<SharedListboxButton>
<span className="block truncate">
{selectedModelDefinition?.name ?? 'Select model'}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span>
</ListboxButton>
</SharedListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions anchor="bottom start" className={listboxOptionsClass}>
<SharedListboxOptions anchor="bottom start">
{modelDefinitions.map((model) => (
<ListboxOption
<SharedListboxOption
key={model.id}
value={model.id}
className={({ active }) => listboxOptionClass(active)}
>
{({ selected }) => (
<>
@ -523,9 +520,9 @@ export function AdminProvidersPanel() {
)}
</>
)}
</ListboxOption>
</SharedListboxOption>
))}
</ListboxOptions>
</SharedListboxOptions>
</Transition>
</Listbox>
{supportsCustomModel && selectedModelId === 'custom' && (
@ -663,56 +660,26 @@ export function AdminProvidersPanel() {
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
<MenuItemsSurface
anchor="bottom end"
className={cn(menuPanelClass, 'z-50 mt-2 min-w-[170px] bg-base focus:outline-none')}
className="z-50 mt-2 min-w-[170px] bg-base focus:outline-none"
>
<MenuItem>
{({ active }) => (
<button
type="button"
onClick={() => startEdit(p)}
className={MenuItemClass(active)}
>
Edit
</button>
)}
</MenuItem>
<MenuItem disabled={!p.enabled || defaultProviderSlug === p.slug}>
{({ active, disabled }) => (
<button
type="button"
onClick={() => setDefault(p.slug)}
disabled={disabled}
className={cn(MenuItemClass(active), disabled ? 'cursor-not-allowed text-faint' : '')}
>
Set as default
</button>
)}
</MenuItem>
<MenuItem>
{({ active }) => (
<button
type="button"
onClick={() => toggleEnabled(p)}
className={MenuItemClass(active)}
>
{p.enabled ? 'Disable' : 'Enable'}
</button>
)}
</MenuItem>
<MenuItem>
{({ active }) => (
<button
type="button"
onClick={() => remove(p.id)}
className={MenuItemClass(active, 'danger')}
>
Delete
</button>
)}
</MenuItem>
</MenuItems>
<MenuActionItem onClick={() => startEdit(p)}>
Edit
</MenuActionItem>
<MenuActionItem
onClick={() => setDefault(p.slug)}
disabled={!p.enabled || defaultProviderSlug === p.slug}
>
Set as default
</MenuActionItem>
<MenuActionItem onClick={() => toggleEnabled(p)}>
{p.enabled ? 'Disable' : 'Enable'}
</MenuActionItem>
<MenuActionItem tone="danger" onClick={() => remove(p.id)}>
Delete
</MenuActionItem>
</MenuItemsSurface>
</Transition>
</Menu>
</div>

View file

@ -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 (
<div className={`flex gap-2 ${className}`}>
<Link href="/signin" className={buttonClass({ variant: 'secondary', size: 'sm' })}>
<ButtonLink href="/signin" variant="secondary" size="sm">
Connect
</Link>
</ButtonLink>
{enableUserSignups && (
<Link href="/signup" className={buttonClass({ variant: 'primary', size: 'sm' })}>
<ButtonLink href="/signup" variant="primary" size="sm">
Create account
</Link>
</ButtonLink>
)}
</div>
);

View file

@ -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 (
<main className="min-h-[calc(100vh-3rem)] bg-background px-4 py-6 text-foreground sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-6xl flex-col gap-5">
<header className="flex flex-col gap-3 rounded-lg border border-line bg-surface p-4 shadow-elev-1 sm:flex-row sm:items-end sm:justify-between">
<div>
<Badge tone="accent">Authenticated dev surface</Badge>
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-foreground">UI system harness</h1>
<p className="mt-1 max-w-2xl text-sm text-soft">
Review shared primitives, state tokens, and theme behavior from the authenticated app shell.
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="primary">Primary action</Button>
<Button variant="secondary">Secondary</Button>
</div>
</header>
<Section
title="Theme Matrix"
subtitle="Switches the real document theme so token regressions are visible in context."
>
<div className="grid grid-cols-[repeat(auto-fit,minmax(7rem,1fr))] gap-2">
{THEME_OPTIONS.map((option) => (
<button
key={option}
type="button"
onClick={() => setTheme(option)}
className={buttonClass({
variant: theme === option ? 'primary' : 'outline',
size: 'sm',
className: 'capitalize',
})}
>
{option}
</button>
))}
</div>
</Section>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_18rem]">
<Panel className="space-y-4 p-4" elevation="2">
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="danger">Danger</Button>
<Button variant="secondary" disabled>Disabled</Button>
<IconButton aria-label="Refresh sample"></IconButton>
<IconButton aria-label="Danger sample" tone="danger">×</IconButton>
</div>
<Divider />
<div className="grid gap-4 md:grid-cols-2">
<Field label="Reader name" hint="Field, input, and hint tokens.">
<Input placeholder="OpenReader" />
</Field>
<Field label="Mode">
<Select
value={selectValue}
onChange={setSelectValue}
options={[
{ value: 'library', label: 'Library' },
{ value: 'reader', label: 'Reader' },
{ value: 'player', label: 'Player' },
]}
/>
</Field>
<Field label="Notes" className="md:col-span-2">
<Textarea rows={3} placeholder="Textarea state preview" />
</Field>
</div>
<div className="grid gap-3 md:grid-cols-2">
<ToggleRow
label="Calm motion"
description="Feedback-only transitions, no ambient movement."
checked={enabled}
onChange={setEnabled}
/>
<Card>
<CheckItem label="Parsed text available" checked={checked} onChange={setChecked} />
<div className="mt-3 flex items-center justify-between">
<span className="text-sm text-soft">Direct switch sample</span>
<Switch checked={enabled} onChange={setEnabled} ariaLabel="Toggle direct switch sample" />
</div>
</Card>
</div>
<Surface tone="sunken" className="p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-foreground">Range control</span>
<Badge tone="foreground">1.2x</Badge>
</div>
<input className={rangeInputClass} type="range" min="0.5" max="3" step="0.1" defaultValue="1.2" />
</Surface>
</Panel>
<Sidebar className="min-h-full p-3">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-semibold text-foreground">Sidebar</span>
<Badge>Nav</Badge>
</div>
<div className="space-y-1">
{['Library', 'Reader', 'Audio', 'Settings'].map((item, index) => (
<button
key={item}
type="button"
className={`w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors duration-fast ease-standard ${
index === 0 ? 'bg-accent-wash text-accent' : 'text-soft hover:bg-accent-wash hover:text-accent'
}`}
>
{item}
</button>
))}
</div>
</Sidebar>
</div>
<Panel elevation="1">
<Toolbar className="static">
<ToolbarGroup>
<ToolbarSegment active>List</ToolbarSegment>
<ToolbarSegment>Grid</ToolbarSegment>
<ToolbarSegment>Gallery</ToolbarSegment>
</ToolbarGroup>
<ToolbarButton active>Sort by name</ToolbarButton>
<ToolbarButton>Recently opened</ToolbarButton>
</Toolbar>
<div className="grid gap-4 p-4 md:grid-cols-3">
<div className={dropzoneSurfaceClass({ active: false })}>
<p className="text-sm font-medium text-foreground">Dropzone idle</p>
<p className="mt-1 text-xs text-soft">Border and wash states live in `dropzoneStyles`.</p>
</div>
<div className={dropzoneSurfaceClass({ active: true })}>
<p className="text-sm font-medium">Dropzone active</p>
<p className="mt-1 text-xs">Drag feedback uses tokenized state colors.</p>
</div>
<div className={popoverPanelClass}>
<p className="text-sm font-medium text-foreground">Popover panel</p>
<p className="mt-1 text-xs text-soft">Shared shell for player and reader controls.</p>
</div>
</div>
</Panel>
</div>
</main>
);
}

View file

@ -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({
</p>
</div>
<div className="flex gap-2">
<Link
href={openHref || '/app'}
prefetch={false}
className={buttonClass({ variant: 'primary', size: 'sm' })}
>
<ButtonLink href={openHref || '/app'} prefetch={false} variant="primary" size="sm">
Open
</Link>
<button
</ButtonLink>
<Button
type="button"
onClick={() => onDeleteDoc(activeDoc)}
className={buttonClass({ variant: 'secondary', size: 'sm' })}
variant="secondary"
size="sm"
>
Delete
</button>
</Button>
</div>
</div>
<dl className="w-full max-w-[280px] sm:max-w-[360px] md:max-w-[340px] grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-md border border-line bg-surface px-3 py-2 text-[11px] md:self-center">

View file

@ -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"
>
<MenuItems
<MenuItemsSurface
anchor="bottom start"
className={`${menuPanelClass} z-50 mt-2 min-w-[180px] focus:outline-none normal-case tracking-normal font-normal`}
className="z-50 mt-2 min-w-[180px] focus:outline-none normal-case tracking-normal font-normal"
>
<MenuItem>
{({ active }) => (
<button
type="button"
onClick={() => {
onNewFolder();
onRowAction?.();
}}
className={MenuItemClass(active)}
>
<FolderPlusIcon className="h-4 w-4" />
New Folder
</button>
)}
</MenuItem>
<MenuItem disabled={folders.length === 0}>
{({ active, disabled }) => (
<button
type="button"
onClick={() => {
onClearFolders();
onRowAction?.();
}}
disabled={disabled}
className={cn(MenuItemClass(active), disabled && 'cursor-not-allowed text-faint')}
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Remove All Folders
</button>
)}
</MenuItem>
</MenuItems>
<MenuActionItem
onClick={() => {
onNewFolder();
onRowAction?.();
}}
>
<FolderPlusIcon className="h-4 w-4" />
New Folder
</MenuActionItem>
<MenuActionItem
disabled={folders.length === 0}
onClick={() => {
onClearFolders();
onRowAction?.();
}}
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Remove All Folders
</MenuActionItem>
</MenuItemsSurface>
</Transition>
</Menu>
)}

View file

@ -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"
>
<MenuItems className={`${menuPanelClass} absolute right-0 z-50 mt-2 min-w-max origin-top-right divide-y divide-line-soft focus:outline-none`}>
<MenuItemsSurface className="absolute right-0 z-50 mt-2 min-w-max origin-top-right divide-y divide-line-soft focus:outline-none">
{/* Zoom Controls Section */}
<div className="px-4 py-3">
<p className="text-xs font-medium text-soft mb-2">Zoom / Padding</p>
@ -125,49 +125,28 @@ export function DocumentHeaderMenu({
{/* Actions Section */}
<div className="p-1">
{onOpenSegments && (
<MenuItem>
{({ active }) => (
<button
onClick={onOpenSegments}
className={MenuItemClass(active || isSegmentsOpen)}
>
<ListIcon className="h-4 w-4" />
{isSegmentsOpen ? 'Hide Segments' : 'Segments'}
</button>
)}
</MenuItem>
<MenuActionItem onClick={onOpenSegments} activeOverride={isSegmentsOpen}>
<ListIcon className="h-4 w-4" />
{isSegmentsOpen ? 'Hide Segments' : 'Segments'}
</MenuActionItem>
)}
{showAudiobookExport && onOpenAudiobook && (
<MenuItem>
{({ active }) => (
<button
onClick={onOpenAudiobook}
className={MenuItemClass(active || isAudiobookOpen)}
>
<DownloadIcon className="h-4 w-4" />
{isAudiobookOpen ? 'Hide Audiobook' : 'Export Audiobook'}
</button>
)}
</MenuItem>
<MenuActionItem onClick={onOpenAudiobook} activeOverride={isAudiobookOpen}>
<DownloadIcon className="h-4 w-4" />
{isAudiobookOpen ? 'Hide Audiobook' : 'Export Audiobook'}
</MenuActionItem>
)}
<MenuItem>
{({ active }) => (
<button
onClick={onOpenSettings}
className={MenuItemClass(active || isSettingsOpen)}
>
<FileSettingsIcon className="h-4 w-4" />
{isSettingsOpen ? 'Hide Settings' : 'Settings'}
</button>
)}
</MenuItem>
<MenuActionItem onClick={onOpenSettings} activeOverride={isSettingsOpen}>
<FileSettingsIcon className="h-4 w-4" />
{isSettingsOpen ? 'Hide Settings' : 'Settings'}
</MenuActionItem>
</div>
{/* Auth Section */}
<div className="p-2 border-t border-line-soft flex justify-center">
<UserMenu />
</div>
</MenuItems>
</MenuItemsSurface>
</Transition>
</Menu>
</div>

View file

@ -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({
<div className="space-y-1.5">
<label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">{label}</label>
<div className="flex items-center gap-3">
<input
type="range"
<RangeInput
min={min}
max={max}
step={step}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
className={`flex-1 ${rangeInputClass}`}
className="flex-1"
/>
<span className={`${valueWidth} text-xs font-semibold text-right text-foreground`}>{formatter(value)}</span>
</div>
@ -202,15 +201,15 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
</span>
<span className="flex items-center gap-1 text-xs text-muted">
<span>{pdf.parseStatus ?? 'pending'}</span>
<button
type="button"
className={buttonClass({ variant: 'ghost', size: 'icon', className: '!h-5 !w-5 shrink-0' })}
<IconButton
size="xs"
className="!h-5 !w-5 shrink-0"
onClick={pdf.onForceReparse}
disabled={isForceReparseDisabled(pdf.parseStatus)}
title="Force reparse"
>
<RefreshIcon className={`h-3 w-3 ${isForceReparseDisabled(pdf.parseStatus) ? 'animate-spin' : ''}`} />
</button>
</IconButton>
</span>
</div>
}

View file

@ -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 (
<div className="flex items-center gap-1 select-none" aria-label="Zoom controls">
<button
type="button"
<IconButton
onClick={onDecrease}
disabled={value <= min}
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 out"
>
</button>
</IconButton>
<span className="text-xs tabular-nums w-12 text-center text-soft">{value}%</span>
<button
type="button"
<IconButton
onClick={onIncrease}
disabled={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"
>
</button>
</IconButton>
</div>
);
}

View file

@ -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 */}
<Popover className="relative mb-1">
<PopoverButton
className={cn(popoverTriggerClass, 'rounded-full bg-surface-sunken px-2 py-0.5 text-xs')}
onClick={handlePopoverOpen}
>
<PopoverTrigger className="rounded-full bg-surface-sunken px-2 py-0.5 text-xs" onClick={handlePopoverOpen}>
<p className="text-xs whitespace-nowrap">
{currentPage} / {numPages || 1}
</p>
</PopoverButton>
<PopoverPanel anchor="top" className={popoverPanelClass}>
</PopoverTrigger>
<PopoverSurface anchor="top">
<div className="flex flex-col space-y-2">
<div className="text-xs font-medium text-foreground">Go to page</div>
<Input
@ -94,7 +91,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
/>
<div className="text-xs text-soft text-center">of {numPages || 1}</div>
</div>
</PopoverPanel>
</PopoverSurface>
</Popover>
{/* Page forward */}

View file

@ -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 (
<Popover className="relative">
<PopoverButton className={`${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`}>
<PopoverTrigger className="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">
<SpeedometerIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
<span className="sm:hidden">{compactTriggerLabel}</span>
<span className="hidden sm:inline">{triggerLabel}</span>
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
</PopoverButton>
<PopoverPanel anchor="top" className={popoverPanelClass}>
</PopoverTrigger>
<PopoverSurface anchor="top">
<div className="flex flex-col space-y-4">
{!nativeSpeedSupported && (
<div className="rounded-md border border-line bg-background px-2 py-1.5 text-[11px] text-soft">
@ -113,8 +113,7 @@ export const SpeedControl = ({
</span>
<span className="text-xs">{max.toFixed(1)}x</span>
</div>
<input
type="range"
<RangeInput
min={min}
max={max}
step={step}
@ -123,7 +122,6 @@ export const SpeedControl = ({
onMouseUp={handleVoiceSpeedChangeComplete}
onKeyUp={handleVoiceSpeedChangeComplete}
onTouchEnd={handleVoiceSpeedChangeComplete}
className={rangeInputClass}
/>
</div>
)}
@ -137,8 +135,7 @@ export const SpeedControl = ({
</span>
<span className="text-xs">{max.toFixed(1)}x</span>
</div>
<input
type="range"
<RangeInput
min={min}
max={max}
step={step}
@ -147,11 +144,10 @@ export const SpeedControl = ({
onMouseUp={handleAudioSpeedChangeComplete}
onKeyUp={handleAudioSpeedChangeComplete}
onTouchEnd={handleAudioSpeedChangeComplete}
className={rangeInputClass}
/>
</div>
</div>
</PopoverPanel>
</PopoverSurface>
</Popover>
);
};

View file

@ -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'

View file

@ -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"
>
<PopoverPanel
<PopoverSurface
anchor="bottom end"
className={`${popoverPanelClass} z-[60] w-[300px] mt-1`}
className="z-[60] w-[300px] mt-1"
>
<dl className="space-y-2">
<Row label="locator">
@ -958,7 +958,7 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
</div>
))}
</dl>
</PopoverPanel>
</PopoverSurface>
</Transition>
</Popover>
);

View file

@ -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({
</button>
);
}
export function ButtonLink({
variant = 'secondary',
size = 'md',
className,
children,
...props
}: LinkProps & AnchorHTMLAttributes<HTMLAnchorElement> & {
variant?: ButtonVariant;
size?: ButtonSize;
children: ReactNode;
}) {
return (
<Link className={buttonClass({ variant, size, className })} {...props}>
{children}
</Link>
);
}
export function ButtonAnchor({
variant = 'secondary',
size = 'md',
className,
children,
...props
}: AnchorHTMLAttributes<HTMLAnchorElement> & {
variant?: ButtonVariant;
size?: ButtonSize;
children: ReactNode;
}) {
return (
<a className={buttonClass({ variant, size, className })} {...props}>
{children}
</a>
);
}

View file

@ -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<typeof MenuItems>) {
return (
<MenuItems className={cn(menuPanelClass, className)} {...props}>
{children}
</MenuItems>
);
}
export function MenuActionItem({
tone = 'default',
activeOverride = false,
disabled = false,
className,
children,
...props
}: Omit<ComponentProps<'button'>, 'className' | 'children'> & {
tone?: 'default' | 'danger';
activeOverride?: boolean;
className?: string;
children: ReactNode;
}) {
return (
<MenuItem disabled={disabled}>
{({ active, disabled: itemDisabled }) => (
<button
type="button"
disabled={itemDisabled}
className={cn(menuItemClass(active || activeOverride, tone), itemDisabled && 'cursor-not-allowed text-faint', className)}
{...props}
>
{children}
</button>
)}
</MenuItem>
);
}

View file

@ -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',
);

View file

@ -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<typeof PopoverButton>) {
return (
<PopoverButton className={cn(popoverTriggerClass, className)} {...props}>
{children}
</PopoverButton>
);
}
export function PopoverSurface({
className,
children,
...props
}: ComponentProps<typeof PopoverPanel>) {
return (
<PopoverPanel className={cn(popoverPanelClass, className)} {...props}>
{children}
</PopoverPanel>
);
}

View file

@ -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<HTMLInputElement>) {
return <input type="range" className={rangeInputClassName(className)} {...props} />;
}

View file

@ -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<typeof ListboxButton> & {
tone?: 'default' | 'toolbar' | 'unstyled';
tone?: 'default' | 'toolbar' | 'popover' | 'unstyled';
}) {
const baseClass = tone === 'toolbar'
? listboxToolbarButtonClass
: tone === 'popover'
? listboxPopoverButtonClass
: tone === 'unstyled'
? ''
: listboxButtonClass;