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';