fix(sema): S-30/S-38 — las cinco reglas hapticas abren la puerta que habian olvidado

Cinco reglas escribian una firma haptica sobre familias cuyo activeChannels es
['sound'] — alertdialog (pulse 0.7/60), sheet movil (tap 0.4/24), editable
shift-enter-mode, stepper shift-step y timeline emerge-reveal. El resolver
instalaba la firma, HapticChannel salia por la puerta de entrada: vibrate() 0
veces desde su nacimiento, con la intencion comentada en los propios packs.

La decision se tomo contrastando con el mundo real, no por limpieza: mi
propuesta inicial era borrar los bloques y el autor la corrigio con las
preguntas correctas — ¿que hacen las plataformas? ¿quien decide? Verificado en
las fuentes: Apple HIG prescribe haptic de warning cuando aparece una alerta
importante y tick de seleccion para cambios de valor discretos; Android pide
moderacion pero con constantes CONFIRM/REJECT. O sea: el alertdialog y el
stepper SON patrones de plataforma, y la intencion escrita en los packs era
diseño, no deriva. El framework ademas ya deja la ultima palabra a quien toca:
la puerta por regla (`channels`), la per-emit, y la haptica entera es opt-in
del producto — ensanchar estas reglas no impone vibracion a nadie.

Arreglo: `channels: ['sound', 'haptic']` en las cinco, cada una con su razon
perceptual escrita. Los 5 waivers de CHANNEL_EXCEPTIONS se retiran: el guard
del censo (punto 2, que estos waivers silenciaban desde 2026-08-06) queda
re-armado y ES el aviso automatico para la proxima vibracion con puerta
cerrada. El aviso del waiver («widening requires completing the firma») estaba
ya pagado por la defensa S-31 de kindToPattern: un {kind:'tick'} parcial
resuelve a valores finitos, nunca vibrate(NaN).

Visto en rojo DOS veces antes de verde: el censo sin waivers listando
exactamente las 5, y el e2e nuevo de resolver.test.ts (resolucion con el pack
real + HapticChannel real: 1 vibracion por regla, patron finito) ejecutado
contra los packs sin ensanchar via stash.

Verificado: censo 67/67 · resolver 30/30 · sema+morfo+contracts 540 ✓ / 6
ajenos · check 69 = base aislada, diff VACIO · prettier limpio · docs 0/624.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 6a75544158
commit 455260ca34

