diff --git a/.gitignore b/.gitignore index 2e7b3b0..b14cbc4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +.claude/settings.local.json .superpowers .brainstorming .worktrees diff --git a/AGENTS.md b/AGENTS.md index 466bfa4..fd404ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,16 +31,3 @@ bun run format # auto-format with biome - **Never edit `themes/**` directly.** These files are generated. Edit `palette/` and `generator/`, then run `bun run build`. CI enforces that committed artifacts match generator output. - **Version lockstep.** The version in `package.json`, `themes/vscode/package.json`, and `themes/zed/extension.toml` must always match. Use `bun scripts/bump-version.ts ` to bump all at once. The lockstep is enforced by `tests/lockstep.test.ts`. - -## Commit conventions - -Use Conventional Commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`. - -## Release process - -See [`RELEASE.md`](RELEASE.md) for the full release operations guide. The short version: - -1. Feature PRs merge into `dev` after CI passes. -2. Trigger the "Release PR" workflow with the next version — it bumps manifests and opens a `dev → main` PR. -3. Merge the release PR into `main` — this auto-tags, creates the GitHub release, and publishes to all configured channels. -4. Merge `main` back into `dev`. diff --git a/README.md b/README.md index 86a15f7..717b04b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,38 @@ +

+ Synthpunk icon +

