fix(words): silence canvas, silver rail, direct undo/redo, popover widths, Enter empty

Six user-reported issues in one pass.

EV-F1 — canvas silence
Remove the contact-focus entry from the sema cascade. The event still
fires (telemetry / a11y can read the data-event* stamps) but no sound
/ haptic plays. Per spec: "no puede emitir eventos es demencial, una
vez que está trabajando con el no tiene sentido". The editor surface
is now perceptually silent on focus; only explicit commits (save,
format, set-link, etc.) make noise.

EV-F2 — rail visual
`--_words-rail-bg` becomes flat silver `#d4d4d4` and the dot
pattern is gone. New `--_words-rail-border` (`#9a9a9a`) lays a
1px hairline on the rail's inline-end so it reads as a separate
gutter ("paper edge" feel). Fixed-tone (not theme-aware) — the rail
is meant to look like the physical margin of a notebook regardless
of the editor theme. Left-side drawer flips the border to inline-
start.

EV-F3 — direct undo/redo in toolbar
Demo presets + custom config now use `['undo', 'redo', ...]` as
direct command strings instead of `['history-menu', ...]` (which
opened a popover to pick from two items — wasted real estate +
two clicks). `link-menu` and `tools-menu` stay as popovers because
they hold 2-3 items each. `insert-menu` is OUT — block construction
lives in the drawer's empty-state Insert panel and the slash menu.
`demoToolbarFamilyItems.insert` collapsed to `[]` accordingly.

EV-F4 — popover widths
Base `[data-words-toolbar-family-panel]` width 12.5rem → 16rem so
content fits without horizontal scroll. Per-family overrides:
tools → 17rem (descriptive labels), link → 20rem (URL input +
button grid). The default panel CSS no longer carries a max-block-
size + scroll — a vertical scroll inside these popovers is a
misdesign signal; size the panel, don't scroll it.

EV-F5 — link popover (covered by EV-F4)
The vertical scroll the user saw was a side-effect of the
12.5rem width forcing the link-editor input + button to wrap, then
the panel running out of vertical room. The new 20rem width
restores the natural one-row layout and no scroll appears.

EV-F6 — Enter on empty block no longer multiplies
`insertParagraph` engine op now guards the default case
(paragraph/heading): if the block is collapsed AND empty (children
length 0, both halves of split empty), Enter on a paragraph is a
no-op and Enter on a heading demotes it to a paragraph (canonical
Notion / Google Docs UX). For non-empty paragraphs/headings the
existing split-and-create-paragraph behavior stands.

Verification: 467/467 tests pass across morfo + sema + eidos +
soma/components/words. 85/85 engine tests pass (insertParagraph
guard non-regressive on existing fixtures). `npm run check` still 0
errors. DOM probe at /uix/components/words confirms toolbar = 5
items (undo, redo, Links, Tools, Find/replace), rail bg
`rgb(212,212,212)` + 1px `rgb(154,154,154)` right border, link
popover 320px wide with no scroll.

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

