Skip to content
Brok UI

Loading…

No results

Color Palette Generator

Open source

Pick a base brand colour and derive a full semantic palette (primary, accent, muted + readable foregrounds) live, with per-swatch copy and a Copy-CSS button — the paletteui core as an installable block.

Responsive Dark mode RTL Localized SEO

Preview

resources/views/blocks/color-palette-generator.blade.php Blade
@props([
    'base' => '#6d28d9',
])

@php
    // Seed the picker with a neutral brand purple by default; consumers can pass
    // any `#rrggbb`. No literal colours live in the chrome below — every swatch
    // surface is painted from a JS-derived `hsl(...)` channel string bound at
    // runtime, so the design-token raw-colour guard stays green.
    $base = is_string($base) && $base !== '' ? $base : '#6d28d9';
@endphp

<section
    data-slot="color-palette-generator"
    x-data="uiColorPaletteGenerator({ base: @js($base) })"
    {{ $attributes->merge(['class' => 'w-full']) }}
    aria-labelledby="cpg-heading"
>
    <x-ui.card class="mx-auto w-full max-w-3xl">
        <x-ui.card.header class="gap-2">
            <div class="flex flex-wrap items-center justify-between gap-4">
                <div class="space-y-2">
                    <x-ui.card.title id="cpg-heading">{{ __('Palette generator') }}</x-ui.card.title>
                    <x-ui.card.description>
                        {{ __('Pick a base brand colour and derive a full semantic palette for the current mode.') }}
                    </x-ui.card.description>
                </div>
                <x-ui.button
                    type="button"
                    variant="outline"
                    size="sm"
                    x-on:click="toggleMode()"
                    :aria-label="__('Toggle preview mode')"
                >
                    <span x-show="!isDark">{{ __('Light') }}</span>
                    <span x-show="isDark" x-cloak>{{ __('Dark') }}</span>
                </x-ui.button>
            </div>
        </x-ui.card.header>

        <x-ui.card.content class="space-y-6">
            {{-- Base colour picker: native swatch + hex field kept in sync. --}}
            <div class="space-y-2">
                <label for="cpg-hex" class="text-sm font-medium text-foreground">
                    {{ __('Base colour') }}
                </label>
                <div class="flex items-center gap-4">
                    <span class="relative inline-flex size-11 shrink-0 overflow-hidden rounded-md border border-input">
                        <input
                            type="color"
                            x-model="pickerValue"
                            class="absolute inset-0 size-full cursor-pointer appearance-none border-0 bg-transparent p-0 [&::-moz-color-swatch]:border-0 [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:border-0"
                            aria-label="{{ __('Pick base colour') }}"
                        />
                    </span>
                    <x-ui.input
                        id="cpg-hex"
                        type="text"
                        x-model="base"
                        :invalid="false"
                        x-bind:aria-invalid="isValidHex ? null : 'true'"
                        autocomplete="off"
                        spellcheck="false"
                        inputmode="text"
                        maxlength="7"
                        placeholder="#6d28d9"
                        class="max-w-[12rem] font-mono"
                        :aria-label="__('Hex value')"
                    />
                </div>
                <p
                    x-show="!isValidHex"
                    x-cloak
                    role="alert"
                    class="text-xs text-destructive"
                >
                    {{ __('Enter a valid hex colour, e.g. #6d28d9.') }}
                </p>
            </div>

            {{-- Derived swatch grid. Painted live from JS-derived channels. --}}
            <div class="space-y-4">
                <div class="flex items-center justify-between gap-4">
                    <h3 class="text-sm font-medium text-foreground">{{ __('Derived tokens') }}</h3>
                    <x-ui.button
                        type="button"
                        variant="secondary"
                        size="sm"
                        x-on:click="copyCss()"
                        :aria-label="__('Copy CSS variables')"
                    >
                        <span x-show="copied !== 'css'">{{ __('Copy CSS') }}</span>
                        <span x-show="copied === 'css'" x-cloak>{{ __('Copied') }}</span>
                    </x-ui.button>
                </div>

                <ul
                    role="list"
                    class="grid grid-cols-1 gap-2 sm:grid-cols-2"
                >
                    <template x-for="token in tokens" :key="token.key">
                        <li
                            data-slot="color-palette-generator-swatch"
                            class="flex items-center gap-4 rounded-md border border-border bg-card p-2"
                        >
                            <span
                                class="size-10 shrink-0 rounded-md border border-border"
                                :style="`background-color: hsl(${token.value})`"
                                aria-hidden="true"
                            ></span>
                            <div class="min-w-0 flex-1">
                                <div class="truncate text-sm font-medium text-foreground" x-text="token.label"></div>
                                <div class="truncate font-mono text-xs text-muted-foreground" x-text="token.value"></div>
                            </div>
                            <x-ui.button
                                type="button"
                                variant="ghost"
                                size="sm"
                                class="size-8 shrink-0 p-0"
                                x-on:click="copyToken(token)"
                                x-bind:aria-label="`{{ __('Copy') }} ${token.label}`"
                            >
                                <svg
                                    x-show="copied !== token.key"
                                    class="size-4"
                                    viewBox="0 0 24 24" fill="none" stroke="currentColor"
                                    stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
                                    aria-hidden="true"
                                >
                                    <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
                                    <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
                                </svg>
                                <svg
                                    x-show="copied === token.key"
                                    x-cloak
                                    class="size-4"
                                    viewBox="0 0 24 24" fill="none" stroke="currentColor"
                                    stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
                                    aria-hidden="true"
                                >
                                    <path d="M20 6 9 17l-5-5" />
                                </svg>
                            </x-ui.button>
                        </li>
                    </template>
                </ul>
            </div>
        </x-ui.card.content>
    </x-ui.card>
