diff --git a/src/uix/sema/cascade-scope.test.ts b/src/uix/sema/cascade-scope.test.ts new file mode 100644 index 000000000..4f7e44cf9 --- /dev/null +++ b/src/uix/sema/cascade-scope.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest'; + +import { resolveSignature, type SemaCascadeRule } from './resolver'; +import { SEMA_MAP } from './sema-map'; + +/** + * Cascade scoping — a rule matches THE TARGET, never an ancestor of it. + * + * `safeMatches` used to accept `target.closest(selector)` as a match, which + * turned every pack rule into an ancestor rule. It only looked harmless + * because the `data-event-*` tokens are transient: a rule needs its ancestor + * to be carrying the very event it names, and holds are ~240ms. + * + * Except when they are NOT transient. A `signal.warn + risk` resolves to + * `persistence: 'untilFix'` (`holds.ts`), so a form that failed validation + * keeps `data-event="signal-warn-invalid"` on itself INDEFINITELY — and from + * that moment every click and every commit inside the form matched the form's + * own rule and replayed the error earcon, until the next submit. The user + * heard an error while fixing the error. + * + * Descendant scoping still works, because it is expressed in the SELECTOR and + * `matches()` evaluates it against the element in its real tree — which is how + * `media-player` scopes its rules over the Button and Slider morfos + * (`[data-media-player] [data-button][data-event="…"]`). Nothing in the + * catalogue needed `closest()`: at the time of this fix, zero pack rules were + * declared without an event conjunct and zero used an `ancestor` matcher. + */ +describe('cascade scoping', () => { + function build() { + const form = document.createElement('form'); + form.setAttribute('data-form', ''); + // The persistent stamp of a failed validation (untilFix). + form.setAttribute('data-event', 'signal-warn-invalid'); + const button = document.createElement('button'); + button.setAttribute('data-button', ''); + form.append(button); + document.body.append(form); + return { form, button }; + } + + /** The form's own rule — the one that plays the error sample. */ + const formRule: SemaCascadeRule = { + selector: '[data-form][data-event="signal-warn-invalid"]', + sound: { gain: 0.9, pitch: 111 } + }; + + it('does not let an ancestor rule reach a descendant signal', () => { + const { button } = build(); + const eff = resolveSignature( + { target: button, name: 'contact-activate', family: 'contact' }, + { map: SEMA_MAP, cascade: [formRule] } + ); + // The button's own contact must keep the contact family base — the + // form's error rule is not addressed to it. + expect(eff.sound?.pitch).not.toBe(111); + expect(eff.sound?.gain).toBe(SEMA_MAP.families.contact.base.sound!.gain); + }); + + it('still honours descendant scoping written in the selector', () => { + const { button } = build(); + const scoped: SemaCascadeRule = { + selector: '[data-form] [data-button][data-event="contact-activate"]', + sound: { pitch: 222 } + }; + button.setAttribute('data-event', 'contact-activate'); + const eff = resolveSignature( + { target: button, name: 'contact-activate', family: 'contact' }, + { map: SEMA_MAP, cascade: [scoped] } + ); + expect(eff.sound?.pitch).toBe(222); + }); + + it('still matches a rule addressed to the target itself', () => { + const { form } = build(); + const eff = resolveSignature( + { target: form, name: 'signal-warn-invalid', family: 'signal', intent: 'risk' }, + { map: SEMA_MAP, cascade: [formRule] } + ); + expect(eff.sound?.pitch).toBe(111); + }); +}); diff --git a/src/uix/sema/resolver.ts b/src/uix/sema/resolver.ts index 0e339226b..2471cc2c3 100644 --- a/src/uix/sema/resolver.ts +++ b/src/uix/sema/resolver.ts @@ -210,7 +210,16 @@ function collectCascadeMatches( function safeMatches(target: HTMLElement, selector: string): boolean { if (selector === '*') return true; try { - return target.matches(selector) || target.closest(selector) !== null; + // THE TARGET, never an ancestor of it. This used to also accept + // `target.closest(selector)`, which turned every pack rule into an + // ancestor rule: a form left carrying `data-event="signal-warn-invalid"` + // (persistence `untilFix` — indefinite by design) made every click and + // every commit inside it replay the form's error earcon until the next + // submit. Descendant scoping is unaffected because it lives in the + // SELECTOR, and `matches()` evaluates that against the element in its + // real tree — which is how `media-player` scopes its rules over the + // Button and Slider morfos. Pinned by `cascade-scope.test.ts`. + return target.matches(selector); } catch { return false; }