waybetter/packages/runtime/src/themeComponents.ts
CRT 16957f4ea7 Theme engine v2, the document side: component tokens, layout variants, decoration, icon packs
A theme may now say how each piece of a site is built, not only one radius and one density:
- components (themeComponents.ts): 36 generic parts with clamped tokens (radius, border incl.
  98.css-style bevels, fill by color role / glass / gradient, padding, sizes, type, shadow, shape,
  hover/active/focus), presets and parents; compiled over a site's part map as soft values
  (--wb-part-*) and rigid rules; text on a chosen fill keeps its contrast.
- variants (themeVariants.ts): fixed words on six axes; the owner holds an axis or switches layout
  changes off; the look of a variant is the site's skin.
- decoration (themeDecor.ts): dividers, chrome (title bar, masthead, prompt), a focus ring that is
  always visible, selection, scrollbars, links, motion under reduced-motion.
- icon packs (themeIcons.ts): a standalone document of glyphs by semantic name (path data, colored
  layers or a small PNG, never an SVG document), with license, source and credits; a theme only
  names a pack; the owner's icon choice is separate; drawn as CSS masks of the current color over
  the site's own svg boxes, behind forced-colors and mask-support guards, per group.
- a theme document gains the optional groups components, variants, decor, icons and
  colors.actions; a theme without them is sanitized to exactly what it was.
- theme.schema.json and the new icons.schema.json are generated from the same tables
  (scripts/theme-schema.mjs; a test fails when they are stale).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:22:36 +07:00

472 lines
28 KiB
TypeScript

// 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 <part>{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<ColorRole, string>;
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<string, ComponentDef>;
export type ComponentId = keyof typeof COMPONENTS;
export const COMPONENT_IDS = Object.keys(COMPONENTS) as ComponentId[];
export type ThemeComponents = Partial<Record<ComponentId, ComponentTokens>>;
/** 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<string, Record<string, ComponentTokens>>;
// ---------------------------------------------------------------------------------------------
// Sanitizing
const obj = (value: unknown): Record<string, unknown> => value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
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 = <T extends string>(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<string, ComponentTokens>)[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<keyof ComponentTokens>;
};
export type PartMap = Partial<Record<ComponentId, PartDef>>;
export type ComponentContext = {
/** Every role as `#rrggbb` (the theme's palette, legible). */
colors: RoleColors;
/** `font-family` per font role. */
fonts: Record<FontRoleName, string>;
};
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<typeof AVATAR_SHAPES[number], Array<[string, string]>> = {
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<typeof CASES[number], Array<[string, string]>> = {
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<string>(part.skip ?? []);
const has = <K extends keyof ComponentTokens>(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<Record<typeof ACTION_NAMES[number], string>>;