diff --git a/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx b/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx index 1cb2298276d..2420b308a58 100644 --- a/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx @@ -30,6 +30,7 @@ import { PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' +import { VSCodeThemeSettings } from './VSCodeThemeSettings' export const ThemeSettings = () => { const [mounted, setMounted] = useState(false) @@ -122,6 +123,7 @@ export const ThemeSettings = () => { + diff --git a/apps/studio/components/interfaces/Account/Preferences/VSCodeThemeSettings.tsx b/apps/studio/components/interfaces/Account/Preferences/VSCodeThemeSettings.tsx new file mode 100644 index 00000000000..b63228ccc9e --- /dev/null +++ b/apps/studio/components/interfaces/Account/Preferences/VSCodeThemeSettings.tsx @@ -0,0 +1,246 @@ +import { AlertCircle, Check, Code2 } from 'lucide-react' +import { useState } from 'react' + +import { useVSCodeTheme } from 'hooks/misc/useVSCodeTheme' +import type { VSCodeThemePreset } from 'lib/vscode-themes' +import { + Alert_Shadcn_, + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Button, + Card, + CardContent, + cn, + Label_Shadcn_, + Separator, + Switch, + TextArea_Shadcn_, +} from 'ui' + +/** + * Theme preview card component + */ +function ThemePreviewCard({ + preset, + isSelected, + onSelect, +}: { + preset: VSCodeThemePreset + isSelected: boolean + onSelect: () => void +}) { + return ( + + ) +} + +/** + * VSCode Theme Settings Component + * + * Allows users to customize the Studio UI using VSCode themes. + */ +export function VSCodeThemeSettings() { + const { + isEnabled, + currentPresetId, + customThemeJson, + presets, + enablePreset, + applyCustomTheme, + setEnabled, + } = useVSCodeTheme() + + const [customJson, setCustomJson] = useState(customThemeJson || '') + const [customError, setCustomError] = useState(null) + const [showCustomInput, setShowCustomInput] = useState(false) + + const handleToggle = (checked: boolean) => { + setEnabled(checked) + if (!checked) { + setCustomError(null) + } + } + + const handlePresetSelect = (presetId: string) => { + enablePreset(presetId) + setShowCustomInput(false) + setCustomError(null) + } + + const handleApplyCustomTheme = () => { + const result = applyCustomTheme(customJson) + if (!result.success) { + setCustomError(result.error || 'Failed to apply theme') + } else { + setCustomError(null) + } + } + + const handleResetCustomTheme = () => { + setCustomJson('') + setCustomError(null) + // If a preset was previously selected, revert to it + if (presets.length > 0) { + enablePreset(presets[0].id) + } + setShowCustomInput(false) + } + + const isUsingCustomTheme = isEnabled && !currentPresetId && customThemeJson + + return ( + <> + + +
+ + + VSCode Theme + +

+ Customize the Studio UI with your favorite VSCode themes. Choose from popular presets or + paste your own theme JSON. +

+
+ +
+ {/* Enable/Disable Toggle */} +
+ + Enable VSCode theme + + +
+ + {isEnabled && ( + <> + {/* Theme Presets Grid */} +
+ {presets.map((preset) => ( + handlePresetSelect(preset.id)} + /> + ))} +
+ + {/* Custom Theme Toggle */} +
+ + {isUsingCustomTheme && ( + Currently using custom theme + )} +
+ + {/* Custom Theme Input */} + {showCustomInput && ( + + +
+ + Custom VSCode Theme JSON + + { + setCustomJson(e.target.value) + setCustomError(null) + }} + placeholder={`{ + "name": "My Theme", + "type": "dark", + "colors": { + "editor.background": "#1e1e1e", + "editor.foreground": "#d4d4d4", + ... + } +}`} + className="font-mono text-xs min-h-[200px]" + /> +

+ Paste a VSCode theme JSON. Must include "name", "type" + (dark/light), and "colors" object. +

+
+ + {customError && ( + + + Invalid theme + {customError} + + )} + +
+ + +
+
+
+ )} + + )} +
+
+ + ) +} diff --git a/apps/studio/components/interfaces/App/MonacoThemeProvider.tsx b/apps/studio/components/interfaces/App/MonacoThemeProvider.tsx index 92b9fb6e0f8..c544e2c1f72 100644 --- a/apps/studio/components/interfaces/App/MonacoThemeProvider.tsx +++ b/apps/studio/components/interfaces/App/MonacoThemeProvider.tsx @@ -2,6 +2,8 @@ import { useMonaco } from '@monaco-editor/react' import { useTheme } from 'next-themes' import { useMemo } from 'react' +import { useVSCodeTheme } from 'hooks/misc/useVSCodeTheme' + const getTheme = (theme: string) => { const isDarkMode = theme.includes('dark') // [TODO] Probably need better theming for light mode @@ -30,15 +32,24 @@ const getTheme = (theme: string) => { export const MonacoThemeProvider = () => { const monaco = useMonaco() const { resolvedTheme } = useTheme() + const { isEnabled: isVSCodeThemeEnabled, monacoTheme, monacoThemeName } = useVSCodeTheme() // Define the supabase theme for Monaco before anything is rendered. Using useEffect would sometime load the theme // after the editor was loaded, so it looked off. useMemo will always be run before rendering useMemo(() => { if (monaco && resolvedTheme) { - const mode = getTheme(resolvedTheme) - monaco.editor.defineTheme('supabase', mode) + // If VSCode theme is enabled and we have a custom Monaco theme, use it + if (isVSCodeThemeEnabled && monacoTheme && monacoThemeName) { + monaco.editor.defineTheme(monacoThemeName, monacoTheme) + // Also define it as 'supabase' so existing editors pick it up + monaco.editor.defineTheme('supabase', monacoTheme) + } else { + // Use the default Supabase theme + const mode = getTheme(resolvedTheme) + monaco.editor.defineTheme('supabase', mode) + } } - }, [resolvedTheme, monaco]) + }, [resolvedTheme, monaco, isVSCodeThemeEnabled, monacoTheme, monacoThemeName]) return null } diff --git a/apps/studio/components/interfaces/UserDropdown.tsx b/apps/studio/components/interfaces/UserDropdown.tsx index 9a742ee85c5..24fc316b090 100644 --- a/apps/studio/components/interfaces/UserDropdown.tsx +++ b/apps/studio/components/interfaces/UserDropdown.tsx @@ -1,15 +1,17 @@ -import { Command, FlaskConical, Loader2, Settings } from 'lucide-react' +import { Check, Code2, Command, FlaskConical, Loader2, Settings } from 'lucide-react' import { useTheme } from 'next-themes' import Link from 'next/link' import { useRouter } from 'next/router' import { ProfileImage } from 'components/ui/ProfileImage' -import { IS_PLATFORM } from 'lib/constants' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { useVSCodeTheme } from 'hooks/misc/useVSCodeTheme' +import { IS_PLATFORM } from 'lib/constants' import { useProfileNameAndPicture } from 'lib/profile' import { useAppStateSnapshot } from 'state/app-state' import { Button, + cn, DropdownMenu, DropdownMenuContent, DropdownMenuGroup, @@ -18,6 +20,9 @@ import { DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, Theme, singleThemes, @@ -36,6 +41,15 @@ export function UserDropdown() { const sendTelemetry = useCommandMenuOpenedTelemetry() const { openFeaturePreviewModal } = useFeaturePreviewModal() + const { + isEnabled: isVSCodeThemeEnabled, + currentPresetId, + currentPreset, + presets: vscodePresets, + enablePreset: enableVSCodePreset, + disable: disableVSCodeTheme, + } = useVSCodeTheme() + const handleCommandMenuOpen = () => { setCommandMenuOpen(true) sendTelemetry() @@ -119,6 +133,64 @@ export function UserDropdown() { ))} + + + + + VSCode Theme + {isVSCodeThemeEnabled && currentPreset && ( + + {currentPreset.name} + + )} + + + disableVSCodeTheme()} + className="flex items-center justify-between" + > + Off + {!isVSCodeThemeEnabled && } + + + {vscodePresets.map((preset) => ( + enableVSCodePreset(preset.id)} + className="flex items-center gap-2" + > +
+
+
+
+ {preset.name} + {isVSCodeThemeEnabled && currentPresetId === preset.id && ( + + )} + + ))} + + + { + if (router.pathname !== '/account/me') { + appStateSnapshot.setLastRouteBeforeVisitingAccountPage(router.asPath) + } + }} + > + Custom theme... + + + + {IS_PLATFORM && ( <> diff --git a/apps/studio/hooks/misc/useVSCodeTheme.ts b/apps/studio/hooks/misc/useVSCodeTheme.ts new file mode 100644 index 00000000000..8e14cc58bc1 --- /dev/null +++ b/apps/studio/hooks/misc/useVSCodeTheme.ts @@ -0,0 +1,278 @@ +/** + * useVSCodeTheme Hook + * + * Manages VSCode theme state, including persistence to localStorage + * and applying themes to the DOM. + */ + +import { LOCAL_STORAGE_KEYS } from 'common' +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { + applyThemeToDOM, + generateMonacoTheme, + generateMonacoThemeName, + getPresetById, + mapVSCodeToSupabase, + parseVSCodeTheme, + removeCustomTheme, + vscodeThemePresets, + type MonacoThemeDefinition, + type VSCodeTheme, + type VSCodeThemePreset, + type VSCodeThemeState, +} from 'lib/vscode-themes' + +/** + * Default theme state + */ +const DEFAULT_STATE: VSCodeThemeState = { + isEnabled: false, + presetId: null, + customThemeJson: null, +} + +/** + * Load theme state from localStorage + */ +function loadStateFromStorage(): VSCodeThemeState { + if (typeof window === 'undefined') { + return DEFAULT_STATE + } + + try { + const enabled = localStorage.getItem(LOCAL_STORAGE_KEYS.VSCODE_THEME_ENABLED) + const preset = localStorage.getItem(LOCAL_STORAGE_KEYS.VSCODE_THEME_PRESET) + const custom = localStorage.getItem(LOCAL_STORAGE_KEYS.VSCODE_THEME_CUSTOM) + + return { + isEnabled: enabled === 'true', + presetId: preset || null, + customThemeJson: custom || null, + } + } catch (e) { + console.error('Failed to load VSCode theme state from localStorage:', e) + return DEFAULT_STATE + } +} + +/** + * Save theme state to localStorage + */ +function saveStateToStorage(state: VSCodeThemeState): void { + if (typeof window === 'undefined') return + + try { + localStorage.setItem(LOCAL_STORAGE_KEYS.VSCODE_THEME_ENABLED, String(state.isEnabled)) + + if (state.presetId) { + localStorage.setItem(LOCAL_STORAGE_KEYS.VSCODE_THEME_PRESET, state.presetId) + } else { + localStorage.removeItem(LOCAL_STORAGE_KEYS.VSCODE_THEME_PRESET) + } + + if (state.customThemeJson) { + localStorage.setItem(LOCAL_STORAGE_KEYS.VSCODE_THEME_CUSTOM, state.customThemeJson) + } else { + localStorage.removeItem(LOCAL_STORAGE_KEYS.VSCODE_THEME_CUSTOM) + } + } catch (e) { + console.error('Failed to save VSCode theme state to localStorage:', e) + } +} + +/** + * Get the current active theme based on state + */ +function getActiveTheme(state: VSCodeThemeState): VSCodeTheme | null { + if (!state.isEnabled) return null + + // Try preset first + if (state.presetId) { + const preset = getPresetById(state.presetId) + if (preset) return preset.theme + } + + // Try custom theme + if (state.customThemeJson) { + const result = parseVSCodeTheme(state.customThemeJson) + if (result.success && result.theme) { + return result.theme + } + } + + return null +} + +export interface UseVSCodeThemeReturn { + // State + isEnabled: boolean + currentPresetId: string | null + customThemeJson: string | null + currentTheme: VSCodeTheme | null + currentPreset: VSCodeThemePreset | null + presets: VSCodeThemePreset[] + + // Monaco theme + monacoTheme: MonacoThemeDefinition | null + monacoThemeName: string | null + + // Actions + enablePreset: (presetId: string) => void + applyCustomTheme: (json: string) => { success: boolean; error?: string } + disable: () => void + setEnabled: (enabled: boolean) => void +} + +/** + * Hook for managing VSCode theme state. + * + * Usage: + * ```tsx + * const { isEnabled, currentPreset, presets, enablePreset, disable } = useVSCodeTheme() + * + * // Enable a preset + * enablePreset('dracula') + * + * // Apply custom theme + * const result = applyCustomTheme(jsonString) + * if (!result.success) { + * console.error(result.error) + * } + * + * // Disable VSCode theme (revert to standard theme) + * disable() + * ``` + */ +export function useVSCodeTheme(): UseVSCodeThemeReturn { + // Start with default state to avoid hydration mismatch + const [state, setState] = useState(DEFAULT_STATE) + const [mounted, setMounted] = useState(false) + + // Load state from localStorage after mounting (client-side only) + useEffect(() => { + setState(loadStateFromStorage()) + setMounted(true) + }, []) + + // Apply theme to DOM when state changes (only after mounted) + useEffect(() => { + if (!mounted) return + + const theme = getActiveTheme(state) + + if (theme) { + const variables = mapVSCodeToSupabase(theme) + applyThemeToDOM(variables) + } else { + removeCustomTheme() + } + }, [state, mounted]) + + // Save state to localStorage when it changes (only after mounted) + useEffect(() => { + if (!mounted) return + saveStateToStorage(state) + }, [state, mounted]) + + // Get current active theme + const currentTheme = useMemo(() => getActiveTheme(state), [state]) + + // Get current preset + const currentPreset = useMemo(() => { + if (!state.presetId) return null + return getPresetById(state.presetId) || null + }, [state.presetId]) + + // Generate Monaco theme + const monacoTheme = useMemo(() => { + if (!currentTheme) return null + return generateMonacoTheme(currentTheme) + }, [currentTheme]) + + const monacoThemeName = useMemo(() => { + if (!currentTheme) return null + return generateMonacoThemeName(currentTheme.name) + }, [currentTheme]) + + // Enable a preset theme + const enablePreset = useCallback((presetId: string) => { + const preset = getPresetById(presetId) + if (!preset) { + console.error(`VSCode theme preset not found: ${presetId}`) + return + } + + setState({ + isEnabled: true, + presetId, + customThemeJson: null, + }) + }, []) + + // Apply a custom theme from JSON + const applyCustomTheme = useCallback( + (json: string): { success: boolean; error?: string } => { + const result = parseVSCodeTheme(json) + + if (!result.success) { + return { success: false, error: result.error } + } + + setState({ + isEnabled: true, + presetId: null, + customThemeJson: json, + }) + + return { success: true } + }, + [] + ) + + // Disable VSCode theme + const disable = useCallback(() => { + setState((prev) => ({ + ...prev, + isEnabled: false, + })) + }, []) + + // Set enabled state (used for toggle) + const setEnabled = useCallback((enabled: boolean) => { + setState((prev) => { + // If enabling and no theme is selected, select the first preset + if (enabled && !prev.presetId && !prev.customThemeJson) { + return { + isEnabled: true, + presetId: vscodeThemePresets[0]?.id || null, + customThemeJson: null, + } + } + return { + ...prev, + isEnabled: enabled, + } + }) + }, []) + + return { + // State + isEnabled: state.isEnabled, + currentPresetId: state.presetId, + customThemeJson: state.customThemeJson, + currentTheme, + currentPreset, + presets: vscodeThemePresets, + + // Monaco + monacoTheme, + monacoThemeName, + + // Actions + enablePreset, + applyCustomTheme, + disable, + setEnabled, + } +} diff --git a/apps/studio/lib/vscode-themes/applicator.ts b/apps/studio/lib/vscode-themes/applicator.ts new file mode 100644 index 00000000000..6f27029ba6d --- /dev/null +++ b/apps/studio/lib/vscode-themes/applicator.ts @@ -0,0 +1,184 @@ +/** + * VSCode Theme Applicator + * + * Functions to apply theme variables to the DOM. + */ + +import type { SupabaseThemeVariables } from './types' + +/** + * CSS variable keys that we manage + */ +const MANAGED_CSS_VARIABLES: (keyof SupabaseThemeVariables)[] = [ + // Background + '--background-default', + '--background-alternative', + '--background-selection', + '--background-control', + '--background-surface-100', + '--background-surface-200', + '--background-surface-300', + '--background-overlay-default', + '--background-overlay-hover', + '--background-muted', + // Foreground + '--foreground-default', + '--foreground-light', + '--foreground-lighter', + '--foreground-muted', + // Border + '--border-default', + '--border-muted', + '--border-secondary', + '--border-overlay', + '--border-control', + '--border-alternative', + '--border-strong', + '--border-stronger', + // Brand + '--brand-default', + '--brand-accent', + '--brand-200', + '--brand-300', + '--brand-400', + '--brand-500', + '--brand-600', + '--brand-link', + // Code blocks + '--code-block-1', + '--code-block-2', + '--code-block-3', + '--code-block-4', + '--code-block-5', +] + +/** + * Custom style element ID for VSCode theme overrides + */ +const STYLE_ELEMENT_ID = 'supabase-vscode-theme' + +/** + * Apply theme variables to the DOM. + * Sets CSS custom properties on :root element. + * @param variables - Theme variables to apply + */ +export function applyThemeToDOM(variables: SupabaseThemeVariables): void { + if (typeof document === 'undefined') return + + const root = document.querySelector(':root') as HTMLElement + if (!root) return + + // Apply each variable + for (const [key, value] of Object.entries(variables)) { + root.style.setProperty(key, value) + } +} + +/** + * Remove all custom theme variables from the DOM. + * Resets to default theme by removing inline styles. + */ +export function removeCustomTheme(): void { + if (typeof document === 'undefined') return + + const root = document.querySelector(':root') as HTMLElement + if (!root) return + + // Remove each managed variable + for (const key of MANAGED_CSS_VARIABLES) { + root.style.removeProperty(key) + } + + // Also remove the style element if it exists + const styleElement = document.getElementById(STYLE_ELEMENT_ID) + if (styleElement) { + styleElement.remove() + } +} + +/** + * Create a style element with theme overrides. + * Alternative to inline styles on :root. + * @param variables - Theme variables to apply + * @returns Style element + */ +export function createThemeStyleElement(variables: SupabaseThemeVariables): HTMLStyleElement { + const styleElement = document.createElement('style') + styleElement.id = STYLE_ELEMENT_ID + + const cssVars = Object.entries(variables) + .map(([key, value]) => ` ${key}: ${value};`) + .join('\n') + + styleElement.textContent = `:root {\n${cssVars}\n}` + + return styleElement +} + +/** + * Apply theme using a style element instead of inline styles. + * This has higher specificity and is easier to manage. + * @param variables - Theme variables to apply + */ +export function applyThemeWithStyleElement(variables: SupabaseThemeVariables): void { + if (typeof document === 'undefined') return + + // Remove existing style element + const existing = document.getElementById(STYLE_ELEMENT_ID) + if (existing) { + existing.remove() + } + + // Create and append new style element + const styleElement = createThemeStyleElement(variables) + document.head.appendChild(styleElement) +} + +/** + * Check if a custom VSCode theme is currently applied. + * @returns True if custom theme styles are applied + */ +export function isCustomThemeApplied(): boolean { + if (typeof document === 'undefined') return false + + // Check for style element + const styleElement = document.getElementById(STYLE_ELEMENT_ID) + if (styleElement) return true + + // Check for inline styles on :root + const root = document.querySelector(':root') as HTMLElement + if (!root) return false + + // Check if any of our managed variables are set inline + for (const key of MANAGED_CSS_VARIABLES) { + if (root.style.getPropertyValue(key)) { + return true + } + } + + return false +} + +/** + * Get all currently applied custom theme variables. + * @returns Object with current variable values, or null if no custom theme + */ +export function getCurrentThemeVariables(): Partial | null { + if (typeof document === 'undefined') return null + + const root = document.querySelector(':root') as HTMLElement + if (!root) return null + + const variables: Partial = {} + let hasAny = false + + for (const key of MANAGED_CSS_VARIABLES) { + const value = root.style.getPropertyValue(key) + if (value) { + variables[key] = value + hasAny = true + } + } + + return hasAny ? variables : null +} diff --git a/apps/studio/lib/vscode-themes/index.ts b/apps/studio/lib/vscode-themes/index.ts new file mode 100644 index 00000000000..fa97f37c50e --- /dev/null +++ b/apps/studio/lib/vscode-themes/index.ts @@ -0,0 +1,49 @@ +/** + * VSCode Themes + * + * A module for parsing VSCode themes and applying them to Supabase Studio. + */ + +// Types +export type { + MonacoThemeDefinition, + SupabaseThemeVariables, + ThemeParseResult, + VSCodeTheme, + VSCodeThemePreset, + VSCodeThemeState, + VSCodeTokenColor, +} from './types' + +// Parser functions +export { + darken, + getLuminance, + hexToHslString, + isDark, + isValidTheme, + lighten, + mix, + parseVSCodeTheme, + saturate, + toHex, +} from './parser' + +// Mapper functions +export { deriveBrandColors, getPreviewColors, mapVSCodeToSupabase } from './mapper' + +// Applicator functions +export { + applyThemeToDOM, + applyThemeWithStyleElement, + createThemeStyleElement, + getCurrentThemeVariables, + isCustomThemeApplied, + removeCustomTheme, +} from './applicator' + +// Monaco integration +export { generateMonacoTheme, generateMonacoThemeName } from './monaco' + +// Presets +export { getPresetById, vscodeThemePresets } from './presets' diff --git a/apps/studio/lib/vscode-themes/mapper.ts b/apps/studio/lib/vscode-themes/mapper.ts new file mode 100644 index 00000000000..bb9a9f4d302 --- /dev/null +++ b/apps/studio/lib/vscode-themes/mapper.ts @@ -0,0 +1,319 @@ +/** + * VSCode Theme Mapper + * + * Maps VSCode theme colors to Supabase CSS variables. + */ + +import { darken, hexToHslString, lighten, mix, saturate } from './parser' +import type { SupabaseThemeVariables, VSCodeTheme } from './types' + +/** + * Default brand color (Supabase green) + */ +const DEFAULT_BRAND_COLOR = '#3ECF8E' + +/** + * Derive a full brand color scale from a single accent color. + * @param accentColor - Hex accent color + * @param isDarkTheme - Whether the theme is dark + * @returns Object with brand color scale + */ +export function deriveBrandColors( + accentColor: string, + isDarkTheme: boolean +): { + '--brand-default': string + '--brand-accent': string + '--brand-200': string + '--brand-300': string + '--brand-400': string + '--brand-500': string + '--brand-600': string + '--brand-link': string +} { + // For dark themes, we create lighter variants going up + // For light themes, we create darker variants going up + if (isDarkTheme) { + return { + '--brand-default': hexToHslString(accentColor), + '--brand-accent': hexToHslString(lighten(accentColor, 15)), + '--brand-200': hexToHslString(darken(saturate(accentColor, -40), 35)), + '--brand-300': hexToHslString(darken(saturate(accentColor, -30), 30)), + '--brand-400': hexToHslString(darken(saturate(accentColor, -20), 25)), + '--brand-500': hexToHslString(darken(saturate(accentColor, -10), 15)), + '--brand-600': hexToHslString(lighten(saturate(accentColor, 10), 20)), + '--brand-link': hexToHslString(accentColor), + } + } else { + return { + '--brand-default': hexToHslString(accentColor), + '--brand-accent': hexToHslString(darken(accentColor, 10)), + '--brand-200': hexToHslString(lighten(saturate(accentColor, -40), 45)), + '--brand-300': hexToHslString(lighten(saturate(accentColor, -30), 40)), + '--brand-400': hexToHslString(lighten(saturate(accentColor, -20), 35)), + '--brand-500': hexToHslString(lighten(saturate(accentColor, -10), 25)), + '--brand-600': hexToHslString(darken(saturate(accentColor, 10), 10)), + '--brand-link': hexToHslString(darken(accentColor, 5)), + } + } +} + +/** + * Get accent color from VSCode theme. + * Tries multiple potential accent color sources. + * @param theme - VSCode theme + * @returns Accent color hex or default brand color + */ +function getAccentColor(theme: VSCodeTheme): string { + const colors = theme.colors + + // Try to find an accent color from various sources + return ( + colors['activityBarBadge.background'] || + colors['button.background'] || + colors['badge.background'] || + colors['textLink.foreground'] || + colors['panelTitle.activeBorder'] || + colors.focusBorder || + DEFAULT_BRAND_COLOR + ) +} + +/** + * Derive background color variants from a base background. + * @param baseBackground - Base background hex color + * @param isDarkTheme - Whether the theme is dark + * @returns Object with background variants + */ +function deriveBackgroundVariants(baseBackground: string, isDarkTheme: boolean) { + if (isDarkTheme) { + return { + '--background-default': hexToHslString(baseBackground), + '--background-alternative': hexToHslString(darken(baseBackground, 2)), + '--background-selection': hexToHslString(lighten(baseBackground, 6)), + '--background-control': hexToHslString(lighten(baseBackground, 7)), + '--background-surface-100': hexToHslString(lighten(baseBackground, 2)), + '--background-surface-200': hexToHslString(lighten(baseBackground, 3)), + '--background-surface-300': hexToHslString(lighten(baseBackground, 6)), + '--background-overlay-default': hexToHslString(lighten(baseBackground, 3)), + '--background-overlay-hover': hexToHslString(lighten(baseBackground, 7)), + '--background-muted': hexToHslString(lighten(baseBackground, 7)), + } + } else { + return { + '--background-default': hexToHslString(baseBackground), + '--background-alternative': hexToHslString(darken(baseBackground, 2)), + '--background-selection': hexToHslString(darken(baseBackground, 5)), + '--background-control': hexToHslString(darken(baseBackground, 3)), + '--background-surface-100': hexToHslString(darken(baseBackground, 2)), + '--background-surface-200': hexToHslString(darken(baseBackground, 4)), + '--background-surface-300': hexToHslString(darken(baseBackground, 6)), + '--background-overlay-default': hexToHslString(darken(baseBackground, 3)), + '--background-overlay-hover': hexToHslString(darken(baseBackground, 6)), + '--background-muted': hexToHslString(darken(baseBackground, 5)), + } + } +} + +/** + * Derive foreground color variants from a base foreground. + * @param baseForeground - Base foreground hex color + * @param isDarkTheme - Whether the theme is dark + * @returns Object with foreground variants + */ +function deriveForegroundVariants(baseForeground: string, isDarkTheme: boolean) { + if (isDarkTheme) { + return { + '--foreground-default': hexToHslString(baseForeground), + '--foreground-light': hexToHslString(darken(baseForeground, 25)), + '--foreground-lighter': hexToHslString(darken(baseForeground, 40)), + '--foreground-muted': hexToHslString(darken(baseForeground, 60)), + } + } else { + return { + '--foreground-default': hexToHslString(baseForeground), + '--foreground-light': hexToHslString(lighten(baseForeground, 20)), + '--foreground-lighter': hexToHslString(lighten(baseForeground, 35)), + '--foreground-muted': hexToHslString(lighten(baseForeground, 55)), + } + } +} + +/** + * Derive border color variants from base colors. + * @param baseBackground - Base background hex color + * @param baseForeground - Base foreground hex color + * @param isDarkTheme - Whether the theme is dark + * @returns Object with border variants + */ +function deriveBorderVariants( + baseBackground: string, + baseForeground: string, + isDarkTheme: boolean +) { + // Create border colors by mixing background and foreground + const borderBase = mix(baseBackground, baseForeground, isDarkTheme ? 15 : 20) + + if (isDarkTheme) { + return { + '--border-default': hexToHslString(lighten(baseBackground, 7)), + '--border-muted': hexToHslString(lighten(baseBackground, 5)), + '--border-secondary': hexToHslString(lighten(baseBackground, 6)), + '--border-overlay': hexToHslString(lighten(baseBackground, 5)), + '--border-control': hexToHslString(lighten(baseBackground, 10)), + '--border-alternative': hexToHslString(lighten(baseBackground, 9)), + '--border-strong': hexToHslString(borderBase), + '--border-stronger': hexToHslString(lighten(borderBase, 5)), + } + } else { + return { + '--border-default': hexToHslString(darken(baseBackground, 10)), + '--border-muted': hexToHslString(darken(baseBackground, 8)), + '--border-secondary': hexToHslString(darken(baseBackground, 9)), + '--border-overlay': hexToHslString(darken(baseBackground, 8)), + '--border-control': hexToHslString(darken(baseBackground, 12)), + '--border-alternative': hexToHslString(darken(baseBackground, 11)), + '--border-strong': hexToHslString(borderBase), + '--border-stronger': hexToHslString(darken(borderBase, 5)), + } + } +} + +/** + * Extract code block colors from VSCode tokenColors. + * @param theme - VSCode theme + * @returns Object with code block colors + */ +function deriveCodeBlockColors(theme: VSCodeTheme): { + '--code-block-1': string + '--code-block-2': string + '--code-block-3': string + '--code-block-4': string + '--code-block-5': string +} { + const tokenColors = theme.tokenColors || [] + + // Default colors for each code block category + const defaults = { + '--code-block-1': '#4EC9B0', // Functions, classes (teal) + '--code-block-2': '#DCDCAA', // Strings (yellow) + '--code-block-3': '#B5CEA8', // Numbers, constants (green) + '--code-block-4': '#C586C0', // Keywords (purple) + '--code-block-5': '#CE9178', // Variables, parameters (orange) + } + + // Scope mappings for each code block category + const scopeMappings = { + '--code-block-1': [ + 'entity.name.function', + 'entity.name.class', + 'support.function', + 'entity.name.type', + ], + '--code-block-2': ['string', 'string.quoted', 'string.template'], + '--code-block-3': ['constant.numeric', 'constant', 'constant.language'], + '--code-block-4': ['keyword', 'storage', 'keyword.control', 'storage.type'], + '--code-block-5': ['variable', 'variable.parameter', 'variable.other', 'entity.name.variable'], + } + + const result: Record = { ...defaults } + + // Find matching token colors + for (const [varName, scopes] of Object.entries(scopeMappings)) { + for (const token of tokenColors) { + const tokenScopes = Array.isArray(token.scope) ? token.scope : [token.scope] + const hasMatch = scopes.some((scope) => + tokenScopes.some((ts) => ts.startsWith(scope) || ts === scope) + ) + + if (hasMatch && token.settings?.foreground) { + result[varName] = token.settings.foreground + break + } + } + } + + return { + '--code-block-1': hexToHslString(result['--code-block-1']), + '--code-block-2': hexToHslString(result['--code-block-2']), + '--code-block-3': hexToHslString(result['--code-block-3']), + '--code-block-4': hexToHslString(result['--code-block-4']), + '--code-block-5': hexToHslString(result['--code-block-5']), + } +} + +/** + * Map VSCode theme to Supabase CSS variables. + * @param theme - Parsed VSCode theme + * @returns Supabase theme variables in HSL format + */ +export function mapVSCodeToSupabase(theme: VSCodeTheme): SupabaseThemeVariables { + const isDarkTheme = theme.type === 'dark' + const colors = theme.colors + + // Get base colors with fallbacks + const baseBackground = + colors['editor.background'] || (isDarkTheme ? '#1e1e1e' : '#ffffff') + const baseForeground = colors['editor.foreground'] || (isDarkTheme ? '#d4d4d4' : '#333333') + + // Use sidebar background if available, otherwise derive from editor background + const sidebarBackground = colors['sideBar.background'] || baseBackground + + // Get accent color for brand colors + const accentColor = getAccentColor(theme) + + // Derive all color variants + const backgroundVars = deriveBackgroundVariants(baseBackground, isDarkTheme) + const foregroundVars = deriveForegroundVariants(baseForeground, isDarkTheme) + const borderVars = deriveBorderVariants(baseBackground, baseForeground, isDarkTheme) + const brandVars = deriveBrandColors(accentColor, isDarkTheme) + const codeBlockVars = deriveCodeBlockColors(theme) + + // Override surface colors with actual VSCode colors if available + if (colors['sideBar.background']) { + backgroundVars['--background-surface-100'] = hexToHslString(sidebarBackground) + } + if (colors['sideBarSectionHeader.background']) { + backgroundVars['--background-surface-200'] = hexToHslString( + colors['sideBarSectionHeader.background'] + ) + } + if (colors['panel.background']) { + backgroundVars['--background-surface-300'] = hexToHslString(colors['panel.background']) + } + + // Override border if panel.border is available + if (colors['panel.border']) { + borderVars['--border-default'] = hexToHslString(colors['panel.border']) + } + + return { + ...backgroundVars, + ...foregroundVars, + ...borderVars, + ...brandVars, + ...codeBlockVars, + } as SupabaseThemeVariables +} + +/** + * Get a minimal set of preview colors from a theme. + * @param theme - VSCode theme + * @returns Preview colors object + */ +export function getPreviewColors(theme: VSCodeTheme): { + background: string + foreground: string + accent: string + surface: string +} { + const colors = theme.colors + const isDarkTheme = theme.type === 'dark' + + return { + background: colors['editor.background'] || (isDarkTheme ? '#1e1e1e' : '#ffffff'), + foreground: colors['editor.foreground'] || (isDarkTheme ? '#d4d4d4' : '#333333'), + accent: getAccentColor(theme), + surface: colors['sideBar.background'] || colors['editor.background'] || (isDarkTheme ? '#252526' : '#f3f3f3'), + } +} diff --git a/apps/studio/lib/vscode-themes/monaco.ts b/apps/studio/lib/vscode-themes/monaco.ts new file mode 100644 index 00000000000..bf69f3347d9 --- /dev/null +++ b/apps/studio/lib/vscode-themes/monaco.ts @@ -0,0 +1,212 @@ +/** + * Monaco Theme Generator + * + * Generates Monaco editor theme definitions from VSCode themes. + */ + +import type { MonacoThemeDefinition, VSCodeTheme, VSCodeTokenColor } from './types' + +/** + * Remove the leading # from a hex color. + * Monaco expects hex colors without the # prefix. + * @param hex - Hex color string + * @returns Hex color without # + */ +function stripHash(hex: string): string { + return hex.replace(/^#/, '') +} + +/** + * Map VSCode token scope to Monaco token. + * VSCode and Monaco have slightly different token naming conventions. + * @param scope - VSCode scope + * @returns Monaco token or null if no mapping + */ +function mapVSCodeScopeToMonaco(scope: string): string | null { + // Common mappings + const mappings: Record = { + // Comments + comment: 'comment', + 'comment.line': 'comment', + 'comment.block': 'comment', + + // Strings + string: 'string', + 'string.quoted': 'string', + 'string.quoted.single': 'string', + 'string.quoted.double': 'string', + 'string.template': 'string', + + // Numbers + 'constant.numeric': 'number', + constant: 'number', + + // Keywords + keyword: 'keyword', + 'keyword.control': 'keyword', + 'keyword.operator': 'keyword', + 'storage.type': 'keyword', + storage: 'keyword', + + // Variables + variable: 'variable', + 'variable.parameter': 'variable.parameter', + 'variable.other': 'variable', + + // Functions + 'entity.name.function': 'function', + 'support.function': 'function', + + // Classes and types + 'entity.name.class': 'type', + 'entity.name.type': 'type', + 'support.type': 'type', + 'support.class': 'type', + + // Tags (HTML, XML) + 'entity.name.tag': 'tag', + 'punctuation.definition.tag': 'delimiter.html', + + // Attributes + 'entity.other.attribute-name': 'attribute.name', + + // Operators + 'keyword.operator.assignment': 'operator', + 'keyword.operator.arithmetic': 'operator', + 'keyword.operator.logical': 'operator', + + // Regex + 'string.regexp': 'regexp', + + // SQL specific + 'keyword.other.DML.sql': 'keyword', + 'keyword.other.DDL.sql': 'keyword', + 'string.quoted.single.sql': 'string.sql', + } + + // Direct mapping + if (mappings[scope]) { + return mappings[scope] + } + + // Try prefix matching + for (const [vsScope, monacoToken] of Object.entries(mappings)) { + if (scope.startsWith(vsScope)) { + return monacoToken + } + } + + return null +} + +/** + * Convert VSCode tokenColors to Monaco rules. + * @param tokenColors - VSCode token colors + * @returns Monaco rules array + */ +function convertTokenColors( + tokenColors: VSCodeTokenColor[] +): MonacoThemeDefinition['rules'] { + const rules: MonacoThemeDefinition['rules'] = [] + const processedTokens = new Set() + + for (const token of tokenColors) { + const scopes = Array.isArray(token.scope) ? token.scope : [token.scope] + + for (const scope of scopes) { + const monacoToken = mapVSCodeScopeToMonaco(scope) + if (monacoToken && !processedTokens.has(monacoToken)) { + processedTokens.add(monacoToken) + + // Skip tokens without settings + if (!token.settings) continue + + const rule: MonacoThemeDefinition['rules'][0] = { + token: monacoToken, + } + + if (token.settings.foreground) { + rule.foreground = stripHash(token.settings.foreground) + } + + if (token.settings.fontStyle) { + rule.fontStyle = token.settings.fontStyle + } + + rules.push(rule) + } + } + } + + return rules +} + +/** + * Generate Monaco editor theme from VSCode theme. + * @param theme - VSCode theme + * @returns Monaco theme definition + */ +export function generateMonacoTheme(theme: VSCodeTheme): MonacoThemeDefinition { + const isDark = theme.type === 'dark' + const colors = theme.colors + + // Build rules from tokenColors + const rules = theme.tokenColors ? convertTokenColors(theme.tokenColors) : [] + + // Build editor colors + const monacoColors: Record = {} + + // Map VSCode colors to Monaco editor colors + const colorMappings: Record = { + 'editor.background': 'editor.background', + 'editor.foreground': 'editor.foreground', + 'editorLineNumber.foreground': 'editorLineNumber.foreground', + 'editorCursor.foreground': 'editorCursor.foreground', + 'editor.selectionBackground': 'editor.selectionBackground', + 'editor.lineHighlightBackground': 'editor.lineHighlightBackground', + 'editorIndentGuide.background': 'editorIndentGuide.background', + 'editorIndentGuide.activeBackground': 'editorIndentGuide.activeBackground', + 'editorWhitespace.foreground': 'editorWhitespace.foreground', + 'editorWidget.background': 'editorWidget.background', + 'editorWidget.border': 'editorWidget.border', + 'editorSuggestWidget.background': 'editorSuggestWidget.background', + 'editorSuggestWidget.foreground': 'editorSuggestWidget.foreground', + 'editorSuggestWidget.selectedBackground': 'editorSuggestWidget.selectedBackground', + 'editorSuggestWidget.highlightForeground': 'editorSuggestWidget.highlightForeground', + 'editorHoverWidget.background': 'editorHoverWidget.background', + 'editorHoverWidget.border': 'editorHoverWidget.border', + 'input.background': 'input.background', + 'input.foreground': 'input.foreground', + 'input.border': 'input.border', + 'dropdown.background': 'dropdown.background', + 'dropdown.foreground': 'dropdown.foreground', + 'dropdown.border': 'dropdown.border', + focusBorder: 'focusBorder', + 'scrollbarSlider.background': 'scrollbarSlider.background', + 'scrollbarSlider.hoverBackground': 'scrollbarSlider.hoverBackground', + 'scrollbarSlider.activeBackground': 'scrollbarSlider.activeBackground', + } + + for (const [vsCodeKey, monacoKey] of Object.entries(colorMappings)) { + const color = colors[vsCodeKey] + if (color) { + monacoColors[monacoKey] = color + } + } + + return { + base: isDark ? 'vs-dark' : 'vs', + inherit: true, + rules, + colors: monacoColors, + } +} + +/** + * Generate a unique theme name for Monaco. + * @param themeName - Original theme name + * @returns Sanitized theme name for Monaco + */ +export function generateMonacoThemeName(themeName: string): string { + return `vscode-${themeName.toLowerCase().replace(/[^a-z0-9]/g, '-')}` +} diff --git a/apps/studio/lib/vscode-themes/parser.ts b/apps/studio/lib/vscode-themes/parser.ts new file mode 100644 index 00000000000..4d74f762491 --- /dev/null +++ b/apps/studio/lib/vscode-themes/parser.ts @@ -0,0 +1,278 @@ +/** + * VSCode Theme Parser + * + * Functions to parse and validate VSCode theme JSON. + */ + +import { TinyColor } from '@ctrl/tinycolor' +import type { ThemeParseResult, VSCodeTheme } from './types' + +/** + * Convert hex color to HSL string format used by Supabase CSS variables. + * @param hex - Hex color string (e.g., "#1e1e1e") + * @returns HSL string in format "Hdeg S% L%" (e.g., "0deg 0% 11.8%") + */ +export function hexToHslString(hex: string): string { + const color = new TinyColor(hex) + const hsl = color.toHsl() + + // Format: "Hdeg S% L%" + const h = Math.round(hsl.h * 10) / 10 + const s = Math.round(hsl.s * 1000) / 10 + const l = Math.round(hsl.l * 1000) / 10 + + return `${h}deg ${s}% ${l}%` +} + +/** + * Convert any color format to hex. + * @param color - Color in any format + * @returns Hex color string or null if invalid + */ +export function toHex(color: string): string | null { + const tinyColor = new TinyColor(color) + if (!tinyColor.isValid) return null + return tinyColor.toHexString() +} + +/** + * Lighten a color by a percentage. + * @param hex - Hex color string + * @param amount - Amount to lighten (0-100) + * @returns Lightened hex color + */ +export function lighten(hex: string, amount: number): string { + return new TinyColor(hex).lighten(amount).toHexString() +} + +/** + * Darken a color by a percentage. + * @param hex - Hex color string + * @param amount - Amount to darken (0-100) + * @returns Darkened hex color + */ +export function darken(hex: string, amount: number): string { + return new TinyColor(hex).darken(amount).toHexString() +} + +/** + * Adjust saturation of a color. + * @param hex - Hex color string + * @param amount - Amount to adjust (-100 to 100) + * @returns Adjusted hex color + */ +export function saturate(hex: string, amount: number): string { + if (amount >= 0) { + return new TinyColor(hex).saturate(amount).toHexString() + } + return new TinyColor(hex).desaturate(Math.abs(amount)).toHexString() +} + +/** + * Mix two colors together. + * @param color1 - First hex color + * @param color2 - Second hex color + * @param amount - Mix ratio (0-100, where 0 is all color1, 100 is all color2) + * @returns Mixed hex color + */ +export function mix(color1: string, color2: string, amount: number): string { + return new TinyColor(color1).mix(color2, amount).toHexString() +} + +/** + * Get the luminance of a color (0-1). + * @param hex - Hex color string + * @returns Luminance value + */ +export function getLuminance(hex: string): number { + return new TinyColor(hex).getLuminance() +} + +/** + * Check if a color is dark. + * @param hex - Hex color string + * @returns True if the color is dark + */ +export function isDark(hex: string): boolean { + return new TinyColor(hex).isDark() +} + +/** + * Strip comments from JSON string (JSONC format). + * Handles both single-line (//) and multi-line comments. + * @param jsonc - JSON string potentially containing comments + * @returns Clean JSON string without comments + */ +function stripJsonComments(jsonc: string): string { + let result = '' + let i = 0 + let inString = false + let stringChar = '' + + while (i < jsonc.length) { + const char = jsonc[i] + const nextChar = jsonc[i + 1] + + // Track if we're inside a string + if ((char === '"' || char === "'") && (i === 0 || jsonc[i - 1] !== '\\')) { + if (!inString) { + inString = true + stringChar = char + } else if (char === stringChar) { + inString = false + } + } + + // If we're in a string, just add the character + if (inString) { + result += char + i++ + continue + } + + // Check for single-line comment + if (char === '/' && nextChar === '/') { + // Skip until end of line + while (i < jsonc.length && jsonc[i] !== '\n') { + i++ + } + continue + } + + // Check for multi-line comment + if (char === '/' && nextChar === '*') { + i += 2 // Skip /* + // Skip until */ + while (i < jsonc.length - 1 && !(jsonc[i] === '*' && jsonc[i + 1] === '/')) { + i++ + } + i += 2 // Skip */ + continue + } + + result += char + i++ + } + + return result +} + +/** + * Parse VSCode theme JSON and validate its structure. + * Supports JSONC format (JSON with comments). + * @param json - JSON string of VSCode theme (may contain comments) + * @returns Parse result with theme or error + */ +export function parseVSCodeTheme(json: string): ThemeParseResult { + try { + // Strip comments before parsing + const cleanJson = stripJsonComments(json) + const parsed = JSON.parse(cleanJson) + + // Validate required fields + if (!parsed.name || typeof parsed.name !== 'string') { + return { + success: false, + error: 'Theme must have a "name" field (string)', + } + } + + if (!parsed.colors || typeof parsed.colors !== 'object') { + return { + success: false, + error: 'Theme must have a "colors" object', + } + } + + // Validate that at least some essential colors are present + const essentialColors = ['editor.background', 'editor.foreground'] + const hasEssentialColors = essentialColors.some((color) => parsed.colors[color]) + + if (!hasEssentialColors) { + return { + success: false, + error: 'Theme must have at least "editor.background" or "editor.foreground" color', + } + } + + // Validate color format (should be hex) + for (const [key, value] of Object.entries(parsed.colors)) { + if (value && typeof value === 'string') { + const color = new TinyColor(value) + if (!color.isValid) { + return { + success: false, + error: `Invalid color value for "${key}": ${value}`, + } + } + } + } + + // Validate tokenColors if present + if (parsed.tokenColors) { + if (!Array.isArray(parsed.tokenColors)) { + return { + success: false, + error: '"tokenColors" must be an array', + } + } + + for (let i = 0; i < parsed.tokenColors.length; i++) { + const token = parsed.tokenColors[i] + if (!token.scope) { + return { + success: false, + error: `tokenColors[${i}] must have a "scope" field`, + } + } + // Settings is optional - some tokens only define scope without settings + } + } + + // Infer theme type from background color if not specified + let themeType: 'dark' | 'light' = parsed.type + if (!themeType || !['dark', 'light'].includes(themeType)) { + const bgColor = parsed.colors['editor.background'] + if (bgColor) { + // Use isDark to determine if it's a dark or light theme + themeType = isDark(bgColor) ? 'dark' : 'light' + } else { + // Default to dark if we can't determine + themeType = 'dark' + } + } + + const theme: VSCodeTheme = { + name: parsed.name, + type: themeType, + colors: parsed.colors, + tokenColors: parsed.tokenColors, + } + + return { + success: true, + theme, + } + } catch (e) { + return { + success: false, + error: e instanceof SyntaxError ? `Invalid JSON: ${e.message}` : 'Failed to parse theme', + } + } +} + +/** + * Validate that a theme object is well-formed. + * @param theme - VSCode theme object + * @returns True if valid + */ +export function isValidTheme(theme: unknown): theme is VSCodeTheme { + if (!theme || typeof theme !== 'object') return false + const t = theme as Record + return ( + typeof t.name === 'string' && + (t.type === 'dark' || t.type === 'light') && + typeof t.colors === 'object' && + t.colors !== null + ) +} diff --git a/apps/studio/lib/vscode-themes/presets/index.ts b/apps/studio/lib/vscode-themes/presets/index.ts new file mode 100644 index 00000000000..e6594289d05 --- /dev/null +++ b/apps/studio/lib/vscode-themes/presets/index.ts @@ -0,0 +1,433 @@ +/** + * VSCode Theme Presets + * + * Popular VSCode themes adapted for Supabase Studio. + */ + +import type { VSCodeThemePreset } from '../types' + +/** + * Dracula Theme + * https://draculatheme.com/ + */ +const dracula: VSCodeThemePreset = { + id: 'dracula', + name: 'Dracula', + preview: { + background: '#282a36', + foreground: '#f8f8f2', + accent: '#bd93f9', + surface: '#21222c', + }, + theme: { + name: 'Dracula', + type: 'dark', + colors: { + 'editor.background': '#282a36', + 'editor.foreground': '#f8f8f2', + 'sideBar.background': '#21222c', + 'sideBar.foreground': '#f8f8f2', + 'activityBar.background': '#343746', + 'activityBar.foreground': '#f8f8f2', + 'activityBarBadge.background': '#bd93f9', + 'panel.background': '#282a36', + 'panel.border': '#44475a', + 'button.background': '#bd93f9', + 'button.foreground': '#f8f8f2', + 'input.background': '#282a36', + 'input.foreground': '#f8f8f2', + 'input.border': '#44475a', + 'list.activeSelectionBackground': '#44475a', + 'list.hoverBackground': '#343746', + focusBorder: '#bd93f9', + 'textLink.foreground': '#8be9fd', + }, + tokenColors: [ + { scope: 'comment', settings: { foreground: '#6272a4' } }, + { scope: 'string', settings: { foreground: '#f1fa8c' } }, + { scope: 'constant.numeric', settings: { foreground: '#bd93f9' } }, + { scope: 'keyword', settings: { foreground: '#ff79c6' } }, + { scope: 'variable', settings: { foreground: '#f8f8f2' } }, + { scope: 'entity.name.function', settings: { foreground: '#50fa7b' } }, + { scope: 'entity.name.type', settings: { foreground: '#8be9fd' } }, + { scope: 'storage.type', settings: { foreground: '#ff79c6' } }, + ], + }, +} + +/** + * One Dark Pro Theme + * https://marketplace.visualstudio.com/items?itemName=zhuangtongfa.Material-theme + */ +const oneDarkPro: VSCodeThemePreset = { + id: 'one-dark-pro', + name: 'One Dark Pro', + preview: { + background: '#282c34', + foreground: '#abb2bf', + accent: '#61afef', + surface: '#21252b', + }, + theme: { + name: 'One Dark Pro', + type: 'dark', + colors: { + 'editor.background': '#282c34', + 'editor.foreground': '#abb2bf', + 'sideBar.background': '#21252b', + 'sideBar.foreground': '#abb2bf', + 'activityBar.background': '#282c34', + 'activityBar.foreground': '#d7dae0', + 'activityBarBadge.background': '#61afef', + 'panel.background': '#21252b', + 'panel.border': '#3e4452', + 'button.background': '#61afef', + 'button.foreground': '#ffffff', + 'input.background': '#1d1f23', + 'input.foreground': '#abb2bf', + 'input.border': '#3e4452', + 'list.activeSelectionBackground': '#2c313a', + 'list.hoverBackground': '#2c313a', + focusBorder: '#61afef', + 'textLink.foreground': '#61afef', + }, + tokenColors: [ + { scope: 'comment', settings: { foreground: '#5c6370', fontStyle: 'italic' } }, + { scope: 'string', settings: { foreground: '#98c379' } }, + { scope: 'constant.numeric', settings: { foreground: '#d19a66' } }, + { scope: 'keyword', settings: { foreground: '#c678dd' } }, + { scope: 'variable', settings: { foreground: '#e06c75' } }, + { scope: 'entity.name.function', settings: { foreground: '#61afef' } }, + { scope: 'entity.name.type', settings: { foreground: '#e5c07b' } }, + { scope: 'storage.type', settings: { foreground: '#c678dd' } }, + ], + }, +} + +/** + * Monokai Theme + */ +const monokai: VSCodeThemePreset = { + id: 'monokai', + name: 'Monokai', + preview: { + background: '#272822', + foreground: '#f8f8f2', + accent: '#a6e22e', + surface: '#1e1f1c', + }, + theme: { + name: 'Monokai', + type: 'dark', + colors: { + 'editor.background': '#272822', + 'editor.foreground': '#f8f8f2', + 'sideBar.background': '#1e1f1c', + 'sideBar.foreground': '#f8f8f2', + 'activityBar.background': '#272822', + 'activityBar.foreground': '#f8f8f2', + 'activityBarBadge.background': '#a6e22e', + 'panel.background': '#1e1f1c', + 'panel.border': '#464741', + 'button.background': '#a6e22e', + 'button.foreground': '#272822', + 'input.background': '#1e1f1c', + 'input.foreground': '#f8f8f2', + 'input.border': '#464741', + 'list.activeSelectionBackground': '#49483e', + 'list.hoverBackground': '#3e3d32', + focusBorder: '#a6e22e', + 'textLink.foreground': '#66d9ef', + }, + tokenColors: [ + { scope: 'comment', settings: { foreground: '#75715e' } }, + { scope: 'string', settings: { foreground: '#e6db74' } }, + { scope: 'constant.numeric', settings: { foreground: '#ae81ff' } }, + { scope: 'keyword', settings: { foreground: '#f92672' } }, + { scope: 'variable', settings: { foreground: '#f8f8f2' } }, + { scope: 'entity.name.function', settings: { foreground: '#a6e22e' } }, + { scope: 'entity.name.type', settings: { foreground: '#66d9ef' } }, + { scope: 'storage.type', settings: { foreground: '#f92672' } }, + ], + }, +} + +/** + * Nord Theme + * https://www.nordtheme.com/ + */ +const nord: VSCodeThemePreset = { + id: 'nord', + name: 'Nord', + preview: { + background: '#2e3440', + foreground: '#d8dee9', + accent: '#88c0d0', + surface: '#3b4252', + }, + theme: { + name: 'Nord', + type: 'dark', + colors: { + 'editor.background': '#2e3440', + 'editor.foreground': '#d8dee9', + 'sideBar.background': '#2e3440', + 'sideBar.foreground': '#d8dee9', + 'activityBar.background': '#2e3440', + 'activityBar.foreground': '#d8dee9', + 'activityBarBadge.background': '#88c0d0', + 'panel.background': '#3b4252', + 'panel.border': '#4c566a', + 'button.background': '#88c0d0', + 'button.foreground': '#2e3440', + 'input.background': '#3b4252', + 'input.foreground': '#d8dee9', + 'input.border': '#4c566a', + 'list.activeSelectionBackground': '#3b4252', + 'list.hoverBackground': '#434c5e', + focusBorder: '#88c0d0', + 'textLink.foreground': '#88c0d0', + }, + tokenColors: [ + { scope: 'comment', settings: { foreground: '#616e88', fontStyle: 'italic' } }, + { scope: 'string', settings: { foreground: '#a3be8c' } }, + { scope: 'constant.numeric', settings: { foreground: '#b48ead' } }, + { scope: 'keyword', settings: { foreground: '#81a1c1' } }, + { scope: 'variable', settings: { foreground: '#d8dee9' } }, + { scope: 'entity.name.function', settings: { foreground: '#88c0d0' } }, + { scope: 'entity.name.type', settings: { foreground: '#8fbcbb' } }, + { scope: 'storage.type', settings: { foreground: '#81a1c1' } }, + ], + }, +} + +/** + * Night Owl Theme + * https://marketplace.visualstudio.com/items?itemName=sdras.night-owl + */ +const nightOwl: VSCodeThemePreset = { + id: 'night-owl', + name: 'Night Owl', + preview: { + background: '#011627', + foreground: '#d6deeb', + accent: '#7fdbca', + surface: '#0b2942', + }, + theme: { + name: 'Night Owl', + type: 'dark', + colors: { + 'editor.background': '#011627', + 'editor.foreground': '#d6deeb', + 'sideBar.background': '#011627', + 'sideBar.foreground': '#d6deeb', + 'activityBar.background': '#011627', + 'activityBar.foreground': '#d6deeb', + 'activityBarBadge.background': '#7fdbca', + 'panel.background': '#011627', + 'panel.border': '#5f7e97', + 'button.background': '#7fdbca', + 'button.foreground': '#011627', + 'input.background': '#0b2942', + 'input.foreground': '#d6deeb', + 'input.border': '#5f7e97', + 'list.activeSelectionBackground': '#0b2942', + 'list.hoverBackground': '#1d3b53', + focusBorder: '#7fdbca', + 'textLink.foreground': '#82aaff', + }, + tokenColors: [ + { scope: 'comment', settings: { foreground: '#637777', fontStyle: 'italic' } }, + { scope: 'string', settings: { foreground: '#ecc48d' } }, + { scope: 'constant.numeric', settings: { foreground: '#f78c6c' } }, + { scope: 'keyword', settings: { foreground: '#c792ea' } }, + { scope: 'variable', settings: { foreground: '#d6deeb' } }, + { scope: 'entity.name.function', settings: { foreground: '#82aaff' } }, + { scope: 'entity.name.type', settings: { foreground: '#7fdbca' } }, + { scope: 'storage.type', settings: { foreground: '#c792ea' } }, + ], + }, +} + +/** + * GitHub Dark Theme + * https://marketplace.visualstudio.com/items?itemName=GitHub.github-vscode-theme + */ +const githubDark: VSCodeThemePreset = { + id: 'github-dark', + name: 'GitHub Dark', + preview: { + background: '#0d1117', + foreground: '#c9d1d9', + accent: '#58a6ff', + surface: '#161b22', + }, + theme: { + name: 'GitHub Dark', + type: 'dark', + colors: { + 'editor.background': '#0d1117', + 'editor.foreground': '#c9d1d9', + 'sideBar.background': '#0d1117', + 'sideBar.foreground': '#c9d1d9', + 'activityBar.background': '#0d1117', + 'activityBar.foreground': '#c9d1d9', + 'activityBarBadge.background': '#58a6ff', + 'panel.background': '#161b22', + 'panel.border': '#30363d', + 'button.background': '#238636', + 'button.foreground': '#ffffff', + 'input.background': '#0d1117', + 'input.foreground': '#c9d1d9', + 'input.border': '#30363d', + 'list.activeSelectionBackground': '#161b22', + 'list.hoverBackground': '#161b22', + focusBorder: '#58a6ff', + 'textLink.foreground': '#58a6ff', + }, + tokenColors: [ + { scope: 'comment', settings: { foreground: '#8b949e', fontStyle: 'italic' } }, + { scope: 'string', settings: { foreground: '#a5d6ff' } }, + { scope: 'constant.numeric', settings: { foreground: '#79c0ff' } }, + { scope: 'keyword', settings: { foreground: '#ff7b72' } }, + { scope: 'variable', settings: { foreground: '#c9d1d9' } }, + { scope: 'entity.name.function', settings: { foreground: '#d2a8ff' } }, + { scope: 'entity.name.type', settings: { foreground: '#7ee787' } }, + { scope: 'storage.type', settings: { foreground: '#ff7b72' } }, + ], + }, +} + +/** + * Solarized Dark Theme + * https://ethanschoonover.com/solarized/ + */ +const solarizedDark: VSCodeThemePreset = { + id: 'solarized-dark', + name: 'Solarized Dark', + preview: { + background: '#002b36', + foreground: '#839496', + accent: '#268bd2', + surface: '#073642', + }, + theme: { + name: 'Solarized Dark', + type: 'dark', + colors: { + 'editor.background': '#002b36', + 'editor.foreground': '#839496', + 'sideBar.background': '#00212b', + 'sideBar.foreground': '#839496', + 'activityBar.background': '#002b36', + 'activityBar.foreground': '#839496', + 'activityBarBadge.background': '#268bd2', + 'panel.background': '#073642', + 'panel.border': '#094554', + 'button.background': '#268bd2', + 'button.foreground': '#fdf6e3', + 'input.background': '#073642', + 'input.foreground': '#839496', + 'input.border': '#094554', + 'list.activeSelectionBackground': '#094554', + 'list.hoverBackground': '#073642', + focusBorder: '#268bd2', + 'textLink.foreground': '#268bd2', + }, + tokenColors: [ + { scope: 'comment', settings: { foreground: '#586e75', fontStyle: 'italic' } }, + { scope: 'string', settings: { foreground: '#2aa198' } }, + { scope: 'constant.numeric', settings: { foreground: '#d33682' } }, + { scope: 'keyword', settings: { foreground: '#859900' } }, + { scope: 'variable', settings: { foreground: '#839496' } }, + { scope: 'entity.name.function', settings: { foreground: '#268bd2' } }, + { scope: 'entity.name.type', settings: { foreground: '#b58900' } }, + { scope: 'storage.type', settings: { foreground: '#859900' } }, + ], + }, +} + +/** + * Tokyo Night Theme + * https://marketplace.visualstudio.com/items?itemName=enkia.tokyo-night + */ +const tokyoNight: VSCodeThemePreset = { + id: 'tokyo-night', + name: 'Tokyo Night', + preview: { + background: '#1a1b26', + foreground: '#a9b1d6', + accent: '#7aa2f7', + surface: '#16161e', + }, + theme: { + name: 'Tokyo Night', + type: 'dark', + colors: { + 'editor.background': '#1a1b26', + 'editor.foreground': '#a9b1d6', + 'sideBar.background': '#16161e', + 'sideBar.foreground': '#a9b1d6', + 'activityBar.background': '#16161e', + 'activityBar.foreground': '#a9b1d6', + 'activityBarBadge.background': '#7aa2f7', + 'panel.background': '#16161e', + 'panel.border': '#29292e', + 'button.background': '#7aa2f7', + 'button.foreground': '#1a1b26', + 'input.background': '#16161e', + 'input.foreground': '#a9b1d6', + 'input.border': '#29292e', + 'list.activeSelectionBackground': '#29292e', + 'list.hoverBackground': '#1f202e', + focusBorder: '#7aa2f7', + 'textLink.foreground': '#7aa2f7', + }, + tokenColors: [ + { scope: 'comment', settings: { foreground: '#565f89', fontStyle: 'italic' } }, + { scope: 'string', settings: { foreground: '#9ece6a' } }, + { scope: 'constant.numeric', settings: { foreground: '#ff9e64' } }, + { scope: 'keyword', settings: { foreground: '#bb9af7' } }, + { scope: 'variable', settings: { foreground: '#c0caf5' } }, + { scope: 'entity.name.function', settings: { foreground: '#7aa2f7' } }, + { scope: 'entity.name.type', settings: { foreground: '#2ac3de' } }, + { scope: 'storage.type', settings: { foreground: '#bb9af7' } }, + ], + }, +} + +/** + * All available VSCode theme presets + */ +export const vscodeThemePresets: VSCodeThemePreset[] = [ + dracula, + oneDarkPro, + monokai, + nord, + nightOwl, + githubDark, + solarizedDark, + tokyoNight, +] + +/** + * Get a preset by ID + */ +export function getPresetById(id: string): VSCodeThemePreset | undefined { + return vscodeThemePresets.find((preset) => preset.id === id) +} + +/** + * Export individual presets for direct import + */ +export { + dracula, + githubDark, + monokai, + nightOwl, + nord, + oneDarkPro, + solarizedDark, + tokyoNight, +} diff --git a/apps/studio/lib/vscode-themes/types.ts b/apps/studio/lib/vscode-themes/types.ts new file mode 100644 index 00000000000..30c37929c67 --- /dev/null +++ b/apps/studio/lib/vscode-themes/types.ts @@ -0,0 +1,230 @@ +/** + * VSCode Theme Types + * + * Type definitions for VSCode theme parsing and mapping to Supabase CSS variables. + */ + +/** + * VSCode token color definition for syntax highlighting + */ +export interface VSCodeTokenColor { + name?: string + scope: string | string[] + settings: { + foreground?: string + fontStyle?: string + } +} + +/** + * VSCode theme structure (subset of full VSCode theme format) + */ +export interface VSCodeTheme { + name: string + type: 'dark' | 'light' + colors: { + // Editor colors + 'editor.background'?: string + 'editor.foreground'?: string + 'editorLineNumber.foreground'?: string + 'editorCursor.foreground'?: string + 'editor.selectionBackground'?: string + 'editor.lineHighlightBackground'?: string + + // Sidebar colors + 'sideBar.background'?: string + 'sideBar.foreground'?: string + 'sideBarTitle.foreground'?: string + 'sideBarSectionHeader.background'?: string + + // Activity bar colors + 'activityBar.background'?: string + 'activityBar.foreground'?: string + 'activityBarBadge.background'?: string + + // Panel colors + 'panel.background'?: string + 'panel.border'?: string + 'panelTitle.activeBorder'?: string + 'panelTitle.activeForeground'?: string + 'panelTitle.inactiveForeground'?: string + + // Input colors + 'input.background'?: string + 'input.foreground'?: string + 'input.border'?: string + 'input.placeholderForeground'?: string + + // Dropdown colors + 'dropdown.background'?: string + 'dropdown.border'?: string + 'dropdown.foreground'?: string + + // Button colors + 'button.background'?: string + 'button.foreground'?: string + 'button.hoverBackground'?: string + + // List colors + 'list.activeSelectionBackground'?: string + 'list.activeSelectionForeground'?: string + 'list.hoverBackground'?: string + 'list.inactiveSelectionBackground'?: string + + // Tab colors + 'tab.activeBackground'?: string + 'tab.activeForeground'?: string + 'tab.inactiveBackground'?: string + 'tab.inactiveForeground'?: string + 'tab.border'?: string + + // Title bar colors + 'titleBar.activeBackground'?: string + 'titleBar.activeForeground'?: string + 'titleBar.inactiveBackground'?: string + 'titleBar.inactiveForeground'?: string + + // Status bar colors + 'statusBar.background'?: string + 'statusBar.foreground'?: string + 'statusBar.border'?: string + + // Terminal colors + 'terminal.background'?: string + 'terminal.foreground'?: string + + // Notification colors + 'notificationCenter.border'?: string + 'notifications.background'?: string + 'notifications.border'?: string + + // Badge colors + 'badge.background'?: string + 'badge.foreground'?: string + + // Scroll bar colors + 'scrollbarSlider.background'?: string + 'scrollbarSlider.hoverBackground'?: string + 'scrollbarSlider.activeBackground'?: string + + // Focus border + focusBorder?: string + + // Foreground + foreground?: string + + // Error/Warning colors + 'errorForeground'?: string + + // Widget colors + 'widget.shadow'?: string + + // Text link colors + 'textLink.foreground'?: string + 'textLink.activeForeground'?: string + + // Allow additional colors + [key: string]: string | undefined + } + tokenColors?: VSCodeTokenColor[] +} + +/** + * Supabase CSS variable structure (HSL format) + */ +export interface SupabaseThemeVariables { + // Background variables + '--background-default': string + '--background-alternative': string + '--background-selection': string + '--background-control': string + '--background-surface-100': string + '--background-surface-200': string + '--background-surface-300': string + '--background-overlay-default': string + '--background-overlay-hover': string + '--background-muted': string + + // Foreground variables + '--foreground-default': string + '--foreground-light': string + '--foreground-lighter': string + '--foreground-muted': string + + // Border variables + '--border-default': string + '--border-muted': string + '--border-secondary': string + '--border-overlay': string + '--border-control': string + '--border-alternative': string + '--border-strong': string + '--border-stronger': string + + // Brand variables + '--brand-default': string + '--brand-accent': string + '--brand-200': string + '--brand-300': string + '--brand-400': string + '--brand-500': string + '--brand-600': string + '--brand-link': string + + // Code block colors (for syntax highlighting) + '--code-block-1': string + '--code-block-2': string + '--code-block-3': string + '--code-block-4': string + '--code-block-5': string +} + +/** + * VSCode theme preset definition + */ +export interface VSCodeThemePreset { + id: string + name: string + theme: VSCodeTheme + preview: { + background: string + foreground: string + accent: string + surface: string + } +} + +/** + * Monaco editor theme definition + */ +export interface MonacoThemeDefinition { + base: 'vs-dark' | 'vs' | 'hc-black' + inherit: boolean + rules: Array<{ + token: string + foreground?: string + background?: string + fontStyle?: string + }> + colors: { + [key: string]: string + } +} + +/** + * VSCode theme state for the hook + */ +export interface VSCodeThemeState { + isEnabled: boolean + presetId: string | null + customThemeJson: string | null +} + +/** + * Result of theme parsing + */ +export interface ThemeParseResult { + success: boolean + theme?: VSCodeTheme + error?: string +} diff --git a/apps/studio/package.json b/apps/studio/package.json index 68d43465df2..a4449849fa0 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -33,6 +33,7 @@ "@ai-sdk/provider-utils": "^3.0.0", "@ai-sdk/react": "2.0.52", "@aws-sdk/credential-providers": "^3.830.0", + "@ctrl/tinycolor": "^3.4.0", "@dagrejs/dagre": "^1.0.4", "@dnd-kit/core": "^6.1.0", "@dnd-kit/modifiers": "^9.0.0", diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 44e56284601..26684d8ace4 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -87,6 +87,11 @@ export const LOCAL_STORAGE_KEYS = { LAST_OPENED_SIDE_BAR: (ref: string) => `last-opened-sidebar-${ref}`, + // VSCode theme settings + VSCODE_THEME_ENABLED: 'supabase-vscode-theme-enabled', + VSCODE_THEME_PRESET: 'supabase-vscode-theme-preset', + VSCODE_THEME_CUSTOM: 'supabase-vscode-theme-custom', + // Project sidebar hotkeys HOTKEY_SIDEBAR: (sidebarId: string) => `supabase-dashboard-hotkey-sidebar-${sidebarId}`, @@ -143,6 +148,9 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [ LOCAL_STORAGE_KEYS.UI_PREVIEW_BRANCHING_2_0, LOCAL_STORAGE_KEYS.LINTER_SHOW_FOOTER, LOCAL_STORAGE_KEYS.SIDEBAR_BEHAVIOR, + LOCAL_STORAGE_KEYS.VSCODE_THEME_ENABLED, + LOCAL_STORAGE_KEYS.VSCODE_THEME_PRESET, + LOCAL_STORAGE_KEYS.VSCODE_THEME_CUSTOM, ] export function clearLocalStorage() { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index faa4dd2006c..cff78e12ea7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -560,7 +560,7 @@ importers: version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nuqs: specifier: ^1.19.1 - version: 1.19.1(next@15.5.10(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)) + version: 1.19.1(next@15.5.10(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)) openai: specifier: ^4.75.1 version: 4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76) @@ -1079,6 +1079,9 @@ importers: '@aws-sdk/credential-providers': specifier: ^3.830.0 version: 3.830.0 + '@ctrl/tinycolor': + specifier: ^3.4.0 + version: 3.6.1 '@dagrejs/dagre': specifier: ^1.0.4 version: 1.0.4 @@ -11669,6 +11672,7 @@ packages: dax-sh@0.39.2: resolution: {integrity: sha512-gpuGEkBQM+5y6p4cWaw9+ePy5TNon+fdwFVtTI8leU3UhwhsBfPewRxMXGuQNC+M2b/MDGMlfgpqynkcd0C3FQ==} + deprecated: This package has moved to simply be 'dax' instead of 'dax-sh' dayjs@1.11.13: resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} @@ -13159,12 +13163,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} @@ -17772,6 +17776,7 @@ packages: scmp@2.1.0: resolution: {integrity: sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==} + deprecated: Just use Node.js's crypto.timingSafeEqual() screenfull@5.2.0: resolution: {integrity: sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==} @@ -36584,7 +36589,7 @@ snapshots: number-flow@0.3.7: {} - nuqs@1.19.1(next@15.5.10(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)): + nuqs@1.19.1(next@15.5.10(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)): dependencies: mitt: 3.0.1 next: 15.5.10(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)