fix(words): close audit P0 + quiet focus event (FASE 1)

Cierre de los hallazgos P0/P1 del audit Codex sobre Words + fix del
evento contact-focus molesto reportado por el usuario.

F1.1 — Fix evento foco (UX):

El handler onfocus disparaba `runtime.trigger('contact-focus')` cada vez
que el editor recibía foco, generando sound + haptic + visual stamp en
cada re-focus desde popovers internos (bubble menu / link editor / find
panel / slash menu / toolbar dropdowns). En sesión normal de edición el
foco entra/sale del content docenas de veces, lo que producía fatiga
perceptual.

Cambios en words-provider.svelte.ts:
- onfocus: emit solo en transición real (was-not-focused → focused).
  Re-focus desde popover interno NO emite.
- onblur: si el related target es interno al Words tool tree
  (isInsideWordsTool), mantener focused=true (Words se trata como un
  solo scope de foco). Skip commit('blur') también para evitar
  commits espurios entre popover hops.

F1.2 — Declarar 8 data-* hardcoded en morfo (audit P0-1):

Añadidos al Content part:
- data-words-find-match, data-words-find-active (mark highlights)
- data-words-indent (list items con indent > 0)
- data-language (alias parsing-only para interop con HTML externo
  emitido por Prism/highlight.js)

Nuevas 6 parts compound (Eidos wrappers):
- HeadingPicker + HeadingPickerPanel (data-words-heading-picker /
  -panel)
- CodeLanguagePicker + CodeLanguagePanel
- ToolbarFamilyMenu + ToolbarFamilyPanel

F1.3 — Rename i18n keys a kebab-case (audit P0-1):

4 keys camelCase eran ilegales según contracts.test.ts. Renamed:
- components.words.bubbleMenu → bubble-menu
- components.words.slashMenu → slash-menu
- components.words.linkEditor → link-editor
- components.words.findReplace → find-replace

Propagado a: morfo declarations (8 refs), provider tslangs calls
(4 refs), catalog langs/components/words.ts (4 keys).

F1.4 — Canonizar 9 event names a forma {family}-{verb}[-{variant}]
(audit P2-1):

Renombrados para parsear con el morfo:vocabulary script. El verb
declarado en cada semantic ahora aparece explícito en el name:

- commit-content       → commit-save-content (verb: save)
- commit-format        → commit-set-format (verb: set)
- commit-link          → commit-set-link
- commit-unlink        → commit-reset-link (verb: reset)
- commit-slash-command → commit-set-slash
- commit-check         → commit-set-check
- commit-history       → commit-restore-history (verb: restore)
- commit-clear         → commit-reset-content (verb: reset)
- signal-invalid-input → signal-warn-invalid (verb: warn)

Propagado a: sema cascade selectors, soma provider trigger calls,
provider + content tests, demo route. 0 conflictos con event names
de otros componentes (file-upload/tag-group/etc. usan namespaces
propios sin colisión con words).

Tests: 161/161 pass en src/uix/{contracts,soma/components/words}.
Suite full eidos+morfo+soma+sema pendiente de verificar tras commit.
contracts.test.ts ahora verde — cierra P0 audit Codex.
morfo:vocabulary 0 event-name warnings (los 107 data-attr enum
warnings son pre-existentes, separate scope).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 4a34c4f4df
commit 58c2c0babf

