test(motion): pin firma-vs-stagger cascade precedence (F4-D motion decision)

Workflow analysis (state map + 4 dispositions + adversarial stress) resolved the
direction dispute: the stagger/coordinated preset winning the (0,3,0) tie over
the generic commit-affirm firma is the DOCTRINE-CORRECT outcome (RFC B.2 — the
coordinated owns the visual axis, the generic firma is silenced). The earlier
"flip to firma" reading was backwards; that flip contradicts B.2 and its
channels:[] safety net is dormant; @layer sinks as a front-line fix (a semantic
ownership question answered with a global cascade tool + a reduced-motion
!important-inversion a11y risk).

No shipped component shows a defect (dropdown select feedback is sound+haptic
only; the item is fading out at select). The only real issue: the correct
outcome rides on an ACCIDENTAL source-order tiebreak (the explicit neutralization
was retired), undocumented + untested. Execute the proportionate fix:

- Regression test (motion.test.ts) pinning the (0,3,0)==(0,3,0) tie + the
  signatures-before-presets emission order + the absence of a (0,4,0)
  [data-event^='commit-select'] signature — a HAZARD MONITOR, so a generator
  reorder or a widened signature fails loudly, not silently.
- Load-bearing comment at the render-css emission order.
- Doctrine note in motion.md: a known-fragile accident, not a contract; the
  structural endpoint ("una firma por evento") + @layer are deferred/tracked.

Zero behavior change; base.css unchanged. Tests 28/28; npm run check src/ = 9
baseline, 0 new.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 04e0a02f3d
commit d8b984bb5a

@ -28,11 +28,11 @@ el **componente real + control real del demo**, nunca DOM fabricado
queries (ejes reservados deliberados; corregido 1 comentario falso de depth-z)
· **4 límites deliberados registrados** (LIM-SND/ICON/TOUCH/RECIPES). **Queda:**
scrim (⏳ decisión de diseño) · gap-3 auto-métricas (opcional).
- **cascada-vs-firmas** (motion, cola lateral): las reglas generadas
`[data-stagger][data-state='open'] > [data-animation-style]` (0,3,0) retienen
`animation` y sombrean firmas de evento (0,2,0). **Documentado**; fijar por
orden de emisión + test — ⏳ pendiente decidir la DIRECCIÓN (firma vs entrada;
los dos análisis discreparon) antes de fijarla.
- **cascada-vs-firmas** (motion, cola lateral): ✅ **RESUELTO 07-12** — la
dirección estaba zanjada por doctrina (B.2: gana la entrada, la firma se
silencia); ejecutado **B** (test de regresión + comentario load-bearing + nota
en `motion.md`) como monitor de hazard. Endpoint «una firma por evento» /
`@layer` diferido y trackeado. Detalle en la tabla F4-D abajo.
- **THM-2 · rol-directo restantes** (cola, opcional): las superficies semánticas
ya colapsaron sus roles a la capa; ampliar su `color` a escalas NO tiene
sentido semántico (decisión tomada). No queda rollout pendiente salvo que se
@ -805,7 +805,7 @@ next/font · Fontsource) + pasada adversarial. Constante: **casi cero deuda real
| **jaula depth-z** | **KEEP** (reserved) | Proyección de `--z-index-*` sobre planos; excluido del bundle a propósito (stacking va por `--{c}-content-z`). Deuda real = 1 comentario falso en `render-css.ts:1170` → ✅ **corregido** este pase. |
| **jaula container-queries** | **KEEP** (open cage) | `[data-container]{container-type}` + emisor `@container` por-recipe, ya en `tsc.md` como eje reservado (Tailwind v4 envía CQ en core). Opcional: dogfoodear 1 recipe → «tested end-to-end». |
| **jaula scrim** | ✅ **PODADO** (07-12) | Analizado con workflow (mapa + 4 disposiciones + estrés): las 4 colapsan en podar vs promover-a-paridad-z; C (cablear) **se hunde** (contradice el precedente z test-lockeado + consenso MD3/Radix/Vaul), D-reubicar invierte el verbo a retener. **Decisión: podar** — scrim NO gana el open-cage de z (z varía por-plano + es impintable-para-siempre; scrim ni varía ni tiene valor que sembrar, su jaula solo duplicaría `--color-overlay` → alias). Quitado de `DepthPlane`/`DEPTH_CUES`/validación/emisor + `applyDepth` doc; RFC enmendado (contrato + Phase-4 + atmósfera). El veil queda en `--{component}-overlay-*` / `--color-overlay` (= referencia). Cambio observable: cero (nunca se emitió). |
| **cascada-vs-firmas** (motion) | Documentar + fijar por orden | La cascada stagger sombrea la firma genérica en ítems de menú, solo por **orden de emisión** (tie 0,3,0), sin doc ni test. Fijar por orden (NO `!important`, que sabotea una futura `@layer`) + test; `@layer` completo = iniciativa L futura. ⏳ **Decisión de diseño pendiente**: ¿la firma de interacción gana a la entrada residual (Framer) o al revés? — los dos análisis discreparon. **No ejecutado** (para no fijar la dirección equivocada). |
| **cascada-vs-firmas** (motion) | ✅ **FIJADO + TEST** (07-12) | Workflow (mapa + 4 disposiciones + estrés) **resolvió la dirección**: gana la entrada/stagger, la firma se silencia = **doctrinalmente correcto** (RFC B.2). La lectura «firma gana» del 2º análisis estaba invertida; C (girar) contradice B.2 y su red `channels:[]` es ilusoria; D (`@layer`) se hunde como front-line (pregunta semántica con herramienta global + inversión `!important` en reduced-motion). **Cero bug visible** (select = sonido+háptico; ítem en fade-out). Ejecutado **B**: test de regresión (`motion.test.ts`) que fija el tie (0,3,0) + orden de emisión + ausencia de firma (0,4,0), como **monitor de hazard** (no «precedencia intencionada»); comentario load-bearing en `render-css.ts`; nota en `motion.md`. Endpoint diferido (trackeado): «una firma por evento» / `@layer`, gatillado por un canal visual de commit real. |
**Límites deliberados registrados** (gap-log §7 — sin código, todos confirmados NO-bug):
- **[LIM-SND]** Sonorización por tema NO es un eje de theming — los packs sema son TS (`SOUND_TUNINGS`), no config de `ThemeDefinition`; los samples = recursos de producto/tema/branding fuera del canon (book-deviations **D.7**). Personalización sonora = app/dev.

