Harden Eidos config and wrappers

active-uix
dev 5 months ago
parent eba7b4522d
commit c939de90c8

@ -2,6 +2,82 @@
Fecha de corte: 2026-05-16. Rama: `active-uix`. 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 `<style>` scoped; la regla de layout del
label vive en `accordion.css`.
- `Toast.Close` emite `data-icon-only` cuando renderiza el icono por defecto,
igual que `Dialog.Close`, `Drawer.Close` y `Popover.Close`.
- `Soma` expone `data-floating-wrapper` como marcador publico del wrapper
flotante; `Popover` y `Tooltip` dejan de depender de
`data-soma-floating-wrapper` en CSS.
- Guardias anadidas/reforzadas:
- `component-api-contract.test.ts` falla si un wrapper Eidos reintroduce
`onMount`, `<style>` scoped o `data-soma-*`.
- `component-visual-attrs.test.ts` cubre ahora `toast-close.svelte` y su
`data-icon-only`.
- Tests de Tooltip/LinkPreview esperan tambien el nuevo
`data-floating-wrapper`.
- Validado:
- `npx vitest run src/uix/eidos/component-api-contract.test.ts src/uix/eidos/component-visual-attrs.test.ts src/uix/eidos/recipe-css-contract.test.ts`
-> 3 archivos, 7 tests OK.
- `npx vitest run src/uix/soma/components/tooltip/tooltip-provider.svelte.test.ts src/uix/soma/components/link-preview/link-preview-provider.svelte.test.ts`
-> 2 archivos, 5 tests OK.
- `npx vitest run src/uix/soma/components/popover/popover-provider.svelte.test.ts`
-> 1 archivo, 2 tests OK.
- `npx vitest run src/uix/eidos` -> 7 archivos, 79 tests OK.
- `npm run check` -> 0 errores, 0 warnings.
- Pendiente deliberado:
- Revisar tokens/recipes antes de cambiar `font-size: 0.9rem/0.85rem` en
`dialog.css` y `popover.css`; no se toco ahora para no mezclarlo con el
problema visual de tipografia observado en `/routes/uix`.
Actualizacion Eidos config audit 2026-05-17:
- Resuelta la parte no-componente de la auditoria consolidada de
`src/uix/eidos`:
- `validateTypography(...)` ya no asume que `sizes`, `families`,
`weights` o `styles` existen y son objetos; ahora produce issues de
validacion.
- `validateSizePrimitive(...)` y `validateTextMetric(...)` degradan campos
ausentes o mal tipados a issues, sin `TypeError` por `.trim()`.
- `validateColorScale(...)` valida escala como objeto plano y rechaza pasos
`null` / `undefined`.
- `primitives.shadow` queda incluido en la validacion de shape.
- El contrato CSS de `size-*-font-line-height` y
`size-*-font-letter-spacing` apunta a
`primitives.typography.sizes.{fontSize}.lineHeight/letterSpacing`, no a
`primitives.size.*.fontSize`.
- `renderThemeCss(config, themeId)` ya no contiene el caso magico
`themeId === 'base-light'`; el renderer puro deriva el selector solo del
`themeId`. `ActiveEidos` sigue siendo quien decide usar `:root` cuando
aplica runtime CSS.
- Tests nuevos en `src/uix/eidos/active-eidos-config.test.ts` cubren
tipografia incompleta, size/text metric incompletos, escalas con `null`,
shape de `shadow`, paths de contrato tipografico y selector raw por
`themeId`.
- Validado:
- `npx vitest run src/uix/eidos` -> 7 archivos, 78 tests OK.
- `npm run check` -> 0 errores, 0 warnings.
- Nota historica: en ese momento quedo pendiente porque se pidio no tocar
componentes aun; queda resuelto en la actualizacion de componentes del
2026-05-17:
- `avatar-image.svelte`: estado `visible` derivable desde `src`.
- `tabs-indicator.svelte`: `ResizeObserver` observa el propio indicador.
- `accordion-trigger.svelte`: unico scoped style dentro de componente.
- `toast-close.svelte`: falta `data-icon-only` consistente con Close de
Dialog/Drawer/Popover.
- `popover.css` y `tooltip.css`: selectores acoplados a
`data-soma-floating-wrapper`.
Actualizacion Eidos ecosystem 2026-05-16: Actualizacion Eidos ecosystem 2026-05-16:
- Correccion de alcance: no seguir tocando demos/rutas ni - Correccion de alcance: no seguir tocando demos/rutas ni

