diff --git a/src/app/(app)/signin/page.tsx b/src/app/(app)/signin/page.tsx index 288b46d..4a26650 100644 --- a/src/app/(app)/signin/page.tsx +++ b/src/app/(app)/signin/page.tsx @@ -9,6 +9,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext import { showPrivacyModal } from '@/components/PrivacyModal'; import { GithubIcon } from '@/components/icons/Icons'; import { LoadingSpinner } from '@/components/Spinner'; +import { buttonClass } from '@/components/formPrimitives'; function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) { const searchParams = useSearchParams(); @@ -196,10 +197,7 @@ function SignInContent() { type="submit" disabled={isAnyLoading} onClick={handleSignIn} - className="w-full rounded-lg bg-accent py-2 text-sm font-medium text-background - hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent - focus:ring-offset-2 disabled:opacity-50 transform transition-transform - duration-200 hover:scale-[1.02]" + className={buttonClass({ variant: 'primary', size: 'md', className: 'w-full hover:scale-[1.02]' })} > {loadingEmail ? : 'Connect'} @@ -210,11 +208,11 @@ function SignInContent() { type="button" disabled={isAnyLoading} onClick={handleGithubSignIn} - className="w-full rounded-lg bg-background py-2 text-sm font-medium text-foreground - hover:bg-offbase focus:outline-none focus:ring-2 focus:ring-accent - focus:ring-offset-2 disabled:opacity-50 border border-offbase - transform transition-transform duration-200 hover:scale-[1.02] - flex items-center justify-center gap-2" + className={buttonClass({ + variant: 'outline', + size: 'md', + className: 'w-full hover:scale-[1.02] flex items-center justify-center gap-2', + })} > {loadingGithub ? ( @@ -233,10 +231,7 @@ function SignInContent() { type="button" disabled={isAnyLoading} onClick={handleAnonymousContinue} - className="w-full rounded-lg bg-background py-2 text-sm font-medium text-foreground - hover:bg-offbase focus:outline-none focus:ring-2 focus:ring-accent - focus:ring-offset-2 disabled:opacity-50 border border-offbase - transform transition-transform duration-200 hover:scale-[1.02]" + className={buttonClass({ variant: 'outline', size: 'md', className: 'w-full hover:scale-[1.02]' })} > {loadingAnonymous ? : 'Continue anonymously'} diff --git a/src/app/(app)/signup/page.tsx b/src/app/(app)/signup/page.tsx index fafed41..9dfc205 100644 --- a/src/app/(app)/signup/page.tsx +++ b/src/app/(app)/signup/page.tsx @@ -8,6 +8,7 @@ import { getAuthClient } from '@/lib/client/auth-client'; import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { showPrivacyModal } from '@/components/PrivacyModal'; import { LoadingSpinner } from '@/components/Spinner'; +import { buttonClass } from '@/components/formPrimitives'; import toast from 'react-hot-toast'; export default function SignUpPage() { @@ -151,7 +152,11 @@ export default function SignUpPage() { setShowPassword(!showPassword)} - className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-foreground" + className={buttonClass({ + variant: 'ghost', + size: 'icon', + className: 'absolute right-2 top-1/2 -translate-y-1/2 h-7 w-7 text-muted', + })} > {showPassword ? '👁️' : '👁️🗨️'} @@ -213,10 +218,7 @@ export default function SignUpPage() { type="submit" disabled={loading} onClick={handleSignUp} - className="w-full rounded-lg bg-accent py-2 text-sm font-medium text-background - hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent - focus:ring-offset-2 disabled:opacity-50 transform transition-transform - duration-200 hover:scale-[1.02]" + className={buttonClass({ variant: 'primary', size: 'md', className: 'w-full hover:scale-[1.02]' })} > {loading ? : 'Create Account'} diff --git a/src/components/CodeBlock.tsx b/src/components/CodeBlock.tsx index a887c96..c0881b5 100644 --- a/src/components/CodeBlock.tsx +++ b/src/components/CodeBlock.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { CopyIcon, CheckIcon } from '@/components/icons/Icons'; +import { buttonClass } from '@/components/formPrimitives'; export function CodeBlock({ children }: { children: string }) { const [copied, setCopied] = useState(false); @@ -20,9 +21,11 @@ export function CodeBlock({ children }: { children: string }) { {copied ? : } diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index 2088be9..f74c7f9 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -1,5 +1,6 @@ import { Fragment, KeyboardEvent } from 'react'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; +import { buttonClass } from '@/components/formPrimitives'; interface ConfirmDialogProps { isOpen: boolean; @@ -75,23 +76,22 @@ export function ConfirmDialog({ {cancelText} {confirmText} diff --git a/src/components/CookieConsentBanner.tsx b/src/components/CookieConsentBanner.tsx index fb796c6..c2c4608 100644 --- a/src/components/CookieConsentBanner.tsx +++ b/src/components/CookieConsentBanner.tsx @@ -4,6 +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/formPrimitives'; export function CookieConsentBanner() { const [show, setShow] = useState(false); @@ -56,13 +57,21 @@ export function CookieConsentBanner() { Decline Non-Essential Accept All diff --git a/src/components/ProgressCard.tsx b/src/components/ProgressCard.tsx index 2fd4e26..1713744 100644 --- a/src/components/ProgressCard.tsx +++ b/src/components/ProgressCard.tsx @@ -1,3 +1,5 @@ +import { buttonClass } from '@/components/formPrimitives'; + interface ProgressCardProps { progress: number; estimatedTimeRemaining?: string; @@ -52,7 +54,11 @@ export function ProgressCard({ onCancel(e)} > {cancelText} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 7f76a8f..c5cd2cf 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -55,10 +55,7 @@ import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel'; import { useSharedProviders } from '@/hooks/useSharedProviders'; import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery'; import { - btnDanger, - btnOutline, - btnPrimary, - btnSecondary, + buttonClass, inputClass, listboxButtonClass, listboxOptionClass, @@ -509,10 +506,6 @@ export function SettingsModal({ className = '' }: { className?: string }) { 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 sectionHeadingClass = 'text-sm font-semibold text-foreground'; - const accountBtnBase = 'inline-flex items-center justify-center rounded-lg text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 transform transition-transform duration-200 ease-in-out'; - const accountBtnPrimary = `${accountBtnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.04]`; - const accountBtnOutline = `${accountBtnBase} bg-background border border-offbase text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.04]`; - const accountBtnDanger = `${accountBtnBase} bg-red-600 text-white border border-red-700 hover:bg-red-700 hover:scale-[1.02]`; const effectiveProviderType = resolveEffectiveProviderType({ providerRef: selectedProviderRef, providerType: localProviderType, @@ -881,7 +874,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { { const defaults = resolveProviderDefaults({ providerRef: runtimeConfig.defaultTtsProvider, @@ -901,7 +894,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { { const defaults = resolveProviderDefaults({ @@ -1167,7 +1160,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { {isImportingLibrary ? `Importing... ${Math.round(progress)}%` : 'Import from library'} @@ -1179,14 +1172,14 @@ export function SettingsModal({ className = '' }: { className?: string }) { Refresh Clear cache @@ -1194,7 +1187,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { setShowDeleteDocsConfirm(true)} disabled={isBusy} - className={`${btnDanger} px-4 py-2`} + className={buttonClass({ variant: 'danger', size: 'md' })} > Delete all data @@ -1288,7 +1281,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { <> Disconnect account @@ -1297,7 +1290,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { Danger Zone setShowDeleteAccountConfirm(true)} - className={`${accountBtnDanger} px-4 py-2`} + className={buttonClass({ variant: 'danger', size: 'md' })} > Delete Account @@ -1315,17 +1308,17 @@ export function SettingsModal({ className = '' }: { className?: string }) { - + Connect - + Create account - + Back to landing page diff --git a/src/components/admin/AdminFeaturesPanel.tsx b/src/components/admin/AdminFeaturesPanel.tsx index 92f3247..b404398 100644 --- a/src/components/admin/AdminFeaturesPanel.tsx +++ b/src/components/admin/AdminFeaturesPanel.tsx @@ -9,8 +9,7 @@ import { Badge, Section, ToggleRow, - btnPrimary, - btnSecondary, + buttonClass, inputClass, listboxButtonClass, listboxOptionClass, @@ -340,14 +339,14 @@ export function AdminFeaturesPanel() { Discard {saving ? 'Saving…' : dirty.size > 0 ? `Save (${dirty.size})` : 'Save'} diff --git a/src/components/admin/AdminProvidersPanel.tsx b/src/components/admin/AdminProvidersPanel.tsx index 6a0663d..847fef5 100644 --- a/src/components/admin/AdminProvidersPanel.tsx +++ b/src/components/admin/AdminProvidersPanel.tsx @@ -13,10 +13,7 @@ import { Field, Section, ToggleRow, - btnDanger, - btnOutline, - btnPrimary, - btnSecondary, + buttonClass, inputClass, listboxButtonClass, listboxOptionClass, @@ -261,7 +258,7 @@ export function AdminProvidersPanel() { {!editingId ? ( @@ -486,13 +483,13 @@ export function AdminProvidersPanel() { /> - + Cancel {saveMutation.isPending ? 'Saving…' : isEditingExisting ? 'Save changes' : 'Create'} @@ -526,14 +523,14 @@ export function AdminProvidersPanel() { startEdit(p)} - className={`${btnOutline} px-2.5 py-1 text-xs`} + className={buttonClass({ variant: 'outline', size: 'xs', className: 'h-7' })} disabled={!!editingId || deleteMutation.isPending} > Edit remove(p.id)} - className={`${btnDanger} px-2.5 py-1 text-xs`} + className={buttonClass({ variant: 'danger', size: 'xs', className: 'h-7' })} disabled={!!editingId || deleteMutation.isPending} > Delete diff --git a/src/components/admin/ui.tsx b/src/components/admin/ui.tsx index 4276ad0..6ed71de 100644 --- a/src/components/admin/ui.tsx +++ b/src/components/admin/ui.tsx @@ -4,8 +4,10 @@ export { Field, Section, ToggleRow, + buttonClass, btnBase, btnDanger, + btnGhost, btnOutline, btnPrimary, btnSecondary, diff --git a/src/components/auth/AuthLoader.tsx b/src/components/auth/AuthLoader.tsx index 05a0374..96968a3 100644 --- a/src/components/auth/AuthLoader.tsx +++ b/src/components/auth/AuthLoader.tsx @@ -7,6 +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/formPrimitives'; function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -266,7 +267,7 @@ export function AuthLoader({ children }: { children: ReactNode }) { setBootstrapError(null); setRetryNonce((v) => v + 1); }} - className="rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-background hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2" + className={buttonClass({ variant: 'primary', size: 'sm' })} > Retry diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx index 4d5e2f9..431049b 100644 --- a/src/components/doclist/DocumentList.tsx +++ b/src/components/doclist/DocumentList.tsx @@ -14,6 +14,7 @@ import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog'; import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton'; import { Button } from '@headlessui/react'; import { DocumentUploader } from '@/components/documents/DocumentUploader'; +import { buttonClass } from '@/components/formPrimitives'; type DocumentToDelete = { id: string; @@ -331,7 +332,11 @@ export function DocumentList() { Drag files on top of each other to make folders setShowHint(false)} - className="p-1 rounded-md hover:bg-base hover:text-accent transition-colors" + className={buttonClass({ + variant: 'ghost', + size: 'icon', + className: 'h-7 w-7 hover:bg-base', + })} aria-label="Dismiss hint" > diff --git a/src/components/doclist/DocumentListItem.tsx b/src/components/doclist/DocumentListItem.tsx index ab5270a..1c56d51 100644 --- a/src/components/doclist/DocumentListItem.tsx +++ b/src/components/doclist/DocumentListItem.tsx @@ -7,6 +7,7 @@ import { DocumentListDocument } from '@/types/documents'; import { DocumentPreview } from '@/components/doclist/DocumentPreview'; import { useAuthSession } from '@/hooks/useAuthSession'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; +import { buttonClass } from '@/components/formPrimitives'; interface DocumentListItemProps { doc: DocumentListDocument; @@ -116,7 +117,11 @@ export function DocumentListItem({ {showDeleteButton && ( onDelete(doc)} - className="ml-1 p-1.5 text-muted hover:text-accent rounded-md hover:bg-offbase transition-colors" + className={buttonClass({ + variant: 'ghost', + size: 'icon', + className: 'ml-1 h-7 w-7 text-muted hover:bg-offbase', + })} aria-label="Delete document" > @@ -155,7 +160,11 @@ export function DocumentListItem({ {showDeleteButton && ( onDelete(doc)} - className="ml-1 p-1.5 text-muted hover:text-accent rounded-md hover:bg-offbase transition-colors" + className={buttonClass({ + variant: 'ghost', + size: 'icon', + className: 'ml-1 h-7 w-7 text-muted hover:bg-offbase', + })} aria-label="Delete document" > diff --git a/src/components/documents/DocumentSelectionModal.tsx b/src/components/documents/DocumentSelectionModal.tsx index 666b5a1..c8d2651 100644 --- a/src/components/documents/DocumentSelectionModal.tsx +++ b/src/components/documents/DocumentSelectionModal.tsx @@ -3,6 +3,7 @@ import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; import { Fragment, useEffect, useState } from 'react'; import { BaseDocument } from '@/types/documents'; +import { buttonClass } from '@/components/formPrimitives'; interface DocumentSelectionModalProps { isOpen: boolean; @@ -207,14 +208,14 @@ export function DocumentSelectionModal({ Cancel diff --git a/src/components/documents/ZoomControl.tsx b/src/components/documents/ZoomControl.tsx index 724c9ac..e8eda44 100644 --- a/src/components/documents/ZoomControl.tsx +++ b/src/components/documents/ZoomControl.tsx @@ -1,3 +1,5 @@ +import { buttonClass } from '@/components/formPrimitives'; + export function ZoomControl({ value, onIncrease, @@ -17,7 +19,11 @@ export function ZoomControl({ type="button" onClick={onDecrease} disabled={value <= min} - className="px-1 text-sm leading-none text-foreground hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed transform transition-transform duration-200 ease-in-out hover:scale-[1.09]" + className={buttonClass({ + variant: 'ghost', + size: 'icon', + className: 'h-6 w-6 text-sm leading-none hover:scale-[1.09]', + })} aria-label="Zoom out" > − @@ -27,7 +33,11 @@ export function ZoomControl({ type="button" onClick={onIncrease} disabled={value >= max} - className="px-1 text-sm leading-none text-foreground hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed transform transition-transform duration-200 ease-in-out hover:scale-[1.09]" + className={buttonClass({ + variant: 'ghost', + size: 'icon', + className: 'h-6 w-6 text-sm leading-none hover:scale-[1.09]', + })} aria-label="Zoom in" > + diff --git a/src/components/formPrimitives.tsx b/src/components/formPrimitives.tsx index 29c0fab..73ce83d 100644 --- a/src/components/formPrimitives.tsx +++ b/src/components/formPrimitives.tsx @@ -13,6 +13,38 @@ export const btnPrimary = `${btnBase} bg-accent text-background hover:bg-seconda export const btnSecondary = `${btnBase} bg-base text-foreground border border-offbase hover:bg-offbase hover:scale-[1.03]`; export const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-base hover:text-accent hover:scale-[1.02]`; export const btnDanger = `${btnBase} bg-red-600 text-white border border-red-700 hover:bg-red-700 hover:scale-[1.02]`; +export const btnGhost = `${btnBase} bg-transparent text-foreground hover:bg-base hover:text-accent`; + +export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost'; +export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon'; + +const BUTTON_VARIANT_CLASS: Record = { + primary: btnPrimary, + secondary: btnSecondary, + outline: btnOutline, + danger: btnDanger, + ghost: btnGhost, +}; + +const BUTTON_SIZE_CLASS: Record = { + xs: 'h-6 px-2 text-xs rounded-md', + sm: 'h-7 px-2.5 text-xs rounded-md', + md: 'h-8 px-3 text-sm rounded-md', + lg: 'h-10 px-4 text-base rounded-lg', + icon: 'h-8 w-8 rounded-md p-0', +}; + +export function buttonClass({ + variant = 'secondary', + size = 'md', + className = '', +}: { + variant?: ButtonVariant; + size?: ButtonSize; + className?: string; +} = {}) { + return [BUTTON_VARIANT_CLASS[variant], BUTTON_SIZE_CLASS[size], className].filter(Boolean).join(' '); +} // Inputs use `bg-base` so they remain visible regardless of whether the // surrounding container is `bg-background` (Card) or `bg-base` (Section). diff --git a/src/components/reader/ReaderSidebarShell.tsx b/src/components/reader/ReaderSidebarShell.tsx index 17ea545..5e4ebdf 100644 --- a/src/components/reader/ReaderSidebarShell.tsx +++ b/src/components/reader/ReaderSidebarShell.tsx @@ -4,6 +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/formPrimitives'; interface ReaderSidebarShellProps { isOpen: boolean; @@ -81,7 +82,11 @@ export function ReaderSidebarShell({ onClick={onClose} aria-label="Close" title="Close" - className="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-offbase bg-base text-muted hover:bg-offbase hover:text-accent transition-colors" + className={buttonClass({ + variant: 'secondary', + size: 'icon', + className: 'h-8 w-8 text-muted', + })} > diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx index e7a84f1..085f836 100644 --- a/src/components/reader/SegmentsSidebar.tsx +++ b/src/components/reader/SegmentsSidebar.tsx @@ -8,6 +8,7 @@ import toast from 'react-hot-toast'; import { useTTS } from '@/contexts/TTSContext'; import { useConfig } from '@/contexts/ConfigContext'; import { RefreshIcon, InfoIcon } from '@/components/icons/Icons'; +import { buttonClass } from '@/components/formPrimitives'; import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator'; import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan'; @@ -590,7 +591,11 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: aria-label="Clear segments cache" title="Clear cache for listed segments" disabled={isClearingSegments} - className="inline-flex items-center justify-center h-8 px-2 rounded-lg border border-offbase bg-base text-xs text-muted hover:bg-offbase hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed transition-colors" + className={buttonClass({ + variant: 'secondary', + size: 'xs', + className: 'h-8 px-2 text-muted', + })} > {isClearingSegments ? 'Clearing…' : 'Clear'} @@ -599,7 +604,11 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: onClick={handleRefresh} aria-label="Refresh segments" title="Refresh" - className="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-offbase bg-base text-muted hover:bg-offbase hover:text-accent transition-colors" + className={buttonClass({ + variant: 'secondary', + size: 'icon', + className: 'h-8 w-8 text-muted', + })} >
Drag files on top of each other to make folders