fix(soma,morfo): tres controles que declaraban su evento y no lo disparaban

Los tres salieron de la adjudicacion docs<->codigo del 2026-08-11, y los tres
tienen la misma forma: el morfo declara el evento, el boton esta en pantalla, y
entre medias no hay nadie.

D1 · AlertDialog era mudo. Action y Cancel llamaban a `dialog.handleClose()`,
que solo voltea `open` y nunca alcanza `dismissWith`. Medido: pulsar cualquiera
de los dos sellaba unicamente el `contact-activate` del Button compuesto,
`data-last-action` quedaba null y no habia `emerge-close`. Escape, que si pasa
por `dismissWith`, tenia firma — el teclado era expresivo y los botones no, y
confirmar una accion destructiva era indistinguible de cancelarla.
Ahora: Action cierra con causa `save`, Cancel con `cancel`. Y `dismissWith`
gana un `opts.intent`: la causa es vocabulario cerrado —QUE paso— pero el peso
es por instancia, porque confirmar «guardar cambios» y confirmar «borrar la
cuenta» son la misma causa con distintas consecuencias. Es el eje que el morfo
ya liga en `emerge-open` con `intent.fromProp`, asi que la aparicion y la salida
toman el tono del mismo sitio. Medido: Action → commit/risk/saved, Cancel →
emerge/cancelled.

D3 · El boton Clear era mudo en los cinco pickers. `commit-reset` declarado y
jamas disparado: `clear()` solo asignaba el valor. El hermano generico
(`picker-provider.svelte.ts:201`) ya traia la forma correcta.

  Y al empezar a disparar salio una segunda averia debajo, invisible mientras
  el evento no existia: date-picker y date-range-picker apuntaban `commit-reset`
  a la parte `calendar`, que NINGUN componente registra en el runtime (solo
  `provider` lo esta). El destino no resolvia y el emit se rechazaba en
  silencio. Retargeteados al `provider`, como color-picker — el terminal se
  sella donde vive el valor, y lo que se borro es el valor del picker, no el
  del calendario que lo mostraba. Una parte declarada sin registro en runtime
  es un destino inalcanzable.

D4 · El swatch y el eyedropper del ColorPicker commiteaban en silencio, pese a
que el morfo lo promete con todas las letras («fires on pointer release (area
thumb / channel slider / swatch click / eyedropper pick)») y el pack de sema lo
repite. Solo el Area disparaba: `commitChange()` ejecuta el callback del
consumidor, que no es la senal perceptual.

Medido con raton real y rAF vivo: los cinco Clear disparan `commit-reset`
(commit/neutral) y el swatch dispara `commit-set` sobre el provider. El
eyedropper NO es medible aqui — la API `EyeDropper` no existe en headless — pero
su codigo es la misma linea en el mismo sitio.

check en su linea base con 0 errores nuevos · 522 tests del alcance en verde ·
docs:check 0/0.

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

