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
14 changes: 7 additions & 7 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

/* ── Light mode tokens ── */
:root {
--bg: #F7F4F0;
--surface: #ffffff;
--border: #E8E3DC;
--text: #181412;
--muted: #5C5550;
--accent: #2563eb;
--bg: #ffffff;
--surface: #f8fafc;
--border: #e2e8f0;
--text: #0f172a;
--muted: #64748b;
--accent: #3b82f6;
--accent-hover: #1d4ed8;
--film-600: #e63946;
--film-400: #ff6b6b;
Expand Down Expand Up @@ -98,4 +98,4 @@ body {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
}
39 changes: 14 additions & 25 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,34 +49,23 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
<script
dangerouslySetInnerHTML={{
__html: `
(function () {
try {
var stored = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var isDark = stored === 'dark' || (!stored && prefersDark);
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
if (stored === 'high-contrast') {
document.documentElement.setAttribute(
'data-theme',
'high-contrast'
);
} else {
document.documentElement.removeAttribute('data-theme');
}
} catch (e) {}
})();
`,
__html: `(function() {
try {
const theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch(e) {}
})();`,
}}
/>
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
</head>
<body className="min-h-screen bg-[var(--bg)] text-[var(--text)] antialiased">

Expand Down Expand Up @@ -106,4 +95,4 @@ export default function RootLayout({
</body>
</html>
);
}
}
4 changes: 2 additions & 2 deletions src/components/ExportOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export default function ExportOverlay({ status, progress, onCancel }: Props) {
role="dialog"
aria-modal="true"
tabIndex={-1}
className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-white/95 dark:bg-black/70 backdrop-blur-sm"
className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-[var(--bg)] backdrop-blur-sm"
>
<div
className="text-center space-y-6 max-w-xs px-6 animate-fade-in"
Expand Down Expand Up @@ -141,4 +141,4 @@ export default function ExportOverlay({ status, progress, onCancel }: Props) {
</div>
</FocusTrap>
);
}
}
14 changes: 7 additions & 7 deletions src/components/OnboardingTour.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,15 +164,15 @@ function Tooltip({ step, stepIndex, totalSteps, rect, onNext, onSkip, tooltipRef
aria-modal="true"
aria-label={`Onboarding step ${stepIndex + 1} of ${totalSteps}: ${step.title}`}
className="fixed z-[9999] w-80 rounded-xl shadow-2xl border
bg-white dark:bg-zinc-900
border-zinc-200 dark:border-zinc-700
text-zinc-900 dark:text-zinc-100
bg-[var(--surface)]
border-[var(--border)]
text-[var(--text)]
transition-all duration-200"
style={{ ...style }}
tabIndex={-1}
>
{/* Progress bar */}
<div className="h-1 rounded-t-xl overflow-hidden bg-zinc-200 dark:bg-zinc-700">
<div className="h-1 rounded-t-xl overflow-hidden bg-[var(--border)]">
<div
className="h-full bg-indigo-500 transition-all duration-300"
style={{ width: `${((stepIndex + 1) / totalSteps) * 100}%` }}
Expand All @@ -186,14 +186,14 @@ function Tooltip({ step, stepIndex, totalSteps, rect, onNext, onSkip, tooltipRef
</p>

<h2 className="text-base font-semibold mb-1">{step.title}</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 leading-relaxed mb-4">
<p className="text-sm text-[var(--muted)] leading-relaxed mb-4">
{step.description}
</p>

<div className="flex items-center justify-between gap-3">
<button
onClick={onSkip}
className="text-xs text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 transition-colors underline underline-offset-2"
className="text-xs text-[var(--muted)] hover:text-[var(--text)] transition-colors underline underline-offset-2"
>
Skip tour
</button>
Expand Down Expand Up @@ -337,4 +337,4 @@ useEffect(() => {
</>,
document.body
);
}
}
75 changes: 26 additions & 49 deletions src/components/ThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
type ReactNode,
} from "react";

type Theme = "light" | "dark" | "high-contrast";
type Theme = "light" | "dark";

interface ThemeContextValue {
theme: Theme;
Expand All @@ -19,40 +19,18 @@ interface ThemeContextValue {

const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>("light");

// On mount: read localStorage or fall back to system preference.
// The inline <script> in layout.tsx already applied the class to <html>;
// we just sync React state here so the toggle button shows the right icon.
useEffect(() => {
try {
const stored = localStorage.getItem("theme") as Theme | null;
if (stored === "light" || stored === "dark" || stored === "high-contrast") {
setThemeState(stored);
} else {
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
setThemeState(prefersDark ? "dark" : "light");
}
} catch {
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
setThemeState(prefersDark ? "dark" : "light");
}
function getCurrentTheme(): Theme {
if (
typeof document !== "undefined" &&
document.documentElement.classList.contains("dark")
) {
return "dark";
}
return "light";
}

// Listen for OS-level preference changes (only when no manual override)
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => {
if (!localStorage.getItem("theme")) {
applyTheme(e.matches ? "dark" : "light", false);
}
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(getCurrentTheme);

const applyTheme = useCallback(
(next: Theme, persist = true) => {
Expand All @@ -62,29 +40,28 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
} else {
document.documentElement.classList.remove("dark");
}
if (next === "high-contrast") {
document.documentElement.setAttribute(
"data-theme",
"high-contrast"
);
} else {
document.documentElement.removeAttribute("data-theme");
}
if (persist) {
localStorage.setItem("theme", next);
}
},
[]
);

useEffect(() => {
setThemeState(getCurrentTheme());

const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => {
if (!localStorage.getItem("theme")) {
applyTheme(e.matches ? "dark" : "light", false);
}
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [applyTheme]);

const toggleTheme = useCallback(() => {
applyTheme(
theme === "light"
? "dark"
: theme === "dark"
? "high-contrast"
: "light"
);
applyTheme(theme === "light" ? "dark" : "light");
}, [theme, applyTheme]);

const setTheme = useCallback(
Expand Down
26 changes: 10 additions & 16 deletions src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,25 @@ import { useTheme } from "./ThemeProvider";

export function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
const isDark = theme === "dark";

return (
<button
type="button"
onClick={toggleTheme}
aria-label={
theme === "light"
? "Switch to dark mode"
: theme === "dark"
? "Switch to high contrast mode"
: "Switch to light mode"
}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
className="
relative flex items-center justify-center
w-9 h-9 rounded-full
bg-gray-100 dark:bg-gray-800
text-gray-700 dark:text-gray-200
border border-gray-200 dark:border-gray-700
hover:bg-gray-200 dark:hover:bg-gray-700
bg-[var(--surface)]
text-[var(--text)]
border border-[var(--border)]
hover:opacity-90
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
dark:focus:ring-offset-gray-900
transition-colors duration-200
"
>
{theme === "light" ? (
{isDark ? (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
Expand All @@ -40,8 +34,7 @@ export function ThemeToggle() {
className="w-4 h-4"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
) : (
<svg
Expand All @@ -55,7 +48,8 @@ export function ThemeToggle() {
className="w-4 h-4"
aria-hidden="true"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
)}
</button>
Expand Down
8 changes: 4 additions & 4 deletions src/components/TipCarousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ export default function TipCarousel() {
}`}
>
<div className="flex items-center gap-2">
<IconComponent className="text-film-600 dark:text-film-400" size={14} />
<span className="text-[10px] font-heading font-bold uppercase tracking-widest text-film-600 dark:text-film-400">
<IconComponent className="text-film-600" size={14} />
<span className="text-[10px] font-heading font-bold uppercase tracking-widest text-film-600">
{activeTip.category}
</span>
</div>
Expand All @@ -119,8 +119,8 @@ export default function TipCarousel() {
aria-label={`Go to tip ${idx + 1}`}
className={`h-1.5 rounded-full transition-all duration-300 ease-out cursor-pointer ${
idx === activeIdx
? "w-4 bg-film-600 dark:bg-film-400"
: "w-1.5 bg-film-100 hover:bg-film-200 dark:bg-slate-700 dark:hover:bg-slate-600"
? "w-4 bg-film-600"
: "w-1.5 bg-[var(--border)] hover:opacity-80"
}`}
/>
))}
Expand Down
13 changes: 8 additions & 5 deletions src/components/__tests__/ThemeToggle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ describe('ThemeToggle', () => {

it('toggles theme and persists choice', async () => {
render(
<ThemeProvider>
<ThemeToggle />
</ThemeProvider>
React.createElement(
ThemeProvider,
null,
React.createElement(ThemeToggle)
)
)

const btn = screen.getByRole('button')
Expand All @@ -28,8 +30,9 @@ describe('ThemeToggle', () => {
expect(document.documentElement.classList.contains('dark')).toBe(true)
expect(localStorage.getItem('theme')).toBe('dark')

// Toggle again (dark -> high-contrast) should update storage
// Toggle again (dark -> light) should update storage
await userEvent.click(btn)
expect(localStorage.getItem('theme')).toBe('high-contrast')
expect(document.documentElement.classList.contains('dark')).toBe(false)
expect(localStorage.getItem('theme')).toBe('light')
})
})
Loading
Loading