@ -8,6 +8,9 @@
import { PickerShell } from '$uix/eidos/components/picker-shell';
import { Tabs } from '$uix/eidos/components/tabs';
import { ColorField } from '$uix/eidos/components/color-field';
import { Button } from '$uix/eidos/components/button';
import { PALETTE_SCALES } from '$uix/eidos/lib/types';
import { INTENTS } from '$uix/intent';
import { compileMorfo } from '$uix/morfo';
import { colorPickerMorfo } from '@/uix/morfo/components/color-picker';
import { colorFieldMorfo } from '@/uix/morfo/components/color-field';
@ -37,6 +40,36 @@
'#0f172a'
];
// Preset groups (Untitled-UI 4-section layout). Each role/scale entry carries
// the theme token to resolve into a concrete color the SwatchTrigger can
// display + commit.
const SYSTEM_ROLES = [
{ key : 'primary' , label : 'Primary' , token : '--primitive-primary-9' } ,
{ key : 'secondary' , label : 'Secondary' , token : '--primitive-secondary-9' } ,
{ key : 'tertiary' , label : 'Tertiary' , token : '--primitive-tertiary-9' }
];
const INTENT_ROLES = INTENTS.map((i) => ({
key: i,
label: i[0].toUpperCase() + i.slice(1),
token: `--primitive-${ i } -9`
}));
const PALETTE_ROLES = PALETTE_SCALES.map((s) => ({
key: s,
label: s[0].toUpperCase() + s.slice(1),
token: `--scale-${ s } -9`
}));
const presetTokens = [...SYSTEM_ROLES, ...INTENT_ROLES, ...PALETTE_ROLES].map((r) => r.token);
// In real use `savedColors` is a consumer-supplied prop; the demo seeds a few.
let savedColors = $state< string [ ] > (['#1e90ff', '#ea580c', '#12a594', '#e5484d']);
const SAVED_COLORS_MAX = 10;
const PRESET_GROUPS = [
{ key : 'system' , label : 'Sistema' , aria : 'System colors' , items : SYSTEM_ROLES , wide : false } ,
{ key : 'intent' , label : 'Intent' , aria : 'Intent colors' , items : INTENT_ROLES , wide : false } ,
{ key : 'palette' , label : 'Paleta' , aria : 'Palette scales' , items : PALETTE_ROLES , wide : true }
];
let tab = $state< Tab > ('live');
let trace = $state< TraceEntry [ ] > ([]);
let stageRef = $state< HTMLElement | null > (null);
@ -44,6 +77,18 @@
let value = $state< ColorValue | undefined > (brand);
let placeholder = $state< ColorValue > (brand);
let format = $state< ColorFormat > ('hex');
// "Guardar color" is enabled only when there's a current color that isn't
// already saved and the cap (10) hasn't been reached.
const canSaveColor = $derived.by(() => {
if (!value || savedColors.length >= SAVED_COLORS_MAX) return false;
const hex = value.hex.toLowerCase();
return !savedColors.some((c) => c.toLowerCase() === hex);
});
function saveColor(): void {
if (!value || !canSaveColor) return;
savedColors = [...savedColors, value.hex];
}
let autoFormat = $state(true);
let showValueText = $state(true);
// Composition toggles — pure { #if } branches in the live markup. Per the
@ -152,6 +197,45 @@
return () => obs.disconnect();
});
// SwatchTrigger commits a parsed ColorValue, so a raw `var(--token)` can't be
// passed — resolve each token to its computed rgb() via a hidden probe. Re-run
// when :root theme attrs change so System/Intent swatches follow retints
// (palette scales are theme-independent but re-resolve cheaply too).
let resolvedTokens = $state< Record < string , string > >({} );
$effect(() => {
const doc = uix.dom?.getDocument?.() ?? document;
const win = uix.dom?.getWindow?.() ?? window;
if (!doc?.body) return;
const probe = doc.createElement('span');
probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none';
doc.body.appendChild(probe);
const reprobe = () => {
const next: Record< string , string > = {} ;
for (const token of presetTokens) {
// Scale tokens resolve to an oklch() sibling the color parser can't read.
// color-mix(in srgb) coerces to the sRGB gamut; serialize to rgb() so the
// SwatchTrigger can both display AND commit (parse) the value.
probe.style.color = `color-mix(in srgb, var(${ token } ), var(${ token } ))`;
const computed = win.getComputedStyle(probe).color;
const srgb = computed.match(/srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
next[token] = srgb
? `rgb(${ Math . round ( + srgb [ 1 ] * 255 )} , ${ Math . round ( + srgb [ 2 ] * 255 )} , ${ Math . round ( + srgb [ 3 ] * 255 )} )`
: computed;
}
resolvedTokens = next;
};
reprobe();
const themeObs = new MutationObserver(reprobe);
themeObs.observe(doc.documentElement, {
attributes: true,
attributeFilter: ['class', 'style', 'data-theme', 'data-uix-theme']
});
return () => {
themeObs.disconnect();
probe.remove();
};
});
const valueLabel = $derived(value?.hex ?? 'empty');
const somaSnippet = $derived(
@ -344,7 +428,7 @@
< Tabs
variant="pills"
fitted
size= { size }
{ size }
bind:value={ pickerTab }
style="--tabs-trigger-font-size: var(--font-size-{ size } )"
>
@ -363,9 +447,9 @@
< div class = "cp-tools-row" >
{ #if showEyeDropper }
< ColorPicker.EyeDropper
aria-label="Pick color from screen"
style="font-size: var(--icon-size-{ size } )"
/>
aria-label="Pick color from screen"
style="font-size: var(--icon-size-{ size } )"
/>
{ /if }
< div class = "cp-sliders" >
{ #if showHue }
@ -376,52 +460,88 @@
{ /if }
< / div >
< / div >
{ #if showChannelInput }
<!-- Value input = the REAL ColorField (its own provider, bound to the
picker's value + format), no swatch. Brings the system-Select format
switcher + separator/glyph styling instead of re-implementing them. -->
< ColorField
bind:value
bind:format
{ allowedFormats }
{ enableAlpha }
{ disabled }
{ readonly }
{ size }
>
< ColorField.Input aria-label = "Color value" swatch >
{ # snippet children ({ segments })}
{ #each segments as { part , value }, i ( `$ { part } - $ { i } `) }
< ColorField.Segment { part } > { value } </ ColorField.Segment >
{ /each }
{ #if showFormatSelect } < ColorField.FormatSelect /> { /if }
{ /snippet }
< / ColorField.Input >
< / ColorField >
{ /if }
< / div >
< / Tabs.Content >
< Tabs.Content value = "presets" >
{ #if showSwatches }
< ColorPicker.SwatchGroup aria-label = "Preset colors" >
{ #each presets as preset }
< ColorPicker.SwatchTrigger color = { preset } >
< ColorPicker.Swatch color = { preset } / >
< ColorPicker.SwatchIndicator / >
< / ColorPicker.SwatchTrigger >
< div class = "cp-presets" style = "font-size: var(--font-size- { size } )" >
{ #each PRESET_GROUPS as group ( group . key )}
< section class = "cp-preset-group" class:cp-preset-group--wide = { group . wide } >
< span class = "cp-preset-label" > { group . label } </ span >
< ColorPicker.SwatchGroup aria-label = { group . aria } >
{ #each group . items as role ( role . key )}
{ #if resolvedTokens [ role . token ]}
< ColorPicker.SwatchTrigger
color={ resolvedTokens [ role . token ]}
title={ role . label }
>
< ColorPicker.Swatch color = { resolvedTokens [ role . token ]} / >
< ColorPicker.SwatchIndicator / >
< / ColorPicker.SwatchTrigger >
{ /if }
{ /each }
< / ColorPicker.SwatchGroup >
< / section >
{ /each }
< / ColorPicker.SwatchGroup >
< section class = "cp-preset-group cp-preset-group--wide" >
< span class = "cp-preset-label" > Guardados< / span >
{ #if savedColors . length }
< ColorPicker.SwatchGroup aria-label = "Saved colors" >
{ #each savedColors as c ( c )}
< ColorPicker.SwatchTrigger color = { c } >
< ColorPicker.Swatch color = { c } / >
< ColorPicker.SwatchIndicator / >
< / ColorPicker.SwatchTrigger >
{ /each }
< / ColorPicker.SwatchGroup >
{ : else }
< p class = "cp-empty" > Sin colores guardados.< / p >
{ /if }
< / section >
< / div >
{ : else }
< p class = "cp-empty" > No presets configured.< / p >
{ /if }
< / Tabs.Content >
< / Tabs >
{ #if showChannelInput }
<!-- Value input lives in the main panel (outside the tabs) so the
selected color stays visible while picking a preset. -->
< ColorField
bind:value
bind:format
{ allowedFormats }
{ enableAlpha }
{ disabled }
{ readonly }
{ size }
>
< ColorField.Input aria-label = "Color value" swatch >
{ # snippet children ({ segments })}
{ #each segments as { part , value }, i ( `$ { part } - $ { i } `) }
< ColorField.Segment { part } > { value } </ ColorField.Segment >
{ /each }
{ #if showFormatSelect } < ColorField.FormatSelect /> { /if }
{ /snippet }
< / ColorField.Input >
< / ColorField >
{ /if }
< / PickerShell.Body >
{ #if showFooter && ( showClear || showCancel || showClose )}
{ #if showFooter }
< ColorPicker.Footer >
< Button
variant="ghost"
color="neutral"
{ size }
disabled={ ! canSaveColor }
onclick={ saveColor }
>
Guardar color
< / Button >
{ #if showClear } < ColorPicker.Clear /> { /if }
{ #if showCancel } < ColorPicker.Cancel /> { /if }
{ #if showClose } < ColorPicker.Close /> { /if }
@ -1348,6 +1468,8 @@
--tabs-pills-trigger-border-active: transparent;
--tabs-pills-trigger-shadow-active: none;
--tabs-pills-trigger-radius: var(--radius-sm);
/* Rail radius concentric with the pill: pill radius + the list inset padding. */
--tabs-list-radius: calc(var(--radius-sm) + var(--tabs-list-padding));
}
.cp-selection {
display: grid;
@ -1366,8 +1488,8 @@
block-size: auto;
inline-size: auto;
/* Outline style: visible border, transparent fill. The pipette icon is
`size="1em"`, so scale it via font-size to stay proportional to the
(taller) square button rather than the default ~1em . */
`size="1em"`; the EyeDropper sets font-size to var(--icon-size-{ size } )
inline so the glyph stays canonical for the picker size . */
border-color: var(--color-border-default);
background: transparent;
}
@ -1386,8 +1508,8 @@
margin-inline: auto;
--_slider-track-size: 12px;
}
/* Value input = the ColorField (no swatch ); fill the row. */
.cp-selection :global([data-color-field-input]) {
/* Value input = the ColorField in the main panel (below the tabs ); fill the row. */
:global([data-color-picker-content] [data-color-field-input]) {
inline-size: 100%;
flex-wrap: nowrap;
}
@ -1397,4 +1519,42 @@
text-align: center;
padding-block: var(--uix-space-4, 1rem);
}
/* Presets tab: four labeled groups (Sistema / Intent / Paleta / Guardados). */
.cp-presets {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--uix-space-3, 0.75rem);
/* Bottom padding so the Presets tab matches the Selección tab height (no jump). */
padding-block-end: var(--uix-space-2, 0.5rem);
}
/* Sistema + Intent share the top row (2 columns); Paleta + Guardados span full. */
.cp-preset-group--wide {
grid-column: 1 / -1;
}
.cp-preset-group {
display: grid;
gap: var(--uix-space-1-5, 0.375rem);
}
.cp-preset-label {
/* §618 — label one typographic step below the input (1em = picker font). */
font-size: calc(1em - (var(--font-size-md) - var(--font-size-sm)));
font-weight: var(--font-weight-regular);
color: var(--uix-text-faint);
}
/* Palette ships 31 scales — wrap each group's row and keep every swatch at its
natural size (the recipe default flex grow/shrink squashes 31 into one row). */
/* Pair groups (Sistema/Intent) keep one content-sized row; only the wide groups
(Paleta/Guardados) wrap across rows. */
.cp-preset-group:not(.cp-preset-group--wide) :global([data-color-picker-swatch-group]) {
inline-size: auto;
}
.cp-preset-group--wide :global([data-color-picker-swatch-group]) {
flex-wrap: wrap;
}
.cp-presets :global([data-color-picker-swatch-trigger]) {
flex: 0 0 var(--_color-picker-swatch-size);
/* Presets are opaque — drop the recipe's transparency checker + border. */
background-image: none;
border: none;
}
< / style >