Files
eranos/docs/THEME_SYSTEM.md
T
Alex Gleason 22f13c1505 Replace __DITTO_CONFIG__ global with import.meta.env.DITTO_CONFIG and remove ThemeSchemaCompat
Move build-time ditto.json injection from a Vite define global to
import.meta.env.DITTO_CONFIG (a JSON string parsed and validated at
runtime via DittoConfigSchema). Remove the global type declaration
from vite-env.d.ts.

Drop ThemeSchemaCompat and its legacy "black"/"pink" migration code
from AppProvider and NostrSync — invalid theme values now simply fail
Zod validation.

Fix a latent bug where a partial feedSettings from ditto.json would
replace the full hardcoded defaults; defaultConfig now deep-merges
feedSettings.
2026-04-01 23:16:33 -05:00

18 KiB

Theme System

This document describes the two separate but overlapping theme features in Ditto: the App Theme (which controls the local UI) and the Profile Theme (which is published to Nostr for others to see). Understanding the distinction is key to working with this codebase.

Overview

Concept Purpose Scope Persistence
App Theme Controls colors, fonts, and background of the local UI Local to the user's browser localStorage + encrypted NIP-78 sync
Profile Theme A set of theme values published as a Nostr event Public, visible to other users Kind 16767 replaceable event

The App Theme and Profile Theme share the same underlying data structure (ThemeConfig), and there is an optional bridge between them (autoShareTheme), but they are fundamentally independent systems.


Part 1: App Theme

The App Theme controls what the user sees in their own browser. It has no inherent connection to Nostr.

Core Concept: 3 Colors Define Everything

The entire theme is derived from just 3 core colors, defined by the CoreThemeColors interface in src/themes.ts:8:

interface CoreThemeColors {
  background: string;  // HSL string, e.g. "228 20% 10%"
  text: string;        // Text/foreground color
  primary: string;     // Primary accent (buttons, links, focus rings)
}

From these 3 values, the system auto-derives 19 CSS tokens (the full ThemeTokens set) via deriveTokensFromCore() in src/lib/colorUtils.ts:141. The derivation algorithm:

  • Detects dark/light mode from background luminance (threshold: 0.2)
  • Derives card and popover surfaces by slightly lightening the background (dark mode) or using it directly (light mode)
  • Derives secondary and muted surfaces by adjusting background lightness
  • Derives border using the primary hue with reduced saturation
  • Computes mutedForeground as a dimmer version of the text color
  • Sets accent = primary and ring = primary
  • Auto-computes primaryForeground using WCAG contrast detection (white or dark)
  • Uses fixed red values for destructive / destructiveForeground

Theme Modes

The Theme type (src/contexts/AppContext.ts:9) has four values:

Mode Behavior
"light" Uses the builtin (or configured) light color set
"dark" Uses the builtin (or configured) dark color set
"system" Resolves to "light" or "dark" based on prefers-color-scheme, with a live media query listener
"custom" Uses user-defined colors stored in config.customTheme

Builtin themes are defined in src/themes.ts:102:

const builtinThemes = {
  light: { background: '270 50% 97%', text: '270 25% 12%', primary: '270 65% 55%' },
  dark:  { background: '228 20% 10%', text: '210 40% 98%', primary: '258 70% 60%' },
};

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

The ThemeConfig type (src/themes.ts:50) wraps the 3 core colors with optional extras:

interface ThemeConfig {
  title?: string;
  colors: CoreThemeColors;
  font?: ThemeFont;        // { family: string; url?: string }
  background?: ThemeBackground;  // { url: string; mode?: 'cover' | 'tile'; ... }
}

This is the canonical type used everywhere: in AppConfig.customTheme, in encrypted settings, and in Nostr theme events.

Theme Presets

Named presets are defined in src/themes.ts:136 (e.g. pink, toxic, sunset). Each preset includes core colors and optionally a font and background image. Applying a preset sets the app theme to "custom" and stores the preset's config as customTheme.

How Themes Apply to the DOM

The theme pipeline has three stages designed to prevent any flash of wrong colors:

Stage 1: Pre-React Blocking Script (public/theme.js)

