feat: tsuki Firefox theme for Nova (157+)
ci / check (push) Successful in 23s
release / release (push) Successful in 2m38s

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:
dev
2026-10-01 09:25:52 +05:30
co-authored by Claude Opus 5.5
commit f1b98696be
15 changed files with 4251 additions and 0 deletions
+125
View File
@@ -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
View File
@@ -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
View File
@@ -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'],
];