+ # Synthpunk High-energy synthwave-inspired color themes for VS Code, Zed, Neovim, WezTerm, and Starship. Four variants: Pastel Dark/Light and Neon Dark/Light. -![Synthpunk Pastel Dark](assets/pastel-dark.png) +![Synthpunk — the same code across all four variants](assets/hero.png) + +## Preview + +Syntax highlighting shown in the **Neon Dark** variant: + +### React + +![Synthpunk Neon Dark — React/TSX](assets/sample-react.png) + +### Python + +![Synthpunk Neon Dark — Python](assets/sample-python.png) + +### Rust + +![Synthpunk Neon Dark — Rust](assets/sample-rust.png) + +### C# + +![Synthpunk Neon Dark — C#](assets/sample-csharp.png) + +### Markdown + +![Synthpunk Neon Dark — Markdown](assets/sample-markdown.png) + +Full palette swatches for every variant: [Pastel Dark](assets/palette-preview-dark.png) · [Pastel Light](assets/palette-preview-light.png) · [Neon Dark](assets/palette-preview-neon-dark.png) · [Neon Light](assets/palette-preview-neon-light.png). Preview images are generated by [`assets/generate/`](assets/generate/). ## Installation diff --git a/assets/generate/README.md b/assets/generate/README.md new file mode 100644 index 0000000..b222c17 --- /dev/null +++ b/assets/generate/README.md @@ -0,0 +1,40 @@ +# Preview image generator + +Generates the marketing images used by the top-level [`README.md`](../../README.md): + +| Output (`assets/`) | What it is | +| -------------------- | ----------------------------------------------------------------- | +| `icon.png` | Angular-synthwave neon icon (1024×1024) | +| `hero.png` | The same snippet rendered across all four variants (2×2 grid) | +| `sample-react.png` | React/TSX preview, Neon Dark | +| `sample-python.png` | Python preview, Neon Dark | +| `sample-rust.png` | Rust preview, Neon Dark | +| `sample-csharp.png` | C# preview, Neon Dark | +| `sample-markdown.png`| Markdown preview, Neon Dark | + +Highlighting is done with [shiki](https://shiki.style) loaded with the repo's own +generated VS Code themes (`themes/vscode/themes/*.json`), so the colors match the +editor exactly. The highlighted HTML is screenshotted with the system Chrome via +`puppeteer-core`. + +## Regenerate + +Run after changing palette colors (and rebuilding the VS Code themes with +`bun run build` from the repo root): + +```sh +cd assets/generate +bun install +bun run generate # or: bun run generate.ts +``` + +Requires a Chromium-based browser. The script auto-detects Google Chrome, Chromium, +and Edge in the usual macOS/Linux locations; set `CHROME_PATH` to override: + +```sh +CHROME_PATH="/path/to/chrome" bun run generate +``` + +The code shown in each image lives in [`snippets/`](snippets/) — edit those files to +change the sample code. This directory is excluded from the repo's `tsc`/biome checks +(see `tsconfig.json` and `biome.json`); its `node_modules/` is git-ignored. diff --git a/assets/generate/bun.lock b/assets/generate/bun.lock new file mode 100644 index 0000000..0e2adea --- /dev/null +++ b/assets/generate/bun.lock @@ -0,0 +1,156 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "synthpunk-preview-generator", + "dependencies": { + "puppeteer-core": "^25.1.0", + "shiki": "^4.2.0", + }, + }, + }, + "packages": { + "@puppeteer/browsers": ["@puppeteer/browsers@3.0.4", "", { "dependencies": { "modern-tar": "^0.7.6", "yargs": "^17.7.2" }, "peerDependencies": { "proxy-agent": ">=8.0.1" }, "optionalPeers": ["proxy-agent"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-HGM8iAmGTf+Y7t0373szVbTmt3d7vPkYL/1bpOkOFO0YUYLgSeuYBCzESklogNPvOBnZ/MRD5f07OkpqH1trtA=="], + + "@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="], + + "@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="], + + "@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="], + + "@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="], + + "@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "chromium-bidi": ["chromium-bidi@16.0.1", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "devtools-protocol": ["devtools-protocol@0.0.1624250", "", {}, "sha512-YFAat/lOiIk0ARmBweG+ygrEcbZrq5B9urRyUoeQKp53MlidHXE2TmTbxKcaXoQj7u/aX+jebDO4BW55rs0WwA=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "modern-tar": ["modern-tar@0.7.6", "", {}, "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg=="], + + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], + + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + + "puppeteer-core": ["puppeteer-core@25.1.0", "", { "dependencies": { "@puppeteer/browsers": "3.0.4", "chromium-bidi": "16.0.1", "devtools-protocol": "0.0.1624250", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.0" } }, "sha512-jKzy5y4WG6uNuFbTWgW1D7mqoT9o0nllc/6a1DGF775T1mPmgw3scdFEtEq67yVFikavQmbYq6NLfbTfxHSlqQ=="], + + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + } +} diff --git a/assets/generate/generate.ts b/assets/generate/generate.ts new file mode 100644 index 0000000..91b8bd4 --- /dev/null +++ b/assets/generate/generate.ts @@ -0,0 +1,208 @@ +/** + * Generates the README marketing images from the Synthpunk VS Code themes: + * - assets/icon.png angular-synthwave neon icon + * - assets/sample-*.png Neon Dark syntax-highlight previews (react/python/rust/csharp/markdown) + * - assets/hero.png 2x2 grid of one snippet across all four variants + * + * Highlighting uses shiki loaded with the repo's own VS Code theme JSON, so the + * colors are exactly what the editor renders. Rendering is done by screenshotting + * HTML with the system Chrome via puppeteer-core. + * + * Run: bun install && bun run generate.ts (set CHROME_PATH to override the browser) + */ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import puppeteer from "puppeteer-core"; +import { createHighlighter, type ThemeRegistrationRaw } from "shiki"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, "..", ".."); +const THEMES_DIR = resolve(REPO, "themes/vscode/themes"); +const SNIPPETS = resolve(HERE, "snippets"); +const OUT = resolve(REPO, "assets"); + +// --- theme loading --------------------------------------------------------- + +const VARIANT_FILES = { + "pastel-dark": "synthpunk-pastel-dark-color-theme.json", + "pastel-light": "synthpunk-pastel-light-color-theme.json", + "neon-dark": "synthpunk-neon-dark-color-theme.json", + "neon-light": "synthpunk-neon-light-color-theme.json", +} as const; +type VariantKey = keyof typeof VARIANT_FILES; + +type Theme = { + name: string; + type: string; + colors: Record; + tokenColors: unknown[]; +}; + +const themes = Object.fromEntries( + (Object.keys(VARIANT_FILES) as VariantKey[]).map((key) => [ + key, + JSON.parse(readFileSync(resolve(THEMES_DIR, VARIANT_FILES[key]), "utf8")) as Theme, + ]), +) as Record; + +// --- browser / chrome ------------------------------------------------------ + +function chromePath(): string { + const candidates = [ + process.env.CHROME_PATH, + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + ].filter((p): p is string => Boolean(p)); + const found = candidates.find((p) => existsSync(p)); + if (!found) { + throw new Error("No Chrome/Chromium found. Set CHROME_PATH to a browser executable."); + } + return found; +} + +// --- html templating ------------------------------------------------------- + +const FONT_STACK = "'Cascadia Code', 'Cascadia Mono', Menlo, Consolas, monospace"; +const UI_FONT = "-apple-system, 'Segoe UI', system-ui, sans-serif"; + +const PAGE_CSS = ` + * { box-sizing: border-box; } + html, body { margin: 0; padding: 0; background: transparent; } + #shot { display: inline-block; padding: 56px; background: transparent; } + .window { + border-radius: 12px; + overflow: hidden; + box-shadow: 0 30px 70px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.05); + } + .titlebar { display: flex; align-items: center; gap: 9px; padding: 13px 16px; } + .dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; } + .filename { margin-left: 10px; font: 500 13px ${UI_FONT}; opacity: 0.85; } + .code { padding: 22px 26px; overflow: hidden; } + .code pre.shiki { margin: 0; background: transparent !important; } + .code pre.shiki code { font: 15px/1.65 ${FONT_STACK}; } + .code .line { white-space: pre; } + .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 36px; } + .cell .label { margin: 0 0 11px 3px; font: 600 14px ${UI_FONT}; color: #9a8fb0; letter-spacing: 0.4px; } +`; + +function page(inner: string, pad = 56): string { + return `
${inner}
`; +} + +function windowHtml(codeHtml: string, theme: Theme, filename: string): string { + const bg = theme.colors["editor.background"]; + const titleBg = theme.colors["titleBar.activeBackground"] ?? bg; + const nameColor = theme.colors["tab.activeForeground"] ?? theme.colors.foreground ?? "#888"; + return `
+
+ + + + ${filename} +
+
${codeHtml}
+
`; +} + +const ICON_HTML = `
+
+
+
+
+
+
+
`; + +// --- rendering ------------------------------------------------------------- + +async function main() { + const highlighter = await createHighlighter({ + themes: Object.values(themes) as ThemeRegistrationRaw[], + langs: ["tsx", "python", "rust", "csharp", "markdown", "typescript"], + }); + + const browser = await puppeteer.launch({ + executablePath: chromePath(), + headless: true, + args: ["--force-color-profile=srgb", "--font-render-hinting=none", "--hide-scrollbars"], + }); + + async function shoot(html: string, outFile: string, selector = "#shot", scale = 2) { + const p = await browser.newPage(); + await p.setViewport({ width: 1700, height: 1100, deviceScaleFactor: scale }); + await p.setContent(html, { waitUntil: "load" }); + await p.evaluate(async () => { + await document.fonts.ready; + }); + const el = await p.$(selector); + if (!el) throw new Error(`selector ${selector} not found`); + await el.screenshot({ path: outFile, omitBackground: true }); + await p.close(); + console.log("wrote", outFile.replace(`${REPO}/`, "")); + } + + function read(name: string): string { + return readFileSync(resolve(SNIPPETS, name), "utf8").replace(/\n+$/, ""); + } + + // Per-language samples in Neon Dark. + const neon = themes["neon-dark"]; + const samples = [ + { out: "sample-react.png", file: "App.tsx", lang: "tsx", snippet: "App.tsx" }, + { out: "sample-python.png", file: "pipeline.py", lang: "python", snippet: "pipeline.py" }, + { out: "sample-rust.png", file: "lib.rs", lang: "rust", snippet: "lib.rs" }, + { out: "sample-csharp.png", file: "Service.cs", lang: "csharp", snippet: "Service.cs" }, + { out: "sample-markdown.png", file: "README.md", lang: "markdown", snippet: "sample.md" }, + ]; + for (const s of samples) { + const codeHtml = highlighter.codeToHtml(read(s.snippet), { lang: s.lang, theme: neon.name }); + await shoot(page(windowHtml(codeHtml, neon, s.file)), resolve(OUT, s.out)); + } + + // Four-variant hero from one shared snippet. + const heroCode = read("hero.ts"); + const order: Array<[VariantKey, string]> = [ + ["pastel-dark", "Pastel Dark"], + ["pastel-light", "Pastel Light"], + ["neon-dark", "Neon Dark"], + ["neon-light", "Neon Light"], + ]; + const cells = order + .map(([key, label]) => { + const codeHtml = highlighter.codeToHtml(heroCode, { lang: "typescript", theme: themes[key].name }); + return `
${label}
${windowHtml(codeHtml, themes[key], "theme.ts")}
`; + }) + .join(""); + await shoot(page(`
${cells}
`, 56), resolve(OUT, "hero.png")); + + // Icon. + await shoot(ICON_HTML, resolve(OUT, "icon.png"), ".icon"); + + await browser.close(); + console.log("done."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/assets/generate/package.json b/assets/generate/package.json new file mode 100644 index 0000000..0c8a1b0 --- /dev/null +++ b/assets/generate/package.json @@ -0,0 +1,14 @@ +{ + "name": "synthpunk-preview-generator", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Generates README marketing images (icon + syntax-highlight previews) from the Synthpunk VS Code themes.", + "scripts": { + "generate": "bun run generate.ts" + }, + "dependencies": { + "puppeteer-core": "^25.1.0", + "shiki": "^4.2.0" + } +} diff --git a/assets/generate/snippets/App.tsx b/assets/generate/snippets/App.tsx new file mode 100644 index 0000000..20da713 --- /dev/null +++ b/assets/generate/snippets/App.tsx @@ -0,0 +1,26 @@ +import { useState, useEffect } from "react"; + +interface CounterProps { + label: string; + initial?: number; +} + +// A tiny counter that shifts hue as it climbs. +export function Counter({ label, initial = 0 }: CounterProps) { + const [count, setCount] = useState(initial); + const hue = (count * 24) % 360; + + useEffect(() => { + document.title = `${label}: ${count}`; + }, [label, count]); + + return ( + + ); +} diff --git a/assets/generate/snippets/Service.cs b/assets/generate/snippets/Service.cs new file mode 100644 index 0000000..3f6ecb1 --- /dev/null +++ b/assets/generate/snippets/Service.cs @@ -0,0 +1,22 @@ +using System.Linq; +using System.Threading.Tasks; + +namespace Synthpunk.Audio; + +/// Streams and ranks tracks by tempo. +public sealed class TrackService(IClock clock) +{ + public required string Channel { get; init; } + + public async Task CountUptempoAsync(Track[] tracks) + { + await Task.Delay(10); + return tracks.Count(t => t.Bpm >= 128); + } + + // Format a track for the now-playing line. + public string Tag(Track t) => + $"[{clock.Now:HH:mm}] {t.Title} — {t.Bpm} bpm"; +} + +public record Track(string Title, int Bpm); diff --git a/assets/generate/snippets/hero.ts b/assets/generate/snippets/hero.ts new file mode 100644 index 0000000..d536200 --- /dev/null +++ b/assets/generate/snippets/hero.ts @@ -0,0 +1,10 @@ +type Variant = "pastel" | "neon"; + +// Build a Synthpunk theme id from its parts. +function themeId(variant: Variant, dark: boolean): string { + const mode = dark ? "dark" : "light"; + return `synthpunk-${variant}-${mode}`; +} + +const current = themeId("neon", true); +console.log(`active → ${current}`); diff --git a/assets/generate/snippets/lib.rs b/assets/generate/snippets/lib.rs new file mode 100644 index 0000000..0ce45b1 --- /dev/null +++ b/assets/generate/snippets/lib.rs @@ -0,0 +1,29 @@ +use std::collections::HashMap; + +/// A neon palette keyed by semantic role. +#[derive(Debug, Clone, Default)] +pub struct Palette { + colors: HashMap, +} + +impl Palette { + pub fn new() -> Self { + Self::default() + } + + /// Insert a color and return `self` for chaining. + pub fn set(&mut self, role: &str, hex: u32) -> &mut Self { + self.colors.insert(role.to_string(), hex); + self + } + + pub fn get(&self, role: &str) -> Option { + self.colors.get(role).copied() + } +} + +fn main() { + let mut p = Palette::new(); + p.set("keyword", 0xFF5CA8).set("string", 0x40FF80); + println!("keyword = {:#08X}", p.get("keyword").unwrap_or(0)); +} diff --git a/assets/generate/snippets/pipeline.py b/assets/generate/snippets/pipeline.py new file mode 100644 index 0000000..a432ee9 --- /dev/null +++ b/assets/generate/snippets/pipeline.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from functools import cache + + +@dataclass(frozen=True) +class Track: + title: str + bpm: int = 120 + + @property + def is_uptempo(self) -> bool: + return self.bpm >= 128 + + +@cache +def neon_label(track: Track) -> str: + """Return a short, human label for a track.""" + mood = "drive" if track.is_uptempo else "chill" + return f"{track.title} ({track.bpm} bpm, {mood})" + + +if __name__ == "__main__": + deck = [Track("Outrun", 140), Track("Midnight", 96)] + for t in deck: + print(neon_label(t)) diff --git a/assets/generate/snippets/sample.md b/assets/generate/snippets/sample.md new file mode 100644 index 0000000..a600e22 --- /dev/null +++ b/assets/generate/snippets/sample.md @@ -0,0 +1,17 @@ +# Synthpunk + +High-energy **synthwave** color themes for your editor. + +> Four variants — *Pastel* and *Neon*, each in light and dark. + +## Install + +1. Open the **Extensions** panel +2. Search for `Synthpunk` +3. Pick a theme via `Color Theme` + +```ts +const theme = "Synthpunk Neon Dark"; +``` + +See the [documentation](https://example.com) for more. diff --git a/assets/hero.png b/assets/hero.png new file mode 100644 index 0000000..4f837e5 Binary files /dev/null and b/assets/hero.png differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..9853567 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/mark.png b/assets/mark.png new file mode 100644 index 0000000..29b39e5 Binary files /dev/null and b/assets/mark.png differ diff --git a/assets/palette-preview-dark.png b/assets/palette-preview-dark.png deleted file mode 100644 index 57fbb9f..0000000 Binary files a/assets/palette-preview-dark.png and /dev/null differ diff --git a/assets/palette-preview-light.png b/assets/palette-preview-light.png deleted file mode 100644 index 7841b28..0000000 Binary files a/assets/palette-preview-light.png and /dev/null differ diff --git a/assets/palette-preview-neon-dark.png b/assets/palette-preview-neon-dark.png deleted file mode 100644 index 62f07d1..0000000 Binary files a/assets/palette-preview-neon-dark.png and /dev/null differ diff --git a/assets/palette-preview-neon-light.png b/assets/palette-preview-neon-light.png deleted file mode 100644 index 4ce5a11..0000000 Binary files a/assets/palette-preview-neon-light.png and /dev/null differ diff --git a/assets/sample-csharp.png b/assets/sample-csharp.png new file mode 100644 index 0000000..7ab699e Binary files /dev/null and b/assets/sample-csharp.png differ diff --git a/assets/sample-markdown.png b/assets/sample-markdown.png new file mode 100644 index 0000000..58121cd Binary files /dev/null and b/assets/sample-markdown.png differ diff --git a/assets/sample-python.png b/assets/sample-python.png new file mode 100644 index 0000000..d323a16 Binary files /dev/null and b/assets/sample-python.png differ diff --git a/assets/sample-react.png b/assets/sample-react.png new file mode 100644 index 0000000..f7b1c7c Binary files /dev/null and b/assets/sample-react.png differ diff --git a/assets/sample-rust.png b/assets/sample-rust.png new file mode 100644 index 0000000..1175f40 Binary files /dev/null and b/assets/sample-rust.png differ diff --git a/biome.json b/biome.json index f6e1f23..32765c9 100644 --- a/biome.json +++ b/biome.json @@ -6,7 +6,8 @@ "useIgnoreFile": true }, "files": { - "ignoreUnknown": false + "ignoreUnknown": false, + "includes": ["**", "!assets/generate"] }, "formatter": { "enabled": true, diff --git a/generator/colorUtils.test.ts b/generator/colorUtils.test.ts index f76373e..63270bc 100644 --- a/generator/colorUtils.test.ts +++ b/generator/colorUtils.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test } from "bun:test"; import { adjustBrightness, + contrastRatio, + ensureContrast, hexToRgb, hslToRgb, + relativeLuminance, rgbToHex, rgbToHsl, } from "./colorUtils"; @@ -52,3 +55,76 @@ describe("rgbToHsl / hslToRgb", () => { expect(b).toBeCloseTo(112, -1); }); }); + +describe("relativeLuminance", () => { + test("black is 0, white is 1", () => { + expect(relativeLuminance([0, 0, 0])).toBeCloseTo(0, 5); + expect(relativeLuminance([255, 255, 255])).toBeCloseTo(1, 5); + }); + + test("a lighter color has higher luminance than a darker one", () => { + expect(relativeLuminance([232, 196, 104])).toBeGreaterThan( + relativeLuminance([46, 26, 36]), + ); + }); +}); + +describe("contrastRatio", () => { + test("black on white is 21:1", () => { + expect(contrastRatio([0, 0, 0], [255, 255, 255])).toBeCloseTo(21, 1); + }); + + test("is symmetric", () => { + const a = contrastRatio([90, 192, 154], [255, 248, 245]); + const b = contrastRatio([255, 248, 245], [90, 192, 154]); + expect(a).toBeCloseTo(b, 6); + }); + + test("identical colors are 1:1", () => { + expect(contrastRatio([120, 120, 120], [120, 120, 120])).toBeCloseTo(1, 6); + }); +}); + +describe("ensureContrast", () => { + const LIGHT_BG: [number, number, number] = [255, 248, 245]; // #FFF8F5 + + test("raises a failing color to at least the target ratio", () => { + const yellow: [number, number, number] = [232, 196, 104]; // #E8C468, ~1.6:1 on light bg + expect(contrastRatio(yellow, LIGHT_BG)).toBeLessThan(3); + const fixed = ensureContrast(yellow, LIGHT_BG, 3); + expect(contrastRatio(fixed, LIGHT_BG)).toBeGreaterThanOrEqual(3); + }); + + test("is a no-op when the color already meets the target", () => { + const text: [number, number, number] = [46, 26, 36]; // #2E1A24, passes easily on light bg + expect(contrastRatio(text, LIGHT_BG)).toBeGreaterThanOrEqual(3); + expect(ensureContrast(text, LIGHT_BG, 3)).toEqual(text); + }); + + test("preserves hue while darkening against a light background", () => { + const green: [number, number, number] = [90, 192, 154]; // #5AC09A + const fixed = ensureContrast(green, LIGHT_BG, 3); + const [hIn] = rgbToHsl(...green); + const [hOut] = rgbToHsl(...fixed); + expect(hOut).toBeCloseTo(hIn, 2); + // darkened (moved away from the light background) + expect(relativeLuminance(fixed)).toBeLessThan(relativeLuminance(green)); + }); + + test("lightens against a dark background", () => { + const DARK_BG: [number, number, number] = [30, 16, 40]; // #1E1028 + const dim: [number, number, number] = [60, 40, 70]; // low contrast on dark bg + expect(contrastRatio(dim, DARK_BG)).toBeLessThan(3); + const fixed = ensureContrast(dim, DARK_BG, 3); + expect(contrastRatio(fixed, DARK_BG)).toBeGreaterThanOrEqual(3); + expect(relativeLuminance(fixed)).toBeGreaterThan(relativeLuminance(dim)); + }); + + test("minimal nudge: a color just under target ends up near the target, not maxed out", () => { + const red: [number, number, number] = [255, 84, 112]; // #FF5470, ~2.85:1 on light bg + const fixed = ensureContrast(red, LIGHT_BG, 3); + const ratio = contrastRatio(fixed, LIGHT_BG); + expect(ratio).toBeGreaterThanOrEqual(3); + expect(ratio).toBeLessThan(4); // didn't slam to black + }); +}); diff --git a/generator/colorUtils.ts b/generator/colorUtils.ts index e8ef2c7..4207e10 100644 --- a/generator/colorUtils.ts +++ b/generator/colorUtils.ts @@ -94,3 +94,78 @@ export function adjustBrightness( number, ]; } + +function clampRgb(rgb: number[]): [number, number, number] { + return rgb.map((c) => Math.max(0, Math.min(255, Math.round(c)))) as [ + number, + number, + number, + ]; +} + +/** + * WCAG 2.x relative luminance of an sRGB color (0 = black, 1 = white). + */ +export function relativeLuminance(rgb: [number, number, number]): number { + const channel = (c: number): number => { + const cs = c / 255; + return cs <= 0.03928 ? cs / 12.92 : ((cs + 0.055) / 1.055) ** 2.4; + }; + const [r, g, b] = rgb; + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +} + +/** + * WCAG contrast ratio between two colors (1:1 .. 21:1). Order-independent. + */ +export function contrastRatio( + a: [number, number, number], + b: [number, number, number], +): number { + const la = relativeLuminance(a); + const lb = relativeLuminance(b); + const hi = Math.max(la, lb); + const lo = Math.min(la, lb); + return (hi + 0.05) / (lo + 0.05); +} + +/** + * Nudge a color's lightness just enough to reach `minRatio` contrast against + * `bg`, preserving hue and saturation. Darkens on a light background and + * lightens on a dark one. Returns the color unchanged when it already passes. + */ +export function ensureContrast( + rgb: [number, number, number], + bg: [number, number, number], + minRatio = 3, +): [number, number, number] { + if (contrastRatio(rgb, bg) >= minRatio) return rgb; + + const [h, s, l] = rgbToHsl(...rgb); + // Move lightness away from the background: toward black on a light bg, + // toward white on a dark one. + const targetL = relativeLuminance(bg) > 0.5 ? 0 : 1; + const ratioAt = (lightness: number): number => + contrastRatio(clampRgb(hslToRgb(h, s, lightness)), bg); + + // If even the extreme can't reach the target (e.g. a very saturated hue + // against a same-luminance bg), return the most-contrasting option we have. + if (ratioAt(targetL) < minRatio) { + return clampRgb(hslToRgb(h, s, targetL)); + } + + // Binary-search the smallest lightness change from `l` toward `targetL` + // that satisfies `minRatio`. `fail` anchors the side below target, `pass` + // the side at/above it; they converge on the boundary. + let fail = l; + let pass = targetL; + for (let i = 0; i < 24; i++) { + const mid = (fail + pass) / 2; + if (ratioAt(mid) >= minRatio) { + pass = mid; + } else { + fail = mid; + } + } + return clampRgb(hslToRgb(h, s, pass)); +} diff --git a/generator/targets/neovim.test.ts b/generator/targets/neovim.test.ts index 33b79e7..e61e021 100644 --- a/generator/targets/neovim.test.ts +++ b/generator/targets/neovim.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import path from "node:path"; +import { contrastRatio, hexToRgb } from "../colorUtils"; import { loadPalette } from "../palette"; import { loadFontStyles, @@ -17,6 +18,14 @@ import { const PROJECT_DIR = path.resolve(import.meta.dir, "../.."); const PALETTE_DIR = path.join(PROJECT_DIR, "palette"); +// terminal[] indices: 0 black, 1-6 chromatic (red..cyan), 7 white, +// 8 bright_black, 9-14 bright chromatic, 15 bright_white. +const CHROMATIC_TERMINAL_INDICES = [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14]; + +function contrastWithBg(color: string, background: string): number { + return contrastRatio(hexToRgb(color), hexToRgb(background)); +} + function loadFixtures(variant: VariantName) { const palette = loadPalette(PALETTE_DIR, variant); const uiMapping = loadUIMapping(PALETTE_DIR); @@ -76,6 +85,46 @@ describe("generateNeovimPalette", () => { expect(result.terminal).toHaveLength(16); }); + for (const variant of ["pastel-light", "neon-light"] as VariantName[]) { + test(`${variant} chromatic terminal colors meet 3:1 contrast on background`, () => { + const { palette, uiMapping, syntaxMapping, scopes, fontStyles } = + loadFixtures(variant); + const result = generateNeovimPalette( + variant, + palette, + uiMapping, + syntaxMapping, + scopes, + fontStyles, + ); + + const bg = result.ui.Normal.bg as string; + for (const i of CHROMATIC_TERMINAL_INDICES) { + expect(contrastWithBg(result.terminal[i], bg)).toBeGreaterThanOrEqual( + 3, + ); + } + }); + } + + test("dark chromatic terminal colors are unchanged by the contrast floor", () => { + const { palette, uiMapping, syntaxMapping, scopes, fontStyles } = + loadFixtures("pastel-dark"); + const result = generateNeovimPalette( + "pastel-dark", + palette, + uiMapping, + syntaxMapping, + scopes, + fontStyles, + ); + + // Already legible on the dark background — palette values preserved. + expect(result.terminal[2]).toBe("#7FD7B5"); // green + expect(result.terminal[3]).toBe("#FFE4B5"); // yellow + expect(result.terminal[6]).toBe("#5ED4E0"); // cyan + }); + test("pastel-dark has @comment with correct fg and italic", () => { const { palette, uiMapping, syntaxMapping, scopes, fontStyles } = loadFixtures("pastel-dark"); diff --git a/generator/targets/neovim.ts b/generator/targets/neovim.ts index 5a2d894..92be7a8 100644 --- a/generator/targets/neovim.ts +++ b/generator/targets/neovim.ts @@ -1,3 +1,4 @@ +import { ensureContrast, rgbToHex } from "../colorUtils"; import { colorToHex, resolveColor } from "../palette"; import { resolveSyntaxColor } from "../syntaxMapping"; import type { @@ -49,6 +50,19 @@ function pc(palette: Palette, colorName: string, alpha?: number): string { return colorToHex(color, alpha); } +// Resolve a color and nudge it just enough to stay legible against the +// terminal background. No-op when it already clears the floor (dark themes). +function floorPc( + palette: Palette, + colorName: string, + bgRgb: [number, number, number], + minRatio = 3, +): string { + const color = resolveColor(palette, colorName); + const adjusted = ensureContrast(color.rgb, bgRgb, minRatio); + return `#${rgbToHex(...adjusted)}`; +} + const TS_TO_CLASSIC: Record = { comment: "Comment", "comment.block": "Comment", @@ -436,18 +450,23 @@ export function generateNeovimPalette( fontStyles, ); + // Terminal background is the editor background (base); floor chromatic ANSI + // text colors against it so they stay legible (no-op on dark themes). + const terminalBgRgb = resolveColor(palette, "base").rgb; + const t = uiMapping.terminal; + const terminal: string[] = []; if (isDark) { terminal.push(pc(palette, "crust")); } else { terminal.push(pc(palette, "text")); } - terminal.push(uc(uiMapping, palette, "terminal", "red")); - terminal.push(uc(uiMapping, palette, "terminal", "green")); - terminal.push(uc(uiMapping, palette, "terminal", "yellow")); - terminal.push(uc(uiMapping, palette, "terminal", "blue")); - terminal.push(uc(uiMapping, palette, "terminal", "magenta")); - terminal.push(uc(uiMapping, palette, "terminal", "cyan")); + terminal.push(floorPc(palette, t.red, terminalBgRgb)); + terminal.push(floorPc(palette, t.green, terminalBgRgb)); + terminal.push(floorPc(palette, t.yellow, terminalBgRgb)); + terminal.push(floorPc(palette, t.blue, terminalBgRgb)); + terminal.push(floorPc(palette, t.magenta, terminalBgRgb)); + terminal.push(floorPc(palette, t.cyan, terminalBgRgb)); if (isDark) { terminal.push(uc(uiMapping, palette, "terminal", "white")); } else { @@ -458,12 +477,12 @@ export function generateNeovimPalette( } else { terminal.push(pc(palette, "subtext1")); } - terminal.push(uc(uiMapping, palette, "terminal", "bright_red")); - terminal.push(uc(uiMapping, palette, "terminal", "bright_green")); - terminal.push(uc(uiMapping, palette, "terminal", "bright_yellow")); - terminal.push(uc(uiMapping, palette, "terminal", "bright_blue")); - terminal.push(uc(uiMapping, palette, "terminal", "bright_magenta")); - terminal.push(uc(uiMapping, palette, "terminal", "bright_cyan")); + terminal.push(floorPc(palette, t.bright_red, terminalBgRgb)); + terminal.push(floorPc(palette, t.bright_green, terminalBgRgb)); + terminal.push(floorPc(palette, t.bright_yellow, terminalBgRgb)); + terminal.push(floorPc(palette, t.bright_blue, terminalBgRgb)); + terminal.push(floorPc(palette, t.bright_magenta, terminalBgRgb)); + terminal.push(floorPc(palette, t.bright_cyan, terminalBgRgb)); terminal.push(uc(uiMapping, palette, "terminal", "bright_white")); const lspLinks: Record = { diff --git a/generator/targets/vscode.test.ts b/generator/targets/vscode.test.ts index bef2998..a3adec2 100644 --- a/generator/targets/vscode.test.ts +++ b/generator/targets/vscode.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import path from "node:path"; +import { contrastRatio, hexToRgb } from "../colorUtils"; import { loadPalette } from "../palette"; import { loadFontStyles, @@ -13,6 +14,28 @@ import { generateVSCodeTheme } from "./vscode"; const PROJECT_DIR = path.resolve(import.meta.dir, "../.."); const PALETTE_DIR = path.join(PROJECT_DIR, "palette"); +const CHROMATIC_ANSI = ["Red", "Green", "Yellow", "Blue", "Magenta", "Cyan"]; + +function buildTheme(variant: VariantName) { + const palette = loadPalette(PALETTE_DIR, variant); + const uiMapping = loadUIMapping(PALETTE_DIR); + const syntaxMapping = loadSyntaxMapping(PALETTE_DIR); + const scopes = loadScopes(PALETTE_DIR); + const fontStyles = loadFontStyles(PALETTE_DIR); + return generateVSCodeTheme( + variant, + palette, + uiMapping, + syntaxMapping, + scopes, + fontStyles, + ); +} + +function contrastWithBg(color: string, background: string): number { + return contrastRatio(hexToRgb(color), hexToRgb(background)); +} + describe("generateVSCodeTheme", () => { test("produces a valid dark theme structure", () => { const variant: VariantName = "pastel-dark"; @@ -64,4 +87,28 @@ describe("generateVSCodeTheme", () => { expect(theme.type).toBe("light"); expect(theme.colors["editor.background"]).toBe("#FFF8F5"); }); + + for (const variant of ["pastel-light", "neon-light"] as VariantName[]) { + test(`${variant} terminal ANSI colors meet 3:1 contrast on background`, () => { + const theme = buildTheme(variant); + const bg = theme.colors["terminal.background"]; + + for (const name of CHROMATIC_ANSI) { + expect( + contrastWithBg(theme.colors[`terminal.ansi${name}`], bg), + ).toBeGreaterThanOrEqual(3); + expect( + contrastWithBg(theme.colors[`terminal.ansiBright${name}`], bg), + ).toBeGreaterThanOrEqual(3); + } + }); + } + + test("dark terminal ANSI colors are unchanged by the contrast floor", () => { + const theme = buildTheme("pastel-dark"); + // Already legible on the dark background — palette values preserved. + expect(theme.colors["terminal.ansiGreen"]).toBe("#7FD7B5"); + expect(theme.colors["terminal.ansiYellow"]).toBe("#FFE4B5"); + expect(theme.colors["terminal.ansiCyan"]).toBe("#5ED4E0"); + }); }); diff --git a/generator/targets/vscode.ts b/generator/targets/vscode.ts index f2254bb..eb2f106 100644 --- a/generator/targets/vscode.ts +++ b/generator/targets/vscode.ts @@ -1,4 +1,4 @@ -import { adjustBrightness } from "../colorUtils"; +import { adjustBrightness, ensureContrast, rgbToHex } from "../colorUtils"; import { OPACITY_RULES } from "../opacity"; import { colorToHex, resolveColor } from "../palette"; import { resolveSyntaxColor } from "../syntaxMapping"; @@ -47,10 +47,35 @@ function pc(palette: Palette, colorName: string, alpha?: number): string { return colorToHex(color, alpha); } -function bright(palette: Palette, colorName: string): string { +// Resolve a color and nudge it just enough to stay legible against the +// terminal background. No-op when it already clears the floor (dark themes). +function floor( + palette: Palette, + colorName: string, + bgRgb: [number, number, number], + minRatio = 3, +): string { const color = resolveColor(palette, colorName); - const brightRgb = adjustBrightness(color.rgb, 0.2, "lighten"); - return `#${brightRgb.map((c) => c.toString(16).padStart(2, "0").toUpperCase()).join("")}`; + const adjusted = ensureContrast(color.rgb, bgRgb, minRatio); + return `#${rgbToHex(...adjusted)}`; +} + +// "Bright" = more emphasized than the base: lighter on a dark background, +// darker on a light one, then floored for legibility. +function bright( + palette: Palette, + colorName: string, + isDark: boolean, + bgRgb: [number, number, number], +): string { + const color = resolveColor(palette, colorName); + const emphasized = adjustBrightness( + color.rgb, + 0.2, + isDark ? "lighten" : "dim", + ); + const adjusted = ensureContrast(emphasized, bgRgb, 3); + return `#${rgbToHex(...adjusted)}`; } export function generateVSCodeTheme( @@ -393,22 +418,25 @@ export function generateVSCodeTheme( ); colors["terminal.foreground"] = uc(uiMapping, palette, "text", "primary"); + // Terminal background matches the editor background; floor chromatic ANSI + // text colors against it so they stay legible (no-op on dark themes). + const terminalBgRgb = resolveColor(palette, "base").rgb; + const ansiFloor = (colorName: string) => + floor(palette, colorName, terminalBgRgb); + const ansiBright = (colorName: string) => + bright(palette, colorName, isDark, terminalBgRgb); + if (isDark) { colors["terminal.ansiBlack"] = pc(palette, "crust"); } else { colors["terminal.ansiBlack"] = pc(palette, "text"); } - colors["terminal.ansiRed"] = uc(uiMapping, palette, "terminal", "red"); - colors["terminal.ansiGreen"] = uc(uiMapping, palette, "terminal", "green"); - colors["terminal.ansiYellow"] = uc(uiMapping, palette, "terminal", "yellow"); - colors["terminal.ansiBlue"] = uc(uiMapping, palette, "terminal", "blue"); - colors["terminal.ansiMagenta"] = uc( - uiMapping, - palette, - "terminal", - "magenta", - ); - colors["terminal.ansiCyan"] = uc(uiMapping, palette, "terminal", "cyan"); + colors["terminal.ansiRed"] = ansiFloor(uiMapping.terminal.red); + colors["terminal.ansiGreen"] = ansiFloor(uiMapping.terminal.green); + colors["terminal.ansiYellow"] = ansiFloor(uiMapping.terminal.yellow); + colors["terminal.ansiBlue"] = ansiFloor(uiMapping.terminal.blue); + colors["terminal.ansiMagenta"] = ansiFloor(uiMapping.terminal.magenta); + colors["terminal.ansiCyan"] = ansiFloor(uiMapping.terminal.cyan); colors["terminal.ansiWhite"] = uc(uiMapping, palette, "terminal", "white"); colors["terminal.ansiBrightBlack"] = uc( uiMapping, @@ -416,25 +444,20 @@ export function generateVSCodeTheme( "terminal", "bright_black", ); - colors["terminal.ansiBrightRed"] = bright(palette, uiMapping.terminal.red); - colors["terminal.ansiBrightGreen"] = bright( - palette, - uiMapping.terminal.green, - ); - colors["terminal.ansiBrightYellow"] = bright( - palette, - uiMapping.terminal.yellow, - ); - colors["terminal.ansiBrightBlue"] = bright(palette, uiMapping.terminal.blue); - colors["terminal.ansiBrightMagenta"] = bright( - palette, - uiMapping.terminal.magenta, - ); - colors["terminal.ansiBrightCyan"] = bright(palette, uiMapping.terminal.cyan); - colors["terminal.ansiBrightWhite"] = bright( - palette, - uiMapping.terminal.white, - ); + colors["terminal.ansiBrightRed"] = ansiBright(uiMapping.terminal.red); + colors["terminal.ansiBrightGreen"] = ansiBright(uiMapping.terminal.green); + colors["terminal.ansiBrightYellow"] = ansiBright(uiMapping.terminal.yellow); + colors["terminal.ansiBrightBlue"] = ansiBright(uiMapping.terminal.blue); + colors["terminal.ansiBrightMagenta"] = ansiBright(uiMapping.terminal.magenta); + colors["terminal.ansiBrightCyan"] = ansiBright(uiMapping.terminal.cyan); + // Structural "white" slot — stays light (no contrast floor / direction flip). + colors["terminal.ansiBrightWhite"] = `#${rgbToHex( + ...adjustBrightness( + resolveColor(palette, uiMapping.terminal.white).rgb, + 0.2, + "lighten", + ), + )}`; colors["input.background"] = uc(uiMapping, palette, "surface", "default"); colors["input.foreground"] = uc(uiMapping, palette, "text", "primary"); diff --git a/generator/targets/wezterm.test.ts b/generator/targets/wezterm.test.ts index 3257b14..e9001a1 100644 --- a/generator/targets/wezterm.test.ts +++ b/generator/targets/wezterm.test.ts @@ -1,11 +1,20 @@ import { describe, expect, test } from "bun:test"; import path from "node:path"; +import { contrastRatio, hexToRgb } from "../colorUtils"; import { loadPalette } from "../palette"; import { loadTerminalMapping } from "../terminalMapping"; import type { VariantName } from "../types"; import { loadUIMapping } from "../uiMapping"; import { generateWeztermTheme, tomlStringify } from "./wezterm"; +// ANSI slots that carry chromatic foreground text (red, green, yellow, blue, +// magenta, cyan). Indices 0 (black) and 7 (white) are structural and excluded. +const CHROMATIC_INDICES = [1, 2, 3, 4, 5, 6]; + +function contrastWithBg(color: string, background: string): number { + return contrastRatio(hexToRgb(color), hexToRgb(background)); +} + const PROJECT_DIR = path.resolve(import.meta.dir, "../.."); const PALETTE_DIR = path.join(PROJECT_DIR, "palette"); @@ -152,6 +161,61 @@ describe("generateWeztermTheme", () => { expect(theme.brights[7]).toBe("#2E1A24"); // text (bright white) }); + test("pastel-light chromatic ANSI colors meet 3:1 contrast on background", () => { + const { palette, uiMapping, terminalMapping } = + loadFixtures("pastel-light"); + const theme = generateWeztermTheme( + "pastel-light", + palette, + uiMapping, + terminalMapping, + ); + + for (const i of CHROMATIC_INDICES) { + expect( + contrastWithBg(theme.ansi[i], theme.background), + ).toBeGreaterThanOrEqual(3); + expect( + contrastWithBg(theme.brights[i], theme.background), + ).toBeGreaterThanOrEqual(3); + } + }); + + test("neon-light chromatic ANSI colors meet 3:1 contrast on background", () => { + const { palette, uiMapping, terminalMapping } = loadFixtures("neon-light"); + const theme = generateWeztermTheme( + "neon-light", + palette, + uiMapping, + terminalMapping, + ); + + for (const i of CHROMATIC_INDICES) { + expect( + contrastWithBg(theme.ansi[i], theme.background), + ).toBeGreaterThanOrEqual(3); + expect( + contrastWithBg(theme.brights[i], theme.background), + ).toBeGreaterThanOrEqual(3); + } + }); + + test("dark chromatic ANSI colors are unchanged by the contrast floor", () => { + const { palette, uiMapping, terminalMapping } = loadFixtures("pastel-dark"); + const theme = generateWeztermTheme( + "pastel-dark", + palette, + uiMapping, + terminalMapping, + ); + + // Already legible on the dark background, so the floor is a no-op: + // these exact palette-derived values must be preserved. + expect(theme.ansi[2]).toBe("#7FD7B5"); // green + expect(theme.ansi[3]).toBe("#FFE4B5"); // yellow + expect(theme.ansi[6]).toBe("#5ED4E0"); // cyan + }); + test("scrollbar and split colors are set", () => { const { palette, uiMapping, terminalMapping } = loadFixtures("pastel-dark"); const theme = generateWeztermTheme( diff --git a/generator/targets/wezterm.ts b/generator/targets/wezterm.ts index 97e95d5..874067e 100644 --- a/generator/targets/wezterm.ts +++ b/generator/targets/wezterm.ts @@ -1,4 +1,4 @@ -import { adjustBrightness } from "../colorUtils"; +import { adjustBrightness, ensureContrast, rgbToHex } from "../colorUtils"; import { colorToHex, resolveColor } from "../palette"; import type { Palette, @@ -41,40 +41,58 @@ function hex(palette: Palette, colorName: string): string { return colorToHex(color); } +// Resolve a color and darken/lighten it just enough to stay legible against the +// terminal background. A no-op when it already clears the contrast floor (so +// dark themes, which already pass, are untouched). +function floorHex( + palette: Palette, + colorName: string, + bgRgb: [number, number, number], + minRatio = 3, +): string { + const color = resolveColor(palette, colorName); + const adjusted = ensureContrast(color.rgb, bgRgb, minRatio); + return `#${rgbToHex(...adjusted)}`; +} + +// "Bright" means more emphasized than the base color: lighter on a dark +// background, darker on a light one. Then floored for legibility. function brightHex( palette: Palette, colorName: string, - factor: number = 0.2, + isDark: boolean, + bgRgb: [number, number, number], + factor = 0.2, + minRatio = 3, ): string { const color = resolveColor(palette, colorName); - const brightRgb = adjustBrightness(color.rgb, factor, "lighten"); - return `#${brightRgb - .map((c) => - Math.max(0, Math.min(255, Math.round(c))) - .toString(16) - .padStart(2, "0") - .toUpperCase(), - ) - .join("")}`; + const emphasized = adjustBrightness( + color.rgb, + factor, + isDark ? "lighten" : "dim", + ); + const adjusted = ensureContrast(emphasized, bgRgb, minRatio); + return `#${rgbToHex(...adjusted)}`; } function buildAnsiColors( palette: Palette, isDark: boolean, uiMapping: UIMapping, + bgRgb: [number, number, number], ): string[] { const t = uiMapping.terminal; const ansi0 = isDark ? hex(palette, "crust") : hex(palette, t.black); - const ansi7 = isDark ? hex(palette, t.white) : hex(palette, t.white); + const ansi7 = hex(palette, t.white); return [ ansi0, - hex(palette, t.red), - hex(palette, t.green), - hex(palette, t.yellow), - hex(palette, t.blue), - hex(palette, t.magenta), - hex(palette, t.cyan), + floorHex(palette, t.red, bgRgb), + floorHex(palette, t.green, bgRgb), + floorHex(palette, t.yellow, bgRgb), + floorHex(palette, t.blue, bgRgb), + floorHex(palette, t.magenta, bgRgb), + floorHex(palette, t.cyan, bgRgb), ansi7, ]; } @@ -83,6 +101,7 @@ function buildBrightColors( palette: Palette, isDark: boolean, uiMapping: UIMapping, + bgRgb: [number, number, number], ): string[] { const t = uiMapping.terminal; const bright0 = isDark ? hex(palette, "subtext0") : hex(palette, "subtext1"); @@ -90,12 +109,12 @@ function buildBrightColors( return [ bright0, - brightHex(palette, t.red), - brightHex(palette, t.green), - brightHex(palette, t.yellow), - brightHex(palette, t.blue), - brightHex(palette, t.magenta), - brightHex(palette, t.cyan), + brightHex(palette, t.red, isDark, bgRgb), + brightHex(palette, t.green, isDark, bgRgb), + brightHex(palette, t.yellow, isDark, bgRgb), + brightHex(palette, t.blue, isDark, bgRgb), + brightHex(palette, t.magenta, isDark, bgRgb), + brightHex(palette, t.cyan, isDark, bgRgb), bright7, ]; } @@ -108,6 +127,7 @@ export function generateWeztermTheme( ): WeztermTheme { const isDark = VARIANT_TYPE[variant] === "dark"; const tm = terminalMapping; + const bgRgb = resolveColor(palette, "base").rgb; const selectionRgb = resolveColor(palette, tm.selection.bg).rgb; const selectionBg = `rgba(${selectionRgb.join(", ")}, ${tm.selection.bg_alpha})`; @@ -138,8 +158,8 @@ export function generateWeztermTheme( fg_color: hex(palette, tm.tab_bar.new_tab.fg), }, }, - ansi: buildAnsiColors(palette, isDark, uiMapping), - brights: buildBrightColors(palette, isDark, uiMapping), + ansi: buildAnsiColors(palette, isDark, uiMapping, bgRgb), + brights: buildBrightColors(palette, isDark, uiMapping, bgRgb), }; } diff --git a/generator/targets/zed.test.ts b/generator/targets/zed.test.ts index 8f55ea1..3e719fc 100644 --- a/generator/targets/zed.test.ts +++ b/generator/targets/zed.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import path from "node:path"; +import { contrastRatio, hexToRgb } from "../colorUtils"; import { loadPalette } from "../palette"; import { loadFontStyles, loadSyntaxMapping } from "../syntaxMapping"; import { loadUIMapping } from "../uiMapping"; @@ -8,6 +9,31 @@ import { generateZedTheme } from "./zed"; const PROJECT_DIR = path.resolve(import.meta.dir, "../.."); const PALETTE_DIR = path.join(PROJECT_DIR, "palette"); +const CHROMATIC_ANSI = ["red", "green", "yellow", "blue", "magenta", "cyan"]; + +// 8-digit "#RRGGBBaa" -> contrast against another 8-digit color (alpha ignored). +function contrastWithBg(color: string, background: string): number { + return contrastRatio(hexToRgb(color), hexToRgb(background)); +} + +function buildPastelTheme() { + const darkPalette = loadPalette(PALETTE_DIR, "pastel-dark"); + const lightPalette = loadPalette(PALETTE_DIR, "pastel-light"); + const uiMapping = loadUIMapping(PALETTE_DIR); + const syntaxMapping = loadSyntaxMapping(PALETTE_DIR); + const fontStyles = loadFontStyles(PALETTE_DIR); + return generateZedTheme( + "synthpunk-pastel", + "Synthpunk Pastel", + "Synthpunk", + darkPalette, + lightPalette, + uiMapping, + syntaxMapping, + fontStyles, + ); +} + describe("generateZedTheme", () => { test("produces two theme variants (dark and light)", () => { const darkPalette = loadPalette(PALETTE_DIR, "pastel-dark"); @@ -185,6 +211,20 @@ describe("generateZedTheme", () => { expect(style["terminal.ansi.red"]).toBe("#FF5470ff"); }); + test("light theme chromatic terminal colors meet 3:1 contrast on background", () => { + const style = buildPastelTheme().themes[1].style; // light variant + const bg = style["terminal.background"] as string; + + for (const name of CHROMATIC_ANSI) { + expect( + contrastWithBg(style[`terminal.ansi.${name}`] as string, bg), + ).toBeGreaterThanOrEqual(3); + expect( + contrastWithBg(style[`terminal.ansi.bright_${name}`] as string, bg), + ).toBeGreaterThanOrEqual(3); + } + }); + test("schema and author are set correctly", () => { const darkPalette = loadPalette(PALETTE_DIR, "pastel-dark"); const lightPalette = loadPalette(PALETTE_DIR, "pastel-light"); diff --git a/generator/targets/zed.ts b/generator/targets/zed.ts index d004c81..9ad4dc9 100644 --- a/generator/targets/zed.ts +++ b/generator/targets/zed.ts @@ -1,4 +1,4 @@ -import { adjustBrightness } from "../colorUtils"; +import { adjustBrightness, ensureContrast } from "../colorUtils"; import { resolveColor } from "../palette"; import type { FontStyleMapping, @@ -69,15 +69,38 @@ function hex8rgb(rgb: [number, number, number], alpha: number = 1): string { return `#${hex}${a}`; } +// Resolve a color and nudge it just enough to stay legible against the +// terminal background. No-op when it already clears the floor (dark themes). +function floor8( + palette: Palette, + colorName: string, + bgRgb: [number, number, number], + alpha: number = 1, + minRatio = 3, +): string { + const color = resolveColor(palette, colorName); + const adjusted = ensureContrast(color.rgb, bgRgb, minRatio); + return hex8rgb(adjusted, alpha); +} + +// "Bright" = more emphasized than the base: lighter on a dark background, +// darker on a light one, then floored for legibility. function bright8( palette: Palette, colorName: string, + isDark: boolean, + bgRgb: [number, number, number], factor: number = 0.2, alpha: number = 1, ): string { const color = resolveColor(palette, colorName); - const brightRgb = adjustBrightness(color.rgb, factor, "lighten"); - return hex8rgb(brightRgb, alpha); + const emphasized = adjustBrightness( + color.rgb, + factor, + isDark ? "lighten" : "dim", + ); + const adjusted = ensureContrast(emphasized, bgRgb, 3); + return hex8rgb(adjusted, alpha); } function dim8( @@ -110,6 +133,9 @@ function buildUIStyle( ): Record { const s = (colorName: string, alpha?: number) => hex8(palette, colorName, alpha); + // Terminal background is the editor background; floor chromatic ANSI text + // against it so it stays legible (no-op on dark themes). + const bgRgb = resolveColor(palette, "base").rgb; const result: Record = { border: s("surface1"), @@ -184,23 +210,23 @@ function buildUIStyle( "terminal.ansi.dim_black": isDark ? dim8(palette, "text", 0.3) : dim8(palette, "text", 0.3), - "terminal.ansi.red": s("red"), - "terminal.ansi.bright_red": bright8(palette, "red"), + "terminal.ansi.red": floor8(palette, "red", bgRgb), + "terminal.ansi.bright_red": bright8(palette, "red", isDark, bgRgb), "terminal.ansi.dim_red": dim8(palette, "red"), - "terminal.ansi.green": s("green"), - "terminal.ansi.bright_green": bright8(palette, "green"), + "terminal.ansi.green": floor8(palette, "green", bgRgb), + "terminal.ansi.bright_green": bright8(palette, "green", isDark, bgRgb), "terminal.ansi.dim_green": dim8(palette, "green"), - "terminal.ansi.yellow": s("yellow"), - "terminal.ansi.bright_yellow": bright8(palette, "yellow"), + "terminal.ansi.yellow": floor8(palette, "yellow", bgRgb), + "terminal.ansi.bright_yellow": bright8(palette, "yellow", isDark, bgRgb), "terminal.ansi.dim_yellow": dim8(palette, "yellow"), - "terminal.ansi.blue": s("blue"), - "terminal.ansi.bright_blue": bright8(palette, "blue"), + "terminal.ansi.blue": floor8(palette, "blue", bgRgb), + "terminal.ansi.bright_blue": bright8(palette, "blue", isDark, bgRgb), "terminal.ansi.dim_blue": dim8(palette, "blue"), - "terminal.ansi.magenta": s("pink"), - "terminal.ansi.bright_magenta": bright8(palette, "pink"), + "terminal.ansi.magenta": floor8(palette, "pink", bgRgb), + "terminal.ansi.bright_magenta": bright8(palette, "pink", isDark, bgRgb), "terminal.ansi.dim_magenta": dim8(palette, "pink"), - "terminal.ansi.cyan": s("teal"), - "terminal.ansi.bright_cyan": bright8(palette, "teal"), + "terminal.ansi.cyan": floor8(palette, "teal", bgRgb), + "terminal.ansi.bright_cyan": bright8(palette, "teal", isDark, bgRgb), "terminal.ansi.dim_cyan": dim8(palette, "teal"), "terminal.ansi.white": isDark ? s("subtext1") : s("subtext0"), "terminal.ansi.bright_white": s("text"), diff --git a/package.json b/package.json index f576543..0f81b0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "synthpunk", - "version": "0.1.2", + "version": "0.1.3", "module": "index.ts", "type": "module", "private": true, diff --git a/themes/neovim/lua/synthpunk/theme.lua b/themes/neovim/lua/synthpunk/theme.lua index 6420411..5194d03 100644 --- a/themes/neovim/lua/synthpunk/theme.lua +++ b/themes/neovim/lua/synthpunk/theme.lua @@ -1546,20 +1546,20 @@ local variants = { }, terminal = { "#2E1A24", - "#FF5470", - "#5AC09A", - "#E8C468", - "#8BA4FF", - "#FF7DB0", - "#5ED4E0", + "#FF516E", + "#3EA27C", + "#B68A1C", + "#6989FF", + "#FF4A91", + "#219FAC", "#6B4F5E", "#6B4F5E", - "#FF5470", - "#5AC09A", - "#E8C468", - "#8BA4FF", - "#FF7DB0", - "#5ED4E0", + "#FF516E", + "#3EA27C", + "#B68A1C", + "#6989FF", + "#FF4A91", + "#219FAC", "#2E1A24" } }, @@ -3107,19 +3107,19 @@ local variants = { terminal = { "#2A1A30", "#E04058", - "#40C080", - "#D8B030", + "#36A26C", + "#AD8B21", "#8050C8", "#D04080", - "#30C0C8", + "#289EA5", "#6B5A78", "#6B5A78", "#E04058", - "#40C080", - "#D8B030", + "#36A26C", + "#AD8B21", "#8050C8", "#D04080", - "#30C0C8", + "#289EA5", "#2A1A30" } }, diff --git a/themes/vscode/package.json b/themes/vscode/package.json index 994eac0..3c84af8 100644 --- a/themes/vscode/package.json +++ b/themes/vscode/package.json @@ -2,7 +2,7 @@ "name": "synthpunk", "displayName": "Synthpunk", "description": "High-energy synthwave-inspired color themes for VSCode", - "version": "0.1.2", + "version": "0.1.3", "publisher": "synthpunk", "license": "MIT", "repository": { diff --git a/themes/vscode/themes/synthpunk-neon-light-color-theme.json b/themes/vscode/themes/synthpunk-neon-light-color-theme.json index d405d46..e57ee0c 100644 --- a/themes/vscode/themes/synthpunk-neon-light-color-theme.json +++ b/themes/vscode/themes/synthpunk-neon-light-color-theme.json @@ -74,19 +74,19 @@ "terminal.foreground": "#2A1A30", "terminal.ansiBlack": "#2A1A30", "terminal.ansiRed": "#E04058", - "terminal.ansiGreen": "#40C080", - "terminal.ansiYellow": "#D8B030", + "terminal.ansiGreen": "#36A26C", + "terminal.ansiYellow": "#AD8B21", "terminal.ansiBlue": "#8050C8", "terminal.ansiMagenta": "#D04080", - "terminal.ansiCyan": "#30C0C8", + "terminal.ansiCyan": "#289EA5", "terminal.ansiWhite": "#6B5A78", "terminal.ansiBrightBlack": "#A898B0", - "terminal.ansiBrightRed": "#E66679", - "terminal.ansiBrightGreen": "#66CD99", - "terminal.ansiBrightYellow": "#E0C059", - "terminal.ansiBrightBlue": "#9973D3", - "terminal.ansiBrightMagenta": "#D96699", - "terminal.ansiBrightCyan": "#56D0D6", + "terminal.ansiBrightRed": "#C62039", + "terminal.ansiBrightGreen": "#339A66", + "terminal.ansiBrightYellow": "#AE8B20", + "terminal.ansiBrightBlue": "#6436AA", + "terminal.ansiBrightMagenta": "#AF2B65", + "terminal.ansiBrightCyan": "#269AA0", "terminal.ansiBrightWhite": "#897698", "input.background": "#F0E8F5", "input.foreground": "#2A1A30", diff --git a/themes/vscode/themes/synthpunk-pastel-light-color-theme.json b/themes/vscode/themes/synthpunk-pastel-light-color-theme.json index 69cd170..35009ac 100644 --- a/themes/vscode/themes/synthpunk-pastel-light-color-theme.json +++ b/themes/vscode/themes/synthpunk-pastel-light-color-theme.json @@ -73,20 +73,20 @@ "terminal.background": "#FFF8F5", "terminal.foreground": "#2E1A24", "terminal.ansiBlack": "#2E1A24", - "terminal.ansiRed": "#FF5470", - "terminal.ansiGreen": "#5AC09A", - "terminal.ansiYellow": "#E8C468", - "terminal.ansiBlue": "#8BA4FF", - "terminal.ansiMagenta": "#FF7DB0", - "terminal.ansiCyan": "#5ED4E0", + "terminal.ansiRed": "#FF516E", + "terminal.ansiGreen": "#3EA27C", + "terminal.ansiYellow": "#B68A1C", + "terminal.ansiBlue": "#6989FF", + "terminal.ansiMagenta": "#FF4A91", + "terminal.ansiCyan": "#219FAC", "terminal.ansiWhite": "#6B4F5E", "terminal.ansiBrightBlack": "#997A8A", - "terminal.ansiBrightRed": "#FF768D", - "terminal.ansiBrightGreen": "#7BCDAE", - "terminal.ansiBrightYellow": "#EDD086", - "terminal.ansiBrightBlue": "#A2B6FF", - "terminal.ansiBrightMagenta": "#FF97C0", - "terminal.ansiBrightCyan": "#7EDDE6", + "terminal.ansiBrightRed": "#FF1037", + "terminal.ansiBrightGreen": "#3DA27D", + "terminal.ansiBrightYellow": "#B68A1C", + "terminal.ansiBrightBlue": "#3C66FF", + "terminal.ansiBrightMagenta": "#FF3182", + "terminal.ansiBrightCyan": "#219FAC", "terminal.ansiBrightWhite": "#906B7F", "input.background": "#F0DEE8", "input.foreground": "#2E1A24", diff --git a/themes/wezterm/synthpunk-neon-light.toml b/themes/wezterm/synthpunk-neon-light.toml index db738f1..e777cbc 100644 --- a/themes/wezterm/synthpunk-neon-light.toml +++ b/themes/wezterm/synthpunk-neon-light.toml @@ -27,6 +27,6 @@ fg_color = "#6B5A78" bg_color = "#F5F0F5" fg_color = "#6B5A78" -ansi = ["#2A1A30", "#E04058", "#40C080", "#D8B030", "#8050C8", "#D04080", "#30C0C8", "#6B5A78"] +ansi = ["#2A1A30", "#E04058", "#36A26C", "#AD8B21", "#8050C8", "#D04080", "#289EA5", "#6B5A78"] -brights = ["#6B5A78", "#E66679", "#66CD99", "#E0C059", "#9973D3", "#D96699", "#56D0D6", "#2A1A30"] +brights = ["#6B5A78", "#C62039", "#339A66", "#AE8B20", "#6436AA", "#AF2B65", "#269AA0", "#2A1A30"] diff --git a/themes/wezterm/synthpunk-pastel-light.toml b/themes/wezterm/synthpunk-pastel-light.toml index c9e6b10..ed8def3 100644 --- a/themes/wezterm/synthpunk-pastel-light.toml +++ b/themes/wezterm/synthpunk-pastel-light.toml @@ -27,6 +27,6 @@ fg_color = "#6B4F5E" bg_color = "#FFF0EB" fg_color = "#6B4F5E" -ansi = ["#2E1A24", "#FF5470", "#5AC09A", "#E8C468", "#8BA4FF", "#FF7DB0", "#5ED4E0", "#6B4F5E"] +ansi = ["#2E1A24", "#FF516E", "#3EA27C", "#B68A1C", "#6989FF", "#FF4A91", "#219FAC", "#6B4F5E"] -brights = ["#6B4F5E", "#FF768D", "#7BCDAE", "#EDD086", "#A2B6FF", "#FF97C0", "#7EDDE6", "#2E1A24"] +brights = ["#6B4F5E", "#FF1037", "#3DA27D", "#B68A1C", "#3C66FF", "#FF3182", "#219FAC", "#2E1A24"] diff --git a/themes/zed/extension.toml b/themes/zed/extension.toml index 1d64156..7088a6f 100644 --- a/themes/zed/extension.toml +++ b/themes/zed/extension.toml @@ -1,6 +1,6 @@ id = "synthpunk" name = "Synthpunk" -version = "0.1.2" +version = "0.1.3" schema_version = 1 authors = ["Synthpunk"] description = "Synthwave-inspired color themes (pastel + neon, dark + light) for Zed" diff --git a/themes/zed/themes/synthpunk-neon.json b/themes/zed/themes/synthpunk-neon.json index 7bbb39f..879e17f 100644 --- a/themes/zed/themes/synthpunk-neon.json +++ b/themes/zed/themes/synthpunk-neon.json @@ -480,22 +480,22 @@ "terminal.ansi.bright_black": "#6B5A78ff", "terminal.ansi.dim_black": "#1D1222ff", "terminal.ansi.red": "#E04058ff", - "terminal.ansi.bright_red": "#E66679ff", + "terminal.ansi.bright_red": "#C62039ff", "terminal.ansi.dim_red": "#AD1C32ff", - "terminal.ansi.green": "#40C080ff", - "terminal.ansi.bright_green": "#66CD99ff", + "terminal.ansi.green": "#36A26Cff", + "terminal.ansi.bright_green": "#339A66ff", "terminal.ansi.dim_green": "#2C875Aff", - "terminal.ansi.yellow": "#D8B030ff", - "terminal.ansi.bright_yellow": "#E0C059ff", + "terminal.ansi.yellow": "#AD8B21ff", + "terminal.ansi.bright_yellow": "#AE8B20ff", "terminal.ansi.dim_yellow": "#9C7D1Dff", "terminal.ansi.blue": "#8050C8ff", - "terminal.ansi.bright_blue": "#9973D3ff", + "terminal.ansi.bright_blue": "#6436AAff", "terminal.ansi.dim_blue": "#582F95ff", "terminal.ansi.magenta": "#D04080ff", - "terminal.ansi.bright_magenta": "#D96699ff", + "terminal.ansi.bright_magenta": "#AF2B65ff", "terminal.ansi.dim_magenta": "#992659ff", - "terminal.ansi.cyan": "#30C0C8ff", - "terminal.ansi.bright_cyan": "#56D0D6ff", + "terminal.ansi.cyan": "#289EA5ff", + "terminal.ansi.bright_cyan": "#269AA0ff", "terminal.ansi.dim_cyan": "#22868Cff", "terminal.ansi.white": "#A898B0ff", "terminal.ansi.bright_white": "#2A1A30ff", diff --git a/themes/zed/themes/synthpunk-pastel.json b/themes/zed/themes/synthpunk-pastel.json index fc66bf6..36d7d6f 100644 --- a/themes/zed/themes/synthpunk-pastel.json +++ b/themes/zed/themes/synthpunk-pastel.json @@ -479,23 +479,23 @@ "terminal.ansi.black": "#2E1A24ff", "terminal.ansi.bright_black": "#6B4F5Eff", "terminal.ansi.dim_black": "#201219ff", - "terminal.ansi.red": "#FF5470ff", - "terminal.ansi.bright_red": "#FF768Dff", + "terminal.ansi.red": "#FF516Eff", + "terminal.ansi.bright_red": "#FF1037ff", "terminal.ansi.dim_red": "#ED0027ff", - "terminal.ansi.green": "#5AC09Aff", - "terminal.ansi.bright_green": "#7BCDAEff", + "terminal.ansi.green": "#3EA27Cff", + "terminal.ansi.bright_green": "#3DA27Dff", "terminal.ansi.dim_green": "#378F6Eff", - "terminal.ansi.yellow": "#E8C468ff", - "terminal.ansi.bright_yellow": "#EDD086ff", + "terminal.ansi.yellow": "#B68A1Cff", + "terminal.ansi.bright_yellow": "#B68A1Cff", "terminal.ansi.dim_yellow": "#CC9B1Fff", - "terminal.ansi.blue": "#8BA4FFff", - "terminal.ansi.bright_blue": "#A2B6FFff", + "terminal.ansi.blue": "#6989FFff", + "terminal.ansi.bright_blue": "#3C66FFff", "terminal.ansi.dim_blue": "#1547FFff", - "terminal.ansi.magenta": "#FF7DB0ff", - "terminal.ansi.bright_magenta": "#FF97C0ff", + "terminal.ansi.magenta": "#FF4A91ff", + "terminal.ansi.bright_magenta": "#FF3182ff", "terminal.ansi.dim_magenta": "#FF0B6Bff", - "terminal.ansi.cyan": "#5ED4E0ff", - "terminal.ansi.bright_cyan": "#7EDDE6ff", + "terminal.ansi.cyan": "#219FACff", + "terminal.ansi.bright_cyan": "#219FACff", "terminal.ansi.dim_cyan": "#24ADBBff", "terminal.ansi.white": "#997A8Aff", "terminal.ansi.bright_white": "#2E1A24ff", diff --git a/tsconfig.json b/tsconfig.json index 21eef14..f413a23 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,5 +12,5 @@ "types": ["bun"] }, "include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "assets/generate"] }