From 3ccce5266d7e3c36c1fed8c24af037329533648e Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Wed, 24 Sep 2025 23:08:30 +0200 Subject: [PATCH 1/2] feat: add dark mode theming system to Hailstorm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added semantic color CSS variables that adapt to theme (light/dark) - Created ThemeProvider component for theme management - Added useTheme hook for programmatic theme control - Migrated Dialog component to use semantic color tokens - Added dark mode story to Dialog Storybook examples - Created comprehensive theming documentation The theming system uses CSS custom properties that automatically adjust based on the active theme. This provides a foundation for full dark mode support and compatibility with shadcn/ui components. Breaking Changes: None - fully backward compatible 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- docs/THEMING.md | 122 ++++++++++++++++++ package.json | 2 +- pnpm-lock.yaml | 6 +- src/components/dialog/dialog.stories.tsx | 56 +++++++- src/components/dialog/dialog.tsx | 6 +- src/components/index.ts | 1 + .../theme-provider/theme-provider.tsx | 73 +++++++++++ src/styles/index.css | 28 ++++ 8 files changed, 286 insertions(+), 8 deletions(-) create mode 100644 docs/THEMING.md create mode 100644 src/components/theme-provider/theme-provider.tsx diff --git a/docs/THEMING.md b/docs/THEMING.md new file mode 100644 index 00000000..5d6a7932 --- /dev/null +++ b/docs/THEMING.md @@ -0,0 +1,122 @@ +# Hailstorm Theming System + +## Overview + +Hailstorm now supports a flexible theming system with built-in dark mode support. The theming system uses CSS custom properties that automatically adjust based on the active theme. + +## Quick Start + +### Using ThemeProvider + +Wrap your application with the `ThemeProvider` component: + +```tsx +import { ThemeProvider } from "@abusix/hailstorm"; + +function App() { + return ( + + {/* Your app content */} + + ); +} +``` + +### Using the Theme Hook + +Access and control the theme programmatically: + +```tsx +import { useTheme } from "@abusix/hailstorm"; + +function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + return ( + + ); +} +``` + +## Semantic Color Tokens + +The theming system introduces semantic color tokens that automatically adapt to the current theme: + +### Core Colors + +- `--color-background`: Main background color +- `--color-background-secondary`: Secondary background (cards, sections) +- `--color-background-tertiary`: Tertiary background (hover states, inputs) +- `--color-foreground`: Main text color +- `--color-foreground-muted`: Muted text +- `--color-foreground-subtle`: Subtle text (descriptions, hints) +- `--color-border`: Default border color +- `--color-border-hover`: Border color on hover +- `--color-border-focus`: Border color on focus + +### Using in Components + +Use these semantic tokens in your Tailwind classes: + +```tsx +
+

Title

+

Description

