From c939de90c823d3fb19443df8c74d76cb3c1ca34a Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 17 May 2026 01:15:16 +0200 Subject: [PATCH] Harden Eidos config and wrappers --- continue.md | 76 +++++ src/uix/eidos/active-eidos-config.test.ts | 68 ++++ src/uix/eidos/component-api-contract.test.ts | 21 ++ src/uix/eidos/component-visual-attrs.test.ts | 1 + .../accordion/accordion-trigger.svelte | 9 +- .../eidos/components/accordion/accordion.css | 5 + .../components/avatar/avatar-image.svelte | 11 +- src/uix/eidos/components/popover/popover.css | 6 +- .../components/tabs/tabs-indicator.svelte | 1 + src/uix/eidos/components/tabs/tabs.svelte | 32 +- .../eidos/components/toast/toast-close.svelte | 2 +- src/uix/eidos/components/tooltip/tooltip.css | 2 +- src/uix/eidos/lib/config.ts | 305 ++++++++++++------ src/uix/eidos/lib/contract.ts | 5 +- src/uix/eidos/lib/render-css.ts | 4 - .../link-preview-provider.svelte.test.ts | 1 + .../tooltip/tooltip-provider.svelte.test.ts | 1 + .../soma/layers/floating/floating.svelte.ts | 1 + 18 files changed, 411 insertions(+), 140 deletions(-) diff --git a/continue.md b/continue.md index a47d0fd24..28cd73a97 100644 --- a/continue.md +++ b/continue.md @@ -2,6 +2,82 @@ Fecha de corte: 2026-05-16. Rama: `active-uix`. +Actualizacion Eidos components audit 2026-05-17: + +- Se acometieron los hallazgos autorizados sobre `src/uix/eidos/components` + sin tocar demos ni rutas `web/routes/uix`: + - `Tabs` instala su contexto visual de forma sincronica durante init, no en + `$effect`; los hijos `Tabs.Content` ya no pueden inicializar sin contexto. + - `Tabs.Indicator` deja de observarse a si mismo con `ResizeObserver`, para + evitar loops de medida provocados por sus propias escrituras inline. + - `Avatar.Image` ya no arranca oculto cuando hay `src`; la visibilidad queda + derivada de `src` y de la ultima URL fallida. + - `Accordion.Trigger` ya no contiene ` diff --git a/src/uix/eidos/components/accordion/accordion.css b/src/uix/eidos/components/accordion/accordion.css index 2df2e9d78..e2d68ba8d 100644 --- a/src/uix/eidos/components/accordion/accordion.css +++ b/src/uix/eidos/components/accordion/accordion.css @@ -162,6 +162,11 @@ cursor: not-allowed; } +[data-accordion-label] { + flex: 1 1 auto; + min-inline-size: 0; +} + /* ── Indicator chevron ─────────────────────────────────────────────── * `` lives inside the trigger's wrapper, marked * `data-accordion-indicator`. The chevron points DOWN by default; on diff --git a/src/uix/eidos/components/avatar/avatar-image.svelte b/src/uix/eidos/components/avatar/avatar-image.svelte index d22b5b88a..3d7240b4e 100644 --- a/src/uix/eidos/components/avatar/avatar-image.svelte +++ b/src/uix/eidos/components/avatar/avatar-image.svelte @@ -2,18 +2,15 @@ import type { AvatarImageProps } from './types'; let { src, alt = '', ...restProps }: AvatarImageProps = $props(); - let visible = $state(false); - - $effect(() => { - visible = Boolean(src); - }); + let failedSrc = $state(undefined); + const visible = $derived(Boolean(src) && src !== failedSrc); function showImage() { - visible = true; + if (failedSrc === src) failedSrc = undefined; } function hideImage() { - visible = false; + failedSrc = src ?? undefined; } diff --git a/src/uix/eidos/components/popover/popover.css b/src/uix/eidos/components/popover/popover.css index 3dc4c499d..a05c439ca 100644 --- a/src/uix/eidos/components/popover/popover.css +++ b/src/uix/eidos/components/popover/popover.css @@ -48,9 +48,9 @@ * z-index unless the content provides one via `getComputedStyle().zIndex`, * which only works for positioned elements. When modal=true, the overlay * (z=60) would otherwise paint over the wrapper, hiding the panel and - * making it unclickable. Lift the wrapper above the overlay directly via - * :has() so we don't need to make `[data-popover-content]` positioned. */ -[data-soma-floating-wrapper]:has(> [data-popover-content]) { +* making it unclickable. Soma exposes a public floating wrapper marker for +* cross-layer styling, so Eidos does not depend on Soma internals here. */ +[data-floating-wrapper]:has(> [data-popover-content]) { z-index: var(--popover-content-z); } diff --git a/src/uix/eidos/components/tabs/tabs-indicator.svelte b/src/uix/eidos/components/tabs/tabs-indicator.svelte index 4bec20897..1509e23a0 100644 --- a/src/uix/eidos/components/tabs/tabs-indicator.svelte +++ b/src/uix/eidos/components/tabs/tabs-indicator.svelte @@ -71,6 +71,7 @@ const ro = new ResizeObserver(() => measure()); ro.observe(list); for (const child of Array.from(list.children)) { + if (child === el) continue; if (child instanceof HTMLElement) ro.observe(child); } diff --git a/src/uix/eidos/components/tabs/tabs.svelte b/src/uix/eidos/components/tabs/tabs.svelte index c7b366fef..20809cf24 100644 --- a/src/uix/eidos/components/tabs/tabs.svelte +++ b/src/uix/eidos/components/tabs/tabs.svelte @@ -51,6 +51,27 @@ let isCollapsed = $state(false); + setTabsEidosContext({ + get size() { + return resolvedSize; + }, + get variant() { + return variant; + }, + get fitted() { + return fitted; + }, + get collapseAt() { + return collapseAt; + }, + get scrollable() { + return scrollable; + }, + get motionPreset() { + return motionPreset; + } + }); + $effect(() => { const el = ref; const px = collapsePx; @@ -66,17 +87,6 @@ ro.observe(el); return () => ro.disconnect(); }); - - $effect(() => { - setTabsEidosContext({ - size: resolvedSize, - variant, - fitted, - collapseAt, - scrollable, - motionPreset - }); - }); - + {#if children} {@render children?.()} {:else} diff --git a/src/uix/eidos/components/tooltip/tooltip.css b/src/uix/eidos/components/tooltip/tooltip.css index ba205b75d..97b5459cc 100644 --- a/src/uix/eidos/components/tooltip/tooltip.css +++ b/src/uix/eidos/components/tooltip/tooltip.css @@ -1,6 +1,6 @@ /* ── Content (variant-shared base) ──────────────────────────────────────── */ -[data-soma-floating-wrapper]:has(> [data-tooltip-content]) { +[data-floating-wrapper]:has(> [data-tooltip-content]) { z-index: var(--tooltip-content-z); } diff --git a/src/uix/eidos/lib/config.ts b/src/uix/eidos/lib/config.ts index 200b0e6ad..6f0d33eb5 100644 --- a/src/uix/eidos/lib/config.ts +++ b/src/uix/eidos/lib/config.ts @@ -34,10 +34,7 @@ import { type EidosConfig, type EidosConfigSnapshot, type RecipeTokenMap, - type SizePrimitive, - type TextMetric, - type ThemeDefinition, - type TypographyPrimitiveSet + type ThemeDefinition } from './config-types' import { createEidosCssContract } from './contract' import { @@ -190,7 +187,8 @@ function validateEidosConfigShape( 'motion', 'icon', 'opacity', - 'zIndex' + 'zIndex', + 'shadow' ] as const for (const key of primitiveKeys) { @@ -490,13 +488,23 @@ function validateColorScale( scale: ColorScale, issues: EidosValidationIssue[] ): void { + if (!isPlainRecord(scale)) { + issues.push({ + path, + message: 'color scale must be a plain object' + }) + return + } + for (const step of COLOR_SCALE_STEPS) { - if (!hasOwn(scale, step) || scale[step] === '') { + if (!hasOwn(scale, step)) { issues.push({ path: `${path}.${step}`, message: 'color scales must define every step from 1 to 12' }) + continue } + validateNonEmptyCssValue(`${path}.${step}`, scale[step], issues) } } @@ -591,79 +599,145 @@ function validateColorRoleSlots( function validateTypography(options: EidosConfig, issues: EidosValidationIssue[]): void { const typography = options.primitives.typography if (!typography) return + const sizes = readRequiredPlainRecord( + 'primitives.typography.sizes', + typography.sizes, + issues + ) + const families = readRequiredPlainRecord( + 'primitives.typography.families', + typography.families, + issues + ) + const weights = readRequiredPlainRecord( + 'primitives.typography.weights', + typography.weights, + issues + ) + const styles = readRequiredPlainRecord( + 'primitives.typography.styles', + typography.styles, + issues + ) - for (const size of TEXT_SIZES) { - const metric = typography.sizes[size] - if (!metric) { - issues.push({ - path: `primitives.typography.sizes.${size}`, - message: 'every canonical text size must be defined' - }) - continue + if (sizes) { + for (const size of TEXT_SIZES) { + const metric = sizes[size] + if (!metric) { + issues.push({ + path: `primitives.typography.sizes.${size}`, + message: 'every canonical text size must be defined' + }) + continue + } + validateTextMetric(`primitives.typography.sizes.${size}`, metric, issues) } - validateTextMetric(`primitives.typography.sizes.${size}`, metric, issues) - } - for (const name of Object.keys(typography.sizes)) { - validateCssTokenSuffix(`primitives.typography.sizes.${name}`, name, 'typography size', issues) - if (!TEXT_SIZE_SET.has(name)) { - issues.push({ - path: `primitives.typography.sizes.${name}`, - message: 'unknown typography size key' - }) + for (const name of Object.keys(sizes)) { + validateCssTokenSuffix( + `primitives.typography.sizes.${name}`, + name, + 'typography size', + issues + ) + if (!TEXT_SIZE_SET.has(name)) { + issues.push({ + path: `primitives.typography.sizes.${name}`, + message: 'unknown typography size key' + }) + } } } - for (const [name, family] of Object.entries(typography.families)) { - validateCssTokenSuffix( - `primitives.typography.families.${name}`, - name, - 'typography family', - issues - ) - if (family.family.trim() === '') { - issues.push({ - path: `primitives.typography.families.${name}.family`, - message: 'font family name cannot be empty' - }) + if (families) { + for (const [name, family] of Object.entries(families)) { + validateCssTokenSuffix( + `primitives.typography.families.${name}`, + name, + 'typography family', + issues + ) + if (!isPlainRecord(family)) { + issues.push({ + path: `primitives.typography.families.${name}`, + message: 'typography family must be a plain object' + }) + continue + } + validateNonEmptyCssValue( + `primitives.typography.families.${name}.family`, + family.family, + issues + ) } } - for (const name of Object.keys(typography.weights)) { - validateCssTokenSuffix( - `primitives.typography.weights.${name}`, - name, - 'typography weight', - issues - ) + if (weights) { + for (const name of Object.keys(weights)) { + validateCssTokenSuffix( + `primitives.typography.weights.${name}`, + name, + 'typography weight', + issues + ) + validateNonEmptyCssValue(`primitives.typography.weights.${name}`, weights[name], issues) + } } - for (const [name, style] of Object.entries(typography.styles)) { - const path = `primitives.typography.styles.${name}` - validateCssTokenSuffix(path, name, 'typography style', issues) + if (styles) { + for (const [name, style] of Object.entries(styles)) { + const path = `primitives.typography.styles.${name}` + validateCssTokenSuffix(path, name, 'typography style', issues) - if (style.family !== undefined && !hasOwn(typography.families, style.family)) { - issues.push({ - path: `${path}.family`, - message: `typography style points to missing family "${style.family}"` - }) - } + if (!isPlainRecord(style)) { + issues.push({ + path, + message: 'typography style must be a plain object' + }) + continue + } - if ( - typeof style.weight === 'string' && - style.weight.trim() !== '' && - !hasOwn(typography.weights, style.weight) - ) { - issues.push({ - path: `${path}.weight`, - message: `typography style points to missing weight "${style.weight}"` - }) - } + const familyRef = style.family + if ( + familyRef !== undefined && + (typeof familyRef !== 'string' || !families || !hasOwn(families, familyRef)) + ) { + issues.push({ + path: `${path}.family`, + message: `typography style points to missing family "${String(familyRef)}"` + }) + } - validateTypographySizeValue(`${path}.size`, style.size, typography, issues) + const weightRef = style.weight + if ( + typeof weightRef === 'string' && + weightRef.trim() !== '' && + (!weights || !hasOwn(weights, weightRef)) + ) { + issues.push({ + path: `${path}.weight`, + message: `typography style points to missing weight "${weightRef}"` + }) + } + + validateTypographySizeValue(`${path}.size`, style.size, sizes, issues) + } } } +function readRequiredPlainRecord( + path: string, + value: unknown, + issues: EidosValidationIssue[] +): Record | undefined { + if (isPlainRecord(value)) return value + issues.push({ + path, + message: 'Eidos config entries must be plain objects' + }) + return undefined +} + function validateSizePrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void { const sizes = options.primitives.size if (!sizes) return @@ -694,36 +768,64 @@ function validateSizePrimitives(options: EidosConfig, issues: EidosValidationIss function validateSizePrimitive( path: string, - size: SizePrimitive, + size: unknown, options: EidosConfig, issues: EidosValidationIssue[] ): void { - if (!hasOwn(options.primitives.controlHeight ?? {}, size.controlHeight)) { + if (!isPlainRecord(size)) { + issues.push({ + path, + message: 'size primitive must be a plain object' + }) + return + } + + const controlHeight = size.controlHeight + if ( + typeof controlHeight !== 'string' || + controlHeight.trim() === '' || + !hasOwn(options.primitives.controlHeight ?? {}, controlHeight) + ) { issues.push({ path: `${path}.controlHeight`, - message: `size primitive points to missing control height "${size.controlHeight}"` + message: `size primitive points to missing control height "${String(controlHeight)}"` }) } const typography = options.primitives.typography - if (!TEXT_SIZE_SET.has(size.fontSize) || !hasOwn(typography?.sizes ?? {}, size.fontSize)) { + const fontSize = size.fontSize + if ( + typeof fontSize !== 'string' || + !TEXT_SIZE_SET.has(fontSize) || + !hasOwn(typography?.sizes ?? {}, fontSize) + ) { issues.push({ path: `${path}.fontSize`, - message: `size primitive points to missing font size "${size.fontSize}"` + message: `size primitive points to missing font size "${String(fontSize)}"` }) } - if (!hasOwn(options.primitives.icon?.size ?? {}, size.iconSize)) { + const iconSize = size.iconSize + if ( + typeof iconSize !== 'string' || + iconSize.trim() === '' || + !hasOwn(options.primitives.icon?.size ?? {}, iconSize) + ) { issues.push({ path: `${path}.iconSize`, - message: `size primitive points to missing icon size "${size.iconSize}"` + message: `size primitive points to missing icon size "${String(iconSize)}"` }) } - if (!hasOwn(options.primitives.radius ?? {}, size.radius)) { + const radius = size.radius + if ( + typeof radius !== 'string' || + radius.trim() === '' || + !hasOwn(options.primitives.radius ?? {}, radius) + ) { issues.push({ path: `${path}.radius`, - message: `size primitive points to missing radius "${size.radius}"` + message: `size primitive points to missing radius "${String(radius)}"` }) } @@ -981,7 +1083,7 @@ function validateRecordPrimitive( }) continue } - validateNonEmptyCssValue(`${path}.${key}`, String(value), issues) + validateNonEmptyCssValue(`${path}.${key}`, value, issues) } } @@ -995,20 +1097,20 @@ function validateOpenScalePrimitive( for (const [key, value] of Object.entries(record)) { validateCssTokenSuffix(`${path}.${key}`, key, label, issues) - validateNonEmptyCssValue(`${path}.${key}`, String(value), issues) + validateNonEmptyCssValue(`${path}.${key}`, value, issues) } } function validateTypographySizeValue( path: string, value: unknown, - typography: TypographyPrimitiveSet, + sizes: Record | undefined, issues: EidosValidationIssue[] ): void { if (value === undefined) return if (typeof value === 'string') { - if (!TEXT_SIZE_SET.has(value) || !hasOwn(typography.sizes, value)) { + if (!TEXT_SIZE_SET.has(value) || !sizes || !hasOwn(sizes, value)) { issues.push({ path, message: `typography style points to missing text size "${value}"` @@ -1017,7 +1119,7 @@ function validateTypographySizeValue( return } - if (isTextMetric(value)) { + if (isPlainRecord(value) && isTextMetricLike(value)) { validateTextMetric(path, value, issues) return } @@ -1030,7 +1132,7 @@ function validateTypographySizeValue( message: `unknown responsive breakpoint "${key}"` }) } - validateTypographySizeValue(`${path}.${key}`, item, typography, issues) + validateTypographySizeValue(`${path}.${key}`, item, sizes, issues) } return } @@ -1043,10 +1145,19 @@ function validateTypographySizeValue( function validateNonEmptyCssValue( path: string, - value: string, + value: unknown, issues: EidosValidationIssue[] ): void { - if (value.trim() === '') { + if (typeof value !== 'string' && typeof value !== 'number') { + issues.push({ + path, + message: 'CSS value must be a string or number' + }) + return + } + + const text = String(value) + if (text.trim() === '') { issues.push({ path, message: 'CSS value cannot be empty' @@ -1054,7 +1165,7 @@ function validateNonEmptyCssValue( return } - if (!/[;{}]/.test(value)) return + if (!/[;{}]/.test(text)) return issues.push({ path, message: 'CSS value cannot contain declaration or block delimiters' @@ -1063,36 +1174,24 @@ function validateNonEmptyCssValue( function validateTextMetric( path: string, - metric: TextMetric, + metric: unknown, issues: EidosValidationIssue[] ): void { - if (metric.size.trim() === '') { + if (!isPlainRecord(metric)) { issues.push({ - path: `${path}.size`, - message: 'text metric size cannot be empty' - }) - } - if (metric.lineHeight.trim() === '') { - issues.push({ - path: `${path}.lineHeight`, - message: 'text metric lineHeight cannot be empty' - }) - } - if (metric.letterSpacing.trim() === '') { - issues.push({ - path: `${path}.letterSpacing`, - message: 'text metric letterSpacing cannot be empty' + path, + message: 'text metric must be a plain object' }) + return } + + validateNonEmptyCssValue(`${path}.size`, metric.size, issues) + validateNonEmptyCssValue(`${path}.lineHeight`, metric.lineHeight, issues) + validateNonEmptyCssValue(`${path}.letterSpacing`, metric.letterSpacing, issues) } -function isTextMetric(value: unknown): value is TextMetric { - return ( - isPlainRecord(value) && - typeof value.size === 'string' && - typeof value.lineHeight === 'string' && - typeof value.letterSpacing === 'string' - ) +function isTextMetricLike(value: Record): boolean { + return hasOwn(value, 'size') || hasOwn(value, 'lineHeight') || hasOwn(value, 'letterSpacing') } function validateCssTokenKey( diff --git a/src/uix/eidos/lib/contract.ts b/src/uix/eidos/lib/contract.ts index 88e4e3410..5d1b8ee2f 100644 --- a/src/uix/eidos/lib/contract.ts +++ b/src/uix/eidos/lib/contract.ts @@ -462,6 +462,7 @@ function appendTypographyContractTokens( function appendSizeContractTokens(tokens: EidosCssContractToken[], sizes: SizePrimitiveSet): void { for (const name of Object.keys(sizes)) { + const size = sizes[name as keyof SizePrimitiveSet] pushToken( tokens, 'static', @@ -481,14 +482,14 @@ function appendSizeContractTokens(tokens: EidosCssContractToken[], sizes: SizePr 'static', 'size', `size-${name}-font-line-height`, - `primitives.size.${name}.fontSize` + `primitives.typography.sizes.${size.fontSize}.lineHeight` ) pushToken( tokens, 'static', 'size', `size-${name}-font-letter-spacing`, - `primitives.size.${name}.fontSize` + `primitives.typography.sizes.${size.fontSize}.letterSpacing` ) pushToken( tokens, diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index 5d7d09517..b1247f924 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -603,10 +603,6 @@ function mergeThemeColor( } function getThemeSelector(themeId: string): string { - if (themeId === 'base-light') { - return `:root,\n[data-theme='${themeId}']` - } - return `[data-theme='${themeId}']` } diff --git a/src/uix/soma/components/link-preview/link-preview-provider.svelte.test.ts b/src/uix/soma/components/link-preview/link-preview-provider.svelte.test.ts index 030898670..22fb3817d 100644 --- a/src/uix/soma/components/link-preview/link-preview-provider.svelte.test.ts +++ b/src/uix/soma/components/link-preview/link-preview-provider.svelte.test.ts @@ -204,6 +204,7 @@ describe('LinkPreviewProvider', () => { expect(['top', 'right', 'bottom', 'left']).toContain(result.content.props['data-side']); expect(result.content.wrapperProps).toMatchObject({ id: 'link-preview-content-wrapper', + 'data-floating-wrapper': '', 'data-soma-floating-wrapper': '' }); expect(result.content.wrapperProps.style).toMatchObject({ diff --git a/src/uix/soma/components/tooltip/tooltip-provider.svelte.test.ts b/src/uix/soma/components/tooltip/tooltip-provider.svelte.test.ts index 8e9bfd1ab..0fa43eca6 100644 --- a/src/uix/soma/components/tooltip/tooltip-provider.svelte.test.ts +++ b/src/uix/soma/components/tooltip/tooltip-provider.svelte.test.ts @@ -249,6 +249,7 @@ describe('TooltipProvider', () => { }); expect(result.content.wrapperProps).toMatchObject({ id: 'tooltip-content-wrapper', + 'data-floating-wrapper': '', 'data-soma-floating-wrapper': '', dir: 'ltr' }); diff --git a/src/uix/soma/layers/floating/floating.svelte.ts b/src/uix/soma/layers/floating/floating.svelte.ts index d41444652..a1e2d3aac 100644 --- a/src/uix/soma/layers/floating/floating.svelte.ts +++ b/src/uix/soma/layers/floating/floating.svelte.ts @@ -184,6 +184,7 @@ export class FloatingContent { () => ({ id: this.opts.wrapperId.current, + 'data-floating-wrapper': '', 'data-soma-floating-wrapper': '', style: { ...this.floating.floatingStyles.current,