From 6fbee4293434d7417561518dab3dd3d164d17984 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 7 Apr 2026 21:54:50 -1000 Subject: [PATCH] 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) --- desktop/package.json | 1 + desktop/pnpm-lock.yaml | 146 +++++++++ .../agents/ui/AddTeamToChannelDialog.tsx | 8 +- .../features/agents/ui/CreateAgentDialog.tsx | 6 +- .../src/features/agents/ui/ModelPicker.tsx | 4 +- .../features/agents/ui/SecretRevealDialog.tsx | 2 +- .../channels/ui/AddChannelBotDialog.tsx | 14 +- .../src/features/messages/ui/DiffMessage.tsx | 2 +- .../src/features/messages/ui/DiffViewer.tsx | 4 +- .../settings/ui/DoctorSettingsPanel.tsx | 12 +- .../features/settings/ui/SettingsPanels.tsx | 140 ++++++--- .../features/tokens/ui/TokenSettingsCard.tsx | 12 +- desktop/src/main.tsx | 2 +- desktop/src/shared/theme/ThemeProvider.tsx | 215 ++++++++++--- desktop/src/shared/theme/adaptive-theme.ts | 266 ++++++++++++++++ desktop/src/shared/theme/theme-loader.ts | 285 ++++++++++++++++++ desktop/tailwind.config.js | 9 + desktop/tests/e2e/profile.spec.ts | 45 ++- 18 files changed, 1042 insertions(+), 131 deletions(-) create mode 100644 desktop/src/shared/theme/adaptive-theme.ts create mode 100644 desktop/src/shared/theme/theme-loader.ts diff --git a/desktop/package.json b/desktop/package.json index 5551f8c80..1e9967d6b 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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" }, diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 9b249260d..81915ee5e 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -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: {} diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index 4cb3951f8..c162e859a 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -243,13 +243,11 @@ export function AddTeamToChannelDialog({ {providerWarnings.length > 0 ? providerWarnings.map((warning) => (
- -

- {warning} -

+ +

