You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/lib/contract.ts

490 lines
18 KiB

import { COLOR_ROLES, type EidosConfig } from './config-types'
import type {
EidosCssContract,
EidosCssContractScope,
EidosCssContractToken
} from './config-types'
import { emptyCssVar, renderBlock, renderStaticCss, renderThemeCss } from './render-css'
/**
* The CSS contract is DERIVED from the emission itself (census-as-data,
* 2026-07-06, theming audit A.9): `createEidosCssContract` renders the same
* static + theme CSS the generators ship and parses every public custom
* property out of it. There is no second hand-maintained enumeration to
* drift — a token exists in the contract iff the generators emit it
* (private `--_*` recipe machinery excluded by the same rule as always).
*
* The previous contract was a parallel config-walk and had drifted by 211
* tokens: whole families born after it (depth, shape, scaling, breakpoints,
* named styles, the overlay z band, …) never got a branch, so
* `setCssVariables` (strict) rejected legitimately-emitted vocabulary and
* the `renderContractCss` skeleton under-reported the theming surface.
*
* Metadata (`category` / `path`) is resolved from the token NAME plus the
* config by an ordered rule table. A family without a rule still enters the
* contract — it just gets its first segment as category and `(derived)` as
* path. Coverage can never silently regress; only metadata richness can.
*/
const contractCache = new WeakMap<EidosConfig, EidosCssContract>()
export function createEidosCssContract(options: EidosConfig): EidosCssContract {
const cached = contractCache.get(options)
if (cached) return cached
const staticNames = extractPublicVariableNames(renderStaticCss(options))
const themeNames = new Set<string>()
const themeIds = Object.keys(options.themes ?? {})
if (themeIds.length === 0) {
// CSS-only setups (themeSource: 'css') configure no themes, but the
// theme-scope vocabulary (scales, roles, surfaces, shadows) still
// exists — derive it from a synthetic empty theme so the fillable
// `renderContractCss` skeleton stays complete.
const probe: EidosConfig = { ...options, themes: { 'contract-probe': {} } }
for (const name of extractPublicVariableNames(
renderThemeCss(probe, 'contract-probe', { scales: 'all' })
)) {
themeNames.add(name)
}
} else {
for (const themeId of themeIds) {
for (const name of extractPublicVariableNames(
renderThemeCss(options, themeId, { scales: 'all' })
)) {
themeNames.add(name)
}
}
}
const resolver = createMetadataResolver(options)
const staticTokens = [...staticNames].map((name) => createToken(name, 'static', resolver))
const themeTokens = [...themeNames]
.filter((name) => !staticNames.has(name))
.map((name) => createToken(name, 'theme', resolver))
const contract = Object.freeze({
static: Object.freeze(staticTokens),
theme: Object.freeze(themeTokens)
})
contractCache.set(options, contract)
return contract
}
export function flattenEidosCssContract(
contract: EidosCssContract
): readonly EidosCssContractToken[] {
return Object.freeze([...contract.static, ...contract.theme])
}
export interface RenderContractCssOptions {
readonly staticSelector?: string
readonly themeSelector?: string
}
/** Fillable skeleton (`--token: ;`) for external CSS-only themes and tooling. */
export function renderContractCss(
options: EidosConfig,
renderOptions: RenderContractCssOptions = {}
): string {
const contract = createEidosCssContract(options)
const staticDeclarations = contract.static.map((token) => emptyCssVar(token.name))
const themeDeclarations = contract.theme.map((token) => emptyCssVar(token.name))
return [
renderBlock(renderOptions.staticSelector ?? ':root', staticDeclarations),
renderBlock(renderOptions.themeSelector ?? '[data-theme]', themeDeclarations)
].join('\n\n')
}
// Matches `--name:` declarations (a `var(--name)` reference is followed by
// `)`/`,`, never `:`, so consumption sites don't match).
const DECLARATION_PATTERN = /--([a-zA-Z0-9_][a-zA-Z0-9_-]*)\s*:/g
function extractPublicVariableNames(css: string): Set<string> {
const names = new Set<string>()
for (const match of css.matchAll(DECLARATION_PATTERN)) {
const name = match[1]
if (!name.startsWith('_')) names.add(name)
}
return names
}
interface TokenMetadata {
readonly category: string
readonly path: string
}
type MetadataResolver = (name: string, scope: EidosCssContractScope) => TokenMetadata
function createToken(
name: string,
scope: EidosCssContractScope,
resolve: MetadataResolver
): EidosCssContractToken {
const { category, path } = resolve(name, scope)
return Object.freeze({ name, cssVar: `--${name}`, scope, category, path })
}
function camelize(kebab: string): string {
return kebab.replace(/-([a-z0-9])/g, (_, ch: string) => ch.toUpperCase())
}
/** Longest-first so multi-segment keys win over shorter homonym prefixes. */
function byLengthDesc(a: string, b: string): number {
return b.length - a.length
}
function createMetadataResolver(options: EidosConfig): MetadataResolver {
// Exact recipe-token names, straight from the config (same exclusions as
// the emitter: reserved sibling keys and private `_` keys never emit
// public names).
const recipeTokenPaths = new Map<string, TokenMetadata>()
for (const [component, tokens] of Object.entries(options.recipes ?? {})) {
for (const key of Object.keys(tokens)) {
if (key === 'composition' || key === 'container' || key.startsWith('_')) continue
recipeTokenPaths.set(`${component}-${key}`, {
category: `recipe.${component}`,
path: `recipes.${component}.${key}`
})
}
}
const sizeRecord = options.primitives.size
const sizeKeys = Object.keys(sizeRecord ?? {}).sort(byLengthDesc)
const sizeFontKey = (k: string): string =>
String(sizeRecord?.[k as keyof typeof sizeRecord]?.fontSize)
const styleKeys = Object.keys(options.primitives.typography?.styles ?? {}).sort(byLengthDesc)
const rolesByLength = [...COLOR_ROLES].sort(byLengthDesc)
const SIZE_SLOT_PATHS: Record<string, (size: string) => string> = {
'control-height': (k) => `primitives.size.${k}.controlHeight`,
'font-size': (k) => `primitives.size.${k}.fontSize`,
'font-line-height': (k) => `primitives.typography.sizes.${sizeFontKey(k)}.lineHeight`,
'font-letter-spacing': (k) => `primitives.typography.sizes.${sizeFontKey(k)}.letterSpacing`,
'icon-size': (k) => `primitives.size.${k}.iconSize`,
'padding-inline': (k) => `primitives.size.${k}.paddingInline`,
'padding-block': (k) => `primitives.size.${k}.paddingBlock`,
gap: (k) => `primitives.size.${k}.gap`,
radius: (k) => `primitives.size.${k}.radius`
}
const STYLE_PROP_PATHS: Record<string, string> = {
'font-family': 'family',
'font-size': 'size',
'line-height': 'lineHeight',
'letter-spacing': 'letterSpacing',
'font-weight': 'weight',
color: 'color'
}
return (name, scope) => {
const recipeMeta = recipeTokenPaths.get(name)
if (recipeMeta) return recipeMeta
if (scope === 'theme') {
let match = /^scale-(.+)-a(\d+)$/.exec(name)
if (match) {
return {
category: 'color.scale.alpha',
path: `themes.*.color.alphaScales.${match[1]}.${match[2]}`
}
}
match = /^scale-(.+)-(\d+)$/.exec(name)
if (match) {
return { category: 'color.scale', path: `themes.*.color.scales.${match[1]}.${match[2]}` }
}
match = /^primitive-(.+)-a(\d+)$/.exec(name)
if (match) {
return {
category: 'color.primitive.alpha',
path: `semantics.color.roles.${match[1]}.alpha.${match[2]}`
}
}
match = /^primitive-(.+)-(\d+)$/.exec(name)
if (match) {
return { category: 'color.primitive', path: `semantics.color.roles.${match[1]}.${match[2]}` }
}
for (const role of rolesByLength) {
if (!name.startsWith(`color-${role}-`)) continue
const slot = name.slice(`color-${role}-`.length)
// P2-4 translucent role surfaces are derived from the alpha steps,
// not authored slots — mirror their provenance.
if (slot === 'surface') {
return { category: 'color.role', path: `(derived) primitive-${role}-a2` }
}
if (slot === 'surface-hover') {
return { category: 'color.role', path: `(derived) primitive-${role}-a3` }
}
return {
category: 'color.role',
path: `semantics.color.roles.${role}.slots.${camelize(slot)}`
}
}
if (name === 'color-overlay') {
return { category: 'color.surface', path: 'themes.*.color.surface.backdrop' }
}
if (name.startsWith('color-surface-')) {
return {
category: 'color.surface',
path: `themes.*.color.surface.${camelize(name.slice('color-surface-'.length))}`
}
}
if (name.startsWith('color-content-')) {
return {
category: 'color.content',
path: `themes.*.color.content.${camelize(name.slice('color-content-'.length))}`
}
}
if (name.startsWith('color-border-')) {
return {
category: 'color.border',
path: `themes.*.color.border.${camelize(name.slice('color-border-'.length))}`
}
}
if (name === 'focus-ring-color') {
return { category: 'color.focus', path: 'themes.*.color.focus.ring' }
}
if (name === 'focus-ring-color-error') {
return { category: 'color.focus', path: 'themes.*.color.focus.ringError' }
}
if (name.startsWith('shadow-')) {
return { category: 'shadow', path: `themes.*.shadow.${name.slice('shadow-'.length)}` }
}
return { category: name.split('-')[0], path: '(derived)' }
}
for (const sizeKey of sizeKeys) {
const prefix = `size-${sizeKey}-`
if (!name.startsWith(prefix)) continue
const slot = name.slice(prefix.length)
const pathFor = SIZE_SLOT_PATHS[slot]
return { category: 'size', path: pathFor ? pathFor(sizeKey) : '(derived)' }
}
for (const styleKey of styleKeys) {
const prefix = `style-${styleKey}-`
if (!name.startsWith(prefix)) continue
const prop = STYLE_PROP_PATHS[name.slice(prefix.length)]
return {
category: 'typography.style',
path: prop ? `primitives.typography.styles.${styleKey}.${prop}` : '(derived)'
}
}
if (name.startsWith('space-')) {
return { category: 'space', path: `primitives.space.${name.slice('space-'.length)}` }
}
if (name.startsWith('control-height-')) {
return {
category: 'control-height',
path: `primitives.controlHeight.${name.slice('control-height-'.length)}`
}
}
if (name === 'radius-factor') {
return { category: 'radius', path: 'primitives.radiusFactor' }
}
if (name === 'radius-default') {
return { category: 'radius', path: 'primitives.radiusDefault' }
}
if (name.startsWith('radius-')) {
return { category: 'radius', path: `primitives.radius.${name.slice('radius-'.length)}` }
}
if (name.startsWith('border-width-')) {
return {
category: 'border.width',
path: `primitives.border.width.${name.slice('border-width-'.length)}`
}
}
if (name.startsWith('border-style-')) {
return {
category: 'border.style',
path: `primitives.border.style.${name.slice('border-style-'.length)}`
}
}
if (name === 'border-width') return { category: 'border', path: 'primitives.border.defaultWidth' }
if (name === 'border-style') return { category: 'border', path: 'primitives.border.defaultStyle' }
if (name === 'border') return { category: 'border', path: 'primitives.border' }
if (name === 'ring-inset-width') {
return { category: 'ring-inset', path: 'primitives.border.insetRingWidth' }
}
if (name === 'focus-ring-offset') {
return { category: 'focus-ring', path: 'primitives.focusRing.offset' }
}
if (name === 'focus-ring-width') {
return { category: 'focus-ring', path: 'primitives.focusRing.width' }
}
if (name === 'focus-ring-inner-width') {
return { category: 'focus-ring', path: 'primitives.focusRing.innerWidth' }
}
if (name === 'focus-ring-error') {
return { category: 'focus-ring', path: 'primitives.focusRing.error' }
}
if (name === 'focus-ring') return { category: 'focus-ring', path: 'primitives.focusRing' }
if (name === 'press-scale') return { category: 'press', path: 'primitives.press.scale' }
if (name === 'press-duration') return { category: 'press', path: 'primitives.press.duration' }
if (name.startsWith('state-')) {
return { category: 'state', path: `primitives.state.${name.slice('state-'.length)}` }
}
if (name === 'floating-gap-menu') {
return { category: 'floating', path: 'primitives.floating.gapMenu' }
}
if (name === 'floating-gap-panel') {
return { category: 'floating', path: 'primitives.floating.gapPanel' }
}
if (name.startsWith('container-width-')) {
return {
category: 'layout.container-width',
path: `primitives.layout.containerWidth.${name.slice('container-width-'.length)}`
}
}
if (name === 'container-padding-inline') {
return { category: 'layout.container-padding', path: 'primitives.layout.containerPaddingInline' }
}
if (name.startsWith('content-width-')) {
return {
category: 'layout.content-width',
path: `primitives.layout.contentWidth.${name.slice('content-width-'.length)}`
}
}
if (name.startsWith('aspect-ratio-')) {
return {
category: 'layout.aspect-ratio',
path: `primitives.layout.aspectRatio.${name.slice('aspect-ratio-'.length)}`
}
}
if (name === 'density-space-scale') {
return { category: 'density.active', path: 'primitives.density.spaceScale' }
}
if (name === 'density-control-scale') {
return { category: 'density.active', path: 'primitives.density.controlScale' }
}
let match = /^density-(.+)-space-scale$/.exec(name)
if (match) {
return { category: 'density.space-scale', path: `primitives.density.spaceScale.${match[1]}` }
}
match = /^density-(.+)-control-scale$/.exec(name)
if (match) {
return { category: 'density.control-scale', path: `primitives.density.controlScale.${match[1]}` }
}
if (name.startsWith('duration-')) {
return { category: 'motion.duration', path: `primitives.motion.duration.${name.slice('duration-'.length)}` }
}
if (name.startsWith('ease-')) {
return { category: 'motion.ease', path: `primitives.motion.ease.${name.slice('ease-'.length)}` }
}
if (name.startsWith('motion-distance-')) {
return {
category: 'motion.distance',
path: `primitives.motion.distance.${name.slice('motion-distance-'.length)}`
}
}
if (name.startsWith('motion-scale-')) {
return {
category: 'motion.scale',
path: `primitives.motion.scale.${name.slice('motion-scale-'.length)}`
}
}
if (name === 'motion-stagger') return { category: 'motion', path: 'primitives.motion.stagger' }
if (name.startsWith('motion-loop-')) return { category: 'motion.loop', path: '(derived)' }
if (name.startsWith('motion-')) return { category: 'motion', path: '(derived)' }
if (name.startsWith('icon-size-')) {
return { category: 'icon.size', path: `primitives.icon.size.${name.slice('icon-size-'.length)}` }
}
if (name.startsWith('icon-stroke-width-')) {
return {
category: 'icon.stroke-width',
path: `primitives.icon.strokeWidth.${name.slice('icon-stroke-width-'.length)}`
}
}
if (name.startsWith('opacity-')) {
return { category: 'opacity', path: `primitives.opacity.${name.slice('opacity-'.length)}` }
}
if (name.startsWith('z-index-')) {
const key = name.slice('z-index-'.length)
const declared = options.primitives.zIndex && key in options.primitives.zIndex
return { category: 'z-index', path: declared ? `primitives.zIndex.${key}` : '(derived)' }
}
if (name.startsWith('font-family-')) {
return {
category: 'typography.family',
path: `primitives.typography.families.${name.slice('font-family-'.length)}`
}
}
if (name.startsWith('font-size-')) {
return {
category: 'typography.size',
path: `primitives.typography.sizes.${name.slice('font-size-'.length)}.size`
}
}
if (name.startsWith('font-line-height-')) {
return {
category: 'typography.size',
path: `primitives.typography.sizes.${name.slice('font-line-height-'.length)}.lineHeight`
}
}
if (name.startsWith('font-letter-spacing-')) {
return {
category: 'typography.size',
path: `primitives.typography.sizes.${name.slice('font-letter-spacing-'.length)}.letterSpacing`
}
}
if (name.startsWith('font-weight-')) {
return {
category: 'typography.weight',
path: `primitives.typography.weights.${name.slice('font-weight-'.length)}`
}
}
if (name.startsWith('font-feature-')) {
return {
category: 'typography.features',
path: `primitives.typography.features.${name.slice('font-feature-'.length)}`
}
}
if (name.startsWith('leading-')) {
// Two config records share the prefix: the leading SCALE and the
// per-role semantic leading — membership decides.
const key = name.slice('leading-'.length)
const semantic = options.primitives.typography?.semanticLeading
return {
category: 'typography.leading',
path:
semantic && key in semantic
? `primitives.typography.semanticLeading.${key}`
: `primitives.typography.leading.${key}`
}
}
if (name.startsWith('tracking-')) {
return {
category: 'typography.tracking',
path: `primitives.typography.tracking.${name.slice('tracking-'.length)}`
}
}
if (name.startsWith('measure-')) {
return {
category: 'typography.measure',
path: `primitives.typography.measure.${name.slice('measure-'.length)}`
}
}
if (name.startsWith('depth-')) return { category: 'depth', path: 'primitives.depth' }
if (name.startsWith('shape-')) return { category: 'shape', path: 'primitives.shape' }
if (name === 'scaling' || name.startsWith('scaling-')) {
return { category: 'scaling', path: '(derived)' }
}
if (name.startsWith('breakpoint-')) return { category: 'breakpoint', path: '(derived)' }
if (name.startsWith('blur-')) {
return { category: 'blur', path: `primitives.blur.${name.slice('blur-'.length)}` }
}
if (name.startsWith('gradient-angle-')) {
return {
category: 'gradient',
path: `primitives.gradientAngle.${name.slice('gradient-angle-'.length)}`
}
}
if (name.startsWith('gradient-')) return { category: 'gradient', path: '(derived)' }
if (name.startsWith('floating-')) return { category: 'floating', path: '(derived)' }
return { category: name.split('-')[0], path: '(derived)' }
}
}

Powered by TurnKey Linux.