Files
firefox-theme/src/build.test.ts
T
devandClaude Opus 5.5 f1b98696be
ci / check (push) Successful in 23s
release / release (push) Successful in 2m38s
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>
2026-10-01 09:25:52 +05:30

126 lines
4.3 KiB
TypeScript

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);
});
});