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,