</section>
resources/js/ui/color-palette-generator.js JS
/**
 * Color Palette Generator — the paletteui core, self-contained.
 *
 * Pick a base brand colour and derive a full set of semantic design tokens
 * (--primary / --primary-foreground / --ring / --secondary / --accent /
 * --muted / their foregrounds / --muted-foreground) for the active light or
 * dark surface, expressed as "H S% L%" HSL channel strings exactly like the
 * tokens in ui.css.
 *
 * Everything is computed here so the block ships standalone — there is no
 * import from the docs app's playground util. Swatch colours are exposed to
 * the Blade via `tokens` and bound with an inline `background-color: hsl(...)`
 * style (the value is generated + trusted, never a hardcoded palette colour),
 * which keeps every literal colour out of the Blade and past the raw-colour
 * guard.
 *
 * Self-registers on `alpine:init` so import order does not matter; the build's
 * `import.meta.glob('./ui/**\/*.js')` wiring picks it up automatically.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiColorPaletteGenerator', (config = {}) => ({
        /** Base brand colour as a `#rrggbb` hex string (the source of truth). */
        base: typeof config.base === 'string' ? config.base : '#6d28d9',

        /**
         * Which surface we derive for. Defaults to the document's current mode
         * so the preview matches what the user is looking at, and can be
         * toggled locally without touching the page theme.
         */
        mode: 'light',

        /** Transient "copied" feedback keyed by token name (or 'css'). */
        copied: null,
        _copyTimer: 0,

        init() {
            this.mode = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
            // Keep the preview honest if the page theme is toggled elsewhere.
            this._observer = new MutationObserver(() => {
                this.mode = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
            });
            this._observer.observe(document.documentElement, {
                attributes: true,
                attributeFilter: ['class'],
            });
        },

        destroy() {
            if (this._observer) this._observer.disconnect();
            if (this._copyTimer) clearTimeout(this._copyTimer);
        },

        get isDark() {
            return this.mode === 'dark';
        },

        /** Normalise the bound hex (the text field may hold partial input). */
        get normalizedHex() {
            return normalizeHex(this.base) || '#6d28d9';
        },

        /** True when the hex field currently parses to a valid colour. */
        get isValidHex() {
            return normalizeHex(this.base) !== null;
        },

        /**
         * The derived token set as an ordered array of
         * `{ key, label, value }` where `value` is an "H S% L%" channel string.
         * Reactive on `base` + `mode`.
         */
        get tokens() {
            return derivePalette(this.normalizedHex, this.isDark);
        },

        /** Toggle the local light/dark derivation preview. */
        toggleMode() {
            this.mode = this.isDark ? 'light' : 'dark';
        },

        /** Keep the `<input type="color">` value as a clean hex. */
        get pickerValue() {
            return this.normalizedHex;
        },
        set pickerValue(v) {
            this.base = v;
        },

        /** The `:root { … }` CSS block for the derived tokens. */
        cssBlock() {
            const lines = this.tokens.map((t) => `  ${t.key}: ${t.value};`);
            return `:root {\n${lines.join('\n')}\n}`;
        },

        copyToken(token) {
            this._copy(token.value, token.key);
        },

        copyCss() {
            this._copy(this.cssBlock(), 'css');
        },

        async _copy(text, id) {
            const ok = await copyText(text);
            if (!ok) return;
            this.copied = id;
            if (this._copyTimer) clearTimeout(this._copyTimer);
            this._copyTimer = setTimeout(() => {
                this.copied = null;
            }, 1500);
        },
    }));
});

