audit(uix): characterize ColorPicker and deduplicate bindings

astra
dev 2 weeks ago
parent 1ba41e3254
commit a8be47319d

@ -1171,6 +1171,34 @@ pasa 511/511 archivos y 5622/5622 tests; src/scripts sin errores nuevos, 89 erro
de web dentro del ledger, 898 documentos y 25 arts sin incidencias, smoke
apps/base 27/27 sin errores de página, consola ni CSP.
### ColorPicker: formatos, canales y swatches (2026-09-27)
La [ficha de ColorPicker](./audit-uix-2026-09-19/color-picker.md) relee las
cuatro capas, pre-flight, receta, historia y Air. Distingue 21 partes Morfo,
23 wrappers locales Soma, 24 Eidos y 34 miembros attached. Consulta cuatro
bibliotecas primarias, MDN y APG; concilia comparaciones desfasadas.
Once canarios nativos y las suites de provider/opts pasan 25/25 en tres archivos.
Seis sondas únicas fallan en otro y registran Q94–Q99 para reparación posterior;
la de formato requiere alternancia con autoFormat, no el cambio externo aislado.
Siete bolsas props→opts conservan por AST 22 conexiones y sus templates y
reducen siete líneas físicas/no vacías. Después se mantienen los 25 positivos
y los seis fallos; dos sondas nuevas antes de step confirman Q100 (swatch por
defecto transparente) y Q101 (estado visual de drag ausente), sin tocar sus
rutas. El switch de step pasa a una expresión idéntica en quince casos y resta
otras doce líneas: balance de ocho archivos ejecutables, 19 líneas menos.
Tras step pasan los mismos 25 positivos y fallan las ocho sondas, retiradas con
fuentes/logs/artefactos. Visual: 11/11 y dos repeticiones con cuatro capturas
inspeccionadas. La vertical revela Q102, confirmado por pseudo-elementos de
6×160px frente a 320×6px; CSS intacto y novena sonda retirada. Contratos 104/104,
899 documentos sin incidencias. La fixture corrige sólo la ubicación de su
snippet auxiliar body (no es prop pública) y pasa de nuevo 25/25. El gate
completo repetido pasa 512/512 archivos y 5633/5633 tests, src/scripts sin errores
nuevos, 89 errores de web dentro del ledger y smoke apps/base 27/27 sin errores.
El inventario alcanza 73 fichas revisadas;
fase 6 abierta. component-audit conserva su baseline NEEDS-WORK por acciones
frente a eventos y la demo congelada; no se altera web ni el sistema probado.
### Revisión de Chronos (2026-09-22)
El provider contiene 1.723 líneas físicas no vacías, pero su volumen no procede

