From f2c7760381ec139be5175841d795abc3f41a0fae Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 1 Jun 2026 16:17:12 -0600 Subject: [PATCH] Redesign: shared UI design system + app-wide migration (#96) * phase 0: token foundation * phase 1: motion language * phase 2: primitives and semantic tokens * phase 3: depth and rhythm polish * phase 4: enforce design system lint rules * phase 5: split ui primitives into modules * phase 7: refactor app surfaces to ui layer * phase 9: enforce ui architecture imports * phase 10: add ui system harness * fix compact reader auth control * fix pdf loader flash * Converge sidebar and reader controls * refactor: remove initial loader state and related logic from PDFViewerPage * Remove legacy UI shim imports * Converge modal and drawer frames * Migrate secondary modals to shared frame * Move settings modal onto shared frame * Use shared cards in audiobook settings * Converge choice and popover surfaces * Converge reader navigation buttons * Refactor UI components to use consistent button and icon styles across the application * refactor(ui): unify button usage in settings, admin, and doclist components Replace native button elements with shared Button, ChoiceTile, and IconButton components for consistent UI behavior and styling. Update classNames and props to match new component APIs. Adjust FinderSidebar to use utility function for conditional class merging. * feat(ui): introduce shared Listbox components for unified select and dropdown styling Replace direct usage of Headless UI Listbox primitives with new SharedListboxButton, SharedListboxOption, and SharedListboxOptions components across AudiobookExportModal, FinderToolbar, VoicesControlBase, and select UI. Refactor related imports and classNames to centralize dropdown styling and logic. Simplify UserMenu button markup for improved consistency. This change consolidates dropdown/select UI patterns, reduces duplication, and improves maintainability by providing a single source of truth for Listbox styling and behavior. * 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. * feat(ui): redesign range input with dynamic progress styling and improved accessibility Revamp the range input component to support dynamic progress indication using CSS custom properties and linear gradients. Add logic to compute and set the progress percentage based on current value, min, and max. Refine focus and disabled states for better accessibility and usability. Update styling for both WebKit and Mozilla engines to ensure consistent appearance. This change enhances visual feedback and modernizes the range slider UI. * style(ui): update range input to use secondary accent color for progress Switch range input progress styling from primary to secondary accent color for both WebKit and Mozilla engines. Remove drop shadow from slider thumb for a cleaner appearance. This change aligns the component with the updated design palette and simplifies visual effects. * refactor(app): remove unused Link imports from public pages and components Eliminate redundant imports of the Link component from Next.js in several public-facing pages and components. These imports were no longer in use after recent UI refactoring and consolidation of navigation elements. This cleanup reduces bundle size and improves code clarity. * chore(ui): remove unused export of segmented control classes from select component Eliminate unnecessary export statements for segmentedButtonClass and segmentedGroupClass in the select component to streamline the module's public API and reduce potential confusion. * test(accessibility): improve confirm dialog test coverage and refactor media state helper Expand accessibility tests for ConfirmDialog to assert dialog semantics, ARIA attributes, and visible destructive actions using test IDs. Refactor expectMediaState helper to check both UI control state and underlying media signals for more robust playback state detection. * refactor(ui): improve segmented control accessibility and update danger color tokens Update SegmentedControl to support full keyboard navigation and focus management, enhancing accessibility. Replace string indicator in Select with icon, and update button danger variant to use new --danger-strong variable for hover states. Add danger-strong token to Tailwind config and globals. Refine dropzone disabled behavior, adjust focus ring for better contrast, and apply minor UI consistency tweaks across components. * feat(ui): convert SidebarNavLink to forwardRef component Refactor SidebarNavLink to use React.forwardRef, enabling parent components to access the underlying anchor element's ref. Update prop typing and function signature accordingly for improved composability and integration with higher-order components. --- eslint.config.mjs | 128 ++++- src/app/(app)/epub/[id]/page.tsx | 25 +- src/app/(app)/html/[id]/page.tsx | 25 +- src/app/(app)/layout.tsx | 7 +- src/app/(app)/pdf/[id]/page.tsx | 66 +-- src/app/(app)/signin/page.tsx | 57 +- src/app/(app)/signup/page.tsx | 81 ++- src/app/(public)/layout.tsx | 23 +- src/app/(public)/page.tsx | 40 +- src/app/(public)/privacy/page.tsx | 25 +- .../api/documents/[id]/parsed/events/route.ts | 2 - src/app/globals.css | 62 ++ src/components/AudiobookExportModal.tsx | 276 ++++----- src/components/CodeBlock.tsx | 16 +- src/components/ColorPicker.tsx | 29 +- src/components/ConfirmDialog.tsx | 95 +-- src/components/CookieConsentBanner.tsx | 34 +- src/components/Header.tsx | 17 +- src/components/HomeContent.tsx | 2 +- src/components/PrivacyModal.tsx | 199 ++----- src/components/ProgressCard.tsx | 20 +- src/components/ProgressPopup.tsx | 6 +- src/components/SettingsModal.tsx | 544 ++++++++---------- src/components/admin/AdminFeaturesPanel.tsx | 39 +- src/components/admin/AdminProvidersPanel.tsx | 120 ++-- src/components/admin/ui.tsx | 23 - src/components/auth/AuthLoader.tsx | 16 +- src/components/auth/ClaimDataModal.tsx | 138 ++--- src/components/auth/RateLimitBanner.tsx | 12 +- src/components/auth/UserMenu.tsx | 75 +-- src/components/doclist/CreateFolderDialog.tsx | 68 +-- src/components/doclist/DocumentList.tsx | 39 +- .../doclist/DocumentListSkeleton.tsx | 52 +- src/components/doclist/DocumentPreview.tsx | 16 +- src/components/doclist/views/DocumentTile.tsx | 23 +- src/components/doclist/views/GalleryView.tsx | 68 ++- src/components/doclist/views/ListView.tsx | 34 +- .../doclist/window/FinderSidebar.tsx | 199 ++----- .../doclist/window/FinderStatusBar.tsx | 4 +- .../doclist/window/FinderToolbar.tsx | 120 ++-- .../doclist/window/FinderWindow.tsx | 84 +-- .../documents/DexieMigrationModal.tsx | 131 ++--- .../documents/DocumentHeaderMenu.tsx | 114 ++-- .../documents/DocumentSelectionModal.tsx | 200 +++---- src/components/documents/DocumentSettings.tsx | 50 +- src/components/documents/DocumentUploader.tsx | 51 +- src/components/documents/ZoomControl.tsx | 28 +- src/components/formPrimitives.tsx | 301 ---------- src/components/icons/Icons.tsx | 6 +- src/components/layout/AppHeader.tsx | 30 + src/components/layout/AppShell.tsx | 14 + src/components/layout/LibraryFrame.tsx | 74 +++ src/components/layout/index.ts | 3 + src/components/player/Navigator.tsx | 33 +- .../player/RateLimitPauseButton.tsx | 7 +- src/components/player/SpeedControl.tsx | 21 +- src/components/player/TTSPlayer.tsx | 19 +- src/components/player/VoicesControlBase.tsx | 47 +- src/components/reader/ReaderSidebarShell.tsx | 28 +- src/components/reader/SegmentsSidebar.tsx | 111 ++-- src/components/ui/badge.tsx | 35 ++ src/components/ui/button.tsx | 114 ++++ src/components/ui/buttonPrimitives.ts | 38 -- src/components/ui/choice-tile.tsx | 32 ++ src/components/ui/cn.ts | 5 + src/components/ui/dialog.tsx | 158 +++++ src/components/ui/divider.tsx | 6 + src/components/ui/dropzone.ts | 40 ++ src/components/ui/field.tsx | 107 ++++ src/components/ui/icon-button.tsx | 47 ++ src/components/ui/index.ts | 24 + src/components/ui/input.tsx | 38 ++ src/components/ui/menu.tsx | 66 +++ src/components/ui/popover.tsx | 35 ++ src/components/ui/range.tsx | 64 +++ src/components/ui/search-field.tsx | 31 + src/components/ui/section.tsx | 53 ++ src/components/ui/segmented-control.tsx | 86 +++ src/components/ui/select.tsx | 142 +++++ src/components/ui/sidebar-nav.tsx | 214 +++++++ src/components/ui/sidebar.tsx | 17 + src/components/ui/surface.tsx | 92 +++ src/components/ui/switch.tsx | 72 +++ src/components/ui/tokens.ts | 3 + src/components/ui/toolbar.tsx | 83 +++ src/components/ui/variants.ts | 25 + src/components/views/EPUBViewer.tsx | 29 +- src/components/views/PDFViewer.tsx | 8 +- tailwind.config.ts | 33 ++ tests/accessibility.spec.ts | 12 +- tests/helpers.ts | 63 +- 91 files changed, 3367 insertions(+), 2582 deletions(-) delete mode 100644 src/components/admin/ui.tsx delete mode 100644 src/components/formPrimitives.tsx create mode 100644 src/components/layout/AppHeader.tsx create mode 100644 src/components/layout/AppShell.tsx create mode 100644 src/components/layout/LibraryFrame.tsx create mode 100644 src/components/layout/index.ts create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/button.tsx delete mode 100644 src/components/ui/buttonPrimitives.ts create mode 100644 src/components/ui/choice-tile.tsx create mode 100644 src/components/ui/cn.ts create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/divider.tsx create mode 100644 src/components/ui/dropzone.ts create mode 100644 src/components/ui/field.tsx create mode 100644 src/components/ui/icon-button.tsx create mode 100644 src/components/ui/index.ts create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/menu.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/components/ui/range.tsx create mode 100644 src/components/ui/search-field.tsx create mode 100644 src/components/ui/section.tsx create mode 100644 src/components/ui/segmented-control.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/sidebar-nav.tsx create mode 100644 src/components/ui/sidebar.tsx create mode 100644 src/components/ui/surface.tsx create mode 100644 src/components/ui/switch.tsx create mode 100644 src/components/ui/tokens.ts create mode 100644 src/components/ui/toolbar.tsx create mode 100644 src/components/ui/variants.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 2dac3b5..2a0310d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,6 +19,103 @@ const LOGGER_RECEIVER_SELECTOR = const SERVER_LOGGER_CALL_SELECTOR = `:matches(${STATIC_LOGGER_CALL_SELECTOR}${LOGGER_RECEIVER_SELECTOR},${DYNAMIC_LOGGER_CALL_SELECTOR}${LOGGER_RECEIVER_SELECTOR})`; const NEXT_RESPONSE_ERROR_JSON_SELECTOR = "CallExpression[callee.type='MemberExpression'][callee.object.name='NextResponse'][callee.property.name='json'][arguments.0.type='ObjectExpression']:has(Property[key.name='error'])"; +const APP_DESIGN_SYSTEM_FILES = [ + "src/app/(app)/**/*.{ts,tsx}", + "src/components/**/*.{ts,tsx}", +]; +const UI_ARCHITECTURE_FILES = [ + "src/app/(app)/**/*.{ts,tsx}", + "src/components/auth/**/*.{ts,tsx}", + "src/components/doclist/**/*.{ts,tsx}", + "src/components/documents/DocumentUploader.tsx", + "src/components/documents/DocumentSelectionModal.tsx", + "src/components/documents/DexieMigrationModal.tsx", + "src/components/documents/ZoomControl.tsx", + "src/components/player/**/*.{ts,tsx}", + "src/components/reader/**/*.{ts,tsx}", +]; +const COMPUTE_CORE_IMPORT_PATTERNS = [ + { + group: [ + "@openreader/compute-core/*", + "!@openreader/compute-core/local-runtime", + "!@openreader/compute-core/types", + "!@openreader/compute-core/api-contracts", + ], + message: + "Use '@openreader/compute-core' root imports for light APIs. Allowed subpaths are '@openreader/compute-core/local-runtime', '@openreader/compute-core/types', and '@openreader/compute-core/api-contracts'.", + }, +]; +const UI_ARCHITECTURE_IMPORT_PATHS = [ + { + name: "@/components/formPrimitives", + message: + "Use '@/components/ui' primitives; formPrimitives has been removed.", + }, + { + name: "@/components/ui/buttonPrimitives", + message: + "Use '@/components/ui' button exports; buttonPrimitives has been removed.", + }, + { + name: "@headlessui/react", + importNames: ["Button", "Input"], + message: + "Use app UI Button/Input primitives; Headless UI should be reserved for structural primitives such as Menu, Listbox, Popover, Dialog, and Transition.", + }, +]; +const RESTRICTED_APP_CLASS_PATTERNS = [ + { + selector: "Literal[value=/(bg|text|border|ring)-(gray|slate|zinc|neutral|stone|red|orange|yellow|amber|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\\d/]", + message: + "Use semantic design-system tokens instead of literal Tailwind palette color classes in app surfaces.", + }, + { + selector: "TemplateElement[value.raw=/(bg|text|border|ring)-(gray|slate|zinc|neutral|stone|red|orange|yellow|amber|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\\d/]", + message: + "Use semantic design-system tokens instead of literal Tailwind palette color classes in app surfaces.", + }, + { + selector: "Literal[value=/transition-all/]", + message: + "Use scoped transition utilities; transition-all is banned by the app design system.", + }, + { + selector: "TemplateElement[value.raw=/transition-all/]", + message: + "Use scoped transition utilities; transition-all is banned by the app design system.", + }, + { + selector: "Literal[value=/(bg|text|border|from|via|to)-(accent|background|foreground|base|offbase|muted|secondary-accent|surface|surface-solid|surface-sunken|line|line-soft|line-strong|soft|faint|accent-wash|accent-line|accent-strong|danger|danger-wash)\\/\\d/]", + message: + "Do not use Tailwind alpha modifiers on theme-token colors; use dedicated wash/semantic tokens.", + }, + { + selector: "TemplateElement[value.raw=/(bg|text|border|from|via|to)-(accent|background|foreground|base|offbase|muted|secondary-accent|surface|surface-solid|surface-sunken|line|line-soft|line-strong|soft|faint|accent-wash|accent-line|accent-strong|danger|danger-wash)\\/\\d/]", + message: + "Do not use Tailwind alpha modifiers on theme-token colors; use dedicated wash/semantic tokens.", + }, + { + selector: "Literal[value=/rounded-\\[[^\\]]*px\\]/]", + message: + "Use the app radius scale instead of arbitrary rounded-[…px] utilities.", + }, + { + selector: "TemplateElement[value.raw=/rounded-\\[[^\\]]*px\\]/]", + message: + "Use the app radius scale instead of arbitrary rounded-[…px] utilities.", + }, + { + selector: "Literal[value=/duration-\\[[^\\]]*ms\\]/]", + message: + "Use the shared motion duration tokens instead of arbitrary duration-[…ms] utilities.", + }, + { + selector: "TemplateElement[value.raw=/duration-\\[[^\\]]*ms\\]/]", + message: + "Use the shared motion duration tokens instead of arbitrary duration-[…ms] utilities.", + }, +]; const eslintConfig = [ ...compat.extends("next/core-web-vitals", "next/typescript"), @@ -27,18 +124,25 @@ const eslintConfig = [ "no-restricted-imports": [ "error", { - patterns: [ - { - group: [ - "@openreader/compute-core/*", - "!@openreader/compute-core/local-runtime", - "!@openreader/compute-core/types", - "!@openreader/compute-core/api-contracts", - ], - message: - "Use '@openreader/compute-core' root imports for light APIs. Allowed subpaths are '@openreader/compute-core/local-runtime', '@openreader/compute-core/types', and '@openreader/compute-core/api-contracts'.", - }, - ], + patterns: COMPUTE_CORE_IMPORT_PATTERNS, + }, + ], + }, + }, + { + files: APP_DESIGN_SYSTEM_FILES, + rules: { + "no-restricted-syntax": ["error", ...RESTRICTED_APP_CLASS_PATTERNS], + }, + }, + { + files: UI_ARCHITECTURE_FILES, + rules: { + "no-restricted-imports": [ + "error", + { + paths: UI_ARCHITECTURE_IMPORT_PATHS, + patterns: COMPUTE_CORE_IMPORT_PATTERNS, }, ], }, diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx index 19e1806..4a25c2e 100644 --- a/src/app/(app)/epub/[id]/page.tsx +++ b/src/app/(app)/epub/[id]/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { useParams, useRouter } from "next/navigation"; -import Link from 'next/link'; import { useCallback, useEffect, useRef, useState } from 'react'; import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton'; import { EPUBViewer } from '@/components/views/EPUBViewer'; @@ -20,6 +19,7 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef'; +import { ButtonLink } from '@/components/ui'; import { useEpubDocument } from './useEpubDocument'; export default function EPUBPage() { @@ -170,16 +170,13 @@ export default function EPUBPage() { if (error) { return (
-

{error}

- - +

{error}

+ + Back to Documents - +
); } @@ -188,16 +185,12 @@ export default function EPUBPage() { <>
- + + Documents - + } title={isLoading ? 'Loading…' : (currDocName || '')} right={ @@ -242,7 +235,7 @@ export default function EPUBPage() { /> )} {isAtLimit ? ( -
+
diff --git a/src/app/(app)/html/[id]/page.tsx b/src/app/(app)/html/[id]/page.tsx index 90fa49a..f0193a3 100644 --- a/src/app/(app)/html/[id]/page.tsx +++ b/src/app/(app)/html/[id]/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { useParams, useRouter } from "next/navigation"; -import Link from 'next/link'; import { useCallback, useEffect, useRef, useState } from 'react'; import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton'; import { HTMLViewer } from '@/components/views/HTMLViewer'; @@ -18,6 +17,7 @@ import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef'; +import { ButtonLink } from '@/components/ui'; import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; import { useHtmlDocument } from './useHtmlDocument'; @@ -157,16 +157,13 @@ export default function HTMLPage() { if (error) { return (
-

{error}

- - +

{error}

+ + Back to Documents - +
); } @@ -175,16 +172,12 @@ export default function HTMLPage() { <>
- + + Documents - + } title={isLoading ? 'Loading…' : (currDocName || '')} right={ @@ -233,7 +226,7 @@ export default function HTMLPage() { /> )} {isAtLimit ? ( -
+
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index dcf5127..c048fbf 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { Toaster } from 'react-hot-toast'; import { Providers } from '@/app/providers'; +import { AppMain, AppShell } from '@/components/layout'; import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config'; export const dynamic = 'force-dynamic'; @@ -32,9 +33,9 @@ export default function AppLayout({ children }: { children: ReactNode }) { allowAnonymousAuthSessions={allowAnonymousAuthSessions} githubAuthEnabled={githubAuthEnabled} > -
-
{children}
-
+ + {children} + -

{error}

- - +

{error}

+ + Back to Documents - +
); } @@ -308,67 +304,68 @@ export default function PDFViewerPage() { : (isMerging ? 'Stage: merge' : 'Stage: infer')); return ( -
-
+
+
{showDetailedParseLoader ? ( -
+
-
+
- PDF Layout Parse + PDF Layout Parse

{statusText}

-
+

{hasMeasuredProgress ? `Page ${pagesParsed} / ${totalPages}` : 'Awaiting first page'}

-

{stageLabel}

+

{stageLabel}

-
+
-

+

{hasMeasuredProgress ? `${Math.round(progressPercent)}% complete` : statusSubText}

{!isLoading && parseUiState === 'failed' ? (
- +
) : null}
) : ( -
+

{compactLabel}

-

{compactSubLabel}

+

{compactSubLabel}

- +
- - - + + +
)} @@ -381,17 +378,12 @@ export default function PDFViewerPage() { <>
- + + Documents - + } title={isLoading ? 'Loading…' : (currDocName || '')} right={ @@ -440,7 +432,7 @@ export default function PDFViewerPage() { /> )} {isAtLimit ? ( -
+
diff --git a/src/app/(app)/signin/page.tsx b/src/app/(app)/signin/page.tsx index fd46578..b5fb29b 100644 --- a/src/app/(app)/signin/page.tsx +++ b/src/app/(app)/signin/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { useState, useEffect, Suspense } from 'react'; -import { Button, Input } from '@headlessui/react'; import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { getAuthClient } from '@/lib/client/auth-client'; @@ -10,7 +9,7 @@ import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { showPrivacyModal } from '@/components/PrivacyModal'; import { GithubIcon } from '@/components/icons/Icons'; import { LoadingSpinner } from '@/components/Spinner'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { Button, Field, Input, Surface } from '@/components/ui'; function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) { const searchParams = useSearchParams(); @@ -120,11 +119,11 @@ function SignInContent() { -
+

{sessionExpired ? 'Session Expired' : 'Connect Account'}

-

+

{sessionExpired ? 'Please sign in again to continue' : 'Connect an email account to sync your data across devices'} @@ -132,45 +131,41 @@ function SignInContent() { {/* Alerts */} {sessionExpired && ( -

-

+

+

Your session has expired. Please sign in again.

)} {error && ( -
-

{error}

+
+

{error}

)}
{/* Email */} -
- + { setEmail(e.target.value); setError(null); }} placeholder="me@example.com" - className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm - focus:outline-none focus:ring-2 focus:ring-accent" + controlSize="lg" /> -
+ {/* Password */} -
- + { setPassword(e.target.value); setError(null); }} placeholder="Password" - className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm - focus:outline-none focus:ring-2 focus:ring-accent" + controlSize="lg" /> -
+ {/* Remember Me */} @@ -188,7 +183,9 @@ function SignInContent() { type="submit" disabled={isAnyLoading} onClick={handleSignIn} - className={buttonClass({ variant: 'primary', size: 'md', className: 'w-full hover:scale-[1.02]' })} + variant="primary" + size="md" + className="w-full" > {loadingEmail ? : 'Connect'} @@ -199,11 +196,9 @@ function SignInContent() { type="button" disabled={isAnyLoading} onClick={handleGithubSignIn} - className={buttonClass({ - variant: 'outline', - size: 'md', - className: 'w-full hover:scale-[1.02] flex items-center justify-center gap-2', - })} + variant="outline" + size="md" + className="w-full gap-2" > {loadingGithub ? ( @@ -222,7 +217,9 @@ function SignInContent() { type="button" disabled={isAnyLoading} onClick={handleAnonymousContinue} - className={buttonClass({ variant: 'outline', size: 'md', className: 'w-full hover:scale-[1.02]' })} + variant="outline" + size="md" + className="w-full" > {loadingAnonymous ? : 'Continue anonymously'} @@ -230,16 +227,16 @@ function SignInContent() {
{/* Footer */} -
+
{enableUserSignups && ( -

+

Don't have an account?{' '} Sign up

)} -

+

By signing in, you agree to our{' '}

-
+
); } diff --git a/src/app/(app)/signup/page.tsx b/src/app/(app)/signup/page.tsx index 9d9fbf1..f2b1ad8 100644 --- a/src/app/(app)/signup/page.tsx +++ b/src/app/(app)/signup/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { useState } from 'react'; -import { Button, Input } from '@headlessui/react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { getAuthClient } from '@/lib/client/auth-client'; @@ -9,7 +8,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { showPrivacyModal } from '@/components/PrivacyModal'; import { LoadingSpinner } from '@/components/Spinner'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { Button, Field, IconButton, Input, Surface } from '@/components/ui'; import toast from 'react-hot-toast'; export default function SignUpPage() { @@ -105,77 +104,69 @@ export default function SignUpPage() { if (!enableUserSignups) { return (
-
+

Sign-ups unavailable

-

+

New account sign-ups are currently disabled by the site administrator.

-
-

+

+

Already have an account?{' '} Sign in

-
+
); } const { checks, strength } = validatePassword(password); const strengthLabels = ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong']; - const strengthColors = ['bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-blue-500', 'bg-green-500']; + const strengthColors = ['bg-danger', 'bg-danger', 'bg-accent', 'bg-accent', 'bg-accent']; return (
-
+

Sign Up

-

Create your account to get started

+

Create your account to get started

{error && ( -
-

{error}

+
+

{error}

)}
{/* Email */} -
- + setEmail(e.target.value)} placeholder="me@example.com" - className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm - focus:outline-none focus:ring-2 focus:ring-accent" + controlSize="lg" /> -
+ {/* Password */} -
- +
setPassword(e.target.value)} placeholder="Password" - className="w-full rounded-lg bg-background py-2 px-3 pr-10 text-foreground shadow-sm - focus:outline-none focus:ring-2 focus:ring-accent" + controlSize="lg" + className="pr-10" /> - +
{/* Password Strength */} @@ -185,17 +176,17 @@ export default function SignUpPage() { {[0, 1, 2, 3, 4].map((i) => (
))}
-

= 3 ? 'text-green-600' : 'text-red-600'}`}> +

= 3 ? 'text-accent' : 'text-danger'}`}> {strengthLabels[strength - 1] || 'Very Weak'}

-
+
{Object.entries(checks).map(([key, passed]) => ( -
+
{passed ? '✓' : '○'} {key === 'length' && 'At least 8 characters'} @@ -209,46 +200,46 @@ export default function SignUpPage() {
)} -
+ {/* Confirm Password */} -
- + setPasswordConfirmation(e.target.value)} placeholder="Confirm Password" - className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm - focus:outline-none focus:ring-2 focus:ring-accent" + controlSize="lg" /> {passwordConfirmation && password && ( -

+

{password === passwordConfirmation ? '✓ Passwords match' : '✗ Passwords do not match'}

)} -
+ {/* Sign Up Button */}
{/* Footer */} -
-

+

+

Already have an account?{' '} Sign in

-

+

By creating an account, you agree to our{' '}

-
+
); } diff --git a/src/app/(public)/layout.tsx b/src/app/(public)/layout.tsx index 7cc7024..f850899 100644 --- a/src/app/(public)/layout.tsx +++ b/src/app/(public)/layout.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import Link from 'next/link'; import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +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
@@ -70,18 +66,11 @@ export default async function PublicLayout({ children }: { children: ReactNode }

{enableUserSignups ? ( - - Create account - + Create account ) : null} - + Star on GitHub - +
diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index f995997..a297501 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -1,8 +1,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/buttonPrimitives'; +import { ButtonAnchor, ButtonLink } from '@/components/ui'; export const metadata: Metadata = { title: 'Open Source Read-Along Workspace', @@ -98,24 +97,13 @@ export default async function LandingPage() {

- - Open the reader - + Open the reader {enableUserSignups ? ( - - Create account - + Create account ) : ( - - Sign in - + Sign in )} - - Read the docs → - + Read the docs →
@@ -316,22 +304,12 @@ export default async function LandingPage() { external compute worker. Every piece is yours to host.

diff --git a/src/app/(public)/privacy/page.tsx b/src/app/(public)/privacy/page.tsx index 644ade8..93f932e 100644 --- a/src/app/(public)/privacy/page.tsx +++ b/src/app/(public)/privacy/page.tsx @@ -1,7 +1,6 @@ import type { Metadata } from 'next'; -import Link from 'next/link'; import { headers } from 'next/headers'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { ButtonAnchor, ButtonLink } from '@/components/ui'; export const metadata: Metadata = { title: 'Privacy & Data Usage', @@ -181,25 +180,13 @@ export default async function PrivacyPage() { want full infrastructure control.

- + Open an Issue - - + + Self-Hosting Guide - - - Back to landing - + + Back to landing
diff --git a/src/app/api/documents/[id]/parsed/events/route.ts b/src/app/api/documents/[id]/parsed/events/route.ts index 927c6ff..31af7fe 100644 --- a/src/app/api/documents/[id]/parsed/events/route.ts +++ b/src/app/api/documents/[id]/parsed/events/route.ts @@ -10,7 +10,6 @@ import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state' import { isValidDocumentId } from '@/lib/server/documents/blobstore'; import { normalizeParseStatus, parseDocumentParseState } from '@/lib/server/documents/parse-state'; import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing'; -import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; import { createRequestLogger, hashForLog } from '@/lib/server/logger'; import { errorResponse } from '@/lib/server/errors/next-response'; @@ -159,7 +158,6 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string ? requestedOpIdRaw.trim() : null; - const testNamespace = getOpenReaderTestNamespace(req.headers); const storageUserId = authCtxOrRes.userId; const storageUserIdHash = hashForLog(storageUserId); const allowedUserIds = [storageUserId]; diff --git a/src/app/globals.css b/src/app/globals.css index 7c8b7db..284d34c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -201,6 +201,57 @@ html.custom { ); } +:root { + --shadow-rgb: 15 23 42; + + --surface: var(--base); + --surface-solid: color-mix(in srgb, var(--base) 96%, var(--background)); + --surface-sunken: color-mix(in srgb, var(--base) 80%, var(--background)); + + --line: color-mix(in srgb, var(--offbase) 70%, transparent); + --line-soft: color-mix(in srgb, var(--offbase) 45%, transparent); + --line-strong: var(--offbase); + + --soft: color-mix(in srgb, var(--foreground) 74%, var(--muted)); + --faint: color-mix(in srgb, var(--foreground) 52%, var(--muted)); + + --accent-wash: color-mix(in srgb, var(--accent) 12%, transparent); + --accent-line: color-mix(in srgb, var(--accent) 40%, transparent); + --accent-strong: color-mix(in srgb, var(--accent) 88%, var(--foreground)); + + --danger: #dc2626; + --danger-strong: #b91c1c; + --danger-wash: color-mix(in srgb, var(--danger) 12%, transparent); + + --elev-1: 0 1px 2px rgb(var(--shadow-rgb) / 0.10); + --elev-2: 0 6px 16px rgb(var(--shadow-rgb) / 0.16); + --elev-3: 0 18px 45px rgb(var(--shadow-rgb) / 0.22); + + --dur-fast: 120ms; + --dur-base: 180ms; + --dur-slow: 260ms; + --ease: cubic-bezier(0.22, 0.61, 0.36, 1); +} + +html.light, +html.lavender, +html.rose, +html.sand, +html.sky, +html.slate { + --shadow-rgb: 15 23 42; +} + +html.dark, +html.ocean, +html.forest, +html.sunset, +html.sea, +html.mint, +html.custom { + --shadow-rgb: 0 0 0; +} + body { color: var(--foreground); background: var(--background); @@ -248,6 +299,17 @@ h1, h2, h3, h4, h5, h6 { to { opacity: 1; } } +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 1ms !important; + } +} + /* Hide react-pdf's default tiny "Loading page..." placeholder to avoid jarring layout shifts. */ .pdf-viewer .react-pdf__message { display: none !important; diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx index 314393e..0bd98b5 100644 --- a/src/components/AudiobookExportModal.tsx +++ b/src/components/AudiobookExportModal.tsx @@ -1,7 +1,7 @@ 'use client'; import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react'; -import { Transition, Button, Listbox, ListboxButton, ListboxOptions, ListboxOption, 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,6 +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, MenuActionItem, MenuItemsSurface, RangeInput, SharedListboxButton, SharedListboxOption, SharedListboxOptions } from '@/components/ui'; import { getAudiobookStatus, deleteAudiobookChapter, @@ -476,12 +477,12 @@ export function AudiobookExportModal({ <>
{!isGenerating && ( -
+
{/* Header */} -
+

Generation settings

{settingsLocked && ( - + Locked @@ -490,9 +491,9 @@ export function AudiobookExportModal({
{isLegacyAudiobookMissingSettings && ( -
+
Saved generation settings not found
-
+
This audiobook was likely created before v1 metadata was introduced, so OpenReader can't know which voice/speeds/format were used. Consider resetting this audiobook to regenerate it with v1 metadata (so settings are saved for resumes across devices). @@ -503,18 +504,18 @@ export function AudiobookExportModal({ {settingsLocked && savedSettings ? (
-
-
Voice
+ +
Voice
{savedSettings.voice}
-
-
-
Format
+ + +
Format
{savedSettings.format.toUpperCase()}
-
+
-
-
Native speed
+ +
Native speed
{resolveTtsProviderModelPolicy({ providerRef: savedSettings.providerRef, @@ -524,13 +525,13 @@ export function AudiobookExportModal({ ? `${formatSpeed(savedSettings.nativeSpeed)}x` : 'Not supported'}
-
-
-
Post speed
+ + +
Post speed
{formatSpeed(savedSettings.postSpeed)}x
-
+
-

+

Reset the audiobook to change generation settings.

@@ -539,7 +540,7 @@ export function AudiobookExportModal({ {/* Voice & Format row */}
- +
- + {chapters.length === 0 ? ( 0 || settingsLocked} >
- + {format.toUpperCase()} - + - + - - + - `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}` - } + inset="none" + itemClassName="py-2" > {({ selected }) => ( M4B )} - - + - `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}` - } + inset="none" + itemClassName="py-2" > {({ selected }) => ( MP3 )} - - + +
@@ -608,9 +607,9 @@ export function AudiobookExportModal({
{/* Speed controls */} -
+ {!nativeSpeedSupported && ( -
+
Native model speed is not available for this model.
)} @@ -619,48 +618,44 @@ export function AudiobookExportModal({ <>
- + {formatSpeed(nativeSpeed)}x
- setNativeSpeed(parseFloat(e.target.value))} - className="w-full h-1.5 bg-offbase rounded-full appearance-none cursor-pointer [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-full [&::-moz-range-track]:h-1.5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent [&::-moz-range-thumb]:border-0" /> -
+
0.5x 3x
-
+
)}
- + {formatSpeed(postSpeed)}x
- setPostSpeed(parseFloat(e.target.value))} - className="w-full h-1.5 bg-offbase rounded-full appearance-none cursor-pointer [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-full [&::-moz-range-track]:h-1.5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent [&::-moz-range-thumb]:border-0" /> -
+
0.5x 3x
-
+
)} @@ -670,10 +665,9 @@ export function AudiobookExportModal({ @@ -682,10 +676,9 @@ export function AudiobookExportModal({ @@ -694,10 +687,8 @@ export function AudiobookExportModal({
)} {showRegenerateHint && ( -
+

TTS audio for this chapter may be cached
Change the TTS playback options or restart the server to force uncached regeneration

- +
)} {/* Progress Info */} @@ -746,34 +737,36 @@ export function AudiobookExportModal({ >

Chapters

- {isRefreshingChapters && } + {isRefreshingChapters && }
{displayChapters.map((chapter) => (
{chapter.status === 'completed' ? ( ) : onRegenerateChapter ? ( - + ) : ( - + )}

{chapter.title}

-

+

{chapter.status !== 'completed' && Missing • }{formatDuration(chapter.duration)}

@@ -782,82 +775,63 @@ export function AudiobookExportModal({ {((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && ( - {chapter.status === 'completed' && ( <> - - {({ active }) => ( - - )} - - - {({ active }) => ( - - )} - + setPendingDeleteChapter(chapter)} + title="Delete this chapter" + > + + Delete + + handleDownloadChapter(chapter)}> + + Download + )} {regeneratingChapter === chapter.index && ( - - {({ active }) => ( - - )} - + + + Cancel + )} {onRegenerateChapter && !isGenerating && ( - - {({ active, disabled }) => ( - - )} - + handleRegenerateChapter(chapter)} + title="Regenerate this chapter" + > + + {regeneratingChapter === chapter.index ? 'Regenerating...' : 'Regenerate'} + )} - + {/* end of menu items */} @@ -868,15 +842,13 @@ export function AudiobookExportModal({
{bookId && !isGenerating && ( -
+
+
); } diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 4dca7ff..374ec34 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -1,9 +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, PopoverSurface } from '@/components/ui'; /** * Curated swatch palettes per color role, sourced from existing themes @@ -78,9 +79,9 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps) return ( - +
- {/* Label */}
@@ -101,14 +101,14 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps) {/* Eyedropper / native picker */}
- + + onChange(color)} - className="group/swatch relative w-full aspect-square rounded-full transition-transform duration-100 hover:scale-125 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1" + className="group/swatch relative w-full aspect-square rounded-full transition-transform duration-fast focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1" style={{ backgroundColor: color, boxShadow: selected ? '0 0 0 2px var(--background), 0 0 0 4px var(--foreground)' : undefined, @@ -156,7 +156,7 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps) borderColor: isLightColor(value) ? '#00000018' : '#ffffff18', }} /> - setHexInput(e.target.value)} @@ -166,10 +166,11 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps) }} spellCheck={false} maxLength={7} - className="flex-1 rounded-lg px-2 py-1 text-xs font-mono border border-offbase bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-accent" + controlSize="sm" + className="flex-1 font-mono" />
- + ); } diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index 582a400..8bdc0b9 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -1,6 +1,5 @@ -import { Fragment, KeyboardEvent } from 'react'; -import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { KeyboardEvent } from 'react'; +import { Button, ModalFrame, ModalTitle } from '@/components/ui'; interface ConfirmDialogProps { isOpen: boolean; @@ -31,77 +30,25 @@ export function ConfirmDialog({ }; return ( - - - + {title} +
+

{message}

+
+ +
+ + - -
- -
-
-
- - + {confirmText} + +
+ ); } diff --git a/src/components/CookieConsentBanner.tsx b/src/components/CookieConsentBanner.tsx index b62a0fd..de748e0 100644 --- a/src/components/CookieConsentBanner.tsx +++ b/src/components/CookieConsentBanner.tsx @@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { getConsentState, setConsentState } from '@/lib/client/analytics'; import { Transition } from '@headlessui/react'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { Button } from '@/components/ui'; export function CookieConsentBanner() { const [show, setShow] = useState(false); @@ -35,46 +35,42 @@ export function CookieConsentBanner() { -
+

🍪 We use cookies

-

+

We use strictly necessary cookies for authentication. Optional analytics is enabled only when you consent. See our Privacy Policy for details.

- - +
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index dfd8367..e6394ef 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,3 +1,4 @@ +import { AppHeader } from '@/components/layout'; import { ReactNode } from "react"; export function Header({ @@ -9,19 +10,5 @@ export function Header({ title?: ReactNode; right?: ReactNode; }) { - return ( -
-
-
- {left} - {typeof title === 'string' ? ( -

{title}

- ) : ( - title - )} -
-
{right}
-
-
- ); + return ; } diff --git a/src/components/HomeContent.tsx b/src/components/HomeContent.tsx index e548230..5774ed9 100644 --- a/src/components/HomeContent.tsx +++ b/src/components/HomeContent.tsx @@ -21,8 +21,8 @@ export function HomeContent() { const appActions = (
setSettingsOpen(true)} /> diff --git a/src/components/PrivacyModal.tsx b/src/components/PrivacyModal.tsx index b462a32..600c7c7 100644 --- a/src/components/PrivacyModal.tsx +++ b/src/components/PrivacyModal.tsx @@ -1,15 +1,8 @@ 'use client'; -import { Fragment, useState, useEffect } from 'react'; -import { - Dialog, - DialogPanel, - DialogTitle, - Transition, - TransitionChild, - Button, -} from '@headlessui/react'; +import { useState, useEffect } from 'react'; import { updateAppConfig } from '@/lib/client/dexie'; +import { Button, ModalFrame, ModalTitle } from '@/components/ui'; interface PrivacyModalProps { isOpen: boolean; @@ -19,9 +12,9 @@ interface PrivacyModalProps { function PrivacyModalBody({ origin }: { origin: string }) { return ( -
-
-
Service Operator
+
+
+
Service Operator
This instance is hosted at {origin || 'this server'}.
@@ -77,85 +70,46 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps) }; return ( - - { })}> - -
- + {})} panelTestId="privacy-modal" className="z-[80]"> + Privacy & Data Usage -
-
- - - - Privacy & Data Usage - + - - -
-
-
- setAgreed(e.target.checked)} - className="h-4 w-4 rounded border-gray-300 text-accent focus:ring-accent bg-base" - /> -
-
- {' '} - - Privacy Policy - -
-
- -
- -
-
-
-
+
+
+
+ setAgreed(e.target.checked)} + className="h-4 w-4 rounded border-line text-accent focus:ring-accent-line bg-surface" + /> +
+
+ {' '} + + Privacy Policy +
-
-
+ +
+ +
+
+ ); } @@ -183,67 +137,28 @@ export function showPrivacyModal(): void { }; return ( - { root.unmount(); container.remove(); }} > - - Privacy & Data Usage + + + +
+ -
- -
-
-
- - + Close + +
+ ); }; diff --git a/src/components/ProgressCard.tsx b/src/components/ProgressCard.tsx index a3543a4..f8341d0 100644 --- a/src/components/ProgressCard.tsx +++ b/src/components/ProgressCard.tsx @@ -1,4 +1,4 @@ -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { Button } from '@/components/ui'; interface ProgressCardProps { progress: number; @@ -32,7 +32,7 @@ export function ProgressCard({ const operationLabel = getOperationLabel(); return ( -
+
{/* Header with operation type and cancel button */}
@@ -52,29 +52,27 @@ export function ProgressCard({
)}
- +
{/* Progress bar */}
{/* Stats row */} -
+
{completedChapters !== undefined && ( <> {completedChapters} chapters diff --git a/src/components/ProgressPopup.tsx b/src/components/ProgressPopup.tsx index 4e0c7db..57afa29 100644 --- a/src/components/ProgressPopup.tsx +++ b/src/components/ProgressPopup.tsx @@ -32,17 +32,17 @@ export function ProgressPopup({
+
+
+
+
+
+
+ ); +} + +function ThemeChoice({ + label, + colors, + selected, + onClick, + className, +}: { + label: string; + colors: ThemeColorSet; + selected: boolean; + onClick: () => void; + className?: string; +}) { + return ( + + {selected ? ( + + ) : ( + + )} + + {label} + + + + ); +} + type SectionId = 'api' | 'theme' | 'docs' | 'account' | 'admin'; type SidebarSection = { @@ -133,20 +178,37 @@ type AdminSubTab = 'providers' | 'features'; export function SettingsTrigger({ className = '', triggerLabel, + variant = 'button', onOpen, }: { className?: string; triggerLabel?: string; + variant?: 'button' | 'sidebar'; onOpen: () => void; }) { + if (variant === 'sidebar') { + return ( + } + label={triggerLabel ?? 'Settings'} + /> + ); + } + return ( ); @@ -483,8 +545,8 @@ export function SettingsModal({ setActiveSection(visibleSections[0]?.id ?? 'theme'); }, [activeSection, visibleSections]); - const fieldLabelClass = 'block text-[11px] font-semibold uppercase tracking-wide text-muted'; - const sectionShellClass = 'space-y-2 pb-3 border-b border-offbase px-0.5'; + const fieldLabelClass = 'block text-[11px] font-semibold uppercase tracking-wide text-soft'; + const sectionShellClass = 'space-y-2 pb-3 border-b border-line-soft px-0.5'; const sectionHeadingClass = 'text-sm font-semibold text-foreground'; const effectiveProviderType = resolveEffectiveProviderType({ providerRef: selectedProviderRef, @@ -511,53 +573,32 @@ export function SettingsModal({ return ( <> - - - -
- - -
-
- - + {/* Header */} -
+
- - Settings - + Settings
@@ -572,50 +613,42 @@ export function SettingsModal({ /> ) : ( <> - {/* Mobile: 2x2 grid nav */} -
+ {/* Mobile nav */} + {visibleSections.map((section) => { const Icon = section.icon; return ( - + active={activeSection === section.id} + icon={} + label={section.label} + /> ); })} -
+
{/* Desktop: vertical sidebar */} - {/* Content */} @@ -630,7 +663,7 @@ export function SettingsModal({
{ttsProviders.length === 0 ? ( -

+

User API keys are restricted and no shared provider is configured. Ask an admin to add one.

) : ( @@ -656,28 +689,26 @@ export function SettingsModal({ setCustomModelInput(''); }} > - + {selectedProviderOption?.name || 'Select Provider'} - + - + - {ttsProviders.map((provider) => ( - listboxOptionClass(active)} value={provider} > {({ selected }) => ( @@ -692,15 +723,15 @@ export function SettingsModal({ )} )} - + ))} - + )}
{restrictUserApiKeys && ( -

+

This instance restricts user API keys. TTS runs through admin-configured shared providers only.

)} @@ -737,7 +768,7 @@ export function SettingsModal({
)} {isSharedSelected && ( -

+

This is a shared provider configured by an admin. API key and base URL are managed server-side.

)} @@ -745,7 +776,7 @@ export function SettingsModal({
{!showAllProviderModels && ( -

+

This instance restricts model selection to each provider's default model.

)} @@ -761,14 +792,14 @@ export function SettingsModal({ } }} > - + {selectedModel ? ( {selectedModel.name} {selectedModelVersion && ( - + {selectedModelVersion} )} @@ -777,23 +808,21 @@ export function SettingsModal({ Select Model )} - + - + - {ttsModels.map((model) => ( - listboxOptionClass(active)} value={model} > {({ selected }) => ( @@ -801,7 +830,7 @@ export function SettingsModal({ {model.name} {model.id.includes(':') && ( - + {model.id.slice(model.id.indexOf(':'))} )} @@ -813,9 +842,9 @@ export function SettingsModal({ )} )} - + ))} - + @@ -849,7 +878,8 @@ export function SettingsModal({
+ className="w-full" + /> ); })()}
{/* Custom theme */}
- + {(() => { const colors = getThemeColors('custom'); const isActive = theme === 'custom'; return (
- - +
{isCustomExpanded && ( @@ -1043,41 +1034,19 @@ export function SettingsModal({ {/* Light themes */}
- +
{lightThemes.map((t) => { const colors = getThemeColors(t.id); const isActive = theme === t.id; return ( - + /> ); })}
@@ -1085,41 +1054,19 @@ export function SettingsModal({ {/* Dark themes */}
- +
{darkThemes.map((t) => { const colors = getThemeColors(t.id); const isActive = theme === t.id; return ( - + /> ); })}
@@ -1135,7 +1082,8 @@ export function SettingsModal({ @@ -1147,14 +1095,16 @@ export function SettingsModal({ @@ -1166,30 +1116,16 @@ export function SettingsModal({ {/* Admin Section */} {activeSection === 'admin' && isAdmin && (
-
- {([ - { id: 'providers', label: 'Shared providers' }, - { id: 'features', label: 'Site features' }, - ] as { id: AdminSubTab; label: string }[]).map((tab) => { - const active = adminSubTab === tab.id; - return ( - - ); - })} -
+ {adminSubTab === 'providers' && } {adminSubTab === 'features' && }
@@ -1199,10 +1135,10 @@ export function SettingsModal({ {activeSection === 'account' && (
{/* Session info */} -
+

Current Session

-

Logged in as:

+

Logged in as:

{session?.user ? ( <>

@@ -1211,7 +1147,7 @@ export function SettingsModal({ : (session.user.name || session.user.email || 'Account')}

{!session.user.isAnonymous && ( -

{session.user.email}

+

{session.user.email}

)} {session.user.isAnonymous && (

Anonymous session

@@ -1225,20 +1161,20 @@ export function SettingsModal({ {/* Export Data */} {session?.user && ( - + )} {/* Actions */} @@ -1247,27 +1183,31 @@ export function SettingsModal({ <> -
- - -

- Permanently deletes your account and all data. -

-
+ {enableDestructiveDelete && ( +
+ + +

+ Permanently deletes your account and all data. +

+
+ )} ) : ( -
-

+

+

{session?.user?.isAnonymous ? (runtimeConfig.enableUserSignups ? 'You are using an anonymous session. Sign up to save your progress permanently, your current data is automatically transferred.' @@ -1278,19 +1218,19 @@ export function SettingsModal({

- {runtimeConfig.enableUserSignups && ( - )} - @@ -1304,12 +1244,7 @@ export function SettingsModal({
)} - - -
-
-
-
+ -
- +

Changelog

-

+

{normalizedAppVersion ? `Current version: v${normalizedAppVersion}` : 'Release history from GitHub'} @@ -1459,20 +1393,20 @@ function SettingsChangelogPanel({

{loading && ( -
+
Loading changelog…
)} {!loading && error && ( -
+

Could not load changelog right now.

-

{error}

+

{error}

Open GitHub Releases @@ -1480,7 +1414,7 @@ function SettingsChangelogPanel({ )} {!loading && !error && manifest.length === 0 && ( -
+
No releases found.
)} @@ -1493,35 +1427,35 @@ function SettingsChangelogPanel({ const normalizedName = normalizeVersion(entry.name || ''); const showName = Boolean(entry.name) && normalizedName !== normalizedTag; return ( -
+
@@ -654,14 +655,16 @@ function SourceBadge({ return (
{canReset && !dirty && ( - + )} {dirty ? ( Modified diff --git a/src/components/admin/AdminProvidersPanel.tsx b/src/components/admin/AdminProvidersPanel.tsx index 1e5ff5c..55dfc0f 100644 --- a/src/components/admin/AdminProvidersPanel.tsx +++ b/src/components/admin/AdminProvidersPanel.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Button, Input, 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,11 +14,15 @@ import { Section, ToggleRow, inputClass, - listboxButtonClass, - listboxOptionClass, - listboxOptionsClass, -} from '@/components/formPrimitives'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; + SharedListboxButton, + SharedListboxOption, + SharedListboxOptions, + Button, + IconButton, + Input, + MenuItemsSurface, + MenuActionItem, +} from '@/components/ui'; type ProviderType = TtsProviderId; @@ -364,7 +368,8 @@ export function AdminProvidersPanel() { {!editingId ? ( @@ -637,7 +641,9 @@ export function AdminProvidersPanel() {
- - - {({ active }) => ( - - )} - - - {({ active, disabled }) => ( - - )} - - - {({ active }) => ( - - )} - - - {({ active }) => ( - - )} - - + startEdit(p)}> + Edit + + setDefault(p.slug)} + disabled={!p.enabled || defaultProviderSlug === p.slug} + > + Set as default + + toggleEnabled(p)}> + {p.enabled ? 'Disable' : 'Enable'} + + remove(p.id)}> + Delete + +
diff --git a/src/components/admin/ui.tsx b/src/components/admin/ui.tsx deleted file mode 100644 index fdf6fa5..0000000 --- a/src/components/admin/ui.tsx +++ /dev/null @@ -1,23 +0,0 @@ -export { - Badge, - Card, - Field, - Section, - ToggleRow, - inputClass, - listboxButtonClass, - listboxOptionClass, - listboxOptionsClass, - segmentedButtonClass, - segmentedGroupClass, -} from '@/components/formPrimitives'; - -export { - buttonClass, - btnBase, - btnDanger, - btnGhost, - btnOutline, - btnPrimary, - btnSecondary, -} from '@/components/ui/buttonPrimitives'; diff --git a/src/components/auth/AuthLoader.tsx b/src/components/auth/AuthLoader.tsx index 0923087..e429b1e 100644 --- a/src/components/auth/AuthLoader.tsx +++ b/src/components/auth/AuthLoader.tsx @@ -7,7 +7,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext import { useAuthSession } from '@/hooks/useAuthSession'; import { getAuthClient } from '@/lib/client/auth-client'; import { LoadingSpinner } from '@/components/Spinner'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { Button } from '@/components/ui'; function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -252,25 +252,25 @@ export function AuthLoader({ children }: { children: ReactNode }) { if (isLoading) { return ( -
+
{bootstrapError ? (
-

{bootstrapError}

- +
) : ( -

+

{isAutoLoggingIn ? 'Starting anonymous session...' : 'Loading...'}

)} diff --git a/src/components/auth/ClaimDataModal.tsx b/src/components/auth/ClaimDataModal.tsx index c463bdd..54f80ec 100644 --- a/src/components/auth/ClaimDataModal.tsx +++ b/src/components/auth/ClaimDataModal.tsx @@ -1,16 +1,9 @@ 'use client'; -import { Fragment, useState } from 'react'; -import { - Dialog, - DialogPanel, - DialogTitle, - Transition, - TransitionChild, - Button, -} from '@headlessui/react'; +import { useState } from 'react'; import { useRouter } from 'next/navigation'; import toast from 'react-hot-toast'; +import { Button, ModalFrame, ModalTitle } from '@/components/ui'; export type ClaimableCounts = { documents: number; @@ -74,91 +67,48 @@ export default function ClaimDataModal({ }; return ( - - - + Existing Data Found + +

+ We found existing anonymous data from before auth was enabled. + Claim it now to attach it to your account. +

+ +
+
Claimable data
+
    +
  • {claimableCounts.documents} document(s)
  • +
  • {claimableCounts.audiobooks} audiobook(s)
  • +
  • {claimableCounts.preferences} preference set(s)
  • +
  • {claimableCounts.progress} reading progress record(s)
  • +
+
+ +

+ ⚠️ First user to claim this data will own it and revoke access for anyone else. +

+ +
+ - -
- -
-
-
- - + Dismiss + + +
+ ); } diff --git a/src/components/auth/RateLimitBanner.tsx b/src/components/auth/RateLimitBanner.tsx index 21a44f8..58975a1 100644 --- a/src/components/auth/RateLimitBanner.tsx +++ b/src/components/auth/RateLimitBanner.tsx @@ -19,13 +19,13 @@ export function RateLimitBanner({ className = '' }: RateLimitBannerProps) { const isAnonymous = status.userType === 'anonymous'; return ( -
+
- + Daily TTS limit reached. - + {`Used ${formatCharCount(status.currentCount)} / ${formatCharCount(status.limit)} characters.`} {' Resets in '}{timeUntilReset}. @@ -36,7 +36,7 @@ export function RateLimitBanner({ className = '' }: RateLimitBannerProps) { href="/signup" className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-md bg-accent text-background hover:bg-secondary-accent - transform transition-transform duration-200 hover:scale-[1.04]" + transform transition-transform duration-base" > Sign up for a higher limit @@ -64,7 +64,7 @@ export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) { if (isAtLimit) { return ( - + Limit reached ); @@ -72,7 +72,7 @@ export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) { if (isWarning) { return ( - + {formatCharCount(status.remainingChars)} chars left ); diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx index a30d419..d9e44ea 100644 --- a/src/components/auth/UserMenu.tsx +++ b/src/components/auth/UserMenu.tsx @@ -1,6 +1,5 @@ 'use client'; -import { Button } from '@headlessui/react'; import Link from 'next/link'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; @@ -8,6 +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 { ButtonLink, IconButton, SidebarNavItem, SidebarNavLink } from '@/components/ui'; type UserMenuVariant = 'toolbar' | 'sidebar'; @@ -31,21 +31,24 @@ export function UserMenu({ router.push('/signin'); }; - const rowClass = - 'w-full inline-flex items-center gap-2 px-2 py-1 rounded-md text-[12px] border border-transparent transition-all duration-200 ease-out text-left hover:scale-[1.01] hover:border-accent hover:bg-offbase hover:text-accent'; - if (!session || session.user.isAnonymous) { if (variant === 'sidebar') { return (
- - - Connect + + } + label="Connect" + /> {enableUserSignups && ( - - - Create account + + } + label="Create account" + /> )}
@@ -54,17 +57,13 @@ export function UserMenu({ return (
- - - + + Connect + {enableUserSignups && ( - - - + + Create account + )}
); @@ -72,40 +71,44 @@ export function UserMenu({ if (variant === 'sidebar') { return ( - + icon={} + label={session.user.email || 'Account'} + trailing={( + + + + + + )} + /> ); } return ( -
- +
+ {session.user.email || 'Account'} - +
); } diff --git a/src/components/doclist/CreateFolderDialog.tsx b/src/components/doclist/CreateFolderDialog.tsx index 69385b5..2e161c3 100644 --- a/src/components/doclist/CreateFolderDialog.tsx +++ b/src/components/doclist/CreateFolderDialog.tsx @@ -1,5 +1,5 @@ -import { Fragment, KeyboardEvent } from 'react'; -import { Dialog, DialogPanel, DialogTitle, Input, Transition, TransitionChild } from '@headlessui/react'; +import { KeyboardEvent } from 'react'; +import { Input, ModalFrame, ModalTitle } from '@/components/ui'; interface CreateFolderDialogProps { isOpen: boolean; @@ -17,52 +17,20 @@ export function CreateFolderDialog({ onClose, }: CreateFolderDialogProps) { return ( - - - -
- - -
-
- - - - Create New Folder - -
- onFolderNameChange(e.target.value)} - onKeyDown={onKeyDown} - placeholder="Enter folder name" - className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-1 focus:ring-accent" - autoFocus - /> -

Press Enter to create or Escape to cancel

-
-
-
-
-
-
-
+ + Create New Folder +
+ onFolderNameChange(e.target.value)} + onKeyDown={onKeyDown} + placeholder="Enter folder name" + controlSize="lg" + autoFocus + /> +

Press Enter to create or Escape to cancel

+
+
); -} \ No newline at end of file +} diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx index 77dfd36..b854554 100644 --- a/src/components/doclist/DocumentList.tsx +++ b/src/components/doclist/DocumentList.tsx @@ -22,6 +22,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'; import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog'; import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton'; import { DocumentUploader, type UploadBatchState } from '@/components/documents/DocumentUploader'; +import { IconButton } from '@/components/ui'; import { DocumentDndProvider } from './dnd/DocumentDndProvider'; import { DocumentSelectionProvider, @@ -139,14 +140,14 @@ function SidebarUploadLoader({ const dashOffset = circumference - (progress / 100) * circumference; return ( -
+
{label} - {completedFiles}/{totalFiles} + {completedFiles}/{totalFiles}
- {progress}% + {progress}%
{currentFileName && ( -

+

{currentFileName}

)} @@ -182,6 +183,16 @@ function SidebarUploadLoader({ ); } +function DocumentListStateLoader() { + return ( +
+ ); +} + function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { const cachedState = cachedDocumentListState; const [sortBy, setSortBy] = useState(cachedState?.sortBy ?? DEFAULT_STATE.sortBy); @@ -649,28 +660,32 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { }} > {!isLoading && showHint && allDocuments.length > 1 && ( -
-
+
+

Drag files onto each other to make folders. Drop into the sidebar to move.

- +
)} {isLoading ? (
- + {isInitialized ? ( + + ) : ( + + )}
) : allDocuments.length === 0 ? (
diff --git a/src/components/doclist/DocumentListSkeleton.tsx b/src/components/doclist/DocumentListSkeleton.tsx index e717f40..b1a6708 100644 --- a/src/components/doclist/DocumentListSkeleton.tsx +++ b/src/components/doclist/DocumentListSkeleton.tsx @@ -15,11 +15,11 @@ function IconsSkeleton({ iconSize }: { iconSize: IconSize }) {
{Array.from({ length: ICON_SKELETON_ITEM_COUNT }).map((_, index) => ( -
-
+
+
-
-
+
+
))} @@ -31,18 +31,18 @@ function IconsSkeleton({ iconSize }: { iconSize: IconSize }) { function ListSkeleton() { return (
-
+
-
+
-
+
-
+
-
+
@@ -50,23 +50,23 @@ function ListSkeleton() { {Array.from({ length: 10 }).map((_, index) => (
-
-
+
+
-
+
-
+
-
+
-
+
))} @@ -78,24 +78,24 @@ function ListSkeleton() { function GallerySkeleton() { return (
-
+
-
-
-
+
+
+
-
-
+
+
-
+
{Array.from({ length: 10 }).map((_, index) => ( -
-
+
+
-
+
))} diff --git a/src/components/doclist/DocumentPreview.tsx b/src/components/doclist/DocumentPreview.tsx index 7f23165..2d0fc08 100644 --- a/src/components/doclist/DocumentPreview.tsx +++ b/src/components/doclist/DocumentPreview.tsx @@ -243,7 +243,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { return (
{imagePreview ? ( <> @@ -261,7 +261,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { ref={imageRef} src={imagePreview} alt={`${doc.name} preview`} - className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-150 ${isImageReady ? 'opacity-100' : 'opacity-0'}`} + className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-fast ${isImageReady ? 'opacity-100' : 'opacity-0'}`} draggable={false} loading="lazy" onLoad={() => { @@ -285,16 +285,16 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { ) : textPreview ? ( <> -
+
-
+
{isTxtFile ? ( -
+                
                   {textPreview}
                 
) : ( -
+
{children}, img: () => null, blockquote: (props) => ( -
+
), code: (props) => ( ), diff --git a/src/components/doclist/views/DocumentTile.tsx b/src/components/doclist/views/DocumentTile.tsx index 467847b..e74cabc 100644 --- a/src/components/doclist/views/DocumentTile.tsx +++ b/src/components/doclist/views/DocumentTile.tsx @@ -2,10 +2,10 @@ import Link from 'next/link'; import { useDrag, useDrop, type DragSourceMonitor } from 'react-dnd'; -import { Button } from '@headlessui/react'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import type { DocumentListDocument, IconSize } from '@/types/documents'; import { DocumentPreview } from '@/components/doclist/DocumentPreview'; +import { IconButton } from '@/components/ui'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes'; @@ -138,11 +138,11 @@ export function DocumentTile({ data-doc-tile aria-selected={isSelected} className={ - 'group relative flex flex-col rounded-md overflow-hidden border transition-all duration-200 ease-out hover:scale-[1.01] ' + + 'group relative flex flex-col rounded-md overflow-hidden border transition duration-base ease-standard ' + (isSelected - ? 'border-accent bg-offbase' - : 'border-offbase bg-base hover:bg-offbase hover:border-accent') + - (isDropTarget ? ' ring-1 ring-accent' : '') + + ? 'border-accent-line bg-surface-sunken' + : 'border-line bg-surface hover:bg-accent-wash hover:border-accent-line') + + (isDropTarget ? ' ring-1 ring-accent-line' : '') + (isDragging ? ' opacity-50' : '') } > @@ -166,11 +166,11 @@ export function DocumentTile({ > {doc.type === 'pdf' ? ( - + ) : doc.type === 'epub' ? ( - + ) : ( - + )} {showDeleteButton && ( - + )}
diff --git a/src/components/doclist/views/GalleryView.tsx b/src/components/doclist/views/GalleryView.tsx index ed6d1bf..a047b63 100644 --- a/src/components/doclist/views/GalleryView.tsx +++ b/src/components/doclist/views/GalleryView.tsx @@ -1,13 +1,12 @@ 'use client'; -import Link from 'next/link'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useDrag, useDrop } from 'react-dnd'; 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/buttonPrimitives'; +import { Button, ButtonLink } from '@/components/ui'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes'; @@ -38,9 +37,9 @@ function formatParseStatus(status: DocumentListDocument['parseStatus']): string } function KindIcon({ doc, className }: { doc: DocumentListDocument; className?: string }) { - if (doc.type === 'pdf') return ; - if (doc.type === 'epub') return ; - return ; + if (doc.type === 'pdf') return ; + if (doc.type === 'epub') return ; + return ; } function GalleryThumb({ @@ -92,34 +91,34 @@ function GalleryThumb({ onClick={onClick} aria-current={active ? 'true' : undefined} className={ - 'group relative w-[98px] sm:w-[110px] shrink-0 cursor-pointer rounded-lg overflow-hidden border bg-base snap-start transition-all duration-200 ease-out ' + + 'group relative w-[98px] sm:w-[110px] shrink-0 cursor-pointer rounded-lg overflow-hidden border bg-surface snap-start transition duration-base ease-standard ' + (active - ? 'border-accent shadow-[0_10px_24px_-18px_rgba(0,0,0,0.85)] -translate-y-0.5' - : 'border-offbase hover:border-accent hover:-translate-y-0.5') + - (isOver && canDrop ? ' border-accent' : '') + + ? 'border-accent-line shadow-elev-2 -translate-y-px' + : 'border-line hover:border-accent-line hover:-translate-y-px hover:shadow-elev-2') + + (isOver && canDrop ? ' border-accent-line' : '') + (isDragging ? ' opacity-50' : '') } title={doc.name} > -
+
@@ -181,51 +180,48 @@ export function GalleryView({ return (
-
+
{activeDoc ? (
-
+

{activeDoc.name}

-

+

{activeDoc.type.toUpperCase()} • {formatDocumentSize(activeDoc.size)}

- + Open - - +
-
-
Type
+
+
Type
{activeDoc.type}
-
Size
+
Size
{formatDocumentSize(activeDoc.size)}
-
Last opened
+
Last opened
{formatDateTime(activeDoc.recentlyOpenedAt)}
-
Last modified
+
Last modified
{formatDateTime(activeDoc.lastModified)}
{activeDoc.folderId && ( <> -
Folder
+
Folder
{folderNameById?.[activeDoc.folderId] ?? activeDoc.folderId}
@@ -233,19 +229,19 @@ export function GalleryView({ )} {activeDoc.type === 'pdf' && ( <> -
Parse status
+
Parse status
{formatParseStatus(activeDoc.parseStatus)}
)}
) : ( -

No documents to show

+

No documents to show

)}
-
+
; - if (doc.type === 'epub') return ; - return ; + if (doc.type === 'pdf') return ; + if (doc.type === 'epub') return ; + return ; } function HeaderCell({ @@ -62,8 +62,8 @@ function HeaderCell({ onSortChange(field, nextDir); }} className={ - 'flex items-center gap-1 px-2 py-1.5 text-[10px] uppercase tracking-wide font-semibold transition-colors duration-200 ease-out hover:text-accent ' + - (active ? 'text-accent' : 'text-muted') + + 'flex items-center gap-1 px-2 py-1.5 text-[10px] uppercase tracking-wide font-semibold transition-colors duration-base ease-standard hover:text-accent ' + + (active ? 'text-accent' : 'text-soft') + (align === 'right' ? ' justify-end' : '') + ' ' + (className ?? '') @@ -131,11 +131,11 @@ function DocRow({ data-doc-tile aria-selected={isSelected} className={ - 'grid grid-cols-[minmax(0,1fr)_44px_72px_104px_28px] sm:grid-cols-[minmax(0,1fr)_56px_96px_140px_32px] items-center text-[12px] border-b border-offbase transition-colors duration-200 ease-out ' + + 'grid grid-cols-[minmax(0,1fr)_44px_72px_104px_28px] sm:grid-cols-[minmax(0,1fr)_56px_96px_140px_32px] items-center text-[12px] border-b border-line-soft transition-colors duration-base ease-standard ' + (isSelected - ? 'bg-offbase text-accent' - : 'text-foreground hover:bg-offbase') + - (isTarget ? ' ring-1 ring-accent ring-inset' : '') + + ? 'bg-surface-sunken text-accent' + : 'text-foreground hover:bg-accent-wash') + + (isTarget ? ' ring-1 ring-accent-line ring-inset' : '') + (isDragging ? ' opacity-50' : '') } > @@ -149,19 +149,19 @@ function DocRow({ {doc.name} - {doc.type} - + {doc.type} + {formatDocumentSize(doc.size)} - + {formatDate(doc.lastModified)} - +
); } @@ -198,7 +198,7 @@ export function ListView({ return (
-
+
diff --git a/src/components/doclist/window/FinderSidebar.tsx b/src/components/doclist/window/FinderSidebar.tsx index 0ecf12c..20ee1c3 100644 --- a/src/components/doclist/window/FinderSidebar.tsx +++ b/src/components/doclist/window/FinderSidebar.tsx @@ -1,12 +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, MenuActionItem, MenuItemsSurface, Sidebar as SidebarShell, SidebarNav, SidebarNavGroup, SidebarNavItem } from '@/components/ui'; interface FinderSidebarProps { filter: SidebarFilter; @@ -30,60 +31,6 @@ interface FinderSidebarProps { const MIN_WIDTH = 168; const MAX_WIDTH = 320; -interface SidebarRowProps { - active: boolean; - onClick: () => void; - icon: ReactNode; - label: string; - count?: number; - countClassName?: string; - trailing?: ReactNode; - isDropTarget?: boolean; -} - -function SidebarRow({ - active, - onClick, - icon, - label, - count, - countClassName, - trailing, - isDropTarget, -}: SidebarRowProps) { - return ( - - ); -} - function FolderRow({ folder, active, @@ -122,7 +69,8 @@ function FolderRow({ ref={dropRef as unknown as React.RefObject} className="group/folder relative" > - } @@ -131,42 +79,20 @@ function FolderRow({ countClassName="group-hover/folder:-translate-x-6 group-focus-within/folder:-translate-x-6" isDropTarget={isDropTarget} /> - -
- ); -} - -function SectionHeader({ - children, - isFirst, - rightSlot, -}: { - children: ReactNode; - isFirst?: boolean; - rightSlot?: ReactNode; -}) { - return ( -
- {children} - {rightSlot && {rightSlot}} +
); } @@ -204,19 +130,20 @@ export function FinderSidebar({ }; return ( - + ); } diff --git a/src/components/doclist/window/FinderStatusBar.tsx b/src/components/doclist/window/FinderStatusBar.tsx index acbd573..2e9c894 100644 --- a/src/components/doclist/window/FinderStatusBar.tsx +++ b/src/components/doclist/window/FinderStatusBar.tsx @@ -19,14 +19,14 @@ export function FinderStatusBar({
{summary} {selectedCount > 0 ? `${selectedCount} of ${itemCount} selected` : `${itemCount} item${itemCount === 1 ? '' : 's'}`} - + {formatDocumentSize(totalSize)}
diff --git a/src/components/doclist/window/FinderToolbar.tsx b/src/components/doclist/window/FinderToolbar.tsx index 4e57f18..e403754 100644 --- a/src/components/doclist/window/FinderToolbar.tsx +++ b/src/components/doclist/window/FinderToolbar.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'; +import { Listbox } from '@headlessui/react'; import type { IconSize, SortBy, SortDirection, ViewMode } from '@/types/documents'; import { IconsViewIcon, @@ -10,6 +10,7 @@ import { HamburgerIcon, } from './finderIcons'; import { ChevronUpDownIcon } from '@/components/icons/Icons'; +import { SearchField, SharedListboxButton, SharedListboxOption, SharedListboxOptions, Toolbar, ToolbarButton, ToolbarGroup, ToolbarSegment } from '@/components/ui'; import type { ReactNode } from 'react'; interface FinderToolbarProps { @@ -52,22 +53,6 @@ const ICON_SIZES: Array<{ value: IconSize; label: string }> = [ { value: 'xl', label: 'XL' }, ]; -// Match SettingsModal / UserMenu trigger sizing exactly so all bar buttons share one rhythm. -const TOOLBAR_BTN = - 'inline-flex items-center py-1 px-2 rounded-md border bg-base text-xs transition-all duration-200 ease-out hover:scale-[1.01]'; -const TOOLBAR_BTN_INACTIVE = - 'border-offbase text-foreground hover:text-accent hover:border-accent hover:bg-offbase'; -const TOOLBAR_BTN_ACTIVE = 'border-accent bg-offbase text-accent'; - -// Pill-grouped segmented control. Outer pill carries the border; inner segments are -// borderless and rely on bg/text color to show active/hover. Sized so the whole pill -// matches the height of a standalone TOOLBAR_BTN. -const PILL = 'inline-flex items-center rounded-md border border-offbase bg-base p-0.5 gap-0.5 shrink-0'; -const PILL_SEGMENT = - 'inline-flex items-center justify-center rounded-[5px] text-xs transition-colors duration-200 ease-out'; -const PILL_SEGMENT_INACTIVE = 'text-muted hover:bg-offbase hover:text-accent'; -const PILL_SEGMENT_ACTIVE = 'bg-offbase text-accent'; - export function FinderToolbar({ viewMode, onViewModeChange, @@ -89,26 +74,25 @@ export function FinderToolbar({ const directionLabel = sortDirection === 'asc' ? currentSort.asc : currentSort.desc; return ( -
-
+ {leftSlot && ( -
+
{leftSlot}
)} - + -
+ {VIEW_BUTTONS.map(({ value, label, Icon }) => { const active = viewMode === value; const isIconsToggle = value === 'icons'; @@ -117,110 +101,84 @@ export function FinderToolbar({ key={value} className={isIconsToggle ? 'relative group/icons inline-flex items-center' : 'inline-flex items-center'} > - + {isIconsToggle && viewMode === 'icons' && (
-
+ {ICON_SIZES.map(({ value: sizeValue, label: sizeLabel }) => { const sizeActive = iconSize === sizeValue; return ( - + ); })} -
+
)}
); })} -
+ {showSortControls && (
- + - + {currentSort.label} - - + + {SORT_OPTIONS.map((opt) => ( - - `cursor-pointer select-none rounded-sm py-1.5 px-2.5 text-xs ${ - active ? 'bg-offbase text-accent' : 'text-foreground' - } ${selected ? 'font-semibold' : ''}` - } + tone="compact" > {opt.label} - + ))} - +
)}
-
- - onQueryChange(e.target.value)} - placeholder="Search" - className="flex-1 min-w-0 bg-transparent outline-none text-xs text-foreground placeholder:text-muted" - /> -
+ onQueryChange(e.target.value)} + placeholder="Search" + className="hidden w-[160px] md:w-[200px] sm:flex" + icon={} + /> {rightSlot && ( -
+
{rightSlot}
)} -
-
+
); } diff --git a/src/components/doclist/window/FinderWindow.tsx b/src/components/doclist/window/FinderWindow.tsx index be5f157..2c10050 100644 --- a/src/components/doclist/window/FinderWindow.tsx +++ b/src/components/doclist/window/FinderWindow.tsx @@ -1,7 +1,7 @@ 'use client'; -import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'; -import { Fragment, useEffect, useState, type ReactNode } from 'react'; +import type { ReactNode } from 'react'; +import { LibraryFrame, useIsNarrow } from '@/components/layout'; interface FinderWindowProps { toolbar: ReactNode; @@ -14,22 +14,7 @@ interface FinderWindowProps { onRequestSidebarClose?: () => void; } -const NARROW_QUERY = '(max-width: 767px)'; - -export function useIsNarrow(): boolean { - const [isNarrow, setIsNarrow] = useState(() => - typeof window !== 'undefined' ? window.matchMedia(NARROW_QUERY).matches : false, - ); - useEffect(() => { - if (typeof window === 'undefined') return; - const mq = window.matchMedia(NARROW_QUERY); - const update = () => setIsNarrow(mq.matches); - update(); - mq.addEventListener('change', update); - return () => mq.removeEventListener('change', update); - }, []); - return isNarrow; -} +export { useIsNarrow }; /** * Finder-style file pane: sidebar + toolbar + content + status bar. @@ -43,60 +28,15 @@ export function FinderWindow({ sidebarOpen, onRequestSidebarClose, }: FinderWindowProps) { - const isNarrow = useIsNarrow(); - return ( -
- {toolbar} - -
- {!isNarrow && sidebarOpen && ( -
{sidebar}
- )} - -
- {children} -
-
- - {statusBar} - - {/* Mobile drawer */} - - undefined)} - className="relative z-40 md:hidden" - > - -
- -
- - - {sidebar} - - -
-
-
-
+ + {children} + ); } diff --git a/src/components/documents/DexieMigrationModal.tsx b/src/components/documents/DexieMigrationModal.tsx index be7bd30..df469d1 100644 --- a/src/components/documents/DexieMigrationModal.tsx +++ b/src/components/documents/DexieMigrationModal.tsx @@ -1,12 +1,12 @@ 'use client'; -import { Fragment, useCallback, useEffect, useState } from 'react'; -import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Button } from '@headlessui/react'; +import { useCallback, useEffect, useState } from 'react'; import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie'; import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents'; import { useDocuments } from '@/contexts/DocumentContext'; import type { BaseDocument } from '@/types/documents'; import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents'; +import { Button, ModalFrame, ModalTitle } from '@/components/ui'; type DexieMigrationModalProps = { isOpen: boolean; @@ -133,87 +133,54 @@ export function DexieMigrationModal({ } }, [loadLocalDexieDocs, onComplete, refreshDocuments]); - if (!isOpen) return null; - return ( - - (closeDisabled ? null : onComplete())}> - -
- - -
-
- - - - {title} - -
-

- Found {localCount} document{localCount === 1 ? '' : 's'} stored locally from an older version. - {displayMissingCount > 0 ? ( - <> {displayMissingCount} {displayMissingCount === 1 ? 'is' : 'are'} not here yet. - ) : null} - {' '}This app now stores documents on the server and keeps a local cache for speed. -

- {isUploading && ( -
-

{status}

-
-
-
-
- )} - {!isUploading && status ?

{status}

: null} -
- -
- - -
- - + { + if (!closeDisabled) onComplete(); + }} + panelTestId="migration-modal" + className="z-[80]" + > + {title} +
+

+ Found {localCount} document{localCount === 1 ? '' : 's'} stored locally from an older version. + {displayMissingCount > 0 ? ( + <> {displayMissingCount} {displayMissingCount === 1 ? 'is' : 'are'} not here yet. + ) : null} + {' '}This app now stores documents on the server and keeps a local cache for speed. +

+ {isUploading && ( +
+

{status}

+
+
+
-
-
-
+ )} + {!isUploading && status ?

{status}

: null} +
+ +
+ + +
+ ); } diff --git a/src/components/documents/DocumentHeaderMenu.tsx b/src/components/documents/DocumentHeaderMenu.tsx index fc12776..b44a479 100644 --- a/src/components/documents/DocumentHeaderMenu.tsx +++ b/src/components/documents/DocumentHeaderMenu.tsx @@ -1,10 +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, MenuActionItem, MenuItemsSurface, ToolbarButton } from '@/components/ui'; interface DocumentHeaderMenuProps { zoomLevel: number; @@ -47,45 +48,33 @@ export function DocumentHeaderMenu({ max={maxZoom} /> {onOpenSegments && ( - + + )} {showAudiobookExport && onOpenAudiobook && ( - + + )} - + +
); @@ -95,35 +84,30 @@ export function DocumentHeaderMenu({
- + - + {/* Zoom Controls Section */}
-

Zoom / Padding

+

Zoom / Padding

{ - // We wrap in a handler to stop propagation if needed, - // but ZoomControl buttons handle their own clicks. - // However, Menu might close on click? - // Headless UI Menu closes on click inside MenuItem, but these are just buttons in a div. - // It should NOT close unless we click a MenuItem. - onZoomIncrease(); - }} + onIncrease={onZoomIncrease} onDecrease={onZoomDecrease} min={minZoom} max={maxZoom} @@ -134,52 +118,28 @@ export function DocumentHeaderMenu({ {/* Actions Section */}
{onOpenSegments && ( - - {({ active }) => ( - - )} - + + + {isSegmentsOpen ? 'Hide Segments' : 'Segments'} + )} {showAudiobookExport && onOpenAudiobook && ( - - {({ active }) => ( - - )} - + + + {isAudiobookOpen ? 'Hide Audiobook' : 'Export Audiobook'} + )} - - {({ active }) => ( - - )} - + + + {isSettingsOpen ? 'Hide Settings' : 'Settings'} +
{/* Auth Section */} -
+
- +
diff --git a/src/components/documents/DocumentSelectionModal.tsx b/src/components/documents/DocumentSelectionModal.tsx index 9d6faa4..a89b152 100644 --- a/src/components/documents/DocumentSelectionModal.tsx +++ b/src/components/documents/DocumentSelectionModal.tsx @@ -1,9 +1,8 @@ 'use client'; -import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; -import { Fragment, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { BaseDocument } from '@/types/documents'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { Button, ModalFrame, ModalTitle } from '@/components/ui'; interface DocumentSelectionModalProps { isOpen: boolean; @@ -114,120 +113,83 @@ export function DocumentSelectionModal({ }; return ( - - - -
- - -
-
- - - - {title} - {files.length > 0 && ( -
- -
- )} -
- -
- {isLoading ? ( - - ) : errorMessage ? ( -
{errorMessage}
- ) : files.length === 0 ? ( -
No documents found.
- ) : ( -
- {files.map((file) => { - const isSelected = selectedIds.has(file.id); - return ( -
handleRowClick(e, file.id)} - className={`flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm select-none - ${isSelected ? 'bg-accent/10' : 'hover:bg-offbase'} - `} - > -
e.stopPropagation()}> - handleCheckboxChange(file.id, e.target.checked)} - className="rounded border-muted text-accent focus:ring-accent" - /> -
-
- {file.name} -
-
{formatSize(file.size)}
-
- ); - })} -
- )} -
- -
- - -
-
-
+ + + {title} + {files.length > 0 && ( +
+
-
-
-
+ )} + + +
+ {isLoading ? ( + + ) : errorMessage ? ( +
{errorMessage}
+ ) : files.length === 0 ? ( +
No documents found.
+ ) : ( +
+ {files.map((file) => { + const isSelected = selectedIds.has(file.id); + return ( +
handleRowClick(e, file.id)} + className={`flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm select-none ${ + isSelected ? 'bg-accent-wash' : 'hover:bg-accent-wash' + }`} + > +
e.stopPropagation()}> + handleCheckboxChange(file.id, e.target.checked)} + className="rounded border-muted text-accent focus:ring-accent-line" + /> +
+
+ {file.name} +
+
{formatSize(file.size)}
+
+ ); + })} +
+ )} +
+ +
+ + +
+ ); } @@ -237,9 +199,9 @@ function DocumentSelectionSkeleton() {
{rows.map((_, index) => (
-
-
-
+
+
+
))}
diff --git a/src/components/documents/DocumentSettings.tsx b/src/components/documents/DocumentSettings.tsx index 4dd1f9b..07442c2 100644 --- a/src/components/documents/DocumentSettings.tsx +++ b/src/components/documents/DocumentSettings.tsx @@ -15,8 +15,7 @@ import { clampSegmentPreloadSentenceLookahead, clampTtsSegmentMaxBlockLength, } from '@/types/config'; -import { Section, ToggleRow, CheckItem, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +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'; @@ -51,8 +50,6 @@ const viewTypeTextMapping = [ { id: 'scroll', name: 'Continuous Scroll' }, ]; -const rangeInputClassName = 'w-full bg-offbase rounded-md appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-md [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-md [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-3.5 [&::-moz-range-thumb]:w-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent'; - type RangeSettingProps = { label: string; value: number; @@ -80,14 +77,13 @@ function RangeSetting({
- onChange(Number(event.target.value))} - className={`flex-1 ${rangeInputClassName}`} + className="flex-1" /> {formatter(value)}
@@ -163,27 +159,13 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { >
-
- {viewTypeTextMapping.map((view) => { - const active = selectedView.id === view.id; - return ( - - ); - })} -
+ ({ value: view.id as ViewType, label: view.name }))} + onChange={(nextViewType) => updateConfigKey('viewType', nextViewType)} + ariaLabel="Page mode" + className="grid-cols-3" + /> {selectedView.id === 'scroll' ? (

Scroll mode may be slower on large PDFs.

) : null} @@ -214,20 +196,20 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { action={
- + PP-DocLayout-V3 {pdf.parseStatus ?? 'pending'} - +
} @@ -240,7 +222,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { disabled={pdf.parseStatus !== 'ready'} variant="flat" /> -
+
Skip Block Kinds While Reading Aloud diff --git a/src/components/documents/DocumentUploader.tsx b/src/components/documents/DocumentUploader.tsx index c5ce9fe..4f1a07c 100644 --- a/src/components/documents/DocumentUploader.tsx +++ b/src/components/documents/DocumentUploader.tsx @@ -6,6 +6,7 @@ import { UploadIcon } from '@/components/icons/Icons'; import { useDocuments } from '@/contexts/DocumentContext'; import { uploadDocxAsPdf } from '@/lib/client/api/documents'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; +import { dropzoneSurfaceClass } from '@/components/ui'; interface DocumentUploaderProps { className?: string; @@ -155,26 +156,7 @@ export function DocumentUploader({ noKeyboard: variant === 'overlay' }); - const containerBase = `group w-full rounded transform transition-all duration-200 ease-in-out ${ - variant === 'compact' ? 'hover:scale-[1.01]' : '' - } ${ - isUploading || isConverting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer' - } ${className}`; - - const borderBgClass = - variant === 'compact' - ? `${ - isDragActive - ? 'border border-accent bg-offbase text-accent' - : 'border border-dashed border-offbase text-foreground hover:border-accent hover:bg-offbase hover:text-accent' - }` - : `${ - isDragActive - ? 'border-2 border-dashed border-accent bg-base text-foreground' - : 'border-2 border-dashed border-muted bg-transparent text-foreground hover:border-accent hover:bg-base hover:scale-[1.01]' - }`; - - const paddingClass = variant === 'compact' ? 'py-1 px-2 rounded-md' : 'py-5 px-3 rounded-lg'; + const isDisabled = isUploading || isConverting; if (variant === 'overlay') { const rootProps = getRootProps(); @@ -183,19 +165,19 @@ export function DocumentUploader({ {children} {isDragActive && ( -
-
- +
+
+

Drop files here to upload

-

+

{enableDocx ? 'Accepts PDF, EPUB, TXT, MD, or DOCX' : 'Accepts PDF, EPUB, TXT, or MD'}

{error && ( -

+

Upload failed: {error} — try again.

)} @@ -203,7 +185,7 @@ export function DocumentUploader({
)} {!isDragActive && error && ( -
+
Upload failed: {error} — try again.
)} @@ -214,12 +196,17 @@ export function DocumentUploader({ return (
{variant === 'compact' ? (
- + {isUploading ? (

Uploading…

) : isConverting ? ( @@ -229,13 +216,13 @@ export function DocumentUploader({

{isDragActive ? 'Drop files here' : 'Upload documents'}

- {error &&

{error}

} + {error &&

{error}

}
)}
) : (
- + {isUploading ? (

Uploading file...

) : isConverting ? ( @@ -245,10 +232,10 @@ export function DocumentUploader({

{isDragActive ? 'Drop your file(s) here' : 'Drop your file(s) here, or click to select'}

-

+

{enableDocx ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'}

- {error &&

{error}

} + {error &&

{error}

} )}
diff --git a/src/components/documents/ZoomControl.tsx b/src/components/documents/ZoomControl.tsx index b0d6a04..5a65840 100644 --- a/src/components/documents/ZoomControl.tsx +++ b/src/components/documents/ZoomControl.tsx @@ -1,4 +1,4 @@ -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { IconButton } from '@/components/ui'; export function ZoomControl({ value, @@ -15,33 +15,25 @@ export function ZoomControl({ }) { return (
- - {value}% - +
); } diff --git a/src/components/formPrimitives.tsx b/src/components/formPrimitives.tsx deleted file mode 100644 index 687fb7a..0000000 --- a/src/components/formPrimitives.tsx +++ /dev/null @@ -1,301 +0,0 @@ -'use client'; - -import { useId, type ReactNode } from 'react'; -import { - btnBase, - btnDanger, - btnGhost, - btnOutline, - btnPrimary, - btnSecondary, - buttonClass, - type ButtonSize, - type ButtonVariant, -} from '@/components/ui/buttonPrimitives'; - -/** - * Shared compact form primitives used by settings-like surfaces across - * the app (settings modal, document settings, and admin panels). - */ - -export { - btnBase, - btnDanger, - btnGhost, - btnOutline, - btnPrimary, - btnSecondary, - buttonClass, - type ButtonSize, - type ButtonVariant, -}; - -// Inputs use `bg-base` so they remain visible regardless of whether the -// surrounding container is `bg-background` (Card) or `bg-base` (Section). -// Using the same `bg-background` as the Card would make the input blend in. -// (Note: never use Tailwind alpha modifiers on these theme variables — they -// resolve to CSS custom properties that don't accept opacity suffixes.) -export const inputClass = - 'w-full rounded-md bg-background border border-offbase px-2.5 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent'; - -export const listboxButtonClass = - 'relative w-full cursor-pointer rounded-md bg-background border border-offbase py-1.5 pl-2.5 pr-9 text-left text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent hover:bg-base transition-transform duration-200 ease-out hover:scale-[1.01]'; -export const listboxOptionsClass = - 'z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain rounded-md bg-background p-1 shadow-lg ring-1 ring-offbase focus:outline-none [--anchor-gap:0.25rem]'; -export const listboxOptionClass = (active: boolean) => - `relative cursor-pointer select-none rounded-sm py-1.5 pl-9 pr-3 text-sm ${active ? 'bg-offbase text-foreground' : 'text-foreground'}`; - -export const segmentedGroupClass = - 'grid gap-1 rounded-full border border-offbase bg-background p-1'; -export const segmentedButtonClass = (active: boolean) => - `rounded-full px-2.5 py-1.5 text-xs font-medium transition-colors transition-transform duration-200 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${ - active - ? 'bg-accent text-background hover:scale-[1.01]' - : 'text-muted hover:bg-base hover:text-foreground hover:scale-[1.02]' - }`; - -export function Section({ - title, - subtitle, - children, - action, - variant = 'panel', -}: { - title: string; - subtitle?: ReactNode; - children: ReactNode; - action?: ReactNode; - variant?: 'panel' | 'flat'; -}) { - if (variant === 'flat') { - return ( -
-
-
-

{title}

- {subtitle ?

{subtitle}

: null} -
- {action ?
{action}
: null} -
- {children} -
- ); - } - - return ( -
-
-
-
-

{title}

- {subtitle ?

{subtitle}

: null} -
- {action ?
{action}
: null} -
-
-
- {children} -
-
- ); -} - -export function Card({ - children, - className = '', -}: { - children: ReactNode; - className?: string; -}) { - return ( -
- {children} -
- ); -} - -export type SwitchSize = 'sm' | 'md'; - -const SWITCH_SIZE: Record = { - sm: { - track: 'h-4 w-7', - thumb: 'h-3 w-3', - on: 'translate-x-3', - off: 'translate-x-0.5', - }, - md: { - track: 'h-5 w-9', - thumb: 'h-4 w-4', - on: 'translate-x-4', - off: 'translate-x-0.5', - }, -}; - -export function Switch({ - checked, - onChange, - disabled = false, - size = 'md', - ariaLabel, - ariaLabelledBy, - ariaDescribedBy, -}: { - checked: boolean; - onChange: (checked: boolean) => void; - disabled?: boolean; - size?: SwitchSize; - ariaLabel?: string; - ariaLabelledBy?: string; - ariaDescribedBy?: string; -}) { - const s = SWITCH_SIZE[size]; - return ( - - ); -} - -export function ToggleRow({ - label, - description, - checked, - onChange, - disabled = false, - right, - variant = 'card', -}: { - label: string; - description: string; - checked: boolean; - onChange: (checked: boolean) => void; - disabled?: boolean; - right?: ReactNode; - variant?: 'card' | 'flat'; -}) { - const labelId = useId(); - const descId = useId(); - const rowClass = - variant === 'flat' - ? 'px-0.5 pt-1 pb-2 border-b border-offbase last:border-b-0 transition-transform duration-200 ease-out hover:scale-[1.003]' - : 'rounded-md border border-offbase bg-background px-2.5 py-1.5 transition-transform duration-200 ease-out hover:scale-[1.005]'; - const handleTextToggle = () => { - if (!disabled) onChange(!checked); - }; - return ( -
-
-
- {label} - {description} -
- {right ?
{right}
: null} - -
-
- ); -} - -export function CheckItem({ - label, - checked, - onChange, - disabled = false, -}: { - label: string; - checked: boolean; - onChange: (checked: boolean) => void; - disabled?: boolean; -}) { - const labelId = useId(); - const handleTextToggle = () => { - if (!disabled) onChange(!checked); - }; - return ( -
- - {label} - - -
- ); -} - -export function Field({ - label, - hint, - className = '', - children, -}: { - label?: string; - hint?: string; - className?: string; - children: ReactNode; -}) { - return ( -
- {label ? : null} - {children} - {hint ?

{hint}

: null} -
- ); -} - -export function Badge({ - tone = 'muted', - children, -}: { - tone?: 'muted' | 'accent' | 'foreground'; - children: ReactNode; -}) { - const toneClass = - tone === 'accent' - ? 'text-accent' - : tone === 'foreground' - ? 'text-foreground bg-offbase' - : 'text-muted bg-offbase'; - return ( - - {children} - - ); -} diff --git a/src/components/icons/Icons.tsx b/src/components/icons/Icons.tsx index 74c6e8f..eef3940 100644 --- a/src/components/icons/Icons.tsx +++ b/src/components/icons/Icons.tsx @@ -246,7 +246,7 @@ export function GithubIcon(props: React.SVGProps) { export function PDFIcon(props: React.SVGProps) { return ( ) { export function EPUBIcon(props: React.SVGProps) { return ( ) { viewBox="0 0 24 24" fill="none" stroke="currentColor" - className={props.className || "w-8 h-8 text-muted"} + className={props.className || "w-8 h-8 text-soft"} {...props} > diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx new file mode 100644 index 0000000..7edc198 --- /dev/null +++ b/src/components/layout/AppHeader.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react'; +import { cn } from '@/components/ui/cn'; + +export function AppHeader({ + left, + title, + right, + className, +}: { + left?: ReactNode; + title?: ReactNode; + right?: ReactNode; + className?: string; +}) { + return ( +
+
+
+ {left} + {typeof title === 'string' ? ( +

{title}

+ ) : ( + title + )} +
+
{right}
+
+
+ ); +} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..f636d11 --- /dev/null +++ b/src/components/layout/AppShell.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; +import { cn } from '@/components/ui/cn'; + +export function AppShell({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} + +export function AppMain({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
; +} diff --git a/src/components/layout/LibraryFrame.tsx b/src/components/layout/LibraryFrame.tsx new file mode 100644 index 0000000..544c271 --- /dev/null +++ b/src/components/layout/LibraryFrame.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { useEffect, useState, type ReactNode } from 'react'; +import { cn } from '@/components/ui/cn'; +import { DrawerFrame } from '@/components/ui'; + +const NARROW_QUERY = '(max-width: 767px)'; + +export function useIsNarrow(): boolean { + const [isNarrow, setIsNarrow] = useState(false); + useEffect(() => { + if (typeof window === 'undefined') return; + const mq = window.matchMedia(NARROW_QUERY); + const update = () => setIsNarrow(mq.matches); + update(); + mq.addEventListener('change', update); + return () => mq.removeEventListener('change', update); + }, []); + return isNarrow; +} + +export function LibraryFrame({ + toolbar, + sidebar, + statusBar, + children, + sidebarOpen, + onRequestSidebarClose, + className, +}: { + toolbar: ReactNode; + sidebar: ReactNode; + statusBar: ReactNode; + children: ReactNode; + sidebarOpen: boolean; + onRequestSidebarClose?: () => void; + className?: string; +}) { + const isNarrow = useIsNarrow(); + + return ( +
+ {toolbar} +
+ {!isNarrow && sidebarOpen &&
{sidebar}
} +
{children}
+
+ {statusBar} + {isNarrow && ( + + {sidebar} + + )} +
+ ); +} + +function LibrarySidebarDrawer({ + open, + onClose, + children, +}: { + open: boolean; + onClose?: () => void; + children: ReactNode; +}) { + return ( +
+ undefined)} side="left" className="w-[80vw] max-w-[280px]"> + {children} + +
+ ); +} diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts new file mode 100644 index 0000000..2c5bdf6 --- /dev/null +++ b/src/components/layout/index.ts @@ -0,0 +1,3 @@ +export * from './AppHeader'; +export * from './AppShell'; +export * from './LibraryFrame'; diff --git a/src/components/player/Navigator.tsx b/src/components/player/Navigator.tsx index dfbcceb..6a7a074 100644 --- a/src/components/player/Navigator.tsx +++ b/src/components/player/Navigator.tsx @@ -1,7 +1,8 @@ 'use client'; -import { Button, Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; +import { Popover } from '@headlessui/react'; import { useState, useEffect, useRef } from 'react'; +import { IconButton, Input, PopoverSurface, PopoverTrigger } from '@/components/ui'; export const Navigator = ({ currentPage, numPages, skipToLocation }: { currentPage: number; @@ -53,36 +54,34 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: { return (
{/* Page back */} - + {/* Page number popup */} - +

{currentPage} / {numPages || 1}

-
- + +
Go to page
- -
of {numPages || 1}
+
of {numPages || 1}
-
+
{/* Page forward */} - +
); } diff --git a/src/components/player/RateLimitPauseButton.tsx b/src/components/player/RateLimitPauseButton.tsx index 3194ace..7f4a9e5 100644 --- a/src/components/player/RateLimitPauseButton.tsx +++ b/src/components/player/RateLimitPauseButton.tsx @@ -1,8 +1,8 @@ 'use client'; -import { Button } from '@headlessui/react'; import { PauseIcon } from '@/components/icons/Icons'; import { useTTS } from '@/contexts/TTSContext'; +import { IconButton } from '@/components/ui'; export function RateLimitPauseButton() { const { isPlaying, togglePlay } = useTTS(); @@ -12,14 +12,13 @@ export function RateLimitPauseButton() { if (!isPlaying) return null; return ( - + ); } diff --git a/src/components/player/SpeedControl.tsx b/src/components/player/SpeedControl.tsx index 2966a91..516d001 100644 --- a/src/components/player/SpeedControl.tsx +++ b/src/components/player/SpeedControl.tsx @@ -1,10 +1,11 @@ 'use client'; -import { Input, 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 { PopoverSurface, PopoverTrigger, RangeInput } from '@/components/ui'; export const SpeedControl = ({ setSpeedAndRestart, @@ -88,16 +89,16 @@ export const SpeedControl = ({ return ( - + {compactTriggerLabel} {triggerLabel} - - + +
{!nativeSpeedSupported && ( -
+
Native model speed is not available for this model.
)} @@ -112,8 +113,7 @@ export const SpeedControl = ({ {max.toFixed(1)}x
-
)} @@ -136,8 +135,7 @@ export const SpeedControl = ({ {max.toFixed(1)}x
-
- + ); }; diff --git a/src/components/player/TTSPlayer.tsx b/src/components/player/TTSPlayer.tsx index 2689747..94dea14 100644 --- a/src/components/player/TTSPlayer.tsx +++ b/src/components/player/TTSPlayer.tsx @@ -1,7 +1,6 @@ 'use client'; import { useTTS } from '@/contexts/TTSContext'; -import { Button } from '@headlessui/react'; import { PlayIcon, PauseIcon, @@ -12,6 +11,7 @@ import { LoadingSpinner } from '@/components/Spinner'; import { VoicesControl } from '@/components/player/VoicesControl'; import { SpeedControl } from '@/components/player/SpeedControl'; import { Navigator } from '@/components/player/Navigator'; +import { IconButton } from '@/components/ui'; export default function TTSPlayer({ currentPage, numPages }: { currentPage?: number; @@ -31,7 +31,7 @@ export default function TTSPlayer({ currentPage, numPages }: { } = useTTS(); return ( -
+
{/* Speed control */} {isProcessing ? : } - + - + - + {/* Voice control */} diff --git a/src/components/player/VoicesControlBase.tsx b/src/components/player/VoicesControlBase.tsx index a94aa32..fd2cad9 100644 --- a/src/components/player/VoicesControlBase.tsx +++ b/src/components/player/VoicesControlBase.tsx @@ -2,15 +2,13 @@ import { Listbox, - ListboxButton, - ListboxOption, - ListboxOptions, } from '@headlessui/react'; import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons'; 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 } from '@/components/ui'; export function VoicesControlBase({ availableVoices, @@ -34,15 +32,16 @@ export function VoicesControlBase({ : 'bottom-full right-0 mb-1'; const buttonClass = variant === 'field' - ? 'relative cursor-pointer rounded-lg bg-base py-1.5 pl-3 pr-10 text-left text-foreground focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent w-full' - : 'flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent'; + ? 'bg-surface pr-10' + : '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' : 'h-3 w-3 sm:h-3.5 sm:w-3.5'; const chevronClass = variant === 'field' - ? 'h-4 w-4 text-muted' + ? 'h-4 w-4 text-soft' : 'h-2.5 w-2.5 sm:h-3 sm:w-3'; const providerModelPolicy = resolveTtsProviderModelPolicy({ @@ -85,7 +84,7 @@ export function VoicesControlBase({ if (availableVoices.length === 0) { return (
-
+
No voices
@@ -121,7 +120,7 @@ export function VoicesControlBase({ } }} > - + {variant === 'field' ? ( <> @@ -139,15 +138,14 @@ export function VoicesControlBase({ )} - - + + {availableVoices.map((voiceId) => ( - - `relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 flex items-center gap-2 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}` - } + inset="none" + itemClassName="flex items-center gap-2 py-1 sm:py-2" > {({ selected }) => ( <> @@ -159,13 +157,13 @@ export function VoicesControlBase({ {voiceId} )} - + ))} - + ) : ( - + {variant === 'field' ? ( <> @@ -183,20 +181,19 @@ export function VoicesControlBase({ )} - - + + {availableVoices.map((voiceId) => ( - - `relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}` - } + inset="none" + itemClassName="py-1 sm:py-2" > {voiceId} - + ))} - + )}
diff --git a/src/components/reader/ReaderSidebarShell.tsx b/src/components/reader/ReaderSidebarShell.tsx index 691762b..89a5ae3 100644 --- a/src/components/reader/ReaderSidebarShell.tsx +++ b/src/components/reader/ReaderSidebarShell.tsx @@ -4,7 +4,7 @@ import { Fragment, type ReactNode } from 'react'; import { Transition } from '@headlessui/react'; import { XCircleIcon } from '@/components/icons/Icons'; import { useReaderSidebarBounds } from '@/hooks/useReaderSidebarBounds'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { IconButton } from '@/components/ui'; interface ReaderSidebarShellProps { isOpen: boolean; @@ -41,10 +41,10 @@ export function ReaderSidebarShell({ > @@ -58,38 +58,34 @@ export function ReaderSidebarShell({