From fcbe7d66e67258d97fe413e6d7b7b1b1bb0d9b79 Mon Sep 17 00:00:00 2001 From: Pablo Allendes Date: Mon, 29 Dec 2025 09:31:20 +0100 Subject: [PATCH 01/18] docs: add dark mode implementation design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design document covering semantic token system, ThemeProvider API, component migration strategy, and Storybook integration for adding dark mode support to the library. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docs/plans/2025-12-24-dark-mode-design.md | 343 ++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 docs/plans/2025-12-24-dark-mode-design.md diff --git a/docs/plans/2025-12-24-dark-mode-design.md b/docs/plans/2025-12-24-dark-mode-design.md new file mode 100644 index 00000000..0e4c5ea1 --- /dev/null +++ b/docs/plans/2025-12-24-dark-mode-design.md @@ -0,0 +1,343 @@ +# Dark Mode Implementation Design + +## Overview + +Add dark mode support to the Hailstorm UI component library using CSS semantic tokens and a React ThemeProvider. + +## Decisions + +| Aspect | Decision | +|--------|----------| +| Theme control | System preference default + manual override | +| Color approach | CSS variables with semantic tokens | +| Migration | Incremental - priority components first | +| API | `` + `useTheme()` hook | +| Toggle component | Not included - consumers build their own | + +--- + +## 1. Semantic Token System + +### Token Architecture + +Extend `src/styles/index.css` with semantic tokens that map to raw colors differently per theme. + +```css +@theme { + /* Raw colors (existing) - keep as-is */ + --color-neutral-900: #504e61; + --color-neutral-0: #ffffff; + /* ... all existing colors stay ... */ +} + +/* Semantic tokens - light theme (default) */ +:root { + /* Backgrounds */ + --color-bg-base: var(--color-neutral-0); + --color-bg-subtle: var(--color-neutral-50); + --color-bg-muted: var(--color-neutral-100); + --color-bg-emphasis: var(--color-neutral-900); + --color-bg-primary: var(--color-primary-500); + --color-bg-success: var(--color-success-100); + --color-bg-danger: var(--color-danger-100); + --color-bg-warning: var(--color-warning-100); + + /* Text */ + --color-text-base: var(--color-neutral-900); + --color-text-muted: var(--color-neutral-600); + --color-text-subtle: var(--color-neutral-500); + --color-text-inverse: var(--color-neutral-0); + --color-text-primary: var(--color-primary-600); + + /* Borders */ + --color-border-default: var(--color-neutral-300); + --color-border-muted: var(--color-neutral-200); + --color-border-emphasis: var(--color-neutral-400); +} + +/* Dark theme overrides */ +.dark { + --color-bg-base: var(--color-neutral-900); + --color-bg-subtle: var(--color-neutral-800); + --color-bg-muted: var(--color-neutral-700); + --color-bg-emphasis: var(--color-neutral-100); + --color-bg-primary: var(--color-primary-400); + --color-bg-success: var(--color-success-900); + --color-bg-danger: var(--color-danger-900); + --color-bg-warning: var(--color-warning-900); + + --color-text-base: var(--color-neutral-100); + --color-text-muted: var(--color-neutral-300); + --color-text-subtle: var(--color-neutral-400); + --color-text-inverse: var(--color-neutral-900); + --color-text-primary: var(--color-primary-300); + + --color-border-default: var(--color-neutral-600); + --color-border-muted: var(--color-neutral-700); + --color-border-emphasis: var(--color-neutral-500); +} +``` + +### Tailwind Integration + +Register semantic tokens in the `@theme` layer so they become Tailwind utilities: + +```css +@theme { + --color-bg-base: var(--color-bg-base); + --color-text-base: var(--color-text-base); + /* etc. */ +} +``` + +This enables `bg-bg-base`, `text-text-base` class usage in components. + +--- + +## 2. ThemeProvider & useTheme + +### File Structure + +``` +src/ + components/ + theme/ + theme-provider.tsx + use-theme.ts + index.ts +``` + +### ThemeProvider Implementation + +```tsx +// theme-provider.tsx +import { createContext, useEffect, useState } from 'react'; + +type Theme = 'light' | 'dark' | 'system'; +type ResolvedTheme = 'light' | 'dark'; + +interface ThemeContextValue { + theme: Theme; + resolvedTheme: ResolvedTheme; + setTheme: (theme: Theme) => void; +} + +const ThemeContext = createContext(undefined); + +interface ThemeProviderProps { + children: React.ReactNode; + defaultTheme?: Theme; + storageKey?: string; +} + +export function ThemeProvider({ + children, + defaultTheme = 'system', + storageKey = 'hailstorm-theme' +}: ThemeProviderProps) { + const [theme, setThemeState] = useState(() => { + if (typeof window === 'undefined') return defaultTheme; + return (localStorage.getItem(storageKey) as Theme) || defaultTheme; + }); + + const [resolvedTheme, setResolvedTheme] = useState('light'); + + useEffect(() => { + const root = document.documentElement; + + const applyTheme = (resolved: ResolvedTheme) => { + root.classList.remove('light', 'dark'); + root.classList.add(resolved); + setResolvedTheme(resolved); + }; + + if (theme === 'system') { + const media = window.matchMedia('(prefers-color-scheme: dark)'); + applyTheme(media.matches ? 'dark' : 'light'); + + const listener = (e: MediaQueryListEvent) => { + applyTheme(e.matches ? 'dark' : 'light'); + }; + media.addEventListener('change', listener); + return () => media.removeEventListener('change', listener); + } else { + applyTheme(theme); + } + }, [theme]); + + const setTheme = (newTheme: Theme) => { + localStorage.setItem(storageKey, newTheme); + setThemeState(newTheme); + }; + + return ( + + {children} + + ); +} + +export { ThemeContext }; +``` + +### useTheme Hook + +```tsx +// use-theme.ts +import { useContext } from 'react'; +import { ThemeContext } from './theme-provider'; + +export function useTheme() { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +} +``` + +### Consumer Usage + +```tsx +import { ThemeProvider, useTheme } from '@abusix/hailstorm'; + +// Wrap app + + + + +// Use anywhere +function MyToggle() { + const { theme, setTheme } = useTheme(); + return ( + + ); +} +``` + +--- + +## 3. Component Migration + +### Phase 1 Priority Components + +| Component | Reason | +|-----------|--------| +| Button | Most used, multiple variants | +| Alert | Intent-based colors | +| Badge | Many color variants | +| Dialog | Backdrop + surface contrast | +| Sidebar | App shell, always visible | +| SidebarMenuLink | App shell navigation | +| TopBar | App shell, brand color | +| Input/TextInput | Core form functionality | +| Card | Common container | + +### Migration Pattern + +**Before:** +```tsx +const buttonVariants = { + primary: "bg-primary-500 text-neutral-0 hover:bg-primary-600", + secondary: "text-neutral-700 bg-neutral-0 border border-neutral-400", +}; +``` + +**After:** +```tsx +const buttonVariants = { + primary: "bg-bg-primary text-text-inverse hover:bg-primary-600", + secondary: "text-text-base bg-bg-base border border-border-default", +}; +``` + +### Token Mapping Reference + +| Current Class | Semantic Replacement | Usage | +|---------------|---------------------|-------| +| `bg-neutral-0` | `bg-bg-base` | Default backgrounds | +| `bg-neutral-50/100` | `bg-bg-subtle` | Subtle backgrounds | +| `text-neutral-900` | `text-text-base` | Primary text | +| `text-neutral-600` | `text-text-muted` | Secondary text | +| `border-neutral-300` | `border-border-default` | Standard borders | +| `bg-primary-500` | `bg-bg-primary` | Primary actions | +| `text-neutral-0` (on dark bg) | `text-text-inverse` | Inverse text | + +--- + +## 4. Storybook Integration + +### Theme Toolbar Toggle + +Update `.storybook/preview.ts`: + +```ts +import type { Preview } from '@storybook/react'; +import '../src/styles/index.css'; + +const preview: Preview = { + globalTypes: { + theme: { + description: 'Global theme for components', + toolbar: { + title: 'Theme', + icon: 'circlehollow', + items: [ + { value: 'light', icon: 'sun', title: 'Light' }, + { value: 'dark', icon: 'moon', title: 'Dark' }, + ], + dynamicTitle: true, + }, + }, + }, + initialGlobals: { + theme: 'light', + }, + decorators: [ + (Story, context) => { + const theme = context.globals.theme || 'light'; + document.documentElement.classList.remove('light', 'dark'); + document.documentElement.classList.add(theme); + return ; + }, + ], +}; + +export default preview; +``` + +### Themed Backgrounds + +```ts +parameters: { + backgrounds: { + default: 'themed', + values: [ + { name: 'themed', value: 'var(--color-bg-base)' }, + { name: 'subtle', value: 'var(--color-bg-subtle)' }, + ], + }, +}, +``` + +--- + +## 5. Future Phases + +- Migrate remaining components incrementally +- Optional: Add `` component if requested +- Optional: Support custom color schemes beyond light/dark + +--- + +## Implementation Order + +1. Add semantic tokens to `src/styles/index.css` +2. Create `ThemeProvider` and `useTheme` in `src/components/theme/` +3. Export from main index +4. Update Storybook preview config +5. Migrate Phase 1 components one by one +6. Add tests for ThemeProvider +7. Update documentation From f61b5444b009b7837abae722b7cea22b1639977a Mon Sep 17 00:00:00 2001 From: Pablo Allendes Date: Mon, 29 Dec 2025 15:04:37 +0100 Subject: [PATCH 02/18] docs: add dark mode implementation design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design document covering semantic token system, ThemeProvider API, component migration strategy, and Storybook integration for adding dark mode support to the library. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docs/plans/2025-12-29-dark-mode-design.md | 343 ++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 docs/plans/2025-12-29-dark-mode-design.md diff --git a/docs/plans/2025-12-29-dark-mode-design.md b/docs/plans/2025-12-29-dark-mode-design.md new file mode 100644 index 00000000..0e4c5ea1 --- /dev/null +++ b/docs/plans/2025-12-29-dark-mode-design.md @@ -0,0 +1,343 @@ +# Dark Mode Implementation Design + +## Overview + +Add dark mode support to the Hailstorm UI component library using CSS semantic tokens and a React ThemeProvider. + +## Decisions + +| Aspect | Decision | +|--------|----------| +| Theme control | System preference default + manual override | +| Color approach | CSS variables with semantic tokens | +| Migration | Incremental - priority components first | +| API | `` + `useTheme()` hook | +| Toggle component | Not included - consumers build their own | + +--- + +## 1. Semantic Token System + +### Token Architecture + +Extend `src/styles/index.css` with semantic tokens that map to raw colors differently per theme. + +```css +@theme { + /* Raw colors (existing) - keep as-is */ + --color-neutral-900: #504e61; + --color-neutral-0: #ffffff; + /* ... all existing colors stay ... */ +} + +/* Semantic tokens - light theme (default) */ +:root { + /* Backgrounds */ + --color-bg-base: var(--color-neutral-0); + --color-bg-subtle: var(--color-neutral-50); + --color-bg-muted: var(--color-neutral-100); + --color-bg-emphasis: var(--color-neutral-900); + --color-bg-primary: var(--color-primary-500); + --color-bg-success: var(--color-success-100); + --color-bg-danger: var(--color-danger-100); + --color-bg-warning: var(--color-warning-100); + + /* Text */ + --color-text-base: var(--color-neutral-900); + --color-text-muted: var(--color-neutral-600); + --color-text-subtle: var(--color-neutral-500); + --color-text-inverse: var(--color-neutral-0); + --color-text-primary: var(--color-primary-600); + + /* Borders */ + --color-border-default: var(--color-neutral-300); + --color-border-muted: var(--color-neutral-200); + --color-border-emphasis: var(--color-neutral-400); +} + +/* Dark theme overrides */ +.dark { + --color-bg-base: var(--color-neutral-900); + --color-bg-subtle: var(--color-neutral-800); + --color-bg-muted: var(--color-neutral-700); + --color-bg-emphasis: var(--color-neutral-100); + --color-bg-primary: var(--color-primary-400); + --color-bg-success: var(--color-success-900); + --color-bg-danger: var(--color-danger-900); + --color-bg-warning: var(--color-warning-900); + + --color-text-base: var(--color-neutral-100); + --color-text-muted: var(--color-neutral-300); + --color-text-subtle: var(--color-neutral-400); + --color-text-inverse: var(--color-neutral-900); + --color-text-primary: var(--color-primary-300); + + --color-border-default: var(--color-neutral-600); + --color-border-muted: var(--color-neutral-700); + --color-border-emphasis: var(--color-neutral-500); +} +``` + +### Tailwind Integration + +Register semantic tokens in the `@theme` layer so they become Tailwind utilities: + +```css +@theme { + --color-bg-base: var(--color-bg-base); + --color-text-base: var(--color-text-base); + /* etc. */ +} +``` + +This enables `bg-bg-base`, `text-text-base` class usage in components. + +--- + +## 2. ThemeProvider & useTheme + +### File Structure + +``` +src/ + components/ + theme/ + theme-provider.tsx + use-theme.ts + index.ts +``` + +### ThemeProvider Implementation + +```tsx +// theme-provider.tsx +import { createContext, useEffect, useState } from 'react'; + +type Theme = 'light' | 'dark' | 'system'; +type ResolvedTheme = 'light' | 'dark'; + +interface ThemeContextValue { + theme: Theme; + resolvedTheme: ResolvedTheme; + setTheme: (theme: Theme) => void; +} + +const ThemeContext = createContext(undefined); + +interface ThemeProviderProps { + children: React.ReactNode; + defaultTheme?: Theme; + storageKey?: string; +} + +export function ThemeProvider({ + children, + defaultTheme = 'system', + storageKey = 'hailstorm-theme' +}: ThemeProviderProps) { + const [theme, setThemeState] = useState(() => { + if (typeof window === 'undefined') return defaultTheme; + return (localStorage.getItem(storageKey) as Theme) || defaultTheme; + }); + + const [resolvedTheme, setResolvedTheme] = useState('light'); + + useEffect(() => { + const root = document.documentElement; + + const applyTheme = (resolved: ResolvedTheme) => { + root.classList.remove('light', 'dark'); + root.classList.add(resolved); + setResolvedTheme(resolved); + }; + + if (theme === 'system') { + const media = window.matchMedia('(prefers-color-scheme: dark)'); + applyTheme(media.matches ? 'dark' : 'light'); + + const listener = (e: MediaQueryListEvent) => { + applyTheme(e.matches ? 'dark' : 'light'); + }; + media.addEventListener('change', listener); + return () => media.removeEventListener('change', listener); + } else { + applyTheme(theme); + } + }, [theme]); + + const setTheme = (newTheme: Theme) => { + localStorage.setItem(storageKey, newTheme); + setThemeState(newTheme); + }; + + return ( + + {children} + + ); +} + +export { ThemeContext }; +``` + +### useTheme Hook + +```tsx +// use-theme.ts +import { useContext } from 'react'; +import { ThemeContext } from './theme-provider'; + +export function useTheme() { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +} +``` + +### Consumer Usage + +```tsx +import { ThemeProvider, useTheme } from '@abusix/hailstorm'; + +// Wrap app + + + + +// Use anywhere +function MyToggle() { + const { theme, setTheme } = useTheme(); + return ( + + ); +} +``` + +--- + +## 3. Component Migration + +### Phase 1 Priority Components + +| Component | Reason | +|-----------|--------| +| Button | Most used, multiple variants | +| Alert | Intent-based colors | +| Badge | Many color variants | +| Dialog | Backdrop + surface contrast | +| Sidebar | App shell, always visible | +| SidebarMenuLink | App shell navigation | +| TopBar | App shell, brand color | +| Input/TextInput | Core form functionality | +| Card | Common container | + +### Migration Pattern + +**Before:** +```tsx +const buttonVariants = { + primary: "bg-primary-500 text-neutral-0 hover:bg-primary-600", + secondary: "text-neutral-700 bg-neutral-0 border border-neutral-400", +}; +``` + +**After:** +```tsx +const buttonVariants = { + primary: "bg-bg-primary text-text-inverse hover:bg-primary-600", + secondary: "text-text-base bg-bg-base border border-border-default", +}; +``` + +### Token Mapping Reference + +| Current Class | Semantic Replacement | Usage | +|---------------|---------------------|-------| +| `bg-neutral-0` | `bg-bg-base` | Default backgrounds | +| `bg-neutral-50/100` | `bg-bg-subtle` | Subtle backgrounds | +| `text-neutral-900` | `text-text-base` | Primary text | +| `text-neutral-600` | `text-text-muted` | Secondary text | +| `border-neutral-300` | `border-border-default` | Standard borders | +| `bg-primary-500` | `bg-bg-primary` | Primary actions | +| `text-neutral-0` (on dark bg) | `text-text-inverse` | Inverse text | + +--- + +## 4. Storybook Integration + +### Theme Toolbar Toggle + +Update `.storybook/preview.ts`: + +```ts +import type { Preview } from '@storybook/react'; +import '../src/styles/index.css'; + +const preview: Preview = { + globalTypes: { + theme: { + description: 'Global theme for components', + toolbar: { + title: 'Theme', + icon: 'circlehollow', + items: [ + { value: 'light', icon: 'sun', title: 'Light' }, + { value: 'dark', icon: 'moon', title: 'Dark' }, + ], + dynamicTitle: true, + }, + }, + }, + initialGlobals: { + theme: 'light', + }, + decorators: [ + (Story, context) => { + const theme = context.globals.theme || 'light'; + document.documentElement.classList.remove('light', 'dark'); + document.documentElement.classList.add(theme); + return ; + }, + ], +}; + +export default preview; +``` + +### Themed Backgrounds + +```ts +parameters: { + backgrounds: { + default: 'themed', + values: [ + { name: 'themed', value: 'var(--color-bg-base)' }, + { name: 'subtle', value: 'var(--color-bg-subtle)' }, + ], + }, +}, +``` + +--- + +## 5. Future Phases + +- Migrate remaining components incrementally +- Optional: Add `` component if requested +- Optional: Support custom color schemes beyond light/dark + +--- + +## Implementation Order + +1. Add semantic tokens to `src/styles/index.css` +2. Create `ThemeProvider` and `useTheme` in `src/components/theme/` +3. Export from main index +4. Update Storybook preview config +5. Migrate Phase 1 components one by one +6. Add tests for ThemeProvider +7. Update documentation From 3203b853e8fd713c941ae64aae66b40a8bcfcff2 Mon Sep 17 00:00:00 2001 From: Pablo Allendes Date: Mon, 29 Dec 2025 15:31:19 +0100 Subject: [PATCH 03/18] chore: add .worktrees to gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index cb85569c..555b8920 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules storybook-static .idea .pnpm-store +.worktrees From cc4b6ba26a2be9a6206c7c0fe1c8acdc75f23200 Mon Sep 17 00:00:00 2001 From: Pablo Allendes Date: Mon, 29 Dec 2025 16:01:36 +0100 Subject: [PATCH 04/18] feat: add semantic color tokens for dark mode support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/styles/index.css | 97 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/src/styles/index.css b/src/styles/index.css index 04b03c06..d2b6ed52 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -101,6 +101,103 @@ --size-184: 46rem; --size-285: 71.25rem; --size-736: 736px; + + /* Semantic tokens for Tailwind utilities */ + --color-bg-base: var(--color-neutral-0); + --color-bg-subtle: var(--color-neutral-50); + --color-bg-muted: var(--color-neutral-100); + --color-bg-emphasis: var(--color-neutral-900); + --color-bg-primary: var(--color-primary-500); + --color-bg-primary-hover: var(--color-primary-600); + --color-bg-success: var(--color-success-100); + --color-bg-danger: var(--color-danger-100); + --color-bg-warning: var(--color-warning-100); + --color-bg-info: var(--color-primary-50); + + --color-text-base: var(--color-neutral-900); + --color-text-muted: var(--color-neutral-600); + --color-text-subtle: var(--color-neutral-500); + --color-text-inverse: var(--color-neutral-0); + --color-text-primary: var(--color-primary-600); + --color-text-success: var(--color-success-600); + --color-text-danger: var(--color-danger-500); + --color-text-warning: var(--color-warning-600); + + --color-border-default: var(--color-neutral-300); + --color-border-muted: var(--color-neutral-200); + --color-border-emphasis: var(--color-neutral-400); + --color-border-primary: var(--color-primary-400); + --color-border-success: var(--color-success-400); + --color-border-danger: var(--color-danger-400); + --color-border-warning: var(--color-warning-500); +} + +/* Semantic color tokens - these switch between light and dark themes */ +:root { + /* Backgrounds */ + --color-bg-base: var(--color-neutral-0); + --color-bg-subtle: var(--color-neutral-50); + --color-bg-muted: var(--color-neutral-100); + --color-bg-emphasis: var(--color-neutral-900); + --color-bg-primary: var(--color-primary-500); + --color-bg-primary-hover: var(--color-primary-600); + --color-bg-success: var(--color-success-100); + --color-bg-danger: var(--color-danger-100); + --color-bg-warning: var(--color-warning-100); + --color-bg-info: var(--color-primary-50); + + /* Text */ + --color-text-base: var(--color-neutral-900); + --color-text-muted: var(--color-neutral-600); + --color-text-subtle: var(--color-neutral-500); + --color-text-inverse: var(--color-neutral-0); + --color-text-primary: var(--color-primary-600); + --color-text-success: var(--color-success-600); + --color-text-danger: var(--color-danger-500); + --color-text-warning: var(--color-warning-600); + + /* Borders */ + --color-border-default: var(--color-neutral-300); + --color-border-muted: var(--color-neutral-200); + --color-border-emphasis: var(--color-neutral-400); + --color-border-primary: var(--color-primary-400); + --color-border-success: var(--color-success-400); + --color-border-danger: var(--color-danger-400); + --color-border-warning: var(--color-warning-500); +} + +/* Dark theme overrides */ +.dark { + /* Backgrounds */ + --color-bg-base: var(--color-neutral-900); + --color-bg-subtle: var(--color-neutral-800); + --color-bg-muted: var(--color-neutral-700); + --color-bg-emphasis: var(--color-neutral-100); + --color-bg-primary: var(--color-primary-400); + --color-bg-primary-hover: var(--color-primary-300); + --color-bg-success: var(--color-success-900); + --color-bg-danger: var(--color-danger-900); + --color-bg-warning: var(--color-warning-900); + --color-bg-info: var(--color-primary-900); + + /* Text */ + --color-text-base: var(--color-neutral-100); + --color-text-muted: var(--color-neutral-300); + --color-text-subtle: var(--color-neutral-400); + --color-text-inverse: var(--color-neutral-900); + --color-text-primary: var(--color-primary-300); + --color-text-success: var(--color-success-400); + --color-text-danger: var(--color-danger-400); + --color-text-warning: var(--color-warning-400); + + /* Borders */ + --color-border-default: var(--color-neutral-600); + --color-border-muted: var(--color-neutral-700); + --color-border-emphasis: var(--color-neutral-500); + --color-border-primary: var(--color-primary-600); + --color-border-success: var(--color-success-600); + --color-border-danger: var(--color-danger-600); + --color-border-warning: var(--color-warning-600); } /* Hide webkit search decorations */ From 30a53b00ac2bffaf8e7cf68683891d061931acd5 Mon Sep 17 00:00:00 2001 From: Pablo Allendes Date: Mon, 29 Dec 2025 16:11:16 +0100 Subject: [PATCH 05/18] feat: add ThemeProvider component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/theme/theme-provider.tsx | 67 +++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/components/theme/theme-provider.tsx diff --git a/src/components/theme/theme-provider.tsx b/src/components/theme/theme-provider.tsx new file mode 100644 index 00000000..2a3324b6 --- /dev/null +++ b/src/components/theme/theme-provider.tsx @@ -0,0 +1,67 @@ +import React, { createContext, useEffect, useState } from "react"; + +export type Theme = "light" | "dark" | "system"; +export type ResolvedTheme = "light" | "dark"; + +export interface ThemeContextValue { + theme: Theme; + resolvedTheme: ResolvedTheme; + setTheme: (theme: Theme) => void; +} + +export const ThemeContext = createContext(undefined); + +export interface ThemeProviderProps { + children: React.ReactNode; + defaultTheme?: Theme; + storageKey?: string; +} + +export const ThemeProvider = ({ + children, + defaultTheme = "system", + storageKey = "hailstorm-theme", +}: ThemeProviderProps) => { + const [theme, setThemeState] = useState(() => { + if (typeof window === "undefined") return defaultTheme; + return (localStorage.getItem(storageKey) as Theme) || defaultTheme; + }); + + const [resolvedTheme, setResolvedTheme] = useState("light"); + + useEffect(() => { + const root = document.documentElement; + + const applyTheme = (resolved: ResolvedTheme) => { + root.classList.remove("light", "dark"); + root.classList.add(resolved); + setResolvedTheme(resolved); + }; + + if (theme === "system") { + const media = window.matchMedia("(prefers-color-scheme: dark)"); + applyTheme(media.matches ? "dark" : "light"); + + const listener = (e: MediaQueryListEvent) => { + applyTheme(e.matches ? "dark" : "light"); + }; + media.addEventListener("change", listener); + return () => media.removeEventListener("change", listener); + } else { + applyTheme(theme); + } + }, [theme]); + + const setTheme = (newTheme: Theme) => { + if (typeof window !== "undefined") { + localStorage.setItem(storageKey, newTheme); + } + setThemeState(newTheme); + }; + + return ( + + {children} + + ); +}; From 5fa69e261c866a490e3d5f2f633a9239098edb13 Mon Sep 17 00:00:00 2001 From: Pablo Allendes Date: Mon, 29 Dec 2025 16:15:39 +0100 Subject: [PATCH 06/18] feat: add useTheme hook --- src/components/theme/use-theme.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/components/theme/use-theme.ts diff --git a/src/components/theme/use-theme.ts b/src/components/theme/use-theme.ts new file mode 100644 index 00000000..2545d5bb --- /dev/null +++ b/src/components/theme/use-theme.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { ThemeContext, ThemeContextValue } from "./theme-provider"; + +export const useTheme = (): ThemeContextValue => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +}; From 104ea0c7c97986f6c4ffc198b53321902a5cc90d Mon Sep 17 00:00:00 2001 From: Pablo Allendes Date: Mon, 29 Dec 2025 16:32:56 +0100 Subject: [PATCH 07/18] feat: export ThemeProvider and useTheme from library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/index.ts | 2 ++ src/components/theme/index.ts | 3 +++ 2 files changed, 5 insertions(+) create mode 100644 src/components/theme/index.ts diff --git a/src/components/index.ts b/src/components/index.ts index ab1e1fad..508875f0 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -35,3 +35,5 @@ export { Disclosure } from "./disclosure"; export { ButtonGroup } from "./button-group"; export { FeaturedTag } from "./featured-tag"; export { Link } from "./link"; +export { ThemeProvider, useTheme } from "./theme"; +export type { ThemeProviderProps, Theme, ResolvedTheme, ThemeContextValue } from "./theme"; diff --git a/src/components/theme/index.ts b/src/components/theme/index.ts new file mode 100644 index 00000000..7d9f002a --- /dev/null +++ b/src/components/theme/index.ts @@ -0,0 +1,3 @@ +export { ThemeProvider } from "./theme-provider"; +export type { ThemeProviderProps, Theme, ResolvedTheme, ThemeContextValue } from "./theme-provider"; +export { useTheme } from "./use-theme"; From 9e73d9631a3c387a1d2251abdd86402235e4dd1f Mon Sep 17 00:00:00 2001 From: Pablo Allendes Date: Mon, 29 Dec 2025 16:37:22 +0100 Subject: [PATCH 08/18] test: add ThemeProvider and useTheme tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/theme/theme-provider.test.tsx | 133 +++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/components/theme/theme-provider.test.tsx diff --git a/src/components/theme/theme-provider.test.tsx b/src/components/theme/theme-provider.test.tsx new file mode 100644 index 00000000..d4a32343 --- /dev/null +++ b/src/components/theme/theme-provider.test.tsx @@ -0,0 +1,133 @@ +import { render, screen, act, cleanup } from "@testing-library/react"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { ThemeProvider } from "./theme-provider"; +import { useTheme } from "./use-theme"; + +const TestComponent = () => { + const { theme, resolvedTheme, setTheme } = useTheme(); + return ( +
+ {theme} + {resolvedTheme} + + + +
+ ); +}; + +// Mock matchMedia +const mockMatchMedia = (matches: boolean) => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +describe("ThemeProvider", () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.classList.remove("light", "dark"); + mockMatchMedia(false); // Default to light mode for system preference + }); + + afterEach(() => { + cleanup(); + }); + + it("renders children", () => { + render( + +
Hello
+
+ ); + expect(screen.getByTestId("child")).toBeInTheDocument(); + }); + + it("defaults to system theme", () => { + render( + + + + ); + expect(screen.getByTestId("theme")).toHaveTextContent("system"); + }); + + it("applies light class when theme is light", () => { + render( + + + + ); + expect(document.documentElement.classList.contains("light")).toBe(true); + expect(screen.getByTestId("resolved")).toHaveTextContent("light"); + }); + + it("applies dark class when theme is dark", () => { + render( + + + + ); + expect(document.documentElement.classList.contains("dark")).toBe(true); + expect(screen.getByTestId("resolved")).toHaveTextContent("dark"); + }); + + it("allows changing theme", async () => { + render( + + + + ); + + await act(async () => { + screen.getByText("Set Dark").click(); + }); + + expect(screen.getByTestId("theme")).toHaveTextContent("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); + + it("persists theme to localStorage", async () => { + render( + + + + ); + + await act(async () => { + screen.getByText("Set Dark").click(); + }); + + expect(localStorage.getItem("test-theme")).toBe("dark"); + }); +}); + +describe("useTheme", () => { + beforeEach(() => { + mockMatchMedia(false); + }); + + afterEach(() => { + cleanup(); + }); + + it("throws when used outside ThemeProvider", () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(() => render()).toThrow( + "useTheme must be used within a ThemeProvider" + ); + + consoleError.mockRestore(); + }); +}); From 5bbe3c90eaa4b645da3baa123777f339903159cd Mon Sep 17 00:00:00 2001 From: Pablo Allendes Date: Mon, 29 Dec 2025 16:39:12 +0100 Subject: [PATCH 09/18] feat: add theme toggle to Storybook toolbar --- .storybook/preview.ts | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 382e4fe8..7a53d04d 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,15 +1,42 @@ import type { Preview } from "@storybook/react"; +import React from "react"; import "../src/styles/index.css"; const preview: Preview = { + globalTypes: { + theme: { + description: "Global theme for components", + toolbar: { + title: "Theme", + icon: "circlehollow", + items: [ + { value: "light", icon: "sun", title: "Light" }, + { value: "dark", icon: "moon", title: "Dark" }, + ], + dynamicTitle: true, + }, + }, + }, + initialGlobals: { + theme: "light", + }, + decorators: [ + (Story, context) => { + const theme = context.globals.theme || "light"; + document.documentElement.classList.remove("light", "dark"); + document.documentElement.classList.add(theme); + return React.createElement(Story); + }, + ], parameters: { actions: { argTypesRegex: "^on[A-Z].*" }, - layout: "centered", // "centered" | "fullscreen" + layout: "centered", backgrounds: { - default: "white", + default: "themed", values: [ + { name: "themed", value: "var(--color-bg-base)" }, + { name: "subtle", value: "var(--color-bg-subtle)" }, { name: "white", value: "#FFFFFF" }, - { name: "light", value: "#FAFAFC" }, { name: "dark", value: "#212121" }, ], }, @@ -26,8 +53,7 @@ const preview: Preview = { showPanel: true, }, }, - - tags: ["autodocs"] + tags: ["autodocs"], }; export default preview; From baf515625a76a1ccddedd54fdc935c373b6d8130 Mon Sep 17 00:00:00 2001 From: Pablo Allendes Date: Mon, 29 Dec 2025 16:40:38 +0100 Subject: [PATCH 10/18] feat: migrate Panel to semantic tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/panel/panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/panel/panel.tsx b/src/components/panel/panel.tsx index 84d711e7..62821911 100644 --- a/src/components/panel/panel.tsx +++ b/src/components/panel/panel.tsx @@ -7,7 +7,7 @@ interface PanelProps { } export const Panel: FC = ({ children, className }) => ( -
+
{children}
); From 21d9cb2e2d3e9eb2fd7af2fb60246a4e4597f512 Mon Sep 17 00:00:00 2001 From: Pablo Allendes Date: Mon, 29 Dec 2025 16:41:54 +0100 Subject: [PATCH 11/18] feat: migrate Dialog to semantic tokens --- src/components/dialog/dialog.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/dialog/dialog.tsx b/src/components/dialog/dialog.tsx index 80ebe9e8..51cf2d54 100644 --- a/src/components/dialog/dialog.tsx +++ b/src/components/dialog/dialog.tsx @@ -81,13 +81,13 @@ export const Dialog = ({ >
- + {title} @@ -105,7 +105,7 @@ export const Dialog = ({