fix(password-field): align input chrome with system + add Form/SIUM demo

Style fix — input chrome was hand-rolled with raw color tokens
(`--color-neutral-border`, `--ring-width`) instead of consuming the
recipe layer like every other text input. Now matches search-field and
number-field exactly:

- Added `password-field` recipe to `src/uix/eidos/lib/recipes/base.ts`
  with the same surface as `search-field` (height, px, gap, font-size
  per size; border, focus, invalid, bg, color, focus-shadow tokens).
- Regenerated `src/uix/eidos/generated/base.css`.
- Rewrote `password-field.css` to consume the new tokens — same focus
  ring (`focus-shadow`), same border colors, same disabled opacity,
  same transition tokens. VisibilityTrigger gets its own border/bg/
  color-on-hover scheme aligned with search-field's clear button.
- Added per-size icon + caps font-size tokens so the trigger and caps
  pill scale with the input.

Form + SIUM demo — answers "how does PasswordField validate?":

Added a new subsection in the Live tab with three working forms wired
through `<Form>` + `<Field>` and SIUM schemas:

1. Login (purpose='current', non-empty password)
2. Registration (purpose='new', complexity rules via regex pipes —
   uppercase + digit + symbol + 8+ chars, plus the visual StrengthMeter
   as a companion to the schema)
