sema: align vocabulary with libro_semantica (signal, shift, loss)

Updates the canonical semantic vocabulary to match the latest
formulation in src/docs/libro_semantica_completo.txt:

Families (7, was 6):
  contact, commit, signal, handle, emerge, shift, sustain
  - `alert` → `signal` (rename: same perceptual function — attention /
    salience — but the book's name reflects the broader scope including
    notify, warn, emphasize, announce).
  - `shift` (NEW, transitional): frame change. Modal mount, navigation,
    mode change. Differs from emerge in that it reorganizes planes
    (heavier backdrop, deeper shadow) — "cruce de umbral" not just
    "aparición". Per book rule, the frame doesn't carry intent on its
    own; evaluation belongs to a companion `signal` event.

Intents (6, was 5):
  neutral, affirm, fulfill, risk, threat, loss
  - `loss` (NEW): consummated negative consequence (negative valence,
    low activation, posterior). Differs from `threat` (anterior, high
    activation, convokes action): loss registers, doesn't convoke. Per
    book §11.5 "la amenaza no es la pérdida".

sema-map.ts perceptual values:

- `signal` family entry: kept the prior `alert` numbers verbatim
  (semantics is the same — attention/salience) under the new name.
- `shift` family entry: motion 260ms ease-in-out 0.98→1, sound
  pitch 500 ascending gain 0.18 (deliberately quieter than emerge's
  0.20 to not compete with companion signal), presence with stronger
  backdrop (0.5) + shadow (blur 32 y 12 op 0.2) emphasizing
  plane reorganization.
- `loss` intent deltas: descending sound contour (-150 pitch),
  contraction (scale.to -0.04, motion duration ×0.95 — brief but
  grave), color toward purple-grave (hue replace 280, lower
  saturation/intensity), withdrawn presence (backdrop 0.05, no glow).

ARIA `role="alert"` (W3C live region) is unrelated and untouched in
soma/announce, soma/toast and morfo announce/field/form. The toast
morfo gains `loss → role:'status' aria-live:'polite'` mapping —
loss is consummated so it shouldn't interrupt.

Verbs added per the book's worked examples: signal {warn, notify,
emphasize}, shift {enter-mode, exit-mode, navigate}, commit {save,
discard, delete}, contact {press, click, hover}, handle {pick, carry,
drop}, emerge {reveal, show, hide}.

Verified: vitest 9 files / 99 tests, smoke 68/68, morfo-check 66/66.
morfo-runtime
dev 5 months ago
parent 3faa4473f2
commit a6e2c1e443

@ -6,8 +6,8 @@
* - data-event (event name, e.g. 'announce', 'dismiss')
* - data-event-id (occurrence id from the engine)
* - data-event-phase ('active')
* - data-event-family (optional, e.g. 'alert')
* - data-event-intent (optional, e.g. 'risk')
* - data-event-family (optional, e.g. 'signal')
* - data-event-intent (optional, e.g. 'loss')
*
* The channel never touches state attrs (`data-state`, `data-intent`,
* `data-disabled`, ...) — those are owned by the runtime/morfo. Read
@ -16,7 +16,7 @@
*
* Hold defaults per family (internal to chans/visual.ts):
* - emerge / commit / handle: 240ms
* - alert / sustain: 600ms
* - signal / shift / sustain: 600ms
* - contact: 120ms
* Authors override per signal via `signal.hold`, or globally via
* `new SemanticEngine({ visual: { defaultHold } })`.

@ -85,7 +85,7 @@ export const dialogMorfo = {
name: 'close-after-fail',
target: v.partRef('content'),
semantic: {
family: 'alert',
family: 'signal',
intent: 'threat'
},
regime: 'lock',

@ -71,7 +71,7 @@ export const drawerMorfo = {
{
name: 'close-after-fail',
target: v.partRef('content'),
semantic: { family: 'alert', intent: 'threat' },
semantic: { family: 'signal', intent: 'threat' },
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'failed' }],
commits: {

@ -71,7 +71,7 @@ export const popoverMorfo = {
{
name: 'close-after-fail',
target: v.partRef('content'),
semantic: { family: 'alert', intent: 'threat' },
semantic: { family: 'signal', intent: 'threat' },
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'failed' }],
commits: {

@ -17,11 +17,11 @@ describe('toastMorfo semantic contract', () => {
const announce = toastMorfo.events?.find((event) => event.name === 'announce')
expect(announce).toBeDefined()
expect(announce?.semantic).toMatchObject({
family: 'alert',
family: 'signal',
intent: {
fromProp: 'intent',
default: 'neutral',
supported: ['neutral', 'affirm', 'fulfill', 'risk', 'threat']
supported: ['neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss']
}
})
})
@ -41,7 +41,8 @@ describe('toastMorfo semantic contract', () => {
affirm: 'status',
fulfill: 'status',
risk: 'alert',
threat: 'alert'
threat: 'alert',
loss: 'status'
}
}
})
@ -56,7 +57,8 @@ describe('toastMorfo semantic contract', () => {
affirm: 'polite',
fulfill: 'polite',
risk: 'assertive',
threat: 'assertive'
threat: 'assertive',
loss: 'polite'
}
}
})

