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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user