refactor(uix): share image adjustment bindings and verify native-scale media

astra
dev 2 weeks ago
parent e3266ffac4
commit 88ec6b6f2b

@ -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 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. 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) ### Revisión de Chronos (2026-09-22)
El provider contiene 1.723 líneas físicas no vacías, pero su volumen no procede El provider contiene 1.723 líneas físicas no vacías, pero su volumen no procede

@ -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 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 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. 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.

@ -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. | | 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. | | 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. | | 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 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 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 distinto de la misma pauta que Q70, en ImageAdjustments, no un defecto
nuevo adjudicado al motor Slider por inferencia. 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 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 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, 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. gate completo 505/505 archivos y 5584/5584 tests. T01 queda cerrado.
Es deuda de pruebas separada de la cola funcional; Es deuda de pruebas separada de la cola funcional;
no permite dar por cerrado Q69 ni otro defecto del sistema. 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.

@ -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.

@ -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 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. siguen presentes; la extracción no los corrige ni cambia su timing.
Las sondas de contraste se retiraron después de terminar. 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.

@ -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 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 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. 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.

@ -53,6 +53,28 @@ inside soma. See
## Validation — what each script catches ## 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. These catch classes of bug that `check` (types) and an HTTP 200 (SSR) miss.
| Command | Catches | | Command | Catches |

@ -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 } }
})
}
}
});