@ -12,8 +12,14 @@
land inside this column. */
--_words-rail-px: 2rem;
--_words-rail-gap: 0.5rem;
--_words-rail-bg: color-mix(in srgb, var(--words-toolbar-border) 35%, transparent);
--_words-rail-dot: color-mix(in srgb, var(--words-status-color) 65%, transparent);
/* Flat light silver band with a slightly darker edge toward the
* editor card. Per spec: "elimna el fondo de la barra de la
* izquierda y dejalo de un color silver claro con un borde a la
* derecha un poco mas oscuro". Fixed-tone (not theme-aware) so
* the gutter reads as a physical "paper edge" regardless of the
* editor theme — same intent as the printed margin in a notebook. */
--_words-rail-bg: #d4d4d4;
--_words-rail-border: #9a9a9a;
--_words-toolbar-px: var(--words-toolbar-px-md);
--_words-toolbar-py: var(--words-toolbar-py-md);
--_words-button-size: var(--words-button-size-md);
@ -177,24 +183,28 @@
}
/* Rail visual — pseudo-element on the editor host that fills the
`rail` grid area with the dotted grey band. The grip + "+" overlays
are JS-positioned via viewport coordinates that land here. */
`rail` grid area with a flat silver band. The edge closest to the
editor card carries a slightly darker hairline so the rail reads as
a separate gutter. Grip + "+" overlays are JS-positioned via
viewport coordinates that land here. */
[data-words]:has([data-words-drawer])::before {
content: '';
grid-area: rail;
background-color: var(--_words-rail-bg);
background-image: radial-gradient(
circle 1px,
var(--_words-rail-dot) 99%,
transparent 100%
);
background-size: 5px 5px;
background-position: 50% 50%;
background-repeat: repeat;
border-radius: var(--_words-radius);
border-inline-end: var(--words-border-width) solid var(--_words-rail-border);
border-radius: var(--_words-radius) 0 0 var(--_words-radius);
pointer-events: none;
}
/* Left-side drawer flips the layout (drawer | content | gap | rail),
so the rail's "darker edge" is on the inline-START (the side facing
the editor card on its right). */
[data-words]:has([data-words-drawer][data-side='left'])::before {
border-inline-end: 0;
border-inline-start: var(--words-border-width) solid var(--_words-rail-border);
border-radius: 0 var(--_words-radius) var(--_words-radius) 0;
}
[data-words]:has([data-words-drawer]) > [data-words-toolbar] {
grid-area: toolbar;
}
@ -1566,8 +1576,12 @@
[data-words-toolbar-family-panel] {
display: grid;
gap: var(--button-gap-xs);
inline-size: min(12.5rem, calc(100vw - var(--space-4)));
inline-size: min(16rem, calc(100vw - var(--space-4)));
max-inline-size: calc(100vw - var(--space-4));
/* No max-block-size + overflow on the panel itself — items should
* size the popover, not the other way around. A scroll inside the
* panel is a misdesign signal: bump the family-specific width
* below instead. */
}
[data-words-toolbar-family-panel][data-family='table'] {
@ -1575,6 +1589,20 @@
gap: var(--button-gap-xs);
}
/* Tools family (clear-formatting / clear-document / commit) — three
* descriptive labels need breathing room so they don't wrap. */
[data-words-toolbar-family-panel][data-family='tools'] {
inline-size: min(17rem, calc(100vw - var(--space-4)));
}
/* Link family hosts the link-editor (URL input + button) which is
* naturally wider than the default. The link-editor's own grid
* (input | button) needs ~18rem to avoid the input getting squeezed
* to a single-character width. */
[data-words-toolbar-family-panel][data-family='link'] {
inline-size: min(20rem, calc(100vw - var(--space-4)));
}
[data-words-toolbar-family-panel] [data-toolbar-button],
[data-words-toolbar-family-panel] [data-words-command-button],
[data-words-toolbar-family-panel] [data-words-code-language-picker],

