import { defineStore, getActivePinia } from 'pinia' import { ref, watch } from 'vue' import type { ThemeMode, ColorPalette, TableDensity } from '../types' declare global { interface ImportMetaEnv { readonly VITE_API_PREFIX?: string } interface ImportMeta { readonly env: ImportMetaEnv } } export interface SettingsState { serverHost: string serverPort: number requestTimeout: number wsAutoReconnect: boolean wsReconnectInterval: number theme: ThemeMode colorPalette: ColorPalette tableDensity: TableDensity } const STORAGE_KEY = 'mint-settings' const LEGACY_STORAGE_KEY = 'mld-settings' function getDefaultServerHost(): string { if (typeof window !== 'undefined' && window.location.hostname !== 'localhost') { return window.location.hostname } return 'localhost' } function getDefaultServerPort(): number { if (typeof window !== 'undefined') { if (window.location.port) { return parseInt(window.location.port, 10) } // Standard ports: 443 for HTTPS, 80 for HTTP (browser omits from location.port) return window.location.protocol === 'https:' ? 443 : 80 } return 8000 } const defaultSettings: SettingsState = { serverHost: getDefaultServerHost(), serverPort: getDefaultServerPort(), requestTimeout: 120000, wsAutoReconnect: true, wsReconnectInterval: 5000, theme: 'system', colorPalette: 'default', tableDensity: 'normal', } export const colorPalettes: Record = { default: { name: 'Default (Cyan-Pink)', hues: [180, 320] }, colorblind: { name: 'Colorblind-friendly', hues: [45, 260] }, viridis: { name: 'Viridis', hues: [280, 80] }, pastel: { name: 'Pastel', hues: [200, 340] }, } /** * Custom properties a non-default palette overrides on ``. * * The saturation/lightness pairs are lifted from the original indigo/orange * tokens in `styles/variables.css` so only the hue moves; the `default` * palette writes nothing at all and the stylesheet values stand. */ export const PALETTE_CSS_VARIABLES = [ '--color-primary', '--color-primary-hover', '--color-primary-soft', '--color-cta', '--color-cta-hover', ] as const /** Build the `` custom-property overrides for a palette. */ export function paletteCssVariables(palette: ColorPalette): Record { if (palette === 'default') return {} const [primaryHue, accentHue] = colorPalettes[palette].hues return { '--color-primary': `hsl(${primaryHue} 84% 67%)`, '--color-primary-hover': `hsl(${primaryHue} 75% 59%)`, '--color-primary-soft': `hsl(${primaryHue} 84% 67% / 0.12)`, '--color-cta': `hsl(${accentHue} 95% 53%)`, '--color-cta-hover': `hsl(${accentHue} 90% 48%)`, } } /** `DataFrame`-style size that each table density maps onto. */ const DENSITY_TABLE_SIZE: Record = { compact: 'sm', normal: 'md', comfortable: 'lg', } /** Map a table density onto the `size` prop shared by the SDK table components. */ export function tableDensityToSize(density: TableDensity): 'sm' | 'md' | 'lg' { return DENSITY_TABLE_SIZE[density] } function loadSettings(): SettingsState { try { const stored = localStorage.getItem(STORAGE_KEY) if (stored) { const parsed = JSON.parse(stored) return { ...defaultSettings, ...parsed } } const legacyStored = localStorage.getItem(LEGACY_STORAGE_KEY) if (legacyStored) { const parsed = JSON.parse(legacyStored) const migrated = { ...defaultSettings, ...parsed } localStorage.setItem(STORAGE_KEY, JSON.stringify(migrated)) localStorage.removeItem(LEGACY_STORAGE_KEY) return migrated } } catch (e) { console.warn('Failed to load settings from localStorage:', e) } return { ...defaultSettings } } function saveSettings(settings: SettingsState): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)) } catch (e) { console.warn('Failed to save settings to localStorage:', e) } } function getSystemPrefersDark(): boolean { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false return window.matchMedia('(prefers-color-scheme: dark)').matches } export const useSettingsStore = defineStore('mint-settings', () => { // State const serverHost = ref(defaultSettings.serverHost) const serverPort = ref(defaultSettings.serverPort) const requestTimeout = ref(defaultSettings.requestTimeout) const wsAutoReconnect = ref(defaultSettings.wsAutoReconnect) const wsReconnectInterval = ref(defaultSettings.wsReconnectInterval) const theme = ref(defaultSettings.theme) const systemPrefersDark = ref(getSystemPrefersDark()) const colorPalette = ref(defaultSettings.colorPalette) const tableDensity = ref(defaultSettings.tableDensity) // API prefix - can be configured via env variable VITE_API_PREFIX const apiPrefix: string = (import.meta.env?.VITE_API_PREFIX as string | undefined) ?? '/api' function _isSameOrigin(): { same: boolean; currentHost: string; currentPort: string } { const currentHost = typeof window !== 'undefined' ? window.location.hostname : 'localhost' const currentPort = typeof window !== 'undefined' ? window.location.port : '8000' const effectivePort = currentPort || (window.location.protocol === 'https:' ? '443' : '80') const same = (serverHost.value === currentHost && String(serverPort.value) === effectivePort) || (serverHost.value === 'localhost' && currentHost !== 'localhost') return { same, currentHost, currentPort } } function getApiBaseUrl(): string { const { same } = _isSameOrigin() if (same) return apiPrefix return `http://${serverHost.value}:${serverPort.value}${apiPrefix}` } function getWsBaseUrl(): string { const { same, currentHost, currentPort } = _isSameOrigin() const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:' if (same) return `${protocol}//${currentHost}${currentPort ? ':' + currentPort : ''}${apiPrefix}` return `ws://${serverHost.value}:${serverPort.value}${apiPrefix}` } let _initialized = false function initialize() { if (_initialized) return _initialized = true const loaded = loadSettings() serverHost.value = loaded.serverHost serverPort.value = loaded.serverPort requestTimeout.value = loaded.requestTimeout wsAutoReconnect.value = loaded.wsAutoReconnect wsReconnectInterval.value = loaded.wsReconnectInterval theme.value = loaded.theme colorPalette.value = loaded.colorPalette tableDensity.value = loaded.tableDensity applyTheme() applyPalette() applyDensity() } function applyTheme() { if (typeof document === 'undefined') return const dark = theme.value === 'system' ? systemPrefersDark.value : theme.value === 'dark' document.documentElement.classList.toggle('dark', dark) } /** * Write the selected palette's hues onto `` as custom-property * overrides. The `default` palette clears them so `styles/variables.css` * keeps ownership of the brand colors. */ function applyPalette() { if (typeof document === 'undefined') return const style = document.documentElement.style const overrides = paletteCssVariables(colorPalette.value) for (const name of PALETTE_CSS_VARIABLES) { const value = overrides[name] if (value) style.setProperty(name, value) else style.removeProperty(name) } } /** Mirror the table density onto `` for CSS consumers. */ function applyDensity() { if (typeof document === 'undefined') return document.documentElement.dataset.density = tableDensity.value } watch(theme, () => { applyTheme() persistSettings() }) watch(colorPalette, applyPalette) watch(tableDensity, applyDensity) if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (event) => { systemPrefersDark.value = event.matches if (theme.value === 'system') { applyTheme() } }) } function persistSettings() { saveSettings({ serverHost: serverHost.value, serverPort: serverPort.value, requestTimeout: requestTimeout.value, wsAutoReconnect: wsAutoReconnect.value, wsReconnectInterval: wsReconnectInterval.value, theme: theme.value, colorPalette: colorPalette.value, tableDensity: tableDensity.value, }) } watch([serverHost, serverPort, requestTimeout, wsAutoReconnect, wsReconnectInterval, colorPalette, tableDensity], () => { persistSettings() }) function resetToDefaults() { serverHost.value = defaultSettings.serverHost serverPort.value = defaultSettings.serverPort requestTimeout.value = defaultSettings.requestTimeout wsAutoReconnect.value = defaultSettings.wsAutoReconnect wsReconnectInterval.value = defaultSettings.wsReconnectInterval theme.value = defaultSettings.theme colorPalette.value = defaultSettings.colorPalette tableDensity.value = defaultSettings.tableDensity } function getPaletteHues(): [number, number] { return colorPalettes[colorPalette.value].hues } /** The `size` the SDK table components default to for the current density. */ function getTableSize(): 'sm' | 'md' | 'lg' { return tableDensityToSize(tableDensity.value) } function isDark(): boolean { return theme.value === 'system' ? systemPrefersDark.value : theme.value === 'dark' } return { serverHost, serverPort, requestTimeout, wsAutoReconnect, wsReconnectInterval, theme, systemPrefersDark, colorPalette, tableDensity, initialize, applyTheme, applyPalette, applyDensity, persistSettings, resetToDefaults, getPaletteHues, getTableSize, isDark, getApiBaseUrl, getWsBaseUrl, } }) /** * Read the settings store without requiring Pinia to be installed. * * SDK components are mounted standalone (unit tests, Histoire stories, plugin * previews) where no Pinia instance exists. They use this to pick up a user's * preferences when the platform is running and fall back to their own defaults * otherwise, instead of throwing. */ export function tryUseSettingsStore(): ReturnType | null { if (!getActivePinia()) return null try { return useSettingsStore() } catch { return null } }