From eb36f37d64e5ba85dc2a4eb5119c62fa1165491b Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Sun, 18 Jan 2026 12:19:53 +0100 Subject: [PATCH] Add dark mode with toggle for prototyping tool - Add ThemeProvider context with system/light/dark modes - Add ThemeToggle button to all pages (Gallery, DemoMode, UserStoriesPage, SpecsPage) - Add --tool-* CSS variables for outer app theming - Keep inner Festipod mockup screens always in light mode - Add subtle glow around phone frame in dark mode for visibility Co-Authored-By: Claude Opus 4.5 --- src/App.tsx | 9 ++- src/components/DemoMode.tsx | 74 +++++++++++++----- src/components/Gallery.tsx | 29 ++++--- src/components/ThemeToggle.tsx | 59 ++++++++++++++ src/components/UserStoriesPage.tsx | 107 ++++++++++++++------------ src/components/sketchy/PhoneFrame.tsx | 3 +- src/components/specs/SpecsPage.tsx | 3 + src/context/ThemeContext.tsx | 83 ++++++++++++++++++++ src/index.css | 35 ++++++++- 9 files changed, 316 insertions(+), 86 deletions(-) create mode 100644 src/components/ThemeToggle.tsx create mode 100644 src/context/ThemeContext.tsx diff --git a/src/App.tsx b/src/App.tsx index e3bb663..1ffa0da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { RouterProvider, useRouter } from './router'; +import { ThemeProvider } from './context/ThemeContext'; import { Gallery } from './components/Gallery'; import { DemoMode } from './components/DemoMode'; import { UserStoriesPage } from './components/UserStoriesPage'; @@ -50,9 +51,11 @@ function AppContent() { export function App() { return ( - - - + + + + + ); } diff --git a/src/components/DemoMode.tsx b/src/components/DemoMode.tsx index 9731668..44178da 100644 --- a/src/components/DemoMode.tsx +++ b/src/components/DemoMode.tsx @@ -3,6 +3,7 @@ import { PhoneFrame } from './sketchy'; import { screens, getScreen } from '../screens'; import { getStoriesForScreen, categoryLabels, categoryColors, priorityColors } from '../data'; import { getStoryUrl } from '../router'; +import { ThemeToggle } from './ThemeToggle'; interface DemoModeProps { initialScreenId: string; @@ -52,8 +53,9 @@ export function DemoMode({ initialScreenId, onBack, onNavigateToStory }: DemoMod display: 'flex', flexDirection: 'row', height: '100vh', - background: 'var(--sketch-bg)', + background: 'var(--tool-bg)', overflow: 'hidden', + transition: 'background-color 0.2s ease', }}> {/* Left Sidebar */}
- {/* Back button */} -
+ {/* Back button and theme toggle */} +
+
{/* Current screen & navigation */} -
+
Écran actuel @@ -90,22 +102,41 @@ export function DemoMode({ initialScreenId, onBack, onNavigateToStory }: DemoMod fontSize: 16, fontWeight: 'bold', marginBottom: 12, + color: 'var(--tool-text)', }}> {currentScreen?.name}
+ + {/* Theme toggle */} +
@@ -114,7 +121,7 @@ export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryP fontFamily: 'var(--font-sketch)', fontSize: 18, margin: '0 0 16px 32px', - color: 'var(--sketch-black)', + color: 'var(--tool-text)', }}> {group.name} @@ -179,7 +186,7 @@ function GalleryItem({ screen, scale, onClick }: GalleryItemProps) { fontSize: 14, textAlign: 'center', marginTop: 8, - color: 'var(--sketch-black)', + color: 'var(--tool-text)', }}> {screen.name}

diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..d53cdc8 --- /dev/null +++ b/src/components/ThemeToggle.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { useTheme } from '../context/ThemeContext'; +import { Sun, Moon, Monitor } from 'lucide-react'; + +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + const cycleTheme = () => { + if (theme === 'system') setTheme('light'); + else if (theme === 'light') setTheme('dark'); + else setTheme('system'); + }; + + const getIcon = () => { + switch (theme) { + case 'light': + return ; + case 'dark': + return ; + case 'system': + return ; + } + }; + + const getLabel = () => { + switch (theme) { + case 'light': + return 'Clair'; + case 'dark': + return 'Sombre'; + case 'system': + return 'Auto'; + } + }; + + return ( + + ); +} diff --git a/src/components/UserStoriesPage.tsx b/src/components/UserStoriesPage.tsx index 7ca5a00..64a1430 100644 --- a/src/components/UserStoriesPage.tsx +++ b/src/components/UserStoriesPage.tsx @@ -10,6 +10,7 @@ import { type StoryCategory, } from '../data'; import { getScreen, screens } from '../screens'; +import { ThemeToggle } from './ThemeToggle'; interface UserStoriesPageProps { selectedStoryId?: string; @@ -101,64 +102,71 @@ export function UserStoriesPage({ selectedStoryId, onBack, onSelectScreen }: Use const hasFilters = selectedCategories.size > 0 || selectedPriorities.size > 0 || selectedScreens.size > 0; return ( -
+
{/* Header */}
- -
-

- User Stories -

-

- {filteredStories.length} / {userStories.length} stories · Cliquez sur un écran pour voir le mockup -

+
+ +
+

+ User Stories +

+

+ {filteredStories.length} / {userStories.length} stories · Cliquez sur un écran pour voir le mockup +

+
+
{/* Filter bar */}
{/* Category filters */}
Catégorie @@ -179,7 +187,7 @@ export function UserStoriesPage({ selectedStoryId, onBack, onSelectScreen }: Use Priorité @@ -200,7 +208,7 @@ export function UserStoriesPage({ selectedStoryId, onBack, onSelectScreen }: Use Écran @@ -209,7 +217,7 @@ export function UserStoriesPage({ selectedStoryId, onBack, onSelectScreen }: Use toggleScreen(screen.id)} /> @@ -243,7 +251,7 @@ export function UserStoriesPage({ selectedStoryId, onBack, onSelectScreen }: Use

@@ -259,6 +267,7 @@ export function UserStoriesPage({ selectedStoryId, onBack, onSelectScreen }: Use display: 'flex', alignItems: 'center', gap: 12, + color: 'var(--tool-text)', }}> ({stories.length} stories) @@ -345,10 +354,10 @@ const StoryCard = React.forwardRef(

@@ -370,6 +379,7 @@ const StoryCard = React.forwardRef( fontFamily: 'var(--font-sketch)', fontSize: 16, margin: 0, + color: 'var(--tool-text)', }}> {story.title} @@ -378,7 +388,7 @@ const StoryCard = React.forwardRef(

@@ -392,13 +402,14 @@ const StoryCard = React.forwardRef( key={id} onClick={() => onSelectScreen(id)} style={{ - background: 'var(--sketch-light-gray)', - border: '1px solid var(--sketch-black)', + background: 'var(--tool-border-light)', + border: '1px solid var(--tool-border)', borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px', padding: '6px 12px', fontFamily: 'var(--font-sketch)', fontSize: 13, cursor: 'pointer', + color: 'var(--tool-text)', }} > → {screen!.name} @@ -409,7 +420,7 @@ const StoryCard = React.forwardRef(

diff --git a/src/components/sketchy/PhoneFrame.tsx b/src/components/sketchy/PhoneFrame.tsx index f02baef..fc19275 100644 --- a/src/components/sketchy/PhoneFrame.tsx +++ b/src/components/sketchy/PhoneFrame.tsx @@ -13,7 +13,7 @@ export function PhoneFrame({ children, scale = 1, className = '' }: PhoneFramePr return (

9:41 diff --git a/src/components/specs/SpecsPage.tsx b/src/components/specs/SpecsPage.tsx index 8463cfc..ac81d11 100644 --- a/src/components/specs/SpecsPage.tsx +++ b/src/components/specs/SpecsPage.tsx @@ -8,6 +8,7 @@ import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '../ui import { Button } from '../ui/button'; import { ArrowLeft, FileText, Monitor, CheckCircle2, XCircle, AlertCircle, ExternalLink } from 'lucide-react'; import type { ParsedFeature } from '../../types/gherkin'; +import { ThemeToggle } from '../ThemeToggle'; interface SpecsPageProps { selectedFeatureId?: string; @@ -109,8 +110,10 @@ export function SpecsPage({ selectedFeatureId, onBack, onSelectScreen, onSelectS Rapport HTML +
)} + {testSummary.totalScenarios === 0 && }
diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx new file mode 100644 index 0000000..c9d9ffb --- /dev/null +++ b/src/context/ThemeContext.tsx @@ -0,0 +1,83 @@ +import React, { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; + +type Theme = 'light' | 'dark' | 'system'; + +interface ThemeContextValue { + theme: Theme; + resolvedTheme: 'light' | 'dark'; + setTheme: (theme: Theme) => void; +} + +const ThemeContext = createContext(null); + +const STORAGE_KEY = 'festipod-theme'; + +function getSystemTheme(): 'light' | 'dark' { + if (typeof window === 'undefined') return 'light'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +function getStoredTheme(): Theme { + if (typeof window === 'undefined') return 'system'; + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'light' || stored === 'dark' || stored === 'system') { + return stored; + } + return 'system'; +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState(getStoredTheme); + const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>( + theme === 'system' ? getSystemTheme() : theme + ); + + const setTheme = (newTheme: Theme) => { + setThemeState(newTheme); + localStorage.setItem(STORAGE_KEY, newTheme); + }; + + useEffect(() => { + const resolved = theme === 'system' ? getSystemTheme() : theme; + setResolvedTheme(resolved); + + const root = document.documentElement; + if (resolved === 'dark') { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } + }, [theme]); + + useEffect(() => { + if (theme !== 'system') return; + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handler = (e: MediaQueryListEvent) => { + setResolvedTheme(e.matches ? 'dark' : 'light'); + const root = document.documentElement; + if (e.matches) { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } + }; + + mediaQuery.addEventListener('change', handler); + return () => mediaQuery.removeEventListener('change', handler); + }, [theme]); + + return ( + + {children} + + ); +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +} diff --git a/src/index.css b/src/index.css index 6fa97f3..9e22f0a 100644 --- a/src/index.css +++ b/src/index.css @@ -10,6 +10,24 @@ --sketch-accent: #4a90d9; --sketch-line-width: 2px; --font-sketch: 'Architects Daughter', cursive; + + /* Prototyping tool theme (outer app) */ + --tool-bg: #fafafa; + --tool-surface: #ffffff; + --tool-text: #2d2d2d; + --tool-text-muted: #666; + --tool-border: #2d2d2d; + --tool-border-light: #e5e5e5; +} + +/* Dark mode for prototyping tool only */ +.dark { + --tool-bg: #1a1a1a; + --tool-surface: #2d2d2d; + --tool-text: #f5f5f5; + --tool-text-muted: #a0a0a0; + --tool-border: #4a4a4a; + --tool-border-light: #3a3a3a; } * { @@ -18,10 +36,11 @@ body { font-family: var(--font-sketch); - background-color: var(--sketch-bg); - color: var(--sketch-black); + background-color: var(--tool-bg); + color: var(--tool-text); margin: 0; padding: 0; + transition: background-color 0.2s ease, color 0.2s ease; } /* Sketchy border effect using border-radius variations */ @@ -312,6 +331,18 @@ body { - Blue = user-provided content only (names, event titles, descriptions, usernames) =========================================== */ +/* Force phone screen to always use light mode colors */ +.phone-screen { + color: var(--sketch-black); + background: var(--sketch-white); +} + +/* Dark mode: add outer glow to phone frame for visibility */ +.dark .phone-frame-wrapper { + box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.15); + border-radius: 44px; +} + /* User content - displayed in blue */ .phone-screen .user-content { color: var(--sketch-accent);