feat: add Theme API v2 with token compiler, skin slot

This commit is contained in:
Linus Rath
2026-04-28 15:39:13 +02:00
parent dafc8ace3c
commit 1b84547211
10 changed files with 1015 additions and 36 deletions
+126 -15
View File
@@ -28,23 +28,11 @@ describe('extractTheme', () => {
});
it('rejects oversized theme', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'big-theme',
name: 'Big',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
}));
// Make a large file > 1MB
zip.file('theme.css', 'x'.repeat(1024 * 1024 + 1));
// Manually create oversized File
const oversizedFile = new File([new ArrayBuffer(1024 * 1024 + 1)], 'big.zip');
// Theme size limit is 2 MB; create a file just past it.
const oversizedFile = new File([new ArrayBuffer(2 * 1024 * 1024 + 1)], 'big.zip');
const result = await extractTheme(oversizedFile);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Theme ZIP exceeds 1 MB size limit');
expect(result.errors).toContain('Theme ZIP exceeds 2 MB size limit');
});
it('rejects non-ZIP file', async () => {
@@ -139,6 +127,129 @@ describe('extractTheme', () => {
expect(result.valid).toBe(true);
expect(result.manifest!.id).toBe('nested-theme');
});
// ── Theme API v2 (advanced manifest) ──────────────────────────────
it('compiles a v2 manifest with tokens and no theme.css', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'tokens-only',
name: 'Tokens Only',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light', 'dark'],
apiVersion: 2,
tokens: {
light: { primary: '#1373d9', background: '#ffffff' },
dark: { primary: '#58c9ff', background: '#1a202c' },
},
}));
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(true);
expect(result.css).toContain('--color-primary: #1373d9');
expect(result.css).toContain('--color-primary: #58c9ff');
});
it('concatenates compiled tokens with author-supplied theme.css', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'tokens-plus-css',
name: 'Tokens + CSS',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
apiVersion: 2,
tokens: { light: { primary: '#000' } },
}));
zip.file('theme.css', '@font-face { font-family: "X"; src: local("X"); }');
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(true);
expect(result.css).toContain('--color-primary: #000');
expect(result.css).toContain('@font-face');
});
it('extracts a skin.css when shipped with a v2 manifest', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'with-skin',
name: 'With Skin',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
apiVersion: 2,
tokens: { light: { primary: '#000' } },
}));
zip.file('skin.css', '[data-tour="email-list"] { font-size: 13px; }');
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(true);
expect(result.skin).not.toBeNull();
expect(result.skin!).toContain('[data-tour="email-list"]');
});
it('strips dangerous patterns from skin.css', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'evil-skin',
name: 'Evil',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
apiVersion: 2,
tokens: { light: { primary: '#000' } },
}));
zip.file('skin.css', '@import url("https://x.com/p.css"); button { background: javascript:alert(1); }');
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(true);
expect(result.skin).not.toBeNull();
expect(result.skin!).not.toContain('javascript:');
expect(result.skin!).not.toContain('@import');
expect(result.warnings.some((w) => w.toLowerCase().includes('skin'))).toBe(true);
});
it('ignores skin.css when manifest is not v2', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'v1-with-skin',
name: 'V1',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
}));
zip.file('theme.css', ':root { --color-primary: #000; }');
zip.file('skin.css', 'body { display: none; }');
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(true);
expect(result.skin).toBeNull();
expect(result.warnings.some((w) => w.includes('skin.css ignored'))).toBe(true);
});
it('rejects a v2 manifest with invalid density', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'bad-density',
name: 'Bad',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
density: 'gigantic',
tokens: { light: { primary: '#000' } },
}));
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('density'))).toBe(true);
});
});
describe('extractPlugin', () => {
+181
View File
@@ -0,0 +1,181 @@
import { describe, it, expect } from 'vitest';
import { compileAdvancedTheme, isAdvancedManifest } from '../theme-compiler';
import type { ThemeManifest } from '../plugin-types';
const baseManifest = (overrides: Partial<ThemeManifest> = {}): ThemeManifest => ({
id: 't',
name: 'T',
version: '1.0.0',
author: 'tester',
description: '',
type: 'theme',
variants: ['light', 'dark'],
...overrides,
});
describe('isAdvancedManifest', () => {
it('returns false for plain v1 manifests', () => {
expect(isAdvancedManifest(baseManifest())).toBe(false);
});
it.each([
{ apiVersion: 2 as const },
{ tokens: { light: { primary: '#000' } } },
{ extends: 'builtin-nord' },
{ derive: true },
{ density: 'compact' as const },
{ radii: { md: '6px' } },
{ typography: { fontSans: 'Inter' } },
])('returns true when manifest has %p', (extra) => {
expect(isAdvancedManifest(baseManifest(extra))).toBe(true);
});
});
describe('compileAdvancedTheme', () => {
it('emits :root and .dark blocks from token sets', () => {
const { css, errors } = compileAdvancedTheme(
baseManifest({
tokens: {
light: { primary: '#1373d9', background: '#ffffff' },
dark: { primary: '#58c9ff', background: '#1a202c' },
},
}),
);
expect(errors).toHaveLength(0);
expect(css).toMatch(/:root\s*\{[\s\S]*--color-primary:\s*#1373d9/);
expect(css).toMatch(/\.dark\s*\{[\s\S]*--color-primary:\s*#58c9ff/);
});
it('omits .dark block for light-only themes', () => {
const { css } = compileAdvancedTheme(
baseManifest({
variants: ['light'],
tokens: { light: { primary: '#000' }, dark: { primary: '#fff' } },
}),
);
expect(css).toContain(':root');
expect(css).not.toContain('.dark');
});
it('emits common tokens into both :root and .dark', () => {
const { css } = compileAdvancedTheme(
baseManifest({
tokens: {
common: { ring: '#abc' },
light: { background: '#fff' },
dark: { background: '#000' },
},
}),
);
const rootMatch = css.match(/:root\s*\{([\s\S]*?)\}/)?.[1] ?? '';
const darkMatch = css.match(/\.dark\s*\{([\s\S]*?)\}/)?.[1] ?? '';
expect(rootMatch).toContain('--color-ring: #abc');
expect(darkMatch).toContain('--color-ring: #abc');
});
it('derives a contrasting *-foreground when derive: true', () => {
const { css } = compileAdvancedTheme(
baseManifest({
derive: true,
tokens: { light: { primary: '#000000' }, dark: { primary: '#ffffff' } },
}),
);
expect(css).toMatch(/:root\s*\{[\s\S]*--color-primary-foreground:\s*#ffffff/);
expect(css).toMatch(/\.dark\s*\{[\s\S]*--color-primary-foreground:\s*#0f172a/);
});
it('respects an author-provided *-foreground over derive', () => {
const { css } = compileAdvancedTheme(
baseManifest({
derive: true,
tokens: {
light: { primary: '#000000', 'primary-foreground': '#ff00ff' },
},
}),
);
expect(css).toContain('--color-primary-foreground: #ff00ff');
});
it('emits radii, typography, and density vars', () => {
const { css } = compileAdvancedTheme(
baseManifest({
tokens: { light: { primary: '#000' } },
radii: { sm: '2px', md: '6px', full: '9999px' },
typography: { fontSans: 'Inter, sans-serif', baseFontSize: '15px' },
density: 'compact',
}),
);
expect(css).toContain('--radius-sm: 2px');
expect(css).toContain('--radius-full: 9999px');
expect(css).toContain('--font-sans: Inter, sans-serif');
expect(css).toContain('--font-size-base: 15px');
expect(css).toContain('--density-row-height: 28px');
});
it('drops tokens with unsafe values and warns', () => {
const { css, warnings } = compileAdvancedTheme(
baseManifest({
tokens: {
light: {
primary: '#000',
evil: 'red; background: url("https://x.com/track.png")',
},
},
}),
);
expect(css).toContain('--color-primary: #000');
expect(css).not.toContain('https://x.com');
expect(warnings.some((w) => w.includes('evil'))).toBe(true);
});
it('drops tokens with unsafe keys and warns', () => {
const { css, warnings } = compileAdvancedTheme(
baseManifest({
tokens: { light: { 'primary }; body { background: red': '#fff', primary: '#000' } },
}),
);
expect(css).toContain('--color-primary: #000');
expect(css).not.toContain('body { background');
expect(warnings.some((w) => w.includes('invalid key'))).toBe(true);
});
it('errors when no structured fields are present', () => {
const { errors } = compileAdvancedTheme(baseManifest());
expect(errors.length).toBeGreaterThan(0);
});
it('inlines parent CSS when extends + resolver supplied', () => {
const { css, warnings } = compileAdvancedTheme(
baseManifest({
extends: 'parent-theme',
tokens: { light: { primary: '#fff' } },
}),
{ resolveExtends: (id) => (id === 'parent-theme' ? ':root { --x: 1; }' : null) },
);
expect(css).toContain('--x: 1');
expect(css).toContain('--color-primary: #fff');
expect(warnings).toHaveLength(0);
});
it('warns when extends parent cannot be resolved', () => {
const { warnings } = compileAdvancedTheme(
baseManifest({
extends: 'missing',
tokens: { light: { primary: '#fff' } },
}),
{ resolveExtends: () => null },
);
expect(warnings.some((w) => w.includes('missing'))).toBe(true);
});
it('appends user-supplied CSS after compiled output', () => {
const { css } = compileAdvancedTheme(
baseManifest({ tokens: { light: { primary: '#fff' } } }),
{ userCSS: '@font-face { font-family: "X"; src: local("X"); }' },
);
const compiledIdx = css.indexOf('--color-primary');
const userIdx = css.indexOf('@font-face');
expect(compiledIdx).toBeGreaterThanOrEqual(0);
expect(userIdx).toBeGreaterThan(compiledIdx);
});
});
+61
View File
@@ -1,9 +1,12 @@
import { describe, it, expect, afterEach } from 'vitest';
import {
sanitizeThemeCSS,
sanitizeSkinCSS,
validateThemeSelectors,
injectThemeCSS,
removeThemeCSS,
injectThemeSkinCSS,
removeThemeSkinCSS,
validateThemeCSSSafety,
} from '../theme-loader';
@@ -144,6 +147,64 @@ describe('theme-loader', () => {
});
});
describe('injectThemeSkinCSS / removeThemeSkinCSS', () => {
afterEach(() => {
removeThemeSkinCSS();
});
it('injects a separate <style> tag from the colour block', () => {
injectThemeCSS(':root { --color-primary: red; }');
injectThemeSkinCSS('button { padding: 4px; }', 'thunderbird');
expect(document.getElementById('active-theme')).not.toBeNull();
expect(document.getElementById('active-theme-skin')).not.toBeNull();
expect(document.getElementById('active-theme-skin')?.textContent).toContain('button');
});
it('sets data-theme-skin on body to the active theme id', () => {
injectThemeSkinCSS('button { padding: 4px; }', 'my-theme');
expect(document.body.getAttribute('data-theme-skin')).toBe('my-theme');
});
it('removes the skin tag and body attribute on remove', () => {
injectThemeSkinCSS('button { padding: 4px; }', 'my-theme');
removeThemeSkinCSS();
expect(document.getElementById('active-theme-skin')).toBeNull();
expect(document.body.getAttribute('data-theme-skin')).toBeNull();
});
it('does not throw when removing without a prior inject', () => {
expect(() => removeThemeSkinCSS()).not.toThrow();
});
});
describe('sanitizeSkinCSS', () => {
it('preserves component-level selectors', () => {
const css = '[data-tour="email-list"] { font-size: 13px; } button { padding: 4px; }';
const { css: cleaned, warnings } = sanitizeSkinCSS(css);
expect(cleaned).toBe(css);
expect(warnings).toHaveLength(0);
});
it('strips dangerous patterns', () => {
const { css: cleaned, warnings } = sanitizeSkinCSS(
'@import url("https://x.com/p.css"); button { background: javascript:alert(1); }',
);
expect(cleaned).not.toContain('@import');
expect(cleaned).not.toContain('javascript:');
expect(warnings.length).toBeGreaterThanOrEqual(2);
});
it('strips @charset and @namespace', () => {
const { css: cleaned, warnings } = sanitizeSkinCSS(
'@charset "utf-8"; @namespace url(http://www.w3.org/1999/xhtml); button { padding: 4px; }',
);
expect(cleaned).not.toContain('@charset');
expect(cleaned).not.toContain('@namespace');
expect(cleaned).toContain('button');
expect(warnings.length).toBeGreaterThanOrEqual(2);
});
});
describe('validateThemeCSSSafety', () => {
it('accepts valid theme CSS', () => {
const css = ':root { --color-primary: #3b82f6; --color-background: #fff; }';
+12
View File
@@ -365,6 +365,18 @@ export const themeHooks = {
onThemeChange: new HookBus(),
onCustomThemeChange: new HookBus(),
onLocaleChange: new HookBus(),
/**
* Transform hook fired immediately before a theme's compiled CSS is
* injected into the document.
*
* handler(css: string, ctx: { themeId: string | null; variant: 'light' | 'dark' }): string | undefined
*
* Return a new CSS string to override what gets injected, or `undefined`
* to pass through unchanged. Use this to inject extra `@font-face` rules,
* patch a third-party theme's variables for accessibility, or implement
* site-wide design-token overrides.
*/
onThemeBeforeApply: new HookBus(),
};
// §7.15 Toast Hooks
+18 -1
View File
@@ -1,9 +1,11 @@
// IndexedDB storage for plugin/theme binary blobs (JS bundles, CSS, previews)
const DB_NAME = 'bulwark-plugins';
const DB_VERSION = 1;
// Bumped to 2 to add the theme-skin store; existing stores are preserved.
const DB_VERSION = 2;
const STORE_PLUGINS = 'plugin-code';
const STORE_THEMES = 'theme-css';
const STORE_THEME_SKINS = 'theme-skin';
const STORE_PREVIEWS = 'previews';
function openDB(): Promise<IDBDatabase> {
@@ -18,6 +20,9 @@ function openDB(): Promise<IDBDatabase> {
if (!db.objectStoreNames.contains(STORE_THEMES)) {
db.createObjectStore(STORE_THEMES);
}
if (!db.objectStoreNames.contains(STORE_THEME_SKINS)) {
db.createObjectStore(STORE_THEME_SKINS);
}
if (!db.objectStoreNames.contains(STORE_PREVIEWS)) {
db.createObjectStore(STORE_PREVIEWS);
}
@@ -83,6 +88,18 @@ export const pluginStorage = {
await deleteItem(STORE_THEMES, themeId);
},
// Theme skin CSS — separate store so it can be present/absent independently
// of the colour-token CSS (e.g. some v2 themes ship colours only).
async saveThemeSkin(themeId: string, skin: string): Promise<void> {
await putItem(STORE_THEME_SKINS, themeId, skin);
},
async getThemeSkin(themeId: string): Promise<string | null> {
return getItem<string>(STORE_THEME_SKINS, themeId);
},
async deleteThemeSkin(themeId: string): Promise<void> {
await deleteItem(STORE_THEME_SKINS, themeId);
},
// Preview images (stored as data URIs)
async savePreview(id: string, dataUri: string): Promise<void> {
await putItem(STORE_PREVIEWS, id, dataUri);
+76 -2
View File
@@ -11,6 +11,41 @@ export type ThemeVariant = 'light' | 'dark';
// ─── Manifests ───────────────────────────────────────────────
/**
* Advanced theme fields ("Theme API v2"). All optional and additive — a
* legacy theme that ships only `:root`/`.dark` CSS continues to work.
*
* When `apiVersion >= 2` (or any of `tokens`/`extends`/`derive`/`density`/
* `radii`/`typography` is present), the theme compiler runs at install time
* and produces a single CSS string from the structured fields, optionally
* concatenated with a hand-written `theme.css` for fine-grained overrides.
*/
export interface ThemeTokenSet {
/** Tokens applied regardless of variant (emitted into `:root`). */
common?: Record<string, string>;
/** Tokens applied in light mode (emitted into `:root`). */
light?: Record<string, string>;
/** Tokens applied in dark mode (emitted into `.dark`). */
dark?: Record<string, string>;
}
export type ThemeDensity = 'compact' | 'normal' | 'touch';
export interface ThemeRadii {
sm?: string;
md?: string;
lg?: string;
xl?: string;
full?: string;
}
export interface ThemeTypography {
fontSans?: string;
fontMono?: string;
fontDisplay?: string;
baseFontSize?: string;
}
export interface ThemeManifest {
id: string;
name: string;
@@ -21,6 +56,22 @@ export interface ThemeManifest {
preview?: string;
variants: ThemeVariant[];
minAppVersion?: string;
// ─── Advanced (Theme API v2) ─────────────────────────────────
/** Theme API version. Defaults to 1 (raw-CSS only). */
apiVersion?: 1 | 2;
/** Inherit tokens/CSS from another installed (or built-in) theme by id. */
extends?: string;
/** Structured colour tokens — compiled into CSS at install time. */
tokens?: ThemeTokenSet;
/** When true, missing standard tokens are derived (e.g. *-foreground from contrast). */
derive?: boolean;
/** Default UI density preset (compact / normal / touch). */
density?: ThemeDensity;
/** Border-radius scale, emitted as `--radius-*` vars. */
radii?: ThemeRadii;
/** Font stacks + base size, emitted as `--font-*` vars. */
typography?: ThemeTypography;
}
export interface PluginManifest {
@@ -70,12 +121,29 @@ export interface InstalledTheme {
author: string;
description: string;
preview?: string; // data: URI or blob URL
css: string; // raw CSS text
css: string; // compiled CSS text — what gets injected
/**
* Optional "skin" CSS shipped by Theme API v2 themes that need to restyle
* actual UI components (toolbars, lists, buttons, etc.) — not just colour
* tokens. Injected into a separate `<style>` tag so it can be stripped
* cleanly when the theme is deactivated. Stored in IndexedDB with the same
* lifecycle as `css` to keep localStorage small.
*/
skin?: string;
variants: ThemeVariant[];
enabled: boolean;
builtIn: boolean;
managed?: boolean;
forceEnabled?: boolean;
// ─── Advanced (Theme API v2) ─ carried over from the manifest ─
apiVersion?: 1 | 2;
extends?: string;
tokens?: ThemeTokenSet;
derive?: boolean;
density?: ThemeDensity;
radii?: ThemeRadii;
typography?: ThemeTypography;
}
export interface InstalledPlugin {
@@ -527,7 +595,13 @@ export const IMPLICIT_PERMISSIONS: Permission[] = ['ui:observe', 'app:lifecycle'
// ─── Validation ──────────────────────────────────────────────
export const MAX_PLUGIN_SIZE = 5 * 1024 * 1024; // 5 MB
export const MAX_THEME_SIZE = 1 * 1024 * 1024; // 1 MB
export const MAX_THEME_SIZE = 2 * 1024 * 1024; // 2 MB (was 1 MB; v2 themes may ship a skin.css)
/**
* Maximum size of an individual `skin.css` payload after extraction.
* Skins are component-level CSS, not images — anything bigger than this is
* almost certainly bundling assets the validator will refuse anyway.
*/
export const MAX_THEME_SKIN_BYTES = 256 * 1024; // 256 KB
export const ALLOWED_PLUGIN_FILES = new Set([
'.js', '.mjs', '.css', '.json', '.png', '.svg', '.woff2', '.jpg', '.jpeg', '.webp',
+91 -14
View File
@@ -8,9 +8,11 @@ import {
ALL_PERMISSIONS,
MAX_PLUGIN_SIZE,
MAX_THEME_SIZE,
MAX_THEME_SKIN_BYTES,
ALLOWED_PLUGIN_FILES,
} from './plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from './theme-loader';
import { sanitizeThemeCSS, sanitizeSkinCSS, validateThemeCSSSafety } from './theme-loader';
import { compileAdvancedTheme, isAdvancedManifest } from './theme-compiler';
export interface ValidationResult {
valid: boolean;
@@ -21,6 +23,11 @@ export interface ValidationResult {
export interface ThemeExtractionResult extends ValidationResult {
manifest: ThemeManifest | null;
css: string;
/**
* Optional skin CSS — component-level overrides extracted from `skin.css`.
* Only populated for Theme API v2 manifests; v1 themes ignore the file.
*/
skin: string | null;
preview: string | null; // data URI
}
@@ -63,6 +70,29 @@ function validateThemeManifest(manifest: Record<string, unknown>): { result: The
if (!valid) errors.push('Variants must be "light" or "dark"');
}
// ── Theme API v2 fields (all optional) ──
if (manifest.apiVersion !== undefined && manifest.apiVersion !== 1 && manifest.apiVersion !== 2) {
errors.push('"apiVersion" must be 1 or 2 if present');
}
if (manifest.extends !== undefined && typeof manifest.extends !== 'string') {
errors.push('"extends" must be a string (the parent theme id)');
}
if (manifest.tokens !== undefined && (typeof manifest.tokens !== 'object' || manifest.tokens === null)) {
errors.push('"tokens" must be an object with optional "common"/"light"/"dark" maps');
}
if (manifest.density !== undefined && !['compact', 'normal', 'touch'].includes(manifest.density as string)) {
errors.push('"density" must be "compact", "normal", or "touch"');
}
if (manifest.derive !== undefined && typeof manifest.derive !== 'boolean') {
errors.push('"derive" must be a boolean');
}
if (manifest.radii !== undefined && (typeof manifest.radii !== 'object' || manifest.radii === null)) {
errors.push('"radii" must be an object');
}
if (manifest.typography !== undefined && (typeof manifest.typography !== 'object' || manifest.typography === null)) {
errors.push('"typography" must be an object');
}
if (errors.length > 0) return { result: null, errors };
return {
@@ -157,7 +187,12 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
// Size check
if (file.size > MAX_THEME_SIZE) {
return { valid: false, errors: ['Theme ZIP exceeds 1 MB size limit'], warnings: [], manifest: null, css: '', preview: null };
return {
valid: false,
errors: [`Theme ZIP exceeds ${Math.round(MAX_THEME_SIZE / (1024 * 1024))} MB size limit`],
warnings: [],
manifest: null, css: '', skin: null, preview: null,
};
}
let zip: JSZip;
@@ -165,7 +200,7 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
const buffer = await file.arrayBuffer();
zip = await JSZip.loadAsync(buffer);
} catch {
return { valid: false, errors: ['Invalid ZIP file'], warnings: [], manifest: null, css: '', preview: null };
return { valid: false, errors: ['Invalid ZIP file'], warnings: [], manifest: null, css: '', skin: null, preview: null };
}
const root = findZipRoot(zip);
@@ -173,7 +208,7 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
// Read manifest
const manifestFile = zip.file(root + 'manifest.json');
if (!manifestFile) {
return { valid: false, errors: ['Missing manifest.json'], warnings: [], manifest: null, css: '', preview: null };
return { valid: false, errors: ['Missing manifest.json'], warnings: [], manifest: null, css: '', skin: null, preview: null };
}
let manifestData: Record<string, unknown>;
@@ -181,28 +216,49 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
const raw = await manifestFile.async('string');
manifestData = JSON.parse(raw);
} catch {
return { valid: false, errors: ['Invalid manifest.json (not valid JSON)'], warnings: [], manifest: null, css: '', preview: null };
return { valid: false, errors: ['Invalid manifest.json (not valid JSON)'], warnings: [], manifest: null, css: '', skin: null, preview: null };
}
const { result: manifest, errors: manifestErrors } = validateThemeManifest(manifestData);
errors.push(...manifestErrors);
if (!manifest) {
return { valid: false, errors, warnings, manifest: null, css: '', preview: null };
return { valid: false, errors, warnings, manifest: null, css: '', skin: null, preview: null };
}
// Read theme.css
// Read theme.css — required for v1 themes, optional when the manifest
// declares Theme API v2 fields (tokens/extends/derive/density/radii/typography),
// since the compiler can produce CSS purely from the manifest.
const cssFile = zip.file(root + 'theme.css');
if (!cssFile) {
const isAdvanced = isAdvancedManifest(manifest);
let userCSS = '';
if (cssFile) {
userCSS = await cssFile.async('string');
const safety = validateThemeCSSSafety(userCSS);
if (!safety.valid) {
// Sanitize instead of rejecting
const sanitized = sanitizeThemeCSS(userCSS);
userCSS = sanitized.css;
warnings.push(...sanitized.warnings);
}
} else if (!isAdvanced) {
errors.push('Missing theme.css');
return { valid: false, errors, warnings, manifest, css: '', preview: null };
return { valid: false, errors, warnings, manifest, css: '', skin: null, preview: null };
}
let rawCSS = await cssFile.async('string');
// Compile advanced tokens into CSS (for v2 manifests). The compiled output
// is concatenated with any user-supplied theme.css for fine-grained overrides.
let rawCSS = userCSS;
if (isAdvanced) {
const compiled = compileAdvancedTheme(manifest, { userCSS });
if (compiled.errors.length > 0) {
errors.push(...compiled.errors);
return { valid: false, errors, warnings, manifest, css: '', skin: null, preview: null };
}
warnings.push(...compiled.warnings);
rawCSS = compiled.css;
// Validate CSS safety
const safety = validateThemeCSSSafety(rawCSS);
if (!safety.valid) {
// Sanitize instead of rejecting
// Run sanitizer over the final compiled output as a defence-in-depth check.
const sanitized = sanitizeThemeCSS(rawCSS);
rawCSS = sanitized.css;
warnings.push(...sanitized.warnings);
@@ -222,12 +278,33 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
}
}
// Read skin.css if present (Theme API v2 only). Skins target real
// component selectors and bypass the strict :root/.dark selector check —
// they still go through the dangerous-pattern sanitizer.
let skin: string | null = null;
const skinFile = zip.file(root + 'skin.css');
if (skinFile) {
if (!isAdvanced) {
warnings.push('skin.css ignored — only Theme API v2 manifests can ship a skin');
} else {
const rawSkin = await skinFile.async('string');
if (rawSkin.length > MAX_THEME_SKIN_BYTES) {
warnings.push(`skin.css exceeds ${Math.round(MAX_THEME_SKIN_BYTES / 1024)} KB and was dropped`);
} else {
const sanitized = sanitizeSkinCSS(rawSkin);
skin = sanitized.css;
warnings.push(...sanitized.warnings);
}
}
}
return {
valid: errors.length === 0,
errors,
warnings,
manifest,
css: rawCSS,
skin,
preview,
};
}
+295
View File
@@ -0,0 +1,295 @@
// Advanced Theme API v2 — compiles structured manifest fields (tokens,
// radii, typography, density, extends) into a single CSS string that the
// existing `injectThemeCSS` pipeline can apply unchanged.
import type {
ThemeDensity,
ThemeManifest,
ThemeRadii,
ThemeTokenSet,
ThemeTypography,
} from './plugin-types';
import { getLuminance, parseColor } from './color-transform';
export interface CompiledTheme {
css: string;
warnings: string[];
errors: string[];
}
/**
* Standard tokens whose `*-foreground` counterpart can be auto-derived from
* contrast when `derive: true` and only the base colour is supplied.
*/
const DERIVE_PAIRS: Array<[base: string, fg: string]> = [
['primary', 'primary-foreground'],
['secondary', 'secondary-foreground'],
['muted', 'muted-foreground'],
['accent', 'accent-foreground'],
['destructive', 'destructive-foreground'],
['popover', 'popover-foreground'],
['card', 'card-foreground'],
['sidebar', 'sidebar-foreground'],
['success', 'success-foreground'],
['warning', 'warning-foreground'],
['info', 'info-foreground'],
];
/** Pick a foreground colour (white or near-black) by background luminance. */
function pickForeground(bg: string): string {
const rgb = parseColor(bg);
if (!rgb) return '#ffffff';
return getLuminance(rgb.r, rgb.g, rgb.b) >= 0.55 ? '#0f172a' : '#ffffff';
}
/**
* Resolve a manifest token key to a fully-qualified CSS custom property:
* "primary" → "--color-primary"
* "color-primary" → "--color-primary"
* "--color-primary" → "--color-primary"
* "font-sans" → "--font-sans"
*/
const PREFIXED_NAMESPACES = ['color-', 'font-', 'radius-', 'density-'];
function tokenName(key: string): string {
if (key.startsWith('--')) return key;
if (PREFIXED_NAMESPACES.some((ns) => key.startsWith(ns))) return `--${key}`;
return `--color-${key}`;
}
function emitTokens(
tokens: Record<string, string>,
derive: boolean,
): { lines: string[]; warnings: string[] } {
const warnings: string[] = [];
const expanded: Record<string, string> = { ...tokens };
if (derive) {
for (const [base, fg] of DERIVE_PAIRS) {
if (expanded[base] && !expanded[fg]) {
expanded[fg] = pickForeground(expanded[base]);
}
}
// Common alias: --color-foreground used as page text colour.
if (expanded.background && !expanded.foreground) {
expanded.foreground = pickForeground(expanded.background);
}
}
const lines: string[] = [];
for (const [rawKey, value] of Object.entries(expanded)) {
if (typeof value !== 'string' || !value.trim()) continue;
if (!isSafeTokenKey(rawKey)) {
warnings.push(`Token "${rawKey}" dropped — invalid key (only [a-z0-9-] allowed)`);
continue;
}
if (!isSafeTokenValue(value)) {
warnings.push(`Token "${rawKey}" dropped — value contains unsafe characters`);
continue;
}
lines.push(` ${tokenName(rawKey)}: ${value.trim()};`);
}
return { lines, warnings };
}
const SAFE_KEY_PATTERN = /^(--)?[a-z][a-z0-9-]*$/;
function isSafeTokenKey(key: string): boolean {
return SAFE_KEY_PATTERN.test(key);
}
/**
* Token values are emitted verbatim into CSS, so they must not contain
* anything that could break out of the declaration (`{`, `}`, `;`,
* `<`/`>`) or pull in remote/scripted content.
*/
function isSafeTokenValue(value: string): boolean {
if (/[{}<>]/.test(value)) return false;
if (value.includes(';')) return false;
if (/url\s*\(\s*['"]?(https?|data|javascript):/i.test(value)) return false;
if (/expression\s*\(/i.test(value)) return false;
if (/-moz-binding/i.test(value)) return false;
if (/javascript\s*:/i.test(value)) return false;
return true;
}
function emitRadii(radii: ThemeRadii): string[] {
const out: string[] = [];
for (const [k, v] of Object.entries(radii)) {
if (typeof v === 'string' && isSafeTokenValue(v)) {
out.push(` --radius-${k}: ${v.trim()};`);
}
}
return out;
}
function emitTypography(typography: ThemeTypography): string[] {
const out: string[] = [];
if (typography.fontSans && isSafeTokenValue(typography.fontSans)) {
out.push(` --font-sans: ${typography.fontSans.trim()};`);
}
if (typography.fontMono && isSafeTokenValue(typography.fontMono)) {
out.push(` --font-mono: ${typography.fontMono.trim()};`);
}
if (typography.fontDisplay && isSafeTokenValue(typography.fontDisplay)) {
out.push(` --font-display: ${typography.fontDisplay.trim()};`);
}
if (typography.baseFontSize && isSafeTokenValue(typography.baseFontSize)) {
out.push(` --font-size-base: ${typography.baseFontSize.trim()};`);
}
return out;
}
const DENSITY_VARS: Record<ThemeDensity, Record<string, string>> = {
compact: {
'--density-row-height': '28px',
'--density-control-height': '28px',
'--density-spacing-1': '2px',
'--density-spacing-2': '4px',
'--density-spacing-3': '6px',
},
normal: {
'--density-row-height': '36px',
'--density-control-height': '32px',
'--density-spacing-1': '4px',
'--density-spacing-2': '8px',
'--density-spacing-3': '12px',
},
touch: {
'--density-row-height': '44px',
'--density-control-height': '40px',
'--density-spacing-1': '6px',
'--density-spacing-2': '12px',
'--density-spacing-3': '18px',
},
};
function emitDensity(density: ThemeDensity): string[] {
return Object.entries(DENSITY_VARS[density]).map(([k, v]) => ` ${k}: ${v};`);
}
export interface CompileOptions {
/**
* Resolves a `extends: <id>` chain to that base theme's compiled CSS.
* Implementations should return null for unknown ids; circular refs are
* the caller's problem (we don't recurse — just one level of inheritance).
*/
resolveExtends?: (id: string) => string | null;
/**
* Optional hand-written CSS appended after compiled tokens. Use this for
* the rare overrides the structured API can't express (extra `@font-face`,
* `@keyframes`, `@media (prefers-contrast)` blocks, etc.).
*/
userCSS?: string;
}
/**
* Compile an advanced theme manifest into a single safe CSS string.
*
* Output layout:
* 1. parent (extends) CSS, if any
* 2. `:root { common + light + radii + typography + density }`
* 3. `.dark { common + dark }` (only when the theme declares a dark variant)
* 4. user-supplied `theme.css` content (sanitized upstream)
*
* The compiler never emits selectors other than `:root` and `.dark`, so the
* existing CSS sanitizer/selector validator continues to apply.
*/
export function compileAdvancedTheme(
manifest: ThemeManifest,
opts: CompileOptions = {},
): CompiledTheme {
const warnings: string[] = [];
const errors: string[] = [];
if (!isAdvancedManifest(manifest)) {
return { css: '', warnings, errors: ['Manifest does not declare any advanced theme fields'] };
}
const tokens: ThemeTokenSet = manifest.tokens ?? {};
const derive = manifest.derive === true;
const wantsDark = manifest.variants.includes('dark');
const wantsLight = manifest.variants.includes('light');
const sections: string[] = [];
// 1. extends — prepend parent CSS verbatim
if (manifest.extends && opts.resolveExtends) {
const parentCSS = opts.resolveExtends(manifest.extends);
if (parentCSS == null) {
warnings.push(`extends: parent theme "${manifest.extends}" not found — skipping`);
} else {
sections.push(`/* inherited from ${manifest.extends} */\n${parentCSS}`);
}
} else if (manifest.extends) {
warnings.push(`extends: no resolver provided — "${manifest.extends}" ignored`);
}
// 2. :root block (light + common + structural)
const rootLines: string[] = [];
if (tokens.common) {
const { lines, warnings: w } = emitTokens(tokens.common, derive);
rootLines.push(...lines);
warnings.push(...w);
}
if (wantsLight && tokens.light) {
const { lines, warnings: w } = emitTokens(tokens.light, derive);
rootLines.push(...lines);
warnings.push(...w);
}
if (manifest.radii) rootLines.push(...emitRadii(manifest.radii));
if (manifest.typography) rootLines.push(...emitTypography(manifest.typography));
if (manifest.density) rootLines.push(...emitDensity(manifest.density));
if (rootLines.length > 0) {
sections.push(`:root {\n${rootLines.join('\n')}\n}`);
}
// 3. .dark block
if (wantsDark) {
const darkLines: string[] = [];
if (tokens.common) {
const { lines, warnings: w } = emitTokens(tokens.common, derive);
darkLines.push(...lines);
warnings.push(...w);
}
if (tokens.dark) {
const { lines, warnings: w } = emitTokens(tokens.dark, derive);
darkLines.push(...lines);
warnings.push(...w);
}
if (darkLines.length > 0) {
sections.push(`.dark {\n${darkLines.join('\n')}\n}`);
}
}
// 4. hand-written overrides
if (opts.userCSS && opts.userCSS.trim()) {
sections.push(`/* user overrides */\n${opts.userCSS.trim()}`);
}
if (sections.length === 0) {
errors.push('Compiled theme is empty — no tokens, radii, typography, or density supplied');
}
return {
css: sections.join('\n\n'),
warnings,
errors,
};
}
/**
* True if a manifest opts into Theme API v2 by setting `apiVersion: 2` or by
* declaring any of the structured fields.
*/
export function isAdvancedManifest(manifest: ThemeManifest): boolean {
return (
manifest.apiVersion === 2 ||
!!manifest.tokens ||
!!manifest.extends ||
!!manifest.derive ||
!!manifest.density ||
!!manifest.radii ||
!!manifest.typography
);
}
+63
View File
@@ -3,6 +3,8 @@
import { DISALLOWED_CSS_PATTERNS } from './plugin-types';
const THEME_STYLE_ID = 'active-theme';
const THEME_SKIN_STYLE_ID = 'active-theme-skin';
const THEME_SKIN_BODY_ATTR = 'data-theme-skin';
/**
* Sanitize theme CSS: strip dangerous patterns like @import, external url(),
@@ -88,6 +90,67 @@ export function removeThemeCSS(): void {
}
}
/**
* Inject a theme's *skin* CSS — component-level overrides shipped by Theme
* API v2 themes via `skin.css`. Lives in a separate `<style>` tag so it can
* be removed cleanly without touching the colour-token block, and is placed
* AFTER the colour block so component rules win specificity.
*
* Also sets `body[data-theme-skin="<themeId>"]` so authors can scope their
* own `:not(...)` overrides if they want belt-and-braces specificity.
*/
export function injectThemeSkinCSS(css: string, themeId: string): void {
if (typeof document === 'undefined') return;
let styleEl = document.getElementById(THEME_SKIN_STYLE_ID) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = THEME_SKIN_STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = css;
if (document.body) {
document.body.setAttribute(THEME_SKIN_BODY_ATTR, themeId);
}
}
export function removeThemeSkinCSS(): void {
if (typeof document === 'undefined') return;
const styleEl = document.getElementById(THEME_SKIN_STYLE_ID);
if (styleEl) styleEl.remove();
if (document.body) document.body.removeAttribute(THEME_SKIN_BODY_ATTR);
}
/**
* Sanitize a theme *skin* — looser than `sanitizeThemeCSS` because skins
* intentionally target real component selectors (toolbars, lists, buttons),
* not just `:root`/`.dark`. The same script-injection / external-resource
* prohibitions still apply.
*/
export function sanitizeSkinCSS(css: string): { css: string; warnings: string[] } {
const warnings: string[] = [];
let cleaned = css;
for (const pattern of DISALLOWED_CSS_PATTERNS) {
if (pattern.test(cleaned)) {
warnings.push(`Skin: removed disallowed pattern: ${pattern.source}`);
cleaned = cleaned.replace(new RegExp(pattern.source, 'gi'), '/* [removed] */');
}
}
// `@import` is already covered by DISALLOWED_CSS_PATTERNS, but skins also
// get an explicit no-`@charset`/`@namespace` policy so they can't change
// how the host stylesheet parses subsequent rules.
cleaned = cleaned.replace(/@(charset|namespace)\b[^;]*;?/gi, () => {
warnings.push('Skin: removed @charset/@namespace directive');
return '/* [removed] */';
});
return { css: cleaned, warnings };
}
/**
* Check if a theme CSS string is valid and safe.
*/
+92 -4
View File
@@ -2,11 +2,18 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { InstalledTheme, ThemeVariant } from '@/lib/plugin-types';
import { pluginStorage } from '@/lib/plugin-storage';
import { injectThemeCSS, removeThemeCSS, sanitizeThemeCSS } from '@/lib/theme-loader';
import {
injectThemeCSS,
removeThemeCSS,
sanitizeThemeCSS,
injectThemeSkinCSS,
removeThemeSkinCSS,
} from '@/lib/theme-loader';
import { extractTheme } from '@/lib/plugin-validator';
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
import { usePolicyStore } from '@/stores/policy-store';
import { apiFetch } from '@/lib/browser-navigation';
import { themeHooks } from '@/lib/plugin-hooks';
type Theme = 'light' | 'dark' | 'system';
@@ -173,9 +180,22 @@ export const useThemeStore = create<ThemeState>()(
return { success: false, error: result.errors.join('; '), warnings: result.warnings };
}
const { manifest, css, preview } = result;
const { manifest, css, skin, preview } = result;
const { installedThemes } = get();
// Carry advanced (Theme API v2) fields from the manifest through
// to the InstalledTheme so the activate/sync paths can re-compile
// or re-apply tokens later if needed.
const advancedFields = {
apiVersion: manifest.apiVersion,
extends: manifest.extends,
tokens: manifest.tokens,
derive: manifest.derive,
density: manifest.density,
radii: manifest.radii,
typography: manifest.typography,
};
// Check for duplicate
if (installedThemes.some(t => t.id === manifest.id)) {
// Update existing
@@ -188,12 +208,19 @@ export const useThemeStore = create<ThemeState>()(
description: manifest.description || '',
preview: preview || undefined,
css: sanitized.css,
skin: skin ?? undefined,
variants: manifest.variants,
enabled: true,
builtIn: false,
...advancedFields,
};
await pluginStorage.saveThemeCSS(manifest.id, sanitized.css);
if (skin) {
await pluginStorage.saveThemeSkin(manifest.id, skin);
} else {
await pluginStorage.deleteThemeSkin(manifest.id);
}
if (preview) await pluginStorage.savePreview(manifest.id, preview);
set({
@@ -215,12 +242,15 @@ export const useThemeStore = create<ThemeState>()(
description: manifest.description || '',
preview: preview || undefined,
css: sanitized.css,
skin: skin ?? undefined,
variants: manifest.variants,
enabled: true,
builtIn: false,
...advancedFields,
};
await pluginStorage.saveThemeCSS(manifest.id, sanitized.css);
if (skin) await pluginStorage.saveThemeSkin(manifest.id, skin);
if (preview) await pluginStorage.savePreview(manifest.id, preview);
set({ installedThemes: [...installedThemes, theme] });
@@ -237,11 +267,13 @@ export const useThemeStore = create<ThemeState>()(
// Deactivate if active
if (activeThemeId === id) {
removeThemeCSS();
removeThemeSkinCSS();
set({ activeThemeId: null });
}
// Clean up storage
pluginStorage.deleteThemeCSS(id);
pluginStorage.deleteThemeSkin(id);
pluginStorage.deletePreview(id);
set({
@@ -264,6 +296,7 @@ export const useThemeStore = create<ThemeState>()(
if (id === null) {
removeThemeCSS();
removeThemeSkinCSS();
set({ activeThemeId: null });
return;
}
@@ -407,10 +440,11 @@ export const useThemeStore = create<ThemeState>()(
partialize: (state) => ({
theme: state.theme,
activeThemeId: state.activeThemeId,
// Store theme metadata but NOT full CSS (that goes in IndexedDB)
// Store theme metadata but NOT full CSS / skin (those go in IndexedDB)
installedThemes: state.installedThemes.map(t => ({
...t,
css: t.builtIn ? t.css : '', // only keep CSS for built-in themes
skin: undefined, // skins also in IndexedDB
preview: undefined, // previews also in IndexedDB
})),
}),
@@ -434,14 +468,68 @@ export const useThemeStore = create<ThemeState>()(
)
);
/** Apply a custom theme's CSS, filtering to the appropriate variant */
/**
* Apply a custom theme's CSS, filtering to the appropriate variant.
*
* Fires the `themeHooks.onThemeBeforeApply` transform hook so plugins can
* post-process the CSS (e.g. inject extra `@font-face` rules or override
* specific tokens). The hook is fire-and-forget — we inject the original
* CSS synchronously first to avoid a flash, then re-inject the transformed
* version once handlers settle.
*
* If the theme also has a `skin` (Theme API v2 component-level overrides),
* it's injected into a separate `<style>` tag after the colour block so
* skin rules win specificity. The skin is hydrated lazily from IndexedDB
* on first activation.
*/
function applyCustomThemeCSS(theme: InstalledTheme, resolvedTheme: 'light' | 'dark'): void {
// If theme only supports one variant and current mode doesn't match, skip
if (!theme.variants.includes(resolvedTheme as ThemeVariant)) {
removeThemeCSS();
removeThemeSkinCSS();
return;
}
injectThemeCSS(theme.css);
// Apply skin if the theme ships one. Hydrate from IndexedDB if the cached
// copy was stripped from localStorage on persist.
if (theme.skin) {
injectThemeSkinCSS(theme.skin, theme.id);
} else if (theme.apiVersion === 2) {
pluginStorage.getThemeSkin(theme.id).then((skin) => {
// Bail out if the user switched themes mid-flight.
if (useThemeStore.getState().activeThemeId !== theme.id) return;
if (skin) {
injectThemeSkinCSS(skin, theme.id);
useThemeStore.setState((state) => ({
installedThemes: state.installedThemes.map((it) =>
it.id === theme.id ? { ...it, skin } : it,
),
}));
} else {
removeThemeSkinCSS();
}
});
} else {
removeThemeSkinCSS();
}
// Run plugin transforms asynchronously and re-inject if any handler
// modified the CSS. No handlers → no extra work.
if (themeHooks.onThemeBeforeApply.size === 0) return;
const themeId = theme.id;
themeHooks.onThemeBeforeApply
.transform(theme.css, { themeId, variant: resolvedTheme })
.then((transformed) => {
// Bail out if the user switched themes while we were awaiting handlers.
if (useThemeStore.getState().activeThemeId !== themeId) return;
if (transformed && transformed !== theme.css) {
injectThemeCSS(transformed);
}
})
.catch(() => {
// Hook failures are tracked by the hook bus; nothing to do here.
});
}
// ─── Server Theme Sync Helpers ───────────────────────────────