diff --git a/continue.md b/continue.md index e6c9e50f9..e93c1f4b9 100644 --- a/continue.md +++ b/continue.md @@ -210,10 +210,28 @@ Actualizacion smoke 2026-05-16: - `src/uix/soma/components/range-calendar/README.md` - `scripts/probe-toast-dismiss.ts` ahora usa por defecto `/uix/components/toast`. -- Pendiente deliberado: `scripts/morfo-check.ts` y - `scripts/permutation-check.ts` siguen siendo runners historicos del arbol - `/test/soma`; no usarlos como release gate hasta reconectarlos a una route - map real del docs shell actual. +- `scripts/morfo-check.ts` queda reconectado parcialmente: valida los morfos + que tienen ruta concreta bajo `/uix/components/{kebab}` y marca el resto como + `SKIP` en vez de fallar por rutas inexistentes. Se puede cambiar el prefijo + con `MORFO_ROUTE_PREFIX`. +- Al volver a ejecutar `morfo-check`, aparecieron y se corrigieron dos + incoherencias de Morfo: + - `fieldMorfo` declaraba los valores de `data-state` como `states`, pero su + fuente runtime real es `state`; tambien se corrige `aria-busy` para mirar + `state === 'loading'`. + - `schema.ts` permite ahora `emit: 'value'` sin `value` source cuando el attr + es declarativo/manual. Esto cubre `Form.AutoFields`, donde el componente + escribe valores como `data-form-auto-fields-field={path}` sin pasar por + `SomaRuntime`. +- Validacion: + - `node --import tsx/esm scripts/morfo-check.ts http://127.0.0.1:5182` -> + 14 morfos con ruta OK, 53 `SKIP`. + - `npx vitest run src/uix/morfo/schema.test.ts src/uix/morfo/registry.test.ts src/uix/morfo/compile.test.ts` + -> 41 tests OK. + - `npm run check` -> 0 errores, 0 warnings. +- Pendiente deliberado: `scripts/permutation-check.ts` sigue siendo runner + historico del arbol `/test/soma`; no usarlo como release gate hasta + reconectarlo a una route map real del docs shell actual. Actualizacion Eidos visual wrappers 2026-05-16: diff --git a/scripts/morfo-check.ts b/scripts/morfo-check.ts index 1118b08e7..231e4dfdb 100644 --- a/scripts/morfo-check.ts +++ b/scripts/morfo-check.ts @@ -2,7 +2,7 @@ * Morfo DOM-validation check. * * For each morfo at `src/uix/morfo/components/*.ts`: - * 1. Navigate to the matching demo route at `/test/soma/{kebab}`. + * 1. Navigate to the matching routed demo at `/uix/components/{kebab}`. * 2. For every `public` part declared in the morfo, query the DOM for * `[data-{component}-{part}]` (or `[data-{component}]` for root). * 3. For each matching element, verify: @@ -13,15 +13,16 @@ * the morfo (strict mode — excludes `data-_*` privates). * * Runs after `npm run smoke` passes. Requires `npm run dev` running. + * Morfos without a current routed demo are reported as SKIP. * * Exit codes: - * 0 — all morfos validate against their rendered DOM. + * 0 — all routed morfos validate against their rendered DOM. * 1 — at least one morfo-vs-DOM discrepancy. - * 2 — no dev server / couldn't load morfos. + * 2 — no dev server / couldn't load morfos / no routed morfos. */ import { chromium, type Page } from 'playwright'; -import { readdirSync } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { dirname, join } from 'node:path'; import type { Morfo, MorfoPart, MorfoData } from '../src/uix/morfo/types'; @@ -29,6 +30,29 @@ import { validateMorfo } from '../src/uix/morfo/schema'; const __dirname = dirname(fileURLToPath(import.meta.url)); const MORFOS_DIR = join(__dirname, '..', 'src', 'uix', 'morfo', 'components'); +const ROUTES_DIR = join(__dirname, '..', 'web', 'routes'); +const DEFAULT_ROUTE_PREFIX = '/uix/components'; +const routePrefix = normaliseRoutePrefix(process.env.MORFO_ROUTE_PREFIX ?? DEFAULT_ROUTE_PREFIX); + +function normaliseRoutePrefix(value: string): string { + const trimmed = value.trim().replace(/\/+$/, ''); + if (!trimmed || trimmed === '/') return ''; + return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; +} + +function routeToPageFile(route: string): string { + const segments = route + .split('/') + .filter(Boolean) + .filter((segment) => !(segment.startsWith('(') && segment.endsWith(')'))); + + return join(ROUTES_DIR, ...segments, '+page.svelte'); +} + +function routeForMorfo(morfo: Morfo): string | null { + const route = `${routePrefix}/${morfo.kebab}`.replace(/\/+/g, '/'); + return existsSync(routeToPageFile(route)) ? route : null; +} async function probePort(start: number, end: number): Promise { for (let port = start; port <= end; port++) { @@ -186,9 +210,18 @@ console.error(`Loaded ${morfos.length} morfo${morfos.length === 1 ? '' : 's'}`); const browser = await chromium.launch(); const ctx = await browser.newContext(); const failures: Array<{ morfo: string; issues: Issue[] }> = []; +const skipped: string[] = []; +let checked = 0; for (const morfo of morfos) { - const route = `/test/soma/${morfo.kebab}`; + const route = routeForMorfo(morfo); + if (!route) { + skipped.push(morfo.kebab); + console.log(`SKIP ${morfo.kebab.padEnd(24)} no routed demo under ${routePrefix}`); + continue; + } + + checked += 1; const page = await ctx.newPage(); try { await page.goto(BASE + route, { waitUntil: 'networkidle', timeout: 20000 }); @@ -215,13 +248,24 @@ for (const morfo of morfos) { await browser.close(); console.log(''); -if (failures.length === 0) { - console.log(`All ${morfos.length} morfo${morfos.length === 1 ? '' : 's'} validate against their demo DOM.`); +if (checked === 0) { + console.log(`No routed morfos found under ${routePrefix}.`); + process.exit(2); +} else if (failures.length === 0) { + console.log( + `All ${checked} routed morfo${checked === 1 ? '' : 's'} validate against their demo DOM.` + ); + if (skipped.length > 0) { + console.log(`${skipped.length}/${morfos.length} morfos skipped without routed demo.`); + } process.exit(0); } else { const totalIssues = failures.reduce((sum, f) => sum + f.issues.length, 0); console.log( - `${failures.length}/${morfos.length} morfos failed (${totalIssues} total issues): ${failures.map((f) => f.morfo).join(', ')}` + `${failures.length}/${checked} routed morfos failed (${totalIssues} total issues): ${failures.map((f) => f.morfo).join(', ')}` ); + if (skipped.length > 0) { + console.log(`${skipped.length}/${morfos.length} morfos skipped without routed demo.`); + } process.exit(1); } diff --git a/src/uix/morfo/README.md b/src/uix/morfo/README.md index 29ad87d98..95ef7e046 100644 --- a/src/uix/morfo/README.md +++ b/src/uix/morfo/README.md @@ -425,6 +425,9 @@ data: [ // Free value: writes the raw value, not an empty presence attr. { attr: 'data-value', value: v.propRef('value'), emit: 'value' }, + // Free value written manually by the component, not by SomaRuntime. + { attr: 'data-form-auto-fields-field', emit: 'value' }, + // Conditionally-emitted attr. { attr: 'data-starting-style', @@ -436,7 +439,10 @@ data: [ Non-enum `data-*` entries default to `emit: 'presence'`: truthy writes `data-x=""`, falsy removes the attr. Use `emit: 'value'` only when the attr -must carry a real value, such as `data-value`, `data-min`, or `data-max`. +must carry a real value, such as `data-value`, `data-min`, `data-max` or a +manual component attr like `data-form-auto-fields-field`. When `value` is +omitted, the attr remains declarative-only: Morfo documents the contract, but +the component/provider is still responsible for writing the value. **Severity rules**: @@ -672,10 +678,11 @@ Two npm scripts exercise the UI shell and, historically, morfos against the real `__uix_lang_missing__` fallbacks. Not morfo-specific but catches common regressions. Set `SMOKE_SCOPE=/uix` to restrict the run to the UIX shell. -- `npm run morfo:check` — Legacy DOM validator from the old per-morfo demo - tree. The routes moved from `/test/soma/{kebab}` to the current - `web/routes` docs shell, so this runner must be reconnected to a concrete - route map before being used as a release gate again. Its contract remains: +- `npm run morfo:check` — DOM validator for morfos that have a routed demo + under `/uix/components/{kebab}`. Morfos without a current routed demo are + reported as `SKIP`; they are not treated as failures. Override the prefix + with `MORFO_ROUTE_PREFIX=/some/path` if a local docs shell maps morfos + elsewhere. Its contract: - Every declared data-attr with `severity: 'required'` is emitted. - Every emitted data-attr value matches `values[]` if declared. - No undeclared `data-{component}-*` attrs are emitted (except `data-_*` private @@ -822,7 +829,7 @@ runtime that interprets a compiled morfo lives in soma — see | `npm run check` | TypeScript type-check across the repo (catches shape errors in morfos). | | `npx vitest run src/uix/morfo` | Run morfo unit tests (schema invariants). | | `npm run smoke` | Playwright smoke over concrete `web/routes` pages (requires dev server). | -| `npm run morfo:check` | Legacy DOM validator; reconnect to current route map before relying on it. | +| `npm run morfo:check` | Validate routed `/uix/components/{kebab}` demos vs morfo; unrouted morfos skip. | | `npm run morfo:vocabulary` | Flag data-state enums that diverge from canonical vocabularies. | --- diff --git a/src/uix/morfo/components/field.ts b/src/uix/morfo/components/field.ts index ae6eebb67..5641bba26 100644 --- a/src/uix/morfo/components/field.ts +++ b/src/uix/morfo/components/field.ts @@ -13,7 +13,7 @@ export const fieldMorfo = { kind: 'public', defaultElement: 'div', optional: false, - states: ['idle', 'error', 'disabled', 'readonly', 'loading'], + states: ['state'], data: [ { attr: 'data-state', @@ -57,7 +57,7 @@ export const fieldMorfo = { kind: 'public', defaultElement: 'div', optional: true, - states: ['idle', 'error', 'disabled', 'readonly', 'loading'], + states: ['state'], data: [ { attr: 'data-state', @@ -76,7 +76,7 @@ export const fieldMorfo = { kind: 'public', defaultElement: 'input', optional: true, - states: ['idle', 'error', 'disabled', 'readonly', 'loading'], + states: ['state'], data: [ { attr: 'data-state', @@ -103,7 +103,7 @@ export const fieldMorfo = { attr: 'aria-busy', value: v.literal('true'), severity: 'optional', - condition: { when: 'state-equals', state: 'loading', value: 'true' } + condition: { when: 'state-equals', state: 'state', value: 'loading' } }, { attr: 'aria-required', diff --git a/src/uix/morfo/schema.test.ts b/src/uix/morfo/schema.test.ts index 6b5999cf9..143ef33a2 100644 --- a/src/uix/morfo/schema.test.ts +++ b/src/uix/morfo/schema.test.ts @@ -55,4 +55,32 @@ describe('validateMorfo — data attrs', () => { /lowercase kebab-case/ ); }); + + it('allows declarative-only free-value attrs', () => { + expect(() => + validateMorfo({ + ...baseMorfo, + parts: [ + { + ...baseMorfo.parts[0], + data: [{ attr: 'data-sample-value', emit: 'value' }] + } + ] + }) + ).not.toThrow(); + }); + + it('rejects emit mode on enum-valued attrs', () => { + expect(() => + validateMorfo({ + ...baseMorfo, + parts: [ + { + ...baseMorfo.parts[0], + data: [{ attr: 'data-state', values: ['open', 'closed'], emit: 'value' }] + } + ] + }) + ).toThrow(/emit is only valid for non-enum data attrs/); + }); }); diff --git a/src/uix/morfo/schema.ts b/src/uix/morfo/schema.ts index 4361bebb3..0841946e0 100644 --- a/src/uix/morfo/schema.ts +++ b/src/uix/morfo/schema.ts @@ -479,9 +479,6 @@ function validateInvariants(morfo: Morfo): void { for (const dataEntry of part.data) { validateDataAttrName(dataEntry.attr, path, 'data attr'); - if (dataEntry.emit && !dataEntry.value) { - throw new MorfoInvariantError(`data[${dataEntry.attr}].emit requires a value source`, path); - } if (dataEntry.emit && dataEntry.values && dataEntry.values.length > 0) { throw new MorfoInvariantError( `data[${dataEntry.attr}].emit is only valid for non-enum data attrs`,