384 lines
18 KiB
Markdown
384 lines
18 KiB
Markdown
# 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`:
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
```typescript
|
|
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 `__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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```css
|
|
* { @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`:
|
|
|
|
```tsx
|
|
<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 |
|