waybetter/packages/create-waybetter/src/theme.js
CRT 67851cd8f5 create-waybetter: npm create waybetter scaffolds plugins (JS/TS), themes and whole apps
- plugin: *.plugin.js with the store header and start/stop on api.scope, storage, observe,
  onKey, the UI kit (toast with action, ui.h settings dialog) and api.theme/--wb-* vars;
  --ts makes a TypeScript project typed by the waybetter package (build keeps the header first)
- theme: JSON with "$schema" -> theme.schema.json, palette derived like themeFromColors,
  font roles (family, tweet), presets
- app: Tauri 2 + waybetter-host + runtime + shell models: SiteSpec, SiteAdapter, shell/tab-chrome/
  site-tab capabilities, tabs_sync, plugin store, a site-only command; presets for youtube.com,
  kick.com, twitch.tv with placeholder selectors; host crate via submodule, path or git
- tests generate into temp dirs: headers via parse_meta (new plugin_meta example), plugin code
  against createCorePluginApi's members, themes against the schema, app TS with tsc against the
  built package, app Rust with cargo metadata/check/test
- wired into bun run check (typecheck incl. checkJs, build:packages, test:create) and pack:check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:17:32 +07:00

179 lines
7.7 KiB
JavaScript

// `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<typeof normalizeThemeOptions>} 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<typeof normalizeThemeOptions>} 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.',
),
},
];
}