A synchronous <script> tag in index.html:43 runs before React mounts. It:

  1. Reads nostr:app-config from localStorage
  2. Resolves "system" via matchMedia
  3. Handles legacy presets ("black", "pink")
  4. Sets document.documentElement.className to the theme name
  5. Sets document.body.style.background to the correct background color
  6. Updates preloader colors (logo and spinner) to match

This prevents any visible flash between the hardcoded dark defaults in index.html:32 and the user's actual theme.

Stage 2: React Provider (src/components/AppProvider.tsx)

Three private hooks run during the provider's lifecycle:

useApplyTheme (line 91) - Uses useLayoutEffect (synchronous before paint) to:

  • Resolve the theme mode
  • Build a full CSS string from CoreThemeColors via buildThemeCssFromCore()
  • Inject/update a <style id="theme-vars"> element with all 19 CSS custom properties
  • Set document.documentElement.className to the resolved theme
  • Remove the inline body style left by theme.js
  • When mode is "system", attach a matchMedia change listener

useApplyFonts (line 133) - Loads and applies custom fonts via loadAndApplyFont() from src/lib/fontLoader.ts.

useApplyBackground (line 156) - Injects/removes a <style id="theme-background"> for background images (cover or tile mode).

Stage 3: Theme Switch (src/hooks/useTheme.ts)

The setTheme() function (line 52) performs a flicker-free theme switch:

  1. Injects a temporary <style> that disables all CSS transitions (transition: none !important)
  2. Synchronously builds and applies CSS vars before React re-renders
  3. Updates document.documentElement.className
  4. Re-enables transitions after browser paint via requestAnimationFrame
  5. Updates localStorage config
  6. Debounce-syncs to encrypted NIP-78 storage (1 second delay)

How Components Consume Theme Values

CSS Custom Properties to Tailwind

tailwind.config.ts maps all 19 CSS custom properties to Tailwind color utilities:

colors: {
  background: 'hsl(var(--background))',
  foreground: 'hsl(var(--foreground))',
  primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' },
  // ... (secondary, destructive, muted, accent, popover, card, border, input, ring)
}

Components use standard Tailwind classes like bg-primary, text-foreground, border-border, etc. These resolve to hsl(var(--primary)), which picks up whichever values are currently set on :root.

The cn() utility in src/lib/utils.ts combines clsx (conditional class joining) with tailwind-merge (intelligent Tailwind class deduplication).

Static CSS

src/index.css applies base styles using theme tokens:

* { @apply border-border; }
body { @apply bg-background text-foreground; }

The only static CSS custom property is --radius: 0.75rem. All color variables are injected dynamically.

ScopedTheme

The ScopedTheme component (src/components/ScopedTheme.tsx) applies a different set of theme colors to a DOM subtree by setting CSS variables as inline style:

<ScopedTheme colors={someColors} className="rounded-lg p-4">
  {/* Children here see different --background, --primary, etc. */}
</ScopedTheme>

It also sets data-theme-mode="dark" or "light" based on background luminance, for CSS targeting.

App Theme Persistence

Layer 1: localStorage (immediate)

The useLocalStorage hook (src/hooks/useLocalStorage.ts) stores the full AppConfig under key "nostr:app-config". This includes theme, customTheme, autoShareTheme, and themes. Changes are reflected immediately and support cross-tab sync via StorageEvent.

Layer 2: Encrypted NIP-78 Settings (cross-device sync)

The useEncryptedSettings hook (src/hooks/useEncryptedSettings.ts) stores theme preferences in a kind 30078 addressable event, encrypted to self via NIP-44. The EncryptedSettings interface includes theme, customTheme, and autoShareTheme among other app settings.

Key behaviors:

  • Query is delayed 5 seconds after login to avoid competing with feed load
  • Uses optimistic updates with a pendingSettings ref for rapid successive mutations
  • A recentlyWritten() guard returns true for 10 seconds after a local write to prevent NostrSync from overwriting the value that was just saved

Sync via NostrSync

The NostrSync component (src/components/NostrSync.tsx) runs globally and syncs encrypted settings from Nostr on login. For theme-related fields, it:

  1. Seeds a lastSyncedTimestamp ref on first load to prevent stale events from overwriting local config
  2. Skips application if recentlyWritten() is true
  3. Only applies changes if the remote timestamp is newer
  4. Handles legacy theme value migration ("black", "pink" to "custom")
  5. Diffs each field individually to avoid unnecessary re-renders

