phase 7: refactor app surfaces to ui layer

This commit is contained in:
Richard R 2026-06-01 02:41:48 -06:00
parent c56c3fa902
commit b4f30cf072
32 changed files with 403 additions and 352 deletions

View file

@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import { Providers } from '@/app/providers'; import { Providers } from '@/app/providers';
import { AppMain, AppShell } from '@/components/layout';
import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config'; import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -32,9 +33,9 @@ export default function AppLayout({ children }: { children: ReactNode }) {
allowAnonymousAuthSessions={allowAnonymousAuthSessions} allowAnonymousAuthSessions={allowAnonymousAuthSessions}
githubAuthEnabled={githubAuthEnabled} githubAuthEnabled={githubAuthEnabled}
> >
<div className="app-shell h-dvh flex flex-col bg-background overflow-hidden"> <AppShell>
<main className="flex-1 min-h-0 flex flex-col">{children}</main> <AppMain>{children}</AppMain>
</div> </AppShell>
<Toaster <Toaster
toastOptions={{ toastOptions={{
style: { style: {

View file

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, Suspense } from 'react'; import { useState, useEffect, Suspense } from 'react';
import { Button, Input } from '@headlessui/react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { getAuthClient } from '@/lib/client/auth-client'; import { getAuthClient } from '@/lib/client/auth-client';
@ -10,7 +9,7 @@ import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
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/ui/buttonPrimitives'; import { Button, Field, Input, Surface } from '@/components/ui';
function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) { function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -120,7 +119,7 @@ function SignInContent() {
<SessionExpiredLoader setSessionExpired={setSessionExpired} /> <SessionExpiredLoader setSessionExpired={setSessionExpired} />
</Suspense> </Suspense>
<div className="w-full max-w-md bg-surface rounded-lg shadow-elev-3 p-6"> <Surface elevation="3" className="w-full max-w-md p-6">
<h1 className="text-xl font-semibold text-foreground"> <h1 className="text-xl font-semibold text-foreground">
{sessionExpired ? 'Session Expired' : 'Connect Account'} {sessionExpired ? 'Session Expired' : 'Connect Account'}
</h1> </h1>
@ -141,36 +140,32 @@ function SignInContent() {
{error && ( {error && (
<div className="mt-4 p-3 bg-danger-wash border border-danger rounded-lg"> <div className="mt-4 p-3 bg-danger-wash border border-danger rounded-lg">
<p className="text-sm text-danger dark:text-danger">{error}</p> <p className="text-sm text-danger">{error}</p>
</div> </div>
)} )}
<div className="mt-6 space-y-4"> <div className="mt-6 space-y-4">
{/* Email */} {/* Email */}
<div> <Field label="Email">
<label className="block text-sm font-medium text-foreground mb-1">Email</label>
<Input <Input
type="email" type="email"
value={email} value={email}
onChange={(e) => { setEmail(e.target.value); setError(null); }} onChange={(e) => { setEmail(e.target.value); setError(null); }}
placeholder="me@example.com" placeholder="me@example.com"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-elev-1 controlSize="lg"
focus:outline-none focus:ring-2 focus:ring-accent-line"
/> />
</div> </Field>
{/* Password */} {/* Password */}
<div> <Field label="Password">
<label className="block text-sm font-medium text-foreground mb-1">Password</label>
<Input <Input
type="password" type="password"
value={password} value={password}
onChange={(e) => { setPassword(e.target.value); setError(null); }} onChange={(e) => { setPassword(e.target.value); setError(null); }}
placeholder="Password" placeholder="Password"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-elev-1 controlSize="lg"
focus:outline-none focus:ring-2 focus:ring-accent-line"
/> />
</div> </Field>
{/* Remember Me */} {/* Remember Me */}
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
@ -188,7 +183,9 @@ function SignInContent() {
type="submit" type="submit"
disabled={isAnyLoading} disabled={isAnyLoading}
onClick={handleSignIn} onClick={handleSignIn}
className={buttonClass({ variant: 'primary', size: 'md', className: 'w-full' })} variant="primary"
size="md"
className="w-full"
> >
{loadingEmail ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Connect'} {loadingEmail ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Connect'}
</Button> </Button>
@ -199,11 +196,9 @@ function SignInContent() {
type="button" type="button"
disabled={isAnyLoading} disabled={isAnyLoading}
onClick={handleGithubSignIn} onClick={handleGithubSignIn}
className={buttonClass({ variant="outline"
variant: 'outline', size="md"
size: 'md', className="w-full gap-2"
className: 'w-full flex items-center justify-center gap-2',
})}
> >
{loadingGithub ? ( {loadingGithub ? (
<LoadingSpinner className="w-4 h-4" /> <LoadingSpinner className="w-4 h-4" />
@ -222,7 +217,9 @@ function SignInContent() {
type="button" type="button"
disabled={isAnyLoading} disabled={isAnyLoading}
onClick={handleAnonymousContinue} onClick={handleAnonymousContinue}
className={buttonClass({ variant: 'outline', size: 'md', className: 'w-full' })} variant="outline"
size="md"
className="w-full"
> >
{loadingAnonymous ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Continue anonymously'} {loadingAnonymous ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Continue anonymously'}
</Button> </Button>
@ -249,7 +246,7 @@ function SignInContent() {
</button> </button>
</p> </p>
</div> </div>
</div> </Surface>
</div> </div>
); );
} }

View file

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Button, Input } from '@headlessui/react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { getAuthClient } from '@/lib/client/auth-client'; import { getAuthClient } from '@/lib/client/auth-client';
@ -9,7 +8,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { showPrivacyModal } from '@/components/PrivacyModal'; import { showPrivacyModal } from '@/components/PrivacyModal';
import { LoadingSpinner } from '@/components/Spinner'; 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'; import toast from 'react-hot-toast';
export default function SignUpPage() { export default function SignUpPage() {
@ -105,7 +104,7 @@ export default function SignUpPage() {
if (!enableUserSignups) { if (!enableUserSignups) {
return ( return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background"> <div className="min-h-screen flex items-center justify-center p-4 bg-background">
<div className="w-full max-w-md bg-surface rounded-lg shadow-elev-3 p-6"> <Surface elevation="3" className="w-full max-w-md p-6">
<h1 className="text-xl font-semibold text-foreground">Sign-ups unavailable</h1> <h1 className="text-xl font-semibold text-foreground">Sign-ups unavailable</h1>
<p className="text-sm text-soft mt-1"> <p className="text-sm text-soft mt-1">
New account sign-ups are currently disabled by the site administrator. New account sign-ups are currently disabled by the site administrator.
@ -118,7 +117,7 @@ export default function SignUpPage() {
</Link> </Link>
</p> </p>
</div> </div>
</div> </Surface>
</div> </div>
); );
} }
@ -129,53 +128,45 @@ export default function SignUpPage() {
return ( return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background"> <div className="min-h-screen flex items-center justify-center p-4 bg-background">
<div className="w-full max-w-md bg-surface rounded-lg shadow-elev-3 p-6"> <Surface elevation="3" className="w-full max-w-md p-6">
<h1 className="text-xl font-semibold text-foreground">Sign Up</h1> <h1 className="text-xl font-semibold text-foreground">Sign Up</h1>
<p className="text-sm text-soft mt-1">Create your account to get started</p> <p className="text-sm text-soft mt-1">Create your account to get started</p>
{error && ( {error && (
<div className="mt-4 p-3 bg-danger-wash border border-danger rounded-lg"> <div className="mt-4 p-3 bg-danger-wash border border-danger rounded-lg">
<p className="text-sm text-danger dark:text-danger">{error}</p> <p className="text-sm text-danger">{error}</p>
</div> </div>
)} )}
<div className="mt-6 space-y-4"> <div className="mt-6 space-y-4">
{/* Email */} {/* Email */}
<div> <Field label="Email">
<label className="block text-sm font-medium text-foreground mb-1">Email</label>
<Input <Input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="me@example.com" placeholder="me@example.com"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-elev-1 controlSize="lg"
focus:outline-none focus:ring-2 focus:ring-accent-line"
/> />
</div> </Field>
{/* Password */} {/* Password */}
<div> <Field label="Password">
<label className="block text-sm font-medium text-foreground mb-1">Password</label>
<div className="relative"> <div className="relative">
<Input <Input
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Password" placeholder="Password"
className="w-full rounded-lg bg-background py-2 px-3 pr-10 text-foreground shadow-elev-1 controlSize="lg"
focus:outline-none focus:ring-2 focus:ring-accent-line" className="pr-10"
/> />
<button <IconButton
type="button"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
className={buttonClass({ className="absolute right-2 top-1/2 h-7 w-7 -translate-y-1/2"
variant: 'ghost',
size: 'icon',
className: 'absolute right-2 top-1/2 -translate-y-1/2 h-7 w-7 text-soft',
})}
> >
{showPassword ? '👁️' : '👁️‍🗨️'} {showPassword ? '👁️' : '👁️‍🗨️'}
</button> </IconButton>
</div> </div>
{/* Password Strength */} {/* Password Strength */}
@ -209,32 +200,32 @@ export default function SignUpPage() {
</div> </div>
</div> </div>
)} )}
</div> </Field>
{/* Confirm Password */} {/* Confirm Password */}
<div> <Field label="Confirm Password">
<label className="block text-sm font-medium text-foreground mb-1">Confirm Password</label>
<Input <Input
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
value={passwordConfirmation} value={passwordConfirmation}
onChange={(e) => setPasswordConfirmation(e.target.value)} onChange={(e) => setPasswordConfirmation(e.target.value)}
placeholder="Confirm Password" placeholder="Confirm Password"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-elev-1 controlSize="lg"
focus:outline-none focus:ring-2 focus:ring-accent-line"
/> />
{passwordConfirmation && password && ( {passwordConfirmation && password && (
<p className={`text-xs mt-1 ${password === passwordConfirmation ? 'text-accent' : 'text-danger'}`}> <p className={`text-xs mt-1 ${password === passwordConfirmation ? 'text-accent' : 'text-danger'}`}>
{password === passwordConfirmation ? '✓ Passwords match' : '✗ Passwords do not match'} {password === passwordConfirmation ? '✓ Passwords match' : '✗ Passwords do not match'}
</p> </p>
)} )}
</div> </Field>
{/* Sign Up Button */} {/* Sign Up Button */}
<Button <Button
type="submit" type="submit"
disabled={loading} disabled={loading}
onClick={handleSignUp} onClick={handleSignUp}
className={buttonClass({ variant: 'primary', size: 'md', className: 'w-full' })} variant="primary"
size="md"
className="w-full"
> >
{loading ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Create Account'} {loading ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Create Account'}
</Button> </Button>
@ -258,7 +249,7 @@ export default function SignUpPage() {
</button> </button>
</p> </p>
</div> </div>
</div> </Surface>
</div> </div>
); );
} }

View file

@ -1,6 +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/ui/buttonPrimitives'; import { Button, dialogPanelStyles } from '@/components/ui';
interface ConfirmDialogProps { interface ConfirmDialogProps {
isOpen: boolean; isOpen: boolean;
@ -62,7 +62,7 @@ export function ConfirmDialog({
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<DialogPanel role='dialog' className="w-full max-w-md transform rounded-lg bg-surface p-6 text-left align-middle shadow-elev-3 transition"> <DialogPanel role='dialog' className={dialogPanelStyles({ size: 'md' })}>
<DialogTitle <DialogTitle
as="h3" as="h3"
className="text-lg font-semibold leading-6 text-foreground" className="text-lg font-semibold leading-6 text-foreground"
@ -74,27 +74,17 @@ export function ConfirmDialog({
</div> </div>
<div className="mt-6 flex justify-end space-x-3"> <div className="mt-6 flex justify-end space-x-3">
<button <Button variant="outline" size="sm" onClick={onClose}>
type="button"
className={buttonClass({
variant: 'outline',
size: 'sm',
})}
onClick={onClose}
>
{cancelText} {cancelText}
</button> </Button>
<button <Button
type="button" variant={isDangerous ? 'danger' : 'primary'}
className={buttonClass({ size="sm"
variant: isDangerous ? 'danger' : 'primary', className="text-wrap"
size: 'sm',
className: 'text-wrap',
})}
onClick={onConfirm} onClick={onConfirm}
> >
{confirmText} {confirmText}
</button> </Button>
</div> </div>
</DialogPanel> </DialogPanel>
</TransitionChild> </TransitionChild>

View file

@ -1,3 +1,4 @@
import { AppHeader } from '@/components/layout';
import { ReactNode } from "react"; import { ReactNode } from "react";
export function Header({ export function Header({
@ -9,19 +10,5 @@ export function Header({
title?: ReactNode; title?: ReactNode;
right?: ReactNode; right?: ReactNode;
}) { }) {
return ( return <AppHeader left={left} title={title} right={right} />;
<div className="sticky top-0 z-40 w-full border-b border-line-soft bg-surface" data-app-header>
<div className="px-2 sm:px-3 py-1 flex items-center justify-between gap-2 min-h-10">
<div className="flex items-center gap-2 min-w-0 flex-1">
{left}
{typeof title === 'string' ? (
<h1 className="text-xs md:text-sm font-semibold truncate text-foreground tracking-tight">{title}</h1>
) : (
title
)}
</div>
<div className="flex items-center gap-2 min-w-0 justify-end">{right}</div>
</div>
</div>
);
} }

View file

@ -7,9 +7,9 @@ import {
DialogTitle, DialogTitle,
Transition, Transition,
TransitionChild, TransitionChild,
Button,
} from '@headlessui/react'; } from '@headlessui/react';
import { updateAppConfig } from '@/lib/client/dexie'; import { updateAppConfig } from '@/lib/client/dexie';
import { Button, dialogPanelStyles } from '@/components/ui';
interface PrivacyModalProps { interface PrivacyModalProps {
isOpen: boolean; isOpen: boolean;
@ -102,7 +102,7 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<DialogPanel data-testid="privacy-modal" className="w-full max-w-md transform rounded-lg bg-surface p-6 text-left align-middle shadow-elev-3 transition"> <DialogPanel data-testid="privacy-modal" className={dialogPanelStyles({ size: 'md' })}>
<DialogTitle <DialogTitle
as="h3" as="h3"
className="text-lg font-semibold leading-6 text-foreground" className="text-lg font-semibold leading-6 text-foreground"
@ -137,13 +137,9 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
data-testid="privacy-continue-button" data-testid="privacy-continue-button"
type="button" variant="primary"
size="lg"
disabled={!agreed} disabled={!agreed}
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
font-medium text-background hover:bg-secondary-accent
disabled:opacity-50 disabled:cursor-not-allowed
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-base ease-standard"
onClick={handleAccept} onClick={handleAccept}
> >
Continue Continue
@ -216,7 +212,7 @@ export function showPrivacyModal(): void {
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<DialogPanel className="w-full max-w-md transform rounded-lg bg-surface p-6 text-left align-middle shadow-elev-3 transition"> <DialogPanel className={dialogPanelStyles({ size: 'md' })}>
<DialogTitle <DialogTitle
as="h3" as="h3"
className="text-lg font-semibold leading-6 text-foreground" className="text-lg font-semibold leading-6 text-foreground"
@ -228,11 +224,8 @@ export function showPrivacyModal(): void {
<div className="mt-6 flex justify-end"> <div className="mt-6 flex justify-end">
<Button <Button
type="button" variant="primary"
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm size="lg"
font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-base ease-standard"
onClick={handleClose} onClick={handleClose}
> >
Close Close

View file

@ -7,7 +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/ui/buttonPrimitives'; import { Button } from '@/components/ui';
function sleep(ms: number) { function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms)); return new Promise<void>((resolve) => setTimeout(resolve, ms));
@ -257,17 +257,17 @@ export function AuthLoader({ children }: { children: ReactNode }) {
{bootstrapError ? ( {bootstrapError ? (
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<p className="text-sm text-soft text-center">{bootstrapError}</p> <p className="text-sm text-soft text-center">{bootstrapError}</p>
<button <Button
type="button"
onClick={() => { onClick={() => {
attemptedForNullSessionRef.current = false; attemptedForNullSessionRef.current = false;
setBootstrapError(null); setBootstrapError(null);
setRetryNonce((v) => v + 1); setRetryNonce((v) => v + 1);
}} }}
className={buttonClass({ variant: 'primary', size: 'sm' })} variant="primary"
size="sm"
> >
Retry Retry
</button> </Button>
</div> </div>
) : ( ) : (
<p className="text-sm text-soft animate-pulse"> <p className="text-sm text-soft animate-pulse">

View file

@ -7,10 +7,10 @@ import {
DialogTitle, DialogTitle,
Transition, Transition,
TransitionChild, TransitionChild,
Button,
} from '@headlessui/react'; } from '@headlessui/react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Button, dialogPanelStyles } from '@/components/ui';
export type ClaimableCounts = { export type ClaimableCounts = {
documents: number; documents: number;
@ -99,7 +99,7 @@ export default function ClaimDataModal({
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<DialogPanel data-testid="claim-modal" className="w-full max-w-md transform rounded-lg bg-surface p-6 text-left align-middle shadow-elev-3 transition"> <DialogPanel data-testid="claim-modal" className={dialogPanelStyles({ size: 'md' })}>
<DialogTitle <DialogTitle
as="h3" as="h3"
className="text-lg font-semibold leading-6 text-foreground mb-4" className="text-lg font-semibold leading-6 text-foreground mb-4"
@ -129,27 +129,19 @@ export default function ClaimDataModal({
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<Button <Button
data-testid="claim-dismiss-button" data-testid="claim-dismiss-button"
type="button" variant="outline"
size="sm"
onClick={onDismiss} onClick={onDismiss}
disabled={isClaiming} disabled={isClaiming}
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-accent-wash focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-base ease-standard hover:text-accent
disabled:opacity-50"
> >
Dismiss Dismiss
</Button> </Button>
<Button <Button
data-testid="claim-submit-button" data-testid="claim-submit-button"
type="button" variant="primary"
size="sm"
onClick={handleClaim} onClick={handleClaim}
disabled={isClaiming} disabled={isClaiming}
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 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
transform transition-transform duration-base ease-standard hover:text-background
disabled:opacity-50"
> >
{isClaiming ? 'Claiming...' : 'Claim Data'} {isClaiming ? 'Claiming...' : 'Claim Data'}
</Button> </Button>

View file

@ -1,5 +1,6 @@
import { Fragment, KeyboardEvent } from 'react'; import { Fragment, KeyboardEvent } from 'react';
import { Dialog, DialogPanel, DialogTitle, Input, Transition, TransitionChild } from '@headlessui/react'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import { dialogPanelStyles, Input } from '@/components/ui';
interface CreateFolderDialogProps { interface CreateFolderDialogProps {
isOpen: boolean; isOpen: boolean;
@ -42,7 +43,7 @@ export function CreateFolderDialog({
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<DialogPanel className="w-full max-w-md transform rounded-lg bg-surface p-6 text-left align-middle shadow-elev-3 transition"> <DialogPanel className={dialogPanelStyles({ size: 'md' })}>
<DialogTitle as="h3" className="text-lg font-semibold text-foreground"> <DialogTitle as="h3" className="text-lg font-semibold text-foreground">
Create New Folder Create New Folder
</DialogTitle> </DialogTitle>
@ -53,7 +54,7 @@ export function CreateFolderDialog({
onChange={(e) => onFolderNameChange(e.target.value)} onChange={(e) => onFolderNameChange(e.target.value)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
placeholder="Enter folder name" placeholder="Enter folder name"
className="w-full rounded-lg bg-surface-sunken py-2 px-3 text-foreground shadow-elev-1 focus:outline-none focus:ring-1 focus:ring-accent-line" controlSize="lg"
autoFocus autoFocus
/> />
<p className="mt-2 text-xs text-soft">Press Enter to create or Escape to cancel</p> <p className="mt-2 text-xs text-soft">Press Enter to create or Escape to cancel</p>
@ -65,4 +66,4 @@ export function CreateFolderDialog({
</Dialog> </Dialog>
</Transition> </Transition>
); );
} }

View file

@ -7,7 +7,7 @@ import type { DocumentListDocument } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import { DocumentPreview } from '@/components/doclist/DocumentPreview'; import { DocumentPreview } from '@/components/doclist/DocumentPreview';
import { formatDocumentSize } from '@/components/doclist/formatSize'; import { formatDocumentSize } from '@/components/doclist/formatSize';
import { buttonClass } from '@/components/ui/buttonPrimitives'; import { buttonClass } from '@/components/ui';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes'; import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';

View file

@ -7,6 +7,7 @@ import type { Folder, SidebarFilter } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons'; import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons';
import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons'; import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
import { IconButton, MenuItemClass, Sidebar as SidebarShell } from '@/components/ui';
interface FinderSidebarProps { interface FinderSidebarProps {
filter: SidebarFilter; filter: SidebarFilter;
@ -204,9 +205,9 @@ export function FinderSidebar({
}; };
return ( return (
<aside <SidebarShell
style={{ '--sidebar-width': `${width}px` } as CSSProperties} style={{ '--sidebar-width': `${width}px` } as CSSProperties}
className="relative h-full w-full md:[width:var(--sidebar-width)] bg-surface border-r border-line-soft shrink-0 flex flex-col" className="relative h-full w-full md:[width:var(--sidebar-width)] rounded-none border-y-0 border-l-0 border-r border-line-soft shadow-none shrink-0 flex flex-col"
> >
<div className="min-h-0 flex-1 overflow-y-auto"> <div className="min-h-0 flex-1 overflow-y-auto">
<div className="p-2 flex flex-col gap-0.5"> <div className="p-2 flex flex-col gap-0.5">
@ -272,7 +273,9 @@ export function FinderSidebar({
rightSlot={( rightSlot={(
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal"> <Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
<MenuButton <MenuButton
className="inline-flex items-center justify-center h-3.5 w-5 rounded-sm text-soft hover:text-accent transition-colors duration-base ease-standard focus:outline-none" as={IconButton}
size="xs"
className="h-3.5 w-5"
title="Folder actions" title="Folder actions"
aria-label="Folder actions" aria-label="Folder actions"
> >
@ -299,7 +302,7 @@ export function FinderSidebar({
onNewFolder(); onNewFolder();
onRowAction?.(); onRowAction?.();
}} }}
className={`${active ? 'bg-surface-sunken text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`} className={MenuItemClass(active)}
> >
<FolderPlusIcon className="h-4 w-4" /> <FolderPlusIcon className="h-4 w-4" />
New Folder New Folder
@ -315,7 +318,7 @@ export function FinderSidebar({
onRowAction?.(); onRowAction?.();
}} }}
disabled={disabled} disabled={disabled}
className={`${disabled ? 'text-faint cursor-not-allowed' : active ? 'bg-surface-sunken text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`} className={disabled ? 'flex w-full cursor-not-allowed items-center gap-2 rounded-md px-2 py-2 text-xs text-faint' : MenuItemClass(active)}
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
@ -372,6 +375,6 @@ export function FinderSidebar({
onPointerCancel={onResizeEnd} onPointerCancel={onResizeEnd}
className="hidden md:block absolute top-0 right-0 h-full w-1 cursor-col-resize hover:bg-accent-wash active:bg-accent transition-colors duration-base ease-standard" className="hidden md:block absolute top-0 right-0 h-full w-1 cursor-col-resize hover:bg-accent-wash active:bg-accent transition-colors duration-base ease-standard"
/> />
</aside> </SidebarShell>
); );
} }

View file

@ -10,6 +10,7 @@ import {
HamburgerIcon, HamburgerIcon,
} from './finderIcons'; } from './finderIcons';
import { ChevronUpDownIcon } from '@/components/icons/Icons'; import { ChevronUpDownIcon } from '@/components/icons/Icons';
import { Toolbar, ToolbarButton, ToolbarGroup, ToolbarSegment, toolbarButtonStyles } from '@/components/ui';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
interface FinderToolbarProps { interface FinderToolbarProps {
@ -52,22 +53,6 @@ const ICON_SIZES: Array<{ value: IconSize; label: string }> = [
{ value: 'xl', label: 'XL' }, { 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-surface text-xs transition duration-base ease-standard';
const TOOLBAR_BTN_INACTIVE =
'border-line text-foreground hover:text-accent hover:border-accent-line hover:bg-accent-wash';
const TOOLBAR_BTN_ACTIVE = 'border-accent-line bg-surface-sunken 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-line bg-surface p-0.5 gap-0.5 shrink-0';
const PILL_SEGMENT =
'inline-flex items-center justify-center rounded-sm text-xs transition-colors duration-base ease-standard';
const PILL_SEGMENT_INACTIVE = 'text-soft hover:bg-accent-wash hover:text-accent';
const PILL_SEGMENT_ACTIVE = 'bg-surface-sunken text-accent';
export function FinderToolbar({ export function FinderToolbar({
viewMode, viewMode,
onViewModeChange, onViewModeChange,
@ -89,26 +74,25 @@ export function FinderToolbar({
const directionLabel = sortDirection === 'asc' ? currentSort.asc : currentSort.desc; const directionLabel = sortDirection === 'asc' ? currentSort.asc : currentSort.desc;
return ( return (
<div className="sticky top-0 z-40 w-full border-b border-line-soft bg-surface"> <Toolbar>
<div className="px-2 sm:px-3 py-1 min-h-10 flex items-center gap-1.5 sm:gap-2">
{leftSlot && ( {leftSlot && (
<div className="shrink-0 flex items-center gap-2 pr-1 sm:pr-2 sm:border-r sm:border-line"> <div className="shrink-0 flex items-center gap-2 pr-1 sm:pr-2 sm:border-r sm:border-line">
{leftSlot} {leftSlot}
</div> </div>
)} )}
<button <ToolbarButton
type="button"
onClick={onToggleSidebar} onClick={onToggleSidebar}
className={`${TOOLBAR_BTN} ${isSidebarOpen ? TOOLBAR_BTN_ACTIVE : TOOLBAR_BTN_INACTIVE} shrink-0`} active={isSidebarOpen}
className="shrink-0"
aria-pressed={isSidebarOpen} aria-pressed={isSidebarOpen}
aria-label="Toggle sidebar" aria-label="Toggle sidebar"
title="Toggle sidebar" title="Toggle sidebar"
> >
<HamburgerIcon className="w-4 h-4" /> <HamburgerIcon className="w-4 h-4" />
</button> </ToolbarButton>
<div className={PILL}> <ToolbarGroup>
{VIEW_BUTTONS.map(({ value, label, Icon }) => { {VIEW_BUTTONS.map(({ value, label, Icon }) => {
const active = viewMode === value; const active = viewMode === value;
const isIconsToggle = value === 'icons'; const isIconsToggle = value === 'icons';
@ -117,65 +101,52 @@ export function FinderToolbar({
key={value} key={value}
className={isIconsToggle ? 'relative group/icons inline-flex items-center' : 'inline-flex items-center'} className={isIconsToggle ? 'relative group/icons inline-flex items-center' : 'inline-flex items-center'}
> >
<button <ToolbarSegment
type="button"
onClick={() => onViewModeChange(value)} onClick={() => onViewModeChange(value)}
active={active}
aria-pressed={active} aria-pressed={active}
aria-label={`${label} view`} aria-label={`${label} view`}
title={`${label} view`} title={`${label} view`}
className={ className="w-7"
PILL_SEGMENT +
' h-6 w-7 ' +
(active ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE)
}
> >
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
</button> </ToolbarSegment>
{isIconsToggle && viewMode === 'icons' && ( {isIconsToggle && viewMode === 'icons' && (
<div <div
className="absolute top-full left-1/2 z-30 -translate-x-1/2 pt-1 opacity-0 pointer-events-none transition-opacity duration-fast group-hover/icons:opacity-100 group-hover/icons:pointer-events-auto group-focus-within/icons:opacity-100 group-focus-within/icons:pointer-events-auto" className="absolute top-full left-1/2 z-30 -translate-x-1/2 pt-1 opacity-0 pointer-events-none transition-opacity duration-fast group-hover/icons:opacity-100 group-hover/icons:pointer-events-auto group-focus-within/icons:opacity-100 group-focus-within/icons:pointer-events-auto"
> >
<div className={`${PILL} shadow-elev-2`}> <ToolbarGroup className="shadow-elev-2">
{ICON_SIZES.map(({ value: sizeValue, label: sizeLabel }) => { {ICON_SIZES.map(({ value: sizeValue, label: sizeLabel }) => {
const sizeActive = iconSize === sizeValue; const sizeActive = iconSize === sizeValue;
return ( return (
<button <ToolbarSegment
key={sizeValue} key={sizeValue}
type="button"
onClick={() => onIconSizeChange(sizeValue)} onClick={() => onIconSizeChange(sizeValue)}
active={sizeActive}
aria-pressed={sizeActive} aria-pressed={sizeActive}
aria-label={`Icon size ${sizeLabel}`} aria-label={`Icon size ${sizeLabel}`}
className={ className="min-w-[26px] px-1.5 font-semibold tracking-wide"
PILL_SEGMENT +
' h-6 min-w-[26px] px-1.5 font-semibold tracking-wide ' +
(sizeActive ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE)
}
> >
{sizeLabel} {sizeLabel}
</button> </ToolbarSegment>
); );
})} })}
</div> </ToolbarGroup>
</div> </div>
)} )}
</div> </div>
); );
})} })}
</div> </ToolbarGroup>
{showSortControls && ( {showSortControls && (
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<button <ToolbarButton onClick={onSortDirectionToggle} className="whitespace-nowrap" title="Toggle sort direction">
type="button"
onClick={onSortDirectionToggle}
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} whitespace-nowrap`}
title="Toggle sort direction"
>
{directionLabel} {directionLabel}
</button> </ToolbarButton>
<Listbox value={sortBy} onChange={onSortByChange}> <Listbox value={sortBy} onChange={onSortByChange}>
<ListboxButton <ListboxButton
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} gap-1 min-w-[90px] justify-between`} className={toolbarButtonStyles({ className: 'gap-1 min-w-[90px] justify-between' })}
> >
<span>{currentSort.label}</span> <span>{currentSort.label}</span>
<ChevronUpDownIcon className="h-3 w-3 opacity-60" /> <ChevronUpDownIcon className="h-3 w-3 opacity-60" />
@ -220,7 +191,6 @@ export function FinderToolbar({
{rightSlot} {rightSlot}
</div> </div>
)} )}
</div> </Toolbar>
</div>
); );
} }

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'; import type { ReactNode } from 'react';
import { Fragment, useEffect, useState, type ReactNode } from 'react'; import { LibraryFrame, useIsNarrow } from '@/components/layout';
interface FinderWindowProps { interface FinderWindowProps {
toolbar: ReactNode; toolbar: ReactNode;
@ -14,22 +14,7 @@ interface FinderWindowProps {
onRequestSidebarClose?: () => void; onRequestSidebarClose?: () => void;
} }
const NARROW_QUERY = '(max-width: 767px)'; export { useIsNarrow };
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;
}
/** /**
* Finder-style file pane: sidebar + toolbar + content + status bar. * Finder-style file pane: sidebar + toolbar + content + status bar.
@ -43,60 +28,15 @@ export function FinderWindow({
sidebarOpen, sidebarOpen,
onRequestSidebarClose, onRequestSidebarClose,
}: FinderWindowProps) { }: FinderWindowProps) {
const isNarrow = useIsNarrow();
return ( return (
<div className="flex flex-col h-full w-full bg-surface-sunken overflow-hidden"> <LibraryFrame
{toolbar} toolbar={toolbar}
sidebar={sidebar}
<div className="flex flex-1 min-h-0"> statusBar={statusBar}
{!isNarrow && sidebarOpen && ( sidebarOpen={sidebarOpen}
<div className="h-full">{sidebar}</div> onRequestSidebarClose={onRequestSidebarClose}
)} >
{children}
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden"> </LibraryFrame>
{children}
</div>
</div>
{statusBar}
{/* Mobile drawer */}
<Transition show={isNarrow && sidebarOpen} as={Fragment}>
<Dialog
onClose={onRequestSidebarClose ?? (() => undefined)}
className="relative z-40 md:hidden"
>
<TransitionChild
as={Fragment}
enter="transition-opacity duration-fast"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-fast"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-y-0 left-0 flex max-w-full">
<TransitionChild
as={Fragment}
enter="transition-transform duration-base ease-standard"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition-transform duration-fast ease-standard"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full"
>
<DialogPanel
className="w-[80vw] max-w-[280px] h-full bg-surface shadow-elev-3"
>
{sidebar}
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</Transition>
</div>
); );
} }

View file

@ -1,12 +1,13 @@
'use client'; 'use client';
import { Fragment, useCallback, useEffect, useState } from 'react'; import { Fragment, useCallback, useEffect, useState } from 'react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Button } from '@headlessui/react'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie'; import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie';
import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents'; import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
import { useDocuments } from '@/contexts/DocumentContext'; import { useDocuments } from '@/contexts/DocumentContext';
import type { BaseDocument } from '@/types/documents'; import type { BaseDocument } from '@/types/documents';
import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents'; import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents';
import { Button, dialogPanelStyles } from '@/components/ui';
type DexieMigrationModalProps = { type DexieMigrationModalProps = {
isOpen: boolean; isOpen: boolean;
@ -161,7 +162,7 @@ export function DexieMigrationModal({
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<DialogPanel data-testid="migration-modal" className="w-full max-w-md transform rounded-lg bg-surface p-6 text-left align-middle shadow-elev-3 transition"> <DialogPanel data-testid="migration-modal" className={dialogPanelStyles({ size: 'md' })}>
<DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground mb-4"> <DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground mb-4">
{title} {title}
</DialogTitle> </DialogTitle>
@ -187,24 +188,18 @@ export function DexieMigrationModal({
<div className="flex justify-end gap-3 mt-6"> <div className="flex justify-end gap-3 mt-6">
<Button <Button
data-testid="migration-skip-button" data-testid="migration-skip-button"
variant="outline"
size="sm"
onClick={handleSkip} onClick={handleSkip}
disabled={isUploading} disabled={isUploading}
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-accent-wash focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-base ease-standard hover:text-accent
disabled:opacity-50"
> >
Skip Skip
</Button> </Button>
<Button <Button
variant="primary"
size="sm"
onClick={handleUpload} onClick={handleUpload}
disabled={isUploading} disabled={isUploading}
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 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
transform transition-transform duration-base ease-standard hover:text-background
disabled:opacity-50"
> >
{isUploading ? 'Uploading…' : 'Upload'} {isUploading ? 'Uploading…' : 'Upload'}
</Button> </Button>

View file

@ -3,7 +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/ui/buttonPrimitives'; import { Button, dialogPanelStyles } from '@/components/ui';
interface DocumentSelectionModalProps { interface DocumentSelectionModalProps {
isOpen: boolean; isOpen: boolean;
@ -139,7 +139,7 @@ export function DocumentSelectionModal({
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<DialogPanel className="w-full max-w-2xl transform rounded-lg bg-surface p-6 text-left align-middle shadow-elev-3 transition flex flex-col h-[80vh]"> <DialogPanel className={dialogPanelStyles({ size: 'lg', className: 'flex h-[80vh] flex-col' })}>
<DialogTitle <DialogTitle
as="h3" as="h3"
className="text-lg font-semibold leading-6 text-foreground mb-4 flex-shrink-0 flex justify-between items-center" className="text-lg font-semibold leading-6 text-foreground mb-4 flex-shrink-0 flex justify-between items-center"
@ -206,21 +206,17 @@ export function DocumentSelectionModal({
</div> </div>
<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 variant="outline" size="md" onClick={onClose}>
type="button"
className={buttonClass({ variant: 'outline', size: 'md' })}
onClick={onClose}
>
Cancel Cancel
</button> </Button>
<button <Button
type="button" variant="primary"
className={buttonClass({ variant: 'primary', size: 'md' })} size="md"
onClick={handleConfirmClick} onClick={handleConfirmClick}
disabled={isLoading || selectedCount === 0 || isProcessing} disabled={isLoading || selectedCount === 0 || isProcessing}
> >
{isProcessing ? 'Processing...' : `${confirmLabel} ${selectedCount > 0 ? `(${selectedCount})` : ''}`} {isProcessing ? 'Processing...' : `${confirmLabel} ${selectedCount > 0 ? `(${selectedCount})` : ''}`}
</button> </Button>
</div> </div>
</DialogPanel> </DialogPanel>
</TransitionChild> </TransitionChild>

View file

@ -6,6 +6,7 @@ import { UploadIcon } from '@/components/icons/Icons';
import { useDocuments } from '@/contexts/DocumentContext'; import { useDocuments } from '@/contexts/DocumentContext';
import { uploadDocxAsPdf } from '@/lib/client/api/documents'; import { uploadDocxAsPdf } from '@/lib/client/api/documents';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { dropzoneSurfaceClass } from '@/components/ui';
interface DocumentUploaderProps { interface DocumentUploaderProps {
className?: string; className?: string;
@ -155,24 +156,7 @@ export function DocumentUploader({
noKeyboard: variant === 'overlay' noKeyboard: variant === 'overlay'
}); });
const containerBase = `group w-full rounded transition duration-base ease-standard ${ const isDisabled = isUploading || isConverting;
isUploading || isConverting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
} ${className}`;
const borderBgClass =
variant === 'compact'
? `${
isDragActive
? 'border border-accent bg-surface-sunken text-accent'
: 'border border-dashed border-line text-foreground hover:border-accent hover:bg-accent-wash hover:text-accent'
}`
: `${
isDragActive
? 'border-2 border-dashed border-accent bg-surface text-foreground'
: 'border-2 border-dashed border-muted bg-transparent text-foreground hover:border-accent hover:bg-surface'
}`;
const paddingClass = variant === 'compact' ? 'py-1 px-2 rounded-md' : 'py-5 px-3 rounded-lg';
if (variant === 'overlay') { if (variant === 'overlay') {
const rootProps = getRootProps(); const rootProps = getRootProps();
@ -183,7 +167,7 @@ export function DocumentUploader({
{isDragActive && ( {isDragActive && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-surface backdrop-blur-md pointer-events-none p-6"> <div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-surface backdrop-blur-md pointer-events-none p-6">
<div className="w-full h-full border-2 border-dashed border-accent rounded-lg flex flex-col items-center justify-center bg-surface-solid text-center p-4"> <div className="w-full h-full border-2 border-dashed border-accent rounded-lg flex flex-col items-center justify-center bg-surface-solid text-center p-4">
<UploadIcon className="w-14 h-14 text-accent mb-4 animate-bounce" /> <UploadIcon className="w-14 h-14 text-accent mb-4" />
<p className="text-xl font-bold text-foreground mb-1.5"> <p className="text-xl font-bold text-foreground mb-1.5">
Drop files here to upload Drop files here to upload
</p> </p>
@ -212,7 +196,12 @@ export function DocumentUploader({
return ( return (
<div <div
{...getRootProps()} {...getRootProps()}
className={`${containerBase} ${borderBgClass} ${paddingClass}`} className={dropzoneSurfaceClass({
variant: variant === 'compact' ? 'compact' : 'default',
active: isDragActive,
disabled: isDisabled,
className,
})}
> >
<input {...getInputProps()} /> <input {...getInputProps()} />
{variant === 'compact' ? ( {variant === 'compact' ? (

View file

@ -1,4 +1,4 @@
import { buttonClass } from '@/components/ui/buttonPrimitives'; import { buttonClass } from '@/components/ui';
export function ZoomControl({ export function ZoomControl({
value, value,

View file

@ -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 (
<div className={cn('sticky top-0 z-40 w-full border-b border-line-soft bg-surface', className)} data-app-header>
<div className="px-2 sm:px-3 py-1 flex items-center justify-between gap-2 min-h-10">
<div className="flex items-center gap-2 min-w-0 flex-1">
{left}
{typeof title === 'string' ? (
<h1 className="text-xs md:text-sm font-semibold truncate text-foreground tracking-tight">{title}</h1>
) : (
title
)}
</div>
<div className="flex items-center gap-2 min-w-0 justify-end">{right}</div>
</div>
</div>
);
}

View file

@ -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 (
<div className={cn('app-shell h-dvh flex flex-col bg-background overflow-hidden', className)}>
{children}
</div>
);
}
export function AppMain({ children, className }: { children: ReactNode; className?: string }) {
return <main className={cn('flex-1 min-h-0 flex flex-col', className)}>{children}</main>;
}

View file

@ -0,0 +1,99 @@
'use client';
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react';
import { Fragment, useEffect, useState, type ReactNode } from 'react';
import { cn } from '@/components/ui/cn';
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 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 (
<div className={cn('flex h-full w-full flex-col overflow-hidden bg-surface-sunken', className)}>
{toolbar}
<div className="flex flex-1 min-h-0">
{!isNarrow && sidebarOpen && <div className="h-full">{sidebar}</div>}
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">{children}</div>
</div>
{statusBar}
<LibrarySidebarDrawer open={isNarrow && sidebarOpen} onClose={onRequestSidebarClose}>
{sidebar}
</LibrarySidebarDrawer>
</div>
);
}
function LibrarySidebarDrawer({
open,
onClose,
children,
}: {
open: boolean;
onClose?: () => void;
children: ReactNode;
}) {
return (
<Transition show={open} as={Fragment}>
<Dialog onClose={onClose ?? (() => undefined)} className="relative z-40 md:hidden">
<TransitionChild
as={Fragment}
enter="transition-opacity duration-fast"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-fast"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-y-0 left-0 flex max-w-full">
<TransitionChild
as={Fragment}
enter="transition-transform duration-base ease-standard"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition-transform duration-fast ease-standard"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full"
>
<DialogPanel className="h-full w-[80vw] max-w-[280px] bg-surface shadow-elev-3">
{children}
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</Transition>
);
}

View file

@ -0,0 +1,3 @@
export * from './AppHeader';
export * from './AppShell';
export * from './LibraryFrame';

View file

@ -1,7 +1,8 @@
'use client'; 'use client';
import { Button, Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { IconButton, Input, cn, popoverPanelClass, popoverTriggerClass } from '@/components/ui';
export const Navigator = ({ currentPage, numPages, skipToLocation }: { export const Navigator = ({ currentPage, numPages, skipToLocation }: {
currentPage: number; currentPage: number;
@ -53,36 +54,37 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
return ( return (
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
{/* Page back */} {/* Page back */}
<Button <IconButton
onClick={() => skipToLocation(currentPage - 1, true)} onClick={() => skipToLocation(currentPage - 1, true)}
disabled={currentPage <= 1} disabled={currentPage <= 1}
className="relative p-2 rounded-full text-foreground hover:bg-accent-wash data-[hover]:bg-accent-wash data-[active]:bg-accent-wash transition duration-base focus:outline-none disabled:opacity-50 transform ease-standard hover:text-accent" className="rounded-full"
aria-label="Previous page" aria-label="Previous page"
> >
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 19l-7-7m0 0l7-7m-7 7h18" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
</Button> </IconButton>
{/* Page number popup */} {/* Page number popup */}
<Popover className="relative mb-1"> <Popover className="relative mb-1">
<PopoverButton <PopoverButton
className="bg-surface-sunken px-2 py-0.5 rounded-full focus:outline-none cursor-pointer hover:bg-accent-wash transform transition-transform duration-base ease-standard hover:text-accent" className={cn(popoverTriggerClass, 'rounded-full bg-surface-sunken px-2 py-0.5 text-xs')}
onClick={handlePopoverOpen} onClick={handlePopoverOpen}
> >
<p className="text-xs whitespace-nowrap"> <p className="text-xs whitespace-nowrap">
{currentPage} / {numPages || 1} {currentPage} / {numPages || 1}
</p> </p>
</PopoverButton> </PopoverButton>
<PopoverPanel anchor="top" className="absolute z-50 bg-surface p-3 rounded-md shadow-elev-2 border border-line"> <PopoverPanel anchor="top" className={popoverPanelClass}>
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<div className="text-xs font-medium text-foreground">Go to page</div> <div className="text-xs font-medium text-foreground">Go to page</div>
<input <Input
ref={inputRef} ref={inputRef}
type="text" type="text"
inputMode="numeric" inputMode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
className="w-20 px-2 py-1 text-xs text-accent bg-surface-sunken rounded border-none outline-none appearance-none text-center" controlSize="sm"
className="w-20 appearance-none border-none text-center text-accent"
value={inputValue} value={inputValue}
onChange={handleInputChange} onChange={handleInputChange}
onBlur={handleInputConfirm} onBlur={handleInputConfirm}
@ -96,16 +98,16 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
</Popover> </Popover>
{/* Page forward */} {/* Page forward */}
<Button <IconButton
onClick={() => skipToLocation(currentPage + 1, true)} onClick={() => skipToLocation(currentPage + 1, true)}
disabled={currentPage >= (numPages || 1)} disabled={currentPage >= (numPages || 1)}
className="relative p-2 rounded-full text-foreground hover:bg-accent-wash data-[hover]:bg-accent-wash data-[active]:bg-accent-wash transition duration-base focus:outline-none disabled:opacity-50 transform ease-standard hover:text-accent" className="rounded-full"
aria-label="Next page" aria-label="Next page"
> >
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg> </svg>
</Button> </IconButton>
</div> </div>
); );
} }

View file

@ -1,8 +1,8 @@
'use client'; 'use client';
import { Button } from '@headlessui/react';
import { PauseIcon } from '@/components/icons/Icons'; import { PauseIcon } from '@/components/icons/Icons';
import { useTTS } from '@/contexts/TTSContext'; import { useTTS } from '@/contexts/TTSContext';
import { IconButton } from '@/components/ui';
export function RateLimitPauseButton() { export function RateLimitPauseButton() {
const { isPlaying, togglePlay } = useTTS(); const { isPlaying, togglePlay } = useTTS();
@ -12,14 +12,13 @@ export function RateLimitPauseButton() {
if (!isPlaying) return null; if (!isPlaying) return null;
return ( return (
<Button <IconButton
onClick={() => { onClick={() => {
if (isPlaying) togglePlay(); if (isPlaying) togglePlay();
}} }}
className="relative p-1.5 rounded-md text-foreground hover:bg-accent-wash transition duration-base focus:outline-none h-8 w-8 flex items-center justify-center transform ease-standard hover:text-accent"
aria-label="Pause" aria-label="Pause"
> >
<PauseIcon className="w-5 h-5" /> <PauseIcon className="w-5 h-5" />
</Button> </IconButton>
); );
} }

View file

@ -1,10 +1,11 @@
'use client'; 'use client';
import { Input, Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons'; import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons';
import { useConfig } from '@/contexts/ConfigContext'; import { useConfig } from '@/contexts/ConfigContext';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { popoverPanelClass, popoverTriggerClass, rangeInputClass } from '@/components/ui';
export const SpeedControl = ({ export const SpeedControl = ({
setSpeedAndRestart, setSpeedAndRestart,
@ -88,13 +89,13 @@ export const SpeedControl = ({
return ( return (
<Popover className="relative"> <Popover className="relative">
<PopoverButton className="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-accent-wash rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-base ease-standard hover:text-accent"> <PopoverButton className={`${popoverTriggerClass} space-x-0.5 px-1.5 py-0.5 text-xs sm:space-x-1 sm:px-2 sm:py-1 sm:text-sm`}>
<SpeedometerIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" /> <SpeedometerIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
<span className="sm:hidden">{compactTriggerLabel}</span> <span className="sm:hidden">{compactTriggerLabel}</span>
<span className="hidden sm:inline">{triggerLabel}</span> <span className="hidden sm:inline">{triggerLabel}</span>
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" /> <ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
</PopoverButton> </PopoverButton>
<PopoverPanel anchor="top" className="absolute z-50 bg-surface p-3 rounded-md shadow-elev-2 border border-line"> <PopoverPanel anchor="top" className={popoverPanelClass}>
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
{!nativeSpeedSupported && ( {!nativeSpeedSupported && (
<div className="rounded-md border border-line bg-background px-2 py-1.5 text-[11px] text-soft"> <div className="rounded-md border border-line bg-background px-2 py-1.5 text-[11px] text-soft">
@ -112,7 +113,7 @@ export const SpeedControl = ({
</span> </span>
<span className="text-xs">{max.toFixed(1)}x</span> <span className="text-xs">{max.toFixed(1)}x</span>
</div> </div>
<Input <input
type="range" type="range"
min={min} min={min}
max={max} max={max}
@ -122,7 +123,7 @@ export const SpeedControl = ({
onMouseUp={handleVoiceSpeedChangeComplete} onMouseUp={handleVoiceSpeedChangeComplete}
onKeyUp={handleVoiceSpeedChangeComplete} onKeyUp={handleVoiceSpeedChangeComplete}
onTouchEnd={handleVoiceSpeedChangeComplete} onTouchEnd={handleVoiceSpeedChangeComplete}
className="w-full bg-surface-sunken rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-surface-sunken [&::-webkit-slider-runnable-track]:rounded-lg [&::-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 [&::-moz-range-track]:bg-surface-sunken [&::-moz-range-track]:rounded-lg [&::-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" className={rangeInputClass}
/> />
</div> </div>
)} )}
@ -136,7 +137,7 @@ export const SpeedControl = ({
</span> </span>
<span className="text-xs">{max.toFixed(1)}x</span> <span className="text-xs">{max.toFixed(1)}x</span>
</div> </div>
<Input <input
type="range" type="range"
min={min} min={min}
max={max} max={max}
@ -146,7 +147,7 @@ export const SpeedControl = ({
onMouseUp={handleAudioSpeedChangeComplete} onMouseUp={handleAudioSpeedChangeComplete}
onKeyUp={handleAudioSpeedChangeComplete} onKeyUp={handleAudioSpeedChangeComplete}
onTouchEnd={handleAudioSpeedChangeComplete} onTouchEnd={handleAudioSpeedChangeComplete}
className="w-full bg-surface-sunken rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-surface-sunken [&::-webkit-slider-runnable-track]:rounded-lg [&::-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 [&::-moz-range-track]:bg-surface-sunken [&::-moz-range-track]:rounded-lg [&::-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" className={rangeInputClass}
/> />
</div> </div>
</div> </div>

View file

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useTTS } from '@/contexts/TTSContext'; import { useTTS } from '@/contexts/TTSContext';
import { Button } from '@headlessui/react';
import { import {
PlayIcon, PlayIcon,
PauseIcon, PauseIcon,
@ -12,6 +11,7 @@ import { LoadingSpinner } from '@/components/Spinner';
import { VoicesControl } from '@/components/player/VoicesControl'; import { VoicesControl } from '@/components/player/VoicesControl';
import { SpeedControl } from '@/components/player/SpeedControl'; import { SpeedControl } from '@/components/player/SpeedControl';
import { Navigator } from '@/components/player/Navigator'; import { Navigator } from '@/components/player/Navigator';
import { IconButton } from '@/components/ui';
export default function TTSPlayer({ currentPage, numPages }: { export default function TTSPlayer({ currentPage, numPages }: {
currentPage?: number; currentPage?: number;
@ -49,32 +49,29 @@ export default function TTSPlayer({ currentPage, numPages }: {
)} )}
{/* Playback Controls */} {/* Playback Controls */}
<Button <IconButton
onClick={skipBackward} onClick={skipBackward}
className="relative p-1.5 rounded-md text-foreground hover:bg-accent-wash transition duration-base focus:outline-none disabled:opacity-50 h-8 w-8 flex items-center justify-center transform ease-standard hover:text-accent"
aria-label="Skip backward" aria-label="Skip backward"
disabled={isProcessing} disabled={isProcessing}
> >
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon className="w-5 h-5" />} {isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon className="w-5 h-5" />}
</Button> </IconButton>
<Button <IconButton
onClick={togglePlay} onClick={togglePlay}
className="relative p-1.5 rounded-md text-foreground hover:bg-accent-wash transition duration-base focus:outline-none h-8 w-8 flex items-center justify-center transform ease-standard hover:text-accent"
aria-label={isPlaying ? 'Pause' : 'Play'} aria-label={isPlaying ? 'Pause' : 'Play'}
disabled={isProcessing && !isPlaying} disabled={isProcessing && !isPlaying}
> >
{isPlaying ? <PauseIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />} {isPlaying ? <PauseIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />}
</Button> </IconButton>
<Button <IconButton
onClick={skipForward} onClick={skipForward}
className="relative p-1.5 rounded-md text-foreground hover:bg-accent-wash transition duration-base focus:outline-none disabled:opacity-50 h-8 w-8 flex items-center justify-center transform ease-standard hover:text-accent"
aria-label="Skip forward" aria-label="Skip forward"
disabled={isProcessing} disabled={isProcessing}
> >
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon className="w-5 h-5" />} {isProcessing ? <LoadingSpinner /> : <SkipForwardIcon className="w-5 h-5" />}
</Button> </IconButton>
{/* Voice control */} {/* Voice control */}
<VoicesControl availableVoices={availableVoices} setVoiceAndRestart={setVoiceAndRestart} /> <VoicesControl availableVoices={availableVoices} setVoiceAndRestart={setVoiceAndRestart} />

View file

@ -0,0 +1,40 @@
import { cn } from './cn';
import { variants } from './variants';
export type DropzoneVariant = 'default' | 'compact';
export const dropzoneStyles = variants({
base: 'group w-full cursor-pointer border-dashed text-foreground transition-colors duration-base ease-standard disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
variants: {
variant: {
default: 'rounded-lg border-2 px-3 py-5',
compact: 'rounded-md border px-2 py-1',
},
active: {
true: 'border-accent bg-surface text-accent',
false: 'border-line bg-transparent hover:border-accent hover:bg-accent-wash hover:text-accent',
},
},
defaults: {
variant: 'default',
active: 'false',
},
});
export function dropzoneSurfaceClass({
variant = 'default',
active = false,
disabled = false,
className,
}: {
variant?: DropzoneVariant;
active?: boolean;
disabled?: boolean;
className?: string;
}) {
return dropzoneStyles({
variant,
active: active ? 'true' : 'false',
className: cn(disabled && 'cursor-not-allowed opacity-50', className),
});
}

View file

@ -1,4 +1,4 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'; import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
import { cn } from './cn'; import { cn } from './cn';
import { variants } from './variants'; import { variants } from './variants';
import { focusRing, motionColors } from './tokens'; import { focusRing, motionColors } from './tokens';
@ -27,21 +27,21 @@ export const iconButtonStyles = variants({
}, },
}); });
export function IconButton({ export const IconButton = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement> & {
children: ReactNode;
tone?: IconButtonTone;
size?: IconButtonSize;
}>(function IconButton({
className, className,
children, children,
tone = 'ghost', tone = 'ghost',
size = 'md', size = 'md',
type = 'button', type = 'button',
...props ...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { }, ref) {
children: ReactNode;
tone?: IconButtonTone;
size?: IconButtonSize;
}) {
return ( return (
<button type={type} className={iconButtonStyles({ tone, size, className })} {...props}> <button ref={ref} type={type} className={iconButtonStyles({ tone, size, className })} {...props}>
{children} {children}
</button> </button>
); );
} });

View file

@ -3,10 +3,13 @@ export * from './button';
export * from './cn'; export * from './cn';
export * from './dialog'; export * from './dialog';
export * from './divider'; export * from './divider';
export * from './dropzone';
export * from './field'; export * from './field';
export * from './icon-button'; export * from './icon-button';
export * from './input'; export * from './input';
export * from './menu'; export * from './menu';
export * from './popover';
export * from './range';
export * from './section'; export * from './section';
export * from './select'; export * from './select';
export * from './sidebar'; export * from './sidebar';

View file

@ -1,4 +1,4 @@
import type { InputHTMLAttributes, TextareaHTMLAttributes } from 'react'; import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react';
import { cn } from './cn'; import { cn } from './cn';
import { variants } from './variants'; import { variants } from './variants';
import { motionColors } from './tokens'; import { motionColors } from './tokens';
@ -21,13 +21,13 @@ export const inputStyles = variants({
export const inputClass = inputStyles(); export const inputClass = inputStyles();
export function Input({ export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement> & { controlSize?: InputControlSize }>(function Input({
className, className,
controlSize = 'md', controlSize = 'md',
...props ...props
}: InputHTMLAttributes<HTMLInputElement> & { controlSize?: InputControlSize }) { }, ref) {
return <input className={inputStyles({ size: controlSize, className })} {...props} />; return <input ref={ref} className={inputStyles({ size: controlSize, className })} {...props} />;
} });
export function Textarea({ export function Textarea({
className, className,

View file

@ -0,0 +1,9 @@
import { cn } from './cn';
export const popoverPanelClass = cn(
'z-50 rounded-md border border-line bg-surface p-3 shadow-elev-2 focus:outline-none',
);
export const popoverTriggerClass = cn(
'inline-flex items-center rounded-md text-foreground hover:bg-accent-wash hover:text-accent focus:outline-none transition-colors duration-fast ease-standard',
);

View file

@ -0,0 +1,9 @@
import { cn } from './cn';
export const rangeInputClass = cn(
'w-full cursor-pointer appearance-none rounded-lg bg-surface-sunken accent-accent',
'[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-runnable-track]:bg-surface-sunken',
'[&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent',
'[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-lg [&::-moz-range-track]:bg-surface-sunken',
'[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-accent',
);

View file

@ -1,4 +1,4 @@
import type { HTMLAttributes, ReactNode } from 'react'; import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react';
import { cn } from './cn'; import { cn } from './cn';
import { variants } from './variants'; import { variants } from './variants';
import { motionColors } from './tokens'; import { motionColors } from './tokens';
@ -34,7 +34,7 @@ export function ToolbarButton({
children, children,
type = 'button', type = 'button',
...props ...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { }: ButtonHTMLAttributes<HTMLButtonElement> & {
active?: boolean; active?: boolean;
children: ReactNode; children: ReactNode;
}) { }) {
@ -63,7 +63,7 @@ export function ToolbarSegment({
children, children,
type = 'button', type = 'button',
...props ...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { }: ButtonHTMLAttributes<HTMLButtonElement> & {
active?: boolean; active?: boolean;
children: ReactNode; children: ReactNode;
}) { }) {