{warning}

)) : null} diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx index 41fb3e344..fe2129c57 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx @@ -421,9 +421,9 @@ export function CreateAgentDialog({ {/* Provider mode: trust warning + config fields */} {isProviderMode && selectedBackendProvider ? (
-
- -

+

+ +

This provider at{" "} {selectedBackendProvider.binaryPath} diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index 67a30fed7..ce7fa859a 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -141,9 +141,7 @@ export function ModelPicker({ {needsRestart ? ( - - restart to apply - + restart to apply ) : null} ); diff --git a/desktop/src/features/agents/ui/SecretRevealDialog.tsx b/desktop/src/features/agents/ui/SecretRevealDialog.tsx index 02f576cb2..a49f4295e 100644 --- a/desktop/src/features/agents/ui/SecretRevealDialog.tsx +++ b/desktop/src/features/agents/ui/SecretRevealDialog.tsx @@ -72,7 +72,7 @@ export function SecretRevealDialog({ ) : null} {created.profileSyncError ? ( -

+

{created.profileSyncError}

) : null} diff --git a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx index 0442de5d1..f3424f91b 100644 --- a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx @@ -432,9 +432,9 @@ export function AddChannelBotDialog({ ) : null} {isProviderMode && selectedBackendProvider ? ( -
- -

+

+ +

This provider at{" "} {selectedBackendProvider.binaryPath} @@ -558,13 +558,11 @@ export function AddChannelBotDialog({ {providerWarnings.length > 0 ? providerWarnings.map((warning) => (

- -

- {warning} -

+ +

{warning}

)) : null} diff --git a/desktop/src/features/messages/ui/DiffMessage.tsx b/desktop/src/features/messages/ui/DiffMessage.tsx index d6f830d7f..ed3e743ac 100644 --- a/desktop/src/features/messages/ui/DiffMessage.tsx +++ b/desktop/src/features/messages/ui/DiffMessage.tsx @@ -116,7 +116,7 @@ export default function DiffMessage({ {/* Truncation warning */} {truncated && ( -
+ {hasMissingSproutTools ? ( -

+

Build the workspace binaries with{" "} cargo build --release --workspace @@ -345,7 +343,7 @@ export function DoctorSettingsPanel() { /> )) ) : ( -

+
No known ACP runtime was detected on your PATH yet. You can still use a custom command in Create agent.
diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index a3c437f19..17f6594ca 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -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 (
-
-
-

Appearance

-

- Choose how Sprout looks on this device. -

-
+
+

Appearance

+

+ Choose a theme for Sprout. Light and dark mode is auto-detected. +

+
-
- {themeOptions.map(({ value, label, icon: Icon }) => { - const isActive = theme === value; +
+ + setSearch(e.target.value)} + placeholder="Search themes..." + type="text" + value={search} + /> +
+ +
+ {filtered.length === 0 ? ( +

+ No themes match your search. +

+ ) : ( + filtered.map((name) => { + const isActive = themeName === name; + const light = isLightTheme(name); return ( ); - })} + }) + )} +
+ +
+

Accent Color

+
+ {ACCENT_COLORS.map((color) => ( + + ))}
diff --git a/desktop/src/features/tokens/ui/TokenSettingsCard.tsx b/desktop/src/features/tokens/ui/TokenSettingsCard.tsx index a20aa5643..e1e6ad354 100644 --- a/desktop/src/features/tokens/ui/TokenSettingsCard.tsx +++ b/desktop/src/features/tokens/ui/TokenSettingsCard.tsx @@ -52,11 +52,9 @@ function StatusBadge({ status }: { status: "active" | "revoked" | "expired" }) { {status} @@ -303,7 +301,7 @@ function CreateTokenDialog({ )}
-
+
This is the only time this token will be shown. Store it @@ -532,7 +530,7 @@ function CreateTokenDialog({
{activeTokenCount >= MAX_ACTIVE_TOKENS ? ( -

+

You already have {MAX_ACTIVE_TOKENS} active tokens. Revoke one before creating another.

@@ -713,7 +711,7 @@ export function TokenSettingsCard({
{hasReachedTokenLimit ? ( -

+

You've reached the active token limit. Revoke an existing token to mint another.

diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 1ede3334c..41fc67cb4 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -25,7 +25,7 @@ const queryClient = new QueryClient({ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx index 85c2c3a36..371fea8cd 100644 --- a/desktop/src/shared/theme/ThemeProvider.tsx +++ b/desktop/src/shared/theme/ThemeProvider.tsx @@ -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(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(() => - readStoredTheme(storageKey, defaultTheme), - ); - const [systemTheme, setSystemTheme] = - useState(detectSystemTheme); + // Apply cached vars synchronously before first render + const [themeName, setThemeName] = useState(() => { + const cached = applyCachedVars(); + return cached ?? readStoredTheme(defaultTheme); + }); + const [isDark, setIsDark] = useState(() => { + return document.documentElement.classList.contains("dark"); + }); + const [isLoading, setIsLoading] = useState(true); + const loadingRef = useRef(null); + const [accentColor, setAccentColorState] = useState(() => { + 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 ( diff --git a/desktop/src/shared/theme/adaptive-theme.ts b/desktop/src/shared/theme/adaptive-theme.ts new file mode 100644 index 000000000..43596c2e3 --- /dev/null +++ b/desktop/src/shared/theme/adaptive-theme.ts @@ -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; +} + +/** + * 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), + }, + }; +} diff --git a/desktop/src/shared/theme/theme-loader.ts b/desktop/src/shared/theme/theme-loader.ts new file mode 100644 index 000000000..31e7567c6 --- /dev/null +++ b/desktop/src/shared/theme/theme-loader.ts @@ -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 = 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 | 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 | 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 | undefined, + ); + return { + name: themeName, + bg, + fg, + comment: extractCommentColor( + theme.settings as ReadonlyArray | undefined, + fg, + ), + ...gitColors, + }; +} + +export async function loadThemeData( + name: SyntaxThemeName, +): Promise { + const loader = themeImports[name]; + const { default: theme } = await loader(); + return theme; +} diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js index 0807c75f6..1ce6562a1 100644 --- a/desktop/tailwind.config.js +++ b/desktop/tailwind.config.js @@ -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)", + }, }, }, }, diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index be8069d07..49cd85518 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -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+,", );