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>
472 lines
28 KiB
TypeScript
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>>;
|