From 6181dba46db30006304b6904b421df4ad06c7c23 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 20 May 2026 23:47:08 +0200 Subject: [PATCH] form: PASS audit + refine R-1.2 to require morfo declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full walk of Form (largest finding set in the project): - Morfo: validated. 14 parts — Provider/Submit/Reset/ErrorSummary + 9 AutoFields parts for the reflective renderer. Provider declares data-pending/dirty/touched/invalid/submitted on the contract. Sema events `commit-submit` (fulfill), `signal-invalid` (risk), `commit-reset` (neutral) are correct. - `texts.label` added with catalog entry (`label = 'Formulario' / 'Form'`). - Recipe CSS: `[data-form][data-invalid]` rule added. Low-emphasis affordance — the ErrorSummary picks up the risk border but field cells keep their own `[data-invalid]` styling via the Field recipe. - README rewritten with canonical sections: - `## Baseline` summarizing air / soma / morfo coverage - `## Comparativa` (was `## Reference Comparison`) — extended to include shadcn-svelte plus AutoFields differentiators (discriminated unions, array fields, first-error focus, validation timing modes) - `## Decisiones` documenting the small-wrapper rule, AutoFields exception, validation timing ownership, the no-noise-on-load default, sema event placement, and the low-emphasis invalid treatment - `## Gaps` (new) with disposition markers — apg is `descartar` (no APG for "Form" — APG covers individual widgets), AutoFields i18n is `implementar`, multi-step / submission feedback / etc. are `diferir`, auto-save / optimistic UI are `descartar` Audit script refinement: - R-1.2 (data-disabled styles) now only fires when the morfo *declares* `data-disabled` on any part. Form's Provider doesn't emit a disabled state at the root (individual fields handle it themselves), so demanding defensive CSS for a state the contract never emits was a false-positive. The rule still fires correctly for components that DO declare `data-disabled` in their morfo. Audit: PASS 7 → 8. Form flips to PASS with only one remaining warn (`A-1.4` no apg URL — `descartar` documented as a gap). Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/component-audit.ts | 11 +++- src/uix/eidos/components/form/README.md | 75 ++++++++++++++++++++----- src/uix/eidos/components/form/form.css | 13 +++++ src/uix/langs/components/form.ts | 12 ++-- src/uix/morfo/components/form.ts | 1 + 5 files changed, 93 insertions(+), 19 deletions(-) diff --git a/scripts/component-audit.ts b/scripts/component-audit.ts index 72853666a..1af7c763c 100644 --- a/scripts/component-audit.ts +++ b/scripts/component-audit.ts @@ -553,12 +553,19 @@ function checkRecipe(kebab: string, info: ComponentReport): CheckResult[] { if (hasRoot) out.push(pass('R-1.1', 'error')); else out.push(fail('R-1.1', 'error', `Missing root selector [data-${kebab}]`)); - // R-1.2: data-disabled state styled if interactive - if (info.interactive) { + // R-1.2: data-disabled state styled. Only required when the morfo + // actually declares `data-disabled` on any part — otherwise the audit + // would demand defensive styling for state the contract never emits. + const morfoDeclaresDisabled = (tryRead(join(MORFO_DIR, `${kebab}.ts`)) ?? '').includes( + 'data-disabled' + ); + if (info.interactive && morfoDeclaresDisabled) { const hasDisabled = /\[data-disabled\b/.test(css); if (hasDisabled) out.push(pass('R-1.2', 'error')); else out.push(fail('R-1.2', 'error', 'No [data-disabled] styles')); + } + if (info.interactive) { // R-1.5: focus-visible const hasFocus = /:focus-visible\b/.test(css); if (hasFocus) out.push(pass('R-1.5', 'error')); diff --git a/src/uix/eidos/components/form/README.md b/src/uix/eidos/components/form/README.md index 725162ea1..724052114 100644 --- a/src/uix/eidos/components/form/README.md +++ b/src/uix/eidos/components/form/README.md @@ -6,6 +6,19 @@ state, error aggregation, reset and first-error focus. Eidos owns only visual attrs: `data-size`, `data-layout`, `data-variant` on the root and `data-size`/`data-variant`/`data-color` on actions. +## Baseline + +- **air**: no había `Form` reflexivo. Air tenía field individuales + un + contenedor `
` HTML plano sin auto-rendering ni error aggregation. +- **soma actual**: posee runtime completo (`createForm` en `$libs/forms`), + Standard Schema/SIUM validation, registro de fields, dirty/touched, error + aggregation, reset, first-error focus. `Form.AutoFields` renderiza fields + reflexivamente desde el schema SIUM. +- **morfo**: declara `Provider` + actions (`Submit`, `Reset`, + `ErrorSummary`) + 11 parts de AutoFields para selectores de la receta. + Eventos sema: `commit-submit` (fulfill), `signal-invalid` (risk), + `commit-reset` (neutral). + ## API Shape ```svelte @@ -67,17 +80,53 @@ invalid value, unless a demo explicitly documents an initially invalid state. AutoFields structural parts are declared in `formMorfo` because their selectors are part of the recipe. They do not create new semantic events. -## Reference Comparison +## Comparativa -| Capability | Radix Form | Ark Field | React Aria | UIX | -| --- | --- | --- | --- | --- | -| Native form submit/reset | yes | yes | yes | yes | -| Field registration | yes | yes | yes | yes | -| Schema validation | native/custom | external | external | Standard Schema, SIUM shown in docs | -| Dirty/touched state | partial | yes | yes | yes | -| Error summary | yes | partial | partial | yes | -| Schema-driven field generation | no | no | no | `Form.AutoFields` for SIUM | - -Decision: the visual wrapper remains small. If Form needs new behavior, it goes -to `$libs/forms`, Soma Form or Morfo first; Eidos only exposes the visual recipe -for the resulting parts and state. +| Capability | Radix Form | Ark Field | React Aria | shadcn-svelte | UIX | +| --- | --- | --- | --- | --- | --- | +| Native form submit/reset | yes | yes | yes | yes | yes | +| Field registration | yes | yes | yes | yes | yes | +| Schema validation | native/custom | external | external | external | Standard Schema + SIUM nativo | +| Dirty/touched state | partial | yes | yes | partial | yes | +| Error summary aggregada con `aria-live` | yes | partial | partial | partial | yes | +| Schema-driven field generation | no | no | no | no | `Form.AutoFields` reflexivo sobre SIUM | +| Discriminated unions auto-renderizadas | no | no | no | no | yes (`AutoFieldsDiscriminated`) | +| Array fields auto-renderizadas | no | no | partial | no | yes (`AutoFieldsArray`) | +| First-error focus on submit fail | yes | no | yes | no | yes | +| Validation timing modes (progressive/onBlur/onChange/onSubmit) | partial | yes | partial | partial | yes | + +## Decisiones + +- **El wrapper Eidos es deliberadamente pequeño**: si Form necesita + comportamiento nuevo, va a `$libs/forms`, Soma Form o Morfo primero; + Eidos sólo expone receta visual para parts y estados que ya existen. +- **`Form.AutoFields` es la excepción**: es un renderer reflexivo sobre + introspección SIUM. Debe vivir dentro de `` para usar el contexto + activo del form y el schema. No es un state holder, es un mapper + schema→DOM. +- **Validation timing pertenece a Soma / `$libs/forms`**, no a Eidos. + Valores soportados: `progressive`, `onSubmit`, `onBlur`, `onChange`. + El demo usa `onChange` (validación tiempo real). +- **Demos arrancan con defaults válidos**: `onChange` validaría + inmediatamente y mostraría errores antes de que el usuario edite, lo + cual es ruido perceptivo. Sólo demos que explícitamente quieran un + estado inválido inicial parten así. +- **Sema sólo en commit-submit / signal-invalid / commit-reset**: los + cambios per-field son del Field, no del Form. El Form sólo emite en + los momentos donde el "todo" cambia (envío, fallo de validación + global, reset). +- **`data-invalid` en el root es una señal de bajo énfasis**: marca el + ErrorSummary con borde risk pero NO repinta los fields individuales — + esos tienen su propio `[data-invalid]` desde el Field recipe. + +## Gaps + +| Gap | Disposición | Detalle | +| --- | --- | --- | +| `apg` URL canónica | **descartar** | No hay un APG específico para "Form"; APG cubre widgets individuales. El form HTML nativo es la referencia accesibilidad. | +| Action confirmation modal antes de submit destructivo | **diferir** | Caso aún sin patrón. Cuando aparezca, vive en Dialog/AlertDialog que el consumer monta dentro de `Form.Submit`. | +| AutoFields i18n (labels desde el schema) | **implementar** | SIUM `meta({ label })` resuelve por idlangref. Falta documentar el patrón y validar con `translations:check`. | +| Multi-step form / wizard | **diferir** | Composición sobre Stepper + Form. No es responsabilidad de Form. Sin patrón canónico definido aún. | +| Submission feedback (toast on success) | **diferir** | El consumer decide; el contrato de Form ya emite `commit-submit` para que un sema listener arme el toast. | +| Optimistic UI rollback | **descartar** | Patrón de aplicación; Form no debe asumir transport. | +| Auto-save / draft persistence | **descartar** | Fuera de scope visual; vive en `$libs/forms` si fuera necesario. | diff --git a/src/uix/eidos/components/form/form.css b/src/uix/eidos/components/form/form.css index 8c4c6a6b4..ba58f4245 100644 --- a/src/uix/eidos/components/form/form.css +++ b/src/uix/eidos/components/form/form.css @@ -55,6 +55,19 @@ cursor: progress; } +/* Whole-form invalid signal: a thin top accent and the ErrorSummary + bordering match the risk tone. Individual fields keep their own + `[data-invalid]` styling via the Field recipe — the form-level + indicator is a low-emphasis affordance so the page-level "errors + present" state reads at a glance. */ +[data-form][data-invalid] { + --_form-invalid-accent: var(--color-risk-border); +} + +[data-form][data-invalid] [data-form-error-summary] { + border-color: var(--_form-invalid-accent); +} + [data-form-submit], [data-form-reset], [data-form-auto-fields-array-add], diff --git a/src/uix/langs/components/form.ts b/src/uix/langs/components/form.ts index df33fba70..278b77bd6 100644 --- a/src/uix/langs/components/form.ts +++ b/src/uix/langs/components/form.ts @@ -8,12 +8,16 @@ import type { LangNode } from '$libs/langs'; * `'#?components.form.{key}|fallback'`. */ export const formLangs = { + label: { + es: 'Formulario', + en: 'Form' + }, 'error-summary-title': { - es: "{{count}} errores del formulario necesitan tu atención", - en: "{{count}} form errors need your attention" + es: '{{count}} errores del formulario necesitan tu atención', + en: '{{count}} form errors need your attention' }, 'error-summary-single': { - es: "1 error del formulario necesita tu atención", - en: "1 form error needs your attention" + es: '1 error del formulario necesita tu atención', + en: '1 form error needs your attention' } } satisfies LangNode; diff --git a/src/uix/morfo/components/form.ts b/src/uix/morfo/components/form.ts index 2c80955fa..83cf6ddb2 100644 --- a/src/uix/morfo/components/form.ts +++ b/src/uix/morfo/components/form.ts @@ -6,6 +6,7 @@ export const formMorfo = { kebab: 'form', scope: ['soma', 'sema'], texts: { + label: '#?components.form.label|Form', 'error-summary-title': '#?components.form.error-summary-title|{{count}} form errors need your attention', 'error-summary-single': '#?components.form.error-summary-single|1 form error needs your attention' },