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:
Taylor Ho
2026-04-07 21:54:50 -10:00
co-authored by Claude Opus 4.6
parent c0421a50ed
commit 6fbee42934
18 changed files with 1042 additions and 131 deletions
+1
View File
@@ -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"
},
+146
View File
@@ -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>
+1 -1
View File
@@ -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>
+168 -47
View File
@@ -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 (
+266
View File
@@ -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),
},
};
}
+285
View File
@@ -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;
}
+9
View File
@@ -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)",
},
},
},
},
+44 -1
View File
@@ -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+,",
);