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.
Preview
@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>
/**
* 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
php artisan ui:add blocks/color-palette-generator
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
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.
# 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
Usage
Render the block as a component, passing data where useful:
<x-blocks.color-palette-generator />
Built from primitives
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.
@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>
/**
* 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