@ -21,19 +21,16 @@ per adjustment from the `adjustments` prop — the consumer doesn't compose rows
## Comparativa ## Comparativa
| Capability | Untitled UI | Chakra | Radix | MUI | → UIX | | 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 |
| -------------------------------- | ----------------- | ------ | ----- | --- | --------------- | | --------------- | --------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------- | --------------------------------------- |
| Filter-slider panel primitive | ✅ (image-picker) | ❌ | ❌ | ❌ | ✅ | | Naming | Label and names per thumb | Composed accessible control | Label and thumb labels | thumbLabel | Visible ItemLabel; thumb naming gap Q77 |
| Live CSS `filter` output | ⚠️ canvas | — | — | — | ✅ string | | Values | Change/end callbacks | Controlled or default | value/onChange/onChangeEnd | Controlled value | Partial record and UIX callbacks |
| Composes the lib's own Slider | ✅ | n/a | n/a | n/a | ✅ | | Range | min/max/step | min/max/step and keyboard | minValue/maxValue/step | Step and keyboard step | Canonical catalog, one Slider per key |
| Configurable adjustment set | fixed | — | — | — | ✅ subset | | Read-out | Composition | Composition | SliderOutput | Formatter and marks | ItemValue without a live region |
| Reusable outside an image picker | ❌ | — | — | — | ✅ subcomponent |
| Sound / haptic on reset | ❌ | ❌ | ❌ | ❌ | ✅ sema | These references describe sliders, not complete filter editors. UIX owns the
filter catalog and composes its own Slider. The September audit compares these
Most libraries treat image filtering as app code, not a primitive. Untitled ships contracts without adding features or changing the approximation for temperature.
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.
## Props ## Props
@ -45,7 +42,7 @@ own Slider.
| `onValueChange` | `(v) => void` | — | full values record | | `onValueChange` | `(v) => void` | — | full values record |
| `onFilterChange` | `(filter) => void` | — | recomputed CSS `filter` — the real output | | `onFilterChange` | `(filter) => void` | — | recomputed CSS `filter` — the real output |
| `adjustments` | `ImageAdjustmentKey[]` | core 6 | which adjustments + order | | `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 | | `showReset` | `boolean` | `true` | render the reset control |
| `disabled` | `boolean` | `false` | disable sliders + reset | | `disabled` | `boolean` | `false` | disable sliders + reset |
@ -63,6 +60,16 @@ own Slider.
- **`temperature` is a CSS approximation** (warm→sepia, cool→hue-rotate+saturate); - **`temperature` is a CSS approximation** (warm→sepia, cool→hue-rotate+saturate);
there is no native white-balance primitive. 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 ## Talla y tema
**16 claves públicas** en `lib/recipes/base.ts`, todas `--image-adjustments-*`. **16 claves públicas** en `lib/recipes/base.ts`, todas `--image-adjustments-*`.

@ -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();
}
};
}

@ -2,8 +2,8 @@ import type { ProviderProps } from '$soma/components/image-adjustments';
import type { ResponsiveProp, Size } from '$uix/eidos/lib/types'; import type { ResponsiveProp, Size } from '$uix/eidos/lib/types';
/** /**
* Sizing subset ImageAdjustments exposes. Maps to the composed `<Slider>` size * Sizing subset ImageAdjustments exposes. Maps to the composed `<Slider>` size.
* + row typography. Narrowed per the per-component-subset doctrine. * Row typography uses recipe tokens. Narrowed per the per-component-subset doctrine.
*/ */
export type ImageAdjustmentsSize = Extract<Size, 'sm' | 'md' | 'lg'>; export type ImageAdjustmentsSize = Extract<Size, 'sm' | 'md' | 'lg'>;
@ -16,7 +16,7 @@ export type ImageAdjustmentsSize = Extract<Size, 'sm' | 'md' | 'lg'>;
* styles them via the `--image-adjustments-*` recipe tokens. * styles them via the `--image-adjustments-*` recipe tokens.
*/ */
export type ImageAdjustmentsProps = ProviderProps & { export type ImageAdjustmentsProps = ProviderProps & {
/** Sizing scale (slider + row typography). @default 'md' */ /** Sizing scale for the composed sliders. @default 'md' */
size?: ResponsiveProp<ImageAdjustmentsSize>; size?: ResponsiveProp<ImageAdjustmentsSize>;
/** Render the reset control below the rows. @default true */ /** Render the reset control below the rows. @default true */
showReset?: boolean; showReset?: boolean;

@ -63,7 +63,7 @@ intentionally out of scope.
Per-slider drag feedback (`handle-drag`) is owned by the composed `<Slider>` pack — Per-slider drag feedback (`handle-drag`) is owned by the composed `<Slider>` pack —
ImageAdjustments doesn't re-emit it. Pack: `src/uix/sema/components/image-adjustments.ts` 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. pulse.
## Props (Provider) ## Props (Provider)
@ -71,10 +71,16 @@ pulse.
| Prop | Type | Default | Notes | | Prop | Type | Default | Notes |
| ---------------- | ----------------------- | ------------------- | -------------------------------- | | ---------------- | ----------------------- | ------------------- | -------------------------------- |
| `value` | `ImageAdjustmentValues` | `{}` | bindable; missing keys = neutral | | `value` | `ImageAdjustmentValues` | `{}` | bindable; missing keys = neutral |
| `onValueChange` | `(v) => void` | — | full values record on any change | | `onValueChange` | `(v) => void` | — | full record on interaction/reset |
| `onFilterChange` | `(filter) => void` | — | recomputed CSS `filter` string | | `onFilterChange` | `(filter) => void` | — | filter on interaction/reset |
| `adjustments` | `ImageAdjustmentKey[]` | core 6 | which adjustments + order | | `adjustments` | `ImageAdjustmentKey[]` | core 6 | which adjustments + order |
| `disabled` | `boolean` | `false` | blocks sliders + reset | | `disabled` | `boolean` | `false` | blocks sliders + reset |
| `aria-label` | `string` | "Image adjustments" | names the group | | `aria-label` | `string` | "Image adjustments" | names the group |
Reuses `Slider` (one per `Item`). Never reinvents a slider. 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.

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { readableActive, writableActive } from '$libs/reactive'; import { bindProps } from '../../../provider';
import { mergeProps } from '../../../props'; import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id'; import { createId } from '$active-uix/id';
import { ImageAdjustmentsProvider } from '../image-adjustments-provider.svelte'; import { ImageAdjustmentsProvider } from '../image-adjustments-provider.svelte';
@ -20,23 +20,22 @@
...restProps ...restProps
}: ImageAdjustmentsProps = $props(); }: ImageAdjustmentsProps = $props();
const state = ImageAdjustmentsProvider.create({ const state = ImageAdjustmentsProvider.create(
id: readableActive(() => id), bindProps<Parameters<typeof ImageAdjustmentsProvider.create>[0]>({
ref: writableActive( id: () => id,
() => ref, ref: { get: () => ref, set: (v) => (ref = v) },
(v) => (ref = v) value: {
), get: () => value,
value: writableActive( set: (v) => {
() => value, value = v;
(v) => { onValueChange(v);
value = v; }
onValueChange(v); },
} onFilterChange: () => onFilterChange,
), adjustments: () => adjustments,
onFilterChange: readableActive(() => onFilterChange), disabled: () => disabled
adjustments: readableActive(() => adjustments), })
disabled: readableActive(() => disabled) );
});
const mergedProps = $derived(mergeProps(restProps, state.props)); const mergedProps = $derived(mergeProps(restProps, state.props));
</script> </script>

Loading…
Cancel
Save

Powered by TurnKey Linux.