{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "theme-customize",
  "title": "Theme Customize",
  "type": "registry:page",
  "files": [
    {
      "path": "packages/registry-angular/blocks/theme-customize/theme-customize.component.ts",
      "content": "// Compact theme customization sheet: Light/Dark/System mode (uipkge-theme cookie),\n// curated color presets overwriting --primary / --primary-foreground / --ring, surface tint,\n// font, and a radius slider bound to --radius. Persists to localStorage with one-click\n// Copy CSS. Port of the Vue/React block 1:1.\nimport { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, computed, signal } from '@angular/core'\nimport {\n  Check,\n  Copy,\n  Download,\n  LucideAngularModule,\n  Monitor,\n  Moon,\n  Palette,\n  Pipette,\n  RotateCcw,\n  Search,\n  Shuffle,\n  Sun,\n} from 'lucide-angular'\nimport { cn } from '@/lib/utils'\nimport { UiButtonComponent } from '@/ui/button/button.component'\nimport {\n  UiSheetComponent,\n  UiSheetContentComponent,\n  UiSheetDescriptionComponent,\n  UiSheetHeaderComponent,\n  UiSheetTitleComponent,\n} from '@/ui/sheet/sheet.component'\nimport { UiSliderComponent } from '@/ui/slider/slider.component'\n\nexport type CustomizeMode = 'light' | 'dark' | 'system'\nexport type 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'\nexport type 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'\nexport type FontKey = 'sans' | 'serif' | 'mono'\nexport type TintKey = 'pure' | 'cool' | 'warm'\nexport type ThemeCategoryKey = 'featured' | 'editorial' | 'bold' | 'cool' | 'soft'\n\nexport interface ColorPreset {\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\nexport function mkPreset(\n  key: PresetKey,\n  label: string,\n  hue: number,\n  l = 0.6,\n  c = 0.2,\n  fg = 'oklch(0.985 0 0)',\n): ColorPreset {\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\nexport const COLOR_PRESETS: ColorPreset[] = [\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  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  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  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  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\nexport interface ThemePreset {\n  key: ThemeKey\n  label: string\n  category: ThemeCategoryKey\n  mode: CustomizeMode\n  color: PresetKey\n  tint: TintKey\n  font: FontKey\n  radius: number\n}\n\nexport const CATEGORY_LABELS: Record<ThemeCategoryKey, string> = {\n  featured: 'Featured',\n  editorial: 'Editorial',\n  bold: 'Bold',\n  cool: 'Cool',\n  soft: 'Soft',\n}\n\nexport const THEMES: ThemePreset[] = [\n  {\n    key: 'newyear',\n    label: 'New Year',\n    category: 'featured',\n    mode: 'dark',\n    color: 'amber',\n    tint: 'warm',\n    font: 'serif',\n    radius: 0.75,\n  },\n  {\n    key: 'sunset',\n    label: 'Sunset',\n    category: 'featured',\n    mode: 'light',\n    color: 'orange',\n    tint: 'warm',\n    font: 'sans',\n    radius: 0.625,\n  },\n  {\n    key: 'cyberpunk',\n    label: 'Cyberpunk',\n    category: 'featured',\n    mode: 'dark',\n    color: 'fuchsia',\n    tint: 'cool',\n    font: 'mono',\n    radius: 0.125,\n  },\n  {\n    key: 'forest',\n    label: 'Forest',\n    category: 'featured',\n    mode: 'light',\n    color: 'emerald',\n    tint: 'warm',\n    font: 'serif',\n    radius: 0.5,\n  },\n  {\n    key: 'ocean',\n    label: 'Ocean',\n    category: 'featured',\n    mode: 'dark',\n    color: 'cyan',\n    tint: 'cool',\n    font: 'sans',\n    radius: 0.5,\n  },\n  {\n    key: 'pastel',\n    label: 'Pastel',\n    category: 'featured',\n    mode: 'light',\n    color: 'rose',\n    tint: 'pure',\n    font: 'sans',\n    radius: 0.875,\n  },\n  {\n    key: 'mono',\n    label: 'Mono',\n    category: 'editorial',\n    mode: 'light',\n    color: 'neutral',\n    tint: 'pure',\n    font: 'mono',\n    radius: 0,\n  },\n  {\n    key: 'brutalist',\n    label: 'Brutalist',\n    category: 'editorial',\n    mode: 'light',\n    color: 'neutral',\n    tint: 'pure',\n    font: 'mono',\n    radius: 0,\n  },\n  {\n    key: 'notebook',\n    label: 'Notebook',\n    category: 'editorial',\n    mode: 'light',\n    color: 'stone',\n    tint: 'warm',\n    font: 'serif',\n    radius: 0.25,\n  },\n  {\n    key: 'vintage',\n    label: 'Vintage',\n    category: 'editorial',\n    mode: 'light',\n    color: 'amber',\n    tint: 'warm',\n    font: 'serif',\n    radius: 0.375,\n  },\n  {\n    key: 'caffeine',\n    label: 'Caffeine',\n    category: 'editorial',\n    mode: 'light',\n    color: 'orange',\n    tint: 'warm',\n    font: 'serif',\n    radius: 0.5,\n  },\n  {\n    key: 'marble',\n    label: 'Marble',\n    category: 'editorial',\n    mode: 'light',\n    color: 'stone',\n    tint: 'pure',\n    font: 'serif',\n    radius: 0.25,\n  },\n  {\n    key: 'retro',\n    label: 'Retro',\n    category: 'bold',\n    mode: 'dark',\n    color: 'lime',\n    tint: 'cool',\n    font: 'mono',\n    radius: 0,\n  },\n  {\n    key: 'tangerine',\n    label: 'Tangerine',\n    category: 'bold',\n    mode: 'light',\n    color: 'orange',\n    tint: 'pure',\n    font: 'sans',\n    radius: 0.75,\n  },\n  {\n    key: 'ember',\n    label: 'Ember',\n    category: 'bold',\n    mode: 'dark',\n    color: 'red',\n    tint: 'warm',\n    font: 'sans',\n    radius: 0.375,\n  },\n  {\n    key: 'tropical',\n    label: 'Tropical',\n    category: 'bold',\n    mode: 'light',\n    color: 'teal',\n    tint: 'cool',\n    font: 'sans',\n    radius: 0.625,\n  },\n  {\n    key: 'midnight',\n    label: 'Midnight',\n    category: 'cool',\n    mode: 'dark',\n    color: 'indigo',\n    tint: 'cool',\n    font: 'sans',\n    radius: 0.5,\n  },\n  {\n    key: 'cosmic',\n    label: 'Cosmic',\n    category: 'cool',\n    mode: 'dark',\n    color: 'violet',\n    tint: 'cool',\n    font: 'sans',\n    radius: 0.625,\n  },\n  {\n    key: 'rainfall',\n    label: 'Rainfall',\n    category: 'cool',\n    mode: 'dark',\n    color: 'sky',\n    tint: 'cool',\n    font: 'sans',\n    radius: 0.5,\n  },\n  {\n    key: 'amethyst',\n    label: 'Amethyst',\n    category: 'cool',\n    mode: 'dark',\n    color: 'purple',\n    tint: 'cool',\n    font: 'sans',\n    radius: 0.625,\n  },\n  {\n    key: 'sage',\n    label: 'Sage',\n    category: 'soft',\n    mode: 'light',\n    color: 'emerald',\n    tint: 'warm',\n    font: 'serif',\n    radius: 0.75,\n  },\n  {\n    key: 'peach',\n    label: 'Peach',\n    category: 'soft',\n    mode: 'light',\n    color: 'amber',\n    tint: 'warm',\n    font: 'sans',\n    radius: 0.875,\n  },\n  {\n    key: 'quartz',\n    label: 'Quartz',\n    category: 'soft',\n    mode: 'light',\n    color: 'violet',\n    tint: 'cool',\n    font: 'sans',\n    radius: 0.875,\n  },\n  {\n    key: 'mocha',\n    label: 'Mocha',\n    category: 'soft',\n    mode: 'light',\n    color: 'rose',\n    tint: 'warm',\n    font: 'serif',\n    radius: 0.5,\n  },\n]\n\nexport const 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\nexport const 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'\nconst COOKIE_KEY = 'uipkge-theme'\n\nfunction readModeCookie(): CustomizeMode {\n  if (typeof document === 'undefined') return 'system'\n  const m = document.cookie.match(/(?:^|; )uipkge-theme=([^;]+)/)\n  const v = m ? decodeURIComponent(m[1]) : 'system'\n  return v === 'light' || v === 'dark' || v === 'system' ? v : 'system'\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-theme-customize, [ui-theme-customize]',\n  standalone: true,\n  // React renders the trigger itself: the host stays out of layout and `class` goes to the root.\n  host: { '[attr.class]': '\"contents\"' },\n  imports: [\n    LucideAngularModule,\n    UiButtonComponent,\n    UiSheetComponent,\n    UiSheetContentComponent,\n    UiSheetDescriptionComponent,\n    UiSheetHeaderComponent,\n    UiSheetTitleComponent,\n    UiSliderComponent,\n  ],\n  template: `\n    <div data-slot=\"theme-customize\" [class]=\"rootClass\">\n      <button ui-button variant=\"outline\" size=\"sm\" class=\"gap-2\" (click)=\"sheetOpen.set(true)\">\n        <lucide-icon [img]=\"Palette\" class=\"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\"\n          >⌘J</kbd\n        >\n      </button>\n\n      <div ui-sheet [open]=\"sheetOpen()\" (openChange)=\"sheetOpen.set($event)\">\n        <div ui-sheet-content side=\"right\" class=\"flex w-full flex-col gap-0 p-0 sm:max-w-md\">\n          <div ui-sheet-header class=\"border-b px-6 py-4\">\n            <div class=\"flex items-center justify-between gap-3\">\n              <h2 ui-sheet-title class=\"flex items-center gap-2 text-base\">\n                <lucide-icon [img]=\"Palette\" class=\"size-4\" /> Customize\n              </h2>\n              @if (activeTheme(); as active) {\n                <span\n                  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                >\n                  <span class=\"size-2 rounded-full shadow-2xs\" [style.background]=\"swatchFor(active.color)\"></span>\n                  {{ active.label }}\n                </span>\n              } @else {\n                <span class=\"text-muted-foreground bg-muted/50 rounded-full px-2 py-0.5 text-xs font-medium\">\n                  Custom\n                </span>\n              }\n            </div>\n            <p ui-sheet-description class=\"text-xs\">\n              Live-edit tokens. Changes write to <code class=\"font-mono\">:root</code> and persist locally.\n            </p>\n            <div class=\"mt-2 flex gap-2\">\n              <button ui-button variant=\"outline\" size=\"xs\" class=\"gap-1.5\" (click)=\"applyRandomTheme()\">\n                <lucide-icon [img]=\"Shuffle\" class=\"size-3\" /> Surprise me\n              </button>\n            </div>\n          </div>\n\n          <div class=\"flex-1 overflow-y-auto px-6 py-5\">\n            <div class=\"space-y-6\">\n              <!-- Curated themes -->\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                    {{ filteredThemes().length }}\n                    @if (themeSearch()) {\n                      <span> / {{ themesCount }} </span>\n                    }\n                    presets\n                  </span>\n                </div>\n\n                <div class=\"relative mb-3\">\n                  <input\n                    [value]=\"themeSearch()\"\n                    (input)=\"themeSearch.set($any($event.target).value)\"\n                    type=\"search\"\n                    placeholder=\"Search themes...\"\n                    class=\"border-input bg-background focus-visible:ring-ring h-8 w-full rounded-md border pr-2 pl-7 text-xs outline-none focus-visible:ring-2\"\n                  />\n                  <lucide-icon\n                    [img]=\"SearchIcon\"\n                    class=\"text-muted-foreground absolute top-1/2 left-2 size-3.5 -translate-y-1/2\"\n                  />\n                </div>\n\n                <div class=\"space-y-4\">\n                  @for (group of themesByCategory(); track group.key) {\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                        @for (t of group.items; track t.key) {\n                          <button\n                            type=\"button\"\n                            [title]=\"t.mode + ' · ' + t.color + ' · ' + t.font + ' · r=' + t.radius\"\n                            [attr.aria-pressed]=\"activeTheme()?.key === t.key\"\n                            [class]=\"themeCardClass(t.key)\"\n                            (click)=\"applyTheme(t)\"\n                          >\n                            <div\n                              class=\"relative h-14 px-2 py-1.5\"\n                              [style.background]=\"tintSurface(t.tint, t.mode)\"\n                              [style.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\n                                    class=\"size-1.5 rounded-full opacity-40\"\n                                    style=\"background: currentColor\"\n                                  ></span>\n                                  <span\n                                    class=\"size-1.5 rounded-full opacity-25\"\n                                    style=\"background: currentColor\"\n                                  ></span>\n                                  <span\n                                    class=\"size-1.5 rounded-full opacity-15\"\n                                    style=\"background: currentColor\"\n                                  ></span>\n                                </div>\n                                <span\n                                  class=\"size-2 rounded-full shadow-2xs\"\n                                  [style.background]=\"swatchFor(t.color)\"\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]=\"swatchFor(t.color)\"\n                                  style=\"color: oklch(0.985 0 0)\"\n                                  [style.border-radius]=\"previewRadius(t.radius)\"\n                                  [style.font-family]=\"fontStack(t.font)\"\n                                  >Aa</span\n                                >\n                                <span\n                                  class=\"block h-1 flex-1 rounded-full opacity-30\"\n                                  style=\"background: currentColor\"\n                                ></span>\n                              </div>\n                              <div class=\"mt-1.5 flex gap-1\">\n                                <span\n                                  class=\"block h-0.5 w-3 rounded-full opacity-20\"\n                                  style=\"background: currentColor\"\n                                ></span>\n                                <span\n                                  class=\"block h-0.5 w-5 rounded-full opacity-15\"\n                                  style=\"background: currentColor\"\n                                ></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                              @if (activeTheme()?.key === t.key) {\n                                <lucide-icon [img]=\"Check\" class=\"text-primary size-3\" />\n                              }\n                            </div>\n                          </button>\n                        }\n                      </div>\n                    </div>\n                  }\n                  @if (filteredThemes().length === 0) {\n                    <div class=\"text-muted-foreground py-6 text-center text-xs\">\n                      No themes match \"<span class=\"font-mono\">{{ themeSearch() }}</span\n                      >\"\n                    </div>\n                  }\n                </div>\n              </div>\n\n              <!-- Mode -->\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                  @for (m of modes; track m.value) {\n                    <button\n                      type=\"button\"\n                      [attr.aria-pressed]=\"mode() === m.value\"\n                      [class]=\"segmentClass(mode() === m.value)\"\n                      (click)=\"setMode(m.value)\"\n                    >\n                      <lucide-icon [img]=\"m.icon\" class=\"size-3.5\" />\n                      {{ m.label }}\n                    </button>\n                  }\n                </div>\n              </div>\n\n              <!-- Color -->\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\">{{ presetsCount }} options</span>\n                </div>\n                <div class=\"grid grid-cols-10 gap-1.5\">\n                  @for (p of presets; track p.key) {\n                    <button\n                      type=\"button\"\n                      [attr.aria-label]=\"p.label\"\n                      [attr.aria-pressed]=\"preset() === p.key\"\n                      [title]=\"p.label\"\n                      [class]=\"swatchClass(preset() === p.key)\"\n                      [style.background]=\"p.swatch\"\n                      (click)=\"setPreset(p.key)\"\n                    >\n                      @if (preset() === p.key) {\n                        <lucide-icon\n                          [img]=\"Check\"\n                          class=\"absolute inset-0 m-auto size-3 text-white mix-blend-difference\"\n                        />\n                      }\n                    </button>\n                  }\n                  <label\n                    [class]=\"customSlotClass()\"\n                    [style.background]=\"preset() === 'custom' ? customColor() : null\"\n                    aria-label=\"Custom color\"\n                    title=\"Custom color\"\n                  >\n                    <input\n                      type=\"color\"\n                      [value]=\"customColor()\"\n                      class=\"sr-only\"\n                      (input)=\"onCustomColor($any($event.target).value)\"\n                    />\n                    @if (preset() !== 'custom') {\n                      <lucide-icon [img]=\"Pipette\" class=\"text-muted-foreground size-3\" />\n                    } @else {\n                      <lucide-icon [img]=\"Check\" class=\"size-3 text-white mix-blend-difference\" />\n                    }\n                  </label>\n                </div>\n              </div>\n\n              <!-- Surface tint -->\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                  @for (t of tints; track t.key) {\n                    <button\n                      type=\"button\"\n                      [attr.aria-pressed]=\"tint() === t.key\"\n                      [class]=\"segmentClass(tint() === t.key)\"\n                      (click)=\"setTint(t.key)\"\n                    >\n                      {{ t.label }}\n                    </button>\n                  }\n                </div>\n              </div>\n\n              <!-- Font -->\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                  @for (f of fonts; track f.key) {\n                    <button\n                      type=\"button\"\n                      [attr.aria-pressed]=\"font() === f.key\"\n                      [class]=\"segmentClass(font() === f.key)\"\n                      [style.font-family]=\"f.stack\"\n                      (click)=\"setFont(f.key)\"\n                    >\n                      {{ f.label }}\n                    </button>\n                  }\n                </div>\n              </div>\n\n              <!-- Radius -->\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\">{{ radius().toFixed(2) }}rem</span>\n                </div>\n                <ui-slider\n                  [value]=\"[radius()]\"\n                  (valueChange)=\"onRadiusSlider($event)\"\n                  [min]=\"0\"\n                  [max]=\"1\"\n                  [step]=\"0.05\"\n                />\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              <!-- Live preview -->\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                    <button ui-button size=\"sm\" class=\"text-xs\">Primary</button>\n                    <button ui-button size=\"sm\" variant=\"outline\" class=\"text-xs\">Outline</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\n                    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                  >\n                    Input sample\n                  </div>\n                  <p class=\"text-muted-foreground text-xs leading-relaxed\">\n                    The quick brown fox jumps over the lazy dog.\n                  </p>\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <!-- Footer actions (sticky bottom) -->\n          <div class=\"bg-background/95 flex gap-2 border-t px-6 py-3 backdrop-blur\">\n            <button ui-button variant=\"ghost\" size=\"sm\" class=\"flex-1 gap-1.5\" (click)=\"reset()\">\n              <lucide-icon [img]=\"RotateCcw\" class=\"size-3.5\" /> Reset\n            </button>\n            <button\n              ui-button\n              aria-label=\"Download attachment\"\n              variant=\"outline\"\n              size=\"sm\"\n              class=\"flex-1 gap-1.5\"\n              (click)=\"exportJson()\"\n            >\n              <lucide-icon [img]=\"Download\" class=\"size-3.5\" />\n              {{ exported() ? 'Saved' : 'Export' }}\n            </button>\n            <button ui-button variant=\"default\" size=\"sm\" class=\"flex-1 gap-1.5\" (click)=\"copyCss()\">\n              @if (copied()) {\n                <lucide-icon [img]=\"Check\" class=\"size-3.5\" />\n              } @else {\n                <lucide-icon [img]=\"Copy\" class=\"size-3.5\" />\n              }\n              {{ copied() ? 'Copied' : 'Copy CSS' }}\n            </button>\n          </div>\n        </div>\n      </div>\n    </div>\n  `,\n})\nexport class UiThemeCustomizeComponent implements OnInit, OnDestroy {\n  protected readonly Check = Check\n  protected readonly Copy = Copy\n  protected readonly Download = Download\n  protected readonly Palette = Palette\n  protected readonly Pipette = Pipette\n  protected readonly RotateCcw = RotateCcw\n  protected readonly Shuffle = Shuffle\n\n  protected readonly presets = COLOR_PRESETS\n  protected readonly presetsCount = COLOR_PRESETS.length\n  protected readonly themesCount = THEMES.length\n  protected readonly fonts = FONTS\n  protected readonly tints = TINTS\n  protected readonly modes: { value: CustomizeMode; 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  /** Search glyph for the theme filter input. */\n  protected readonly SearchIcon = Search\n  private copyTimer: ReturnType<typeof setTimeout> | undefined\n  private exportTimer: ReturnType<typeof setTimeout> | undefined\n\n  readonly mode = signal<CustomizeMode>('system')\n  readonly preset = signal<PresetKey>('neutral')\n  readonly customColor = signal('#ec4899')\n  readonly font = signal<FontKey>('sans')\n  readonly tint = signal<TintKey>('pure')\n  readonly radius = signal(0.3)\n  readonly copied = signal(false)\n  readonly exported = signal(false)\n  readonly themeSearch = signal('')\n  readonly sheetOpen = signal(false)\n\n  @Input('class') className?: string\n\n  readonly activeTheme = computed<ThemePreset | null>(\n    () =>\n      THEMES.find(\n        (t) => t.mode === this.mode() && t.color === this.preset() && t.tint === this.tint() && t.font === this.font(),\n      ) ?? null,\n  )\n\n  readonly filteredThemes = computed(() => {\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  readonly themesByCategory = computed<{ key: ThemeCategoryKey; label: string; items: ThemePreset[] }[]>(() => {\n    const order: ThemeCategoryKey[] = ['featured', 'editorial', 'bold', 'cool', 'soft']\n    return order\n      .map((c) => ({\n        key: c,\n        label: CATEGORY_LABELS[c],\n        items: this.filteredThemes().filter((t) => t.category === c),\n      }))\n      .filter((g) => g.items.length > 0)\n  })\n\n  private onKeydown = (e: KeyboardEvent) => {\n    if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'j') {\n      e.preventDefault()\n      this.sheetOpen.update((v) => !v)\n    }\n  }\n\n  ngOnInit(): void {\n    this.load()\n    this.mode.set(readModeCookie())\n    this.applyAll()\n    if (typeof window !== 'undefined') window.addEventListener('keydown', this.onKeydown)\n  }\n\n  ngOnDestroy(): void {\n    if (typeof window !== 'undefined') window.removeEventListener('keydown', this.onKeydown)\n    clearTimeout(this.copyTimer)\n    clearTimeout(this.exportTimer)\n  }\n\n  get rootClass(): string {\n    return cn(this.className)\n  }\n\n  swatchFor(key: PresetKey): string {\n    return COLOR_PRESETS.find((p) => p.key === key)?.swatch ?? '#ec4899'\n  }\n\n  fontStack(key: FontKey): string {\n    return FONTS.find((f) => f.key === key)?.stack ?? FONTS[0]!.stack\n  }\n\n  tintSurface(key: TintKey, mode: CustomizeMode): string {\n    const t = TINTS.find((x) => x.key === key) ?? TINTS[0]!\n    return mode === 'dark' ? t.dark : t.light\n  }\n\n  previewRadius(r: number): string {\n    return `${Math.min(r, 0.5)}rem`\n  }\n\n  segmentClass(active: boolean): string {\n    return cn(\n      'flex items-center justify-center gap-1.5 rounded px-2 py-1.5 text-xs font-medium transition-colors',\n      active ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground',\n    )\n  }\n\n  themeCardClass(key: ThemeKey): string {\n    return cn(\n      'group relative flex flex-col overflow-hidden rounded-lg border text-left transition-[box-shadow,scale]',\n      this.activeTheme()?.key === key\n        ? 'ring-ring ring-offset-background border-transparent ring-2 ring-offset-2'\n        : 'hover:scale-[1.03] hover:shadow-md',\n    )\n  }\n\n  swatchClass(active: boolean): string {\n    return cn(\n      'group ring-offset-background relative aspect-square rounded-md transition-transform',\n      active ? 'ring-ring ring-2 ring-offset-2' : 'hover:scale-110',\n    )\n  }\n\n  customSlotClass(): string {\n    return cn(\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    )\n  }\n\n  isDarkActive(): boolean {\n    if (typeof document === 'undefined') return false\n    return document.documentElement.classList.contains('dark')\n  }\n\n  applyPreset(key: PresetKey): void {\n    if (typeof document === 'undefined') return\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 = COLOR_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  applyTint(key: TintKey): void {\n    if (typeof document === 'undefined') return\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  applyFont(key: FontKey): void {\n    if (typeof document === 'undefined') return\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  applyRadius(rem: number): void {\n    if (typeof document === 'undefined') return\n    document.documentElement.style.setProperty('--radius', `${rem}rem`)\n  }\n\n  applyMode(mode: CustomizeMode): void {\n    if (typeof document === 'undefined') return\n    try {\n      document.cookie = `${COOKIE_KEY}=${encodeURIComponent(mode)}; path=/; max-age=${60 * 60 * 24 * 365}; samesite=lax`\n    } catch {\n      /* ignore */\n    }\n    const dark = mode === 'dark' || (mode === 'system' && !!window.matchMedia?.('(prefers-color-scheme: dark)').matches)\n    document.documentElement.classList.toggle('dark', dark)\n    // Re-resolve light/dark token axes after the class flip (mirrors the Vue theme watcher).\n    setTimeout(() => {\n      this.applyPreset(this.preset())\n      this.applyTint(this.tint())\n    }, 0)\n  }\n\n  save(): void {\n    try {\n      localStorage.setItem(\n        STORAGE_KEY,\n        JSON.stringify({\n          preset: this.preset(),\n          customColor: this.customColor(),\n          font: this.font(),\n          tint: this.tint(),\n          radius: this.radius(),\n        }),\n      )\n    } catch {\n      /* ignore */\n    }\n  }\n\n  load(): void {\n    try {\n      const raw = localStorage.getItem(STORAGE_KEY)\n      if (!raw) return\n      const parsed = JSON.parse(raw)\n      if (parsed.preset) this.preset.set(parsed.preset)\n      if (parsed.customColor) this.customColor.set(parsed.customColor)\n      if (parsed.font) this.font.set(parsed.font)\n      if (parsed.tint) this.tint.set(parsed.tint)\n      if (typeof parsed.radius === 'number') this.radius.set(parsed.radius)\n    } catch {\n      /* ignore */\n    }\n  }\n\n  applyAll(): void {\n    this.applyPreset(this.preset())\n    this.applyTint(this.tint())\n    this.applyFont(this.font())\n    this.applyRadius(this.radius())\n  }\n\n  setMode(v: CustomizeMode): void {\n    this.mode.set(v)\n    this.applyMode(v)\n  }\n\n  setPreset(v: PresetKey): void {\n    this.preset.set(v)\n    this.applyPreset(v)\n    this.save()\n  }\n\n  onCustomColor(v: string): void {\n    this.customColor.set(v)\n    this.preset.set('custom')\n    this.applyPreset('custom')\n    this.save()\n  }\n\n  setTint(v: TintKey): void {\n    this.tint.set(v)\n    this.applyTint(v)\n    this.save()\n  }\n\n  setFont(v: FontKey): void {\n    this.font.set(v)\n    this.applyFont(v)\n    this.save()\n  }\n\n  onRadiusSlider(v: number[]): void {\n    if (typeof v[0] === 'number') {\n      this.radius.set(v[0])\n      this.applyRadius(v[0])\n      this.save()\n    }\n  }\n\n  applyTheme(t: ThemePreset): void {\n    // Themes drive mode / color / tint / font. Radius stays user-controlled (default 0.3).\n    this.setMode(t.mode)\n    this.preset.set(t.color)\n    this.tint.set(t.tint)\n    this.font.set(t.font)\n    this.applyPreset(t.color)\n    this.applyTint(t.tint)\n    this.applyFont(t.font)\n    this.save()\n  }\n\n  applyRandomTheme(): void {\n    const active = this.activeTheme()?.key\n    const candidates = THEMES.filter((t) => t.key !== active)\n    const pick = candidates[Math.floor(Math.random() * candidates.length)]\n    if (pick) this.applyTheme(pick)\n  }\n\n  reset(): void {\n    this.preset.set('neutral')\n    this.customColor.set('#ec4899')\n    this.font.set('sans')\n    this.tint.set('pure')\n    this.radius.set(0.3)\n    if (typeof document !== 'undefined') {\n      const root = document.documentElement\n      ;['--primary', '--primary-foreground', '--ring', '--background', '--font-sans', '--radius'].forEach((p) =>\n        root.style.removeProperty(p),\n      )\n    }\n    try {\n      localStorage.removeItem(STORAGE_KEY)\n    } catch {\n      /* ignore */\n    }\n  }\n\n  buildCss(): string {\n    const found = COLOR_PRESETS.find((p) => p.key === this.preset()) ?? COLOR_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  async copyCss(): Promise<void> {\n    try {\n      await navigator.clipboard.writeText(this.buildCss())\n      this.copied.set(true)\n      clearTimeout(this.copyTimer)\n      this.copyTimer = setTimeout(() => this.copied.set(false), 1600)\n    } catch {\n      /* ignore */\n    }\n  }\n\n  exportJson(): void {\n    const blob = new Blob(\n      [\n        JSON.stringify(\n          {\n            preset: this.preset(),\n            customColor: this.customColor(),\n            font: this.font(),\n            tint: this.tint(),\n            radius: this.radius(),\n            css: this.buildCss(),\n          },\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.set(true)\n    clearTimeout(this.exportTimer)\n    this.exportTimer = setTimeout(() => this.exported.set(false), 1600)\n  }\n}\n",
      "type": "registry:page",
      "target": "~/src/app/components/blocks/theme-customize/theme-customize.component.ts"
    },
    {
      "path": "packages/registry-angular/blocks/theme-customize/index.ts",
      "content": "export {\n  UiThemeCustomizeComponent,\n  type ColorPreset,\n  type CustomizeMode,\n  type FontKey,\n  type PresetKey,\n  type ThemeCategoryKey,\n  type ThemeKey,\n  type ThemePreset,\n  type TintKey,\n  COLOR_PRESETS,\n  CATEGORY_LABELS,\n  FONTS,\n  THEMES,\n  TINTS,\n  mkPreset,\n} from './theme-customize.component'\n",
      "type": "registry:page",
      "target": "~/src/app/components/blocks/theme-customize/index.ts"
    }
  ],
  "dependencies": [
    "lucide-angular"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/angular/button.json",
    "https://uipkge.dev/r/angular/slider.json",
    "https://uipkge.dev/r/angular/sheet.json"
  ],
  "description": "Compact theme customization popover. Light/Dark/System mode (via the uipkge-theme cookie), 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"
  ]
}