/* ------------------------------------------------------------------ *
 * Pure colour helpers (no Alpine, no DOM) — embedded so the block is
 * fully standalone.
 * ------------------------------------------------------------------ */

/** Coerce any `#rgb` / `#rrggbb` / `rrggbb` input to `#rrggbb`, or null. */
function normalizeHex(input) {
    if (typeof input !== 'string') return null;
    let h = input.trim().replace(/^#/, '');
    if (/^[0-9a-fA-F]{3}$/.test(h)) {
        h = h
            .split('')
            .map((c) => c + c)
            .join('');
    }
    if (!/^[0-9a-fA-F]{6}$/.test(h)) return null;
    return '#' + h.toLowerCase();
}

/** `#rrggbb` → { h, s, l } with s,l in 0..100, h in 0..360. */
function hexToHsl(hex) {
    const h = normalizeHex(hex) || '#000000';
    const r = parseInt(h.slice(1, 3), 16) / 255;
    const g = parseInt(h.slice(3, 5), 16) / 255;
    const b = parseInt(h.slice(5, 7), 16) / 255;

    const max = Math.max(r, g, b);
    const min = Math.min(r, g, b);
    const delta = max - min;

    let hue = 0;
    if (delta !== 0) {
        if (max === r) hue = ((g - b) / delta) % 6;
        else if (max === g) hue = (b - r) / delta + 2;
        else hue = (r - g) / delta + 4;
        hue *= 60;
        if (hue < 0) hue += 360;
    }

    const lightness = (max + min) / 2;
    const saturation = delta === 0 ? 0 : delta / (1 - Math.abs(2 * lightness - 1));

    return {
        h: Math.round(hue),
        s: Math.round(saturation * 100),
        l: Math.round(lightness * 100),
    };
}

/** Format HSL parts into the canonical "H S% L%" channel string. */
function formatChannels(h, s, l) {
    const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, Math.round(n)));
    return `${clamp(h, 0, 360)} ${clamp(s, 0, 100)}% ${clamp(l, 0, 100)}%`;
}

/**
 * Relative luminance (WCAG) of a hex colour, 0..1 — used to choose a readable
 * foreground and to branch the primary-foreground / tint maths.
 */
