From e0bb7ed5f58aa12b223e68eb1fd3140f01c4bc2e Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 17 Jun 2026 18:07:27 +0200 Subject: [PATCH] feat(eidos): shared field control-trigger + segment state/width layers; uix- id prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the per-field / per-picker duplication the audit surfaced: - Shared control trigger — new field/field-control-trigger.css selected by a `data-field-control-trigger` marker: flush (no border, no shadow), UNIFORM neutral hover tint (not the field accent), font-relative size. Migrated date-picker + the password visibility toggle; deletes their per-picker chrome. (date-range / time / time-range pickers still to migrate.) - Shared segment state layer — new field/field-segment-state.css selected by a `data-field-segment` marker (date / time / color fields): one hover (editable) + active (focus) rule. Fixes a specificity bug where the hover selector out-ranked :focus, so hovering the active segment downgraded its highlight. - Segment fixed width — min-inline-size: calc(2ch + 2*segment-px) + font-variant-numeric: tabular-nums so a segment never reflows when its value changes 1<->2 digits or between glyphs (no horizontal jitter). date-field done. - date-range-picker trigger uses the same calendar icon as date-picker (was a distinct calendar-range glyph). - Generated element ids use the framework-neutral `uix-` prefix instead of the layer name `soma-` (createId / useId + announce / menubar inline ids) — the doctrine that bans data-soma-* / --soma-* keeps a layer name out of ids too. - field.css: label + helper text scale one step below the control (font-relative calc); optional-indicator weight to regular. Foundation tokens added: --field-segment-hover-bg, --field-control-trigger-hover-bg. Verified in-browser by measurement (date-field segments: states + no-jitter; date-picker trigger: flush + uniform hover). check = 58 baseline (0 new); recipe-css-contract clean (only pre-existing palabras WIP). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/uix/active-uix/id/create-id.ts | 11 +-- src/uix/active-uix/id/use-id.ts | 5 +- .../color-field/color-field-segment.svelte | 3 +- .../components/color-field/color-field.css | 8 +- .../date-field/date-field-segment.svelte | 3 +- .../components/date-field/date-field.css | 33 ++----- .../date-picker/date-picker-trigger.svelte | 2 + .../components/date-picker/date-picker.css | 85 +------------------ .../date-range-picker-trigger.svelte | 4 +- .../field/field-control-trigger.css | 63 ++++++++++++++ .../components/field/field-segment-state.css | 42 +++++++++ src/uix/eidos/components/field/field.css | 16 ++-- .../password-field/password-field.css | 10 ++- .../time-field/time-field-segment.svelte | 3 +- .../components/time-field/time-field.css | 8 +- src/uix/eidos/generated/base.css | 10 +-- src/uix/eidos/lib/recipes/base.ts | 8 +- src/uix/eidos/lib/render-css.ts | 19 +++++ .../soma/components/announce/global.svelte.ts | 4 +- .../menubar/menubar-provider.svelte.ts | 2 +- src/uix/soma/datetime/announcer.ts | 6 +- 21 files changed, 181 insertions(+), 164 deletions(-) create mode 100644 src/uix/eidos/components/field/field-control-trigger.css create mode 100644 src/uix/eidos/components/field/field-segment-state.css diff --git a/src/uix/active-uix/id/create-id.ts b/src/uix/active-uix/id/create-id.ts index b687e4d01..ba4b44389 100644 --- a/src/uix/active-uix/id/create-id.ts +++ b/src/uix/active-uix/id/create-id.ts @@ -2,8 +2,8 @@ * Create an id from a Svelte `$props.id()` uid, with optional component context. * * ```ts - * createId(uid) // "soma-c12" - * createId(uid, 'dialog-trigger') // "soma-dialog-trigger-c12" + * createId(uid) // "uix-c12" + * createId(uid, 'dialog-trigger') // "uix-dialog-trigger-c12" * ``` * * The `uid` comes from the consuming component's `$props.id()`, so the output @@ -12,9 +12,10 @@ * * Lives in active-uix (imported by subpath `$active-uix/id`) so every UIX layer * — soma, eidos, demos, apps — shares one id authority without cross-layer - * coupling. The `soma-` prefix is the UIX id convention, kept for output - * stability across the migration. + * coupling. The prefix is `uix-` (the framework), NOT a layer name: the same + * doctrine that bans `data-soma-*` / `--soma-*` keeps a layer name out of + * element ids too. */ export function createId(uid: string, component?: string): string { - return component ? `soma-${component}-${uid}` : `soma-${uid}` + return component ? `uix-${component}-${uid}` : `uix-${uid}` } diff --git a/src/uix/active-uix/id/use-id.ts b/src/uix/active-uix/id/use-id.ts index b321a5884..9071cc603 100644 --- a/src/uix/active-uix/id/use-id.ts +++ b/src/uix/active-uix/id/use-id.ts @@ -8,11 +8,14 @@ * NOT for SSR / ARIA element ids derived from the component tree — those use * `createId` (same module), which wraps Svelte's `$props.id()` for * hydration-stable output. A global counter would desync server / client. + * + * Prefix is `uix-` (framework-neutral), never a layer name — same doctrine as + * the `data-soma-*` / `--soma-*` bans. */ let counter = 0 export function useId(component = ''): string { const id = ++counter - return component ? `soma-${component}-${id}` : `soma-${id}` + return component ? `uix-${component}-${id}` : `uix-${id}` } diff --git a/src/uix/eidos/components/color-field/color-field-segment.svelte b/src/uix/eidos/components/color-field/color-field-segment.svelte index 640d68aac..e11a4c3b6 100644 --- a/src/uix/eidos/components/color-field/color-field-segment.svelte +++ b/src/uix/eidos/components/color-field/color-field-segment.svelte @@ -1,10 +1,11 @@ - + {@render children?.()} diff --git a/src/uix/eidos/components/color-field/color-field.css b/src/uix/eidos/components/color-field/color-field.css index 90dd0037e..822b84d44 100644 --- a/src/uix/eidos/components/color-field/color-field.css +++ b/src/uix/eidos/components/color-field/color-field.css @@ -178,12 +178,8 @@ color: var(--color-field-placeholder-color); } -[data-color-field-segment]:focus-visible { - /* Active highlight: shared `--field-segment-active-*` foundation tokens. */ - background: var(--field-segment-active-bg); - color: var(--field-segment-active-text); - font-weight: 600; -} +/* Segment hover + active states are shared — see field/field-segment-state.css + (selects `[data-field-segment]`, the eidos marker on every field segment). */ [data-color-field-segment][data-invalid] { color: var(--color-field-segment-invalid-color); diff --git a/src/uix/eidos/components/date-field/date-field-segment.svelte b/src/uix/eidos/components/date-field/date-field-segment.svelte index 2a5297bf6..e9282adae 100644 --- a/src/uix/eidos/components/date-field/date-field-segment.svelte +++ b/src/uix/eidos/components/date-field/date-field-segment.svelte @@ -1,10 +1,11 @@ - + {@render children?.()} diff --git a/src/uix/eidos/components/date-field/date-field.css b/src/uix/eidos/components/date-field/date-field.css index b085c7c66..4936ea014 100644 --- a/src/uix/eidos/components/date-field/date-field.css +++ b/src/uix/eidos/components/date-field/date-field.css @@ -157,7 +157,10 @@ display: inline-flex; align-items: center; justify-content: center; - min-inline-size: var(--date-field-segment-min-width); + /* Fixed 2-digit width (font-relative) + tabular figures (shared layer) so the + segment never reflows when the value changes 1 → 2 digits or between glyphs. + border-box: hold 2ch of content + the inline padding. */ + min-inline-size: calc(2ch + 2 * var(--_date-field-segment-px)); block-size: var(--date-field-segment-height); padding-inline: var(--_date-field-segment-px); border-radius: var(--date-field-segment-radius); @@ -191,32 +194,8 @@ cursor: text; } -[data-date-field-segment]:not([data-segment='literal']):not([data-readonly]):not([data-disabled]):hover { - background: var(--date-field-segment-hover-bg, color-mix(in srgb, var(--color-content-primary) 6%, transparent)); -} - -/* - * Active segment — the one the user is editing (focused via click, - * tap, arrow-key navigation or paste). Catch BOTH `:focus` and - * `:focus-visible` so the affordance shows regardless of input - * modality — segments are direct-manipulation surfaces where the - * user is about to type, so always advertising the active target - * is correct (vs the global `:focus-visible` convention which - * suppresses focus rings on mouse-down). - * - * Visual: accent element bg + accent text + bold weight. NO border/ring — - * the active segment is highlighted by background only (homogeneous across - * the date/time/color field family). - */ -[data-date-field-segment]:focus, -[data-date-field-segment]:focus-visible { - /* Active highlight: theme-owned via the shared `--field-segment-active-*` - foundation tokens (the bg carries its own transparency). */ - background: var(--field-segment-active-bg); - color: var(--field-segment-active-text); - font-weight: 600; - outline: none; -} +/* Segment hover + active states are shared — see field/field-segment-state.css + (selects `[data-field-segment]`, the eidos marker on every field segment). */ [data-date-field-segment][data-invalid] { color: var(--date-field-segment-invalid-color); diff --git a/src/uix/eidos/components/date-picker/date-picker-trigger.svelte b/src/uix/eidos/components/date-picker/date-picker-trigger.svelte index 91ca34d21..3775de82b 100644 --- a/src/uix/eidos/components/date-picker/date-picker-trigger.svelte +++ b/src/uix/eidos/components/date-picker/date-picker-trigger.svelte @@ -2,6 +2,7 @@ import { ActiveEidos } from '$uix/eidos'; import * as DatePicker from '$soma/components/date-picker'; import CalendarIcon from '../icon/lib/calendar.svelte'; + import '../field/field-control-trigger.css'; import { getDatePickerVisualContext } from './context.svelte'; import type { DatePickerTriggerProps } from './types'; @@ -16,6 +17,7 @@ as a descendant of - * , the icon becomes a flush affordance at the input's - * trailing edge: no border, no separate background, smaller hit area sized - * to the segment height, and a subtle hover tint that doesn't compete with - * the field's focus ring. The standalone treatment above stays available - * for consumers who place the trigger as a sibling. */ - -[data-date-field-input] [data-date-picker-trigger] { - flex: 0 0 auto; - /* `--date-field-segment-height` is scoped to `[data-date-field]`, which the - picker doesn't render — so it's undefined here and the box collapsed to the - icon. Inline the same calc off the picker-local `--_date-field-height` (which - scales per size). `font: inherit` so the `1em` icon tracks the field font. */ - inline-size: calc(var(--_date-field-height) - var(--space-2)); - block-size: calc(var(--_date-field-height) - var(--space-2)); - margin-inline-start: auto; - padding: 0; - border: none; - border-radius: var(--date-field-segment-radius); - background: transparent; - color: var(--date-field-segment-color); - box-shadow: none; - font: inherit; -} - -[data-date-field-input] [data-date-picker-trigger]:hover { - border: none; - background: var(--_date-field-accent-track); - color: var(--_date-field-accent-text); -} - -[data-date-field-input] [data-date-picker-trigger]:focus-visible { - outline: none; - border: none; - background: var(--field-segment-active-bg); - color: var(--field-segment-active-text); -} +/* Trigger chrome (flush affordance, hover / focus states, size) is shared — + see field/field-control-trigger.css (`[data-field-control-trigger]`, the + eidos marker on the trigger). */ [data-popover-content]:has([data-date-picker-calendar]) { --_popover-content-width-override: max-content; @@ -216,9 +143,3 @@ inline-size: max-content; min-inline-size: max-content; } - -@media (prefers-reduced-motion: reduce) { - [data-date-picker-trigger] { - transition: none; - } -} diff --git a/src/uix/eidos/components/date-range-picker/date-range-picker-trigger.svelte b/src/uix/eidos/components/date-range-picker/date-range-picker-trigger.svelte index 36bd7680f..005f0407f 100644 --- a/src/uix/eidos/components/date-range-picker/date-range-picker-trigger.svelte +++ b/src/uix/eidos/components/date-range-picker/date-range-picker-trigger.svelte @@ -1,7 +1,7 @@ - + {@render children?.()} diff --git a/src/uix/eidos/components/time-field/time-field.css b/src/uix/eidos/components/time-field/time-field.css index f418301e3..17d79b015 100644 --- a/src/uix/eidos/components/time-field/time-field.css +++ b/src/uix/eidos/components/time-field/time-field.css @@ -173,12 +173,8 @@ color: var(--time-field-placeholder-color); } -[data-time-field-segment]:focus-visible { - /* Active highlight: shared `--field-segment-active-*` foundation tokens. */ - background: var(--field-segment-active-bg); - color: var(--field-segment-active-text); - font-weight: 600; -} +/* Segment hover + active states are shared — see field/field-segment-state.css + (selects `[data-field-segment]`, the eidos marker on every field segment). */ [data-time-field-segment][data-invalid] { color: var(--time-field-segment-invalid-color); diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 87f437051..7a551fb87 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -180,6 +180,8 @@ --focus-ring-error: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color-error), 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-error); --field-segment-active-bg: color-mix(in srgb, var(--color-primary-border) 40%, transparent); --field-segment-active-text: var(--color-primary-text); + --field-segment-hover-bg: color-mix(in srgb, var(--color-content-primary) 6%, transparent); + --field-control-trigger-hover-bg: color-mix(in srgb, var(--color-content-primary) 8%, transparent); --container-width-sm: 448px; --container-width-md: 688px; --container-width-lg: 880px; @@ -1425,7 +1427,6 @@ --date-field-segment-readonly-bg: var(--color-surface-overlay); --date-field-segment-readonly-color: var(--color-content-secondary); --date-field-segment-readonly-decoration: underline dotted var(--color-content-muted); - --date-field-segment-hover-bg: color-mix(in srgb, var(--color-content-primary) 6%, transparent); --date-field-transition-duration: var(--duration-fast); --date-field-transition-ease: var(--ease-default); --date-field-disabled-opacity: var(--opacity-disabled); @@ -2117,14 +2118,9 @@ --password-field-color: var(--color-content-primary); --password-field-placeholder-color: var(--color-content-muted); --password-field-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 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-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-radius: var(--radius-sm); --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: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 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(--control-height-xs); --password-field-icon-size-sm: var(--control-height-xs); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index 14ad2b301..3ff5e0924 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -1249,7 +1249,6 @@ export const THEME_BASE_RECIPE_TOKENS = { 'segment-readonly-bg': 'var(--color-surface-overlay)', 'segment-readonly-color': 'var(--color-content-secondary)', 'segment-readonly-decoration': 'underline dotted var(--color-content-muted)', - 'segment-hover-bg': 'color-mix(in srgb, var(--color-content-primary) 6%, transparent)', 'transition-duration': 'var(--duration-fast)', 'transition-ease': 'var(--ease-default)', 'disabled-opacity': 'var(--opacity-disabled)' @@ -2156,14 +2155,9 @@ export const THEME_BASE_RECIPE_TOKENS = { 'placeholder-color': 'var(--color-content-muted)', 'focus-shadow': 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 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-radius': 'var(--radius-full)', - 'trigger-border-width': 'var(--border-width)', - 'trigger-border': 'transparent', - 'trigger-border-hover': 'var(--color-border-subtle)', + 'trigger-radius': 'var(--radius-sm)', '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': 'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 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(--control-height-xs)', diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index bb8744143..c65b7c5b9 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -212,6 +212,25 @@ export function renderStaticCss(options: EidosConfig): string { ) declarations.push(cssVar('field-segment-active-text', 'var(--color-primary-text)')) + // Hover affordance for segments — the faint "you can edit here" tint, shared + // by every date / time / color field segment (distinct from the active fill). + declarations.push( + cssVar( + 'field-segment-hover-bg', + 'color-mix(in srgb, var(--color-content-primary) 6%, transparent)' + ) + ) + + // Control-trigger hover — uniform neutral tint for the affordance that opens a + // field overlay (calendar / clock / swatch) or toggles password visibility. NOT + // the field's accent: every field's control reads the same on hover. + declarations.push( + cssVar( + 'field-control-trigger-hover-bg', + 'color-mix(in srgb, var(--color-content-primary) 8%, transparent)' + ) + ) + if (primitives.layout) { appendLayoutDeclarations(declarations, primitives.layout) } diff --git a/src/uix/soma/components/announce/global.svelte.ts b/src/uix/soma/components/announce/global.svelte.ts index d151f90a8..f38f7313d 100644 --- a/src/uix/soma/components/announce/global.svelte.ts +++ b/src/uix/soma/components/announce/global.svelte.ts @@ -23,8 +23,8 @@ import type { AnnouncePriority, AnnounceApi } from './types'; * resolve a document. */ -const POLITE_ID = 'soma-announce-global-polite'; -const ASSERTIVE_ID = 'soma-announce-global-assertive'; +const POLITE_ID = 'uix-announce-global-polite'; +const ASSERTIVE_ID = 'uix-announce-global-assertive'; const VISUALLY_HIDDEN_STYLE = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0'; diff --git a/src/uix/soma/components/menubar/menubar-provider.svelte.ts b/src/uix/soma/components/menubar/menubar-provider.svelte.ts index 019012b03..09e256046 100644 --- a/src/uix/soma/components/menubar/menubar-provider.svelte.ts +++ b/src/uix/soma/components/menubar/menubar-provider.svelte.ts @@ -202,7 +202,7 @@ export class MenubarMenuProvider { /** Nested DropdownMenu provider — supplies Content/Item/Sub with context. */ readonly menu: DropdownMenuProvider; - private readonly menuId = state(`soma-menubar-menu-${Math.random().toString(36).slice(2, 8)}`); + private readonly menuId = state(`uix-menubar-menu-${Math.random().toString(36).slice(2, 8)}`); private constructor(opts: MenubarMenuOpts) { this.opts = opts; diff --git a/src/uix/soma/datetime/announcer.ts b/src/uix/soma/datetime/announcer.ts index 24f754c83..1961e36b6 100644 --- a/src/uix/soma/datetime/announcer.ts +++ b/src/uix/soma/datetime/announcer.ts @@ -18,7 +18,7 @@ function initAnnouncer(dom: ActiveDom | null, target?: Node | null) { if (!isHTMLElement(el)) { el = - dom.writeNode('soma-sr-announcer', { + dom.writeNode('uix-sr-announcer', { host: doc.body, position: 'prepend', attrs: { @@ -29,11 +29,11 @@ function initAnnouncer(dom: ActiveDom | null, target?: Node | null) { } if (isHTMLElement(el)) { - dom.writeNode('soma-sr-announcer-assertive', { + dom.writeNode('uix-sr-announcer-assertive', { host: el, attrs: { role: 'log', 'aria-live': 'assertive', 'aria-relevant': 'additions' } }); - dom.writeNode('soma-sr-announcer-polite', { + dom.writeNode('uix-sr-announcer-polite', { host: el, attrs: { role: 'log', 'aria-live': 'polite', 'aria-relevant': 'additions' } });