@ -14,6 +14,7 @@ import {
extendEidosConfig, extendEidosConfig,
parseEidosConfigFromJson, parseEidosConfigFromJson,
parseEidosConfigDocument, parseEidosConfigDocument,
renderThemeCss,
STATIC_CONTROL_HEIGHT, STATIC_CONTROL_HEIGHT,
STATIC_DENSITY, STATIC_DENSITY,
STATIC_LAYOUT, STATIC_LAYOUT,
@ -342,6 +343,7 @@ describe('ActiveEidos config', () => {
const malformed = structuredClone(createThemeBaseEidos().toDocument()) const malformed = structuredClone(createThemeBaseEidos().toDocument())
;(malformed.options.primitives as unknown as Record<string, unknown>).typography = 'bad' ;(malformed.options.primitives as unknown as Record<string, unknown>).typography = 'bad'
;(malformed.options.primitives as unknown as Record<string, unknown>).shadow = 'bad'
;(malformed.options.themes!['base-light'] as unknown as Record<string, unknown>).color = 'bad' ;(malformed.options.themes!['base-light'] as unknown as Record<string, unknown>).color = 'bad'
;(malformed.options.recipes as unknown as Record<string, unknown>).tooltip = 'bad' ;(malformed.options.recipes as unknown as Record<string, unknown>).tooltip = 'bad'
@ -353,6 +355,7 @@ describe('ActiveEidos config', () => {
expect((error as EidosConfigValidationError).issues.map((issue) => issue.path)).toEqual( expect((error as EidosConfigValidationError).issues.map((issue) => issue.path)).toEqual(
expect.arrayContaining([ expect.arrayContaining([
'primitives.typography', 'primitives.typography',
'primitives.shadow',
'themes.base-light.color', 'themes.base-light.color',
'recipes.tooltip' 'recipes.tooltip'
]) ])
@ -360,6 +363,50 @@ describe('ActiveEidos config', () => {
} }
}) })
it('reports malformed typography primitive groups without throwing', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
const typography = cfg.primitives.typography as unknown as Record<string, unknown>
delete typography.sizes
typography.families = 'bad'
typography.weights = null
typography.styles = ['bad']
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(report.issues.map((issue) => issue.path)).toEqual(
expect.arrayContaining([
'primitives.typography.sizes',
'primitives.typography.families',
'primitives.typography.weights',
'primitives.typography.styles'
])
)
})
it('reports incomplete primitive values without throwing', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
delete (cfg.primitives.size!.md as unknown as Record<string, unknown>).paddingInline
delete (cfg.primitives.typography!.sizes.md as unknown as Record<string, unknown>).lineHeight
;(cfg.themes!['base-light'].color!.scales!.purple as unknown as Record<
string,
unknown
>)['9'] = null
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(report.issues.map((issue) => issue.path)).toEqual(
expect.arrayContaining([
'primitives.size.md.paddingInline',
'primitives.typography.sizes.md.lineHeight',
'themes.base-light.color.scales.purple.9'
])
)
})
it('ships typography families separately from typography styles', () => { it('ships typography families separately from typography styles', () => {
const typography = createThemeBaseEidos().snapshot().primitives.typography! const typography = createThemeBaseEidos().snapshot().primitives.typography!
@ -769,6 +816,20 @@ describe('ActiveEidos config', () => {
category: 'size', category: 'size',
path: 'primitives.size.md.controlHeight' path: 'primitives.size.md.controlHeight'
}) })
expect(contract.static).toContainEqual({
name: 'size-md-font-line-height',
cssVar: '--size-md-font-line-height',
scope: 'static',
category: 'size',
path: `primitives.typography.sizes.${STATIC_SIZE.md.fontSize}.lineHeight`
})
expect(contract.static).toContainEqual({
name: 'size-md-font-letter-spacing',
cssVar: '--size-md-font-letter-spacing',
scope: 'static',
category: 'size',
path: `primitives.typography.sizes.${STATIC_SIZE.md.fontSize}.letterSpacing`
})
expect(contract.static).toContainEqual({ expect(contract.static).toContainEqual({
name: 'container-width-md', name: 'container-width-md',
cssVar: '--container-width-md', cssVar: '--container-width-md',
@ -899,6 +960,13 @@ describe('ActiveEidos config', () => {
expect(css).toContain('--shadow-raised: var(--shadow-3);') expect(css).toContain('--shadow-raised: var(--shadow-3);')
}) })
it('renders raw theme CSS with a selector derived only from the theme id', () => {
const css = renderThemeCss(THEME_BASE_OPTIONS, 'base-light')
expect(css).toContain("[data-theme='base-light'] {")
expect(css).not.toContain(':root')
})
it('renders explicit alpha color scales when a theme provides them', () => { it('renders explicit alpha color scales when a theme provides them', () => {
const eidos = createThemeBaseEidos({ const eidos = createThemeBaseEidos({
themes: { themes: {

@ -94,4 +94,25 @@ describe('Eidos component API contract', () => {
expect(violations).toEqual([]) expect(violations).toEqual([])
}) })
it('keeps component wrappers free of legacy lifecycle and scoped styles', () => {
const violations: string[] = []
for (const component of readComponentDirs()) {
const dir = join(COMPONENTS_DIR, component)
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.svelte')) continue
const path = join(dir, entry.name)
const source = readFileSync(path, 'utf8')
if (/\bonMount\b/.test(source)) violations.push(`${component}/${entry.name}: onMount`)
if (/<style(?:\s|>)/.test(source)) violations.push(`${component}/${entry.name}: <style>`)
if (/\bdata-soma-/.test(source)) {
violations.push(`${component}/${entry.name}: data-soma-*`)
}
}
}
expect(violations).toEqual([])
})
}) })

@ -23,6 +23,7 @@ const VISUAL_ATTRS = [
['tabs/tabs.svelte', ['data-size', 'data-variant', 'data-fitted', 'data-collapsed', 'data-motion']], ['tabs/tabs.svelte', ['data-size', 'data-variant', 'data-fitted', 'data-collapsed', 'data-motion']],
['tabs/tabs-content.svelte', ['data-motion']], ['tabs/tabs-content.svelte', ['data-motion']],
['tabs/tabs-indicator.svelte', ['data-ready']], ['tabs/tabs-indicator.svelte', ['data-ready']],
['toast/toast-close.svelte', ['data-icon-only']],
['toast/toast-viewport.svelte', ['data-position']], ['toast/toast-viewport.svelte', ['data-position']],
['toggle/toggle.svelte', ['data-size', 'data-variant', 'data-block', 'data-icon-only']], ['toggle/toggle.svelte', ['data-size', 'data-variant', 'data-block', 'data-icon-only']],
['tooltip/tooltip-content.svelte', ['data-size', 'data-variant']], ['tooltip/tooltip-content.svelte', ['data-size', 'data-variant']],

@ -28,17 +28,10 @@
</script> </script>
<Accordion.Trigger {...rest} data-size={resolvedSize} data-indicator-placement={indicatorPlacement}> <Accordion.Trigger {...rest} data-size={resolvedSize} data-indicator-placement={indicatorPlacement}>
<span data-accordion-label class="accordion-trigger-label">{@render children?.()}</span> <span data-accordion-label>{@render children?.()}</span>
{#if !hideIndicator} {#if !hideIndicator}
<span data-accordion-indicator aria-hidden="true"> <span data-accordion-indicator aria-hidden="true">
<SvgChevron /> <SvgChevron />
</span> </span>
{/if} {/if}
</Accordion.Trigger> </Accordion.Trigger>
<style>
.accordion-trigger-label {
flex: 1 1 auto;
min-inline-size: 0;
}
</style>

@ -162,6 +162,11 @@
cursor: not-allowed; cursor: not-allowed;
} }
[data-accordion-label] {
flex: 1 1 auto;
min-inline-size: 0;
}
/* ── Indicator chevron ─────────────────────────────────────────────── /* ── Indicator chevron ───────────────────────────────────────────────
* `<SvgChevron>` lives inside the trigger's wrapper, marked * `<SvgChevron>` lives inside the trigger's wrapper, marked
* `data-accordion-indicator`. The chevron points DOWN by default; on * `data-accordion-indicator`. The chevron points DOWN by default; on

@ -2,18 +2,15 @@
import type { AvatarImageProps } from './types'; import type { AvatarImageProps } from './types';
let { src, alt = '', ...restProps }: AvatarImageProps = $props(); let { src, alt = '', ...restProps }: AvatarImageProps = $props();
let visible = $state(false); let failedSrc = $state<string | undefined>(undefined);
const visible = $derived(Boolean(src) && src !== failedSrc);
$effect(() => {
visible = Boolean(src);
});
function showImage() { function showImage() {
visible = true; if (failedSrc === src) failedSrc = undefined;
} }
function hideImage() { function hideImage() {
visible = false; failedSrc = src ?? undefined;
} }
</script> </script>

@ -48,9 +48,9 @@
* z-index unless the content provides one via `getComputedStyle().zIndex`, * z-index unless the content provides one via `getComputedStyle().zIndex`,
* which only works for positioned elements. When modal=true, the overlay * which only works for positioned elements. When modal=true, the overlay
* (z=60) would otherwise paint over the wrapper, hiding the panel and * (z=60) would otherwise paint over the wrapper, hiding the panel and
* making it unclickable. Lift the wrapper above the overlay directly via * making it unclickable. Soma exposes a public floating wrapper marker for
* :has() so we don't need to make `[data-popover-content]` positioned. */ * cross-layer styling, so Eidos does not depend on Soma internals here. */
[data-soma-floating-wrapper]:has(> [data-popover-content]) { [data-floating-wrapper]:has(> [data-popover-content]) {
z-index: var(--popover-content-z); z-index: var(--popover-content-z);
} }

@ -71,6 +71,7 @@
const ro = new ResizeObserver(() => measure()); const ro = new ResizeObserver(() => measure());
ro.observe(list); ro.observe(list);
for (const child of Array.from(list.children)) { for (const child of Array.from(list.children)) {
if (child === el) continue;
if (child instanceof HTMLElement) ro.observe(child); if (child instanceof HTMLElement) ro.observe(child);
} }

@ -51,6 +51,27 @@
let isCollapsed = $state(false); 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(() => { $effect(() => {
const el = ref; const el = ref;
const px = collapsePx; const px = collapsePx;
@ -66,17 +87,6 @@
ro.observe(el); ro.observe(el);
return () => ro.disconnect(); return () => ro.disconnect();
}); });
$effect(() => {
setTabsEidosContext({
size: resolvedSize,
variant,
fitted,
collapseAt,
scrollable,
motionPreset
});
});
</script> </script>
<SomaTabs.Provider <SomaTabs.Provider

@ -5,7 +5,7 @@
let { children, ...rest }: ToastCloseProps = $props(); let { children, ...rest }: ToastCloseProps = $props();
</script> </script>
<Toast.Close {...rest}> <Toast.Close {...rest} data-icon-only={!children ? '' : undefined}>
{#if children} {#if children}
{@render children?.()} {@render children?.()}
{:else} {:else}

@ -1,6 +1,6 @@
/* ── Content (variant-shared base) ──────────────────────────────────────── */ /* ── 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); z-index: var(--tooltip-content-z);
} }

@ -34,10 +34,7 @@ import {
type EidosConfig, type EidosConfig,
type EidosConfigSnapshot, type EidosConfigSnapshot,
type RecipeTokenMap, type RecipeTokenMap,
type SizePrimitive, type ThemeDefinition
type TextMetric,
type ThemeDefinition,
type TypographyPrimitiveSet
} from './config-types' } from './config-types'
import { createEidosCssContract } from './contract' import { createEidosCssContract } from './contract'
import { import {
@ -190,7 +187,8 @@ function validateEidosConfigShape(
'motion', 'motion',
'icon', 'icon',
'opacity', 'opacity',
'zIndex' 'zIndex',
'shadow'
] as const ] as const
for (const key of primitiveKeys) { for (const key of primitiveKeys) {
@ -490,13 +488,23 @@ function validateColorScale(
scale: ColorScale, scale: ColorScale,
issues: EidosValidationIssue[] issues: EidosValidationIssue[]
): void { ): void {
if (!isPlainRecord(scale)) {
issues.push({
path,
message: 'color scale must be a plain object'
})
return
}
for (const step of COLOR_SCALE_STEPS) { for (const step of COLOR_SCALE_STEPS) {
if (!hasOwn(scale, step) || scale[step] === '') { if (!hasOwn(scale, step)) {
issues.push({ issues.push({
path: `${path}.${step}`, path: `${path}.${step}`,
message: 'color scales must define every step from 1 to 12' 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 { function validateTypography(options: EidosConfig, issues: EidosValidationIssue[]): void {
const typography = options.primitives.typography const typography = options.primitives.typography
if (!typography) return 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) { if (sizes) {
const metric = typography.sizes[size] for (const size of TEXT_SIZES) {
if (!metric) { const metric = sizes[size]
issues.push({ if (!metric) {
path: `primitives.typography.sizes.${size}`, issues.push({
message: 'every canonical text size must be defined' path: `primitives.typography.sizes.${size}`,
}) message: 'every canonical text size must be defined'
continue })
continue
}
validateTextMetric(`primitives.typography.sizes.${size}`, metric, issues)
} }
validateTextMetric(`primitives.typography.sizes.${size}`, metric, issues)
}
for (const name of Object.keys(typography.sizes)) { for (const name of Object.keys(sizes)) {
validateCssTokenSuffix(`primitives.typography.sizes.${name}`, name, 'typography size', issues) validateCssTokenSuffix(
if (!TEXT_SIZE_SET.has(name)) { `primitives.typography.sizes.${name}`,
issues.push({ name,
path: `primitives.typography.sizes.${name}`, 'typography size',
message: 'unknown typography size key' 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)) { if (families) {
validateCssTokenSuffix( for (const [name, family] of Object.entries(families)) {
`primitives.typography.families.${name}`, validateCssTokenSuffix(
name, `primitives.typography.families.${name}`,
'typography family', name,
issues 'typography family',
) issues
if (family.family.trim() === '') { )
issues.push({ if (!isPlainRecord(family)) {
path: `primitives.typography.families.${name}.family`, issues.push({
message: 'font family name cannot be empty' 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)) { if (weights) {
validateCssTokenSuffix( for (const name of Object.keys(weights)) {
`primitives.typography.weights.${name}`, validateCssTokenSuffix(
name, `primitives.typography.weights.${name}`,
'typography weight', name,
issues 'typography weight',
) issues
)
validateNonEmptyCssValue(`primitives.typography.weights.${name}`, weights[name], issues)
}
} }
for (const [name, style] of Object.entries(typography.styles)) { if (styles) {
const path = `primitives.typography.styles.${name}` for (const [name, style] of Object.entries(styles)) {
validateCssTokenSuffix(path, name, 'typography style', issues) const path = `primitives.typography.styles.${name}`
validateCssTokenSuffix(path, name, 'typography style', issues)
if (style.family !== undefined && !hasOwn(typography.families, style.family)) { if (!isPlainRecord(style)) {
issues.push({ issues.push({
path: `${path}.family`, path,
message: `typography style points to missing family "${style.family}"` message: 'typography style must be a plain object'
}) })
} continue
}
if ( const familyRef = style.family
typeof style.weight === 'string' && if (
style.weight.trim() !== '' && familyRef !== undefined &&
!hasOwn(typography.weights, style.weight) (typeof familyRef !== 'string' || !families || !hasOwn(families, familyRef))
) { ) {
issues.push({ issues.push({
path: `${path}.weight`, path: `${path}.family`,
message: `typography style points to missing weight "${style.weight}"` 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<string, unknown> | 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 { function validateSizePrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void {
const sizes = options.primitives.size const sizes = options.primitives.size
if (!sizes) return if (!sizes) return
@ -694,36 +768,64 @@ function validateSizePrimitives(options: EidosConfig, issues: EidosValidationIss
function validateSizePrimitive( function validateSizePrimitive(
path: string, path: string,
size: SizePrimitive, size: unknown,
options: EidosConfig, options: EidosConfig,
issues: EidosValidationIssue[] issues: EidosValidationIssue[]
): void { ): 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({ issues.push({
path: `${path}.controlHeight`, 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 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({ issues.push({
path: `${path}.fontSize`, 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({ issues.push({
path: `${path}.iconSize`, 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({ issues.push({
path: `${path}.radius`, 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 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)) { for (const [key, value] of Object.entries(record)) {
validateCssTokenSuffix(`${path}.${key}`, key, label, issues) validateCssTokenSuffix(`${path}.${key}`, key, label, issues)
validateNonEmptyCssValue(`${path}.${key}`, String(value), issues) validateNonEmptyCssValue(`${path}.${key}`, value, issues)
} }
} }
function validateTypographySizeValue( function validateTypographySizeValue(
path: string, path: string,
value: unknown, value: unknown,
typography: TypographyPrimitiveSet, sizes: Record<string, unknown> | undefined,
issues: EidosValidationIssue[] issues: EidosValidationIssue[]
): void { ): void {
if (value === undefined) return if (value === undefined) return
if (typeof value === 'string') { 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({ issues.push({
path, path,
message: `typography style points to missing text size "${value}"` message: `typography style points to missing text size "${value}"`
@ -1017,7 +1119,7 @@ function validateTypographySizeValue(
return return
} }
if (isTextMetric(value)) { if (isPlainRecord(value) && isTextMetricLike(value)) {
validateTextMetric(path, value, issues) validateTextMetric(path, value, issues)
return return
} }
@ -1030,7 +1132,7 @@ function validateTypographySizeValue(
message: `unknown responsive breakpoint "${key}"` message: `unknown responsive breakpoint "${key}"`
}) })
} }
validateTypographySizeValue(`${path}.${key}`, item, typography, issues) validateTypographySizeValue(`${path}.${key}`, item, sizes, issues)
} }
return return
} }
@ -1043,10 +1145,19 @@ function validateTypographySizeValue(
function validateNonEmptyCssValue( function validateNonEmptyCssValue(
path: string, path: string,
value: string, value: unknown,
issues: EidosValidationIssue[] issues: EidosValidationIssue[]
): void { ): 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({ issues.push({
path, path,
message: 'CSS value cannot be empty' message: 'CSS value cannot be empty'
@ -1054,7 +1165,7 @@ function validateNonEmptyCssValue(
return return
} }
if (!/[;{}]/.test(value)) return if (!/[;{}]/.test(text)) return
issues.push({ issues.push({
path, path,
message: 'CSS value cannot contain declaration or block delimiters' message: 'CSS value cannot contain declaration or block delimiters'
@ -1063,36 +1174,24 @@ function validateNonEmptyCssValue(
function validateTextMetric( function validateTextMetric(
path: string, path: string,
metric: TextMetric, metric: unknown,
issues: EidosValidationIssue[] issues: EidosValidationIssue[]
): void { ): void {
if (metric.size.trim() === '') { if (!isPlainRecord(metric)) {
issues.push({ issues.push({
path: `${path}.size`, path,
message: 'text metric size cannot be empty' message: 'text metric must be a plain object'
})
}
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'
}) })
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 { function isTextMetricLike(value: Record<string, unknown>): boolean {
return ( return hasOwn(value, 'size') || hasOwn(value, 'lineHeight') || hasOwn(value, 'letterSpacing')
isPlainRecord(value) &&
typeof value.size === 'string' &&
typeof value.lineHeight === 'string' &&
typeof value.letterSpacing === 'string'
)
} }
function validateCssTokenKey( function validateCssTokenKey(

@ -462,6 +462,7 @@ function appendTypographyContractTokens(
function appendSizeContractTokens(tokens: EidosCssContractToken[], sizes: SizePrimitiveSet): void { function appendSizeContractTokens(tokens: EidosCssContractToken[], sizes: SizePrimitiveSet): void {
for (const name of Object.keys(sizes)) { for (const name of Object.keys(sizes)) {
const size = sizes[name as keyof SizePrimitiveSet]
pushToken( pushToken(
tokens, tokens,
'static', 'static',
@ -481,14 +482,14 @@ function appendSizeContractTokens(tokens: EidosCssContractToken[], sizes: SizePr
'static', 'static',
'size', 'size',
`size-${name}-font-line-height`, `size-${name}-font-line-height`,
`primitives.size.${name}.fontSize` `primitives.typography.sizes.${size.fontSize}.lineHeight`
) )
pushToken( pushToken(
tokens, tokens,
'static', 'static',
'size', 'size',
`size-${name}-font-letter-spacing`, `size-${name}-font-letter-spacing`,
`primitives.size.${name}.fontSize` `primitives.typography.sizes.${size.fontSize}.letterSpacing`
) )
pushToken( pushToken(
tokens, tokens,

@ -603,10 +603,6 @@ function mergeThemeColor(
} }
function getThemeSelector(themeId: string): string { function getThemeSelector(themeId: string): string {
if (themeId === 'base-light') {
return `:root,\n[data-theme='${themeId}']`
}
return `[data-theme='${themeId}']` return `[data-theme='${themeId}']`
} }

@ -204,6 +204,7 @@ describe('LinkPreviewProvider', () => {
expect(['top', 'right', 'bottom', 'left']).toContain(result.content.props['data-side']); expect(['top', 'right', 'bottom', 'left']).toContain(result.content.props['data-side']);
expect(result.content.wrapperProps).toMatchObject({ expect(result.content.wrapperProps).toMatchObject({
id: 'link-preview-content-wrapper', id: 'link-preview-content-wrapper',
'data-floating-wrapper': '',
'data-soma-floating-wrapper': '' 'data-soma-floating-wrapper': ''
}); });
expect(result.content.wrapperProps.style).toMatchObject({ expect(result.content.wrapperProps.style).toMatchObject({

@ -249,6 +249,7 @@ describe('TooltipProvider', () => {
}); });
expect(result.content.wrapperProps).toMatchObject({ expect(result.content.wrapperProps).toMatchObject({
id: 'tooltip-content-wrapper', id: 'tooltip-content-wrapper',
'data-floating-wrapper': '',
'data-soma-floating-wrapper': '', 'data-soma-floating-wrapper': '',
dir: 'ltr' dir: 'ltr'
}); });

@ -184,6 +184,7 @@ export class FloatingContent {
() => () =>
({ ({
id: this.opts.wrapperId.current, id: this.opts.wrapperId.current,
'data-floating-wrapper': '',
'data-soma-floating-wrapper': '', 'data-soma-floating-wrapper': '',
style: { style: {
...this.floating.floatingStyles.current, ...this.floating.floatingStyles.current,

Loading…
Cancel
Save

Powered by TurnKey Linux.