3. Password change (3 fields with a cross-field `refine()` so new ===
   confirm; demonstrates SIUM's refine pattern)

The section explicitly documents that PasswordField has no built-in
complexity rules — validation is schema-first. The schema decides
what's "correct"; PasswordField's flags propagate via Field's OR-merge
of `formFieldState.isInvalid`.

svelte-check: 0 errors.

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

@ -1,129 +1,168 @@
/* PasswordField — single-line password input with visibility toggle. */ /*
* PasswordField — single-line password input with visibility toggle,
* CapsLock indicator and optional strength meter.
*
* Mirrors search-field's chrome (border, radius, focus ring, padding,
* disabled opacity) so every text input in the system reads the same
* — only the affordances inside the control differ.
*/
[data-password-field] { [data-password-field] {
--_password-field-px: var(--space-3); --_password-field-height: var(--password-field-height-md);
--_password-field-py: var(--space-2); --_password-field-px: var(--password-field-px-md);
--_password-field-gap: var(--space-2); --_password-field-gap: var(--password-field-gap-md);
--_password-field-font-size: var(--font-size-md); --_password-field-font-size: var(--password-field-font-size-md);
--_password-field-line-height: var(--leading-normal); --_password-field-trigger-size: var(--password-field-icon-size-md);
--_password-field-radius: var(--radius-md); --_password-field-caps-font-size: var(--password-field-caps-font-size-md);
--_password-field-border: var(--color-neutral-border); --_password-field-accent-border: var(--color-primary-border);
--_password-field-border-hover: var(--color-neutral-border-strong); --_password-field-accent-track: var(--color-primary-track);
--_password-field-border-focus: var(--color-primary-border-strong); --_password-field-accent-text: var(--color-primary-text);
--_password-field-bg: var(--color-surface);
--_password-field-color: var(--color-content);
--_password-field-muted: var(--color-content-muted);
display: inline-flex; display: inline-flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: stretch; align-items: center;
gap: var(--_password-field-gap); gap: var(--_password-field-gap);
inline-size: 100%; inline-size: 100%;
min-inline-size: 0; min-inline-size: 0;
position: relative; block-size: var(--_password-field-height);
padding-inline: var(--_password-field-px);
border: var(--password-field-border-width) solid var(--password-field-border);
border-radius: var(--password-field-radius);
background: var(--password-field-bg);
color: var(--password-field-color);
font-family: var(--password-field-font-family);
font-size: var(--_password-field-font-size);
line-height: var(--password-field-line-height);
transition:
background var(--password-field-transition-duration) var(--password-field-transition-ease),
border-color var(--password-field-transition-duration) var(--password-field-transition-ease),
box-shadow var(--password-field-transition-duration) var(--password-field-transition-ease);
} }
[data-password-field][data-size='xs'] { /* When the StrengthMeter is composed, it claims a second row and the
--_password-field-px: var(--space-2); * height auto-grows; otherwise the single-row chrome rules. */
--_password-field-py: var(--space-1); [data-password-field]:has([data-password-field-strength-meter]) {
--_password-field-font-size: var(--font-size-xs); block-size: auto;
--_password-field-radius: var(--radius-sm); min-block-size: var(--_password-field-height);
} padding-block: var(--password-field-gap-sm);
[data-password-field][data-size='sm'] {
--_password-field-px: var(--space-2);
--_password-field-py: var(--space-1-5);
--_password-field-font-size: var(--font-size-sm);
}
[data-password-field][data-size='lg'] {
--_password-field-px: var(--space-4);
--_password-field-py: var(--space-3);
--_password-field-font-size: var(--font-size-lg);
--_password-field-radius: var(--radius-lg);
}
[data-password-field][data-size='xl'] {
--_password-field-px: var(--space-5);
--_password-field-py: var(--space-4);
--_password-field-font-size: var(--font-size-xl);
--_password-field-radius: var(--radius-lg);
} }
/* Color accents on focus + invalid. */ /* ── Color accents ─────────────────────────────────────────────────── */
[data-password-field][data-color='secondary'] { [data-password-field][data-color='secondary'] {
--_password-field-border-focus: var(--color-secondary-border-strong); --_password-field-accent-border: var(--color-secondary-border);
--_password-field-accent-track: var(--color-secondary-track);
--_password-field-accent-text: var(--color-secondary-text);
} }
[data-password-field][data-color='neutral'] { [data-password-field][data-color='neutral'] {
--_password-field-border-focus: var(--color-neutral-border-strong); --_password-field-accent-border: var(--color-neutral-border);
--_password-field-accent-track: var(--color-neutral-track);
--_password-field-accent-text: var(--color-neutral-text);
} }
[data-password-field][data-color='affirm'] { [data-password-field][data-color='affirm'] {
--_password-field-border-focus: var(--color-affirm-border-strong); --_password-field-accent-border: var(--color-affirm-border);
--_password-field-accent-track: var(--color-affirm-track);
--_password-field-accent-text: var(--color-affirm-text);
} }
[data-password-field][data-color='fulfill'] { [data-password-field][data-color='fulfill'] {
--_password-field-border-focus: var(--color-fulfill-border-strong); --_password-field-accent-border: var(--color-fulfill-border);
--_password-field-accent-track: var(--color-fulfill-track);
--_password-field-accent-text: var(--color-fulfill-text);
} }
[data-password-field][data-color='risk'] { [data-password-field][data-color='risk'] {
--_password-field-border-focus: var(--color-risk-border-strong); --_password-field-accent-border: var(--color-risk-border);
--_password-field-accent-track: var(--color-risk-track);
--_password-field-accent-text: var(--color-risk-text);
} }
[data-password-field][data-color='threat'] { [data-password-field][data-color='threat'] {
--_password-field-border-focus: var(--color-threat-border-strong); --_password-field-accent-border: var(--color-threat-border);
--_password-field-accent-track: var(--color-threat-track);
--_password-field-accent-text: var(--color-threat-text);
} }
[data-password-field][data-color='loss'] { [data-password-field][data-color='loss'] {
--_password-field-border-focus: var(--color-loss-border-strong); --_password-field-accent-border: var(--color-loss-border);
--_password-field-accent-track: var(--color-loss-track);
--_password-field-accent-text: var(--color-loss-text);
} }
/* ── Control row (input + trigger + caps indicator) ───────────────── */ /* ── Sizes ─────────────────────────────────────────────────────────── */
[data-password-field] { [data-password-field][data-size='xs'] {
border: var(--border-width) solid var(--_password-field-border); --_password-field-height: var(--password-field-height-xs);
border-radius: var(--_password-field-radius); --_password-field-px: var(--password-field-px-xs);
background: var(--_password-field-bg); --_password-field-gap: var(--password-field-gap-xs);
padding-inline: var(--_password-field-px); --_password-field-font-size: var(--password-field-font-size-xs);
transition: --_password-field-trigger-size: var(--password-field-icon-size-xs);
border-color var(--duration-fast) var(--ease-default), --_password-field-caps-font-size: var(--password-field-caps-font-size-xs);
box-shadow var(--duration-fast) var(--ease-default); }
[data-password-field][data-size='sm'] {
--_password-field-height: var(--password-field-height-sm);
--_password-field-px: var(--password-field-px-sm);
--_password-field-gap: var(--password-field-gap-sm);
--_password-field-font-size: var(--password-field-font-size-sm);
--_password-field-trigger-size: var(--password-field-icon-size-sm);
--_password-field-caps-font-size: var(--password-field-caps-font-size-sm);
}
[data-password-field][data-size='lg'] {
--_password-field-height: var(--password-field-height-lg);
--_password-field-px: var(--password-field-px-lg);
--_password-field-gap: var(--password-field-gap-lg);
--_password-field-font-size: var(--password-field-font-size-lg);
--_password-field-trigger-size: var(--password-field-icon-size-lg);
--_password-field-caps-font-size: var(--password-field-caps-font-size-lg);
}
[data-password-field][data-size='xl'] {
--_password-field-height: var(--password-field-height-xl);
--_password-field-px: var(--password-field-px-xl);
--_password-field-gap: var(--password-field-gap-xl);
--_password-field-font-size: var(--password-field-font-size-xl);
--_password-field-trigger-size: var(--password-field-icon-size-xl);
--_password-field-caps-font-size: var(--password-field-caps-font-size-xl);
} }
/* ── Variants ──────────────────────────────────────────────────────── */
[data-password-field][data-variant='outline'] { [data-password-field][data-variant='outline'] {
background: transparent; background: transparent;
} }
[data-password-field][data-variant='ghost'] { [data-password-field][data-variant='ghost'] {
background: transparent;
border-color: transparent; border-color: transparent;
background: transparent;
padding-inline: 0;
} }
[data-password-field]:hover:not([data-disabled]):not([data-readonly]) { /* ── State chrome ──────────────────────────────────────────────────── */
border-color: var(--_password-field-border-hover);
}
[data-password-field][data-focused] { [data-password-field][data-focused] {
border-color: var(--_password-field-border-focus); border-color: var(--password-field-border-focus);
box-shadow: 0 0 0 var(--ring-width, 2px) box-shadow: var(--password-field-focus-shadow);
color-mix(in srgb, var(--_password-field-border-focus) 25%, transparent);
} }
[data-password-field][data-invalid] { [data-password-field][data-invalid] {
border-color: var(--color-threat-border-strong); border-color: var(--password-field-border-invalid);
} }
[data-password-field][data-disabled] { [data-password-field][data-disabled] {
opacity: var(--opacity-disabled, 0.6); opacity: var(--password-field-disabled-opacity);
} }
/* ── Input ─────────────────────────────────────────────────────────── */
[data-password-field-input] { [data-password-field-input] {
flex: 1 1 auto; flex: 1 1 auto;
min-inline-size: 0; min-inline-size: 0;
padding-block: var(--_password-field-py);
border: 0; border: 0;
background: transparent; background: transparent;
color: var(--_password-field-color); color: inherit;
font-family: var(--font-family-body); font: inherit;
font-size: var(--_password-field-font-size);
line-height: var(--_password-field-line-height);
outline: none; outline: none;
letter-spacing: 0.05em; /* Slightly wider tracking helps recognise the · placeholder mask. */
letter-spacing: 0.02em;
} }
[data-password-field-input]::placeholder { [data-password-field-input]::placeholder {
color: var(--_password-field-muted); color: var(--password-field-placeholder-color);
letter-spacing: 0; letter-spacing: 0;
} }
@ -138,38 +177,41 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
inline-size: calc(var(--_password-field-font-size) * 1.5); inline-size: var(--_password-field-trigger-size);
block-size: calc(var(--_password-field-font-size) * 1.5); block-size: var(--_password-field-trigger-size);
margin-block: auto; margin-block: auto;
padding: 0; padding: 0;
border: 0; border: var(--password-field-trigger-border-width) solid var(--password-field-trigger-border);
border-radius: var(--radius-sm); border-radius: var(--password-field-trigger-radius);
background: transparent; background: var(--password-field-trigger-bg);
color: var(--_password-field-muted); color: var(--password-field-trigger-color);
cursor: pointer; cursor: pointer;
transition: transition:
color var(--duration-fast) var(--ease-default), background var(--password-field-transition-duration) var(--password-field-transition-ease),
background var(--duration-fast) var(--ease-default); border-color var(--password-field-transition-duration) var(--password-field-transition-ease),
color var(--password-field-transition-duration) var(--password-field-transition-ease),
box-shadow var(--password-field-transition-duration) var(--password-field-transition-ease);
} }
[data-password-field-visibility-trigger]:hover { [data-password-field-visibility-trigger]:hover:not([data-disabled]) {
color: var(--_password-field-color); background: var(--password-field-trigger-bg-hover);
background: color-mix(in srgb, var(--_password-field-border) 30%, transparent); border-color: var(--password-field-trigger-border-hover);
color: var(--password-field-trigger-color-hover);
} }
[data-password-field-visibility-trigger]:focus-visible { [data-password-field-visibility-trigger]:focus-visible {
outline: var(--ring-width, 2px) solid var(--_password-field-border-focus); outline: none;
outline-offset: 1px; box-shadow: var(--password-field-trigger-focus-shadow);
} }
[data-password-field-visibility-trigger][data-disabled] { [data-password-field-visibility-trigger][data-disabled] {
opacity: 0.5; opacity: var(--password-field-disabled-opacity);
cursor: not-allowed; cursor: not-allowed;
} }
[data-password-field-visibility-trigger] svg { [data-password-field-visibility-trigger] svg {
inline-size: calc(var(--_password-field-font-size) * 1.1); inline-size: calc(var(--_password-field-trigger-size) - 8px);
block-size: calc(var(--_password-field-font-size) * 1.1); block-size: calc(var(--_password-field-trigger-size) - 8px);
} }
/* ── CapsLockIndicator ─────────────────────────────────────────────── */ /* ── CapsLockIndicator ─────────────────────────────────────────────── */
@ -183,20 +225,20 @@
padding-inline: 0.4em; padding-inline: 0.4em;
padding-block: 0.2em; padding-block: 0.2em;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: calc(var(--_password-field-font-size) * 0.75); font-size: var(--_password-field-caps-font-size);
font-weight: 600; font-weight: var(--font-weight-medium, 500);
color: transparent; color: transparent;
background: transparent; background: transparent;
transition: transition:
color var(--duration-fast) var(--ease-default), color var(--password-field-transition-duration) var(--password-field-transition-ease),
background var(--duration-fast) var(--ease-default); background var(--password-field-transition-duration) var(--password-field-transition-ease);
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
} }
[data-password-field-caps-lock-indicator][data-active] { [data-password-field-caps-lock-indicator][data-active] {
color: var(--color-risk-text); color: var(--password-field-caps-color);
background: color-mix(in srgb, var(--color-risk-track) 50%, transparent); background: var(--password-field-caps-bg);
} }
[data-password-field-caps-lock-glyph] { [data-password-field-caps-lock-glyph] {
@ -204,7 +246,7 @@
line-height: 1; line-height: 1;
} }
/* ── StrengthMeter ─────────────────────────────────────────────────── */ /* ── StrengthMeter (composable; layout below the control row) ─────── */
[data-password-field-strength-meter] { [data-password-field-strength-meter] {
flex: 1 1 100%; flex: 1 1 100%;
@ -212,38 +254,42 @@
flex-direction: column; flex-direction: column;
gap: 0.25em; gap: 0.25em;
margin-top: var(--space-1); margin-top: var(--space-1);
padding-inline: 0;
} }
[data-password-field-strength-meter-track] { [data-password-field-strength-meter-track] {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 4px; gap: var(--password-field-strength-segment-gap);
} }
[data-password-field-strength-meter-segment] { [data-password-field-strength-meter-segment] {
block-size: 4px; block-size: var(--password-field-strength-segment-height);
border-radius: var(--radius-sm); border-radius: var(--password-field-strength-radius);
background: var(--color-neutral-track); background: var(--password-field-strength-segment-bg);
transition: background var(--duration-fast) var(--ease-default); transition:
background var(--password-field-transition-duration) var(--password-field-transition-ease);
} }
[data-password-field-strength-meter][data-strength='1'] [data-password-field-strength-meter-segment][data-active] { [data-password-field-strength-meter][data-strength='1']
[data-password-field-strength-meter-segment][data-active] {
background: var(--color-loss-solid, var(--color-threat-solid)); background: var(--color-loss-solid, var(--color-threat-solid));
} }
[data-password-field-strength-meter][data-strength='2'] [data-password-field-strength-meter-segment][data-active] { [data-password-field-strength-meter][data-strength='2']
[data-password-field-strength-meter-segment][data-active] {
background: var(--color-risk-solid); background: var(--color-risk-solid);
} }
[data-password-field-strength-meter][data-strength='3'] [data-password-field-strength-meter-segment][data-active] { [data-password-field-strength-meter][data-strength='3']
[data-password-field-strength-meter-segment][data-active] {
background: var(--color-fulfill-solid); background: var(--color-fulfill-solid);
} }
[data-password-field-strength-meter][data-strength='4'] [data-password-field-strength-meter-segment][data-active] { [data-password-field-strength-meter][data-strength='4']
[data-password-field-strength-meter-segment][data-active] {
background: var(--color-affirm-solid); background: var(--color-affirm-solid);
} }
[data-password-field-strength-meter-label] { [data-password-field-strength-meter-label] {
font-size: calc(var(--_password-field-font-size) * 0.75); font-size: calc(var(--_password-field-font-size) * 0.85);
color: var(--_password-field-muted); color: var(--password-field-placeholder-color);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }

@ -22,7 +22,7 @@ rich-text ni wrapper visual reutilizable.
| Active formatting state | aria-pressed on toggles | Selection state | Extension state | `data-state`, `aria-pressed` | implement | | Active formatting state | aria-pressed on toggles | Selection state | Extension state | `data-state`, `aria-pressed` | implement |
| Placeholder | Input hint pattern | Plugin/placeholder | Placeholder extension | `Words.Placeholder` | implement | | Placeholder | Input hint pattern | Plugin/placeholder | Placeholder extension | `Words.Placeholder` | implement |
| Form serialization | Native input when needed | external | external | `Words.HiddenInput` JSON | implement | | Form serialization | Native input when needed | external | external | `Words.HiddenInput` JSON | implement |
| Import/export HTML | not owned | available | available | defer safe importer | defer | | Import/export HTML | not owned | available | available | clean export; defer importer | partial |
| Bubble menu/link popover | not owned | plugins | extension UI | needs Soma selection UX | defer | | Bubble menu/link popover | not owned | plugins | extension UI | needs Soma selection UX | defer |
| Images/tables/collab | not owned | plugins | extensions | outside V1 core | defer | | Images/tables/collab | not owned | plugins | extensions | outside V1 core | defer |
@ -52,7 +52,8 @@ rich-text ni wrapper visual reutilizable.
- Air parity: no Air component existed; implement from UIX architecture. - Air parity: no Air component existed; implement from UIX architecture.
- Bubble/link popover: defer until Soma exposes stable selection anchored UI. - Bubble/link popover: defer until Soma exposes stable selection anchored UI.
- HTML/Markdown import/export: defer until sanitizer/import pipeline is closed. - HTML export: implemented as a clean Soma serializer. HTML import and Markdown
import/export defer until the sanitizer/import pipeline is closed.
- Tables/images/collaboration: defer to V2 or ActiveApp-backed packs. - Tables/images/collaboration: defer to V2 or ActiveApp-backed packs.
- Demo scope signed by user on 2026-05-24 via "continua" after the Eidos/demo - Demo scope signed by user on 2026-05-24 via "continua" after the Eidos/demo
slice was proposed. slice was proposed.

@ -1555,6 +1555,66 @@
--search-field-transition-duration: var(--duration-fast); --search-field-transition-duration: var(--duration-fast);
--search-field-transition-ease: var(--ease-default); --search-field-transition-ease: var(--ease-default);
--search-field-disabled-opacity: 0.56; --search-field-disabled-opacity: 0.56;
--password-field-height-xs: var(--control-height-xs);
--password-field-height-sm: var(--control-height-sm);
--password-field-height-md: var(--control-height-md);
--password-field-height-lg: var(--control-height-lg);
--password-field-height-xl: var(--control-height-xl);
--password-field-px-xs: var(--space-2);
--password-field-px-sm: var(--space-2-5);
--password-field-px-md: var(--space-3);
--password-field-px-lg: var(--space-3-5);
--password-field-px-xl: var(--space-4);
--password-field-gap-xs: var(--space-1);
--password-field-gap-sm: var(--space-1-5);
--password-field-gap-md: var(--space-2);
--password-field-gap-lg: var(--space-2-5);
--password-field-gap-xl: var(--space-3);
--password-field-font-family: var(--font-ui);
--password-field-font-size-xs: var(--font-size-xs);
--password-field-font-size-sm: var(--font-size-xs);
--password-field-font-size-md: var(--font-size-sm);
--password-field-font-size-lg: var(--font-size-sm);
--password-field-font-size-xl: var(--font-size-md);
--password-field-line-height: var(--leading-ui);
--password-field-radius: var(--radius-md);
--password-field-border-width: var(--border-width);
--password-field-border: var(--color-border-default);
--password-field-border-focus: var(--color-focus-ring);
--password-field-border-invalid: var(--color-risk-border);
--password-field-bg: var(--color-surface-default);
--password-field-color: var(--color-content-primary);
--password-field-placeholder-color: var(--color-content-muted);
--password-field-focus-shadow: 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--password-field-trigger-size: var(--control-height-xs);
--password-field-trigger-radius: var(--radius-full);
--password-field-trigger-border-width: var(--border-width);
--password-field-trigger-border: transparent;
--password-field-trigger-border-hover: var(--color-border-subtle);
--password-field-trigger-bg: transparent;
--password-field-trigger-bg-hover: var(--color-surface-raised);
--password-field-trigger-color: var(--color-content-secondary);
--password-field-trigger-color-hover: var(--color-content-primary);
--password-field-trigger-focus-shadow: 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--password-field-icon-size-xs: var(--icon-size-xs);
--password-field-icon-size-sm: var(--icon-size-sm);
--password-field-icon-size-md: var(--icon-size-md);
--password-field-icon-size-lg: var(--icon-size-md);
--password-field-icon-size-xl: var(--icon-size-lg);
--password-field-caps-color: var(--color-risk-text);
--password-field-caps-bg: color-mix(in srgb, var(--color-risk-track) 50%, transparent);
--password-field-caps-font-size-xs: var(--font-size-xs);
--password-field-caps-font-size-sm: var(--font-size-xs);
--password-field-caps-font-size-md: var(--font-size-xs);
--password-field-caps-font-size-lg: var(--font-size-sm);
--password-field-caps-font-size-xl: var(--font-size-sm);
--password-field-strength-segment-bg: var(--color-neutral-track);
--password-field-strength-segment-gap: 4px;
--password-field-strength-segment-height: 4px;
--password-field-strength-radius: var(--radius-sm);
--password-field-transition-duration: var(--duration-fast);
--password-field-transition-ease: var(--ease-default);
--password-field-disabled-opacity: 0.56;
--select-stack-gap: var(--space-2); --select-stack-gap: var(--space-2);
--select-font-family: var(--font-ui); --select-font-family: var(--font-ui);
--select-font-size-xs: var(--font-size-xs); --select-font-size-xs: var(--font-size-xs);

@ -1310,6 +1310,70 @@ export const THEME_BASE_RECIPE_TOKENS = {
'transition-ease': 'var(--ease-default)', 'transition-ease': 'var(--ease-default)',
'disabled-opacity': '0.56' 'disabled-opacity': '0.56'
}, },
'password-field': {
'height-xs': 'var(--control-height-xs)',
'height-sm': 'var(--control-height-sm)',
'height-md': 'var(--control-height-md)',
'height-lg': 'var(--control-height-lg)',
'height-xl': 'var(--control-height-xl)',
'px-xs': 'var(--space-2)',
'px-sm': 'var(--space-2-5)',
'px-md': 'var(--space-3)',
'px-lg': 'var(--space-3-5)',
'px-xl': 'var(--space-4)',
'gap-xs': 'var(--space-1)',
'gap-sm': 'var(--space-1-5)',
'gap-md': 'var(--space-2)',
'gap-lg': 'var(--space-2-5)',
'gap-xl': 'var(--space-3)',
'font-family': 'var(--font-ui)',
'font-size-xs': 'var(--font-size-xs)',
'font-size-sm': 'var(--font-size-xs)',
'font-size-md': 'var(--font-size-sm)',
'font-size-lg': 'var(--font-size-sm)',
'font-size-xl': 'var(--font-size-md)',
'line-height': 'var(--leading-ui)',
radius: 'var(--radius-md)',
'border-width': 'var(--border-width)',
border: 'var(--color-border-default)',
'border-focus': 'var(--color-focus-ring)',
'border-invalid': 'var(--color-risk-border)',
bg: 'var(--color-surface-default)',
color: 'var(--color-content-primary)',
'placeholder-color': 'var(--color-content-muted)',
'focus-shadow':
'0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'trigger-size': 'var(--control-height-xs)',
'trigger-radius': 'var(--radius-full)',
'trigger-border-width': 'var(--border-width)',
'trigger-border': 'transparent',
'trigger-border-hover': 'var(--color-border-subtle)',
'trigger-bg': 'transparent',
'trigger-bg-hover': 'var(--color-surface-raised)',
'trigger-color': 'var(--color-content-secondary)',
'trigger-color-hover': 'var(--color-content-primary)',
'trigger-focus-shadow':
'0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'icon-size-xs': 'var(--icon-size-xs)',
'icon-size-sm': 'var(--icon-size-sm)',
'icon-size-md': 'var(--icon-size-md)',
'icon-size-lg': 'var(--icon-size-md)',
'icon-size-xl': 'var(--icon-size-lg)',
'caps-color': 'var(--color-risk-text)',
'caps-bg': 'color-mix(in srgb, var(--color-risk-track) 50%, transparent)',
'caps-font-size-xs': 'var(--font-size-xs)',
'caps-font-size-sm': 'var(--font-size-xs)',
'caps-font-size-md': 'var(--font-size-xs)',
'caps-font-size-lg': 'var(--font-size-sm)',
'caps-font-size-xl': 'var(--font-size-sm)',
'strength-segment-bg': 'var(--color-neutral-track)',
'strength-segment-gap': '4px',
'strength-segment-height': '4px',
'strength-radius': 'var(--radius-sm)',
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)',
'disabled-opacity': '0.56'
},
select: { select: {
'stack-gap': 'var(--space-2)', 'stack-gap': 'var(--space-2)',
'font-family': 'var(--font-ui)', 'font-family': 'var(--font-ui)',

@ -15,7 +15,7 @@ empezar hasta cerrar contrato Morfo y alcance V1.
`contenteditable`. `contenteditable`.
- Slice 6 cierra wrapper Eidos, receta visual, pack Sema y demo UIX de 6 tabs. - Slice 6 cierra wrapper Eidos, receta visual, pack Sema y demo UIX de 6 tabs.
- Tests focalizados en `engine/engine.test.ts` y - Tests focalizados en `engine/engine.test.ts` y
`words-provider.svelte.test.ts` con 23 casos. `words-provider.svelte.test.ts` con 32 casos.
## Responsabilidad ## Responsabilidad
@ -132,6 +132,8 @@ type WordsMark = 'bold' | 'italic' | 'underline' | 'strike' | 'code';
| `engine/input.ts` | Traduccion de `beforeinput` a comandos o accion de history. | | `engine/input.ts` | Traduccion de `beforeinput` a comandos o accion de history. |
| `engine/render.ts` | Descriptor de render headless con tags y attrs para `contenteditable`. | | `engine/render.ts` | Descriptor de render headless con tags y attrs para `contenteditable`. |
| `engine/serialize-json.ts` | Parse normalizado y serialize sin mutacion implicita. | | `engine/serialize-json.ts` | Parse normalizado y serialize sin mutacion implicita. |
| `engine/serialize-html.ts` | Export HTML limpio sin `data-words-*` ni zero-width placeholders. |
| `engine/serialize-text.ts` | Import seguro de texto plano a parrafos canonicos. |
| `engine/engine.test.ts` | Cobertura de normalizacion, listas, rangos, links, history y teclado. | | `engine/engine.test.ts` | Cobertura de normalizacion, listas, rangos, links, history y teclado. |
## Correcciones cerradas en slice 5 ## Correcciones cerradas en slice 5
@ -156,6 +158,18 @@ type WordsMark = 'bold' | 'italic' | 'underline' | 'strike' | 'code';
clear y commit; expone estado activo/deshabilitado por Morfo. clear y commit; expone estado activo/deshabilitado por Morfo.
- Paste HTML sin texto plano se rechaza y se reporta por el logger UIX como - Paste HTML sin texto plano se rechaza y se reporta por el logger UIX como
`soma.words`. `soma.words`.
- IME/composition queda conectado al Provider: `compositionend` aplica el
comando final.
- `deleteWord*` y `deleteLine*` tienen comandos propios; los `beforeinput`
no soportados no se cancelan cuando el motor no los posee.
- Las listas tienen salida por Enter en item vacio y Backspace al inicio del
primer item; `setBlock` sobre listas produce un bloque por item.
- Los botones de lista se activan por `listKind`, no por cualquier bloque
`list`.
- Los links anidados se aplanan en normalizacion y `insertLink` no anida dentro
de un link existente.
- `exportContent` expone JSON, texto plano y HTML limpio; `importContent`
acepta JSON y texto plano.
## Correcciones cerradas en slice 2 ## Correcciones cerradas en slice 2
@ -196,7 +210,7 @@ type WordsMark = 'bold' | 'italic' | 'underline' | 'strike' | 'code';
- Agrupar input/composition en history batches semanticos, no un snapshot por - Agrupar input/composition en history batches semanticos, no un snapshot por
caracter. caracter.
- Importador HTML seguro y Markdown/HTML export controlado. - Importador HTML seguro y Markdown import/export controlado.
- Drop/clipboard avanzado a traves de servicios ActiveUIX. - Drop/clipboard avanzado a traves de servicios ActiveUIX.
- Bubble toolbar y popover de links como partes Soma reales si entran en V1. - Bubble toolbar y popover de links como partes Soma reales si entran en V1.
- Cobertura de componentes Svelte renderizados, no solo providers. - Cobertura de componentes Svelte renderizados, no solo providers.

@ -20,6 +20,13 @@ import {
type WordsEditorState, type WordsEditorState,
type WordsOperationResult type WordsOperationResult
} from './operations'; } from './operations';
import { renderWordsPlainText } from './render';
import { serializeWordsHtml } from './serialize-html';
import { parseWordsJson, serializeWordsJson } from './serialize-json';
import { parseWordsPlainText } from './serialize-text';
export type WordsImportFormat = 'json' | 'text';
export type WordsExportFormat = 'json' | 'text' | 'html';
export type WordsCommand = export type WordsCommand =
| { type: 'insertText'; text: string } | { type: 'insertText'; text: string }
@ -38,7 +45,9 @@ export type WordsCommand =
| { type: 'toggleList'; kind: WordsListKind } | { type: 'toggleList'; kind: WordsListKind }
| { type: 'insertLink'; href: string; title?: string } | { type: 'insertLink'; href: string; title?: string }
| { type: 'unlink' } | { type: 'unlink' }
| { type: 'replaceDocument'; document: unknown }; | { type: 'replaceDocument'; document: unknown }
| { type: 'importContent'; content: string; format: WordsImportFormat }
| { type: 'exportContent'; format: WordsExportFormat };
export function applyWordsCommand( export function applyWordsCommand(
state: WordsEditorState, state: WordsEditorState,
@ -79,5 +88,37 @@ export function applyWordsCommand(
return unlink(state); return unlink(state);
case 'replaceDocument': case 'replaceDocument':
return replaceDocument(state, command.document); return replaceDocument(state, command.document);
case 'importContent':
return importWordsContent(state, command.content, command.format);
case 'exportContent':
return exportWordsContent(state, command.format);
}
}
function importWordsContent(
state: WordsEditorState,
content: string,
format: WordsImportFormat
): WordsOperationResult {
try {
return replaceDocument(
state,
format === 'json' ? parseWordsJson(content) : parseWordsPlainText(content)
);
} catch {
return { state, changed: false };
} }
} }
function exportWordsContent(
state: WordsEditorState,
format: WordsExportFormat
): WordsOperationResult {
const exportedContent =
format === 'json'
? serializeWordsJson(state.document)
: format === 'html'
? serializeWordsHtml(state.document)
: renderWordsPlainText(state.document);
return { state, changed: false, exportedContent };
}

