{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "theme-customize",
  "title": "Theme Customize",
  "type": "registry:page",
  "description": "Compact theme customization popover. Light/Dark/System mode (via next-themes), six curated color presets that overwrite `--primary` / `--primary-foreground` / `--ring`, and a radius slider bound to `--radius`. Persists to localStorage and exposes a one-click Copy CSS for the active token set.",
  "categories": [
    "layout",
    "dashboard"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/blocks/theme-customize/theme-customize.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport {\n  Check,\n  Copy,\n  Download,\n  Monitor,\n  Moon,\n  Palette,\n  Pipette,\n  RotateCcw,\n  Search,\n  Shuffle,\n  Sun,\n} from 'lucide'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\n// Side-effect imports: register the composed elements for standalone use\n// (consumers who install only this block still get working primitives).\nimport '../../components/button/button'\nimport '../../components/input/input'\nimport type { UipInput } from '../../components/input/input'\nimport '../../components/sheet/sheet'\nimport '../../components/slider/slider'\n\ntype Mode = 'light' | 'dark' | 'system'\ntype PresetKey =\n  | 'neutral'\n  | 'zinc'\n  | 'slate'\n  | 'stone'\n  | 'rose'\n  | 'red'\n  | 'orange'\n  | 'amber'\n  | 'yellow'\n  | 'lime'\n  | 'emerald'\n  | 'teal'\n  | 'cyan'\n  | 'sky'\n  | 'blue'\n  | 'indigo'\n  | 'violet'\n  | 'purple'\n  | 'fuchsia'\n  | 'pink'\n  | 'custom'\ntype ThemeKey =\n  | 'newyear'\n  | 'sunset'\n  | 'cyberpunk'\n  | 'forest'\n  | 'ocean'\n  | 'pastel'\n  | 'mono'\n  | 'brutalist'\n  | 'notebook'\n  | 'caffeine'\n  | 'midnight'\n  | 'mocha'\n  | 'retro'\n  | 'tangerine'\n  | 'quartz'\n  | 'cosmic'\n  | 'sage'\n  | 'marble'\n  | 'ember'\n  | 'amethyst'\n  | 'rainfall'\n  | 'peach'\n  | 'tropical'\n  | 'vintage'\ntype FontKey = 'sans' | 'serif' | 'mono'\ntype TintKey = 'pure' | 'cool' | 'warm'\n\ninterface Preset {\n  key: PresetKey\n  label: string\n  swatch: string\n  light: { primary: string; primaryForeground: string; ring: string }\n  dark: { primary: string; primaryForeground: string; ring: string }\n}\n\nfunction mkPreset(key: PresetKey, label: string, hue: number, l = 0.6, c = 0.2, fg = 'oklch(0.985 0 0)'): Preset {\n  const swatch = `oklch(${l} ${c} ${hue})`\n  return {\n    key,\n    label,\n    swatch,\n    light: { primary: swatch, primaryForeground: fg, ring: swatch },\n    dark: { primary: swatch, primaryForeground: fg, ring: swatch },\n  }\n}\n\nconst PRESETS: Preset[] = [\n  // Greyscale family\n  {\n    key: 'neutral',\n    label: 'Neutral',\n    swatch: 'oklch(0.205 0 0)',\n    light: { primary: 'oklch(0.205 0 0)', primaryForeground: 'oklch(0.985 0 0)', ring: 'oklch(0.708 0 0)' },\n    dark: { primary: 'oklch(0.922 0 0)', primaryForeground: 'oklch(0.205 0 0)', ring: 'oklch(0.556 0 0)' },\n  },\n  {\n    key: 'zinc',\n    label: 'Zinc',\n    swatch: 'oklch(0.21 0.006 285.885)',\n    light: {\n      primary: 'oklch(0.21 0.006 285.885)',\n      primaryForeground: 'oklch(0.985 0 0)',\n      ring: 'oklch(0.705 0.015 286.067)',\n    },\n    dark: {\n      primary: 'oklch(0.92 0.004 286.32)',\n      primaryForeground: 'oklch(0.21 0.006 285.885)',\n      ring: 'oklch(0.552 0.016 285.938)',\n    },\n  },\n  {\n    key: 'slate',\n    label: 'Slate',\n    swatch: 'oklch(0.21 0.034 264.665)',\n    light: {\n      primary: 'oklch(0.21 0.034 264.665)',\n      primaryForeground: 'oklch(0.985 0 0)',\n      ring: 'oklch(0.554 0.046 257.417)',\n    },\n    dark: {\n      primary: 'oklch(0.929 0.013 255.508)',\n      primaryForeground: 'oklch(0.21 0.034 264.665)',\n      ring: 'oklch(0.554 0.046 257.417)',\n    },\n  },\n  {\n    key: 'stone',\n    label: 'Stone',\n    swatch: 'oklch(0.216 0.006 56.043)',\n    light: {\n      primary: 'oklch(0.216 0.006 56.043)',\n      primaryForeground: 'oklch(0.985 0.001 106.423)',\n      ring: 'oklch(0.553 0.013 58.071)',\n    },\n    dark: {\n      primary: 'oklch(0.923 0.003 48.717)',\n      primaryForeground: 'oklch(0.216 0.006 56.043)',\n      ring: 'oklch(0.553 0.013 58.071)',\n    },\n  },\n  // Warm\n  mkPreset('rose', 'Rose', 16.439, 0.645, 0.246),\n  mkPreset('red', 'Red', 27.325, 0.637, 0.237),\n  mkPreset('orange', 'Orange', 47.604, 0.705, 0.213),\n  mkPreset('amber', 'Amber', 70.08, 0.769, 0.188),\n  mkPreset('yellow', 'Yellow', 95.277, 0.795, 0.184, 'oklch(0.286 0.066 53.813)'),\n  // Greens\n  mkPreset('lime', 'Lime', 130.85, 0.768, 0.233, 'oklch(0.274 0.072 132.109)'),\n  mkPreset('emerald', 'Emerald', 162.48, 0.696, 0.17),\n  mkPreset('teal', 'Teal', 184.704, 0.704, 0.14, 'oklch(0.984 0.014 180.72)'),\n  // Cool\n  mkPreset('cyan', 'Cyan', 221.723, 0.715, 0.143, 'oklch(0.302 0.056 229.695)'),\n  mkPreset('sky', 'Sky', 235.711, 0.685, 0.169),\n  mkPreset('blue', 'Blue', 264.376, 0.546, 0.245),\n  mkPreset('indigo', 'Indigo', 277.117, 0.511, 0.262),\n  // Purples\n  mkPreset('violet', 'Violet', 293.009, 0.541, 0.281),\n  mkPreset('purple', 'Purple', 303.9, 0.558, 0.288),\n  mkPreset('fuchsia', 'Fuchsia', 322.16, 0.667, 0.295),\n  mkPreset('pink', 'Pink', 354.308, 0.656, 0.241),\n]\n\ntype CategoryKey = 'featured' | 'editorial' | 'bold' | 'cool' | 'soft'\n\ninterface ThemePreset {\n  key: ThemeKey\n  label: string\n  category: CategoryKey\n  mode: Mode\n  color: PresetKey\n  tint: TintKey\n  font: FontKey\n  radius: number\n}\n\nconst CATEGORY_LABELS: Record<CategoryKey, string> = {\n  featured: 'Featured',\n  editorial: 'Editorial',\n  bold: 'Bold',\n  cool: 'Cool',\n  soft: 'Soft',\n}\n\nconst THEMES: ThemePreset[] = [\n  { key: 'newyear', label: 'New Year', category: 'featured', mode: 'dark', color: 'amber', tint: 'warm', font: 'serif', radius: 0.75 },\n  { key: 'sunset', label: 'Sunset', category: 'featured', mode: 'light', color: 'orange', tint: 'warm', font: 'sans', radius: 0.625 },\n  { key: 'cyberpunk', label: 'Cyberpunk', category: 'featured', mode: 'dark', color: 'fuchsia', tint: 'cool', font: 'mono', radius: 0.125 },\n  { key: 'forest', label: 'Forest', category: 'featured', mode: 'light', color: 'emerald', tint: 'warm', font: 'serif', radius: 0.5 },\n  { key: 'ocean', label: 'Ocean', category: 'featured', mode: 'dark', color: 'cyan', tint: 'cool', font: 'sans', radius: 0.5 },\n  { key: 'pastel', label: 'Pastel', category: 'featured', mode: 'light', color: 'rose', tint: 'pure', font: 'sans', radius: 0.875 },\n  { key: 'mono', label: 'Mono', category: 'editorial', mode: 'light', color: 'neutral', tint: 'pure', font: 'mono', radius: 0 },\n  { key: 'brutalist', label: 'Brutalist', category: 'editorial', mode: 'light', color: 'neutral', tint: 'pure', font: 'mono', radius: 0 },\n  { key: 'notebook', label: 'Notebook', category: 'editorial', mode: 'light', color: 'stone', tint: 'warm', font: 'serif', radius: 0.25 },\n  { key: 'vintage', label: 'Vintage', category: 'editorial', mode: 'light', color: 'amber', tint: 'warm', font: 'serif', radius: 0.375 },\n  { key: 'caffeine', label: 'Caffeine', category: 'editorial', mode: 'light', color: 'orange', tint: 'warm', font: 'serif', radius: 0.5 },\n  { key: 'marble', label: 'Marble', category: 'editorial', mode: 'light', color: 'stone', tint: 'pure', font: 'serif', radius: 0.25 },\n  { key: 'retro', label: 'Retro', category: 'bold', mode: 'dark', color: 'lime', tint: 'cool', font: 'mono', radius: 0 },\n  { key: 'tangerine', label: 'Tangerine', category: 'bold', mode: 'light', color: 'orange', tint: 'pure', font: 'sans', radius: 0.75 },\n  { key: 'ember', label: 'Ember', category: 'bold', mode: 'dark', color: 'red', tint: 'warm', font: 'sans', radius: 0.375 },\n  { key: 'tropical', label: 'Tropical', category: 'bold', mode: 'light', color: 'teal', tint: 'cool', font: 'sans', radius: 0.625 },\n  { key: 'midnight', label: 'Midnight', category: 'cool', mode: 'dark', color: 'indigo', tint: 'cool', font: 'sans', radius: 0.5 },\n  { key: 'cosmic', label: 'Cosmic', category: 'cool', mode: 'dark', color: 'violet', tint: 'cool', font: 'sans', radius: 0.625 },\n  { key: 'rainfall', label: 'Rainfall', category: 'cool', mode: 'dark', color: 'sky', tint: 'cool', font: 'sans', radius: 0.5 },\n  { key: 'amethyst', label: 'Amethyst', category: 'cool', mode: 'dark', color: 'purple', tint: 'cool', font: 'sans', radius: 0.625 },\n  { key: 'sage', label: 'Sage', category: 'soft', mode: 'light', color: 'emerald', tint: 'warm', font: 'serif', radius: 0.75 },\n  { key: 'peach', label: 'Peach', category: 'soft', mode: 'light', color: 'amber', tint: 'warm', font: 'sans', radius: 0.875 },\n  { key: 'quartz', label: 'Quartz', category: 'soft', mode: 'light', color: 'violet', tint: 'cool', font: 'sans', radius: 0.875 },\n  { key: 'mocha', label: 'Mocha', category: 'soft', mode: 'light', color: 'rose', tint: 'warm', font: 'serif', radius: 0.5 },\n]\n\nconst FONTS: { key: FontKey; label: string; stack: string }[] = [\n  { key: 'sans', label: 'Sans', stack: '\"DM Sans\", ui-sans-serif, system-ui, sans-serif' },\n  { key: 'serif', label: 'Serif', stack: '\"Source Serif Pro\", ui-serif, Georgia, serif' },\n  { key: 'mono', label: 'Mono', stack: '\"JetBrains Mono\", ui-monospace, monospace' },\n]\n\nconst TINTS: { key: TintKey; label: string; light: string; dark: string }[] = [\n  { key: 'pure', label: 'Pure', light: 'oklch(1 0 0)', dark: 'oklch(0.145 0 0)' },\n  { key: 'cool', label: 'Cool', light: 'oklch(0.99 0.005 240)', dark: 'oklch(0.16 0.008 250)' },\n  { key: 'warm', label: 'Warm', light: 'oklch(0.99 0.005 70)', dark: 'oklch(0.16 0.008 60)' },\n]\n\nconst STORAGE_KEY = 'uipkge-theme-customize'\n\nconst modes: { value: Mode; label: string; icon: typeof Sun }[] = [\n  { value: 'light', label: 'Light', icon: Sun },\n  { value: 'dark', label: 'Dark', icon: Moon },\n  { value: 'system', label: 'System', icon: Monitor },\n]\n\n/**\n * <uip-theme-customize> — the registry ThemeCustomize block as a web component.\n *\n * Class strings are React's ThemeCustomize verbatim; the PRESETS / THEMES /\n * FONTS / TINTS tables are copied verbatim. React state (useState) becomes\n * { state: true } fields; React's useEffect watchers become explicit\n * apply-after-set calls. There are no onX callbacks in React, so no\n * CustomEvents.\n *\n * Deviations: `next-themes` is not a Lit dependency — mode writes the\n * `dark` class on documentElement directly (`system` follows matchMedia),\n * and the search field's absolute overlay icon moves into <uip-input>'s\n * `prefix` slot (same visual result).\n */\nexport class UipThemeCustomize extends LitElement {\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }\n  // :host display can't be a utility class (the host has no template of its own).\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    mode: { state: true },\n    preset: { state: true },\n    customColor: { state: true },\n    font: { state: true },\n    tint: { state: true },\n    radius: { state: true },\n    copied: { state: true },\n    exported: { state: true },\n    themeSearch: { state: true },\n    sheetOpen: { state: true },\n  }\n\n  private mode: Mode = 'system'\n  private preset: PresetKey = 'neutral'\n  private customColor = '#ec4899'\n  private font: FontKey = 'sans'\n  private tint: TintKey = 'pure'\n  private radius = 0.3\n  private copied = false\n  private exported = false\n  private themeSearch = ''\n  private sheetOpen = false\n  private timers: number[] = []\n  private media?: MediaQueryList\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'theme-customize')\n    try {\n      const raw = localStorage.getItem(STORAGE_KEY)\n      if (raw) {\n        const parsed = JSON.parse(raw)\n        if (parsed.preset) this.preset = parsed.preset\n        if (parsed.customColor) this.customColor = parsed.customColor\n        if (parsed.font) this.font = parsed.font\n        if (parsed.tint) this.tint = parsed.tint\n        if (typeof parsed.radius === 'number') this.radius = parsed.radius\n      }\n    } catch {\n      // ignore\n    }\n    this.media = window.matchMedia('(prefers-color-scheme: dark)')\n    this.media.addEventListener('change', this.onSystemChange)\n    window.addEventListener('keydown', this.onKeydown)\n    this.applyAll()\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.media?.removeEventListener('change', this.onSystemChange)\n    window.removeEventListener('keydown', this.onKeydown)\n    this.timers.forEach((t) => window.clearTimeout(t))\n  }\n\n  private onSystemChange = () => {\n    if (this.mode === 'system') this.applyAll()\n  }\n\n  private onKeydown = (e: KeyboardEvent) => {\n    // Cmd/Ctrl + J -> toggle the customizer\n    if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'j') {\n      e.preventDefault()\n      this.sheetOpen = !this.sheetOpen\n    }\n  }\n\n  private isDarkActive(): boolean {\n    if (this.mode === 'dark') return true\n    if (this.mode === 'light') return false\n    return this.media?.matches ?? document.documentElement.classList.contains('dark')\n  }\n\n  private get activeTheme(): ThemePreset | null {\n    return THEMES.find((t) => t.mode === this.mode && t.color === this.preset && t.tint === this.tint && t.font === this.font) ?? null\n  }\n\n  private get filteredThemes(): ThemePreset[] {\n    const q = this.themeSearch.trim().toLowerCase()\n    if (!q) return THEMES\n    return THEMES.filter((t) => t.label.toLowerCase().includes(q) || t.key.includes(q))\n  }\n\n  private get themesByCategory(): { key: CategoryKey; label: string; items: ThemePreset[] }[] {\n    const order: CategoryKey[] = ['featured', 'editorial', 'bold', 'cool', 'soft']\n    return order\n      .map((c) => ({ key: c, label: CATEGORY_LABELS[c], items: this.filteredThemes.filter((t) => t.category === c) }))\n      .filter((g) => g.items.length > 0)\n  }\n\n  private later(fn: () => void, ms: number) {\n    this.timers.push(window.setTimeout(fn, ms))\n  }\n\n  private pulseTransition() {\n    const root = document.documentElement\n    root.style.transition = 'background-color 240ms ease, color 240ms ease'\n    this.later(() => {\n      root.style.transition = ''\n    }, 300)\n  }\n\n  private applyPreset(key: PresetKey) {\n    const root = document.documentElement\n    if (key === 'custom') {\n      root.style.setProperty('--primary', this.customColor)\n      root.style.setProperty('--primary-foreground', 'oklch(0.985 0 0)')\n      root.style.setProperty('--ring', this.customColor)\n      return\n    }\n    const found = PRESETS.find((p) => p.key === key)\n    if (!found) return\n    const tokens = this.isDarkActive() ? found.dark : found.light\n    root.style.setProperty('--primary', tokens.primary)\n    root.style.setProperty('--primary-foreground', tokens.primaryForeground)\n    root.style.setProperty('--ring', tokens.ring)\n  }\n\n  private applyTint(key: TintKey) {\n    const t = TINTS.find((x) => x.key === key)\n    if (!t) return\n    document.documentElement.style.setProperty('--background', this.isDarkActive() ? t.dark : t.light)\n  }\n\n  private applyFont(key: FontKey) {\n    const f = FONTS.find((x) => x.key === key)\n    if (!f) return\n    document.documentElement.style.setProperty('--font-sans', f.stack)\n  }\n\n  private applyRadius(rem: number) {\n    document.documentElement.style.setProperty('--radius', `${rem}rem`)\n  }\n\n  private applyMode(mode: Mode) {\n    const root = document.documentElement\n    if (mode === 'dark') root.classList.add('dark')\n    else if (mode === 'light') root.classList.remove('dark')\n    else root.classList.toggle('dark', this.media?.matches ?? false)\n  }\n\n  private save() {\n    try {\n      localStorage.setItem(\n        STORAGE_KEY,\n        JSON.stringify({ preset: this.preset, customColor: this.customColor, font: this.font, tint: this.tint, radius: this.radius }),\n      )\n    } catch {\n      // ignore\n    }\n  }\n\n  private applyAll() {\n    this.applyMode(this.mode)\n    this.applyPreset(this.preset)\n    this.applyTint(this.tint)\n    this.applyFont(this.font)\n    this.applyRadius(this.radius)\n    this.save()\n    this.pulseTransition()\n  }\n\n  private setMode(mode: Mode) {\n    this.mode = mode\n    this.applyAll()\n  }\n\n  private setPreset(key: PresetKey) {\n    this.preset = key\n    this.applyAll()\n  }\n\n  private setTint(key: TintKey) {\n    this.tint = key\n    this.applyAll()\n  }\n\n  private setFont(key: FontKey) {\n    this.font = key\n    this.applyAll()\n  }\n\n  private setRadius(rem: number) {\n    this.radius = rem\n    this.applyAll()\n  }\n\n  private applyTheme(t: ThemePreset) {\n    // Themes drive mode / color / tint / font. Radius is treated as a\n    // user-controlled value (default 0.3) and is not changed by presets.\n    this.mode = t.mode\n    this.preset = t.color\n    this.tint = t.tint\n    this.font = t.font\n    this.applyAll()\n  }\n\n  private applyRandomTheme() {\n    const candidates = THEMES.filter((t) => t.key !== this.activeTheme?.key)\n    const pick = candidates[Math.floor(Math.random() * candidates.length)]\n    if (pick) this.applyTheme(pick)\n  }\n\n  private reset() {\n    this.preset = 'neutral'\n    this.customColor = '#ec4899'\n    this.font = 'sans'\n    this.tint = 'pure'\n    this.radius = 0.3\n    const root = document.documentElement\n    ;['--primary', '--primary-foreground', '--ring', '--background', '--font-sans', '--radius'].forEach((p) =>\n      root.style.removeProperty(p),\n    )\n    try {\n      localStorage.removeItem(STORAGE_KEY)\n    } catch {\n      // ignore\n    }\n  }\n\n  private buildCss(): string {\n    const found = PRESETS.find((p) => p.key === this.preset) ?? PRESETS[0]\n    const isCustom = this.preset === 'custom'\n    const tokens = this.isDarkActive() ? found.dark : found.light\n    const t = TINTS.find((x) => x.key === this.tint) ?? TINTS[0]\n    const f = FONTS.find((x) => x.key === this.font) ?? FONTS[0]\n    const primary = isCustom ? this.customColor : tokens.primary\n    const primaryFg = isCustom ? 'oklch(0.985 0 0)' : tokens.primaryForeground\n    const ring = isCustom ? this.customColor : tokens.ring\n    return `:root {\n  --background: ${this.isDarkActive() ? t.dark : t.light};\n  --primary: ${primary};\n  --primary-foreground: ${primaryFg};\n  --ring: ${ring};\n  --radius: ${this.radius}rem;\n  --font-sans: ${f.stack};\n}`\n  }\n\n  private async copyCss() {\n    try {\n      await navigator.clipboard.writeText(this.buildCss())\n      this.copied = true\n      this.later(() => {\n        this.copied = false\n      }, 1600)\n    } catch {\n      // ignore\n    }\n  }\n\n  private exportJson() {\n    const blob = new Blob(\n      [\n        JSON.stringify(\n          { preset: this.preset, customColor: this.customColor, font: this.font, tint: this.tint, radius: this.radius, css: this.buildCss() },\n          null,\n          2,\n        ),\n      ],\n      { type: 'application/json' },\n    )\n    const url = URL.createObjectURL(blob)\n    const a = document.createElement('a')\n    a.href = url\n    a.download = 'theme.json'\n    a.click()\n    URL.revokeObjectURL(url)\n    this.exported = true\n    this.later(() => {\n      this.exported = false\n    }, 1600)\n  }\n\n  render() {\n    const activeTheme = this.activeTheme\n    return html`\n      <uip-sheet\n        data-slot=\"theme-customize\"\n        side=\"right\"\n        class=\"[&::part(content)]:flex [&::part(content)]:w-full [&::part(content)]:flex-col [&::part(content)]:gap-0 [&::part(content)]:p-0 [&::part(content)]:sm:max-w-md [&::part(footer)]:px-6 [&::part(footer)]:py-3\"\n        ?open=${this.sheetOpen}\n        @open-change=${(e: CustomEvent<{ open: boolean }>) => {\n          this.sheetOpen = e.detail.open\n        }}\n      >\n        <uip-button slot=\"trigger\" variant=\"outline\" size=\"sm\" class=\"gap-2\">\n          ${icon(Palette, 'palette', 'size-4')}\n          <span class=\"hidden sm:inline\">Customize</span>\n          <kbd class=\"bg-muted text-muted-foreground hidden rounded px-1 py-0.5 text-xs font-medium sm:inline-block\">⌘J</kbd>\n        </uip-button>\n        <div slot=\"title\" class=\"flex items-center justify-between gap-3\">\n          <span class=\"flex items-center gap-2 text-base\">${icon(Palette, 'palette', 'size-4')} Customize</span>\n          ${activeTheme\n            ? html`<span class=\"bg-muted text-foreground inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium\">\n                <span\n                  class=\"size-2 rounded-full shadow-2xs\"\n                  style=${`background: ${PRESETS.find((p) => p.key === activeTheme.color)?.swatch}`}\n                ></span>\n                ${activeTheme.label}\n              </span>`\n            : html`<span class=\"text-muted-foreground bg-muted/50 rounded-full px-2 py-0.5 text-xs font-medium\">Custom</span>`}\n        </div>\n        <div slot=\"description\" class=\"text-xs\">\n          Live-edit tokens. Changes write to <code class=\"font-mono\">:root</code> and persist locally.\n        </div>\n        <div class=\"px-6 py-4\">\n          <div class=\"mt-2 flex gap-2\">\n            <uip-button variant=\"outline\" size=\"xs\" class=\"gap-1.5\" @click=${this.applyRandomTheme}>\n              ${icon(Shuffle, 'shuffle', 'size-3')} Surprise me\n            </uip-button>\n          </div>\n        </div>\n        <uip-sheet-body class=\"[&::part(base)]:px-6 [&::part(base)]:py-5\">\n          <div class=\"space-y-6\">\n            <div>\n              <div class=\"mb-2 flex items-baseline justify-between\">\n                <span class=\"text-muted-foreground text-xs font-medium tracking-wide uppercase\">Themes</span>\n                <span class=\"text-muted-foreground text-xs\">\n                  ${this.filteredThemes.length}${this.themeSearch ? html`<span> / ${THEMES.length}</span>` : nothing} presets\n                </span>\n              </div>\n\n              <div class=\"mb-3\">\n                <uip-input\n                  .value=${this.themeSearch}\n                  type=\"search\"\n                  placeholder=\"Search themes...\"\n                  class=\"h-8 text-xs\"\n                  @input=${(e: Event) => {\n                    this.themeSearch = (e.target as UipInput).value\n                  }}\n                >\n                  <span slot=\"prefix\">${icon(Search, 'search', 'size-3.5')}</span>\n                </uip-input>\n              </div>\n\n              <div class=\"space-y-4\">\n                ${this.themesByCategory.map(\n                  (group) => html`\n                    <div>\n                      <div class=\"text-muted-foreground/70 mb-1.5 text-xs font-medium tracking-wider uppercase\">\n                        ${group.label}\n                      </div>\n                      <div class=\"grid grid-cols-3 gap-2\">\n                        ${group.items.map(\n                          (t) => html`\n                            <button\n                              type=\"button\"\n                              title=${`${t.mode} · ${t.color} · ${t.font} · r=${t.radius}`}\n                              aria-pressed=${activeTheme?.key === t.key ? 'true' : 'false'}\n                              class=${[\n                                'group relative flex flex-col overflow-hidden rounded-lg border text-left transition-[box-shadow,scale]',\n                                activeTheme?.key === t.key\n                                  ? 'ring-ring ring-offset-background border-transparent ring-2 ring-offset-2'\n                                  : 'hover:scale-[1.03] hover:shadow-md',\n                              ].join(' ')}\n                              @click=${() => this.applyTheme(t)}\n                            >\n                              <div\n                                class=\"relative h-14 px-2 py-1.5\"\n                                style=${`background: ${(TINTS.find((x) => x.key === t.tint) as unknown as Record<string, string>)[t.mode === 'dark' ? 'dark' : 'light']}; color: ${t.mode === 'dark' ? 'oklch(0.985 0 0)' : 'oklch(0.205 0 0)'}`}\n                              >\n                                <div class=\"flex items-center justify-between\">\n                                  <div class=\"flex gap-0.5\">\n                                    <span class=\"size-1.5 rounded-full opacity-40\" style=\"background: currentColor\"></span>\n                                    <span class=\"size-1.5 rounded-full opacity-25\" style=\"background: currentColor\"></span>\n                                    <span class=\"size-1.5 rounded-full opacity-15\" style=\"background: currentColor\"></span>\n                                  </div>\n                                  <span\n                                    class=\"size-2 rounded-full shadow-2xs\"\n                                    style=${`background: ${PRESETS.find((p) => p.key === t.color)?.swatch}`}\n                                  ></span>\n                                </div>\n                                <div class=\"mt-2 flex items-center gap-1\">\n                                  <span\n                                    class=\"inline-block h-3 px-1.5 text-xs leading-3 font-semibold\"\n                                    style=${`background: ${PRESETS.find((p) => p.key === t.color)?.swatch}; color: oklch(0.985 0 0); border-radius: ${Math.min(t.radius, 0.5)}rem; font-family: ${FONTS.find((f) => f.key === t.font)?.stack}`}\n                                  >\n                                    Aa\n                                  </span>\n                                  <span class=\"block h-1 flex-1 rounded-full opacity-30\" style=\"background: currentColor\"></span>\n                                </div>\n                                <div class=\"mt-1.5 flex gap-1\">\n                                  <span class=\"block h-0.5 w-3 rounded-full opacity-20\" style=\"background: currentColor\"></span>\n                                  <span class=\"block h-0.5 w-5 rounded-full opacity-15\" style=\"background: currentColor\"></span>\n                                </div>\n                              </div>\n                              <div class=\"bg-muted/40 flex items-center justify-between px-2 py-1\">\n                                <span class=\"text-xs leading-none font-medium\">${t.label}</span>\n                                ${activeTheme?.key === t.key ? icon(Check, 'check', 'text-primary size-3') : nothing}\n                              </div>\n                            </button>\n                          `,\n                        )}\n                      </div>\n                    </div>\n                  `,\n                )}\n                ${this.filteredThemes.length === 0\n                  ? html`<div class=\"text-muted-foreground py-6 text-center text-xs\">\n                      No themes match \"<span class=\"font-mono\">${this.themeSearch}</span>\"\n                    </div>`\n                  : nothing}\n              </div>\n            </div>\n\n            <div>\n              <div class=\"text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase\">Mode</div>\n              <div class=\"bg-muted/50 grid grid-cols-3 gap-1 rounded-md p-1\">\n                ${modes.map((m) => {\n                  const Icon = m.icon\n                  const name = m.value === 'light' ? 'sun' : m.value === 'dark' ? 'moon' : 'monitor'\n                  return html`\n                    <button\n                      type=\"button\"\n                      aria-pressed=${this.mode === m.value ? 'true' : 'false'}\n                      class=${[\n                        'flex items-center justify-center gap-1.5 rounded px-2 py-1.5 text-xs font-medium transition-colors',\n                        this.mode === m.value ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground',\n                      ].join(' ')}\n                      @click=${() => this.setMode(m.value)}\n                    >\n                      ${icon(Icon, name, 'size-3.5')} ${m.label}\n                    </button>\n                  `\n                })}\n              </div>\n            </div>\n\n            <div>\n              <div class=\"mb-2 flex items-baseline justify-between\">\n                <span class=\"text-muted-foreground text-xs font-medium tracking-wide uppercase\">Color</span>\n                <span class=\"text-muted-foreground text-xs\">${PRESETS.length} options</span>\n              </div>\n              <div class=\"grid grid-cols-10 gap-1.5\">\n                ${PRESETS.map(\n                  (p) => html`\n                    <button\n                      type=\"button\"\n                      aria-label=${p.label}\n                      aria-pressed=${this.preset === p.key ? 'true' : 'false'}\n                      title=${p.label}\n                      class=${[\n                        'group ring-offset-background relative aspect-square rounded-md transition-transform',\n                        this.preset === p.key ? 'ring-ring ring-2 ring-offset-2' : 'hover:scale-110',\n                      ].join(' ')}\n                      style=${`background: ${p.swatch}`}\n                      @click=${() => this.setPreset(p.key)}\n                    >\n                      ${this.preset === p.key ? icon(Check, 'check', 'absolute inset-0 m-auto size-3 text-white mix-blend-difference') : nothing}\n                    </button>\n                  `,\n                )}\n\n                <label\n                  class=${[\n                    'relative flex aspect-square cursor-pointer items-center justify-center rounded-md border transition-transform',\n                    this.preset === 'custom'\n                      ? 'ring-ring ring-offset-background border-transparent ring-2 ring-offset-2'\n                      : 'border-dashed hover:scale-110',\n                  ].join(' ')}\n                  style=${this.preset === 'custom' ? `background: ${this.customColor}` : nothing}\n                  aria-label=\"Custom color\"\n                  title=\"Custom color\"\n                >\n                  <input\n                    type=\"color\"\n                    .value=${this.customColor}\n                    class=\"sr-only\"\n                    @change=${(e: Event) => {\n                      this.customColor = (e.target as HTMLInputElement).value\n                      this.setPreset('custom')\n                    }}\n                  />\n                  ${this.preset !== 'custom'\n                    ? icon(Pipette, 'pipette', 'text-muted-foreground size-3')\n                    : icon(Check, 'check', 'size-3 text-white mix-blend-difference')}\n                </label>\n              </div>\n            </div>\n\n            <div>\n              <div class=\"text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase\">Surface</div>\n              <div class=\"bg-muted/50 grid grid-cols-3 gap-1 rounded-md p-1\">\n                ${TINTS.map(\n                  (t) => html`\n                    <button\n                      type=\"button\"\n                      aria-pressed=${this.tint === t.key ? 'true' : 'false'}\n                      class=${[\n                        'rounded px-2 py-1.5 text-xs font-medium transition-colors',\n                        this.tint === t.key ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground',\n                      ].join(' ')}\n                      @click=${() => this.setTint(t.key)}\n                    >\n                      ${t.label}\n                    </button>\n                  `,\n                )}\n              </div>\n            </div>\n\n            <div>\n              <div class=\"text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase\">Font</div>\n              <div class=\"bg-muted/50 grid grid-cols-3 gap-1 rounded-md p-1\">\n                ${FONTS.map(\n                  (f) => html`\n                    <button\n                      type=\"button\"\n                      aria-pressed=${this.font === f.key ? 'true' : 'false'}\n                      class=${[\n                        'rounded px-2 py-1.5 text-xs font-medium transition-colors',\n                        this.font === f.key ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground',\n                      ].join(' ')}\n                      style=${`font-family: ${f.stack}`}\n                      @click=${() => this.setFont(f.key)}\n                    >\n                      ${f.label}\n                    </button>\n                  `,\n                )}\n              </div>\n            </div>\n\n            <div>\n              <div class=\"mb-2 flex items-baseline justify-between\">\n                <span class=\"text-muted-foreground text-xs font-medium tracking-wide uppercase\">Radius</span>\n                <span class=\"text-muted-foreground font-mono text-xs\">${this.radius.toFixed(2)}rem</span>\n              </div>\n              <uip-slider\n                .value=${[this.radius]}\n                min=\"0\"\n                max=\"1\"\n                step=\"0.05\"\n                @value-change=${(e: CustomEvent<number[]>) => this.setRadius(e.detail[0] ?? this.radius)}\n              ></uip-slider>\n              <div class=\"text-muted-foreground mt-1 flex justify-between text-xs\">\n                <span>0</span>\n                <span>0.5</span>\n                <span>1</span>\n              </div>\n            </div>\n\n            <div>\n              <div class=\"text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase\">Preview</div>\n              <div class=\"bg-card space-y-2.5 rounded-lg border p-3\">\n                <div class=\"flex items-center gap-2\">\n                  <uip-button size=\"sm\" class=\"text-xs\">Primary</uip-button>\n                  <uip-button size=\"sm\" variant=\"outline\" class=\"text-xs\">Outline</uip-button>\n                  <span class=\"bg-primary/10 text-primary rounded-md px-2 py-0.5 text-xs font-medium\">Badge</span>\n                </div>\n                <div class=\"ring-ring/40 bg-background border-input flex h-8 items-center rounded-md border px-2 text-xs focus-within:ring-2\">\n                  Input sample\n                </div>\n                <p class=\"text-muted-foreground text-xs leading-relaxed\">The quick brown fox jumps over the lazy dog.</p>\n              </div>\n            </div>\n          </div>\n        </uip-sheet-body>\n        <div slot=\"footer\" class=\"flex w-full gap-2\">\n          <uip-button variant=\"ghost\" size=\"sm\" class=\"flex-1 gap-1.5\" @click=${this.reset}>\n            ${icon(RotateCcw, 'rotate-ccw', 'size-3.5')} Reset\n          </uip-button>\n          <uip-button aria-label=\"Download attachment\" variant=\"outline\" size=\"sm\" class=\"flex-1 gap-1.5\" @click=${this.exportJson}>\n            ${icon(Download, 'download', 'size-3.5')} ${this.exported ? 'Saved' : 'Export'}\n          </uip-button>\n          <uip-button variant=\"default\" size=\"sm\" class=\"flex-1 gap-1.5\" @click=${this.copyCss}>\n            ${this.copied ? icon(Check, 'check', 'size-3.5') : icon(Copy, 'copy', 'size-3.5')} ${this.copied ? 'Copied' : 'Copy CSS'}\n          </uip-button>\n        </div>\n      </uip-sheet>\n    `\n  }\n}\n\ncustomElements.get('uip-theme-customize') || customElements.define('uip-theme-customize', UipThemeCustomize)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-theme-customize': UipThemeCustomize\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/blocks/theme-customize/theme-customize.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/button.json",
    "https://uipkge.dev/r/lit/input.json",
    "https://uipkge.dev/r/lit/lit-core.json",
    "https://uipkge.dev/r/lit/sheet.json",
    "https://uipkge.dev/r/lit/slider.json"
  ]
}
