feat: tsuki Firefox theme for Nova (157+)
Adaptive light/dark static theme generated from @tsuki/tokens, plus an optional userChrome.css layer (IBM Plex, tsuki radii, shu accent) that overrides Nova's design tokens. Self-hosted via AMO unlisted signing and Gitea releases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { kami, shu, sumi } from '@tsuki/tokens';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildManifest, buildUpdates, buildUserChrome, GECKO_ID, resolve } from './build';
|
||||
import { type ColorMap, markPairs, modes, textPairs } from './mapping';
|
||||
|
||||
const palette = new Set<string>([
|
||||
...Object.values(kami),
|
||||
...Object.values(sumi),
|
||||
...Object.values(shu),
|
||||
]);
|
||||
|
||||
function luminance(hex: string): number {
|
||||
const [r, g, b] = [1, 3, 5].map((i) => {
|
||||
const c = Number.parseInt(hex.slice(i, i + 2), 16) / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
}) as [number, number, number];
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
function contrast(a: string, b: string): number {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
}
|
||||
|
||||
const manifest = buildManifest('1.2.3');
|
||||
|
||||
describe('manifest', () => {
|
||||
it('targets Nova and self-distribution', () => {
|
||||
expect(manifest.manifest_version).toBe(3);
|
||||
expect(manifest.browser_specific_settings.gecko).toMatchObject({
|
||||
id: GECKO_ID,
|
||||
strict_min_version: '157.0',
|
||||
});
|
||||
expect(manifest.browser_specific_settings.gecko.update_url).toMatch(/^https:\/\//);
|
||||
});
|
||||
|
||||
it('is adaptive: light in `theme`, dark in `dark_theme`', () => {
|
||||
expect(manifest.theme.properties.color_scheme).toBe('light');
|
||||
expect(manifest.dark_theme.properties.color_scheme).toBe('dark');
|
||||
});
|
||||
|
||||
it('ships no images — tsuki has no gradients or textures', () => {
|
||||
expect(manifest.theme).not.toHaveProperty('images');
|
||||
expect(manifest.dark_theme).not.toHaveProperty('images');
|
||||
expect(JSON.stringify(manifest)).not.toMatch(/gradient/i);
|
||||
});
|
||||
|
||||
it('omits keys that do nothing in Nova', () => {
|
||||
for (const block of [manifest.theme, manifest.dark_theme]) {
|
||||
for (const dead of ['ntp_text', 'sidebar_highlight', 'sidebar_highlight_text']) {
|
||||
expect(block.colors).not.toHaveProperty(dead);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('light and dark define the same keys', () => {
|
||||
expect(Object.keys(modes.dark).sort()).toEqual(Object.keys(modes.light).sort());
|
||||
});
|
||||
|
||||
it('every color is a tsuki token (or transparent)', () => {
|
||||
for (const block of [manifest.theme, manifest.dark_theme]) {
|
||||
for (const [key, value] of Object.entries(block.colors)) {
|
||||
expect(value === 'transparent' || palette.has(value), `${key}: ${value}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolve', () => {
|
||||
it('maps token names to @tsuki/tokens hex', () => {
|
||||
expect(resolve('shu-500')).toBe(shu['500']);
|
||||
expect(resolve('kami-0')).toBe(kami['0']);
|
||||
});
|
||||
|
||||
it('throws on unknown tokens', () => {
|
||||
expect(() => resolve('shu-300')).toThrow(/Unknown tsuki token/);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(Object.entries(modes))('%s contrast', (_mode, map: ColorMap) => {
|
||||
it.each(textPairs)('%s on %s ≥ 4.5:1', (fg, bg) => {
|
||||
expect(contrast(resolve(map[fg]), resolve(map[bg]))).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it.each(markPairs)('%s on %s ≥ 3:1', (fg, bg) => {
|
||||
expect(contrast(resolve(map[fg]), resolve(map[bg]))).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updates.json', () => {
|
||||
it('points the gecko id at the release asset for this version', () => {
|
||||
const [update] = buildUpdates('1.2.3').addons[GECKO_ID]!.updates;
|
||||
expect(update!.version).toBe('1.2.3');
|
||||
expect(update!.update_link).toMatch(/\/releases\/download\/v1\.2\.3\/tsuki-1\.2\.3\.xpi$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('userChrome.css', () => {
|
||||
const css = buildUserChrome('1.2.3');
|
||||
|
||||
it('marks every declaration !important so it beats Nova author sheets', () => {
|
||||
const decls = css
|
||||
.replace(/@font-face\s*{[^}]*}/g, '')
|
||||
.match(/^\s+--?[a-z-]+:[^;]+;/gm);
|
||||
expect(decls?.length).toBeGreaterThan(10);
|
||||
for (const d of decls ?? []) expect(d, d.trim()).toMatch(/!important;$/);
|
||||
});
|
||||
|
||||
it('keeps corners within tsuki limits (never 0, never 12+)', () => {
|
||||
for (const [, px] of css.matchAll(/radius[a-z-]*:\s*(\d+)px/g)) {
|
||||
expect(Number(px)).toBeGreaterThan(0);
|
||||
expect(Number(px)).toBeLessThan(12);
|
||||
}
|
||||
});
|
||||
|
||||
it('caps font weight at 500', () => {
|
||||
for (const [, w] of css.matchAll(/font-weight[a-z-]*:\s*(\d+)/g)) {
|
||||
expect(Number(w)).toBeLessThanOrEqual(500);
|
||||
}
|
||||
});
|
||||
|
||||
it('has no gradients', () => {
|
||||
expect(css).not.toMatch(/gradient/i);
|
||||
});
|
||||
});
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
// Generates the Firefox theme and the optional userChrome.css layer from
|
||||
// @tsuki/tokens. Everything in dist/ (and updates.json) is generated — edit
|
||||
// src/mapping.ts or this file, never the output.
|
||||
|
||||
import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { kami, radius, shu, sumi } from '@tsuki/tokens';
|
||||
import { type ColorMap, type Mode, modes, type TokenRef } from './mapping';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string };
|
||||
|
||||
export const GECKO_ID = 'theme@tsuki.d-v.cc';
|
||||
export const MIN_VERSION = '157.0';
|
||||
const REPO = 'https://git.d-v.cc/tsuki/firefox-theme';
|
||||
export const UPDATE_URL = `${REPO}/raw/branch/master/updates.json`;
|
||||
export const xpiUrl = (version: string) =>
|
||||
`${REPO}/releases/download/v${version}/tsuki-${version}.xpi`;
|
||||
|
||||
const ramps: Record<string, Record<string, string>> = { kami, sumi, shu };
|
||||
|
||||
export function resolve(ref: TokenRef): string {
|
||||
if (ref === 'transparent') return ref;
|
||||
const [ramp, step] = ref.split('-') as [string, string];
|
||||
const hex = ramps[ramp]?.[step];
|
||||
if (!hex) throw new Error(`Unknown tsuki token: ${ref}`);
|
||||
return hex;
|
||||
}
|
||||
|
||||
function resolveColors(map: ColorMap): Record<string, string> {
|
||||
return Object.fromEntries(Object.entries(map).map(([key, ref]) => [key, resolve(ref)]));
|
||||
}
|
||||
|
||||
function themeBlock(mode: Mode) {
|
||||
return {
|
||||
colors: resolveColors(modes[mode]),
|
||||
properties: {
|
||||
color_scheme: mode,
|
||||
content_color_scheme: 'auto',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildManifest(version = pkg.version) {
|
||||
return {
|
||||
manifest_version: 3,
|
||||
name: 'tsuki',
|
||||
description: 'Warm paper, warm ink, one moment of shu. Follows your system light/dark setting.',
|
||||
version,
|
||||
author: 'shima',
|
||||
homepage_url: REPO,
|
||||
icons: { '32': 'icon.svg', '64': 'icon.svg' },
|
||||
browser_specific_settings: {
|
||||
gecko: {
|
||||
id: GECKO_ID,
|
||||
strict_min_version: MIN_VERSION,
|
||||
update_url: UPDATE_URL,
|
||||
data_collection_permissions: { required: ['none'] },
|
||||
},
|
||||
},
|
||||
// `theme` covers the light color scheme; `dark_theme` takes over when the
|
||||
// system prefers dark.
|
||||
theme: themeBlock('light'),
|
||||
dark_theme: themeBlock('dark'),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUpdates(version = pkg.version) {
|
||||
return {
|
||||
addons: {
|
||||
[GECKO_ID]: {
|
||||
updates: [
|
||||
{
|
||||
version,
|
||||
update_link: xpiUrl(version),
|
||||
applications: { gecko: { strict_min_version: MIN_VERSION } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const FONT_FILES = [
|
||||
['IBM Plex Sans', 400, 'ibm-plex-sans-400.woff2'],
|
||||
['IBM Plex Sans', 500, 'ibm-plex-sans-500.woff2'],
|
||||
['IBM Plex Mono', 400, 'ibm-plex-mono-400.woff2'],
|
||||
] as const;
|
||||
|
||||
// userChrome.css reaches what the theme API can't: type, corner radii, and the
|
||||
// chrome accent (focus rings, links, primary buttons), which Nova hard-codes
|
||||
// to violet. Overrides target Nova's design tokens rather than element
|
||||
// selectors so the file survives Firefox's internal markup churn. User sheets
|
||||
// lose to author sheets without !important, hence every declaration has it.
|
||||
export function buildUserChrome(version = pkg.version) {
|
||||
const fontFaces = FONT_FILES.map(
|
||||
([family, weight, file]) =>
|
||||
`@font-face {\n font-family: '${family}';\n font-weight: ${weight};\n font-style: normal;\n src: url('tsuki-fonts/${file}') format('woff2');\n}`,
|
||||
).join('\n\n');
|
||||
|
||||
return `/* tsuki — userChrome.css v${version}
|
||||
Generated from @tsuki/tokens by @tsuki/firefox-theme. Do not edit by hand.
|
||||
Requires the tsuki theme and
|
||||
toolkit.legacyUserProfileCustomizations.stylesheets = true. */
|
||||
|
||||
${fontFaces}
|
||||
|
||||
:root {
|
||||
/* Corners: 4px interactive, 6px surfaces, 8px sheets. Never 12+. */
|
||||
--border-radius-xsmall: ${radius.sm} !important;
|
||||
--border-radius-small: ${radius.sm} !important;
|
||||
--border-radius-medium: ${radius.md} !important;
|
||||
--border-radius-large: ${radius.lg} !important;
|
||||
--border-radius-xlarge: ${radius.lg} !important;
|
||||
--button-border-radius: ${radius.sm} !important;
|
||||
--toolbarbutton-border-radius: ${radius.sm} !important;
|
||||
--tab-border-radius: ${radius.sm} !important;
|
||||
--urlbar-border-radius: ${radius.sm} !important;
|
||||
--panel-border-radius: ${radius.md} !important;
|
||||
--popup-border-radius: ${radius.md} !important;
|
||||
|
||||
/* Shu replaces Nova's violet accent. light-dark() follows the theme's
|
||||
color_scheme, so light gets shu-500 and dark gets shu-400. */
|
||||
--color-accent-primary: light-dark(${shu['500']}, ${shu['400']}) !important;
|
||||
--color-accent-primary-hover: light-dark(${shu['400']}, ${shu['200']}) !important;
|
||||
--color-accent-primary-active: light-dark(${shu['600']}, ${shu['500']}) !important;
|
||||
--color-accent-primary-selected: light-dark(${shu['600']}, ${shu['500']}) !important;
|
||||
--focus-outline-color: light-dark(${shu['500']}, ${shu['400']}) !important;
|
||||
--link-color: light-dark(${shu['700']}, ${shu['200']}) !important;
|
||||
--link-color-hover: light-dark(${shu['600']}, ${shu['100']}) !important;
|
||||
--link-color-active: light-dark(${shu['800']}, ${shu['50']}) !important;
|
||||
|
||||
/* No weights above 500. */
|
||||
--font-weight-semibold: 500 !important;
|
||||
--font-weight-bold: 500 !important;
|
||||
--font-weight-heading: 500 !important;
|
||||
|
||||
font-family: 'IBM Plex Sans', -apple-system, system-ui, sans-serif !important;
|
||||
}
|
||||
|
||||
menupopup,
|
||||
panel,
|
||||
tooltip,
|
||||
#urlbar-input {
|
||||
font-family: 'IBM Plex Sans', -apple-system, system-ui, sans-serif !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const dist = join(root, 'dist');
|
||||
rmSync(dist, { recursive: true, force: true });
|
||||
|
||||
const themeDir = join(dist, 'theme');
|
||||
mkdirSync(themeDir, { recursive: true });
|
||||
writeFileSync(join(themeDir, 'manifest.json'), `${JSON.stringify(buildManifest(), null, 2)}\n`);
|
||||
copyFileSync(join(root, 'assets/icon.svg'), join(themeDir, 'icon.svg'));
|
||||
|
||||
const chromeDir = join(dist, 'userChrome');
|
||||
const fontsDir = join(chromeDir, 'tsuki-fonts');
|
||||
mkdirSync(fontsDir, { recursive: true });
|
||||
writeFileSync(join(chromeDir, 'userChrome.css'), buildUserChrome());
|
||||
const require = createRequire(import.meta.url);
|
||||
const fontsPkg = dirname(require.resolve('@tsuki/fonts/files/LICENSE-IBM-Plex.txt'));
|
||||
for (const [, , file] of FONT_FILES) copyFileSync(join(fontsPkg, file), join(fontsDir, file));
|
||||
copyFileSync(join(fontsPkg, 'LICENSE-IBM-Plex.txt'), join(fontsDir, 'LICENSE-IBM-Plex.txt'));
|
||||
|
||||
// Committed at the repo root; Firefox polls it via gecko.update_url.
|
||||
writeFileSync(join(root, 'updates.json'), `${JSON.stringify(buildUpdates(), null, 2)}\n`);
|
||||
|
||||
console.log(`Built tsuki ${pkg.version} → dist/theme, dist/userChrome, updates.json`);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) main();
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
// Firefox theme key → tsuki token, per mode.
|
||||
//
|
||||
// Values are token names (`kami-50`, `shu-500`), never hex — build.ts resolves
|
||||
// them against @tsuki/tokens so the theme can't drift from the palette.
|
||||
// `transparent` is the only non-token value allowed.
|
||||
//
|
||||
// Keys that do nothing in Nova (Firefox 157) are deliberately absent:
|
||||
// ntp_text, sidebar_highlight, sidebar_highlight_text.
|
||||
|
||||
export type Ramp = 'kami' | 'sumi' | 'shu';
|
||||
export type TokenRef = `${Ramp}-${string}` | 'transparent';
|
||||
export type Mode = 'light' | 'dark';
|
||||
|
||||
export type ColorKey =
|
||||
| 'frame'
|
||||
| 'frame_inactive'
|
||||
| 'tab_selected'
|
||||
| 'tab_text'
|
||||
| 'tab_background_text'
|
||||
| 'tab_line'
|
||||
| 'tab_loading'
|
||||
| 'icons'
|
||||
| 'icons_attention'
|
||||
| 'toolbar'
|
||||
| 'toolbar_text'
|
||||
| 'toolbar_field'
|
||||
| 'toolbar_field_text'
|
||||
| 'toolbar_field_border'
|
||||
| 'toolbar_field_focus'
|
||||
| 'toolbar_field_text_focus'
|
||||
| 'toolbar_field_border_focus'
|
||||
| 'toolbar_field_highlight'
|
||||
| 'toolbar_field_highlight_text'
|
||||
| 'toolbar_top_separator'
|
||||
| 'toolbar_bottom_separator'
|
||||
| 'toolbar_vertical_separator'
|
||||
| 'button_background_hover'
|
||||
| 'button_background_active'
|
||||
| 'popup'
|
||||
| 'popup_text'
|
||||
| 'popup_border'
|
||||
| 'popup_highlight'
|
||||
| 'popup_highlight_text'
|
||||
| 'sidebar'
|
||||
| 'sidebar_text'
|
||||
| 'sidebar_border'
|
||||
| 'ntp_background'
|
||||
| 'ntp_card_background';
|
||||
|
||||
export type ColorMap = Record<ColorKey, TokenRef>;
|
||||
|
||||
// Light — paper-on-table. The selected tab and the toolbar share kami-0 so
|
||||
// they read as one sheet lifted off the kami-50 frame; no shadow needed.
|
||||
export const light: ColorMap = {
|
||||
frame: 'kami-50',
|
||||
frame_inactive: 'kami-100',
|
||||
tab_selected: 'kami-0',
|
||||
tab_text: 'kami-800',
|
||||
tab_background_text: 'kami-600',
|
||||
tab_line: 'shu-500',
|
||||
tab_loading: 'shu-500',
|
||||
icons: 'kami-700',
|
||||
icons_attention: 'shu-500',
|
||||
toolbar: 'kami-0',
|
||||
toolbar_text: 'kami-700',
|
||||
toolbar_field: 'kami-50',
|
||||
toolbar_field_text: 'kami-800',
|
||||
toolbar_field_border: 'kami-200',
|
||||
toolbar_field_focus: 'kami-0',
|
||||
toolbar_field_text_focus: 'kami-800',
|
||||
toolbar_field_border_focus: 'shu-500',
|
||||
toolbar_field_highlight: 'shu-100',
|
||||
toolbar_field_highlight_text: 'shu-800',
|
||||
toolbar_top_separator: 'transparent',
|
||||
toolbar_bottom_separator: 'kami-100',
|
||||
toolbar_vertical_separator: 'kami-100',
|
||||
button_background_hover: 'kami-100',
|
||||
button_background_active: 'kami-200',
|
||||
popup: 'kami-0',
|
||||
popup_text: 'kami-700',
|
||||
popup_border: 'kami-200',
|
||||
popup_highlight: 'kami-50',
|
||||
popup_highlight_text: 'kami-800',
|
||||
sidebar: 'kami-0',
|
||||
sidebar_text: 'kami-700',
|
||||
sidebar_border: 'kami-100',
|
||||
ntp_background: 'kami-0',
|
||||
ntp_card_background: 'kami-50',
|
||||
};
|
||||
|
||||
// Dark — ink-at-night. Shu steps up to 400 (tsuki's dark --border-focus) so
|
||||
// the accent holds contrast against sumi.
|
||||
export const dark: ColorMap = {
|
||||
frame: 'sumi-0',
|
||||
frame_inactive: 'sumi-50',
|
||||
tab_selected: 'sumi-100',
|
||||
tab_text: 'sumi-800',
|
||||
tab_background_text: 'sumi-600',
|
||||
tab_line: 'shu-400',
|
||||
tab_loading: 'shu-400',
|
||||
icons: 'sumi-700',
|
||||
icons_attention: 'shu-400',
|
||||
toolbar: 'sumi-100',
|
||||
toolbar_text: 'sumi-700',
|
||||
toolbar_field: 'sumi-50',
|
||||
toolbar_field_text: 'sumi-800',
|
||||
toolbar_field_border: 'sumi-300',
|
||||
toolbar_field_focus: 'sumi-0',
|
||||
toolbar_field_text_focus: 'sumi-800',
|
||||
toolbar_field_border_focus: 'shu-400',
|
||||
toolbar_field_highlight: 'sumi-300',
|
||||
toolbar_field_highlight_text: 'sumi-950',
|
||||
toolbar_top_separator: 'transparent',
|
||||
toolbar_bottom_separator: 'sumi-200',
|
||||
toolbar_vertical_separator: 'sumi-200',
|
||||
button_background_hover: 'sumi-200',
|
||||
button_background_active: 'sumi-300',
|
||||
popup: 'sumi-100',
|
||||
popup_text: 'sumi-700',
|
||||
popup_border: 'sumi-300',
|
||||
popup_highlight: 'sumi-200',
|
||||
popup_highlight_text: 'sumi-800',
|
||||
sidebar: 'sumi-100',
|
||||
sidebar_text: 'sumi-700',
|
||||
sidebar_border: 'sumi-200',
|
||||
ntp_background: 'sumi-0',
|
||||
ntp_card_background: 'sumi-100',
|
||||
};
|
||||
|
||||
export const modes: Record<Mode, ColorMap> = { light, dark };
|
||||
|
||||
// Text-on-surface pairs that must clear WCAG AA (4.5:1). Checked in tests.
|
||||
export const textPairs: [fg: ColorKey, bg: ColorKey][] = [
|
||||
['tab_text', 'tab_selected'],
|
||||
['tab_background_text', 'frame'],
|
||||
['toolbar_text', 'toolbar'],
|
||||
['toolbar_field_text', 'toolbar_field'],
|
||||
['toolbar_field_text_focus', 'toolbar_field_focus'],
|
||||
['toolbar_field_highlight_text', 'toolbar_field_highlight'],
|
||||
['popup_text', 'popup'],
|
||||
['popup_highlight_text', 'popup_highlight'],
|
||||
['sidebar_text', 'sidebar'],
|
||||
];
|
||||
|
||||
// Non-text UI marks that must clear WCAG 1.4.11 (3:1).
|
||||
export const markPairs: [fg: ColorKey, bg: ColorKey][] = [
|
||||
['icons', 'toolbar'],
|
||||
['icons_attention', 'toolbar'],
|
||||
['tab_line', 'tab_selected'],
|
||||
['toolbar_field_border_focus', 'toolbar_field_focus'],
|
||||
];
|
||||
Reference in New Issue
Block a user