function luminance(hex) {
    const h = normalizeHex(hex) || '#000000';
    const channel = (v) => {
        const c = v / 255;
        return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
    };
    const r = channel(parseInt(h.slice(1, 3), 16));
    const g = channel(parseInt(h.slice(3, 5), 16));
    const b = channel(parseInt(h.slice(5, 7), 16));
    return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

/**
 * Derive the semantic palette for a base hex + mode.
 * Returns an ordered array of { key, label, value(channel string) }.
 */
function derivePalette(hex, dark) {
    const base = hexToHsl(hex);
    const lum = luminance(hex);
    const baseIsLight = lum > 0.5;

    // Readable foreground for the base/primary surface.
    const primaryForeground = baseIsLight
        ? formatChannels(base.h, 47, 11) // near-black, tinted to base hue
        : '210 20% 98%'; // near-white

    // Neutral tint shared by secondary / accent / muted, keyed off the base hue.
    const tint = {
        h: base.h,
        s: Math.min(base.s, dark ? 32 : 40),
        l: dark ? 17.5 : 96.1,
    };
    const tintChannels = formatChannels(tint.h, tint.s, tint.l);

    // Foreground that sits readably on the tint surface.
    const tintForeground = dark
        ? '210 20% 98%' // light text on a dark tint
        : formatChannels(base.h, 47, 11); // dark text on a light tint

    // Muted foreground — a mid-contrast neutral at the base hue.
    const mutedForeground = dark
        ? formatChannels(base.h, 20, 65)
        : formatChannels(base.h, 16, 47);

    const baseChannels = formatChannels(base.h, base.s, base.l);

    return [
        { key: '--primary', label: 'Primary', value: baseChannels },
        { key: '--primary-foreground', label: 'Primary Foreground', value: primaryForeground },
        { key: '--ring', label: 'Ring', value: baseChannels },
        { key: '--secondary', label: 'Secondary', value: tintChannels },
        { key: '--secondary-foreground', label: 'Secondary Foreground', value: tintForeground },
        { key: '--accent', label: 'Accent', value: tintChannels },
        { key: '--accent-foreground', label: 'Accent Foreground', value: tintForeground },
        { key: '--muted', label: 'Muted', value: tintChannels },
        { key: '--muted-foreground', label: 'Muted Foreground', value: mutedForeground },
    ];
}

/** Clipboard write with a textarea fallback for non-secure contexts. */
async function copyText(text) {
    try {
        if (navigator.clipboard && window.isSecureContext) {
            await navigator.clipboard.writeText(text);
            return true;
        }
    } catch {
        /* fall through to legacy path */
    }
    try {
        const ta = document.createElement('textarea');
        ta.value = text;
        ta.setAttribute('readonly', '');
        ta.style.position = 'absolute';
        ta.style.left = '-9999px';
        document.body.appendChild(ta);
        ta.select();
        const ok = document.execCommand('copy');
        ta.remove();
        return ok;
    } catch {
        return false;
    }
}

Installation

terminal
php artisan ui:add blocks/color-palette-generator

Installs these dependencies automatically:

Registry contract

Install confidence

php artisan ui:add blocks/color-palette-generator writes only the generated targets below. The CLI validates each file hash before writing and prompts before replacing local changes unless --force is used.

Version
1.0.4
License
open
Stability
stable
Contract
v1
Foundation
≥ 1.0.0
Type Generated target
blade resources/views/blocks/color-palette-generator.blade.php
js resources/js/ui/color-palette-generator.js

Registry dependencies

card input button

Package dependencies

composer: jml/brok:^0.2

npm: alpinejs

Use with AI

A brief for your coding agent: what the block is, the install command, how to render it, its props and the rules. Copy it, or open a prompt about this block in an assistant.

color-palette-generator.md
# Brok UI block: Color Palette Generator (`color-palette-generator`)

Pick a base brand colour and derive a full semantic palette (primary, accent, muted + readable foregrounds) live, with per-swatch copy and a Copy-CSS button — the paletteui core as an installable block.

Brok UI is a Laravel Blade registry. `ui:add` copies this block into the app as plain Blade the app owns; it composes installed `<brok:*>` primitives and semantic design tokens.

## Install

```bash
php artisan ui:add blocks/color-palette-generator
```

## Render it

```blade
<x-blocks.color-palette-generator />
```

## Props

- `base` (string, default `#6d28d9`)

## Use when

- Letting a visitor pick a base brand colour and see a derived semantic palette (primary, accent, muted, foregrounds) update live.
- Copying the generated palette as ready-to-paste CSS via a per-swatch copy action and a Copy CSS button.

## Avoid when

- This is a standalone demo tool, not a theming primitive; use themes-gallery to preview whole theme presets, or edit packages/ui/resources/css/ui.css directly to change the shipped design tokens.

## Rules

- Render the installed block with `<x-blocks.color-palette-generator />` and pass data through its props; edit the copied file only for structural changes.
- Keep the semantic design tokens (`bg-background`, `text-muted-foreground`); never swap in raw colour utilities.
- Keep the `data-slot` attributes and the logical (start/end) spacing so the markup still mirrors under `dir="rtl"`.

## Links

- Docs: https://brokui.dev/blocks/color-palette-generator
- Registry JSON (files, props, contract): https://brokui.dev/r/open/blocks/color-palette-generator.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Guidance

Use when

  • Letting a visitor pick a base brand colour and see a derived semantic palette (primary, accent, muted, foregrounds) update live.
  • Copying the generated palette as ready-to-paste CSS via a per-swatch copy action and a Copy CSS button.

Avoid when

  • This is a standalone demo tool, not a theming primitive; use themes-gallery to preview whole theme presets, or edit packages/ui/resources/css/ui.css directly to change the shipped design tokens.

Anti-patterns

  • Do not replace semantic props with conflicting utility classes.
  • Do not remove labels, focus styles, or state attributes.
Anatomy
root color-palette-generator color-palette-generator-swatch

Usage

Render the block as a component, passing data where useful:

resources/views/welcome.blade.php Blade
<x-blocks.color-palette-generator />

Built from primitives

Composes <brok:*> Semantic tokens Re-themeable RTL-ready

This block composes installed <brok:*> primitives and semantic design tokens only — it does not reimplement any primitive. Re-theme it (light, dark, admin, customer) by editing CSS variables; flip the preview to RTL to confirm it mirrors.

Source

The exact, editable files ui:add writes into your app. The preview above renders this same source — there are no preview-only blocks.

resources/views/blocks/color-palette-generator.blade.php Blade
@props([
    'base' => '#6d28d9',
])

@php
    // Seed the picker with a neutral brand purple by default; consumers can pass
    // any `#rrggbb`. No literal colours live in the chrome below — every swatch
    // surface is painted from a JS-derived `hsl(...)` channel string bound at
    // runtime, so the design-token raw-colour guard stays green.
    $base = is_string($base) && $base !== '' ? $base : '#6d28d9';
@endphp

<section
    data-slot="color-palette-generator"
    x-data="uiColorPaletteGenerator({ base: @js($base) })"
    {{ $attributes->merge(['class' => 'w-full']) }}
    aria-labelledby="cpg-heading"
>
    <x-ui.card class="mx-auto w-full max-w-3xl">
        <x-ui.card.header class="gap-2">
            <div class="flex flex-wrap items-center justify-between gap-4">
                <div class="space-y-2">
                    <x-ui.card.title id="cpg-heading">{{ __('Palette generator') }}</x-ui.card.title>
                    <x-ui.card.description>
                        {{ __('Pick a base brand colour and derive a full semantic palette for the current mode.') }}
                    </x-ui.card.description>
                </div>
                <x-ui.button
                    type="button"
                    variant="outline"
                    size="sm"
                    x-on:click="toggleMode()"
                    :aria-label="__('Toggle preview mode')"
                >
                    <span x-show="!isDark">{{ __('Light') }}</span>
                    <span x-show="isDark" x-cloak>{{ __('Dark') }}</span>
                </x-ui.button>
            </div>
        </x-ui.card.header>

        <x-ui.card.content class="space-y-6">
            {{-- Base colour picker: native swatch + hex field kept in sync. --}}
            <div class="space-y-2">
                <label for="cpg-hex" class="text-sm font-medium text-foreground">
                    {{ __('Base colour') }}
                </label>
                <div class="flex items-center gap-4">
                    <span class="relative inline-flex size-11 shrink-0 overflow-hidden rounded-md border border-input">
                        <input
                            type="color"
                            x-model="pickerValue"
                            class="absolute inset-0 size-full cursor-pointer appearance-none border-0 bg-transparent p-0 [&::-moz-color-swatch]:border-0 [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:border-0"
                            aria-label="{{ __('Pick base colour') }}"
                        />
                    </span>
                    <x-ui.input
                        id="cpg-hex"
                        type="text"
                        x-model="base"
                        :invalid="false"
                        x-bind:aria-invalid="isValidHex ? null : 'true'"
                        autocomplete="off"
                        spellcheck="false"
                        inputmode="text"
                        maxlength="7"
                        placeholder="#6d28d9"
                        class="max-w-[12rem] font-mono"
                        :aria-label="__('Hex value')"
                    />
                </div>
                <p
                    x-show="!isValidHex"
                    x-cloak
                    role="alert"
                    class="text-xs text-destructive"
                >
                    {{ __('Enter a valid hex colour, e.g. #6d28d9.') }}
                </p>
            </div>

            {{-- Derived swatch grid. Painted live from JS-derived channels. --}}
            <div class="space-y-4">
                <div class="flex items-center justify-between gap-4">
                    <h3 class="text-sm font-medium text-foreground">{{ __('Derived tokens') }}</h3>
                    <x-ui.button
                        type="button"
                        variant="secondary"
                        size="sm"
                        x-on:click="copyCss()"
                        :aria-label="__('Copy CSS variables')"
                    >
                        <span x-show="copied !== 'css'">{{ __('Copy CSS') }}</span>
                        <span x-show="copied === 'css'" x-cloak>{{ __('Copied') }}</span>
                    </x-ui.button>
                </div>

                <ul
                    role="list"
                    class="grid grid-cols-1 gap-2 sm:grid-cols-2"
                >
                    <template x-for="token in tokens" :key="token.key">
                        <li
                            data-slot="color-palette-generator-swatch"
                            class="flex items-center gap-4 rounded-md border border-border bg-card p-2"
                        >
                            <span
                                class="size-10 shrink-0 rounded-md border border-border"
                                :style="`background-color: hsl(${token.value})`"
                                aria-hidden="true"
                            ></span>
                            <div class="min-w-0 flex-1">
                                <div class="truncate text-sm font-medium text-foreground" x-text="token.label"></div>
                                <div class="truncate font-mono text-xs text-muted-foreground" x-text="token.value"></div>
                            </div>
                            <x-ui.button
                                type="button"
                                variant="ghost"
                                size="sm"
                                class="size-8 shrink-0 p-0"
                                x-on:click="copyToken(token)"
                                x-bind:aria-label="`{{ __('Copy') }} ${token.label}`"
                            >
                                <svg
                                    x-show="copied !== token.key"
                                    class="size-4"
                                    viewBox="0 0 24 24" fill="none" stroke="currentColor"
                                    stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
                                    aria-hidden="true"
                                >
                                    <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
                                    <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
                                </svg>
                                <svg
                                    x-show="copied === token.key"
                                    x-cloak
                                    class="size-4"
                                    viewBox="0 0 24 24" fill="none" stroke="currentColor"
                                    stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
                                    aria-hidden="true"
                                >
                                    <path d="M20 6 9 17l-5-5" />
                                </svg>
                            </x-ui.button>
                        </li>
                    </template>
                </ul>
            </div>
        </x-ui.card.content>
    </x-ui.card>
</section>
resources/js/ui/color-palette-generator.js JS
/**
 * Color Palette Generator — the paletteui core, self-contained.
 *
 * Pick a base brand colour and derive a full set of semantic design tokens
 * (--primary / --primary-foreground / --ring / --secondary / --accent /
 * --muted / their foregrounds / --muted-foreground) for the active light or
 * dark surface, expressed as "H S% L%" HSL channel strings exactly like the
 * tokens in ui.css.
 *
 * Everything is computed here so the block ships standalone — there is no
 * import from the docs app's playground util. Swatch colours are exposed to
 * the Blade via `tokens` and bound with an inline `background-color: hsl(...)`
 * style (the value is generated + trusted, never a hardcoded palette colour),
 * which keeps every literal colour out of the Blade and past the raw-colour
 * guard.
 *
 * Self-registers on `alpine:init` so import order does not matter; the build's
 * `import.meta.glob('./ui/**\/*.js')` wiring picks it up automatically.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiColorPaletteGenerator', (config = {}) => ({
        /** Base brand colour as a `#rrggbb` hex string (the source of truth). */
        base: typeof config.base === 'string' ? config.base : '#6d28d9',

        /**
         * Which surface we derive for. Defaults to the document's current mode
         * so the preview matches what the user is looking at, and can be
         * toggled locally without touching the page theme.
         */
        mode: 'light',

        /** Transient "copied" feedback keyed by token name (or 'css'). */
        copied: null,
        _copyTimer: 0,

        init() {
            this.mode = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
            // Keep the preview honest if the page theme is toggled elsewhere.
            this._observer = new MutationObserver(() => {
                this.mode = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
            });
            this._observer.observe(document.documentElement, {
                attributes: true,
                attributeFilter: ['class'],
            });
        },

        destroy() {
            if (this._observer) this._observer.disconnect();
            if (this._copyTimer) clearTimeout(this._copyTimer);
        },

        get isDark() {
            return this.mode === 'dark';
        },

        /** Normalise the bound hex (the text field may hold partial input). */
        get normalizedHex() {
            return normalizeHex(this.base) || '#6d28d9';
        },

        /** True when the hex field currently parses to a valid colour. */
        get isValidHex() {
            return normalizeHex(this.base) !== null;
        },

        /**
         * The derived token set as an ordered array of
         * `{ key, label, value }` where `value` is an "H S% L%" channel string.
         * Reactive on `base` + `mode`.
         */
        get tokens() {
            return derivePalette(this.normalizedHex, this.isDark);
        },

        /** Toggle the local light/dark derivation preview. */
        toggleMode() {
            this.mode = this.isDark ? 'light' : 'dark';
        },

        /** Keep the `<input type="color">` value as a clean hex. */
        get pickerValue() {
            return this.normalizedHex;
        },
        set pickerValue(v) {
            this.base = v;
        },

        /** The `:root { … }` CSS block for the derived tokens. */
        cssBlock() {
            const lines = this.tokens.map((t) => `  ${t.key}: ${t.value};`);
            return `:root {\n${lines.join('\n')}\n}`;
        },

        copyToken(token) {
            this._copy(token.value, token.key);
        },

        copyCss() {
            this._copy(this.cssBlock(), 'css');
        },

        async _copy(text, id) {
            const ok = await copyText(text);
            if (!ok) return;
            this.copied = id;
            if (this._copyTimer) clearTimeout(this._copyTimer);
            this._copyTimer = setTimeout(() => {
                this.copied = null;
            }, 1500);
        },
    }));
});

