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
+28
View File
@@ -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
+59
View File
@@ -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"
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
web-ext-artifacts/
.DS_Store
*.log
coverage/
.amo-upload-uuid
.preview-profile/
+3
View File
@@ -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/
+75
View File
@@ -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.
+11
View File
@@ -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
View File
@@ -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"]
}
}
+33
View File
@@ -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"
}
}
+3527
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+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'],
];
+19
View File
@@ -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/**/*"]
}
+17
View File
@@ -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"
}
}
}
]
}
}
}