form: PASS audit + refine R-1.2 to require morfo declaration

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) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 714df0e5c9
commit 6181dba46d

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

@ -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 `<form>` 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 `<Form>` 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. |

@ -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],

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

@ -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'
},

Loading…
Cancel
Save

Powered by TurnKey Linux.