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,28 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: |
|
||||
cat > .npmrc <<EOF
|
||||
@tsuki:registry=https://git.d-v.cc/api/packages/tsuki/npm/
|
||||
//git.d-v.cc/api/packages/tsuki/npm/:_authToken=${{ secrets.NPM_TOKEN }}
|
||||
EOF
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
- run: pnpm check
|
||||
- run: pnpm test
|
||||
- run: pnpm lint
|
||||
@@ -0,0 +1,59 @@
|
||||
name: release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- name: Verify tag matches package.json
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/v}"
|
||||
PKG=$(node -p 'require("./package.json").version')
|
||||
[ "$TAG" = "$PKG" ] || { echo "Tag v$TAG != package.json $PKG"; exit 1; }
|
||||
- run: |
|
||||
cat > .npmrc <<EOF
|
||||
@tsuki:registry=https://git.d-v.cc/api/packages/tsuki/npm/
|
||||
//git.d-v.cc/api/packages/tsuki/npm/:_authToken=${{ secrets.NPM_TOKEN }}
|
||||
EOF
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
- name: updates.json must be committed for this version
|
||||
run: git diff --exit-code updates.json
|
||||
- run: pnpm test
|
||||
- run: pnpm lint
|
||||
# Release Firefox only installs Mozilla-signed add-ons. The unlisted
|
||||
# channel signs without publishing on addons.mozilla.org.
|
||||
- name: Sign (AMO unlisted)
|
||||
env:
|
||||
WEB_EXT_API_KEY: ${{ secrets.AMO_JWT_ISSUER }}
|
||||
WEB_EXT_API_SECRET: ${{ secrets.AMO_JWT_SECRET }}
|
||||
run: pnpm sign
|
||||
- name: Bundle userChrome layer
|
||||
run: |
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
(cd dist && zip -r "../web-ext-artifacts/tsuki-userchrome-$VERSION.zip" userChrome)
|
||||
- name: Create Gitea release with assets
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
ID=$(curl -fsS -X POST "$API/releases" \
|
||||
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d "{\"tag_name\":\"v$VERSION\",\"name\":\"tsuki $VERSION\"}" | node -p 'JSON.parse(require("fs").readFileSync(0)).id')
|
||||
XPI=$(ls web-ext-artifacts/*.xpi | head -1)
|
||||
curl -fsS -X POST "$API/releases/$ID/assets?name=tsuki-$VERSION.xpi" \
|
||||
-H "Authorization: token $TOKEN" -F "attachment=@$XPI"
|
||||
curl -fsS -X POST "$API/releases/$ID/assets?name=tsuki-userchrome-$VERSION.zip" \
|
||||
-H "Authorization: token $TOKEN" -F "attachment=@web-ext-artifacts/tsuki-userchrome-$VERSION.zip"
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
web-ext-artifacts/
|
||||
.DS_Store
|
||||
*.log
|
||||
coverage/
|
||||
.amo-upload-uuid
|
||||
.preview-profile/
|
||||
@@ -0,0 +1,3 @@
|
||||
# Scope mapping for @tsuki packages. Auth comes from ~/.npmrc locally,
|
||||
# and from a runtime-written .npmrc in CI (see .gitea/workflows/).
|
||||
@tsuki:registry=https://git.d-v.cc/api/packages/tsuki/npm/
|
||||
@@ -0,0 +1,75 @@
|
||||
# @tsuki/firefox-theme
|
||||
|
||||
tsuki for Firefox 157+ (Nova). One adaptive theme: kami (paper) when your system is light, sumi (ink) when it's dark, with shu marking the active tab, loading state, and focused address bar.
|
||||
|
||||
Two layers:
|
||||
|
||||
- **Theme** (`tsuki-<version>.xpi`) — colors only. Signed by Mozilla, self-hosted on Gitea, updates automatically.
|
||||
- **userChrome.css** (optional) — what the theme API can't reach: IBM Plex Sans, tsuki corner radii (4/6/8px instead of Nova's 12–24px), and shu in place of Nova's violet for focus rings, links, and primary buttons.
|
||||
|
||||
Both are generated from [`@tsuki/tokens`](https://git.d-v.cc/tsuki/tokens). No hex values are written by hand.
|
||||
|
||||
## Install
|
||||
|
||||
### Theme
|
||||
|
||||
Download `tsuki-<version>.xpi` from the [latest release](https://git.d-v.cc/tsuki/firefox-theme/releases) and open it in Firefox. Firefox checks `updates.json` in this repo for new versions.
|
||||
|
||||
### userChrome.css
|
||||
|
||||
1. Open `about:config` and set `toolkit.legacyUserProfileCustomizations.stylesheets` to `true`.
|
||||
2. Open `about:support` and click **Open Folder** next to *Profile Folder*.
|
||||
3. Unzip `tsuki-userchrome-<version>.zip` from the release and copy the contents of its `userChrome/` folder into a `chrome/` folder in your profile, giving you `chrome/userChrome.css` and `chrome/tsuki-fonts/`.
|
||||
4. Restart Firefox.
|
||||
|
||||
If you already have a `userChrome.css`, paste tsuki's at the top of yours and keep the `tsuki-fonts/` folder next to it.
|
||||
|
||||
The overrides target Nova's design tokens (`--border-radius-*`, `--color-accent-primary`, `--focus-outline-color`), not element selectors, so they should survive most Firefox updates. Mozilla doesn't support userChrome.css, though, and a future release may rename these tokens.
|
||||
|
||||
## Design notes
|
||||
|
||||
- **No gradients.** Nova encourages gradient themes. tsuki doesn't use them, and the tests fail on any `gradient` or `images` in the output.
|
||||
- **Selected tab = toolbar.** Both are `kami-0` / `sumi-100`, so the active tab reads as one sheet with the toolbar. Separation comes from color, not shadow.
|
||||
- **Dead Nova keys are omitted:** `ntp_text`, `sidebar_highlight`, `sidebar_highlight_text`.
|
||||
- **Contrast is tested:** text pairs ≥ 4.5:1, icons and accent marks ≥ 3:1, in both modes.
|
||||
|
||||
To change a color, edit [`src/mapping.ts`](src/mapping.ts).
|
||||
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build # dist/theme, dist/userChrome, updates.json
|
||||
pnpm test
|
||||
pnpm lint # web-ext lint (self-hosted rules)
|
||||
pnpm start # Firefox with the theme + userChrome, light
|
||||
pnpm start:dark # same, forced dark
|
||||
```
|
||||
|
||||
`pnpm start` uses a throwaway profile in `.preview-profile/`, not your own.
|
||||
|
||||
### Test checklist (Mozilla's for Nova)
|
||||
|
||||
Horizontal and vertical tabs · sidebar closed / open / expand-on-hover · compact density · private window · light and dark · hover states on tabs and toolbar buttons.
|
||||
|
||||
## Releasing
|
||||
|
||||
Release Firefox only installs Mozilla-signed add-ons, so "self-hosted" means signed through AMO's **unlisted** channel (not published on addons.mozilla.org), then distributed from Gitea.
|
||||
|
||||
One-time setup:
|
||||
|
||||
1. Create AMO API credentials at <https://addons.mozilla.org/developers/addon/api/key/>.
|
||||
2. Add repo secrets: `AMO_JWT_ISSUER`, `AMO_JWT_SECRET`, `NPM_TOKEN` (registry read), `RELEASE_TOKEN` (Gitea token with repo write).
|
||||
3. The repo must be public: Firefox fetches `updates.json` and the XPI without auth.
|
||||
|
||||
Cut a release:
|
||||
|
||||
```bash
|
||||
npm version <patch|minor|major> --no-git-tag-version
|
||||
pnpm build # regenerates updates.json for the new version
|
||||
git commit -am "release: vX.Y.Z"
|
||||
git tag vX.Y.Z
|
||||
git push --follow-tags
|
||||
```
|
||||
|
||||
CI verifies `updates.json` is committed for the tag, signs the XPI, and attaches it and the userChrome zip to a Gitea release.
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="tsuki">
|
||||
<title>tsuki</title>
|
||||
<rect width="512" height="512" rx="96" ry="96" fill="#9B3C30"/>
|
||||
<text x="256" y="256"
|
||||
font-family="'Noto Serif JP', 'Hiragino Mincho ProN', 'Yu Mincho', 'Songti SC', serif"
|
||||
font-weight="600"
|
||||
font-size="380"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
fill="#FAF8F4">月</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 457 B |
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single"
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"ignore": ["dist", "node_modules"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@tsuki/firefox-theme",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "tsuki theme for Firefox 157+ (Nova) — adaptive light/dark, plus an optional userChrome.css layer",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.d-v.cc/tsuki/firefox-theme.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsx src/build.ts",
|
||||
"check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"lint": "web-ext lint --self-hosted --source-dir dist/theme",
|
||||
"start": "pnpm preview:profile && web-ext run --source-dir dist/theme --firefox /Applications/Firefox.app/Contents/MacOS/firefox --profile-create-if-missing --firefox-profile \"$PWD/.preview-profile\" --keep-profile-changes --pref toolkit.legacyUserProfileCustomizations.stylesheets=true",
|
||||
"package": "web-ext build --source-dir dist/theme --artifacts-dir web-ext-artifacts --overwrite-dest",
|
||||
"sign": "web-ext sign --source-dir dist/theme --artifacts-dir web-ext-artifacts --channel unlisted",
|
||||
"clean": "rm -rf dist web-ext-artifacts",
|
||||
"start:dark": "pnpm preview:profile && web-ext run --source-dir dist/theme --firefox /Applications/Firefox.app/Contents/MacOS/firefox --profile-create-if-missing --firefox-profile \"$PWD/.preview-profile\" --keep-profile-changes --pref toolkit.legacyUserProfileCustomizations.stylesheets=true --pref ui.systemUsesDarkTheme=1",
|
||||
"preview:profile": "mkdir -p .preview-profile/chrome && cp -R dist/userChrome/. .preview-profile/chrome/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsuki/fonts": "^0.1.0",
|
||||
"@tsuki/tokens": "^0.2.1",
|
||||
"@types/node": "^24.13.5",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.0",
|
||||
"vitest": "^5.0.1",
|
||||
"web-ext": "^10.7.0"
|
||||
}
|
||||
}
|
||||
Generated
+3527
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
@@ -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'],
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"addons": {
|
||||
"theme@tsuki.d-v.cc": {
|
||||
"updates": [
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"update_link": "https://git.d-v.cc/tsuki/firefox-theme/releases/download/v0.1.0/tsuki-0.1.0.xpi",
|
||||
"applications": {
|
||||
"gecko": {
|
||||
"strict_min_version": "157.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user