Part 2: Profile Theme

The Profile Theme is a public Nostr event that represents a user's chosen theme. Other clients can read it to style that user's profile page, or users can browse and copy each other's themes.

Nostr Event Kinds

Kind 36767: Theme Definition (addressable, multiple per user)

A shareable, named theme that a user has created. Think of these as "published theme presets." Tags:

Tag Purpose Example
d Identifier (slug) ["d", "ocean-night"]
c Color (hex + role) ["c", "#1a1a2e", "background"]
f Font (family + optional URL) ["f", "Comfortaa", "https://cdn.jsdelivr.net/..."]
bg Background (imeta-style variadic) ["bg", "url https://...", "mode cover", "m image/jpeg"]
title Display name ["title", "Ocean Night"]
alt NIP-31 description ["alt", "Custom theme: Ocean Night"]
t Topic tag ["t", "theme"]
description Optional description ["description", "A deep blue theme"]

Colors are stored as hex in c tags (converted to/from HSL internally). The content field is empty (legacy events may have JSON in content for backward compatibility).

Kind 16767: Active Profile Theme (replaceable, one per user)

The user's currently active profile theme. Same tag structure as kind 36767 but without d or description tags, and with an optional a tag referencing the source theme definition:

Tag Purpose
c Color tags (same as 36767)
f Font tag (same as 36767)
bg Background tag (same as 36767)
alt Always "Active profile theme"
title Optional theme name
a Optional reference to source kind 36767 event

Hooks

Hook File Purpose
usePublishTheme src/hooks/usePublishTheme.ts Publish/update/delete theme definitions (36767), set/clear active profile theme (16767)
useUserThemes src/hooks/useUserThemes.ts Query all kind 36767 themes by a user, deduplicated by d-tag, sorted newest first
useActiveProfileTheme src/hooks/useActiveProfileTheme.ts Query a user's kind 16767 active profile theme

Publishing and Parsing

All event building and parsing is in src/lib/themeEvent.ts:

  • buildThemeDefinitionTags() / parseThemeDefinition() - Kind 36767
  • buildActiveThemeTags() / parseActiveProfileTheme() - Kind 16767
  • buildColorTags() / parseColorTags() - HSL-to-hex conversion for c tags
  • buildFontTag() / parseFontTag() - Font f tags
  • buildBackgroundTag() / parseBackgroundTag() - Background bg tags (imeta-style)
  • titleToSlug() - Generate d-tag identifiers from titles

Backward compatibility: if c tags are missing, the parser falls back to reading legacy JSON from content (handling both the old 19-token format and the 4-color format).


Part 3: The Bridge Between App Theme and Profile Theme

The two systems are connected by the autoShareTheme setting and the NostrSync component.

App Theme -> Profile Theme

When autoShareTheme is enabled (default: true) and the user applies a custom theme via applyCustomTheme(), the useTheme hook automatically publishes the custom theme as a kind 16767 active profile theme, debounced by 2 seconds.

User picks a custom theme
  -> applyCustomTheme() in useTheme.ts:88
    -> Updates local config (localStorage)
    -> Syncs to encrypted NIP-78 storage (1s debounce)
    -> If autoShareTheme: publishes kind 16767 (2s debounce)

Profile Theme -> App Theme

On page load, if autoShareTheme is enabled, NostrSync (line 174) fetches the user's kind 16767 event and applies it as customTheme without changing the theme mode. This means:

  • If the user is on theme: "dark", their profile theme is stored as customTheme but the UI stays in dark mode
  • If the user is on theme: "custom", the profile theme's colors are applied to the UI
  • This allows the profile theme to stay in sync across devices without forcing the user into custom mode

Theme Definitions (Kind 36767)

Theme definitions are independent of the app theme. Users can create, publish, edit, and delete named themes. Other users can view them in feeds (via ThemeUpdateCard) and copy them. These are purely social objects on the Nostr network.


Font System

Fonts are managed by src/lib/fontLoader.ts and src/lib/fonts.ts.

Bundled Fonts

10 fonts are bundled via @fontsource packages with lazy loading (dynamic imports):

