From f7a7564b393c9f7c5e6a92dfa0c08388366dfb36 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 6 Aug 2026 04:31:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(morfo):=20allowedTargets=20=E2=80=94=20el?= =?UTF-8?q?=20morfo=20declara=20a=20que=20partes=20puede=20viajar=20el=20e?= =?UTF-8?q?stampado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El runtime deja que el proveedor redirija el estampado por emision (fallbackTarget GANA sobre el ref del target declarado). Es comportamiento correcto para partes repetidas — el dia pulsado del Calendar, la pagina clicada de Pagination — pero el morfo no decia nada: la declaracion leia target: provider mientras cada emision real caia en day / item / prev-button. Un pack escrito desde el morfo apuntaba a un nodo que nunca lleva el estampado, y nada podia distinguir ese error de esta redireccion legitima. La auditoria 2026-08-05 midio el error (S-12: TextArea 6x/13x sobre su firma escrita); el censo nuevo encontro 12 reglas identicas en forma que estaban BIEN. Solo el morfo puede separarlas, asi que el morfo lo dice. allowedTargets es el simetrico exacto de allowedFamilies en el otro eje: el target canonico sigue siendo el default que el runtime resuelve; la lista declara las demas superficies donde una emision puede caer. Invariantes en schema.ts: parte inexistente o auto-listado = error de compilacion del morfo. Declarado en calendar (5 superficies), pagination (5), rating-group, toolbar y tree-view (branch) — cada entrada verificada contra sus emisores reales, no contra el pack. Pendiente declarado: renombrar fallbackTarget -> targetOverride (nombre honesto para una opcion que gana) va en commit aparte — 193 apariciones en 63 ficheros esconderian este contrato dentro de un diff de renombrado. Co-Authored-By: Claude Opus 5 --- docs/architecture/morfo.md | 11 ++++++++++ src/uix/morfo/components/calendar.ts | 13 +++++++++++- src/uix/morfo/components/pagination.ts | 12 ++++++++++- src/uix/morfo/components/rating-group.ts | 5 ++++- src/uix/morfo/components/toolbar.ts | 5 ++++- src/uix/morfo/components/tree-view.ts | 6 +++++- src/uix/morfo/schema.ts | 19 +++++++++++++++-- src/uix/morfo/types.ts | 27 ++++++++++++++++++++++++ 8 files changed, 91 insertions(+), 7 deletions(-) diff --git a/docs/architecture/morfo.md b/docs/architecture/morfo.md index ce1a8e034..594a69acc 100644 --- a/docs/architecture/morfo.md +++ b/docs/architecture/morfo.md @@ -590,6 +590,17 @@ Field rules: - **`semantic.target`** — the `partRef` whose DOM element receives the `data-event-*` attrs during the visual hold. Lives inside `semantic` per the doctrinal shape (was at event-level pre-2026-05-08). +- **`semantic.allowedTargets`** — optional list of `partRef`s the provider + may redirect the stamp to at trigger time (the `fallbackTarget` trigger + option wins over the registered target ref). The exact counterpart of + `allowedFamilies` on the target axis: `target` stays the default the + runtime resolves; this declares the OTHER surfaces an emission may land + on — repeated parts like the pressed `day` of a Calendar or the clicked + `item` of a Pagination. Declaring it is what lets `pack-census.test.ts` + tell a legitimate redirection from drift: a sema rule may select the + declared target or a part listed here, and nothing else (the TextArea + class of bug — rules aimed at a part the stamp never visits — ran 6×/13× + above its written gain for months without any guard seeing it). - **`semantic.sequence`** — when the perceptual signal happens relative to the structural state change. Default `'pre'` preserves the runtime semantics where the signal completes before the commit. diff --git a/src/uix/morfo/components/calendar.ts b/src/uix/morfo/components/calendar.ts index a084439d5..f697e5060 100644 --- a/src/uix/morfo/components/calendar.ts +++ b/src/uix/morfo/components/calendar.ts @@ -37,7 +37,18 @@ export const calendarMorfo = { family: 'shift', verb: 'navigate', target: v.partRef('provider'), - sequence: 'post' + sequence: 'post', + // The provider hands the surface that CAUSED the navigation — the + // arrow pressed, the select changed, the day whose month spilled + // over — so the frame change is felt where it was asked for. The + // provider is the target only when nothing concrete caused it. + allowedTargets: [ + v.partRef('prev-button'), + v.partRef('next-button'), + v.partRef('month-select'), + v.partRef('year-select'), + v.partRef('day') + ] } } ], diff --git a/src/uix/morfo/components/pagination.ts b/src/uix/morfo/components/pagination.ts index a11073470..74202ecc1 100644 --- a/src/uix/morfo/components/pagination.ts +++ b/src/uix/morfo/components/pagination.ts @@ -23,7 +23,17 @@ export const paginationMorfo = { verb: 'set', target: v.partRef('provider'), intent: 'neutral', - sequence: 'post' + sequence: 'post', + // One event, five surfaces: the provider hands the control that + // set the page so the commit is felt under the finger that asked + // for it, not on the wrapper. + allowedTargets: [ + v.partRef('first-trigger'), + v.partRef('prev-trigger'), + v.partRef('item'), + v.partRef('next-trigger'), + v.partRef('last-trigger') + ] } } ], diff --git a/src/uix/morfo/components/rating-group.ts b/src/uix/morfo/components/rating-group.ts index 1ffefbe27..ef45680a0 100644 --- a/src/uix/morfo/components/rating-group.ts +++ b/src/uix/morfo/components/rating-group.ts @@ -18,7 +18,10 @@ export const ratingGroupMorfo = { verb: 'set', target: v.partRef('provider'), intent: 'neutral', - sequence: 'post' + sequence: 'post', + // The rated item carries the commit: a repeated part, so the + // provider hands the exact star that was chosen. + allowedTargets: [v.partRef('item')] } } ], diff --git a/src/uix/morfo/components/toolbar.ts b/src/uix/morfo/components/toolbar.ts index 0a7d3301b..4508079fe 100644 --- a/src/uix/morfo/components/toolbar.ts +++ b/src/uix/morfo/components/toolbar.ts @@ -18,7 +18,10 @@ export const toolbarMorfo = { verb: 'toggle', target: v.partRef('provider'), intent: 'neutral', - sequence: 'post' + sequence: 'post', + // The toggled item of a group is a repeated part; the provider + // hands the one that flipped. + allowedTargets: [v.partRef('group-item')] } } ], diff --git a/src/uix/morfo/components/tree-view.ts b/src/uix/morfo/components/tree-view.ts index 6ef60d21b..ab6d2decd 100644 --- a/src/uix/morfo/components/tree-view.ts +++ b/src/uix/morfo/components/tree-view.ts @@ -22,7 +22,11 @@ export const treeViewMorfo = { verb: 'select', target: v.partRef('item'), intent: 'affirm', - sequence: 'post' + sequence: 'post', + // A branch is selectable too, and it is a different part from a + // leaf: the provider hands whichever node was chosen, which is + // why the pack carries a rule for each. + allowedTargets: [v.partRef('branch')] } }, { diff --git a/src/uix/morfo/schema.ts b/src/uix/morfo/schema.ts index f665e55f1..8b3c8191e 100644 --- a/src/uix/morfo/schema.ts +++ b/src/uix/morfo/schema.ts @@ -266,7 +266,8 @@ const eventSemanticSchema = union( verb: optional(string()), sequence: optional(sequenceSchema), persistence: optional(persistenceSchema), - allowedFamilies: optional(array(semaFamilySchema)) + allowedFamilies: optional(array(semaFamilySchema)), + allowedTargets: optional(array(partRefSchema)) }), object({ family: semaIntentExpectedFamilySchema, @@ -276,7 +277,8 @@ const eventSemanticSchema = union( verb: optional(string()), sequence: optional(sequenceSchema), persistence: optional(persistenceSchema), - allowedFamilies: optional(array(semaFamilySchema)) + allowedFamilies: optional(array(semaFamilySchema)), + allowedTargets: optional(array(partRefSchema)) }) ); @@ -635,6 +637,19 @@ function validateInvariants(morfo: Morfo): void { ); } + for (const allowed of event.semantic.allowedTargets ?? []) { + if (!kebabs.has(allowed.target)) { + throw new MorfoInvariantError( + `event "${event.name}" allows unknown target part "${allowed.target}"` + ); + } + if (allowed.target === event.semantic.target.target) { + throw new MorfoInvariantError( + `event "${event.name}" lists its own target "${allowed.target}" in allowedTargets — the canonical target is always allowed` + ); + } + } + if ('intent' in event.semantic && typeof event.semantic.intent === 'object') { if (event.semantic.intent.supported.length === 0) { throw new MorfoInvariantError( diff --git a/src/uix/morfo/types.ts b/src/uix/morfo/types.ts index 3ec617f58..da41b96fd 100644 --- a/src/uix/morfo/types.ts +++ b/src/uix/morfo/types.ts @@ -449,6 +449,33 @@ export type MorfoEventSemantic = ( * channels: ['sound'] */ channels?: readonly SemaChannelId[]; + /** + * Parts the provider may redirect the STAMP to at trigger time, on top of + * the canonical `target`. The exact counterpart of `allowedFamilies`, on + * the other axis: `allowedFamilies` declares the families a provider may + * concrete to, `allowedTargets` declares the parts it may land on. + * + * WHY IT EXISTS. `runtime.trigger(name, { fallbackTarget })` lets a + * provider point at the exact interaction surface of a repeated part — the + * pressed day of a Calendar, the clicked page of a Pagination — and the + * option WINS over the registered ref of the declared target. That is + * correct behaviour and every one of those components needs it. What was + * missing is that the morfo said nothing about it: the declaration read + * `target: v.partRef('provider')` while every real emission landed on + * `day` / `item` / `prev-button`, so a sema pack written from the morfo + * aimed at a node that never carries the stamp — and nothing could tell + * the difference between that mistake and this legitimate redirection. + * The 2026-08-05 audit measured the mistake (TextArea: two rules on + * `provider` while the stamp fell on `input`, running 6× and 13× louder + * than written); the census found 12 more rules that looked identical and + * were RIGHT. Only the morfo can tell them apart, so the morfo says it. + * + * The canonical `target` stays the DEFAULT the runtime resolves when no + * override is passed; `allowedTargets` never changes resolution. It is a + * contract, read by `pack-census.test.ts` — a pack rule may select the + * declared target or any part listed here, and nothing else. + */ + allowedTargets?: readonly PartRef[]; /** * Per-event signature override (resolution layer 3). Applied AFTER * family.base + intent.deltas and BEFORE app-level cascade rules. Use