/* ------------------------------------------------------------------ *
 * Pure colour helpers (no Alpine, no DOM) — embedded so the block is
 * fully standalone.
 * ------------------------------------------------------------------ */

/** Coerce any `#rgb` / `#rrggbb` / `rrggbb` input to `#rrggbb`, or null. */
function normalizeHex(input) {
    if (typeof input !== 'string') return null;
    let h = input.trim().replace(/^#/, '');
    if (/^[0-9a-fA-F]{3}$/.test(h)) {
        h = h
            .split('')
            .map((c) => c + c)
            .join('');
    }
    if (!/^[0-9a-fA-F]{6}$/.test(h)) return null;
    return '#' + h.toLowerCase();
}

/** `#rrggbb` → { h, s, l } with s,l in 0..100, h in 0..360. */
function hexToHsl(hex) {
    const h = normalizeHex(hex) || '#000000';
    const r = parseInt(h.slice(1, 3), 16) / 255;
    const g = parseInt(h.slice(3, 5), 16) / 255;
    const b = parseInt(h.slice(5, 7), 16) / 255;

    const max = Math.max(r, g, b);
    const min = Math.min(r, g, b);
    const delta = max - min;

    let hue = 0;
    if (delta !== 0) {
        if (max === r) hue = ((g - b) / delta) % 6;
        else if (max === g) hue = (b - r) / delta + 2;
        else hue = (r - g) / delta + 4;
        hue *= 60;
        if (hue < 0) hue += 360;
    }

    const lightness = (max + min) / 2;
    const saturation = delta === 0 ? 0 : delta / (1 - Math.abs(2 * lightness - 1));

    return {
        h: Math.round(hue),
        s: Math.round(saturation * 100),
        l: Math.round(lightness * 100),
    };
}

/** Format HSL parts into the canonical "H S% L%" channel string. */
function formatChannels(h, s, l) {
    const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, Math.round(n)));
    return `${clamp(h, 0, 360)} ${clamp(s, 0, 100)}% ${clamp(l, 0, 100)}%`;
}

