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/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/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/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/AppProviders.tsx b/app/src/app/providers/AppProviders.tsx index 76e67a3..64aaf6d 100644 --- a/app/src/app/providers/AppProviders.tsx +++ b/app/src/app/providers/AppProviders.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import { BrowserRouter } from 'react-router-dom'; import { GoogleAuthProvider } from '@features/google-auth'; +import { ThemeProvider } from './theme-provider'; type AppProvidersProps = { children: ReactNode; @@ -9,8 +10,10 @@ type AppProvidersProps = { export function AppProviders({ children }: AppProvidersProps) { return ( - - {children} - + + + {children} + + ); } 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 new file mode 100644 index 0000000..27c667f --- /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); + +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'; +} + +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/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/manage-currencies/ui/CurrencySelect.tsx b/app/src/features/manage-currencies/ui/CurrencySelect.tsx index d7700a3..1a67a42 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 ( updatePreference('currency', event.target.value.toUpperCase())} + value={currency} + /> + toggleContainers(event.target.checked)} type="checkbox" /> - Enable containers + Enable + {!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

-
-
- - - +

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. +

+
+
+ + + +
+
+
+ {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)} /> +
{ + 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/badge/Badge.tsx b/app/src/shared/ui/badge/Badge.tsx index c3ba8c8..f7f48ea 100644 --- a/app/src/shared/ui/badge/Badge.tsx +++ b/app/src/shared/ui/badge/Badge.tsx @@ -9,11 +9,11 @@ type BadgeProps = HTMLAttributes & { }; 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 ( -
+
({ 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} {description ? ( - + {description} ) : null} {isOpen ? (
- + {MONTHS[viewDate.getMonth()]} {viewDate.getFullYear()} 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 ( -