@ -159,11 +159,30 @@ export function renderStaticCss(options: EidosConfig): string {
appendRecipeDeclarations ( declarations , options . recipes )
appendRecipeDeclarations ( declarations , options . recipes )
const blocks = [ renderBlock ( ':root' , declarations ) ]
const blocks = [ renderBlock ( ':root' , declarations ) ]
// Responsive typography style overrides — one media-query block per
// breakpoint that has at least one responsive size override.
const styleResponsive = collectTypographyStyleResponsive ( primitives . typography )
for ( const bp of STYLE_BREAKPOINT_ORDER ) {
const lines = styleResponsive [ bp ]
if ( ! lines || lines . length === 0 ) continue
const width = STYLE_BREAKPOINT_WIDTHS [ bp ]
const rootBlock = renderBlock ( ':root' , lines )
blocks . push ( ` @media (min-width: ${ width } px) { \ n ${ indentBlock ( rootBlock ) } \ n} ` )
}
if ( primitives . density ) blocks . push ( renderDensityBlocks ( ) )
if ( primitives . density ) blocks . push ( renderDensityBlocks ( ) )
return blocks . join ( '\n\n' )
return blocks . join ( '\n\n' )
}
}
function indentBlock ( block : string ) : string {
return block
. split ( '\n' )
. map ( ( line ) = > ( line ? ` \ t ${ line } ` : line ) )
. join ( '\n' )
}
export function renderContractCss (
export function renderContractCss (
options : EidosConfig ,
options : EidosConfig ,
renderOptions : RenderContractCssOptions = { }
renderOptions : RenderContractCssOptions = { }
@ -342,6 +361,172 @@ function appendTypographyDeclarations(
for ( const [ name , weight ] of Object . entries ( typography . weights ) ) {
for ( const [ name , weight ] of Object . entries ( typography . weights ) ) {
declarations . push ( cssVar ( ` font-weight- ${ name } ` , weight ) )
declarations . push ( cssVar ( ` font-weight- ${ name } ` , weight ) )
}
}
if ( typography . styles ) {
appendTypographyStyleDeclarations ( declarations , typography )
}
}
/ * *
* Breakpoint min - width thresholds matching ` $ libs/dom/responsive ` .
* ` base ` is the implicit 0 fallback — emitted into ` :root ` , not as a
* media query .
* /
const STYLE_BREAKPOINT_WIDTHS : Record < string , number > = {
sm : 480 ,
md : 768 ,
lg : 1024 ,
xl : 1280 ,
xxl : 1536
}
const STYLE_BREAKPOINT_ORDER = [ 'sm' , 'md' , 'lg' , 'xl' , 'xxl' ] as const
/ * *
* Emit the named typography styles ( hero , h1 . . h6 , body , prose , label ,
* caption , code , … ) as ` --style-{name}-{font-family,font-size,
* font - weight , line - height , letter - spacing , color } ` custom properties.
*
* Each style entry references primitives the foundation already emits
* ( ` var(--font-family-X) ` , ` var(--font-size-Y) ` , etc . ) . Components
* consume these tokens directly — ` <Heading level=1> ` reads
* ` var(--style-h1-font-size) ` and friends .
*
* Responsive sizes ( e . g . ` hero.size = { base: 'xxl', md: 'xxxl' } ` )
* are emitted as base declarations in ` :root ` plus per - breakpoint
* ` @media (min-width: …) ` overrides appended after the main block .
* Non - size fields ( family , weight , line - height , letter - spacing , color )
* are not responsive in this contract .
* /
function appendTypographyStyleDeclarations (
declarations : string [ ] ,
typography : TypographyPrimitiveSet
) : void {
const styles = typography . styles
if ( ! styles ) return
// Capture responsive size overrides per breakpoint. Same shape as
// `declarations` but keyed by the active breakpoint so we can emit
// them as media-query blocks after the base.
for ( const [ name , style ] of Object . entries ( styles ) ) {
if ( style . family ) {
declarations . push (
cssVar ( ` style- ${ name } -font-family ` , ` var(--font-family- ${ style . family } ) ` )
)
}
const sizeRes = style . size
if ( sizeRes !== undefined ) {
const baseSize = pickResponsiveBase ( sizeRes )
if ( baseSize !== undefined ) {
declarations . push (
cssVar ( ` style- ${ name } -font-size ` , resolveStyleSizeRef ( baseSize ) )
)
declarations . push (
cssVar (
` style- ${ name } -line-height ` ,
style . lineHeight ? ? resolveStyleLineHeightRef ( baseSize )
)
)
declarations . push (
cssVar (
` style- ${ name } -letter-spacing ` ,
style . letterSpacing ? ? resolveStyleLetterSpacingRef ( baseSize )
)
)
} else if ( style . lineHeight ) {
declarations . push ( cssVar ( ` style- ${ name } -line-height ` , style . lineHeight ) )
}
} else if ( style . lineHeight ) {
declarations . push ( cssVar ( ` style- ${ name } -line-height ` , style . lineHeight ) )
}
if ( style . weight !== undefined ) {
const weightRef =
typeof style . weight === 'number'
? String ( style . weight )
: ` var(--font-weight- ${ style . weight } ) `
declarations . push ( cssVar ( ` style- ${ name } -font-weight ` , weightRef ) )
}
if ( style . color ) {
declarations . push (
cssVar ( ` style- ${ name } -color ` , ` var(--color- ${ style . color . replace ( /\./g , '-' ) } ) ` )
)
}
}
}
/ * *
* Collect per - breakpoint style size overrides . Returns a record mapping
* breakpoint - > declaration list . Empty buckets are dropped by the
* caller before emitting media - query blocks .
* /
function collectTypographyStyleResponsive (
typography : TypographyPrimitiveSet | undefined
) : Record < string , string [ ] > {
const result : Record < string , string [ ] > = { }
if ( ! typography ? . styles ) return result
for ( const [ name , style ] of Object . entries ( typography . styles ) ) {
const sizeRes = style . size
if ( sizeRes === undefined ) continue
if ( typeof sizeRes !== 'object' || sizeRes === null || isTextMetric ( sizeRes ) ) continue
const responsive = sizeRes as Partial < Record < string , unknown > >
for ( const bp of STYLE_BREAKPOINT_ORDER ) {
const value = responsive [ bp ]
if ( value === undefined ) continue
const list = result [ bp ] ? ? ( result [ bp ] = [ ] )
list . push ( cssVar ( ` style- ${ name } -font-size ` , resolveStyleSizeRef ( value ) ) )
list . push ( cssVar ( ` style- ${ name } -line-height ` , resolveStyleLineHeightRef ( value ) ) )
list . push ( cssVar ( ` style- ${ name } -letter-spacing ` , resolveStyleLetterSpacingRef ( value ) ) )
}
}
return result
}
function pickResponsiveBase ( value : unknown ) : unknown {
if ( value === undefined || value === null ) return undefined
if ( typeof value === 'string' || typeof value === 'number' ) return value
if ( isTextMetric ( value ) ) return value
if ( typeof value === 'object' ) {
const obj = value as Partial < Record < string , unknown > >
if ( obj . base !== undefined ) return obj . base
// Fallback: take the first defined breakpoint in order.
for ( const bp of STYLE_BREAKPOINT_ORDER ) {
if ( obj [ bp ] !== undefined ) return obj [ bp ]
}
}
return undefined
}
function isTextMetric ( value : unknown ) : boolean {
return (
typeof value === 'object' &&
value !== null &&
'size' in value &&
'lineHeight' in value &&
'letterSpacing' in value
)
}
function resolveStyleSizeRef ( value : unknown ) : string {
if ( typeof value === 'string' ) return ` var(--font-size- ${ value } ) `
if ( isTextMetric ( value ) ) return ( value as { size : string } ) . size
return 'inherit'
}
function resolveStyleLineHeightRef ( value : unknown ) : string {
if ( typeof value === 'string' ) return ` var(--font-line-height- ${ value } ) `
if ( isTextMetric ( value ) ) return ( value as { lineHeight : string } ) . lineHeight
return 'inherit'
}
function resolveStyleLetterSpacingRef ( value : unknown ) : string {
if ( typeof value === 'string' ) return ` var(--font-letter-spacing- ${ value } ) `
if ( isTextMetric ( value ) ) return ( value as { letterSpacing : string } ) . letterSpacing
return 'normal'
}
}
function appendSizeDeclarations ( declarations : string [ ] , sizes : SizePrimitiveSet ) : void {
function appendSizeDeclarations ( declarations : string [ ] , sizes : SizePrimitiveSet ) : void {