{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "theme-switcher",
  "type": "registry:ui",
  "title": "Theme Switcher",
  "description": "The UI half of the two-axis theme contract (`styles/index.css`, `src/lib/use-theme.ts`). Two controls, because the two axes are genuinely different questions and a single control that tries to serve both ends up as a four-item list called \"Light / Dark /…",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "foundation",
    "primitive"
  ],
  "dependencies": [
    "class-variance-authority",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/dropdown-menu.json",
    "https://ds.interlace.tools/r/theme-tokens.json",
    "https://ds.interlace.tools/r/toggle.json",
    "https://ds.interlace.tools/r/use-theme.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/theme-switcher.tsx",
      "target": "components/ui/theme-switcher.tsx",
      "type": "registry:ui",
      "content": "'use client';\n\nimport * as React from 'react';\n\n// @interlace/theme-switcher v1.0.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/theme-switcher\n// What changed since: https://ds.interlace.tools/c/theme-switcher#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — ThemeSwitcher + ThemeSchemeToggle\n *\n * The UI half of the two-axis theme contract (`styles/index.css`,\n * `src/lib/use-theme.ts`). Two controls, because the two axes are genuinely\n * different questions and a single control that tries to serve both ends up\n * as a four-item list called \"Light / Dark / Harbor / Harbor Dark\" — a\n * cross-product that grows multiplicatively with every theme added.\n *\n *   ThemeSwitcher       menu: which THEME  ×  which SCHEME  (two radio groups)\n *   ThemeSchemeToggle   one-click light ⇄ dark, for a nav bar\n *\n * Behaviour lives in `useTheme()`; Base UI owns the a11y (roles, roving\n * focus, typeahead, Escape, focus restore, `aria-checked` / `aria-pressed`).\n * This file owns the pixels and nothing else.\n *\n * ## Anatomy\n *\n *   <ThemeSwitcher>                       ← DropdownMenuTrigger (button)\n *     └ popup                             ← data-min-viewport, role=\"menu\"\n *        ├ \"Theme\"        radiogroup      ← one item per registered theme\n *        ├ separator\n *        └ \"Appearance\"   radiogroup      ← Light / Dark / System\n *\n *   <ThemeSchemeToggle>                   ← Base UI Toggle, aria-pressed\n *\n * ## MIN_VIEWPORT — 320\n *\n * The trigger collapses to its icon below `sm` (the label is `hidden\n * sm:inline`), so it is a 32–36px square at the 320 floor — clear of the\n * WCAG 2.5.5 target-size minimum. The popup is `min-w-48` and Base UI's\n * positioner shifts it into view, so it fits a 320 column.\n *\n * | Rule | Concept                          | Where in this file                                             |\n * | ---- | -------------------------------- | -------------------------------------------------------------- |\n * | R4   | Extends the underlying part      | `ComponentProps<typeof DropdownMenuTrigger> & VariantProps<…>`   |\n * | R6   | data-slot on every part          | `theme-switcher` / `-content` / `-item` / `theme-scheme-toggle` |\n * | R7   | className merged + ...rest       | `cn(themeSwitcherVariants(…), className)` + `{...props}`        |\n * | R8   | No `isXxx`; enums for >2 states  | `size` (sm/default); scheme is a 3-value enum, not a boolean     |\n * | R11  | One variable per part            | Trigger owns `size`; the menu owns nothing configurable          |\n * | R12  | Reuse over wrap                  | Composes DropdownMenu / Toggle rather than re-implementing them  |\n * | R13  | Ecosystem first                  | `@base-ui/react/menu` owns menuitemradio + typeahead + Escape    |\n * | R14  | Declares min viewport            | `data-min-viewport={String(MIN_VIEWPORT)}` + exported const      |\n * | R17  | API parity                       | `value`/`onValueChange` naming inherited from Base UI + MUI      |\n * | R18  | Tailwind only                    | Zero inline `style`; cva classes only                            |\n * | R19  | Tokens only                      | bg-background / text-foreground / ring — no hex, no palette escape |\n * | R20  | AA contrast                      | Every pair is a theme token measured by `theme-contract-lock`     |\n * | R25  | Client component                 | localStorage + matchMedia + DOM writes                           |\n * | R26  | A11y from upstream               | role=menuitemradio + aria-checked (menu), aria-pressed (toggle)  |\n *\n * ## Why no colour swatches\n *\n * The obvious flourish — a dot per theme painted in that theme's primary —\n * cannot be done honestly today. A swatch would need `[data-theme='X']` on\n * the dot itself, and the default theme has no such selector (it IS `:root`),\n * so the Interlace dot would silently paint in whatever theme is currently\n * active. A dot that lies about which brand it represents is worse than no\n * dot; the check indicator carries the state instead. Adding\n * `[data-theme='interlace']` as an alias for `:root` would fix it and is a\n * contract change, not a component change.\n *\n * ## Out of scope\n *\n * No provider, no context, no SSR theme cookie. The `<html>` element is the\n * shared state and `THEME_SCRIPT` (see `lib/theme-script.ts`) is what makes\n * it correct before first paint — a consumer that skips the script gets a\n * working switcher and a flash on reload, which is a documentation problem,\n * not one more moving part in here.\n */\n\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { MonitorIcon, MoonIcon, SunIcon } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\nimport { useTheme, type SchemePreference } from '@/hooks/use-theme';\nimport type { ThemeName } from '@/lib/theme-tokens';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuLabel,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu';\nimport { Toggle } from '@/components/ui/toggle';\n\n/**\n * Minimum viable viewport (CSS px) for this primitive — DESIGN_PRINCIPLES #14.\n */\nexport const MIN_VIEWPORT = 320 as const;\n\n/**\n * The appearance axis, as the user sees it.\n *\n * `system` is FIRST-CLASS, not a checkbox hidden under the other two: it is\n * the state a user is in before they touch anything, and a switcher with no\n * way back to it turns one click into a permanent override of the OS\n * setting. (This is why `useTheme` keeps `schemePreference` separate from\n * the resolved `scheme`.)\n */\nconst SCHEME_OPTIONS: ReadonlyArray<{\n  value: SchemePreference;\n  label: string;\n  Icon: typeof SunIcon;\n}> = [\n  { value: 'light', label: 'Light', Icon: SunIcon },\n  { value: 'dark', label: 'Dark', Icon: MoonIcon },\n  { value: 'system', label: 'System', Icon: MonitorIcon },\n];\n\nconst themeSwitcherVariants = cva(\n  cn(\n    'inline-flex items-center justify-center gap-2 rounded-md border font-medium transition-colors',\n    'border-input bg-background text-foreground',\n    'hover:bg-accent hover:text-accent-foreground',\n    // `focus-visible:`, never bare `focus:` — a ring painted on mouse clicks\n    // trains reviewers to read \"ring present\" as \"keyboard affordance\n    // present\", and it is the first thing trimmed for looking noisy.\n    'focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',\n    'disabled:pointer-events-none disabled:opacity-50',\n    'data-[popup-open]:bg-accent data-[popup-open]:text-accent-foreground',\n    '[&_svg]:pointer-events-none [&_svg]:shrink-0',\n  ),\n  {\n    variants: {\n      size: {\n        sm: 'h-8 px-2 text-ui-sm',\n        default: 'h-9 px-3 text-ui-sm',\n      },\n    },\n    defaultVariants: { size: 'default' },\n  },\n);\n\ninterface ThemeSwitcherProps\n  extends Omit<React.ComponentProps<typeof DropdownMenuTrigger>, 'render'>,\n    VariantProps<typeof themeSwitcherVariants> {\n  /** Accessible name for the trigger. Default: `'Theme'`. */\n  label?: string;\n  /** Popup alignment against the trigger. */\n  align?: React.ComponentProps<typeof DropdownMenuContent>['align'];\n  /** Popup side. */\n  side?: React.ComponentProps<typeof DropdownMenuContent>['side'];\n}\n\nconst ThemeSwitcher = React.forwardRef<HTMLButtonElement, ThemeSwitcherProps>(\n  function ThemeSwitcher(\n    { className, size, label = 'Theme', align = 'end', side, ...props },\n    ref,\n  ) {\n    const {\n      theme,\n      setTheme,\n      scheme,\n      schemePreference,\n      setScheme,\n      themes,\n      mounted,\n    } = useTheme();\n\n    const active = SCHEME_OPTIONS.find(\n      (option) => option.value === schemePreference,\n    );\n    // `??` rather than `!`: an unknown preference must still render an icon.\n    const TriggerIcon = active?.Icon ?? SunIcon;\n    const activeTheme = themes.find((entry) => entry.name === theme);\n\n    return (\n      <DropdownMenu>\n        <DropdownMenuTrigger\n          ref={ref}\n          data-slot=\"theme-switcher\"\n          data-min-viewport={String(MIN_VIEWPORT)}\n          // Reflected so a consumer can style off the active pair, and so an\n          // e2e test can assert what the control BELIEVES independently of\n          // what the <html> element says. When those two disagree, the bug is\n          // worth finding.\n          data-theme-value={theme}\n          data-scheme-value={scheme}\n          aria-label={label}\n          className={cn(themeSwitcherVariants({ size }), className)}\n          {...props}\n        >\n          <TriggerIcon aria-hidden=\"true\" className=\"size-4\" />\n          <span className=\"hidden sm:inline\">\n            {activeTheme ? activeTheme.label : label}\n          </span>\n        </DropdownMenuTrigger>\n\n        <DropdownMenuContent\n          data-slot=\"theme-switcher-content\"\n          align={align}\n          side={side}\n          className=\"min-w-48\"\n        >\n          <DropdownMenuLabel>Theme</DropdownMenuLabel>\n          <DropdownMenuRadioGroup\n            value={theme}\n            onValueChange={(value: ThemeName) => setTheme(value)}\n          >\n            {themes.map((entry) => (\n              <DropdownMenuRadioItem\n                key={entry.name}\n                data-slot=\"theme-switcher-item\"\n                value={entry.name}\n                // `aria-checked` comes from Base UI's menuitemradio role.\n                // `aria-current` is the extra signal for \"this is the one in\n                // effect right now\" — and it is suppressed until `mounted`,\n                // because before the storage read the hook is reporting the\n                // DEFAULT, not the user's choice, and announcing the wrong\n                // one is worse than announcing nothing.\n                aria-current={mounted && entry.name === theme ? 'true' : undefined}\n              >\n                {entry.label}\n              </DropdownMenuRadioItem>\n            ))}\n          </DropdownMenuRadioGroup>\n\n          <DropdownMenuSeparator />\n\n          <DropdownMenuLabel>Appearance</DropdownMenuLabel>\n          <DropdownMenuRadioGroup\n            value={schemePreference}\n            onValueChange={(value: SchemePreference) => setScheme(value)}\n          >\n            {SCHEME_OPTIONS.map((option) => (\n              <DropdownMenuRadioItem\n                key={option.value}\n                data-slot=\"theme-switcher-item\"\n                value={option.value}\n                aria-current={\n                  mounted && option.value === schemePreference\n                    ? 'true'\n                    : undefined\n                }\n              >\n                <option.Icon aria-hidden=\"true\" className=\"size-4\" />\n                {option.label}\n              </DropdownMenuRadioItem>\n            ))}\n          </DropdownMenuRadioGroup>\n        </DropdownMenuContent>\n      </DropdownMenu>\n    );\n  },\n);\nThemeSwitcher.displayName = 'ThemeSwitcher';\n\ninterface ThemeSchemeToggleProps\n  extends Omit<\n    React.ComponentProps<typeof Toggle>,\n    'pressed' | 'defaultPressed' | 'onPressedChange' | 'value'\n  > {\n  /** Accessible name. Default: `'Dark mode'`. */\n  label?: string;\n}\n\n/**\n * One-click light ⇄ dark, for a nav bar that has no room for a menu.\n *\n * Deliberately does NOT cycle through `system`: a three-state control with\n * one affordance is unusable without a legend, and `aria-pressed` is a\n * boolean by definition. A user who wants \"follow my OS\" back reaches for\n * `ThemeSwitcher` — which is why the two ship together and why this one\n * writes an explicit preference rather than pretending to be neutral.\n */\nconst ThemeSchemeToggle = React.forwardRef<\n  HTMLButtonElement,\n  ThemeSchemeToggleProps\n>(function ThemeSchemeToggle({ className, label = 'Dark mode', ...props }, ref) {\n  const { scheme, setScheme } = useTheme();\n  const dark = scheme === 'dark';\n\n  return (\n    <Toggle\n      ref={ref}\n      data-slot=\"theme-scheme-toggle\"\n      data-min-viewport={String(MIN_VIEWPORT)}\n      aria-label={label}\n      // Base UI projects this onto `aria-pressed` — the correct ARIA for a\n      // two-state button (a switch role would demand on/off semantics the\n      // user never sees).\n      pressed={dark}\n      onPressedChange={(pressed: boolean) =>\n        setScheme(pressed ? 'dark' : 'light')\n      }\n      className={cn('size-9 p-0', className)}\n      {...props}\n    >\n      {dark ? (\n        <MoonIcon aria-hidden=\"true\" className=\"size-4\" />\n      ) : (\n        <SunIcon aria-hidden=\"true\" className=\"size-4\" />\n      )}\n    </Toggle>\n  );\n});\nThemeSchemeToggle.displayName = 'ThemeSchemeToggle';\n\nexport { ThemeSwitcher, ThemeSchemeToggle, themeSwitcherVariants };\nexport type { ThemeSwitcherProps, ThemeSchemeToggleProps };\n"
    }
  ],
  "meta": {
    "tier": "primitive",
    "client": true,
    "minViewport": 320,
    "loading": false,
    "version": "1.0.0",
    "since": "1.0.0"
  },
  "docs": "## @interlace/theme-switcher\n\nInstalled to `components/ui/theme-switcher.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/theme-switcher';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/theme-switcher\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
