phase 2: primitives and semantic tokens

This commit is contained in:
Richard R 2026-05-31 20:15:44 -06:00
parent 11e138ae36
commit 745c846a6e
28 changed files with 196 additions and 108 deletions

View file

@ -170,7 +170,7 @@ export default function EPUBPage() {
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-red-500 mb-4">{error}</p>
<p className="text-danger mb-4">{error}</p>
<Link
href="/app"
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition duration-base ease-standard hover:text-accent"

View file

@ -157,7 +157,7 @@ export default function HTMLPage() {
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-red-500 mb-4">{error}</p>
<p className="text-danger mb-4">{error}</p>
<Link
href="/app"
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition duration-base ease-standard hover:text-accent"

View file

@ -251,7 +251,7 @@ export default function PDFViewerPage() {
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-red-500 mb-4">{error}</p>
<p className="text-danger mb-4">{error}</p>
<Link
href="/app"
onClick={handleBackToDocuments}
@ -311,18 +311,18 @@ export default function PDFViewerPage() {
<div className="h-full w-full bg-base">
<div className={`mx-auto flex h-full items-center px-4 py-6 transition duration-slow ease-standard ${showDetailedParseLoader ? 'max-w-lg' : 'max-w-md'}`}>
{showDetailedParseLoader ? (
<div className="w-full rounded-xl border border-offbase bg-offbase/95 shadow-sm overflow-hidden">
<div className="w-full rounded-xl border border-offbase bg-surface-sunken shadow-sm overflow-hidden">
<div className="h-1 bg-[linear-gradient(90deg,var(--accent),transparent_80%)]" />
<div className="p-3.5 sm:p-4">
<div className="space-y-1.5">
<div className="inline-flex items-center gap-2 rounded-md border border-offbase bg-base/70 px-2.5 py-1">
<div className="inline-flex items-center gap-2 rounded-md border border-offbase bg-surface-solid px-2.5 py-1">
<LoadingSpinner className="h-3.5 w-3.5 text-accent" />
<span className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted">PDF Layout Parse</span>
</div>
<p className="text-sm font-semibold text-foreground">{statusText}</p>
</div>
<div className="mt-3 rounded-lg border border-offbase bg-base/75 p-2.5">
<div className="mt-3 rounded-lg border border-offbase bg-surface-solid p-2.5">
<div className="mb-1.5 flex items-end justify-between gap-2">
<p className="text-[11px] font-semibold text-foreground">
{hasMeasuredProgress ? `Page ${pagesParsed} / ${totalPages}` : 'Awaiting first page'}
@ -354,7 +354,7 @@ export default function PDFViewerPage() {
</div>
</div>
) : (
<div className="w-full rounded-xl border border-offbase bg-offbase/95 p-4 shadow-sm transition duration-slow ease-standard overflow-hidden">
<div className="w-full rounded-xl border border-offbase bg-surface-sunken p-4 shadow-sm transition duration-slow ease-standard overflow-hidden">
<div className="h-0.5 -mx-4 -mt-4 mb-3 bg-[linear-gradient(90deg,var(--accent),transparent_75%)]" />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
@ -366,9 +366,9 @@ export default function PDFViewerPage() {
</span>
</div>
<div className="mt-3 grid grid-cols-3 gap-1.5">
<span className="h-1.5 rounded-full bg-accent/30 animate-pulse" />
<span className="h-1.5 rounded-full bg-accent/20 animate-pulse [animation-delay:120ms]" />
<span className="h-1.5 rounded-full bg-accent/15 animate-pulse [animation-delay:220ms]" />
<span className="h-1.5 rounded-full bg-accent-wash animate-pulse" />
<span className="h-1.5 rounded-full bg-accent-wash animate-pulse [animation-delay:120ms]" />
<span className="h-1.5 rounded-full bg-accent-wash animate-pulse [animation-delay:220ms]" />
</div>
</div>
)}

View file

@ -132,16 +132,16 @@ function SignInContent() {
{/* Alerts */}
{sessionExpired && (
<div className="mt-4 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg">
<p className="text-sm text-amber-700 dark:text-amber-400">
<div className="mt-4 p-3 bg-accent-wash border border-accent-line rounded-lg">
<p className="text-sm text-accent ">
Your session has expired. Please sign in again.
</p>
</div>
)}
{error && (
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
<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>
</div>
)}

View file

@ -125,7 +125,7 @@ export default function SignUpPage() {
const { checks, strength } = validatePassword(password);
const strengthLabels = ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong'];
const strengthColors = ['bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-blue-500', 'bg-green-500'];
const strengthColors = ['bg-danger', 'bg-danger', 'bg-accent', 'bg-accent', 'bg-accent'];
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
@ -134,8 +134,8 @@ export default function SignUpPage() {
<p className="text-sm text-muted mt-1">Create your account to get started</p>
{error && (
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
<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>
</div>
)}
@ -190,12 +190,12 @@ export default function SignUpPage() {
/>
))}
</div>
<p className={`text-xs ${strength >= 3 ? 'text-green-600' : 'text-red-600'}`}>
<p className={`text-xs ${strength >= 3 ? 'text-accent' : 'text-danger'}`}>
{strengthLabels[strength - 1] || 'Very Weak'}
</p>
<div className="text-xs space-y-0.5 text-muted">
{Object.entries(checks).map(([key, passed]) => (
<div key={key} className={`flex items-center gap-1 ${passed ? 'text-green-600' : ''}`}>
<div key={key} className={`flex items-center gap-1 ${passed ? 'text-accent' : ''}`}>
<span>{passed ? '✓' : '○'}</span>
<span>
{key === 'length' && 'At least 8 characters'}
@ -223,7 +223,7 @@ export default function SignUpPage() {
focus:outline-none focus:ring-2 focus:ring-accent"
/>
{passwordConfirmation && password && (
<p className={`text-xs mt-1 ${password === passwordConfirmation ? 'text-green-600' : 'text-red-600'}`}>
<p className={`text-xs mt-1 ${password === passwordConfirmation ? 'text-accent' : 'text-danger'}`}>
{password === passwordConfirmation ? '✓ Passwords match' : '✗ Passwords do not match'}
</p>
)}

View file

@ -490,7 +490,7 @@ export function AudiobookExportModal({
<div className="p-4">
{isLegacyAudiobookMissingSettings && (
<div className="mb-4 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-xs text-foreground">
<div className="mb-4 rounded-lg border border-accent-line bg-accent-wash p-3 text-xs text-foreground">
<div className="font-medium">Saved generation settings not found</div>
<div className="mt-1 text-muted">
This audiobook was likely created before v1 metadata was introduced, so OpenReader can&apos;t know
@ -694,9 +694,9 @@ export function AudiobookExportModal({
<Button
onClick={() => setShowResetConfirm(true)}
disabled={isGenerating}
className="inline-flex justify-center rounded-lg border border-red-500 bg-transparent px-3 py-2 text-sm
font-medium text-red-500 hover:bg-red-500 hover:text-background focus:outline-none
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
className="inline-flex justify-center rounded-lg border border-danger bg-transparent px-3 py-2 text-sm
font-medium text-danger hover:bg-danger-wash hover:text-background focus:outline-none
focus-visible:ring-2 focus-visible:ring-danger focus-visible:ring-offset-2
transform transition-transform duration-base ease-standard"
title="Delete all generated chapters/pages for this document"
>
@ -760,7 +760,7 @@ export function AudiobookExportModal({
<Button
onClick={() => handleRegenerateChapter(chapter)}
disabled={regeneratingChapter !== null || chapter.status === 'generating' || isGenerating}
className="inline-flex items-center justify-center rounded-full bg-offbase text-accent hover:bg-accent/20 p-1.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transform transition-transform duration-base ease-standard disabled:opacity-50 disabled:cursor-not-allowed"
className="inline-flex items-center justify-center rounded-full bg-offbase text-accent hover:bg-accent-wash p-1.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transform transition-transform duration-base ease-standard disabled:opacity-50 disabled:cursor-not-allowed"
title={chapter.status === 'generating' ? 'Generating...' : 'Regenerate this chapter'}
>
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index || chapter.status === 'generating' ? 'animate-spin' : ''}`} />
@ -807,7 +807,7 @@ export function AudiobookExportModal({
{({ active }) => (
<button
onClick={() => setPendingDeleteChapter(chapter)}
className={`${active ? 'bg-offbase' : ''} text-red-500 group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
className={`${active ? 'bg-offbase' : ''} text-danger group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
title="Delete this chapter"
>
<XCircleIcon className="h-4 w-4" />
@ -833,7 +833,7 @@ export function AudiobookExportModal({
{({ active }) => (
<button
onClick={handleCancel}
className={`${active ? 'bg-offbase text-red-500' : 'text-red-500'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
className={`${active ? 'bg-offbase text-danger' : 'text-danger'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
title="Cancel this chapter regeneration"
>
<XCircleIcon className="h-4 w-4" />

View file

@ -70,7 +70,7 @@ export function ConfirmDialog({
{title}
</DialogTitle>
<div className="mt-2">
<p className="text-sm text-foreground/90 break-words">{message}</p>
<p className="text-sm text-soft break-words">{message}</p>
</div>
<div className="mt-6 flex justify-end space-x-3">

View file

@ -48,7 +48,7 @@ export function CookieConsentBanner() {
<h3 className="mb-2 text-lg font-bold">
🍪 We use cookies
</h3>
<p className="text-sm leading-relaxed text-foreground/90">
<p className="text-sm leading-relaxed text-soft">
We use strictly necessary cookies for authentication. Optional analytics is enabled only when you consent.
See our <Link href="/privacy" className="font-medium text-accent hover:underline">Privacy Policy</Link> for details.
</p>

View file

@ -19,8 +19,8 @@ interface PrivacyModalProps {
function PrivacyModalBody({ origin }: { origin: string }) {
return (
<div className="mt-4 space-y-4 text-sm text-foreground/90">
<div className="rounded-lg border border-offbase bg-offbase/40 p-3">
<div className="mt-4 space-y-4 text-sm text-soft">
<div className="rounded-lg border border-offbase bg-surface-sunken p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Service Operator</div>
<div className="mt-1">
This instance is hosted at <span className="font-bold">{origin || 'this server'}</span>.
@ -113,7 +113,7 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
<PrivacyModalBody origin={origin} />
<div className="mt-6 space-y-4">
<div className="flex items-start gap-3 rounded-lg border border-offbase p-3 bg-offbase/20">
<div className="flex items-start gap-3 rounded-lg border border-offbase p-3 bg-surface-sunken">
<div className="flex h-6 items-center">
<input
data-testid="privacy-agree-checkbox"
@ -121,7 +121,7 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-accent focus:ring-accent bg-base"
className="h-4 w-4 rounded border-line text-accent focus:ring-accent bg-base"
/>
</div>
<div className="text-sm leading-6">

View file

@ -57,7 +57,7 @@ export function ProgressCard({
className={buttonClass({
variant: 'ghost',
size: 'xs',
className: 'shrink-0 hover:bg-background/50',
className: 'shrink-0 hover:bg-surface-sunken',
})}
onClick={(e) => onCancel(e)}
>

View file

@ -630,7 +630,7 @@ export function SettingsModal({
<div className="space-y-1.5">
<label className={fieldLabelClass}>TTS Provider</label>
{ttsProviders.length === 0 ? (
<p className="text-xs text-amber-500">
<p className="text-xs text-accent">
User API keys are restricted and no shared provider is configured. Ask an admin to add one.
</p>
) : (
@ -1253,7 +1253,7 @@ export function SettingsModal({
</Button>
<div className="pt-4 mt-4 border-t border-offbase">
<label className="block text-sm font-medium text-red-500 mb-2">Danger Zone</label>
<label className="block text-sm font-medium text-danger mb-2">Danger Zone</label>
<Button
onClick={() => setShowDeleteAccountConfirm(true)}
className={buttonClass({ variant: 'danger', size: 'md' })}

View file

@ -674,7 +674,7 @@ export function AdminProvidersPanel() {
type="button"
onClick={() => setDefault(p.slug)}
disabled={disabled}
className={`${disabled ? 'text-muted/60 cursor-not-allowed' : active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
className={`${disabled ? 'text-faint cursor-not-allowed' : active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
Set as default
</button>
@ -696,7 +696,7 @@ export function AdminProvidersPanel() {
<button
type="button"
onClick={() => remove(p.id)}
className={`${active ? 'bg-offbase' : ''} text-red-500 flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
className={`${active ? 'bg-offbase' : ''} text-danger flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
Delete
</button>

View file

@ -112,9 +112,9 @@ export default function ClaimDataModal({
Claim it now to attach it to your account.
</p>
<div className="mb-4 rounded-lg border border-offbase bg-offbase/40 p-3">
<div className="mb-4 rounded-lg border border-offbase bg-surface-sunken p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Claimable data</div>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-foreground/90">
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-soft">
<li>{claimableCounts.documents} document(s)</li>
<li>{claimableCounts.audiobooks} audiobook(s)</li>
<li>{claimableCounts.preferences} preference set(s)</li>
@ -122,7 +122,7 @@ export default function ClaimDataModal({
</ul>
</div>
<p className="text-xs text-muted/70 mb-6 italic">
<p className="text-xs text-faint mb-6 italic">
First user to claim this data will own it and revoke access for anyone else.
</p>

View file

@ -19,13 +19,13 @@ export function RateLimitBanner({ className = '' }: RateLimitBannerProps) {
const isAnonymous = status.userType === 'anonymous';
return (
<div className={`bg-amber-500/10 border border-amber-500/30 rounded-lg px-3 py-2 ${className}`}>
<div className={`bg-accent-wash border border-accent-line rounded-lg px-3 py-2 ${className}`}>
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div className="text-xs sm:text-sm">
<span className="font-medium text-amber-700 dark:text-amber-400">
<span className="font-medium text-accent ">
Daily TTS limit reached.
</span>
<span className="text-amber-600 dark:text-amber-500 ml-1.5">
<span className="text-accent ml-1.5">
{`Used ${formatCharCount(status.currentCount)} / ${formatCharCount(status.limit)} characters.`}
{' Resets in '}{timeUntilReset}.
</span>
@ -64,7 +64,7 @@ export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) {
if (isAtLimit) {
return (
<span className={`text-xs font-medium text-amber-600 dark:text-amber-400 ${className}`}>
<span className={`text-xs font-medium text-accent ${className}`}>
Limit reached
</span>
);

View file

@ -139,7 +139,7 @@ function SidebarUploadLoader({
const dashOffset = circumference - (progress / 100) * circumference;
return (
<div className="rounded-md border border-offbase bg-offbase/60 px-2 py-1.5">
<div className="rounded-md border border-offbase bg-surface-sunken px-2 py-1.5">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex items-center gap-1.5 text-[11px] leading-tight">
<span className="font-medium text-foreground">{label}</span>

View file

@ -285,16 +285,16 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
</>
) : textPreview ? (
<>
<div className="absolute inset-0 bg-gradient-to-br from-slate-50 to-slate-200" />
<div className="absolute inset-0 bg-gradient-to-br from-surface-solid to-surface-sunken" />
<div className="absolute inset-0 opacity-[0.06] bg-[radial-gradient(circle_at_1px_1px,rgba(0,0,0,1)_1px,transparent_0)] [background-size:12px_12px]" />
<div className="relative z-10 h-full w-full p-2 flex flex-col">
<div className="mt-auto rounded-md bg-white/70 backdrop-blur-[1px] shadow-sm ring-1 ring-black/5 p-2.5 max-h-[70%] overflow-hidden">
<div className="mt-auto rounded-md bg-surface-solid backdrop-blur-[1px] shadow-elev-1 ring-1 ring-line-soft p-2.5 max-h-[70%] overflow-hidden">
{isTxtFile ? (
<pre className="text-[10px] sm:text-[11px] leading-snug text-slate-900 whitespace-pre-wrap font-mono">
<pre className="text-[10px] sm:text-[11px] leading-snug text-foreground whitespace-pre-wrap font-mono">
{textPreview}
</pre>
) : (
<div className="text-[10px] sm:text-[11px] leading-snug text-slate-900 break-words [overflow-wrap:anywhere]">
<div className="text-[10px] sm:text-[11px] leading-snug text-foreground break-words [overflow-wrap:anywhere]">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
@ -311,11 +311,11 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
a: ({ children }) => <span>{children}</span>,
img: () => null,
blockquote: (props) => (
<blockquote className="m-0 pl-2 border-l-2 border-slate-300 text-slate-700" {...props} />
<blockquote className="m-0 pl-2 border-l-2 border-line text-soft" {...props} />
),
code: (props) => (
<code
className="font-mono text-[10px] bg-slate-900/5 rounded px-1 whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
className="font-mono text-[10px] bg-surface-sunken rounded px-1 whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
{...props}
/>
),

View file

@ -166,9 +166,9 @@ export function DocumentTile({
>
<span className="flex-shrink-0 flex items-center">
{doc.type === 'pdf' ? (
<PDFIcon className={`${FILE_ICON_CLASSES[iconSize]} text-red-500`} />
<PDFIcon className={`${FILE_ICON_CLASSES[iconSize]} text-danger`} />
) : doc.type === 'epub' ? (
<EPUBIcon className={`${FILE_ICON_CLASSES[iconSize]} text-blue-500`} />
<EPUBIcon className={`${FILE_ICON_CLASSES[iconSize]} text-accent`} />
) : (
<FileIcon className={`${FILE_ICON_CLASSES[iconSize]} text-muted`} />
)}

View file

@ -38,8 +38,8 @@ function formatParseStatus(status: DocumentListDocument['parseStatus']): string
}
function KindIcon({ doc, className }: { doc: DocumentListDocument; className?: string }) {
if (doc.type === 'pdf') return <PDFIcon className={className ?? 'w-4 h-4 text-red-500'} />;
if (doc.type === 'epub') return <EPUBIcon className={className ?? 'w-4 h-4 text-blue-500'} />;
if (doc.type === 'pdf') return <PDFIcon className={className ?? 'w-4 h-4 text-danger'} />;
if (doc.type === 'epub') return <EPUBIcon className={className ?? 'w-4 h-4 text-accent'} />;
return <FileIcon className={className ?? 'w-4 h-4 text-muted'} />;
}

View file

@ -29,8 +29,8 @@ function formatDate(ms: number): string {
}
function KindIcon({ doc }: { doc: DocumentListDocument }) {
if (doc.type === 'pdf') return <PDFIcon className="w-4 h-4 shrink-0 text-red-500" />;
if (doc.type === 'epub') return <EPUBIcon className="w-4 h-4 shrink-0 text-blue-500" />;
if (doc.type === 'pdf') return <PDFIcon className="w-4 h-4 shrink-0 text-danger" />;
if (doc.type === 'epub') return <EPUBIcon className="w-4 h-4 shrink-0 text-accent" />;
return <FileIcon className="w-4 h-4 shrink-0 text-muted" />;
}

View file

@ -315,7 +315,7 @@ export function FinderSidebar({
onRowAction?.();
}}
disabled={disabled}
className={`${disabled ? 'text-muted/60 cursor-not-allowed' : active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
className={`${disabled ? 'text-faint cursor-not-allowed' : active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
<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" />

View file

@ -181,7 +181,7 @@ export function DexieMigrationModal({
</div>
</div>
)}
{!isUploading && status ? <p className="text-xs text-red-500">{status}</p> : null}
{!isUploading && status ? <p className="text-xs text-danger">{status}</p> : null}
</div>
<div className="flex justify-end gap-3 mt-6">

View file

@ -167,7 +167,7 @@ export function DocumentSelectionModal({
{isLoading ? (
<DocumentSelectionSkeleton />
) : errorMessage ? (
<div className="flex items-center justify-center h-full text-red-500">{errorMessage}</div>
<div className="flex items-center justify-center h-full text-danger">{errorMessage}</div>
) : files.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted">No documents found.</div>
) : (
@ -179,7 +179,7 @@ export function DocumentSelectionModal({
key={file.id}
onClick={(e) => handleRowClick(e, file.id)}
className={`flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm select-none
${isSelected ? 'bg-accent/10' : 'hover:bg-offbase'}
${isSelected ? 'bg-accent-wash' : 'hover:bg-offbase'}
`}
>
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>

View file

@ -214,14 +214,14 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
action={
<div className="flex flex-col items-end gap-1">
<span className="flex items-center gap-1 text-muted">
<SparkleIcon className="h-3 w-3 text-accent/70" />
<SparkleIcon className="h-3 w-3 text-accent" />
<span className="text-xs">PP-DocLayout-V3</span>
</span>
<span className="flex items-center gap-1 text-xs text-muted">
<span>{pdf.parseStatus ?? 'pending'}</span>
<button
type="button"
className={buttonClass({ variant: 'ghost', size: 'icon', className: '!h-5 !w-5 hover:scale-[1.1] shrink-0' })}
className={buttonClass({ variant: 'ghost', size: 'icon', className: '!h-5 !w-5 shrink-0' })}
onClick={pdf.onForceReparse}
disabled={isForceReparseDisabled(pdf.parseStatus)}
title="Force reparse"
@ -240,7 +240,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
disabled={pdf.parseStatus !== 'ready'}
variant="flat"
/>
<details className="rounded-md border border-offbase bg-base/50 px-3 py-2">
<details className="rounded-md border border-offbase bg-surface-solid px-3 py-2">
<summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wide text-muted">
Skip Block Kinds While Reading Aloud
</summary>

View file

@ -181,19 +181,19 @@ export function DocumentUploader({
<input {...getInputProps()} />
{children}
{isDragActive && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-background/90 backdrop-blur-md pointer-events-none p-6">
<div className="w-full h-full border-2 border-dashed border-accent rounded-xl flex flex-col items-center justify-center bg-base/60 text-center p-4">
<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-xl 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" />
<p className="text-xl font-bold text-foreground mb-1.5">
Drop files here to upload
</p>
<p className="text-sm text-foreground/70">
<p className="text-sm text-soft">
{enableDocx
? 'Accepts PDF, EPUB, TXT, MD, or DOCX'
: 'Accepts PDF, EPUB, TXT, or MD'}
</p>
{error && (
<p className="mt-3 text-sm text-red-500">
<p className="mt-3 text-sm text-danger">
Upload failed: {error} try again.
</p>
)}
@ -201,7 +201,7 @@ export function DocumentUploader({
</div>
)}
{!isDragActive && error && (
<div className="absolute inset-x-4 bottom-4 z-40 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-center text-sm text-red-500 pointer-events-none">
<div className="absolute inset-x-4 bottom-4 z-40 rounded-md border border-danger bg-danger-wash px-3 py-2 text-center text-sm text-danger pointer-events-none">
Upload failed: {error} try again.
</div>
)}
@ -227,7 +227,7 @@ export function DocumentUploader({
<p className="text-[12px] truncate flex-1">
{isDragActive ? 'Drop files here' : 'Upload documents'}
</p>
{error && <p className="text-[10px] text-red-500 truncate shrink-0">{error}</p>}
{error && <p className="text-[10px] text-danger truncate shrink-0">{error}</p>}
</div>
)}
</div>
@ -246,7 +246,7 @@ export function DocumentUploader({
<p className="text-xs sm:text-sm text-muted">
{enableDocx ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'}
</p>
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
{error && <p className="mt-2 text-sm text-danger">{error}</p>}
</>
)}
</div>

View file

@ -1,6 +1,6 @@
'use client';
import { useId, type ReactNode } from 'react';
import { useId, type ButtonHTMLAttributes, type ReactNode } from 'react';
import {
btnBase,
btnDanger,
@ -36,24 +36,112 @@ export {
// (Note: never use Tailwind alpha modifiers on these theme variables — they
// resolve to CSS custom properties that don't accept opacity suffixes.)
export const inputClass =
'w-full rounded-md bg-background border border-offbase px-2.5 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent';
'w-full rounded-md bg-surface-sunken border border-line px-2.5 py-1.5 text-sm text-foreground transition-colors duration-fast ease-standard focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent-line';
export const listboxButtonClass =
'relative w-full cursor-pointer rounded-md bg-background border border-offbase py-1.5 pl-2.5 pr-9 text-left text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent hover:bg-base transition-colors duration-fast ease-standard';
'relative w-full cursor-pointer rounded-md bg-surface-sunken border border-line py-1.5 pl-2.5 pr-9 text-left text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent hover:bg-accent-wash transition-colors duration-fast ease-standard';
export const listboxOptionsClass =
'z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain rounded-md bg-background p-1 shadow-lg ring-1 ring-offbase focus:outline-none [--anchor-gap:0.25rem]';
'z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain rounded-md bg-surface p-1 shadow-elev-2 ring-1 ring-line focus:outline-none [--anchor-gap:0.25rem]';
export const listboxOptionClass = (active: boolean) =>
`relative cursor-pointer select-none rounded-sm py-1.5 pl-9 pr-3 text-sm ${active ? 'bg-offbase text-foreground' : 'text-foreground'}`;
`relative cursor-pointer select-none rounded-sm py-1.5 pl-9 pr-3 text-sm ${active ? 'bg-accent-wash text-foreground' : 'text-foreground'}`;
export const segmentedGroupClass =
'grid gap-1 rounded-full border border-offbase bg-background p-1';
'grid gap-1 rounded-full border border-line bg-surface-sunken p-1';
export const segmentedButtonClass = (active: boolean) =>
`rounded-full px-2.5 py-1.5 text-xs font-medium transition-colors duration-fast ease-standard focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
active
? 'bg-accent text-background'
: 'text-muted hover:bg-base hover:text-foreground'
: 'text-soft hover:bg-accent-wash hover:text-foreground'
}`;
type SurfaceElevation = 'none' | '1' | '2' | '3';
const SURFACE_ELEVATION_CLASS: Record<SurfaceElevation, string> = {
none: '',
'1': 'shadow-elev-1',
'2': 'shadow-elev-2',
'3': 'shadow-elev-3',
};
export function Surface({
children,
className = '',
elevation = 'none',
}: {
children: ReactNode;
className?: string;
elevation?: SurfaceElevation;
}) {
return (
<div className={`rounded-lg border border-line bg-surface text-foreground ${SURFACE_ELEVATION_CLASS[elevation]} ${className}`}>
{children}
</div>
);
}
export function Panel({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) {
return (
<Surface elevation="1" className={`overflow-hidden ${className}`}>
{children}
</Surface>
);
}
export function Sidebar({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) {
return (
<aside className={`rounded-lg border border-line bg-surface text-foreground shadow-elev-2 ${className}`}>
{children}
</aside>
);
}
export function Divider({ className = '' }: { className?: string }) {
return <div aria-hidden="true" className={`border-t border-line-soft ${className}`} />;
}
export function IconButton({
className = '',
children,
type = 'button',
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
type={type}
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-soft transition-colors duration-fast ease-standard hover:bg-accent-wash hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
{...props}
>
{children}
</button>
);
}
export function Menu({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={`rounded-md border border-line bg-surface p-1 shadow-elev-2 ring-1 ring-line-soft ${className}`}>
{children}
</div>
);
}
export function Section({
title,
subtitle,
@ -69,11 +157,11 @@ export function Section({
}) {
if (variant === 'flat') {
return (
<section className="space-y-2 pb-3 border-b border-offbase last:border-b-0">
<section className="space-y-2 pb-3 border-b border-line-soft last:border-b-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{subtitle ? <p className="text-xs text-muted mt-0.5">{subtitle}</p> : null}
{subtitle ? <p className="text-xs text-soft mt-0.5">{subtitle}</p> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
@ -83,12 +171,12 @@ export function Section({
}
return (
<section className="rounded-xl border border-offbase bg-base overflow-hidden">
<div className="px-3 py-2 bg-background border-b border-offbase">
<section className="rounded-lg border border-line bg-surface overflow-hidden">
<div className="px-3 py-2 bg-surface-solid border-b border-line-soft">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{subtitle ? <p className="text-xs text-muted mt-0.5">{subtitle}</p> : null}
{subtitle ? <p className="text-xs text-soft mt-0.5">{subtitle}</p> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
@ -108,7 +196,7 @@ export function Card({
className?: string;
}) {
return (
<div className={`rounded-lg border border-offbase bg-background px-3 py-2 transition-colors duration-fast ease-standard ${className}`}>
<div className={`rounded-lg border border-line bg-surface px-3 py-2 transition-colors duration-fast ease-standard ${className}`}>
{children}
</div>
);
@ -159,13 +247,13 @@ export function Switch({
aria-describedby={ariaDescribedBy}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative inline-flex shrink-0 cursor-pointer items-center rounded-full border border-offbase transition-colors duration-fast ease-standard focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${s.track} ${
checked ? 'bg-accent' : 'bg-offbase'
className={`relative inline-flex shrink-0 cursor-pointer items-center rounded-full border border-line transition-colors duration-fast ease-standard focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${s.track} ${
checked ? 'bg-accent' : 'bg-line-strong'
}`}
>
<span
aria-hidden="true"
className={`pointer-events-none inline-block rounded-full bg-background shadow-sm ring-0 transition-transform duration-fast ease-standard ${s.thumb} ${
className={`pointer-events-none inline-block rounded-full bg-surface shadow-elev-1 ring-0 transition-transform duration-fast ease-standard ${s.thumb} ${
checked ? s.on : s.off
}`}
/>
@ -194,8 +282,8 @@ export function ToggleRow({
const descId = useId();
const rowClass =
variant === 'flat'
? 'px-0.5 pt-1 pb-2 border-b border-offbase last:border-b-0 transition-colors duration-fast ease-standard'
: 'rounded-md border border-offbase bg-background px-2.5 py-1.5 transition-colors duration-fast ease-standard';
? 'px-0.5 pt-1 pb-2 border-b border-line-soft last:border-b-0 transition-colors duration-fast ease-standard'
: 'rounded-md border border-line bg-surface px-2.5 py-1.5 transition-colors duration-fast ease-standard';
const handleTextToggle = () => {
if (!disabled) onChange(!checked);
};
@ -207,7 +295,7 @@ export function ToggleRow({
onClick={handleTextToggle}
>
<span id={labelId} className="block text-sm font-medium leading-5 text-foreground">{label}</span>
<span id={descId} className="block text-xs leading-4 text-muted">{description}</span>
<span id={descId} className="block text-xs leading-4 text-soft">{description}</span>
</div>
{right ? <div className="shrink-0 self-start pl-1.5">{right}</div> : null}
<Switch
@ -273,9 +361,9 @@ export function Field({
}) {
return (
<div className={`space-y-1 ${className}`}>
{label ? <label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">{label}</label> : null}
{label ? <label className="block text-[11px] font-semibold uppercase tracking-wide text-faint">{label}</label> : null}
{children}
{hint ? <p className="text-[11px] text-muted">{hint}</p> : null}
{hint ? <p className="text-[11px] text-faint">{hint}</p> : null}
</div>
);
}
@ -289,10 +377,10 @@ export function Badge({
}) {
const toneClass =
tone === 'accent'
? 'text-accent'
? 'text-accent bg-accent-wash'
: tone === 'foreground'
? 'text-foreground bg-offbase'
: 'text-muted bg-offbase';
? 'text-foreground bg-surface-sunken'
: 'text-soft bg-surface-sunken';
return (
<span className={`inline-flex items-center text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 ${toneClass}`}>
{children}

View file

@ -246,7 +246,7 @@ export function GithubIcon(props: React.SVGProps<SVGSVGElement>) {
export function PDFIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
className={`w-6 h-6 sm:w-8 sm:h-8 text-red-500 ${props.className || ''}`}
className={`w-6 h-6 sm:w-8 sm:h-8 text-danger ${props.className || ''}`}
fill="currentColor"
stroke="currentColor"
viewBox="-4 0 40 40"
@ -261,7 +261,7 @@ export function PDFIcon(props: React.SVGProps<SVGSVGElement>) {
export function EPUBIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
className={`w-6 h-6 sm:w-8 sm:h-8 text-blue-500 ${props.className || ''}`}
className={`w-6 h-6 sm:w-8 sm:h-8 text-accent ${props.className || ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"

View file

@ -99,7 +99,7 @@ function settingsAreEqual(a: TTSSegmentSettings | null, b: TTSSegmentSettings |
function statusColor(status: TTSSegmentVariant['status']): string {
if (status === 'completed') return 'bg-accent';
if (status === 'error') return 'bg-red-500';
if (status === 'error') return 'bg-danger';
return 'bg-muted';
}
@ -689,7 +689,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
) : isManifestLoading ? (
<div className="animate-pulse h-3 w-36 rounded bg-offbase" aria-label="Loading segment summary" aria-busy="true" />
) : hasManifestError ? (
<span className="text-red-500">error</span>
<span className="text-danger">error</span>
) : (
<span></span>
)}
@ -698,7 +698,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
<div ref={listRef} className="flex-1 overflow-y-auto">
{hasManifestError && (
<div className="px-4 py-6 text-sm text-red-500">{manifestErrorMessage}</div>
<div className="px-4 py-6 text-sm text-danger">{manifestErrorMessage}</div>
)}
{isManifestLoading && (
<SegmentsListSkeleton />
@ -741,10 +741,10 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
<li
key={`${isSynthesized ? 'syn' : 'mfr'}::${groupKey}::${segmentIndex}::${rowIndex}`}
data-active-segment={isCurrent ? 'true' : undefined}
className={`relative px-4 py-3 ${isCurrent ? 'bg-offbase/40' : ''}`}
className={`relative px-4 py-3 ${isCurrent ? 'bg-surface-sunken' : ''}`}
>
{showGroupHeader && (
<div className="mb-2 -mx-4 px-4 py-1.5 bg-offbase/30 border-y border-offbase">
<div className="mb-2 -mx-4 px-4 py-1.5 bg-surface-sunken border-y border-offbase">
<span className="text-[10px] uppercase tracking-[0.14em] text-muted">
{groupLabel}
</span>
@ -758,7 +758,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
type="button"
onClick={() => { if (canJump) handleJump(segmentIndex, row.locator); }}
disabled={!canJump}
className={`text-xs font-medium shrink-0 pt-0.5 ${canJump ? 'text-muted hover:text-accent' : 'text-muted/50 cursor-not-allowed'}`}
className={`text-xs font-medium shrink-0 pt-0.5 ${canJump ? 'text-muted hover:text-accent' : 'text-faint cursor-not-allowed'}`}
title={canJump ? (playable ? 'Play this segment' : 'Jump to this segment') : 'Text not loaded yet'}
aria-label={`Segment ${segmentIndex + 1}`}
>
@ -772,7 +772,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
disabled={!canJump}
className={`block w-full text-left ${canJump ? '' : 'cursor-not-allowed'}`}
>
<p className={`text-sm leading-snug ${isCurrent ? 'text-foreground' : 'text-foreground/90'} line-clamp-2`}>
<p className={`text-sm leading-snug ${isCurrent ? 'text-foreground' : 'text-soft'} line-clamp-2`}>
{sentenceText || (
<span className="text-muted italic text-xs">
[text not loaded press play to fetch]
@ -796,7 +796,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
</span>
)}
{!canJump && (
<span className="text-[10px] text-muted/80 border border-offbase rounded px-1 py-0.5">
<span className="text-[10px] text-faint border border-offbase rounded px-1 py-0.5">
not loaded
</span>
)}
@ -820,7 +820,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
className={[
'max-w-full whitespace-normal break-words text-left leading-none text-[10px] px-1 py-0.5 rounded border transition-colors',
isActive
? 'border-accent text-accent bg-offbase/60'
? 'border-accent text-accent bg-surface-sunken'
: known
? 'border-offbase text-muted hover:border-accent hover:text-accent'
: 'border-offbase text-muted opacity-60 cursor-not-allowed',

View file

@ -480,7 +480,7 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro
return (
<div
key={`${block.id}:${fragment.page}:${fragment.readingOrder}`}
className="absolute border border-accent/70 rounded-[2px]"
className="absolute border border-accent-line rounded-[2px]"
style={{
left: `${leftPct}%`,
top: `${topPct}%`,