diff --git a/src/uix/eidos/active-eidos-config.test.ts b/src/uix/eidos/active-eidos-config.test.ts index 00093144e..6e402c06a 100644 --- a/src/uix/eidos/active-eidos-config.test.ts +++ b/src/uix/eidos/active-eidos-config.test.ts @@ -543,6 +543,15 @@ describe('ActiveEidos config', () => { expect(css).toContain('corner-shape: squircle;'); }); + it('emits the Fase 2 concentric nesting helper ([data-shape-nest] + --shape-nest-gap)', () => { + const css = createThemeBaseEidos().renderStaticCss(); + expect(css).toContain('--shape-nest-gap: var(--space-2);'); + expect(css).toContain('[data-shape-nest] {'); + expect(css).toContain( + 'border-radius: max(0px, calc(var(--shape-outer-radius, var(--radius-lg)) - var(--shape-nest-gap)));' + ); + }); + it('emits the Phase 3 typography scales + optical tracking', () => { const css = createThemeBaseEidos().renderStaticCss() expect(css).toContain('--tracking-tight: -0.02em;') diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 8d71582ec..9b2b5b9be 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -458,6 +458,7 @@ --depth-recessed-shadow: inset 0 1px 2px color-mix(in srgb, var(--color-neutral-contrast) 12%, transparent); --depth-recessed-z: var(--z-index-base); --shape-smoothing: 2; + --shape-nest-gap: var(--space-2); --control-height-2xs: var(--control-height-xxs); --radius-xs: var(--radius-sm); --font-sans: var(--font-family-primary); @@ -4378,6 +4379,10 @@ corner-shape: scoop; } +[data-shape-nest] { + border-radius: max(0px, calc(var(--shape-outer-radius, var(--radius-lg)) - var(--shape-nest-gap))); +} + [data-density='compact'] { --density-space-scale: var(--density-compact-space-scale); --density-control-scale: var(--density-compact-control-scale); diff --git a/src/uix/eidos/lib/config-types.ts b/src/uix/eidos/lib/config-types.ts index b26ed6158..b3fe9fe48 100644 --- a/src/uix/eidos/lib/config-types.ts +++ b/src/uix/eidos/lib/config-types.ts @@ -709,6 +709,13 @@ export interface DepthPrimitiveSet { export interface ShapePrimitiveSet { /** Superellipse exponent for the `continuous` family, emitted as `--shape-smoothing`. */ readonly smoothing: string; + /** + * Concentric nesting gap, emitted as `--shape-nest-gap`. A `[data-shape-nest]` child derives + * `border-radius: max(0px, var(--shape-outer-radius) − gap)` so its corners stay parallel to + * the parent's — the harmonic-radii rule (SHAPE_ENGINE_RFC Fase 2). Optional: omit to ship a + * shape system without the nesting helper. + */ + readonly nestGap?: string; /** `data-shape='{family}'` → `corner-shape` value (round / superellipse(…) / bevel / scoop). */ readonly families: Record; } diff --git a/src/uix/eidos/lib/config.ts b/src/uix/eidos/lib/config.ts index 8d5db5b88..20e5c2527 100644 --- a/src/uix/eidos/lib/config.ts +++ b/src/uix/eidos/lib/config.ts @@ -932,6 +932,8 @@ function validateShapePrimitives(options: EidosConfig, issues: EidosValidationIs const shape = options.primitives.shape if (shape === undefined) return validateNonEmptyCssValue('primitives.shape.smoothing', shape.smoothing, issues) + if (shape.nestGap !== undefined) + validateNonEmptyCssValue('primitives.shape.nestGap', shape.nestGap, issues) if (!isPlainRecord(shape.families)) { issues.push({ path: 'primitives.shape.families', message: 'shape families must be a plain object' }) return diff --git a/src/uix/eidos/lib/primitives/static.ts b/src/uix/eidos/lib/primitives/static.ts index f538a727c..0e72d2145 100644 --- a/src/uix/eidos/lib/primitives/static.ts +++ b/src/uix/eidos/lib/primitives/static.ts @@ -250,6 +250,7 @@ export const STATIC_DEPTH: DepthPrimitiveSet = { // `corner-shape` is unsupported. (SHAPE_ENGINE_RFC) export const STATIC_SHAPE: ShapePrimitiveSet = { smoothing: '2', + nestGap: 'var(--space-2)', families: { rounded: 'round', continuous: 'superellipse(var(--shape-smoothing))', diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index 041789d84..507d97bab 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -824,21 +824,33 @@ function renderDepthBlocks(depth: DepthPrimitiveSet): string[] { // ── Shape (SHAPE_ENGINE_RFC) ───────────────────────────────────────────────── -/** Emit the shape token — `--shape-smoothing` (superellipse exponent of the `continuous` family). */ +/** Emit the shape tokens — `--shape-smoothing` (superellipse exponent) + `--shape-nest-gap`. */ function appendShapeDeclarations(declarations: string[], shape: ShapePrimitiveSet): void { declarations.push(cssVar('shape-smoothing', shape.smoothing)) + if (shape.nestGap !== undefined) declarations.push(cssVar('shape-nest-gap', shape.nestGap)) } /** * `[data-shape='{family}']` sets `corner-shape` (continuity / perceptual family) — opt-in, so the * magnitude (`border-radius`) stays universal and the corners degrade to the plain arc where - * `corner-shape` is unsupported. Magnitude is never touched here. (SHAPE_ENGINE_RFC) + * `corner-shape` is unsupported. `[data-shape-nest]` adds the concentric harmony: a child's + * radius = the parent's `--shape-outer-radius` minus the nest gap, so nested corners stay + * parallel. Magnitude is never touched on the family rules. (SHAPE_ENGINE_RFC) */ function renderShapeBlocks(shape: ShapePrimitiveSet): string[] { const blocks: string[] = [] for (const [family, value] of Object.entries(shape.families)) { blocks.push(renderBlock(`[data-shape='${family}']`, [`corner-shape: ${value};`])) } + // Concentric nesting (Fase 2): inner radius = outer − gap, clamped ≥ 0. The parent exposes + // its radius via `--shape-outer-radius` (inherited); the gap defaults to the nest token. + if (shape.nestGap !== undefined) { + blocks.push( + renderBlock(`[data-shape-nest]`, [ + `border-radius: max(0px, calc(var(--shape-outer-radius, var(--radius-lg)) - var(--shape-nest-gap)));` + ]) + ) + } return blocks }