mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: replace light/dark/system toggle with adaptive IDE theme system
Port the adaptive theme engine from builderbot/staged into sprout. Loads Shiki theme JSON files (54 themes, lazy-loaded), extracts key colors, and derives the full shadcn CSS variable palette directly from bg, fg, comment, and git decoration colors. Light vs dark is auto-detected from background luminance. Replace the 3-button toggle with a searchable theme picker. Add a configurable accent color picker (8 predefined colors) with auto-contrast foreground calculation, independent of the syntax theme. Default theme is houston, default accent is blue. Migrate all hardcoded dark: variants to semantic tokens (text-warning, text-status-added, text-status-deleted). Cached theme + accent vars applied synchronously on mount to prevent FOUC. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
c0421a50ed
commit
6fbee42934
@@ -51,6 +51,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.0.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
|
||||
Generated
+146
@@ -89,6 +89,9 @@ importers:
|
||||
remark-gfm:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
shiki:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2
|
||||
tailwind-merge:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
@@ -979,6 +982,7 @@ packages:
|
||||
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
||||
@@ -996,6 +1000,7 @@ packages:
|
||||
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
||||
@@ -1053,6 +1058,37 @@ packages:
|
||||
'@scure/bip39@2.0.1':
|
||||
resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==}
|
||||
|
||||
'@shikijs/core@4.0.2':
|
||||
resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-javascript@4.0.2':
|
||||
resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-oniguruma@4.0.2':
|
||||
resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/langs@4.0.2':
|
||||
resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/primitive@4.0.2':
|
||||
resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/themes@4.0.2':
|
||||
resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/types@4.0.2':
|
||||
resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2':
|
||||
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
||||
|
||||
'@tanstack/query-core@5.90.20':
|
||||
resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==}
|
||||
|
||||
@@ -1411,6 +1447,9 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
|
||||
|
||||
hast-util-to-jsx-runtime@2.3.6:
|
||||
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
|
||||
|
||||
@@ -1420,6 +1459,9 @@ packages:
|
||||
html-url-attributes@3.0.1:
|
||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
inline-style-parser@0.2.7:
|
||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||
|
||||
@@ -1681,6 +1723,12 @@ packages:
|
||||
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
oniguruma-parser@0.12.1:
|
||||
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
|
||||
|
||||
oniguruma-to-es@4.3.5:
|
||||
resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==}
|
||||
|
||||
parse-entities@4.0.2:
|
||||
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
|
||||
|
||||
@@ -1830,6 +1878,15 @@ packages:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||
|
||||
regex-utilities@2.3.0:
|
||||
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
|
||||
|
||||
regex@6.1.0:
|
||||
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
|
||||
|
||||
remark-breaks@4.0.0:
|
||||
resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==}
|
||||
|
||||
@@ -1872,6 +1929,10 @@ packages:
|
||||
shallow-equal@3.1.0:
|
||||
resolution: {integrity: sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==}
|
||||
|
||||
shiki@4.0.2:
|
||||
resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2821,6 +2882,46 @@ snapshots:
|
||||
'@noble/hashes': 2.0.1
|
||||
'@scure/base': 2.0.0
|
||||
|
||||
'@shikijs/core@4.0.2':
|
||||
dependencies:
|
||||
'@shikijs/primitive': 4.0.2
|
||||
'@shikijs/types': 4.0.2
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/engine-javascript@4.0.2':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.0.2
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
oniguruma-to-es: 4.3.5
|
||||
|
||||
'@shikijs/engine-oniguruma@4.0.2':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.0.2
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@shikijs/langs@4.0.2':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.0.2
|
||||
|
||||
'@shikijs/primitive@4.0.2':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.0.2
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/themes@4.0.2':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.0.2
|
||||
|
||||
'@shikijs/types@4.0.2':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2': {}
|
||||
|
||||
'@tanstack/query-core@5.90.20': {}
|
||||
|
||||
'@tanstack/react-query@5.90.21(react@19.2.4)':
|
||||
@@ -3155,6 +3256,20 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/unist': 3.0.3
|
||||
ccount: 2.0.1
|
||||
comma-separated-tokens: 2.0.3
|
||||
hast-util-whitespace: 3.0.0
|
||||
html-void-elements: 3.0.0
|
||||
mdast-util-to-hast: 13.2.1
|
||||
property-information: 7.1.0
|
||||
space-separated-tokens: 2.0.2
|
||||
stringify-entities: 4.0.4
|
||||
zwitch: 2.0.4
|
||||
|
||||
hast-util-to-jsx-runtime@2.3.6:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -3181,6 +3296,8 @@ snapshots:
|
||||
|
||||
html-url-attributes@3.0.1: {}
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
|
||||
is-alphabetical@2.0.1: {}
|
||||
@@ -3630,6 +3747,14 @@ snapshots:
|
||||
|
||||
object-hash@3.0.0: {}
|
||||
|
||||
oniguruma-parser@0.12.1: {}
|
||||
|
||||
oniguruma-to-es@4.3.5:
|
||||
dependencies:
|
||||
oniguruma-parser: 0.12.1
|
||||
regex: 6.1.0
|
||||
regex-recursion: 6.0.2
|
||||
|
||||
parse-entities@4.0.2:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
@@ -3774,6 +3899,16 @@ snapshots:
|
||||
dependencies:
|
||||
picomatch: 2.3.2
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
regex-utilities@2.3.0: {}
|
||||
|
||||
regex@6.1.0:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
remark-breaks@4.0.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -3863,6 +3998,17 @@ snapshots:
|
||||
|
||||
shallow-equal@3.1.0: {}
|
||||
|
||||
shiki@4.0.2:
|
||||
dependencies:
|
||||
'@shikijs/core': 4.0.2
|
||||
'@shikijs/engine-javascript': 4.0.2
|
||||
'@shikijs/engine-oniguruma': 4.0.2
|
||||
'@shikijs/langs': 4.0.2
|
||||
'@shikijs/themes': 4.0.2
|
||||
'@shikijs/types': 4.0.2
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
space-separated-tokens@2.0.2: {}
|
||||
|
||||
@@ -243,13 +243,11 @@ export function AddTeamToChannelDialog({
|
||||
{providerWarnings.length > 0
|
||||
? providerWarnings.map((warning) => (
|
||||
<div
|
||||
className="flex gap-3 rounded-2xl border border-amber-400/60 bg-amber-50/60 px-4 py-3 dark:border-amber-500/40 dark:bg-amber-950/30"
|
||||
className="flex gap-3 rounded-2xl border border-warning/30 bg-warning-bg px-4 py-3"
|
||||
key={warning}
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||
{warning}
|
||||
</p>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||||
<p className="text-sm text-warning">{warning}</p>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
|
||||
@@ -421,9 +421,9 @@ export function CreateAgentDialog({
|
||||
{/* Provider mode: trust warning + config fields */}
|
||||
{isProviderMode && selectedBackendProvider ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3 rounded-2xl border border-amber-400/60 bg-amber-50/60 px-4 py-3 dark:border-amber-500/40 dark:bg-amber-950/30">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||
<div className="flex gap-3 rounded-2xl border border-warning/30 bg-warning-bg px-4 py-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||||
<p className="text-sm text-warning">
|
||||
This provider at{" "}
|
||||
<span className="font-mono font-medium">
|
||||
{selectedBackendProvider.binaryPath}
|
||||
|
||||
@@ -141,9 +141,7 @@ export function ModelPicker({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{needsRestart ? (
|
||||
<span className="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
restart to apply
|
||||
</span>
|
||||
<span className="text-[10px] text-warning">restart to apply</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -72,7 +72,7 @@ export function SecretRevealDialog({
|
||||
) : null}
|
||||
|
||||
{created.profileSyncError ? (
|
||||
<p className="rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
|
||||
<p className="rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-warning">
|
||||
{created.profileSyncError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -432,9 +432,9 @@ export function AddChannelBotDialog({
|
||||
) : null}
|
||||
|
||||
{isProviderMode && selectedBackendProvider ? (
|
||||
<div className="flex gap-3 rounded-2xl border border-amber-400/60 bg-amber-50/60 px-4 py-3 dark:border-amber-500/40 dark:bg-amber-950/30">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||
<div className="flex gap-3 rounded-2xl border border-warning/30 bg-warning-bg px-4 py-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||||
<p className="text-sm text-warning">
|
||||
This provider at{" "}
|
||||
<span className="font-mono font-medium">
|
||||
{selectedBackendProvider.binaryPath}
|
||||
@@ -558,13 +558,11 @@ export function AddChannelBotDialog({
|
||||
{providerWarnings.length > 0
|
||||
? providerWarnings.map((warning) => (
|
||||
<div
|
||||
className="flex gap-3 rounded-2xl border border-amber-400/60 bg-amber-50/60 px-4 py-3 dark:border-amber-500/40 dark:bg-amber-950/30"
|
||||
className="flex gap-3 rounded-2xl border border-warning/30 bg-warning-bg px-4 py-3"
|
||||
key={warning}
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||
{warning}
|
||||
</p>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||||
<p className="text-sm text-warning">{warning}</p>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function DiffMessage({
|
||||
|
||||
{/* Truncation warning */}
|
||||
{truncated && (
|
||||
<div className="px-3 py-2 border-t border-border/50 bg-amber-500/10 text-xs text-amber-700 dark:text-amber-400">
|
||||
<div className="px-3 py-2 border-t border-border/50 bg-amber-500/10 text-xs text-warning">
|
||||
Diff truncated.{" "}
|
||||
{safeRepoUrl && commitUrl ? (
|
||||
<a
|
||||
|
||||
@@ -38,8 +38,8 @@ function FileChangeBadge({
|
||||
className={cn(
|
||||
"rounded-md px-1.5 py-0.5 font-mono text-[10px] font-semibold",
|
||||
tone === "positive"
|
||||
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: "bg-rose-500/10 text-rose-700 dark:text-rose-300",
|
||||
? "bg-emerald-500/10 text-status-added"
|
||||
: "bg-rose-500/10 text-status-deleted",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
|
||||
@@ -20,9 +20,9 @@ import { Input } from "@/shared/ui/input";
|
||||
|
||||
function StatusIcon({ available }: { available: boolean }) {
|
||||
return available ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
<CheckCircle2 className="h-4 w-4 text-status-added" />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,9 +62,7 @@ function CommandCheckRow({
|
||||
<p
|
||||
className={cn(
|
||||
"mt-2 text-xs",
|
||||
isAvailable
|
||||
? "text-muted-foreground"
|
||||
: "text-amber-700 dark:text-amber-300",
|
||||
isAvailable ? "text-muted-foreground" : "text-warning",
|
||||
)}
|
||||
>
|
||||
{availability?.resolvedPath
|
||||
@@ -260,7 +258,7 @@ export function DoctorSettingsPanel() {
|
||||
</div>
|
||||
|
||||
{hasMissingSproutTools ? (
|
||||
<p className="mt-4 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
|
||||
<p className="mt-4 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-warning">
|
||||
Build the workspace binaries with{" "}
|
||||
<code className="font-mono">
|
||||
cargo build --release --workspace
|
||||
@@ -345,7 +343,7 @@ export function DoctorSettingsPanel() {
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-warning">
|
||||
No known ACP runtime was detected on your PATH yet. You can
|
||||
still use a custom command in Create agent.
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import {
|
||||
BellRing,
|
||||
Check,
|
||||
KeyRound,
|
||||
MonitorCog,
|
||||
Moon,
|
||||
Search,
|
||||
Stethoscope,
|
||||
Sun,
|
||||
UserRound,
|
||||
@@ -14,7 +17,8 @@ import type {
|
||||
} from "@/features/notifications/hooks";
|
||||
import { TokenSettingsCard } from "@/features/tokens/ui/TokenSettingsCard";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useTheme } from "@/shared/theme/ThemeProvider";
|
||||
import { ACCENT_COLORS, useTheme } from "@/shared/theme/ThemeProvider";
|
||||
import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader";
|
||||
import { DoctorSettingsPanel } from "./DoctorSettingsPanel";
|
||||
import { NotificationSettingsCard } from "./NotificationSettingsCard";
|
||||
import { ProfileSettingsCard } from "./ProfileSettingsCard";
|
||||
@@ -47,12 +51,6 @@ export type SettingsPanelProps = {
|
||||
onSetNeedsActionNotificationsEnabled: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
type ThemeOption = {
|
||||
value: "light" | "dark" | "system";
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
export const settingsSections: SettingsSectionDescriptor[] = [
|
||||
{
|
||||
value: "profile",
|
||||
@@ -81,65 +79,117 @@ export const settingsSections: SettingsSectionDescriptor[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const themeOptions: ThemeOption[] = [
|
||||
{
|
||||
value: "light",
|
||||
label: "Light",
|
||||
icon: Sun,
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: "Dark",
|
||||
icon: Moon,
|
||||
},
|
||||
{
|
||||
value: "system",
|
||||
label: "System",
|
||||
icon: MonitorCog,
|
||||
},
|
||||
];
|
||||
function formatThemeLabel(name: string): string {
|
||||
return name
|
||||
.split("-")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function ThemeSettingsCard() {
|
||||
const { setTheme, theme } = useTheme();
|
||||
const { setTheme, themeName, accentColor, setAccentColor } = useTheme();
|
||||
const [search, setSearch] = useState("");
|
||||
const didScrollRef = useRef(false);
|
||||
const activeRef = (node: HTMLButtonElement | null) => {
|
||||
if (node && !didScrollRef.current) {
|
||||
didScrollRef.current = true;
|
||||
node.scrollIntoView({ block: "center" });
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
if (!q) return SYNTAX_THEMES;
|
||||
return SYNTAX_THEMES.filter((name) => name.includes(q));
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="rounded-xl border border-border/80 bg-card/80 p-4 shadow-sm"
|
||||
data-testid="settings-theme"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Appearance</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose how Sprout looks on this device.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-3 min-w-0">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Appearance</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a theme for Sprout. Light and dark mode is auto-detected.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex w-full flex-col gap-1 rounded-xl border border-border/70 bg-background/70 p-1 sm:w-auto sm:flex-row">
|
||||
{themeOptions.map(({ value, label, icon: Icon }) => {
|
||||
const isActive = theme === value;
|
||||
<div className="relative mb-3">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
className="w-full rounded-lg border border-border/70 bg-background/70 py-2 pl-9 pr-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search themes..."
|
||||
type="text"
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto rounded-lg border border-border/70 bg-background/70">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
No themes match your search.
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((name) => {
|
||||
const isActive = themeName === name;
|
||||
const light = isLightTheme(name);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
? "bg-primary/10 text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
data-testid={`theme-option-${value}`}
|
||||
key={value}
|
||||
onClick={() => {
|
||||
setTheme(value);
|
||||
}}
|
||||
data-testid={`theme-option-${name}`}
|
||||
key={name}
|
||||
onClick={() => setTheme(name)}
|
||||
ref={isActive ? activeRef : undefined}
|
||||
type="button"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{label}</span>
|
||||
{light ? (
|
||||
<Sun className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate">
|
||||
{formatThemeLabel(name)}
|
||||
</span>
|
||||
{isActive && (
|
||||
<Check className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<h3 className="mb-2 text-sm font-medium">Accent Color</h3>
|
||||
<div className="flex gap-2">
|
||||
{ACCENT_COLORS.map((color) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-full transition-transform hover:scale-110",
|
||||
accentColor === color.value &&
|
||||
"ring-2 ring-ring ring-offset-2 ring-offset-background",
|
||||
)}
|
||||
data-testid={`accent-color-${color.name.toLowerCase()}`}
|
||||
key={color.value}
|
||||
onClick={() => setAccentColor(color.value)}
|
||||
style={{ backgroundColor: color.value }}
|
||||
title={color.name}
|
||||
type="button"
|
||||
>
|
||||
{accentColor === color.value && (
|
||||
<Check className="h-3.5 w-3.5 text-white" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -52,11 +52,9 @@ function StatusBadge({ status }: { status: "active" | "revoked" | "expired" }) {
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
status === "active" &&
|
||||
"bg-green-500/10 text-green-700 dark:text-green-400",
|
||||
status === "active" && "bg-green-500/10 text-status-added",
|
||||
status === "revoked" && "bg-muted text-muted-foreground",
|
||||
status === "expired" &&
|
||||
"bg-yellow-500/10 text-yellow-700 dark:text-yellow-400",
|
||||
status === "expired" && "bg-yellow-500/10 text-warning",
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
@@ -303,7 +301,7 @@ function CreateTokenDialog({
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
<div className="flex items-start gap-2 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-sm text-warning">
|
||||
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
This is the only time this token will be shown. Store it
|
||||
@@ -532,7 +530,7 @@ function CreateTokenDialog({
|
||||
</div>
|
||||
|
||||
{activeTokenCount >= MAX_ACTIVE_TOKENS ? (
|
||||
<p className="rounded-xl border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
<p className="rounded-xl border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-sm text-warning">
|
||||
You already have {MAX_ACTIVE_TOKENS} active tokens. Revoke one
|
||||
before creating another.
|
||||
</p>
|
||||
@@ -713,7 +711,7 @@ export function TokenSettingsCard({
|
||||
</div>
|
||||
|
||||
{hasReachedTokenLimit ? (
|
||||
<p className="mt-3 rounded-xl border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
<p className="mt-3 rounded-xl border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-sm text-warning">
|
||||
You've reached the active token limit. Revoke an existing token to
|
||||
mint another.
|
||||
</p>
|
||||
|
||||
@@ -25,7 +25,7 @@ const queryClient = new QueryClient({
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider defaultTheme="system">
|
||||
<ThemeProvider defaultTheme="houston">
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -1,85 +1,206 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createThemeVars, hexToHsl } from "./adaptive-theme";
|
||||
import {
|
||||
SYNTAX_THEMES,
|
||||
type SyntaxThemeName,
|
||||
extractThemeInfo,
|
||||
loadThemeData,
|
||||
} from "./theme-loader";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
type ResolvedTheme = "light" | "dark";
|
||||
const STORAGE_KEY = "sprout-theme";
|
||||
const CACHE_KEY = "sprout-theme-cache";
|
||||
const ACCENT_KEY = "sprout-accent-color";
|
||||
|
||||
export const ACCENT_COLORS = [
|
||||
{ name: "Blue", value: "#3b82f6" },
|
||||
{ name: "Cyan", value: "#06b6d4" },
|
||||
{ name: "Green", value: "#22c55e" },
|
||||
{ name: "Orange", value: "#f97316" },
|
||||
{ name: "Red", value: "#ef4444" },
|
||||
{ name: "Pink", value: "#ec4899" },
|
||||
{ name: "Purple", value: "#a855f7" },
|
||||
{ name: "Indigo", value: "#6366f1" },
|
||||
] as const;
|
||||
|
||||
const DEFAULT_ACCENT = "#3b82f6";
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
themeName: string;
|
||||
isDark: boolean;
|
||||
isLoading: boolean;
|
||||
accentColor: string;
|
||||
setTheme: (name: string) => void;
|
||||
setAccentColor: (color: string) => void;
|
||||
};
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
defaultTheme?: SyntaxThemeName;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
function readStoredTheme(storageKey: string, fallback: Theme): Theme {
|
||||
if (typeof window === "undefined") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const value = window.localStorage.getItem(storageKey);
|
||||
return value === "light" || value === "dark" || value === "system"
|
||||
? value
|
||||
: fallback;
|
||||
function isValidThemeName(name: string): name is SyntaxThemeName {
|
||||
return (SYNTAX_THEMES as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
function detectSystemTheme(): ResolvedTheme {
|
||||
if (typeof window === "undefined") {
|
||||
return "light";
|
||||
}
|
||||
/** Read stored theme, migrating legacy "light"/"dark"/"system" values. */
|
||||
function readStoredTheme(fallback: SyntaxThemeName): SyntaxThemeName {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return fallback;
|
||||
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
// Migrate legacy values
|
||||
if (stored === "light") return "catppuccin-latte";
|
||||
if (stored === "dark" || stored === "system") return "houston";
|
||||
|
||||
return isValidThemeName(stored) ? stored : fallback;
|
||||
}
|
||||
|
||||
function applyThemeToRoot(theme: ResolvedTheme) {
|
||||
document.documentElement.classList.remove("light", "dark");
|
||||
document.documentElement.classList.add(theme);
|
||||
function getContrastColor(hex: string): string {
|
||||
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})/i.exec(hex);
|
||||
if (!m) return "#ffffff";
|
||||
const r = parseInt(m[1], 16);
|
||||
const g = parseInt(m[2], 16);
|
||||
const b = parseInt(m[3], 16);
|
||||
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return lum > 0.5 ? "#000000" : "#ffffff";
|
||||
}
|
||||
|
||||
function applyAccentColor(hex: string) {
|
||||
const root = document.documentElement;
|
||||
const accentHsl = hexToHsl(hex);
|
||||
const fgHsl = hexToHsl(getContrastColor(hex));
|
||||
root.style.setProperty("--primary", accentHsl);
|
||||
root.style.setProperty("--primary-foreground", fgHsl);
|
||||
root.style.setProperty("--sidebar-primary", accentHsl);
|
||||
root.style.setProperty("--sidebar-primary-foreground", fgHsl);
|
||||
}
|
||||
|
||||
/** Apply cached CSS vars synchronously to prevent FOUC. */
|
||||
function applyCachedVars(): string | null {
|
||||
try {
|
||||
const cached = window.localStorage.getItem(CACHE_KEY);
|
||||
if (!cached) return null;
|
||||
const { themeName, vars, isDark } = JSON.parse(cached);
|
||||
const root = document.documentElement;
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
root.style.setProperty(key, value as string);
|
||||
}
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(isDark ? "dark" : "light");
|
||||
|
||||
// Also apply cached accent
|
||||
const accent = window.localStorage.getItem(ACCENT_KEY) ?? DEFAULT_ACCENT;
|
||||
applyAccentColor(accent);
|
||||
|
||||
return themeName;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a theme: load data, derive CSS vars, set them on :root. */
|
||||
async function applyTheme(name: SyntaxThemeName): Promise<{ isDark: boolean }> {
|
||||
const themeData = await loadThemeData(name);
|
||||
const info = extractThemeInfo(name, themeData);
|
||||
const { isDark, vars } = createThemeVars(info.bg, info.fg, info.comment, {
|
||||
added: info.added,
|
||||
deleted: info.deleted,
|
||||
modified: info.modified,
|
||||
});
|
||||
|
||||
const root = document.documentElement;
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
root.style.setProperty(key, value);
|
||||
}
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(isDark ? "dark" : "light");
|
||||
|
||||
// Cache for FOUC prevention
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
CACHE_KEY,
|
||||
JSON.stringify({ themeName: name, vars, isDark }),
|
||||
);
|
||||
} catch {
|
||||
// Storage full — non-critical
|
||||
}
|
||||
|
||||
return { isDark };
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "sprout-theme",
|
||||
defaultTheme = "houston",
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setThemeState] = useState<Theme>(() =>
|
||||
readStoredTheme(storageKey, defaultTheme),
|
||||
);
|
||||
const [systemTheme, setSystemTheme] =
|
||||
useState<ResolvedTheme>(detectSystemTheme);
|
||||
// Apply cached vars synchronously before first render
|
||||
const [themeName, setThemeName] = useState<string>(() => {
|
||||
const cached = applyCachedVars();
|
||||
return cached ?? readStoredTheme(defaultTheme);
|
||||
});
|
||||
const [isDark, setIsDark] = useState<boolean>(() => {
|
||||
return document.documentElement.classList.contains("dark");
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const loadingRef = useRef<string | null>(null);
|
||||
const [accentColor, setAccentColorState] = useState<string>(() => {
|
||||
return window.localStorage.getItem(ACCENT_KEY) ?? DEFAULT_ACCENT;
|
||||
});
|
||||
|
||||
// Load and apply theme
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const onChange = (event: MediaQueryListEvent) =>
|
||||
setSystemTheme(event.matches ? "dark" : "light");
|
||||
if (!isValidThemeName(themeName)) return;
|
||||
|
||||
mediaQuery.addEventListener("change", onChange);
|
||||
return () => mediaQuery.removeEventListener("change", onChange);
|
||||
// Track which theme we're loading to avoid race conditions
|
||||
const thisTheme = themeName;
|
||||
loadingRef.current = thisTheme;
|
||||
setIsLoading(true);
|
||||
|
||||
applyTheme(themeName).then(({ isDark: dark }) => {
|
||||
// Only update if this is still the theme we want
|
||||
if (loadingRef.current === thisTheme) {
|
||||
setIsDark(dark);
|
||||
setIsLoading(false);
|
||||
// Re-apply accent after theme load (theme vars don't include primary)
|
||||
applyAccentColor(
|
||||
window.localStorage.getItem(ACCENT_KEY) ?? DEFAULT_ACCENT,
|
||||
);
|
||||
}
|
||||
});
|
||||
}, [themeName]);
|
||||
|
||||
// Apply accent color changes
|
||||
useEffect(() => {
|
||||
applyAccentColor(accentColor);
|
||||
}, [accentColor]);
|
||||
|
||||
const setTheme = useCallback((name: string) => {
|
||||
if (!isValidThemeName(name)) return;
|
||||
setThemeName(name);
|
||||
window.localStorage.setItem(STORAGE_KEY, name);
|
||||
}, []);
|
||||
|
||||
const resolvedTheme = theme === "system" ? systemTheme : theme;
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeToRoot(resolvedTheme);
|
||||
window.localStorage.setItem(storageKey, theme);
|
||||
}, [resolvedTheme, storageKey, theme]);
|
||||
const setAccentColor = useCallback((color: string) => {
|
||||
window.localStorage.setItem(ACCENT_KEY, color);
|
||||
setAccentColorState(color);
|
||||
}, []);
|
||||
|
||||
const value: ThemeContextValue = {
|
||||
theme,
|
||||
resolvedTheme,
|
||||
setTheme: setThemeState,
|
||||
themeName,
|
||||
isDark,
|
||||
isLoading,
|
||||
accentColor,
|
||||
setTheme,
|
||||
setAccentColor,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Adaptive Theme Engine
|
||||
*
|
||||
* Derives shadcn CSS variables from a syntax theme's key colors (bg, fg, comment, git).
|
||||
* Detects light vs dark from background luminance and adjusts accordingly.
|
||||
*
|
||||
* Ported from builderbot/apps/staged/src/lib/theme.ts, flattened to emit
|
||||
* shadcn CSS vars directly (no intermediate Theme object).
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Color Utilities
|
||||
// =============================================================================
|
||||
|
||||
interface RGB {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): RGB {
|
||||
const long = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(
|
||||
hex,
|
||||
);
|
||||
if (long) {
|
||||
return {
|
||||
r: parseInt(long[1], 16),
|
||||
g: parseInt(long[2], 16),
|
||||
b: parseInt(long[3], 16),
|
||||
};
|
||||
}
|
||||
|
||||
const short = /^#?([a-f\d])([a-f\d])([a-f\d])([a-f\d])?$/i.exec(hex);
|
||||
if (short) {
|
||||
return {
|
||||
r: parseInt(short[1] + short[1], 16),
|
||||
g: parseInt(short[2] + short[2], 16),
|
||||
b: parseInt(short[3] + short[3], 16),
|
||||
};
|
||||
}
|
||||
|
||||
return { r: 128, g: 128, b: 128 };
|
||||
}
|
||||
|
||||
function rgbToHex({ r, g, b }: RGB): string {
|
||||
const clamp = (n: number) => Math.max(0, Math.min(255, Math.round(n)));
|
||||
return `#${[r, g, b].map((c) => clamp(c).toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
export function luminance(hex: string): number {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
const [rs, gs, bs] = [r, g, b].map((c) => {
|
||||
const s = c / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
||||
}
|
||||
|
||||
function mix(hex1: string, hex2: string, factor: number): string {
|
||||
const c1 = hexToRgb(hex1);
|
||||
const c2 = hexToRgb(hex2);
|
||||
return rgbToHex({
|
||||
r: c1.r + (c2.r - c1.r) * factor,
|
||||
g: c1.g + (c2.g - c1.g) * factor,
|
||||
b: c1.b + (c2.b - c1.b) * factor,
|
||||
});
|
||||
}
|
||||
|
||||
function adjust(hex: string, amount: number): string {
|
||||
const target = amount > 0 ? "#ffffff" : "#000000";
|
||||
return mix(hex, target, Math.abs(amount));
|
||||
}
|
||||
|
||||
function overlay(hex: string, alpha: number): string {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Chrome Color Calculation
|
||||
// =============================================================================
|
||||
|
||||
const CONTRAST_VALUE = 0.035;
|
||||
const CONTRAST_OFFSET = 0.0135;
|
||||
|
||||
function calculateLumDiff(bgLum: number): number {
|
||||
return CONTRAST_VALUE * Math.log(1 + (bgLum + CONTRAST_OFFSET) * 10);
|
||||
}
|
||||
|
||||
function findColorWithLuminance(baseColor: string, targetLum: number): string {
|
||||
const baseLum = luminance(baseColor);
|
||||
if (Math.abs(baseLum - targetLum) < 0.001) return baseColor;
|
||||
|
||||
const target = targetLum < baseLum ? "#000000" : "#ffffff";
|
||||
let lo = 0;
|
||||
let hi = 1;
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
const testLum = luminance(mix(baseColor, target, mid));
|
||||
const diff = testLum - targetLum;
|
||||
|
||||
if (Math.abs(diff) < 0.001) break;
|
||||
|
||||
if (target === "#000000") {
|
||||
if (testLum > targetLum) lo = mid;
|
||||
else hi = mid;
|
||||
} else {
|
||||
if (testLum < targetLum) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
}
|
||||
return mix(baseColor, target, (lo + hi) / 2);
|
||||
}
|
||||
|
||||
function calculateChromeColors(syntaxBg: string): {
|
||||
chrome: string;
|
||||
primary: string;
|
||||
} {
|
||||
const bgLum = luminance(syntaxBg);
|
||||
const lumDiff = calculateLumDiff(bgLum);
|
||||
const targetChromeLum = bgLum - lumDiff;
|
||||
|
||||
if (targetChromeLum >= 0) {
|
||||
return {
|
||||
chrome: findColorWithLuminance(syntaxBg, targetChromeLum),
|
||||
primary: syntaxBg,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
chrome: findColorWithLuminance(syntaxBg, 0),
|
||||
primary: findColorWithLuminance(syntaxBg, lumDiff),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hex → HSL component format ("H S% L%") for Tailwind's hexToHsl() wrappers
|
||||
// =============================================================================
|
||||
|
||||
export function hexToHsl(hex: string): string {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
const rn = r / 255;
|
||||
const gn = g / 255;
|
||||
const bn = b / 255;
|
||||
|
||||
const max = Math.max(rn, gn, bn);
|
||||
const min = Math.min(rn, gn, bn);
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max === min) {
|
||||
return `0 0% ${(l * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
let h: number;
|
||||
if (max === rn) {
|
||||
h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
|
||||
} else if (max === gn) {
|
||||
h = ((bn - rn) / d + 2) / 6;
|
||||
} else {
|
||||
h = ((rn - gn) / d + 4) / 6;
|
||||
}
|
||||
|
||||
return `${(h * 360).toFixed(1)} ${(s * 100).toFixed(2)}% ${(l * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Adaptive Theme Generator — emits shadcn CSS vars directly
|
||||
// =============================================================================
|
||||
|
||||
export interface ThemeGitColors {
|
||||
added: string | null;
|
||||
deleted: string | null;
|
||||
modified: string | null;
|
||||
}
|
||||
|
||||
export interface ThemeResult {
|
||||
isDark: boolean;
|
||||
vars: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a full set of shadcn CSS variables from syntax theme colors.
|
||||
*
|
||||
* Takes bg, fg, comment hex colors (+ optional git decoration colors) and
|
||||
* returns the var map ready to apply via style.setProperty().
|
||||
*/
|
||||
export function createThemeVars(
|
||||
syntaxBg: string,
|
||||
syntaxFg: string,
|
||||
syntaxComment: string,
|
||||
gitColors?: ThemeGitColors,
|
||||
): ThemeResult {
|
||||
const isDark = luminance(syntaxBg) < 0.5;
|
||||
|
||||
const { chrome: chromeColor, primary: primaryBg } =
|
||||
calculateChromeColors(syntaxBg);
|
||||
|
||||
const dir = isDark ? 1 : -1;
|
||||
const elevate = (amount: number) => adjust(primaryBg, dir * amount);
|
||||
|
||||
// Git/accent colors with fallbacks
|
||||
const fallbackGreen = isDark ? "#3fb950" : "#1a7f37";
|
||||
const fallbackRed = isDark ? "#f85149" : "#cf222e";
|
||||
const fallbackOrange = isDark ? "#d29922" : "#9a6700";
|
||||
|
||||
const accentGreen = gitColors?.added ?? fallbackGreen;
|
||||
const accentRed = gitColors?.deleted ?? fallbackRed;
|
||||
const accentOrange = fallbackOrange;
|
||||
|
||||
// Derived colors
|
||||
const borderColor = mix(primaryBg, syntaxFg, isDark ? 0.15 : 0.12);
|
||||
const hoverBg = elevate(0.06);
|
||||
const primaryFg = hexToHsl(primaryBg);
|
||||
const textFg = hexToHsl(syntaxFg);
|
||||
|
||||
return {
|
||||
isDark,
|
||||
vars: {
|
||||
// Backgrounds
|
||||
"--background": hexToHsl(primaryBg),
|
||||
"--card": hexToHsl(primaryBg),
|
||||
"--popover": hexToHsl(elevate(0.08)),
|
||||
"--muted": hexToHsl(hoverBg),
|
||||
"--accent": hexToHsl(hoverBg),
|
||||
"--secondary": hexToHsl(hoverBg),
|
||||
|
||||
// Foregrounds
|
||||
"--foreground": textFg,
|
||||
"--card-foreground": textFg,
|
||||
"--popover-foreground": textFg,
|
||||
"--muted-foreground": hexToHsl(syntaxComment),
|
||||
"--accent-foreground": textFg,
|
||||
"--secondary-foreground": textFg,
|
||||
|
||||
// Destructive
|
||||
"--destructive": hexToHsl(accentRed),
|
||||
"--destructive-foreground": primaryFg,
|
||||
|
||||
// Borders
|
||||
"--border": hexToHsl(borderColor),
|
||||
"--input": hexToHsl(borderColor),
|
||||
"--ring": textFg,
|
||||
|
||||
// Sidebar
|
||||
"--sidebar-background": hexToHsl(chromeColor),
|
||||
"--sidebar-foreground": textFg,
|
||||
"--sidebar-accent": hexToHsl(primaryBg),
|
||||
"--sidebar-accent-foreground": textFg,
|
||||
"--sidebar-border": hexToHsl(borderColor),
|
||||
"--sidebar-ring": hexToHsl(borderColor),
|
||||
|
||||
// Status colors (hex — used directly via var())
|
||||
"--status-added": accentGreen,
|
||||
"--status-deleted": accentRed,
|
||||
"--status-modified": accentOrange,
|
||||
|
||||
// Warning
|
||||
"--ui-warning": accentOrange,
|
||||
"--ui-warning-bg": overlay(accentOrange, isDark ? 0.1 : 0.08),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Theme Loader
|
||||
*
|
||||
* Loads Shiki theme JSON files and extracts key colors (bg, fg, comment, git).
|
||||
* Only imports the theme JSON — the Shiki highlighter engine is not used here.
|
||||
*/
|
||||
|
||||
import type { ThemeRegistrationRaw } from "shiki";
|
||||
|
||||
// Available syntax themes (all Shiki bundled themes, alphabetically sorted)
|
||||
export const SYNTAX_THEMES = [
|
||||
"andromeeda",
|
||||
"aurora-x",
|
||||
"ayu-dark",
|
||||
"catppuccin-frappe",
|
||||
"catppuccin-latte",
|
||||
"catppuccin-macchiato",
|
||||
"catppuccin-mocha",
|
||||
"dark-plus",
|
||||
"dracula",
|
||||
"dracula-soft",
|
||||
"everforest-dark",
|
||||
"everforest-light",
|
||||
"github-dark",
|
||||
"github-dark-default",
|
||||
"github-dark-dimmed",
|
||||
"github-dark-high-contrast",
|
||||
"github-light",
|
||||
"github-light-default",
|
||||
"github-light-high-contrast",
|
||||
"gruvbox-dark-hard",
|
||||
"gruvbox-dark-medium",
|
||||
"gruvbox-dark-soft",
|
||||
"gruvbox-light-hard",
|
||||
"gruvbox-light-medium",
|
||||
"gruvbox-light-soft",
|
||||
"houston",
|
||||
"kanagawa-dragon",
|
||||
"kanagawa-lotus",
|
||||
"kanagawa-wave",
|
||||
"laserwave",
|
||||
"light-plus",
|
||||
"material-theme",
|
||||
"material-theme-darker",
|
||||
"material-theme-lighter",
|
||||
"material-theme-ocean",
|
||||
"material-theme-palenight",
|
||||
"min-dark",
|
||||
"min-light",
|
||||
"monokai",
|
||||
"night-owl",
|
||||
"nord",
|
||||
"one-dark-pro",
|
||||
"one-light",
|
||||
"plastic",
|
||||
"poimandres",
|
||||
"red",
|
||||
"rose-pine",
|
||||
"rose-pine-dawn",
|
||||
"rose-pine-moon",
|
||||
"slack-dark",
|
||||
"slack-ochin",
|
||||
"snazzy-light",
|
||||
"solarized-dark",
|
||||
"solarized-light",
|
||||
"synthwave-84",
|
||||
"tokyo-night",
|
||||
"vesper",
|
||||
"vitesse-black",
|
||||
"vitesse-dark",
|
||||
"vitesse-light",
|
||||
] as const;
|
||||
|
||||
export type SyntaxThemeName = (typeof SYNTAX_THEMES)[number];
|
||||
|
||||
// Known light themes — used by the theme picker to show sun/moon icons
|
||||
// for themes that haven't been loaded yet.
|
||||
export const LIGHT_THEMES: ReadonlySet<SyntaxThemeName> = new Set([
|
||||
"catppuccin-latte",
|
||||
"everforest-light",
|
||||
"github-light",
|
||||
"github-light-default",
|
||||
"github-light-high-contrast",
|
||||
"gruvbox-light-hard",
|
||||
"gruvbox-light-medium",
|
||||
"gruvbox-light-soft",
|
||||
"kanagawa-lotus",
|
||||
"light-plus",
|
||||
"material-theme-lighter",
|
||||
"min-light",
|
||||
"one-light",
|
||||
"rose-pine-dawn",
|
||||
"slack-ochin",
|
||||
"snazzy-light",
|
||||
"solarized-light",
|
||||
"vitesse-light",
|
||||
]);
|
||||
|
||||
// Static theme imports (Vite needs static strings for tree-shaking)
|
||||
const themeImports: Record<
|
||||
SyntaxThemeName,
|
||||
() => Promise<{ default: ThemeRegistrationRaw }>
|
||||
> = {
|
||||
andromeeda: () => import("shiki/themes/andromeeda.mjs"),
|
||||
"aurora-x": () => import("shiki/themes/aurora-x.mjs"),
|
||||
"ayu-dark": () => import("shiki/themes/ayu-dark.mjs"),
|
||||
"catppuccin-frappe": () => import("shiki/themes/catppuccin-frappe.mjs"),
|
||||
"catppuccin-latte": () => import("shiki/themes/catppuccin-latte.mjs"),
|
||||
"catppuccin-macchiato": () => import("shiki/themes/catppuccin-macchiato.mjs"),
|
||||
"catppuccin-mocha": () => import("shiki/themes/catppuccin-mocha.mjs"),
|
||||
"dark-plus": () => import("shiki/themes/dark-plus.mjs"),
|
||||
dracula: () => import("shiki/themes/dracula.mjs"),
|
||||
"dracula-soft": () => import("shiki/themes/dracula-soft.mjs"),
|
||||
"everforest-dark": () => import("shiki/themes/everforest-dark.mjs"),
|
||||
"everforest-light": () => import("shiki/themes/everforest-light.mjs"),
|
||||
"github-dark": () => import("shiki/themes/github-dark.mjs"),
|
||||
"github-dark-default": () => import("shiki/themes/github-dark-default.mjs"),
|
||||
"github-dark-dimmed": () => import("shiki/themes/github-dark-dimmed.mjs"),
|
||||
"github-dark-high-contrast": () =>
|
||||
import("shiki/themes/github-dark-high-contrast.mjs"),
|
||||
"github-light": () => import("shiki/themes/github-light.mjs"),
|
||||
"github-light-default": () => import("shiki/themes/github-light-default.mjs"),
|
||||
"github-light-high-contrast": () =>
|
||||
import("shiki/themes/github-light-high-contrast.mjs"),
|
||||
"gruvbox-dark-hard": () => import("shiki/themes/gruvbox-dark-hard.mjs"),
|
||||
"gruvbox-dark-medium": () => import("shiki/themes/gruvbox-dark-medium.mjs"),
|
||||
"gruvbox-dark-soft": () => import("shiki/themes/gruvbox-dark-soft.mjs"),
|
||||
"gruvbox-light-hard": () => import("shiki/themes/gruvbox-light-hard.mjs"),
|
||||
"gruvbox-light-medium": () => import("shiki/themes/gruvbox-light-medium.mjs"),
|
||||
"gruvbox-light-soft": () => import("shiki/themes/gruvbox-light-soft.mjs"),
|
||||
houston: () => import("shiki/themes/houston.mjs"),
|
||||
"kanagawa-dragon": () => import("shiki/themes/kanagawa-dragon.mjs"),
|
||||
"kanagawa-lotus": () => import("shiki/themes/kanagawa-lotus.mjs"),
|
||||
"kanagawa-wave": () => import("shiki/themes/kanagawa-wave.mjs"),
|
||||
laserwave: () => import("shiki/themes/laserwave.mjs"),
|
||||
"light-plus": () => import("shiki/themes/light-plus.mjs"),
|
||||
"material-theme": () => import("shiki/themes/material-theme.mjs"),
|
||||
"material-theme-darker": () =>
|
||||
import("shiki/themes/material-theme-darker.mjs"),
|
||||
"material-theme-lighter": () =>
|
||||
import("shiki/themes/material-theme-lighter.mjs"),
|
||||
"material-theme-ocean": () => import("shiki/themes/material-theme-ocean.mjs"),
|
||||
"material-theme-palenight": () =>
|
||||
import("shiki/themes/material-theme-palenight.mjs"),
|
||||
"min-dark": () => import("shiki/themes/min-dark.mjs"),
|
||||
"min-light": () => import("shiki/themes/min-light.mjs"),
|
||||
monokai: () => import("shiki/themes/monokai.mjs"),
|
||||
"night-owl": () => import("shiki/themes/night-owl.mjs"),
|
||||
nord: () => import("shiki/themes/nord.mjs"),
|
||||
"one-dark-pro": () => import("shiki/themes/one-dark-pro.mjs"),
|
||||
"one-light": () => import("shiki/themes/one-light.mjs"),
|
||||
plastic: () => import("shiki/themes/plastic.mjs"),
|
||||
poimandres: () => import("shiki/themes/poimandres.mjs"),
|
||||
red: () => import("shiki/themes/red.mjs"),
|
||||
"rose-pine": () => import("shiki/themes/rose-pine.mjs"),
|
||||
"rose-pine-dawn": () => import("shiki/themes/rose-pine-dawn.mjs"),
|
||||
"rose-pine-moon": () => import("shiki/themes/rose-pine-moon.mjs"),
|
||||
"slack-dark": () => import("shiki/themes/slack-dark.mjs"),
|
||||
"slack-ochin": () => import("shiki/themes/slack-ochin.mjs"),
|
||||
"snazzy-light": () => import("shiki/themes/snazzy-light.mjs"),
|
||||
"solarized-dark": () => import("shiki/themes/solarized-dark.mjs"),
|
||||
"solarized-light": () => import("shiki/themes/solarized-light.mjs"),
|
||||
"synthwave-84": () => import("shiki/themes/synthwave-84.mjs"),
|
||||
"tokyo-night": () => import("shiki/themes/tokyo-night.mjs"),
|
||||
vesper: () => import("shiki/themes/vesper.mjs"),
|
||||
"vitesse-black": () => import("shiki/themes/vitesse-black.mjs"),
|
||||
"vitesse-dark": () => import("shiki/themes/vitesse-dark.mjs"),
|
||||
"vitesse-light": () => import("shiki/themes/vitesse-light.mjs"),
|
||||
};
|
||||
|
||||
export function isLightTheme(name: string): boolean {
|
||||
return LIGHT_THEMES.has(name as SyntaxThemeName);
|
||||
}
|
||||
|
||||
// Theme settings type from Shiki
|
||||
interface ThemeSetting {
|
||||
scope?: string | string[];
|
||||
settings?: { foreground?: string };
|
||||
}
|
||||
|
||||
function extractCommentColor(
|
||||
settings: ReadonlyArray<ThemeSetting> | undefined,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (!settings) return fallback;
|
||||
|
||||
for (const setting of settings) {
|
||||
if (!setting.scope || !setting.settings?.foreground) continue;
|
||||
const scopes = Array.isArray(setting.scope)
|
||||
? setting.scope
|
||||
: [setting.scope];
|
||||
if (scopes.includes("comment")) {
|
||||
return setting.settings.foreground;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function stripAlpha(color: string): string {
|
||||
if (color.length === 9 && color.startsWith("#")) {
|
||||
return color.slice(0, 7);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
function extractGitColors(colors: Record<string, string> | undefined): {
|
||||
added: string | null;
|
||||
deleted: string | null;
|
||||
modified: string | null;
|
||||
} {
|
||||
if (!colors) {
|
||||
return { added: null, deleted: null, modified: null };
|
||||
}
|
||||
|
||||
const addedKeys = [
|
||||
"gitDecoration.addedResourceForeground",
|
||||
"editorGutter.addedBackground",
|
||||
"diffEditor.insertedTextBackground",
|
||||
];
|
||||
const deletedKeys = [
|
||||
"gitDecoration.deletedResourceForeground",
|
||||
"editorGutter.deletedBackground",
|
||||
"diffEditor.removedTextBackground",
|
||||
];
|
||||
const modifiedKeys = [
|
||||
"gitDecoration.modifiedResourceForeground",
|
||||
"editorGutter.modifiedBackground",
|
||||
];
|
||||
|
||||
const findColor = (keys: string[]): string | null => {
|
||||
for (const key of keys) {
|
||||
const value = colors[key];
|
||||
if (value) return stripAlpha(value);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
added: findColor(addedKeys),
|
||||
deleted: findColor(deletedKeys),
|
||||
modified: findColor(modifiedKeys),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ThemeInfo {
|
||||
name: string;
|
||||
bg: string;
|
||||
fg: string;
|
||||
comment: string;
|
||||
added: string | null;
|
||||
deleted: string | null;
|
||||
modified: string | null;
|
||||
}
|
||||
|
||||
export function extractThemeInfo(
|
||||
themeName: string,
|
||||
theme: ThemeRegistrationRaw,
|
||||
): ThemeInfo {
|
||||
const bg =
|
||||
(theme.colors?.["editor.background"] as string | undefined) || "#1e1e1e";
|
||||
const fg =
|
||||
(theme.colors?.["editor.foreground"] as string | undefined) || "#d4d4d4";
|
||||
const gitColors = extractGitColors(
|
||||
theme.colors as Record<string, string> | undefined,
|
||||
);
|
||||
return {
|
||||
name: themeName,
|
||||
bg,
|
||||
fg,
|
||||
comment: extractCommentColor(
|
||||
theme.settings as ReadonlyArray<ThemeSetting> | undefined,
|
||||
fg,
|
||||
),
|
||||
...gitColors,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadThemeData(
|
||||
name: SyntaxThemeName,
|
||||
): Promise<ThemeRegistrationRaw> {
|
||||
const loader = themeImports[name];
|
||||
const { default: theme } = await loader();
|
||||
return theme;
|
||||
}
|
||||
@@ -55,6 +55,15 @@ export default {
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
ring: "hsl(var(--sidebar-ring))",
|
||||
},
|
||||
status: {
|
||||
added: "var(--status-added)",
|
||||
deleted: "var(--status-deleted)",
|
||||
modified: "var(--status-modified)",
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: "var(--ui-warning)",
|
||||
bg: "var(--ui-warning-bg)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -183,7 +183,45 @@ test("opens settings with the keyboard shortcut and updates theme", async ({
|
||||
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
await page.getByTestId("settings-nav-appearance").click();
|
||||
await page.getByTestId("theme-option-dark").click();
|
||||
|
||||
// Default theme is catppuccin-macchiato (dark)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => document.documentElement.classList.contains("dark")),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// Switch to a light theme — verifies dark→light transition
|
||||
await page.getByTestId("theme-option-github-light").click();
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => document.documentElement.classList.contains("light")),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => document.documentElement.classList.contains("dark")),
|
||||
)
|
||||
.toBe(false);
|
||||
|
||||
// CSS variables are set on the root element (the real theming mechanism)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
document.documentElement.style.getPropertyValue("--background").trim(),
|
||||
),
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
// Theme name persists in localStorage
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => localStorage.getItem("sprout-theme")))
|
||||
.toBe("github-light");
|
||||
|
||||
// Switch back to a dark theme — verifies light→dark transition
|
||||
await page.getByTestId("theme-option-dracula").click();
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
@@ -191,6 +229,11 @@ test("opens settings with the keyboard shortcut and updates theme", async ({
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => localStorage.getItem("sprout-theme")))
|
||||
.toBe("dracula");
|
||||
|
||||
// Close settings with keyboard shortcut
|
||||
await page.keyboard.press(
|
||||
process.platform === "darwin" ? "Meta+," : "Control+,",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user