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