/**
 * Relative luminance (WCAG) of a hex colour, 0..1 — used to choose a readable
 * foreground and to branch the primary-foreground / tint maths.
 */
function luminance(hex) {
    const h = normalizeHex(hex) || '#000000';
    const channel = (v) => {
        const c = v / 255;
        return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
    };
    const r = channel(parseInt(h.slice(1, 3), 16));
    const g = channel(parseInt(h.slice(3, 5), 16));
    const b = channel(parseInt(h.slice(5, 7), 16));
    return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

/**
 * Derive the semantic palette for a base hex + mode.
 * Returns an ordered array of { key, label, value(channel string) }.
 */
function derivePalette(hex, dark) {
    const base = hexToHsl(hex);
    const lum = luminance(hex);
    const baseIsLight = lum > 0.5;

    // Readable foreground for the base/primary surface.
    const primaryForeground = baseIsLight
        ? formatChannels(base.h, 47, 11) // near-black, tinted to base hue
        : '210 20% 98%'; // near-white

    // Neutral tint shared by secondary / accent / muted, keyed off the base hue.
    const tint = {
        h: base.h,
        s: Math.min(base.s, dark ? 32 : 40),
        l: dark ? 17.5 : 96.1,
    };
    const tintChannels = formatChannels(tint.h, tint.s, tint.l);

    // Foreground that sits readably on the tint surface.
    const tintForeground = dark
        ? '210 20% 98%' // light text on a dark tint
        : formatChannels(base.h, 47, 11); // dark text on a light tint

    // Muted foreground — a mid-contrast neutral at the base hue.
    const mutedForeground = dark
        ? formatChannels(base.h, 20, 65)
        : formatChannels(base.h, 16, 47);

    const baseChannels = formatChannels(base.h, base.s, base.l);

    return [
        { key: '--primary', label: 'Primary', value: baseChannels },
        { key: '--primary-foreground', label: 'Primary Foreground', value: primaryForeground },
        { key: '--ring', label: 'Ring', value: baseChannels },
        { key: '--secondary', label: 'Secondary', value: tintChannels },
        { key: '--secondary-foreground', label: 'Secondary Foreground', value: tintForeground },
        { key: '--accent', label: 'Accent', value: tintChannels },
        { key: '--accent-foreground', label: 'Accent Foreground', value: tintForeground },
        { key: '--muted', label: 'Muted', value: tintChannels },
        { key: '--muted-foreground', label: 'Muted Foreground', value: mutedForeground },
    ];
}

/** Clipboard write with a textarea fallback for non-secure contexts. */
async function copyText(text) {
    try {
        if (navigator.clipboard && window.isSecureContext) {
            await navigator.clipboard.writeText(text);
            return true;
        }
    } catch {
        /* fall through to legacy path */
    }
    try {
        const ta = document.createElement('textarea');
        ta.value = text;
        ta.setAttribute('readonly', '');
        ta.style.position = 'absolute';
        ta.style.left = '-9999px';
        document.body.appendChild(ta);
        ta.select();
        const ok = document.execCommand('copy');
        ta.remove();
        return ok;
    } catch {
        return false;
    }
}

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.0.4
Status
Stable
License
open
Deprecation
Not deprecated
Contract
v1
Foundation
≥ 1.0.0