parent
a8be47319d
commit
0d956fbe2e
@ -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.
|
||||
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
Loading…
Reference in new issue