- 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>
179 lines
7.7 KiB
JavaScript
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.',
|
|
),
|
|
},
|
|
];
|
|
}
|