diff --git a/scripts/eidos-lint-all.ts b/scripts/eidos-lint-all.ts index b2d5f75ef..2b6f170ce 100644 --- a/scripts/eidos-lint-all.ts +++ b/scripts/eidos-lint-all.ts @@ -22,38 +22,9 @@ import { loadEventVocabulary } from './eidos-event-vocabulary'; -const EIDOS_ONLY_ATTRS = new Set([ - 'data-archetype', - 'data-block', - 'data-collapsed', - 'data-combobox-control', - 'data-color', - 'data-columns', - 'data-dragging', - // The sema stamp (`src/uix/sema/stamp.ts`), not a morfo part. Allowlisted - // STRUCTURALLY only — the VALUE is validated against the morfo catalogue - // by `lintEventValues` below. - 'data-event', - 'data-event-direction', - 'data-event-family', - 'data-event-id', - 'data-event-intent', - 'data-event-phase', - 'data-fitted', - 'data-icon-only', - 'data-indicator-placement', - 'data-motion', - 'data-motion-disabled', - 'data-position', - 'data-ready', - 'data-scrollable', - 'data-sheet', - 'data-size', - 'data-floating-wrapper', - 'data-svg', - 'data-svg-direction', - 'data-variant' -]); +// Shared with morfo-check so the two instruments cannot drift (P0 fase A, +// audit 2026-08-26) — the list lives in ./eidos-only-attrs.ts. +import { EIDOS_ONLY_ATTRS } from './eidos-only-attrs'; const RUNTIME_SIGNAL_ATTRS = new Set([ 'data-disabled', diff --git a/scripts/eidos-only-attrs.ts b/scripts/eidos-only-attrs.ts new file mode 100644 index 000000000..adc28962f --- /dev/null +++ b/scripts/eidos-only-attrs.ts @@ -0,0 +1,49 @@ +/** + * Cross-cutting attrs the EIDOS layer owns — stamped by wrappers or shared + * styling hooks, deliberately NOT part of any morfo contract (the signed + * "visual wrapper attrs do not enter the morfo" doctrine; `data-combobox- + * control` set the precedent for hooks that squat a component's namespace). + * + * Consumed by BOTH instruments so they cannot drift apart (P0 fase A, audit + * 2026-08-26): `eidos-lint-all` classifies CSS selectors with it, and + * `morfo-check` skips these names in its undeclared-attr sweep. + */ +export const EIDOS_ONLY_ATTRS = new Set([ + 'data-archetype', + 'data-block', + // Shared calendar-surface sizing hook (`src/uix/eidos/lib/calendar-surface.css`): + // calendar / month-grid / year-grid / range-calendar wrappers stamp it. + // Squats the calendar namespace the way data-combobox-control squats combobox's. + 'data-calendar-surface', + 'data-collapsed', + 'data-combobox-control', + 'data-color', + 'data-columns', + 'data-dragging', + // The sema stamp (`src/uix/sema/stamp.ts`), not a morfo part. Allowlisted + // STRUCTURALLY only — the VALUE is validated against the morfo catalogue + // by `lintEventValues` in eidos-lint-all. + 'data-event', + 'data-event-direction', + 'data-event-family', + 'data-event-id', + 'data-event-intent', + 'data-event-phase', + // Fab's wrapper visual axis (`fab.svelte` stamps it next to data-extended; + // the recipe sizes off it). Wrapper attr, not contract. + 'data-fab-size', + 'data-fitted', + 'data-icon-only', + 'data-indicator-placement', + 'data-motion', + 'data-motion-disabled', + 'data-position', + 'data-ready', + 'data-scrollable', + 'data-sheet', + 'data-size', + 'data-floating-wrapper', + 'data-svg', + 'data-svg-direction', + 'data-variant' +]); diff --git a/scripts/morfo-check.ts b/scripts/morfo-check.ts index 231e4dfdb..0e1d42b6b 100644 --- a/scripts/morfo-check.ts +++ b/scripts/morfo-check.ts @@ -22,6 +22,7 @@ */ import { chromium, type Page } from 'playwright'; +import { EIDOS_ONLY_ATTRS } from './eidos-only-attrs'; import { existsSync, readdirSync } from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { dirname, join } from 'node:path'; @@ -71,9 +72,7 @@ async function probePort(start: number, end: number): Promise { } async function loadMorfos(): Promise { - const files = readdirSync(MORFOS_DIR).filter( - (f) => f.endsWith('.ts') && !f.endsWith('.test.ts') - ); + const files = readdirSync(MORFOS_DIR).filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts')); const out: Morfo[] = []; for (const f of files) { // Use file:// URL on Windows — absolute paths starting with "g:" are @@ -144,9 +143,7 @@ async function validateMorfoAgainstDom(page: Page, morfo: Morfo): Promise( - part.data.map((d) => [d.attr, d]) - ); + const declaredByAttr = new Map(part.data.map((d) => [d.attr, d])); for (const elAttrs of elementsAttrs) { // (a) required attrs present @@ -177,6 +174,11 @@ async function validateMorfoAgainstDom(page: Page, morfo: Morfo): Promise this.runtimePart.assert({ - ...this.runtimePart.props, + ...this.runtimePart.props // Raw, so no assertion leaves the root inheriting. The in-place half paints // from it: the control's flex row (input / trigger / clear-button order) and // the chip strip's asymmetric `padding-inline` + `margin-inline-start`. The @@ -381,7 +378,7 @@ export class ComboboxInputProvider { owner: this, // Per-part source for the morfo's `aria-activedescendant` (propRef // 'highlightedId'). The component-level sources don't carry it, so - // register it here → `renderProps()` resolves it from the morfo + // register it here → `the render bag` resolves it from the morfo // instead of the provider re-declaring it. props: { highlightedId: () => this.provider.highlightedId || undefined } }); @@ -486,11 +483,16 @@ export class ComboboxInputProvider { readonly props = $derived.by(() => this.runtimePart.assert({ // Morfo-declared: role, aria-haspopup/expanded/controls/activedescendant/ - // required/autocomplete, data-state — resolved against this part's - // registered sources (incl. the per-part `highlightedId`). - ...this.runtimePart.renderProps(), - // Soma-only: what the morfo can't express. + // required/autocomplete — resolved against this part's registered + // sources (incl. the per-part `highlightedId`). + ...this.runtimePart.props, + // Soma-only: what the morfo can't express, plus the data-* the morfo + // declares WITHOUT a value (soma owns the value — the trigger's own + // idiom; the old comment claimed the bag resolved data-state, which a + // valueless declaration cannot do. morfo:check caught the gap, P0 + // fase A, audit 2026-08-26). type: 'text' as const, + 'data-state': getDataOpenClosed(this.provider.opts.open.current), 'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current), disabled: this.provider.opts.disabled.current || undefined, value: this.provider.opts.inputValue.current, @@ -505,7 +507,6 @@ export class ComboboxInputProvider { interface ComboboxTriggerOpts extends WithRefOpts {} export class ComboboxTriggerProvider { - readonly opts: ComboboxTriggerOpts; readonly runtimePart: SomaRuntimePart; static create(opts: ComboboxTriggerOpts) { @@ -533,7 +534,7 @@ export class ComboboxTriggerProvider { readonly props = $derived.by(() => this.runtimePart.assert({ // Morfo-declared: type, aria-label/haspopup/expanded/controls. - ...this.runtimePart.renderProps(), + ...this.runtimePart.props, // Soma-only: native tabindex, the data-* the morfo declares WITHOUT a // value (soma owns the value), the native disabled attr, the handler. tabindex: -1, @@ -685,7 +686,7 @@ export class ComboboxContentProvider { readonly props = $derived.by(() => this.runtimePart.assert({ // Morfo-declared: role, aria-labelledby, aria-multiselectable. - ...this.runtimePart.renderProps(), + ...this.runtimePart.props, // Soma-only: data-state (morfo declares it WITHOUT a value), the // floating/presence/dismissal layer attrs, native tabindex. 'data-state': getDataOpenClosed(this.provider.opts.open.current), @@ -755,7 +756,7 @@ export class ComboboxItemProvider { owner: this, // Per-part state source for the morfo's `aria-selected` (stateRef // 'checked'). Each Item carries its own selection state, so it's - // registered per-instance here → `renderProps()` resolves + // registered per-instance here → `the render bag` resolves // aria-selected from the morfo instead of the provider re-declaring it. states: { checked: () => this.isSelected } }); @@ -791,7 +792,7 @@ export class ComboboxItemProvider { readonly props = $derived.by(() => this.runtimePart.assert({ // Morfo-declared: role, aria-selected (from the per-part `checked` state). - ...this.runtimePart.renderProps(), + ...this.runtimePart.props, // Soma-only: native tabindex + the data-* the morfo declares WITHOUT a // value (soma owns value/label/highlighted/disabled/state) + handlers. tabindex: this.opts.disabled.current ? undefined : -1, @@ -838,7 +839,7 @@ export class ComboboxGroupProvider { context: ComboboxGroupProvider.ctx, // Per-part source for the morfo's `aria-labelledby` (partRef // 'group-heading'). The heading id is per-group-instance (set by the - // nested GroupHeading), so it's registered here → `renderProps()` + // nested GroupHeading), so it's registered here → `the render bag` // resolves aria-labelledby + its part-present condition from the morfo. parts: { 'group-heading': () => this.headingId.current || undefined } }); @@ -847,7 +848,7 @@ export class ComboboxGroupProvider { readonly props = $derived.by(() => this.runtimePart.assert({ // Morfo-declared: role, aria-labelledby (from the per-part heading id). - ...this.runtimePart.renderProps() + ...this.runtimePart.props } as const) ); } @@ -909,7 +910,7 @@ export class ComboboxSeparatorProvider { readonly props = $derived.by(() => this.runtimePart.assert({ // Morfo-declared: role. No soma-specific extras. - ...this.runtimePart.renderProps() + ...this.runtimePart.props } as const) ); } @@ -972,7 +973,7 @@ export class ComboboxSelectedTagsProvider { readonly props = $derived.by(() => // Morfo-declared: role, aria-label (translationRef 'selected-tags'), // data-empty (propRef 'selectedTagsEmpty'). No soma-specific extras. - this.runtimePart.assert({ ...this.runtimePart.renderProps() } as const) + this.runtimePart.assert({ ...this.runtimePart.props } as const) ); } @@ -1065,7 +1066,7 @@ export class ComboboxSelectedTagRemoveProvider { this.runtimePart.assert({ // Morfo-declared: type, aria-label (translationRef 'remove' — the morfo // declares it; previously the provider never applied it). - ...this.runtimePart.renderProps(), + ...this.runtimePart.props, // Soma-only: data-disabled (morfo declares it WITHOUT a value), the // native disabled attr, the handler. 'data-disabled': boolToEmptyStrOrUndef(this.disabled), @@ -1106,7 +1107,7 @@ export class ComboboxClearButtonProvider { this.runtimePart.assert({ // Morfo-declared: type, aria-label (translationRef 'clear'), data-disabled // (propRef 'clearDisabled' = disabled || isEmpty). - ...this.runtimePart.renderProps(), + ...this.runtimePart.props, // Soma-only: native tabindex + the native disabled attr + the handler. tabindex: -1, disabled: this.provider.opts.disabled.current || this.provider.isEmpty || undefined, diff --git a/src/uix/soma/components/gradient-builder/gradient-builder-provider.svelte.ts b/src/uix/soma/components/gradient-builder/gradient-builder-provider.svelte.ts index bc22fb4c7..92f12eaf8 100644 --- a/src/uix/soma/components/gradient-builder/gradient-builder-provider.svelte.ts +++ b/src/uix/soma/components/gradient-builder/gradient-builder-provider.svelte.ts @@ -257,7 +257,7 @@ export class GradientBuilderProvider { /** Provider element attrs — morfo-resolved + `data-kind` (set imperatively). */ get props() { - return { ...this.runtimePart.renderProps(), 'data-kind': this.kind }; + return { ...this.runtimePart.props, 'data-kind': this.kind }; } /** Map a pointer x to a position 0..1 along the registered track. */ @@ -312,7 +312,7 @@ export class GradientStopProvider { context: GradientStopProvider.ctx, syncAttrs: true, // Per-stop sources for the morfo's value-bound aria (aria-valuenow / - // aria-valuetext / aria-label) — resolved via `renderProps()`. + // aria-valuetext / aria-label) — resolved via `the render bag`. props: { position: () => this.position, valueText: () => this.valueText, @@ -342,7 +342,7 @@ export class GradientStopProvider { /** Element attrs — morfo-resolved aria + state attrs + the drag/keyboard handlers. */ get props() { return { - ...this.runtimePart.renderProps(), + ...this.runtimePart.props, tabindex: 0, 'data-selected': boolToEmptyStrOrUndef(this.isSelected), 'data-dragging': boolToEmptyStrOrUndef(this.provider.activeDragIndex === this.index), @@ -450,7 +450,14 @@ export class GradientTrackProvider { // click on the bare track inserts a stop at the pointer position. Clicks // that land on a Stop are ignored here (the Stop owns select / edit / drag); // the coordinate-less Add button stays as the fallback. - return { ...this.runtimePart.renderProps(), onclick: this.onclick }; + return { + ...this.runtimePart.props, + // The morfo declares `data-kind` WITHOUT a value — soma owns it, same + // idiom as preview's hand stamp. It was never written; morfo:check + // caught the required-attr gap (P0 fase A, audit 2026-08-26). + 'data-kind': this.provider.kind, + onclick: this.onclick + }; } private onclick = (e: MouseEvent): void => {