@ -24,19 +24,12 @@ const onFindReplace = (matchers?: Parameters<typeof semaSelector<typeof wordsMor
export const wordsSema: Sema = {
name: 'words',
cascade: [
{
// Words-level focus signal. Fires ONCE per real focus
// transition (provider target — see morfo note). Internal
// tool bounces (drawer / block-handle / inserter / float
// bar / popovers) do NOT re-fire because `isInsideWordsTool`
// holds `focused` across them.
selector: onProvider({ eventName: 'contact-focus' }),
sound: soundTuning('form.commit.subtle', {
gain: { op: 'multiply', factor: 0.55 },
pitch: { op: 'add', value: 40 }
}),
haptic: { kind: 'tick', intensity: 0.12, duration: 6 }
},
// contact-focus is intentionally NOT mapped to a perceptual
// cascade rule. The event is still emitted by soma on focus
// transitions (telemetry / a11y consumers may read the
// data-event* stamps), but no sound / haptic plays. Per spec:
// "no puede emitir eventos es demencial, una vez que está
// trabajando con el no tiene sentido" — silent editing surface.
{
selector: onProvider({ eventName: 'commit-save-content' }),
sound: soundTuning('form.commit.soft'),

@ -1587,6 +1587,40 @@ export function insertParagraph(state: WordsEditorState): WordsOperationResult {
};
}
// Default case: paragraph / heading.
// Enter on an EMPTY paragraph is a no-op (otherwise the user gets
// a chain of empty paragraphs every Enter press). Enter on an
// EMPTY heading demotes the block to a paragraph — canonical
// Notion / Google Docs UX: "done with the heading, give me the
// body". For non-empty blocks the split-and-create-paragraph
// behavior below stands.
if (
range.collapsed &&
inlineTextLength(block.children) === 0 &&
inlineTextLength(split.before) === 0 &&
inlineTextLength(split.after) === 0
) {
if (block.type === 'paragraph') {
return { state, changed: false };
}
if (block.type === 'heading') {
const demotedDoc = normalizeDocument({
...workingState.document,
children: replaceAt(workingState.document.children, blockIndex, createParagraph())
}).document;
const demotedPoint = pointFromInlineTextOffset(demotedDoc, [blockIndex], 0);
const demotedSelection = createCollapsedSelection(demotedPoint.path, demotedPoint.offset);
return {
state: {
document: demotedDoc,
selection: demotedSelection,
activeMarks: getActiveMarksForSelection(demotedDoc, demotedSelection)
},
changed: true
};
}
}
const current = withInlineChildren(block, ensureInlineChildren(split.before));
const next = createParagraph(ensureInlineChildren(split.after), blockTextAlign(block));
const normalized = normalizeDocument({

@ -95,21 +95,21 @@
const exportFormats: WordsExportFormat[] = ['json', 'text', 'html', 'markdown'];
const importFormats: WordsImportFormat[] = ['json', 'text', 'html', 'markdown'];
const toolbarChoices: ToolbarChoice[] = ['none', 'minimal', 'formatting', 'full', 'custom'];
// Top-toolbar canon (post-drawer): the drawer owns CONTEXTUAL
// actions (format/block/list/align/cell/row/table/code/image —
// scoped to the caret position). The toolbar owns GLOBAL actions:
// history (undo/redo across the document), insert (creates new
// blocks), link (selection-bound but distinct flow), tools
// (find/replace + clear). Per-mark and per-block menus moved out
// because the drawer already covers them — having them in both
// surfaces is noisy redundancy.
// Top-toolbar canon (post-drawer + post-direct-buttons):
// - undo + redo go DIRECT (no popover — they are two clicks and a
// menu for them wastes real estate + the user's time).
// - link-menu / tools-menu stay as family popovers (3+ items).
// - insert-menu is OUT: block construction lives in the drawer's
// empty-state Insert panel and the slash menu.
// - text / block / list / align / table menus moved out earlier
// because the drawer already owns them per scope.
const toolbarPresetGroups: Record<WordsToolbarPreset, WordsToolbarGroupConfig[]> = {
minimal: [['history-menu']],
formatting: [['history-menu', 'insert-menu', 'link-menu']],
full: [['history-menu', 'insert-menu', 'link-menu', 'tools-menu'], ['find-replace']]
minimal: [['undo', 'redo']],
formatting: [['undo', 'redo', 'link-menu']],
full: [['undo', 'redo', 'link-menu', 'tools-menu'], ['find-replace']]
};
const toolbarCustomGroups: WordsToolbarGroupConfig[] = [
['history-menu', 'insert-menu', 'link-menu', 'tools-menu'],
['undo', 'redo', 'link-menu', 'tools-menu'],
['find-replace']
];
const demoToolbarFamilyItems: Record<
@ -129,7 +129,7 @@
],
list: ['unordered-list', 'ordered-list', 'check-list', 'increase-indent', 'decrease-indent'],
align: ['align-left', 'align-center', 'align-right', 'align-justify'],
insert: ['insert-table'],
insert: [],
table: [
'insert-table',
'insert-table-row',

Loading…
Cancel
Save

Powered by TurnKey Linux.