parent
e3266ffac4
commit
88ec6b6f2b
@ -0,0 +1,102 @@
|
||||
# ImageAdjustments — auditoría de calidad (2026-09-26)
|
||||
|
||||
## Alcance y pre-flight
|
||||
|
||||
Se revisan Morfo, Soma, Sema y Eidos, los cinco wrappers headless, catálogo,
|
||||
receta, traducciones y consumidores ImagePicker/Palabras. Se han leído completos
|
||||
component-audit, demo-authoring, el contrato Eidos y building-a-component.
|
||||
Alcance autorizado por el usuario en esta auditoría: caracterización, duplicación
|
||||
y refactorización neutral; los defectos funcionales quedan para después.
|
||||
No se modifica web. La ficha histórica de julio era de análisis, no cobertura
|
||||
nativa; no se reabre la corrección de value-color ya registrada como resuelta.
|
||||
No hay componente equivalente en el árbol Air de la referencia morfo-runtime.
|
||||
|
||||
## Matriz de referencias
|
||||
|
||||
Las referencias son primitivas de slider; no se atribuye a esas bibliotecas
|
||||
un editor de filtros inexistente por ausencia de un ejemplo en su página.
|
||||
|
||||
| Aspecto | [Ark UI](https://ark-ui.com/docs/components/slider) | [Radix](https://www.radix-ui.com/primitives/docs/components/slider) | [React Aria](https://react-aria.adobe.com/Slider) | [Mantine](https://mantine.dev/core/slider/) | UIX y decisión |
|
||||
| ----------------- | --------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Nombre | Label y nombres por thumb | Composición y aria del control | Label y nombres de thumbs | thumbLabel | Contrastar los ocho thumbs; Q77 ya existe, no crear otro ID. |
|
||||
| Teclado y límites | min/max/step | Flechas, Home/End y Page | minValue/maxValue/step | Paso de teclado | Probar entrada nativa contra rangos actuales, sin cambiar Slider. |
|
||||
| Valor | Callbacks de cambio y fin | Controlado o default | value/onChange/onChangeEnd | value y formatter | Conservar record parcial y callbacks propios de UIX. |
|
||||
| Read-out | Composición | Composición | SliderOutput | Texto del valor y marks | Conservar ItemValue sin añadir una live region. |
|
||||
| Disabled | Prop de estado | Prop de estado | Estado del control | Prop de estado | Probar controles y reset actuales. |
|
||||
|
||||
El [APG Slider](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) exige nombre
|
||||
para cada slider. [Filter Effects](https://www.w3.org/TR/filter-effects-1/)
|
||||
define aplicación de funciones en el orden declarado. Estas fuentes orientan
|
||||
la comprobación; no autorizan nuevas features ni cambios de semántica.
|
||||
|
||||
## Arquitectura y límites
|
||||
|
||||
Soma posee ocho definiciones, seis keys por defecto, values parciales,
|
||||
computeImageFilter, isModified y reset. Morfo declara grupo/filas/reset y
|
||||
commit-reset post; Sema aporta el tap y hereda el sonido del sistema.
|
||||
Eidos compone un Slider por fila y un reset opcional; talla sm/md/lg viaja
|
||||
al Slider, la tipografía de cabecera usa tokens propios sin eje de talla.
|
||||
No hay variantes ni intención cromática pública. El catálogo aproxima
|
||||
temperature; highlights/shadows y white balance exacto siguen diferidos.
|
||||
|
||||
ImagePicker aplica el filtro al contenedor Image. Palabras adapta valores a
|
||||
su modelo; schemeImageFilter usa escalas, serialización y neutral distinto.
|
||||
Esas semejanzas no justifican reemplazar sus funciones sin demostrar paridad.
|
||||
No se crea una clase base para providers ni un constructor genérico de catálogo.
|
||||
|
||||
## Baseline y trabajo en curso
|
||||
|
||||
component:audit pasa con cero errores y un aviso D-1.2 de la demo congelada.
|
||||
Los doce tests existentes han pasado en los lotes dirigidos de ImagePicker,
|
||||
y el gate anterior pasa 505 archivos/5584 tests. La suite existente sustituye
|
||||
Soma; se añade caracterización con servicios UIX reales antes de producción.
|
||||
|
||||
Diez canarios Eidos con servicios reales pasan junto a esa suite: **22/22 en
|
||||
dos archivos**, antes de producción. Cada key se prueba por ArrowUp/Home/End,
|
||||
rangos, read-out, callbacks value→filter y reset nativo. El grupo y el reset
|
||||
usan nombres españoles. Se verifican disabled, talla de sliders sin cambiar
|
||||
tipografía, reset opcional, orden del subconjunto, identidad keyed y retención
|
||||
de values no visibles. Se inspeccionan capturas del panel completo y subconjunto.
|
||||
Las capturas se solicitan con VITE_UIX_AUDIT_SCREENSHOTS=1; la suite normal
|
||||
conserva acciones y aserciones sin recoger esos artefactos.
|
||||
|
||||
Ocho sondas adicionales de nombre fallan mientras pasan los 22 tests. Los
|
||||
ocho thumbs reciben vacío frente a su label visible; se amplía **Q77**, sin
|
||||
crear otro ID ni modificar Slider. Las sondas se retiran tras terminar.
|
||||
No se acredita lector de pantalla, touch, wheel, otros motores, audio ni
|
||||
vibración físicos.
|
||||
|
||||
## Extracción neutral y documentación
|
||||
|
||||
El wrapper root usa bindProps existente, con tipo derivado del parámetro
|
||||
de ImageAdjustmentsProvider.create. Los seis enlaces conservan por AST nombres,
|
||||
orden, getters y setters; el setter de value asigna y luego emite onValueChange
|
||||
igual que antes. Se eliminan las llamadas repetidas de conexión sin crear
|
||||
otro helper, interfaz ni export público. Baja de **50 a 49 líneas físicas**
|
||||
y de **45 a 44 no vacías**: una línea menos. El catálogo, providers, CSS,
|
||||
callbacks y exports ejecutables permanecen iguales. Item ahorraría muy poco
|
||||
y no se extrae por simetría; Label/Value/Reset ya usan partOpts.
|
||||
|
||||
Las props externas actualizan rows y estado derivado, pero no emiten esos
|
||||
callbacks por sí mismas; éstos salen de setAdjustment/reset. La caracterización
|
||||
conserva ese comportamiento. Se documenta la diferencia entre estado derivado
|
||||
y salida por callback, sin añadir un efecto que cambie la dinámica.
|
||||
Se corrige la promesa documental de tipografía por size y el sonido fijo tick:
|
||||
la talla afecta al Slider, la fila usa tokens y Sema hereda la selección sonora.
|
||||
No se afirma ahorro de bundle o tiempo por reducir texto.
|
||||
|
||||
Después de la extracción pasan **25/25 tests en tres archivos**: las dos
|
||||
suites de ajustes y opts-census. Se vuelven a inspeccionar ambas capturas.
|
||||
La comprobación dirigida del helper pasa **7/7 en otro archivo**. El gate
|
||||
completo pasa antes del ajuste de infraestructura visual: 506 archivos y
|
||||
5594 tests, src/scripts sin errores de tipos y deuda de web dentro del ledger,
|
||||
893 documentos sin errores ni avisos, build de apps/base y smoke 27/27 sin
|
||||
errores de página, consola ni CSP.
|
||||
|
||||
T02 registra la escala reducida de las capturas por el iframe de Vitest.
|
||||
La configuración audit-visual produce 1:1: **19/19 tests en cinco archivos**,
|
||||
18 canarios de ZoomPan/Cropper/ImagePicker/ImageAdjustments y una sonda temporal
|
||||
de escala. Se inspeccionaron ocho capturas nuevas y se retiró la sonda.
|
||||
El gate con la configuración mantenida también pasa: 506/506 archivos y
|
||||
5594/5594 tests, 893 documentos sin incidencias, src/scripts sin errores de
|
||||
tipos y smoke apps/base 27/27. T02 queda cerrado como infraestructura visual.
|
||||
@ -0,0 +1,33 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import base from '../vite.config.ts';
|
||||
|
||||
const client = base.test?.projects?.find(
|
||||
(project) =>
|
||||
typeof project === 'object' &&
|
||||
project !== null &&
|
||||
'test' in project &&
|
||||
project.test?.name === 'client'
|
||||
);
|
||||
|
||||
if (typeof client !== 'object' || !client || !('test' in client) || !client.test?.browser) {
|
||||
throw new Error('The client test project is required for UIX visual auditing.');
|
||||
}
|
||||
|
||||
// The larger outer viewport keeps the audit iframe at its native CSS scale.
|
||||
// Reuse the normal plugins, aliases, instances and test discovery.
|
||||
export default defineConfig({
|
||||
...base,
|
||||
test: {
|
||||
name: 'audit-visual',
|
||||
expect: base.test?.expect,
|
||||
include: client.test.include,
|
||||
browser: {
|
||||
...client.test.browser,
|
||||
ui: false,
|
||||
provider: playwright({
|
||||
contextOptions: { viewport: { width: 1280, height: 1400 } }
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,138 @@
|
||||
import { beforeAll, expect, it, vi } from 'vitest';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import { renderImageAdjustments } from './test/image-adjustments-audit-view';
|
||||
|
||||
beforeAll(() => userEvent.keyboard('{Shift}'), 60_000);
|
||||
|
||||
const cases = [
|
||||
['brightness', 'Brillo', -100, 100, 1, '+1', 'brightness(1.01)'],
|
||||
['contrast', 'Contraste', -100, 100, 1, '+1', 'contrast(1.01)'],
|
||||
['saturation', 'Saturación', -100, 100, 1, '+1', 'saturate(1.01)'],
|
||||
['temperature', 'Temperatura', -100, 100, 1, '+1', 'sepia(0.005)'],
|
||||
['hue', 'Tono', -180, 180, 1, '1°', 'hue-rotate(1deg)'],
|
||||
['blur', 'Desenfoque', 0, 20, 0.5, '0.5px', 'blur(0.5px)'],
|
||||
['grayscale', 'Escala de grises', 0, 100, 1, '1%', 'grayscale(1%)'],
|
||||
['sepia', 'Sepia', 0, 100, 1, '1%', 'sepia(1%)']
|
||||
] as const;
|
||||
|
||||
it.each(cases)(
|
||||
'binds %s keyboard input, read-out, range and callback order',
|
||||
async (key, label, min, max, step, formatted, filter) => {
|
||||
const view = renderImageAdjustments();
|
||||
try {
|
||||
const row = view.root.querySelector<HTMLElement>(`[data-adjustment="${key}"]`)!;
|
||||
const thumb = row.querySelector<HTMLElement>('[role="slider"]')!;
|
||||
expect(row.querySelector('[data-image-adjustments-item-label]')!.textContent?.trim()).toBe(
|
||||
label
|
||||
);
|
||||
expect(thumb.getAttribute('aria-valuemin')).toBe(String(min));
|
||||
expect(thumb.getAttribute('aria-valuemax')).toBe(String(max));
|
||||
thumb.focus();
|
||||
await userEvent.keyboard('{ArrowUp}');
|
||||
expect(view.value()).toEqual({ [key]: step });
|
||||
expect(thumb.getAttribute('aria-valuenow')).toBe(String(step));
|
||||
expect(row.querySelector('[data-image-adjustments-item-value]')!.textContent?.trim()).toBe(
|
||||
formatted
|
||||
);
|
||||
expect(view.onValueChange).toHaveBeenLastCalledWith({ [key]: step });
|
||||
expect(view.onFilterChange).toHaveBeenLastCalledWith(filter);
|
||||
expect(view.events).toEqual(['value', 'filter']);
|
||||
await userEvent.keyboard('{Home}');
|
||||
expect(view.value()[key]).toBe(min);
|
||||
await userEvent.keyboard('{End}');
|
||||
expect(view.value()[key]).toBe(max);
|
||||
const reset = view.root.querySelector<HTMLButtonElement>('[data-image-adjustments-reset]')!;
|
||||
await expect.element(reset).toHaveAccessibleName('Restablecer');
|
||||
await userEvent.click(reset);
|
||||
await vi.waitFor(() => expect(view.value()).toEqual({}));
|
||||
expect(view.onFilterChange).toHaveBeenLastCalledWith('none');
|
||||
expect(view.events.slice(-2)).toEqual(['value', 'filter']);
|
||||
expect(reset.disabled).toBe(true);
|
||||
} finally {
|
||||
await view.dispose();
|
||||
}
|
||||
},
|
||||
30_000
|
||||
);
|
||||
|
||||
it('preserves keyed row identity, subset order and values outside the visible subset', async () => {
|
||||
const view = renderImageAdjustments();
|
||||
try {
|
||||
await expect.element(view.root).toHaveAccessibleName('Ajustes de imagen');
|
||||
await userEvent.click(view.button('Seed externally'));
|
||||
await userEvent.click(view.button('Subset'));
|
||||
expect(
|
||||
Array.from(view.root.querySelectorAll('[data-adjustment]')).map((row) =>
|
||||
row.getAttribute('data-adjustment')
|
||||
)
|
||||
).toEqual(['sepia', 'brightness']);
|
||||
const brightness = view.root.querySelector<HTMLElement>('[data-adjustment="brightness"]')!;
|
||||
const thumb = brightness.querySelector<HTMLElement>('[role="slider"]')!;
|
||||
expect(thumb.getAttribute('aria-valuenow')).toBe('20');
|
||||
await userEvent.click(view.button('Reverse subset'));
|
||||
expect(view.root.querySelector('[data-adjustment="brightness"]')).toBe(brightness);
|
||||
expect(
|
||||
Array.from(view.root.querySelectorAll('[data-adjustment]')).map((row) =>
|
||||
row.getAttribute('data-adjustment')
|
||||
)
|
||||
).toEqual(['brightness', 'sepia']);
|
||||
thumb.focus();
|
||||
await userEvent.keyboard('{ArrowUp}');
|
||||
expect(view.value()).toEqual({ brightness: 21, contrast: -20, hue: 30 });
|
||||
expect(view.onFilterChange).toHaveBeenLastCalledWith('brightness(1.21)');
|
||||
expect(getComputedStyle(view.target.querySelector('[data-filter-preview]')!).filter).toBe(
|
||||
'brightness(1.21)'
|
||||
);
|
||||
if (import.meta.env.VITE_UIX_AUDIT_SCREENSHOTS === '1') {
|
||||
await page.screenshot({
|
||||
element: view.target,
|
||||
path: '../../../../../tmp/image-adjustments-subset.png'
|
||||
});
|
||||
}
|
||||
await userEvent.click(view.root.querySelector('[data-image-adjustments-reset]')!);
|
||||
await vi.waitFor(() => expect(view.value()).toEqual({}));
|
||||
} finally {
|
||||
await view.dispose();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('propagates disabled, size and optional reset without changing row typography', async () => {
|
||||
const view = renderImageAdjustments();
|
||||
try {
|
||||
const label = view.root.querySelector<HTMLElement>('[data-image-adjustments-item-label]')!;
|
||||
const fontSize = getComputedStyle(label).fontSize;
|
||||
const thumb = view.root.querySelector<HTMLElement>('[role="slider"]')!;
|
||||
await userEvent.click(view.button('Seed externally'));
|
||||
await userEvent.click(view.button('Toggle disabled'));
|
||||
expect(view.onFilterChange).not.toHaveBeenCalled();
|
||||
expect(view.root.hasAttribute('data-disabled')).toBe(true);
|
||||
expect(thumb.getAttribute('aria-disabled')).toBe('true');
|
||||
expect(
|
||||
view.root.querySelector<HTMLButtonElement>('[data-image-adjustments-reset]')!.disabled
|
||||
).toBe(true);
|
||||
thumb.focus();
|
||||
await userEvent.keyboard('{ArrowUp}');
|
||||
expect(view.value().brightness).toBe(20);
|
||||
expect(view.onValueChange).not.toHaveBeenCalled();
|
||||
await userEvent.click(view.button('Toggle disabled'));
|
||||
await userEvent.click(view.button('Toggle size'));
|
||||
expect(view.root.getAttribute('data-size')).toBe('lg');
|
||||
expect(
|
||||
Array.from(view.root.querySelectorAll('[data-slider]')).every(
|
||||
(slider) => slider.getAttribute('data-size') === 'lg'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(getComputedStyle(label).fontSize).toBe(fontSize);
|
||||
if (import.meta.env.VITE_UIX_AUDIT_SCREENSHOTS === '1') {
|
||||
await page.screenshot({
|
||||
element: view.target,
|
||||
path: '../../../../../tmp/image-adjustments-full.png'
|
||||
});
|
||||
}
|
||||
await userEvent.click(view.button('Toggle reset'));
|
||||
expect(view.root.querySelector('[data-image-adjustments-reset]')).toBeNull();
|
||||
expect(view.root.querySelectorAll('[role="slider"]')).toHaveLength(8);
|
||||
} finally {
|
||||
await view.dispose();
|
||||
}
|
||||
}, 30_000);
|
||||
@ -0,0 +1,70 @@
|
||||
<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 ImageAdjustments from '../index';
|
||||
import type { ImageAdjustmentKey, ImageAdjustmentValues, ImageAdjustmentsSize } from '../types';
|
||||
|
||||
let {
|
||||
uix,
|
||||
onValueChange,
|
||||
onFilterChange
|
||||
}: {
|
||||
uix: ActiveUix;
|
||||
onValueChange: (value: ImageAdjustmentValues) => void;
|
||||
onFilterChange: (filter: string) => void;
|
||||
} = $props();
|
||||
// svelte-ignore state_referenced_locally
|
||||
setActiveUix(uix);
|
||||
Soma.create();
|
||||
const eidos = ActiveEidos.create({ applyDom: false });
|
||||
onDestroy(() => eidos.dispose());
|
||||
let value = $state<ImageAdjustmentValues>({});
|
||||
let adjustments = $state<ImageAdjustmentKey[]>([
|
||||
'brightness',
|
||||
'contrast',
|
||||
'saturation',
|
||||
'temperature',
|
||||
'hue',
|
||||
'blur',
|
||||
'grayscale',
|
||||
'sepia'
|
||||
]);
|
||||
let disabled = $state(false);
|
||||
let showReset = $state(true);
|
||||
let size = $state<ImageAdjustmentsSize>('md');
|
||||
let filter = $state('none');
|
||||
</script>
|
||||
|
||||
<section style="width: 360px; padding: 16px; background: white; color: black;" dir="rtl">
|
||||
<h1>ImageAdjustments audit</h1>
|
||||
<div
|
||||
data-filter-preview
|
||||
style:filter
|
||||
style="height: 48px; background: linear-gradient(90deg, red, green, blue);"
|
||||
></div>
|
||||
<ImageAdjustments
|
||||
bind:value
|
||||
{adjustments}
|
||||
{disabled}
|
||||
{showReset}
|
||||
{size}
|
||||
{onValueChange}
|
||||
onFilterChange={(next) => {
|
||||
filter = next;
|
||||
onFilterChange(next);
|
||||
}}
|
||||
/>
|
||||
<output data-adjustments-value>{JSON.stringify(value)}</output>
|
||||
<div>
|
||||
<button onclick={() => (disabled = !disabled)}>Toggle disabled</button>
|
||||
<button onclick={() => (showReset = !showReset)}>Toggle reset</button>
|
||||
<button onclick={() => (size = size === 'md' ? 'lg' : 'md')}>Toggle size</button>
|
||||
<button onclick={() => (adjustments = ['sepia', 'brightness'])}>Subset</button>
|
||||
<button onclick={() => (adjustments = ['brightness', 'sepia'])}>Reverse subset</button>
|
||||
<button onclick={() => (value = { brightness: 20, contrast: -20, hue: 30 })}
|
||||
>Seed externally</button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
@ -0,0 +1,44 @@
|
||||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import { vi } from 'vitest';
|
||||
import { createActiveUix } from '$active-uix';
|
||||
import '../../../index.css';
|
||||
import ImageAdjustmentsAuditHarness from './image-adjustments-audit-harness.svelte';
|
||||
import type { ImageAdjustmentValues } from '../types';
|
||||
|
||||
export function renderImageAdjustments() {
|
||||
const uix = createActiveUix({
|
||||
langs: { schema: {}, defaultLocale: 'es' },
|
||||
prefs: { storage: false },
|
||||
projectPrefs: false
|
||||
});
|
||||
const target = document.createElement('div');
|
||||
document.body.appendChild(target);
|
||||
const events: string[] = [];
|
||||
const onValueChange = vi.fn((_value: ImageAdjustmentValues) => events.push('value'));
|
||||
const onFilterChange = vi.fn((_filter: string) => events.push('filter'));
|
||||
const component = mount(ImageAdjustmentsAuditHarness, {
|
||||
target,
|
||||
props: { uix, onValueChange, onFilterChange }
|
||||
});
|
||||
flushSync();
|
||||
return {
|
||||
target,
|
||||
onValueChange,
|
||||
onFilterChange,
|
||||
events,
|
||||
root: target.querySelector<HTMLElement>('[data-image-adjustments]')!,
|
||||
value(): ImageAdjustmentValues {
|
||||
return JSON.parse(target.querySelector('[data-adjustments-value]')!.textContent!);
|
||||
},
|
||||
button(text: string) {
|
||||
return Array.from(target.querySelectorAll('button')).find(
|
||||
(element) => element.textContent?.trim() === text
|
||||
)!;
|
||||
},
|
||||
async dispose() {
|
||||
await unmount(component);
|
||||
uix.dispose();
|
||||
target.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
Loading…
Reference in new issue