@ -15,11 +15,11 @@ export const toastMorfo = {
name: 'announce',
target: v.partRef('item'),
semantic: {
family: 'alert',
family: 'signal',
intent: {
fromProp: 'intent',
default: 'neutral',
supported: ['neutral', 'affirm', 'fulfill', 'risk', 'threat']
supported: ['neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss']
}
}
},
@ -95,7 +95,10 @@ export const toastMorfo = {
affirm: 'status',
fulfill: 'status',
risk: 'alert',
threat: 'alert'
threat: 'alert',
// `loss` is a consummated negative consequence (no urgency
// to interrupt) — `status` is the appropriate ARIA role.
loss: 'status'
})
},
{
@ -105,7 +108,8 @@ export const toastMorfo = {
affirm: 'polite',
fulfill: 'polite',
risk: 'assertive',
threat: 'assertive'
threat: 'assertive',
loss: 'polite'
}),
severity: 'recommended'
},

@ -184,18 +184,23 @@ const keyboardSchema = object({
// ── Semantic events ───────────────────────────────────────────────────────
const semaIntentSchema = union(
literal('threat'),
literal('risk'),
literal('neutral'),
literal('affirm'),
literal('fulfill')
literal('fulfill'),
literal('risk'),
literal('threat'),
literal('loss')
)
const semaTransitionalFamilySchema = union(literal('emerge'), literal('sustain'))
const semaTransitionalFamilySchema = union(
literal('emerge'),
literal('shift'),
literal('sustain')
)
const semaValencedFamilySchema = union(
literal('contact'),
literal('commit'),
literal('alert'),
literal('signal'),
literal('handle')
)

@ -301,7 +301,7 @@ export interface MorfoEvent {
*
* Resolution precedence: imperative `signal.hold` (caller passes at emit
* time) > this `events[].hold` (declarative, this field) > sema family
* fallback (`'emerge'` → `'brief'`, `'alert'` → `'noticed'`, ...).
* fallback (`'emerge'` → `'brief'`, `'signal'` → `'noticed'`, ...).
*/
hold?: SemaDurationSpec;
mode?: SemaMode;

@ -5,8 +5,8 @@ señales perceptivas.
## Qué es
- familias canónicas: `contact`, `commit`, `alert`, `handle`, `emerge`, `sustain`
- intents canónicos: `neutral`, `affirm`, `fulfill`, `risk`, `threat`
- familias canónicas: `contact`, `commit`, `signal`, `handle`, `emerge`, `shift`, `sustain`
- intents canónicos: `neutral`, `affirm`, `fulfill`, `risk`, `threat`, `loss`
- normalización entre shape estructurado y label canónico
- validación mínima del dominio
- `EngineSemantic` como registry de canales + dispatch de ocurrencias
@ -229,11 +229,12 @@ este vocabulario para que sema/sound/vibra puedan suscribir por verb y
eidos pueda escribir selectores transversales (`[data-event^=dismiss]`).
```
emerge: present · dismiss · open · close · expand · collapse
commit: commit · cancel · confirm · submit · reset · fail
alert: announce · alert
contact: activate · select · toggle
handle: acknowledge · edit · drag · resize
emerge: present · dismiss · open · close · expand · collapse · reveal · show · hide
shift: enter-mode · exit-mode · navigate
commit: commit · cancel · confirm · submit · reset · fail · save · discard · delete
signal: announce · alert · warn · notify · emphasize
contact: activate · select · toggle · press · click · hover
handle: acknowledge · edit · drag · resize · pick · carry · drop
sustain: tick · progress
```

@ -38,7 +38,7 @@ describe('VisualChannel', () => {
{
target,
name: 'announce',
family: 'alert',
family: 'signal',
intent: 'risk',
id: 'sig-test-1',
hold: 100
@ -49,7 +49,7 @@ describe('VisualChannel', () => {
expect(target.getAttribute('data-event')).toBe('announce')
expect(target.getAttribute('data-event-id')).toBe('sig-test-1')
expect(target.getAttribute('data-event-phase')).toBe('active')
expect(target.getAttribute('data-event-family')).toBe('alert')
expect(target.getAttribute('data-event-family')).toBe('signal')
expect(target.getAttribute('data-event-intent')).toBe('risk')
})
@ -86,17 +86,17 @@ describe('VisualChannel', () => {
it('uses effective.motion.duration when no explicit hold (resolver path)', async () => {
const channel = new VisualChannel()
// Mimic what the engine produces for an alert signal: motion.duration
// Mimic what the engine produces for a signal-family event: motion.duration
// dictates the hold so the visual marker, the sound earcon, and the
// CSS transition all share the same family-level budget.
const effective: EffectiveSignature = {
family: 'alert',
family: 'signal',
intent: undefined,
activeChannels: ['motion'],
motion: { duration: 220, easing: 'ease-in-out' }
}
const promise = channel.handle(
{ target, name: 'announce', family: 'alert', id: 'sig-1' },
{ target, name: 'announce', family: 'signal', id: 'sig-1' },
effective
)
@ -111,9 +111,9 @@ describe('VisualChannel', () => {
it('falls back to the family fallback table when neither hold nor motion is given', async () => {
const channel = new VisualChannel()
// alert family fallback → 600ms (SEMA_DURATIONS['noticed'])
// signal family fallback → 600ms (SEMA_DURATIONS['noticed'])
const promise = channel.handle(
{ target, name: 'announce', family: 'alert', id: 'sig-1' },
{ target, name: 'announce', family: 'signal', id: 'sig-1' },
NO_EFFECTIVE
)
@ -155,13 +155,13 @@ describe('VisualChannel', () => {
it('signal.hold takes precedence over effective.motion.duration', async () => {
const channel = new VisualChannel()
const effective: EffectiveSignature = {
family: 'alert',
family: 'signal',
intent: undefined,
activeChannels: ['motion'],
motion: { duration: 220, easing: 'ease-in-out' }
}
const promise = channel.handle(
{ target, name: 'announce', family: 'alert', id: 'sig-1', hold: 999 },
{ target, name: 'announce', family: 'signal', id: 'sig-1', hold: 999 },
effective
)
@ -180,7 +180,7 @@ describe('VisualChannel', () => {
{
target,
name: 'announce',
family: 'alert',
family: 'signal',
intent: 'risk',
id: 'sig-1',
hold: 100
@ -232,7 +232,7 @@ describe('VisualChannel', () => {
{
target,
name: 'announce',
family: 'alert',
family: 'signal',
intent: 'risk',
id: 'sig-1',
hold: 100

@ -50,10 +50,11 @@ export interface VisualChannelOptions {
*/
const FAMILY_FALLBACK_LABEL: Record<string, SemaDurationLabel> = {
emerge: 'brief',
shift: 'noticed',
sustain: 'noticed',
contact: 'glimpse',
commit: 'brief',
alert: 'noticed',
signal: 'noticed',
handle: 'brief'
}

@ -26,11 +26,11 @@ describe('EngineSemantic.emit (integration with built-in VisualChannel)', () =>
it('writes signal attrs synchronously', () => {
const engine = new EngineSemantic()
void engine.emit({ target, name: 'announce', family: 'alert', intent: 'risk' })
void engine.emit({ target, name: 'announce', family: 'signal', intent: 'risk' })
expect(target.getAttribute('data-event')).toBe('announce')
expect(target.getAttribute('data-event-phase')).toBe('active')
expect(target.getAttribute('data-event-family')).toBe('alert')
expect(target.getAttribute('data-event-family')).toBe('signal')
expect(target.getAttribute('data-event-intent')).toBe('risk')
expect(target.getAttribute('data-event-id')).toMatch(/^sig-\d+$/)
})
@ -62,10 +62,10 @@ describe('EngineSemantic.emit (integration with built-in VisualChannel)', () =>
it('uses the resolver motion.duration for hold when no explicit hold', async () => {
const engine = new EngineSemantic()
// `alert` family motion.duration in SEMA_MAP is 220ms — the visual
// `signal` family motion.duration in SEMA_MAP is 220ms — the visual
// hold now mirrors that perceptual budget instead of the legacy
// 600ms family fallback.
const promise = engine.emit({ target, name: 'announce', family: 'alert' })
const promise = engine.emit({ target, name: 'announce', family: 'signal' })
expect(target.getAttribute('data-event')).toBe('announce')
vi.advanceTimersByTime(219)
@ -121,7 +121,7 @@ describe('EngineSemantic.emit (integration with built-in VisualChannel)', () =>
const engine = new EngineSemantic({ visual: false })
// No DOM mutation when visual channel isn't registered.
await engine.emit({ target, name: 'announce', family: 'alert', intent: 'risk', hold: 9999 })
await engine.emit({ target, name: 'announce', family: 'signal', intent: 'risk', hold: 9999 })
expect(target.hasAttribute('data-event')).toBe(false)
})
})

@ -12,45 +12,52 @@ import type {
export const SEMA_VALENCED_FAMILIES = [
'contact',
'commit',
'alert',
'signal',
'handle'
] as const satisfies readonly SemaValencedFamily[]
export const SEMA_TRANSITIONAL_FAMILIES = [
'emerge',
'shift',
'sustain'
] as const satisfies readonly SemaTransitionalFamily[]
export const SEMA_INTENTS = [
'threat',
'risk',
'neutral',
'affirm',
'fulfill'
'fulfill',
'risk',
'threat',
'loss'
] as const satisfies readonly SemaIntent[]
export const SEMA_EVENT_LABELS = [
'contact-neutral',
'contact-threat',
'contact-risk',
'contact-affirm',
'contact-fulfill',
'contact-risk',
'contact-threat',
'contact-loss',
'commit-neutral',
'commit-threat',
'commit-risk',
'commit-affirm',
'commit-fulfill',
'alert-neutral',
'alert-threat',
'alert-risk',
'alert-affirm',
'alert-fulfill',
'commit-risk',
'commit-threat',
'commit-loss',
'signal-neutral',
'signal-affirm',
'signal-fulfill',
'signal-risk',
'signal-threat',
'signal-loss',
'handle-neutral',
'handle-threat',
'handle-risk',
'handle-affirm',
'handle-fulfill',
'handle-risk',
'handle-threat',
'handle-loss',
'emerge',
'shift',
'sustain'
] as const satisfies readonly SemaEventLabel[]

@ -104,7 +104,7 @@ export interface SemaMap {
// ── The map ────────────────────────────────────────────────────────────────
export const SEMA_MAP: SemaMap = {
version: '0.5.0',
version: '0.6.0',
families: {
contact: {
base: {
@ -157,7 +157,7 @@ export const SEMA_MAP: SemaMap = {
},
activeChannels: ['motion', 'sound', 'color']
},
alert: {
signal: {
base: {
motion: {
duration: 220,
@ -214,6 +214,41 @@ export const SEMA_MAP: SemaMap = {
},
activeChannels: ['motion', 'presence', 'sound']
},
shift: {
// Shift = cambio de marco operativo (modal bloqueante, navegación,
// cambio de régimen). Se diferencia de emerge en que reorganiza
// los planos: backdrop pesado y shadow más profundo comunican
// "cruce de umbral", no solo "aparición". Sound es más sutil que
// emerge para no competir con el signal que el marco pueda
// contener (regla del libro: el marco no absorbe el mensaje).
base: {
motion: {
duration: 260,
easing: 'ease-in-out',
scale: { from: 0.98, to: 1 },
translate: { x: 0, y: 0 }
},
sound: {
pitch: 500,
centroid: 1400,
roughness: 0.05,
attack: 14,
decay: 220,
duration: 160,
contour: 'ascending',
gain: 0.18
},
color: null,
presence: {
opacity: { from: 0, to: 1 },
shadow: { blur: 32, y: 12, opacity: 0.2 },
backdrop: 0.5,
duration: 300,
easing: 'ease-out'
}
},
activeChannels: ['motion', 'presence', 'sound']
},
handle: {
base: {
motion: {
@ -309,6 +344,32 @@ export const SEMA_MAP: SemaMap = {
shadow: { blur: 1 }
}
}
},
loss: {
// Loss = consecuencia consumada (negativo + baja activación,
// posterior). Se diferencia de threat (anterior, alta activación,
// convoca acción): loss registra, no convoca. Por eso desciende y
// se contrae en vez de irrumpir, color púrpura-grave en vez de
// rojo-alarma, y backdrop sutil en vez de glow.
deltas: {
motion: {
duration: { op: 'multiply', factor: 0.95 },
scale: { to: -0.04 }
},
sound: {
pitch: -150,
contour: 'descending'
},
color: {
hue: { op: 'replace', value: 280 },
saturation: -0.1,
intensity: 0.15
},
presence: {
backdrop: 0.05,
shadow: { blur: 0 }
}
}
}
},
soundPack: {}
@ -319,6 +380,6 @@ export const SEMA_MAP: SemaMap = {
export const SEMA_VALENCED_FAMILY_LIST: readonly SemaValencedFamily[] = [
'contact',
'commit',
'alert',
'signal',
'handle'
]

@ -10,11 +10,11 @@ import type { PartRef } from '../lib/types'
// ── Core domain ────────────────────────────────────────────────────────────
export type SemaValencedFamily = 'contact' | 'commit' | 'alert' | 'handle'
export type SemaTransitionalFamily = 'emerge' | 'sustain'
export type SemaValencedFamily = 'contact' | 'commit' | 'signal' | 'handle'
export type SemaTransitionalFamily = 'emerge' | 'shift' | 'sustain'
export type SemaFamily = SemaValencedFamily | SemaTransitionalFamily
export type SemaIntent = 'threat' | 'risk' | 'neutral' | 'affirm' | 'fulfill'
export type SemaIntent = 'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss'
export type SemaMode = 'blocking' | 'advisory'
export type SemaRegime = 'replace' | 'collapse' | 'lock' | 'queue'
@ -23,26 +23,31 @@ export type SemaCause = 'keyboard' | 'pointer' | 'programmatic' | 'validation'
export type SemaEventLabel =
| 'contact-neutral'
| 'contact-threat'
| 'contact-risk'
| 'contact-affirm'
| 'contact-fulfill'
| 'contact-risk'
| 'contact-threat'
| 'contact-loss'
| 'commit-neutral'
| 'commit-threat'
| 'commit-risk'
| 'commit-affirm'
| 'commit-fulfill'
| 'alert-neutral'
| 'alert-threat'
| 'alert-risk'
| 'alert-affirm'
| 'alert-fulfill'
| 'commit-risk'
| 'commit-threat'
| 'commit-loss'
| 'signal-neutral'
| 'signal-affirm'
| 'signal-fulfill'
| 'signal-risk'
| 'signal-threat'
| 'signal-loss'
| 'handle-neutral'
| 'handle-threat'
| 'handle-risk'
| 'handle-affirm'
| 'handle-fulfill'
| 'handle-risk'
| 'handle-threat'
| 'handle-loss'
| 'emerge'
| 'shift'
| 'sustain'
// ── Structured semantics ───────────────────────────────────────────────────

@ -26,6 +26,14 @@ export const SEMA_VERBS = [
'close',
'expand',
'collapse',
'reveal',
'show',
'hide',
// shift family — frame change (modal mount, navigation, mode change)
'enter-mode',
'exit-mode',
'navigate',
// commit family — user-initiated commit (valenced by intent)
'commit',
@ -34,21 +42,34 @@ export const SEMA_VERBS = [
'submit',
'reset',
'fail',
'save',
'discard',
'delete',
// alert family — system signals attention (valenced by intent)
// signal family — system requests attention (valenced by intent)
'announce',
'alert',
'warn',
'notify',
'emphasize',
// contact family — first / one-shot interaction (valenced by intent)
'activate',
'select',
'toggle',
'press',
'click',
'hover',
// handle family — sustained interaction (valenced by intent)
// handle family — sustained interaction / manipulation (valenced; intent
// belongs primarily to drop)
'acknowledge',
'edit',
'drag',
'resize',
'pick',
'carry',
'drop',
// sustain family — ongoing periodic
'tick',

@ -316,7 +316,7 @@ describe('runtime.trigger', () => {
expect(sem.calls[0]).toMatchObject({
target: item,
name: 'announce',
family: 'alert',
family: 'signal',
intent: 'risk'
})
@ -339,7 +339,7 @@ describe('runtime.trigger', () => {
void r.trigger('announce')
})
expect(sem.calls[0]).toMatchObject({ family: 'alert', intent: 'neutral' })
expect(sem.calls[0]).toMatchObject({ family: 'signal', intent: 'neutral' })
sem.resolve()
await Promise.resolve()
cleanup()

Loading…
Cancel
Save

Powered by TurnKey Linux.