Category Fonts
Sans Inter, DM Sans, Outfit, Montserrat
Serif Lora, Merriweather, Playfair Display
Mono JetBrains Mono
Display Comfortaa
Handwriting Comic Relief

Each has a load() function and a cdnUrl for Nostr event publishing.

Font Application

Three <style> elements manage fonts:

ID Purpose
theme-font-faces @font-face rules for remote fonts
theme-font-overrides html { font-family: "CustomFont", "Inter Variable", ... !important; }
theme-vars Theme CSS custom properties (not font-specific, but part of the pipeline)

The loadAndApplyFont() function:

  1. Tries to load via bundled @fontsource package first
  2. Falls back to injecting a @font-face rule from a remote URL
  3. Applies a global font-family override via <style id="theme-font-overrides">
  4. Passing undefined clears the override (reverts to default Inter)

Color Utilities

src/lib/colorUtils.ts provides the color math underpinning the theme system:

Function Purpose
parseHsl / formatHsl Parse/format HSL strings ("228 20% 10%")
hslToRgb / rgbToHsl HSL-RGB conversion
hexToRgb / rgbToHex Hex-RGB conversion
hexToHslString / hslStringToHex Direct hex-to-HSL-string conversion (used for Nostr c tags)
getLuminance WCAG 2.1 relative luminance
getContrastRatio / getContrastRatioHsl WCAG contrast ratio between two colors
isDarkTheme Determines if a background is "dark" (luminance < 0.2)
deriveTokensFromCore The core algorithm: 3 colors -> 19 tokens
tokensToCoreColors Extract 3 core colors from a legacy 19-token object

All colors are stored internally as HSL strings without the hsl() wrapper (e.g. "228 20% 10%"). The hsl() wrapper is added by Tailwind's config (hsl(var(--background))).


Validation

Theme data is validated with Zod schemas in src/lib/schemas.ts:

  • ThemeSchema - Validates 'dark' | 'light' | 'system' | 'custom'
  • CoreThemeColorsSchema - Validates the 3 HSL string fields
  • ThemeConfigSchema - Full config with optional font/background
  • ThemeConfigCompatSchema - Accepts both ThemeConfig and bare CoreThemeColors
  • ThemeColorsCompatSchema - Union of current 3-color, old 4-color, and legacy 19-token formats
  • AppConfigSchema - Full app config including all theme fields
  • EncryptedSettingsSchema - Encrypted settings including theme fields

The AppProvider deserializer (src/components/AppProvider.tsx:32) validates each top-level field individually with safeParse, so a single invalid field doesn't nuke the entire config.


File Index

File Role
src/themes.ts Core types (CoreThemeColors, ThemeConfig, ThemeTokens), builtin themes, presets, CSS builders
src/lib/colorUtils.ts Color conversion, contrast detection, token derivation
src/lib/themeEvent.ts Nostr event kinds (36767, 16767), tag building/parsing
src/lib/fontLoader.ts Font loading and CSS injection
src/lib/fonts.ts Bundled font definitions
src/lib/schemas.ts Zod validation schemas
src/contexts/AppContext.ts Theme type, AppConfig interface, React context
src/hooks/useTheme.ts Primary theme API: setTheme(), applyCustomTheme(), setAutoShareTheme()
src/hooks/useAppContext.ts Context consumer hook
src/hooks/useEncryptedSettings.ts NIP-78 encrypted settings (cross-device sync)
src/hooks/usePublishTheme.ts Publish theme definitions and active profile theme
src/hooks/useUserThemes.ts Query user's theme definitions
src/hooks/useActiveProfileTheme.ts Query user's active profile theme
src/components/AppProvider.tsx Theme application to DOM (useApplyTheme, useApplyFonts, useApplyBackground)
src/components/NostrSync.tsx Cross-device sync for encrypted settings and profile theme
src/components/ScopedTheme.tsx Scoped CSS variable overrides for subtrees
src/components/ThemeSelector.tsx Full settings UI for theme management
src/components/SidebarThemeDropdown.tsx Compact theme picker dropdown
public/theme.js Pre-React blocking script for flash prevention
index.html Hardcoded dark defaults, preloader, blocking script tag
tailwind.config.ts CSS custom property to Tailwind color mapping
src/index.css Base styles using theme tokens