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'; 'use client';
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import Link from 'next/link';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton'; import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
import { EPUBViewer } from '@/components/views/EPUBViewer'; import { EPUBViewer } from '@/components/views/EPUBViewer';
@ -20,7 +19,7 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef'; import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef';
import { buttonClass } from '@/components/ui'; import { ButtonLink } from '@/components/ui';
import { useEpubDocument } from './useEpubDocument'; import { useEpubDocument } from './useEpubDocument';
export default function EPUBPage() { export default function EPUBPage() {
@ -172,15 +171,12 @@ export default function EPUBPage() {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen"> <div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-danger mb-4">{error}</p> <p className="text-danger mb-4">{error}</p>
<Link <ButtonLink href="/app" variant="secondary" size="md" className="gap-2">
href="/app"
className={buttonClass({ variant: 'secondary', size: 'md', className: 'gap-2' })}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
Back to Documents Back to Documents
</Link> </ButtonLink>
</div> </div>
); );
} }
@ -189,16 +185,12 @@ export default function EPUBPage() {
<> <>
<Header <Header
left={ left={
<Link <ButtonLink href="/app" variant="secondary" size="sm" className="gap-2" aria-label="Back to documents">
href="/app"
className={buttonClass({ 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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
Documents Documents
</Link> </ButtonLink>
} }
title={isLoading ? 'Loading…' : (currDocName || '')} title={isLoading ? 'Loading…' : (currDocName || '')}
right={ right={

View file

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import Link from 'next/link';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton'; import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
import { HTMLViewer } from '@/components/views/HTMLViewer'; import { HTMLViewer } from '@/components/views/HTMLViewer';
@ -18,7 +17,7 @@ import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef'; import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef';
import { buttonClass } from '@/components/ui'; import { ButtonLink } from '@/components/ui';
import type { TTSAudiobookChapter } from '@/types/tts'; import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client'; import type { AudiobookGenerationSettings } from '@/types/client';
import { useHtmlDocument } from './useHtmlDocument'; import { useHtmlDocument } from './useHtmlDocument';
@ -159,15 +158,12 @@ export default function HTMLPage() {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen"> <div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-danger mb-4">{error}</p> <p className="text-danger mb-4">{error}</p>
<Link <ButtonLink href="/app" variant="secondary" size="md" className="gap-2">
href="/app"
className={buttonClass({ variant: 'secondary', size: 'md', className: 'gap-2' })}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
Back to Documents Back to Documents
</Link> </ButtonLink>
</div> </div>
); );
} }
@ -176,16 +172,12 @@ export default function HTMLPage() {
<> <>
<Header <Header
left={ left={
<Link <ButtonLink href="/app" variant="secondary" size="sm" className="gap-2" aria-label="Back to documents">
href="/app"
className={buttonClass({ 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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
Documents Documents
</Link> </ButtonLink>
} }
title={isLoading ? 'Loading…' : (currDocName || '')} title={isLoading ? 'Loading…' : (currDocName || '')}
right={ right={

View file

@ -2,7 +2,6 @@
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { useCallback, useEffect, useRef, useState, type MouseEvent } from 'react'; import { useCallback, useEffect, useRef, useState, type MouseEvent } from 'react';
import { useTTS } from '@/contexts/TTSContext'; import { useTTS } from '@/contexts/TTSContext';
import { DocumentSettings } from '@/components/documents/DocumentSettings'; import { DocumentSettings } from '@/components/documents/DocumentSettings';
@ -20,7 +19,7 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { LoadingSpinner } from '@/components/Spinner'; import { LoadingSpinner } from '@/components/Spinner';
import { buttonClass } from '@/components/ui'; import { Button, ButtonLink } from '@/components/ui';
import { import {
FORCE_REPARSE_CONFIRM_MESSAGE, FORCE_REPARSE_CONFIRM_MESSAGE,
FORCE_REPARSE_CONFIRM_TEXT, FORCE_REPARSE_CONFIRM_TEXT,
@ -253,16 +252,12 @@ export default function PDFViewerPage() {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen"> <div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-danger mb-4">{error}</p> <p className="text-danger mb-4">{error}</p>
<Link <ButtonLink href="/app" onClick={handleBackToDocuments} variant="secondary" size="md" className="gap-2">
href="/app"
onClick={handleBackToDocuments}
className={buttonClass({ variant: 'secondary', size: 'md', className: 'gap-2' })}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
Back to Documents Back to Documents
</Link> </ButtonLink>
</div> </div>
); );
} }
@ -343,13 +338,14 @@ export default function PDFViewerPage() {
{!isLoading && parseUiState === 'failed' ? ( {!isLoading && parseUiState === 'failed' ? (
<div className="mt-3 flex justify-start"> <div className="mt-3 flex justify-start">
<button <Button
type="button" type="button"
onClick={requestForceReparse} onClick={requestForceReparse}
className={buttonClass({ variant: 'secondary', size: 'sm' })} variant="secondary"
size="sm"
> >
Retry Parse Retry Parse
</button> </Button>
</div> </div>
) : null} ) : null}
</div> </div>
@ -382,17 +378,12 @@ export default function PDFViewerPage() {
<> <>
<Header <Header
left={ left={
<Link <ButtonLink href="/app" onClick={handleBackToDocuments} variant="secondary" size="sm" className="gap-2" aria-label="Back to documents">
href="/app"
onClick={handleBackToDocuments}
className={buttonClass({ 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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
Documents Documents
</Link> </ButtonLink>
} }
title={isLoading ? 'Loading…' : (currDocName || '')} title={isLoading ? 'Loading…' : (currDocName || '')}
right={ right={

View file

@ -1,7 +1,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc'; import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc';
import { buttonClass } from '@/components/ui'; import { ButtonAnchor, ButtonLink } from '@/components/ui';
import './public.css'; import './public.css';
export default async function PublicLayout({ children }: { children: ReactNode }) { export default async function PublicLayout({ children }: { children: ReactNode }) {
@ -42,12 +42,8 @@ export default async function PublicLayout({ children }: { children: ReactNode }
GitHub GitHub
</a> </a>
<span className="public-nav-divider" aria-hidden="true" /> <span className="public-nav-divider" aria-hidden="true" />
<Link href="/signin" className={buttonClass({ variant: 'ghost', size: 'sm' })}> <ButtonLink href="/signin" variant="ghost" size="sm">Sign in</ButtonLink>
Sign in <ButtonLink href="/app" variant="primary" size="sm">Open app</ButtonLink>
</Link>
<Link href="/app" className={buttonClass({ variant: 'primary', size: 'sm' })}>
Open app
</Link>
</nav> </nav>
</header> </header>
</div> </div>
@ -70,18 +66,11 @@ export default async function PublicLayout({ children }: { children: ReactNode }
</p> </p>
<div className="public-footer-cta"> <div className="public-footer-cta">
{enableUserSignups ? ( {enableUserSignups ? (
<Link href="/signup" className={buttonClass({ variant: 'outline', size: 'sm' })}> <ButtonLink href="/signup" variant="outline" size="sm">Create account</ButtonLink>
Create account
</Link>
) : null} ) : null}
<a <ButtonAnchor href="https://github.com/richardr1126/openreader" target="_blank" rel="noopener noreferrer" variant="ghost" size="sm">
href="https://github.com/richardr1126/openreader"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'ghost', size: 'sm' })}
>
Star on GitHub Star on GitHub
</a> </ButtonAnchor>
</div> </div>
</div> </div>

View file

@ -2,7 +2,7 @@ import type { CSSProperties } from 'react';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc'; import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc';
import { buttonClass } from '@/components/ui'; import { ButtonAnchor, ButtonLink } from '@/components/ui';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Open Source Read-Along Workspace', title: 'Open Source Read-Along Workspace',
@ -98,24 +98,13 @@ export default async function LandingPage() {
</p> </p>
<div className="public-actions"> <div className="public-actions">
<Link href="/app" className={buttonClass({ variant: 'primary', size: 'lg' })}> <ButtonLink href="/app" variant="primary" size="lg">Open the reader</ButtonLink>
Open the reader
</Link>
{enableUserSignups ? ( {enableUserSignups ? (
<Link href="/signup" className={buttonClass({ variant: 'outline', size: 'lg' })}> <ButtonLink href="/signup" variant="outline" size="lg">Create account</ButtonLink>
Create account
</Link>
) : ( ) : (
<Link href="/signin" className={buttonClass({ variant: 'outline', size: 'lg' })}> <ButtonLink href="/signin" variant="outline" size="lg">Sign in</ButtonLink>
Sign in
</Link>
)} )}
<Link <ButtonLink href="https://docs.openreader.richardr.dev/" variant="ghost" size="lg">Read the docs </ButtonLink>
href="https://docs.openreader.richardr.dev/"
className={buttonClass({ variant: 'ghost', size: 'lg' })}
>
Read the docs
</Link>
</div> </div>
<div className="public-formats" aria-label="Supported formats"> <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. external compute worker. Every piece is yours to host.
</p> </p>
<div className="public-actions"> <div className="public-actions">
<a <ButtonAnchor href="https://github.com/richardr1126/openreader#readme" target="_blank" rel="noopener noreferrer" variant="primary" size="lg">
href="https://github.com/richardr1126/openreader#readme"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'primary', size: 'lg' })}
>
View the repository View the repository
</a> </ButtonAnchor>
<a <ButtonAnchor href="https://docs.openreader.richardr.dev/docker-quick-start" target="_blank" rel="noopener noreferrer" variant="outline" size="lg">
href="https://docs.openreader.richardr.dev/docker-quick-start"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'outline', size: 'lg' })}
>
Deployment guides Deployment guides
</a> </ButtonAnchor>
</div> </div>
</div> </div>

View file

@ -1,7 +1,7 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { buttonClass } from '@/components/ui'; import { ButtonAnchor, ButtonLink } from '@/components/ui';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Privacy & Data Usage', title: 'Privacy & Data Usage',
@ -181,25 +181,13 @@ export default async function PrivacyPage() {
want full infrastructure control. want full infrastructure control.
</p> </p>
<div className="policy-actions"> <div className="policy-actions">
<a <ButtonAnchor href="https://github.com/richardr1126/openreader/issues" target="_blank" rel="noopener noreferrer" variant="primary" size="md">
href="https://github.com/richardr1126/openreader/issues"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'primary', size: 'md' })}
>
Open an Issue Open an Issue
</a> </ButtonAnchor>
<a <ButtonAnchor href="https://github.com/richardr1126/openreader#readme" target="_blank" rel="noopener noreferrer" variant="outline" size="md">
href="https://github.com/richardr1126/openreader#readme"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'outline', size: 'md' })}
>
Self-Hosting Guide Self-Hosting Guide
</a> </ButtonAnchor>
<Link href="/?redirect=false" className={buttonClass({ variant: 'ghost', size: 'md' })}> <ButtonLink href="/?redirect=false" variant="ghost" size="md">Back to landing</ButtonLink>
Back to landing
</Link>
</div> </div>
</section> </section>
</div> </div>

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react'; 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 { useTimeEstimation } from '@/hooks/useTimeEstimation';
import { ProgressPopup } from '@/components/ProgressPopup'; import { ProgressPopup } from '@/components/ProgressPopup';
import { ProgressCard } from '@/components/ProgressCard'; import { ProgressCard } from '@/components/ProgressCard';
@ -13,7 +13,7 @@ import { VoicesControlBase } from '@/components/player/VoicesControlBase';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; 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 { import {
getAudiobookStatus, getAudiobookStatus,
deleteAudiobookChapter, deleteAudiobookChapter,
@ -621,14 +621,12 @@ export function AudiobookExportModal({
<label className="text-[11px] uppercase tracking-wider font-medium text-soft">Native model speed</label> <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> <span className="text-xs font-medium text-accent tabular-nums">{formatSpeed(nativeSpeed)}x</span>
</div> </div>
<input <RangeInput
type="range"
min="0.5" min="0.5"
max="3" max="3"
step="0.1" step="0.1"
value={nativeSpeed} value={nativeSpeed}
onChange={(e) => setNativeSpeed(parseFloat(e.target.value))} onChange={(e) => setNativeSpeed(parseFloat(e.target.value))}
className={rangeInputClass}
/> />
<div className="flex justify-between text-[10px] text-soft"> <div className="flex justify-between text-[10px] text-soft">
<span>0.5x</span> <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> <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> <span className="text-xs font-medium text-accent tabular-nums">{formatSpeed(postSpeed)}x</span>
</div> </div>
<input <RangeInput
type="range"
min="0.5" min="0.5"
max="3" max="3"
step="0.1" step="0.1"
value={postSpeed} value={postSpeed}
onChange={(e) => setPostSpeed(parseFloat(e.target.value))} onChange={(e) => setPostSpeed(parseFloat(e.target.value))}
className={rangeInputClass}
/> />
<div className="flex justify-between text-[10px] text-soft"> <div className="flex justify-between text-[10px] text-soft">
<span>0.5x</span> <span>0.5x</span>
@ -794,68 +790,48 @@ export function AudiobookExportModal({
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<MenuItems <MenuItemsSurface
anchor={{ to: 'bottom end', gap: '8px', padding: '12px' }} anchor={{ to: 'bottom end', gap: '8px', padding: '12px' }}
portal 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' && ( {chapter.status === 'completed' && (
<> <>
<MenuItem> <MenuActionItem
{({ active }) => ( tone="danger"
<button onClick={() => setPendingDeleteChapter(chapter)}
onClick={() => setPendingDeleteChapter(chapter)} title="Delete this chapter"
className={MenuItemClass(active, 'danger')} >
title="Delete this chapter" <XCircleIcon className="h-4 w-4" />
> <span>Delete</span>
<XCircleIcon className="h-4 w-4" /> </MenuActionItem>
<span>Delete</span> <MenuActionItem onClick={() => handleDownloadChapter(chapter)}>
</button> <DownloadIcon className="h-4 w-4" />
)} <span>Download</span>
</MenuItem> </MenuActionItem>
<MenuItem>
{({ active }) => (
<button
onClick={() => handleDownloadChapter(chapter)}
className={MenuItemClass(active)}
>
<DownloadIcon className="h-4 w-4" />
<span>Download</span>
</button>
)}
</MenuItem>
</> </>
)} )}
{regeneratingChapter === chapter.index && ( {regeneratingChapter === chapter.index && (
<MenuItem> <MenuActionItem
{({ active }) => ( tone="danger"
<button onClick={handleCancel}
onClick={handleCancel} title="Cancel this chapter regeneration"
className={MenuItemClass(active, 'danger')} >
title="Cancel this chapter regeneration" <XCircleIcon className="h-4 w-4" />
> <span>Cancel</span>
<XCircleIcon className="h-4 w-4" /> </MenuActionItem>
<span>Cancel</span>
</button>
)}
</MenuItem>
)} )}
{onRegenerateChapter && !isGenerating && ( {onRegenerateChapter && !isGenerating && (
<MenuItem disabled={regeneratingChapter !== null}> <MenuActionItem
{({ active, disabled }) => ( disabled={regeneratingChapter !== null}
<button onClick={() => handleRegenerateChapter(chapter)}
onClick={() => handleRegenerateChapter(chapter)} title="Regenerate this chapter"
disabled={disabled} >
className={cn(MenuItemClass(active), 'disabled:cursor-not-allowed disabled:opacity-50')} <RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index ? 'animate-spin' : ''}`} />
title="Regenerate this chapter" <span>{regeneratingChapter === chapter.index ? 'Regenerating...' : 'Regenerate'}</span>
> </MenuActionItem>
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index ? 'animate-spin' : ''}`} />
<span>{regeneratingChapter === chapter.index ? 'Regenerating...' : 'Regenerate'}</span>
</button>
)}
</MenuItem>
)} )}
</MenuItems> </MenuItemsSurface>
{/* end of menu items */} {/* end of menu items */}
</Transition> </Transition>
</Menu> </Menu>

View file

@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { CopyIcon, CheckIcon } from '@/components/icons/Icons'; import { CopyIcon, CheckIcon } from '@/components/icons/Icons';
import { buttonClass } from '@/components/ui'; import { Button } from '@/components/ui';
export function CodeBlock({ children }: { children: string }) { export function CodeBlock({ children }: { children: string }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@ -19,17 +19,15 @@ export function CodeBlock({ children }: { children: string }) {
font-mono text-foreground border border-line"> font-mono text-foreground border border-line">
<code>{children}</code> <code>{children}</code>
</pre> </pre>
<button <Button
onClick={handleCopy} onClick={handleCopy}
className={buttonClass({ variant="secondary"
variant: 'secondary', size="icon"
size: 'icon', className="absolute top-2 right-2 h-7 w-7 opacity-0 group-hover:opacity-100 focus:opacity-100"
className: 'absolute top-2 right-2 h-7 w-7 opacity-0 group-hover:opacity-100 focus:opacity-100',
})}
title="Copy to clipboard" title="Copy to clipboard"
> >
{copied ? <CheckIcon className="w-4 h-4" /> : <CopyIcon className="w-4 h-4" />} {copied ? <CheckIcon className="w-4 h-4" /> : <CopyIcon className="w-4 h-4" />}
</button> </Button>
</div> </div>
); );
} }

View file

@ -1,10 +1,10 @@
'use client'; 'use client';
import { useRef, useState, useEffect, useCallback } from 'react'; 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 { isLightColor, type CustomThemeColors } from '@/contexts/ThemeContext';
import { PaletteIcon } from '@/components/icons/Icons'; 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 * Curated swatch palettes per color role, sourced from existing themes
@ -89,13 +89,10 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
/> />
</PopoverButton> </PopoverButton>
<PopoverPanel <PopoverSurface
anchor="bottom start" anchor="bottom start"
transition transition
className={cn( 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"
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',
)}
> >
{/* Label */} {/* Label */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -173,7 +170,7 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
className="flex-1 font-mono" className="flex-1 font-mono"
/> />
</div> </div>
</PopoverPanel> </PopoverSurface>
</Popover> </Popover>
); );
} }

View file

@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { getConsentState, setConsentState } from '@/lib/client/analytics'; import { getConsentState, setConsentState } from '@/lib/client/analytics';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { buttonClass } from '@/components/ui'; import { Button } from '@/components/ui';
export function CookieConsentBanner() { export function CookieConsentBanner() {
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
@ -55,26 +55,22 @@ export function CookieConsentBanner() {
</div> </div>
<div className="flex flex-col gap-3 min-w-fit sm:flex-row"> <div className="flex flex-col gap-3 min-w-fit sm:flex-row">
<button <Button
onClick={handleDecline} onClick={handleDecline}
className={buttonClass({ variant="ghost"
variant: 'ghost', size="md"
size: 'md', className="whitespace-nowrap"
className: 'whitespace-nowrap',
})}
> >
Decline Non-Essential Decline Non-Essential
</button> </Button>
<button <Button
onClick={handleAccept} onClick={handleAccept}
className={buttonClass({ variant="primary"
variant: 'primary', size="md"
size: 'md', className="whitespace-nowrap font-bold shadow-elev-1"
className: 'whitespace-nowrap font-bold shadow-elev-1',
})}
> >
Accept All Accept All
</button> </Button>
</div> </div>
</div> </div>
</Transition> </Transition>

View file

@ -1,4 +1,4 @@
import { buttonClass } from '@/components/ui'; import { Button } from '@/components/ui';
interface ProgressCardProps { interface ProgressCardProps {
progress: number; progress: number;
@ -52,17 +52,15 @@ export function ProgressCard({
</div> </div>
)} )}
</div> </div>
<button <Button
type="button" type="button"
className={buttonClass({ variant="ghost"
variant: 'ghost', size="xs"
size: 'xs', className="shrink-0"
className: 'shrink-0 hover:bg-accent-wash',
})}
onClick={(e) => onCancel(e)} onClick={(e) => onCancel(e)}
> >
<span>{cancelText}</span> <span>{cancelText}</span>
</button> </Button>
</div> </div>
{/* Progress bar */} {/* Progress bar */}

View file

@ -4,9 +4,6 @@ import { Fragment, useState, useEffect, useCallback, useMemo } from 'react';
import { import {
Transition, Transition,
Listbox, Listbox,
ListboxButton,
ListboxOptions,
ListboxOption,
} from '@headlessui/react'; } from '@headlessui/react';
import Link from 'next/link'; import Link from 'next/link';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
@ -56,9 +53,9 @@ import {
ModalFrame, ModalFrame,
ModalTitle, ModalTitle,
inputClass, inputClass,
listboxButtonClass, SharedListboxButton,
listboxOptionClass, SharedListboxOption,
listboxOptionsClass, SharedListboxOptions,
} from '@/components/ui'; } from '@/components/ui';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
@ -692,28 +689,26 @@ export function SettingsModal({
setCustomModelInput(''); setCustomModelInput('');
}} }}
> >
<ListboxButton className={listboxButtonClass}> <SharedListboxButton>
<span className="block truncate"> <span className="block truncate">
{selectedProviderOption?.name || 'Select Provider'} {selectedProviderOption?.name || 'Select Provider'}
</span> </span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-soft" /> <ChevronUpDownIcon className="h-5 w-5 text-soft" />
</span> </span>
</ListboxButton> </SharedListboxButton>
<Transition <Transition
as={Fragment} as={Fragment}
leave="transition ease-standard duration-fast" leave="transition ease-standard duration-fast"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<ListboxOptions <SharedListboxOptions
anchor="bottom start" anchor="bottom start"
className={listboxOptionsClass}
> >
{ttsProviders.map((provider) => ( {ttsProviders.map((provider) => (
<ListboxOption <SharedListboxOption
key={provider.id} key={provider.id}
className={({ active }) => listboxOptionClass(active)}
value={provider} value={provider}
> >
{({ selected }) => ( {({ selected }) => (
@ -728,9 +723,9 @@ export function SettingsModal({
)} )}
</> </>
)} )}
</ListboxOption> </SharedListboxOption>
))} ))}
</ListboxOptions> </SharedListboxOptions>
</Transition> </Transition>
</Listbox> </Listbox>
)} )}
@ -797,7 +792,7 @@ export function SettingsModal({
} }
}} }}
> >
<ListboxButton className={listboxButtonClass}> <SharedListboxButton>
{selectedModel ? ( {selectedModel ? (
<span className="block"> <span className="block">
<span className="block truncate"> <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"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-soft" /> <ChevronUpDownIcon className="h-5 w-5 text-soft" />
</span> </span>
</ListboxButton> </SharedListboxButton>
<Transition <Transition
as={Fragment} as={Fragment}
leave="transition ease-standard duration-fast" leave="transition ease-standard duration-fast"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<ListboxOptions <SharedListboxOptions
anchor="bottom start" anchor="bottom start"
className={listboxOptionsClass}
> >
{ttsModels.map((model) => ( {ttsModels.map((model) => (
<ListboxOption <SharedListboxOption
key={model.id} key={model.id}
className={({ active }) => listboxOptionClass(active)}
value={model} value={model}
> >
{({ selected }) => ( {({ selected }) => (
@ -849,9 +842,9 @@ export function SettingsModal({
)} )}
</> </>
)} )}
</ListboxOption> </SharedListboxOption>
))} ))}
</ListboxOptions> </SharedListboxOptions>
</Transition> </Transition>
</Listbox> </Listbox>

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { Fragment, useEffect, useMemo, useState } from 'react'; 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons'; import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
@ -10,9 +10,9 @@ import {
Section, Section,
ToggleRow, ToggleRow,
inputClass, inputClass,
listboxButtonClass, SharedListboxButton,
listboxOptionClass, SharedListboxOption,
listboxOptionsClass, SharedListboxOptions,
Button, Button,
} from '@/components/ui'; } from '@/components/ui';
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
@ -205,24 +205,23 @@ export function AdminFeaturesPanel() {
</div> </div>
{providerOptions.length > 0 ? ( {providerOptions.length > 0 ? (
<Listbox value={selectedProviderOption} onChange={handleProviderChange}> <Listbox value={selectedProviderOption} onChange={handleProviderChange}>
<ListboxButton className={listboxButtonClass}> <SharedListboxButton>
<span className="block truncate">{selectedProviderOption?.name ?? 'Select provider'}</span> <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"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" /> <ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span> </span>
</ListboxButton> </SharedListboxButton>
<Transition <Transition
as={Fragment} as={Fragment}
leave="transition ease-in duration-100" leave="transition ease-in duration-100"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<ListboxOptions anchor="bottom start" className={listboxOptionsClass}> <SharedListboxOptions anchor="bottom start">
{providerOptions.map((opt) => ( {providerOptions.map((opt) => (
<ListboxOption <SharedListboxOption
key={opt.id} key={opt.id}
value={opt} value={opt}
className={({ active }) => listboxOptionClass(active)}
> >
{({ selected }) => ( {({ selected }) => (
<> <>
@ -236,9 +235,9 @@ export function AdminFeaturesPanel() {
)} )}
</> </>
)} )}
</ListboxOption> </SharedListboxOption>
))} ))}
</ListboxOptions> </SharedListboxOptions>
</Transition> </Transition>
</Listbox> </Listbox>
) : ( ) : (

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; 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 { Fragment } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@ -14,15 +14,14 @@ import {
Section, Section,
ToggleRow, ToggleRow,
inputClass, inputClass,
listboxButtonClass, SharedListboxButton,
listboxOptionClass, SharedListboxOption,
listboxOptionsClass, SharedListboxOptions,
Button, Button,
IconButton, IconButton,
Input, Input,
MenuItemClass, MenuItemsSurface,
cn, MenuActionItem,
menuPanelClass,
} from '@/components/ui'; } from '@/components/ui';
type ProviderType = TtsProviderId; type ProviderType = TtsProviderId;
@ -426,24 +425,23 @@ export function AdminProvidersPanel() {
setCustomModelInput(''); setCustomModelInput('');
}} }}
> >
<ListboxButton className={listboxButtonClass}> <SharedListboxButton>
<span className="block truncate">{selectedProviderType.label}</span> <span className="block truncate">{selectedProviderType.label}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" /> <ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span> </span>
</ListboxButton> </SharedListboxButton>
<Transition <Transition
as={Fragment} as={Fragment}
leave="transition ease-in duration-100" leave="transition ease-in duration-100"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<ListboxOptions anchor="bottom start" className={listboxOptionsClass}> <SharedListboxOptions anchor="bottom start">
{PROVIDER_TYPE_OPTIONS.map((opt) => ( {PROVIDER_TYPE_OPTIONS.map((opt) => (
<ListboxOption <SharedListboxOption
key={opt.value} key={opt.value}
value={opt} value={opt}
className={({ active }) => listboxOptionClass(active)}
> >
{({ selected }) => ( {({ selected }) => (
<> <>
@ -457,9 +455,9 @@ export function AdminProvidersPanel() {
)} )}
</> </>
)} )}
</ListboxOption> </SharedListboxOption>
))} ))}
</ListboxOptions> </SharedListboxOptions>
</Transition> </Transition>
</Listbox> </Listbox>
</Field> </Field>
@ -485,26 +483,25 @@ export function AdminProvidersPanel() {
setCustomModelInput(''); setCustomModelInput('');
}} }}
> >
<ListboxButton className={listboxButtonClass}> <SharedListboxButton>
<span className="block truncate"> <span className="block truncate">
{selectedModelDefinition?.name ?? 'Select model'} {selectedModelDefinition?.name ?? 'Select model'}
</span> </span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" /> <ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span> </span>
</ListboxButton> </SharedListboxButton>
<Transition <Transition
as={Fragment} as={Fragment}
leave="transition ease-in duration-100" leave="transition ease-in duration-100"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<ListboxOptions anchor="bottom start" className={listboxOptionsClass}> <SharedListboxOptions anchor="bottom start">
{modelDefinitions.map((model) => ( {modelDefinitions.map((model) => (
<ListboxOption <SharedListboxOption
key={model.id} key={model.id}
value={model.id} value={model.id}
className={({ active }) => listboxOptionClass(active)}
> >
{({ selected }) => ( {({ selected }) => (
<> <>
@ -523,9 +520,9 @@ export function AdminProvidersPanel() {
)} )}
</> </>
)} )}
</ListboxOption> </SharedListboxOption>
))} ))}
</ListboxOptions> </SharedListboxOptions>
</Transition> </Transition>
</Listbox> </Listbox>
{supportsCustomModel && selectedModelId === 'custom' && ( {supportsCustomModel && selectedModelId === 'custom' && (
@ -663,56 +660,26 @@ export function AdminProvidersPanel() {
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<MenuItems <MenuItemsSurface
anchor="bottom end" 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> <MenuActionItem onClick={() => startEdit(p)}>
{({ active }) => ( Edit
<button </MenuActionItem>
type="button" <MenuActionItem
onClick={() => startEdit(p)} onClick={() => setDefault(p.slug)}
className={MenuItemClass(active)} disabled={!p.enabled || defaultProviderSlug === p.slug}
> >
Edit Set as default
</button> </MenuActionItem>
)} <MenuActionItem onClick={() => toggleEnabled(p)}>
</MenuItem> {p.enabled ? 'Disable' : 'Enable'}
<MenuItem disabled={!p.enabled || defaultProviderSlug === p.slug}> </MenuActionItem>
{({ active, disabled }) => ( <MenuActionItem tone="danger" onClick={() => remove(p.id)}>
<button Delete
type="button" </MenuActionItem>
onClick={() => setDefault(p.slug)} </MenuItemsSurface>
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>
</Transition> </Transition>
</Menu> </Menu>
</div> </div>

View file

@ -7,7 +7,7 @@ import { useAuthSession } from '@/hooks/useAuthSession';
import { getAuthClient } from '@/lib/client/auth-client'; import { getAuthClient } from '@/lib/client/auth-client';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { UserIcon } from '@/components/icons/Icons'; 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'; type UserMenuVariant = 'toolbar' | 'sidebar';
@ -57,13 +57,13 @@ export function UserMenu({
return ( return (
<div className={`flex gap-2 ${className}`}> <div className={`flex gap-2 ${className}`}>
<Link href="/signin" className={buttonClass({ variant: 'secondary', size: 'sm' })}> <ButtonLink href="/signin" variant="secondary" size="sm">
Connect Connect
</Link> </ButtonLink>
{enableUserSignups && ( {enableUserSignups && (
<Link href="/signup" className={buttonClass({ variant: 'primary', size: 'sm' })}> <ButtonLink href="/signup" variant="primary" size="sm">
Create account Create account
</Link> </ButtonLink>
)} )}
</div> </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 { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import { DocumentPreview } from '@/components/doclist/DocumentPreview'; import { DocumentPreview } from '@/components/doclist/DocumentPreview';
import { formatDocumentSize } from '@/components/doclist/formatSize'; import { formatDocumentSize } from '@/components/doclist/formatSize';
import { buttonClass } from '@/components/ui'; import { Button, ButtonLink } from '@/components/ui';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes'; import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';
@ -198,20 +198,17 @@ export function GalleryView({
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Link <ButtonLink href={openHref || '/app'} prefetch={false} variant="primary" size="sm">
href={openHref || '/app'}
prefetch={false}
className={buttonClass({ variant: 'primary', size: 'sm' })}
>
Open Open
</Link> </ButtonLink>
<button <Button
type="button" type="button"
onClick={() => onDeleteDoc(activeDoc)} onClick={() => onDeleteDoc(activeDoc)}
className={buttonClass({ variant: 'secondary', size: 'sm' })} variant="secondary"
size="sm"
> >
Delete Delete
</button> </Button>
</div> </div>
</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"> <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'; '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 { Fragment, useRef, type CSSProperties, type ReactNode } from 'react';
import { useDrop } from 'react-dnd'; import { useDrop } from 'react-dnd';
import type { Folder, SidebarFilter } from '@/types/documents'; import type { Folder, SidebarFilter } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons'; import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons';
import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons'; import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; 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 { interface FinderSidebarProps {
filter: SidebarFilter; filter: SidebarFilter;
@ -220,44 +220,32 @@ export function FinderSidebar({
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<MenuItems <MenuItemsSurface
anchor="bottom start" 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> <MenuActionItem
{({ active }) => ( onClick={() => {
<button onNewFolder();
type="button" onRowAction?.();
onClick={() => { }}
onNewFolder(); >
onRowAction?.(); <FolderPlusIcon className="h-4 w-4" />
}} New Folder
className={MenuItemClass(active)} </MenuActionItem>
> <MenuActionItem
<FolderPlusIcon className="h-4 w-4" /> disabled={folders.length === 0}
New Folder onClick={() => {
</button> onClearFolders();
)} onRowAction?.();
</MenuItem> }}
<MenuItem disabled={folders.length === 0}> >
{({ active, disabled }) => ( <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<button <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
type="button" </svg>
onClick={() => { Remove All Folders
onClearFolders(); </MenuActionItem>
onRowAction?.(); </MenuItemsSurface>
}}
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>
</Transition> </Transition>
</Menu> </Menu>
)} )}

View file

@ -1,11 +1,11 @@
'use client'; 'use client';
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'; import { Menu, MenuButton, Transition } from '@headlessui/react';
import { Fragment } from 'react'; import { Fragment } from 'react';
import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon, ListIcon } from '@/components/icons/Icons'; import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon, ListIcon } from '@/components/icons/Icons';
import { ZoomControl } from '@/components/documents/ZoomControl'; import { ZoomControl } from '@/components/documents/ZoomControl';
import { UserMenu } from '@/components/auth/UserMenu'; import { UserMenu } from '@/components/auth/UserMenu';
import { IconButton, MenuItemClass, ToolbarButton, menuPanelClass } from '@/components/ui'; import { IconButton, MenuActionItem, MenuItemsSurface, ToolbarButton } from '@/components/ui';
interface DocumentHeaderMenuProps { interface DocumentHeaderMenuProps {
zoomLevel: number; zoomLevel: number;
@ -100,7 +100,7 @@ export function DocumentHeaderMenu({
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" 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 */} {/* Zoom Controls Section */}
<div className="px-4 py-3"> <div className="px-4 py-3">
<p className="text-xs font-medium text-soft mb-2">Zoom / Padding</p> <p className="text-xs font-medium text-soft mb-2">Zoom / Padding</p>
@ -125,49 +125,28 @@ export function DocumentHeaderMenu({
{/* Actions Section */} {/* Actions Section */}
<div className="p-1"> <div className="p-1">
{onOpenSegments && ( {onOpenSegments && (
<MenuItem> <MenuActionItem onClick={onOpenSegments} activeOverride={isSegmentsOpen}>
{({ active }) => ( <ListIcon className="h-4 w-4" />
<button {isSegmentsOpen ? 'Hide Segments' : 'Segments'}
onClick={onOpenSegments} </MenuActionItem>
className={MenuItemClass(active || isSegmentsOpen)}
>
<ListIcon className="h-4 w-4" />
{isSegmentsOpen ? 'Hide Segments' : 'Segments'}
</button>
)}
</MenuItem>
)} )}
{showAudiobookExport && onOpenAudiobook && ( {showAudiobookExport && onOpenAudiobook && (
<MenuItem> <MenuActionItem onClick={onOpenAudiobook} activeOverride={isAudiobookOpen}>
{({ active }) => ( <DownloadIcon className="h-4 w-4" />
<button {isAudiobookOpen ? 'Hide Audiobook' : 'Export Audiobook'}
onClick={onOpenAudiobook} </MenuActionItem>
className={MenuItemClass(active || isAudiobookOpen)}
>
<DownloadIcon className="h-4 w-4" />
{isAudiobookOpen ? 'Hide Audiobook' : 'Export Audiobook'}
</button>
)}
</MenuItem>
)} )}
<MenuItem> <MenuActionItem onClick={onOpenSettings} activeOverride={isSettingsOpen}>
{({ active }) => ( <FileSettingsIcon className="h-4 w-4" />
<button {isSettingsOpen ? 'Hide Settings' : 'Settings'}
onClick={onOpenSettings} </MenuActionItem>
className={MenuItemClass(active || isSettingsOpen)}
>
<FileSettingsIcon className="h-4 w-4" />
{isSettingsOpen ? 'Hide Settings' : 'Settings'}
</button>
)}
</MenuItem>
</div> </div>
{/* Auth Section */} {/* Auth Section */}
<div className="p-2 border-t border-line-soft flex justify-center"> <div className="p-2 border-t border-line-soft flex justify-center">
<UserMenu /> <UserMenu />
</div> </div>
</MenuItems> </MenuItemsSurface>
</Transition> </Transition>
</Menu> </Menu>
</div> </div>

View file

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

View file

@ -1,4 +1,4 @@
import { buttonClass } from '@/components/ui'; import { IconButton } from '@/components/ui';
export function ZoomControl({ export function ZoomControl({
value, value,
@ -15,33 +15,25 @@ export function ZoomControl({
}) { }) {
return ( return (
<div className="flex items-center gap-1 select-none" aria-label="Zoom controls"> <div className="flex items-center gap-1 select-none" aria-label="Zoom controls">
<button <IconButton
type="button"
onClick={onDecrease} onClick={onDecrease}
disabled={value <= min} disabled={value <= min}
className={buttonClass({ size="sm"
variant: 'ghost', className="h-6 w-6 text-sm leading-none"
size: 'icon',
className: 'h-6 w-6 text-sm leading-none',
})}
aria-label="Zoom out" aria-label="Zoom out"
> >
</button> </IconButton>
<span className="text-xs tabular-nums w-12 text-center text-soft">{value}%</span> <span className="text-xs tabular-nums w-12 text-center text-soft">{value}%</span>
<button <IconButton
type="button"
onClick={onIncrease} onClick={onIncrease}
disabled={value >= max} disabled={value >= max}
className={buttonClass({ size="sm"
variant: 'ghost', className="h-6 w-6 text-sm leading-none"
size: 'icon',
className: 'h-6 w-6 text-sm leading-none',
})}
aria-label="Zoom in" aria-label="Zoom in"
> >
</button> </IconButton>
</div> </div>
); );
} }

View file

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

View file

@ -1,11 +1,11 @@
'use client'; 'use client';
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; import { Popover } from '@headlessui/react';
import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons'; import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons';
import { useConfig } from '@/contexts/ConfigContext'; import { useConfig } from '@/contexts/ConfigContext';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; 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 = ({ export const SpeedControl = ({
setSpeedAndRestart, setSpeedAndRestart,
@ -89,13 +89,13 @@ export const SpeedControl = ({
return ( return (
<Popover className="relative"> <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" /> <SpeedometerIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
<span className="sm:hidden">{compactTriggerLabel}</span> <span className="sm:hidden">{compactTriggerLabel}</span>
<span className="hidden sm:inline">{triggerLabel}</span> <span className="hidden sm:inline">{triggerLabel}</span>
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" /> <ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
</PopoverButton> </PopoverTrigger>
<PopoverPanel anchor="top" className={popoverPanelClass}> <PopoverSurface anchor="top">
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
{!nativeSpeedSupported && ( {!nativeSpeedSupported && (
<div className="rounded-md border border-line bg-background px-2 py-1.5 text-[11px] text-soft"> <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>
<span className="text-xs">{max.toFixed(1)}x</span> <span className="text-xs">{max.toFixed(1)}x</span>
</div> </div>
<input <RangeInput
type="range"
min={min} min={min}
max={max} max={max}
step={step} step={step}
@ -123,7 +122,6 @@ export const SpeedControl = ({
onMouseUp={handleVoiceSpeedChangeComplete} onMouseUp={handleVoiceSpeedChangeComplete}
onKeyUp={handleVoiceSpeedChangeComplete} onKeyUp={handleVoiceSpeedChangeComplete}
onTouchEnd={handleVoiceSpeedChangeComplete} onTouchEnd={handleVoiceSpeedChangeComplete}
className={rangeInputClass}
/> />
</div> </div>
)} )}
@ -137,8 +135,7 @@ export const SpeedControl = ({
</span> </span>
<span className="text-xs">{max.toFixed(1)}x</span> <span className="text-xs">{max.toFixed(1)}x</span>
</div> </div>
<input <RangeInput
type="range"
min={min} min={min}
max={max} max={max}
step={step} step={step}
@ -147,11 +144,10 @@ export const SpeedControl = ({
onMouseUp={handleAudioSpeedChangeComplete} onMouseUp={handleAudioSpeedChangeComplete}
onKeyUp={handleAudioSpeedChangeComplete} onKeyUp={handleAudioSpeedChangeComplete}
onTouchEnd={handleAudioSpeedChangeComplete} onTouchEnd={handleAudioSpeedChangeComplete}
className={rangeInputClass}
/> />
</div> </div>
</div> </div>
</PopoverPanel> </PopoverSurface>
</Popover> </Popover>
); );
}; };

View file

@ -8,7 +8,7 @@ import { useEffect, useMemo, useState } from 'react';
import { buildKokoroVoiceString, parseKokoroVoiceNames } from '@/lib/shared/kokoro'; import { buildKokoroVoiceString, parseKokoroVoiceNames } from '@/lib/shared/kokoro';
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog'; import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; 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({ export function VoicesControlBase({
availableVoices, availableVoices,
@ -33,8 +33,8 @@ export function VoicesControlBase({
const buttonClass = variant === 'field' const buttonClass = variant === 'field'
? 'bg-surface pr-10' ? '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'); : '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'; const buttonTone = variant === 'field' ? 'default' : 'popover';
const iconClass = variant === 'field' const iconClass = variant === 'field'
? 'h-3.5 w-3.5 shrink-0' ? 'h-3.5 w-3.5 shrink-0'

View file

@ -1,14 +1,14 @@
'use client'; 'use client';
import { Fragment, type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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 { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { Book } from 'epubjs'; import type { Book } from 'epubjs';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useTTS } from '@/contexts/TTSContext'; import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext'; import { useConfig } from '@/contexts/ConfigContext';
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons'; 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 { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator'; import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan'; import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';
@ -903,9 +903,9 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
leaveFrom="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1" leaveTo="opacity-0 translate-y-1"
> >
<PopoverPanel <PopoverSurface
anchor="bottom end" anchor="bottom end"
className={`${popoverPanelClass} z-[60] w-[300px] mt-1`} className="z-[60] w-[300px] mt-1"
> >
<dl className="space-y-2"> <dl className="space-y-2">
<Row label="locator"> <Row label="locator">
@ -958,7 +958,7 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
</div> </div>
))} ))}
</dl> </dl>
</PopoverPanel> </PopoverSurface>
</Transition> </Transition>
</Popover> </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 { cn } from './cn';
import { variants } from './variants'; import { variants } from './variants';
import { focusRing, motionColors } from './tokens'; 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 btnDanger = buttonStyles({ variant: 'danger', size: 'md' });
export const btnGhost = buttonStyles({ variant: 'ghost', size: 'md' }); export const btnGhost = buttonStyles({ variant: 'ghost', size: 'md' });
export function buttonClass({ function buttonClass({
variant = 'secondary', variant = 'secondary',
size = 'md', size = 'md',
className = '', className = '',
@ -75,3 +76,39 @@ export function Button({
</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 type { HTMLAttributes, ReactNode } from 'react';
import { cn } from './cn'; 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({ export function Menu({
children, 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') { 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 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'); 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'; import { cn } from './cn';
export const rangeInputClass = cn( const rangeInputClass = cn(
'w-full cursor-pointer appearance-none rounded-lg bg-surface-sunken accent-accent', '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-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', '[&::-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', '[&::-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); 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'; import { cn } from './cn';
export { segmentedButtonClass, segmentedGroupClass } from './segmented-control'; 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'; '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'; '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'; '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]'); 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]'; '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( cn(
'relative cursor-pointer select-none rounded-sm py-1.5 text-sm', 'relative cursor-pointer select-none rounded-sm py-1.5 text-sm',
inset === 'check' ? 'pl-9 pr-3' : 'px-2.5', inset === 'check' ? 'pl-9 pr-3' : 'px-2.5',
selected ? 'bg-accent text-background font-medium' : active ? 'bg-accent-wash text-foreground' : 'text-foreground', 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( cn(
'relative cursor-pointer select-none rounded-sm px-2 py-1 text-xs', 'relative cursor-pointer select-none rounded-sm px-2 py-1 text-xs',
active active
@ -43,10 +46,12 @@ export function SharedListboxButton({
children, children,
...props ...props
}: ComponentProps<typeof ListboxButton> & { }: ComponentProps<typeof ListboxButton> & {
tone?: 'default' | 'toolbar' | 'unstyled'; tone?: 'default' | 'toolbar' | 'popover' | 'unstyled';
}) { }) {
const baseClass = tone === 'toolbar' const baseClass = tone === 'toolbar'
? listboxToolbarButtonClass ? listboxToolbarButtonClass
: tone === 'popover'
? listboxPopoverButtonClass
: tone === 'unstyled' : tone === 'unstyled'
? '' ? ''
: listboxButtonClass; : listboxButtonClass;