@ -0,0 +1,134 @@
---
title: UIX — auditoría de ColorPicker
type: notes
audience: human + agent
authority: process — caracterización y extracción neutral del contrato existente
status: reviewed
---
# ColorPicker
Fecha: 2026-09-27. Rama: astra. Fase 6 abierta. Se conservan comportamiento,
API, geometría y CSS; web queda excluido. Las reparaciones se difieren.
## Corpus y procedencia
Se releen completos los cuatro pre-flight canónicos, Morfo, el provider Soma
(1403 líneas físicas), sus tests, tipos, exports, 23 wrappers locales Soma,
24 locales Eidos, contexto, CSS, spectrum, Sema, traducciones y receta de
71 claves públicas más el forward de paleta. Se contrastan las fichas históricas
de julio y agosto y los 16 archivos ColorPicker de Air en morfo-runtime.
Las medidas históricas de theming conservan su fecha, no se repiten ni se
presentan como cobertura nueva. ColorField/Popover/Slider se leen sólo como
composiciones necesarias: no se declara una nueva auditoría íntegra de ellos.
El censo distingue 21 partes Morfo y 34 miembros attached Eidos, incluidos los
delegados de Slider, Popover y PickerShell. No compara recuentos de unidades
distintas como porcentajes de cobertura. La base privada de diez partes Soma
ya extraída el 22 de septiembre (19 líneas no vacías menos) no se cuenta otra vez.
L-152 conserva identidad para los bindings ref; este lote no lo repara.
## Matriz primaria y decisión
| Fuente | Contrato contrastado | Decisión UIX |
| ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| [Ark UI](https://ark-ui.com/docs/components/color-picker) | Área configurable, sliders por canal, value/open controlados y formulario. | Conservar el modelo 2D y retirar la comparación que fijaba sus ejes. |
| [Chakra UI](https://chakra-ui.com/docs/components/color-picker) | Sliders RGB/alpha, swatches y composición controlada. | Retirar la afirmación de que no ofrece RGB. |
| [React Aria ColorArea](https://react-aria.adobe.com/ColorArea) y [hook](https://react-aria.adobe.com/ColorArea/useColorArea) | Ejes configurables y composición accesible de dos canales. | Mantener el área 2D; adjudicar ARIA aparte, sin convertirla en dos sliders. |
| [Mantine](https://mantine.dev/core/color-picker/) | Formatos de string, swatches, tallas y teclas nativas. | Comparar contratos sin inferir carencias por ejemplos. |
| [MDN EyeDropper](https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper_API) | API con soporte limitado, gesto de usuario y contexto seguro. | No sustituir ni simular captura física de pantalla. |
Consulta del 2026-09-27. APG [slider](https://www.w3.org/WAI/ARIA/apg/patterns/slider/)
y [radio](https://www.w3.org/WAI/ARIA/apg/patterns/radio/) fundamentan la
adjudicación de valores numéricos y navegación/foco; no amplían la API.
La separación entre formato externo y formato de edición es un contrato local,
no una afirmación de exclusividad mundial. No se adopta una biblioteca nueva.
## Caracterización
Once canarios Eidos montan ActiveUix, Soma y ActiveEidos reales: ocho teclas del
área, hue/alpha/RGB, drag con captura real y un commit, ejes RGB sin pérdida,
RTL y rampas, formatos/autoFormat, presets, guardado/borrado/cap, locks,
Popover modal/Escape/foco y talla/variante/checker/formato reactivos.
Se esperan dos frames nativos tras montaje por autofocus. No se sustituyen
servicios ni pointer capture. No se invoca la captura física EyeDropper.
La primera ejecución aislada da diez canarios correctos y uno fallido más
cinco sondas negativas. Se separa el fallo de display del select del resto del
contrato de formatos. La actualización externa aislada sí pasa; la secuencia
con autoFormat, HSL, RGB y cambio externo HSL reproduce la discrepancia Q99.
La baseline del árbol mantenido pasa 25/25 positivos en tres archivos (11
canarios, siete tests del provider y siete de opts) y falla las seis sondas
únicas en otro archivo. El fallo esperado de esas sondas no es un gate verde.
## Defectos diferidos
| ID | Evidencia | Límite / prueba de cierre |
| --- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Q94 | name pasado por rest no llega al provider; HiddenInput no aporta brandColor a FormData. | Adjudicar API pública de name y cableado; probar formatos, reset, disabled y FormData. |
| Q95 | AreaThumb role=slider expone valuetext pero carece de now/min/max numéricos. | DOM confirmado; adjudicar representación 2D y probar tecnología de asistencia. |
| Q96 | ArrowRight tras seleccionar rojo no mueve foco ni selecciona verde en Presets. | Clic y teclado nativos; cubrir roving, Home/End, RTL y SavedSwatches. |
| Q97 | Preset verde tiene aria-checked=true pero su indicador computa display:none. | DOM/CSS reales; conectar selección y probar presets/saved/parte independiente. |
| Q98 | Bajo readonly Save sigue habilitado pero el clic no muta la colección ni llama al callback. | Discrepancia de affordance, no escritura indebida; adjudicar disabled/readonly y Save/Delete. |
| Q99 | Tras alternar con autoFormat, cambio externo HSL actualiza segmentos pero select.value queda rgb. | Secuencia nativa confirmada esperando actualización; probar select, segmentos, callbacks y flags. |
Las sondas añadidas después de bindProps confirman otros dos defectos: Q100,
SwatchTrigger por defecto selecciona verde pero su Swatch queda transparente;
Q101, hay captura y commit nativos pero el thumb nunca expone data-dragging.
Sus rutas de provider y templates no se modifican por este lote. La ejecución
ampliada mantiene 25 positivos y ocho sondas negativas en cuatro archivos.
No se presenta esta ampliación como una medición anterior a bindProps.
## Extracción y validación
Se sustituyen sólo siete bolsas props→opts por el bindProps existente: Area
(cuatro conexiones), ValueText, TransparencyGrid, SwatchGroup, SwatchTrigger,
Swatch y EyeDropper (tres cada una). La prueba AST conserva las 22 conexiones,
su orden, getters/setters y los siete templates byte a byte. Cada wrapper pierde
una línea física y no vacía: siete menos. Root y Trigger conservan las bolsas
que coordinan varios providers; no se unifican políticas de composición.
Después de bindProps pasan los mismos 25/25 positivos y fallan las seis sondas
originales. Antes de simplificar step se añaden las dos sondas descritas: 25
positivos y ocho fallos esperados. El switch de ChannelSlider devuelve 0.01 para
alpha y 1 en cualquier otra rama; se reduce a una expresión derived. Object.is
conserva quince casos (siete canales, fallback y valores runtime ajenos al tipo).
Pasa de 104 a 92 líneas físicas y 96 a 84 no vacías. Balance de ocho archivos
ejecutables: 19 líneas físicas/no vacías menos. Se precisa el comentario de
unidades; no cambian pasos ni Shift. Después de step vuelven a pasar 25/25
positivos y fallan las mismas ocho sondas únicas. Sus fuentes, logs y artefactos
se retiran a tmp/color-picker-audit-evidence-2026-09-27.
Visual nativo: once canarios pasan y dos seleccionados se repiten para capturas
adicionales. Se inspeccionan cuatro imágenes a escala nativa: surface RTL,
ghost lg con checker/formato actualizados, RGB vertical y contenido modal con
Footer. No hay comparación pixel baseline. La imagen vertical revela Q102:
pista de 6×160px frente a checker de 320×6px. Una sonda de pseudo-elementos en
Chromium lo confirma; CSS intacto. Se retira esa novena sonda y se conserva en
la misma carpeta, subdirectorio vertical-visual. Cierre: adjudicar geometría del
checker horizontal/vertical, tallas y RTL, sin alterar el gradiente ni valores.
No se afirma que la composición visual esté libre de defectos.
Los contratos pasan 104/104 en cuatro archivos. docs-check cierra 899 documentos
sin incidencias tras precisar que el recuento pertenece a partes ColorPicker,
no al catálogo completo. El comparador de referencias requiere filas de tabla;
se conserva una matriz primaria de cuatro fuentes en Eidos además de Soma.
El primer gate supera lint y se detiene en check:gate por una única deuda nueva
de la fixture: body declarado dentro del componente se convierte en prop que
ColorPickerProps no admite. El diagnóstico completo confirma 90 errores frente
a los 89 del ledger. Se mueve la declaración auxiliar fuera del componente;
no se añade esa prop ni se toca producción. La fixture corregida pasa 25/25
pruebas en tres archivos. El gate completo repetido pasa 512/512 archivos y
5633/5633 tests: src/scripts sin errores nuevos, los 89 errores de web dentro
del ledger, 899 documentos y 25 arts sin incidencias, smoke apps/base 27/27
sin errores de página, consola ni CSP.
component-audit conserva
NEEDS-WORK: A-3.7 cuenta ocho acciones de teclado frente a cuatro eventos y
D-1.2 detecta la demo congelada. No se inventan eventos ni se cambia web.
## Límites
Chromium no acredita lectores de pantalla, touch físico, otros motores ni
EyeDropper físico. No se reejecuta toda la medición de tokens de agosto.
Las nueve reproducciones negativas quedan retiradas de la suite mantenida con
fuentes, logs y artefactos en tmp; se convertirán en pruebas de cierre al reparar.

@ -176,10 +176,19 @@ Lotes posteriores de ciclo de vida de MediaPlayer:
Lotes posteriores de los controles MediaPlayer usados por AudioPlayer:
| Lote posterior | ID | Condición y prueba de cierre |
| --------------------------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Nombres de tiempo y volumen | Q86 | Nombrar los thumbs de TimeSlider con y sin peaks y de VolumeSlider; cubrir traducciones, teclado y composición independiente del skin. |
| Texto accesible del tiempo | Q87 | Adjudicar y conectar el formato temporal en ambos scrubbers, conservar dominio numérico y probar cambios de tiempo/duración e idiomas con tecnología de asistencia. |
| Lote posterior | ID | Condición y prueba de cierre |
| ---------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Nombres de tiempo y volumen | Q86 | Nombrar los thumbs de TimeSlider con y sin peaks y de VolumeSlider; cubrir traducciones, teclado y composición independiente del skin. |
| Texto accesible del tiempo | Q87 | Adjudicar y conectar el formato temporal en ambos scrubbers, conservar dominio numérico y probar cambios de tiempo/duración e idiomas con tecnología de asistencia. |
| Formulario ColorPicker | Q94 | Adjudicar y conectar name; probar FormData, formatos, reset y disabled. |
| ARIA numérica del área | Q95 | Adjudicar la superficie accesible 2D y comprobarla con tecnología de asistencia. |
| Navegación de swatches | Q96 | Cubrir flechas, foco, roving, Home/End, RTL y ambas colecciones. |
| Indicador seleccionado | Q97 | Conectar estado checked, probar presets, guardados y partes independientes. |
| Affordance Save/Delete | Q98 | Adjudicar disabled/readonly, conservar guards y callbacks. |
| Display nativo de formato | Q99 | Cubrir alternancia autoFormat y cambios externos con select/segmentos sincronizados. |
| Color del swatch por defecto | Q100 | Reenviar o heredar color; probar ambas composiciones y cambios reactivos. |
| Estado visual de arrastre | Q101 | Conectar estado de drag y cubrir inicio/movimiento/up/cancel/locks/limpieza. |
| Checker vertical del canal | Q102 | Corregir geometría tras cubrir orientación, talla, RTL, alfa y gradiente. |
Al aparecer otro defecto durante la auditoría se añade aquí y en la tabla de
evidencia de abajo con un ID existente del ledger o un `Q*` nuevo, reproducción
@ -275,6 +284,43 @@ Ninguno entra en la cola de correcciones confirmadas por inferencia.
| Q73 | Cropper puntero | Down ID1 en Selection y move ID2 de 32px desplazan crop.x de 0.25 a 0.35; la sesión no conserva pointerId. | Chromium montado, eventos sintéticos y captura mock; sonda retirada | Cubrir selección, handle y pan; reproducir multi-touch nativo. |
| Q74 | Cropper metadatos | apg afirma keyboard nudge desde C5, pero Selection/Handle no tienen handler de teclado y README lo difiere. El campo de metadatos se conserva. | Discrepancia confirmada por lectura; efecto en consumidores de metadatos sin reproducir | Adjudicar y alinear la justificación; no implementar teclado por inferencia. |
### ColorPicker: Q94–Q102
Fecha: 2026-09-27. La [ficha de ColorPicker](./color-picker.md) conserva el
contrato existente. Baseline nativa: 25 positivos en tres archivos y seis
sondas negativas únicas en otro; no se arreglan durante la extracción.
| ID | Área | Evidencia reproducida | Límite | Prueba posterior |
| --- | --------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| Q94 | HiddenInput | FormData(form).get('brandColor') es null aunque name se pasa por rest. | name no se desestructura ni llama setName; adjudicar su API pública. | Cablear nombre y probar formatos, reset y locks. |
| Q95 | Área 2D / ARIA | role=slider con valuetext sin aria-valuenow/min/max. | DOM real; no se ha probado lector de pantalla. | Adjudicar representación 2D y probar asistencia. |
| Q96 | Presets / radio | Tras clic en rojo, ArrowRight deja foco en rojo y no selecciona verde. | Teclado nativo; no generalizar aún a todos los grupos. | Roving/flechas, Home/End, RTL, presets y guardados. |
| Q97 | Indicador de swatch | Verde aria-checked=true pero indicador display:none. | DOM/CSS real; no hay una fuente checked propia del indicador. | Conectar selección y comprobar las composiciones. |
| Q98 | SaveAction / readonly | Save sigue habilitado; clic no escribe ni llama al callback. | El guard de mutación sí funciona; discrepancia de affordance. | Adjudicar readonly/disabled para Save y Delete. |
| Q99 | FormatSelect | Con autoFormat, HSL→RGB y externo HSL, segmentos cambian a HSL pero select.value sigue rgb. | La actualización externa aislada sí pasa; reproducción con espera. | Sincronizar display con segmentos, preservar callbacks y separación de formatos. |
Las primeras cinco sondas se reproducen en la ejecución aislada y en el árbol
mantenido; Q99 requiere la secuencia completa tras activar autoFormat. No se
presenta como un fallo universal del binding externo. L-152 mantiene su ID.
Q100 y Q101 se añaden tras bindProps, antes de step: la ejecución ampliada
mantiene 25 positivos y reproduce ocho fallos únicos. Q100: un SwatchTrigger
por defecto elige verde y queda checked, pero su Swatch computa fondo transparente;
Soma no hereda color y Eidos no lo reenvía al hijo. Q101: drag nativo acredita
captura y un commit, mientras el thumb nunca expone data-dragging; su estado
isDragging se inicializa false y no recibe escrituras. No se afirma ausencia
de drag ni de commit. Cierre: color heredado/composición explícita y estados
inicio/movimiento/up/cancel, locks, RTL y limpieza. Esas rutas del provider y
los templates permanecen intactos. Después de step se mantienen los 25 positivos
y los ocho fallos esperados. Las fuentes, logs y artefactos se retiran a
tmp/color-picker-audit-evidence-2026-09-27.
Q102 se detecta al inspeccionar la captura RGB vertical y se confirma por
pseudo-elementos en Chromium: pista ::before 6×160px, checker ::after 320×6px.
La regla checker fija geometría horizontal para toda orientación. CSS intacto,
valores y teclado del slider sí pasan; no es un fallo del motor de color.
La novena sonda se retira con su log y screenshot en vertical-visual. Cierre:
geometría horizontal/vertical, tallas/RTL, alfa y gradiente sin pérdidas.
### FloatPanel: Q90–Q93
Reproducción del 2026-09-27 en Chromium, con ActiveUix, Soma y ActiveEidos

@ -13,7 +13,7 @@ color-picker/`) ya implementaba una superficie de color-picker con
Provider · Trigger · Content · Area + AreaThumb · HueSlider ·
AlphaSlider · Input · Swatches/Swatch · EyeDropper. Esta receta sigue
las mismas líneas pero monta sobre la implementación Soma actual, que
es más rica: 23 partes públicas (incluyendo `ValueSwatch`, `ValueText`,
tiene 24 wrappers locales y 34 miembros attached (incluyendo `ValueSwatch`, `ValueText`,
`TransparencyGrid`, `SwatchIndicator`, control sliders por canal RGB
además del par HSV, `ChannelInput`+`ChannelSegment`+`FormatSelect`
compuestos desde `ColorField`).
@ -116,22 +116,25 @@ puede escribir, y el guard R-5.4 comprueba que ninguna miente.
## Comparativa
| Capacidad | Soma/Eidos | Air (morfo-runtime) | Ark UI | Chakra UI |
| --------------------------------- | ------------------------------------ | ------------------- | ------------ | ------------ |
| 2D area (sat/bright) | Sí | Sí | Sí | Sí |
| Hue slider | Sí (`ChannelSlider channel='hue'`) | Sí | Sí | Sí |
| Alpha slider | Sí (`ChannelSlider channel='alpha'`) | Sí | Sí | Sí |
| RGB sliders | Sí (red/green/blue channels) | No | No | No |
| Saturation/brightness sliders | Sí | No | No | No |
| Channel input (segmented) | Sí (compone ColorField) | Input simple | Input simple | Input simple |
| Format switch (hex/rgb/hsl) | Sí (`FormatSelect`) | Sí | Sí | Sí |
| `allowedFormats` whitelist | Sí | No | No | No |
| Eye dropper | Sí (native API) | Sí | Sí | Sí |
| Preset swatches | Sí (`SwatchGroup`) | Sí | Sí | Sí |
| Hidden input para forms | Sí | No | Via picker | Via picker |
| `closeOnSelect` | Sí | No | Sí | Sí |
| Inline mode | Sí (`inline={true}`) | No | Sí | Sí |
| Variante / tamaño / paleta visual | Sí, Eidos | CSS air-specific | CSS externo | CSS externo |
| Fuente primaria | Contrato contrastado | Decisión |
| --------------------------------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------- |
| [Ark UI](https://ark-ui.com/docs/components/color-picker) | Ejes configurables y sliders por canal. | Conservar los ejes actuales; no tratarlos como ventaja exclusiva. |
| [Chakra UI](https://chakra-ui.com/docs/components/color-picker) | Sliders RGB/alpha y swatches. | Retirar la comparación que negaba RGB. |
| [React Aria](https://react-aria.adobe.com/ColorArea) | Área configurable y composición accesible. | Adjudicar ARIA 2D por separado. |
| [Mantine](https://mantine.dev/core/color-picker/) | Formatos, swatches, tallas y teclado. | Comparar contratos sin portar funcionalidad. |
Consulta primaria del 2026-09-27. Ark y Chakra documentan sliders RGB y ejes
configurables; las anteriores celdas "No" no describían su API vigente. La
[matriz Soma](../../../soma/components/color-picker/README.md#comparison-with-reference-libraries)
recoge contratos y decisiones. UIX conserva su segmentación, separación de
formatos y composición de Popover; no introduce funciones de esas bibliotecas.
El censo distingue 21 partes Morfo, 23 wrappers locales Soma, 24 locales Eidos
y 34 miembros attached, incluyendo delegados de Slider, Popover y PickerShell.
HiddenInput requiere adjudicar su conexión de nombre (Q94). Los presets tienen
estado radio comprobado por clic, con navegación por flechas (Q96) e indicador
seleccionado (Q97) pendientes de corrección. Las medidas de theming de agosto
anteriores conservan su fecha y no representan una medición nueva de este lote.
## Decisiones

@ -0,0 +1,263 @@
import { beforeAll, expect, it, vi } from 'vitest';
import { page, userEvent } from 'vitest/browser';
import { renderColorPicker } from './test/color-picker-audit-view';
beforeAll(async () => {
await page.viewport(1100, 1000);
await userEvent.keyboard('{Shift}');
}, 60_000);
it('edits both area axes with native keys, preserving hue and alpha', async () => {
const v = renderColorPicker();
try {
await v.ready();
const thumb = v.part('area-thumb');
thumb.focus();
await userEvent.keyboard('{ArrowRight}{Shift>}{ArrowUp}{/Shift}');
expect(v.value()?.hsv).toEqual({ h: 210, s: 51, v: 70, a: 0.8 });
expect(v.onValueChangeEnd).toHaveBeenCalledTimes(2);
await userEvent.keyboard('{Home}{End}{PageDown}{PageUp}');
expect(v.value()?.hsv.s).toBe(100);
expect(v.value()?.hsv.v).toBe(100);
expect(thumb.getAttribute('aria-valuetext')).toBe('saturation: 100, brightness: 100');
} finally {
await v.dispose();
}
}, 30_000);
it('mirrors the area and horizontal channel ramp reactively in RTL', async () => {
const v = renderColorPicker();
try {
await v.ready();
await userEvent.click(v.button('Toggle direction'));
expect(v.root.dir).toBe('rtl');
const thumb = v.part('area-thumb');
thumb.focus();
await userEvent.keyboard('{ArrowRight}');
expect(v.value()?.hsv.s).toBe(49);
expect(parseFloat(thumb.style.left)).toBe(51);
expect(getComputedStyle(v.part('area-background'), '::before').backgroundImage).toContain(
'to left'
);
expect(
v.part('channel-slider').style.getPropertyValue('--_color-picker-channel-gradient')
).toContain('to left');
if (import.meta.env.VITE_UIX_AUDIT_SCREENSHOTS === '1')
await page.screenshot({
path: 'tmp/color-picker-rtl.png',
element: page.elementLocator(v.target)
});
} finally {
await v.dispose();
}
}, 30_000);
it('captures a native drag and commits once after the last continuous value', async () => {
const v = renderColorPicker();
try {
await v.ready();
const area = v.part('area');
let trusted = false;
let captured = false;
area.addEventListener('pointermove', (e) => {
trusted ||= e.isTrusted;
captured ||= area.hasPointerCapture(e.pointerId);
});
await userEvent.dragAndDrop(
v.part('area-thumb'),
v.target.querySelector('[data-destination]')!
);
expect(trusted).toBe(true);
expect(captured).toBe(true);
expect(v.value()?.hsv.s).toBe(0);
expect(v.value()?.hsv.v).toBe(0);
expect(v.onValueChange).toHaveBeenCalled();
expect(v.onValueChangeEnd).toHaveBeenCalledTimes(1);
expect(v.onValueChangeEnd).toHaveBeenLastCalledWith(v.value());
} finally {
await v.dispose();
}
}, 30_000);
it('changes area channels to RGB without a lossy HSV round trip', async () => {
const v = renderColorPicker();
try {
await v.ready();
const old = v.value()!.rgb;
await userEvent.click(v.button('RGB area'));
expect(v.part('area').dataset.xChannel).toBe('red');
const thumb = v.part('area-thumb');
thumb.focus();
await userEvent.keyboard('{ArrowRight}{ArrowUp}');
expect(v.value()?.rgb).toEqual({ r: old.r + 1, g: old.g + 1, b: old.b, a: old.a });
} finally {
await v.dispose();
}
}, 30_000);
it('composes native hue, alpha and reactive vertical RGB sliders', async () => {
const v = renderColorPicker();
try {
await v.ready();
const sliders = () =>
Array.from(document.querySelectorAll<HTMLElement>('[data-color-picker-channel-slider]'));
let thumb = sliders()[0].querySelector<HTMLElement>('[data-slider-thumb]')!;
thumb.focus();
await userEvent.keyboard('{ArrowRight}');
expect(v.value()?.hsv.h).toBe(211);
expect(thumb.getAttribute('aria-valuemin')).toBe('0');
expect(thumb.getAttribute('aria-valuemax')).toBe('360');
sliders()[1].querySelector<HTMLElement>('[data-slider-thumb]')!.focus();
await userEvent.keyboard('{Home}');
expect(v.value()?.rgb.a).toBe(0);
await userEvent.click(v.button('Red vertical'));
thumb = sliders()[0].querySelector<HTMLElement>('[data-slider-thumb]')!;
expect(thumb.getAttribute('aria-orientation')).toBe('vertical');
expect(sliders()[0].style.getPropertyValue('--_color-picker-channel-gradient')).toContain(
'to top'
);
thumb.focus();
await userEvent.keyboard('{End}');
expect(v.value()?.rgb.r).toBe(255);
if (import.meta.env.VITE_UIX_AUDIT_SCREENSHOTS === '1')
await page.screenshot({
path: 'tmp/color-picker-rgb-vertical.png',
element: page.elementLocator(v.target)
});
} finally {
await v.dispose();
}
}, 30_000);
it('keeps the edit format separate and propagates it only with autoFormat', async () => {
const v = renderColorPicker();
try {
await v.ready();
const select = v.part('format-select');
await userEvent.selectOptions(select, 'rgb');
expect(v.format()).toBe('hex');
expect(v.onFormatChange).not.toHaveBeenCalled();
expect(
v.part('channel-input').querySelectorAll('[data-color-picker-channel-segment]').length
).toBeGreaterThan(1);
await userEvent.click(v.button('Toggle auto format'));
await userEvent.selectOptions(select, 'hsl');
expect(v.format()).toBe('hsl');
expect(v.onFormatChange).toHaveBeenCalledTimes(1);
await userEvent.selectOptions(select, 'rgb');
await userEvent.click(v.button('External HSL'));
expect(v.format()).toBe('hsl');
expect(v.part('channel-input').dataset.format).toBe('hsl');
await userEvent.click(v.button('Lock format'));
expect(v.part('format-select').tagName).toBe('SPAN');
expect(v.part('format-select').hasAttribute('data-locked')).toBe(true);
} finally {
await v.dispose();
}
}, 30_000);
it('commits preset radios and updates checked state and composed swatch paint', async () => {
const v = renderColorPicker();
try {
await v.ready();
const radios = v.radios('Presets');
await userEvent.click(radios[1]);
expect(v.value()?.hex).toBe('#00ff00');
expect(radios[1].getAttribute('aria-checked')).toBe('true');
expect(radios[0].getAttribute('aria-checked')).toBe('false');
expect(v.onValueChangeEnd).toHaveBeenCalledTimes(1);
expect(radios[1].querySelector('[data-color-swatch]')).not.toBeNull();
} finally {
await v.dispose();
}
}, 30_000);
it('saves and deletes bound swatches, enforces the cap and avoids duplicates', async () => {
const v = renderColorPicker();
try {
await v.ready();
const initial = v.value()!.hex;
await userEvent.click(v.button('Save color'));
expect(v.saved()).toEqual(['#ff0000', initial]);
expect(v.onSavedColorsChange).toHaveBeenCalledTimes(1);
await userEvent.click(v.radios('Saved')[1]);
await userEvent.click(v.button('Delete color'));
expect(v.saved()).toEqual(['#ff0000']);
expect(v.onSavedColorsChange).toHaveBeenCalledTimes(2);
await userEvent.click(v.radios('Presets')[1]);
await userEvent.click(v.button('Save color'));
expect(v.saved()).toEqual(['#ff0000', '#00ff00']);
await userEvent.click(v.radios('Presets')[2]);
expect((v.button('Save color') as HTMLButtonElement).disabled).toBe(true);
expect(v.saved()).toHaveLength(2);
} finally {
await v.dispose();
}
}, 30_000);
it('locks native edits under readonly and disabled while retaining readouts', async () => {
const v = renderColorPicker();
try {
await v.ready();
const original = v.value();
await userEvent.click(v.button('Toggle readonly'));
v.part('area-thumb').focus();
await userEvent.keyboard('{ArrowRight}');
await userEvent.click(v.radios('Presets')[1]);
expect(v.value()).toEqual(original);
expect(v.onValueChangeEnd).not.toHaveBeenCalled();
await userEvent.click(v.button('Toggle disabled'));
expect(v.part('area-thumb').tabIndex).toBe(-1);
expect(v.radios('Presets')[0].disabled).toBe(true);
await expect.element(v.part('value-text')).toHaveTextContent(original!.hex);
} finally {
await v.dispose();
}
}, 30_000);
it('opens a native popover and restores focus and modal snapshot on Escape', async () => {
const v = renderColorPicker({ inline: false, mode: 'modal' });
try {
await v.ready();
const original = v.value();
const trigger = v.part('trigger');
trigger.focus();
await userEvent.keyboard('{Enter}');
await vi.waitFor(() => expect(v.open()).toBe(true));
await vi.waitFor(() => expect(v.part('area-thumb')).not.toBeNull());
await v.ready();
v.part('area-thumb').focus();
await userEvent.keyboard('{ArrowRight}');
expect(v.value()).not.toEqual(original);
expect(v.onValueChangeEnd).toHaveBeenCalledTimes(1);
if (import.meta.env.VITE_UIX_AUDIT_SCREENSHOTS === '1')
await page.screenshot({
path: 'tmp/color-picker-popup.png',
element: page.elementLocator(document.body)
});
await userEvent.keyboard('{Escape}');
await vi.waitFor(() => expect(v.open()).toBe(false));
expect(v.value()).toEqual(original);
await vi.waitFor(() => expect(document.activeElement).toBe(trigger));
} finally {
await v.dispose();
}
}, 30_000);
it('updates sizes, variants, format override and checker cells on the same root', async () => {
const v = renderColorPicker();
try {
await v.ready();
const root = v.root;
const trigger = v.part('trigger');
const medium = getComputedStyle(trigger).minHeight;
await userEvent.click(v.button('Next size'));
const small = getComputedStyle(trigger).minHeight;
expect(parseFloat(small)).toBeLessThan(parseFloat(medium));
await userEvent.click(v.button('Next size'));
expect(parseFloat(getComputedStyle(trigger).minHeight)).toBeGreaterThan(parseFloat(medium));
await userEvent.click(v.button('Next variant'));
expect(trigger.dataset.variant).toBe('outline');
await userEvent.click(v.button('Next variant'));
expect(trigger.dataset.variant).toBe('ghost');
expect(v.root).toBe(root);
await userEvent.click(v.button('Change displays'));
expect(v.part('transparency-grid').style.backgroundSize).toBe('24px 24px');
expect(v.target.querySelector('[data-override-display]')!.textContent).toContain('rgba(');
if (import.meta.env.VITE_UIX_AUDIT_SCREENSHOTS === '1')
await page.screenshot({
path: 'tmp/color-picker-displays.png',
element: page.elementLocator(v.target)
});
} finally {
await v.dispose();
}
}, 30_000);

@ -0,0 +1,158 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { setActiveUix, type ActiveUix } from '$active-uix';
import { Soma } from '$soma/core/soma.svelte';
import { ActiveEidos } from '$uix/eidos';
import { colorValueFromHsv, type ColorValue, type ColorFormat } from '$libs/color';
import { ColorPicker } from '../index';
import type { ColorChannel, ColorPickerSize, ColorPickerVariant } from '../types';
let {
uix,
inline = true,
mode = 'inline',
onValueChange,
onValueChangeEnd,
onFormatChange,
onOpenChange,
onSavedColorsChange
}: {
uix: ActiveUix;
inline?: boolean;
mode?: 'inline' | 'modal';
onValueChange: (v: ColorValue | undefined) => void;
onValueChangeEnd: (v: ColorValue | undefined) => void;
onFormatChange: (v: ColorFormat) => void;
onOpenChange: (v: boolean) => void;
onSavedColorsChange: (v: string[]) => void;
} = $props();
// svelte-ignore state_referenced_locally
setActiveUix(uix);
Soma.create();
const eidos = ActiveEidos.create({ applyDom: false });
onDestroy(() => eidos.dispose());
let value = $state<ColorValue | undefined>(colorValueFromHsv({ h: 210, s: 50, v: 60, a: 0.8 }));
let format = $state<ColorFormat>('hex');
let open = $state(false);
let savedColors = $state(['#ff0000']);
let dir = $state<'ltr' | 'rtl'>('ltr');
let size = $state<ColorPickerSize>('md');
let variant = $state<ColorPickerVariant>('surface');
let disabled = $state(false);
let readonly = $state(false);
let autoFormat = $state(false);
let allowedFormats = $state<ColorFormat[]>(['hex', 'rgb', 'hsl']);
let xChannel = $state<ColorChannel | undefined>();
let yChannel = $state<ColorChannel | undefined>();
let channel = $state<ColorChannel>('hue');
let orientation = $state<'horizontal' | 'vertical'>('horizontal');
let cellSize = $state(8);
let displayFormat = $state<ColorFormat | undefined>();
</script>
{#snippet body()}
<div data-audit-body style="width: 320px; display: grid; gap: 12px;">
<ColorPicker.Area {xChannel} {yChannel} />
<ColorPicker.ChannelSlider {channel} {orientation} />
<ColorPicker.ChannelSlider channel="alpha" />
<ColorPicker.ChannelInput aria-label="Color editor"
>{#snippet children({
segments
})}{#each segments as segment, i (`${segment.part}:${i}`)}<ColorPicker.ChannelSegment
part={segment.part}>{segment.value}</ColorPicker.ChannelSegment
>{/each}{/snippet}</ColorPicker.ChannelInput
>
<ColorPicker.FormatSelect />
<ColorPicker.ValueText format={displayFormat} data-override-display />
<ColorPicker.TransparencyGrid {cellSize} style="height: 20px;" />
<ColorPicker.Presets aria-label="Presets" />
<ColorPicker.SwatchGroup aria-label="Direct"
><ColorPicker.SwatchTrigger color="#00ff00" /></ColorPicker.SwatchGroup
>
<ColorPicker.SavedSwatches aria-label="Saved" />
<ColorPicker.SaveAction saveLabel="Save color" deleteLabel="Delete color" />
<ColorPicker.EyeDropper />
{#if !inline}<ColorPicker.Footer
><ColorPicker.Clear>Clear</ColorPicker.Clear><ColorPicker.Cancel>Cancel</ColorPicker.Cancel
><ColorPicker.Close>Done</ColorPicker.Close></ColorPicker.Footer
>{/if}
</div>
{/snippet}
<section style="width: 900px; padding: 16px; background: white; color: black;">
<h1>ColorPicker audit</h1>
<form onsubmit={(e) => e.preventDefault()}>
<ColorPicker
bind:value
bind:format
bind:open
bind:savedColors
{inline}
{mode}
{dir}
{size}
{variant}
{disabled}
{readonly}
{autoFormat}
{allowedFormats}
{onValueChange}
{onValueChangeEnd}
{onFormatChange}
{onOpenChange}
{onSavedColorsChange}
maxSavedColors={2}
presets={['#ff0000', '#00ff00', '#0000ff']}
{...{ name: 'brandColor' }}
>
<ColorPicker.Label>Accent</ColorPicker.Label>
<ColorPicker.Control
><ColorPicker.Trigger><ColorPicker.ValueText /></ColorPicker.Trigger
><ColorPicker.ValueSwatch /></ColorPicker.Control
>
<ColorPicker.HiddenInput />
{#if inline}{@render body()}{:else}<ColorPicker.Portal
><ColorPicker.Content motion="none">{@render body()}</ColorPicker.Content
></ColorPicker.Portal
>{/if}
</ColorPicker>
</form>
<button onclick={() => (dir = dir === 'ltr' ? 'rtl' : 'ltr')}>Toggle direction</button>
<button onclick={() => (size = size === 'md' ? 'sm' : size === 'sm' ? 'lg' : 'md')}
>Next size</button
>
<button
onclick={() =>
(variant = variant === 'surface' ? 'outline' : variant === 'outline' ? 'ghost' : 'surface')}
>Next variant</button
>
<button onclick={() => (disabled = !disabled)}>Toggle disabled</button><button
onclick={() => (readonly = !readonly)}>Toggle readonly</button
>
<button onclick={() => (autoFormat = !autoFormat)}>Toggle auto format</button><button
onclick={() => (format = 'hsl')}>External HSL</button
><button onclick={() => (allowedFormats = ['hex'])}>Lock format</button>
<button
onclick={() => {
xChannel = 'red';
yChannel = 'green';
}}>RGB area</button
>
<button
onclick={() => {
channel = 'red';
orientation = 'vertical';
}}>Red vertical</button
><button
onclick={() => {
cellSize = 12;
displayFormat = 'rgb';
}}>Change displays</button
>
<output data-value>{JSON.stringify(value)}</output><output data-format>{format}</output><output
data-open>{String(open)}</output
><output data-saved>{JSON.stringify(savedColors)}</output>
<div
data-destination
style="position: absolute; left: 0; top: 850px; width: 12px; height: 12px; background: gray;"
></div>
</section>

@ -0,0 +1,72 @@
import { flushSync, mount, unmount } from 'svelte';
import { expect, vi } from 'vitest';
import { createActiveUix } from '$active-uix';
import type { ColorValue } from '$libs/color';
import '../../../index.css';
import ColorPickerAuditHarness from './color-picker-audit-harness.svelte';
export function renderColorPicker(options: { inline?: boolean; mode?: 'inline' | 'modal' } = {}) {
const uix = createActiveUix({
langs: { schema: {}, defaultLocale: 'es' },
prefs: { storage: false },
projectPrefs: false
});
const target = document.createElement('div');
document.body.append(target);
const callbacks = {
onValueChange: vi.fn(),
onValueChangeEnd: vi.fn(),
onFormatChange: vi.fn(),
onOpenChange: vi.fn(),
onSavedColorsChange: vi.fn()
};
const component = mount(ColorPickerAuditHarness, {
target,
props: { uix, ...options, ...callbacks }
});
flushSync();
return {
target,
uix,
...callbacks,
get root() {
return target.querySelector<HTMLElement>('[data-color-picker]')!;
},
part(name: string) {
return document.querySelector<HTMLElement>(`[data-color-picker-${name}]`)!;
},
button(text: string) {
return Array.from(document.querySelectorAll('button')).find(
(b) => b.textContent?.trim() === text
)!;
},
value(): ColorValue | undefined {
return JSON.parse(target.querySelector('output[data-value]')!.textContent!);
},
format() {
return target.querySelector('output[data-format]')!.textContent!;
},
open() {
return target.querySelector('output[data-open]')!.textContent === 'true';
},
saved(): string[] {
return JSON.parse(target.querySelector('output[data-saved]')!.textContent!);
},
radios(label: string) {
return Array.from(
document
.querySelector(`[role="radiogroup"][aria-label="${label}"]`)!
.querySelectorAll<HTMLButtonElement>('[role="radio"]')
);
},
async ready() {
await new Promise<void>((r) => requestAnimationFrame(() => r()));
await new Promise<void>((r) => requestAnimationFrame(() => r()));
await vi.waitFor(() => expect(this.root).not.toBeNull());
},
async dispose() {
await unmount(component);
uix.dispose();
target.remove();
}
};
}

@ -1,6 +1,6 @@
# ColorPicker
Full-featured headless color picker — 22 parts, composes `Popover` + `ColorField`. Exposes a 2D saturation/brightness area, per-channel sliders (hue, alpha, or any RGB/HSV channel), the browser's native EyeDropper API, preset swatches, and the segmented `ColorField` as `ChannelInput`.
Full-featured headless color picker — 23 local wrappers plus composed parts, composes `Popover` + `ColorField`. Exposes a 2D saturation/brightness area, per-channel sliders (hue, alpha, or any RGB/HSV channel), the browser's native EyeDropper API, preset swatches, and the segmented `ColorField` as `ChannelInput`.
## Anatomy
@ -67,7 +67,7 @@ Full-featured headless color picker — 22 parts, composes `Popover` + `ColorFie
Set `inline` on `Provider` to skip the `Popover` and render the content surface directly — the picker then behaves as an always-open panel.
## Parts (22)
## Local and composed parts
| Part | Source | Notes |
| ------------------------------------- | ---------- | ----------------------------------------------------------------------------- |
@ -91,10 +91,10 @@ Set `inline` on `Provider` to skip the `Popover` and render the content surface
| `ChannelSegment` | ColorField | Re-export. Single segment of ChannelInput. |
| `SwatchGroup` | picker | `role="radiogroup"` of preset colors. |
| `SwatchTrigger` | picker | `role="radio"` focusable swatch (takes `color: string`). |
| `Swatch` | picker | Visual color block (takes `color` or inherits from `SwatchTrigger`). |
| `Swatch` | picker | Visual color block; pass `color` explicitly (inheritance is not implemented). |
| `SwatchIndicator` | picker | Indicator shown when `SwatchTrigger[data-checked]`. |
| `EyeDropper` | picker | Invokes the browser's native EyeDropper API. `data-unsupported` when missing. |
| `HiddenInput` | picker | Rendered form input (needs `name` on Provider via `restProps`). |
| `HiddenInput` | picker | Conditional form input; provider name wiring remains an audit defect (Q94). |
## Composition
@ -255,42 +255,25 @@ Wrapping in `Field.Provider` inherits its `disabled`/`readonly`/`required`/`inva
## Comparison with reference libraries
> **Format-independence is unique to soma.** Every other mainstream headless picker ties the `format` prop to a _single_ reactive state — changing format anywhere inside the picker immediately flips what the `value` text / hidden input / callbacks report. Soma splits this into an external format (canonical) and a popover-internal format (view) so the user can inspect / edit in any representation without mutating the committed output unless `autoFormat=true`.
| Feature | React Aria | Ark UI | Zag.js | Chakra (v3) | **Soma** |
| -------------------------------------------------- | --------------------- | ------------------- | ------------------- | ------------------- | --------------------------------------------------- |
| Composed parts | 8 (split hooks) | 22 | 22 | 22 (via Ark) | **22** |
| `value` / `onValueChange` | ✓ (Color obj) | ✓ | ✓ | ✓ | ✓ (`ColorValue` with `hex / rgb / hsl / hsv` views) |
| `onValueChangeEnd` (drag end) | ✓ | ✓ | ✓ | ✓ | ✓ |
| `format` / `defaultFormat` / `onFormatChange` | per-field only | ✓ | ✓ | ✓ | ✓ |
| **External ↔ popover format decoupling** | ✗ (single state) | ✗ (single state) | ✗ (single state) | ✗ (single state) | ✓ **Soma design (`autoFormat` toggle)** |
| `allowedFormats` restriction | ✗ | ✗ | ✗ | ✗ | ✓ (list; single entry renders a label) |
| 2D Area + `xChannel / yChannel` configurable | ✓ | fixed (S × V) | fixed (S × V) | fixed (S × V) | ✓ (configurable — React Aria parity) |
| ChannelSlider (any channel) | ✓ | ✓ | ✓ | ✓ | ✓ |
| ChannelInput — unified segmented hex / rgb / hsl | ✗ (one per channel) | ✗ (one per channel) | ✗ (one per channel) | ✗ (one per channel) | ✓ (single component; composes `ColorField`) |
| Segmented HEX (6-char single segment + `A–F` keys) | ✗ (text) | ✗ (text) | ✗ (text) | ✗ (text) | ✓ **Soma design** |
| FormatSelect | ✗ | ✓ | ✓ | ✓ | ✓ (auto-renders as label when locked) |
| Swatches (`role="radiogroup"`) | ✓ `ColorSwatchPicker` | ✓ | ✓ | ✓ | ✓ |
| EyeDropper (native browser API) | ✗ | ✓ | ✓ | ✓ | ✓ |
| TransparencyGrid (checkerboard) | ✗ | ✓ | ✓ | ✓ | ✓ |
| Inline vs popover | compose yourself | `inline` prop | `inline` prop | `inline` prop | ✓ (`inline` prop — skips `Popover` entirely) |
| `closeOnSelect` | ✗ | ✓ | ✓ | ✓ | ✓ |
| Lossless round-trip preserving edited channel | ✓ | partial | partial | partial | ✓ (`colorValueFromRgb / Hsl` bypass HSV) |
| Parent Field integration (OR-merge flags) | via `<Form>` | via `FieldRoot` | ✗ | via Ark | ✓ (soma `Field`) |
| ColorWheel (circular hue) | ✓ | ✗ | ✗ | ✗ | ✗ (deliberately omitted — per study §16.6) |
### Format-independence — why soma does it differently
Every other library treats the picker's format as a **single reactive knob**. The consumer passes `format="hex"` → the `ChannelInput` shows hex, the textual output reads hex, form submission reads hex. If the user clicks `FormatSelect` and picks RGB, _all three_ switch. The consumer has no way to keep "the value the user picked is committed to my backend as hex, regardless of what format they chose to edit it in."
Soma splits this into two concerns:
1. **Canonical / external format** — used by anything that exits the component (Trigger value, hidden `<input>`, `onFormatChange`). This is what the consumer controls with `bind:format`.
2. **View / edit format** — used only by `FormatSelect` + `ChannelInput` inside the popover, so the user can switch representation freely while editing.
Both are synced external → internal on every external change (so `bind:format` still drives the popover), but internal → external is gated by the `autoFormat` opt-in. This is the only new format-related concept in soma's API — everything else (`format`, `onFormatChange`, `allowedFormats`) behaves as other libraries do.
The precedent for this split in the broader ecosystem is the common "display format vs value format" pattern in date / number fields (where the user might see a formatted date while the value is always ISO). Soma applies the same idea to color.
Checked on 2026-09-27 against the current primary documentation. Counts of
Morfo parts, local wrappers and composed namespace members are different units:
UIX has 21 parts declared by [ColorPicker Morfo](../../../morfo/components/color-picker.ts), 23 local Soma wrappers, 24 local Eidos wrappers and 34
attached Eidos members. They are not library-to-library coverage scores.
| Reference | Documented surface | UIX decision |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
| [Ark UI](https://ark-ui.com/docs/components/color-picker) | Configurable Area channels, channel sliders, controlled value/open and hidden input. | Preserve the existing configurable axes and slider bridge. |
| [Chakra UI](https://chakra-ui.com/docs/components/color-picker) | RGB/alpha channel sliders, swatches and controlled composition. | Remove the old claim that RGB sliders are absent. |
| [React Aria ColorArea](https://react-aria.adobe.com/ColorArea) | Configurable channel axes and composed accessible color controls. | Keep the 2D channel model; adjudicate the slider's numeric ARIA surface separately (Q95). |
| [Mantine](https://mantine.dev/core/color-picker/) | String formats, swatches, sizes and native keyboard interaction. | Compare public contracts without inferring missing features from examples. |
UIX keeps an external format for Trigger/ValueText and a separate edit format
for FormatSelect/ChannelInput. Internal writes propagate outward only with
`autoFormat=true`; explicit external changes propagate inward. This is a local
contract, not a claim of uniqueness across every library. Native select display
after alternating formats requires the separate audit reproduction Q99.
HiddenInput form wiring, radio navigation and selected indicator visibility
remain recorded defects; this comparison does not repair them.
## Example — form field with EyeDropper and presets

@ -1,5 +1,5 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { bindProps } from '../../../provider';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ColorPickerAreaProvider } from '../color-picker-provider.svelte';
@ -17,15 +17,14 @@
...restProps
}: ColorPickerAreaProps = $props();
const area = ColorPickerAreaProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
xChannel: readableActive(() => xChannel),
yChannel: readableActive(() => yChannel)
});
const area = ColorPickerAreaProvider.create(
bindProps<Required<Parameters<typeof ColorPickerAreaProvider.create>[0]>>({
id: () => id,
ref: { get: () => ref, set: (v) => (ref = v) },
xChannel: () => xChannel,
yChannel: () => yChannel
})
);
const mergedProps = $derived(mergeProps(restProps, area.props));

@ -45,20 +45,8 @@
// Continuous values (0..255 for RGB, 0..360 for hue, 0..100 for saturation/
// brightness, 0..1 for alpha). Step sizes chosen so keyboard arrows produce
// ~1% increments by default (Shift = ~10%).
const stepForChannel = $derived.by(() => {
switch (channel) {
case 'alpha':
return 0.01;
case 'hue':
return 1;
case 'saturation':
case 'brightness':
return 1;
default:
return 1;
}
});
// one channel unit (0.01 for alpha); Shift multiplies the step by ten.
const stepForChannel = $derived(channel === 'alpha' ? 0.01 : 1);
const slider = SliderProvider.create({
id: readableActive(() => id),

@ -1,5 +1,5 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { bindProps } from '../../../provider';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ColorPickerEyeDropperProvider } from '../color-picker-provider.svelte';
@ -16,14 +16,13 @@
...restProps
}: ColorPickerEyeDropperProps = $props();
const state = ColorPickerEyeDropperProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
ariaLabel: readableActive(() => ariaLabel)
});
const state = ColorPickerEyeDropperProvider.create(
bindProps<Required<Parameters<typeof ColorPickerEyeDropperProvider.create>[0]>>({
id: () => id,
ref: { get: () => ref, set: (v) => (ref = v) },
ariaLabel: () => ariaLabel
})
);
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>

@ -1,5 +1,5 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { bindProps } from '../../../provider';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ColorPickerSwatchGroupProvider } from '../color-picker-provider.svelte';
@ -16,14 +16,13 @@
...restProps
}: ColorPickerSwatchGroupProps = $props();
const state = ColorPickerSwatchGroupProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
ariaLabel: readableActive(() => ariaLabel)
});
const state = ColorPickerSwatchGroupProvider.create(
bindProps<Required<Parameters<typeof ColorPickerSwatchGroupProvider.create>[0]>>({
id: () => id,
ref: { get: () => ref, set: (v) => (ref = v) },
ariaLabel: () => ariaLabel
})
);
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>

@ -1,5 +1,5 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { bindProps } from '../../../provider';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ColorPickerSwatchTriggerProvider } from '../color-picker-provider.svelte';
@ -16,14 +16,13 @@
...restProps
}: ColorPickerSwatchTriggerProps = $props();
const state = ColorPickerSwatchTriggerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
color: readableActive(() => color)
});
const state = ColorPickerSwatchTriggerProvider.create(
bindProps<Required<Parameters<typeof ColorPickerSwatchTriggerProvider.create>[0]>>({
id: () => id,
ref: { get: () => ref, set: (v) => (ref = v) },
color: () => color
})
);
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>

@ -1,5 +1,5 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { bindProps } from '../../../provider';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ColorPickerSwatchProvider } from '../color-picker-provider.svelte';
@ -16,14 +16,13 @@
...restProps
}: ColorPickerSwatchProps = $props();
const state = ColorPickerSwatchProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
color: readableActive(() => color)
});
const state = ColorPickerSwatchProvider.create(
bindProps<Required<Parameters<typeof ColorPickerSwatchProvider.create>[0]>>({
id: () => id,
ref: { get: () => ref, set: (v) => (ref = v) },
color: () => color
})
);
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>

@ -1,5 +1,5 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { bindProps } from '../../../provider';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ColorPickerTransparencyGridProvider } from '../color-picker-provider.svelte';
@ -16,14 +16,13 @@
...restProps
}: ColorPickerTransparencyGridProps = $props();
const state = ColorPickerTransparencyGridProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
cellSize: readableActive(() => cellSize)
});
const state = ColorPickerTransparencyGridProvider.create(
bindProps<Required<Parameters<typeof ColorPickerTransparencyGridProvider.create>[0]>>({
id: () => id,
ref: { get: () => ref, set: (v) => (ref = v) },
cellSize: () => cellSize
})
);
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>

@ -1,5 +1,5 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { bindProps } from '../../../provider';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ColorPickerValueTextProvider } from '../color-picker-provider.svelte';
@ -16,14 +16,13 @@
...restProps
}: ColorPickerValueTextProps = $props();
const state = ColorPickerValueTextProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
format: readableActive(() => format)
});
const state = ColorPickerValueTextProvider.create(
bindProps<Required<Parameters<typeof ColorPickerValueTextProvider.create>[0]>>({
id: () => id,
ref: { get: () => ref, set: (v) => (ref = v) },
format: () => format
})
);
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>

Loading…
Cancel
Save

Powered by TurnKey Linux.