// `npm create waybetter -- theme`: a wayBetter theme file (JSON, data not a stylesheet) with // "$schema" pointing at the schema published in the `waybetter` package, a full palette derived // from three colors the way the runtime derives missing roles, and the font roles. import { UsageError, headerValue, hexValue, lines, slugify } from './names.js'; /** Where the `waybetter` package publishes the theme JSON Schema (runtime `THEME_SCHEMA_URL`). */ export const THEME_SCHEMA_URL = 'https://unpkg.com/waybetter/theme.schema.json'; export const THEME_MODES = ['dark', 'light', 'auto']; /** Generic families the schema accepts in a font stack, besides font names. */ const GENERIC_FONTS = ['system-ui', 'sans-serif', 'serif', 'monospace', 'cursive', 'ui-sans-serif', 'ui-serif', 'ui-monospace', 'ui-rounded', '-apple-system', 'BlinkMacSystemFont']; const FONT_NAME = /^[A-Za-z0-9][A-Za-z0-9 .\-]{0,47}$/; /** Starting points: `--preset` fills the three seed colors. */ export const THEME_PRESETS = { midnight: { mode: 'dark', bg: '#0b1110', text: '#e8f3ef', accent: '#3ddc97' }, paper: { mode: 'light', bg: '#fbf8f1', text: '#1f1b16', accent: '#c2410c' }, dusk: { mode: 'dark', bg: '#15121f', text: '#ece8f6', accent: '#a78bfa' }, }; /** * @typedef {{ name: string, description?: string, author?: string, mode?: string, preset?: string, * bg?: string, text?: string, accent?: string, font?: string, postFont?: string, * radius?: number | string, schema?: string }} ThemeInput */ /** `"Geist, system-ui"` -> `["Geist", "system-ui"]`, each checked against the schema's font rules. */ export function fontStack(label, value) { if (value == null || value === '') return []; const names = (Array.isArray(value) ? value : String(value).split(',')) .map(name => String(name).trim().replace(/^["']|["']$/g, '')) .filter(Boolean); if (names.length > 8) throw new UsageError(`${label}: at most 8 fonts.`); for (const name of names) { if (!GENERIC_FONTS.includes(name) && !FONT_NAME.test(name)) throw new UsageError(`${label}: "${name}" is not a font name (letters, digits, spaces, "." and "-").`); } return names; } function channels(hex) { const n = parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } function luminance(hex) { const [r, g, b] = channels(hex).map(v => { const c = v / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } /** WCAG contrast ratio of two `#rrggbb` colors. */ export function contrast(a, b) { const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); } function mix(a, b, t) { const [x, y] = [channels(a), channels(b)]; return `#${x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, '0')).join('')}`; } /** * Every color role from bg/text/accent, with the proportions the runtime uses for a theme that * sets only those three (`themeFromColors` in waybetter/runtime/themeApi). */ export function derivePalette(bg, text, accent) { const dark = luminance(bg) < luminance(text); return { bg, surface: mix(bg, text, dark ? 0.05 : 0.03), elevated: mix(bg, text, dark ? 0.1 : 0.07), border: mix(bg, text, dark ? 0.2 : 0.15), text, muted: mix(bg, text, 0.62), accent, inverse: text, onInverse: bg, page: bg, }; } /** @param {ThemeInput} input */ export function normalizeThemeOptions(input) { const name = headerValue('Theme name', input.name, { max: 60, required: true }); const id = slugify(name, 40); if (!id) throw new UsageError(`Theme name "${name}" needs at least one letter or digit.`); const preset = input.preset ? THEME_PRESETS[/** @type {keyof typeof THEME_PRESETS} */ (input.preset)] : THEME_PRESETS.midnight; if (!preset) throw new UsageError(`--preset must be one of: ${Object.keys(THEME_PRESETS).join(', ')}.`); const mode = String(input.mode || preset.mode); if (!THEME_MODES.includes(mode)) throw new UsageError(`--mode must be one of: ${THEME_MODES.join(', ')}.`); const bg = hexValue('Background (--bg)', input.bg || preset.bg); const text = hexValue('Text (--text)', input.text || preset.text); const accent = hexValue('Accent (--accent)', input.accent || preset.accent); const radius = input.radius == null || input.radius === '' ? 16 : Number(input.radius); if (!Number.isInteger(radius) || radius < 0 || radius > 28) throw new UsageError('--radius must be a whole number from 0 to 28.'); const schema = String(input.schema || 'url'); if (!['url', 'local'].includes(schema)) throw new UsageError('--schema must be url or local.'); const warnings = []; const ratio = contrast(bg, text); if (ratio < 7) warnings.push(`Text on background is ${ratio.toFixed(1)}:1; the app raises text to 7:1 when it imports the theme.`); if (contrast(bg, accent) < 4.5) warnings.push(`Accent on background is ${contrast(bg, accent).toFixed(1)}:1; the app adjusts it to 4.5:1 for links.`); return { name, id, description: headerValue('Description', input.description || `${name}, a wayBetter theme.`, { max: 200 }), author: headerValue('Author', input.author || 'you', { max: 60 }), mode, colors: derivePalette(bg, text, accent), family: fontStack('Interface font (--font)', input.font ?? 'system-ui'), postFont: fontStack('Post font (--post-font)', input.postFont), radius, schema, warnings, }; } /** @param {ReturnType} o */ export function themeDocument(o) { return { $schema: o.schema === 'local' ? './node_modules/waybetter/theme.schema.json' : THEME_SCHEMA_URL, id: o.id, name: o.name, description: o.description, author: o.author, version: 1, mode: o.mode, colors: o.colors, font: { // Font roles: `family` is the interface, `tweet` the text of posts/messages. Empty keeps the site's font. family: o.family, tweet: o.postFont, scale: 1, weight: 400, bold: 700, tracking: 0, leading: 0, }, shape: { radius: o.radius, button: 9999, avatar: 'circle' }, effects: { motion: 120, hover: 'tint', shadow: 'soft', crt: false }, }; } /** @param {ReturnType} o */ export function themeFiles(o) { const file = `${o.id}.theme.json`; return [ { path: file, content: `${JSON.stringify(themeDocument(o), null, 2)}\n` }, { path: 'README.md', content: lines( `# ${o.name}`, '', o.description, '', `A wayBetter theme: \`${file}\` is data, not a stylesheet. The app maps the site's own colors`, 'onto this palette and keeps every text readable.', '', '## Use it', '', `**wayBetter → Appearance → Import** and pick \`${file}\`. Export any theme from the same place.`, '', '## Edit it', '', '- `colors`: `bg`, `text` and `accent` are required; `surface`, `elevated`, `border`, `muted`,', ' `inverse`/`onInverse` (the filled primary button) and `page` were derived from them and can be', ' changed or removed (removed roles are derived again). Six-digit hex only.', '- `font.family` (interface) and `font.tweet` (post text): installed font names or generic', ' families; `Geist` and `Geist Mono` are bundled with wayBetter for X. `scale`, `weight`, `bold`,', ' `tracking` and `leading` fine-tune them.', '- `shape`, `layout`, `background`, `effects` and `css`: see THEMES.md (wayBetter for X:', ' `docs/THEMES.md`).', '', 'The `"$schema"` line gives editors like VS Code completion and range checks for every field.', 'The app is more forgiving than the schema: out-of-range values are clamped on import.', ), }, ]; }