This commit is contained in:
Jordi Enric committed 2026-02-05 19:41:31 +01:00
1 parent ad5442153a
commit c6087f2ead
15 files changed
+2337 -9

No files matched your search

@@ -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 = () => {
</Select_Shadcn_>
</FormItemLayout>
</CardContent>
<VSCodeThemeSettings />
</Card>
</PageSectionContent>
</PageSection>
@@ -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 (
<button
type="button"
onClick={onSelect}
className={cn(
'relative flex flex-col rounded-lg border p-3 text-left transition-all hover:border-foreground-muted',
isSelected ? 'border-brand ring-1 ring-brand' : 'border-default'
)}
>
{/* Theme preview colors */}
<div className="flex gap-1 mb-2">
<div
className="w-8 h-8 rounded"
style={{ backgroundColor: preset.preview.background }}
title="Background"
/>
<div
className="w-8 h-8 rounded"
style={{ backgroundColor: preset.preview.surface }}
title="Surface"
/>
<div
className="w-8 h-8 rounded"
style={{ backgroundColor: preset.preview.accent }}
title="Accent"
/>
<div
className="w-8 h-8 rounded border border-default"
style={{ backgroundColor: preset.preview.foreground }}
title="Foreground"
/>
</div>
{/* Theme name */}
<span className="text-sm font-medium text-foreground">{preset.name}</span>
{/* Selected indicator */}
{isSelected && (
<div className="absolute top-2 right-2 flex h-5 w-5 items-center justify-center rounded-full bg-brand">
<Check className="h-3 w-3 text-brand-600" strokeWidth={3} />
</div>
)}
</button>
)
}
/**
* 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<string | null>(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 (
<>
<Separator />
<CardContent className="grid grid-cols-12 gap-6">
<div className="col-span-full md:col-span-4 flex flex-col gap-2">
<Label_Shadcn_ htmlFor="vscode-theme" className="text-foreground flex items-center gap-2">
<Code2 className="h-4 w-4" />
VSCode Theme
</Label_Shadcn_>
<p className="text-sm text-foreground-light">
Customize the Studio UI with your favorite VSCode themes. Choose from popular presets or
paste your own theme JSON.
</p>
</div>
<div className="col-span-full md:col-span-8 flex flex-col gap-4">
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between">
<Label_Shadcn_ htmlFor="vscode-theme-toggle" className="text-sm text-foreground-light">
Enable VSCode theme
</Label_Shadcn_>
<Switch id="vscode-theme-toggle" checked={isEnabled} onCheckedChange={handleToggle} />
</div>
{isEnabled && (
<>
{/* Theme Presets Grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{presets.map((preset) => (
<ThemePreviewCard
key={preset.id}
preset={preset}
isSelected={currentPresetId === preset.id}
onSelect={() => handlePresetSelect(preset.id)}
/>
))}
</div>
{/* Custom Theme Toggle */}
<div className="flex items-center gap-2">
<Button
type="default"
size="small"
onClick={() => setShowCustomInput(!showCustomInput)}
>
{showCustomInput ? 'Hide custom theme' : 'Use custom theme'}
</Button>
{isUsingCustomTheme && (
<span className="text-xs text-foreground-light">Currently using custom theme</span>
)}
</div>
{/* Custom Theme Input */}
{showCustomInput && (
<Card className="bg-surface-100">
<CardContent className="p-4 space-y-4">
<div className="space-y-2">
<Label_Shadcn_ htmlFor="custom-theme-json" className="text-sm">
Custom VSCode Theme JSON
</Label_Shadcn_>
<TextArea_Shadcn_
id="custom-theme-json"
value={customJson}
onChange={(e) => {
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]"
/>
<p className="text-xs text-foreground-muted">
Paste a VSCode theme JSON. Must include &quot;name&quot;, &quot;type&quot;
(dark/light), and &quot;colors&quot; object.
</p>
</div>
{customError && (
<Alert_Shadcn_ variant="warning">
<AlertCircle className="h-4 w-4" />
<AlertTitle_Shadcn_>Invalid theme</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>{customError}</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
<div className="flex gap-2">
<Button
type="primary"
size="small"
onClick={handleApplyCustomTheme}
disabled={!customJson.trim()}
>
Apply Theme
</Button>
<Button type="default" size="small" onClick={handleResetCustomTheme}>
Reset
</Button>
</div>
</CardContent>
</Card>
)}
</>
)}
</div>
</CardContent>
</>
)
}
@@ -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
}
@@ -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() {
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex items-center gap-2">
<Code2 size={14} strokeWidth={1.5} className="text-foreground-lighter" />
<span>VSCode Theme</span>
{isVSCodeThemeEnabled && currentPreset && (
<span className="ml-auto text-xs text-foreground-muted truncate max-w-[80px]">
{currentPreset.name}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-48">
<DropdownMenuItem
onClick={() => disableVSCodeTheme()}
className="flex items-center justify-between"
>
<span>Off</span>
{!isVSCodeThemeEnabled && <Check size={14} className="text-brand" />}
</DropdownMenuItem>
<DropdownMenuSeparator />
{vscodePresets.map((preset) => (
<DropdownMenuItem
key={preset.id}
onClick={() => enableVSCodePreset(preset.id)}
className="flex items-center gap-2"
>
<div className="flex gap-0.5 shrink-0">
<div
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: preset.preview.background }}
/>
<div
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: preset.preview.accent }}
/>
</div>
<span className="flex-1 truncate">{preset.name}</span>
{isVSCodeThemeEnabled && currentPresetId === preset.id && (
<Check size={14} className="text-brand shrink-0" />
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href="/account/me"
className="text-xs text-foreground-light"
onClick={() => {
if (router.pathname !== '/account/me') {
appStateSnapshot.setLastRouteBeforeVisitingAccountPage(router.asPath)
}
}}
>
Custom theme...
</Link>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
{IS_PLATFORM && (
<>
+278
View File
@@ -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<VSCodeThemeState>(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,
}
}
+184
View File
@@ -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<SupabaseThemeVariables> | null {
if (typeof document === 'undefined') return null
const root = document.querySelector(':root') as HTMLElement
if (!root) return null
const variables: Partial<SupabaseThemeVariables> = {}
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
}
+49
View File
@@ -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'
+319
View File
@@ -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<string, string> = { ...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'),
}
}
+212
View File
@@ -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<string, string> = {
// 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<string>()
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<string, string> = {}
// Map VSCode colors to Monaco editor colors
const colorMappings: Record<string, string> = {
'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, '-')}`
}
+278
View File
@@ -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<string, unknown>
return (
typeof t.name === 'string' &&
(t.type === 'dark' || t.type === 'light') &&
typeof t.colors === 'object' &&
t.colors !== null
)
}
@@ -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,
}
+230
View File
@@ -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
}
+1
View File
@@ -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",
@@ -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() {
+9 -4
View File
@@ -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)