@ -198,6 +198,27 @@ does NOT say only one may animate — **eidos reads both and animates both**.
What is forbidden is stepping on the other's name, not animating over both
axes.
> **When both write `animation` on ONE node (KNOWN-FRAGILE).** Eidos animates
> both axes, but a firma and a `--state`/stagger preset both set the single
> `animation` shorthand — only one applies. On a shared node (a `DropdownMenu`
> item that staggers on open/close AND receives a `commit-select` event) both
> selectors compute `(0,3,0)`, so **CSS source order** decides: the generator
> emits all `signatures` before all `presets`, so the later stagger wins and the
> generic firma is masked — directionally the intended outcome (the coordinated
> owns the visual axis, RFC B.2), but produced **accidentally by emission order**,
> NOT by an opt-out (the `channels:[]` / `expression:'none'` source-silence is
> dormant; the old `animation: none !important` neutralization is retired). No
> shipped component shows a defect (e.g. dropdown select feedback is sound+haptic
> only; the masked pulse is the generic fallback and the item is fading out at
> select). This is a **provisional accident, not a contract** — pinned by
> `motion.test.ts` ("pins the firma-vs-stagger cascade precedence") so a generator
> reorder or a future `(0,4,0)` `[data-event^='…']` signature fails loudly. The
> structural fix ("una firma por evento") is
> [RFC §D.4-B](../../src/uix/eidos/MOTION_SERVICE_RFC.md). `@layer` (order-
> independent precedence) is a deferred, tracked end-state — triggered only when a
> coordinated component gains a visual commit channel, `(0,4,0)` signatures ship,
> or `@layer` is adopted for other reasons.
> **Child coordination (stagger / cascade) — CLOSED model, RFC §D.11.**
> There was a third "coordinated" axis (`PresenceGroup` / `cascade-*` presets
> over `data-starting/ending-style`); it was **retired** on 2026-06-19. The

