cleanup 5
continuous-integration/drone/pr Build is failing

This commit is contained in:
2026-07-03 18:59:50 -04:00
parent c9c9563a1f
commit 5a8b773a0b
13 changed files with 206 additions and 123 deletions
+4 -1
View File
@@ -26,7 +26,10 @@ Also follow the repository root guide at `../AGENTS.md`.
- Nav items and page titles come from `src/components/navigation.ts` (`navItems`, `adminNavItems`, `getPageTitle`) — add routes there, not in `Layout`/`HamburgerMenu`. - Nav items and page titles come from `src/components/navigation.ts` (`navItems`, `adminNavItems`, `getPageTitle`) — add routes there, not in `Layout`/`HamburgerMenu`.
- Avoid custom class names in JSX `className` values unless the Tailwind lint config already allows them. - Avoid custom class names in JSX `className` values unless the Tailwind lint config already allows them.
- For decorative icons in inputs or labels, disable hover styling via the icon component API rather than overriding it ad hoc. - For decorative icons in inputs or labels, disable hover styling via the icon component API rather than overriding it ad hoc.
- Prefer `LoadingState` for result-area loading indicators (the single loading convention); avoid early returns that unmount search/filter forms during fetches. - Prefer `LoadingState` for result-area loading indicators (the single loading convention); avoid early returns that unmount search/filter forms during fetches. React Query `isLoading` is initial-load-only (false on background refetches), so a full-page early-return on `isLoading` is fine for pages with no persistent filter form.
- For mutations, use `useMutationWithToast` (declarative `.mutate(vars, options)` for fire-and-forget) or its imperative sibling `useToastMutation` (awaited, returns a success `boolean`) instead of hand-rolling `mutateAsync``getResponseError` → toast blocks.
- Use `SegmentedControl` for active/inactive toggle groups (view mode, period, reader theme/font) rather than re-implementing `option.map` + ternary class toggling; pass per-call `activeClassName`/`inactiveClassName`.
- For a persistent/progress toast that resolves in place (long-running actions), create it with `showInfo(msg, 0)` and finish with `updateToast(id, { message, type, duration })`.
- Use theme tokens defined in `src/index.css` `@theme` (`bg-surface`, `text-content`, `border-border`, `primary`, etc.) for new UI work instead of adding raw light/dark color pairs. There is no `tailwind.config.js` — Tailwind v4 config is CSS-first. - Use theme tokens defined in `src/index.css` `@theme` (`bg-surface`, `text-content`, `border-border`, `primary`, etc.) for new UI work instead of adding raw light/dark color pairs. There is no `tailwind.config.js` — Tailwind v4 config is CSS-first.
- Semantic colors map to runtime CSS variables (`--color-x: rgb(var(--x))`) via `@theme inline`; light/dark values live in `:root` / `.dark` in `src/index.css`. Dark mode is class-based via `@custom-variant dark`, toggled by `ThemeProvider`. - Semantic colors map to runtime CSS variables (`--color-x: rgb(var(--x))`) via `@theme inline`; light/dark values live in `:root` / `.dark` in `src/index.css`. Dark mode is class-based via `@custom-variant dark`, toggled by `ThemeProvider`.
- Store frontend-only preferences in `src/utils/localSettings.ts` so appearance and view settings share one local-storage shape. - Store frontend-only preferences in `src/utils/localSettings.ts` so appearance and view settings share one local-storage shape.
@@ -0,0 +1,48 @@
import { ReactNode } from 'react';
import { cn } from '../utils/cn';
interface SegmentedOption<T extends string> {
value: T;
label: ReactNode;
}
interface SegmentedControlProps<T extends string> {
options: SegmentedOption<T>[];
value: T;
onChange: (value: T) => void;
activeClassName: string;
inactiveClassName: string;
className?: string;
buttonClassName?: string;
ariaLabel?: string;
}
export function SegmentedControl<T extends string>({
options,
value,
onChange,
activeClassName,
inactiveClassName,
className,
buttonClassName,
ariaLabel,
}: SegmentedControlProps<T>) {
return (
<div className={className} role="group" aria-label={ariaLabel}>
{options.map(option => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
aria-pressed={value === option.value}
className={cn(
buttonClassName,
value === option.value ? activeClassName : inactiveClassName
)}
>
{option.label}
</button>
))}
</div>
);
}
+13 -1
View File
@@ -1,11 +1,18 @@
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'; import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { Toast, ToastType, ToastProps } from './Toast'; import { Toast, ToastType, ToastProps } from './Toast';
interface ToastUpdate {
message?: string;
type?: ToastType;
duration?: number;
}
interface ToastContextType { interface ToastContextType {
showToast: (message: string, type?: ToastType, duration?: number) => string; showToast: (message: string, type?: ToastType, duration?: number) => string;
showInfo: (message: string, duration?: number) => string; showInfo: (message: string, duration?: number) => string;
showWarning: (message: string, duration?: number) => string; showWarning: (message: string, duration?: number) => string;
showError: (message: string, duration?: number) => string; showError: (message: string, duration?: number) => string;
updateToast: (id: string, update: ToastUpdate) => void;
removeToast: (id: string) => void; removeToast: (id: string) => void;
clearToasts: () => void; clearToasts: () => void;
} }
@@ -49,13 +56,18 @@ export function ToastProvider({ children }: { children: ReactNode }) {
[showToast] [showToast]
); );
// In-Place Update - Long-running flows show a persistent toast (duration 0) that resolves into its result toast; changing duration restarts the Toast's auto-dismiss timer.
const updateToast = useCallback((id: string, update: ToastUpdate) => {
setToasts(prev => prev.map(toast => (toast.id === id ? { ...toast, ...update } : toast)));
}, []);
const clearToasts = useCallback(() => { const clearToasts = useCallback(() => {
setToasts([]); setToasts([]);
}, []); }, []);
return ( return (
<ToastContext.Provider <ToastContext.Provider
value={{ showToast, showInfo, showWarning, showError, removeToast, clearToasts }} value={{ showToast, showInfo, showWarning, showError, updateToast, removeToast, clearToasts }}
> >
{children} {children}
<ToastContainer toasts={toasts} /> <ToastContainer toasts={toasts} />
+1
View File
@@ -17,3 +17,4 @@ export { Field, FieldLabel, FieldValue, FieldActions } from './Field';
export { Button } from './Button'; export { Button } from './Button';
export { Table, type Column, type TableProps } from './Table'; export { Table, type Column, type TableProps } from './Table';
export { IconInput } from './IconInput'; export { IconInput } from './IconInput';
export { SegmentedControl } from './SegmentedControl';
@@ -29,3 +29,40 @@ export function useMutationWithToast() {
}; };
}; };
} }
interface RunToastMutationOptions<T> {
error: string;
success?: string;
onSuccess?: (response: T) => void;
}
/**
* Imperative sibling of `useMutationWithToast` for flows that must `await` a result (e.g. keep an
* editor open on failure). Runs the action, treats non-2xx as failure, toasts accordingly, and
* resolves to `true` only on success.
*/
export function useToastMutation() {
const { showInfo, showError } = useToasts();
return async function runWithToast<T extends ApiResponseLike>(
action: () => Promise<T>,
{ error, success, onSuccess }: RunToastMutationOptions<T>
): Promise<boolean> {
try {
const response = await action();
const message = getResponseError(response);
if (message) {
showError(`${error}: ${message}`);
return false;
}
onSuccess?.(response);
if (success) {
showInfo(success);
}
return true;
} catch (err) {
showError(`${error}: ${getErrorMessage(err)}`);
return false;
}
};
}
+3 -3
View File
@@ -244,13 +244,13 @@ export class EBookReader {
const prevPage = this.prevPage.bind(this); const prevPage = this.prevPage.bind(this);
this.keyupHandler = (event: KeyboardEvent) => { this.keyupHandler = (event: KeyboardEvent) => {
if ((event.keyCode || event.which) === 37) { if (event.key === 'ArrowLeft') {
void prevPage(); void prevPage();
} }
if ((event.keyCode || event.which) === 39) { if (event.key === 'ArrowRight') {
void nextPage(); void nextPage();
} }
if ((event.keyCode || event.which) === 84) { if (event.key === 't' || event.key === 'T') {
const currentTheme = this.readerSettings.theme?.colorScheme || 'tan'; const currentTheme = this.readerSettings.theme?.colorScheme || 'tan';
const currentThemeIdx = READER_COLOR_SCHEMES.indexOf(currentTheme); const currentThemeIdx = READER_COLOR_SCHEMES.indexOf(currentTheme);
const colorScheme = const colorScheme =
+14 -9
View File
@@ -13,7 +13,7 @@ interface BackupTypes {
export default function AdminPage() { export default function AdminPage() {
const postAdminAction = usePostAdminAction(); const postAdminAction = usePostAdminAction();
const { showInfo, showError, removeToast } = useToasts(); const { showInfo, showError, updateToast } = useToasts();
const toastMutationOptions = useMutationWithToast(); const toastMutationOptions = useMutationWithToast();
const [backupTypes, setBackupTypes] = useState<BackupTypes>({ const [backupTypes, setBackupTypes] = useState<BackupTypes>({
@@ -61,8 +61,8 @@ export default function AdminPage() {
e.preventDefault(); e.preventDefault();
if (!restoreFile) return; if (!restoreFile) return;
// Persistent progress toast - Restore is long-running, so we surface a 'started' toast that is dismissed on completion; the standard toast helper has no notion of a progress indicator. // Progress Toast - Restore is long-running; a persistent 'started' toast resolves in place into the result toast on completion.
const startedToastId = showInfo('Restore started', 0); const toastId = showInfo('Restore started', 0);
try { try {
const response = await postAdminAction.mutateAsync({ const response = await postAdminAction.mutateAsync({
@@ -72,18 +72,23 @@ export default function AdminPage() {
}, },
}); });
removeToast(startedToastId);
const message = getResponseError(response); const message = getResponseError(response);
if (message) { if (message) {
showError('Restore failed: ' + message); updateToast(toastId, {
type: 'error',
message: `Restore failed: ${message}`,
duration: 5000,
});
return; return;
} }
showInfo('Restore completed successfully'); updateToast(toastId, { message: 'Restore completed successfully', duration: 5000 });
} catch (error) { } catch (error) {
removeToast(startedToastId); updateToast(toastId, {
showError('Restore failed: ' + getErrorMessage(error)); type: 'error',
message: `Restore failed: ${getErrorMessage(error)}`,
duration: 5000,
});
} }
}; };
+10 -24
View File
@@ -8,16 +8,11 @@ import {
} from '../generated/anthoLumeAPIV1'; } from '../generated/anthoLumeAPIV1';
import type { EditDocumentBody } from '../generated/model'; import type { EditDocumentBody } from '../generated/model';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
import { getErrorMessage, getResponseError } from '../utils/errors'; import { useToastMutation } from '../hooks/useMutationWithToast';
import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons'; import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons';
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components'; import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
import { useToasts } from '../components/ToastContext';
const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-content'; const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-content';
const popupClassName =
'rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200';
const editInputClassName =
'w-full rounded border border-border bg-surface-muted p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-primary-600';
interface EditableFieldProps { interface EditableFieldProps {
label: string; label: string;
@@ -100,7 +95,7 @@ function EditableField({
type="text" type="text"
value={draft} value={draft}
onChange={e => setDraft(e.target.value)} onChange={e => setDraft(e.target.value)}
className={editInputClassName} className="w-full rounded border border-border bg-surface-muted p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-primary-600"
/> />
)} )}
</div> </div>
@@ -118,7 +113,7 @@ export default function DocumentPage() {
query: { enabled: Boolean(id) }, query: { enabled: Boolean(id) },
}); });
const editMutation = useEditDocument(); const editMutation = useEditDocument();
const { showError } = useToasts(); const runWithToast = useToastMutation();
const [showTimeReadInfo, setShowTimeReadInfo] = useState(false); const [showTimeReadInfo, setShowTimeReadInfo] = useState(false);
@@ -136,21 +131,12 @@ export default function DocumentPage() {
const secondsPerPercent = document.seconds_per_percent || 0; const secondsPerPercent = document.seconds_per_percent || 0;
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent); const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
const save = async (data: EditDocumentBody): Promise<boolean> => { const save = (data: EditDocumentBody): Promise<boolean> =>
try { runWithToast(() => editMutation.mutateAsync({ id: document.id, data }), {
const response = await editMutation.mutateAsync({ id: document.id, data }); error: 'Failed to save',
const message = getResponseError(response); onSuccess: response =>
if (message) { queryClient.setQueryData(getGetDocumentQueryKey(document.id), response),
showError('Failed to save: ' + message); });
return false;
}
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
return true;
} catch (err) {
showError('Failed to save: ' + getErrorMessage(err));
return false;
}
};
return ( return (
<div className="relative size-full"> <div className="relative size-full">
@@ -235,7 +221,7 @@ export default function DocumentPage() {
<InfoIcon size={18} /> <InfoIcon size={18} />
</button> </button>
<div <div
className={`absolute right-0 top-7 z-30 ${popupClassName} ${ className={`absolute right-0 top-7 z-30 rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200 ${
showTimeReadInfo ? 'opacity-100' : 'pointer-events-none opacity-0' showTimeReadInfo ? 'opacity-100' : 'pointer-events-none opacity-0'
}`} }`}
> >
+27 -30
View File
@@ -3,10 +3,11 @@ import { Link } from 'react-router-dom';
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1'; import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
import type { Document } from '../generated/model'; import type { Document } from '../generated/model';
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons'; import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
import { LoadingState, Pagination, TextInput, IconInput } from '../components'; import { LoadingState, Pagination, TextInput, IconInput, SegmentedControl } from '../components';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { useMutationWithToast } from '../hooks/useMutationWithToast'; import { useMutationWithToast } from '../hooks/useMutationWithToast';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
import { cn } from '../utils/cn';
import { useDebouncedState } from '../hooks/useDebouncedState'; import { useDebouncedState } from '../hooks/useDebouncedState';
import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings'; import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings';
@@ -99,6 +100,16 @@ function DocumentItem({ doc, layout }: DocumentItemProps) {
); );
} }
function EmptyDocuments({ className }: { className?: string }) {
return (
<div
className={cn('rounded bg-surface p-6 text-center text-content-muted shadow-lg', className)}
>
No documents found.
</div>
);
}
export default function DocumentsPage() { export default function DocumentsPage() {
const [search, setSearch, debouncedSearch] = useDebouncedState('', 300); const [search, setSearch, debouncedSearch] = useDebouncedState('', 300);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -148,13 +159,6 @@ export default function DocumentsPage() {
} }
}; };
const getViewModeButtonClasses = (mode: DocumentsViewMode) =>
`rounded px-3 py-1 text-sm font-medium transition-colors ${
viewMode === mode
? 'bg-content text-content-inverse'
: 'text-content-muted hover:bg-surface-muted'
}`;
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex grow flex-col gap-4 rounded bg-surface p-4 text-content-muted shadow-lg"> <div className="flex grow flex-col gap-4 rounded bg-surface p-4 text-content-muted shadow-lg">
@@ -170,22 +174,19 @@ export default function DocumentsPage() {
/> />
</IconInput> </IconInput>
</div> </div>
<div className="inline-flex rounded border border-border bg-surface p-1"> <SegmentedControl<DocumentsViewMode>
<button className="inline-flex rounded border border-border bg-surface p-1"
type="button" ariaLabel="Document view mode"
onClick={() => setViewMode('grid')} value={viewMode}
className={getViewModeButtonClasses('grid')} onChange={setViewMode}
> buttonClassName="rounded px-3 py-1 text-sm font-medium transition-colors"
Grid activeClassName="bg-content text-content-inverse"
</button> inactiveClassName="text-content-muted hover:bg-surface-muted"
<button options={[
type="button" { value: 'grid', label: 'Grid' },
onClick={() => setViewMode('list')} { value: 'list', label: 'List' },
className={getViewModeButtonClasses('list')} ]}
> />
List
</button>
</div>
</div> </div>
</div> </div>
@@ -196,9 +197,7 @@ export default function DocumentsPage() {
) : docs && docs.length > 0 ? ( ) : docs && docs.length > 0 ? (
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="grid" />) docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="grid" />)
) : ( ) : (
<div className="col-span-full rounded bg-surface p-6 text-center text-content-muted shadow-lg"> <EmptyDocuments className="col-span-full" />
No documents found.
</div>
)} )}
</div> </div>
) : ( ) : (
@@ -208,9 +207,7 @@ export default function DocumentsPage() {
) : docs && docs.length > 0 ? ( ) : docs && docs.length > 0 ? (
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="list" />) docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="list" />)
) : ( ) : (
<div className="rounded bg-surface p-6 text-center text-content-muted shadow-lg"> <EmptyDocuments />
No documents found.
</div>
)} )}
</div> </div>
)} )}
+17 -23
View File
@@ -1,12 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { LoadingState } from '../components'; import { LoadingState, SegmentedControl } from '../components';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useGetHome } from '../generated/anthoLumeAPIV1'; import { useGetHome } from '../generated/anthoLumeAPIV1';
import type { import type { LeaderboardData, LeaderboardEntry, UserStreak } from '../generated/model';
LeaderboardData,
LeaderboardEntry,
UserStreak,
} from '../generated/model';
import ReadingHistoryGraph from '../components/ReadingHistoryGraph'; import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
import { formatNumber, formatDuration } from '../utils/formatters'; import { formatNumber, formatDuration } from '../utils/formatters';
@@ -115,9 +111,6 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
const currentData = data[selectedPeriod]; const currentData = data[selectedPeriod];
const getPeriodClassName = (period: TimePeriod) =>
`cursor-pointer ${selectedPeriod === period ? 'text-content' : 'text-content-subtle hover:text-content'}`;
return ( return (
<div className="w-full"> <div className="w-full">
<div className="flex size-full flex-col justify-between rounded bg-surface px-4 py-6 text-content shadow-lg"> <div className="flex size-full flex-col justify-between rounded bg-surface px-4 py-6 text-content shadow-lg">
@@ -126,20 +119,21 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
<p className="w-max border-b border-border text-sm font-semibold text-content-muted"> <p className="w-max border-b border-border text-sm font-semibold text-content-muted">
{name} Leaderboard {name} Leaderboard
</p> </p>
<div className="flex items-center gap-2 text-xs"> <SegmentedControl<TimePeriod>
<button type="button" onClick={() => setSelectedPeriod('all')} className={getPeriodClassName('all')}> className="flex items-center gap-2 text-xs"
all ariaLabel={`${name} leaderboard period`}
</button> value={selectedPeriod}
<button type="button" onClick={() => setSelectedPeriod('year')} className={getPeriodClassName('year')}> onChange={setSelectedPeriod}
year buttonClassName="cursor-pointer"
</button> activeClassName="text-content"
<button type="button" onClick={() => setSelectedPeriod('month')} className={getPeriodClassName('month')}> inactiveClassName="text-content-subtle hover:text-content"
month options={[
</button> { value: 'all', label: 'all' },
<button type="button" onClick={() => setSelectedPeriod('week')} className={getPeriodClassName('week')}> { value: 'year', label: 'year' },
week { value: 'month', label: 'month' },
</button> { value: 'week', label: 'week' },
</div> ]}
/>
</div> </div>
</div> </div>
+2
View File
@@ -51,6 +51,7 @@ describe('LoginPage', () => {
showInfo: vi.fn(), showInfo: vi.fn(),
showWarning: vi.fn(), showWarning: vi.fn(),
showError: vi.fn(), showError: vi.fn(),
updateToast: vi.fn(),
removeToast: vi.fn(), removeToast: vi.fn(),
clearToasts: vi.fn(), clearToasts: vi.fn(),
}); });
@@ -112,6 +113,7 @@ describe('LoginPage', () => {
showInfo: vi.fn(), showInfo: vi.fn(),
showWarning: vi.fn(), showWarning: vi.fn(),
showError: showErrorMock, showError: showErrorMock,
updateToast: vi.fn(),
removeToast: vi.fn(), removeToast: vi.fn(),
clearToasts: vi.fn(), clearToasts: vi.fn(),
}); });
+28 -32
View File
@@ -2,15 +2,23 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1'; import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
import { LoadingState } from '../components/LoadingState'; import { LoadingState } from '../components/LoadingState';
import { SegmentedControl } from '../components/SegmentedControl';
import { CloseIcon } from '../icons'; import { CloseIcon } from '../icons';
import { import {
READER_COLOR_SCHEMES, READER_COLOR_SCHEMES,
READER_FONT_FAMILIES, READER_FONT_FAMILIES,
type ReaderColorScheme,
type ReaderFontFamily,
getReaderDevice, getReaderDevice,
useLocalSetting, useLocalSetting,
} from '../utils/localSettings'; } from '../utils/localSettings';
import { useEpubReader } from '../hooks/useEpubReader'; import { useEpubReader } from '../hooks/useEpubReader';
const READER_SEGMENT_BUTTON = 'rounded border px-2 py-1.5 text-xs sm:text-sm';
const READER_SEGMENT_ACTIVE = 'border-primary-500 bg-primary-500/10 text-content';
const READER_SEGMENT_INACTIVE =
'border-border text-content-muted hover:bg-surface-muted hover:text-content';
export default function ReaderPage() { export default function ReaderPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [isTopBarOpen, setIsTopBarOpen] = useState(false); const [isTopBarOpen, setIsTopBarOpen] = useState(false);
@@ -214,42 +222,30 @@ export default function ReaderPage() {
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle"> <p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">
Theme Theme
</p> </p>
<div className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5"> <SegmentedControl<ReaderColorScheme>
{READER_COLOR_SCHEMES.map(option => ( className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5"
<button ariaLabel="Reader theme"
key={option} value={colorScheme}
type="button" onChange={setColorScheme}
onClick={() => setColorScheme(option)} buttonClassName={`${READER_SEGMENT_BUTTON} capitalize`}
className={`rounded border px-2 py-1.5 text-xs capitalize sm:text-sm ${ activeClassName={READER_SEGMENT_ACTIVE}
colorScheme === option inactiveClassName={READER_SEGMENT_INACTIVE}
? 'border-primary-500 bg-primary-500/10 text-content' options={READER_COLOR_SCHEMES.map(value => ({ value, label: value }))}
: 'border-border text-content-muted hover:bg-surface-muted hover:text-content' />
}`}
>
{option}
</button>
))}
</div>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Font</p> <p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Font</p>
<div className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4"> <SegmentedControl<ReaderFontFamily>
{READER_FONT_FAMILIES.map(option => ( className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4"
<button ariaLabel="Reader font"
key={option} value={fontFamily}
type="button" onChange={setFontFamily}
onClick={() => setFontFamily(option)} buttonClassName={READER_SEGMENT_BUTTON}
className={`rounded border px-2 py-1.5 text-xs sm:text-sm ${ activeClassName={READER_SEGMENT_ACTIVE}
fontFamily === option inactiveClassName={READER_SEGMENT_INACTIVE}
? 'border-primary-500 bg-primary-500/10 text-content' options={READER_FONT_FAMILIES.map(value => ({ value, label: value }))}
: 'border-border text-content-muted hover:bg-surface-muted hover:text-content' />
}`}
>
{option}
</button>
))}
</div>
</div> </div>
<div> <div>
+2
View File
@@ -51,6 +51,7 @@ describe('RegisterPage', () => {
showInfo: vi.fn(), showInfo: vi.fn(),
showWarning: vi.fn(), showWarning: vi.fn(),
showError: vi.fn(), showError: vi.fn(),
updateToast: vi.fn(),
removeToast: vi.fn(), removeToast: vi.fn(),
clearToasts: vi.fn(), clearToasts: vi.fn(),
}); });
@@ -113,6 +114,7 @@ describe('RegisterPage', () => {
showInfo: vi.fn(), showInfo: vi.fn(),
showWarning: vi.fn(), showWarning: vi.fn(),
showError: showErrorMock, showError: showErrorMock,
updateToast: vi.fn(),
removeToast: vi.fn(), removeToast: vi.fn(),
clearToasts: vi.fn(), clearToasts: vi.fn(),
}); });