Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "sheetly-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173,
"autoPort": true
}
]
}
55 changes: 55 additions & 0 deletions app/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']),
{
Expand All @@ -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',
},
},
]);
14 changes: 14 additions & 0 deletions app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@
<script src="https://accounts.google.com/gsi/client" async defer></script>
</head>
<body>
<script>
(function () {
var t = localStorage.getItem('theme') || 'system';
var d =
t === 'dark'
? 'dark'
: t === 'light'
? 'light'
: window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
document.documentElement.setAttribute('data-theme', d);
})();
</script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
Expand Down
3 changes: 3 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 6 additions & 3 deletions app/src/app/providers/AppProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ 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;
};

export function AppProviders({ children }: AppProvidersProps) {
return (
<GoogleAuthProvider>
<BrowserRouter basename={import.meta.env.BASE_URL}>{children}</BrowserRouter>
</GoogleAuthProvider>
<ThemeProvider>
<GoogleAuthProvider>
<BrowserRouter basename={import.meta.env.BASE_URL}>{children}</BrowserRouter>
</GoogleAuthProvider>
</ThemeProvider>
);
}
127 changes: 127 additions & 0 deletions app/src/app/providers/theme-provider.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ThemeProvider>child</ThemeProvider>);
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
});

it('reads initial theme from localStorage', () => {
localStorage.setItem('theme', 'dark');
mockMatchMedia(false);
render(<ThemeProvider>child</ThemeProvider>);
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});

it('falls back to system when localStorage is empty', () => {
mockMatchMedia(true);
render(<ThemeProvider>child</ThemeProvider>);
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 }) => <ThemeProvider>{children}</ThemeProvider>,
});
expect(result.current.theme).toBe('light');
});

it('setTheme updates the theme state and persists to localStorage', () => {
mockMatchMedia(false);
const { result } = renderHook(() => useTheme(), {
wrapper: ({ children }) => <ThemeProvider>{children}</ThemeProvider>,
});

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 }) => <ThemeProvider>{children}</ThemeProvider>,
});

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 }) => <ThemeProvider>{children}</ThemeProvider>,
});

act(() => {
result.current.setTheme('system');
});

expect(mq.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
});
});
50 changes: 50 additions & 0 deletions app/src/app/providers/theme-provider.tsx
Original file line number Diff line number Diff line change
@@ -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<ThemeContextValue | null>(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<ThemeValue>(
() => (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 <ThemeContext.Provider value={{ setTheme, theme }}>{children}</ThemeContext.Provider>;
}

export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used inside ThemeProvider');
return ctx;
}
Loading
Loading