From bb56e948645dfad17c764b52bbb96297b31879f1 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 24 May 2026 13:49:11 +0200 Subject: [PATCH] fix(password-field): chrome layout + meter visibility + form field styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three real visual bugs caught after browser verification: 1. **No visible border around input + meter row.** The CSS used `:has([data-password-field-strength-meter])::before` to lift the border onto a pseudo-element that spans grid row 1. The pseudo collapsed to 2px tall because it had no `align-self: stretch` and no explicit `min-block-size`. Added both — the pseudo now fills its grid cell at the full `--_password-field-height`. Plus the same `data-focused` / `data-invalid` state propagation already in place. 2. **Strength meter segments invisible.** Default `--password-field-strength-segment-bg` resolved to `var(--color-neutral-track)` which is `rgb(17,17,17)` — identical to the surface in dark mode. Changed to `var(--color-border-default)` so inactive segments read as visible grey rails. Also bumped segment height from 4 → 6 px so the bar is clearly readable. 3. **Eye icon too small inside a thin trigger.** `trigger-size` resolved to `--icon-size-md` (≈18 px) which made the touch target too small and the SVG cramped. Promoted trigger-size to use `--control-height-{xs..md}` so it matches the input row height per size. SVG sizing switched from `calc(size - 8px)` (which clipped at small sizes) to `65%` of trigger. 4. **Stage wrapper collapsed to content width.** The inner `
` lacked `inline-size: 100%`, so inside the stage's `display: grid` it shrank to its content. Added the explicit `inline-size: 100%`. 5. **Username/Email rendered as native unstyled inputs** in the Form/SIUM demo because they wrapped raw `` instead of `` (Field.Control only styles its dedicated Input subpart, not arbitrary children). Swapped to ``. 6. **SIUM error refs (`#?sium.errors.min_length|...`) rendered literal.** `Field.ErrorText` just renders the string it receives. Added a local `issueText(form, field)` helper that calls `uix.langs.ts(raw)` to resolve the langref and then interpolates `{{param}}` from `form.issues[field][0].params`. Now errors render as proper Spanish/English text from the registered siumLangs. Verified visually via Claude_Preview: input row has a clean border with the eye icon at the right edge, strength meter sits below with 4 visible segments + label, all three form columns render with consistent chrome. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../password-field/password-field.css | 7 +- src/uix/eidos/components/words/README.md | 29 +-- src/uix/eidos/components/words/types.ts | 5 +- src/uix/eidos/components/words/words.svelte | 5 +- src/uix/eidos/generated/base.css | 14 +- src/uix/eidos/lib/recipes/base.ts | 14 +- src/uix/soma/components/words/README.md | 3 + .../components/password-field/+page.svelte | 42 ++-- web/routes/uix/components/words/+page.svelte | 179 ++++++++---------- 9 files changed, 150 insertions(+), 148 deletions(-) diff --git a/src/uix/eidos/components/password-field/password-field.css b/src/uix/eidos/components/password-field/password-field.css index 1fccd0986..9ab5aeb48 100644 --- a/src/uix/eidos/components/password-field/password-field.css +++ b/src/uix/eidos/components/password-field/password-field.css @@ -67,6 +67,9 @@ content: ''; grid-column: 1 / -1; grid-row: 1 / 2; + align-self: stretch; + justify-self: stretch; + min-block-size: var(--_password-field-height); border: var(--password-field-border-width) solid var(--password-field-border); border-radius: var(--password-field-radius); background: var(--password-field-bg); @@ -271,8 +274,8 @@ } [data-password-field-visibility-trigger] svg { - inline-size: calc(var(--_password-field-trigger-size) - 8px); - block-size: calc(var(--_password-field-trigger-size) - 8px); + inline-size: 65%; + block-size: 65%; } /* ── CapsLockIndicator ─────────────────────────────────────────────── */ diff --git a/src/uix/eidos/components/words/README.md b/src/uix/eidos/components/words/README.md index 0eb26239c..6e7b6ce11 100644 --- a/src/uix/eidos/components/words/README.md +++ b/src/uix/eidos/components/words/README.md @@ -14,17 +14,17 @@ rich-text ni wrapper visual reutilizable. #### Feature parity matrix -| Feature | React Aria / WAI-ARIA | Lexical / svelte-lexical | Edra / Tiptap | UIX V1 | Decision | -| ------------------------ | ----------------------------------- | ------------------------ | --------------------- | ----------------------------- | --------- | -| Multiline editable | Textbox multiline + toolbar pattern | ContentEditable root | Editor view | `Words.Content` role textbox | implement | -| Document state | Consumer-owned value | EditorState | ProseMirror doc | JSON canonico `WordsDocument` | implement | -| Toolbar commands | Native buttons with labels | Commands/plugins | Command chain | `Words.CommandButton` | 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 | -| Form serialization | Native input when needed | external | external | `Words.HiddenInput` JSON | implement | -| 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 | +| Feature | React Aria / WAI-ARIA | Lexical / svelte-lexical | Edra / Tiptap | UIX V1 | Decision | +| ------------------------ | ----------------------------------- | ------------------------ | --------------------- | ---------------------------------------------- | --------- | +| Multiline editable | Textbox multiline + toolbar pattern | ContentEditable root | Editor view | `Words.Content` role textbox | implement | +| Document state | Consumer-owned value | EditorState | ProseMirror doc | JSON canonico `WordsDocument` | implement | +| Toolbar commands | Native buttons with labels | Commands/plugins | Command chain | `Words.CommandButton` + root `toolbar` presets | 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 | +| Form serialization | Native input when needed | external | external | `Words.HiddenInput` JSON | implement | +| 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 | #### Architectural choices @@ -32,7 +32,9 @@ rich-text ni wrapper visual reutilizable. selection, input, history and commands; Sema maps high-level events; Eidos owns only wrappers, `size`/`variant`/`color` attrs and CSS. - Composition: one compound root `` with explicit children - ``, ``, ``, etc. + ``, ``, ``, etc. The root + also offers a `toolbar` preset/config prop as a convenience renderer; manual + composition remains the full custom path. - Sizes covered: `sm`, `md`, `lg` as composite panel sizes. - Variants: full `ControlVariant`: `surface`, `outline`, `ghost`. - Color intent palette: full `ColorRole`: `primary`, `secondary`, `neutral`, @@ -76,6 +78,9 @@ rich-text ni wrapper visual reutilizable. - `WordsVariant = ControlVariant`. - `WordsColor = ColorRole`, no paleta truncada. - Los wrappers no exportan `Provider`; la API publica es compound. +- `toolbar` en el root acepta `false`, `true`, `minimal`, `formatting`, `full` + o grupos de comandos. Es un renderer de conveniencia; no reemplaza la + composicion manual de ``. - `words.css` usa solo tokens publicos `--words-*` declarados en `THEME_BASE_RECIPE_TOKENS.words`. - Las marcas inline se pintan desde `data-words-marks`, emitido por el motor diff --git a/src/uix/eidos/components/words/types.ts b/src/uix/eidos/components/words/types.ts index 1a7016f2a..0f648e1fc 100644 --- a/src/uix/eidos/components/words/types.ts +++ b/src/uix/eidos/components/words/types.ts @@ -29,10 +29,7 @@ export type WordsToolbarCommandConfig = listKind?: WordsListKind; }; export type WordsToolbarGroupConfig = readonly WordsToolbarCommandConfig[]; -export type WordsToolbarConfig = - | boolean - | WordsToolbarPreset - | readonly WordsToolbarGroupConfig[]; +export type WordsToolbarConfig = boolean | WordsToolbarPreset | readonly WordsToolbarGroupConfig[]; export type WordsProps = ProviderProps & { /** Composite editor density. @default 'md' */ diff --git a/src/uix/eidos/components/words/words.svelte b/src/uix/eidos/components/words/words.svelte index 5804414b1..76fce47ff 100644 --- a/src/uix/eidos/components/words/words.svelte +++ b/src/uix/eidos/components/words/words.svelte @@ -94,7 +94,10 @@ const command = typeof config === 'string' ? config : config.command; return { command, - label: typeof config === 'string' ? COMMAND_LABELS[command] : (config.label ?? COMMAND_LABELS[command]), + label: + typeof config === 'string' + ? COMMAND_LABELS[command] + : (config.label ?? COMMAND_LABELS[command]), ...(typeof config === 'string' ? {} : { level: config.level, listKind: config.listKind }) }; } diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 5b77f3444..a3370b20a 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -1596,11 +1596,11 @@ --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-icon-size-xs: var(--control-height-xs); + --password-field-icon-size-sm: var(--control-height-xs); + --password-field-icon-size-md: var(--control-height-sm); + --password-field-icon-size-lg: var(--control-height-sm); + --password-field-icon-size-xl: var(--control-height-md); --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); @@ -1608,9 +1608,9 @@ --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-bg: var(--color-border-default); --password-field-strength-segment-gap: 4px; - --password-field-strength-segment-height: 4px; + --password-field-strength-segment-height: 6px; --password-field-strength-radius: var(--radius-sm); --password-field-transition-duration: var(--duration-fast); --password-field-transition-ease: var(--ease-default); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index e55f7397b..0b95758cb 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -1354,11 +1354,11 @@ export const THEME_BASE_RECIPE_TOKENS = { '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)', + 'icon-size-xs': 'var(--control-height-xs)', + 'icon-size-sm': 'var(--control-height-xs)', + 'icon-size-md': 'var(--control-height-sm)', + 'icon-size-lg': 'var(--control-height-sm)', + 'icon-size-xl': 'var(--control-height-md)', '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)', @@ -1366,9 +1366,9 @@ export const THEME_BASE_RECIPE_TOKENS = { '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-bg': 'var(--color-border-default)', 'strength-segment-gap': '4px', - 'strength-segment-height': '4px', + 'strength-segment-height': '6px', 'strength-radius': 'var(--radius-sm)', 'transition-duration': 'var(--duration-fast)', 'transition-ease': 'var(--ease-default)', diff --git a/src/uix/soma/components/words/README.md b/src/uix/soma/components/words/README.md index 0fa6a141d..1eba62e13 100644 --- a/src/uix/soma/components/words/README.md +++ b/src/uix/soma/components/words/README.md @@ -179,6 +179,9 @@ type WordsMark = 'bold' | 'italic' | 'underline' | 'strike' | 'code'; - El Provider usa `batch: 'typing'` de `beforeinput` para agrupar escritura, borrado e IME contiguos en una sola entrada de undo; seleccion externa, blur, commit, undo/redo o comandos de formato cierran el batch. +- La seleccion de rango se sincroniza desde `selectionchange`, teclado, + puntero y `select`; `CommandButton` preserva la seleccion del editor antes + de aplicar comandos de toolbar sobre el rango seleccionado. ## Correcciones cerradas en slice 2 diff --git a/web/routes/uix/components/password-field/+page.svelte b/web/routes/uix/components/password-field/+page.svelte index ac13e997e..62ff9c12b 100644 --- a/web/routes/uix/components/password-field/+page.svelte +++ b/web/routes/uix/components/password-field/+page.svelte @@ -11,7 +11,7 @@ import { compileMorfo } from '$uix/morfo'; import { passwordFieldMorfo } from '@/uix/morfo/components/password-field'; import { getActiveUix } from '$active-uix'; - import { createForm } from '$libs/forms'; + import { createForm, type FormIssue } from '$libs/forms'; import { object, string, pipe, min, regex, meta, refine } from '$sium/core'; type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y'; @@ -144,6 +144,26 @@ estimateStrength(String(registrationForm.values.password ?? '')) ); + /** + * Resolve a SIUM error langref to the active locale. + * `#?sium.errors.min_length|Must have at least {{min}} characters` → + * the registered Spanish/English text with `{{min}}` interpolated. + */ + function issueText>( + form: { errors: Record; issues: Record }, + field: string + ): string { + const issue = form.issues[field]?.[0]; + const raw = issue?.message ?? form.errors[field]?.[0]; + if (!raw) return ''; + const resolved = uix.langs.ts(raw); + if (!issue?.params) return resolved; + return Object.entries(issue.params).reduce( + (acc, [key, value]) => acc.replaceAll(`{{${key}}}`, String(value)), + resolved + ); + } + $effect(() => { const el = stageRef; if (!el) return; @@ -263,7 +283,7 @@
-
+
Username - - {loginForm.errors.username?.[0]} + {issueText(loginForm, 'username')} Password @@ -536,7 +556,7 @@ - {loginForm.errors.password?.[0]} + {issueText(loginForm, 'password')} Sign in {#if loginResult !== '—'} @@ -555,13 +575,13 @@ Email - - {registrationForm.errors.email?.[0]} + {issueText(registrationForm, 'email')} Password @@ -575,7 +595,7 @@ - {registrationForm.errors.password?.[0]} + {issueText(registrationForm, 'password')} Create account {#if registrationResult !== '—'} @@ -601,7 +621,7 @@ - {changeForm.errors.current?.[0]} + {issueText(changeForm, 'current')} New password @@ -613,7 +633,7 @@ - {changeForm.errors.next?.[0]} + {issueText(changeForm, 'next')} Confirm @@ -625,7 +645,7 @@ - {changeForm.errors.confirm?.[0]} + {issueText(changeForm, 'confirm')} Update password {#if changeResult !== '—'} diff --git a/web/routes/uix/components/words/+page.svelte b/web/routes/uix/components/words/+page.svelte index 6c0c362c1..ce009c10f 100644 --- a/web/routes/uix/components/words/+page.svelte +++ b/web/routes/uix/components/words/+page.svelte @@ -3,6 +3,8 @@ Words, type WordsColor, type WordsSize, + type WordsToolbarConfig, + type WordsToolbarPreset, type WordsVariant } from '$uix/eidos/components/words'; import type { @@ -11,22 +13,6 @@ WordsSelection, WordsToolbarOrientation } from '$soma/components/words'; - import { - Bold, - Code, - Eraser, - Heading1, - Italic, - List, - ListOrdered, - Pilcrow, - Quote, - Redo2, - Save, - Strikethrough, - Underline, - Undo2 - } from '$uix/eidos/components/icon'; import { compileMorfo } from '$uix/morfo'; import { wordsMorfo } from '@/uix/morfo/components/words'; import { getActiveUix } from '$active-uix'; @@ -34,6 +20,7 @@ type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y'; type DocumentPreset = 'sample' | 'empty' | 'long'; + type ToolbarChoice = 'none' | WordsToolbarPreset; type TraceEntry = { event: string; family: string; intent?: string; at: number }; type RawValue = { kind?: string; value?: unknown; prop?: string; part?: string }; @@ -85,6 +72,20 @@ ]; const orientations: WordsToolbarOrientation[] = ['horizontal', 'vertical']; const presets: DocumentPreset[] = ['sample', 'empty', 'long']; + const toolbarChoices: ToolbarChoice[] = ['none', 'minimal', 'formatting', 'full']; + const toolbarPresetGroups: Record = { + minimal: [['bold', 'italic', 'underline']], + formatting: [ + ['bold', 'italic', 'underline', 'strike', 'code'], + ['paragraph', 'heading', 'quote'] + ], + full: [ + ['undo', 'redo'], + ['bold', 'italic', 'underline', 'strike', 'code'], + ['paragraph', 'heading', 'quote', 'unordered-list', 'ordered-list'], + ['clear-formatting', 'clear-document', 'commit'] + ] + }; let tab = $state('live'); let preset = $state('sample'); @@ -96,7 +97,7 @@ let required = $state(false); let invalid = $state(false); let commitOnBlur = $state(true); - let showToolbar = $state(true); + let toolbarChoice = $state('full'); let showStatus = $state(true); let orientation = $state('horizontal'); let size = $state('md'); @@ -111,6 +112,12 @@ const wordCount = $derived(countWords(plainText)); const selectionState = $derived(selectionLabel(selection)); const documentJson = $derived(JSON.stringify(value, null, 2)); + const toolbarConfig = $derived( + toolbarChoice === 'none' ? false : toolbarChoice + ); + const toolbarSnippetGroups = $derived( + toolbarChoice === 'none' ? [] : toolbarPresetGroups[toolbarChoice] + ); const somaSnippet = $derived( [ @@ -130,16 +137,7 @@ invalid && ' invalid', !commitOnBlur && ' commitOnBlur={false}', '>', - showToolbar && ` `, - showToolbar && ' ', - showToolbar && ' ', - showToolbar && ' ', - showToolbar && - ' ', - showToolbar && ' ', - showToolbar && ' ', - showToolbar && ' ', - showToolbar && ' ', + ...somaToolbarSnippet(toolbarSnippetGroups, orientation), ' ', ' ', ' ', @@ -154,7 +152,6 @@ [ "