diff --git a/THEME_JSON_SYSTEM.md b/THEME_JSON_SYSTEM.md new file mode 100644 index 0000000..f7d258a --- /dev/null +++ b/THEME_JSON_SYSTEM.md @@ -0,0 +1,195 @@ +# Theme JSON System Implementation + +## Overview +Implemented a comprehensive theme configuration system that loads styling from `theme.json`, making the application's visual appearance highly configurable without code changes. + +## What Was Fixed +1. **Sidebar Styling Issues**: Fixed messed up sidebar styling by implementing proper theme loading and CSS variable management +2. **Theme JSON Loading**: Created a robust theme loading system that reads from `theme.json` +3. **CSS Variables**: Properly configured CSS custom properties for sidebar and application theming +4. **Type Safety**: Added TypeScript interfaces for theme configuration + +## Files Changed + +### New Files +- `/src/hooks/use-theme-config.ts` - Hook for loading and applying theme configuration +- Updated `/theme.json` - Comprehensive theme configuration with sidebar settings + +### Modified Files +- `/src/components/ui/sidebar.tsx` - Now uses theme config for sidebar dimensions +- `/src/index.css` - Added sidebar CSS variables and component classes +- `/src/hooks/index.ts` - Exported the new `useThemeConfig` hook + +## Theme Configuration Structure + +The `theme.json` file supports the following structure: + +```json +{ + "sidebar": { + "width": "16rem", + "widthMobile": "18rem", + "widthIcon": "3rem", + "backgroundColor": "oklch(0.19 0.02 265)", + "foregroundColor": "oklch(0.95 0.01 265)", + "borderColor": "oklch(0.28 0.03 265)", + "accentColor": "oklch(0.58 0.24 265)", + "accentForeground": "oklch(1 0 0)", + "hoverBackground": "oklch(0.25 0.03 265)", + "activeBackground": "oklch(0.30 0.04 265)", + "headerHeight": "4rem", + "transitionDuration": "200ms", + "zIndex": 40 + }, + "colors": { + "background": "oklch(...)", + "foreground": "oklch(...)", + "primary": "oklch(...)", + ... + }, + "spacing": { + "radius": "0.5rem" + }, + "typography": { + "fontFamily": { + "body": "'IBM Plex Sans', sans-serif", + "heading": "'JetBrains Mono', monospace", + "code": "'JetBrains Mono', monospace" + } + } +} +``` + +## How It Works + +### 1. Theme Loading +The `useThemeConfig` hook: +- Fetches `/theme.json` on mount +- Falls back to sensible defaults if loading fails +- Returns loading state for conditional rendering + +### 2. CSS Variable Application +When theme config loads, it automatically sets CSS custom properties: +- `--sidebar-width` +- `--sidebar-bg` +- `--sidebar-fg` +- `--sidebar-border` +- And many more... + +### 3. Component Usage +The sidebar component uses these CSS variables: +```tsx +const { themeConfig } = useThemeConfig() +const sidebarWidth = themeConfig.sidebar?.width || '16rem' +``` + +## Benefits + +1. **Easy Customization**: Change colors, sizes, and spacing without touching code +2. **Consistent Theming**: Single source of truth for design tokens +3. **Runtime Updates**: Theme can be modified without rebuilding +4. **Type Safety**: Full TypeScript support with interfaces +5. **Graceful Fallbacks**: Defaults ensure app works even if theme.json is missing + +## Usage Examples + +### Changing Sidebar Width +Edit `theme.json`: +```json +{ + "sidebar": { + "width": "20rem" + } +} +``` + +### Changing Color Scheme +```json +{ + "sidebar": { + "backgroundColor": "oklch(0.25 0.05 280)", + "accentColor": "oklch(0.65 0.25 200)" + } +} +``` + +### Using in Custom Components +```tsx +import { useThemeConfig } from '@/hooks/use-theme-config' + +function MyComponent() { + const { themeConfig, isLoading } = useThemeConfig() + + if (isLoading) return + + return ( +
+ Content +
+ ) +} +``` + +## CSS Variables Reference + +The following CSS variables are automatically set: + +### Sidebar Variables +- `--sidebar-width`: Sidebar width (default: 16rem) +- `--sidebar-width-mobile`: Mobile sidebar width (default: 18rem) +- `--sidebar-width-icon`: Icon-only sidebar width (default: 3rem) +- `--sidebar-bg`: Sidebar background color +- `--sidebar-fg`: Sidebar text color +- `--sidebar-border`: Sidebar border color +- `--sidebar-accent`: Accent color for highlights +- `--sidebar-accent-fg`: Accent foreground color +- `--sidebar-hover-bg`: Hover state background +- `--sidebar-active-bg`: Active state background +- `--sidebar-header-height`: Header height +- `--sidebar-transition`: Transition duration +- `--sidebar-z-index`: Z-index for layering + +### Color Variables +All color tokens from `theme.json` are mapped to CSS variables following the pattern: +- `--color-{name}`: For each color defined in the theme + +## Technical Details + +### Hook Implementation +- Uses `useState` and `useEffect` for async loading +- Applies CSS variables via `document.documentElement.style.setProperty` +- Provides loading state for better UX +- Merges loaded config with defaults using spread operator + +### Sidebar Component Integration +- Removed hardcoded constants (`SIDEBAR_WIDTH`, etc.) +- Now reads from theme config +- Falls back to defaults if theme not loaded +- Maintains backward compatibility + +### CSS Strategy +- Uses CSS custom properties for runtime theming +- Includes fallback values in all properties +- Component-level classes for sidebar-specific styling +- Tailwind theme integration via `@theme` directive + +## Future Enhancements + +Potential improvements: +1. Hot-reload theme changes in development +2. Theme validation and error reporting +3. Multiple theme support (light/dark/custom) +4. Theme editor UI +5. Theme export/import functionality +6. Animation settings in theme config +7. Breakpoint customization + +## Migration Notes + +If you were previously using hardcoded values: +1. Move those values to `theme.json` +2. Update components to use `useThemeConfig` hook +3. Use CSS variables instead of hardcoded colors +4. Test with and without theme.json to ensure fallbacks work diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 3b351b2..9d3c184 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -6,6 +6,7 @@ import { VariantProps, cva } from "class-variance-authority" import PanelLeftIcon from "lucide-react/dist/esm/icons/panel-left" import { useIsMobile } from "@/hooks/use-mobile" +import { useThemeConfig } from "@/hooks/use-theme-config" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -27,9 +28,6 @@ import { const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 -const SIDEBAR_WIDTH = "16rem" -const SIDEBAR_WIDTH_MOBILE = "18rem" -const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_KEYBOARD_SHORTCUT = "b" type SidebarContextProps = { @@ -67,10 +65,12 @@ function SidebarProvider({ onOpenChange?: (open: boolean) => void }) { const isMobile = useIsMobile() + const { themeConfig } = useThemeConfig() const [openMobile, setOpenMobile] = useState(false) - // This is the internal state of the sidebar. - // We use openProp and setOpenProp for control from outside the component. + const sidebarWidth = themeConfig.sidebar?.width || '16rem' + const sidebarWidthIcon = themeConfig.sidebar?.widthIcon || '3rem' + const [_open, _setOpen] = useState(defaultOpen) const open = openProp ?? _open const setOpen = useCallback( @@ -82,18 +82,15 @@ function SidebarProvider({ _setOpen(openState) } - // This sets the cookie to keep the sidebar state. document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` }, [setOpenProp, open] ) - // Helper to toggle the sidebar. const toggleSidebar = useCallback(() => { return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) }, [isMobile, setOpen, setOpenMobile]) - // Adds a keyboard shortcut to toggle the sidebar. useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if ( @@ -109,8 +106,6 @@ function SidebarProvider({ return () => window.removeEventListener("keydown", handleKeyDown) }, [toggleSidebar]) - // We add a state so that we can do data-state="expanded" or "collapsed". - // This makes it easier to style the sidebar with Tailwind classes. const state = open ? "expanded" : "collapsed" const contextValue = useMemo( @@ -133,8 +128,8 @@ function SidebarProvider({ data-slot="sidebar-wrapper" style={ { - "--sidebar-width": SIDEBAR_WIDTH, - "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, + "--sidebar-width": sidebarWidth, + "--sidebar-width-icon": sidebarWidthIcon, ...style, } as CSSProperties } @@ -164,6 +159,9 @@ function Sidebar({ collapsible?: "offcanvas" | "icon" | "none" }) { const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + const { themeConfig } = useThemeConfig() + + const sidebarWidthMobile = themeConfig.sidebar?.widthMobile || '18rem' if (collapsible === "none") { return ( @@ -190,7 +188,7 @@ function Sidebar({ className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden" style={ { - "--sidebar-width": SIDEBAR_WIDTH_MOBILE, + "--sidebar-width": sidebarWidthMobile, } as CSSProperties } side={side} @@ -214,7 +212,6 @@ function Sidebar({ data-side={side} data-slot="sidebar" > - {/* This is what handles the sidebar gap on desktop */}
+ spacing?: { + radius?: string + } + typography?: { + fontFamily?: { + body?: string + heading?: string + code?: string + } + } +} + +const DEFAULT_THEME: ThemeConfig = { + sidebar: { + width: '16rem', + widthMobile: '18rem', + widthIcon: '3rem', + backgroundColor: 'oklch(0.19 0.02 265)', + foregroundColor: 'oklch(0.95 0.01 265)', + borderColor: 'oklch(0.28 0.03 265)', + accentColor: 'oklch(0.58 0.24 265)', + accentForeground: 'oklch(1 0 0)', + hoverBackground: 'oklch(0.25 0.03 265)', + activeBackground: 'oklch(0.30 0.04 265)', + headerHeight: '4rem', + transitionDuration: '200ms', + zIndex: 40, + }, + colors: {}, + spacing: { + radius: '0.5rem', + }, + typography: { + fontFamily: { + body: "'IBM Plex Sans', sans-serif", + heading: "'JetBrains Mono', monospace", + code: "'JetBrains Mono', monospace", + }, + }, +} + +export function useThemeConfig() { + const [themeConfig, setThemeConfig] = useState(DEFAULT_THEME) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + async function loadTheme() { + try { + const response = await fetch('/theme.json') + if (response.ok) { + const config = await response.json() + setThemeConfig({ ...DEFAULT_THEME, ...config }) + } else { + console.warn('Failed to load theme.json, using defaults') + setThemeConfig(DEFAULT_THEME) + } + } catch (error) { + console.error('Error loading theme config:', error) + setThemeConfig(DEFAULT_THEME) + } finally { + setIsLoading(false) + } + } + + loadTheme() + }, []) + + useEffect(() => { + if (themeConfig.sidebar && !isLoading) { + const root = document.documentElement + const sidebar = themeConfig.sidebar + + if (sidebar.width) root.style.setProperty('--sidebar-width', sidebar.width) + if (sidebar.widthMobile) root.style.setProperty('--sidebar-width-mobile', sidebar.widthMobile) + if (sidebar.widthIcon) root.style.setProperty('--sidebar-width-icon', sidebar.widthIcon) + if (sidebar.backgroundColor) root.style.setProperty('--sidebar-bg', sidebar.backgroundColor) + if (sidebar.foregroundColor) root.style.setProperty('--sidebar-fg', sidebar.foregroundColor) + if (sidebar.borderColor) root.style.setProperty('--sidebar-border', sidebar.borderColor) + if (sidebar.accentColor) root.style.setProperty('--sidebar-accent', sidebar.accentColor) + if (sidebar.accentForeground) root.style.setProperty('--sidebar-accent-fg', sidebar.accentForeground) + if (sidebar.hoverBackground) root.style.setProperty('--sidebar-hover-bg', sidebar.hoverBackground) + if (sidebar.activeBackground) root.style.setProperty('--sidebar-active-bg', sidebar.activeBackground) + if (sidebar.headerHeight) root.style.setProperty('--sidebar-header-height', sidebar.headerHeight) + if (sidebar.transitionDuration) root.style.setProperty('--sidebar-transition', sidebar.transitionDuration) + if (sidebar.zIndex !== undefined) root.style.setProperty('--sidebar-z-index', sidebar.zIndex.toString()) + } + }, [themeConfig, isLoading]) + + return { themeConfig, isLoading } +} diff --git a/src/index.css b/src/index.css index cd86046..a22612c 100644 --- a/src/index.css +++ b/src/index.css @@ -20,6 +20,20 @@ } :root { + --sidebar-width: 16rem; + --sidebar-width-mobile: 18rem; + --sidebar-width-icon: 3rem; + --sidebar-bg: oklch(0.19 0.02 265); + --sidebar-fg: oklch(0.95 0.01 265); + --sidebar-border: oklch(0.28 0.03 265); + --sidebar-accent: oklch(0.58 0.24 265); + --sidebar-accent-fg: oklch(1 0 0); + --sidebar-hover-bg: oklch(0.25 0.03 265); + --sidebar-active-bg: oklch(0.30 0.04 265); + --sidebar-header-height: 4rem; + --sidebar-transition: 200ms; + --sidebar-z-index: 40; + --background: oklch(0.15 0.02 265); --foreground: oklch(0.95 0.01 265); @@ -52,6 +66,14 @@ } @theme { + --color-sidebar: var(--sidebar-bg); + --color-sidebar-foreground: var(--sidebar-fg); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-fg); + --color-sidebar-hover: var(--sidebar-hover-bg); + --color-sidebar-active: var(--sidebar-active-bg); + --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); @@ -79,3 +101,17 @@ --radius-2xl: calc(var(--radius) * 3); --radius-full: 9999px; } + +@layer components { + .bg-sidebar { + background-color: var(--sidebar-bg); + } + + .text-sidebar-foreground { + color: var(--sidebar-fg); + } + + .border-sidebar-border { + border-color: var(--sidebar-border); + } +} diff --git a/theme.json b/theme.json index 9e26dfe..f1cd45d 100644 --- a/theme.json +++ b/theme.json @@ -1 +1,48 @@ -{} \ No newline at end of file +{ + "sidebar": { + "width": "16rem", + "widthMobile": "18rem", + "widthIcon": "3rem", + "backgroundColor": "oklch(0.19 0.02 265)", + "foregroundColor": "oklch(0.95 0.01 265)", + "borderColor": "oklch(0.28 0.03 265)", + "accentColor": "oklch(0.58 0.24 265)", + "accentForeground": "oklch(1 0 0)", + "hoverBackground": "oklch(0.25 0.03 265)", + "activeBackground": "oklch(0.30 0.04 265)", + "headerHeight": "4rem", + "transitionDuration": "200ms", + "zIndex": 40 + }, + "colors": { + "background": "oklch(0.15 0.02 265)", + "foreground": "oklch(0.95 0.01 265)", + "card": "oklch(0.19 0.02 265)", + "cardForeground": "oklch(0.95 0.01 265)", + "popover": "oklch(0.19 0.02 265)", + "popoverForeground": "oklch(0.95 0.01 265)", + "primary": "oklch(0.58 0.24 265)", + "primaryForeground": "oklch(1 0 0)", + "secondary": "oklch(0.25 0.03 265)", + "secondaryForeground": "oklch(0.95 0.01 265)", + "muted": "oklch(0.25 0.03 265)", + "mutedForeground": "oklch(0.60 0.02 265)", + "accent": "oklch(0.75 0.20 145)", + "accentForeground": "oklch(0.15 0.02 265)", + "destructive": "oklch(0.60 0.25 25)", + "destructiveForeground": "oklch(1 0 0)", + "border": "oklch(0.28 0.03 265)", + "input": "oklch(0.28 0.03 265)", + "ring": "oklch(0.75 0.20 145)" + }, + "spacing": { + "radius": "0.5rem" + }, + "typography": { + "fontFamily": { + "body": "'IBM Plex Sans', sans-serif", + "heading": "'JetBrains Mono', monospace", + "code": "'JetBrains Mono', monospace" + } + } +} \ No newline at end of file