+
+``` + +## Migrating Components + +To migrate existing components to use the theming system: + +### Before +```tsx +
+ Content +
+``` + +### After +```tsx +
+ Content +
+``` + +## Theme Modes + +The system supports three theme modes: + +1. **light**: Light theme +2. **dark**: Dark theme +3. **system**: Follows the user's system preference + +## Customization + +You can customize theme colors by modifying the CSS variables in `src/styles/index.css`: + +```css +/* Light mode (default) */ +@theme { + --color-background: var(--color-neutral-0); + --color-foreground: var(--color-neutral-900); + /* ... */ +} + +/* Dark mode */ +.dark { + --color-background: #0a0a0a; + --color-foreground: #fafafa; + /* ... */ +} +``` + +## Compatibility with shadcn/ui + +This theming system is designed to be compatible with shadcn/ui components. You can now integrate shadcn/ui components directly into Hailstorm with minimal adjustments. + +## Examples + +See the Dialog component's "DarkMode" story in Storybook for a live example of the theming system in action. \ No newline at end of file diff --git a/package.json b/package.json index bfa13b82..c65db82c 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "@headlessui/tailwindcss": "^0.2.1", "@popperjs/core": "^2.11.8", "@tailwindcss/forms": "^0.5.9", - "@tailwindcss/vite": "^4.1.13", "@tanstack/react-table": "^8.20.5", "@tanstack/react-virtual": "^3.10.8", "@tanstack/table-core": "^8.20.5", @@ -72,6 +71,7 @@ "@storybook/react-vite": "^8.3.6", "@svgr/cli": "8.1.0", "@tailwindcss/postcss": "^4.1.13", + "@tailwindcss/vite": "^4.1.13", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "6.6.2", "@testing-library/react": "^16.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 220abcea..ed9d7a8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,9 +24,6 @@ importers: '@tailwindcss/forms': specifier: ^0.5.9 version: 0.5.10(tailwindcss@4.1.13) - '@tailwindcss/vite': - specifier: ^4.1.13 - version: 4.1.13(vite@5.4.20(@types/node@24.3.1)(lightningcss@1.30.1)(terser@5.44.0)) '@tanstack/react-table': specifier: ^8.20.5 version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -97,6 +94,9 @@ importers: '@tailwindcss/postcss': specifier: ^4.1.13 version: 4.1.13 + '@tailwindcss/vite': + specifier: ^4.1.13 + version: 4.1.13(vite@5.4.20(@types/node@24.3.1)(lightningcss@1.30.1)(terser@5.44.0)) '@testing-library/dom': specifier: ^10.4.0 version: 10.4.0 diff --git a/src/components/dialog/dialog.stories.tsx b/src/components/dialog/dialog.stories.tsx index e4514eb0..38c55634 100644 --- a/src/components/dialog/dialog.stories.tsx +++ b/src/components/dialog/dialog.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { getStoryDescription, hiddenArgControl } from "../../util/storybook-utils"; import { Dialog } from "./dialog"; import { Button } from "../button/button"; @@ -107,6 +107,60 @@ export const WithFooterButtons: Story = { }, }; +export const DarkMode: Story = { + args: { + footer: , + }, + argTypes: { + footer: hiddenArgControl, + }, + render: ({ children, ...args }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [isShown, setIsShown] = useState(false); + // eslint-disable-next-line react-hooks/rules-of-hooks + const [theme, setTheme] = useState<"light" | "dark">("light"); + const toggleBtn = () => setIsShown((val) => !val); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + document.documentElement.classList.remove("light", "dark"); + document.documentElement.classList.add(theme); + }, [theme]); + + return ( +
+
+ + +
+ + setIsShown(false)} />} + isShown={isShown} + onClose={toggleBtn} + > +

+ This dialog supports both light and dark modes! Toggle the theme to see how it adapts. +

