refactor(button): align with book cap. 22 — contact.activate, intent visual-only (Plan B commit 2)

Button morfo declared `commit-action` with verb `action` (not in canon)
and full intent range. Per «Diseñando lo que ocurre»:

- Cap 22 §10: "Botón: contact.press" — the press is reception of the
  gesture, not the commit.
- Cap 22 §11: "el intent fuerte no debería vivir en el contacto, sino
  en la señal o consecuencia posterior. La interfaz puede anticipar el
  peso mediante forma, color o señal previa."
- Cap 22 §8: "Error típico: contact.press + fulfill — Esto hace que el
  sistema diga 'ya terminó' cuando solo ha empezado."

Strict book reading applied:

- Morfo event renamed: `commit-action` → `contact-activate`
  (family: `contact`, verb: `activate`, no intent binding)
- Soma provider trigger string + comments updated
- `intent` prop survives as VISUAL signal only — drives `data-color`
  and chip variant (anticipatory weight via form/color per cap. 22 §11)
- README + types JSDoc updated with the book's prescription and the
  composition pattern: perceptual richness emerges from the OTHER
  morfos in the flow (Dialog warning, Item being deleted, etc.) firing
  their own commit/signal events at the actual moment of the
  consequence
- `EVENT_NAME_ALLOWLIST` in morfo-vocabulary-check.ts emptied — no
  exceptions to the canon needed

No `declaresOutcome` props, no imperative sema escape hatches: the
morfo remains the single source of truth for events. If a flow loses
perceptual differentiation, the answer is to model the missing morfo,
not to overload the Button.

Verification:
- npm run morfo:vocabulary → EXIT 0 (zero allowlisted)
- npx vitest src/uix/sema src/uix/morfo → 194/194 pass
- npm run check → no new errors from these changes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent af6d6c762a
commit 23afeb6f86

