diff --git a/design/package.json b/design/package.json index d69bb3f..d5a746f 100644 --- a/design/package.json +++ b/design/package.json @@ -1,6 +1,6 @@ { "name": "@orangecheck/design", - "version": "0.3.0", + "version": "0.4.0", "description": "OrangeCheck design system — multi-theme tokens, primitives, and family composites for the .ochk.io sub-sites. Tailwind 4 + Next.js Pages Router. Not for third-party integration.", "keywords": [ "orangecheck", diff --git a/design/src/components/patterns.stories.tsx b/design/src/components/patterns.stories.tsx index 40c2e3b..a7c2f1f 100644 --- a/design/src/components/patterns.stories.tsx +++ b/design/src/components/patterns.stories.tsx @@ -7,9 +7,8 @@ import { } from '@orangecheck/ui'; import { ThemeProvider } from 'next-themes'; -import { ThemeToggle } from '../primitives/theme-toggle'; +import { OcAppearanceMenu } from '../tokens/appearance-menu'; import { OcThemeProvider } from '../tokens/provider'; -import { OcThemePicker } from '../tokens/theme-picker'; const meta = { title: 'Patterns/App Chrome', @@ -66,8 +65,7 @@ function HeaderBar() {
- - +
diff --git a/design/src/tokens/appearance-menu.stories.tsx b/design/src/tokens/appearance-menu.stories.tsx new file mode 100644 index 0000000..981330d --- /dev/null +++ b/design/src/tokens/appearance-menu.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { ThemeProvider } from 'next-themes'; + +import { OcAppearanceMenu } from './appearance-menu'; +import { OcThemeProvider } from './provider'; + +const meta = { + title: 'Themes/Appearance Menu', + component: OcAppearanceMenu, + parameters: { layout: 'padded' }, + decorators: [ + (Story) => ( + + + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * The single appearance control every family site uses — one icon, a dropdown + * with both axes: light/dark/system mode AND the theme/skin choice. Click it; + * the page recolors and the mode flips, and both choices persist across + * *.ochk.io. + */ +export const Default: Story = { + render: () => ( +
+ +

+ One control → light/dark/system + every theme. Replaces the separate ThemeToggle + + OcThemePicker. +

+
+ ), +}; diff --git a/design/src/tokens/appearance-menu.tsx b/design/src/tokens/appearance-menu.tsx new file mode 100644 index 0000000..c79248f --- /dev/null +++ b/design/src/tokens/appearance-menu.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { Check, Monitor, Moon, Palette, Sun } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { useEffect, useRef, useState } from 'react'; + +import { cn } from './cn'; +import { useOcSkin } from './provider'; + +/** + * OcAppearanceMenu — the single header control for ALL appearance settings. + * + * One icon button opens a dropdown with both axes: + * • mode — light / dark / system (next-themes `.dark` class) + * • theme — the registered skins (data-oc-theme, persisted .ochk.io) + * + * Replaces the separate `ThemeToggle` + `OcThemePicker` so every family site + * carries exactly one appearance control. Self-contained dropdown (no Radix): + * outside-click + Escape to close. Requires `` above it (skin + * axis) and a next-themes `` (mode axis) — both already mounted + * on every family site. + */ +export interface OcAppearanceMenuProps { + className?: string; + triggerClassName?: string; + popoverClassName?: string; +} + +const MODES = [ + { id: 'light', label: 'light', Icon: Sun }, + { id: 'dark', label: 'dark', Icon: Moon }, + { id: 'system', label: 'system', Icon: Monitor }, +] as const; + +export function OcAppearanceMenu({ + className, + triggerClassName, + popoverClassName, +}: OcAppearanceMenuProps) { + const { skin, setSkin, themes } = useOcSkin(); + const { theme, setTheme } = useTheme(); + const [open, setOpen] = useState(false); + const [mounted, setMounted] = useState(false); + const rootRef = useRef(null); + + useEffect(() => setMounted(true), []); + + useEffect(() => { + if (!open) return; + function onDown(e: MouseEvent) { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); + } + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false); + } + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + // Avoid a hydration mismatch: only reflect the active mode after mount. + const activeMode = mounted ? (theme ?? 'system') : null; + + return ( +
+ + + {open && ( +
+ {/* Mode — segmented light / dark / system */} +
mode
+
+ {MODES.map(({ id, label, Icon }) => { + const active = activeMode === id; + return ( + + ); + })} +
+ + {/* Theme — the registered skins */} +
+ theme +
+
    + {themes.map((t) => { + const active = t.id === skin; + return ( +
  • + +
  • + ); + })} +
+
+ )} +
+ ); +} diff --git a/design/src/tokens/index.ts b/design/src/tokens/index.ts index 033a247..a5add2f 100644 --- a/design/src/tokens/index.ts +++ b/design/src/tokens/index.ts @@ -16,3 +16,4 @@ export { } from './provider'; export { OcThemeBridge } from './theme-bridge'; export { OcThemePicker, type OcThemePickerProps } from './theme-picker'; +export { OcAppearanceMenu, type OcAppearanceMenuProps } from './appearance-menu';