+
+
+ ); + }, +}; + export const WithLongContent: Story = { args: { children: ( diff --git a/src/components/dialog/dialog.tsx b/src/components/dialog/dialog.tsx index 80ebe9e8..cdb1fc45 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 = ({ ), }; + +export const DarkMode: Story = { + argTypes: { + variant: hiddenArgControl, + LeftIcon: hiddenArgControl, + RightIcon: hiddenArgControl, + loading: hiddenArgControl, + }, + render: () => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [theme, setTheme] = useState<"light" | "dark">("light"); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + document.documentElement.classList.remove("light", "dark"); + document.documentElement.classList.add(theme); + }, [theme]); + + return ( +
+
+ +
+ +
+
+

Primary Buttons

+
+ + + + +
+
+ +
+

Secondary Buttons

+
+ + + + +
+
+ +
+

Minimal Buttons

+
+ + + + +
+
+ +
+

Danger Buttons

+
+ + + + +
+
+
+
+ ); + }, +}; diff --git a/src/components/button/button.tsx b/src/components/button/button.tsx index b584f6ae..7a8d38e6 100644 --- a/src/components/button/button.tsx +++ b/src/components/button/button.tsx @@ -7,20 +7,20 @@ const buttonVariants = { primary: "bg-primary-500 text-neutral-0 hover:bg-primary-600 active:bg-primary-600 focus:ring-2 focus:ring-primary-200 focus:bg-primary-600 disabled:bg-primary-200 fill-neutral-0", secondary: - "text-neutral-700 bg-neutral-0 border border-neutral-400 hover:border-neutral-600 hover:text-neutral-800 active:bg-neutral-100 focus:ring-2 focus:ring-primary-200 focus:text-neutral-800 disabled:text-neutral-500 disabled:border-neutral-300 disabled:bg-neutral-0 fill-neutral-0", + "text-foreground bg-background border border-border hover:border-border-hover hover:text-foreground active:bg-background-tertiary focus:ring-2 focus:ring-primary-200 focus:text-foreground disabled:text-foreground-subtle disabled:border-border disabled:bg-background fill-foreground-muted", minimal: - "text-neutral-700 hover:bg-neutral-100 hover:text-neutral-800 active:bg-neutral-200 focus:ring-2 focus:ring-primary-200 focus:text-neutral-800 disabled:text-neutral-500 disabled:bg-neutral-0 fill-neutral-0 underline", + "text-foreground hover:bg-background-tertiary hover:text-foreground active:bg-background-secondary focus:ring-2 focus:ring-primary-200 focus:text-foreground disabled:text-foreground-subtle disabled:bg-background fill-foreground-muted underline", danger: "text-neutral-0 bg-danger-500 hover:bg-danger-500 active:bg-danger-700 focus:ring-2 focus:ring-danger-100 focus:bg-danger-600 disabled:bg-danger-100 fill-neutral-0", "danger-secondary": - "bg-neutral-0 text-danger-500 border border-danger-400 hover:bg-danger-50 hover:text-danger-600 active:border-danger-700 active:text-danger-700 active:bg-danger-100 focus:ring-2 focus:ring-danger-100 focus:text-danger-600 disabled:border-danger-100 disabled:text-danger-100 disabled:bg-neutral-0 fill-danger-600 disabled:fill-danger-100", + "bg-background text-danger-500 border border-danger-400 hover:bg-danger-50 hover:text-danger-600 active:border-danger-700 active:text-danger-700 active:bg-danger-100 focus:ring-2 focus:ring-danger-100 focus:text-danger-600 disabled:border-danger-100 disabled:text-danger-100 disabled:bg-background fill-danger-600 disabled:fill-danger-100", }; const iconVariants = { primary: "text-neutral-0", secondary: - "fill-neutral-600 group-hover:text-neutral-700 group-focus:text-neutral-700 group-disabled:text-neutral-400", + "fill-foreground-muted group-hover:text-foreground group-focus:text-foreground group-disabled:text-foreground-subtle", minimal: - "fill-neutral-600 group-hover:text-neutral-700 group-focus:text-neutral-700 group-disabled:text-neutral-400", + "fill-foreground-muted group-hover:text-foreground group-focus:text-foreground group-disabled:text-foreground-subtle", danger: "", "danger-secondary": "", }; diff --git a/src/components/dialog/dialog.stories.tsx b/src/components/dialog/dialog.stories.tsx index 38c55634..0033b545 100644 --- a/src/components/dialog/dialog.stories.tsx +++ b/src/components/dialog/dialog.stories.tsx @@ -129,18 +129,18 @@ export const DarkMode: Story = { return (
-
+
@@ -153,7 +153,8 @@ export const DarkMode: Story = { onClose={toggleBtn} >

- This dialog supports both light and dark modes! Toggle the theme to see how it adapts. + This dialog supports both light and dark modes! Toggle the theme to see how + it adapts.

diff --git a/src/components/dialog/dialog.tsx b/src/components/dialog/dialog.tsx index cdb1fc45..a00e537b 100644 --- a/src/components/dialog/dialog.tsx +++ b/src/components/dialog/dialog.tsx @@ -87,7 +87,7 @@ export const Dialog = ({ )} >
- + {title} diff --git a/src/components/theme-provider/theme-provider.tsx b/src/components/theme-provider/theme-provider.tsx index 0f49ab62..d29400d4 100644 --- a/src/components/theme-provider/theme-provider.tsx +++ b/src/components/theme-provider/theme-provider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useEffect, useState } from "react"; +import React, { createContext, useContext, useEffect, useState, useMemo } from "react"; type Theme = "dark" | "light" | "system"; @@ -47,13 +47,16 @@ export const ThemeProvider = ({ root.classList.add(theme); }, [theme]); - const value = { - theme, - setTheme: (newTheme: Theme) => { - localStorage.setItem(storageKey, newTheme); - setTheme(newTheme); - }, - }; + const value = useMemo( + () => ({ + theme, + setTheme: (newTheme: Theme) => { + localStorage.setItem(storageKey, newTheme); + setTheme(newTheme); + }, + }), + [theme, storageKey] + ); return ( @@ -70,4 +73,4 @@ export const useTheme = () => { } return context; -}; \ No newline at end of file +};