feat(uix): ColorPicker presets 4-group + save-color button (demo)

Presets tab reorganized into 4 labeled groups: Sistema (3 roles) + Intent
(6, incl. neutral) side by side, Paleta (31 scales) + Guardados (savedColors)
full width. Role/scale tokens resolved to rgb() via a color-mix probe so the
SwatchTrigger can commit on click. Labels follow the field canon (one step
below the input, regular weight). Preset swatches drop the recipe checker +
border (opaque). Bottom padding equalizes the two tabs' height.

A "Guardar color" button in the footer (ghost/neutral, matching Borrar/
Cancelar) saves the current value to Guardados: enabled only when a color is
selected, not already saved, and under the 10-color cap.

KNOWN ISSUE: the footer overflows with 4 buttons (Listo clipped) -- the save
button needs to become icon-only. See color-picker/CONTINUE.md.
active-uix
dev 3 months ago
parent 151873d795
commit b2ca998985

@ -3,6 +3,48 @@
Rediseño del ColorPicker al estilo Untitled-UI. Estado al cierre de la sesión
**2026-06-28** (continuar 2026-06-29).
## Sesión #2 — presets 4-grupos + botón Guardar (en progreso)
**Hecho** (demo `web/routes/uix/components/color-picker/+page.svelte`):
- **Presets en 4 grupos**: Sistema (3 roles `--primitive-{role}-9`) | Intent (6,
incl. neutral, `--primitive-{intent}-9`) en **2 columnas misma fila**
(`grid-template-columns: auto 1fr`; los swatch-groups del par van
`inline-size: auto` + nowrap para no partirse); Paleta (31 `--scale-{name}-9`,
full width, wrap) + Guardados (prop `savedColors`) full width.
- **Color resuelto a `rgb()`**: el probe usa
`color-mix(in srgb, var(--token), var(--token))` → `color(srgb …)` → parseado a
`rgb()`. El SwatchTrigger NECESITA color parseable para commitear al clicar;
`var()` / `oklch()` / `color(srgb)` NO commitean. Reactivo al tema
(MutationObserver en `:root`).
- **Labels §618**: `calc(1em - (var(--font-size-md) - var(--font-size-sm)))` (un
paso bajo el input; escala porque `.cp-presets` lleva
`style="font-size: var(--font-size-{size})"`), `font-weight:
var(--font-weight-regular)`.
- **Swatches sin checker ni borde** en presets (opacos):
`background-image: none; border: none`.
- **Padding-bottom** en `.cp-presets` (`--uix-space-2`) para igualar altura con la
tab Selección (no salta al cambiar de tab).
- **Botón "Guardar color" en el FOOTER**: `<Button variant="ghost" color="neutral"
size={size}>` = mismo estilo que Borrar/Cancelar. `canSaveColor` = hay color +
no está ya guardado + `savedColors.length < 10`; `saveColor()` añade
`value.hex`. La condición del footer pasó a `{#if showFooter}` (siempre con el
save).
**PENDIENTE — empieza por aquí**:
- ⚠️ **El footer DESBORDA con 4 botones**: "Guardar color" (texto largo) + Borrar +
Cancelar + Listo NO caben en el ancho del popover (~344px) → Listo se sale
(medido: Listo `right=622` vs content `right=542`). **FIX**: convertir "Guardar
color" en **botón de icono** (`iconOnly` + un icono plus/save de
`src/uix/eidos/components/icon/lib/` — p. ej. `plus.svelte` / `save.svelte` /
`bookmark-plus.svelte` —, `aria-label="Guardar color"`). Me cortaron mientras
elegía el icono.
- Verificar el cap-10 llegando a 10 (lógica OK por el `$derived`, no probado al límite).
- `npm run check`: 0 errores míos. La captura se cuelga por el `backdrop-filter`
del popover → desactivarlo via eval (`el.style.backdropFilter='none'`) antes de
`preview_screenshot`.
## Hecho
**ColorField** (commit `5974be26`, ya cerrado): separadores verticales por canal

@ -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>

Loading…
Cancel
Save

Powered by TurnKey Linux.