Menu

Change language

GitHub repo

Theme Customize

blocklayout

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.

Installation

$npx shadcn@latest add https://uipkge.dev/r/lit/theme-customize.json
Manifest:https://uipkge.dev/r/lit/theme-customize.jsonInstalls to:src/uip/blocks/theme-customize/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

Preset
interface Preset {
  key: PresetKey
  label: string
  swatch: string
  light: { primary: string; primaryForeground: string; ring: string }
  dark: { primary: string; primaryForeground: string; ring: string }
}
ThemePreset
interface ThemePreset {
  key: ThemeKey
  label: string
  category: CategoryKey
  mode: Mode
  color: PresetKey
  tint: TintKey
  font: FontKey
  radius: number
}
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-theme-customize': UipThemeCustomize
  }
}

Files installed (1)

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

Raw manifest:https://uipkge.dev/r/lit/theme-customize.json