From 1c3097e07d5d68db0d3301e69f4b08bf72d8ef9e Mon Sep 17 00:00:00 2001 From: Ermolz <00ermzahar@gmail.com> Date: Wed, 24 Jun 2026 20:54:06 +0300 Subject: [PATCH 1/5] Add semantic color tokens, light/dark themes, and fix chart theming Introduces 24 CSS custom property tokens via Tailwind v4 @theme with light defaults and [data-theme="dark"] overrides. All shared UI components, widgets, and pages now use semantic classes (bg-surface, text-text, border-border, etc.) instead of hardcoded zinc/white/indigo. Theme wiring: anti-flash inline script in index.html, useApplyTheme hook (watches localStorage + prefers-color-scheme + sheetly:theme-change event), ThemeWiring mounted in AppProviders. Theme selector in Settings applies instantly without page reload. Fixes Recharts tooltip white background in dark mode via custom ChartTooltipContent component using theme tokens. Fixes EmptyState clipping by rendering it outside ResponsiveContainer when chart has no data. Also themes CartesianGrid stroke and axis ticks via CSS vars. --- .claude/launch.json | 12 ++ app/index.html | 14 ++ app/src/app/hooks/use-apply-theme.ts | 29 +++ app/src/app/providers/AppProviders.tsx | 11 +- app/src/app/styles/index.css | 78 +++++++- .../ui/CreateTransactionModal.tsx | 2 +- .../ui/ManageContainersModal.tsx | 10 +- .../ui/SetupSpreadsheetButton.tsx | 4 +- app/src/pages/dashboard/ui/DashboardPage.tsx | 6 +- app/src/pages/settings/ui/SettingsPage.tsx | 43 ++--- app/src/pages/setup/ui/SetupPage.tsx | 2 +- app/src/shared/ui/badge/Badge.tsx | 10 +- app/src/shared/ui/button/button-variants.ts | 10 +- app/src/shared/ui/card/Card.tsx | 2 +- app/src/shared/ui/combobox/Combobox.tsx | 16 +- app/src/shared/ui/date-picker/DatePicker.tsx | 34 ++-- app/src/shared/ui/empty-state/EmptyState.tsx | 6 +- app/src/shared/ui/input/Input.tsx | 8 +- app/src/shared/ui/loader/Loader.tsx | 4 +- app/src/shared/ui/modal/Modal.tsx | 12 +- app/src/shared/ui/page-header/PageHeader.tsx | 4 +- app/src/shared/ui/select/Select.tsx | 18 +- app/src/shared/ui/skeleton/Skeleton.tsx | 2 +- app/src/shared/ui/toast/Toast.tsx | 6 +- app/src/widgets/app-layout/ui/AppLayout.tsx | 10 +- .../ui/DashboardAnalytics.tsx | 166 +++++++++++------- .../ui/TransactionsHistory.tsx | 50 +++--- 27 files changed, 373 insertions(+), 196 deletions(-) create mode 100644 .claude/launch.json create mode 100644 app/src/app/hooks/use-apply-theme.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..f585122 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "sheetly-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 5173, + "autoPort": true + } + ] +} diff --git a/app/index.html b/app/index.html index caa61aa..c69fbde 100644 --- a/app/index.html +++ b/app/index.html @@ -14,6 +14,20 @@ +
diff --git a/app/src/app/hooks/use-apply-theme.ts b/app/src/app/hooks/use-apply-theme.ts new file mode 100644 index 0000000..29d2463 --- /dev/null +++ b/app/src/app/hooks/use-apply-theme.ts @@ -0,0 +1,29 @@ +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 76e67a3..a74f675 100644 --- a/app/src/app/providers/AppProviders.tsx +++ b/app/src/app/providers/AppProviders.tsx @@ -2,15 +2,24 @@ 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'; type AppProvidersProps = { children: ReactNode; }; +function ThemeWiring() { + useApplyTheme(); + return null; +} + export function AppProviders({ children }: AppProvidersProps) { return ( - {children} + + + {children} + ); } diff --git a/app/src/app/styles/index.css b/app/src/app/styles/index.css index 0a3f9cd..ae5153d 100644 --- a/app/src/app/styles/index.css +++ b/app/src/app/styles/index.css @@ -1,14 +1,84 @@ @import 'tailwindcss'; @theme { + /* Surface */ + --color-app-bg: #ffffff; + --color-surface: #ffffff; + --color-surface-muted: #f8fafc; + --color-surface-hover: #f1f5f9; + --color-surface-strong: #18181b; + + /* Border */ + --color-border: #e4e4e7; + --color-border-strong: #a1a1aa; + + /* Text */ + --color-text: #18181b; + --color-text-muted: #52525b; + --color-text-soft: #a1a1aa; + --color-text-inverted: #ffffff; + + /* Brand */ --color-brand: #6366f1; - --color-success: #22c55e; - --color-danger: #ef4444; + --color-brand-hover: #4f46e5; + --color-brand-soft: #eef2ff; + --color-brand-ring: #c7d2fe; + + /* Semantic states */ + --color-danger: #dc2626; + --color-danger-hover: #b91c1c; + --color-danger-soft: #fef2f2; + --color-danger-ring: #fecaca; + + --color-success: #16a34a; + --color-success-soft: #f0fdf4; + + --color-warning: #d97706; + --color-warning-soft: #fffbeb; + + --color-info: #6366f1; + --color-info-soft: #eef2ff; +} + +/* Dark theme overrides */ +[data-theme='dark'] { + --color-app-bg: #0a0f1e; + --color-surface: #111827; + --color-surface-muted: #1f2937; + --color-surface-hover: #2d3748; + --color-surface-strong: #64748b; + + --color-border: #1e293b; + --color-border-strong: #334155; + + --color-text: #f1f5f9; + --color-text-muted: #94a3b8; + --color-text-soft: #64748b; + --color-text-inverted: #ffffff; + + --color-brand: #818cf8; + --color-brand-hover: #6366f1; + --color-brand-soft: #1e1b4b; + --color-brand-ring: #3730a3; + + --color-danger: #f87171; + --color-danger-hover: #ef4444; + --color-danger-soft: #450a0a; + --color-danger-ring: #7f1d1d; + + --color-success: #4ade80; + --color-success-soft: #052e16; + + --color-warning: #fbbf24; + --color-warning-soft: #451a03; + + --color-info: #818cf8; + --color-info-soft: #1e1b4b; } :root { - color: #18181b; - background: #f8fafc; + color: var(--color-text); + background-color: var(--color-app-bg); font-family: Inter, ui-sans-serif, diff --git a/app/src/features/create-transaction/ui/CreateTransactionModal.tsx b/app/src/features/create-transaction/ui/CreateTransactionModal.tsx index ba8c9ad..131c9ad 100644 --- a/app/src/features/create-transaction/ui/CreateTransactionModal.tsx +++ b/app/src/features/create-transaction/ui/CreateTransactionModal.tsx @@ -267,7 +267,7 @@ export function CreateTransactionModal({
{amountPresets.map((amount) => (
-
+
{isLoading ? ( -
Loading containers...
+
Loading containers...
) : containers.length ? ( containers.map((container) => (
-

{container.name}

-

{container.currency}

+

{container.name}

+

{container.currency}

{container.isDefault ? 'Default' : 'Custom'} @@ -73,7 +73,7 @@ export function ManageContainersModal({ isOpen, onClose }: ManageContainersModal
)) ) : ( -
No containers yet.
+
No containers yet.
)}
diff --git a/app/src/features/setup-spreadsheet/ui/SetupSpreadsheetButton.tsx b/app/src/features/setup-spreadsheet/ui/SetupSpreadsheetButton.tsx index d74a3ec..ba11da7 100644 --- a/app/src/features/setup-spreadsheet/ui/SetupSpreadsheetButton.tsx +++ b/app/src/features/setup-spreadsheet/ui/SetupSpreadsheetButton.tsx @@ -12,14 +12,14 @@ export function SetupSpreadsheetButton() {
-

Spreadsheet template

+

Spreadsheet template

{status === 'ready' ? 'Ready' : status === 'error' ? 'Needs attention' : 'Not checked'}
-

+

Create required sheets, headers, default categories and template version.

{error ?

{error}

: null} diff --git a/app/src/pages/dashboard/ui/DashboardPage.tsx b/app/src/pages/dashboard/ui/DashboardPage.tsx index 757f6f6..29eae3b 100644 --- a/app/src/pages/dashboard/ui/DashboardPage.tsx +++ b/app/src/pages/dashboard/ui/DashboardPage.tsx @@ -33,14 +33,14 @@ type SummaryCardProps = { function SummaryCard({ label, getValue, summaries }: SummaryCardProps) { return ( -

{label}

+

{label}

{summaries.length === 0 ? ( -

—

+

—

) : (
    {summaries.map((entry) => (
  • {formatMoney(getValue(entry), entry.currency)} diff --git a/app/src/pages/settings/ui/SettingsPage.tsx b/app/src/pages/settings/ui/SettingsPage.tsx index 750eebb..91f6030 100644 --- a/app/src/pages/settings/ui/SettingsPage.tsx +++ b/app/src/pages/settings/ui/SettingsPage.tsx @@ -56,6 +56,7 @@ export function SettingsPage() { if (key === 'theme') { setTheme(value); + window.dispatchEvent(new Event('sheetly:theme-change')); } } @@ -143,12 +144,12 @@ export function SettingsPage() {
    -

    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.

    -