@ -52,13 +52,8 @@ const MORFOS_DIR = join(__dirname, '..', 'src', 'uix', 'morfo', 'components');
* canon. Remove an entry the moment the underlying morfo is updated.
*/
const EVENT_NAME_ALLOWLIST: Record<string, string> = {
// TODO(Button): `commit-action` uses verb `action`, not in canon.
// Per book cap. 22 §11 "el intent fuerte no debería vivir en el contacto,
// sino en la señal o consecuencia posterior." Deciding between
// (a) `contact-activate` + intent stays as visual chip variant only
// (b) keep `commit` family but pick a canonical verb (e.g. acknowledge)
// is an API decision that needs design sign-off — see Plan B commit 2.
'button:commit-action': 'Pending Button intent/family design decision (Plan B commit 2)'
// Empty — Plan B commit 2 resolved `button.commit-action` to
// `button.contact-activate` per book cap. 22 §10-11.
};
async function loadMorfos(): Promise<Morfo[]> {

@ -3,10 +3,11 @@
* Eidos `<Button>` — visual wrapper over Soma's headless button.
*
* Soma owns the click flow, `data-state`, `aria-busy`, `aria-disabled`
* and the `commit-action` perceptual event. Eidos adds the visual API
* on top — `variant`, `size`, `rounded`, `block`, `iconOnly`, the
* `icon` / `endIcon` snippet slots, and the loading Spinner — and
* wires those to the `data-*` selectors that `button.css` reacts to.
* and the `contact-activate` perceptual event (book cap. 22 §10).
* Eidos adds the visual API on top — `variant`, `size`, `rounded`,
* `block`, `iconOnly`, the `icon` / `endIcon` snippet slots, and the
* loading Spinner — and wires those to the `data-*` selectors that
* `button.css` reacts to.
*/
import { ActiveEidos } from '$uix/eidos';
import * as Button from '$soma/components/button';

@ -4,17 +4,34 @@ import { v } from '../types';
/**
* Button — single-press action surface.
*
* Per the semántica book (anexo, Tabla 1: Componentes × familias):
* Per «Diseñando lo que ocurre» cap. 22 §10:
*
* > "Button — Evento principal: commit.action — Intents posibles:
* > neutral (default), affirm, risk, threat según consecuencia.
* > fulfill se reserva para acciones de cierre/completar; loss
* > para acciones irreversibles (delete-forever, sign-out)."
* > "Botón: contact.press — microcompresión, estado pressed, retorno
* > rápido."
*
* One doctrinal event: `commit-action`. Sequence is `pre` because the
* user expects audible/visual feedback at the moment of the click; the
* consumer's onclick may then run async work without blocking the
* perceptual signal.
* The press itself is `contact.activate`: pre-evaluation reception of
* the user's gesture. The consumer's onclick then triggers whatever
* actually happens (a commit, a shift, a delegate). The Button's morfo
* declares ONLY the contact event — it does not pretend to know what
* the click will resolve.
*
* Crucially, the press carries no intent. Per cap. 22 §11:
*
* > "En acciones graves, la interfaz puede anticipar el peso mediante
* > forma, color o señal previa, pero el intent fuerte no debería
* > vivir en el contacto, sino en la señal o consecuencia posterior."
*
* And cap. 22 §8 names `contact.press + fulfill` an antipattern:
*
* > "Esto hace que el sistema diga 'ya terminó' cuando solo ha
* > empezado. Contact inicia. Commit resuelve."
*
* So Button.intent is a VISUAL prop only — it modulates the chip variant
* and `data-color` (anticipa peso) but does not load the sema event with
* threat/loss/etc. tonality. A delete button gets a red chip + a generic
* contact tick at click; the threat sound lives in `signal.warn + threat`
* before the click, and the loss sound lives in `commit.delete + loss`
* after, both fired by the consumer.
*
* Two parts:
* - Provider (the `<button>` itself)
@ -35,22 +52,21 @@ export const buttonMorfo = {
},
events: [
{
name: 'commit-action',
name: 'contact-activate',
semantic: {
family: 'commit',
verb: 'action',
family: 'contact',
verb: 'activate',
target: v.partRef('provider'),
// Sequence 'pre' — the perceptual signal lands at the moment
// the user clicks, before any async work in the consumer's
// onclick. Consumers wanting deferred feedback (e.g. "saved!"
// chime after a successful network call) emit their own
// event from the resolved state.
sequence: 'pre',
intent: {
fromProp: 'intent',
default: 'neutral',
supported: ['neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss']
}
// onclick. Consumers wanting deferred semantic feedback
// (e.g. "saved!" after a successful network call) emit their
// own commit.* event from the resolved state.
//
// NO intent — per book cap. 22 §11. The visual `intent` prop
// drives data-color (anticipatory weight via form/color), not
// the sema event's evaluative load.
sequence: 'pre'
}
}
],

@ -51,11 +51,30 @@ Single-action `<button type="button">` (or `<a>` when `href` is provided) with p
## Sema events
| Event | Family | Verb | When |
| --------------- | --------- | -------- | ----------------------------------------------------- |
| `commit-action` | `commit` | `submit` | Click handler invoked (not while `disabled`/`pending`). |
| Event | Family | Verb | When |
| ------------------ | --------- | ---------- | ----------------------------------------------------- |
| `contact-activate` | `contact` | `activate` | Click handler invoked (not while `disabled`/`pending`). |
Per book cap. 22 §10 ("Botón: `contact.press`"), the Button's morfo
event is just the *reception* of the user's gesture — pre-evaluation,
no intent on the event itself. The `intent` prop drives visual weight
(`data-color`, chip variant) but does NOT load the contact event with
threat/loss/etc. tonality. For the evaluative consequence (the actual
save sound, the delete haptic), consumers fire downstream semantic
events when the real outcome lands:
The semantic event's `intent` is taken from the `intent` prop, so the visual layer can tint the press-flash and the sound channel can pick the right note.
```svelte
<Button intent="threat" onclick={confirmDelete}>Delete</Button>
<!--
contact-activate at click → generic contact tick
signal.warn + threat → consumer fires before destructive op
commit.delete + loss → consumer fires after destructive op completes
-->
```
This way the Button stops "celebrating before time" (book cap. 22 §8) —
the celebration sound plays at the moment the save *actually* resolves,
not when the click is received.
## See also

@ -28,9 +28,12 @@ type ButtonOpts = OptsFromProps<
/**
* Button provider — runtime-direct.
*
* Owns the press → `commit-action` flow. Sequence is `pre` so the
* Owns the press → `contact-activate` flow per «Diseñando lo que ocurre»
* cap. 22 §10 ("Botón: contact.press"). Sequence is `pre` so the
* perceptual signal lands at the moment of the click; the consumer's
* onclick may then run async work without blocking it.
* onclick may then run async work that might fire its own commit/signal
* events when the actual outcome lands. The Button itself does not
* pretend to know what the click resolves.
*/
export class ButtonProvider {
static create(opts: ButtonOpts) {
@ -73,10 +76,13 @@ export class ButtonProvider {
'aria-label': () => opts['aria-label'].current
},
events: {
'commit-action': () => {
'contact-activate': () => {
// The consumer's onclick has already been (or will be)
// invoked by native button click dispatch. We only fire
// onPress for diagnostic / analytics consumers.
// onPress for diagnostic / analytics consumers. The
// `intent` echoed back is the visual prop value (visual
// context for analytics), not a semantic event payload —
// the contact carries no intent per book cap. 22 §11.
opts.onPress.current?.({ intent: opts.intent.current ?? 'neutral' });
}
}
@ -102,8 +108,9 @@ export class ButtonProvider {
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.isDisabled || this.isLoading) return;
// Sequence 'pre' — the perceptual signal fires synchronously with
// the click; consumer's onclick runs in parallel.
void this.runtime.trigger('commit-action');
// the click; consumer's onclick runs in parallel and may dispatch
// its own commit/signal events when the real outcome lands.
void this.runtime.trigger('contact-activate');
};
/**

@ -2,19 +2,28 @@ import type { WithChild, Without, OnChangeFn } from '../../types';
import type { PrimitiveButtonAttributes, PrimitiveSpanAttributes } from '../../types';
/**
* Doctrinal intent of the Button's commit (anexo Tabla 1).
* Visual evaluative tint of the Button's surface.
*
* - `neutral` (default) — action without affective load
* - `affirm` — positive lightweight commit (e.g. "subscribe")
* - `neutral` (default) — no affective tint; `color` hierarchy wins
* - `affirm` — positive lightweight (e.g. "subscribe", "follow")
* - `fulfill` — closure / completion (e.g. "save", "submit")
* - `risk` — moderate negative consequence (e.g. "leave page")
* - `threat` — active negative consequence (e.g. "delete")
* - `loss` — irreversible loss (e.g. "delete forever", "sign out")
*
* Drives the `commit-action` event's non-visual perceptual signature
* (sound / haptic deltas via `SEMA_MAP.intents`). When non-neutral, also
* wins the visual `data-color` over any `color` hierarchy override —
* doctrine forbids aesthetic from contradicting semantic.
* Per book cap. 22 §11 ("el intent fuerte no debería vivir en el
* contacto"), this prop does NOT load the morfo's `contact-activate`
* event with intent. It drives the visual `data-color` (anticipatory
* weight via form/color) and the eidos chip variant.
*
* For the actual evaluative consequence (the sound at click, the haptic
* at delete, etc.), consumers wire a downstream semantic event:
*
* - `commit.save + affirm` when the save resolves
* - `signal.warn + threat` BEFORE the delete confirmation
* - `commit.delete + loss` AFTER the destructive operation completes
*
* This way the Button stops "celebrating before time" (book cap. 22 §8).
*/
export type ButtonIntent = 'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss';
@ -44,8 +53,11 @@ export type ButtonProps = WithChild<
/** DOM id. Auto-generated when omitted. */
id?: string;
/**
* Doctrinal intent of the action's consequence. Always present;
* default `neutral`. See {@link ButtonIntent}.
* Visual evaluative tint of the button's surface. Drives the chip
* variant + `data-color`. Does NOT load the contact event with
* intent — per book cap. 22 §11 the semantic intent of the
* consequence lives in whatever commit/signal the consumer fires
* downstream. See {@link ButtonIntent}.
*
* @default 'neutral'
*/
@ -70,7 +82,7 @@ export type ButtonProps = WithChild<
disabled?: boolean;
/**
* Loading state. While true, the click handler is gated (no
* `commit-action` emitted), the button is `aria-busy`, and the
* `contact-activate` emitted), the button is `aria-busy`, and the
* Spinner part is rendered. The Eidos recipe may also swap the
* label with the localised `loadingText`.
*
@ -92,10 +104,11 @@ export type ButtonProps = WithChild<
/** Override accessible name. Required when `iconOnly` is true. */
'aria-label'?: string;
/**
* Called on every press change for diagnostic / analytics
* purposes. The actual side effect is the consumer's
* `onclick` — `onPress` is read-only feedback that the
* `commit-action` event fired.
* Called on every press for diagnostic / analytics purposes. The
* actual side effect is the consumer's `onclick` — `onPress` is
* read-only feedback that the `contact-activate` event fired.
* The `intent` payload echoes the visual prop value, not a
* semantic claim about the press.
*/
onPress?: OnChangeFn<{ intent: ButtonIntent }>;
},

Loading…
Cancel
Save

Powered by TurnKey Linux.