diff --git a/apps/studio/components/interfaces/Account/Preferences/ThemeColorSettings.tsx b/apps/studio/components/interfaces/Account/Preferences/ThemeColorSettings.tsx new file mode 100644 index 00000000000..b67e6699fb1 --- /dev/null +++ b/apps/studio/components/interfaces/Account/Preferences/ThemeColorSettings.tsx @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Button, CardContent, Slider } from 'ui' + +import { useThemeOverrides } from '@/hooks/misc/useThemeOverrides' +import { + applyResolvedThemeOverrides, + getThemeOverrideValue, + hasThemeOverrides, + previewThemeOverride, + sliderValueToThemeOverride, + THEME_OVERRIDE_KNOBS, + ThemeOverrideKey, + ThemeOverrides, + themeOverrideToSliderValue, +} from '@/lib/theme-overrides' + +export const ThemeColorSettings = ({ isVisible = true }: { isVisible?: boolean }) => { + const { mode, overrides, setOverride, resetOverrides } = useThemeOverrides() + const [draft, setDraft] = useState({}) + const draftRef = useRef({}) + const modeRef = useRef(mode) + const overridesRef = useRef(overrides) + + modeRef.current = mode + overridesRef.current = overrides + + const writeDraft = useCallback((next: ThemeOverrides) => { + draftRef.current = next + setDraft(next) + }, []) + + useEffect(() => writeDraft({}), [mode, writeDraft]) + + useEffect( + () => () => { + const root = document.documentElement + applyResolvedThemeOverrides(root, root.dataset.theme, modeRef.current, overridesRef.current) + }, + [] + ) + + const handleReset = useCallback(() => { + writeDraft({}) + resetOverrides() + }, [resetOverrides, writeDraft]) + + const commitDraft = useCallback( + (key: ThemeOverrideKey, committed?: number) => { + const pending = draftRef.current[key] ?? committed + if (pending === undefined) return + + setOverride(key, pending) + const { [key]: _flushed, ...rest } = draftRef.current + writeDraft(rest) + }, + [setOverride, writeDraft] + ) + + if (!isVisible) return null + + return ( + +
+

Theme colors

+

+ Changes are saved separately for light and dark mode. +

+ {hasThemeOverrides(overrides) && ( + + )} +
+ +
+ {THEME_OVERRIDE_KNOBS.map((knob) => { + const rawValue = draft[knob.key] ?? getThemeOverrideValue(knob, mode, overrides) + const sliderValue = themeOverrideToSliderValue(knob, mode, rawValue) + + return ( +
+
+
+ + {knob.label} + + {knob.description} +
+ + {sliderValue} + +
+ { + const raw = sliderValueToThemeOverride(knob, mode, next) + writeDraft({ ...draftRef.current, [knob.key]: raw }) + previewThemeOverride(knob, mode, raw) + }} + onValueCommit={([next]) => + commitDraft(knob.key, sliderValueToThemeOverride(knob, mode, next)) + } + onLostPointerCapture={() => commitDraft(knob.key)} + /> +
+ ) + })} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx b/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx index 6037e19d3b0..19a1bb0d23b 100644 --- a/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx @@ -1,6 +1,6 @@ import { LOCAL_STORAGE_KEYS } from 'common' import { useTheme } from 'next-themes' -import { useEffect, useState } from 'react' +import { memo, useEffect, useState } from 'react' import SVG from 'react-inlinesvg' import { Card, @@ -13,7 +13,6 @@ import { SelectItem, SelectTrigger, SelectValue, - Separator, singleThemes, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -26,10 +25,50 @@ import { PageSectionTitle, } from 'ui-patterns/PageSection' +import { ThemeColorSettings } from './ThemeColorSettings' import { DEFAULT_SIDEBAR_BEHAVIOR } from '@/components/interfaces/Sidebar' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { BASE_PATH } from '@/lib/constants' +/** + * Declared at module scope and memoized deliberately. While this lived inside + * `ThemeSettings` React saw a brand new component type on every parent render + * and remounted the whole radio group, so the four `react-inlinesvg` previews + * restarted their fetch and rendered nothing until it resolved — collapsing + * the cards for a frame. That was invisible while the parent only re-rendered + * on a theme change, but it became a continuous flicker once dragging a + * customize-theme slider started re-rendering the parent every frame. + */ +const SingleThemeSelection = memo(function SingleThemeSelection({ + theme, + setTheme, +}: { + theme: string | undefined + setTheme: (theme: string) => void +}) { + return ( + + {singleThemes.map((themeMode) => ( + + + + ))} + + ) +}) + export const ThemeSettings = () => { const [mounted, setMounted] = useState(false) const { theme, setTheme } = useTheme() @@ -47,30 +86,6 @@ export const ThemeSettings = () => { if (!mounted) return null - function SingleThemeSelection() { - return ( - - {singleThemes.map((theme) => ( - - - - ))} - - ) - } - return ( @@ -88,16 +103,16 @@ export const ThemeSettings = () => { -

+

Choose how Supabase looks to you. Select a single theme, or sync with your system.

- +
- + { + const { resolvedTheme } = useTheme() + const { mode, overrides } = useThemeOverrides() + + useIsomorphicLayoutEffect(() => { + if (resolvedTheme === undefined) return + applyResolvedThemeOverrides(document.documentElement, resolvedTheme, mode, overrides) + }, [mode, overrides, resolvedTheme]) + + return null +} diff --git a/apps/studio/hooks/misc/useThemeOverrides.ts b/apps/studio/hooks/misc/useThemeOverrides.ts new file mode 100644 index 00000000000..71557a8e951 --- /dev/null +++ b/apps/studio/hooks/misc/useThemeOverrides.ts @@ -0,0 +1,69 @@ +import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common' +import { useTheme } from 'next-themes' +import { useCallback, useMemo } from 'react' + +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' +import { + clearThemeOverridesForMode, + mergeThemeOverride, + parseThemeOverridesByMode, + resolveThemeOverrideMode, + ThemeOverrideKey, + ThemeOverrideMode, + ThemeOverrides, + ThemeOverridesByMode, +} from '@/lib/theme-overrides' + +const EMPTY_OVERRIDES: ThemeOverrides = {} + +function readStoredThemeOverrides(): ThemeOverridesByMode { + const stored = safeLocalStorage.getItem(LOCAL_STORAGE_KEYS.UI_THEME_OVERRIDES) + if (stored === null) return {} + + try { + return parseThemeOverridesByMode(JSON.parse(stored)) + } catch { + return {} + } +} + +/** + * Reads and writes the colour-system overrides for the currently resolved + * theme mode. Stored alongside the other appearance preferences in + * localStorage under a single key, keyed by mode. + */ +export function useThemeOverrides() { + const { resolvedTheme } = useTheme() + const mode: ThemeOverrideMode = resolveThemeOverrideMode(resolvedTheme) + + const [storedOverrides, setStoredOverrides] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.UI_THEME_OVERRIDES, + readStoredThemeOverrides() + ) + const overridesByMode = useMemo( + () => parseThemeOverridesByMode(storedOverrides), + [storedOverrides] + ) + + const overrides = overridesByMode[mode] ?? EMPTY_OVERRIDES + + const setOverride = useCallback( + (key: ThemeOverrideKey, value: number) => { + setStoredOverrides((current: unknown) => + mergeThemeOverride(parseThemeOverridesByMode(current), mode, key, value) + ) + }, + [mode, setStoredOverrides] + ) + + const resetOverrides = useCallback(() => { + setStoredOverrides((current: unknown) => + clearThemeOverridesForMode(parseThemeOverridesByMode(current), mode) + ) + }, [mode, setStoredOverrides]) + + return useMemo( + () => ({ mode, overrides, setOverride, resetOverrides }), + [mode, overrides, setOverride, resetOverrides] + ) +} diff --git a/apps/studio/lib/__tests__/theme-overrides.test.ts b/apps/studio/lib/__tests__/theme-overrides.test.ts new file mode 100644 index 00000000000..fb34acc6027 --- /dev/null +++ b/apps/studio/lib/__tests__/theme-overrides.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest' + +import { + applyResolvedThemeOverrides, + applyThemeOverrides, + clearThemeOverridesForMode, + getThemeOverrideRange, + getThemeOverrideValue, + hasThemeOverrides, + mergeThemeOverride, + parseThemeOverridesByMode, + resolveThemeOverrideMode, + sliderValueToThemeOverride, + THEME_OVERRIDE_DEFAULTS, + THEME_OVERRIDE_KNOBS, + ThemeOverrideMode, + themeOverrideToSliderValue, +} from '@/lib/theme-overrides' + +const knob = (key: string) => { + const found = THEME_OVERRIDE_KNOBS.find((candidate) => candidate.key === key) + if (found === undefined) throw new Error(`No knob found for ${key}`) + return found +} + +describe.each(['dark', 'light'])('%s theme slider mappings', (mode) => { + it.each(THEME_OVERRIDE_KNOBS)('maps $label endpoints to its safe range', (setting) => { + const range = getThemeOverrideRange(setting, mode) + + expect(sliderValueToThemeOverride(setting, mode, 0)).toBe(range.min) + expect(sliderValueToThemeOverride(setting, mode, 100)).toBe(range.max) + expect(themeOverrideToSliderValue(setting, mode, range.min)).toBe(0) + expect(themeOverrideToSliderValue(setting, mode, range.max)).toBe(100) + }) + + it.each(THEME_OVERRIDE_KNOBS)('round trips $label positions', (setting) => { + for (const sliderValue of [0, 17, 50, 83, 100]) { + const rawValue = sliderValueToThemeOverride(setting, mode, sliderValue) + expect(themeOverrideToSliderValue(setting, mode, rawValue)).toBe(sliderValue) + } + }) + + it.each(THEME_OVERRIDE_KNOBS)('clamps $label outside the safe range', (setting) => { + expect(themeOverrideToSliderValue(setting, mode, Number.NEGATIVE_INFINITY)).toBe(0) + expect(themeOverrideToSliderValue(setting, mode, Number.POSITIVE_INFINITY)).toBe(100) + expect(sliderValueToThemeOverride(setting, mode, -1)).toBe( + getThemeOverrideRange(setting, mode).min + ) + expect(sliderValueToThemeOverride(setting, mode, 101)).toBe( + getThemeOverrideRange(setting, mode).max + ) + }) +}) + +describe('dark theme surface elevation', () => { + it('keeps the base surface independent while giving raised layers meaningful contrast', () => { + const surface = getThemeOverrideRange(knob('surface'), 'dark').min + const elevationStep = getThemeOverrideRange(knob('elevationStep'), 'dark').max + + expect(surface).toBe(0.12) + expect(surface + elevationStep).toBeCloseTo(0.36) + expect(surface + elevationStep * 1.5).toBeCloseTo(0.48) + }) +}) + +describe('theme override storage', () => { + it('keeps sibling settings and modes', () => { + const merged = mergeThemeOverride( + { dark: { chroma: 0.02 }, light: { surface: 0.9 } }, + 'dark', + 'contrast', + 0.7 + ) + + expect(merged).toEqual({ + dark: { chroma: 0.02, contrast: 0.7 }, + light: { surface: 0.9 }, + }) + }) + + it('removes an override returned to its displayed default', () => { + const merged = mergeThemeOverride( + { dark: { chroma: 0.02, contrast: 0.7 } }, + 'dark', + 'contrast', + THEME_OVERRIDE_DEFAULTS.dark.contrast + ) + + expect(merged).toEqual({ dark: { chroma: 0.02 } }) + }) + + it('removes an empty mode returned to its displayed defaults', () => { + const merged = mergeThemeOverride( + { dark: { contrast: 0.7 }, light: { surface: 0.9 } }, + 'dark', + 'contrast', + THEME_OVERRIDE_DEFAULTS.dark.contrast + ) + + expect(merged).toEqual({ light: { surface: 0.9 } }) + }) + + it('clears one mode without leaving an empty entry', () => { + expect( + clearThemeOverridesForMode({ dark: { contrast: 0.7 }, light: { surface: 0.9 } }, 'dark') + ).toEqual({ light: { surface: 0.9 } }) + }) + + it('parses legacy values, strips unknown data, and clamps each mode', () => { + expect( + parseThemeOverridesByMode({ + dark: { chroma: 99, surface: 0.2, unknown: 123 }, + light: { contrast: -5 }, + other: { surface: 0 }, + }) + ).toEqual({ dark: { chroma: 0.04, surface: 0.2 }, light: { contrast: 0.45 } }) + }) + + it.each([null, [], 'invalid', { dark: { chroma: 'high' } }])( + 'falls back for malformed data: %j', + (value) => expect(parseThemeOverridesByMode(value)).toEqual({}) + ) +}) + +describe('theme override application', () => { + it('uses the mode default when a setting has no override', () => { + expect(getThemeOverrideValue(knob('surface'), 'dark', {})).toBe( + THEME_OVERRIDE_DEFAULTS.dark.surface + ) + expect(getThemeOverrideValue(knob('surface'), 'light', {})).toBe( + THEME_OVERRIDE_DEFAULTS.light.surface + ) + }) + + it('writes stored settings and hands missing settings back to CSS', () => { + const root = document.createElement('html') + root.style.setProperty('--contrast', '0.7') + + applyThemeOverrides(root, 'dark', { chroma: 0.02 }) + + expect(root.style.getPropertyValue('--chroma')).toBe('0.02') + expect(root.style.getPropertyValue('--contrast')).toBe('') + }) + + it('removes stored settings from Classic Dark', () => { + const root = document.createElement('html') + root.style.setProperty('--chroma', '0.04') + + applyResolvedThemeOverrides(root, 'classic-dark', 'dark', { chroma: 0.02 }) + + expect(root.style.getPropertyValue('--chroma')).toBe('') + }) + + it('reports whether a mode has overrides', () => { + expect(hasThemeOverrides({})).toBe(false) + expect(hasThemeOverrides({ chroma: 0.02 })).toBe(true) + }) + + it('resolves classic dark as dark for legacy stored themes', () => { + expect(resolveThemeOverrideMode('dark')).toBe('dark') + expect(resolveThemeOverrideMode('classic-dark')).toBe('dark') + expect(resolveThemeOverrideMode('light')).toBe('light') + expect(resolveThemeOverrideMode(undefined)).toBe('light') + }) +}) diff --git a/apps/studio/lib/theme-overrides.ts b/apps/studio/lib/theme-overrides.ts new file mode 100644 index 00000000000..9ecbe8b741d --- /dev/null +++ b/apps/studio/lib/theme-overrides.ts @@ -0,0 +1,216 @@ +import * as z from 'zod' + +export type ThemeOverrideKey = 'chroma' | 'contrast' | 'surface' | 'elevationStep' +export type ThemeOverrideMode = 'dark' | 'light' + +type ThemeOverrideRange = { min: number; max: number } + +export interface ThemeOverrideKnob { + key: ThemeOverrideKey + cssVar: string + label: string + description: string + ranges: Record +} + +export const THEME_OVERRIDE_KNOBS: readonly ThemeOverrideKnob[] = [ + { + key: 'chroma', + cssVar: '--chroma', + label: 'Color intensity', + description: 'Controls how vivid interface colors appear.', + ranges: { dark: { min: 0, max: 0.04 }, light: { min: 0, max: 0.03 } }, + }, + { + key: 'contrast', + cssVar: '--contrast', + label: 'Contrast', + description: 'Controls the difference between text, borders, and backgrounds.', + ranges: { dark: { min: 0.4, max: 0.8 }, light: { min: 0.45, max: 0.8 } }, + }, + { + key: 'surface', + cssVar: '--surface', + label: 'Surface brightness', + description: 'Controls how light or dark background surfaces appear.', + ranges: { dark: { min: 0.12, max: 0.32 }, light: { min: 0.82, max: 0.995 } }, + }, + { + key: 'elevationStep', + cssVar: '--elevation-step', + label: 'Layer contrast', + description: 'Controls the difference between stacked surfaces.', + ranges: { dark: { min: 0, max: 0.24 }, light: { min: 0, max: 0.024 } }, + }, +] + +export const THEME_OVERRIDE_DEFAULTS: Record< + ThemeOverrideMode, + Record +> = { + dark: { chroma: 0.005, contrast: 0.5, surface: 0.19, elevationStep: 0.025 }, + light: { chroma: 0, contrast: 0.53, surface: 0.995, elevationStep: 0.024 }, +} + +export type ThemeOverrides = Partial> +export type ThemeOverridesByMode = Partial> + +const themeOverridesSchema = z + .object({ + chroma: z.number().finite().optional(), + contrast: z.number().finite().optional(), + surface: z.number().finite().optional(), + elevationStep: z.number().finite().optional(), + }) + .strip() + +const themeOverridesByModeSchema = z + .object({ dark: themeOverridesSchema.optional(), light: themeOverridesSchema.optional() }) + .strip() + +export function resolveThemeOverrideMode(resolvedTheme: string | undefined): ThemeOverrideMode { + return resolvedTheme?.includes('dark') ? 'dark' : 'light' +} + +export function getThemeOverrideRange( + knob: ThemeOverrideKnob, + mode: ThemeOverrideMode +): ThemeOverrideRange { + return knob.ranges[mode] +} + +export function clampThemeOverride( + knob: ThemeOverrideKnob, + mode: ThemeOverrideMode, + value: number +): number { + const { min, max } = getThemeOverrideRange(knob, mode) + return Math.min(max, Math.max(min, value)) +} + +export function themeOverrideToSliderValue( + knob: ThemeOverrideKnob, + mode: ThemeOverrideMode, + value: number +): number { + const { min, max } = getThemeOverrideRange(knob, mode) + const clamped = clampThemeOverride(knob, mode, value) + return Math.round(((clamped - min) / (max - min)) * 100) +} + +export function sliderValueToThemeOverride( + knob: ThemeOverrideKnob, + mode: ThemeOverrideMode, + value: number +): number { + const { min, max } = getThemeOverrideRange(knob, mode) + const clamped = Math.min(100, Math.max(0, value)) + return Number((min + (max - min) * (clamped / 100)).toFixed(6)) +} + +export function getThemeOverrideValue( + knob: ThemeOverrideKnob, + mode: ThemeOverrideMode, + overrides: ThemeOverrides +): number { + const value = overrides[knob.key] ?? THEME_OVERRIDE_DEFAULTS[mode][knob.key] + return clampThemeOverride(knob, mode, value) +} + +function isDefaultValue(knob: ThemeOverrideKnob, mode: ThemeOverrideMode, value: number): boolean { + const defaultValue = THEME_OVERRIDE_DEFAULTS[mode][knob.key] + return ( + themeOverrideToSliderValue(knob, mode, value) === + themeOverrideToSliderValue(knob, mode, defaultValue) + ) +} + +export function mergeThemeOverride( + current: ThemeOverridesByMode, + mode: ThemeOverrideMode, + key: ThemeOverrideKey, + value: number +): ThemeOverridesByMode { + const knob = THEME_OVERRIDE_KNOBS.find((candidate) => candidate.key === key) + if (knob === undefined) return current + + const nextMode = { ...current[mode] } + const clamped = clampThemeOverride(knob, mode, value) + + if (isDefaultValue(knob, mode, clamped)) delete nextMode[key] + else nextMode[key] = clamped + + if (!hasThemeOverrides(nextMode)) return clearThemeOverridesForMode(current, mode) + return { ...current, [mode]: nextMode } +} + +export function clearThemeOverridesForMode( + current: ThemeOverridesByMode, + mode: ThemeOverrideMode +): ThemeOverridesByMode { + const { [mode]: _removed, ...rest } = current + return rest +} + +export function hasThemeOverrides(overrides: ThemeOverrides): boolean { + return THEME_OVERRIDE_KNOBS.some((knob) => overrides[knob.key] !== undefined) +} + +function sanitizeThemeOverrides( + mode: ThemeOverrideMode, + overrides: ThemeOverrides +): ThemeOverrides { + return THEME_OVERRIDE_KNOBS.reduce((result, knob) => { + const value = overrides[knob.key] + if (value === undefined) return result + + const clamped = clampThemeOverride(knob, mode, value) + if (!isDefaultValue(knob, mode, clamped)) result[knob.key] = clamped + return result + }, {}) +} + +export function parseThemeOverridesByMode(value: unknown): ThemeOverridesByMode { + const parsed = themeOverridesByModeSchema.safeParse(value) + if (!parsed.success) return {} + + const dark = sanitizeThemeOverrides('dark', parsed.data.dark ?? {}) + const light = sanitizeThemeOverrides('light', parsed.data.light ?? {}) + + return { + ...(hasThemeOverrides(dark) ? { dark } : {}), + ...(hasThemeOverrides(light) ? { light } : {}), + } +} + +export function applyThemeOverrides( + root: HTMLElement, + mode: ThemeOverrideMode, + overrides: ThemeOverrides +) { + THEME_OVERRIDE_KNOBS.forEach((knob) => { + const value = overrides[knob.key] + if (value === undefined) root.style.removeProperty(knob.cssVar) + else root.style.setProperty(knob.cssVar, String(clampThemeOverride(knob, mode, value))) + }) +} + +export function applyResolvedThemeOverrides( + root: HTMLElement, + resolvedTheme: string | undefined, + mode: ThemeOverrideMode, + overrides: ThemeOverrides +) { + applyThemeOverrides(root, mode, resolvedTheme === 'classic-dark' ? {} : overrides) +} + +export function previewThemeOverride( + knob: ThemeOverrideKnob, + mode: ThemeOverrideMode, + value: number +) { + document.documentElement.style.setProperty( + knob.cssVar, + String(clampThemeOverride(knob, mode, value)) + ) +} diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index 618085cdba4..5d913d89585 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -38,6 +38,7 @@ import { ErrorBoundary } from 'react-error-boundary' import { TooltipProvider } from 'ui' import { TimestampInfoProvider } from 'ui-patterns/TimestampInfo' +import { AppearanceSettingsProvider } from '@/components/interfaces/App/AppearanceSettingsProvider' import { StudioCommandMenu } from '@/components/interfaces/App/CommandMenu' import { StudioCommandProvider as CommandProvider } from '@/components/interfaces/App/CommandMenu/StudioCommandProvider' import { FeaturePreviewContextProvider } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' @@ -224,6 +225,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { + diff --git a/apps/studio/routes/__root.tsx b/apps/studio/routes/__root.tsx index 3609593df9d..1df82f1ea1b 100644 --- a/apps/studio/routes/__root.tsx +++ b/apps/studio/routes/__root.tsx @@ -60,6 +60,7 @@ import { ErrorBoundary } from 'react-error-boundary' import { TooltipProvider } from 'ui' import { TimestampInfoProvider } from 'ui-patterns/TimestampInfo' +import { AppearanceSettingsProvider } from '@/components/interfaces/App/AppearanceSettingsProvider' import { StudioCommandMenu } from '@/components/interfaces/App/CommandMenu' import { StudioCommandProvider as CommandProvider } from '@/components/interfaces/App/CommandMenu/StudioCommandProvider' import { FeaturePreviewContextProvider } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' @@ -376,12 +377,7 @@ function RootComponent() { - + @@ -401,6 +397,7 @@ function RootComponent() { + diff --git a/apps/studio/tests/components/interfaces/Account/Preferences/ThemeColorSettings.test.tsx b/apps/studio/tests/components/interfaces/Account/Preferences/ThemeColorSettings.test.tsx new file mode 100644 index 00000000000..acb23e506e8 --- /dev/null +++ b/apps/studio/tests/components/interfaces/Account/Preferences/ThemeColorSettings.test.tsx @@ -0,0 +1,101 @@ +import { fireEvent, screen } from '@testing-library/react' +import type { ComponentProps } from 'react' +import type * as UI from 'ui' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { ThemeColorSettings } from '@/components/interfaces/Account/Preferences/ThemeColorSettings' +import { applyResolvedThemeOverrides } from '@/lib/theme-overrides' +import { customRender } from '@/tests/lib/custom-render' + +type SliderProps = ComponentProps + +const { resetOverrides, setOverride } = vi.hoisted(() => ({ + resetOverrides: vi.fn(), + setOverride: vi.fn(), +})) + +vi.mock('@/hooks/misc/useThemeOverrides', () => ({ + useThemeOverrides: () => ({ + mode: 'dark', + overrides: { chroma: 0.02 }, + resetOverrides, + setOverride, + }), +})) + +vi.mock('ui', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + Slider: (props: SliderProps) => ( +
props.onValueChange?.([100])} + onKeyUp={() => props.onValueCommit?.([100])} + onLostPointerCapture={props.onLostPointerCapture} + /> + ), + } +}) + +describe('ThemeColorSettings', () => { + beforeEach(() => { + resetOverrides.mockReset() + setOverride.mockReset() + document.documentElement.style.removeProperty('--chroma') + document.documentElement.dataset.theme = 'dark' + }) + + it('persists a rapid pointer change for the active mode', () => { + customRender() + + const slider = screen.getByRole('slider', { name: 'Color intensity' }) + fireEvent.click(slider) + fireEvent.lostPointerCapture(slider) + + expect(setOverride).toHaveBeenCalledWith('chroma', 0.04) + }) + + it('persists an ordinary committed change', () => { + customRender() + + const slider = screen.getByRole('slider', { name: 'Color intensity' }) + fireEvent.click(slider) + fireEvent.keyUp(slider) + + expect(setOverride).toHaveBeenCalledWith('chroma', 0.04) + }) + + it('resets the active mode', () => { + customRender() + + fireEvent.click(screen.getByRole('button', { name: 'Reset' })) + + expect(resetOverrides).toHaveBeenCalledOnce() + }) + + it('restores persisted values when an active preview unmounts', () => { + const { unmount } = customRender() + + fireEvent.click(screen.getByRole('slider', { name: 'Color intensity' })) + expect(document.documentElement.style.getPropertyValue('--chroma')).toBe('0.04') + + unmount() + + expect(document.documentElement.style.getPropertyValue('--chroma')).toBe('0.02') + }) + + it('does not restore persisted Dark values when switching to Classic Dark', () => { + const { rerender } = customRender() + + fireEvent.click(screen.getByRole('slider', { name: 'Color intensity' })) + applyResolvedThemeOverrides(document.documentElement, 'classic-dark', 'dark', { chroma: 0.02 }) + rerender() + + expect(document.documentElement.style.getPropertyValue('--chroma')).toBe('') + }) +}) diff --git a/apps/studio/vite.config.ts b/apps/studio/vite.config.ts index 1b860d0e2fa..9ac9552668a 100644 --- a/apps/studio/vite.config.ts +++ b/apps/studio/vite.config.ts @@ -629,6 +629,11 @@ export default defineConfig(({ command, mode }) => { // whichever page/feature chunk happens to be its only non-barrel // importer (a form page, `components/interfaces/Sidebar.tsx`) // while `ui`'s barrel re-exports them too. + // + // `packages/ui/src/components/shadcn/ui/slider.tsx` — same shape + // again: its only non-barrel importer is the Appearance settings' + // `ThemeColorSettings`, so Rolldown pools it into the `/account/me` + // page chunk while `ui`'s barrel re-exports it too. build: { rollupOptions: { output: { @@ -665,6 +670,9 @@ export default defineConfig(({ command, mode }) => { ) { return 'ui-sidebar' } + if (id.includes('packages/ui/src/components/shadcn/ui/slider.tsx')) { + return 'ui-slider' + } return undefined }, }, diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 23808138393..d713fbe3026 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -17,6 +17,7 @@ export const LOCAL_STORAGE_KEYS = { UNIFIED_LOGS_DOCK: 'unified-logs-dock', UI_TIMEZONE: 'supabase-ui-timezone', + UI_THEME_OVERRIDES: 'supabase-ui-theme-overrides', UI_PREVIEW_CLS: 'supabase-ui-cls', UI_PREVIEW_INLINE_EDITOR: 'supabase-ui-preview-inline-editor', UI_PREVIEW_UNIFIED_LOGS: 'supabase-ui-preview-unified-logs', @@ -180,6 +181,7 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [ LOCAL_STORAGE_KEYS.LINTER_SHOW_FOOTER, LOCAL_STORAGE_KEYS.SIDEBAR_BEHAVIOR, LOCAL_STORAGE_KEYS.UI_TIMEZONE, + LOCAL_STORAGE_KEYS.UI_THEME_OVERRIDES, ] export function clearLocalStorage() {