From 88ec6b6f2b454f36763c61dd75768047e9251fd0 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 27 Sep 2026 00:30:05 +0200 Subject: [PATCH] refactor(uix): share image adjustment bindings and verify native-scale media --- docs/process/PLAN-uix-quality-2026-09-19.md | 23 +++ docs/process/audit-uix-2026-09-19/cropper.md | 4 + .../audit-uix-2026-09-19/findings-register.md | 27 +++- .../audit-uix-2026-09-19/image-adjustments.md | 102 +++++++++++++ .../audit-uix-2026-09-19/image-picker.md | 4 + docs/process/audit-uix-2026-09-19/zoom-pan.md | 4 + docs/testing-and-tooling.md | 22 +++ scripts/audit-visual.config.ts | 33 +++++ .../components/image-adjustments/README.md | 35 +++-- .../image-adjustments.svelte.test.ts | 138 ++++++++++++++++++ .../image-adjustments-audit-harness.svelte | 70 +++++++++ .../test/image-adjustments-audit-view.ts | 44 ++++++ .../components/image-adjustments/types.ts | 6 +- .../components/image-adjustments/README.md | 12 +- .../components/image-adjustments.svelte | 35 +++-- 15 files changed, 520 insertions(+), 39 deletions(-) create mode 100644 docs/process/audit-uix-2026-09-19/image-adjustments.md create mode 100644 scripts/audit-visual.config.ts create mode 100644 src/uix/eidos/components/image-adjustments/image-adjustments.svelte.test.ts create mode 100644 src/uix/eidos/components/image-adjustments/test/image-adjustments-audit-harness.svelte create mode 100644 src/uix/eidos/components/image-adjustments/test/image-adjustments-audit-view.ts diff --git a/docs/process/PLAN-uix-quality-2026-09-19.md b/docs/process/PLAN-uix-quality-2026-09-19.md index ab90f6742..9d46b99b8 100644 --- a/docs/process/PLAN-uix-quality-2026-09-19.md +++ b/docs/process/PLAN-uix-quality-2026-09-19.md @@ -1014,6 +1014,29 @@ abierta. No se acredita diálogo del sistema, touch, otros motores, lectores de pantalla ni sonido/vibración físicos. El gate compartido con Cropper pasa 505 archivos y 5584 tests, build y smoke 27/27 de apps/base. +### Panel de filtros: ImageAdjustments (2026-09-26) + +La [ficha de ImageAdjustments](./audit-uix-2026-09-19/image-adjustments.md) +revisa las cuatro capas, catálogo, wrappers y consumidores, y contrasta cuatro +primitivas de slider. Diez canarios con UIX real pasan junto a la suite previa: +22/22 antes de producción; después, 25/25 incluyendo opts-census y 7/7 del +helper compartido en una ejecución dirigida adicional. Se prueban +ocho keys por teclado, extremos, read-outs, orden value→filter, reset, disabled, +subconjuntos, identidad keyed, valores no visibles y talla. Se inspeccionan +capturas antes/después. Ocho sondas amplían Q77 a todos los thumbs; el ID +conserva su estado abierto y las sondas se retiran tras terminar. +bindProps elimina conexión repetida del root: seis enlaces iguales por AST, +**una línea física y no vacía menos** (50→49 y 45→44). No se unifican filtros de +Palabras con escalas distintas. Los README precisan tamaño, callback externo +sin emisión y sonido heredado. Catálogo, CSS y providers conservan comportamiento. +El inventario alcanza **67 fichas**, con fase 6 abierta; no se acredita lector +de pantalla, touch, otros motores ni audio/vibración físicos. El gate pasa +506 archivos/5594 tests, build y smoke 27/27. T02 registra capturas reducidas +por el iframe de Vitest y su repetición a 1:1 con audit-visual: 19/19 tests, +incluida una sonda temporal de escala que se retira después. Se inspeccionan +ocho imágenes de los cuatro controles. El gate con la herramienta también pasa +506/506 archivos y 5594/5594 tests; T02 queda cerrado. + ### Revisión de Chronos (2026-09-22) El provider contiene 1.723 líneas físicas no vacías, pero su volumen no procede diff --git a/docs/process/audit-uix-2026-09-19/cropper.md b/docs/process/audit-uix-2026-09-19/cropper.md index 3753c6b9b..7ec7c65eb 100644 --- a/docs/process/audit-uix-2026-09-19/cropper.md +++ b/docs/process/audit-uix-2026-09-19/cropper.md @@ -97,3 +97,7 @@ pack-census. El gate completo compartido con ImagePicker pasa: 505 archivos y 892 documentos sin errores ni avisos, build de apps/base y smoke 27/27 sin errores de página, consola ni CSP. La incidencia del canario ZoomPan bajo carga y su validación con capturas se registran como T01, separada de la cola funcional. + +Las capturas se repiten a escala 1:1 con audit-visual (T02): los tres canarios +de Cropper pasan dentro de los 19 tests dirigidos compartidos con otros controles +de imagen. Se inspeccionan de nuevo selección inicial y máscara redonda. diff --git a/docs/process/audit-uix-2026-09-19/findings-register.md b/docs/process/audit-uix-2026-09-19/findings-register.md index 7be9561ee..09c352497 100644 --- a/docs/process/audit-uix-2026-09-19/findings-register.md +++ b/docs/process/audit-uix-2026-09-19/findings-register.md @@ -250,7 +250,7 @@ Ninguno entra en la cola de correcciones confirmadas por inferencia. | --- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | Q75 | ImagePicker payload | La selección emite file nuevo con url null; remove emite file null con la URL anterior. El efecto de URL se ejecuta después del callback. | Dos sondas Chromium con PNG y URLs reales fallan. | Fijar correspondencia y timing de callbacks, reemplazo y cleanup. | | Q76 | ImagePicker onChange | ArrowUp cambia brillo y preview, pero conserva una sola emisión de selección. Cambiar fit externamente a contain tampoco emite. | Dos sondas con teclado/clic nativos fallan. | Adjudicar y cubrir el contrato que promete full value whenever anything changes. | -| Q77 | ImageAdjustments ARIA | El slider de brillo embebido tiene nombre accesible vacío; aria-label del root compuesto no nombra su thumb. | Matcher Chromium recibe vacío en vez de Brillo. | Cubrir nombres de todos los thumbs, traducciones y teclado. | +| Q77 | ImageAdjustments ARIA | Los ocho thumbs tienen nombre accesible vacío; aria-label del root compuesto no nombra el thumb. | Ocho sondas Chromium del panel real reciben vacío; brillo embebido ya estaba reproducido. | Cubrir nombres de todos los thumbs, traducciones y teclado. | | Q78 | ImagePicker / FileUpload | La transición empty→ready desmonta FileUpload antes de emitir commit-set-add; el runtime informa destino provider ausente y omite señal perceptual. | Sonda captura el diagnóstico del logger real tras upload nativo; selección y preview funcionan. | Conservar el destino semántico y el handler durante selección/desmontaje. | Las seis sondas fallaron mientras pasaban 21 tests de caracterización en @@ -269,6 +269,10 @@ Un botón que cambia el binding fit a contain reproduce la misma ausencia. Q77: el matcher de nombre del thumb de brillo recibe vacío. Es un uso distinto de la misma pauta que Q70, en ImageAdjustments, no un defecto nuevo adjudicado al motor Slider por inferencia. +La auditoría propia del panel amplía la reproducción a brightness, contrast, +saturation, temperature, hue, blur, grayscale y sepia: ocho sondas fallan +mientras pasan 22 tests de caracterización en dos archivos. Se montan servicios +UIX reales y las sondas se retiran al terminar. Q77 conserva su ID y sigue abierto. Q78: 100ms después de la selección, console recibe el mensaje del runtime file-upload.commit-set-add found no DOM element for target part provider. El flujo sigue en ready y el handler corre; se pierde la señal, @@ -761,3 +765,24 @@ Validación final: 12/12 tests dirigidos con capturas, ambas inspeccionadas; gate completo 505/505 archivos y 5584/5584 tests. T01 queda cerrado. Es deuda de pruebas separada de la cola funcional; no permite dar por cerrado Q69 ni otro defecto del sistema. + +## T02 — escala de las capturas de auditoría (infraestructura de pruebas) + +Fecha: 2026-09-27. El panel mide 392px en su DOM y body/section tienen zoom=1, +pero el iframe de Vitest mide 156.88px frente a sus 414px CSS. Las capturas +guardadas tenían 158px de ancho. La sonda que exige proporción 1 falla; no +demuestra que la receta reduzca el componente. + +Con sólo browser.ui=false el iframe pasa a 332.68px: la sonda sigue fallando +mientras pasan 18 canarios funcionales. El contexto Playwright exterior de +1280×1400 mantiene la escala 1: la configuración temporal pasa 19/19 tests +en cinco archivos. La configuración mantenida scripts/audit-visual.config.ts +repite 19/19, reutilizando plugins, aliases, descubrimiento y browsers normales. +Se inspeccionan ocho capturas nuevas de ZoomPan, Cropper, ImagePicker y +ImageAdjustments. Se retira la sonda y los artefactos de fallo se conservan en tmp. + +El gate previo de ImageAdjustments pasa 506 archivos/5594 tests. El gate con la +herramienta mantenida repite 506/506 archivos y 5594/5594 tests: tipos de +src/scripts sin errores, 893 documentos sin incidencias y smoke apps/base 27/27. +T02 queda cerrado como infraestructura visual, separada de la cola funcional; +no cierra Q69–Q78. diff --git a/docs/process/audit-uix-2026-09-19/image-adjustments.md b/docs/process/audit-uix-2026-09-19/image-adjustments.md new file mode 100644 index 000000000..410aca97b --- /dev/null +++ b/docs/process/audit-uix-2026-09-19/image-adjustments.md @@ -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. diff --git a/docs/process/audit-uix-2026-09-19/image-picker.md b/docs/process/audit-uix-2026-09-19/image-picker.md index 98c0e5235..6607a93bc 100644 --- a/docs/process/audit-uix-2026-09-19/image-picker.md +++ b/docs/process/audit-uix-2026-09-19/image-picker.md @@ -88,3 +88,7 @@ de bindProps mientras pasan los cuatro canarios: selección con URL null, remove con URL previa y ajustes/fit sin nueva emisión. Esos defectos siguen presentes; la extracción no los corrige ni cambia su timing. Las sondas de contraste se retiraron después de terminar. + +Se repiten las capturas de ready y rotación a escala 1:1 con audit-visual +(T02) y se inspeccionan ambas. Los cuatro canarios pasan en la comprobación +compartida de 19 tests con ZoomPan/Cropper/ImageAdjustments y la sonda de escala. diff --git a/docs/process/audit-uix-2026-09-19/zoom-pan.md b/docs/process/audit-uix-2026-09-19/zoom-pan.md index 7b370dd00..4fab93339 100644 --- a/docs/process/audit-uix-2026-09-19/zoom-pan.md +++ b/docs/process/audit-uix-2026-09-19/zoom-pan.md @@ -76,3 +76,7 @@ nativos, geometría, inversión, reset, trustedClicks y el presupuesto de dirigida con captura antes del gate completo: 12/12 tests pasan en dos archivos y se inspeccionan ambas imágenes. La repetición del gate pasa 505/505 archivos y 5584/5584 tests; T01 queda validado sin cambiar el motor ni ampliar el timeout. + +T02 amplía la comprobación de capturas: se repiten ancla y límite a escala 1:1 +con audit-visual y se inspeccionan ambas. El canario pasa junto a los controles +de imagen en la ejecución dirigida de 19 tests y cinco archivos. diff --git a/docs/testing-and-tooling.md b/docs/testing-and-tooling.md index f6e212068..f08aad075 100644 --- a/docs/testing-and-tooling.md +++ b/docs/testing-and-tooling.md @@ -53,6 +53,28 @@ inside soma. See ## Validation — what each script catches +### Native-scale audit captures + +The Vitest UI can scale its test iframe to fit the outer browser window. +DOM geometry inside that iframe can remain correct while saved element +screenshots are reduced. `scripts/audit-visual.config.ts` reuses the normal +plugins, aliases, client discovery and browser instances with a larger outer +Playwright viewport and the UI disabled. Use this client profile for audit +captures and `npm run gate` for full validation. + +For canaries that collect images only when requested, in PowerShell: + +```powershell +$env:VITE_UIX_AUDIT_SCREENSHOTS = '1' +npx vitest run --config scripts/audit-visual.config.ts src/uix/eidos/components/image-adjustments/image-adjustments.svelte.test.ts +Remove-Item Env:VITE_UIX_AUDIT_SCREENSHOTS +``` + +Check captured dimensions against the rendered DOM when changing viewport +settings. Disabling the UI alone did not produce 1:1 captures in the September +audit; its diagnostic and measurements are recorded as +[T02](./process/audit-uix-2026-09-19/findings-register.md). + These catch classes of bug that `check` (types) and an HTTP 200 (SSR) miss. | Command | Catches | diff --git a/scripts/audit-visual.config.ts b/scripts/audit-visual.config.ts new file mode 100644 index 000000000..e9e7be234 --- /dev/null +++ b/scripts/audit-visual.config.ts @@ -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 } } + }) + } + } +}); diff --git a/src/uix/eidos/components/image-adjustments/README.md b/src/uix/eidos/components/image-adjustments/README.md index 6aefd2140..660b9bb00 100644 --- a/src/uix/eidos/components/image-adjustments/README.md +++ b/src/uix/eidos/components/image-adjustments/README.md @@ -21,19 +21,16 @@ per adjustment from the `adjustments` prop — the consumer doesn't compose rows ## Comparativa -| Capability | Untitled UI | Chakra | Radix | MUI | → UIX | -| -------------------------------- | ----------------- | ------ | ----- | --- | --------------- | -| Filter-slider panel primitive | ✅ (image-picker) | ❌ | ❌ | ❌ | ✅ | -| Live CSS `filter` output | ⚠️ canvas | — | — | — | ✅ string | -| Composes the lib's own Slider | ✅ | n/a | n/a | n/a | ✅ | -| Configurable adjustment set | fixed | — | — | — | ✅ subset | -| Reusable outside an image picker | ❌ | — | — | — | ✅ subcomponent | -| Sound / haptic on reset | ❌ | ❌ | ❌ | ❌ | ✅ sema | - -Most libraries treat image filtering as app code, not a primitive. Untitled ships -an adjustment-sliders block (the visual reference); UIX makes it a reusable -subcomponent that emits a portable `filter` string and composes the framework's -own Slider. +| Slider contract | [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 | +| --------------- | --------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------- | --------------------------------------- | +| Naming | Label and names per thumb | Composed accessible control | Label and thumb labels | thumbLabel | Visible ItemLabel; thumb naming gap Q77 | +| Values | Change/end callbacks | Controlled or default | value/onChange/onChangeEnd | Controlled value | Partial record and UIX callbacks | +| Range | min/max/step | min/max/step and keyboard | minValue/maxValue/step | Step and keyboard step | Canonical catalog, one Slider per key | +| Read-out | Composition | Composition | SliderOutput | Formatter and marks | ItemValue without a live region | + +These references describe sliders, not complete filter editors. UIX owns the +filter catalog and composes its own Slider. The September audit compares these +contracts without adding features or changing the approximation for temperature. ## Props @@ -45,7 +42,7 @@ own Slider. | `onValueChange` | `(v) => void` | — | full values record | | `onFilterChange` | `(filter) => void` | — | recomputed CSS `filter` — the real output | | `adjustments` | `ImageAdjustmentKey[]` | core 6 | which adjustments + order | -| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | slider + row typography | +| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | composed slider size | | `showReset` | `boolean` | `true` | render the reset control | | `disabled` | `boolean` | `false` | disable sliders + reset | @@ -63,6 +60,16 @@ own Slider. - **`temperature` is a CSS approximation** (warm→sepia, cool→hue-rotate+saturate); there is no native white-balance primitive. +Interaction and reset emit `onValueChange` followed by `onFilterChange`. +External `value` or `adjustments` updates recompute provider state and rows; +they do not emit these callbacks themselves. A callback-fed preview therefore +does not automatically follow an externally seeded value. This is the observed +behavior preserved by the audit, not a new effect or notification policy. + +Q77 is reproduced in all eight thumbs: their visible labels do not currently +become accessible names. It remains in the +[post-audit register](../../../../../docs/process/audit-uix-2026-09-19/findings-register.md). + ## Talla y tema **16 claves públicas** en `lib/recipes/base.ts`, todas `--image-adjustments-*`. diff --git a/src/uix/eidos/components/image-adjustments/image-adjustments.svelte.test.ts b/src/uix/eidos/components/image-adjustments/image-adjustments.svelte.test.ts new file mode 100644 index 000000000..332f66ae0 --- /dev/null +++ b/src/uix/eidos/components/image-adjustments/image-adjustments.svelte.test.ts @@ -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(`[data-adjustment="${key}"]`)!; + const thumb = row.querySelector('[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('[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('[data-adjustment="brightness"]')!; + const thumb = brightness.querySelector('[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('[data-image-adjustments-item-label]')!; + const fontSize = getComputedStyle(label).fontSize; + const thumb = view.root.querySelector('[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('[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); diff --git a/src/uix/eidos/components/image-adjustments/test/image-adjustments-audit-harness.svelte b/src/uix/eidos/components/image-adjustments/test/image-adjustments-audit-harness.svelte new file mode 100644 index 000000000..090a65a96 --- /dev/null +++ b/src/uix/eidos/components/image-adjustments/test/image-adjustments-audit-harness.svelte @@ -0,0 +1,70 @@ + + +
+