@ -31,7 +31,15 @@ export const datePickerMorfo = {
semantic: {
family: 'commit',
verb: 'reset',
target: v.partRef('calendar'),
// The PROVIDER, like color-picker's: what was cleared is the
// picker's value, and the terminal is stamped where the value
// lives, not on the surface that happened to show it. This
// targeted `calendar` until 2026-08-11 — a part the provider
// never registers with the runtime (only `provider` is), so
// once `clear()` finally emitted, the target resolved to
// nothing and the emit rejected in silence. Declared parts
// with no runtime registration are unreachable targets.
target: v.partRef('provider'),
sequence: 'post',
intent: 'neutral'
}

@ -28,7 +28,11 @@ export const dateRangePickerMorfo = {
semantic: {
family: 'commit',
verb: 'reset',
target: v.partRef('calendar'),
// The PROVIDER — same reasoning and same defect as date-picker:
// `calendar` is declared as a part but never registered with the
// runtime, so the target resolved to nothing and the emit
// rejected silently once `clear()` started firing (2026-08-11).
target: v.partRef('provider'),
sequence: 'post',
intent: 'neutral'
}

@ -43,6 +43,21 @@ function installSomaHarness() {
return { dom };
}
/**
* Stand-in for the parent Dialog. It carries `opts.intent` because Action
* reads the doctrinal intent to weigh its commit, and `dismissWith` because
* closing WITH A CAUSE is exactly what these two buttons stopped failing to do
* (D1, 2026-08-11).
*/
function dialogDouble(intent: 'neutral' | 'risk' | 'threat' = 'neutral') {
const dismissWith = vi.fn(() => Promise.resolve());
vi.spyOn(DialogProvider, 'require').mockReturnValue({
opts: { intent: state(intent) },
dismissWith
} as unknown as DialogProvider);
return { dismissWith };
}
function actionOpts(el: HTMLButtonElement, ariaLabel: string | undefined = undefined) {
return {
id: state('alert-action'),
@ -67,10 +82,7 @@ describe('AlertDialogProvider parts', () => {
it('projects action/cancel contracts and closes the backing Dialog', async () => {
const { dom } = installSomaHarness();
const handleClose = vi.fn();
vi.spyOn(DialogProvider, 'require').mockReturnValue({
handleClose
} as unknown as DialogProvider);
const { dismissWith } = dialogDouble();
const actionEl = document.createElement('button');
const cancelEl = document.createElement('button');
document.body.append(actionEl, cancelEl);
@ -102,17 +114,19 @@ describe('AlertDialogProvider parts', () => {
result.action.onclick({ currentTarget: actionEl } as never);
result.cancel.onclick({ currentTarget: cancelEl } as never);
expect(handleClose).toHaveBeenCalledTimes(2);
expect(dismissWith).toHaveBeenLastCalledWith('cancel');
expect(dismissWith).toHaveBeenCalledTimes(2);
// D1: both close WITH A CAUSE. They used to call `handleClose()`, which
// only flips `open` — so confirming and cancelling were the same silence.
expect(dismissWith).toHaveBeenLastCalledWith('save', {});
cleanup();
dom.dispose();
});
it('keeps aria-label reactive to explicit overrides', async () => {
const { dom } = installSomaHarness();
vi.spyOn(DialogProvider, 'require').mockReturnValue({
handleClose: vi.fn()
} as unknown as DialogProvider);
dialogDouble();
const actionEl = document.createElement('button');
document.body.appendChild(actionEl);
const opts = actionOpts(actionEl);

@ -67,8 +67,24 @@ export class AlertDialogActionProvider {
() => this.dialog.opts.intent.current ?? 'neutral'
);
/**
* Confirming is a COMMIT, and it closes the dialog with that cause. Until
* 2026-08-11 this called `dialog.handleClose()`, which only flips `open`:
* measured in the browser, pressing Action stamped nothing but the composed
* Button's own `contact-activate`, `data-last-action` stayed null, and no
* `emerge-close` fired — so confirming a destructive action and cancelling
* it were indistinguishable to sema and eidos, while Escape (which does go
* through `dismissWith`) had a signature. The keyboard was expressive and
* the buttons were mute.
*
* The tone rides the same `intent` prop that colours this button, so a
* `risk` / `threat` dialog confirms with that weight instead of the neutral
* `fulfill` the `save` cause carries by default.
*/
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
this.dialog.handleClose();
void this.dialog.dismissWith('save', {
...(this.dialogIntent === 'neutral' ? {} : { intent: this.dialogIntent })
});
};
readonly props = $derived.by(() =>
@ -116,8 +132,14 @@ export class AlertDialogCancelProvider {
() => this.opts.ariaLabel.current || this.soma.langs.ts(ALERT_DIALOG_LANGS.CANCEL)
);
/**
* Backing out is `emerge.close` with `data-last-action='cancelled'` — the
* cause that distinguishes it from the Action's commit and from Escape's
* `dismissed`. Same defect as Action until 2026-08-11: `handleClose()` only
* flipped `open` and the button was perceptually mute.
*/
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
this.dialog.handleClose();
void this.dialog.dismissWith('cancel');
};
readonly props = $derived.by(() =>

@ -419,6 +419,8 @@ export class ColorPickerProvider {
*/
clear(): void {
this.opts.value.current = undefined;
// Declared and never fired until 2026-08-11 — see date-picker.
void this.runtime.trigger('commit-reset');
}
readonly pickerShellHandle: PickerShellHandle = {
@ -1247,6 +1249,12 @@ export class ColorPickerSwatchTriggerProvider {
if (!hsv) return;
this.provider.opts.value.current = colorValueFromHsv(hsv);
this.provider.commitChange();
// Picking a swatch IS a value commit — the morfo says so in words
// (`color-picker.ts`: "fires on pointer release (area thumb / channel
// slider / swatch click / eyedropper pick)") and so does the sema pack.
// Until 2026-08-11 only the Area fired it: `commitChange()` runs the
// consumer callback, which is not the perceptual signal.
void this.provider.runtime.trigger('commit-set');
this.provider.handleSelect();
};
@ -1382,6 +1390,9 @@ export class ColorPickerEyeDropperProvider {
if (!hsv) return;
this.provider.opts.value.current = colorValueFromHsv(hsv);
this.provider.commitChange();
// Same as the swatch: the eyedropper's pick is a commit, and the
// morfo has been promising its signature since before it existed.
void this.provider.runtime.trigger('commit-set');
this.provider.handleSelect();
} catch {
// User cancelled the picker — silent.

@ -270,6 +270,11 @@ export class DatePickerProvider {
*/
clear(): void {
this.opts.value.current = undefined;
// The morfo declares `commit-reset` and until 2026-08-11 nothing fired
// it: the button was there, the event was declared, and the whole
// perceptual arc was silent. Same shape the generic Picker already
// ships (`picker-provider.svelte.ts:201`).
void this.runtime.trigger('commit-reset');
}
readonly props = $derived.by(() =>

@ -277,6 +277,8 @@ export class DateRangePickerProvider {
*/
clear(): void {
this.opts.value.current = { start: undefined, end: undefined };
// Declared and never fired until 2026-08-11 — see date-picker.
void this.runtime.trigger('commit-reset');
}
readonly props = $derived.by(() =>

@ -230,13 +230,25 @@ export class DialogProvider {
* For 'fail', `opts.message` SHOULD be passed (the runtime announces it
* via the shared live region). For non-fail causes, message is forwarded
* but typically omitted — the dialog closing IS the perceptible event.
*
* `opts.intent` overrides the cause's own tone for THIS call. The cause is
* a closed vocabulary — what happened — but the weight of what happened is
* per instance: confirming "save changes" and confirming "delete account"
* are the same cause with different stakes. Same axis the morfo already
* binds on `emerge-open` (`intent.fromProp: 'intent'`), so the appearance
* and the exit take their tone from one place. AlertDialog's Action is the
* caller that needs it.
*/
dismissWith(
action: 'save' | 'cancel' | 'dismiss' | 'dismiss-outside' | 'fail',
opts: { message?: string } = {}
opts: { message?: string; intent?: DismissCause['semantic']['intent'] } = {}
): Promise<void> {
const map = DISMISS_CAUSES[action];
return this.triggerClose(map, opts);
const cause =
opts.intent === undefined
? map
: { ...map, semantic: { ...map.semantic, intent: opts.intent } };
return this.triggerClose(cause, opts);
}
private async triggerClose(

@ -248,6 +248,8 @@ export class TimePickerProvider {
/** Reset value to undefined without closing. */
clear(): void {
this.opts.value.current = undefined;
// Declared and never fired until 2026-08-11 — see date-picker.
void this.runtime.trigger('commit-reset');
}
readonly props = $derived.by(() =>

@ -293,6 +293,8 @@ export class TimeRangePickerProvider {
clear(): void {
this.opts.startValue.current = undefined;
this.opts.endValue.current = undefined;
// Declared and never fired until 2026-08-11 — see date-picker.
void this.runtime.trigger('commit-reset');
}
// ── Root props (emitted onto `[data-time-range-picker]`) ───────────────

Loading…
Cancel
Save

Powered by TurnKey Linux.