-
Spreadsheet connection
+ Spreadsheet connection
{spreadsheetId ? 'Connected' : 'Not connected'}
-
+
{spreadsheetId
? `Current spreadsheet id: ${spreadsheetId}`
: 'No spreadsheet is connected yet.'}
@@ -162,8 +163,8 @@ export function SettingsPage() {
-
Google authorization
-
+
Google authorization
+
{googleAuth.isConfigured
? 'Google OAuth client id is configured. Access tokens are kept in memory only.'
: 'Set VITE_GOOGLE_CLIENT_ID before using Google authorization.'}
@@ -182,18 +183,18 @@ export function SettingsPage() {
-
Containers
+ Containers
{containersEnabled ? 'Enabled' : 'Disabled'}
-
+
Containers are separate money stores used by transactions. When enabled, every new
transaction requires one.
-
+
- Preferences
+ Preferences
updatePreference('theme', value)}
@@ -248,8 +249,8 @@ export function SettingsPage() {
-
Local data
-
+
Local data
+
Export or import local Sheetly preferences, and reset only this browser cache when
diagnostics look stale.
@@ -277,8 +278,8 @@ export function SettingsPage() {
-
Diagnostics
-
+
Diagnostics
+
Spreadsheet: {spreadsheetId ?? 'not connected'}
Google: {googleAuth.status}
Network: {syncStatus.isOnline ? 'online' : 'offline'}
@@ -291,11 +292,11 @@ export function SettingsPage() {
Last sync error: {syncStatus.lastError}
) : null}
-
-
+
+
-
Queue inspector
-
+
Queue inspector
+
Pending and failed local changes waiting for Google Sheets sync.
@@ -322,15 +323,15 @@ export function SettingsPage() {
-
+
{syncStatus.queueItems.length ? (
syncStatus.queueItems.map((item) => (
-
+
{item.operation}
-
+
{item.transaction?.categoryName ?? item.transactionId}
{item.transaction?.comment ? {item.transaction.comment} : null}
@@ -344,7 +345,7 @@ export function SettingsPage() {
))
) : (
-
Queue is empty.
+
Queue is empty.
)}
diff --git a/app/src/pages/setup/ui/SetupPage.tsx b/app/src/pages/setup/ui/SetupPage.tsx
index 3a99c76..dd25a3a 100644
--- a/app/src/pages/setup/ui/SetupPage.tsx
+++ b/app/src/pages/setup/ui/SetupPage.tsx
@@ -12,7 +12,7 @@ export function SetupPage() {
const [isConnectOpen, setIsConnectOpen] = useState(false);
return (
-
+
& {
};
const variantClasses: Record = {
- danger: 'bg-red-50 text-red-700 ring-red-200',
- info: 'bg-indigo-50 text-indigo-700 ring-indigo-200',
- neutral: 'bg-zinc-100 text-zinc-700 ring-zinc-200',
- success: 'bg-green-50 text-green-700 ring-green-200',
- warning: 'bg-amber-50 text-amber-700 ring-amber-200',
+ danger: 'bg-danger-soft text-danger ring-danger/20',
+ info: 'bg-info-soft text-info ring-info/20',
+ neutral: 'bg-surface-muted text-text-muted ring-border',
+ success: 'bg-success-soft text-success ring-success/20',
+ warning: 'bg-warning-soft text-warning ring-warning/20',
};
export function Badge({ className, variant = 'neutral', ...props }: BadgeProps) {
diff --git a/app/src/shared/ui/button/button-variants.ts b/app/src/shared/ui/button/button-variants.ts
index da81ca6..510d618 100644
--- a/app/src/shared/ui/button/button-variants.ts
+++ b/app/src/shared/ui/button/button-variants.ts
@@ -4,10 +4,12 @@ export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg';
const variantClasses: Record = {
- primary: 'bg-brand text-white hover:bg-indigo-500 focus-visible:outline-brand',
- secondary: 'bg-zinc-900 text-white hover:bg-zinc-700 focus-visible:outline-zinc-900',
- ghost: 'bg-transparent text-zinc-700 hover:bg-zinc-100 focus-visible:outline-zinc-400',
- danger: 'bg-danger text-white hover:bg-red-500 focus-visible:outline-danger',
+ primary: 'bg-brand text-text-inverted hover:bg-brand-hover focus-visible:outline-brand',
+ secondary:
+ 'bg-surface-strong text-text-inverted hover:bg-surface-strong/80 focus-visible:outline-surface-strong',
+ ghost:
+ 'bg-transparent text-text-muted hover:bg-surface-hover focus-visible:outline-border-strong',
+ danger: 'bg-danger text-text-inverted hover:bg-danger-hover focus-visible:outline-danger',
};
const sizeClasses: Record = {
diff --git a/app/src/shared/ui/card/Card.tsx b/app/src/shared/ui/card/Card.tsx
index 454877e..f313769 100644
--- a/app/src/shared/ui/card/Card.tsx
+++ b/app/src/shared/ui/card/Card.tsx
@@ -5,7 +5,7 @@ import { cn } from '@shared/lib/classnames/cn';
export function Card({ className, ...props }: HTMLAttributes) {
return (
);
diff --git a/app/src/shared/ui/combobox/Combobox.tsx b/app/src/shared/ui/combobox/Combobox.tsx
index a10535e..3d6b409 100644
--- a/app/src/shared/ui/combobox/Combobox.tsx
+++ b/app/src/shared/ui/combobox/Combobox.tsx
@@ -82,7 +82,7 @@ export function Combobox({
}
return (
-
+
{label}
({
aria-expanded={isOpen}
aria-invalid={Boolean(error)}
className={cn(
- 'h-10 rounded-md border border-zinc-200 bg-white px-3 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-indigo-100 disabled:cursor-not-allowed disabled:bg-zinc-100 disabled:text-zinc-500',
- error && 'border-danger focus:border-danger focus:ring-red-100',
+ 'h-10 rounded-md border border-border bg-surface px-3 text-sm text-text outline-none transition placeholder:text-text-soft focus:border-brand focus:ring-2 focus:ring-brand-ring disabled:cursor-not-allowed disabled:bg-surface-muted disabled:text-text-soft',
+ error && 'border-danger focus:border-danger focus:ring-danger-soft',
)}
disabled={disabled}
id={generatedId}
@@ -134,7 +134,7 @@ export function Combobox
({
/>
{description ? (
{description}
@@ -142,7 +142,7 @@ export function Combobox({
) : null}
{isOpen ? (
@@ -150,8 +150,8 @@ export function Combobox
({
options.map((option, index) => (
({
))
) : (
- {emptyLabel}
+ {emptyLabel}
)}
) : null}
diff --git a/app/src/shared/ui/date-picker/DatePicker.tsx b/app/src/shared/ui/date-picker/DatePicker.tsx
index eab2290..dd625bf 100644
--- a/app/src/shared/ui/date-picker/DatePicker.tsx
+++ b/app/src/shared/ui/date-picker/DatePicker.tsx
@@ -85,7 +85,7 @@ export function DatePicker({ error, hint, id, label, onChange, value }: DatePick
const todayStr = toDateString(today);
return (
-
+
{label ?
{label} : null}
setIsOpen((o) => !o)}
@@ -106,29 +106,29 @@ export function DatePicker({ error, hint, id, label, onChange, value }: DatePick
{description ? (
-
+
{description}
) : null}
{isOpen ? (
moveMonth(-1)}
type="button"
>
-
+
{MONTHS[viewDate.getMonth()]} {viewDate.getFullYear()}
moveMonth(1)}
type="button"
>
@@ -139,7 +139,7 @@ export function DatePicker({ error, hint, id, label, onChange, value }: DatePick
{WEEKDAYS.map((day) => (
-
+
{day}
))}
@@ -162,10 +162,10 @@ export function DatePicker({ error, hint, id, label, onChange, value }: DatePick
className={cn(
'flex h-8 w-full items-center justify-center rounded-lg text-sm transition',
isSelected
- ? 'bg-brand font-semibold text-white'
+ ? 'bg-brand font-semibold text-text-inverted'
: isToday
- ? 'border border-brand font-semibold text-brand hover:bg-indigo-50'
- : 'text-zinc-700 hover:bg-zinc-100 hover:text-zinc-900',
+ ? 'border border-brand font-semibold text-brand hover:bg-brand-soft'
+ : 'text-text-muted hover:bg-surface-hover hover:text-text',
)}
key={day}
onClick={() => {
@@ -181,9 +181,9 @@ export function DatePicker({ error, hint, id, label, onChange, value }: DatePick
-
+
{
onChange('');
setIsOpen(false);
@@ -193,7 +193,7 @@ export function DatePicker({ error, hint, id, label, onChange, value }: DatePick
Clear
{
const t = new Date();
setViewDate(new Date(t.getFullYear(), t.getMonth(), 1));
@@ -215,7 +215,7 @@ function CalendarIcon() {
return (
- {title}
- {description}
+ {title}
+ {description}
{actionLabel && onAction ? (
{actionLabel}
diff --git a/app/src/shared/ui/input/Input.tsx b/app/src/shared/ui/input/Input.tsx
index babf2ef..6a2e424 100644
--- a/app/src/shared/ui/input/Input.tsx
+++ b/app/src/shared/ui/input/Input.tsx
@@ -16,14 +16,14 @@ export function Input({ className, error, hint, id, label, ...props }: InputProp
const description = error ?? hint;
return (
-
+
{label ? {label} : null}
{description ? (
{description}
diff --git a/app/src/shared/ui/loader/Loader.tsx b/app/src/shared/ui/loader/Loader.tsx
index 68c8e81..1ef476a 100644
--- a/app/src/shared/ui/loader/Loader.tsx
+++ b/app/src/shared/ui/loader/Loader.tsx
@@ -4,8 +4,8 @@ type LoaderProps = {
export function Loader({ label = 'Loading' }: LoaderProps) {
return (
-
-
+
+
{label}
);
diff --git a/app/src/shared/ui/modal/Modal.tsx b/app/src/shared/ui/modal/Modal.tsx
index 55f850f..018a3e7 100644
--- a/app/src/shared/ui/modal/Modal.tsx
+++ b/app/src/shared/ui/modal/Modal.tsx
@@ -38,7 +38,7 @@ export function Modal({ children, description, footer, isOpen, onClose, title }:
return (
{
if (event.target === event.currentTarget) {
onClose();
@@ -49,16 +49,16 @@ export function Modal({ children, description, footer, isOpen, onClose, title }:
aria-describedby={description ? descriptionId : undefined}
aria-labelledby={titleId}
aria-modal="true"
- className="max-h-[92vh] w-full overflow-y-auto rounded-t-lg bg-white shadow-xl sm:max-w-lg sm:rounded-lg"
+ className="max-h-[92vh] w-full overflow-y-auto rounded-t-lg bg-surface shadow-xl sm:max-w-lg sm:rounded-lg"
role="dialog"
>
-
+
-
+
{title}
{description ? (
-
+
{description}
) : null}
@@ -68,7 +68,7 @@ export function Modal({ children, description, footer, isOpen, onClose, title }:
{children}
- {footer ?
: null}
+ {footer ?
: null}
);
diff --git a/app/src/shared/ui/page-header/PageHeader.tsx b/app/src/shared/ui/page-header/PageHeader.tsx
index a440bed..41d4f38 100644
--- a/app/src/shared/ui/page-header/PageHeader.tsx
+++ b/app/src/shared/ui/page-header/PageHeader.tsx
@@ -10,9 +10,9 @@ export function PageHeader({ actions, description, title }: PageHeaderProps) {
return (
-
{title}
+
{title}
{description ? (
-
{description}
+
{description}
) : null}
{actions ? (
diff --git a/app/src/shared/ui/select/Select.tsx b/app/src/shared/ui/select/Select.tsx
index b36122c..715ca26 100644
--- a/app/src/shared/ui/select/Select.tsx
+++ b/app/src/shared/ui/select/Select.tsx
@@ -59,7 +59,7 @@ export function Select({
}, [isOpen]);
return (
-
+
{label ?
{label} : null}
setIsOpen((o) => !o)}
@@ -79,21 +79,21 @@ export function Select({
{selectedOption?.label ?? 'Select...'}
{description ? (
-
+
{description}
) : null}
{isOpen && !disabled ? (
{options.map((option) => {
@@ -104,8 +104,8 @@ export function Select({
className={cn(
'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition',
isSelected
- ? 'bg-indigo-50 font-medium text-brand'
- : 'text-zinc-700 hover:bg-zinc-50 hover:text-zinc-900',
+ ? 'bg-brand-soft font-medium text-brand'
+ : 'text-text-muted hover:bg-surface-hover hover:text-text',
)}
key={option.value}
onClick={() => {
diff --git a/app/src/shared/ui/skeleton/Skeleton.tsx b/app/src/shared/ui/skeleton/Skeleton.tsx
index db29825..3e0bb4e 100644
--- a/app/src/shared/ui/skeleton/Skeleton.tsx
+++ b/app/src/shared/ui/skeleton/Skeleton.tsx
@@ -6,7 +6,7 @@ export function Skeleton({ className, ...props }: HTMLAttributes
return (
);
diff --git a/app/src/shared/ui/toast/Toast.tsx b/app/src/shared/ui/toast/Toast.tsx
index bf49155..f005748 100644
--- a/app/src/shared/ui/toast/Toast.tsx
+++ b/app/src/shared/ui/toast/Toast.tsx
@@ -9,9 +9,9 @@ type ToastProps = {
};
const variantClasses: Record = {
- error: 'border-red-200 bg-red-50 text-red-900',
- info: 'border-indigo-200 bg-indigo-50 text-indigo-950',
- success: 'border-green-200 bg-green-50 text-green-900',
+ error: 'border-danger/20 bg-danger-soft text-danger',
+ info: 'border-info/20 bg-info-soft text-info',
+ success: 'border-success/20 bg-success-soft text-success',
};
export function Toast({ message, title, variant = 'info' }: ToastProps) {
diff --git a/app/src/widgets/app-layout/ui/AppLayout.tsx b/app/src/widgets/app-layout/ui/AppLayout.tsx
index 24d7725..efc6455 100644
--- a/app/src/widgets/app-layout/ui/AppLayout.tsx
+++ b/app/src/widgets/app-layout/ui/AppLayout.tsx
@@ -22,25 +22,25 @@ export function AppLayout({ actions, children }: AppLayoutProps) {
: { label: 'Offline', variant: 'warning' as const };
return (
-
-
+
+
{appConfig.name}
-
{appConfig.description}
+
{appConfig.description}
{syncBadge.label}
Dashboard
Settings
diff --git a/app/src/widgets/dashboard-analytics/ui/DashboardAnalytics.tsx b/app/src/widgets/dashboard-analytics/ui/DashboardAnalytics.tsx
index 0c2135a..462140a 100644
--- a/app/src/widgets/dashboard-analytics/ui/DashboardAnalytics.tsx
+++ b/app/src/widgets/dashboard-analytics/ui/DashboardAnalytics.tsx
@@ -95,16 +95,48 @@ function getExpenseCategoryStats(categoryStats: CategoryStats[]) {
return categoryStats.filter((categoryStatsItem) => categoryStatsItem.kind === 'expense');
}
-function renderEmptyChart(title: string) {
- return ;
+type ChartTooltipContentProps = {
+ active?: boolean;
+ formatter?: (value: number) => string;
+ label?: string;
+ payload?: Array<{ color?: string; name?: string; value?: number }>;
+};
+
+function ChartTooltipContent({ active, formatter, label, payload }: ChartTooltipContentProps) {
+ if (!active || !payload?.length) return null;
+ return (
+
+ {label ?
{label}
: null}
+ {payload.map((entry) => (
+
+ {'● '}
+ {entry.name}: {formatter ? formatter(Number(entry.value)) : String(entry.value)}
+
+ ))}
+
+ );
+}
+
+function getChartEmptyTitle(chartId: ChartId): string {
+ if (chartId === 'monthly-cashflow') return 'No monthly stats';
+ if (chartId === 'balance-trend') return 'No balance trend';
+ if (chartId === 'expense-categories') return 'No expenses yet';
+ if (chartId === 'category-bar') return 'No category totals';
+ if (chartId === 'income-expense-ratio') return 'No totals yet';
+ return 'No daily expenses';
}
+const axisTickStyle = { fill: 'var(--color-text-soft)', fontSize: 12 };
+
function renderChart({
categoryStats,
chartId,
dailyExpenseStats,
monthlyStats,
-}: Pick) {
+}: Pick<
+ ChartSlotProps,
+ 'categoryStats' | 'chartId' | 'dailyExpenseStats' | 'monthlyStats'
+>): JSX.Element | null {
const expenseCategoryStats = getExpenseCategoryStats(categoryStats);
const ratioStats = [
{
@@ -117,18 +149,18 @@ function renderChart({
},
];
+ const tooltipEl = ;
+
if (chartId === 'monthly-cashflow') {
- if (!monthlyStats.length) {
- return renderEmptyChart('No monthly stats');
- }
+ if (!monthlyStats.length) return null;
return (
-
-
-
- formatMoney(Number(value))} />
-
+
+
+
+
+
@@ -136,25 +168,21 @@ function renderChart({
}
if (chartId === 'balance-trend') {
- if (!monthlyStats.length) {
- return renderEmptyChart('No balance trend');
- }
+ if (!monthlyStats.length) return null;
return (
-
-
-
- formatMoney(Number(value))} />
+
+
+
+
);
}
if (chartId === 'expense-categories') {
- if (!expenseCategoryStats.length) {
- return renderEmptyChart('No expenses yet');
- }
+ if (!expenseCategoryStats.length) return null;
return (
@@ -173,38 +201,34 @@ function renderChart({
/>
))}
- formatMoney(Number(value))} />
+
);
}
if (chartId === 'category-bar') {
- if (!expenseCategoryStats.length) {
- return renderEmptyChart('No category totals');
- }
+ if (!expenseCategoryStats.length) return null;
return (
right.total - left.total)}>
-
-
-
- formatMoney(Number(value))} />
+
+
+
+
);
}
if (chartId === 'income-expense-ratio') {
- if (!ratioStats.some((stat) => stat.total > 0)) {
- return renderEmptyChart('No totals yet');
- }
+ if (!ratioStats.some((stat) => stat.total > 0)) return null;
return (
-
-
-
- formatMoney(Number(value))} />
+
+
+
+
{ratioStats.map((stat, index) => (
|
@@ -214,16 +238,14 @@ function renderChart({
);
}
- if (!dailyExpenseStats.length) {
- return renderEmptyChart('No daily expenses');
- }
+ if (!dailyExpenseStats.length) return null;
return (
-
-
-
- formatMoney(Number(value))} />
+
+
+
+
);
@@ -241,6 +263,7 @@ function ChartSlot({
slotLabel,
}: ChartSlotProps) {
const chartMeta = getChartMeta(chartId);
+ const chart = renderChart({ categoryStats, chartId, dailyExpenseStats, monthlyStats });
return (
chart.id === droppedChartId) &&
+ chartCatalog.some((c) => c.id === droppedChartId) &&
(!isWide || supportsWideChart(droppedChartId as ChartId))
) {
onDropChart(droppedChartId as ChartId);
@@ -259,9 +282,9 @@ function ChartSlot({
>
-
{slotLabel}
-
{chartMeta.title}
-
{chartMeta.description}
+
{slotLabel}
+
{chartMeta.title}
+
{chartMeta.description}
{isWide && onSplit ? (
@@ -275,9 +298,18 @@ function ChartSlot({
-
- {renderChart({ categoryStats, chartId, dailyExpenseStats, monthlyStats })}
-
+ {chart ? (
+
+ {chart}
+
+ ) : (
+
+
+
+ )}
);
@@ -290,21 +322,29 @@ function ChartDragPreview({
monthlyStats,
}: Pick) {
const chartMeta = getChartMeta(chartId);
+ const chart = renderChart({ categoryStats, chartId, dailyExpenseStats, monthlyStats });
return (
-
Chart preview
-
{chartMeta.title}
+
Chart preview
+
{chartMeta.title}
-
- {renderChart({ categoryStats, chartId, dailyExpenseStats, monthlyStats })}
-
+ {chart ? (
+
+ {chart}
+
+ ) : (
+
+ )}
);
@@ -400,7 +440,7 @@ export function DashboardAnalytics({ transactions }: DashboardAnalyticsProps) {
-
+
{source === 'google-sheets' ? 'Google Sheets analytics' : 'Local analytics'}
@@ -468,8 +508,8 @@ export function DashboardAnalytics({ transactions }: DashboardAnalyticsProps) {
-
Chart library
-
+
Chart library
+
Choose chart for slot {selectedSlotIndex + 1} or wide view.
@@ -483,8 +523,8 @@ export function DashboardAnalytics({ transactions }: DashboardAnalyticsProps) {
-
{chart.title}
-
{chart.description}
+
{chart.title}
+
{chart.description}
-
+
{Array.from({ length: 6 }, (_, index) => (
))}
-
+
{Array.from({ length: 6 }, (_, index) => (
setIsOpen((current) => !current)}
type="button"
>
...
{isOpen ? (
-
+
{
onEdit();
setIsOpen(false);
@@ -154,7 +154,7 @@ function RowActions({ onDelete, onEdit }: RowActionsProps) {
Edit
{
void onDelete();
setIsOpen(false);
@@ -171,11 +171,11 @@ function RowActions({ onDelete, onEdit }: RowActionsProps) {
function CommentCell({ value }: { value?: string }) {
if (!value) {
- return -
;
+ return -
;
}
return (
-
+
{value}
);
@@ -184,7 +184,7 @@ function CommentCell({ value }: { value?: string }) {
function TransactionField({ children, label }: { children: ReactNode; label: string }) {
return (
-
+
{label}
{children}
@@ -242,7 +242,7 @@ export function TransactionsHistory({
if (error && !transactions.length) {
return (
-
+
{error}
void onRefresh()} variant="secondary">
@@ -268,15 +268,15 @@ export function TransactionsHistory({
return (
-
+
-
Transactions history
-
+
Transactions history
+
{`${filteredTransactions.length} of ${transactions.length} transactions`}
{isLoading ? Refreshing... : null}
{error ?
{error}
: null}
- {syncWarning ?
{syncWarning}
: null}
+ {syncWarning ?
{syncWarning}
: null}
{hasPendingSync ? (
@@ -300,7 +300,7 @@ export function TransactionsHistory({
-
+
{isAdvancedFiltersOpen ? (
-
+
-
+
) : (
-
+
Date
Type
Category
@@ -450,24 +450,24 @@ export function TransactionsHistory({
Status
Actions
-
+
{filteredTransactions.map((transaction) => (
- {transaction.date}
+ {transaction.date}
- {formatKind(transaction.kind)}
+ {formatKind(transaction.kind)}
- {transaction.categoryName}
+ {transaction.categoryName}
- {transaction.paymentMethod || '-'}
+ {transaction.paymentMethod || '-'}
- {transaction.containerName || '-'}
+ {transaction.containerName || '-'}
From 71f816d7326dcd31ad8e4acffd3005816766d3de Mon Sep 17 00:00:00 2001
From: Ermolz <00ermzahar@gmail.com>
Date: Wed, 24 Jun 2026 21:16:50 +0300
Subject: [PATCH 2/5] Add ThemeProvider, redesign Settings page, fix loading
currency select
ThemeProvider (TASK-3): replaces ThemeWiring + event-dispatch pattern.
Theme is now React state in context; setTheme() from useTheme() persists
to localStorage and applies data-theme to immediately. Watches
prefers-color-scheme only when theme === 'system'. Deleted
use-apply-theme.ts.
Settings redesign (TASK-4/5/6): Appearance section with three theme
cards (Light/Dark/System) each with a hardcoded mini-preview using fixed
hex colors so the preview always shows that theme regardless of current
mode. Sections restructured: Appearance, General, Spreadsheet (merged 3
cards into 1 with divide-y), Containers, Data & Sync, Advanced
diagnostics. Advanced is collapsible and shows a mini sync summary
(status, cached count, pending count) in the collapsed state.
Select isLoading prop (fix): CurrencySelect no longer passes 'Loading
currencies...' as hint text, which was expanding the select cell height
and misaligning the Add button. A spinner icon replaces the chevron
inside the trigger while loading.
---
app/src/app/hooks/use-apply-theme.ts | 29 -
app/src/app/providers/AppProviders.tsx | 18 +-
app/src/app/providers/theme-provider.tsx | 50 ++
.../manage-currencies/ui/CurrencySelect.tsx | 3 +-
app/src/pages/settings/ui/SettingsPage.tsx | 556 ++++++++++++------
app/src/shared/ui/select/Select.tsx | 33 +-
6 files changed, 468 insertions(+), 221 deletions(-)
delete mode 100644 app/src/app/hooks/use-apply-theme.ts
create mode 100644 app/src/app/providers/theme-provider.tsx
diff --git a/app/src/app/hooks/use-apply-theme.ts b/app/src/app/hooks/use-apply-theme.ts
deleted file mode 100644
index 29d2463..0000000
--- a/app/src/app/hooks/use-apply-theme.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { useEffect } from 'react';
-
-import { localStorageService } from '@shared/lib/storage/local-storage.service';
-
-function getEffectiveTheme(theme: string): 'light' | 'dark' {
- if (theme === 'dark') return 'dark';
- if (theme === 'light') return 'light';
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
-}
-
-export function useApplyTheme() {
- useEffect(() => {
- function apply() {
- const theme = localStorageService.get('theme') ?? 'system';
- document.documentElement.setAttribute('data-theme', getEffectiveTheme(theme));
- }
-
- apply();
-
- const mq = window.matchMedia('(prefers-color-scheme: dark)');
- mq.addEventListener('change', apply);
- window.addEventListener('sheetly:theme-change', apply);
-
- return () => {
- mq.removeEventListener('change', apply);
- window.removeEventListener('sheetly:theme-change', apply);
- };
- }, []);
-}
diff --git a/app/src/app/providers/AppProviders.tsx b/app/src/app/providers/AppProviders.tsx
index a74f675..64aaf6d 100644
--- a/app/src/app/providers/AppProviders.tsx
+++ b/app/src/app/providers/AppProviders.tsx
@@ -2,24 +2,18 @@ import type { ReactNode } from 'react';
import { BrowserRouter } from 'react-router-dom';
import { GoogleAuthProvider } from '@features/google-auth';
-import { useApplyTheme } from '@app/hooks/use-apply-theme';
+import { ThemeProvider } from './theme-provider';
type AppProvidersProps = {
children: ReactNode;
};
-function ThemeWiring() {
- useApplyTheme();
- return null;
-}
-
export function AppProviders({ children }: AppProvidersProps) {
return (
-
-
-
- {children}
-
-
+
+
+ {children}
+
+
);
}
diff --git a/app/src/app/providers/theme-provider.tsx b/app/src/app/providers/theme-provider.tsx
new file mode 100644
index 0000000..c95317d
--- /dev/null
+++ b/app/src/app/providers/theme-provider.tsx
@@ -0,0 +1,50 @@
+/* eslint-disable react-refresh/only-export-components */
+import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
+
+import { localStorageService } from '@shared/lib/storage/local-storage.service';
+
+export type ThemeValue = 'dark' | 'light' | 'system';
+
+type ThemeContextValue = {
+ setTheme: (theme: ThemeValue) => void;
+ theme: ThemeValue;
+};
+
+const ThemeContext = createContext
(null);
+
+function getEffectiveTheme(theme: ThemeValue): 'dark' | 'light' {
+ if (theme === 'dark') return 'dark';
+ if (theme === 'light') return 'light';
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+}
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ const [theme, setThemeState] = useState(
+ () => (localStorageService.get('theme') as ThemeValue | null) ?? 'system',
+ );
+
+ function setTheme(next: ThemeValue) {
+ localStorageService.set('theme', next);
+ setThemeState(next);
+ }
+
+ useEffect(() => {
+ document.documentElement.setAttribute('data-theme', getEffectiveTheme(theme));
+
+ if (theme === 'system') {
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
+ const apply = () =>
+ document.documentElement.setAttribute('data-theme', getEffectiveTheme('system'));
+ mq.addEventListener('change', apply);
+ return () => mq.removeEventListener('change', apply);
+ }
+ }, [theme]);
+
+ return {children} ;
+}
+
+export function useTheme(): ThemeContextValue {
+ const ctx = useContext(ThemeContext);
+ if (!ctx) throw new Error('useTheme must be used inside ThemeProvider');
+ return ctx;
+}
diff --git a/app/src/features/manage-currencies/ui/CurrencySelect.tsx b/app/src/features/manage-currencies/ui/CurrencySelect.tsx
index d7700a3..b8f7ebc 100644
--- a/app/src/features/manage-currencies/ui/CurrencySelect.tsx
+++ b/app/src/features/manage-currencies/ui/CurrencySelect.tsx
@@ -47,8 +47,9 @@ export function CurrencySelect({
return (
({
diff --git a/app/src/pages/settings/ui/SettingsPage.tsx b/app/src/pages/settings/ui/SettingsPage.tsx
index 91f6030..180b1be 100644
--- a/app/src/pages/settings/ui/SettingsPage.tsx
+++ b/app/src/pages/settings/ui/SettingsPage.tsx
@@ -1,5 +1,6 @@
import { type ChangeEvent, useRef, useState } from 'react';
+import { useTheme, type ThemeValue } from '@app/providers/theme-provider';
import { ConnectSpreadsheetModal } from '@features/connect-spreadsheet';
import { GoogleConnectButton, GoogleConnectionStatus, useGoogleAuth } from '@features/google-auth';
import { ManageContainersModal } from '@features/manage-containers';
@@ -13,6 +14,7 @@ import { AppLayout } from '@widgets/app-layout';
import { Badge } from '@shared/ui/badge';
import { Button } from '@shared/ui/button';
import { Card } from '@shared/ui/card';
+import { cn } from '@shared/lib/classnames/cn';
import { ConfirmModal } from '@shared/ui/confirm-modal';
import { Input } from '@shared/ui/input';
import { localStorageService } from '@shared/lib/storage/local-storage.service';
@@ -20,8 +22,159 @@ import { PageHeader } from '@shared/ui/page-header';
import { Select } from '@shared/ui/select';
import { Toast } from '@shared/ui/toast';
+// ---------- theme mini-previews (hardcoded colors — always show fixed appearance) ----------
+
+function ThemeMiniPreview({ variant }: { variant: ThemeValue }) {
+ if (variant === 'system') {
+ return (
+
+ );
+ }
+
+ const isLight = variant === 'light';
+ const c = {
+ bg: isLight ? '#ffffff' : '#111827',
+ border: isLight ? '#e4e4e7' : '#1e293b',
+ brand: isLight ? '#6366f1' : '#818cf8',
+ navBg: isLight ? '#f8fafc' : '#1f2937',
+ navDot: isLight ? '#a1a1aa' : '#334155',
+ text: isLight ? '#18181b' : '#f1f5f9',
+ textMuted: isLight ? '#a1a1aa' : '#64748b',
+ };
+
+ return (
+
+ );
+}
+
+type ThemeCardProps = {
+ description: string;
+ isActive: boolean;
+ label: string;
+ onClick: () => void;
+ variant: ThemeValue;
+};
+
+function ThemeCard({ description, isActive, label, onClick, variant }: ThemeCardProps) {
+ return (
+
+
+ {label}
+ {description}
+
+ );
+}
+
+// ---------- page ----------
+
export function SettingsPage() {
const fileInputRef = useRef(null);
+ const { setTheme, theme } = useTheme();
+ const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [isConnectOpen, setIsConnectOpen] = useState(false);
const [isContainersOpen, setIsContainersOpen] = useState(false);
const [isResetOpen, setIsResetOpen] = useState(false);
@@ -31,7 +184,6 @@ export function SettingsPage() {
const [currency, setCurrency] = useState(localStorageService.get('currency') ?? 'UAH');
const [language, setLanguage] = useState(localStorageService.get('language') ?? 'en');
const [settingsMessage, setSettingsMessage] = useState(null);
- const [theme, setTheme] = useState(localStorageService.get('theme') ?? 'system');
const spreadsheetId = localStorageService.get('spreadsheetId');
const googleAuth = useGoogleAuth();
const { refresh: refreshSyncStatus, status: syncStatus } = useOfflineSyncStatus(spreadsheetId);
@@ -42,22 +194,11 @@ export function SettingsPage() {
localStorageService.set('containersEnabled', String(enabled));
}
- function updatePreference(key: 'currency' | 'language' | 'theme', value: string) {
+ function updatePreference(key: 'currency' | 'language', value: string) {
localStorageService.set(key, value);
setSettingsMessage('Preferences saved locally.');
-
- if (key === 'currency') {
- setCurrency(value);
- }
-
- if (key === 'language') {
- setLanguage(value);
- }
-
- if (key === 'theme') {
- setTheme(value);
- window.dispatchEvent(new Event('sheetly:theme-change'));
- }
+ if (key === 'currency') setCurrency(value);
+ if (key === 'language') setLanguage(value);
}
function exportSettings() {
@@ -66,9 +207,7 @@ export function SettingsPage() {
settings: localStorageService.exportKnownKeys(),
version: 1,
};
- const blob = new Blob([JSON.stringify(payload, null, 2)], {
- type: 'application/json',
- });
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
@@ -82,24 +221,19 @@ export function SettingsPage() {
async function importSettings(event: ChangeEvent) {
const file = event.target.files?.[0];
- if (!file) {
- return;
- }
+ if (!file) return;
try {
const payload = JSON.parse(await file.text()) as {
settings?: ReturnType;
};
- if (!payload.settings) {
- throw new Error('Invalid settings file.');
- }
+ if (!payload.settings) throw new Error('Invalid settings file.');
localStorageService.importKnownKeys(payload.settings);
setContainersEnabled(localStorageService.get('containersEnabled') === 'true');
setCurrency(localStorageService.get('currency') ?? 'UAH');
setLanguage(localStorageService.get('language') ?? 'en');
- setTheme(localStorageService.get('theme') ?? 'system');
setSettingsMessage('Settings imported. Reload the page if connection state looks stale.');
} catch (error) {
setSettingsMessage(error instanceof Error ? error.message : 'Could not import settings.');
@@ -135,50 +269,108 @@ export function SettingsPage() {
>
}
>
-
+
{settingsMessage ? : null}
+
+ {/* Appearance */}
-
-
-
-
Spreadsheet connection
-
- {spreadsheetId ? 'Connected' : 'Not connected'}
-
-
-
- {spreadsheetId
- ? `Current spreadsheet id: ${spreadsheetId}`
- : 'No spreadsheet is connected yet.'}
-
-
-
setIsConnectOpen(true)} variant="secondary">
- Change connection
-
+
Appearance
+
Choose how Sheetly looks on this device.
+
+ setTheme('light')}
+ variant="light"
+ />
+ setTheme('dark')}
+ variant="dark"
+ />
+ setTheme('system')}
+ variant="system"
+ />
+
+ {/* General */}
-
-
-
Google authorization
-
- {googleAuth.isConfigured
- ? 'Google OAuth client id is configured. Access tokens are kept in memory only.'
- : 'Set VITE_GOOGLE_CLIENT_ID before using Google authorization.'}
-
- {googleAuth.error ? (
-
{googleAuth.error}
- ) : null}
-
-
+
General
+
+ updatePreference('currency', event.target.value.toUpperCase())}
+ value={currency}
+ />
+ updatePreference('language', value)}
+ options={[
+ { label: 'English', value: 'en' },
+ { label: 'Russian', value: 'ru' },
+ { label: 'Ukrainian', value: 'uk' },
+ ]}
+ value={language}
+ />
+
+ {/* Spreadsheet */}
-
+ Spreadsheet
+
+
+
+
+
Connection
+
+ {spreadsheetId ? 'Connected' : 'Not connected'}
+
+
+
+ {spreadsheetId
+ ? `Spreadsheet ID: ${spreadsheetId}`
+ : 'No spreadsheet connected yet.'}
+
+
+
setIsConnectOpen(true)} variant="secondary">
+ Change
+
+
+
+
+
Google authorization
+
+ {googleAuth.isConfigured
+ ? 'OAuth client ID configured. Tokens are kept in memory only.'
+ : 'Set VITE_GOOGLE_CLIENT_ID to enable Google authorization.'}
+
+ {googleAuth.error ? (
+
{googleAuth.error}
+ ) : null}
+
+
+
+
+
+
+
+
+ {/* Containers */}
@@ -189,8 +381,7 @@ export function SettingsPage() {
- Containers are separate money stores used by transactions. When enabled, every new
- transaction requires one.
+ Separate money stores for transactions. When enabled, every transaction requires one.
@@ -201,7 +392,7 @@ export function SettingsPage() {
onChange={(event) => toggleContainers(event.target.checked)}
type="checkbox"
/>
- Enable containers
+ Enable
setIsContainersOpen(true)} variant="secondary">
Manage containers
@@ -209,50 +400,14 @@ export function SettingsPage() {
-
- Preferences
-
- updatePreference('currency', event.target.value.toUpperCase())}
- value={currency}
- />
- updatePreference('language', value)}
- options={[
- { label: 'English', value: 'en' },
- { label: 'Russian', value: 'ru' },
- { label: 'Ukrainian', value: 'uk' },
- ]}
- value={language}
- />
- updatePreference('theme', value)}
- options={[
- { label: 'System', value: 'system' },
- { label: 'Light', value: 'light' },
- { label: 'Dark', value: 'dark' },
- ]}
- value={theme}
- />
-
-
+
+ {/* Data & Sync */}
-
Local data
+
Data & Sync
- Export or import local Sheetly preferences, and reset only this browser cache when
- diagnostics look stale.
+ Export or import local preferences, and reset the browser cache when needed.
@@ -275,83 +430,138 @@ export function SettingsPage() {
-
-
-
-
Diagnostics
-
-
Spreadsheet: {spreadsheetId ?? 'not connected'}
-
Google: {googleAuth.status}
-
Network: {syncStatus.isOnline ? 'online' : 'offline'}
-
Cached transactions: {syncStatus.cachedTransactions}
-
- Queue: {syncStatus.pending} pending, {syncStatus.failed} failed
-
-
Last sync: {syncStatus.lastSuccessfulSyncAt ?? 'never'}
- {syncStatus.lastError ? (
-
Last sync error: {syncStatus.lastError}
- ) : null}
+
+ {/* Advanced diagnostics — collapsible with mini sync summary when closed */}
+
+
+
setIsAdvancedOpen((prev) => !prev)}
+ type="button"
+ >
+ Advanced diagnostics
+
+ {isAdvancedOpen ? 'Hide ↑' : 'Show ↓'}
+
+
+ {!isAdvancedOpen ? (
+
+
+
+ Sync status:
+ 0
+ ? 'text-danger'
+ : syncStatus.pending > 0
+ ? 'text-warning'
+ : 'text-success',
+ )}
+ >
+ {syncStatus.failed > 0
+ ? `${syncStatus.failed} failed`
+ : syncStatus.pending > 0
+ ? `${syncStatus.pending} pending`
+ : syncStatus.isOnline
+ ? 'Up to date'
+ : 'Offline'}
+
+
+
+ Cached transactions:
+ {syncStatus.cachedTransactions}
+
+
+ Pending changes:
+ {syncStatus.totalQueued}
+
+
-
-
-
-
Queue inspector
-
- Pending and failed local changes waiting for Google Sheets sync.
+ ) : null}
+
+ {isAdvancedOpen ? (
+
+
+
+
Diagnostics
+
+
Spreadsheet: {spreadsheetId ?? 'not connected'}
+
Google: {googleAuth.status}
+
Network: {syncStatus.isOnline ? 'online' : 'offline'}
+
Cached transactions: {syncStatus.cachedTransactions}
+
+ Queue: {syncStatus.pending} pending, {syncStatus.failed} failed
-
-
-
void retryQueuedChanges()}
- size="sm"
- variant="secondary"
- >
- Retry all
-
-
void refreshSyncStatus()} size="sm" variant="ghost">
- Refresh queue
-
-
void clearFailedQueue()}
- size="sm"
- variant="danger"
- >
- Clear failed
-
+
Last sync: {syncStatus.lastSuccessfulSyncAt ?? 'never'}
+ {syncStatus.lastError ? (
+
Last sync error: {syncStatus.lastError}
+ ) : null}
-
- {syncStatus.queueItems.length ? (
- syncStatus.queueItems.map((item) => (
-
-
-
- {item.operation}
-
-
- {item.transaction?.categoryName ?? item.transactionId}
-
- {item.transaction?.comment ?
{item.transaction.comment} : null}
+
+
+
+
Queue inspector
+
+ Pending and failed changes waiting for Google Sheets sync.
+
+
+
+ void retryQueuedChanges()}
+ size="sm"
+ variant="secondary"
+ >
+ Retry all
+
+ void refreshSyncStatus()} size="sm" variant="ghost">
+ Refresh queue
+
+ void clearFailedQueue()}
+ size="sm"
+ variant="danger"
+ >
+ Clear failed
+
+
+
+
+ {syncStatus.queueItems.length ? (
+ syncStatus.queueItems.map((item) => (
+
+
+
+ {item.operation}
+
+
+ {item.transaction?.categoryName ?? item.transactionId}
+
+ {item.transaction?.comment ? (
+ {item.transaction.comment}
+ ) : null}
+
+
+ Attempts: {item.attempts} · Created: {item.createdAt}
+
+ {item.lastError ? (
+
Last error: {item.lastError}
+ ) : null}
-
- Attempts: {item.attempts} · Created: {item.createdAt}
-
- {item.lastError ? (
-
Last error: {item.lastError}
- ) : null}
-
- ))
- ) : (
-
Queue is empty.
- )}
+ ))
+ ) : (
+
Queue is empty.
+ )}
+
-
-
-
+
+ ) : null}
+
+
setIsConnectOpen(false)} />
setIsContainersOpen(false)} />
void;
options: SelectOption[];
@@ -23,6 +24,7 @@ export function Select({
error,
hint,
id,
+ isLoading,
label,
onChange,
options,
@@ -77,12 +79,16 @@ export function Select({
type="button"
>
{selectedOption?.label ?? 'Select...'}
-
+ {isLoading ? (
+
+ ) : (
+
+ )}
{description ? (
@@ -157,3 +163,18 @@ function CheckIcon({ className }: { className?: string }) {
);
}
+
+function SpinnerIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ );
+}
From df5978b1cccf6f2998a5e5036af33f25a9c8b2d9 Mon Sep 17 00:00:00 2001
From: Ermolz <00ermzahar@gmail.com>
Date: Wed, 24 Jun 2026 21:21:05 +0300
Subject: [PATCH 3/5] Add live theme preview in Settings and color-ban ESLint
rule
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
TASK-10: ThemePreview component renders a miniature app UI using only
semantic tokens (bg-surface, text-text, bg-brand, etc.) so it instantly
reflects any theme change without extra code. Inserted below the theme
selection cards in the Appearance section.
TASK-11: Inline ESLint plugin 'color-ban' in eslint.config.js bans
hardcoded Tailwind color utilities (bg-white, bg-zinc-*, bg-slate-*,
text-zinc-*, text-red-*, text-green-*, text-indigo-*, border-zinc-*,
ring-red-*, ring-indigo-*, hover:bg-zinc-*) across src/**/*.{ts,tsx}.
Full src/ lint pass confirms zero violations — codebase is already clean.
---
app/eslint.config.js | 55 ++++++++++++++++++++++
app/src/pages/settings/ui/SettingsPage.tsx | 41 ++++++++++++++++
2 files changed, 96 insertions(+)
diff --git a/app/eslint.config.js b/app/eslint.config.js
index 6e372c2..b723ee3 100644
--- a/app/eslint.config.js
+++ b/app/eslint.config.js
@@ -5,6 +5,54 @@ import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
import tseslint from 'typescript-eslint';
+// Inline plugin: bans raw Tailwind color utilities that should be replaced
+// with semantic design-system tokens (bg-surface, text-text, border-border…).
+// Exceptions live in app/src/app/styles/ (token definitions) and are
+// implicitly excluded because those files are .css, not .ts/.tsx.
+const colorBanPlugin = {
+ rules: {
+ 'no-hardcoded-colors': {
+ create(context) {
+ const banned = [
+ /\bbg-white\b/,
+ /\bbg-zinc-\d+/,
+ /\bbg-slate-\d+/,
+ /\btext-zinc-\d+/,
+ /\btext-red-\d+/,
+ /\btext-green-\d+/,
+ /\btext-indigo-\d+/,
+ /\bborder-zinc-\d+/,
+ /\bring-red-\d+/,
+ /\bring-indigo-\d+/,
+ /\bhover:bg-zinc-\d+/,
+ ];
+
+ function check(node, value) {
+ for (const re of banned) {
+ const match = re.exec(value);
+ if (match) {
+ context.report({
+ node,
+ message: `Hardcoded color "${match[0]}" is not allowed. Use a semantic token instead (e.g. bg-surface, text-text, border-border).`,
+ });
+ return;
+ }
+ }
+ }
+
+ return {
+ Literal(node) {
+ if (typeof node.value === 'string') check(node, node.value);
+ },
+ TemplateLiteral(node) {
+ for (const q of node.quasis) check(q, q.value.raw);
+ },
+ };
+ },
+ },
+ },
+};
+
export default defineConfig([
globalIgnores(['dist', 'coverage']),
{
@@ -19,4 +67,11 @@ export default defineConfig([
globals: globals.browser,
},
},
+ {
+ files: ['src/**/*.{ts,tsx}'],
+ plugins: { 'color-ban': colorBanPlugin },
+ rules: {
+ 'color-ban/no-hardcoded-colors': 'error',
+ },
+ },
]);
diff --git a/app/src/pages/settings/ui/SettingsPage.tsx b/app/src/pages/settings/ui/SettingsPage.tsx
index 180b1be..2367aeb 100644
--- a/app/src/pages/settings/ui/SettingsPage.tsx
+++ b/app/src/pages/settings/ui/SettingsPage.tsx
@@ -169,6 +169,43 @@ function ThemeCard({ description, isActive, label, onClick, variant }: ThemeCard
);
}
+function ThemePreview() {
+ return (
+
+ {/* Mock nav header */}
+
+
+
Sheetly
+
Personal finance
+
+
+
+ Synced
+
+
+
+ {/* Mock page content */}
+
+
Dashboard
+ {/* Mock balance card */}
+
+ {/* Mock action buttons */}
+
+
+ Add transaction
+
+
+ Export
+
+
+
+
+ );
+}
+
// ---------- page ----------
export function SettingsPage() {
@@ -299,6 +336,10 @@ export function SettingsPage() {
variant="system"
/>
+
{/* General */}
From 7f7a2a2f83181f5eebab50ea7a7a15381ea98bd4 Mon Sep 17 00:00:00 2001
From: Ermolz <00ermzahar@gmail.com>
Date: Wed, 24 Jun 2026 21:34:21 +0300
Subject: [PATCH 4/5] Add Vitest test coverage for critical paths and enforce
coverage thresholds
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New test files (83 tests):
- local-storage.service.test.ts: get/set/remove/export/import/clear across all
known keys — foundation for all persistence in the app
- theme-provider.test.tsx: getEffectiveTheme resolution (dark/light/system +
OS matchMedia), ThemeProvider DOM wiring, useTheme hook state/localStorage
persistence and listener registration
- Select.test.tsx: rendering (label, placeholder, selected, spinner, chevron,
error, hint), interaction (open/close, onChange, Escape key, isLoading guard,
disabled guard, aria-selected)
- color-ban-patterns.test.ts: 14 banned cases + 23 allowed semantic-token cases
that act as the specification for the ESLint color-ban rule
Also:
- Export getEffectiveTheme from theme-provider.tsx for direct unit testing
- Fix Select.tsx: isLoading now prevents dropdown from opening (was missing guard)
- Add @testing-library/react + @testing-library/user-event + @testing-library/jest-dom
- Add test-setup.ts (jest-dom matchers) wired via vite.config.ts setupFiles
- Add coverage thresholds (lines/statements 35%, branches/functions 33%) so CI
fails if coverage regresses below the current baseline
---
app/package.json | 3 +
app/src/app/providers/theme-provider.test.tsx | 127 +++++++++++++
app/src/app/providers/theme-provider.tsx | 2 +-
.../lib/color-ban/color-ban-patterns.test.ts | 89 +++++++++
.../lib/storage/local-storage.service.test.ts | 110 +++++++++++
app/src/shared/ui/select/Select.test.tsx | 116 ++++++++++++
app/src/shared/ui/select/Select.tsx | 4 +-
app/src/test-setup.ts | 1 +
app/vite.config.ts | 7 +
pnpm-lock.yaml | 178 ++++++++++++++++++
10 files changed, 635 insertions(+), 2 deletions(-)
create mode 100644 app/src/app/providers/theme-provider.test.tsx
create mode 100644 app/src/shared/lib/color-ban/color-ban-patterns.test.ts
create mode 100644 app/src/shared/lib/storage/local-storage.service.test.ts
create mode 100644 app/src/shared/ui/select/Select.test.tsx
create mode 100644 app/src/test-setup.ts
diff --git a/app/package.json b/app/package.json
index 3b6ab4d..8d53f1f 100644
--- a/app/package.json
+++ b/app/package.json
@@ -34,6 +34,9 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
diff --git a/app/src/app/providers/theme-provider.test.tsx b/app/src/app/providers/theme-provider.test.tsx
new file mode 100644
index 0000000..bb7325e
--- /dev/null
+++ b/app/src/app/providers/theme-provider.test.tsx
@@ -0,0 +1,127 @@
+import { render, renderHook, act } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { getEffectiveTheme, ThemeProvider, useTheme } from './theme-provider';
+
+function mockMatchMedia(dark: boolean) {
+ const mq = {
+ matches: dark,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ };
+ vi.stubGlobal('matchMedia', vi.fn().mockReturnValue(mq));
+ return mq;
+}
+
+afterEach(() => {
+ localStorage.clear();
+ document.documentElement.removeAttribute('data-theme');
+ vi.unstubAllGlobals();
+});
+
+// ── getEffectiveTheme ──────────────────────────────────────────────────────────
+
+describe('getEffectiveTheme', () => {
+ it("'dark' always resolves to 'dark'", () => {
+ mockMatchMedia(false);
+ expect(getEffectiveTheme('dark')).toBe('dark');
+ });
+
+ it("'light' always resolves to 'light'", () => {
+ mockMatchMedia(true);
+ expect(getEffectiveTheme('light')).toBe('light');
+ });
+
+ it("'system' resolves to 'dark' when OS prefers dark", () => {
+ mockMatchMedia(true);
+ expect(getEffectiveTheme('system')).toBe('dark');
+ });
+
+ it("'system' resolves to 'light' when OS prefers light", () => {
+ mockMatchMedia(false);
+ expect(getEffectiveTheme('system')).toBe('light');
+ });
+});
+
+// ── ThemeProvider DOM integration ─────────────────────────────────────────────
+
+describe('ThemeProvider', () => {
+ it('sets data-theme on documentElement on mount', () => {
+ mockMatchMedia(false);
+ render(
child );
+ expect(document.documentElement.getAttribute('data-theme')).toBe('light');
+ });
+
+ it('reads initial theme from localStorage', () => {
+ localStorage.setItem('theme', 'dark');
+ mockMatchMedia(false);
+ render(
child );
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
+ });
+
+ it('falls back to system when localStorage is empty', () => {
+ mockMatchMedia(true);
+ render(
child );
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
+ });
+});
+
+// ── useTheme hook ─────────────────────────────────────────────────────────────
+
+describe('useTheme', () => {
+ it('throws when used outside ThemeProvider', () => {
+ expect(() => renderHook(() => useTheme())).toThrow(
+ 'useTheme must be used inside ThemeProvider',
+ );
+ });
+
+ it('returns the current theme value', () => {
+ localStorage.setItem('theme', 'light');
+ mockMatchMedia(false);
+ const { result } = renderHook(() => useTheme(), {
+ wrapper: ({ children }) =>
{children} ,
+ });
+ expect(result.current.theme).toBe('light');
+ });
+
+ it('setTheme updates the theme state and persists to localStorage', () => {
+ mockMatchMedia(false);
+ const { result } = renderHook(() => useTheme(), {
+ wrapper: ({ children }) =>
{children} ,
+ });
+
+ act(() => {
+ result.current.setTheme('dark');
+ });
+
+ expect(result.current.theme).toBe('dark');
+ expect(localStorage.getItem('theme')).toBe('dark');
+ });
+
+ it('setTheme updates data-theme attribute on documentElement', () => {
+ mockMatchMedia(false);
+ const { result } = renderHook(() => useTheme(), {
+ wrapper: ({ children }) =>
{children} ,
+ });
+
+ act(() => {
+ result.current.setTheme('dark');
+ });
+
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
+ });
+
+ it('switching to system registers a matchMedia listener', () => {
+ localStorage.setItem('theme', 'light');
+ const mq = mockMatchMedia(false);
+ const { result } = renderHook(() => useTheme(), {
+ wrapper: ({ children }) =>
{children} ,
+ });
+
+ act(() => {
+ result.current.setTheme('system');
+ });
+
+ expect(mq.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
+ });
+});
diff --git a/app/src/app/providers/theme-provider.tsx b/app/src/app/providers/theme-provider.tsx
index c95317d..27c667f 100644
--- a/app/src/app/providers/theme-provider.tsx
+++ b/app/src/app/providers/theme-provider.tsx
@@ -12,7 +12,7 @@ type ThemeContextValue = {
const ThemeContext = createContext
(null);
-function getEffectiveTheme(theme: ThemeValue): 'dark' | 'light' {
+export function getEffectiveTheme(theme: ThemeValue): 'dark' | 'light' {
if (theme === 'dark') return 'dark';
if (theme === 'light') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
diff --git a/app/src/shared/lib/color-ban/color-ban-patterns.test.ts b/app/src/shared/lib/color-ban/color-ban-patterns.test.ts
new file mode 100644
index 0000000..ea97ed3
--- /dev/null
+++ b/app/src/shared/lib/color-ban/color-ban-patterns.test.ts
@@ -0,0 +1,89 @@
+/* eslint-disable color-ban/no-hardcoded-colors */
+import { describe, expect, it } from 'vitest';
+
+// Mirrors the patterns in app/eslint.config.js colorBanPlugin.
+// Keeping them here provides a regression-test spec: if someone edits the rule,
+// the test fails and forces a conscious decision about what's allowed.
+const BANNED: RegExp[] = [
+ /\bbg-white\b/,
+ /\bbg-zinc-\d+/,
+ /\bbg-slate-\d+/,
+ /\btext-zinc-\d+/,
+ /\btext-red-\d+/,
+ /\btext-green-\d+/,
+ /\btext-indigo-\d+/,
+ /\bborder-zinc-\d+/,
+ /\bring-red-\d+/,
+ /\bring-indigo-\d+/,
+ /\bhover:bg-zinc-\d+/,
+];
+
+function findBanned(value: string): string | null {
+ for (const re of BANNED) {
+ const match = re.exec(value);
+ if (match) return match[0];
+ }
+ return null;
+}
+
+describe('color-ban patterns — must catch banned Tailwind utilities', () => {
+ const bannedCases: [string, string][] = [
+ ['bg-white', 'bg-white'],
+ ['bg-zinc-100', 'bg-zinc-100'],
+ ['bg-zinc-900', 'bg-zinc-900'],
+ ['bg-slate-800', 'bg-slate-800'],
+ ['text-zinc-500', 'text-zinc-500'],
+ ['text-red-500', 'text-red-500'],
+ ['text-green-500', 'text-green-500'],
+ ['text-indigo-600', 'text-indigo-600'],
+ ['border-zinc-200', 'border-zinc-200'],
+ ['ring-red-500', 'ring-red-500'],
+ ['ring-indigo-200', 'ring-indigo-200'],
+ // hover: prefix doesn't escape the check — bg-zinc-\d+ catches it first via \b after ':'
+ ['hover:bg-zinc-100', 'bg-zinc-100'],
+ ['embedded in a template className="bg-zinc-50 px-3"', 'bg-zinc-50'],
+ ['multiple classes "text-red-600 font-bold"', 'text-red-600'],
+ ];
+
+ it.each(bannedCases)('catches "%s"', (input, expected) => {
+ expect(findBanned(input)).toBe(expected);
+ });
+});
+
+describe('color-ban patterns — must allow semantic tokens', () => {
+ const allowedCases = [
+ 'bg-surface',
+ 'bg-surface-hover',
+ 'bg-surface-muted',
+ 'bg-surface-strong',
+ 'bg-app-bg',
+ 'bg-brand',
+ 'bg-brand-soft',
+ 'bg-danger',
+ 'bg-danger-soft',
+ 'bg-success-soft',
+ 'bg-warning-soft',
+ 'text-text',
+ 'text-text-soft',
+ 'text-text-muted',
+ 'text-text-inverted',
+ 'text-brand',
+ 'text-danger',
+ 'text-success',
+ 'text-warning',
+ 'border-border',
+ 'border-border-strong',
+ 'ring-brand-ring',
+ 'ring-danger-soft',
+ 'hover:bg-surface-hover',
+ 'hover:text-text',
+ // partial words that contain banned substrings but are NOT banned
+ 'bg-zinc', // no digit suffix
+ 'text-reds', // not text-red-
+ 'bg-whites-soft', // bg-white is only the exact token
+ ];
+
+ it.each(allowedCases)('allows "%s"', (input) => {
+ expect(findBanned(input)).toBeNull();
+ });
+});
diff --git a/app/src/shared/lib/storage/local-storage.service.test.ts b/app/src/shared/lib/storage/local-storage.service.test.ts
new file mode 100644
index 0000000..b68fcbc
--- /dev/null
+++ b/app/src/shared/lib/storage/local-storage.service.test.ts
@@ -0,0 +1,110 @@
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { localStorageService } from './local-storage.service';
+
+afterEach(() => {
+ localStorage.clear();
+});
+
+describe('localStorageService.get', () => {
+ it('returns null for a key that has never been set', () => {
+ expect(localStorageService.get('theme')).toBeNull();
+ });
+
+ it('returns the stored string value', () => {
+ localStorage.setItem('theme', 'dark');
+ expect(localStorageService.get('theme')).toBe('dark');
+ });
+});
+
+describe('localStorageService.set', () => {
+ it('persists the value so get returns it', () => {
+ localStorageService.set('theme', 'light');
+ expect(localStorage.getItem('theme')).toBe('light');
+ });
+
+ it('overwrites an existing value', () => {
+ localStorageService.set('theme', 'dark');
+ localStorageService.set('theme', 'system');
+ expect(localStorage.getItem('theme')).toBe('system');
+ });
+});
+
+describe('localStorageService.remove', () => {
+ it('deletes a previously stored key', () => {
+ localStorageService.set('theme', 'dark');
+ localStorageService.remove('theme');
+ expect(localStorage.getItem('theme')).toBeNull();
+ });
+
+ it('is a no-op for a key that does not exist', () => {
+ expect(() => localStorageService.remove('theme')).not.toThrow();
+ });
+});
+
+describe('localStorageService.exportKnownKeys', () => {
+ it('returns null for all keys when storage is empty', () => {
+ const snapshot = localStorageService.exportKnownKeys();
+ expect(Object.values(snapshot).every((v) => v === null)).toBe(true);
+ });
+
+ it('includes the stored value for each set key', () => {
+ localStorageService.set('theme', 'dark');
+ localStorageService.set('currency', 'USD');
+ const snapshot = localStorageService.exportKnownKeys();
+ expect(snapshot.theme).toBe('dark');
+ expect(snapshot.currency).toBe('USD');
+ expect(snapshot.language).toBeNull();
+ });
+
+ it('does not include unknown/external keys', () => {
+ localStorage.setItem('someRandomKey', 'value');
+ const snapshot = localStorageService.exportKnownKeys();
+ expect(Object.keys(snapshot)).not.toContain('someRandomKey');
+ });
+});
+
+describe('localStorageService.importKnownKeys', () => {
+ it('writes provided keys to storage', () => {
+ localStorageService.importKnownKeys({ theme: 'light', currency: 'EUR' });
+ expect(localStorage.getItem('theme')).toBe('light');
+ expect(localStorage.getItem('currency')).toBe('EUR');
+ });
+
+ it('skips keys with null values', () => {
+ localStorage.setItem('theme', 'dark');
+ localStorageService.importKnownKeys({ theme: null });
+ expect(localStorage.getItem('theme')).toBe('dark');
+ });
+
+ it('skips keys with undefined values', () => {
+ localStorage.setItem('theme', 'dark');
+ localStorageService.importKnownKeys({ language: undefined });
+ expect(localStorage.getItem('theme')).toBe('dark');
+ });
+
+ it('only imports known keys — unknown keys are ignored', () => {
+ localStorageService.importKnownKeys({ theme: 'dark' } as never);
+ expect(localStorage.getItem('rogue')).toBeNull();
+ });
+});
+
+describe('localStorageService.clearKnownKeys', () => {
+ it('removes all known keys', () => {
+ localStorageService.set('theme', 'dark');
+ localStorageService.set('currency', 'USD');
+ localStorageService.set('spreadsheetId', 'abc123');
+
+ localStorageService.clearKnownKeys();
+
+ expect(localStorage.getItem('theme')).toBeNull();
+ expect(localStorage.getItem('currency')).toBeNull();
+ expect(localStorage.getItem('spreadsheetId')).toBeNull();
+ });
+
+ it('does not remove unknown keys', () => {
+ localStorage.setItem('externalKey', 'should-survive');
+ localStorageService.clearKnownKeys();
+ expect(localStorage.getItem('externalKey')).toBe('should-survive');
+ });
+});
diff --git a/app/src/shared/ui/select/Select.test.tsx b/app/src/shared/ui/select/Select.test.tsx
new file mode 100644
index 0000000..57ecb62
--- /dev/null
+++ b/app/src/shared/ui/select/Select.test.tsx
@@ -0,0 +1,116 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+import { Select } from './Select';
+
+const OPTIONS = [
+ { label: 'Apple', value: 'apple' },
+ { label: 'Banana', value: 'banana' },
+ { label: 'Cherry', value: 'cherry' },
+];
+
+function setup(overrides = {}) {
+ const onChange = vi.fn();
+ const user = userEvent.setup();
+ render( );
+ return { onChange, user };
+}
+
+// ── Rendering ─────────────────────────────────────────────────────────────────
+
+describe('Select rendering', () => {
+ it('shows the label when provided', () => {
+ setup({ label: 'Fruit' });
+ expect(screen.getByText('Fruit')).toBeInTheDocument();
+ });
+
+ it('shows fallback text when no option matches the value', () => {
+ setup({ value: '' });
+ expect(screen.getByRole('button', { name: /select\.\.\./i })).toBeInTheDocument();
+ });
+
+ it('shows the selected option label', () => {
+ setup({ value: 'banana' });
+ expect(screen.getByRole('button').textContent).toContain('Banana');
+ });
+
+ it('renders a chevron icon by default (not loading)', () => {
+ const { container } = render( );
+ // Spinner has a specific arc path; chevron has the down-chevron path
+ expect(container.querySelector('path[d="m6 9 6 6 6-6"]')).not.toBeNull();
+ expect(container.querySelector('path[d="M12 2C6.477 2 2 6.477 2 12"]')).toBeNull();
+ });
+
+ it('renders spinner icon when isLoading is true', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector('path[d="M12 2C6.477 2 2 6.477 2 12"]')).not.toBeNull();
+ expect(container.querySelector('path[d="m6 9 6 6 6-6"]')).toBeNull();
+ });
+
+ it('shows error message when error prop is provided', () => {
+ setup({ error: 'Required field' });
+ expect(screen.getByText('Required field')).toBeInTheDocument();
+ });
+
+ it('shows hint text when hint prop is provided', () => {
+ setup({ hint: 'Choose a fruit' });
+ expect(screen.getByText('Choose a fruit')).toBeInTheDocument();
+ });
+});
+
+// ── Interaction ───────────────────────────────────────────────────────────────
+
+describe('Select interaction', () => {
+ it('opens the dropdown when trigger is clicked', async () => {
+ const { user } = setup({ value: 'apple' });
+ await user.click(screen.getByRole('button', { name: /apple/i }));
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
+ });
+
+ it('closes the dropdown after selecting an option', async () => {
+ const { user } = setup({ value: 'apple' });
+ await user.click(screen.getByRole('button', { name: /apple/i }));
+ await user.click(screen.getByRole('option', { name: /banana/i }));
+ expect(screen.queryByRole('listbox')).toBeNull();
+ });
+
+ it('calls onChange with the selected option value', async () => {
+ const { user, onChange } = setup({ value: 'apple' });
+ await user.click(screen.getByRole('button', { name: /apple/i }));
+ await user.click(screen.getByRole('option', { name: /cherry/i }));
+ expect(onChange).toHaveBeenCalledWith('cherry');
+ });
+
+ it('closes on Escape key', async () => {
+ const { user } = setup({ value: 'apple' });
+ await user.click(screen.getByRole('button', { name: /apple/i }));
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
+ await user.keyboard('{Escape}');
+ expect(screen.queryByRole('listbox')).toBeNull();
+ });
+
+ it('does NOT open when isLoading is true', async () => {
+ const { user } = setup({ isLoading: true, value: 'apple' });
+ // The trigger button is disabled-by-design when loading: clicking it opens nothing
+ const trigger = screen.getAllByRole('button')[0];
+ await user.click(trigger);
+ expect(screen.queryByRole('listbox')).toBeNull();
+ });
+
+ it('does NOT open when disabled', async () => {
+ const { user } = setup({ disabled: true, value: 'apple' });
+ const trigger = screen.getByRole('button');
+ await user.click(trigger);
+ expect(screen.queryByRole('listbox')).toBeNull();
+ });
+
+ it('marks the currently selected option with aria-selected', async () => {
+ const { user } = setup({ value: 'banana' });
+ await user.click(screen.getByRole('button', { name: /banana/i }));
+ const selected = screen.getByRole('option', { name: /banana/i });
+ expect(selected).toHaveAttribute('aria-selected', 'true');
+ });
+});
diff --git a/app/src/shared/ui/select/Select.tsx b/app/src/shared/ui/select/Select.tsx
index 05e42aa..a929a3e 100644
--- a/app/src/shared/ui/select/Select.tsx
+++ b/app/src/shared/ui/select/Select.tsx
@@ -75,7 +75,9 @@ export function Select({
error && 'border-danger focus:border-danger focus:ring-danger-soft',
)}
disabled={disabled}
- onClick={() => setIsOpen((o) => !o)}
+ onClick={() => {
+ if (!isLoading) setIsOpen((o) => !o);
+ }}
type="button"
>
{selectedOption?.label ?? 'Select...'}
diff --git a/app/src/test-setup.ts b/app/src/test-setup.ts
new file mode 100644
index 0000000..7b0828b
--- /dev/null
+++ b/app/src/test-setup.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom';
diff --git a/app/vite.config.ts b/app/vite.config.ts
index 4622691..0bfd660 100644
--- a/app/vite.config.ts
+++ b/app/vite.config.ts
@@ -18,6 +18,7 @@ export default defineConfig({
},
},
test: {
+ setupFiles: ['./src/test-setup.ts'],
coverage: {
exclude: [
'dist/**',
@@ -30,6 +31,12 @@ export default defineConfig({
provider: 'v8',
reporter: ['text', 'html', 'json-summary', 'lcov'],
reportsDirectory: './coverage',
+ thresholds: {
+ branches: 33,
+ functions: 33,
+ lines: 35,
+ statements: 35,
+ },
},
environment: 'jsdom',
globals: true,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ba7b6f3..2687364 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -35,6 +35,15 @@ importers:
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1(eslint@10.4.0(jiti@2.7.0))
+ '@testing-library/jest-dom':
+ specifier: ^6.9.1
+ version: 6.9.1
+ '@testing-library/react':
+ specifier: ^16.3.2
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@testing-library/user-event':
+ specifier: ^14.6.1
+ version: 14.6.1(@testing-library/dom@10.4.1)
'@types/node':
specifier: ^24.12.3
version: 24.12.4
@@ -89,6 +98,9 @@ importers:
packages:
+ '@adobe/css-tools@4.5.0':
+ resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
+
'@asamuzakjp/css-color@5.1.11':
resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -159,6 +171,10 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
@@ -526,9 +542,41 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
+ '@testing-library/dom@10.4.1':
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+ engines: {node: '>=18'}
+
+ '@testing-library/jest-dom@6.9.1':
+ resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
+ engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
+
+ '@testing-library/react@16.3.2':
+ resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@testing-library/dom': ^10.0.0
+ '@types/react': ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^18.0.0 || ^19.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@testing-library/user-event@14.6.1':
+ resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
+ engines: {node: '>=12', npm: '>=6'}
+ peerDependencies:
+ '@testing-library/dom': '>=7.21.4'
+
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
+ '@types/aria-query@5.0.4':
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -712,14 +760,25 @@ packages:
resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==}
engines: {node: '>=18'}
+ ansi-regex@5.0.1:
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+ engines: {node: '>=8'}
+
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
+ ansi-styles@5.2.0:
+ resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
+ engines: {node: '>=10'}
+
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
+ aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -782,6 +841,9 @@ packages:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+ css.escape@1.5.1:
+ resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
+
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -851,10 +913,20 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+ dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
+ dom-accessibility-api@0.5.16:
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+
+ dom-accessibility-api@0.6.3:
+ resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+
electron-to-chromium@1.5.359:
resolution: {integrity: sha512-8lPELWuYZIWk7NDvCNthtmMw/7Q5Wu25NpM4djFMHBmk8DubPAtL4YTOp7ou0e7HyJtwkVlWv8XMLURnrtgJQw==}
@@ -1047,6 +1119,10 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
+ indent-string@4.0.0:
+ resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
+ engines: {node: '>=8'}
+
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
@@ -1224,6 +1300,10 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+ lz-string@1.5.0:
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+ hasBin: true
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -1241,6 +1321,10 @@ packages:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
+ min-indent@1.0.1:
+ resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+ engines: {node: '>=4'}
+
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
@@ -1312,6 +1396,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
+ pretty-format@27.5.1:
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -1321,6 +1409,9 @@ packages:
peerDependencies:
react: ^19.2.6
+ react-is@17.0.2:
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+
react-is@19.2.6:
resolution: {integrity: sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==}
@@ -1365,6 +1456,10 @@ packages:
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ redent@3.0.0:
+ resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
+ engines: {node: '>=8'}
+
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
@@ -1460,6 +1555,10 @@ packages:
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'}
+ strip-indent@3.0.0:
+ resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+ engines: {node: '>=8'}
+
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -1708,6 +1807,8 @@ packages:
snapshots:
+ '@adobe/css-tools@4.5.0': {}
+
'@asamuzakjp/css-color@5.1.11':
dependencies:
'@asamuzakjp/generational-cache': 1.0.1
@@ -1805,6 +1906,8 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
+ '@babel/runtime@7.29.7': {}
+
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
@@ -2089,11 +2192,47 @@ snapshots:
tailwindcss: 4.3.0
vite: 8.0.13(@types/node@24.12.4)(jiti@2.7.0)(yaml@2.9.0)
+ '@testing-library/dom@10.4.1':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/runtime': 7.29.7
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ picocolors: 1.1.1
+ pretty-format: 27.5.1
+
+ '@testing-library/jest-dom@6.9.1':
+ dependencies:
+ '@adobe/css-tools': 4.5.0
+ aria-query: 5.3.0
+ css.escape: 1.5.1
+ dom-accessibility-api: 0.6.3
+ picocolors: 1.1.1
+ redent: 3.0.0
+
+ '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@testing-library/dom': 10.4.1
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ optionalDependencies:
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
+
+ '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
+ dependencies:
+ '@testing-library/dom': 10.4.1
+
'@tybys/wasm-util@0.10.2':
dependencies:
tslib: 2.8.1
optional: true
+ '@types/aria-query@5.0.4': {}
+
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@@ -2313,10 +2452,18 @@ snapshots:
dependencies:
environment: 1.1.0
+ ansi-regex@5.0.1: {}
+
ansi-regex@6.2.2: {}
+ ansi-styles@5.2.0: {}
+
ansi-styles@6.2.3: {}
+ aria-query@5.3.0:
+ dependencies:
+ dequal: 2.0.3
+
assertion-error@2.0.1: {}
ast-v8-to-istanbul@1.0.0:
@@ -2375,6 +2522,8 @@ snapshots:
mdn-data: 2.27.1
source-map-js: 1.2.1
+ css.escape@1.5.1: {}
+
csstype@3.2.3: {}
d3-array@3.2.4:
@@ -2432,8 +2581,14 @@ snapshots:
deep-is@0.1.4: {}
+ dequal@2.0.3: {}
+
detect-libc@2.1.2: {}
+ dom-accessibility-api@0.5.16: {}
+
+ dom-accessibility-api@0.6.3: {}
+
electron-to-chromium@1.5.359: {}
emoji-regex@10.6.0: {}
@@ -2613,6 +2768,8 @@ snapshots:
imurmurhash@0.1.4: {}
+ indent-string@4.0.0: {}
+
internmap@2.0.3: {}
is-extglob@2.1.1: {}
@@ -2777,6 +2934,8 @@ snapshots:
dependencies:
yallist: 3.1.1
+ lz-string@1.5.0: {}
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -2795,6 +2954,8 @@ snapshots:
mimic-function@5.0.1: {}
+ min-indent@1.0.1: {}
+
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.6
@@ -2854,6 +3015,12 @@ snapshots:
prettier@3.8.3: {}
+ pretty-format@27.5.1:
+ dependencies:
+ ansi-regex: 5.0.1
+ ansi-styles: 5.2.0
+ react-is: 17.0.2
+
punycode@2.3.1: {}
react-dom@19.2.6(react@19.2.6):
@@ -2861,6 +3028,8 @@ snapshots:
react: 19.2.6
scheduler: 0.27.0
+ react-is@17.0.2: {}
+
react-is@19.2.6: {}
react-redux@9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1):
@@ -2908,6 +3077,11 @@ snapshots:
- '@types/react'
- redux
+ redent@3.0.0:
+ dependencies:
+ indent-string: 4.0.0
+ strip-indent: 3.0.0
+
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
@@ -3001,6 +3175,10 @@ snapshots:
dependencies:
ansi-regex: 6.2.2
+ strip-indent@3.0.0:
+ dependencies:
+ min-indent: 1.0.1
+
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
From b1bb0ebc8cd8a04777f14b774ea3130fbea12b04 Mon Sep 17 00:00:00 2001
From: Ermolz <00ermzahar@gmail.com>
Date: Wed, 24 Jun 2026 22:48:01 +0300
Subject: [PATCH 5/5] Fix TypeScript errors breaking CI
- DashboardAnalytics: import JSX type from 'react' (namespace not globally
available in strict mode with React 19)
- CurrencySelect: convert hint={warning} (string|null) to hint={warning??undefined}
to match Select prop type (string|undefined)
---
app/src/features/manage-currencies/ui/CurrencySelect.tsx | 2 +-
app/src/widgets/dashboard-analytics/ui/DashboardAnalytics.tsx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/src/features/manage-currencies/ui/CurrencySelect.tsx b/app/src/features/manage-currencies/ui/CurrencySelect.tsx
index b8f7ebc..1a67a42 100644
--- a/app/src/features/manage-currencies/ui/CurrencySelect.tsx
+++ b/app/src/features/manage-currencies/ui/CurrencySelect.tsx
@@ -47,7 +47,7 @@ export function CurrencySelect({
return (