ImageAdjustments audit

+
+ { + filter = next; + onFilterChange(next); + }} + /> + {JSON.stringify(value)} +
+ + + + + + +
+
diff --git a/src/uix/eidos/components/image-adjustments/test/image-adjustments-audit-view.ts b/src/uix/eidos/components/image-adjustments/test/image-adjustments-audit-view.ts new file mode 100644 index 000000000..6a9b0ac04 --- /dev/null +++ b/src/uix/eidos/components/image-adjustments/test/image-adjustments-audit-view.ts @@ -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('[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(); + } + }; +} diff --git a/src/uix/eidos/components/image-adjustments/types.ts b/src/uix/eidos/components/image-adjustments/types.ts index 78f55500f..6980714d5 100644 --- a/src/uix/eidos/components/image-adjustments/types.ts +++ b/src/uix/eidos/components/image-adjustments/types.ts @@ -2,8 +2,8 @@ import type { ProviderProps } from '$soma/components/image-adjustments'; import type { ResponsiveProp, Size } from '$uix/eidos/lib/types'; /** - * Sizing subset ImageAdjustments exposes. Maps to the composed `` size - * + row typography. Narrowed per the per-component-subset doctrine. + * Sizing subset ImageAdjustments exposes. Maps to the composed `` size. + * Row typography uses recipe tokens. Narrowed per the per-component-subset doctrine. */ export type ImageAdjustmentsSize = Extract; @@ -16,7 +16,7 @@ export type ImageAdjustmentsSize = Extract; * styles them via the `--image-adjustments-*` recipe tokens. */ export type ImageAdjustmentsProps = ProviderProps & { - /** Sizing scale (slider + row typography). @default 'md' */ + /** Sizing scale for the composed sliders. @default 'md' */ size?: ResponsiveProp; /** Render the reset control below the rows. @default true */ showReset?: boolean; diff --git a/src/uix/soma/components/image-adjustments/README.md b/src/uix/soma/components/image-adjustments/README.md index 73b0d07ee..4fcaf79d0 100644 --- a/src/uix/soma/components/image-adjustments/README.md +++ b/src/uix/soma/components/image-adjustments/README.md @@ -63,7 +63,7 @@ intentionally out of scope. Per-slider drag feedback (`handle-drag`) is owned by the composed `` pack — ImageAdjustments doesn't re-emit it. Pack: `src/uix/sema/components/image-adjustments.ts` -(`tick` + a `tap`). `sequence: 'post'` — the values mutate first, then the +(`tap`, with sound inherited from the semantic engine). `sequence: 'post'` — the values mutate first, then the pulse. ## Props (Provider) @@ -71,10 +71,16 @@ pulse. | Prop | Type | Default | Notes | | ---------------- | ----------------------- | ------------------- | -------------------------------- | | `value` | `ImageAdjustmentValues` | `{}` | bindable; missing keys = neutral | -| `onValueChange` | `(v) => void` | — | full values record on any change | -| `onFilterChange` | `(filter) => void` | — | recomputed CSS `filter` string | +| `onValueChange` | `(v) => void` | — | full record on interaction/reset | +| `onFilterChange` | `(filter) => void` | — | filter on interaction/reset | | `adjustments` | `ImageAdjustmentKey[]` | core 6 | which adjustments + order | | `disabled` | `boolean` | `false` | blocks sliders + reset | | `aria-label` | `string` | "Image adjustments" | names the group | Reuses `Slider` (one per `Item`). Never reinvents a slider. + +Callbacks fire through `setAdjustment` and `reset`, in value→filter order. +External controlled prop updates recompute `filter`, `isModified` and item +values without emitting those callbacks. A callback-fed preview is only updated +by interaction/reset; consumers can read the derived snippet state instead. +The September audit characterizes this distinction without changing behavior. diff --git a/src/uix/soma/components/image-adjustments/components/image-adjustments.svelte b/src/uix/soma/components/image-adjustments/components/image-adjustments.svelte index 421cdb981..620b43dfe 100644 --- a/src/uix/soma/components/image-adjustments/components/image-adjustments.svelte +++ b/src/uix/soma/components/image-adjustments/components/image-adjustments.svelte @@ -1,5 +1,5 @@