Menu

Change language

GitHub repo

Theme Customize

blocklayout

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.

Installation

$npx uipkge-ng@latest add theme-customize
Manifest:https://uipkge.dev/r/angular/theme-customize.jsonInstalls to:src/app/components/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.

ColorPreset
interface ColorPreset {
  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: ThemeCategoryKey
  mode: CustomizeMode
  color: PresetKey
  tint: TintKey
  font: FontKey
  radius: number
}

npm dependencies

Files installed (2)

  • src/app/components/blocks/theme-customize/theme-customize.component.ts36.6 kB
    // Compact theme customization sheet: Light/Dark/System mode (uipkge-theme cookie),
    // curated color presets overwriting --primary / --primary-foreground / --ring, surface tint,
    // font, and a radius slider bound to --radius. Persists to localStorage with one-click
    // Copy CSS. Port of the Vue/React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, computed, signal } from '@angular/core'
    import {
      Check,
      Copy,
      Download,
      LucideAngularModule,
      Monitor,
      Moon,
      Palette,
      Pipette,
      RotateCcw,
      Search,
      Shuffle,
      Sun,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiSheetComponent,
      UiSheetContentComponent,
      UiSheetDescriptionComponent,
      UiSheetHeaderComponent,
      UiSheetTitleComponent,
    } from '@/ui/sheet/sheet.component'
    import { UiSliderComponent } from '@/ui/slider/slider.component'
    
    export type CustomizeMode = 'light' | 'dark' | 'system'
    export type PresetKey =
      | 'neutral'
      | 'zinc'
      | 'slate'
      | 'stone'
      | 'rose'
      | 'red'
      | 'orange'
      | 'amber'
      | 'yellow'
      | 'lime'
      | 'emerald'
      | 'teal'
      | 'cyan'
      | 'sky'
      | 'blue'
      | 'indigo'
      | 'violet'
      | 'purple'
      | 'fuchsia'
      | 'pink'
      | 'custom'
    export 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'
    export type FontKey = 'sans' | 'serif' | 'mono'
    export type TintKey = 'pure' | 'cool' | 'warm'
    export type ThemeCategoryKey = 'featured' | 'editorial' | 'bold' | 'cool' | 'soft'
    
    export interface ColorPreset {
      key: PresetKey
      label: string
      swatch: string
      light: { primary: string; primaryForeground: string; ring: string }
      dark: { primary: string; primaryForeground: string; ring: string }
    }
    
    export function mkPreset(
      key: PresetKey,
      label: string,
      hue: number,
      l = 0.6,
      c = 0.2,
      fg = 'oklch(0.985 0 0)',
    ): ColorPreset {
      const swatch = `oklch(${l} ${c} ${hue})`
      return {
        key,
        label,
        swatch,
        light: { primary: swatch, primaryForeground: fg, ring: swatch },
        dark: { primary: swatch, primaryForeground: fg, ring: swatch },
      }
    }
    
    export const COLOR_PRESETS: ColorPreset[] = [
      {
        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)',
        },
      },
      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)'),
      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)'),
      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),
      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),
    ]
    
    export interface ThemePreset {
      key: ThemeKey
      label: string
      category: ThemeCategoryKey
      mode: CustomizeMode
      color: PresetKey
      tint: TintKey
      font: FontKey
      radius: number
    }
    
    export const CATEGORY_LABELS: Record<ThemeCategoryKey, string> = {
      featured: 'Featured',
      editorial: 'Editorial',
      bold: 'Bold',
      cool: 'Cool',
      soft: 'Soft',
    }
    
    export 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,
      },
    ]
    
    export 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' },
    ]
    
    export 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 COOKIE_KEY = 'uipkge-theme'
    
    function readModeCookie(): CustomizeMode {
      if (typeof document === 'undefined') return 'system'
      const m = document.cookie.match(/(?:^|; )uipkge-theme=([^;]+)/)
      const v = m ? decodeURIComponent(m[1]) : 'system'
      return v === 'light' || v === 'dark' || v === 'system' ? v : 'system'
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-theme-customize, [ui-theme-customize]',
      standalone: true,
      // React renders the trigger itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiButtonComponent,
        UiSheetComponent,
        UiSheetContentComponent,
        UiSheetDescriptionComponent,
        UiSheetHeaderComponent,
        UiSheetTitleComponent,
        UiSliderComponent,
      ],
      template: `
        <div data-slot="theme-customize" [class]="rootClass">
          <button ui-button variant="outline" size="sm" class="gap-2" (click)="sheetOpen.set(true)">
            <lucide-icon [img]="Palette" class="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
            >
          </button>
    
          <div ui-sheet [open]="sheetOpen()" (openChange)="sheetOpen.set($event)">
            <div ui-sheet-content side="right" class="flex w-full flex-col gap-0 p-0 sm:max-w-md">
              <div ui-sheet-header class="border-b px-6 py-4">
                <div class="flex items-center justify-between gap-3">
                  <h2 ui-sheet-title class="flex items-center gap-2 text-base">
                    <lucide-icon [img]="Palette" class="size-4" /> Customize
                  </h2>
                  @if (activeTheme(); as active) {
                    <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]="swatchFor(active.color)"></span>
                      {{ active.label }}
                    </span>
                  } @else {
                    <span class="text-muted-foreground bg-muted/50 rounded-full px-2 py-0.5 text-xs font-medium">
                      Custom
                    </span>
                  }
                </div>
                <p ui-sheet-description class="text-xs">
                  Live-edit tokens. Changes write to <code class="font-mono">:root</code> and persist locally.
                </p>
                <div class="mt-2 flex gap-2">
                  <button ui-button variant="outline" size="xs" class="gap-1.5" (click)="applyRandomTheme()">
                    <lucide-icon [img]="Shuffle" class="size-3" /> Surprise me
                  </button>
                </div>
              </div>
    
              <div class="flex-1 overflow-y-auto px-6 py-5">
                <div class="space-y-6">
                  <!-- Curated themes -->
                  <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">
                        {{ filteredThemes().length }}
                        @if (themeSearch()) {
                          <span> / {{ themesCount }} </span>
                        }
                        presets
                      </span>
                    </div>
    
                    <div class="relative mb-3">
                      <input
                        [value]="themeSearch()"
                        (input)="themeSearch.set($any($event.target).value)"
                        type="search"
                        placeholder="Search themes..."
                        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"
                      />
                      <lucide-icon
                        [img]="SearchIcon"
                        class="text-muted-foreground absolute top-1/2 left-2 size-3.5 -translate-y-1/2"
                      />
                    </div>
    
                    <div class="space-y-4">
                      @for (group of themesByCategory(); track group.key) {
                        <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">
                            @for (t of group.items; track t.key) {
                              <button
                                type="button"
                                [title]="t.mode + ' · ' + t.color + ' · ' + t.font + ' · r=' + t.radius"
                                [attr.aria-pressed]="activeTheme()?.key === t.key"
                                [class]="themeCardClass(t.key)"
                                (click)="applyTheme(t)"
                              >
                                <div
                                  class="relative h-14 px-2 py-1.5"
                                  [style.background]="tintSurface(t.tint, t.mode)"
                                  [style.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]="swatchFor(t.color)"
                                    ></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]="swatchFor(t.color)"
                                      style="color: oklch(0.985 0 0)"
                                      [style.border-radius]="previewRadius(t.radius)"
                                      [style.font-family]="fontStack(t.font)"
                                      >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>
                                  @if (activeTheme()?.key === t.key) {
                                    <lucide-icon [img]="Check" class="text-primary size-3" />
                                  }
                                </div>
                              </button>
                            }
                          </div>
                        </div>
                      }
                      @if (filteredThemes().length === 0) {
                        <div class="text-muted-foreground py-6 text-center text-xs">
                          No themes match "<span class="font-mono">{{ themeSearch() }}</span
                          >"
                        </div>
                      }
                    </div>
                  </div>
    
                  <!-- Mode -->
                  <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">
                      @for (m of modes; track m.value) {
                        <button
                          type="button"
                          [attr.aria-pressed]="mode() === m.value"
                          [class]="segmentClass(mode() === m.value)"
                          (click)="setMode(m.value)"
                        >
                          <lucide-icon [img]="m.icon" class="size-3.5" />
                          {{ m.label }}
                        </button>
                      }
                    </div>
                  </div>
    
                  <!-- Color -->
                  <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">{{ presetsCount }} options</span>
                    </div>
                    <div class="grid grid-cols-10 gap-1.5">
                      @for (p of presets; track p.key) {
                        <button
                          type="button"
                          [attr.aria-label]="p.label"
                          [attr.aria-pressed]="preset() === p.key"
                          [title]="p.label"
                          [class]="swatchClass(preset() === p.key)"
                          [style.background]="p.swatch"
                          (click)="setPreset(p.key)"
                        >
                          @if (preset() === p.key) {
                            <lucide-icon
                              [img]="Check"
                              class="absolute inset-0 m-auto size-3 text-white mix-blend-difference"
                            />
                          }
                        </button>
                      }
                      <label
                        [class]="customSlotClass()"
                        [style.background]="preset() === 'custom' ? customColor() : null"
                        aria-label="Custom color"
                        title="Custom color"
                      >
                        <input
                          type="color"
                          [value]="customColor()"
                          class="sr-only"
                          (input)="onCustomColor($any($event.target).value)"
                        />
                        @if (preset() !== 'custom') {
                          <lucide-icon [img]="Pipette" class="text-muted-foreground size-3" />
                        } @else {
                          <lucide-icon [img]="Check" class="size-3 text-white mix-blend-difference" />
                        }
                      </label>
                    </div>
                  </div>
    
                  <!-- Surface tint -->
                  <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">
                      @for (t of tints; track t.key) {
                        <button
                          type="button"
                          [attr.aria-pressed]="tint() === t.key"
                          [class]="segmentClass(tint() === t.key)"
                          (click)="setTint(t.key)"
                        >
                          {{ t.label }}
                        </button>
                      }
                    </div>
                  </div>
    
                  <!-- Font -->
                  <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">
                      @for (f of fonts; track f.key) {
                        <button
                          type="button"
                          [attr.aria-pressed]="font() === f.key"
                          [class]="segmentClass(font() === f.key)"
                          [style.font-family]="f.stack"
                          (click)="setFont(f.key)"
                        >
                          {{ f.label }}
                        </button>
                      }
                    </div>
                  </div>
    
                  <!-- Radius -->
                  <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">{{ radius().toFixed(2) }}rem</span>
                    </div>
                    <ui-slider
                      [value]="[radius()]"
                      (valueChange)="onRadiusSlider($event)"
                      [min]="0"
                      [max]="1"
                      [step]="0.05"
                    />
                    <div class="text-muted-foreground mt-1 flex justify-between text-xs">
                      <span>0</span>
                      <span>0.5</span>
                      <span>1</span>
                    </div>
                  </div>
    
                  <!-- Live preview -->
                  <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">
                        <button ui-button size="sm" class="text-xs">Primary</button>
                        <button ui-button size="sm" variant="outline" class="text-xs">Outline</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>
              </div>
    
              <!-- Footer actions (sticky bottom) -->
              <div class="bg-background/95 flex gap-2 border-t px-6 py-3 backdrop-blur">
                <button ui-button variant="ghost" size="sm" class="flex-1 gap-1.5" (click)="reset()">
                  <lucide-icon [img]="RotateCcw" class="size-3.5" /> Reset
                </button>
                <button
                  ui-button
                  aria-label="Download attachment"
                  variant="outline"
                  size="sm"
                  class="flex-1 gap-1.5"
                  (click)="exportJson()"
                >
                  <lucide-icon [img]="Download" class="size-3.5" />
                  {{ exported() ? 'Saved' : 'Export' }}
                </button>
                <button ui-button variant="default" size="sm" class="flex-1 gap-1.5" (click)="copyCss()">
                  @if (copied()) {
                    <lucide-icon [img]="Check" class="size-3.5" />
                  } @else {
                    <lucide-icon [img]="Copy" class="size-3.5" />
                  }
                  {{ copied() ? 'Copied' : 'Copy CSS' }}
                </button>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiThemeCustomizeComponent implements OnInit, OnDestroy {
      protected readonly Check = Check
      protected readonly Copy = Copy
      protected readonly Download = Download
      protected readonly Palette = Palette
      protected readonly Pipette = Pipette
      protected readonly RotateCcw = RotateCcw
      protected readonly Shuffle = Shuffle
    
      protected readonly presets = COLOR_PRESETS
      protected readonly presetsCount = COLOR_PRESETS.length
      protected readonly themesCount = THEMES.length
      protected readonly fonts = FONTS
      protected readonly tints = TINTS
      protected readonly modes: { value: CustomizeMode; label: string; icon: typeof Sun }[] = [
        { value: 'light', label: 'Light', icon: Sun },
        { value: 'dark', label: 'Dark', icon: Moon },
        { value: 'system', label: 'System', icon: Monitor },
      ]
    
      /** Search glyph for the theme filter input. */
      protected readonly SearchIcon = Search
      private copyTimer: ReturnType<typeof setTimeout> | undefined
      private exportTimer: ReturnType<typeof setTimeout> | undefined
    
      readonly mode = signal<CustomizeMode>('system')
      readonly preset = signal<PresetKey>('neutral')
      readonly customColor = signal('#ec4899')
      readonly font = signal<FontKey>('sans')
      readonly tint = signal<TintKey>('pure')
      readonly radius = signal(0.3)
      readonly copied = signal(false)
      readonly exported = signal(false)
      readonly themeSearch = signal('')
      readonly sheetOpen = signal(false)
    
      @Input('class') className?: string
    
      readonly activeTheme = computed<ThemePreset | null>(
        () =>
          THEMES.find(
            (t) => t.mode === this.mode() && t.color === this.preset() && t.tint === this.tint() && t.font === this.font(),
          ) ?? null,
      )
    
      readonly filteredThemes = computed(() => {
        const q = this.themeSearch().trim().toLowerCase()
        if (!q) return THEMES
        return THEMES.filter((t) => t.label.toLowerCase().includes(q) || t.key.includes(q))
      })
    
      readonly themesByCategory = computed<{ key: ThemeCategoryKey; label: string; items: ThemePreset[] }[]>(() => {
        const order: ThemeCategoryKey[] = ['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 onKeydown = (e: KeyboardEvent) => {
        if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'j') {
          e.preventDefault()
          this.sheetOpen.update((v) => !v)
        }
      }
    
      ngOnInit(): void {
        this.load()
        this.mode.set(readModeCookie())
        this.applyAll()
        if (typeof window !== 'undefined') window.addEventListener('keydown', this.onKeydown)
      }
    
      ngOnDestroy(): void {
        if (typeof window !== 'undefined') window.removeEventListener('keydown', this.onKeydown)
        clearTimeout(this.copyTimer)
        clearTimeout(this.exportTimer)
      }
    
      get rootClass(): string {
        return cn(this.className)
      }
    
      swatchFor(key: PresetKey): string {
        return COLOR_PRESETS.find((p) => p.key === key)?.swatch ?? '#ec4899'
      }
    
      fontStack(key: FontKey): string {
        return FONTS.find((f) => f.key === key)?.stack ?? FONTS[0]!.stack
      }
    
      tintSurface(key: TintKey, mode: CustomizeMode): string {
        const t = TINTS.find((x) => x.key === key) ?? TINTS[0]!
        return mode === 'dark' ? t.dark : t.light
      }
    
      previewRadius(r: number): string {
        return `${Math.min(r, 0.5)}rem`
      }
    
      segmentClass(active: boolean): string {
        return cn(
          'flex items-center justify-center gap-1.5 rounded px-2 py-1.5 text-xs font-medium transition-colors',
          active ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground',
        )
      }
    
      themeCardClass(key: ThemeKey): string {
        return cn(
          'group relative flex flex-col overflow-hidden rounded-lg border text-left transition-[box-shadow,scale]',
          this.activeTheme()?.key === key
            ? 'ring-ring ring-offset-background border-transparent ring-2 ring-offset-2'
            : 'hover:scale-[1.03] hover:shadow-md',
        )
      }
    
      swatchClass(active: boolean): string {
        return cn(
          'group ring-offset-background relative aspect-square rounded-md transition-transform',
          active ? 'ring-ring ring-2 ring-offset-2' : 'hover:scale-110',
        )
      }
    
      customSlotClass(): string {
        return cn(
          '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',
        )
      }
    
      isDarkActive(): boolean {
        if (typeof document === 'undefined') return false
        return document.documentElement.classList.contains('dark')
      }
    
      applyPreset(key: PresetKey): void {
        if (typeof document === 'undefined') return
        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 = COLOR_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)
      }
    
      applyTint(key: TintKey): void {
        if (typeof document === 'undefined') return
        const t = TINTS.find((x) => x.key === key)
        if (!t) return
        document.documentElement.style.setProperty('--background', this.isDarkActive() ? t.dark : t.light)
      }
    
      applyFont(key: FontKey): void {
        if (typeof document === 'undefined') return
        const f = FONTS.find((x) => x.key === key)
        if (!f) return
        document.documentElement.style.setProperty('--font-sans', f.stack)
      }
    
      applyRadius(rem: number): void {
        if (typeof document === 'undefined') return
        document.documentElement.style.setProperty('--radius', `${rem}rem`)
      }
    
      applyMode(mode: CustomizeMode): void {
        if (typeof document === 'undefined') return
        try {
          document.cookie = `${COOKIE_KEY}=${encodeURIComponent(mode)}; path=/; max-age=${60 * 60 * 24 * 365}; samesite=lax`
        } catch {
          /* ignore */
        }
        const dark = mode === 'dark' || (mode === 'system' && !!window.matchMedia?.('(prefers-color-scheme: dark)').matches)
        document.documentElement.classList.toggle('dark', dark)
        // Re-resolve light/dark token axes after the class flip (mirrors the Vue theme watcher).
        setTimeout(() => {
          this.applyPreset(this.preset())
          this.applyTint(this.tint())
        }, 0)
      }
    
      save(): void {
        try {
          localStorage.setItem(
            STORAGE_KEY,
            JSON.stringify({
              preset: this.preset(),
              customColor: this.customColor(),
              font: this.font(),
              tint: this.tint(),
              radius: this.radius(),
            }),
          )
        } catch {
          /* ignore */
        }
      }
    
      load(): void {
        try {
          const raw = localStorage.getItem(STORAGE_KEY)
          if (!raw) return
          const parsed = JSON.parse(raw)
          if (parsed.preset) this.preset.set(parsed.preset)
          if (parsed.customColor) this.customColor.set(parsed.customColor)
          if (parsed.font) this.font.set(parsed.font)
          if (parsed.tint) this.tint.set(parsed.tint)
          if (typeof parsed.radius === 'number') this.radius.set(parsed.radius)
        } catch {
          /* ignore */
        }
      }
    
      applyAll(): void {
        this.applyPreset(this.preset())
        this.applyTint(this.tint())
        this.applyFont(this.font())
        this.applyRadius(this.radius())
      }
    
      setMode(v: CustomizeMode): void {
        this.mode.set(v)
        this.applyMode(v)
      }
    
      setPreset(v: PresetKey): void {
        this.preset.set(v)
        this.applyPreset(v)
        this.save()
      }
    
      onCustomColor(v: string): void {
        this.customColor.set(v)
        this.preset.set('custom')
        this.applyPreset('custom')
        this.save()
      }
    
      setTint(v: TintKey): void {
        this.tint.set(v)
        this.applyTint(v)
        this.save()
      }
    
      setFont(v: FontKey): void {
        this.font.set(v)
        this.applyFont(v)
        this.save()
      }
    
      onRadiusSlider(v: number[]): void {
        if (typeof v[0] === 'number') {
          this.radius.set(v[0])
          this.applyRadius(v[0])
          this.save()
        }
      }
    
      applyTheme(t: ThemePreset): void {
        // Themes drive mode / color / tint / font. Radius stays user-controlled (default 0.3).
        this.setMode(t.mode)
        this.preset.set(t.color)
        this.tint.set(t.tint)
        this.font.set(t.font)
        this.applyPreset(t.color)
        this.applyTint(t.tint)
        this.applyFont(t.font)
        this.save()
      }
    
      applyRandomTheme(): void {
        const active = this.activeTheme()?.key
        const candidates = THEMES.filter((t) => t.key !== active)
        const pick = candidates[Math.floor(Math.random() * candidates.length)]
        if (pick) this.applyTheme(pick)
      }
    
      reset(): void {
        this.preset.set('neutral')
        this.customColor.set('#ec4899')
        this.font.set('sans')
        this.tint.set('pure')
        this.radius.set(0.3)
        if (typeof document !== 'undefined') {
          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 */
        }
      }
    
      buildCss(): string {
        const found = COLOR_PRESETS.find((p) => p.key === this.preset()) ?? COLOR_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};
    }`
      }
    
      async copyCss(): Promise<void> {
        try {
          await navigator.clipboard.writeText(this.buildCss())
          this.copied.set(true)
          clearTimeout(this.copyTimer)
          this.copyTimer = setTimeout(() => this.copied.set(false), 1600)
        } catch {
          /* ignore */
        }
      }
    
      exportJson(): void {
        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.set(true)
        clearTimeout(this.exportTimer)
        this.exportTimer = setTimeout(() => this.exported.set(false), 1600)
      }
    }
    
  • src/app/components/blocks/theme-customize/index.ts0.3 kB

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