@ -134,7 +134,24 @@ cualquier etiqueta (`validation.ts:29`). **Alcance medido**: `SemaActionEvent` n
tiene consumidores fuera de los tres ficheros del propio sema. Va pegado a S-33:
la misma unión, las mismas líneas.
### S-30/S-38 · cinco reglas de pack vibran donde el canal no existe
### ~~S-30/S-38~~ · EJECUTADO 2026-08-13 — las cinco reglas ABREN LA PUERTA
**La decision se INVIRTIO al contrastarla con el mundo real y con el autor.** Mi
recomendacion era borrar los bloques; el autor pregunto que hacen iOS/Android y
quien decide — y la verificacion en las fuentes (Apple HIG: haptic de warning
para alertas; tick de seleccion para cambios de valor; Android: «less is more»
pero con constantes CONFIRM/REJECT) mas un hecho del propio framework (la
intencion estaba ESCRITA y comentada en los packs, y la haptica entera es
opt-in del producto) cambiaron el veredicto: las reglas llevaban la intencion
del disenador y solo habian olvidado la puerta. Arreglo: `channels: ['sound',
'haptic']` en las cinco, con la razon perceptual escrita en cada una; los 5
waivers de `CHANNEL_EXCEPTIONS` fuera (el guard del censo, re-armado, ES el
test que avisa si vuelve a pasar); y un e2e nuevo en `resolver.test.ts` que
prueba resolucion + `vibrate()` con valores FINITOS (la defensa S-31 cubre los
`{kind:'tick'}` parciales — el aviso del waiver ya estaba pagado). Visto en
rojo dos veces: el censo sin waivers listando las 5, y el e2e contra los packs
sin ensanchar.
### S-30/S-38 · (ficha original, cerrada)
Dialog ×2 (alertdialog y sheet), editable, stepper y timeline escriben
`haptic: {…}` sobre eventos de familia `emerge`/`shift`, cuyo `activeChannels`
es `['sound']`. Medido por el verificador con resolver y canal reales: firma
@ -167,9 +184,18 @@ por los dos caminos.
## 4 · La cola, en orden
1. **S-33** (§1) — firmado, listo para ejecutar.
2. **S-35** — decisión; va con S-33 por tocar las mismas líneas.
3. **S-30/S-38** · **S-19** — decisiones (§3).
1. ~~**S-33**~~ EJECUTADO 2026-08-13 (`1a174d5a6`): los tres cubos derivados en
positivo + rama `intent?: never` + `intentRequirementOf()` (leer el const
directo ESTRECHA y el gate no compila — arista que la ficha no vio).
Desviacion declarada: `validation.ts` NO se toco (su rama de politica es
inalcanzable — hallazgo nuevo, abierto).
2. ~~**S-35**~~ EJECUTADO 2026-08-13 (`6a7554415`): `SemaEventKey` derivado de la
politica (bare solo sin required; pareja sin forbidden), `SEMA_EVENT_LABELS`
56→54, `isSemaEventLabel('commit')` false → `validateSemaEvent` lanza. Dos
hallazgos ABIERTOS de su paso: el vocabulario de etiquetas no tiene NINGUN
consumidor en produccion (¿se gana el sitio?), y la rama `required` de
`validation.ts:39-46` es codigo muerto (inalcanzable tras `isSemaEvent`).
3. ~~**S-30/S-38**~~ EJECUTADO (la decision se invirtio, ver §3) · **S-19** — decision (§3).
4. **§3.5 fable** — `M1 emission` (aceptada y nunca implementada; cerraría los
waivers de tooltip y gradient-picker) · `M4` (`enumPair` frágil con 3+
valores) · `M5` (tipar el matcher `state`) · **`M6`** (`IntentExpectedFamily`

@ -82,8 +82,12 @@ export const dialogSema: Sema = {
// ─── Variant funcional (alertdialog vs dialog) ─────────────────────
// Solo haptic; el motion (scale, duration) es decisión visual.
// `channels` widens the family default: emerge does not vibrate, but an
// alert INTERRUPTING is the platform warning-notification pattern (iOS
// HIG: haptics for warnings/errors). Signed S-30/S-38, 2026-08-13.
{
selector: onContent({ aria: { attr: 'role', value: 'alertdialog' }, eventFamily: 'emerge' }),
channels: ['sound', 'haptic'],
haptic: { kind: 'pulse', intensity: 0.7, duration: 60 }
},
@ -93,8 +97,12 @@ export const dialogSema: Sema = {
},
// ─── Sheet (auto en mobile) ─────────────────────────────────────────
// `channels` widens the family default: a mobile sheet is a handheld
// surface arriving under the thumb — a light appearance tap.
// Signed S-30/S-38, 2026-08-13.
{
selector: onContent({ state: { attr: 'data-sheet', value: '' }, eventFamily: 'emerge' }),
channels: ['sound', 'haptic'],
haptic: { kind: 'tap', intensity: 0.4, duration: 24 }
}
]

@ -10,7 +10,10 @@ export const editableSema: Sema = {
cascade: [
{
selector: onProvider({ eventName: 'shift-enter-mode' }),
// `channels` widens the family default: shift does not vibrate, but
// crossing into edit mode is a mode change under the finger — the
// selection-tick pattern. Signed S-30/S-38, 2026-08-13.
channels: ['sound', 'haptic'],
haptic: { kind: 'tick' }
},
{

@ -10,7 +10,10 @@ export const stepperSema: Sema = {
cascade: [
{
selector: onProvider({ eventName: 'shift-step' }),
// `channels` widens the family default: shift does not vibrate, but
// advancing a step is a discrete value change — the selection-tick
// pattern (iOS HIG). Signed S-30/S-38, 2026-08-13.
channels: ['sound', 'haptic'],
haptic: { kind: 'tick' }
},
{

@ -32,7 +32,10 @@ export const timelineSema: Sema = {
cascade: [
{
selector: onItem({ eventName: 'emerge-reveal' }),
// `channels` widens the family default the docblock above already
// promised («emerge carries no haptic by default» — and this pack
// ADDS it). Signed S-30/S-38, 2026-08-13.
channels: ['sound', 'haptic'],
haptic: { kind: 'tick' }
}
]

@ -277,13 +277,7 @@ const RULE_CHANNELS = ['sound', 'haptic'] as const satisfies readonly SemaChanne
* whole (S-31) — `{kind:'tick'}` alone ends in `navigator.vibrate(NaN)`.
* These waivers record the finding; they do not bless it.
*/
const CHANNEL_EXCEPTIONS: Record<string, string> = {
'dialog#7': 'S-38 pending: alertdialog pulse on emerge — widen with channels, or drop',
'dialog#9': 'S-38 pending: sheet appearance tap on emerge — widen with channels, or drop',
'editable#0': 'S-38 pending: enter-mode tick on shift — widen + complete firma, or drop',
'stepper#0': 'S-38 pending: step tick on shift — widen + complete firma, or drop',
'timeline#0': 'S-38 pending: feed-reveal tick on emerge, with no user gesture behind it'
};
const CHANNEL_EXCEPTIONS: Record<string, string> = {};
// ── 3 · Tuning head ───────────────────────────────────────────────────────

@ -4,10 +4,14 @@ import { applyMapOverrides, resolveSignature, type SemaCascadeRule } from './res
import { SEMA_MAP } from './sema-map';
import { SOUNDS } from './sound-names';
import { SILENT } from './channels';
import { HapticChannel } from './chans/haptic';
import type { SemanticSignal } from './signal';
import { dialogSema } from './components/dialog';
import { editableSema } from './components/editable';
import { fileUploadSema } from './components/file-upload';
import { sliderSema } from './components/slider';
import { stepperSema } from './components/stepper';
import { timelineSema } from './components/timeline';
import { tagGroupSema } from './components/tag-group';
import { tagsInputSema } from './components/tags-input';
@ -429,3 +433,80 @@ describe('resolveSignature', () => {
expect(eff.sound?.pitch).toBe(SOUNDS.open.pitch);
});
});
/**
* S-30/S-38 (signed 2026-08-13) — the five rules that used to write a haptic
* signature their family never activates. The audit measured them with the
* real resolver and the real channel: `vibrate()` called ZERO times, inert
* since birth, while their own comments documented the intention. The fix is
* the designer's door the rules forgot: `channels` widening the family
* default. This locks the whole path — the rule matches, the channel opens,
* and the value that reaches the device is finite (the S-31 defence fills the
* partial `{kind:'tick'}` firmas: emerge/shift have no `base.haptic`).
*/
describe('S-30/S-38 — widened haptic rules fire end-to-end', () => {
const CASES = [
{
pack: dialogSema,
pick: (sel: string) => sel.includes('alertdialog'),
signal: { name: 'emerge-open', family: 'emerge' }
},
{
pack: dialogSema,
pick: (sel: string) => sel.includes('data-sheet'),
signal: { name: 'emerge-open', family: 'emerge' }
},
{
pack: editableSema,
pick: (sel: string) => sel.includes('shift-enter-mode'),
signal: { name: 'shift-enter-mode', family: 'shift' }
},
{
pack: stepperSema,
pick: (sel: string) => sel.includes('shift-step'),
signal: { name: 'shift-step', family: 'shift' }
},
{
pack: timelineSema,
pick: (sel: string) => sel.includes('emerge-reveal'),
signal: { name: 'emerge-reveal', family: 'emerge' }
}
] as const;
function mockTargetMatching(selector: string): HTMLElement {
const el: { matches: (sel: string) => boolean; closest: (sel: string) => HTMLElement | null } =
{
matches: (sel: string) => sel === selector,
closest(sel: string) {
return sel === selector ? (el as unknown as HTMLElement) : null;
}
};
return el as unknown as HTMLElement;
}
it('every widened rule resolves haptic open AND vibrates with finite values', async () => {
for (const { pack, pick, signal } of CASES) {
const rule = pack.cascade.find((r) => pick(r.selector));
expect(rule, `${pack.name}: rule not found`).toBeDefined();
const target = mockTargetMatching(rule!.selector);
const full = { target, ...signal } as SemanticSignal;
const eff = resolveSignature(full, {
map: SEMA_MAP,
cascade: pack.cascade as unknown as SemaCascadeRule[]
});
const label = `${pack.name} · ${rule!.selector}`;
expect(eff.activeChannels, label).toContain('haptic');
expect(eff.activeChannels, label).toContain('sound');
expect(eff.haptic, label).toBeDefined();
// The device end: exactly one vibration, every number finite.
const calls: (number | number[])[] = [];
const channel = new HapticChannel({ vibrateFn: (p) => (calls.push(p), true) });
await channel.handle(full, eff);
expect(calls, label).toHaveLength(1);
const pattern = Array.isArray(calls[0]) ? calls[0] : [calls[0]];
expect(pattern.every(Number.isFinite), `${label} — pattern ${String(calls[0])}`).toBe(true);
}
});
});

Loading…
Cancel
Save

Powered by TurnKey Linux.