diff --git a/docs/THEME_SYSTEM.md b/docs/THEME_SYSTEM.md index 9160faa2..3fcb9646 100644 --- a/docs/THEME_SYSTEM.md +++ b/docs/THEME_SYSTEM.md @@ -60,7 +60,7 @@ const builtinThemes = { }; ``` -Self-hosters can override these at build time via `ditto.json` (injected through `__DITTO_CONFIG__` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`. +Self-hosters can override these at build time via `ditto.json` (injected through `import.meta.env.DITTO_CONFIG` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`. ### ThemeConfig diff --git a/src/App.tsx b/src/App.tsx index 374f2938..a7526894 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard"; import type { AppConfig } from "@/contexts/AppContext"; import { NWCProvider } from "@/contexts/NWCContext"; import { PROTOCOL_MODE } from "@/lib/dmConstants"; +import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas"; import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext"; import AppRouter from "./AppRouter"; @@ -149,15 +150,30 @@ const hardcodedConfig: AppConfig = { imageQuality: 'compressed', }; +/** + * Parse and validate build-time ditto.json overrides from the env string. + * Returns an empty object when no config file was provided or validation fails. + */ +function parseDittoConfig(): DittoConfig { + try { + const json = JSON.parse(import.meta.env.DITTO_CONFIG); + if (!json) return {}; + return DittoConfigSchema.parse(json); + } catch { + return {}; + } +} + /** * Merge hardcoded defaults with build-time ditto.json overrides. + * Deep-merges feedSettings so a partial override doesn't erase defaults. * Precedence (handled by AppProvider): user localStorage > build-time > hardcoded. */ +const dittoConfig = parseDittoConfig(); const defaultConfig: AppConfig = { ...hardcodedConfig, - ...(typeof __DITTO_CONFIG__ !== "undefined" && __DITTO_CONFIG__ - ? __DITTO_CONFIG__ - : {}), + ...dittoConfig, + feedSettings: { ...hardcodedConfig.feedSettings, ...dittoConfig.feedSettings }, }; export function App() { diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index 8ee84469..b495defc 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -1,7 +1,7 @@ import { ReactNode, useLayoutEffect, useEffect, useRef } from 'react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { AppContext, type AppConfig, type AppContextType, type Theme } from '@/contexts/AppContext'; -import { builtinThemes, themePresets, buildThemeCssFromCore, resolveTheme, resolveThemeConfig, type ThemeConfig, type ThemesConfig } from '@/themes'; +import { builtinThemes, buildThemeCssFromCore, resolveTheme, resolveThemeConfig, type ThemeConfig, type ThemesConfig } from '@/themes'; import { AppConfigSchema } from '@/lib/schemas'; import { loadAndApplyFont, loadAndApplyTitleFont } from '@/lib/fontLoader'; import { hslToRgb, parseHsl, rgbToHex } from '@/lib/colorUtils'; @@ -47,13 +47,6 @@ export function AppProvider(props: AppProviderProps) { } } - // Migrate legacy theme values ("black", "pink") to "custom" + customTheme - const legacyTheme = result.theme as string | undefined; - if (legacyTheme && legacyTheme in themePresets) { - result.theme = 'custom'; - result.customTheme = { colors: themePresets[legacyTheme].colors }; - } - // Migrate legacy blossomServers (string[]) to blossomServerMetadata if (!result.blossomServerMetadata) { const legacyServers = parsed.blossomServers; diff --git a/src/components/NostrSync.tsx b/src/components/NostrSync.tsx index d5ae7d42..4e8b85bc 100644 --- a/src/components/NostrSync.tsx +++ b/src/components/NostrSync.tsx @@ -9,7 +9,7 @@ import { isSyncDone } from "@/hooks/useInitialSync"; import { parseBlossomServerList } from "@/lib/appBlossom"; import { ACTIVE_THEME_KIND, parseActiveProfileTheme } from "@/lib/themeEvent"; import type { ThemeConfig } from "@/themes"; -import { themePresets } from "@/themes"; + /** * NostrSync - Syncs user's Nostr data @@ -285,19 +285,7 @@ export function NostrSync() { const updates = { ...current }; if (encryptedSettings.theme) { - // Migrate legacy theme values ("black", "pink") from older encrypted settings - const remoteTheme = encryptedSettings.theme as string; - if (remoteTheme in themePresets) { - if ( - current.theme !== "custom" || - JSON.stringify(current.customTheme?.colors) !== - JSON.stringify(themePresets[remoteTheme].colors) - ) { - updates.theme = "custom"; - updates.customTheme = { colors: themePresets[remoteTheme].colors }; - changed = true; - } - } else if (encryptedSettings.theme !== current.theme) { + if (encryptedSettings.theme !== current.theme) { updates.theme = encryptedSettings.theme; changed = true; } diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 40fce382..fc5880b3 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -8,11 +8,6 @@ import type { CoreThemeColors, ThemeConfig, ThemesConfig } from '@/themes'; /** Zod schema for Theme validation */ export const ThemeSchema = z.enum(['dark', 'light', 'system', 'custom']) satisfies z.ZodType; -/** - * Accepts current theme values as well as legacy values ("black", "pink") - * from older configs. Consumers should migrate legacy values to "custom". - */ -export const ThemeSchemaCompat = z.enum(['dark', 'light', 'system', 'custom', 'black', 'pink']); /** HSL value string like "258 70% 55%" */ const HslValue = z.string().regex(/^\d/); @@ -208,10 +203,8 @@ export const SavedFeedSchema = z.object({ /** * Zod schema for the full AppConfig stored in localStorage. * - * Uses compat sub-schemas (ThemeSchemaCompat, ThemeConfigCompatSchema) so - * legacy values parse successfully. Migration from legacy theme values - * ("black", "pink") to "custom" + customTheme is handled downstream by - * the AppProvider deserializer. + * Uses ThemeConfigCompatSchema for the customTheme field so legacy + * 19-token color objects still parse successfully. */ export const AppConfigSchema = z.object({ appName: z.string().optional(), @@ -220,7 +213,7 @@ export const AppConfigSchema = z.object({ clientName: z.string().optional(), client: z.string().optional(), magicMouse: z.boolean().optional(), - theme: ThemeSchemaCompat, + theme: ThemeSchema, customTheme: ThemeConfigCompatSchema.optional(), autoShareTheme: z.boolean(), themes: ThemesConfigSchema.optional(), @@ -298,7 +291,7 @@ export const ContentFilterSchema = z.object({ * Uses looseObject to preserve unknown keys from newer app versions. */ export const EncryptedSettingsSchema = z.looseObject({ - theme: ThemeSchemaCompat.optional(), + theme: ThemeSchema.optional(), customTheme: ThemeConfigCompatSchema.optional(), autoShareTheme: z.boolean().optional(), useAppRelays: z.boolean().optional(), diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 8e9cae86..3e4ef7cb 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -15,10 +15,6 @@ interface ImportMetaEnv { readonly COMMIT_SHA: string; /** Git tag for the current commit (e.g., "v2.0.0"). Empty string if untagged (pre-release build). */ readonly COMMIT_TAG: string; + /** Build-time configuration injected from ditto.json as a JSON string. `"null"` when no config file was provided. */ + readonly DITTO_CONFIG: string; } - -/** - * Build-time configuration injected by Vite from ditto.json. - * `null` when no config file was provided at build time. - */ -declare const __DITTO_CONFIG__: Partial | null; diff --git a/vite.config.ts b/vite.config.ts index c1a6e711..6797c040 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -136,7 +136,7 @@ export default defineConfig(() => { ...(publicDir ? [mergePublicDir(publicDir)] : []), ], define: { - __DITTO_CONFIG__: JSON.stringify(dittoConfig ?? null), + 'import.meta.env.DITTO_CONFIG': JSON.stringify(JSON.stringify(dittoConfig ?? null)), 'import.meta.env.VERSION': JSON.stringify(pkg.version), 'import.meta.env.BUILD_DATE': JSON.stringify(new Date().toISOString()), 'import.meta.env.COMMIT_SHA': JSON.stringify(getCommitSha()),