audit(uix): characterize ColorSwatch and clarify its contract

astra
dev 2 weeks ago
parent a8be47319d
commit 0d956fbe2e

@ -1199,6 +1199,24 @@ 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.
### ColorSwatch: primitivo visual pasivo (2026-09-27)
La [ficha de ColorSwatch](./audit-uix-2026-09-19/color-swatch.md) revisa el
contrato Eidos, Morfo, CSS, receta, historia y consumidores directos; consulta
cinco referencias primarias. Cinco canarios aislados pasan y dos capturas
se inspeccionan a escala nativa. Una sonda DOM registra Q103: aria-label sobre
el span por defecto no aporta una semántica nombrable. No es una prueba con
lector de pantalla ni adjudica img como única composición válida.
El árbol mantenido repite 5/5 positivos y el mismo fallo esperado antes/después;
la sonda queda retirada con evidencia. Visual mantenido 5/5 y dos capturas
inspeccionadas; contratos 104/104. Sólo comentarios y README se precisan:
tres archivos, ocho líneas físicas menos, cero líneas ejecutables eliminadas.
JS/template/CSS iguales tras retirar comentarios. component-audit PASS,
900 documentos sin incidencias. Gate completo: 513/513 archivos y 5638/5638
tests, src/scripts sin errores nuevos, 89 errores de web dentro del ledger;
apps/base check/build y smoke 27/27 sin errores. El inventario alcanza 74
fichas revisadas; fase 6 abierta.
### 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,81 @@
---
title: UIX — auditoría de ColorSwatch
type: notes
audience: human + agent
authority: process — caracterización del contrato visual existente
status: reviewed
---
# ColorSwatch
Fecha: 2026-09-27. Rama: astra. Fase 6 abierta. Alcance autorizado: auditoría,
caracterización y documentación; comportamiento/API/CSS conservados, web excluido.
No se fuerza una extracción: el componente ya es un primitivo visual pequeño.
## Corpus y contrato
Se releen los pre-flight canónicos, Morfo completo (una parte, scope eidos,
cero eventos), wrapper, tipos, index, CSS, receta de catorce claves y README.
Se contrastan las fichas históricas de julio y agosto, con sus anotaciones de
canal de valor y límites del instrumento. No hay carpeta ColorSwatch en Air
morfo-runtime. ColorPicker presets/saved/trigger y ColorField/GradientBuilder
se leen sólo como consumidores, no como auditorías completas nuevas.
El componente escribe color en el canal privado --\_color-swatch-fill. CSS pinta
el checker en el root, color en before y anillo opt-in en after; el anillo no
cambia dimensiones. Cinco tamaños 16/20/24/28/32, cuatro radios, resolución
responsive compartida y tag dinámico. Sin provider Soma propio ni Sema.
No se añade lógica de selección/copia/naming ni dependencias nuevas.
## Matriz primaria
| Referencia | Superficie documentada | Decisión UIX |
| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| [React Aria ColorSwatch](https://react-aria.adobe.com/ColorSwatch) | Muestra y descripción de color localizada, colorName y contexto aria-label. | Mantener primitivo pasivo; naming automático diferido. |
| [Chakra ColorSwatch](https://chakra-ui.com/docs/components/color-swatch) | Muestra dedicada, tallas, alfa y composición de paleta/mezcla. | Retirar la comparación que negaba el primitivo; mezcla diferida. |
| [Mantine ColorSwatch](https://mantine.dev/core/color-swatch/) | Color CSS, sombra interior por defecto y root polimórfico. | Conservar anillo opt-in y tag actual; no adoptar su default. |
| [Radix Themes Box](https://www.radix-ui.com/themes/docs/components/box) | Contenedor div/span, asChild y dimensiones responsive. | UIX conserva el átomo que centraliza checker/pintura, sin portar contenedor. |
| [Spectrum Swatch](https://opensource.adobe.com/spectrum-web-components/components/swatch/) | Muestras, tamaños/radios y selección mediante grupos. | No introducir interacción en el scope pasivo. |
Consulta del 2026-09-27. [APG nombres](https://www.w3.org/WAI/ARIA/apg/practices/names-and-descriptions/)
y [WAI-ARIA generic](https://www.w3.org/TR/wai-aria-1.2/#generic) delimitan la
composición significativa: generic no admite nombre. Un consumidor puede
aportar role img y aria-label, sin que este lote añada un rol por defecto.
## Caracterización y límites
Baseline aislada en Chromium: cinco canarios pasan, una sonda de rol falla.
Cubren color opaco/alfa, checker/anillo por defecto, las cinco tallas, cambios
reactivos de color/radio/anillo/tagStyle/tag, colores CSS/variable y composición
explícita role img con label, además de resolución responsive 1100→480px.
Runtime ActiveUix/Soma/ActiveEidos real, sin mocks del viewport ni resolución.
El primer canario esperaba el serializer rgba con alfa 0.502 y recibió 0.5:
se precisa sólo la aserción con tolerancia de dos decimales sobre 128/255.
No se diagnostica esa serialización como un defecto de pintura.
La sonda comprueba que aria-label sobre span no aporta role img. Ese resultado
es evidencia de DOM y no una prueba con lector de pantalla ni una adjudicación
de que img sea la única semántica válida. La promesa documental de aria-label
solo requiere precisar composición; se difiere la política de rol/naming.
component-audit baseline PASS. La ejecución visual aislada pasa 5/5 y se
inspeccionan dos capturas a escala nativa: las cinco tallas con alfa y el cambio
reactivo de color/radio/anillo/tag. No hay comparación pixel baseline. El árbol mantenido repite cinco positivos y la misma sonda negativa antes/después.
La sonda se retira con sus fuentes/logs/artefactos a
tmp/color-swatch-audit-evidence-2026-09-27. Visual mantenido: 5/5, dos nuevas
capturas inspeccionadas a escala nativa. Contratos: 104/104 en cuatro archivos.
Gate completo: 513/513 archivos y 5638/5638 tests aprobados; src/scripts
sin errores nuevos, 89 errores de web dentro del ledger, 900 documentos y
25 arts sin incidencias. Apps/base: check/build y smoke 27/27, foundation 1/1,
sin errores de página, consola ni CSP, delta/flips cero; LTR/RTL y
dark/reduced comprobados por smoke. component-audit PASS. No se declara una nueva medición
de todos los tokens de agosto.
## Reducción aplicada
No hay una extracción de lógica útil: el wrapper ya delega resolución responsive
y los consumidores componen el mismo ColorSwatch. Un helper adicional para
Presets/SavedSwatches introduciría más armazón y ocultaría ownership distinto.
Se precisan sólo comentarios obsoletos sobre la escala de iconos, anillo por
defecto y semántica del label. La prueba AST conserva JS y template; el CSS
tras retirar comentarios es idéntico. Tres archivos: ocho líneas físicas menos
de comentarios, cero líneas ejecutables eliminadas y ninguna API nueva.

@ -190,6 +190,12 @@ Lotes posteriores de los controles MediaPlayer usados por AudioPlayer:
| 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. |
Lote posterior de ColorSwatch:
| Lote posterior | ID | Condición y prueba de cierre |
| ----------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------- |
| Composición accesible de la muestra | Q103 | Adjudicar rol/naming del primitivo y sus consumidores; probar composición significativa y decorativa con tecnología de asistencia. |
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
o límite de la evidencia, consumidor afectado y prueba de cierre. Al terminar
@ -284,6 +290,20 @@ 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. |
### ColorSwatch: Q103
Fecha: 2026-09-27. La [ficha de ColorSwatch](./color-swatch.md) registra la
discrepancia de la documentación que prometía aria-label solo sobre el span
por defecto: generic prohíbe nombre accesible según WAI-ARIA. Una sonda DOM
confirma span, label presente y rol ausente; la composición explícita img +
label sí pasa. Esa sonda propone img, sin adjudicarlo como única solución ni
equivaler a una prueba con lector de pantalla. Cinco canarios positivos pasan
en baseline aislada. Se precisa la documentación; la política de rol/naming
queda diferida y no se añade un rol por defecto ni interacción.
El árbol mantenido repite cinco positivos y el mismo fallo antes/después.
La sonda queda retirada con sus fuentes/logs/artefactos en
tmp/color-swatch-audit-evidence-2026-09-27; no entra en el gate.
### ColorPicker: Q94–Q102
Fecha: 2026-09-27. La [ficha de ColorPicker](./color-picker.md) conserva el

@ -1,7 +1,7 @@
# Eidos ColorSwatch
A colour-sample **primitive** — a square (or circular) chip of a CSS colour
painted over a transparency checker, with a subtle inset ring. The canonical,
painted over a transparency checker, with an optional inset ring. The canonical,
size-scaled way to show a colour, reused across `ColorPicker` / `ColorField`
(value preview, preset swatches, trigger dot) and anywhere a colour needs a
consistent sample.
@ -52,8 +52,10 @@ glyph), one ring, one checker.
| `bordered` | `boolean` | `false` | opt-in subtle inset ring (for light / white colours) |
| `as` | `string` | `span` | render a different tag |
Decorative by default — pass an `aria-label` (or wrap it in an interactive
control) when the colour conveys meaning.
Decorative by default. A meaningful standalone sample needs suitable semantics:
pass `role="img"` together with `aria-label`, or compose it inside a named
interactive control. A label alone on the default `span` does not provide that
semantic role (audit Q103).
## Talla y tema
@ -63,6 +65,9 @@ control) when the colour conveys meaning.
(`radius-{sm,md,lg,full}`), el anillo opt-in (`border-width`, `border-fg`) y el
damero de transparencia (`checker-{cell,bg,square}`).
Las adjudicaciones siguientes conservan su fecha de agosto; sus consumidores,
rutas y porcentajes no se presentan como una medición nueva del lote de auditoría.
Lo que entró el **2026-08-23** (70 % → 100 %):
- **`radius-{sm,md,lg,full}`** — los tres pasos que no son el default ataban
@ -109,18 +114,23 @@ passive (no events). Eidos-native, no soma (pure visual, like `Kbd` / `Icon`).
Sin baseline air: el primitivo se EXTRAJO durante el rediseño Untitled-UI de
ColorPicker (2026-06-28) para matar los chips ad-hoc — la muestra canónica de
color, reutilizada por `ColorPicker` / `ColorField` (preview del valor,
presets, dot del trigger). La única referencia headless que lo publica como
primitivo es React Aria (`ColorSwatch`).
presets, dot del trigger). React Aria, Chakra y Mantine publican muestras dedicadas; la comparación actual
incluye esos contratos sin afirmar exclusividad.
## Comparativa
| Capacidad | UIX | React Aria ColorSwatch | Chakra/Radix | Chips ad-hoc |
| ------------------------------------- | --- | ---------------------- | ------------ | ------------ |
| Primitivo dedicado reutilizable | ✓ | ✓ | No | — |
| Checker de transparencia bajo alfa | ✓ | ✓ | — | Raro |
| Escala de tallas + `rounded`/`full` | ✓ | CSS externo | — | — |
| Ring inset (legibilidad sobre claros) | ✓ | No | — | No |
| Pasivo por contrato (0 eventos) | ✓ | ✓ | — | — |
Consulta primaria del 2026-09-27. El ámbito UIX sigue siendo pasivo y visual.
| Referencia | Superficie documentada | Decisión UIX |
| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| [React Aria ColorSwatch](https://react-aria.adobe.com/ColorSwatch) | Descripción localizada del color, colorName y contexto accesible. | Mantener naming automático diferido y composición explícita. |
| [Chakra ColorSwatch](https://chakra-ui.com/docs/components/color-swatch) | Primitivo dedicado, tallas, alfa y paletas/mezclas. | Retirar la comparación que negaba el primitivo; no añadir mezcla. |
| [Mantine ColorSwatch](https://mantine.dev/core/color-swatch/) | CSS color, sombra interior por defecto y root polimórfico. | Conservar el anillo opt-in actual y el tag dinámico. |
| [Radix Themes Box](https://www.radix-ui.com/themes/docs/components/box) | Div/span con composición y dimensiones responsive. | Conservar el átomo UIX que centraliza checker/pintura; no ampliar props de contenedor. |
| [Spectrum Swatch](https://opensource.adobe.com/spectrum-web-components/components/swatch/) | Tallas/radios y estados de selección mediante grupos. | No introducir selección en el scope pasivo. |
Las comparaciones describen documentación primaria, no pruebas ejecutadas de
esas bibliotecas. Las medidas de agosto anteriores conservan su fecha.
## Decisiones
@ -129,7 +139,7 @@ primitivo es React Aria (`ColorSwatch`).
inline (los consumidores del picker lo prueban).
- **El checker es parte del primitivo**, no del consumidor: un color con alfa
SIEMPRE se lee contra el damero.
- **Ring inset sutil** para que una muestra clara no se funda con la
- **Ring inset opt-in** para que una muestra clara no se funda con la
superficie clara — legibilidad, no decoración.
- **Pasivo por contrato** (S7, átomo ejemplar): mostrar un color no tiene
momento perceptivo propio; la interacción (elegir, copiar) pertenece al

@ -14,8 +14,7 @@
* still delineated and the box never grows (the ring
* is a box-shadow, not a border).
*
* Size scales via `--icon-size-*` (1:1 with the icon scale → follows the size of the
* control/text it sits beside, and `--scaling`).
* Sizes use the dedicated `--color-swatch-size-*` scale and follow `--scaling`.
*/
[data-color-swatch] {

@ -1,16 +1,9 @@
<script lang="ts">
import './color-swatch.css';
/**
* Eidos `<ColorSwatch>` — a colour chip painted over a transparency checker.
*
* <ColorSwatch color="#1e90ff" />
* <ColorSwatch color="rgb(234 88 12 / 0.6)" size="lg" />
* <ColorSwatch color={value} rounded="full" /> <!-- a colour dot -->
*
* Opaque colours fill solid; colours with alpha reveal the checker beneath.
* A subtle inset ring delineates it on any background. Scales with `size`
* (via `--icon-size-*`). Decorative by default — pass an `aria-label` (or
* wrap it in an interactive control) when the colour conveys meaning.
* Paint a CSS color over the shared transparency checker; the ring is opt-in.
* Sizes use the dedicated swatch scale. For a meaningful standalone sample,
* compose role="img" and an accessible label; otherwise use a named control.
*/
import { ActiveEidos } from '$uix/eidos';
import type { ColorSwatchProps } from './types';

@ -0,0 +1,111 @@
import { beforeAll, expect, it, vi } from 'vitest';
import { page, userEvent } from 'vitest/browser';
import { renderColorSwatch } from './test/color-swatch-audit-view';
beforeAll(async () => {
await page.viewport(1100, 1000);
await userEvent.keyboard('{Shift}');
}, 60_000);
it('paints an opaque color over the checker without an opt-in ring', async () => {
const v = renderColorSwatch();
try {
await v.ready();
const chip = v.sample('live');
expect(chip.tagName).toBe('SPAN');
expect(chip.hasAttribute('data-bordered')).toBe(false);
const style = getComputedStyle(chip);
expect(style.width).toBe('24px');
expect(style.height).toBe('24px');
expect(style.backgroundImage).toContain('linear-gradient');
expect(getComputedStyle(chip, '::before').backgroundColor).toBe('rgb(255, 0, 0)');
expect(getComputedStyle(chip, '::after').content).toBe('none');
} finally {
await v.dispose();
}
}, 30_000);
it('retains the five dedicated sizes and alpha checker', async () => {
const v = renderColorSwatch();
try {
await v.ready();
for (const [name, px] of [
['xs', 16],
['sm', 20],
['md', 24],
['lg', 28],
['xl', 32]
] as const) {
const chip = v.sample(name);
expect(getComputedStyle(chip).width).toBe(`${px}px`);
expect(getComputedStyle(chip).height).toBe(`${px}px`);
expect(getComputedStyle(chip, '::before').backgroundColor).toBe('rgba(255, 0, 0, 0.5)');
}
if (import.meta.env.VITE_UIX_AUDIT_SCREENSHOTS === '1')
await page.screenshot({
path: 'tmp/color-swatch-sizes.png',
element: page.elementLocator(v.target)
});
} finally {
await v.dispose();
}
}, 30_000);
it('updates color, radius, ring and tag style before replacing the element tag', async () => {
const v = renderColorSwatch();
try {
await v.ready();
const initial = v.sample('live');
await userEvent.click(v.button('Change appearance'));
expect(v.sample('live')).toBe(initial);
expect(initial.dataset.size).toBe('xl');
expect(initial.dataset.rounded).toBe('full');
expect(initial.className).toBe('live-chip');
expect(getComputedStyle(initial).width).toBe('32px');
expect(getComputedStyle(initial).opacity).toBe('0.75');
const paint = getComputedStyle(initial, '::before').backgroundColor;
expect(paint).toMatch(/^rgba\(0, 255, 0,/);
expect(parseFloat(paint.split(',')[3])).toBeCloseTo(128 / 255, 2);
expect(getComputedStyle(initial, '::after').boxShadow).toContain('inset');
await userEvent.click(v.button('Change tag'));
expect(v.sample('live').tagName).toBe('I');
expect(v.sample('live').dataset.size).toBe('xl');
if (import.meta.env.VITE_UIX_AUDIT_SCREENSHOTS === '1')
await page.screenshot({
path: 'tmp/color-swatch-ring-alpha.png',
element: page.elementLocator(v.target)
});
} finally {
await v.dispose();
}
}, 30_000);
it('accepts native CSS colors and an explicit accessible image composition', async () => {
const v = renderColorSwatch();
try {
await v.ready();
expect(getComputedStyle(v.sample('transparent'), '::before').backgroundColor).toBe(
'rgba(0, 0, 0, 0)'
);
expect(getComputedStyle(v.sample('named-color'), '::before').backgroundColor).toBe(
'rgb(102, 51, 153)'
);
expect(getComputedStyle(v.sample('hsl'), '::before').backgroundColor).toBe('rgb(0, 255, 0)');
expect(getComputedStyle(v.sample('oklch'), '::before').backgroundColor).toContain('oklch(');
expect(getComputedStyle(v.sample('css-var'), '::before').backgroundColor).toBe(
'rgb(0, 0, 255)'
);
await expect.element(page.getByRole('img', { name: 'Composed blue' })).toBeInTheDocument();
} finally {
await v.dispose();
}
}, 30_000);
it('resolves size and rounding against the native viewport', async () => {
const v = renderColorSwatch();
try {
await v.ready();
expect(v.sample('responsive').dataset.size).toBe('lg');
expect(v.sample('responsive').dataset.rounded).toBe('full');
await page.viewport(480, 1000);
await vi.waitFor(() => expect(v.sample('responsive').dataset.size).toBe('xs'));
expect(v.sample('responsive').dataset.rounded).toBe('sm');
} finally {
await page.viewport(1100, 1000);
await v.dispose();
}
}, 30_000);

@ -0,0 +1,71 @@
<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 {
ColorSwatch,
type ColorSwatchSize,
type ColorSwatchRounded
} from '$uix/eidos/components/color-swatch';
let { uix }: { uix: ActiveUix } = $props();
// svelte-ignore state_referenced_locally
setActiveUix(uix);
Soma.create();
const eidos = ActiveEidos.create({ applyDom: false });
onDestroy(() => eidos.dispose());
let color = $state('#ff0000');
let size = $state<ColorSwatchSize>('md');
let rounded = $state<ColorSwatchRounded | undefined>();
let bordered = $state(false);
let as = $state('span');
let extra = $state<string | undefined>();
</script>
<section style="width: 640px; padding: 16px; background: white; color: black;">
<h1>ColorSwatch audit</h1>
<div style="display: flex; gap: 16px; align-items: center;">
<ColorSwatch
data-sample="live"
{color}
{size}
{rounded}
{bordered}
{as}
tagStyle={extra}
class="live-chip"
/>
{#each ['xs', 'sm', 'md', 'lg', 'xl'] as fixed}<ColorSwatch
data-sample={fixed}
color="rgba(255,0,0,0.5)"
size={fixed as ColorSwatchSize}
/>{/each}
</div>
<ColorSwatch
data-sample="responsive"
color="#00ff00"
size={{ base: 'xs', md: 'lg' }}
rounded={{ base: 'sm', md: 'full' }}
/>
<ColorSwatch data-sample="transparent" color="transparent" /><ColorSwatch
data-sample="named-color"
color="rebeccapurple"
/>
<ColorSwatch data-sample="hsl" color="hsl(120 100% 50%)" /><ColorSwatch
data-sample="oklch"
color="oklch(70% 0.1 50)"
/>
<ColorSwatch data-sample="css-var" color="var(--audit-color)" tagStyle="--audit-color: blue;" />
<ColorSwatch data-sample="label-only" color="blue" aria-label="Meaningful blue" />
<ColorSwatch data-sample="image" color="blue" role="img" aria-label="Composed blue" />
<button
onclick={() => {
color = '#00ff0080';
size = 'xl';
rounded = 'full';
bordered = true;
extra = 'opacity: 0.75;';
}}>Change appearance</button
>
<button onclick={() => (as = 'i')}>Change tag</button>
</section>

@ -0,0 +1,34 @@
import { mount, unmount, flushSync } from 'svelte';
import { createActiveUix } from '$active-uix';
import '$uix/eidos/index.css';
import Harness from './color-swatch-audit-harness.svelte';
export function renderColorSwatch() {
const uix = createActiveUix({
langs: { schema: {}, defaultLocale: 'es' },
prefs: { storage: false },
projectPrefs: false
});
const target = document.createElement('div');
document.body.append(target);
const component = mount(Harness, { target, props: { uix } });
flushSync();
return {
target,
sample(name: string) {
return target.querySelector<HTMLElement>(`[data-sample="${name}"]`)!;
},
button(text: string) {
return Array.from(target.querySelectorAll('button')).find(
(b) => b.textContent?.trim() === text
)!;
},
async ready() {
await new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())));
},
async dispose() {
await unmount(component);
uix.dispose();
target.remove();
}
};
}

@ -14,7 +14,7 @@ export type ColorSwatchRounded = 'sm' | 'md' | 'lg' | 'full';
export type ColorSwatchProps = Omit<HTMLAttributes<HTMLElement>, 'style' | 'children'> & {
/** The colour to sample — any CSS colour string (hex / rgb / hsl / oklch / named). */
color: string;
/** Size scale (maps to `--icon-size-*`, scales with `--scaling`). @default 'md' */
/** Dedicated swatch size scale; follows `--scaling`. @default 'md' */
size?: ResponsiveProp<ColorSwatchSize>;
/** Corner rounding; `'full'` = circular dot. @default 'sm' */
rounded?: ResponsiveProp<ColorSwatchRounded>;

Loading…
Cancel
Save

Powered by TurnKey Linux.