refactor(components): unify button styling using buttonClass utility across multiple components

This commit is contained in:
Richard R 2026-05-14 12:06:21 -06:00
parent 205d8f37e4
commit c3fb3af3d3
18 changed files with 156 additions and 78 deletions

View file

@ -9,6 +9,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext
import { showPrivacyModal } from '@/components/PrivacyModal'; import { showPrivacyModal } from '@/components/PrivacyModal';
import { GithubIcon } from '@/components/icons/Icons'; import { GithubIcon } from '@/components/icons/Icons';
import { LoadingSpinner } from '@/components/Spinner'; import { LoadingSpinner } from '@/components/Spinner';
import { buttonClass } from '@/components/formPrimitives';
function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) { function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -196,10 +197,7 @@ function SignInContent() {
type="submit" type="submit"
disabled={isAnyLoading} disabled={isAnyLoading}
onClick={handleSignIn} onClick={handleSignIn}
className="w-full rounded-lg bg-accent py-2 text-sm font-medium text-background className={buttonClass({ variant: 'primary', size: 'md', className: 'w-full hover:scale-[1.02]' })}
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]"
> >
{loadingEmail ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Connect'} {loadingEmail ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Connect'}
</Button> </Button>
@ -210,11 +208,11 @@ function SignInContent() {
type="button" type="button"
disabled={isAnyLoading} disabled={isAnyLoading}
onClick={handleGithubSignIn} onClick={handleGithubSignIn}
className="w-full rounded-lg bg-background py-2 text-sm font-medium text-foreground className={buttonClass({
hover:bg-offbase focus:outline-none focus:ring-2 focus:ring-accent variant: 'outline',
focus:ring-offset-2 disabled:opacity-50 border border-offbase size: 'md',
transform transition-transform duration-200 hover:scale-[1.02] className: 'w-full hover:scale-[1.02] flex items-center justify-center gap-2',
flex items-center justify-center gap-2" })}
> >
{loadingGithub ? ( {loadingGithub ? (
<LoadingSpinner className="w-4 h-4" /> <LoadingSpinner className="w-4 h-4" />
@ -233,10 +231,7 @@ function SignInContent() {
type="button" type="button"
disabled={isAnyLoading} disabled={isAnyLoading}
onClick={handleAnonymousContinue} onClick={handleAnonymousContinue}
className="w-full rounded-lg bg-background py-2 text-sm font-medium text-foreground className={buttonClass({ variant: 'outline', size: 'md', className: 'w-full hover:scale-[1.02]' })}
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]"
> >
{loadingAnonymous ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Continue anonymously'} {loadingAnonymous ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Continue anonymously'}
</Button> </Button>

View file

@ -8,6 +8,7 @@ import { getAuthClient } from '@/lib/client/auth-client';
import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { showPrivacyModal } from '@/components/PrivacyModal'; import { showPrivacyModal } from '@/components/PrivacyModal';
import { LoadingSpinner } from '@/components/Spinner'; import { LoadingSpinner } from '@/components/Spinner';
import { buttonClass } from '@/components/formPrimitives';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
export default function SignUpPage() { export default function SignUpPage() {
@ -151,7 +152,11 @@ export default function SignUpPage() {
<button <button
type="button" type="button"
onClick={() => setShowPassword(!showPassword)} onClick={() => 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 ? '👁️' : '👁️‍🗨️'} {showPassword ? '👁️' : '👁️‍🗨️'}
</button> </button>
@ -213,10 +218,7 @@ export default function SignUpPage() {
type="submit" type="submit"
disabled={loading} disabled={loading}
onClick={handleSignUp} onClick={handleSignUp}
className="w-full rounded-lg bg-accent py-2 text-sm font-medium text-background className={buttonClass({ variant: 'primary', size: 'md', className: 'w-full hover:scale-[1.02]' })}
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]"
> >
{loading ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Create Account'} {loading ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Create Account'}
</Button> </Button>

View file

@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { CopyIcon, CheckIcon } from '@/components/icons/Icons'; import { CopyIcon, CheckIcon } from '@/components/icons/Icons';
import { buttonClass } from '@/components/formPrimitives';
export function CodeBlock({ children }: { children: string }) { export function CodeBlock({ children }: { children: string }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@ -20,9 +21,11 @@ export function CodeBlock({ children }: { children: string }) {
</pre> </pre>
<button <button
onClick={handleCopy} onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md bg-base hover:bg-offbase hover:text-accent className={buttonClass({
transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 variant: 'secondary',
hover:scale-[1.05]" size: 'icon',
className: 'absolute top-2 right-2 h-7 w-7 opacity-0 group-hover:opacity-100 focus:opacity-100 hover:scale-[1.05]',
})}
title="Copy to clipboard" title="Copy to clipboard"
> >
{copied ? <CheckIcon className="w-4 h-4" /> : <CopyIcon className="w-4 h-4" />} {copied ? <CheckIcon className="w-4 h-4" /> : <CopyIcon className="w-4 h-4" />}

View file

@ -1,5 +1,6 @@
import { Fragment, KeyboardEvent } from 'react'; import { Fragment, KeyboardEvent } from 'react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import { buttonClass } from '@/components/formPrimitives';
interface ConfirmDialogProps { interface ConfirmDialogProps {
isOpen: boolean; isOpen: boolean;
@ -75,23 +76,22 @@ export function ConfirmDialog({
<div className="mt-6 flex justify-end space-x-3"> <div className="mt-6 flex justify-end space-x-3">
<button <button
type="button" type="button"
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm className={buttonClass({
font-medium text-foreground hover:bg-offbase focus:outline-none variant: 'outline',
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 size: 'sm',
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" className: 'hover:scale-[1.04]',
})}
onClick={onClose} onClick={onClose}
> >
{cancelText} {cancelText}
</button> </button>
<button <button
type="button" type="button"
className={`inline-flex justify-center rounded-lg px-3 py-1.5 text-sm text-wrap className={buttonClass({
font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 variant: isDangerous ? 'danger' : 'primary',
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] size: 'sm',
${isDangerous className: 'text-wrap hover:scale-[1.04]',
? 'bg-accent text-background hover:bg-secondary-accent focus-visible:ring-accent' })}
: 'bg-accent text-background hover:bg-secondary-accent focus-visible:ring-accent'
}`}
onClick={onConfirm} onClick={onConfirm}
> >
{confirmText} {confirmText}

View file

@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { getConsentState, setConsentState } from '@/lib/client/analytics'; import { getConsentState, setConsentState } from '@/lib/client/analytics';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { buttonClass } from '@/components/formPrimitives';
export function CookieConsentBanner() { export function CookieConsentBanner() {
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
@ -56,13 +57,21 @@ export function CookieConsentBanner() {
<div className="flex flex-col gap-3 min-w-fit sm:flex-row"> <div className="flex flex-col gap-3 min-w-fit sm:flex-row">
<button <button
onClick={handleDecline} onClick={handleDecline}
className="whitespace-nowrap rounded-lg px-4 py-2.5 text-sm font-medium text-foreground hover:bg-offbase focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transition-colors" className={buttonClass({
variant: 'ghost',
size: 'md',
className: 'whitespace-nowrap',
})}
> >
Decline Non-Essential Decline Non-Essential
</button> </button>
<button <button
onClick={handleAccept} onClick={handleAccept}
className="whitespace-nowrap rounded-lg bg-accent px-6 py-2.5 text-sm font-bold text-background hover:bg-secondary-accent shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transition-transform hover:scale-[1.02]" className={buttonClass({
variant: 'primary',
size: 'md',
className: 'whitespace-nowrap font-bold shadow-sm',
})}
> >
Accept All Accept All
</button> </button>

View file

@ -1,3 +1,5 @@
import { buttonClass } from '@/components/formPrimitives';
interface ProgressCardProps { interface ProgressCardProps {
progress: number; progress: number;
estimatedTimeRemaining?: string; estimatedTimeRemaining?: string;
@ -52,7 +54,11 @@ export function ProgressCard({
</div> </div>
<button <button
type="button" type="button"
className="shrink-0 inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium text-foreground hover:text-accent hover:bg-background/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.04]" className={buttonClass({
variant: 'ghost',
size: 'xs',
className: 'shrink-0 hover:bg-background/50 hover:scale-[1.04]',
})}
onClick={(e) => onCancel(e)} onClick={(e) => onCancel(e)}
> >
<span>{cancelText}</span> <span>{cancelText}</span>

View file

@ -55,10 +55,7 @@ import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
import { useSharedProviders } from '@/hooks/useSharedProviders'; import { useSharedProviders } from '@/hooks/useSharedProviders';
import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery'; import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
import { import {
btnDanger, buttonClass,
btnOutline,
btnPrimary,
btnSecondary,
inputClass, inputClass,
listboxButtonClass, listboxButtonClass,
listboxOptionClass, listboxOptionClass,
@ -509,10 +506,6 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const fieldLabelClass = 'block text-[11px] font-semibold uppercase tracking-wide text-muted'; 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 sectionShellClass = 'space-y-2 pb-3 border-b border-offbase px-0.5';
const sectionHeadingClass = 'text-sm font-semibold text-foreground'; 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({ const effectiveProviderType = resolveEffectiveProviderType({
providerRef: selectedProviderRef, providerRef: selectedProviderRef,
providerType: localProviderType, providerType: localProviderType,
@ -881,7 +874,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<div className="pt-4 flex justify-end gap-2"> <div className="pt-4 flex justify-end gap-2">
<Button <Button
type="button" type="button"
className={`${btnSecondary} px-4 py-2`} className={buttonClass({ variant: 'secondary', size: 'md' })}
onClick={async () => { onClick={async () => {
const defaults = resolveProviderDefaults({ const defaults = resolveProviderDefaults({
providerRef: runtimeConfig.defaultTtsProvider, providerRef: runtimeConfig.defaultTtsProvider,
@ -901,7 +894,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<Button <Button
data-testid="settings-save-button" data-testid="settings-save-button"
type="button" type="button"
className={`${btnPrimary} px-4 py-2`} className={buttonClass({ variant: 'primary', size: 'md' })}
disabled={!canSubmit} disabled={!canSubmit}
onClick={async () => { onClick={async () => {
const defaults = resolveProviderDefaults({ const defaults = resolveProviderDefaults({
@ -1167,7 +1160,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<Button <Button
onClick={handleImportLibrary} onClick={handleImportLibrary}
disabled={isBusy} disabled={isBusy}
className={`${btnOutline} px-4 py-2 disabled:opacity-50`} className={buttonClass({ variant: 'outline', size: 'md' })}
> >
{isImportingLibrary ? `Importing... ${Math.round(progress)}%` : 'Import from library'} {isImportingLibrary ? `Importing... ${Math.round(progress)}%` : 'Import from library'}
</Button> </Button>
@ -1179,14 +1172,14 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<Button <Button
onClick={handleRefresh} onClick={handleRefresh}
disabled={isBusy} disabled={isBusy}
className={`${btnOutline} px-4 py-2 disabled:opacity-50`} className={buttonClass({ variant: 'outline', size: 'md' })}
> >
Refresh Refresh
</Button> </Button>
<Button <Button
onClick={handleClearCache} onClick={handleClearCache}
disabled={isBusy} disabled={isBusy}
className={`${btnOutline} px-4 py-2 disabled:opacity-50`} className={buttonClass({ variant: 'outline', size: 'md' })}
> >
Clear cache Clear cache
</Button> </Button>
@ -1194,7 +1187,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<Button <Button
onClick={() => setShowDeleteDocsConfirm(true)} onClick={() => setShowDeleteDocsConfirm(true)}
disabled={isBusy} disabled={isBusy}
className={`${btnDanger} px-4 py-2`} className={buttonClass({ variant: 'danger', size: 'md' })}
> >
Delete all data Delete all data
</Button> </Button>
@ -1288,7 +1281,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<> <>
<Button <Button
onClick={handleSignOut} onClick={handleSignOut}
className={`${accountBtnOutline} px-4 py-2`} className={buttonClass({ variant: 'outline', size: 'md', className: 'hover:scale-[1.04]' })}
> >
Disconnect account Disconnect account
</Button> </Button>
@ -1297,7 +1290,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<label className="block text-sm font-medium text-red-500 mb-2">Danger Zone</label> <label className="block text-sm font-medium text-red-500 mb-2">Danger Zone</label>
<Button <Button
onClick={() => setShowDeleteAccountConfirm(true)} onClick={() => setShowDeleteAccountConfirm(true)}
className={`${accountBtnDanger} px-4 py-2`} className={buttonClass({ variant: 'danger', size: 'md' })}
> >
Delete Account Delete Account
</Button> </Button>
@ -1315,17 +1308,17 @@ export function SettingsModal({ className = '' }: { className?: string }) {
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Link href="/signin"> <Link href="/signin">
<Button className={`${accountBtnOutline} px-4 py-2`}> <Button className={buttonClass({ variant: 'outline', size: 'md', className: 'hover:scale-[1.04]' })}>
Connect Connect
</Button> </Button>
</Link> </Link>
<Link href="/signup"> <Link href="/signup">
<Button className={`${accountBtnPrimary} px-4 py-2`}> <Button className={buttonClass({ variant: 'primary', size: 'md', className: 'hover:scale-[1.04]' })}>
Create account Create account
</Button> </Button>
</Link> </Link>
<Link href="/?redirect=false"> <Link href="/?redirect=false">
<Button className={`${accountBtnOutline} px-4 py-2`}> <Button className={buttonClass({ variant: 'outline', size: 'md', className: 'hover:scale-[1.04]' })}>
Back to landing page Back to landing page
</Button> </Button>
</Link> </Link>

View file

@ -9,8 +9,7 @@ import {
Badge, Badge,
Section, Section,
ToggleRow, ToggleRow,
btnPrimary, buttonClass,
btnSecondary,
inputClass, inputClass,
listboxButtonClass, listboxButtonClass,
listboxOptionClass, listboxOptionClass,
@ -340,14 +339,14 @@ export function AdminFeaturesPanel() {
<Button <Button
onClick={discardAll} onClick={discardAll}
disabled={dirty.size === 0 || saving} disabled={dirty.size === 0 || saving}
className={`${btnSecondary} px-4 py-1.5`} className={buttonClass({ variant: 'secondary', size: 'sm' })}
> >
Discard Discard
</Button> </Button>
<Button <Button
onClick={saveAll} onClick={saveAll}
disabled={dirty.size === 0 || saving} disabled={dirty.size === 0 || saving}
className={`${btnPrimary} px-4 py-1.5`} className={buttonClass({ variant: 'primary', size: 'sm' })}
> >
{saving ? 'Saving…' : dirty.size > 0 ? `Save (${dirty.size})` : 'Save'} {saving ? 'Saving…' : dirty.size > 0 ? `Save (${dirty.size})` : 'Save'}
</Button> </Button>

View file

@ -13,10 +13,7 @@ import {
Field, Field,
Section, Section,
ToggleRow, ToggleRow,
btnDanger, buttonClass,
btnOutline,
btnPrimary,
btnSecondary,
inputClass, inputClass,
listboxButtonClass, listboxButtonClass,
listboxOptionClass, listboxOptionClass,
@ -261,7 +258,7 @@ export function AdminProvidersPanel() {
{!editingId ? ( {!editingId ? (
<Button <Button
onClick={startCreate} onClick={startCreate}
className={`${btnPrimary} h-7 w-7 p-0 inline-flex items-center justify-center`} className={buttonClass({ variant: 'primary', size: 'icon', className: 'h-7 w-7' })}
aria-label="Add provider" aria-label="Add provider"
title="Add provider" title="Add provider"
> >
@ -486,13 +483,13 @@ export function AdminProvidersPanel() {
/> />
<div className="pt-1 flex justify-end gap-2"> <div className="pt-1 flex justify-end gap-2">
<Button onClick={cancelEdit} className={`${btnSecondary} px-4 py-1.5`}> <Button onClick={cancelEdit} className={buttonClass({ variant: 'secondary', size: 'sm' })}>
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={submit} onClick={submit}
disabled={submitDisabled} disabled={submitDisabled}
className={`${btnPrimary} px-4 py-1.5`} className={buttonClass({ variant: 'primary', size: 'sm' })}
> >
{saveMutation.isPending ? 'Saving…' : isEditingExisting ? 'Save changes' : 'Create'} {saveMutation.isPending ? 'Saving…' : isEditingExisting ? 'Save changes' : 'Create'}
</Button> </Button>
@ -526,14 +523,14 @@ export function AdminProvidersPanel() {
<div className="shrink-0 flex gap-1.5"> <div className="shrink-0 flex gap-1.5">
<Button <Button
onClick={() => startEdit(p)} onClick={() => startEdit(p)}
className={`${btnOutline} px-2.5 py-1 text-xs`} className={buttonClass({ variant: 'outline', size: 'xs', className: 'h-7' })}
disabled={!!editingId || deleteMutation.isPending} disabled={!!editingId || deleteMutation.isPending}
> >
Edit Edit
</Button> </Button>
<Button <Button
onClick={() => remove(p.id)} onClick={() => 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} disabled={!!editingId || deleteMutation.isPending}
> >
Delete Delete

View file

@ -4,8 +4,10 @@ export {
Field, Field,
Section, Section,
ToggleRow, ToggleRow,
buttonClass,
btnBase, btnBase,
btnDanger, btnDanger,
btnGhost,
btnOutline, btnOutline,
btnPrimary, btnPrimary,
btnSecondary, btnSecondary,

View file

@ -7,6 +7,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext
import { useAuthSession } from '@/hooks/useAuthSession'; import { useAuthSession } from '@/hooks/useAuthSession';
import { getAuthClient } from '@/lib/client/auth-client'; import { getAuthClient } from '@/lib/client/auth-client';
import { LoadingSpinner } from '@/components/Spinner'; import { LoadingSpinner } from '@/components/Spinner';
import { buttonClass } from '@/components/formPrimitives';
function sleep(ms: number) { function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms)); return new Promise<void>((resolve) => setTimeout(resolve, ms));
@ -266,7 +267,7 @@ export function AuthLoader({ children }: { children: ReactNode }) {
setBootstrapError(null); setBootstrapError(null);
setRetryNonce((v) => v + 1); 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 Retry
</button> </button>

View file

@ -14,6 +14,7 @@ import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton'; import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton';
import { Button } from '@headlessui/react'; import { Button } from '@headlessui/react';
import { DocumentUploader } from '@/components/documents/DocumentUploader'; import { DocumentUploader } from '@/components/documents/DocumentUploader';
import { buttonClass } from '@/components/formPrimitives';
type DocumentToDelete = { type DocumentToDelete = {
id: string; id: string;
@ -331,7 +332,11 @@ export function DocumentList() {
<p className="text-sm text-foreground">Drag files on top of each other to make folders</p> <p className="text-sm text-foreground">Drag files on top of each other to make folders</p>
<Button <Button
onClick={() => setShowHint(false)} onClick={() => 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" aria-label="Dismiss hint"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View file

@ -7,6 +7,7 @@ import { DocumentListDocument } from '@/types/documents';
import { DocumentPreview } from '@/components/doclist/DocumentPreview'; import { DocumentPreview } from '@/components/doclist/DocumentPreview';
import { useAuthSession } from '@/hooks/useAuthSession'; import { useAuthSession } from '@/hooks/useAuthSession';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { buttonClass } from '@/components/formPrimitives';
interface DocumentListItemProps { interface DocumentListItemProps {
doc: DocumentListDocument; doc: DocumentListDocument;
@ -116,7 +117,11 @@ export function DocumentListItem({
{showDeleteButton && ( {showDeleteButton && (
<Button <Button
onClick={() => onDelete(doc)} onClick={() => 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" aria-label="Delete document"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -155,7 +160,11 @@ export function DocumentListItem({
{showDeleteButton && ( {showDeleteButton && (
<Button <Button
onClick={() => onDelete(doc)} onClick={() => 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" aria-label="Delete document"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View file

@ -3,6 +3,7 @@
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import { Fragment, useEffect, useState } from 'react'; import { Fragment, useEffect, useState } from 'react';
import { BaseDocument } from '@/types/documents'; import { BaseDocument } from '@/types/documents';
import { buttonClass } from '@/components/formPrimitives';
interface DocumentSelectionModalProps { interface DocumentSelectionModalProps {
isOpen: boolean; isOpen: boolean;
@ -207,14 +208,14 @@ export function DocumentSelectionModal({
<div className="mt-4 flex justify-end gap-3 flex-shrink-0"> <div className="mt-4 flex justify-end gap-3 flex-shrink-0">
<button <button
type="button" type="button"
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm font-medium text-foreground hover:bg-offbase focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2" className={buttonClass({ variant: 'outline', size: 'md' })}
onClick={onClose} onClick={onClose}
> >
Cancel Cancel
</button> </button>
<button <button
type="button" type="button"
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm font-medium text-background hover:bg-secondary-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed" className={buttonClass({ variant: 'primary', size: 'md' })}
onClick={handleConfirmClick} onClick={handleConfirmClick}
disabled={isLoading || selectedCount === 0 || isProcessing} disabled={isLoading || selectedCount === 0 || isProcessing}
> >

View file

@ -1,3 +1,5 @@
import { buttonClass } from '@/components/formPrimitives';
export function ZoomControl({ export function ZoomControl({
value, value,
onIncrease, onIncrease,
@ -17,7 +19,11 @@ export function ZoomControl({
type="button" type="button"
onClick={onDecrease} onClick={onDecrease}
disabled={value <= min} 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" aria-label="Zoom out"
> >
@ -27,7 +33,11 @@ export function ZoomControl({
type="button" type="button"
onClick={onIncrease} onClick={onIncrease}
disabled={value >= max} 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" aria-label="Zoom in"
> >

View file

@ -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 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 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 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<ButtonVariant, string> = {
primary: btnPrimary,
secondary: btnSecondary,
outline: btnOutline,
danger: btnDanger,
ghost: btnGhost,
};
const BUTTON_SIZE_CLASS: Record<ButtonSize, string> = {
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 // Inputs use `bg-base` so they remain visible regardless of whether the
// surrounding container is `bg-background` (Card) or `bg-base` (Section). // surrounding container is `bg-background` (Card) or `bg-base` (Section).

View file

@ -4,6 +4,7 @@ import { Fragment, type ReactNode } from 'react';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { XCircleIcon } from '@/components/icons/Icons'; import { XCircleIcon } from '@/components/icons/Icons';
import { useReaderSidebarBounds } from '@/hooks/useReaderSidebarBounds'; import { useReaderSidebarBounds } from '@/hooks/useReaderSidebarBounds';
import { buttonClass } from '@/components/formPrimitives';
interface ReaderSidebarShellProps { interface ReaderSidebarShellProps {
isOpen: boolean; isOpen: boolean;
@ -81,7 +82,11 @@ export function ReaderSidebarShell({
onClick={onClose} onClick={onClose}
aria-label="Close" aria-label="Close"
title="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',
})}
> >
<XCircleIcon className="w-4 h-4" /> <XCircleIcon className="w-4 h-4" />
</button> </button>

View file

@ -8,6 +8,7 @@ import toast from 'react-hot-toast';
import { useTTS } from '@/contexts/TTSContext'; import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext'; import { useConfig } from '@/contexts/ConfigContext';
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons'; import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
import { buttonClass } from '@/components/formPrimitives';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator'; import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan'; import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';
@ -590,7 +591,11 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
aria-label="Clear segments cache" aria-label="Clear segments cache"
title="Clear cache for listed segments" title="Clear cache for listed segments"
disabled={isClearingSegments} 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'} {isClearingSegments ? 'Clearing…' : 'Clear'}
</button> </button>
@ -599,7 +604,11 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
onClick={handleRefresh} onClick={handleRefresh}
aria-label="Refresh segments" aria-label="Refresh segments"
title="Refresh" 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',
})}
> >
<RefreshIcon className="w-3.5 h-3.5" /> <RefreshIcon className="w-3.5 h-3.5" />
</button> </button>