diff --git a/src/uix/eidos/components/password-field/password-field.css b/src/uix/eidos/components/password-field/password-field.css index 20c68175a..91444d483 100644 --- a/src/uix/eidos/components/password-field/password-field.css +++ b/src/uix/eidos/components/password-field/password-field.css @@ -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] { - --_password-field-px: var(--space-3); - --_password-field-py: var(--space-2); - --_password-field-gap: var(--space-2); - --_password-field-font-size: var(--font-size-md); - --_password-field-line-height: var(--leading-normal); - --_password-field-radius: var(--radius-md); - --_password-field-border: var(--color-neutral-border); - --_password-field-border-hover: var(--color-neutral-border-strong); - --_password-field-border-focus: var(--color-primary-border-strong); - --_password-field-bg: var(--color-surface); - --_password-field-color: var(--color-content); - --_password-field-muted: var(--color-content-muted); + --_password-field-height: var(--password-field-height-md); + --_password-field-px: var(--password-field-px-md); + --_password-field-gap: var(--password-field-gap-md); + --_password-field-font-size: var(--password-field-font-size-md); + --_password-field-trigger-size: var(--password-field-icon-size-md); + --_password-field-caps-font-size: var(--password-field-caps-font-size-md); + --_password-field-accent-border: var(--color-primary-border); + --_password-field-accent-track: var(--color-primary-track); + --_password-field-accent-text: var(--color-primary-text); display: inline-flex; flex-wrap: wrap; - align-items: stretch; + align-items: center; gap: var(--_password-field-gap); inline-size: 100%; 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'] { - --_password-field-px: var(--space-2); - --_password-field-py: var(--space-1); - --_password-field-font-size: var(--font-size-xs); - --_password-field-radius: var(--radius-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); +/* When the StrengthMeter is composed, it claims a second row and the + * height auto-grows; otherwise the single-row chrome rules. */ +[data-password-field]:has([data-password-field-strength-meter]) { + block-size: auto; + min-block-size: var(--_password-field-height); + padding-block: var(--password-field-gap-sm); } -/* Color accents on focus + invalid. */ +/* ── Color accents ─────────────────────────────────────────────────── */ + [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'] { - --_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'] { - --_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'] { - --_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'] { - --_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'] { - --_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'] { - --_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] { - border: var(--border-width) solid var(--_password-field-border); - border-radius: var(--_password-field-radius); - background: var(--_password-field-bg); - padding-inline: var(--_password-field-px); - transition: - border-color var(--duration-fast) var(--ease-default), - box-shadow var(--duration-fast) var(--ease-default); +[data-password-field][data-size='xs'] { + --_password-field-height: var(--password-field-height-xs); + --_password-field-px: var(--password-field-px-xs); + --_password-field-gap: var(--password-field-gap-xs); + --_password-field-font-size: var(--password-field-font-size-xs); + --_password-field-trigger-size: var(--password-field-icon-size-xs); + --_password-field-caps-font-size: var(--password-field-caps-font-size-xs); +} +[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'] { background: transparent; } + [data-password-field][data-variant='ghost'] { - background: transparent; border-color: transparent; + background: transparent; + padding-inline: 0; } -[data-password-field]:hover:not([data-disabled]):not([data-readonly]) { - border-color: var(--_password-field-border-hover); -} +/* ── State chrome ──────────────────────────────────────────────────── */ [data-password-field][data-focused] { - border-color: var(--_password-field-border-focus); - box-shadow: 0 0 0 var(--ring-width, 2px) - color-mix(in srgb, var(--_password-field-border-focus) 25%, transparent); + border-color: var(--password-field-border-focus); + box-shadow: var(--password-field-focus-shadow); } [data-password-field][data-invalid] { - border-color: var(--color-threat-border-strong); + border-color: var(--password-field-border-invalid); } [data-password-field][data-disabled] { - opacity: var(--opacity-disabled, 0.6); + opacity: var(--password-field-disabled-opacity); } +/* ── Input ─────────────────────────────────────────────────────────── */ + [data-password-field-input] { flex: 1 1 auto; min-inline-size: 0; - padding-block: var(--_password-field-py); border: 0; background: transparent; - color: var(--_password-field-color); - font-family: var(--font-family-body); - font-size: var(--_password-field-font-size); - line-height: var(--_password-field-line-height); + color: inherit; + font: inherit; outline: none; - letter-spacing: 0.05em; + /* Slightly wider tracking helps recognise the · placeholder mask. */ + letter-spacing: 0.02em; } [data-password-field-input]::placeholder { - color: var(--_password-field-muted); + color: var(--password-field-placeholder-color); letter-spacing: 0; } @@ -138,38 +177,41 @@ display: inline-flex; align-items: center; justify-content: center; - inline-size: calc(var(--_password-field-font-size) * 1.5); - block-size: calc(var(--_password-field-font-size) * 1.5); + inline-size: var(--_password-field-trigger-size); + block-size: var(--_password-field-trigger-size); margin-block: auto; padding: 0; - border: 0; - border-radius: var(--radius-sm); - background: transparent; - color: var(--_password-field-muted); + border: var(--password-field-trigger-border-width) solid var(--password-field-trigger-border); + border-radius: var(--password-field-trigger-radius); + background: var(--password-field-trigger-bg); + color: var(--password-field-trigger-color); cursor: pointer; transition: - color var(--duration-fast) var(--ease-default), - background var(--duration-fast) var(--ease-default); + background var(--password-field-transition-duration) var(--password-field-transition-ease), + 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 { - color: var(--_password-field-color); - background: color-mix(in srgb, var(--_password-field-border) 30%, transparent); +[data-password-field-visibility-trigger]:hover:not([data-disabled]) { + background: var(--password-field-trigger-bg-hover); + border-color: var(--password-field-trigger-border-hover); + color: var(--password-field-trigger-color-hover); } [data-password-field-visibility-trigger]:focus-visible { - outline: var(--ring-width, 2px) solid var(--_password-field-border-focus); - outline-offset: 1px; + outline: none; + box-shadow: var(--password-field-trigger-focus-shadow); } [data-password-field-visibility-trigger][data-disabled] { - opacity: 0.5; + opacity: var(--password-field-disabled-opacity); cursor: not-allowed; } [data-password-field-visibility-trigger] svg { - inline-size: calc(var(--_password-field-font-size) * 1.1); - block-size: calc(var(--_password-field-font-size) * 1.1); + inline-size: calc(var(--_password-field-trigger-size) - 8px); + block-size: calc(var(--_password-field-trigger-size) - 8px); } /* ── CapsLockIndicator ─────────────────────────────────────────────── */ @@ -183,20 +225,20 @@ padding-inline: 0.4em; padding-block: 0.2em; border-radius: var(--radius-sm); - font-size: calc(var(--_password-field-font-size) * 0.75); - font-weight: 600; + font-size: var(--_password-field-caps-font-size); + font-weight: var(--font-weight-medium, 500); color: transparent; background: transparent; transition: - color var(--duration-fast) var(--ease-default), - background var(--duration-fast) var(--ease-default); + color var(--password-field-transition-duration) var(--password-field-transition-ease), + background var(--password-field-transition-duration) var(--password-field-transition-ease); pointer-events: none; user-select: none; } [data-password-field-caps-lock-indicator][data-active] { - color: var(--color-risk-text); - background: color-mix(in srgb, var(--color-risk-track) 50%, transparent); + color: var(--password-field-caps-color); + background: var(--password-field-caps-bg); } [data-password-field-caps-lock-glyph] { @@ -204,7 +246,7 @@ line-height: 1; } -/* ── StrengthMeter ─────────────────────────────────────────────────── */ +/* ── StrengthMeter (composable; layout below the control row) ─────── */ [data-password-field-strength-meter] { flex: 1 1 100%; @@ -212,38 +254,42 @@ flex-direction: column; gap: 0.25em; margin-top: var(--space-1); - padding-inline: 0; } [data-password-field-strength-meter-track] { display: grid; grid-template-columns: repeat(4, 1fr); - gap: 4px; + gap: var(--password-field-strength-segment-gap); } [data-password-field-strength-meter-segment] { - block-size: 4px; - border-radius: var(--radius-sm); - background: var(--color-neutral-track); - transition: background var(--duration-fast) var(--ease-default); + block-size: var(--password-field-strength-segment-height); + border-radius: var(--password-field-strength-radius); + background: var(--password-field-strength-segment-bg); + 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)); } -[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); } -[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); } -[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); } [data-password-field-strength-meter-label] { - font-size: calc(var(--_password-field-font-size) * 0.75); - color: var(--_password-field-muted); + font-size: calc(var(--_password-field-font-size) * 0.85); + color: var(--password-field-placeholder-color); font-variant-numeric: tabular-nums; } diff --git a/src/uix/eidos/components/words/README.md b/src/uix/eidos/components/words/README.md index 651800b8c..0eb26239c 100644 --- a/src/uix/eidos/components/words/README.md +++ b/src/uix/eidos/components/words/README.md @@ -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 | | Placeholder | Input hint pattern | Plugin/placeholder | Placeholder extension | `Words.Placeholder` | 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 | | 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. - 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. - Demo scope signed by user on 2026-05-24 via "continua" after the Eidos/demo slice was proposed. diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index cffa05360..5b77f3444 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -1555,6 +1555,66 @@ --search-field-transition-duration: var(--duration-fast); --search-field-transition-ease: var(--ease-default); --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-font-family: var(--font-ui); --select-font-size-xs: var(--font-size-xs); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index acc3c85c1..e55f7397b 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -1310,6 +1310,70 @@ export const THEME_BASE_RECIPE_TOKENS = { 'transition-ease': 'var(--ease-default)', '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: { 'stack-gap': 'var(--space-2)', 'font-family': 'var(--font-ui)', diff --git a/src/uix/soma/components/words/README.md b/src/uix/soma/components/words/README.md index 53119540e..1373afa52 100644 --- a/src/uix/soma/components/words/README.md +++ b/src/uix/soma/components/words/README.md @@ -15,7 +15,7 @@ empezar hasta cerrar contrato Morfo y alcance V1. `contenteditable`. - Slice 6 cierra wrapper Eidos, receta visual, pack Sema y demo UIX de 6 tabs. - 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 @@ -132,6 +132,8 @@ type WordsMark = 'bold' | 'italic' | 'underline' | 'strike' | 'code'; | `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/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. | ## 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. - Paste HTML sin texto plano se rechaza y se reporta por el logger UIX como `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 @@ -196,7 +210,7 @@ type WordsMark = 'bold' | 'italic' | 'underline' | 'strike' | 'code'; - Agrupar input/composition en history batches semanticos, no un snapshot por 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. - Bubble toolbar y popover de links como partes Soma reales si entran en V1. - Cobertura de componentes Svelte renderizados, no solo providers. diff --git a/src/uix/soma/components/words/engine/commands.ts b/src/uix/soma/components/words/engine/commands.ts index c0e06b60b..5702cd876 100644 --- a/src/uix/soma/components/words/engine/commands.ts +++ b/src/uix/soma/components/words/engine/commands.ts @@ -20,6 +20,13 @@ import { type WordsEditorState, type WordsOperationResult } 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 = | { type: 'insertText'; text: string } @@ -38,7 +45,9 @@ export type WordsCommand = | { type: 'toggleList'; kind: WordsListKind } | { type: 'insertLink'; href: string; title?: string } | { type: 'unlink' } - | { type: 'replaceDocument'; document: unknown }; + | { type: 'replaceDocument'; document: unknown } + | { type: 'importContent'; content: string; format: WordsImportFormat } + | { type: 'exportContent'; format: WordsExportFormat }; export function applyWordsCommand( state: WordsEditorState, @@ -79,5 +88,37 @@ export function applyWordsCommand( return unlink(state); case 'replaceDocument': 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 }; +} diff --git a/src/uix/soma/components/words/engine/document.ts b/src/uix/soma/components/words/engine/document.ts index 76a4468c4..f97f9968e 100644 --- a/src/uix/soma/components/words/engine/document.ts +++ b/src/uix/soma/components/words/engine/document.ts @@ -173,6 +173,12 @@ export function getBlockText(block: WordsBlock): string { } export function getInlineText(inline: WordsInline): string { + return getInlineTextSafe(inline, new WeakSet()); +} + +function getInlineTextSafe(inline: WordsInline, seen: WeakSet): string { + if (seen.has(inline)) return ''; + seen.add(inline); if (inline.type === 'text') return inline.text; - return inline.children.map(getInlineText).join(''); + return inline.children.map((child) => getInlineTextSafe(child, seen)).join(''); } diff --git a/src/uix/soma/components/words/engine/engine.test.ts b/src/uix/soma/components/words/engine/engine.test.ts index 533a59ea2..25dfa06e4 100644 --- a/src/uix/soma/components/words/engine/engine.test.ts +++ b/src/uix/soma/components/words/engine/engine.test.ts @@ -29,11 +29,14 @@ import { encodeWordsPath, normalizeDocument, parseWordsJson, + parseWordsPlainText, redoWords, + renderWordsDomHtml, renderWordsDocument, resolveDomPointFromTarget, restoreSelectionApiFromDomTarget, serializeWordsJson, + serializeWordsHtml, selectionApiToWordsSelection, updateWordsComposition, 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', () => { const doc: WordsDocument = { 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', () => { const initial = stateWithText('A'); 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', () => { let state = createWordsState( { @@ -408,6 +530,71 @@ describe('Words engine', () => { 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( + '

ADocs

' + ); + 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('

Alpha

Beta

'); + + 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', () => { const doc: WordsDocument = { version: 1, diff --git a/src/uix/soma/components/words/engine/index.ts b/src/uix/soma/components/words/engine/index.ts index 0a05da3bb..0535b0077 100644 --- a/src/uix/soma/components/words/engine/index.ts +++ b/src/uix/soma/components/words/engine/index.ts @@ -9,4 +9,6 @@ export * from './operations'; export * from './path'; export * from './render'; export * from './selection'; +export * from './serialize-html'; export * from './serialize-json'; +export * from './serialize-text'; diff --git a/src/uix/soma/components/words/engine/operations.ts b/src/uix/soma/components/words/engine/operations.ts index d78168237..b7dc4f01c 100644 --- a/src/uix/soma/components/words/engine/operations.ts +++ b/src/uix/soma/components/words/engine/operations.ts @@ -52,6 +52,7 @@ export interface WordsEditorState { export interface WordsOperationResult { readonly state: WordsEditorState; readonly changed: boolean; + readonly exportedContent?: string; } interface DocumentRangeResult { diff --git a/src/uix/soma/components/words/engine/render.ts b/src/uix/soma/components/words/engine/render.ts index be6db7bce..aa5c1dbe3 100644 --- a/src/uix/soma/components/words/engine/render.ts +++ b/src/uix/soma/components/words/engine/render.ts @@ -152,10 +152,12 @@ export function isRenderedEmptyText(node: WordsRenderElement): boolean { 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(''); } +export const renderWordsHtml = renderWordsDomHtml; + function renderWordsNodeHtml(node: WordsRenderNode): string { if (node.kind === 'text') return escapeHtml(node.text); const attrs = Object.entries(node.attrs) diff --git a/src/uix/soma/components/words/engine/serialize-html.ts b/src/uix/soma/components/words/engine/serialize-html.ts new file mode 100644 index 000000000..479b5bd92 --- /dev/null +++ b/src/uix/soma/components/words/engine/serialize-html.ts @@ -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; + +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' + ? ` ` + : ''; + return `
  • ${checkbox}${serializeInlinesHtml(item.children)}
  • `; + }) + .join(''); + return `<${tag}>${children}`; + } + + if (block.type === 'heading') { + return `${serializeInlinesHtml(block.children)}`; + } + + if (block.type === 'quote') { + return `
    ${serializeInlinesHtml(block.children)}
    `; + } + + return `

    ${serializeInlinesHtml(block.children)}

    `; +} + +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 `${serializeInlinesHtml(inline.children)}`; + } + + 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}`; + }, value); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function escapeHtmlAttr(value: string): string { + return escapeHtml(value).replace(/`/g, '`'); +} diff --git a/src/uix/soma/components/words/engine/serialize-text.ts b/src/uix/soma/components/words/engine/serialize-text.ts new file mode 100644 index 000000000..ed96abe1b --- /dev/null +++ b/src/uix/soma/components/words/engine/serialize-text.ts @@ -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)])) + }; +} diff --git a/src/uix/soma/components/words/types.ts b/src/uix/soma/components/words/types.ts index 1e9b899e3..522ab6ed4 100644 --- a/src/uix/soma/components/words/types.ts +++ b/src/uix/soma/components/words/types.ts @@ -8,7 +8,9 @@ import type { } from '../../types'; import type { WordsDocument, + WordsExportFormat, WordsHeadingLevel, + WordsImportFormat, WordsListKind, WordsMark, WordsSelection @@ -51,6 +53,8 @@ export type WordsProviderSnippetProps = { readonly canRedo: boolean; readonly focus: () => void; readonly runCommand: (command: WordsCommandName) => void; + readonly importContent: (content: string, format: WordsImportFormat) => void; + readonly exportContent: (format: WordsExportFormat) => string; readonly commit: (reason?: WordsCommitReason) => void; readonly undo: () => void; readonly redo: () => void; diff --git a/src/uix/soma/components/words/words-provider.svelte.test.ts b/src/uix/soma/components/words/words-provider.svelte.test.ts index 195f0ad55..fc644bd14 100644 --- a/src/uix/soma/components/words/words-provider.svelte.test.ts +++ b/src/uix/soma/components/words/words-provider.svelte.test.ts @@ -19,6 +19,8 @@ import { import type { WordsCommandName } from './types'; import { createEmptyWordsDocument, + createList, + createListItem, createParagraph, createText, renderWordsPlainText, @@ -205,6 +207,58 @@ describe('WordsProvider', () => { 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(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('

    Alpha

    Beta

    '); + expect(result.provider.snippetProps.exportContent('html')).not.toContain('data-words'); + + cleanup(); + dom.dispose(); + }); + it('drives command buttons from active marks and history state', () => { const { dom } = installSomaHarness(); const root = document.createElement('div'); @@ -239,6 +293,57 @@ describe('WordsProvider', () => { 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(unorderedEl), + command: state('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(orderedEl), + command: state('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(checkEl), + command: state('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', () => { const { dom, logger } = installSomaHarness(); const root = document.createElement('div'); @@ -274,4 +379,38 @@ describe('WordsProvider', () => { cleanup(); 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(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(); + }); }); diff --git a/src/uix/soma/components/words/words-provider.svelte.ts b/src/uix/soma/components/words/words-provider.svelte.ts index 0fcf0a93f..45309f21e 100644 --- a/src/uix/soma/components/words/words-provider.svelte.ts +++ b/src/uix/soma/components/words/words-provider.svelte.ts @@ -23,6 +23,7 @@ import { actionFromBeforeInput, actionFromPaste, applyHistoryCommand, + applyWordsCommand, beginWordsComposition, createEmptyWordsDocument, createWordsCompositionState, @@ -33,7 +34,7 @@ import { getTextEntries, isCollapsedSelection, redoWords, - renderWordsHtml, + renderWordsDomHtml, renderWordsPlainText, restoreSelectionApiFromDomTarget, selectionApiToWordsSelection, @@ -43,8 +44,10 @@ import { updateWordsComposition, type WordsCommand, type WordsDocument, + type WordsExportFormat, type WordsEditorState, type WordsHistory, + type WordsImportFormat, type WordsMark, type WordsSelection } from './engine'; @@ -166,7 +169,7 @@ export class WordsProvider { readonly selection = $derived.by(() => this.history.present.selection); readonly activeMarks = $derived.by(() => this.history.present.activeMarks); 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( () => this.opts.disabled.current || (this.field?.isDisabled ?? false) @@ -196,6 +199,12 @@ export class WordsProvider { 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 = readableActive(() => { if (this.field?.labelId.current) return undefined; return ( @@ -206,7 +215,7 @@ export class WordsProvider { }); focus() { - this.soma.dom.focus(this.contentRef.current); + this.soma.dom.focus(this.contentRef.current, { preventScroll: true }); } setContentRef(el: HTMLElement | null) { @@ -285,6 +294,15 @@ export class WordsProvider { 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) => { this.focused = true; this.ensureSelection(); @@ -338,6 +356,7 @@ export class WordsProvider { const action = actionFromBeforeInput(e); if (action.type === 'ignore') { if (action.reason === 'composition') return; + if (action.reason === 'unsupported') return; e.preventDefault(); this.signalInvalidInput(action.reason, e.currentTarget); return; @@ -380,8 +399,16 @@ export class WordsProvider { 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(); + if (action.type === 'ignore') return; + this.syncSelectionFromDom(); + this.applyCommand(action.command); }; readonly snippetProps = $derived.by(() => ({ @@ -398,6 +425,9 @@ export class WordsProvider { canRedo: this.canRedo, focus: () => this.focus(), 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), undo: () => this.undo(), redo: () => this.redo() @@ -715,9 +745,11 @@ export class WordsCommandButtonProvider { case 'quote': return provider.currentBlock === 'quote'; case 'unordered-list': + return provider.currentBlock === 'list' && provider.currentListKind === 'unordered'; case 'ordered-list': + return provider.currentBlock === 'list' && provider.currentListKind === 'ordered'; case 'check-list': - return provider.currentBlock === 'list'; + return provider.currentBlock === 'list' && provider.currentListKind === 'check'; default: return false; } diff --git a/web/routes/uix/components/password-field/+page.svelte b/web/routes/uix/components/password-field/+page.svelte index b27bc18b6..62e4a3487 100644 --- a/web/routes/uix/components/password-field/+page.svelte +++ b/web/routes/uix/components/password-field/+page.svelte @@ -6,9 +6,13 @@ type PasswordFieldSize, type PasswordFieldVariant } 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 { passwordFieldMorfo } from '@/uix/morfo/components/password-field'; 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 TraceEntry = { event: string; family: string; intent?: string; at: number }; @@ -77,6 +81,84 @@ } 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 `` 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(() => { const el = stageRef; if (!el) return; @@ -426,6 +508,173 @@
    {lastSubmit}
    {/if} + +
    + soma integration · Form + SIUM (Standard Schema) +
    +

    + 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 <Field.Provider name="…"> inside a + <Form.Provider>; the form runs the schema and propagates + form.errors into Field.isInvalid, which OR-merges into + PasswordField.invalid (red border + aria-invalid). The + Field.ErrorText renders the first error message. +

    + +
    + +
    +

    + Login form · purpose="current" +

    +
    +
    + + Username + + + + {loginForm.errors.username?.[0]} + + + + Password + + + + + + + + {loginForm.errors.password?.[0]} + + + Sign in + {#if loginResult !== '—'} +
    {loginResult}
    + {/if} +
    +
    +
    + + +
    +

    + Registration · purpose="new" + complexity +

    +
    +
    + + Email + + + + {registrationForm.errors.email?.[0]} + + + + Password + + + + + + + + + {registrationForm.errors.password?.[0]} + + + Create account + {#if registrationResult !== '—'} +
    {registrationResult}
    + {/if} +
    +
    +
    + + +
    +

    + Change · cross-field refine() +

    +
    +
    + + Current password + + + + + + + {changeForm.errors.current?.[0]} + + + + New password + + + + + + + {changeForm.errors.next?.[0]} + + + + Confirm + + + + + + + {changeForm.errors.confirm?.[0]} + + + Update password + {#if changeResult !== '—'} +
    {changeResult}
    + {/if} +
    +
    +
    +
    +