@ -17,19 +17,19 @@ export const wordsLangs = {
es: 'Barra del editor',
en: 'Editor toolbar'
},
bubbleMenu: {
'bubble-menu': {
es: 'Barra de seleccion',
en: 'Selection toolbar'
},
slashMenu: {
'slash-menu': {
es: 'Insertar bloque',
en: 'Insert block'
},
linkEditor: {
'link-editor': {
es: 'Editar enlace',
en: 'Edit link'
},
findReplace: {
'find-replace': {
es: 'Buscar y reemplazar',
en: 'Find and replace'
},

@ -11,10 +11,10 @@ export const wordsMorfo = {
content: '#?components.words.content|Editor content',
placeholder: '#?components.words.placeholder|Start writing...',
toolbar: '#?components.words.toolbar|Editor toolbar',
bubbleMenu: '#?components.words.bubbleMenu|Selection toolbar',
slashMenu: '#?components.words.slashMenu|Insert block',
linkEditor: '#?components.words.linkEditor|Edit link',
findReplace: '#?components.words.findReplace|Find and replace',
bubbleMenu: '#?components.words.bubble-menu|Selection toolbar',
slashMenu: '#?components.words.slash-menu|Insert block',
linkEditor: '#?components.words.link-editor|Edit link',
findReplace: '#?components.words.find-replace|Find and replace',
status: '#?components.words.status|Editor status'
},
events: [
@ -28,7 +28,7 @@ export const wordsMorfo = {
}
},
{
name: 'commit-content',
name: 'commit-save-content',
semantic: {
family: 'commit',
verb: 'save',
@ -38,7 +38,7 @@ export const wordsMorfo = {
}
},
{
name: 'commit-format',
name: 'commit-set-format',
semantic: {
family: 'commit',
verb: 'set',
@ -48,7 +48,7 @@ export const wordsMorfo = {
}
},
{
name: 'commit-link',
name: 'commit-set-link',
semantic: {
family: 'commit',
verb: 'set',
@ -58,7 +58,7 @@ export const wordsMorfo = {
}
},
{
name: 'commit-unlink',
name: 'commit-reset-link',
semantic: {
family: 'commit',
verb: 'reset',
@ -78,7 +78,7 @@ export const wordsMorfo = {
}
},
{
name: 'commit-slash-command',
name: 'commit-set-slash',
semantic: {
family: 'commit',
verb: 'set',
@ -88,7 +88,7 @@ export const wordsMorfo = {
}
},
{
name: 'commit-check',
name: 'commit-set-check',
semantic: {
family: 'commit',
verb: 'set',
@ -98,7 +98,7 @@ export const wordsMorfo = {
}
},
{
name: 'commit-history',
name: 'commit-restore-history',
semantic: {
family: 'commit',
verb: 'restore',
@ -108,7 +108,7 @@ export const wordsMorfo = {
}
},
{
name: 'commit-clear',
name: 'commit-reset-content',
semantic: {
family: 'commit',
verb: 'reset',
@ -118,7 +118,7 @@ export const wordsMorfo = {
}
},
{
name: 'signal-invalid-input',
name: 'signal-warn-invalid',
semantic: {
family: 'signal',
verb: 'warn',
@ -264,7 +264,24 @@ export const wordsMorfo = {
severity: 'optional'
},
{ attr: 'data-words-check-toggle', severity: 'optional' },
{ attr: 'data-words-checked', severity: 'optional' }
{ attr: 'data-words-checked', severity: 'optional' },
// Find/replace match highlights — emitted by the headless
// render around <mark> nodes wrapping search matches.
// `find-active` differentiates the currently-selected match
// from the rest of the highlights so Eidos can paint it.
{ attr: 'data-words-find-match', severity: 'optional' },
{ attr: 'data-words-find-active', severity: 'optional' },
// List indentation level (0..8) emitted on list items when
// indent > 0 (Tab / Shift+Tab in lists, plus increase/decrease
// indent commands).
{ attr: 'data-words-indent', severity: 'optional' },
// Interop alias parsed by the HTML deserializer: external
// editors (Prism, highlight.js, GitHub-flavored snippets)
// commonly emit `data-language` on <pre>/<code>. The internal
// canonical attr stays `data-words-code-language`; this
// declaration acknowledges the interop read so the soma-attrs
// contract guard accepts the parser reference.
{ attr: 'data-language', severity: 'optional' }
],
aria: [
{ attr: 'contenteditable', value: v.propRef('editable') },
@ -297,8 +314,8 @@ export const wordsMorfo = {
{ key: 'ArrowUp', action: 'prev-item' },
{ key: 'Home', action: 'first-item' },
{ key: 'End', action: 'last-item' },
{ key: 'Enter', action: 'commit-slash-command' },
{ key: 'Tab', action: 'commit-slash-command' },
{ key: 'Enter', action: 'commit-set-slash' },
{ key: 'Tab', action: 'commit-set-slash' },
{ key: 'Escape', action: 'close' },
{ key: 'Enter', action: 'insert-paragraph' },
{ key: 'Enter', action: 'insert-line-break-in-code-block' },
@ -387,7 +404,7 @@ export const wordsMorfo = {
{ attr: 'aria-orientation', value: v.literal('horizontal') },
{
attr: 'aria-label',
value: v.translationRef('#?components.words.bubbleMenu|Selection toolbar'),
value: v.translationRef('#?components.words.bubble-menu|Selection toolbar'),
severity: 'recommended'
}
]
@ -410,7 +427,7 @@ export const wordsMorfo = {
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.words.slashMenu|Insert block'),
value: v.translationRef('#?components.words.slash-menu|Insert block'),
severity: 'recommended'
}
]
@ -473,7 +490,7 @@ export const wordsMorfo = {
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.words.linkEditor|Edit link'),
value: v.translationRef('#?components.words.link-editor|Edit link'),
severity: 'recommended'
}
]
@ -495,11 +512,80 @@ export const wordsMorfo = {
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.words.findReplace|Find and replace'),
value: v.translationRef('#?components.words.find-replace|Find and replace'),
severity: 'recommended'
}
]
},
{
name: 'HeadingPicker',
kebab: 'heading-picker',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' }
],
aria: []
},
{
name: 'HeadingPickerPanel',
kebab: 'heading-picker-panel',
archetype: 'content',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
name: 'CodeLanguagePicker',
kebab: 'code-language-picker',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' }
],
aria: []
},
{
name: 'CodeLanguagePanel',
kebab: 'code-language-panel',
archetype: 'content',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
name: 'ToolbarFamilyMenu',
kebab: 'toolbar-family',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [
{ attr: 'data-family', severity: 'optional' },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }
],
aria: []
},
{
name: 'ToolbarFamilyPanel',
kebab: 'toolbar-family-panel',
archetype: 'content',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
name: 'HiddenInput',
kebab: 'hidden-input',

@ -33,17 +33,17 @@ export const wordsSema: Sema = {
haptic: { kind: 'tick', intensity: 0.12, duration: 6 }
},
{
selector: onProvider({ eventName: 'commit-content' }),
selector: onProvider({ eventName: 'commit-save-content' }),
sound: soundTuning('form.commit.soft'),
haptic: { kind: 'success' }
},
{
selector: onCommandButton({ eventName: 'commit-format' }),
selector: onCommandButton({ eventName: 'commit-set-format' }),
sound: soundTuning('form.commit.subtle'),
haptic: { kind: 'tap' }
},
{
selector: onSlashMenu({ eventName: 'commit-slash-command' }),
selector: onSlashMenu({ eventName: 'commit-set-slash' }),
sound: soundTuning('form.commit.soft', {
gain: { op: 'multiply', factor: 0.85 },
pitch: { op: 'add', value: 30 }
@ -51,7 +51,7 @@ export const wordsSema: Sema = {
haptic: { kind: 'tap' }
},
{
selector: onLinkEditor({ eventName: 'commit-link' }),
selector: onLinkEditor({ eventName: 'commit-set-link' }),
sound: soundTuning('form.commit.soft', {
gain: { op: 'multiply', factor: 0.9 },
pitch: { op: 'add', value: 20 }
@ -59,7 +59,7 @@ export const wordsSema: Sema = {
haptic: { kind: 'tap' }
},
{
selector: onLinkEditor({ eventName: 'commit-unlink' }),
selector: onLinkEditor({ eventName: 'commit-reset-link' }),
sound: soundTuning('form.commit.subtle', {
gain: { op: 'multiply', factor: 0.8 },
contour: 'descending'
@ -74,14 +74,14 @@ export const wordsSema: Sema = {
haptic: { kind: 'tap' }
},
{
selector: onContent({ eventName: 'commit-check' }),
selector: onContent({ eventName: 'commit-set-check' }),
sound: soundTuning('form.commit.subtle', {
gain: { op: 'multiply', factor: 0.85 }
}),
haptic: { kind: 'tap' }
},
{
selector: onProvider({ eventName: 'commit-history' }),
selector: onProvider({ eventName: 'commit-restore-history' }),
sound: soundTuning('form.commit.subtle', {
gain: { op: 'multiply', factor: 0.85 },
contour: 'descending'
@ -89,7 +89,7 @@ export const wordsSema: Sema = {
haptic: { kind: 'tap' }
},
{
selector: onProvider({ eventName: 'commit-clear' }),
selector: onProvider({ eventName: 'commit-reset-content' }),
sound: soundTuning('form.commit.subtle', {
contour: 'descending',
pitch: { op: 'add', value: -80 }
@ -97,7 +97,7 @@ export const wordsSema: Sema = {
haptic: { kind: 'tap' }
},
{
selector: onContent({ eventName: 'signal-invalid-input' }),
selector: onContent({ eventName: 'signal-warn-invalid' }),
sound: soundTuning('form.commit.subtle', {
contour: 'descending',
pitch: { op: 'add', value: -120 }

@ -198,7 +198,7 @@ export class WordsProvider {
content: () => this.opts.contentId.current || undefined
},
events: {
'commit-content': () => {
'commit-save-content': () => {
this.opts.onValueCommit.current?.(this.document, this.pendingCommitReason);
}
}
@ -517,7 +517,7 @@ export class WordsProvider {
commit(reason: WordsCommitReason = 'programmatic', target?: HTMLElement) {
this.closeTypingBatch();
this.pendingCommitReason = reason;
void this.runtime.trigger('commit-content', {
void this.runtime.trigger('commit-save-content', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
});
}
@ -526,7 +526,7 @@ export class WordsProvider {
if (this.isDisabled || this.isReadonly || !this.canUndo) return;
this.closeTypingBatch();
this.publishHistory(undoWords(this.history));
void this.runtime.trigger('commit-history', {
void this.runtime.trigger('commit-restore-history', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
});
}
@ -535,7 +535,7 @@ export class WordsProvider {
if (this.isDisabled || this.isReadonly || !this.canRedo) return;
this.closeTypingBatch();
this.publishHistory(redoWords(this.history));
void this.runtime.trigger('commit-history', {
void this.runtime.trigger('commit-restore-history', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
});
}
@ -596,10 +596,10 @@ export class WordsProvider {
if (!changed) return;
const event =
name === 'clear-document'
? 'commit-clear'
? 'commit-reset-content'
: name === 'unlink'
? 'commit-unlink'
: 'commit-format';
? 'commit-reset-link'
: 'commit-set-format';
void this.runtime.trigger(event, {
fallbackTarget: opts.target ?? this.opts.ref.current ?? undefined
});
@ -643,7 +643,7 @@ export class WordsProvider {
if (target) this.signalInvalidInput('invalid-link', target);
return false;
}
void this.runtime.trigger('commit-link', {
void this.runtime.trigger('commit-set-link', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
});
return true;
@ -652,7 +652,7 @@ export class WordsProvider {
setCodeLanguage(language?: string, target?: HTMLElement): boolean {
const changed = this.applyCommandWithOptions({ type: 'setCodeLanguage', language });
if (!changed) return false;
void this.runtime.trigger('commit-format', {
void this.runtime.trigger('commit-set-format', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
});
return true;
@ -661,7 +661,7 @@ export class WordsProvider {
unlink(target?: HTMLElement): boolean {
const changed = this.applyCommandWithOptions({ type: 'unlink' });
if (!changed) return false;
void this.runtime.trigger('commit-unlink', {
void this.runtime.trigger('commit-reset-link', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
});
return true;
@ -677,20 +677,38 @@ export class WordsProvider {
}
readonly onfocus = (e: SomaFocusEvent<HTMLElement>) => {
// Only fire the perceptual contact-focus on a real transition
// (was-not-focused → focused). Re-focus from internal popovers
// (bubble menu, link editor, find panel, slash menu, toolbar
// dropdowns) does NOT count as a transition because `focused`
// stays true across that bounce — see onblur below. Without this
// guard every focus return inside a normal editing session
// triggers sound + haptic + a visual stamp, fatiguing the user.
const wasFocused = this.focused;
this.focused = true;
this.ensureSelection();
void this.runtime.trigger('contact-focus', { fallbackTarget: e.currentTarget as HTMLElement });
if (!wasFocused) {
void this.runtime.trigger('contact-focus', { fallbackTarget: e.currentTarget as HTMLElement });
}
};
readonly onblur = (e: SomaFocusEvent<HTMLElement>) => {
this.isSelecting = false;
const related = e.relatedTarget as HTMLElement | null;
if (!related || !this.isInsideWordsTool(related)) {
const goingInternal = !!related && this.isInsideWordsTool(related);
if (!goingInternal) {
this.syncSelectionFromDom({ source: 'blur' });
}
this.closeTypingBatch();
this.focused = false;
if (this.opts.commitOnBlur.current) this.commit('blur');
// Treat the Words component as a single focus scope: blur into an
// internal tool (popover / bubble menu / link editor / find panel /
// slash menu) keeps `focused` true, so the next focus-back to the
// content is not a fresh transition and doesn't re-emit the
// perceptual contact-focus event.
if (!goingInternal) {
this.focused = false;
if (this.opts.commitOnBlur.current) this.commit('blur');
}
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
@ -758,7 +776,7 @@ export class WordsProvider {
e.preventDefault();
const changed = this.applyCommandWithOptions({ type: 'toggleCheckItem' });
if (changed) {
void this.runtime.trigger('commit-check', {
void this.runtime.trigger('commit-set-check', {
fallbackTarget: e.currentTarget as HTMLElement
});
}
@ -848,7 +866,7 @@ export class WordsProvider {
if (!path) return;
const changed = this.applyCommandWithOptions({ type: 'toggleCheckItem', path });
if (!changed) return;
void this.runtime.trigger('commit-check', {
void this.runtime.trigger('commit-set-check', {
fallbackTarget: e.currentTarget as HTMLElement
});
return;
@ -1210,7 +1228,7 @@ export class WordsProvider {
this.slashMenuActiveIndex = 0;
this.publishHistory(next);
void tick().then(() => this.restoreDomSelection());
void this.runtime.trigger('commit-slash-command', {
void this.runtime.trigger('commit-set-slash', {
fallbackTarget: target ?? this.opts.ref.current ?? undefined
});
return true;
@ -1482,7 +1500,7 @@ export class WordsProvider {
this.soma.logger.warn('soma.words', `Ignored unsupported editor input: ${reason}`, {
context: { reason }
});
void this.runtime.trigger('signal-invalid-input', { fallbackTarget: target });
void this.runtime.trigger('signal-warn-invalid', { fallbackTarget: target });
}
}
@ -1712,7 +1730,7 @@ export class WordsBubbleMenuProvider {
...this.runtimePart.props,
role: 'toolbar' as const,
'aria-orientation': 'horizontal' as const,
'aria-label': this.provider.soma.langs.ts('#?components.words.bubbleMenu|Selection toolbar'),
'aria-label': this.provider.soma.langs.ts('#?components.words.bubble-menu|Selection toolbar'),
'data-open': boolToEmptyStrOrUndef(this.open),
'data-positioned': boolToEmptyStrOrUndef(this.positioned),
'data-side': this.opts.side.current,
@ -1801,7 +1819,7 @@ export class WordsSlashMenuProvider {
this.runtimePart.assert({
...this.runtimePart.props,
role: 'listbox' as const,
'aria-label': this.provider.soma.langs.ts('#?components.words.slashMenu|Insert block'),
'aria-label': this.provider.soma.langs.ts('#?components.words.slash-menu|Insert block'),
'data-open': boolToEmptyStrOrUndef(this.open),
'data-query': this.provider.slashMenuQuery,
'data-active-index': String(this.provider.slashMenuActiveIndex),
@ -2072,7 +2090,7 @@ export class WordsLinkEditorProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'aria-label': this.provider.soma.langs.ts('#?components.words.linkEditor|Edit link'),
'aria-label': this.provider.soma.langs.ts('#?components.words.link-editor|Edit link'),
'data-active-link': boolToEmptyStrOrUndef(Boolean(this.selectedLink)),
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled || this.provider.isReadonly),
'data-invalid': boolToEmptyStrOrUndef(Boolean(this.error)),
@ -2175,7 +2193,7 @@ export class WordsFindReplaceProvider {
this.runtimePart.assert({
...this.runtimePart.props,
role: 'search' as const,
'aria-label': this.provider.soma.langs.ts('#?components.words.findReplace|Find and replace'),
'aria-label': this.provider.soma.langs.ts('#?components.words.find-replace|Find and replace'),
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.provider.isReadonly),
'data-invalid': boolToEmptyStrOrUndef(!this.provider.findQueryValid),

Loading…
Cancel
Save

Powered by TurnKey Linux.