@ -173,6 +173,12 @@ export function getBlockText(block: WordsBlock): string {
} }
export function getInlineText(inline: WordsInline): string { export function getInlineText(inline: WordsInline): string {
return getInlineTextSafe(inline, new WeakSet<object>());
}
function getInlineTextSafe(inline: WordsInline, seen: WeakSet<object>): string {
if (seen.has(inline)) return '';
seen.add(inline);
if (inline.type === 'text') return inline.text; if (inline.type === 'text') return inline.text;
return inline.children.map(getInlineText).join(''); return inline.children.map((child) => getInlineTextSafe(child, seen)).join('');
} }

@ -29,11 +29,14 @@ import {
encodeWordsPath, encodeWordsPath,
normalizeDocument, normalizeDocument,
parseWordsJson, parseWordsJson,
parseWordsPlainText,
redoWords, redoWords,
renderWordsDomHtml,
renderWordsDocument, renderWordsDocument,
resolveDomPointFromTarget, resolveDomPointFromTarget,
restoreSelectionApiFromDomTarget, restoreSelectionApiFromDomTarget,
serializeWordsJson, serializeWordsJson,
serializeWordsHtml,
selectionApiToWordsSelection, selectionApiToWordsSelection,
updateWordsComposition, updateWordsComposition,
undoWords, undoWords,
@ -115,6 +118,37 @@ describe('Words engine', () => {
}); });
}); });
it('flattens nested links during normalization and blocks inserting links inside links', () => {
const result = normalizeDocument({
version: 1,
children: [
createParagraph([
createLink('/outer', [createText('Outer '), createLink('/inner', [createText('Inner')])])
])
]
});
expect(result.issues.map((issue) => issue.reason)).toContain('nested-link');
expect(result.document.children[0]).toMatchObject({
type: 'paragraph',
children: [
{
type: 'link',
href: '/outer',
children: [{ type: 'text', text: 'Outer Inner' }]
}
]
});
const state = createWordsState(
result.document,
createCollapsedSelection([0, 0, 0], 'Outer'.length)
);
const nested = applyWordsCommand(state, { type: 'insertLink', href: '/nested' });
expect(nested.changed).toBe(false);
expect(nested.state.document).toEqual(state.document);
});
it('serializes and parses canonical JSON', () => { it('serializes and parses canonical JSON', () => {
const doc: WordsDocument = { const doc: WordsDocument = {
version: 1, version: 1,
@ -223,6 +257,34 @@ describe('Words engine', () => {
}); });
}); });
it('deletes by word or line instead of collapsing advanced beforeinput to one char', () => {
let state = stateWithText('Alpha beta');
let result = applyWordsCommand(state, { type: 'deleteWordBackward' });
expect(documentPlainText(result.state.document)).toBe('Alpha ');
expect(result.state.selection).toEqual(createCollapsedSelection([0, 0], 6));
state = stateWithText('Alpha beta');
result = applyWordsCommand(
{ ...state, selection: createCollapsedSelection([0, 0], 6) },
{ type: 'deleteWordForward' }
);
expect(documentPlainText(result.state.document)).toBe('Alpha ');
result = applyWordsCommand(state, { type: 'deleteLineBackward' });
expect(documentPlainText(result.state.document)).toBe('');
expect(actionFromBeforeInput({ inputType: 'deleteWordBackward' })).toEqual({
type: 'command',
command: { type: 'deleteWordBackward' },
batch: 'typing'
});
expect(actionFromBeforeInput({ inputType: 'deleteHardLineForward' })).toEqual({
type: 'command',
command: { type: 'deleteLineForward' },
batch: 'typing'
});
});
it('tracks undo and redo history around changed commands only', () => { it('tracks undo and redo history around changed commands only', () => {
const initial = stateWithText('A'); const initial = stateWithText('A');
let history = createWordsHistory(initial); let history = createWordsHistory(initial);
@ -355,6 +417,66 @@ describe('Words engine', () => {
}); });
}); });
it('converts list items to blocks and lets users leave lists', () => {
let state = createWordsState(
{
version: 1,
children: [
createList('unordered', [
createListItem([createText('One')]),
createListItem([createText('Two')])
])
]
},
createCollapsedSelection([0, 1, 0], 1)
);
let result = applyWordsCommand(state, { type: 'setBlock', block: 'paragraph' });
expect(result.state.document.children).toMatchObject([
{ type: 'paragraph', children: [{ type: 'text', text: 'One' }] },
{ type: 'paragraph', children: [{ type: 'text', text: 'Two' }] }
]);
expect(result.state.selection).toEqual(createCollapsedSelection([1, 0], 1));
state = createWordsState(
{
version: 1,
children: [
createList('unordered', [
createListItem([createText('Item')]),
createListItem([createText('')])
])
]
},
createCollapsedSelection([0, 1, 0], 0)
);
result = applyWordsCommand(state, { type: 'insertParagraph' });
expect(result.state.document.children).toMatchObject([
{ type: 'list', children: [{ children: [{ type: 'text', text: 'Item' }] }] },
{ type: 'paragraph', children: [{ type: 'text', text: '' }] }
]);
expect(result.state.selection).toEqual(createCollapsedSelection([1, 0], 0));
state = createWordsState(
{
version: 1,
children: [
createList('check', [
createListItem([createText('First')], true),
createListItem([createText('Second')])
])
]
},
createCollapsedSelection([0, 0, 0], 0)
);
result = applyWordsCommand(state, { type: 'deleteBackward' });
expect(result.state.document.children).toMatchObject([
{ type: 'paragraph', children: [{ type: 'text', text: 'First' }] },
{ type: 'list', kind: 'check', children: [{ children: [{ type: 'text', text: 'Second' }] }] }
]);
expect(result.state.selection).toEqual(createCollapsedSelection([0, 0], 0));
});
it('unlinks from an inner text selection and allows relative URLs', () => { it('unlinks from an inner text selection and allows relative URLs', () => {
let state = createWordsState( let state = createWordsState(
{ {
@ -408,6 +530,71 @@ describe('Words engine', () => {
expect(history.past).toHaveLength(2); expect(history.past).toHaveLength(2);
}); });
it('keeps DOM render metadata separate from clean export formats', () => {
const doc: WordsDocument = {
version: 1,
children: [
createParagraph([
createText('A', ['bold']),
createLink('/docs', [createText('Docs', ['italic'])])
]),
createList('check', [
createListItem([createText('Done')], true),
createListItem([createText('')])
])
]
};
const domHtml = renderWordsDomHtml(doc);
expect(domHtml).toContain('data-words-path');
expect(domHtml).toContain('\u200b');
const cleanHtml = serializeWordsHtml(doc);
expect(cleanHtml).toBe(
'<p><strong>A</strong><a href="/docs"><em>Docs</em></a></p><ul><li><input type="checkbox" checked disabled> Done</li><li><input type="checkbox" disabled> </li></ul>'
);
expect(cleanHtml).not.toContain('data-words');
expect(cleanHtml).not.toContain('\u200b');
expect(parseWordsPlainText('A\r\nB\u0000')).toEqual({
version: 1,
children: [createParagraph([createText('A')]), createParagraph([createText('B')])]
});
});
it('imports and exports content through explicit commands', () => {
const initial = stateWithText('');
const imported = applyWordsCommand(initial, {
type: 'importContent',
format: 'text',
content: 'Alpha\nBeta'
});
expect(imported.changed).toBe(true);
expect(documentPlainText(imported.state.document)).toBe('Alpha\nBeta');
const exportedHtml = applyWordsCommand(imported.state, {
type: 'exportContent',
format: 'html'
});
expect(exportedHtml.changed).toBe(false);
expect(exportedHtml.exportedContent).toBe('<p>Alpha</p><p>Beta</p>');
const exportedJson = applyWordsCommand(imported.state, {
type: 'exportContent',
format: 'json'
});
expect(exportedJson.exportedContent).toBe(serializeWordsJson(imported.state.document));
const invalid = applyWordsCommand(imported.state, {
type: 'importContent',
format: 'json',
content: '{'
});
expect(invalid.changed).toBe(false);
expect(invalid.state).toBe(imported.state);
});
it('renders stable data-words metadata for DOM reconciliation', () => { it('renders stable data-words metadata for DOM reconciliation', () => {
const doc: WordsDocument = { const doc: WordsDocument = {
version: 1, version: 1,

@ -9,4 +9,6 @@ export * from './operations';
export * from './path'; export * from './path';
export * from './render'; export * from './render';
export * from './selection'; export * from './selection';
export * from './serialize-html';
export * from './serialize-json'; export * from './serialize-json';
export * from './serialize-text';

@ -52,6 +52,7 @@ export interface WordsEditorState {
export interface WordsOperationResult { export interface WordsOperationResult {
readonly state: WordsEditorState; readonly state: WordsEditorState;
readonly changed: boolean; readonly changed: boolean;
readonly exportedContent?: string;
} }
interface DocumentRangeResult { interface DocumentRangeResult {

@ -152,10 +152,12 @@ export function isRenderedEmptyText(node: WordsRenderElement): boolean {
return node.attrs[WORDS_EMPTY_TEXT_ATTR] === ''; return node.attrs[WORDS_EMPTY_TEXT_ATTR] === '';
} }
export function renderWordsHtml(document: WordsDocument): string { export function renderWordsDomHtml(document: WordsDocument): string {
return renderWordsDocument(document).map(renderWordsNodeHtml).join(''); return renderWordsDocument(document).map(renderWordsNodeHtml).join('');
} }
export const renderWordsHtml = renderWordsDomHtml;
function renderWordsNodeHtml(node: WordsRenderNode): string { function renderWordsNodeHtml(node: WordsRenderNode): string {
if (node.kind === 'text') return escapeHtml(node.text); if (node.kind === 'text') return escapeHtml(node.text);
const attrs = Object.entries(node.attrs) const attrs = Object.entries(node.attrs)

@ -0,0 +1,81 @@
import {
getInlineText,
type WordsBlock,
type WordsDocument,
type WordsInline,
type WordsMark
} from './document';
import { sanitizeWordsUrl } from './normalize';
const MARK_TAGS = {
bold: 'strong',
italic: 'em',
underline: 'u',
strike: 's',
code: 'code'
} as const satisfies Record<WordsMark, string>;
export function serializeWordsHtml(document: WordsDocument): string {
return document.children.map(serializeBlockHtml).join('');
}
function serializeBlockHtml(block: WordsBlock): string {
if (block.type === 'list') {
const tag = block.kind === 'ordered' ? 'ol' : 'ul';
const children = block.children
.map((item) => {
const checkbox =
block.kind === 'check'
? `<input type="checkbox"${item.checked === true ? ' checked' : ''} disabled> `
: '';
return `<li>${checkbox}${serializeInlinesHtml(item.children)}</li>`;
})
.join('');
return `<${tag}>${children}</${tag}>`;
}
if (block.type === 'heading') {
return `<h${block.level}>${serializeInlinesHtml(block.children)}</h${block.level}>`;
}
if (block.type === 'quote') {
return `<blockquote>${serializeInlinesHtml(block.children)}</blockquote>`;
}
return `<p>${serializeInlinesHtml(block.children)}</p>`;
}
function serializeInlinesHtml(inlines: readonly WordsInline[]): string {
return inlines.map(serializeInlineHtml).join('');
}
function serializeInlineHtml(inline: WordsInline): string {
if (inline.type === 'link') {
const href = sanitizeWordsUrl(inline.href);
if (!href) return escapeHtml(inline.children.map(getInlineText).join(''));
const title = inline.title ? ` title="${escapeHtmlAttr(inline.title)}"` : '';
return `<a href="${escapeHtmlAttr(href)}"${title}>${serializeInlinesHtml(inline.children)}</a>`;
}
return wrapMarks(escapeHtml(inline.text), inline.marks ?? []);
}
function wrapMarks(value: string, marks: readonly WordsMark[]): string {
return marks.reduce((html, mark) => {
const tag = MARK_TAGS[mark];
return `<${tag}>${html}</${tag}>`;
}, value);
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeHtmlAttr(value: string): string {
return escapeHtml(value).replace(/`/g, '&#96;');
}

@ -0,0 +1,15 @@
import {
WORDS_DOCUMENT_VERSION,
createParagraph,
createText,
type WordsDocument
} from './document';
export function parseWordsPlainText(text: string): WordsDocument {
const normalized = text.replace(/\r\n?/g, '\n').replace(/\u0000/g, '');
const lines = normalized.split('\n');
return {
version: WORDS_DOCUMENT_VERSION,
children: (lines.length ? lines : ['']).map((line) => createParagraph([createText(line)]))
};
}

@ -8,7 +8,9 @@ import type {
} from '../../types'; } from '../../types';
import type { import type {
WordsDocument, WordsDocument,
WordsExportFormat,
WordsHeadingLevel, WordsHeadingLevel,
WordsImportFormat,
WordsListKind, WordsListKind,
WordsMark, WordsMark,
WordsSelection WordsSelection
@ -51,6 +53,8 @@ export type WordsProviderSnippetProps = {
readonly canRedo: boolean; readonly canRedo: boolean;
readonly focus: () => void; readonly focus: () => void;
readonly runCommand: (command: WordsCommandName) => void; readonly runCommand: (command: WordsCommandName) => void;
readonly importContent: (content: string, format: WordsImportFormat) => void;
readonly exportContent: (format: WordsExportFormat) => string;
readonly commit: (reason?: WordsCommitReason) => void; readonly commit: (reason?: WordsCommitReason) => void;
readonly undo: () => void; readonly undo: () => void;
readonly redo: () => void; readonly redo: () => void;

@ -19,6 +19,8 @@ import {
import type { WordsCommandName } from './types'; import type { WordsCommandName } from './types';
import { import {
createEmptyWordsDocument, createEmptyWordsDocument,
createList,
createListItem,
createParagraph, createParagraph,
createText, createText,
renderWordsPlainText, renderWordsPlainText,
@ -205,6 +207,58 @@ describe('WordsProvider', () => {
dom.dispose(); dom.dispose();
}); });
it('commits IME composition text through the provider', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const contentEl = document.createElement('div');
document.body.append(root, contentEl);
const opts = wordsOpts(root);
const { result, cleanup } = withEffectRoot(() => {
const provider = WordsProvider.create(opts);
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
const content = WordsContentProvider.create({
id: state('words-content'),
ref: state<HTMLElement | null>(contentEl)
});
provider.setContentRef(contentEl);
return { provider, content };
});
contentEl.innerHTML = result.provider.html;
mountRenderedSelection(contentEl, 0);
result.content.props.oncompositionstart({ data: 'a' } as never);
result.content.props.oncompositionupdate({ data: 'あ' } as never);
result.content.props.oncompositionend({ data: 'あ' } as never);
expect(renderWordsPlainText(opts.value.current)).toBe('あ');
expect(result.provider.composition.active).toBe(false);
cleanup();
dom.dispose();
});
it('exposes explicit import and clean export helpers', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
document.body.append(root);
const opts = wordsOpts(root);
const { result, cleanup } = withEffectRoot(() => {
const provider = WordsProvider.create(opts);
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
return { provider };
});
result.provider.snippetProps.importContent('Alpha\nBeta', 'text');
expect(renderWordsPlainText(opts.value.current)).toBe('Alpha\nBeta');
expect(result.provider.snippetProps.exportContent('html')).toBe('<p>Alpha</p><p>Beta</p>');
expect(result.provider.snippetProps.exportContent('html')).not.toContain('data-words');
cleanup();
dom.dispose();
});
it('drives command buttons from active marks and history state', () => { it('drives command buttons from active marks and history state', () => {
const { dom } = installSomaHarness(); const { dom } = installSomaHarness();
const root = document.createElement('div'); const root = document.createElement('div');
@ -239,6 +293,57 @@ describe('WordsProvider', () => {
dom.dispose(); dom.dispose();
}); });
it('activates only the command button matching the current list kind', () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const unorderedEl = document.createElement('button');
const orderedEl = document.createElement('button');
const checkEl = document.createElement('button');
document.body.append(root, unorderedEl, orderedEl, checkEl);
const opts = wordsOpts(root, {
version: 1,
children: [createList('ordered', [createListItem([createText('Item')])])]
});
opts.selection.current = {
anchor: { path: [0, 0, 0], offset: 0 },
focus: { path: [0, 0, 0], offset: 0 }
};
const { result, cleanup } = withEffectRoot(() => {
const provider = WordsProvider.create(opts);
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
const unordered = WordsCommandButtonProvider.create({
id: state('words-unordered'),
ref: state<HTMLElement | null>(unorderedEl),
command: state<WordsCommandName>('unordered-list'),
level: state<1 | 2 | 3 | undefined>(undefined),
listKind: state<'ordered' | 'unordered' | 'check' | undefined>('unordered')
});
const ordered = WordsCommandButtonProvider.create({
id: state('words-ordered'),
ref: state<HTMLElement | null>(orderedEl),
command: state<WordsCommandName>('ordered-list'),
level: state<1 | 2 | 3 | undefined>(undefined),
listKind: state<'ordered' | 'unordered' | 'check' | undefined>('ordered')
});
const check = WordsCommandButtonProvider.create({
id: state('words-check'),
ref: state<HTMLElement | null>(checkEl),
command: state<WordsCommandName>('check-list'),
level: state<1 | 2 | 3 | undefined>(undefined),
listKind: state<'ordered' | 'unordered' | 'check' | undefined>('check')
});
return { unordered, ordered, check };
});
expect(result.unordered.props['aria-pressed']).toBe(false);
expect(result.ordered.props['aria-pressed']).toBe(true);
expect(result.check.props['aria-pressed']).toBe(false);
cleanup();
dom.dispose();
});
it('logs unsupported paste payloads through the UIX logger', () => { it('logs unsupported paste payloads through the UIX logger', () => {
const { dom, logger } = installSomaHarness(); const { dom, logger } = installSomaHarness();
const root = document.createElement('div'); const root = document.createElement('div');
@ -274,4 +379,38 @@ describe('WordsProvider', () => {
cleanup(); cleanup();
dom.dispose(); dom.dispose();
}); });
it('does not cancel native beforeinput when the engine does not own the input type', () => {
const { dom, logger } = installSomaHarness();
const root = document.createElement('div');
const contentEl = document.createElement('div');
document.body.append(root, contentEl);
const opts = wordsOpts(root);
const { result, cleanup } = withEffectRoot(() => {
const provider = WordsProvider.create(opts);
vi.spyOn(WordsProvider, 'require').mockReturnValue(provider);
const content = WordsContentProvider.create({
id: state('words-content'),
ref: state<HTMLElement | null>(contentEl)
});
provider.setContentRef(contentEl);
return { content };
});
const preventDefault = vi.fn();
result.content.props.onbeforeinput({
inputType: 'insertFromYank',
data: null,
isComposing: false,
currentTarget: contentEl,
preventDefault
} as never);
expect(preventDefault).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
cleanup();
dom.dispose();
});
}); });

@ -23,6 +23,7 @@ import {
actionFromBeforeInput, actionFromBeforeInput,
actionFromPaste, actionFromPaste,
applyHistoryCommand, applyHistoryCommand,
applyWordsCommand,
beginWordsComposition, beginWordsComposition,
createEmptyWordsDocument, createEmptyWordsDocument,
createWordsCompositionState, createWordsCompositionState,
@ -33,7 +34,7 @@ import {
getTextEntries, getTextEntries,
isCollapsedSelection, isCollapsedSelection,
redoWords, redoWords,
renderWordsHtml, renderWordsDomHtml,
renderWordsPlainText, renderWordsPlainText,
restoreSelectionApiFromDomTarget, restoreSelectionApiFromDomTarget,
selectionApiToWordsSelection, selectionApiToWordsSelection,
@ -43,8 +44,10 @@ import {
updateWordsComposition, updateWordsComposition,
type WordsCommand, type WordsCommand,
type WordsDocument, type WordsDocument,
type WordsExportFormat,
type WordsEditorState, type WordsEditorState,
type WordsHistory, type WordsHistory,
type WordsImportFormat,
type WordsMark, type WordsMark,
type WordsSelection type WordsSelection
} from './engine'; } from './engine';
@ -166,7 +169,7 @@ export class WordsProvider {
readonly selection = $derived.by(() => this.history.present.selection); readonly selection = $derived.by(() => this.history.present.selection);
readonly activeMarks = $derived.by(() => this.history.present.activeMarks); readonly activeMarks = $derived.by(() => this.history.present.activeMarks);
readonly plainText = $derived.by(() => renderWordsPlainText(this.document)); readonly plainText = $derived.by(() => renderWordsPlainText(this.document));
readonly html = $derived.by(() => renderWordsHtml(this.document)); readonly html = $derived.by(() => renderWordsDomHtml(this.document));
readonly isDisabled = $derived.by( readonly isDisabled = $derived.by(
() => this.opts.disabled.current || (this.field?.isDisabled ?? false) () => this.opts.disabled.current || (this.field?.isDisabled ?? false)
@ -196,6 +199,12 @@ export class WordsProvider {
return this.document.children[index]?.type ?? 'paragraph'; return this.document.children[index]?.type ?? 'paragraph';
}); });
readonly currentListKind = $derived.by(() => {
const index = this.selection?.anchor.path[0] ?? 0;
const block = this.document.children[index];
return block?.type === 'list' ? block.kind : undefined;
});
readonly resolvedContentLabel: Active<string | undefined> = readableActive(() => { readonly resolvedContentLabel: Active<string | undefined> = readableActive(() => {
if (this.field?.labelId.current) return undefined; if (this.field?.labelId.current) return undefined;
return ( return (
@ -206,7 +215,7 @@ export class WordsProvider {
}); });
focus() { focus() {
this.soma.dom.focus(this.contentRef.current); this.soma.dom.focus(this.contentRef.current, { preventScroll: true });
} }
setContentRef(el: HTMLElement | null) { setContentRef(el: HTMLElement | null) {
@ -285,6 +294,15 @@ export class WordsProvider {
this.publishHistory(next); this.publishHistory(next);
} }
importContent(content: string, format: WordsImportFormat) {
this.applyCommand({ type: 'importContent', content, format });
}
exportContent(format: WordsExportFormat): string {
const result = applyWordsCommand(this.history.present, { type: 'exportContent', format });
return result.exportedContent ?? '';
}
readonly onfocus = (e: SomaFocusEvent<HTMLElement>) => { readonly onfocus = (e: SomaFocusEvent<HTMLElement>) => {
this.focused = true; this.focused = true;
this.ensureSelection(); this.ensureSelection();
@ -338,6 +356,7 @@ export class WordsProvider {
const action = actionFromBeforeInput(e); const action = actionFromBeforeInput(e);
if (action.type === 'ignore') { if (action.type === 'ignore') {
if (action.reason === 'composition') return; if (action.reason === 'composition') return;
if (action.reason === 'unsupported') return;
e.preventDefault(); e.preventDefault();
this.signalInvalidInput(action.reason, e.currentTarget); this.signalInvalidInput(action.reason, e.currentTarget);
return; return;
@ -380,8 +399,16 @@ export class WordsProvider {
this.composition = updateWordsComposition(this.composition, e.data ?? ''); this.composition = updateWordsComposition(this.composition, e.data ?? '');
}; };
readonly oncompositionend = (_e: CompositionEvent) => { readonly oncompositionend = (e: CompositionEvent) => {
const finalComposition =
e.data && e.data !== this.composition.text
? updateWordsComposition(this.composition, e.data)
: this.composition;
const action = endWordsComposition(finalComposition);
this.composition = createWordsCompositionState(); this.composition = createWordsCompositionState();
if (action.type === 'ignore') return;
this.syncSelectionFromDom();
this.applyCommand(action.command);
}; };
readonly snippetProps = $derived.by(() => ({ readonly snippetProps = $derived.by(() => ({
@ -398,6 +425,9 @@ export class WordsProvider {
canRedo: this.canRedo, canRedo: this.canRedo,
focus: () => this.focus(), focus: () => this.focus(),
runCommand: (command: WordsCommandName) => this.runCommandName(command), runCommand: (command: WordsCommandName) => this.runCommandName(command),
importContent: (content: string, format: WordsImportFormat) =>
this.importContent(content, format),
exportContent: (format: WordsExportFormat) => this.exportContent(format),
commit: (reason: WordsCommitReason = 'programmatic') => this.commit(reason), commit: (reason: WordsCommitReason = 'programmatic') => this.commit(reason),
undo: () => this.undo(), undo: () => this.undo(),
redo: () => this.redo() redo: () => this.redo()
@ -715,9 +745,11 @@ export class WordsCommandButtonProvider {
case 'quote': case 'quote':
return provider.currentBlock === 'quote'; return provider.currentBlock === 'quote';
case 'unordered-list': case 'unordered-list':
return provider.currentBlock === 'list' && provider.currentListKind === 'unordered';
case 'ordered-list': case 'ordered-list':
return provider.currentBlock === 'list' && provider.currentListKind === 'ordered';
case 'check-list': case 'check-list':
return provider.currentBlock === 'list'; return provider.currentBlock === 'list' && provider.currentListKind === 'check';
default: default:
return false; return false;
} }

@ -6,9 +6,13 @@
type PasswordFieldSize, type PasswordFieldSize,
type PasswordFieldVariant type PasswordFieldVariant
} from '$uix/eidos/components/password-field'; } from '$uix/eidos/components/password-field';
import { Form } from '$uix/eidos/components/form';
import { Field } from '$uix/eidos/components/field';
import { compileMorfo } from '$uix/morfo'; import { compileMorfo } from '$uix/morfo';
import { passwordFieldMorfo } from '@/uix/morfo/components/password-field'; import { passwordFieldMorfo } from '@/uix/morfo/components/password-field';
import { getActiveUix } from '$active-uix'; import { getActiveUix } from '$active-uix';
import { createForm } from '$libs/forms';
import { object, string, pipe, min, regex, meta, refine } from '$sium/core';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y'; type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number }; type TraceEntry = { event: string; family: string; intent?: string; at: number };
@ -77,6 +81,84 @@
} }
const strength = $derived(estimateStrength(value)); const strength = $derived(estimateStrength(value));
// ── Form + SIUM demos ─────────────────────────────────────────────
// Three real forms that wire PasswordField through Field + Form so the
// validation chain (schema → form.errors → Field.isInvalid →
// PasswordField data-invalid) is visible end-to-end. The schemas use
// the project's SIUM core (Standard Schema implementation).
let loginResult = $state('—');
let registrationResult = $state('—');
let changeResult = $state('—');
// 1) Login — minimal: username + non-empty password.
const loginSchema = object({
username: pipe(string(), min(3), meta({ label: 'Username' })),
password: pipe(string(), min(1), meta({ label: 'Password' }))
});
const loginForm = createForm({
schema: loginSchema,
defaults: { username: '', password: '' },
validationBehaviour: 'onBlur',
onValidSubmit: (v) => {
loginResult = JSON.stringify(v, null, 2);
}
});
// 2) Registration — `purpose="new"` + complexity rules in the schema.
// The PasswordField's strength meter is a visual companion; the schema
// is what blocks submission until the password matches the policy.
const registrationSchema = object({
email: pipe(string(), regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/), meta({ label: 'Email' })),
password: pipe(
string(),
min(8),
regex(/[A-Z]/),
regex(/\d/),
regex(/[^\w\s]/),
meta({ label: 'Password' })
)
});
const registrationForm = createForm({
schema: registrationSchema,
defaults: { email: '', password: '' },
validationBehaviour: 'onChange',
onValidSubmit: (v) => {
registrationResult = JSON.stringify(v, null, 2);
}
});
// 3) Password change — three fields with a cross-field refine() guard
// (new password must equal the confirmation). `refine()` runs after the
// object schema; the error lands on the `confirm` path via opts.
const passwordChangeBaseSchema = object({
current: pipe(string(), min(1), meta({ label: 'Current password' })),
next: pipe(string(), min(8), meta({ label: 'New password' })),
confirm: pipe(string(), min(1), meta({ label: 'Confirm new password' }))
});
// Cross-field refine — the error lands at the schema root.
// In a real app, structure the schema differently or display the message
// via `<Form.ErrorSummary>` to surface root-level errors.
const passwordChangeSchema = pipe(
passwordChangeBaseSchema,
refine(
(v) => v.next === v.confirm,
{ code: 'custom', message: "Passwords don't match" }
)
);
const changeForm = createForm({
schema: passwordChangeSchema,
defaults: { current: '', next: '', confirm: '' },
validationBehaviour: 'onBlur',
onValidSubmit: (v) => {
changeResult = JSON.stringify(v, null, 2);
}
});
const registrationStrength = $derived(
estimateStrength(String(registrationForm.values.password ?? ''))
);
$effect(() => { $effect(() => {
const el = stageRef; const el = stageRef;
if (!el) return; if (!el) return;
@ -426,6 +508,173 @@
<pre data-uix-snippet><code>{lastSubmit}</code></pre> <pre data-uix-snippet><code>{lastSubmit}</code></pre>
{/if} {/if}
<!-- ── Form + SIUM integration ─────────────────────────────────── -->
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> integration · Form + SIUM (Standard Schema)
</div>
<p data-uix-section-desc>
PasswordField has no built-in complexity rules — validation is schema-first via the
project's SIUM core (any Standard Schema implementor: SIUM, Zod, Valibot, ArkType…).
Wrap in <code>&lt;Field.Provider name="…"&gt;</code> inside a
<code>&lt;Form.Provider&gt;</code>; the form runs the schema and propagates
<code>form.errors</code> into <code>Field.isInvalid</code>, which OR-merges into
<code>PasswordField.invalid</code> (red border + <code>aria-invalid</code>). The
<code>Field.ErrorText</code> renders the first error message.
</p>
<div
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: var(--uix-space-4); margin-top: var(--uix-space-3);"
>
<!-- ── Login form ────────────────────────────────────────────── -->
<div data-uix-snippet style="padding: var(--uix-space-4);">
<h3 style="margin: 0 0 var(--uix-space-3); font-size: var(--uix-text-sm);">
Login form · <code>purpose="current"</code>
</h3>
<Form form={loginForm}>
<div style="display: flex; flex-direction: column; gap: var(--uix-space-3);">
<Field name="username" label="Username" required>
<Field.Label>Username</Field.Label>
<Field.Control>
<input
type="text"
autocomplete="username"
bind:value={loginForm.values.username}
/>
</Field.Control>
<Field.ErrorText>{loginForm.errors.username?.[0]}</Field.ErrorText>
</Field>
<Field name="password" label="Password" required>
<Field.Label>Password</Field.Label>
<Field.Control>
<PasswordField bind:value={loginForm.values.password} purpose="current" name="password">
<PasswordField.Input />
<PasswordField.CapsLockIndicator />
<PasswordField.VisibilityTrigger />
</PasswordField>
</Field.Control>
<Field.ErrorText>{loginForm.errors.password?.[0]}</Field.ErrorText>
</Field>
<Form.Submit>Sign in</Form.Submit>
{#if loginResult !== '—'}
<pre style="margin: 0; font-size: var(--uix-text-xs);"><code
>{loginResult}</code
></pre>
{/if}
</div>
</Form>
</div>
<!-- ── Registration form ─────────────────────────────────────── -->
<div data-uix-snippet style="padding: var(--uix-space-4);">
<h3 style="margin: 0 0 var(--uix-space-3); font-size: var(--uix-text-sm);">
Registration · <code>purpose="new"</code> + complexity
</h3>
<Form form={registrationForm}>
<div style="display: flex; flex-direction: column; gap: var(--uix-space-3);">
<Field name="email" label="Email" required>
<Field.Label>Email</Field.Label>
<Field.Control>
<input
type="email"
autocomplete="email"
bind:value={registrationForm.values.email}
/>
</Field.Control>
<Field.ErrorText>{registrationForm.errors.email?.[0]}</Field.ErrorText>
</Field>
<Field name="password" label="Password" required>
<Field.Label>Password</Field.Label>
<Field.Control>
<PasswordField
bind:value={registrationForm.values.password}
purpose="new"
name="password"
>
<PasswordField.Input />
<PasswordField.CapsLockIndicator />
<PasswordField.VisibilityTrigger />
<PasswordField.StrengthMeter strength={registrationStrength} />
</PasswordField>
</Field.Control>
<Field.ErrorText>{registrationForm.errors.password?.[0]}</Field.ErrorText>
</Field>
<Form.Submit>Create account</Form.Submit>
{#if registrationResult !== '—'}
<pre style="margin: 0; font-size: var(--uix-text-xs);"><code
>{registrationResult}</code
></pre>
{/if}
</div>
</Form>
</div>
<!-- ── Password change form ──────────────────────────────────── -->
<div data-uix-snippet style="padding: var(--uix-space-4);">
<h3 style="margin: 0 0 var(--uix-space-3); font-size: var(--uix-text-sm);">
Change · cross-field <code>refine()</code>
</h3>
<Form form={changeForm}>
<div style="display: flex; flex-direction: column; gap: var(--uix-space-3);">
<Field name="current" label="Current password" required>
<Field.Label>Current password</Field.Label>
<Field.Control>
<PasswordField
bind:value={changeForm.values.current}
purpose="current"
name="current-password"
>
<PasswordField.Input />
<PasswordField.VisibilityTrigger />
</PasswordField>
</Field.Control>
<Field.ErrorText>{changeForm.errors.current?.[0]}</Field.ErrorText>
</Field>
<Field name="next" label="New password" required>
<Field.Label>New password</Field.Label>
<Field.Control>
<PasswordField
bind:value={changeForm.values.next}
purpose="new"
name="new-password"
>
<PasswordField.Input />
<PasswordField.VisibilityTrigger />
</PasswordField>
</Field.Control>
<Field.ErrorText>{changeForm.errors.next?.[0]}</Field.ErrorText>
</Field>
<Field name="confirm" label="Confirm new password" required>
<Field.Label>Confirm</Field.Label>
<Field.Control>
<PasswordField
bind:value={changeForm.values.confirm}
purpose="new"
name="confirm-password"
>
<PasswordField.Input />
<PasswordField.VisibilityTrigger />
</PasswordField>
</Field.Control>
<Field.ErrorText>{changeForm.errors.confirm?.[0]}</Field.ErrorText>
</Field>
<Form.Submit>Update password</Form.Submit>
{#if changeResult !== '—'}
<pre style="margin: 0; font-size: var(--uix-text-xs);"><code
>{changeResult}</code
></pre>
{/if}
</div>
</Form>
</div>
</div>
<!-- ── Code snippets per layer ─────────────────────────────────── --> <!-- ── Code snippets per layer ─────────────────────────────────── -->
<div data-uix-code> <div data-uix-code>
<div data-uix-code-head> <div data-uix-code-head>

Loading…
Cancel
Save

Powered by TurnKey Linux.