// Component tokens: the part of a theme that says how each piece of the site is built (a button's // radius and border, a post's padding and fill, the size and case of a handle), instead of one radius // and one density for everything. A theme names generic components (`post`, `buttonPrimary`, // `navItem`); the site adapter says which selectors they are (its part map). Pure. // // Safety is the same as the rest of a theme document: every token is a clamped number, a fixed word // or a color role name. No token carries CSS text, a selector or a color literal, so one contrast // pass over the palette covers every component, and the CSS is built from the vocabulary below. // // How it lands in the page: values are custom properties at zero specificity // (`:where(:root){--wb-part-post-radius:0px}`), rules are rigid (`:root:root {border-radius: // var(--wb-part-post-radius) !important}`). An owner's Custom CSS or a plugin overrides a value by // setting the variable, for the whole page or for one subtree, without fighting `!important`. // // The bevel borders (`bevel-out`, `bevel-in`) follow 98.css's raised and sunken edges (MIT, Jordan // Scales, https://github.com/jdan/98.css): four inset shadows, here computed from the theme's roles. import { contrastRatio, hexToRgb, mix, relativeLuminance, type FontRoleName } from './themeApi.ts'; // --------------------------------------------------------------------------------------------- // Vocabulary /** Color roles a component may name. `onAccent` is the readable label on the accent. */ export const COLOR_ROLES = ['bg', 'surface', 'elevated', 'page', 'border', 'text', 'muted', 'accent', 'onAccent', 'inverse', 'onInverse'] as const; export type ColorRole = typeof COLOR_ROLES[number]; export type RoleColors = Record; export const BORDER_STYLES = ['none', 'solid', 'dashed', 'dotted', 'double', 'bevel-out', 'bevel-in'] as const; export const SHADOWS = ['none', 'soft', 'deep', 'hard', 'inset', 'glow'] as const; export const CASES = ['none', 'upper', 'lower', 'capitalize', 'small-caps'] as const; export const AVATAR_SHAPES = ['circle', 'rounded', 'square', 'squircle', 'hexagon', 'octagon', 'diamond'] as const; export const CORNERS = ['round', 'cut'] as const; const FONT_ROLES: readonly FontRoleName[] = ['interface', 'post', 'heading', 'numbers', 'mono', 'chrome', 'dialog']; export type Gradient = { from: ColorRole; to: ColorRole; angle: number }; export type Fill = ColorRole | 'transparent' | 'glass' | Gradient; export type StateTokens = { fill?: Fill; text?: ColorRole; borderColor?: ColorRole; shadow?: typeof SHADOWS[number]; lift?: number; scale?: number; underline?: boolean }; export type ComponentTokens = { preset?: string; radius?: number | 'pill'; borderWidth?: number; borderStyle?: typeof BORDER_STYLES[number]; borderColor?: ColorRole; fill?: Fill; text?: ColorRole; padX?: number; padY?: number; gap?: number; height?: number; width?: number; size?: number; maxHeight?: number; font?: FontRoleName; fontSize?: number; weight?: number; case?: typeof CASES[number]; tracking?: number; leading?: number; shadow?: typeof SHADOWS[number]; shape?: typeof AVATAR_SHAPES[number]; corner?: typeof CORNERS[number]; visible?: boolean; hover?: StateTokens; active?: StateTokens; focus?: StateTokens; }; type ComponentDef = { /** Tokens set on the parent apply to this component too. */ parent?: string; /** A text part: type tokens land on its leaves. */ text?: boolean; /** Size ranges in px (absent: the token is not accepted on this component). */ height?: [number, number]; width?: [number, number]; size?: [number, number]; maxHeight?: [number, number]; fontSize?: [number, number]; /** Tokens only some components take. */ shape?: boolean; corner?: boolean; visible?: boolean; /** Glass fills (a blur) are allowed here; never on parts that repeat down a timeline. */ glass?: boolean; /** Gradient fills are allowed here (bars and buttons). */ gradient?: boolean; /** Presets of this component's family. */ presets?: keyof typeof PRESETS; /** Contrast its text must reach on its own fill. */ contrast?: number; }; const LABEL: [number, number] = [12, 20]; const SMALL: [number, number] = [11, 18]; /** Every component a theme can name. Sites map them to selectors; a component a site does not have is ignored. */ export const COMPONENTS = { nav: { width: [56, 320], glass: true, gradient: true }, navItem: { height: [36, 64], fontSize: LABEL, text: true }, topBar: { height: [40, 64], glass: true, gradient: true, fontSize: LABEL }, tabs: { presets: 'tabs', gradient: true }, tab: { height: [32, 56], fontSize: LABEL, text: true, presets: 'tabs' }, post: { corner: true, presets: 'post', contrast: 7 }, avatar: { size: [20, 64], shape: true, visible: true }, displayName: { text: true, fontSize: LABEL, visible: true }, handle: { text: true, fontSize: SMALL, visible: true }, postText: { text: true, fontSize: [13, 24], contrast: 7 }, meta: { text: true, fontSize: SMALL }, actionBar: {}, action: { size: [28, 44] }, count: { text: true, fontSize: SMALL, visible: true }, media: { corner: true, maxHeight: [200, 700] }, linkCard: { corner: true }, quote: {}, button: { height: [24, 56], fontSize: LABEL, text: true, gradient: true, presets: 'button' }, buttonPrimary: { parent: 'button', height: [24, 56], fontSize: LABEL, text: true, gradient: true, presets: 'button' }, buttonSecondary: { parent: 'button', height: [24, 56], fontSize: LABEL, text: true, gradient: true, presets: 'button' }, buttonGhost: { parent: 'button', height: [24, 56], fontSize: LABEL, text: true, gradient: true, presets: 'button' }, input: { height: [32, 56], fontSize: LABEL, text: true, presets: 'input' }, search: { height: [32, 56], fontSize: LABEL, text: true, presets: 'input' }, composer: { fontSize: [13, 24], text: true }, /** The tool icons under a composer (media, poll, emoji); `text` is their color. */ composerTool: { size: [28, 44] }, panel: { glass: true, presets: 'panel' }, menu: { parent: 'panel', glass: true, presets: 'panel' }, dialog: { parent: 'panel', glass: true, presets: 'panel' }, toast: { parent: 'panel', glass: true, presets: 'panel' }, hoverCard: { parent: 'panel', glass: true, presets: 'panel' }, sidebarCard: { parent: 'panel', glass: true, corner: true, presets: 'panel' }, badge: { size: [14, 24], corner: true, fontSize: SMALL, text: true }, pill: { fontSize: SMALL, text: true }, divider: {}, scrollbar: {}, profileHeader: {}, } as const satisfies Record; export type ComponentId = keyof typeof COMPONENTS; export const COMPONENT_IDS = Object.keys(COMPONENTS) as ComponentId[]; export type ThemeComponents = Partial>; /** Clamp ranges of the tokens every component shares (the JSON Schema says the same; tested). */ export const TOKEN_LIMITS = { radius: [0, 32], borderWidth: [0, 4], padX: [0, 32], padY: [0, 32], gap: [0, 24], weight: [100, 900], tracking: [-0.05, 0.2], leading: [1.1, 2], angle: [0, 360], lift: [0, 4], scale: [0.96, 1.04], } as const; /** Named token bundles per family; a theme's own tokens win over its preset's. Frozen once shipped. */ export const PRESETS = { button: { pill: { radius: 'pill', borderWidth: 0 }, soft: { radius: 8, borderWidth: 0 }, square: { radius: 0, borderWidth: 1, borderStyle: 'solid' }, outline: { radius: 6, borderWidth: 1, borderStyle: 'solid', fill: 'transparent', text: 'text', borderColor: 'border' }, bevel: { radius: 0, borderStyle: 'bevel-out', fill: 'surface', text: 'text', active: { shadow: 'inset' } }, gloss: { radius: 'pill', borderWidth: 1, borderStyle: 'solid', shadow: 'soft' }, 'flat-link': { radius: 0, borderWidth: 0, fill: 'transparent', text: 'accent', hover: { underline: true } }, }, post: { flat: { radius: 0, borderWidth: 0, fill: 'transparent', shadow: 'none' }, card: { radius: 14, borderWidth: 1, borderStyle: 'solid', borderColor: 'border', fill: 'surface' }, bubble: { radius: 20, borderWidth: 0, fill: 'surface' }, window: { radius: 0, borderStyle: 'bevel-out', fill: 'surface' }, ruled: { radius: 0, borderWidth: 0, fill: 'transparent' }, }, input: { pill: { radius: 'pill', borderWidth: 1, borderStyle: 'solid', borderColor: 'border' }, underline: { radius: 0, borderWidth: 0, fill: 'transparent' }, sunken: { radius: 0, borderStyle: 'bevel-in', fill: 'bg', text: 'text' }, boxed: { radius: 4, borderWidth: 1, borderStyle: 'solid', borderColor: 'border', fill: 'bg' }, }, panel: { sheet: { radius: 12, borderWidth: 1, borderStyle: 'solid', borderColor: 'border', fill: 'surface', shadow: 'soft' }, window: { radius: 0, borderStyle: 'bevel-out', fill: 'surface', shadow: 'none' }, glass: { radius: 16, borderWidth: 1, borderStyle: 'solid', borderColor: 'border', fill: 'glass', shadow: 'soft' }, }, tabs: { underline: { radius: 0 }, pills: { radius: 'pill' }, folder: { radius: 6, borderWidth: 1, borderStyle: 'solid', borderColor: 'border' }, blocks: { radius: 0, borderWidth: 1, borderStyle: 'solid', borderColor: 'border' }, }, } as const satisfies Record>; // --------------------------------------------------------------------------------------------- // Sanitizing const obj = (value: unknown): Record => value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); const clamp = (value: number, [min, max]: readonly [number, number]) => Math.min(max, Math.max(min, value)); const oneOf = (value: unknown, options: readonly T[]): T | undefined => options.includes(value as T) ? value as T : undefined; const role = (value: unknown) => oneOf(value, COLOR_ROLES); function sanitizeFill(value: unknown, def: ComponentDef): Fill | undefined { if (value === 'transparent') return 'transparent'; if (value === 'glass') return def.glass ? 'glass' : undefined; const named = role(value); if (named) return named; const source = obj(value); const from = role(source.from), to = role(source.to); if (def.gradient && from && to) return { from, to, angle: Math.round(clamp(finite(source.angle) ? source.angle : 180, TOKEN_LIMITS.angle)) }; return undefined; } function sanitizeState(value: unknown, def: ComponentDef): StateTokens | undefined { const source = obj(value); const out: StateTokens = {}; const fill = sanitizeFill(source.fill, def); if (fill !== undefined) out.fill = fill; const text = role(source.text); if (text) out.text = text; const borderColor = role(source.borderColor); if (borderColor) out.borderColor = borderColor; const shadow = oneOf(source.shadow, SHADOWS); if (shadow) out.shadow = shadow; if (finite(source.lift)) out.lift = +clamp(source.lift, TOKEN_LIMITS.lift).toFixed(1); if (finite(source.scale)) out.scale = +clamp(source.scale, TOKEN_LIMITS.scale).toFixed(3); if (typeof source.underline === 'boolean') out.underline = source.underline; return Object.keys(out).length ? out : undefined; } /** One component's tokens from JSON: clamped, unknown names and tokens the component does not take dropped. */ export function sanitizeComponentTokens(id: ComponentId, value: unknown): ComponentTokens | undefined { const def: ComponentDef = COMPONENTS[id]; const source = obj(value); const out: ComponentTokens = {}; if (def.presets && typeof source.preset === 'string' && Object.hasOwn(PRESETS[def.presets], source.preset)) out.preset = source.preset; if (source.radius === 'pill') out.radius = 'pill'; else if (finite(source.radius)) out.radius = Math.round(clamp(source.radius, TOKEN_LIMITS.radius)); if (finite(source.borderWidth)) out.borderWidth = +clamp(source.borderWidth, TOKEN_LIMITS.borderWidth).toFixed(1); const borderStyle = oneOf(source.borderStyle, BORDER_STYLES); if (borderStyle) out.borderStyle = borderStyle; const borderColor = role(source.borderColor); if (borderColor) out.borderColor = borderColor; const fill = sanitizeFill(source.fill, def); if (fill !== undefined) out.fill = fill; const text = role(source.text); if (text) out.text = text; for (const key of ['padX', 'padY', 'gap'] as const) if (finite(source[key])) out[key] = Math.round(clamp(source[key] as number, TOKEN_LIMITS[key])); for (const key of ['height', 'width', 'size', 'maxHeight', 'fontSize'] as const) { const range = def[key]; if (range && finite(source[key])) out[key] = Math.round(clamp(source[key] as number, range)); } if (def.text || def.fontSize) { const font = oneOf(source.font, FONT_ROLES); if (font) out.font = font; if (finite(source.weight)) out.weight = Math.round(clamp(source.weight, TOKEN_LIMITS.weight) / 50) * 50; const textCase = oneOf(source.case, CASES); if (textCase && id !== 'postText') out.case = textCase; if (finite(source.tracking)) out.tracking = +clamp(source.tracking, TOKEN_LIMITS.tracking).toFixed(3); if (finite(source.leading)) out.leading = +clamp(source.leading, TOKEN_LIMITS.leading).toFixed(2); } const shadow = oneOf(source.shadow, SHADOWS); if (shadow) out.shadow = shadow; if (def.shape) { const shape = oneOf(source.shape, AVATAR_SHAPES); if (shape) out.shape = shape; } if (def.corner) { const corner = oneOf(source.corner, CORNERS); if (corner) out.corner = corner; } if (def.visible && typeof source.visible === 'boolean') out.visible = source.visible; for (const state of ['hover', 'active', 'focus'] as const) { const tokens = sanitizeState(source[state], def); if (tokens) out[state] = tokens; } return Object.keys(out).length ? out : undefined; } /** The `components` group from JSON. Null when nothing valid is left (the theme sets no component). */ export function sanitizeComponents(value: unknown): ThemeComponents | null { const source = obj(value); const out: ThemeComponents = {}; for (const id of COMPONENT_IDS) { if (!Object.hasOwn(source, id)) continue; const tokens = sanitizeComponentTokens(id, source[id]); if (tokens) out[id] = tokens; } return Object.keys(out).length ? out : null; } /** Every component with its preset and its parent's tokens folded in (own tokens win, then the preset, then the parent). */ export function resolveComponents(components: ThemeComponents): ThemeComponents { const expand = (id: ComponentId, tokens: ComponentTokens | undefined): ComponentTokens => { if (!tokens) return {}; const def: ComponentDef = COMPONENTS[id]; const preset = tokens.preset && def.presets ? (PRESETS[def.presets] as Record)[tokens.preset] : undefined; const { preset: _name, ...own } = tokens; return { ...preset, ...own }; }; const out: ThemeComponents = {}; for (const id of COMPONENT_IDS) { const def: ComponentDef = COMPONENTS[id]; const parent = def.parent as ComponentId | undefined; const inherited = parent ? expand(parent, components[parent]) : {}; const merged = { ...inherited, ...expand(id, components[id]) }; if (Object.keys(merged).length) out[id] = merged; } return out; } // --------------------------------------------------------------------------------------------- // Compiling /** A site's selectors for one component. */ export type PartDef = { /** The component's box. */ sel: string; /** Its text leaves (sites often put type on leaf spans); defaults to the box. */ text?: string; /** Where a fill is painted when it is not the box itself. */ fill?: string; /** Tokens the site cannot honor on this part (left out of the CSS). */ skip?: ReadonlyArray; }; export type PartMap = Partial>; export type ComponentContext = { /** Every role as `#rrggbb` (the theme's palette, legible). */ colors: RoleColors; /** `font-family` per font role. */ fonts: Record; }; const rgba = (hex: string, alpha: number) => `rgba(${hexToRgb(hex).join(', ')}, ${alpha})`; const dark = (hex: string) => relativeLuminance(hex) < 0.18; /** The most readable of the theme's text-capable roles on `fill`. */ export function readableRole(fill: string, colors: RoleColors, preferred?: ColorRole): ColorRole { const candidates: ColorRole[] = ['text', 'onAccent', 'onInverse', 'bg', 'inverse']; if (preferred && !candidates.includes(preferred)) candidates.unshift(preferred); return candidates.reduce((best, candidate) => contrastRatio(colors[candidate], fill) > contrastRatio(colors[best], fill) ? candidate : best, preferred ?? 'text'); } function fillCss(fill: Fill, colors: RoleColors): { decl: Array<[string, string]>; solid: string | null } { if (fill === 'transparent') return { decl: [['background-color', 'transparent'], ['background-image', 'none']], solid: null }; if (fill === 'glass') return { decl: [['background-color', rgba(colors.surface, 0.72)], ['backdrop-filter', 'blur(14px) saturate(1.4)'], ['-webkit-backdrop-filter', 'blur(14px) saturate(1.4)']], solid: colors.surface }; if (typeof fill === 'string') return { decl: [['background-color', colors[fill]], ['background-image', 'none']], solid: colors[fill] }; // A gradient is judged at its less readable end: the midpoint stands in for both when choosing a label. return { decl: [['background-color', colors[fill.to]], ['background-image', `linear-gradient(${fill.angle}deg, ${colors[fill.from]}, ${colors[fill.to]})`]], solid: mix(colors[fill.from], colors[fill.to], 0.5) }; } /** Raised and sunken edges after 98.css: two light and two dark inset lines, from the part's own fill. */ function bevel(out: boolean, face: string): string { const light = mix(face, '#ffffff', 0.85), soft = mix(face, '#ffffff', 0.35), shade = mix(face, '#000000', 0.35), deep = mix(face, '#000000', 0.9); return out ? `inset -1px -1px ${deep}, inset 1px 1px ${light}, inset -2px -2px ${shade}, inset 2px 2px ${soft}` : `inset -1px -1px ${light}, inset 1px 1px ${shade}, inset -2px -2px ${soft}, inset 2px 2px ${deep}`; } function shadowCss(name: typeof SHADOWS[number], colors: RoleColors): string { const base = dark(colors.bg); switch (name) { case 'soft': return base ? `0 1px 2px ${rgba('#000000', 0.4)}, 0 8px 24px ${rgba('#000000', 0.35)}` : `0 1px 2px ${rgba('#000000', 0.06)}, 0 8px 24px ${rgba('#000000', 0.1)}`; case 'deep': return `0 0 0 1px ${rgba(colors.border, 0.6)}, 0 24px 64px ${rgba('#000000', base ? 0.6 : 0.28)}`; case 'hard': return `4px 4px 0 ${colors.accent}`; case 'inset': return `inset 1px 1px 3px ${rgba('#000000', base ? 0.6 : 0.25)}`; case 'glow': return `0 0 0 1px ${rgba(colors.accent, 0.5)}, 0 0 18px ${rgba(colors.accent, 0.35)}`; default: return 'none'; } } const SHAPES: Record> = { circle: [['border-radius', '50%']], rounded: [['border-radius', '26%']], square: [['border-radius', '2px']], squircle: [['border-radius', '34%']], hexagon: [['border-radius', '0'], ['clip-path', 'polygon(25% 4%, 75% 4%, 100% 50%, 75% 96%, 25% 96%, 0 50%)']], octagon: [['border-radius', '0'], ['clip-path', 'polygon(30% 0, 70% 0, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0 70%, 0 30%)']], diamond: [['border-radius', '0'], ['clip-path', 'polygon(50% 0, 100% 50%, 50% 100%, 0 50%)']], }; const CASE_CSS: Record> = { none: [['text-transform', 'none'], ['font-variant-caps', 'normal']], upper: [['text-transform', 'uppercase']], lower: [['text-transform', 'lowercase']], capitalize: [['text-transform', 'capitalize']], 'small-caps': [['text-transform', 'none'], ['font-variant-caps', 'all-small-caps']], }; /** A part rule's selector: two `:root`s lift it over the site's atomic classes and inline styles' non-important values. */ export const partRule = (selector: string) => selector.split(/,(?![^(]*\))/).map(one => `:root:root ${one.trim()}`).join(','); export type CompiledComponents = { /** `--wb-part-*` declarations (for `:where(:root)`). */ values: string[]; /** Rules that read them. */ rules: string[]; /** What the contrast pass changed, for import notes and the builder's report. */ notes: string[]; }; /** * CSS for a theme's components over a site's part map. A component without a part, and a token the * part skips, emits nothing. Text on a fill the theme chose is held to the component's contrast * (the text role is swapped for the most readable one); sizes stay at or above a 24px target. */ export function compileComponents(components: ThemeComponents, parts: PartMap, ctx: ComponentContext): CompiledComponents { const values: string[] = [], rules: string[] = [], notes: string[] = []; const resolved = resolveComponents(components); for (const id of COMPONENT_IDS) { const tokens = resolved[id], part = parts[id]; if (!tokens || !part) continue; const def: ComponentDef = COMPONENTS[id]; const skip = new Set(part.skip ?? []); const has = (key: K): boolean => tokens[key] !== undefined && !skip.has(key); const box: string[] = [], type: string[] = [], fillRule: string[] = []; const put = (list: string[], token: string, property: string, value: string) => { const name = `--wb-part-${id}-${token}`; if (!values.some(item => item.startsWith(`${name}:`))) values.push(`${name}:${value}`); list.push(`${property}:var(${name}) !important`); }; // Fill first: the text role depends on it. let face: string | null = null; if (has('fill')) { const { decl, solid } = fillCss(tokens.fill!, ctx.colors); face = solid; decl.forEach(([property, value], index) => put(part.fill ? fillRule : box, `fill${index ? `-${index}` : ''}`, property, value)); } let textRole = has('text') ? tokens.text! : undefined; if (face && (def.text || textRole)) { const need = def.contrast ?? 4.5; if (!textRole || contrastRatio(ctx.colors[textRole], face) < need) { const readable = readableRole(face, ctx.colors, textRole); if (textRole && readable !== textRole) notes.push(`${id}: text "${textRole}" on its fill is under ${need}:1, using "${readable}"`); textRole = readable; } } if (textRole) put(type, 'text', 'color', ctx.colors[textRole]); if (has('radius')) put(box, 'radius', 'border-radius', tokens.radius === 'pill' ? '9999px' : `${tokens.radius}px`); const style = has('borderStyle') ? tokens.borderStyle! : undefined; if (style === 'bevel-out' || style === 'bevel-in') { const base = face ?? ctx.colors.surface; put(box, 'border-style', 'border-style', 'none'); put(box, 'bevel', 'box-shadow', bevel(style === 'bevel-out', base)); } else { if (style) put(box, 'border-style', 'border-style', style); if (has('borderWidth')) put(box, 'border-width', 'border-width', `${tokens.borderWidth}px`); // A width without a style would draw nothing: X's boxes are `border: 0 solid`. if (has('borderWidth') && !style && tokens.borderWidth! > 0) put(box, 'border-style', 'border-style', 'solid'); if (has('shadow')) put(box, 'shadow', 'box-shadow', shadowCss(tokens.shadow!, ctx.colors)); } if (has('borderColor')) put(box, 'border-color', 'border-color', ctx.colors[tokens.borderColor!]); if (has('padX')) put(box, 'pad-x', 'padding-inline', `${tokens.padX}px`); if (has('padY')) put(box, 'pad-y', 'padding-block', `${tokens.padY}px`); if (has('gap')) put(box, 'gap', 'gap', `${tokens.gap}px`); if (has('height')) put(box, 'height', 'min-height', `${Math.max(24, tokens.height!)}px`); if (has('width')) put(box, 'width', 'width', `${tokens.width}px`); if (has('size')) { put(box, 'size', 'width', `${tokens.size}px`); box.push(`height:var(--wb-part-${id}-size) !important`); } if (has('maxHeight')) put(box, 'max-height', 'max-height', `${tokens.maxHeight}px`); if (has('shape')) SHAPES[tokens.shape!].forEach(([property, value], index) => put(box, `shape${index ? `-${index}` : ''}`, property, value)); if (has('corner') && tokens.corner === 'cut') put(box, 'corner', 'clip-path', 'polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 12px 100%, 0 calc(100% - 12px))'); if (has('visible') && tokens.visible === false) put(box, 'display', 'display', 'none'); if (has('font')) put(type, 'font', 'font-family', ctx.fonts[tokens.font!]); if (has('fontSize')) { put(type, 'font-size', 'font-size', `${tokens.fontSize}px`); put(type, 'line-height', 'line-height', String(tokens.leading ?? 1.3)); } else if (has('leading')) put(type, 'line-height', 'line-height', String(tokens.leading)); if (has('weight')) put(type, 'weight', 'font-weight', String(tokens.weight)); if (has('case')) CASE_CSS[tokens.case!].forEach(([property, value], index) => put(type, `case${index ? `-${index}` : ''}`, property, value)); if (has('tracking')) put(type, 'tracking', 'letter-spacing', `${tokens.tracking}em`); const textSel = part.text ?? part.sel; if (box.length) rules.push(`${partRule(part.sel)}{${box.join(';')}}`); if (fillRule.length) rules.push(`${partRule(part.fill!)}{${fillRule.join(';')}}`); if (type.length) rules.push(`${partRule(textSel)}{${type.join(';')}}`); for (const state of ['hover', 'active', 'focus'] as const) { const tokensOf = tokens[state]; if (!tokensOf || skip.has(state)) continue; const pseudo = state === 'focus' ? ':focus-visible' : `:${state}`; const still: string[] = [], moving: string[] = [], label: string[] = []; let stateFace = face; if (tokensOf.fill !== undefined) { const { decl, solid } = fillCss(tokensOf.fill, ctx.colors); stateFace = solid ?? face; for (const [property, value] of decl) still.push(`${property}:${value} !important`); } let stateText = tokensOf.text; if (stateFace && (stateText || tokensOf.fill !== undefined) && (def.text || stateText)) { if (!stateText || contrastRatio(ctx.colors[stateText], stateFace) < (def.contrast ?? 4.5)) stateText = readableRole(stateFace, ctx.colors, stateText); } if (stateText) label.push(`color:${ctx.colors[stateText]} !important`); if (tokensOf.underline !== undefined) label.push(`text-decoration:${tokensOf.underline ? 'underline' : 'none'} !important`); if (tokensOf.borderColor) still.push(`border-color:${ctx.colors[tokensOf.borderColor]} !important`); if (tokensOf.shadow) still.push(`box-shadow:${tokensOf.shadow === 'inset' && (style === 'bevel-out' || style === 'bevel-in') ? bevel(false, face ?? ctx.colors.surface) : shadowCss(tokensOf.shadow, ctx.colors)} !important`); const transforms = [tokensOf.lift ? `translateY(-${tokensOf.lift}px)` : '', tokensOf.scale && tokensOf.scale !== 1 ? `scale(${tokensOf.scale})` : ''].filter(Boolean); if (transforms.length) moving.push(`transform:${transforms.join(' ')} !important`); const stateSel = (selector: string) => selector.split(/,(?![^(]*\))/).map(one => `${one.trim()}${pseudo}`).join(','); if (still.length) rules.push(`${partRule(stateSel(part.sel))}{${still.join(';')}}`); // The label of a hovered or pressed part: the box and the text leaves under it. if (label.length) rules.push(`${partRule(`${stateSel(part.sel)},${part.sel.split(/,(?![^(]*\))/).map(one => `${one.trim()}${pseudo} :is(span,div,a)`).join(',')}`)}{${label.join(';')}}`); // Movement only for people who have not asked for less of it. if (moving.length) rules.push(`@media (prefers-reduced-motion:no-preference){${partRule(stateSel(part.sel))}{${moving.join(';')}}}`); } } return { values, rules, notes }; } /** The `colors.actions` group: the colors of an on state (liked, reposted, bookmarked) and of the reply hover. */ export const ACTION_NAMES = ['reply', 'repost', 'like', 'bookmark'] as const; export type ActionColors = Partial>;