{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "theme-switch",
  "title": "Theme Switch",
  "type": "registry:ui",
  "description": "Light / dark / system theme toggle — drop in the header. Seven visual variants: `cards`, `icons`, `icon-only`, `dropdown`, `pill`, `pill-4`, and `switch`. Persists choice to `localStorage` and respects `prefers-color-scheme` for `system`.",
  "categories": [
    "action"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/theme-switch/theme-switch.ts",
      "content": "import { LitElement, css, html, isServer, nothing } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport { ChevronDown, Monitor, Moon, Palette, Sparkles, Sun, type IconNode } from 'lucide'\nimport { icon } from '../../lib/icon'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport '../section-card/section-card'\nimport '../dropdown-menu/dropdown-menu'\n\nexport type Theme = 'light' | 'dark' | 'system' | 'black'\nexport type ThemeSwitchVariant = 'cards' | 'icons' | 'icon-only' | 'dropdown' | 'pill' | 'pill-4' | 'switch'\n\nconst ICONS: Record<Theme, [IconNode, string]> = {\n  light: [Sun, 'sun'],\n  dark: [Moon, 'moon'],\n  system: [Monitor, 'monitor'],\n  black: [Sparkles, 'sparkles'],\n}\n\nconst LABELS: Record<Theme, string> = {\n  light: 'Light',\n  dark: 'Dark',\n  system: 'System',\n  black: 'Black',\n}\n\nconst VARIANT_OPTIONS: Record<ThemeSwitchVariant, Theme[]> = {\n  cards: ['light', 'dark', 'system'],\n  icons: ['light', 'dark', 'system'],\n  'icon-only': ['light', 'dark'],\n  dropdown: ['light', 'dark', 'system'],\n  pill: ['light', 'dark', 'system'],\n  'pill-4': ['system', 'light', 'dark', 'black'],\n  switch: ['light', 'dark'],\n}\n\nconst ic = (t: Theme, cls: string) => icon(ICONS[t][0], ICONS[t][1], cls)\n\n// --- page theme store ----------------------------------------------------------\n// The React registry delegates to next-themes, configured by the registry's\n// ThemeProvider as `attribute=\"class\" defaultTheme=\"system\" enableSystem\n// disableTransitionOnChange`. This store reproduces that behaviour so Lit and\n// React pages share one persisted choice:\n//  - localStorage key `theme` (next-themes' default storageKey), default `system`;\n//  - the resolved theme as a class on <html> (`light` / `dark`), `system`\n//    resolving through `prefers-color-scheme` and following its changes;\n//  - `color-scheme` on <html> for light/dark;\n//  - transitions suppressed for the swap;\n//  - other tabs picked up through the `storage` event.\n// Every element's ThemeController watches `html.dark`, so all uip-* elements\n// follow automatically.\n\nconst STORAGE_KEY = 'theme'\nconst MEDIA = '(prefers-color-scheme: dark)'\nconst subscribers = new Set<UipThemeSwitch>()\nlet current: Theme | undefined\nlet listening = false\n\nconst readStored = (): Theme => {\n  try {\n    const v = localStorage.getItem(STORAGE_KEY)\n    return v === 'light' || v === 'dark' || v === 'system' || v === 'black' ? v : 'system'\n  } catch {\n    return 'system'\n  }\n}\n\nfunction disableTransitions() {\n  const style = document.createElement('style')\n  style.appendChild(\n    document.createTextNode(\n      '*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}',\n    ),\n  )\n  document.head.appendChild(style)\n  return () => {\n    // Force a restyle so the new colours commit with transitions off.\n    void getComputedStyle(document.body)\n    setTimeout(() => style.remove(), 1)\n  }\n}\n\nfunction applyTheme(theme: Theme) {\n  const resolved = theme === 'system' ? (matchMedia(MEDIA).matches ? 'dark' : 'light') : theme\n  const d = document.documentElement\n  const enable = disableTransitions()\n  d.classList.remove('light', 'dark', 'black')\n  d.classList.add(resolved)\n  d.style.colorScheme = resolved === 'light' || resolved === 'dark' ? resolved : ''\n  enable()\n}\n\nfunction startListening() {\n  if (listening) return\n  listening = true\n  current = readStored()\n  applyTheme(current)\n  matchMedia(MEDIA).addEventListener('change', () => {\n    if (current === 'system') applyTheme('system')\n  })\n  addEventListener('storage', (e) => {\n    if (e.key !== STORAGE_KEY) return\n    current = readStored()\n    applyTheme(current)\n    subscribers.forEach((s) => s.requestUpdate())\n  })\n}\n\n/** Set the page theme (persisted, applied to <html>, synced to every uncontrolled uip-theme-switch). */\nexport function setTheme(theme: Theme) {\n  current = theme\n  try {\n    localStorage.setItem(STORAGE_KEY, theme)\n  } catch {\n    /* storage unavailable — keep the in-memory choice */\n  }\n  applyTheme(theme)\n  subscribers.forEach((s) => s.requestUpdate())\n}\n\n// --- theme reveal (View Transition) ----------------------------------------------\ntype ViewTransitionDocument = Document & {\n  startViewTransition?: (callback: () => unknown) => { finished: Promise<void> }\n}\n\nlet revealing = false\n\n/**\n * React's withThemeReveal, verbatim in behaviour: the new theme wipes in as a\n * circle from the clicked control. The keyframes live in the page's\n * tailwind.css behind `html[data-uipkge-theme-reveal]`. React runs the swap in\n * flushSync; here the callback returns the elements' updateComplete so the API\n * captures the DOM after Lit re-renders.\n */\nasync function withThemeReveal(enabled: boolean, point: { x: number; y: number } | undefined, swap: () => unknown) {\n  const startViewTransition = (document as ViewTransitionDocument).startViewTransition?.bind(document)\n  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches\n  if (!enabled || !startViewTransition || reduceMotion) {\n    swap()\n    return\n  }\n  if (revealing) return\n  revealing = true\n\n  const root = document.documentElement\n  const x = point?.x ?? window.innerWidth / 2\n  const y = point?.y ?? window.innerHeight / 2\n  const radius = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y))\n  root.style.setProperty('--uipkge-theme-x', `${x}px`)\n  root.style.setProperty('--uipkge-theme-y', `${y}px`)\n  root.style.setProperty('--uipkge-theme-r', `${radius}px`)\n  root.setAttribute('data-uipkge-theme-reveal', '')\n\n  try {\n    await startViewTransition(swap).finished\n  } finally {\n    root.removeAttribute('data-uipkge-theme-reveal')\n    revealing = false\n  }\n}\n\nconst pointOf = (e?: MouseEvent) =>\n  // Keyboard activation dispatches a synthetic click at (0, 0): reveal from the centre instead.\n  e && e.detail > 0 ? { x: e.clientX, y: e.clientY } : undefined\n\n/**\n * <uip-theme-switch> — the registry ThemeSwitch as a web component.\n *\n *   <uip-theme-switch></uip-theme-switch>                          (cards, drives the page theme)\n *   <uip-theme-switch variant=\"dropdown\"></uip-theme-switch>\n *   <uip-theme-switch variant=\"pill\" value=\"system\" class=\"max-w-sm\"></uip-theme-switch>\n *\n * Props: `value` ('light' | 'dark' | 'system' | 'black'), `variant` ('cards' |\n * 'icons' | 'icon-only' | 'dropdown' | 'pill' | 'pill-4' | 'switch', default\n * 'cards'), `heading` (React's `title` — `title` is taken by HTMLElement),\n * `description`, `view-transition` (default true; `view-transition=\"false\"`\n * turns the circular reveal off).\n *\n * Without `value` the element is bound to the page theme, like React falling\n * back to next-themes: it reads/writes localStorage `theme`, sets the `light` /\n * `dark` class and `color-scheme` on <html>, follows `prefers-color-scheme` in\n * `system` mode, and every unbound switch on the page stays in sync. With\n * `value` set (React's controlled `value` + `onValueChange`) it only updates\n * its own `value` and emits events; the page theme is left to the consumer\n * (call the exported `setTheme()`).\n *\n * Events (bubbling, composed): `input`, `change`, `value-change` { value }.\n * React's `className` → a class on the host (layout, e.g. `max-w-md`) or\n * `[&::part(base)]:…` for the root control.\n */\nexport class UipThemeSwitch extends LitElement {\n  // Block for the full-width variants (cards, pill), inline otherwise — the host has no template of its own.\n  static styles = [\n    tailwind,\n    css`:host { display: inline-block; } :host(:not([variant])), :host([variant='cards']), :host([variant^='pill']) { display: block; }`,\n  ]\n\n  static properties = {\n    value: { reflect: true },\n    variant: { reflect: true },\n    heading: {},\n    description: {},\n    viewTransition: {\n      attribute: 'view-transition',\n      type: Boolean,\n      converter: { fromAttribute: (v: string | null) => v !== null && v !== 'false' },\n    },\n  }\n\n  value?: Theme\n  variant: ThemeSwitchVariant = 'cards'\n  heading?: string\n  description?: string\n  viewTransition = true\n\n  /** Pointer position of the last press in the dropdown (its `select` event carries none). */\n  private menuPoint?: { x: number; y: number }\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    if (isServer) return\n    subscribers.add(this)\n    if (this.value === undefined) startListening()\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    subscribers.delete(this)\n  }\n\n  private get bound() {\n    return this.value === undefined\n  }\n\n  private get modelValue(): Theme {\n    return this.value ?? current ?? 'system'\n  }\n\n  private get options() {\n    return VARIANT_OPTIONS[this.variant] ?? VARIANT_OPTIONS.cards\n  }\n\n  private get activeIndex() {\n    const i = this.options.indexOf(this.modelValue)\n    return i === -1 ? 0 : i\n  }\n\n  private set(t: Theme, point?: { x: number; y: number }) {\n    void withThemeReveal(this.viewTransition, point, () => {\n      if (this.bound) {\n        startListening()\n        setTheme(t)\n      } else this.value = t\n      this.dispatchEvent(new Event('input', { bubbles: true, composed: true }))\n      this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n      this.dispatchEvent(new CustomEvent('value-change', { detail: { value: t }, bubbles: true, composed: true }))\n      return Promise.all([...subscribers].map((s) => s.updateComplete))\n    })\n  }\n\n  private cycle(e?: MouseEvent) {\n    const next = this.options[(this.activeIndex + 1) % this.options.length]\n    if (next) this.set(next, pointOf(e))\n  }\n\n  render() {\n    const v = this.modelValue\n    const options = this.options\n    const label = this.heading ?? 'Theme'\n\n    if (this.variant === 'icons') {\n      return html`<div\n        part=\"base\"\n        role=\"radiogroup\"\n        aria-label=${label}\n        class=\"border-border bg-card inline-flex items-center gap-0.5 rounded-md border p-0.5\"\n      >\n        ${options.map(\n          (t) => html`<button\n            type=\"button\"\n            role=\"radio\"\n            aria-checked=${v === t ? 'true' : 'false'}\n            aria-label=${LABELS[t]}\n            class=${[\n              'focus-visible:ring-ring grid size-7 place-items-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none',\n              v === t ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground',\n            ].join(' ')}\n            @click=${(e: MouseEvent) => this.set(t, pointOf(e))}\n          >\n            ${ic(t, 'size-4')}\n          </button>`,\n        )}\n      </div>`\n    }\n\n    if (this.variant === 'icon-only') {\n      return html`<button\n        part=\"base\"\n        type=\"button\"\n        aria-label=${LABELS[v]}\n        class=\"text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring inline-flex size-8 items-center justify-center rounded-lg transition-colors focus-visible:ring-2 focus-visible:outline-none\"\n        @click=${(e: MouseEvent) => this.cycle(e)}\n      >\n        ${ic(v, 'size-4')}\n      </button>`\n    }\n\n    if (this.variant === 'dropdown') {\n      return html`<uip-dropdown-menu\n        class=\"[&::part(content)]:min-w-[140px]\"\n        @pointerup=${(e: PointerEvent) => (this.menuPoint = { x: e.clientX, y: e.clientY })}\n        @keydown=${() => (this.menuPoint = undefined)}\n      >\n        <button\n          slot=\"trigger\"\n          part=\"base\"\n          type=\"button\"\n          class=\"border-border bg-card hover:bg-muted focus-visible:ring-ring inline-flex h-9 items-center gap-2 rounded-md border px-3 text-sm transition focus-visible:ring-2 focus-visible:outline-none\"\n        >\n          ${ic(v, 'size-4')}\n          <span>${LABELS[v]}</span>\n          ${icon(ChevronDown, 'chevron-down', 'size-3 opacity-60')}\n        </button>\n        <uip-dropdown-menu-content align=\"end\">\n          ${options.map(\n            (t) => html`<uip-dropdown-menu-item\n              @select=${() => {\n                this.set(t, this.menuPoint)\n                this.menuPoint = undefined\n              }}\n              >${ic(t, 'mr-2 size-4')}<span>${LABELS[t]}</span></uip-dropdown-menu-item\n            >`,\n          )}\n        </uip-dropdown-menu-content>\n      </uip-dropdown-menu>`\n    }\n\n    if (this.variant === 'pill' || this.variant === 'pill-4') {\n      const indicatorStyle = {\n        width: `calc((100% - 4px) / ${options.length})`,\n        transform: `translateX(calc(${this.activeIndex} * 100%))`,\n      }\n      return html`<div\n        part=\"base\"\n        role=\"radiogroup\"\n        aria-label=${label}\n        class=\"border-border bg-card relative inline-flex w-full max-w-md rounded-full border p-0.5\"\n      >\n        <span\n          aria-hidden=\"true\"\n          class=\"bg-primary pointer-events-none absolute top-0.5 bottom-0.5 left-0.5 rounded-full transition-transform duration-300 ease-out\"\n          style=${styleMap(indicatorStyle)}\n        ></span>\n        ${options.map(\n          (t) => html`<button\n            type=\"button\"\n            role=\"radio\"\n            aria-checked=${v === t ? 'true' : 'false'}\n            aria-label=${LABELS[t]}\n            class=${[\n              'focus-visible:ring-ring relative z-[1] inline-flex h-7 flex-1 items-center justify-center gap-1.5 rounded-full px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none',\n              v === t ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground',\n            ].join(' ')}\n            @click=${(e: MouseEvent) => this.set(t, pointOf(e))}\n          >\n            ${ic(t, 'size-3.5')}\n            <span>${LABELS[t]}</span>\n          </button>`,\n        )}\n      </div>`\n    }\n\n    if (this.variant === 'switch') {\n      return html`<button\n        part=\"base\"\n        type=\"button\"\n        role=\"switch\"\n        aria-checked=${v === 'dark' ? 'true' : 'false'}\n        aria-label=${LABELS[v]}\n        class=${[\n          'border-border focus-visible:ring-ring relative inline-flex h-8 w-16 items-center rounded-full border transition-colors focus-visible:ring-2 focus-visible:outline-none',\n          v === 'dark' ? 'bg-primary' : 'bg-muted',\n        ].join(' ')}\n        @click=${(e: MouseEvent) => this.set(v === 'dark' ? 'light' : 'dark', pointOf(e))}\n      >\n        ${icon(\n          Sun,\n          'sun',\n          ['text-warning absolute left-1.5 size-4 transition-opacity', v === 'dark' ? 'opacity-30' : 'opacity-100'].join(' '),\n        )}\n        ${icon(\n          Moon,\n          'moon',\n          ['text-muted-foreground absolute right-1.5 size-4 transition-opacity', v === 'light' ? 'opacity-30' : 'opacity-100'].join(\n            ' ',\n          ),\n        )}\n        <span\n          aria-hidden=\"true\"\n          class=\"bg-card border-border absolute size-6 rounded-full border shadow transition-transform duration-300 ease-out\"\n          style=${styleMap({ transform: `translateX(${v === 'dark' ? '36px' : '4px'})` })}\n        ></span>\n      </button>`\n    }\n\n    // Cards (default): SectionCard with a 3-button grid.\n    return html`<uip-section-card\n      exportparts=\"base, content\"\n      .heading=${this.heading ?? 'Appearance'}\n      .description=${this.description ?? 'Choose your interface theme.'}\n    >\n      <span slot=\"header-action\" class=\"contents\">${icon(Palette, 'palette', 'text-muted-foreground size-5')}</span>\n      <div class=\"grid grid-cols-3 gap-2\" role=\"radiogroup\" aria-label=${this.heading ?? 'Theme'}>\n        ${options.map(\n          (t) => html`<button\n            type=\"button\"\n            role=\"radio\"\n            aria-checked=${v === t ? 'true' : 'false'}\n            class=${[\n              'focus-visible:ring-ring rounded-md border p-3 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:outline-none',\n              v === t ? 'border-primary ring-primary bg-primary/5 ring-1' : 'border-border hover:bg-muted/50',\n            ].join(' ')}\n            @click=${(e: MouseEvent) => this.set(t, pointOf(e))}\n          >\n            ${ic(t, 'text-muted-foreground mb-2 size-4')}\n            <p class=\"text-xs font-medium\">${LABELS[t]}</p>\n          </button>`,\n        )}\n      </div>\n    </uip-section-card>`\n  }\n}\n\ncustomElements.get('uip-theme-switch') || customElements.define('uip-theme-switch', UipThemeSwitch)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-theme-switch': UipThemeSwitch\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/theme-switch/theme-switch.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/dropdown-menu.json",
    "https://uipkge.dev/r/lit/lit-core.json",
    "https://uipkge.dev/r/lit/section-card.json"
  ]
}