@ -870,6 +870,16 @@ function renderMotionBlocks(motion: MotionConfig): string {
blocks.push(renderKeyframes(name, stops))
}
// LOAD-BEARING ORDER (KNOWN-FRAGILE) — signatures are emitted BEFORE presets on
// purpose: when a firma and a stagger/state preset both set `animation` on ONE node
// at equal (0,3,0) specificity (a DropdownMenu item that staggers AND receives a
// `commit-select` event), the later-emitted preset wins the SOURCE-ORDER tiebreak,
// masking the generic firma — directionally the intended B.2 outcome (the coordinated
// wins the visual axis), but produced by emission order, NOT by an opt-out. Reordering
// these two loops silently flips the winner. Guarded by `motion.test.ts` ("pins the
// firma-vs-stagger cascade precedence"). The structural fix ("una firma por evento")
// lives in `MOTION_SERVICE_RFC.md` D.4-B — this order is a provisional accident, not a
// contract.
// Momento --event: la firma perceptiva (genérica por family/intent/event).
for (const [name, signature] of Object.entries(motion.signatures ?? {})) {
blocks.push(...renderSignatureRules(name, signature))

@ -164,6 +164,50 @@ describe('eidos motion — CSS generation', () => {
);
expect(withSig).toContain('fade-in var(--duration-slow)');
});
it('pins the firma-vs-stagger cascade precedence (KNOWN-FRAGILE source-order tiebreak)', () => {
// HAZARD MONITOR — not "intended precedence" doctrine. On a SHARED node (a
// DropdownMenu item that staggers on open/close AND receives a `commit-select`
// event) two generated rules both set `animation`: the commit-affirm firma and
// the stagger fade preset. Both compute (0,3,0), so CSS SOURCE ORDER breaks the
// tie — the generator emits ALL signatures before ALL presets, so the stagger
// (later) wins and the generic firma is masked. Directionally this matches the
// intended outcome (the coordinated wins the visual axis; RFC B.2), but it is
// produced ACCIDENTALLY by emission order, not by an opt-out: the
// `channels:[]` / `expression:'none'` source-silence is dormant and the old
// `animation: none !important` neutralization is retired. The RFC target design
// ("una firma por evento", D.4-B) removes this collision structurally. No shipped
// component shows a defect (dropdown select feedback is sound+haptic only; the
// masked pulse is the generic fallback and the item is fading out at select).
// This test makes the two silent-flip vectors LOUD: (1) a signatures/presets
// emission reorder, and (2) a future (0,4,0) `[data-event^='commit-select']`
// event-scoped signature that would out-rank the stagger on specificity.
const firma = "[data-event-family='commit'][data-event-intent='affirm'][data-event-phase='active']";
const staggerOpen = "[data-stagger][data-state='open'] > [data-animation-style='fade']";
const staggerClosed = "[data-stagger][data-state='closed'] > [data-animation-style='fade']";
expect(css).toContain(firma);
expect(css).toContain(staggerOpen);
expect(css).toContain(staggerClosed);
// Equal specificity: attribute-only selectors with the SAME attribute count
// → (0,3,0) == (0,3,0), so neither out-ranks the other on specificity.
const attrCount = (sel: string) => (sel.match(/\[/g) ?? []).length;
expect(attrCount(firma)).toBe(3);
expect(attrCount(staggerClosed)).toBe(3);
expect(attrCount(firma)).toBe(attrCount(staggerClosed));
// Tie broken by SOURCE ORDER: the firma is emitted BEFORE the stagger rules, so
// the later-emitted stagger wins. Reordering the generator's signatures/presets
// loops flips the winner and trips this test.
expect(css.indexOf(firma)).toBeLessThan(css.indexOf(staggerClosed));
expect(css.indexOf(firma)).toBeLessThan(css.indexOf(staggerOpen));
// No (0,4,0) escalation: no event-scoped `[data-event^='commit-select']`
// signature exists — one WOULD out-rank the stagger and flip the winner
// regardless of emission order.
expect(css).not.toContain("[data-event^='commit-select']");
});
});
describe('eidos motion — runtime registry', () => {

Loading…
Cancel
Save

Powered by TurnKey Linux.